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 @@ -8,6 +8,7 @@
- Fixture quality gates that complete intermediate Ultragoal stories now write file-backed adversarial artifact proof; skill-state hooks and computer red-team fixtures match the unconditional adversarial path check so #3543 CI stays fail-closed without weakening hydration exactness (#3543).
- 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).
- Telegram forum-topic routing now releases a topic id's collision quarantine once its delete settles definitely. A `delete_pending` record quarantines its topic id to fail closed, but `settleDelete` removed the record without recomputing the derived routing tables, so the id stayed permanently unroutable and unadoptable for the life of the daemon process. Re-adopting the same user-created topic after a settled close was refused with `topic adoption refused: intent chat/binding/topic unavailable`, and a session that had collided on that id stopped receiving inbound replies until the daemon restarted.

### Fixed

Expand Down
9 changes: 8 additions & 1 deletion packages/coding-agent/src/sdk/bus/topic-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -799,11 +799,18 @@ export class TopicRegistry {
await this.inflight.get(sessionId)?.catch(() => undefined);
}

/** Remove only after a definite remote deletion; ambiguity deliberately retains its fence. */
/**
* Remove only after a definite remote deletion; ambiguity deliberately retains
* its fence. Once the record is gone its topic id no longer collides, so the
* derived routing tables are recomputed: a surviving colliding record becomes
* routable again and a settled id becomes adoptable, without waiting for a
* daemon restart to rebuild them from the persisted snapshot.
*/
settleDelete(sessionId: string, topicId: string): boolean {
const record = this.topics.get(sessionId);
if (!record || record.topicId !== topicId || record.authorityState !== "delete_pending") return false;
this.topics.delete(sessionId);
this.rebuildInboundRoutes();
return true;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, test } from "bun:test";
import { TopicRegistry, type TopicRegistryState } from "../src/sdk/bus/topic-registry";

/**
* A crash mid-close leaves a `delete_pending` record on disk. Loading it
* quarantines its topic id so no stale route survives. Once the delete settles
* definitely, the quarantine must be released: the id is no longer claimed by
* any record, so a later adoption of that same user-created Telegram topic has
* to be routable in the same daemon process, exactly as it is after a restart.
*/
describe("TopicRegistry settled delete", () => {
const fencedState = (): TopicRegistryState => ({
topics: {
closing: {
topicId: "42",
identitySent: true,
createdAt: 1,
topicOrigin: "user_created",
authorityState: "delete_pending",
authorityEpoch: 1,
chatId: "77",
endpointKey: "ws://closing",
endpointDigest: "digest-closing",
endpointGeneration: 1,
},
},
fences: { closing: 1 },
});

const resumedBinding = {
chatId: "77",
endpointKey: "ws://resumed",
endpointDigest: "digest-resumed",
endpointGeneration: 1,
};

test("releases the topic-id quarantine so a re-adopted user topic routes inbound", async () => {
const reg = new TopicRegistry(fencedState());
expect(reg.sessionForTopic("42")).toBeUndefined();
expect(reg.isTopicIdAvailable("42")).toBe(false);

expect(reg.settleDelete("closing", "42")).toBe(true);
expect(reg.get("closing")).toBeUndefined();
expect(reg.isTopicIdAvailable("42")).toBe(true);

const adopted = await reg.getOrCreateTopic(
"resumed",
async () => "42",
() => 2,
undefined,
resumedBinding,
undefined,
undefined,
"user_created",
);
expect(adopted.topicId).toBe("42");
expect(reg.sessionForTopic("42")).toBe("resumed");
expect(reg.endpointAuthority(resumedBinding)).toEqual({ state: "unique", sessionId: "resumed" });
});

test("routes an adopted topic identically before and after a restart reload", async () => {
const live = new TopicRegistry(fencedState());
expect(live.settleDelete("closing", "42")).toBe(true);
await live.getOrCreateTopic(
"resumed",
async () => "42",
() => 2,
undefined,
resumedBinding,
undefined,
undefined,
"user_created",
);

const reloaded = new TopicRegistry(live.serialize());
expect(reloaded.sessionForTopic("42")).toBe("resumed");
expect(live.sessionForTopic("42")).toBe(reloaded.sessionForTopic("42"));
});
});
Loading