From cd7fb3818b8f003f3574d67966f555d87d550a55 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Thu, 1 Oct 2026 09:21:22 +0100 Subject: [PATCH] Await anchor plan publication before sidecar persistence --- AGENTS.md | 6 +----- apps/server/src/agent/tools.test.ts | 23 ++++++++++++++++++++--- apps/server/src/agent/tools.ts | 2 +- docs/architecture.md | 2 -- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 90ba88b9..00a1f940 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,8 +118,7 @@ external implementation runs are durable. - **Repository node IDs are authoritative.** Owner and repository names resolve GitHub requests but never replace the stored node identity. - **Persistence should precede publication.** Do not acknowledge or broadcast a - domain mutation before its fenced durable commit. `anchor_plan` currently has - a known ordering race described below; do not copy that pattern. + domain mutation before its fenced durable commit. ## Conversation and Planner addressing @@ -311,9 +310,6 @@ so merge ranges from all mounted editors before replacing a registry entry. does not currently remove every Planner label from the UI. - A success callback for persisted sidecar work is not optional. Calling it after persistence prevents durable transcript state from being dropped. -- `anchor_plan` calls the asynchronous question-placement publish without - awaiting it before separate sidecar persistence and anchor broadcast. Fix the - serialization before relying on its ordering guarantee. ### Browser and editor diff --git a/apps/server/src/agent/tools.test.ts b/apps/server/src/agent/tools.test.ts index f233191a..8df47417 100644 --- a/apps/server/src/agent/tools.test.ts +++ b/apps/server/src/agent/tools.test.ts @@ -287,7 +287,7 @@ test("read_reference accepts only ids made available by the active chat session" expect(reads).toEqual([available]); }); -test("anchor_plan publishes moving a decision beside the validated prose", async () => { +test("anchor_plan waits for decision placement before persisting and broadcasting anchors", async () => { let { plan, server } = await opened(SOURCE, { revision: 1, questions: [{ @@ -308,14 +308,25 @@ test("anchor_plan publishes moving a decision beside the validated prose", async }); let published: unknown[] = []; let anchors = 0; + let started = Promise.withResolvers(); + let release = Promise.withResolvers(); + let finished = Promise.withResolvers(); + let persisted = false; let anchorPlan = fixtureTools({ plan, server, room: "test", - persist: () => Service.persist(plan), + persist: async () => { + persisted = true; + await Service.persist(plan); + }, exclusive: action => Service.exclusive(plan, action), publish: async mutation => { + started.resolve(); + await release.promise; + await Service.publish(plan, server, "test", mutation); published.push(mutation); + finished.resolve(); }, anchors: () => anchors++, changes() {}, @@ -328,12 +339,18 @@ test("anchor_plan publishes moving a decision beside the validated prose", async revision: plan.revision, anchors: [{ widget: WIDGET, question: QUESTION, blocks: [{ index: 1, digest }] }], }; - let response = await anchorPlan.handler(args, { + let pending = anchorPlan.handler(args, { sessionId: "session", toolCallId: "call", toolName: "anchor_plan", arguments: args, }); + await started.promise; + let beforeCommit = { persisted, anchors }; + release.resolve(); + let response = await pending; + await finished.promise; + expect(beforeCommit).toEqual({ persisted: false, anchors: 0 }); if (typeof response !== "string") throw new Error("anchor_plan returned no text"); let result = JSON.parse(response); diff --git a/apps/server/src/agent/tools.ts b/apps/server/src/agent/tools.ts index bb8e57bc..d933222a 100644 --- a/apps/server/src/agent/tools.ts +++ b/apps/server/src/agent/tools.ts @@ -560,7 +560,7 @@ export const documentTools = { } } let mutation = Questions.place(context.plan, placements); - if (mutation) context.publish(mutation); + if (mutation) await context.publish(mutation); await context.persist(); context.anchors(); diff --git a/docs/architecture.md b/docs/architecture.md index a901b469..3f6964f5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -446,8 +446,6 @@ or route for a person to approve the draft. See The prototype still has several places where implementation falls short of the intended boundaries above: -- `anchor_plan` does not await a question-placement document mutation before its - separate sidecar persistence and anchor broadcast, leaving an ordering race. - Idle-room eviction removes the registry entry before its asynchronous final close and checkpoint completes, so a replacement room can briefly overlap. - Browser CRDT updates do not cross-check record-owned decision projections, and