Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- Runtime settings reconciliation now validates every `web_search.fallback` entry against the declared provider enum instead of accepting unsupported or non-string array items (#3601).
- Ultragoal critic-gate, dogfood, review, durable-completion, and runtime test suites now pin `CI_DEV_CHANGED_PATHS` hermetically in their setup/teardown. Their temp checkpoints live inside the enclosing git work tree, so the CI planner's changed paths (which include computer control surface paths on branches that touch them) previously leaked into the computed change set and falsely triggered the mandatory computer red-team suite (`COMPUTER_REDTEAM_CASE_MISSING: … must include kill-switch-bypass`). The production kill-switch-bypass gate is unchanged; only the test fixtures now isolate their own contract from the host branch's diff (#3533).
- Ultragoal critic-gate, dogfood, review, durable-completion, and runtime test suites now relocate temp dirs to `os.tmpdir()` (outside the enclosing git work tree) and pin `CI_DEV_CHANGED_PATHS` to a non-computer test path. The prior in-repo temp dirs caused `computeCheckpointChangeSet` to return `captureIncomplete=true` under parallel shard load (git command timeouts), which unconditionally triggered the mandatory computer red-team suite even when no computer surface was touched. The production kill-switch-bypass gate is unchanged; the `.tmp-*` gitignore entry prevents in-repo test artifacts from polluting untracked-file inventory (#3533).
- Telegram topic delete settlement is now fence-epoch bound, two-phase, and durably route-atomic. `TopicRegistry.settleDelete` requires the caller's dispatched authority epoch to still equal both the record's own epoch and the session's current epoch, so a held earlier delete can no longer settle a newer scan/close-started fence for the same session and topic and release its quarantine; it now removes the record but deliberately *retains* the topic-id quarantine and returns a settlement token instead of publishing routes, so no colliding survivor becomes routable and no settled id becomes adoptable while the clear is still only in memory. `commitSettledDelete` publishes the rebuilt inbound routes and releases the quarantine only after the durable topic-state persist resolves, and `rollbackSettledDelete` undoes a failed persist as a compare-and-set that applies only while the post-settlement state is still exactly current, so a stale rollback can no longer resurrect a deleted record over a newer fence. A refused settlement returns no token and is therefore structurally incapable of being rolled back. Authority-epoch advancement is routed through a single saturating helper capped at `Number.MAX_SAFE_INTEGER`, and settlement fails closed (keeping the fence) on a non-safe-integer, negative, or already-saturated epoch instead of settling against an unsound comparison. Telegram's first create-compensation path now marks compensation complete only after that durable clear commits, so a failed persist leaves the fence supervised rather than stranding a cleared memory state against a `delete_pending` disk state.

### Fixed

Expand Down
109 changes: 77 additions & 32 deletions packages/coding-agent/src/sdk/bus/telegram-daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6814,17 +6814,18 @@ export class TelegramNotificationDaemon {
acceptedTopicId = String(tid);
this.#malformedTopicCreateEndpoints.delete(sessionId);
if (capturedCreationLease && !(await this.#awaitCreationLeaseAuthority(capturedCreationLease))) {
if (
!this.topics.fenceAcceptedCreateForLease(
sessionId,
acceptedTopicId,
creationLeaseEpoch,
this.opts.now,
name,
creationBinding,
)
)
throw new Error("topic authority was revoked during creation");
const fencedCreate = this.topics.fenceAcceptedCreateForLease(
sessionId,
acceptedTopicId,
creationLeaseEpoch,
this.opts.now,
name,
creationBinding,
);
if (!fencedCreate) throw new Error("topic authority was revoked during creation");
// Epoch held when the compensating delete is dispatched below; a
// concurrent re-fence must not be settled by this delete's result.
const fencedCreateEpoch = fencedCreate.authorityEpoch ?? 0;
try {
await this.persistTopics();
} finally {
Expand All @@ -6833,11 +6834,27 @@ export class TelegramNotificationDaemon {
chat_id: this.opts.chatId,
message_thread_id: tid,
});
acceptedTopicCompensated = topicDeleteSettled(deletion);

// Remote compensation succeeded, but the transaction is not complete
// until the registry clear is durable. `acceptedTopicCompensated` is
// what tells outer recovery to stop supervising the fence, so it is
// set only after the phase-2 commit: a failed clear persist leaves
// the fence supervised instead of stranding a cleared memory state
// against a `delete_pending` disk state.
if (topicDeleteSettled(deletion)) {
this.topics.settleDelete(sessionId, acceptedTopicId);
await this.persistTopics();
const settled = this.topics.settleDelete(sessionId, acceptedTopicId, fencedCreateEpoch);
if (!settled) {
this.#superviseCompensationFence(sessionId);
await this.#persistTopicsWithRetry().catch(() => undefined);
} else
try {
await this.persistTopics();
this.topics.commitSettledDelete(settled);
acceptedTopicCompensated = true;
} catch {
this.topics.rollbackSettledDelete(settled);
this.#superviseCompensationFence(sessionId);
await this.#persistTopicsWithRetry().catch(() => undefined);
}
} else {
this.#superviseCompensationFence(sessionId);
await this.#persistTopicsWithRetry().catch(() => undefined);
Expand Down Expand Up @@ -6916,16 +6933,18 @@ export class TelegramNotificationDaemon {
) {
// A failed initial commit must never make compensation conditional on
// successfully publishing its fence.
if (
this.topics.fenceAcceptedCreateForLease(
sessionId,
acceptedTopicId,
creationLeaseEpoch,
this.opts.now,
name,
creationBinding,
)
) {
const fencedCompensation = this.topics.fenceAcceptedCreateForLease(
sessionId,
acceptedTopicId,
creationLeaseEpoch,
this.opts.now,
name,
creationBinding,
);
if (fencedCompensation) {
// Epoch held when the compensating delete is dispatched below; a
// concurrent re-fence must not be settled by this delete's result.
const fencedCompensationEpoch = fencedCompensation.authorityEpoch ?? 0;
try {
await this.#persistTopicsWithRetry();
} catch {
Expand All @@ -6938,8 +6957,19 @@ export class TelegramNotificationDaemon {
message_thread_id: Number(acceptedTopicId),
});
if (topicDeleteSettled(deletion)) {
this.topics.settleDelete(sessionId, acceptedTopicId);
await this.persistTopics();
const settled = this.topics.settleDelete(sessionId, acceptedTopicId, fencedCompensationEpoch);
if (!settled) {
this.#superviseCompensationFence(sessionId);
await this.#persistTopicsWithRetry().catch(() => undefined);
} else
try {
await this.persistTopics();
this.topics.commitSettledDelete(settled);
} catch {
this.topics.rollbackSettledDelete(settled);
this.#superviseCompensationFence(sessionId);
await this.#persistTopicsWithRetry().catch(() => undefined);
}
} else {
this.#superviseCompensationFence(sessionId);
await this.#persistTopicsWithRetry().catch(() => undefined);
Expand Down Expand Up @@ -6989,8 +7019,11 @@ export class TelegramNotificationDaemon {
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
deleteFenceAlreadyPublished = false,
): Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled"> {
const deleteSnapshot = this.topics.captureDeleteAuthority(sessionId);
let record = deleteFenceAlreadyPublished ? this.topics.get(sessionId) : this.topics.beginDelete(sessionId);
// Authority epoch held for this delete. Captured before any dispatch so a
// concurrent scan/close re-fence of the same session cannot be settled by
// this delete's definite result.
const dispatchedAuthorityEpoch = this.topics.authorityEpoch(sessionId);
if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
await this.persistTopics();
if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
Expand All @@ -7013,7 +7046,13 @@ export class TelegramNotificationDaemon {
await this.flushPool();
if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
if (record.topicOrigin === "user_created") {
this.topics.settleDelete(sessionId, record.topicId);
// Phase 1: drop the record but keep the topic id quarantined. Routes are
// only republished by `commitSettledDelete` once the clear is durable.
const settled = this.topics.settleDelete(sessionId, record.topicId, dispatchedAuthorityEpoch);
if (!settled) {
await this.#persistTopicsWithRetry().catch(() => undefined);
return "post_dispatch_pending";
}
for (const k of [...this.liveMessages.keys()])
if (k.startsWith(`${sessionId}:`)) {
this.liveMessages.delete(k);
Expand All @@ -7025,9 +7064,10 @@ export class TelegramNotificationDaemon {
this.pendingThreadedFrames.delete(sessionId);
try {
await this.persistTopics();
this.topics.commitSettledDelete(settled);
return "settled";
} catch {
this.topics.restoreDeleteFence(deleteSnapshot);
this.topics.rollbackSettledDelete(settled);
await this.#persistTopicsWithRetry().catch(() => undefined);
return "post_dispatch_pending";
}
Expand All @@ -7037,7 +7077,11 @@ export class TelegramNotificationDaemon {
message_thread_id: Number(record.topicId),
})) as { ok?: boolean };
if (!topicDeleteSettled(res)) return "post_dispatch_pending";
this.topics.settleDelete(sessionId, record.topicId);
const settled = this.topics.settleDelete(sessionId, record.topicId, dispatchedAuthorityEpoch);
if (!settled) {
await this.#persistTopicsWithRetry().catch(() => undefined);
return "post_dispatch_pending";
}
for (const k of [...this.liveMessages.keys()])
if (k.startsWith(`${sessionId}:`)) {
this.liveMessages.delete(k);
Expand All @@ -7049,9 +7093,10 @@ export class TelegramNotificationDaemon {
this.pendingThreadedFrames.delete(sessionId);
try {
await this.persistTopics();
this.topics.commitSettledDelete(settled);
return "settled";
} catch {
this.topics.restoreDeleteFence(deleteSnapshot);
this.topics.rollbackSettledDelete(settled);
await this.#persistTopicsWithRetry().catch(() => undefined);
return "post_dispatch_pending";
}
Expand Down
Loading
Loading