fix(daemon): attribute stuck-sweep closures to the detector (#1811)

When the stuck sweep closes its own finding, its transition history and `queue.updated` event now name `stuck-sweep@system` as the actor. Previously they credited the finding's source agent with that automatic action. The finding keeps its source, destination and claim, and the underlying work is unchanged. The closure query no longer reads the unused `source_session`, and the coordination architecture document explains the difference between ownership and the actor.

Addresses the closure-attribution part of #1419, following the retry-suppression fix in #1449. It covers attribution only, not every delivery or pickup evidence-policy case in that issue, and existing transition history isn't rewritten.

Validation: three regression cases failed before the fix, recording the source agent instead of the sweep, and pass after it; 183 tests pass across the stuck-sweep, human-custody, wake-ladder and pickup daemon files, covering pending and claimed findings, durable transition and event actors, unchanged ownership and source work, and no second closure on the next sweep. A posted human notice keeps the person's original ask open. Build, lint and the UI tests pass.

Thanks to @Aschenbath.
This commit is contained in:
Nash Aschenbath authored and GitHub committed 2026-10-24 23:38:10 -07:00
1 parent 70c39372d5
commit d41fb29ac2
3 files changed
+48 -7

No files matched your search

@@ -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
@@ -772,11 +772,11 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
// with its reason — the sweep cleans up after itself, no human unwind.
const openFindings = deps.db
.prepare(
`SELECT qitem_id, source_session, tags FROM queue_items
`SELECT qitem_id, tags FROM queue_items
WHERE state IN ('pending', 'in-progress', 'blocked')
AND tags LIKE ?`,
)
.all(`%"${STUCK_SWEEP_FINDING_TAG}"%`) as Array<{ qitem_id: string; source_session: string; tags: string }>;
.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<StuckSweepRes
if (suppressedTags.has(dedupTag)) continue;
await deps.queueRepo.update({
qitemId: f.qitem_id,
actorSession: f.source_session,
// The sweep performed this transition; ownership stays on the existing row.
actorSession: "stuck-sweep@system",
state: "done",
closureReason: "no-follow-on",
transitionNote: `stuck-sweep resolved: ${kind ?? "finding"} on ${stuckId ?? "row"} no longer detected`,
+33 -4
View File
@@ -588,11 +588,18 @@ describe("S02 standing stuck sweep — both halves, routed findings, quiet-but-o
expect(await findingsFor(row.qitemId)).toHaveLength(1);
});
it("RESOLUTION CLOSES: when the underlying row resolves, the next sweep closes the finding with its reason", async () => {
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))