diff --git a/docs/as-built/architecture/coordination-primitive.md b/docs/as-built/architecture/coordination-primitive.md index 305be22bb..e265d1a2d 100644 --- a/docs/as-built/architecture/coordination-primitive.md +++ b/docs/as-built/architecture/coordination-primitive.md @@ -178,6 +178,17 @@ the destination's orchestrator when one is derivable, otherwise to the destination itself, and that seat may be dead. Overdue claims go to their destination. Repeated detections reuse the existing finding without changing its route; when the source condition resolves, the sweep closes its own finding. +That automatic closure records `stuck-sweep@system` as the actor in both the +transition and the `queue.updated` audit event. Previously it credited the +finding's source agent with the detector's action. The finding's source, +destination and claim remain its existing ownership records; the underlying work +is not reassigned, and existing transition history is not rewritten. + +`queue-stuck-sweep.test.ts` checks this with migrated SQLite: pending and claimed +findings close once, their transition and event name the sweep, and ownership +stays intact. The posted-human-notice case also checks the actor while leaving +the person's original ask open. These checks exercise the detector and queue +repository, not a live agent or connector. The sweep reports six finding kinds. Its custody check covers closures that name a successor qitem or a seat (`closure_reason = handed_off_to`) in the last diff --git a/packages/daemon/src/domain/queue-stuck-sweep.ts b/packages/daemon/src/domain/queue-stuck-sweep.ts index a1a9e4dff..050753bf5 100644 --- a/packages/daemon/src/domain/queue-stuck-sweep.ts +++ b/packages/daemon/src/domain/queue-stuck-sweep.ts @@ -772,11 +772,11 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise; + .all(`%"${STUCK_SWEEP_FINDING_TAG}"%`) as Array<{ qitem_id: string; tags: string }>; for (const f of openFindings) { let tags: string[] = []; try { @@ -795,7 +795,8 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise { + it.each(["pending", "in-progress"] as const)("#1419 RESOLUTION ACTOR: closes a %s finding as the sweep while preserving its ownership", async (findingState) => { const row = await mkRow(); repo.claim({ qitemId: row.qitemId, destinationSession: "worker@r" }); makeOverdue(row.qitemId); await runSweep(); + const [finding] = await findingsFor(row.qitemId); + expect(finding).toBeDefined(); + if (findingState === "in-progress") { + repo.claim({ qitemId: finding!.qitemId, destinationSession: "worker@r" }); + } + const before = repo.getById(finding!.qitemId)!; + expect(before).toMatchObject({ state: findingState, sourceSession: "sender@r", destinationSession: "worker@r" }); repo.update({ qitemId: row.qitemId, actorSession: "worker@r", @@ -600,11 +607,30 @@ describe("S02 standing stuck sweep — both halves, routed findings, quiet-but-o closureReason: "no-follow-on", transitionNote: "finished the work", }); - await runSweep(); + const sourceAfterWork = repo.getById(row.qitemId); + const { result } = await runSweep(); + expect(result.findings).toContainEqual({ kind: "overdue-claim", qitemId: row.qitemId, findingQitemId: before.qitemId, action: "closed" }); const findings = await findingsFor(row.qitemId); expect(findings).toHaveLength(1); - expect(findings[0]!.state).toBe("done"); - expect(findings[0]!.closureReason).toBeTruthy(); + expect(findings[0]).toMatchObject({ + state: "done", closureReason: "no-follow-on", + sourceSession: before.sourceSession, destinationSession: before.destinationSession, + claimedAt: before.claimedAt, evidenceRef: before.evidenceRef, + }); + expect(repo.getById(row.qitemId)).toEqual(sourceAfterWork); + expect(repo.transitionLog.listForQitem(before.qitemId).at(-1)).toMatchObject({ + state: "done", actorSession: "stuck-sweep@system", closureReason: "no-follow-on", + transitionNote: `stuck-sweep resolved: overdue-claim on ${row.qitemId} no longer detected`, + }); + const closingEvents = () => (db.prepare( + "SELECT payload FROM events WHERE type = 'queue.updated' AND json_extract(payload, '$.qitemId') = ? AND json_extract(payload, '$.toState') = 'done'", + ).all(before.qitemId) as Array<{ payload: string }>).map((event) => JSON.parse(event.payload)); + expect(closingEvents()).toEqual([expect.objectContaining({ + qitemId: before.qitemId, fromState: findingState, toState: "done", actorSession: "stuck-sweep@system", + })]); + await runSweep(); + expect(repo.transitionLog.listForQitem(before.qitemId).filter((t) => t.state === "done")).toHaveLength(1); + expect(closingEvents()).toHaveLength(1); }); it("QUIET IS CHEAP: a clean sweep creates zero rows and records one observable heartbeat", async () => { @@ -1794,6 +1820,9 @@ describe("#514 unclaimed sweep and a pending ask posted to a registered human", expect(second.findings).toContainEqual(expect.objectContaining({ findingQitemId: finding!.qitemId, action: "closed" })); expect(repo.getById(finding!.qitemId)).toMatchObject({ state: "done", closureReason: "no-follow-on" }); expect(repo.getById(row.qitemId)).toMatchObject({ state: "pending", destinationSession: "human-owner@external" }); + expect(repo.transitionLog.listForQitem(finding!.qitemId).at(-1)).toMatchObject({ + state: "done", actorSession: "stuck-sweep@system", closureReason: "no-follow-on", + }); }); const unclaimedFinding = async (qitemId: string) => (await findingsFor(qitemId))