diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b4506214d6..23e1ec704a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/sdk/bus/topic-registry.ts b/packages/coding-agent/src/sdk/bus/topic-registry.ts index d18ecdc413..fd10ba5df7 100644 --- a/packages/coding-agent/src/sdk/bus/topic-registry.ts +++ b/packages/coding-agent/src/sdk/bus/topic-registry.ts @@ -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; } diff --git a/packages/coding-agent/test/notifications-topic-registry-settle-quarantine.test.ts b/packages/coding-agent/test/notifications-topic-registry-settle-quarantine.test.ts new file mode 100644 index 0000000000..fb59d0190f --- /dev/null +++ b/packages/coding-agent/test/notifications-topic-registry-settle-quarantine.test.ts @@ -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")); + }); +});