From ce26cea9e77a1cfaddfeb0e33e87fd9994695c15 Mon Sep 17 00:00:00 2001 From: Dan Shipper Date: Sat, 12 Sep 2026 23:10:38 -0400 Subject: [PATCH 01/16] feat(sweeps): judgments can name their card; sweep:status reports what is unpresented A review or routine_action judgment may carry a stable `cardId`, the id the agent reuses in card:upsert. work:complete for recollection work then checks each such judgment against that exact card: it must exist, list the run in sourceRunIds, and be visible for review now or acted on since the run. Judgments without a cardId keep the count-based check, over cards not claimed by an exact match, so nothing existing breaks. Two judgments may share one cardId when one card deliberately presents both. Bad ids are rejected when the run is recorded. `tend cli sweep:status --feed ` reports whether the current sweep is ready to complete and lists every unpresented judgment with the run, its position, its cardId, and the exact reason; work:complete refuses with the same summary. Re-claiming recollection work whose batch is already recorded returns that list as operatorGuidance.pendingPresentation, and the claim-time requiredWriteBack, judge prompt template, agent contract, skill, capability map, and runbook describe the convention. CLI contract advances to 0.9. Co-Authored-By: Claude Fable 5.1 --- CAPABILITY_MAP.md | 2 +- CHANGELOG.md | 6 ++ RUNBOOK.md | 17 ++++ docs/AGENT_CONTRACT.md | 25 ++++-- docs/SKILL.md | 5 +- server/cli/contract.ts | 1 + server/cli/operator.ts | 8 ++ server/domain.ts | 147 +++++++++++++++++++++++++-------- server/operator.ts | 14 +++- server/templates.ts | 3 + server/version.ts | 2 +- shared/types.ts | 33 ++++++++ test/cli-contract.test.ts | 11 ++- test/sweep-checkpoints.test.ts | 136 ++++++++++++++++++++++++++++-- 14 files changed, 354 insertions(+), 56 deletions(-) diff --git a/CAPABILITY_MAP.md b/CAPABILITY_MAP.md index 0600c5ce..27cc1215 100644 --- a/CAPABILITY_MAP.md +++ b/CAPABILITY_MAP.md @@ -23,7 +23,7 @@ | Recover parked or stuck Claude work | Parked notice → `Reassign to Codex` | Queued/parked items use `work:assign --feed ... --work ... --agent codex`; claimed/stuck items use claim replay by the Claude lane or `work:release --feed ... --work ... --token ...` to re-queue and rotate the token | | Schedule refresh and drain | Approve proposed cadence | `feed:heartbeat:propose`, then host `automation_update`, then `feed:heartbeat:installed` | | Verify an approved external action or cleanup | Choose the exact visible mutation, cleanup, or routine group | `action:verify` rereads the selected card-action ID, current artifact, cleanup, or batch digest immediately before mutation and records verification durably; Inbox replies additionally require `--mailbox ` to match the source message's received-at mailbox | -| Collect evidence | Render the resulting cards | `source:record-run` or `source:import-json-file` writes immutable snapshots and checkpoints; `sweep:record-batch` records the judged batch separately | +| Collect evidence | Render the resulting cards | `source:record-run` (judgments may name their `cardId`) writes immutable snapshots and, for claimed recollection work, holds the checkpoint on the run; `sweep:record-batch` records the judged batch; `sweep:status` lists what is still unpresented; `work:complete` commits the held checkpoints once every judgment is presented | | Rejudge sweep feedback | Submit dock feedback to `This sweep` | `sweep:rejudge` writes an explicit kept order and removed-card set before recollection is offered | | Render a judged item | Review its validated structured blocks, including compact two-series charts for comparative metrics | `card:upsert --feed ... --card-file ...` | | Compound learning | Answer yes when the feed thread offers its end-of-sweep learning pass; review and edit the resulting full-screen policy proposal before applying it | An idle `work:list` or `work:claim` reminds Codex to offer the pass after meaningful sweep work; `learning:request --feed ...` queues one `compound_learnings` job; Codex reviews durable evidence and uses `revision:propose --source compound`; the browser applies only the user-reviewed Markdown | diff --git a/CHANGELOG.md b/CHANGELOG.md index 09d2d657..7f964a81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ a promise of ongoing maintenance. ## Unreleased +- Let sweep judgments name the card that will present them (`cardId`), so `work:complete` and the + new `tend cli sweep:status --feed ` check each judged item against its exact card and report + precisely what is still missing; judgments without a `cardId` keep the count-based check. Re-claimed + recollection work carries the same list as `operatorGuidance.pendingPresentation`, and the judge + prompt, agent contract, skill, and runbook describe the convention. Advances the CLI contract to + `0.9`. - Hold source checkpoints recorded for claimed recollection work until `work:complete` succeeds, and refuse completion until every `review` judgment has a card and every `routine_action` judgment has a card or a proposed routine action group, so a sweep diff --git a/RUNBOOK.md b/RUNBOOK.md index 81c24e24..efe1e13e 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -161,6 +161,23 @@ treat broad natural-language dock input as a literal prompt edit. ## Collect +### Finish an interrupted sweep + +A sweep recorded for claimed recollection work keeps its source checkpoints on the run records until +`work:complete` succeeds, so an agent that dies after judging but before presenting loses nothing: +the next sweep starts from the previous checkpoint. To finish the interrupted one instead, re-claim +the work (the claim output carries `operatorGuidance.pendingPresentation`) or ask directly: + +```bash +tend cli sweep:status --feed +``` + +It reports `ready` plus every judgment that is not presented yet, with the run, the judgment's +position, its `cardId` when it named one, and the exact reason. Upsert the listed cards with +`sourceRunIds` including the run (or propose the routine action group), re-run `sweep:status`, then +`work:complete`. Judgments that name a `cardId` are checked against that exact card; judgments +without one are matched by count. + ### Compose meeting cards with independent readers The optional meeting-reading workflow uses ordinary feeds, source runs and cards. Follow diff --git a/docs/AGENT_CONTRACT.md b/docs/AGENT_CONTRACT.md index 79a31e28..f4d46341 100644 --- a/docs/AGENT_CONTRACT.md +++ b/docs/AGENT_CONTRACT.md @@ -70,15 +70,23 @@ For `recollect_sources` work: ["INBOX"])`. Its checkpoint's `inboxEnumeration.messages` must map every authoritative `messageId` to its direct-read `threadId`; every resulting conversation must appear exactly once in `readThreadIds` or `carriedForwardThreadIds` before recording the batch. +- Give every `review` or `routine_action` judgment a stable, file-safe `cardId` and reuse that + exact id in `card:upsert`. A judgment looks like + `{"decision":"review","cardId":"gmail-18c2f0a1","reason":"..."}`; `suppress` judgments need no + card. Two judgments may share one `cardId` when one card deliberately presents both. Judgments + without a `cardId` still work and are matched by count instead of by id. - Record the resulting sweep with `tend cli sweep:record-batch --work `. -- Complete the work only after the source run and sweep batch are written back. -- Checkpoints recorded with `--work` are held on the run until `work:complete` succeeds, and - completion is refused until every `review` judgment in a run has its own card whose - `sourceRunIds` lists that run and that is visible for review in the current pass (or was acted - on since the run), and every `routine_action` judgment is presented by such a card or by an - item of a routine action group proposed since the sweep. If the work never completes, - the next sweep starts from the previous checkpoint and re-reads those items; a held checkpoint is - not written over one that changed after the run was recorded. +- Upsert one card per judgment with `sourceRunIds` including its run, then run + `tend cli sweep:status --feed `; it reports `ready` and lists every judgment that is not + presented yet with the exact reason (no such card, card does not list the run, card hidden). +- Complete the work only when `sweep:status` reports `ready`. Checkpoints recorded with `--work` + are held on the run until `work:complete` succeeds, and completion is refused with the same list + otherwise. A card presents a judgment only if it is visible for review in the current pass or was + acted on since the run; `routine_action` judgments without a card may also be covered by items of + a routine action group proposed since the sweep. If the work never completes, the next sweep + starts from the previous checkpoint and re-reads those items; a held checkpoint is not written + over one that changed after the run was recorded. Re-claiming such work returns + `operatorGuidance.pendingPresentation` with the same list. ## Core Commands @@ -103,6 +111,7 @@ Run `tend cli help` for the full command surface. Core feed-runner commands are: | Remove source | `tend cli source:remove --feed --source ` | | Record source run | `tend cli source:record-run --feed --source (--snapshots \| --snapshots-file ) (--judgments \| --judgments-file ) (--checkpoint \| --checkpoint-file ) [--context-use-file ]` | | Record sweep batch | `tend cli sweep:record-batch --feed --runs [--context ]` | +| Check what the current sweep still needs before completing | `tend cli sweep:status --feed ` | | Record sweep rejudgment | `tend cli sweep:rejudge --feed --feedback --ordered-cards --removed-cards ` | | Upsert card | `tend cli card:upsert --feed --card ` | | Run configured readers on frozen input | `tend cli readers:run --feed --run --packet-file --readers-file ` | diff --git a/docs/SKILL.md b/docs/SKILL.md index 853d12a9..ed40777b 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -64,8 +64,9 @@ waking this same thread and saying `go deal with the feed`. 5. Use local connectors only for the claimed item. 6. Write results back through the relevant `tend cli` command. 7. For `sweep_rejudge`, run `sweep:rejudge` against the returned `operatorGuidance.visibleCardIds` before completing the work. -8. For source recollection, record source runs and a sweep batch with the claimed `--work` id, then upsert a card for every kept judgment before completing the work. - Checkpoints recorded with `--work` only advance when `work:complete` succeeds, and completion is refused until every `review` judgment has its own card and every `routine_action` judgment has a card or a proposed routine action group. +8. For source recollection, record source runs and a sweep batch with the claimed `--work` id, then upsert a card for every `review` or `routine_action` judgment before completing the work. + Give each such judgment a stable `cardId` (for example `gmail-`) and reuse it as the card id; run `sweep:status --feed ` and complete only when it reports `ready`. + Checkpoints recorded with `--work` only advance when `work:complete` succeeds; it is refused with the list of unpresented judgments otherwise, and a re-claim returns that list as `operatorGuidance.pendingPresentation`. If context influenced collection, include a file-backed `contextUse` on the relevant source run and pin the same update id to the sweep batch. A full Gmail sweep must begin with paginated `gmail_search_email_ids(query: "", label_ids: diff --git a/server/cli/contract.ts b/server/cli/contract.ts index 173c8943..30409d99 100644 --- a/server/cli/contract.ts +++ b/server/cli/contract.ts @@ -17,6 +17,7 @@ export const CLI_COMMANDS = [ "source:remove --feed --source ", "source:record-run --feed --source (--snapshots | --snapshots-file ) (--judgments | --judgments-file ) (--checkpoint | --checkpoint-file ) [--work ] [--context-use | --context-use-file ]", "sweep:record-batch --feed --runs [--work ] [--context ]", + "sweep:status --feed ", "sweep:rejudge --feed --feedback --ordered-cards --removed-cards ", "source:import-json-file --feed --source --path ", "source:import-file --feed --source --path ", diff --git a/server/cli/operator.ts b/server/cli/operator.ts index a1e2e571..5c5e6852 100644 --- a/server/cli/operator.ts +++ b/server/cli/operator.ts @@ -155,6 +155,9 @@ export async function runOperatorCli(rawArgs: string[]): Promise { value("context"), ); break; + case "sweep:status": + output = await domain.sweepPresentationStatus(required("feed")); + break; case "sweep:rejudge": output = await domain.recordSweepRejudgment( required("feed"), @@ -312,11 +315,16 @@ export async function runOperatorCli(rawArgs: string[]): Promise { claimed?.kind === "execute_approved_action" ? await store.readConfig(feedId) : undefined; + const sweepPresentation = + claimed?.intent === "recollect_sources" + ? await domain.sweepPresentationStatus(feedId) + : undefined; output = formatWorkClaimOutput(feedId, work, { card, feedConfig, routineActionGroup, sweepFeedback, + sweepPresentation, }); } break; diff --git a/server/domain.ts b/server/domain.ts index 1b81f0c3..71e79b10 100644 --- a/server/domain.ts +++ b/server/domain.ts @@ -42,6 +42,9 @@ import type { SourceRecipe, SourceRun, SweepBatch, + SweepPresentationGap, + SweepPresentationRun, + SweepPresentationStatus, SourceRunContextUse, SweepFeedbackTrace, ThreadBinding, @@ -393,6 +396,16 @@ const CARD_BLOCK_TYPES = new Set([ const REVIEW_DECISIONS = new Set(["review", "keep"]); const ROUTINE_DECISION = "routine_action"; +// A judgment may name the card that will present it; the id must be usable as a card id so the agent +// hears about a bad id when recording the run rather than when completing the work. +function validateJudgmentCardIds(judgments: unknown[]): void { + judgments.forEach((judgment, index) => { + if (!isRecord(judgment) || judgment.cardId === undefined) return; + if (typeof judgment.cardId !== "string" || !judgment.cardId.trim()) throw new Error(`Judgment ${index + 1} cardId must be a non-empty string.`); + safeIdentifier(judgment.cardId, `Judgment ${index + 1} cardId`); + }); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -3694,6 +3707,7 @@ export class AttentionDomain { if (!feed.sources.some((source) => source.id === sourceId)) throw new Error(`Source recipe not found: ${sourceId}`); if (triggerWorkId) await this.assertClaimedRecollectionWork(feedId, triggerWorkId); assertGmailInboxSweepEnumeration(sourceId, checkpoint); + validateJudgmentCardIds(judgments); const normalizedContextUse = contextUse ? normalizeContextUse(contextUse, await this.requireCurrentMindContext(contextUse.updateId), snapshots) : undefined; @@ -3760,48 +3774,115 @@ export class AttentionDomain { }); } - // Recollection work completes only once every judgment that needs a card has one; that is when the - // checkpoints held on the batch's runs become the sources' live checkpoints, inside the same transaction. - // Every run is validated before anything is written so a refusal leaves both SQLite and the file mirrors untouched. - private async commitHeldSourceCheckpoints(feedId: string, batch: SweepBatch): Promise { + /** + * What the current sweep still needs before its recollection work can complete. Judgments that name a + * `cardId` are matched exactly against that card; judgments without one fall back to counting cards that + * present the run and were not claimed by an exact match. `routine_action` judgments without a card may + * also be covered by items of routine action groups proposed since the sweep. + */ + async sweepPresentationStatus(feedId: string): Promise { + return this.store.serialize(async () => { + const sweep = await this.store.readSweepState(feedId); + if (!sweep.currentBatchId) { + return { status: "idle", currentBatchId: null, workId: null, workStatus: null, ready: true, runs: [], missing: [], routineGroupItems: 0, summary: "No sweep batch has been recorded for this feed." }; + } + return this.presentationReport(feedId, await this.store.readSweepBatch(feedId, sweep.currentBatchId)); + }); + } + + private async presentationReport(feedId: string, batch: SweepBatch): Promise { const cards = await this.store.listCards(feedId); + const cardsById = new Map(cards.map((card) => [card.id, card])); const feed = await this.store.readFeed(feedId); const workItems = await this.store.readWorkItems(feedId); const runs = await Promise.all(batch.sourceRunIds.map((runId) => this.store.readRun(feedId, runId))); - // Cards carry no judgment ids, so presentation is counted: each review judgment needs its own card that the - // user can actually see now or has already acted on since the run; routine judgments need cards or items of - // routine action groups proposed since the batch was recorded. - const presentsRun = (card: Card, run: SourceRun): boolean => { + const currentPass = feed.config.currentPass; + // Why a card does not present a run (undefined when it does): it must be written for the run and be + // visible for review now, or have been acted on since the run was recorded. + const hiddenReason = (card: Card, run: SourceRun): string | undefined => { const recordedAt = run.completedAt ?? ""; - if (!card.sourceRunIds?.includes(run.id) || card.updatedAt < recordedAt) return false; - if (card.status === "to_review_new" || card.status === "to_review_updated") return card.readyForPass <= feed.config.currentPass; - if (card.status === "done") return (card.completedAt ?? "") >= recordedAt; - return workItems.some((work) => work.cardId === card.id && work.createdAt >= recordedAt); + if (!card.sourceRunIds?.includes(run.id)) return `card ${card.id} does not list run ${run.id} in sourceRunIds`; + if (card.updatedAt < recordedAt) return `card ${card.id} was last written before this run was recorded`; + if (card.status === "to_review_new" || card.status === "to_review_updated") { + return card.readyForPass <= currentPass ? undefined : `card ${card.id} is deferred to pass ${card.readyForPass} (current pass ${currentPass})`; + } + if (card.status === "done") { + return (card.completedAt ?? "") >= recordedAt ? undefined : `card ${card.id} was dismissed or completed before this run and has not been resurfaced (upsert it with status to_review_updated)`; + } + return workItems.some((work) => work.cardId === card.id && work.createdAt >= recordedAt) ? undefined : `card ${card.id} is ${card.status} from before this run with no new approval since`; }; - let routineNeeded = 0; + const missing: SweepPresentationGap[] = []; + const runReports: SweepPresentationRun[] = []; + const routineFallback: Array<{ gap: SweepPresentationGap; report: SweepPresentationRun }> = []; for (const run of runs) { - const decisions = run.judgments.flatMap((judgment) => isRecord(judgment) && typeof judgment.decision === "string" ? [judgment.decision] : []); - const reviews = decisions.filter((decision) => REVIEW_DECISIONS.has(decision)).length; - const routines = decisions.filter((decision) => decision === ROUTINE_DECISION).length; - const presented = cards.filter((card) => presentsRun(card, run)).length; - if (presented < reviews) { - throw new Error( - `Source run ${run.id} (${sourceDisplayName(run.sourceId)}) has ${reviews} review ${reviews === 1 ? "judgment" : "judgments"} but only ${presented} ${presented === 1 ? "card presents" : "cards present"} it. Upsert one card per review judgment with sourceRunIds including ${run.id}, in a review status for the current pass, before completing this work; Tend holds the source checkpoint until then.`, - ); - } - routineNeeded += Math.max(0, routines - (presented - reviews)); + const claimed = new Set(); + const countedReview: SweepPresentationGap[] = []; + const countedRoutine: SweepPresentationGap[] = []; + const report: SweepPresentationRun = { runId: run.id, sourceId: run.sourceId, checkpointHeld: run.pendingCheckpoint !== undefined, judgments: run.judgments.length, needingPresentation: 0, presented: 0 }; + run.judgments.forEach((judgment, index) => { + if (!isRecord(judgment) || typeof judgment.decision !== "string") return; + const decision = judgment.decision; + const review = REVIEW_DECISIONS.has(decision); + if (!review && decision !== ROUTINE_DECISION) return; + report.needingPresentation += 1; + const gap: SweepPresentationGap = { runId: run.id, sourceId: run.sourceId, judgment: index + 1, decision, reason: "" }; + const cardId = typeof judgment.cardId === "string" ? judgment.cardId : undefined; + if (cardId === undefined) { + (review ? countedReview : countedRoutine).push(gap); + return; + } + const card = cardsById.get(cardId); + const reason = card ? hiddenReason(card, run) : `no card with id ${cardId} exists`; + if (reason) missing.push({ ...gap, cardId, reason }); + else { + claimed.add(cardId); + report.presented += 1; + } + }); + // Judgments without a cardId fall back to counting presenting cards that no exact match claimed. + const pool = cards.filter((card) => !claimed.has(card.id) && hiddenReason(card, run) === undefined).length; + const reviewCovered = Math.min(pool, countedReview.length); + report.presented += reviewCovered; + for (const gap of countedReview.slice(reviewCovered)) { + missing.push({ ...gap, reason: `no unclaimed card presents this review judgment (${pool} available for ${countedReview.length} review ${countedReview.length === 1 ? "judgment" : "judgments"} without a cardId); add a cardId or upsert another card with sourceRunIds including ${run.id}` }); + } + const routineCovered = Math.max(0, Math.min(pool - countedReview.length, countedRoutine.length)); + report.presented += routineCovered; + for (const gap of countedRoutine.slice(routineCovered)) routineFallback.push({ gap, report }); + runReports.push(report); } - if (routineNeeded > 0) { - const earliestRecordedAt = runs.map((run) => run.completedAt ?? "").sort()[0] ?? ""; - const routineItems = feed.routineActions - .filter((group) => group.status !== "stale" && group.status !== "failed" && group.createdAt >= earliestRecordedAt) - .reduce((total, group) => total + group.items.length, 0); - if (routineItems < routineNeeded) { - throw new Error( - `This sweep has ${routineNeeded} routine_action ${routineNeeded === 1 ? "judgment" : "judgments"} without a card, but routine action groups proposed since it was recorded hold only ${routineItems} ${routineItems === 1 ? "item" : "items"}. Propose the routine action group or upsert cards for them before completing this work; Tend holds the source checkpoints until then.`, - ); - } + const earliestRecordedAt = runs.map((run) => run.completedAt ?? "").sort()[0] ?? ""; + const routineGroupItems = feed.routineActions + .filter((group) => group.status !== "stale" && group.status !== "failed" && group.createdAt >= earliestRecordedAt) + .reduce((total, group) => total + group.items.length, 0); + routineFallback.forEach(({ gap, report }, index) => { + if (index < routineGroupItems) report.presented += 1; + else missing.push({ ...gap, reason: `no card or routine action group item presents this routine_action judgment (${routineGroupItems} group ${routineGroupItems === 1 ? "item" : "items"} proposed since the sweep for ${routineFallback.length} such ${routineFallback.length === 1 ? "judgment" : "judgments"}); add a cardId, upsert a card, or propose the group` }); + }); + const held = runReports.filter((report) => report.checkpointHeld).length; + const needing = runReports.reduce((total, report) => total + report.needingPresentation, 0); + const ready = missing.length === 0; + const status = held ? "pending" : "committed"; + const workId = batch.triggerWorkId ?? null; + const workStatus = workId ? (await this.store.readWorkItems(feedId)).find((work) => work.id === workId)?.status ?? null : null; + const describe = (gap: SweepPresentationGap) => `run ${gap.runId} judgment ${gap.judgment} (${gap.decision}${gap.cardId ? `, cardId ${gap.cardId}` : ""}): ${gap.reason}`; + const summary = status === "committed" + ? `Batch ${batch.id} is committed; ${ready ? `all ${needing} judgments that need presentation are presented.` : `${missing.length} of ${needing} judgments have no presenting card (informational).`}` + : ready + ? `All ${needing} judgments that need presentation are presented; work:complete will commit ${held} held checkpoint${held === 1 ? "" : "s"}.` + : `${missing.length} of ${needing} judgments are not presented yet: ${missing.slice(0, 3).map(describe).join("; ")}${missing.length > 3 ? `; and ${missing.length - 3} more` : ""}.`; + return { status, currentBatchId: batch.id, workId, workStatus, ready, runs: runReports, missing, routineGroupItems, summary }; + } + + // Recollection work completes only once every judgment that needs presentation has it; that is when the + // checkpoints held on the batch's runs become the sources' live checkpoints, inside the same transaction. + // The whole batch is validated before anything is written so a refusal leaves both SQLite and the file mirrors untouched. + private async commitHeldSourceCheckpoints(feedId: string, batch: SweepBatch): Promise { + const report = await this.presentationReport(feedId, batch); + if (!report.ready) { + throw new Error(`${report.summary} Tend holds the source checkpoints until then; run \`tend cli sweep:status --feed ${feedId}\` for the full list.`); } + const runs = await Promise.all(batch.sourceRunIds.map((runId) => this.store.readRun(feedId, runId))); // The newest run per source (by recording sequence, then recorded time) owns that source's checkpoint; // earlier same-source runs in the batch are superseded. const recordingOrder = (a: SourceRun, b: SourceRun) => (a.checkpointSequence ?? 0) - (b.checkpointSequence ?? 0) || (a.completedAt ?? "").localeCompare(b.completedAt ?? ""); diff --git a/server/operator.ts b/server/operator.ts index 1957e1cd..e61e5386 100644 --- a/server/operator.ts +++ b/server/operator.ts @@ -1,4 +1,4 @@ -import type { Card, CardBlock, FeedConfig, ProposedAction, RoutineActionGroup, SweepFeedbackTrace, WorkClaimResult, WorkClaimedByReport, WorkItem, WorkItemView } from "../shared/types"; +import type { Card, CardBlock, FeedConfig, ProposedAction, RoutineActionGroup, SweepFeedbackTrace, SweepPresentationStatus, WorkClaimResult, WorkClaimedByReport, WorkItem, WorkItemView } from "../shared/types"; import { actionEmailRecipients } from "../shared/emailRecipients"; import { actionDigest, cleanupDigest, configuredApprovalAction, routineActionDigest } from "./workflow/approvals"; @@ -26,6 +26,8 @@ export interface ClaimedWorkOutput extends Omit; routineActionGroup?: RoutineActionGroup; sweepFeedback?: Pick; + sweepPresentation?: SweepPresentationStatus; } export interface UserAuthorizationReceipt { @@ -256,7 +259,14 @@ export function formatWorkClaimOutput(feedId: string, work: WorkClaimResult, con } if (work.intent === "recollect_sources") { - operatorGuidance.requiredWriteBack = "Record one or more source runs with `source:record-run --work `, then create a sweep batch with `sweep:record-batch --work `, then upsert one card (with sourceRunIds) per review judgment and present routine_action judgments as cards or a proposed routine action group before `work:complete`. Checkpoints recorded with --work advance only when completion succeeds; completion is refused until every judgment is presented."; + operatorGuidance.requiredWriteBack = "Record one or more source runs with `source:record-run --work `, giving every review or routine_action judgment a stable `cardId` (for example `gmail-`) that you will reuse as the card id in `card:upsert`; then `sweep:record-batch --work `; then upsert one card per judgment with sourceRunIds including the run. Run `sweep:status --feed ` to see exactly what is still missing; `work:complete` is refused until it reports ready, and the source checkpoints advance only then. Judgments without a cardId are matched by count instead."; + const pending = context.sweepPresentation; + if (pending?.status === "pending" && pending.workId === work.id) { + operatorGuidance.completionPrerequisite = pending.ready + ? `Batch ${pending.currentBatchId} recorded by this work is fully presented; finish with work:complete.` + : `Batch ${pending.currentBatchId} was already recorded by this work; do not record another. Present the judgments listed in pendingPresentation.missing, then work:complete.`; + operatorGuidance.pendingPresentation = pending; + } operatorGuidance.sourceRunRule = feedId === "inbox" ? "For a full Gmail sweep, first paginate gmail_search_email_ids(query='', label_ids=['INBOX']). Treat that message-ID manifest as authoritative, direct-read every ID, and record an inboxEnumeration.messages entry mapping each messageId to its threadId. Its readThreadIds and carriedForwardThreadIds must then classify every resulting thread exactly once. gmail_search_emails results may enrich the run but cannot define the Inbox universe. Source recollection must complete with a new sweep batch recorded for this exact work item." : "Source recollection work must complete with a new sweep batch recorded for this exact work item."; diff --git a/server/templates.ts b/server/templates.ts index 3f7db03f..6a3fa3c3 100644 --- a/server/templates.ts +++ b/server/templates.ts @@ -22,6 +22,9 @@ Before composing full cards, choose a disposition for each source item: \`review deserve individual attention, \`routine_action\` for a conservative batch with the same obvious cleanup, or \`suppress\` for items that should stay out of the sweep. Routine actions remain proposed until the user approves the exact visible group. Never hide ambiguity inside a routine-action batch. +Give every \`review\` or \`routine_action\` judgment a stable \`cardId\` (for example \`gmail-\`) and +reuse that exact id in \`card:upsert\`; Tend verifies each judged item was presented before the source +checkpoint advances. `; export const COMPOSE_CARD_PROMPT = `# Compose card prompt diff --git a/server/version.ts b/server/version.ts index 2ccf940a..f6edfff3 100644 --- a/server/version.ts +++ b/server/version.ts @@ -2,7 +2,7 @@ import packageJson from "../package.json"; export const APP_NAME = packageJson.name; export const APP_VERSION = packageJson.version; -export const CLI_CONTRACT_VERSION = "0.8"; +export const CLI_CONTRACT_VERSION = "0.9"; export function versionInfo() { return { diff --git a/shared/types.ts b/shared/types.ts index c1f180ff..d95d88ef 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -653,6 +653,39 @@ export interface SourceRun { checkpointCommittedAt?: string; } +/** One judgment of the current sweep that is not presented yet, with the exact reason. */ +export interface SweepPresentationGap { + runId: string; + sourceId: string; + /** 1-based position in the run's judgments array. */ + judgment: number; + decision: string; + cardId?: string; + reason: string; +} + +export interface SweepPresentationRun { + runId: string; + sourceId: string; + checkpointHeld: boolean; + judgments: number; + needingPresentation: number; + presented: number; +} + +/** What `sweep:status` reports and what `work:complete` checks for recollection work. */ +export interface SweepPresentationStatus { + status: "idle" | "committed" | "pending"; + currentBatchId: string | null; + workId: string | null; + workStatus: WorkStatus | null; + ready: boolean; + runs: SweepPresentationRun[]; + missing: SweepPresentationGap[]; + routineGroupItems: number; + summary: string; +} + export interface AppFeedback { id: string; feedId: FeedId; diff --git a/test/cli-contract.test.ts b/test/cli-contract.test.ts index 1ed4a8b0..641dddb4 100644 --- a/test/cli-contract.test.ts +++ b/test/cli-contract.test.ts @@ -29,6 +29,7 @@ describe("CLI contract", () => { expect(commandNames).toContain("work:complete"); expect(commandNames).toContain("work:reconcile-approved"); expect(commandNames).toContain("source:record-run"); + expect(commandNames).toContain("sweep:status"); expect(commandNames).toContain("card:upsert"); expect(commandNames).toContain("card:dismiss"); expect(commandNames).toContain("card:cleanup-source"); @@ -43,7 +44,7 @@ describe("CLI contract", () => { }); test("exposes native readers and exact-version feedback without edition commands", () => { - expect(CLI_CONTRACT_VERSION).toBe("0.8"); + expect(CLI_CONTRACT_VERSION).toBe("0.9"); expect(CLI_COMMANDS).toContain("readers:run --feed --run --packet-file --readers-file [--prompt-sha256 ]"); expect(CLI_COMMANDS).toContain("readers:status --feed --run "); expect(CLI_COMMANDS).toContain("readers:output --feed --run --reader "); @@ -218,6 +219,14 @@ describe("CLI contract", () => { const runId = JSON.parse(recorded.stdout) as string; const store = new AttentionStore(path.join(home, "data")); expect(await store.readRun("company-attention", runId)).toMatchObject({ snapshots: 1, judgments: [] }); + const idle = await run(["sweep:status", "--feed", "company-attention"]); + expect(idle.code).toBe(0); + expect(JSON.parse(idle.stdout)).toMatchObject({ status: "idle", ready: true, missing: [] }); + const batch = await run(["sweep:record-batch", "--feed", "company-attention", "--runs", JSON.stringify([runId])]); + expect(batch.code).toBe(0); + const committed = await run(["sweep:status", "--feed", "company-attention"]); + expect(committed.code).toBe(0); + expect(JSON.parse(committed.stdout)).toMatchObject({ status: "committed", currentBatchId: JSON.parse(batch.stdout), ready: true, missing: [], runs: [{ runId, checkpointHeld: false, judgments: 0, needingPresentation: 0, presented: 0 }] }); const saved = JSON.parse(await readFile(store.feedPath("company-attention", "raw", runId, "company-attention", "snapshot-1.json"), "utf8")); expect(saved).toEqual(snapshots[0]); const missing = await run(["readers:run", "--feed", "company-attention", "--run", runId, "--packet-file", "unused.txt"]); diff --git a/test/sweep-checkpoints.test.ts b/test/sweep-checkpoints.test.ts index 414b369d..68792f1b 100644 --- a/test/sweep-checkpoints.test.ts +++ b/test/sweep-checkpoints.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { AttentionDomain } from "../server/domain"; import { createLocalRuntime } from "../server/runtime"; +import { formatWorkClaimOutput } from "../server/operator"; import type { WorkItem } from "../shared/types"; const FEED = "company-attention"; @@ -43,7 +44,7 @@ test("a recollection checkpoint advances only when the work completes with cards expect(await store.readSourceCheckpoint(FEED, SOURCE)).toEqual(before); await expect(domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Collected." })) - .rejects.toThrow(`Source run ${run} (Company Attention) has 1 review judgment but only 0 cards present it`); + .rejects.toThrow(`1 of 1 judgments are not presented yet: run ${run} judgment 1 (review): no unclaimed card presents this review judgment (0 available for 1 review judgment without a cardId)`); expect(await store.readSourceCheckpoint(FEED, SOURCE)).toEqual(before); expect(await mirrorCheckpoint()).toEqual(before); expect((await store.readWork(FEED, work.id)).status).toBe("working"); @@ -143,7 +144,7 @@ test("a refused completion writes nothing, even when an earlier run in the batch const missing = await domain.recordSourceRun(FEED, second.id, [{ note: "Not presented." }], [{ decision: "review" }], { cursor: "missing" }, work.id); await domain.recordSweepBatch(FEED, [presented, missing], work.id); await domain.upsertCard(FEED, { id: "presented-card", title: "Presented", why: "Has a card.", blocks: [], sourceRunIds: [presented] }); - await expect(domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })).rejects.toThrow(`Source run ${missing}`); + await expect(domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })).rejects.toThrow(`1 of 2 judgments are not presented yet: run ${missing} judgment 1 (review)`); expect(await store.readSourceCheckpoint(FEED, SOURCE)).toEqual(before); expect(await mirrorCheckpoint()).toEqual(beforeMirror); expect((await store.readRun(FEED, presented)).pendingCheckpoint).toEqual({ cursor: "presented" }); @@ -163,10 +164,10 @@ test("every review judgment needs its own card, and routine_action judgments nee await domain.recordSweepBatch(FEED, [run], work.id); await domain.upsertCard(FEED, { id: "only-one", title: "One", why: "Covers one review judgment.", blocks: [], sourceRunIds: [run] }); await expect(domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })) - .rejects.toThrow(`has 2 review judgments but only 1 card presents it`); + .rejects.toThrow("2 of 3 judgments are not presented yet: run " + run + " judgment 2 (review): no unclaimed card presents this review judgment (1 available for 2 review judgments without a cardId)"); await domain.upsertCard(FEED, { id: "the-other", title: "Two", why: "Covers the other review judgment.", blocks: [], sourceRunIds: [run] }); await expect(domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })) - .rejects.toThrow(`This sweep has 1 routine_action judgment without a card, but routine action groups proposed since it was recorded hold only 0 items`); + .rejects.toThrow("judgment 3 (routine_action): no card or routine action group item presents this routine_action judgment (0 group items proposed since the sweep for 1 such judgment)"); expect(await store.readSourceCheckpoint(FEED, SOURCE)).toEqual(before); await domain.upsertRoutineActionGroup(FEED, { id: "routine-fixture", label: "Archive newsletters", summary: "Three newsletters with the same obvious cleanup.", @@ -221,7 +222,7 @@ test("a dismissed card that merely gains the run id does not count as presented" await domain.upsertCard(FEED, { id: "old-thread", title: "Old thread", why: "A new message arrived.", blocks: [], sourceRunIds: [run] }); expect((await store.readCard(FEED, "old-thread")).status).toBe("done"); await expect(domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })) - .rejects.toThrow("has 1 review judgment but only 0 cards present it"); + .rejects.toThrow("1 of 1 judgments are not presented yet: run " + run + " judgment 1 (review): no unclaimed card presents this review judgment"); expect(await store.readSourceCheckpoint(FEED, SOURCE)).toEqual(before); await domain.upsertCard(FEED, { id: "old-thread", title: "Old thread", why: "A new message arrived.", blocks: [], sourceRunIds: [run], status: "to_review_updated" }); expect((await domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })).status).toBe("completed"); @@ -246,7 +247,7 @@ test("cards deferred to a later pass or merely re-tagged while active do not cou await domain.upsertCard(FEED, { id: "already-queued", title: "Queued earlier", why: "Approved before the sweep.", blocks: [], sourceRunIds: [run] }); expect((await store.readCard(FEED, "already-queued")).status).toBe("queued"); await expect(domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })) - .rejects.toThrow("has 2 review judgments but only 0 cards present it"); + .rejects.toThrow("2 of 2 judgments are not presented yet"); expect(await store.readSourceCheckpoint(FEED, SOURCE)).toEqual(before); await domain.upsertCard(FEED, { id: "deferred", title: "Deferred", why: "Now visible.", blocks: [], sourceRunIds: [run], readyForPass: currentPass }); await domain.upsertCard(FEED, { id: "already-queued", title: "Queued earlier", why: "Back in review with new evidence.", blocks: [], sourceRunIds: [run], status: "to_review_updated" }); @@ -271,7 +272,7 @@ test("a routine action group proposed before the sweep does not present its rout const run = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }, { b: 2 }], [{ decision: "routine_action" }, { decision: "routine_action" }], { cursor: "routine" }, work.id); await domain.recordSweepBatch(FEED, [run], work.id); // supersedes the earlier proposed group await expect(domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })) - .rejects.toThrow("This sweep has 2 routine_action judgments without a card, but routine action groups proposed since it was recorded hold only 0 items"); + .rejects.toThrow("2 of 2 judgments are not presented yet: run " + run + " judgment 1 (routine_action): no card or routine action group item presents this routine_action judgment (0 group items proposed since the sweep for 2 such judgments)"); expect(await store.readSourceCheckpoint(FEED, SOURCE)).toEqual(before); await domain.upsertRoutineActionGroup(FEED, { id: "fresh-routine", label: "New batch", summary: "Proposed for this sweep.", @@ -279,7 +280,7 @@ test("a routine action group proposed before the sweep does not present its rout items: [{ id: "new-1", title: "Digest", reason: "Routine." }], }); await expect(domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })) - .rejects.toThrow("hold only 1 item"); + .rejects.toThrow("1 of 2 judgments are not presented yet: run " + run + " judgment 2 (routine_action): no card or routine action group item presents this routine_action judgment (1 group item proposed since the sweep for 2 such judgments)"); await domain.upsertRoutineActionGroup(FEED, { id: "fresh-routine", label: "New batch", summary: "Proposed for this sweep.", proposedAction: { label: "Archive", instruction: "Archive the listed items." }, @@ -293,6 +294,125 @@ test("a routine action group proposed before the sweep does not present its rout } }); +test("judgments that name a cardId are matched against that exact card, with precise reasons", async () => { + const { root, runtime, store, domain } = await setup(); + try { + const before = await store.readSourceCheckpoint(FEED, SOURCE); + expect(await domain.sweepPresentationStatus(FEED)).toMatchObject({ status: "idle", ready: true, missing: [] }); + const work = await claimRecollection(domain); + const run = await domain.recordSourceRun(FEED, SOURCE, [{ thread: "a" }, { thread: "b" }, { thread: "c" }], + [{ decision: "review", cardId: "thread-a" }, { decision: "review", cardId: "thread-b" }, { decision: "suppress", cardId: "ignored" }], { cursor: "exact" }, work.id); + await domain.recordSweepBatch(FEED, [run], work.id); + + let status = await domain.sweepPresentationStatus(FEED); + expect(status).toMatchObject({ status: "pending", currentBatchId: expect.any(String), workId: work.id, workStatus: "working", ready: false }); + expect(status.missing.map((gap) => [gap.judgment, gap.cardId, gap.reason])).toEqual([ + [1, "thread-a", "no card with id thread-a exists"], + [2, "thread-b", "no card with id thread-b exists"], + ]); + await expect(domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })) + .rejects.toThrow("2 of 2 judgments are not presented yet: run " + run + " judgment 1 (review, cardId thread-a): no card with id thread-a exists"); + + // The right card id but no provenance, then a card that was dismissed before this run. + await domain.upsertCard(FEED, { id: "thread-a", title: "A", why: "Forgot sourceRunIds.", blocks: [] }); + await domain.upsertCard(FEED, { id: "thread-b", title: "B", why: "Old.", blocks: [], sourceRunIds: [run], status: "done", completionDisposition: "dismissed", completedAt: "2020-01-01T00:00:00.000Z" }); + status = await domain.sweepPresentationStatus(FEED); + expect(status.missing.map((gap) => gap.reason)).toEqual([ + `card thread-a does not list run ${run} in sourceRunIds`, + "card thread-b was dismissed or completed before this run and has not been resurfaced (upsert it with status to_review_updated)", + ]); + expect(await store.readSourceCheckpoint(FEED, SOURCE)).toEqual(before); + + await domain.upsertCard(FEED, { id: "thread-a", title: "A", why: "Now with provenance.", blocks: [], sourceRunIds: [run] }); + await domain.upsertCard(FEED, { id: "thread-b", title: "B", why: "Resurfaced.", blocks: [], sourceRunIds: [run], status: "to_review_updated" }); + status = await domain.sweepPresentationStatus(FEED); + expect(status.ready).toBe(true); + expect(status.runs[0]).toMatchObject({ runId: run, checkpointHeld: true, judgments: 3, needingPresentation: 2, presented: 2 }); + expect((await domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })).status).toBe("completed"); + expect(await store.readSourceCheckpoint(FEED, SOURCE)).toEqual({ cursor: "exact" }); + expect((await domain.sweepPresentationStatus(FEED)).status).toBe("committed"); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("a card claimed by an exact match does not also count for a judgment without a cardId", async () => { + const { root, runtime, store, domain } = await setup(); + try { + const work = await claimRecollection(domain); + const run = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }, { b: 2 }], [{ decision: "review", cardId: "named" }, { decision: "review" }], { cursor: "mixed" }, work.id); + await domain.recordSweepBatch(FEED, [run], work.id); + await domain.upsertCard(FEED, { id: "named", title: "Named", why: "Exact match.", blocks: [], sourceRunIds: [run] }); + const status = await domain.sweepPresentationStatus(FEED); + expect(status.missing).toHaveLength(1); + expect(status.missing[0]).toMatchObject({ judgment: 2, decision: "review" }); + expect(status.missing[0].cardId).toBeUndefined(); + expect(status.missing[0].reason).toContain("0 available for 1 review judgment without a cardId"); + await domain.upsertCard(FEED, { id: "anonymous", title: "Other", why: "Counted.", blocks: [], sourceRunIds: [run] }); + expect((await domain.sweepPresentationStatus(FEED)).ready).toBe(true); + expect((await domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })).status).toBe("completed"); + expect(await store.readSourceCheckpoint(FEED, SOURCE)).toEqual({ cursor: "mixed" }); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("two judgments may deliberately share one cardId", async () => { + const { root, runtime, store, domain } = await setup(); + try { + const work = await claimRecollection(domain); + const run = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }, { b: 2 }], [{ decision: "review", cardId: "merged" }, { decision: "routine_action", cardId: "merged" }], { cursor: "shared" }, work.id); + await domain.recordSweepBatch(FEED, [run], work.id); + await domain.upsertCard(FEED, { id: "merged", title: "Merged", why: "Presents both.", blocks: [], sourceRunIds: [run] }); + expect((await domain.sweepPresentationStatus(FEED)).ready).toBe(true); + expect((await domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })).status).toBe("completed"); + expect(await store.readSourceCheckpoint(FEED, SOURCE)).toEqual({ cursor: "shared" }); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("a judgment cardId is validated when the run is recorded", async () => { + const { root, runtime, domain } = await setup(); + try { + const work = await claimRecollection(domain); + await expect(domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "review", cardId: "has spaces" }], { cursor: "bad" }, work.id)) + .rejects.toThrow("Judgment 1 cardId must use only letters, numbers, dots, underscores, and hyphens."); + await expect(domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "suppress" }, { decision: "review", cardId: 7 }], { cursor: "bad" }, work.id)) + .rejects.toThrow("Judgment 2 cardId must be a non-empty string."); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("re-claiming an interrupted recollection returns what is still missing", async () => { + const { root, runtime, domain } = await setup(); + try { + const work = await claimRecollection(domain); + const run = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "review", cardId: "thread-x" }], { cursor: "reclaim" }, work.id); + await domain.recordSweepBatch(FEED, [run], work.id); + // The claiming session dies; a fresh session re-claims the same work through the lane replay path. + const released = await domain.releaseWork(FEED, work.id, work.capabilityToken); + expect(released.status).toBe("queued"); + const reclaimed = await domain.claimWork(FEED, THREAD) as WorkItem; + expect(reclaimed.id).toBe(work.id); + const output = formatWorkClaimOutput(FEED, reclaimed, { sweepPresentation: await domain.sweepPresentationStatus(FEED) }); + if (!("operatorGuidance" in output)) throw new Error("Expected claim guidance."); + expect(output.operatorGuidance?.completionPrerequisite).toContain("do not record another"); + expect(output.operatorGuidance?.pendingPresentation?.missing.map((gap) => gap.cardId)).toEqual(["thread-x"]); + expect(output.operatorGuidance?.requiredWriteBack).toContain("cardId"); + await domain.upsertCard(FEED, { id: "thread-x", title: "X", why: "Presented after re-claim.", blocks: [], sourceRunIds: [run] }); + expect((await domain.completeWork(FEED, work.id, reclaimed.capabilityToken, { response: "Finished." })).status).toBe("completed"); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + test("runs recorded without recollection work still advance the checkpoint immediately", async () => { const { root, runtime, store, domain } = await setup(); try { From 0f3fd2a9d83946567f3b3a36ad42fdcdb194dea8 Mon Sep 17 00:00:00 2001 From: Dan Shipper Date: Sat, 12 Sep 2026 23:19:24 -0400 Subject: [PATCH 02/16] fix(sweeps): reserve exactly named cards across the whole batch Exact-card claims were scoped per run, so a card named by one run's judgment that also listed a second run could silently satisfy that run's unnamed judgment. Collect exact claims across the batch first, then count unnamed judgments over cards no exact match reserved; sharing a card is explicit, every judgment it presents names it. Tests cover both run orders, and the judge prompt's cardId instruction is now pinned by a test. Co-Authored-By: Claude Fable 5.1 --- server/domain.ts | 20 ++++++++++++++------ test/sweep-checkpoints.test.ts | 30 ++++++++++++++++++++++++++++++ test/templates.test.ts | 8 +++++++- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/server/domain.ts b/server/domain.ts index 71e79b10..dce0c57b 100644 --- a/server/domain.ts +++ b/server/domain.ts @@ -3814,11 +3814,14 @@ export class AttentionDomain { const missing: SweepPresentationGap[] = []; const runReports: SweepPresentationRun[] = []; const routineFallback: Array<{ gap: SweepPresentationGap; report: SweepPresentationRun }> = []; + // Pass 1: judgments that name a card are matched exactly, and the cards they claim are reserved across the + // whole batch, so a card named by one run's judgment never silently satisfies another run's unnamed one. + // Sharing a card is explicit: every judgment it presents names it. + const claimed = new Set(); + const counted = new Map(); for (const run of runs) { - const claimed = new Set(); - const countedReview: SweepPresentationGap[] = []; - const countedRoutine: SweepPresentationGap[] = []; const report: SweepPresentationRun = { runId: run.id, sourceId: run.sourceId, checkpointHeld: run.pendingCheckpoint !== undefined, judgments: run.judgments.length, needingPresentation: 0, presented: 0 }; + const unnamed = { review: [] as SweepPresentationGap[], routine: [] as SweepPresentationGap[] }; run.judgments.forEach((judgment, index) => { if (!isRecord(judgment) || typeof judgment.decision !== "string") return; const decision = judgment.decision; @@ -3828,7 +3831,7 @@ export class AttentionDomain { const gap: SweepPresentationGap = { runId: run.id, sourceId: run.sourceId, judgment: index + 1, decision, reason: "" }; const cardId = typeof judgment.cardId === "string" ? judgment.cardId : undefined; if (cardId === undefined) { - (review ? countedReview : countedRoutine).push(gap); + (review ? unnamed.review : unnamed.routine).push(gap); return; } const card = cardsById.get(cardId); @@ -3839,7 +3842,13 @@ export class AttentionDomain { report.presented += 1; } }); - // Judgments without a cardId fall back to counting presenting cards that no exact match claimed. + runReports.push(report); + counted.set(run.id, unnamed); + } + // Pass 2: judgments without a cardId fall back to counting presenting cards that no exact match claimed. + for (const run of runs) { + const report = runReports.find((candidate) => candidate.runId === run.id)!; + const { review: countedReview, routine: countedRoutine } = counted.get(run.id)!; const pool = cards.filter((card) => !claimed.has(card.id) && hiddenReason(card, run) === undefined).length; const reviewCovered = Math.min(pool, countedReview.length); report.presented += reviewCovered; @@ -3849,7 +3858,6 @@ export class AttentionDomain { const routineCovered = Math.max(0, Math.min(pool - countedReview.length, countedRoutine.length)); report.presented += routineCovered; for (const gap of countedRoutine.slice(routineCovered)) routineFallback.push({ gap, report }); - runReports.push(report); } const earliestRecordedAt = runs.map((run) => run.completedAt ?? "").sort()[0] ?? ""; const routineGroupItems = feed.routineActions diff --git a/test/sweep-checkpoints.test.ts b/test/sweep-checkpoints.test.ts index 68792f1b..1a10eb37 100644 --- a/test/sweep-checkpoints.test.ts +++ b/test/sweep-checkpoints.test.ts @@ -359,6 +359,36 @@ test("a card claimed by an exact match does not also count for a judgment withou } }); +for (const order of ["named-first", "unnamed-first"] as const) { + test(`a card named by one run's judgment is not counted for another run's unnamed judgment (${order})`, async () => { + const { root, runtime, store, domain } = await setup(); + try { + const work = await claimRecollection(domain); + const second = await domain.addSourceFromBrief(FEED, "Read the dispute ledger."); + const named = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "review", cardId: "shared" }], { cursor: "named" }, work.id); + const unnamed = await domain.recordSourceRun(FEED, second.id, [{ b: 2 }], [{ decision: "review" }], { cursor: "unnamed" }, work.id); + await domain.recordSweepBatch(FEED, order === "named-first" ? [named, unnamed] : [unnamed, named], work.id); + await domain.upsertCard(FEED, { id: "shared", title: "Shared", why: "Lists both runs but is named by only one judgment.", blocks: [], sourceRunIds: [named, unnamed] }); + const status = await domain.sweepPresentationStatus(FEED); + expect(status.ready).toBe(false); + expect(status.missing.map((gap) => [gap.runId, gap.judgment, gap.cardId])).toEqual([[unnamed, 1, undefined]]); + await expect(domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })).rejects.toThrow("1 of 2 judgments are not presented yet"); + // Naming the same card from the second judgment makes the sharing explicit. + await domain.upsertCard(FEED, { id: "shared", title: "Shared", why: "Same card, now named by both.", blocks: [], sourceRunIds: [named, unnamed] }); + const rerecorded = await domain.recordSourceRun(FEED, second.id, [{ b: 2 }], [{ decision: "review", cardId: "shared" }], { cursor: "unnamed" }, work.id); + await domain.recordSweepBatch(FEED, [named, rerecorded], work.id); + await domain.upsertCard(FEED, { id: "shared", title: "Shared", why: "Same card, now named by both.", blocks: [], sourceRunIds: [named, rerecorded] }); + expect((await domain.sweepPresentationStatus(FEED)).ready).toBe(true); + expect((await domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })).status).toBe("completed"); + expect(await store.readSourceCheckpoint(FEED, SOURCE)).toEqual({ cursor: "named" }); + expect(await store.readSourceCheckpoint(FEED, second.id)).toEqual({ cursor: "unnamed" }); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } + }); +} + test("two judgments may deliberately share one cardId", async () => { const { root, runtime, store, domain } = await setup(); try { diff --git a/test/templates.test.ts b/test/templates.test.ts index 0f645caa..8baa6f24 100644 --- a/test/templates.test.ts +++ b/test/templates.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { COMPOSE_CARD_PROMPT } from "../server/templates"; +import { BASE_JUDGE_PROMPT, COMPOSE_CARD_PROMPT } from "../server/templates"; test("card composition prompt distinguishes local dismissal from source cleanup", () => { expect(COMPOSE_CARD_PROMPT).toContain("`dismiss_card`"); @@ -7,3 +7,9 @@ test("card composition prompt distinguishes local dismissal from source cleanup" expect(COMPOSE_CARD_PROMPT).toContain("explicit source cleanup"); expect(COMPOSE_CARD_PROMPT).toContain("routine “clear this card” control"); }); + +test("the judge prompt tells agents to name each judgment's card and reuse it", () => { + expect(BASE_JUDGE_PROMPT).toContain("stable `cardId`"); + expect(BASE_JUDGE_PROMPT).toContain("reuse that exact id in `card:upsert`"); + expect(BASE_JUDGE_PROMPT).toContain("before the source\ncheckpoint advances"); +}); From 13eaaec3ff1a76dfd2de47762462fdfef0c0afcd Mon Sep 17 00:00:00 2001 From: Dan Shipper Date: Sat, 12 Sep 2026 23:25:30 -0400 Subject: [PATCH 03/16] fix(sweeps): list every gap at once, honor work state, aggregate routine coverage Second-review follow-ups to sweep:status and the completion check: - Every card a judgment names is reserved across the batch before its own presentation is validated, so the report shows all gaps in one pass and a named card never satisfies an unnamed judgment elsewhere. - `ready` now also requires the recollection work to be claimable (working or queued); a failed or cancelled owner reports ready: false with a summary that says to request a new recollection. - routine_action judgments without a card are covered by routine action group items as an aggregate; when coverage falls short every such judgment is listed instead of an order-dependent subset. - The missing list is sorted by batch run order and judgment position. Co-Authored-By: Claude Fable 5.1 --- server/domain.ts | 117 ++++++++++++++++++--------------- test/sweep-checkpoints.test.ts | 57 +++++++++++++++- 2 files changed, 117 insertions(+), 57 deletions(-) diff --git a/server/domain.ts b/server/domain.ts index dce0c57b..ea34438f 100644 --- a/server/domain.ts +++ b/server/domain.ts @@ -3811,75 +3811,84 @@ export class AttentionDomain { } return workItems.some((work) => work.cardId === card.id && work.createdAt >= recordedAt) ? undefined : `card ${card.id} is ${card.status} from before this run with no new approval since`; }; - const missing: SweepPresentationGap[] = []; - const runReports: SweepPresentationRun[] = []; - const routineFallback: Array<{ gap: SweepPresentationGap; report: SweepPresentationRun }> = []; - // Pass 1: judgments that name a card are matched exactly, and the cards they claim are reserved across the - // whole batch, so a card named by one run's judgment never silently satisfies another run's unnamed one. - // Sharing a card is explicit: every judgment it presents names it. - const claimed = new Set(); - const counted = new Map(); + type Judged = { run: SourceRun; gap: SweepPresentationGap; review: boolean; cardId?: string }; + const judged: Judged[] = []; + const runReports = new Map(); for (const run of runs) { - const report: SweepPresentationRun = { runId: run.id, sourceId: run.sourceId, checkpointHeld: run.pendingCheckpoint !== undefined, judgments: run.judgments.length, needingPresentation: 0, presented: 0 }; - const unnamed = { review: [] as SweepPresentationGap[], routine: [] as SweepPresentationGap[] }; + runReports.set(run.id, { runId: run.id, sourceId: run.sourceId, checkpointHeld: run.pendingCheckpoint !== undefined, judgments: run.judgments.length, needingPresentation: 0, presented: 0 }); run.judgments.forEach((judgment, index) => { if (!isRecord(judgment) || typeof judgment.decision !== "string") return; - const decision = judgment.decision; - const review = REVIEW_DECISIONS.has(decision); - if (!review && decision !== ROUTINE_DECISION) return; - report.needingPresentation += 1; - const gap: SweepPresentationGap = { runId: run.id, sourceId: run.sourceId, judgment: index + 1, decision, reason: "" }; + const review = REVIEW_DECISIONS.has(judgment.decision); + if (!review && judgment.decision !== ROUTINE_DECISION) return; + runReports.get(run.id)!.needingPresentation += 1; const cardId = typeof judgment.cardId === "string" ? judgment.cardId : undefined; - if (cardId === undefined) { - (review ? unnamed.review : unnamed.routine).push(gap); - return; - } - const card = cardsById.get(cardId); - const reason = card ? hiddenReason(card, run) : `no card with id ${cardId} exists`; - if (reason) missing.push({ ...gap, cardId, reason }); - else { - claimed.add(cardId); - report.presented += 1; - } + judged.push({ run, gap: { runId: run.id, sourceId: run.sourceId, judgment: index + 1, decision: judgment.decision, ...(cardId ? { cardId } : {}), reason: "" }, review, cardId }); }); - runReports.push(report); - counted.set(run.id, unnamed); } - // Pass 2: judgments without a cardId fall back to counting presenting cards that no exact match claimed. + const missing: SweepPresentationGap[] = []; + // Every card a judgment names is reserved for exact matching across the whole batch, whether or not it + // presents its run yet, so the report lists all gaps at once and a named card never silently satisfies an + // unnamed judgment elsewhere. Sharing a card is explicit: every judgment it presents names it. + const reserved = new Set(judged.flatMap((item) => item.cardId ? [item.cardId] : [])); + for (const item of judged) { + if (item.cardId === undefined) continue; + const card = cardsById.get(item.cardId); + const reason = card ? hiddenReason(card, item.run) : `no card with id ${item.cardId} exists`; + if (reason) missing.push({ ...item.gap, reason }); + else runReports.get(item.run.id)!.presented += 1; + } + // Judgments without a cardId are matched by count over the run's presenting cards that no judgment reserved. + const routineWithoutCard: Judged[] = []; for (const run of runs) { - const report = runReports.find((candidate) => candidate.runId === run.id)!; - const { review: countedReview, routine: countedRoutine } = counted.get(run.id)!; - const pool = cards.filter((card) => !claimed.has(card.id) && hiddenReason(card, run) === undefined).length; - const reviewCovered = Math.min(pool, countedReview.length); - report.presented += reviewCovered; - for (const gap of countedReview.slice(reviewCovered)) { - missing.push({ ...gap, reason: `no unclaimed card presents this review judgment (${pool} available for ${countedReview.length} review ${countedReview.length === 1 ? "judgment" : "judgments"} without a cardId); add a cardId or upsert another card with sourceRunIds including ${run.id}` }); - } - const routineCovered = Math.max(0, Math.min(pool - countedReview.length, countedRoutine.length)); - report.presented += routineCovered; - for (const gap of countedRoutine.slice(routineCovered)) routineFallback.push({ gap, report }); + const unnamed = judged.filter((item) => item.run === run && item.cardId === undefined); + const reviews = unnamed.filter((item) => item.review); + const routines = unnamed.filter((item) => !item.review); + const pool = cards.filter((card) => !reserved.has(card.id) && hiddenReason(card, run) === undefined).length; + const reviewCovered = Math.min(pool, reviews.length); + const routineCovered = Math.max(0, Math.min(pool - reviews.length, routines.length)); + runReports.get(run.id)!.presented += reviewCovered + routineCovered; + for (const item of reviews.slice(reviewCovered)) { + missing.push({ ...item.gap, reason: `no unclaimed card presents this review judgment (${pool} available for ${reviews.length} review ${reviews.length === 1 ? "judgment" : "judgments"} without a cardId); add a cardId or upsert another card with sourceRunIds including ${run.id}` }); + } + routineWithoutCard.push(...routines.slice(routineCovered)); } + // routine_action judgments without a card may be covered by items of routine action groups proposed since the + // sweep. Group items do not identify judgments, so this coverage is aggregate: when it falls short, every such + // judgment is listed rather than an invented subset. const earliestRecordedAt = runs.map((run) => run.completedAt ?? "").sort()[0] ?? ""; const routineGroupItems = feed.routineActions .filter((group) => group.status !== "stale" && group.status !== "failed" && group.createdAt >= earliestRecordedAt) .reduce((total, group) => total + group.items.length, 0); - routineFallback.forEach(({ gap, report }, index) => { - if (index < routineGroupItems) report.presented += 1; - else missing.push({ ...gap, reason: `no card or routine action group item presents this routine_action judgment (${routineGroupItems} group ${routineGroupItems === 1 ? "item" : "items"} proposed since the sweep for ${routineFallback.length} such ${routineFallback.length === 1 ? "judgment" : "judgments"}); add a cardId, upsert a card, or propose the group` }); - }); - const held = runReports.filter((report) => report.checkpointHeld).length; - const needing = runReports.reduce((total, report) => total + report.needingPresentation, 0); - const ready = missing.length === 0; + if (routineGroupItems < routineWithoutCard.length) { + for (const item of routineWithoutCard) { + missing.push({ ...item.gap, reason: `no card presents this routine_action judgment, and the ${routineWithoutCard.length} such ${routineWithoutCard.length === 1 ? "judgment" : "judgments"} in this sweep ${routineWithoutCard.length === 1 ? "exceeds" : "exceed"} the ${routineGroupItems} routine action group ${routineGroupItems === 1 ? "item" : "items"} proposed since it was recorded; add a cardId, upsert a card, or propose the group` }); + } + } + const runOrder = new Map(runs.map((run, index) => [run.id, index])); + missing.sort((a, b) => (runOrder.get(a.runId)! - runOrder.get(b.runId)!) || (a.judgment - b.judgment)); + const reports = [...runReports.values()]; + const held = reports.filter((report) => report.checkpointHeld).length; + const needing = reports.reduce((total, report) => total + report.needingPresentation, 0); + const presentationReady = missing.length === 0; const status = held ? "pending" : "committed"; const workId = batch.triggerWorkId ?? null; - const workStatus = workId ? (await this.store.readWorkItems(feedId)).find((work) => work.id === workId)?.status ?? null : null; + const workStatus = workId ? workItems.find((work) => work.id === workId)?.status ?? null : null; + const completable = workStatus === "working" || workStatus === "queued"; + const ready = presentationReady && (status === "committed" || completable); const describe = (gap: SweepPresentationGap) => `run ${gap.runId} judgment ${gap.judgment} (${gap.decision}${gap.cardId ? `, cardId ${gap.cardId}` : ""}): ${gap.reason}`; - const summary = status === "committed" - ? `Batch ${batch.id} is committed; ${ready ? `all ${needing} judgments that need presentation are presented.` : `${missing.length} of ${needing} judgments have no presenting card (informational).`}` - : ready - ? `All ${needing} judgments that need presentation are presented; work:complete will commit ${held} held checkpoint${held === 1 ? "" : "s"}.` - : `${missing.length} of ${needing} judgments are not presented yet: ${missing.slice(0, 3).map(describe).join("; ")}${missing.length > 3 ? `; and ${missing.length - 3} more` : ""}.`; - return { status, currentBatchId: batch.id, workId, workStatus, ready, runs: runReports, missing, routineGroupItems, summary }; + let summary: string; + if (status === "committed") { + summary = `Batch ${batch.id} is committed; ${presentationReady ? `all ${needing} judgments that need presentation are presented.` : `${missing.length} of ${needing} judgments have no presenting card (informational).`}`; + } else if (!presentationReady) { + summary = `${missing.length} of ${needing} judgments are not presented yet: ${missing.slice(0, 3).map(describe).join("; ")}${missing.length > 3 ? `; and ${missing.length - 3} more` : ""}.`; + } else if (workStatus === "working") { + summary = `All ${needing} judgments that need presentation are presented; work:complete will commit ${held} held checkpoint${held === 1 ? "" : "s"}.`; + } else if (workStatus === "queued") { + summary = `All ${needing} judgments that need presentation are presented; claim work ${workId} again, then work:complete will commit ${held} held checkpoint${held === 1 ? "" : "s"}.`; + } else { + summary = `All ${needing} judgments that need presentation are presented, but recollection work ${workId ?? "(none)"} is ${workStatus ?? "missing"} and cannot complete; request a new recollection to re-read from the held checkpoint${held === 1 ? "" : "s"}.`; + } + return { status, currentBatchId: batch.id, workId, workStatus, ready, runs: reports, missing, routineGroupItems, summary }; } // Recollection work completes only once every judgment that needs presentation has it; that is when the diff --git a/test/sweep-checkpoints.test.ts b/test/sweep-checkpoints.test.ts index 1a10eb37..2f3f2573 100644 --- a/test/sweep-checkpoints.test.ts +++ b/test/sweep-checkpoints.test.ts @@ -167,7 +167,7 @@ test("every review judgment needs its own card, and routine_action judgments nee .rejects.toThrow("2 of 3 judgments are not presented yet: run " + run + " judgment 2 (review): no unclaimed card presents this review judgment (1 available for 2 review judgments without a cardId)"); await domain.upsertCard(FEED, { id: "the-other", title: "Two", why: "Covers the other review judgment.", blocks: [], sourceRunIds: [run] }); await expect(domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })) - .rejects.toThrow("judgment 3 (routine_action): no card or routine action group item presents this routine_action judgment (0 group items proposed since the sweep for 1 such judgment)"); + .rejects.toThrow("judgment 3 (routine_action): no card presents this routine_action judgment, and the 1 such judgment in this sweep exceeds the 0 routine action group items proposed since it was recorded"); expect(await store.readSourceCheckpoint(FEED, SOURCE)).toEqual(before); await domain.upsertRoutineActionGroup(FEED, { id: "routine-fixture", label: "Archive newsletters", summary: "Three newsletters with the same obvious cleanup.", @@ -272,7 +272,7 @@ test("a routine action group proposed before the sweep does not present its rout const run = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }, { b: 2 }], [{ decision: "routine_action" }, { decision: "routine_action" }], { cursor: "routine" }, work.id); await domain.recordSweepBatch(FEED, [run], work.id); // supersedes the earlier proposed group await expect(domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })) - .rejects.toThrow("2 of 2 judgments are not presented yet: run " + run + " judgment 1 (routine_action): no card or routine action group item presents this routine_action judgment (0 group items proposed since the sweep for 2 such judgments)"); + .rejects.toThrow("2 of 2 judgments are not presented yet: run " + run + " judgment 1 (routine_action): no card presents this routine_action judgment, and the 2 such judgments in this sweep exceed the 0 routine action group items proposed since it was recorded"); expect(await store.readSourceCheckpoint(FEED, SOURCE)).toEqual(before); await domain.upsertRoutineActionGroup(FEED, { id: "fresh-routine", label: "New batch", summary: "Proposed for this sweep.", @@ -280,7 +280,7 @@ test("a routine action group proposed before the sweep does not present its rout items: [{ id: "new-1", title: "Digest", reason: "Routine." }], }); await expect(domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })) - .rejects.toThrow("1 of 2 judgments are not presented yet: run " + run + " judgment 2 (routine_action): no card or routine action group item presents this routine_action judgment (1 group item proposed since the sweep for 2 such judgments)"); + .rejects.toThrow("2 of 2 judgments are not presented yet: run " + run + " judgment 1 (routine_action): no card presents this routine_action judgment, and the 2 such judgments in this sweep exceed the 1 routine action group item proposed since it was recorded"); await domain.upsertRoutineActionGroup(FEED, { id: "fresh-routine", label: "New batch", summary: "Proposed for this sweep.", proposedAction: { label: "Archive", instruction: "Archive the listed items." }, @@ -389,6 +389,57 @@ for (const order of ["named-first", "unnamed-first"] as const) { }); } +test("a named card that does not yet present its run is still reserved, so every gap is listed at once", async () => { + const { root, runtime, domain } = await setup(); + try { + const work = await claimRecollection(domain); + const second = await domain.addSourceFromBrief(FEED, "Read the dispute ledger."); + const named = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "review", cardId: "shared" }], { cursor: "named" }, work.id); + const unnamed = await domain.recordSourceRun(FEED, second.id, [{ b: 2 }], [{ decision: "review" }], { cursor: "unnamed" }, work.id); + await domain.recordSweepBatch(FEED, [named, unnamed], work.id); + // The card exists but lists only the unnamed run: it must not count for that run's unnamed judgment either. + await domain.upsertCard(FEED, { id: "shared", title: "Shared", why: "Wrong provenance.", blocks: [], sourceRunIds: [unnamed] }); + const status = await domain.sweepPresentationStatus(FEED); + expect(status.missing.map((gap) => [gap.runId, gap.judgment, gap.cardId ?? null])).toEqual([[named, 1, "shared"], [unnamed, 1, null]]); + expect(status.missing[0].reason).toBe(`card shared does not list run ${named} in sourceRunIds`); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("readiness reflects the work's state, and the missing list follows batch order", async () => { + const { root, runtime, domain } = await setup(); + try { + const work = await claimRecollection(domain); + const run = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }, { b: 2 }, { c: 3 }], + [{ decision: "review" }, { decision: "review", cardId: "named" }, { decision: "routine_action" }], { cursor: "ordered" }, work.id); + await domain.recordSweepBatch(FEED, [run], work.id); + let status = await domain.sweepPresentationStatus(FEED); + expect(status.missing.map((gap) => gap.judgment)).toEqual([1, 2, 3]); + await domain.upsertCard(FEED, { id: "named", title: "Named", why: "Exact.", blocks: [], sourceRunIds: [run] }); + await domain.upsertCard(FEED, { id: "counted-1", title: "One", why: "Counted.", blocks: [], sourceRunIds: [run] }); + await domain.upsertCard(FEED, { id: "counted-2", title: "Two", why: "Counted.", blocks: [], sourceRunIds: [run] }); + status = await domain.sweepPresentationStatus(FEED); + expect(status).toMatchObject({ ready: true, workStatus: "working" }); + expect(status.summary).toContain("work:complete will commit 1 held checkpoint"); + + await domain.releaseWork(FEED, work.id, work.capabilityToken); + status = await domain.sweepPresentationStatus(FEED); + expect(status).toMatchObject({ ready: true, workStatus: "queued" }); + expect(status.summary).toContain(`claim work ${work.id} again`); + + const reclaimed = await domain.claimWork(FEED, THREAD) as WorkItem; + await domain.failWork(FEED, work.id, reclaimed.capabilityToken, "Connector died."); + status = await domain.sweepPresentationStatus(FEED); + expect(status).toMatchObject({ ready: false, workStatus: "failed", missing: [] }); + expect(status.summary).toContain("cannot complete; request a new recollection"); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + test("two judgments may deliberately share one cardId", async () => { const { root, runtime, store, domain } = await setup(); try { From f83cdcd5d2070a1e57a42a7ce3e7c0bbebdb4365 Mon Sep 17 00:00:00 2001 From: Dan Shipper Date: Sat, 12 Sep 2026 23:30:32 -0400 Subject: [PATCH 04/16] fix(sweeps): say when the owner cannot complete, expose group coverage, test ordering Third-review follow-ups to sweep:status: - When the owning recollection work has failed or been cancelled, the summary says so first and keeps the gap list, instead of directing the agent to present cards that can no longer commit anything. - routineCoveredByGroups reports how many routine_action judgments without a card are covered by group items, so per-run `presented` is clearly card-only. - The reservation test now runs both batch orders and checks the missing list follows batch order with both gaps present. Co-Authored-By: Claude Fable 5.1 --- server/domain.ts | 18 ++++++---- shared/types.ts | 4 +++ test/sweep-checkpoints.test.ts | 65 +++++++++++++++++++++++----------- 3 files changed, 60 insertions(+), 27 deletions(-) diff --git a/server/domain.ts b/server/domain.ts index ea34438f..b85a2c42 100644 --- a/server/domain.ts +++ b/server/domain.ts @@ -3784,7 +3784,7 @@ export class AttentionDomain { return this.store.serialize(async () => { const sweep = await this.store.readSweepState(feedId); if (!sweep.currentBatchId) { - return { status: "idle", currentBatchId: null, workId: null, workStatus: null, ready: true, runs: [], missing: [], routineGroupItems: 0, summary: "No sweep batch has been recorded for this feed." }; + return { status: "idle", currentBatchId: null, workId: null, workStatus: null, ready: true, runs: [], missing: [], routineGroupItems: 0, routineCoveredByGroups: 0, summary: "No sweep batch has been recorded for this feed." }; } return this.presentationReport(feedId, await this.store.readSweepBatch(feedId, sweep.currentBatchId)); }); @@ -3859,6 +3859,7 @@ export class AttentionDomain { const routineGroupItems = feed.routineActions .filter((group) => group.status !== "stale" && group.status !== "failed" && group.createdAt >= earliestRecordedAt) .reduce((total, group) => total + group.items.length, 0); + const routineCoveredByGroups = Math.min(routineGroupItems, routineWithoutCard.length); if (routineGroupItems < routineWithoutCard.length) { for (const item of routineWithoutCard) { missing.push({ ...item.gap, reason: `no card presents this routine_action judgment, and the ${routineWithoutCard.length} such ${routineWithoutCard.length === 1 ? "judgment" : "judgments"} in this sweep ${routineWithoutCard.length === 1 ? "exceeds" : "exceed"} the ${routineGroupItems} routine action group ${routineGroupItems === 1 ? "item" : "items"} proposed since it was recorded; add a cardId, upsert a card, or propose the group` }); @@ -3876,19 +3877,22 @@ export class AttentionDomain { const completable = workStatus === "working" || workStatus === "queued"; const ready = presentationReady && (status === "committed" || completable); const describe = (gap: SweepPresentationGap) => `run ${gap.runId} judgment ${gap.judgment} (${gap.decision}${gap.cardId ? `, cardId ${gap.cardId}` : ""}): ${gap.reason}`; + const gaps = `${missing.length} of ${needing} judgments are not presented yet: ${missing.slice(0, 3).map(describe).join("; ")}${missing.length > 3 ? `; and ${missing.length - 3} more` : ""}.`; + const checkpoints = `${held} held checkpoint${held === 1 ? "" : "s"}`; let summary: string; if (status === "committed") { summary = `Batch ${batch.id} is committed; ${presentationReady ? `all ${needing} judgments that need presentation are presented.` : `${missing.length} of ${needing} judgments have no presenting card (informational).`}`; + } else if (!completable) { + // The owner can never complete, so say that first: presenting more cards will not commit these checkpoints. + summary = `Recollection work ${workId ?? "(none)"} is ${workStatus ?? "missing"} and cannot complete; request a new recollection to re-read from the ${checkpoints}. ${presentationReady ? `All ${needing} judgments that need presentation are presented.` : gaps}`; } else if (!presentationReady) { - summary = `${missing.length} of ${needing} judgments are not presented yet: ${missing.slice(0, 3).map(describe).join("; ")}${missing.length > 3 ? `; and ${missing.length - 3} more` : ""}.`; - } else if (workStatus === "working") { - summary = `All ${needing} judgments that need presentation are presented; work:complete will commit ${held} held checkpoint${held === 1 ? "" : "s"}.`; + summary = gaps; } else if (workStatus === "queued") { - summary = `All ${needing} judgments that need presentation are presented; claim work ${workId} again, then work:complete will commit ${held} held checkpoint${held === 1 ? "" : "s"}.`; + summary = `All ${needing} judgments that need presentation are presented; claim work ${workId} again, then work:complete will commit the ${checkpoints}.`; } else { - summary = `All ${needing} judgments that need presentation are presented, but recollection work ${workId ?? "(none)"} is ${workStatus ?? "missing"} and cannot complete; request a new recollection to re-read from the held checkpoint${held === 1 ? "" : "s"}.`; + summary = `All ${needing} judgments that need presentation are presented; work:complete will commit the ${checkpoints}.`; } - return { status, currentBatchId: batch.id, workId, workStatus, ready, runs: reports, missing, routineGroupItems, summary }; + return { status, currentBatchId: batch.id, workId, workStatus, ready, runs: reports, missing, routineGroupItems, routineCoveredByGroups, summary }; } // Recollection work completes only once every judgment that needs presentation has it; that is when the diff --git a/shared/types.ts b/shared/types.ts index d95d88ef..f97b34a1 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -670,6 +670,7 @@ export interface SweepPresentationRun { checkpointHeld: boolean; judgments: number; needingPresentation: number; + /** Judgments presented by cards (exact matches plus counted cards). Routine judgments covered by group items are reported batch-wide in `routineCoveredByGroups`. */ presented: number; } @@ -682,7 +683,10 @@ export interface SweepPresentationStatus { ready: boolean; runs: SweepPresentationRun[]; missing: SweepPresentationGap[]; + /** Items of routine action groups proposed since the sweep, available to cover routine_action judgments without a card. */ routineGroupItems: number; + /** routine_action judgments without a card that those group items cover (aggregate, not attributable to a run). */ + routineCoveredByGroups: number; summary: string; } diff --git a/test/sweep-checkpoints.test.ts b/test/sweep-checkpoints.test.ts index 2f3f2573..e2aa16a5 100644 --- a/test/sweep-checkpoints.test.ts +++ b/test/sweep-checkpoints.test.ts @@ -286,6 +286,9 @@ test("a routine action group proposed before the sweep does not present its rout proposedAction: { label: "Archive", instruction: "Archive the listed items." }, items: [{ id: "new-1", title: "Digest", reason: "Routine." }, { id: "new-2", title: "Other digest", reason: "Routine." }], }); + const covered = await domain.sweepPresentationStatus(FEED); + expect(covered).toMatchObject({ ready: true, routineGroupItems: 2, routineCoveredByGroups: 2 }); + expect(covered.runs[0]).toMatchObject({ needingPresentation: 2, presented: 0 }); expect((await domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })).status).toBe("completed"); expect(await store.readSourceCheckpoint(FEED, SOURCE)).toEqual({ cursor: "routine" }); } finally { @@ -389,24 +392,28 @@ for (const order of ["named-first", "unnamed-first"] as const) { }); } -test("a named card that does not yet present its run is still reserved, so every gap is listed at once", async () => { - const { root, runtime, domain } = await setup(); - try { - const work = await claimRecollection(domain); - const second = await domain.addSourceFromBrief(FEED, "Read the dispute ledger."); - const named = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "review", cardId: "shared" }], { cursor: "named" }, work.id); - const unnamed = await domain.recordSourceRun(FEED, second.id, [{ b: 2 }], [{ decision: "review" }], { cursor: "unnamed" }, work.id); - await domain.recordSweepBatch(FEED, [named, unnamed], work.id); - // The card exists but lists only the unnamed run: it must not count for that run's unnamed judgment either. - await domain.upsertCard(FEED, { id: "shared", title: "Shared", why: "Wrong provenance.", blocks: [], sourceRunIds: [unnamed] }); - const status = await domain.sweepPresentationStatus(FEED); - expect(status.missing.map((gap) => [gap.runId, gap.judgment, gap.cardId ?? null])).toEqual([[named, 1, "shared"], [unnamed, 1, null]]); - expect(status.missing[0].reason).toBe(`card shared does not list run ${named} in sourceRunIds`); - } finally { - runtime.sqlite.close(); - await rm(root, { recursive: true, force: true }); - } -}); +for (const order of ["named-first", "unnamed-first"] as const) { + test(`a named card that does not yet present its run is still reserved, and gaps follow batch order (${order})`, async () => { + const { root, runtime, domain } = await setup(); + try { + const work = await claimRecollection(domain); + const second = await domain.addSourceFromBrief(FEED, "Read the dispute ledger."); + const named = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "review", cardId: "shared" }], { cursor: "named" }, work.id); + const unnamed = await domain.recordSourceRun(FEED, second.id, [{ b: 2 }], [{ decision: "review" }], { cursor: "unnamed" }, work.id); + const batchOrder = order === "named-first" ? [named, unnamed] : [unnamed, named]; + await domain.recordSweepBatch(FEED, batchOrder, work.id); + // The card exists but lists only the unnamed run: it must not count for that run's unnamed judgment either. + await domain.upsertCard(FEED, { id: "shared", title: "Shared", why: "Wrong provenance.", blocks: [], sourceRunIds: [unnamed] }); + const status = await domain.sweepPresentationStatus(FEED); + expect(status.missing.map((gap) => gap.runId)).toEqual(batchOrder); + expect(status.missing.find((gap) => gap.runId === named)).toMatchObject({ judgment: 1, cardId: "shared", reason: `card shared does not list run ${named} in sourceRunIds` }); + expect(status.missing.find((gap) => gap.runId === unnamed)?.cardId).toBeUndefined(); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } + }); +} test("readiness reflects the work's state, and the missing list follows batch order", async () => { const { root, runtime, domain } = await setup(); @@ -422,7 +429,7 @@ test("readiness reflects the work's state, and the missing list follows batch or await domain.upsertCard(FEED, { id: "counted-2", title: "Two", why: "Counted.", blocks: [], sourceRunIds: [run] }); status = await domain.sweepPresentationStatus(FEED); expect(status).toMatchObject({ ready: true, workStatus: "working" }); - expect(status.summary).toContain("work:complete will commit 1 held checkpoint"); + expect(status.summary).toContain("work:complete will commit the 1 held checkpoint"); await domain.releaseWork(FEED, work.id, work.capabilityToken); status = await domain.sweepPresentationStatus(FEED); @@ -433,7 +440,25 @@ test("readiness reflects the work's state, and the missing list follows batch or await domain.failWork(FEED, work.id, reclaimed.capabilityToken, "Connector died."); status = await domain.sweepPresentationStatus(FEED); expect(status).toMatchObject({ ready: false, workStatus: "failed", missing: [] }); - expect(status.summary).toContain("cannot complete; request a new recollection"); + expect(status.summary.startsWith(`Recollection work ${work.id} is failed and cannot complete`)).toBe(true); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("a failed owner is reported before the gap list", async () => { + const { root, runtime, domain } = await setup(); + try { + const work = await claimRecollection(domain); + const run = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "review", cardId: "never-made" }], { cursor: "failed" }, work.id); + await domain.recordSweepBatch(FEED, [run], work.id); + await domain.failWork(FEED, work.id, work.capabilityToken, "Died before presenting."); + const status = await domain.sweepPresentationStatus(FEED); + expect(status).toMatchObject({ status: "pending", ready: false, workStatus: "failed" }); + expect(status.missing).toHaveLength(1); + expect(status.summary.startsWith(`Recollection work ${work.id} is failed and cannot complete; request a new recollection`)).toBe(true); + expect(status.summary).toContain("1 of 1 judgments are not presented yet"); } finally { runtime.sqlite.close(); await rm(root, { recursive: true, force: true }); From be8abd49cddb5352ab77f5bb53ca96a6efddb147 Mon Sep 17 00:00:00 2001 From: Dan Shipper Date: Sat, 12 Sep 2026 23:37:55 -0400 Subject: [PATCH 05/16] fix(sweeps): grouped cards do not present review judgments; batches cover every held run Fourth-review follow-ups: - A review judgment needs an individually reviewable card: a card that has been pulled into a routine action group no longer presents it (the review tab hides grouped cards). Routine judgments may still be presented by grouped cards. - Routine group coverage is keyed on the proposal cycle (updatedAt), since a stale group re-proposed under a reused id keeps its original createdAt. - sweep:record-batch for claimed work refuses a batch that leaves out a run recorded for that work while it still holds a checkpoint, unless a newer run for the same source in the batch supersedes it. Co-Authored-By: Claude Fable 5.1 --- docs/AGENT_CONTRACT.md | 10 +++-- server/domain.ts | 31 ++++++++++++---- test/sweep-checkpoints.test.ts | 67 ++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 11 deletions(-) diff --git a/docs/AGENT_CONTRACT.md b/docs/AGENT_CONTRACT.md index f4d46341..47d52a9f 100644 --- a/docs/AGENT_CONTRACT.md +++ b/docs/AGENT_CONTRACT.md @@ -75,15 +75,17 @@ For `recollect_sources` work: `{"decision":"review","cardId":"gmail-18c2f0a1","reason":"..."}`; `suppress` judgments need no card. Two judgments may share one `cardId` when one card deliberately presents both. Judgments without a `cardId` still work and are matched by count instead of by id. -- Record the resulting sweep with `tend cli sweep:record-batch --work `. +- Record the resulting sweep with `tend cli sweep:record-batch --work `. The batch must include + every run recorded for the work (or a newer run for the same source that supersedes it). - Upsert one card per judgment with `sourceRunIds` including its run, then run `tend cli sweep:status --feed `; it reports `ready` and lists every judgment that is not presented yet with the exact reason (no such card, card does not list the run, card hidden). - Complete the work only when `sweep:status` reports `ready`. Checkpoints recorded with `--work` are held on the run until `work:complete` succeeds, and completion is refused with the same list - otherwise. A card presents a judgment only if it is visible for review in the current pass or was - acted on since the run; `routine_action` judgments without a card may also be covered by items of - a routine action group proposed since the sweep. If the work never completes, the next sweep + otherwise. A card presents a `review` judgment only if it is individually reviewable (not part of a + routine action group) in the current pass, or was acted on since the run; `routine_action` + judgments without a card may also be covered by items of a routine action group proposed since + the sweep. If the work never completes, the next sweep starts from the previous checkpoint and re-reads those items; a held checkpoint is not written over one that changed after the run was recorded. Re-claiming such work returns `operatorGuidance.pendingPresentation` with the same list. diff --git a/server/domain.ts b/server/domain.ts index b85a2c42..69d5c8ae 100644 --- a/server/domain.ts +++ b/server/domain.ts @@ -3765,6 +3765,18 @@ export class AttentionDomain { } } if (researchQuestions.size > 1) throw new Error("One sweep may originate only one On Your Mind research question."); + if (triggerWork) { + // Every run recorded for this work that still holds a checkpoint must be in the batch, or be superseded + // there by a newer run for the same source; otherwise its judged items would be neither presented nor re-read. + const included = await Promise.all(sourceRunIds.map((runId) => this.store.readRun(feedId, runId))); + const leftOut = (await this.store.listRuns(feedId)).filter((run) => + run.triggerWorkId === triggerWork.id && run.pendingCheckpoint !== undefined && !sourceRunIds.includes(run.id) + && !included.some((candidate) => candidate.sourceId === run.sourceId && (candidate.checkpointSequence ?? 0) > (run.checkpointSequence ?? 0)), + ); + if (leftOut.length) { + throw new Error(`Source ${leftOut.length === 1 ? "run" : "runs"} ${leftOut.map((run) => `${run.id} (${sourceDisplayName(run.sourceId)})`).join(", ")} recorded for this recollection ${leftOut.length === 1 ? "is" : "are"} missing from the batch. Include ${leftOut.length === 1 ? "it" : "them"}, or record a newer run for the same source and include that.`); + } + } const batchId = makeId("batch"); const supersededRoutineGroups = await this.staleProposedRoutineActionGroups(feedId, `Superseded by newer sweep batch ${batchId}.`); await this.store.writeSweepBatch({ id: batchId, feedId, sourceRunIds, ...(normalizedContextUpdateId ? { contextUpdateId: normalizedContextUpdateId } : {}), ...(triggerWorkId ? { triggerWorkId } : {}), createdAt: isoNow() }); @@ -3799,10 +3811,11 @@ export class AttentionDomain { const currentPass = feed.config.currentPass; // Why a card does not present a run (undefined when it does): it must be written for the run and be // visible for review now, or have been acted on since the run was recorded. - const hiddenReason = (card: Card, run: SourceRun): string | undefined => { + const hiddenReason = (card: Card, run: SourceRun, forReview: boolean): string | undefined => { const recordedAt = run.completedAt ?? ""; if (!card.sourceRunIds?.includes(run.id)) return `card ${card.id} does not list run ${run.id} in sourceRunIds`; if (card.updatedAt < recordedAt) return `card ${card.id} was last written before this run was recorded`; + if (forReview && card.routineActionGroupId) return `card ${card.id} belongs to routine action group ${card.routineActionGroupId}, so it is not individually reviewable; a review judgment needs its own card`; if (card.status === "to_review_new" || card.status === "to_review_updated") { return card.readyForPass <= currentPass ? undefined : `card ${card.id} is deferred to pass ${card.readyForPass} (current pass ${currentPass})`; } @@ -3833,7 +3846,7 @@ export class AttentionDomain { for (const item of judged) { if (item.cardId === undefined) continue; const card = cardsById.get(item.cardId); - const reason = card ? hiddenReason(card, item.run) : `no card with id ${item.cardId} exists`; + const reason = card ? hiddenReason(card, item.run, item.review) : `no card with id ${item.cardId} exists`; if (reason) missing.push({ ...item.gap, reason }); else runReports.get(item.run.id)!.presented += 1; } @@ -3843,12 +3856,14 @@ export class AttentionDomain { const unnamed = judged.filter((item) => item.run === run && item.cardId === undefined); const reviews = unnamed.filter((item) => item.review); const routines = unnamed.filter((item) => !item.review); - const pool = cards.filter((card) => !reserved.has(card.id) && hiddenReason(card, run) === undefined).length; - const reviewCovered = Math.min(pool, reviews.length); - const routineCovered = Math.max(0, Math.min(pool - reviews.length, routines.length)); + const unreserved = cards.filter((card) => !reserved.has(card.id)); + const reviewPool = unreserved.filter((card) => hiddenReason(card, run, true) === undefined).length; + const routinePool = unreserved.filter((card) => hiddenReason(card, run, false) === undefined).length; + const reviewCovered = Math.min(reviewPool, reviews.length); + const routineCovered = Math.max(0, Math.min(routinePool - reviewCovered, routines.length)); runReports.get(run.id)!.presented += reviewCovered + routineCovered; for (const item of reviews.slice(reviewCovered)) { - missing.push({ ...item.gap, reason: `no unclaimed card presents this review judgment (${pool} available for ${reviews.length} review ${reviews.length === 1 ? "judgment" : "judgments"} without a cardId); add a cardId or upsert another card with sourceRunIds including ${run.id}` }); + missing.push({ ...item.gap, reason: `no unclaimed card presents this review judgment (${reviewPool} available for ${reviews.length} review ${reviews.length === 1 ? "judgment" : "judgments"} without a cardId); add a cardId or upsert another card with sourceRunIds including ${run.id}` }); } routineWithoutCard.push(...routines.slice(routineCovered)); } @@ -3856,8 +3871,10 @@ export class AttentionDomain { // sweep. Group items do not identify judgments, so this coverage is aggregate: when it falls short, every such // judgment is listed rather than an invented subset. const earliestRecordedAt = runs.map((run) => run.completedAt ?? "").sort()[0] ?? ""; + // A group counts when it was proposed (or re-proposed) since the sweep: recording the batch marks earlier + // proposals stale, so a live group with a fresh updatedAt is one for this sweep even if its id was reused. const routineGroupItems = feed.routineActions - .filter((group) => group.status !== "stale" && group.status !== "failed" && group.createdAt >= earliestRecordedAt) + .filter((group) => group.status !== "stale" && group.status !== "failed" && group.updatedAt >= earliestRecordedAt) .reduce((total, group) => total + group.items.length, 0); const routineCoveredByGroups = Math.min(routineGroupItems, routineWithoutCard.length); if (routineGroupItems < routineWithoutCard.length) { diff --git a/test/sweep-checkpoints.test.ts b/test/sweep-checkpoints.test.ts index e2aa16a5..02da932a 100644 --- a/test/sweep-checkpoints.test.ts +++ b/test/sweep-checkpoints.test.ts @@ -465,6 +465,73 @@ test("a failed owner is reported before the gap list", async () => { } }); +test("a review card pulled into a proposed routine group no longer presents its judgment", async () => { + const { root, runtime, domain } = await setup(); + try { + const work = await claimRecollection(domain); + const run = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "review", cardId: "x" }], { cursor: "grouped" }, work.id); + await domain.recordSweepBatch(FEED, [run], work.id); + await domain.upsertCard(FEED, { id: "x", title: "X", why: "Reviewable.", blocks: [], sourceRunIds: [run] }); + expect((await domain.sweepPresentationStatus(FEED)).ready).toBe(true); + await domain.upsertRoutineActionGroup(FEED, { + id: "group-with-x", label: "Batch", summary: "Contains the review card.", + proposedAction: { label: "Archive", instruction: "Archive these." }, + items: [{ id: "item-x", cardId: "x", title: "X", reason: "Routine." }], + }); + const status = await domain.sweepPresentationStatus(FEED); + expect(status.ready).toBe(false); + expect(status.missing[0].reason).toBe("card x belongs to routine action group group-with-x, so it is not individually reviewable; a review judgment needs its own card"); + await expect(domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })).rejects.toThrow("not individually reviewable"); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("a routine action group re-proposed under a reused id counts for the new sweep", async () => { + const { root, runtime, store, domain } = await setup(); + try { + await domain.upsertRoutineActionGroup(FEED, { + id: "weekly-digests", label: "Digests", summary: "Last sweep's group.", + proposedAction: { label: "Archive", instruction: "Archive the digests." }, + items: [{ id: "old", title: "Old digest", reason: "Routine." }], + }); + const work = await claimRecollection(domain); + const run = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "routine_action" }], { cursor: "reused" }, work.id); + await domain.recordSweepBatch(FEED, [run], work.id); // supersedes the earlier proposal + expect((await store.readFeed(FEED)).routineActions.find((group) => group.id === "weekly-digests")?.status).toBe("stale"); + expect((await domain.sweepPresentationStatus(FEED)).ready).toBe(false); + await domain.upsertRoutineActionGroup(FEED, { + id: "weekly-digests", label: "Digests", summary: "This sweep's group, same id.", + proposedAction: { label: "Archive", instruction: "Archive the digests." }, + items: [{ id: "new", title: "New digest", reason: "Routine." }], + }); + expect(await domain.sweepPresentationStatus(FEED)).toMatchObject({ ready: true, routineGroupItems: 1, routineCoveredByGroups: 1 }); + expect((await domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })).status).toBe("completed"); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("a batch must include every held run recorded for its work unless a newer run for that source supersedes it", async () => { + const { root, runtime, store, domain } = await setup(); + try { + const work = await claimRecollection(domain); + const second = await domain.addSourceFromBrief(FEED, "Read the dispute ledger."); + const a = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "review", cardId: "a-card" }], { cursor: "a" }, work.id); + const b = await domain.recordSourceRun(FEED, second.id, [{ b: 1 }], [{ decision: "suppress" }], { cursor: "b" }, work.id); + await expect(domain.recordSweepBatch(FEED, [b], work.id)).rejects.toThrow(`Source run ${a} (Company Attention) recorded for this recollection is missing from the batch`); + const aAgain = await domain.recordSourceRun(FEED, SOURCE, [{ a: 2 }], [{ decision: "suppress" }], { cursor: "a2" }, work.id); + await domain.recordSweepBatch(FEED, [b, aAgain], work.id); // the newer run for the same source supersedes the first + expect((await domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })).status).toBe("completed"); + expect(await store.readSourceCheckpoint(FEED, SOURCE)).toEqual({ cursor: "a2" }); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + test("two judgments may deliberately share one cardId", async () => { const { root, runtime, store, domain } = await setup(); try { From 0ab84ef1bced42a4860eef0d986cb51d475cf9db Mon Sep 17 00:00:00 2001 From: Dan Shipper Date: Sat, 12 Sep 2026 23:44:09 -0400 Subject: [PATCH 06/16] fix(sweeps): no double counting through card-backed group items; settle superseded runs Fifth-review follow-ups: - A card that already presents a judgment (named or counted) adds no routine group capacity when it also appears as a group item, so one card cannot satisfy two judgments through two different routes. - When recollection work completes, runs recorded for that work but left out of the batch (which record-batch only allows when a newer run for the same source supersedes them) are settled with a skipped reason instead of staying held forever. Co-Authored-By: Claude Fable 5.1 --- server/domain.ts | 29 ++++++++++++++++++++++++----- test/sweep-checkpoints.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/server/domain.ts b/server/domain.ts index 69d5c8ae..e7822e2a 100644 --- a/server/domain.ts +++ b/server/domain.ts @@ -3843,12 +3843,17 @@ export class AttentionDomain { // presents its run yet, so the report lists all gaps at once and a named card never silently satisfies an // unnamed judgment elsewhere. Sharing a card is explicit: every judgment it presents names it. const reserved = new Set(judged.flatMap((item) => item.cardId ? [item.cardId] : [])); + // Cards that presented a judgment (named or counted); a routine group item backed by one of them adds no capacity. + const presenting = new Set(); for (const item of judged) { if (item.cardId === undefined) continue; const card = cardsById.get(item.cardId); const reason = card ? hiddenReason(card, item.run, item.review) : `no card with id ${item.cardId} exists`; if (reason) missing.push({ ...item.gap, reason }); - else runReports.get(item.run.id)!.presented += 1; + else { + runReports.get(item.run.id)!.presented += 1; + presenting.add(item.cardId); + } } // Judgments without a cardId are matched by count over the run's presenting cards that no judgment reserved. const routineWithoutCard: Judged[] = []; @@ -3857,10 +3862,13 @@ export class AttentionDomain { const reviews = unnamed.filter((item) => item.review); const routines = unnamed.filter((item) => !item.review); const unreserved = cards.filter((card) => !reserved.has(card.id)); - const reviewPool = unreserved.filter((card) => hiddenReason(card, run, true) === undefined).length; - const routinePool = unreserved.filter((card) => hiddenReason(card, run, false) === undefined).length; + const reviewCards = unreserved.filter((card) => hiddenReason(card, run, true) === undefined); + const routineOnlyCards = unreserved.filter((card) => hiddenReason(card, run, false) === undefined && hiddenReason(card, run, true) !== undefined); + const reviewPool = reviewCards.length; const reviewCovered = Math.min(reviewPool, reviews.length); - const routineCovered = Math.max(0, Math.min(routinePool - reviewCovered, routines.length)); + const routineCandidates = [...reviewCards.slice(reviewCovered), ...routineOnlyCards]; + const routineCovered = Math.min(routineCandidates.length, routines.length); + for (const card of [...reviewCards.slice(0, reviewCovered), ...routineCandidates.slice(0, routineCovered)]) presenting.add(card.id); runReports.get(run.id)!.presented += reviewCovered + routineCovered; for (const item of reviews.slice(reviewCovered)) { missing.push({ ...item.gap, reason: `no unclaimed card presents this review judgment (${reviewPool} available for ${reviews.length} review ${reviews.length === 1 ? "judgment" : "judgments"} without a cardId); add a cardId or upsert another card with sourceRunIds including ${run.id}` }); @@ -3875,7 +3883,7 @@ export class AttentionDomain { // proposals stale, so a live group with a fresh updatedAt is one for this sweep even if its id was reused. const routineGroupItems = feed.routineActions .filter((group) => group.status !== "stale" && group.status !== "failed" && group.updatedAt >= earliestRecordedAt) - .reduce((total, group) => total + group.items.length, 0); + .reduce((total, group) => total + group.items.filter((item) => !item.cardId || !presenting.has(item.cardId)).length, 0); const routineCoveredByGroups = Math.min(routineGroupItems, routineWithoutCard.length); if (routineGroupItems < routineWithoutCard.length) { for (const item of routineWithoutCard) { @@ -3929,6 +3937,17 @@ export class AttentionDomain { const committedAt = isoNow(); const committed: string[] = []; const skipped: Array<{ runId: string; reason: string }> = []; + // Runs recorded for this work but left out of the batch were superseded by a newer run for their source + // (record-batch refuses any other omission); settle them so nothing stays held after the work completes. + if (batch.triggerWorkId) { + for (const run of await this.store.listRuns(feedId)) { + if (run.triggerWorkId !== batch.triggerWorkId || run.pendingCheckpoint === undefined || batch.sourceRunIds.includes(run.id)) continue; + skipped.push({ runId: run.id, reason: "superseded by a newer run for the same source, which the batch included" }); + const settled: SourceRun = { ...run, checkpointCommittedAt: committedAt }; + delete settled.pendingCheckpoint; + await this.store.writeRun(settled); + } + } for (const run of runs) { if (run.pendingCheckpoint === undefined) continue; if (newestBySource.get(run.sourceId) === run) { diff --git a/test/sweep-checkpoints.test.ts b/test/sweep-checkpoints.test.ts index 02da932a..df956255 100644 --- a/test/sweep-checkpoints.test.ts +++ b/test/sweep-checkpoints.test.ts @@ -526,6 +526,38 @@ test("a batch must include every held run recorded for its work unless a newer r await domain.recordSweepBatch(FEED, [b, aAgain], work.id); // the newer run for the same source supersedes the first expect((await domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })).status).toBe("completed"); expect(await store.readSourceCheckpoint(FEED, SOURCE)).toEqual({ cursor: "a2" }); + const superseded = await store.readRun(FEED, a); + expect(superseded.pendingCheckpoint).toBeUndefined(); + expect(typeof superseded.checkpointCommittedAt).toBe("string"); + const event = (await store.readEvents(FEED)).find((item) => item.type === "sweep.checkpoints_committed"); + expect(event?.detail).toMatchObject({ skipped: expect.arrayContaining([{ runId: a, reason: "superseded by a newer run for the same source, which the batch included" }]) }); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("a card that already presents a judgment adds no routine group capacity", async () => { + const { root, runtime, domain } = await setup(); + try { + const work = await claimRecollection(domain); + const run = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }, { b: 2 }], [{ decision: "routine_action", cardId: "x" }, { decision: "routine_action" }], { cursor: "double" }, work.id); + await domain.recordSweepBatch(FEED, [run], work.id); + await domain.upsertCard(FEED, { id: "x", title: "X", why: "Presents judgment 1.", blocks: [], sourceRunIds: [run] }); + await domain.upsertRoutineActionGroup(FEED, { + id: "one-item", label: "Batch", summary: "Only card x.", + proposedAction: { label: "Archive", instruction: "Archive these." }, + items: [{ id: "item-x", cardId: "x", title: "X", reason: "Routine." }], + }); + const status = await domain.sweepPresentationStatus(FEED); + expect(status).toMatchObject({ ready: false, routineGroupItems: 0, routineCoveredByGroups: 0 }); + expect(status.missing.map((gap) => gap.judgment)).toEqual([2]); + await domain.upsertRoutineActionGroup(FEED, { + id: "one-item", label: "Batch", summary: "Card x plus a card-less item for judgment 2.", + proposedAction: { label: "Archive", instruction: "Archive these." }, + items: [{ id: "item-x", cardId: "x", title: "X", reason: "Routine." }, { id: "item-2", title: "Other", reason: "Routine." }], + }); + expect(await domain.sweepPresentationStatus(FEED)).toMatchObject({ ready: true, routineGroupItems: 1, routineCoveredByGroups: 1 }); } finally { runtime.sqlite.close(); await rm(root, { recursive: true, force: true }); From 4ec187885261f5bbbf8993398ad783f5a63690b8 Mon Sep 17 00:00:00 2001 From: Dan Shipper Date: Sat, 12 Sep 2026 23:50:06 -0400 Subject: [PATCH 07/16] fix(sweeps): a run recorded after the batch blocks completion until batched Sixth-review follow-up: batch completeness was only checked when the batch was recorded, so a run recorded for the same work afterwards was neither presented nor superseded, yet completion settled it as superseded. sweep:status and work:complete now report such runs as unbatchedRuns and refuse until the batch is recorded again including them (or a newer run for the same source). Co-Authored-By: Claude Fable 5.1 --- server/domain.ts | 21 ++++++++++++++++----- shared/types.ts | 2 ++ test/sweep-checkpoints.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/server/domain.ts b/server/domain.ts index e7822e2a..70e9c3bb 100644 --- a/server/domain.ts +++ b/server/domain.ts @@ -3796,7 +3796,7 @@ export class AttentionDomain { return this.store.serialize(async () => { const sweep = await this.store.readSweepState(feedId); if (!sweep.currentBatchId) { - return { status: "idle", currentBatchId: null, workId: null, workStatus: null, ready: true, runs: [], missing: [], routineGroupItems: 0, routineCoveredByGroups: 0, summary: "No sweep batch has been recorded for this feed." }; + return { status: "idle", currentBatchId: null, workId: null, workStatus: null, ready: true, runs: [], missing: [], unbatchedRuns: [], routineGroupItems: 0, routineCoveredByGroups: 0, summary: "No sweep batch has been recorded for this feed." }; } return this.presentationReport(feedId, await this.store.readSweepBatch(feedId, sweep.currentBatchId)); }); @@ -3895,14 +3895,25 @@ export class AttentionDomain { const reports = [...runReports.values()]; const held = reports.filter((report) => report.checkpointHeld).length; const needing = reports.reduce((total, report) => total + report.needingPresentation, 0); - const presentationReady = missing.length === 0; - const status = held ? "pending" : "committed"; const workId = batch.triggerWorkId ?? null; + // A run recorded for this work after the batch is neither presented nor superseded; the batch must be + // recorded again so its judgments are checked and its checkpoint commits with the others. + const unbatchedRuns = workId + ? (await this.store.listRuns(feedId)) + .filter((run) => run.triggerWorkId === workId && run.pendingCheckpoint !== undefined && !batch.sourceRunIds.includes(run.id) + && !runs.some((included) => included.sourceId === run.sourceId && (included.checkpointSequence ?? 0) > (run.checkpointSequence ?? 0))) + .map((run) => ({ runId: run.id, sourceId: run.sourceId })) + : []; + const presentationReady = missing.length === 0 && unbatchedRuns.length === 0; + const status = held || unbatchedRuns.length ? "pending" : "committed"; const workStatus = workId ? workItems.find((work) => work.id === workId)?.status ?? null : null; const completable = workStatus === "working" || workStatus === "queued"; const ready = presentationReady && (status === "committed" || completable); const describe = (gap: SweepPresentationGap) => `run ${gap.runId} judgment ${gap.judgment} (${gap.decision}${gap.cardId ? `, cardId ${gap.cardId}` : ""}): ${gap.reason}`; - const gaps = `${missing.length} of ${needing} judgments are not presented yet: ${missing.slice(0, 3).map(describe).join("; ")}${missing.length > 3 ? `; and ${missing.length - 3} more` : ""}.`; + const unbatched = unbatchedRuns.length + ? `Source ${unbatchedRuns.length === 1 ? "run" : "runs"} ${unbatchedRuns.map((run) => `${run.runId} (${sourceDisplayName(run.sourceId)})`).join(", ")} recorded for this work after batch ${batch.id} ${unbatchedRuns.length === 1 ? "is" : "are"} not in it; record the batch again including ${unbatchedRuns.length === 1 ? "it" : "them"} (or a newer run for the same source). ` + : ""; + const gaps = `${unbatched}${missing.length ? `${missing.length} of ${needing} judgments are not presented yet: ${missing.slice(0, 3).map(describe).join("; ")}${missing.length > 3 ? `; and ${missing.length - 3} more` : ""}.` : ""}`.trim(); const checkpoints = `${held} held checkpoint${held === 1 ? "" : "s"}`; let summary: string; if (status === "committed") { @@ -3917,7 +3928,7 @@ export class AttentionDomain { } else { summary = `All ${needing} judgments that need presentation are presented; work:complete will commit the ${checkpoints}.`; } - return { status, currentBatchId: batch.id, workId, workStatus, ready, runs: reports, missing, routineGroupItems, routineCoveredByGroups, summary }; + return { status, currentBatchId: batch.id, workId, workStatus, ready, runs: reports, missing, unbatchedRuns, routineGroupItems, routineCoveredByGroups, summary }; } // Recollection work completes only once every judgment that needs presentation has it; that is when the diff --git a/shared/types.ts b/shared/types.ts index f97b34a1..b037c45b 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -683,6 +683,8 @@ export interface SweepPresentationStatus { ready: boolean; runs: SweepPresentationRun[]; missing: SweepPresentationGap[]; + /** Held runs recorded for the batch's work that the batch neither includes nor supersedes; the batch must be recorded again. */ + unbatchedRuns: Array<{ runId: string; sourceId: string }>; /** Items of routine action groups proposed since the sweep, available to cover routine_action judgments without a card. */ routineGroupItems: number; /** routine_action judgments without a card that those group items cover (aggregate, not attributable to a run). */ diff --git a/test/sweep-checkpoints.test.ts b/test/sweep-checkpoints.test.ts index df956255..484b7ba0 100644 --- a/test/sweep-checkpoints.test.ts +++ b/test/sweep-checkpoints.test.ts @@ -564,6 +564,31 @@ test("a card that already presents a judgment adds no routine group capacity", a } }); +test("a run recorded after the batch must be batched before the work can complete", async () => { + const { root, runtime, store, domain } = await setup(); + try { + const work = await claimRecollection(domain); + const second = await domain.addSourceFromBrief(FEED, "Read the dispute ledger."); + const a = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "suppress" }], { cursor: "a" }, work.id); + await domain.recordSweepBatch(FEED, [a], work.id); + const b = await domain.recordSourceRun(FEED, second.id, [{ b: 1 }], [{ decision: "review", cardId: "b-card" }], { cursor: "b" }, work.id); + const status = await domain.sweepPresentationStatus(FEED); + expect(status).toMatchObject({ ready: false, status: "pending", unbatchedRuns: [{ runId: b, sourceId: second.id }] }); + expect(status.summary).toMatch(new RegExp(`^Source run ${b} \\(.+\\) recorded for this work after batch .+ is not in it; record the batch again`)); + await expect(domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })).rejects.toThrow("record the batch again"); + expect((await store.readRun(FEED, b)).pendingCheckpoint).toEqual({ cursor: "b" }); + await domain.recordSweepBatch(FEED, [a, b], work.id); + await domain.upsertCard(FEED, { id: "b-card", title: "B", why: "Presented.", blocks: [], sourceRunIds: [b] }); + expect((await domain.sweepPresentationStatus(FEED)).ready).toBe(true); + expect((await domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })).status).toBe("completed"); + expect(await store.readSourceCheckpoint(FEED, SOURCE)).toEqual({ cursor: "a" }); + expect(await store.readSourceCheckpoint(FEED, second.id)).toEqual({ cursor: "b" }); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + test("two judgments may deliberately share one cardId", async () => { const { root, runtime, store, domain } = await setup(); try { From fb794f7fd867e45a4d76e677bd683f4f02875869 Mon Sep 17 00:00:00 2001 From: Dan Shipper Date: Sat, 12 Sep 2026 23:56:39 -0400 Subject: [PATCH 08/16] fix(sweeps): a counted card presents one judgment per sweep; re-claim guidance covers unbatched runs Seventh-review follow-ups: - Cards matched by count are used once across the batch, in batch order, so one card listing two runs cannot satisfy an unnamed judgment in each; sharing a card stays explicit through cardId. - Re-claim guidance now tells the agent to record the batch again when runs were recorded after it, instead of "do not record another". Co-Authored-By: Claude Fable 5.1 --- docs/AGENT_CONTRACT.md | 3 ++- server/domain.ts | 5 +++-- server/operator.ts | 8 +++++--- test/sweep-checkpoints.test.ts | 27 ++++++++++++++++++++++++++- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/docs/AGENT_CONTRACT.md b/docs/AGENT_CONTRACT.md index 47d52a9f..20bea19f 100644 --- a/docs/AGENT_CONTRACT.md +++ b/docs/AGENT_CONTRACT.md @@ -74,7 +74,8 @@ For `recollect_sources` work: exact id in `card:upsert`. A judgment looks like `{"decision":"review","cardId":"gmail-18c2f0a1","reason":"..."}`; `suppress` judgments need no card. Two judgments may share one `cardId` when one card deliberately presents both. Judgments - without a `cardId` still work and are matched by count instead of by id. + without a `cardId` still work and are matched by count instead of by id; a card counts once per + sweep unless the judgments it presents name it. - Record the resulting sweep with `tend cli sweep:record-batch --work `. The batch must include every run recorded for the work (or a newer run for the same source that supersedes it). - Upsert one card per judgment with `sourceRunIds` including its run, then run diff --git a/server/domain.ts b/server/domain.ts index 70e9c3bb..b1c86198 100644 --- a/server/domain.ts +++ b/server/domain.ts @@ -3855,13 +3855,14 @@ export class AttentionDomain { presenting.add(item.cardId); } } - // Judgments without a cardId are matched by count over the run's presenting cards that no judgment reserved. + // Judgments without a cardId are matched by count, in batch order, over the run's presenting cards that no + // judgment reserved and no earlier match already used: a card counts once per batch unless judgments name it. const routineWithoutCard: Judged[] = []; for (const run of runs) { const unnamed = judged.filter((item) => item.run === run && item.cardId === undefined); const reviews = unnamed.filter((item) => item.review); const routines = unnamed.filter((item) => !item.review); - const unreserved = cards.filter((card) => !reserved.has(card.id)); + const unreserved = cards.filter((card) => !reserved.has(card.id) && !presenting.has(card.id)); const reviewCards = unreserved.filter((card) => hiddenReason(card, run, true) === undefined); const routineOnlyCards = unreserved.filter((card) => hiddenReason(card, run, false) === undefined && hiddenReason(card, run, true) !== undefined); const reviewPool = reviewCards.length; diff --git a/server/operator.ts b/server/operator.ts index e61e5386..cc41c3d4 100644 --- a/server/operator.ts +++ b/server/operator.ts @@ -262,9 +262,11 @@ export function formatWorkClaimOutput(feedId: string, work: WorkClaimResult, con operatorGuidance.requiredWriteBack = "Record one or more source runs with `source:record-run --work `, giving every review or routine_action judgment a stable `cardId` (for example `gmail-`) that you will reuse as the card id in `card:upsert`; then `sweep:record-batch --work `; then upsert one card per judgment with sourceRunIds including the run. Run `sweep:status --feed ` to see exactly what is still missing; `work:complete` is refused until it reports ready, and the source checkpoints advance only then. Judgments without a cardId are matched by count instead."; const pending = context.sweepPresentation; if (pending?.status === "pending" && pending.workId === work.id) { - operatorGuidance.completionPrerequisite = pending.ready - ? `Batch ${pending.currentBatchId} recorded by this work is fully presented; finish with work:complete.` - : `Batch ${pending.currentBatchId} was already recorded by this work; do not record another. Present the judgments listed in pendingPresentation.missing, then work:complete.`; + operatorGuidance.completionPrerequisite = pending.unbatchedRuns.length + ? `Batch ${pending.currentBatchId} was recorded by this work, but ${pending.unbatchedRuns.map((run) => run.runId).join(", ")} recorded afterwards ${pending.unbatchedRuns.length === 1 ? "is" : "are"} not in it. Record the batch again with sweep:record-batch --work ${work.id} including ${pending.unbatchedRuns.length === 1 ? "it" : "them"} (or a newer run for the same source), present the judgments listed in pendingPresentation.missing, then work:complete.` + : pending.ready + ? `Batch ${pending.currentBatchId} recorded by this work is fully presented; finish with work:complete.` + : `Batch ${pending.currentBatchId} was already recorded by this work; do not record another. Present the judgments listed in pendingPresentation.missing, then work:complete.`; operatorGuidance.pendingPresentation = pending; } operatorGuidance.sourceRunRule = feedId === "inbox" diff --git a/test/sweep-checkpoints.test.ts b/test/sweep-checkpoints.test.ts index 484b7ba0..e5b35875 100644 --- a/test/sweep-checkpoints.test.ts +++ b/test/sweep-checkpoints.test.ts @@ -589,6 +589,25 @@ test("a run recorded after the batch must be batched before the work can complet } }); +test("a card counted for one run's unnamed judgment is not counted again for another run", async () => { + const { root, runtime, domain } = await setup(); + try { + const work = await claimRecollection(domain); + const second = await domain.addSourceFromBrief(FEED, "Read the dispute ledger."); + const a = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "review" }], { cursor: "a" }, work.id); + const b = await domain.recordSourceRun(FEED, second.id, [{ b: 1 }], [{ decision: "review" }], { cursor: "b" }, work.id); + await domain.recordSweepBatch(FEED, [a, b], work.id); + await domain.upsertCard(FEED, { id: "both", title: "Both", why: "Lists both runs without being named by either judgment.", blocks: [], sourceRunIds: [a, b] }); + const status = await domain.sweepPresentationStatus(FEED); + expect(status.ready).toBe(false); + expect(status.missing.map((gap) => gap.runId)).toEqual([b]); + expect(status.runs.map((run) => run.presented)).toEqual([1, 0]); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + test("two judgments may deliberately share one cardId", async () => { const { root, runtime, store, domain } = await setup(); try { @@ -635,7 +654,13 @@ test("re-claiming an interrupted recollection returns what is still missing", as expect(output.operatorGuidance?.completionPrerequisite).toContain("do not record another"); expect(output.operatorGuidance?.pendingPresentation?.missing.map((gap) => gap.cardId)).toEqual(["thread-x"]); expect(output.operatorGuidance?.requiredWriteBack).toContain("cardId"); - await domain.upsertCard(FEED, { id: "thread-x", title: "X", why: "Presented after re-claim.", blocks: [], sourceRunIds: [run] }); + // A run recorded after the batch changes the instruction: re-record the batch first. + const late = await domain.recordSourceRun(FEED, SOURCE, [{ later: true }], [{ decision: "suppress" }], { cursor: "late" }, work.id); + const lateOutput = formatWorkClaimOutput(FEED, reclaimed, { sweepPresentation: await domain.sweepPresentationStatus(FEED) }); + if (!("operatorGuidance" in lateOutput)) throw new Error("Expected claim guidance."); + expect(lateOutput.operatorGuidance?.completionPrerequisite).toContain(`${late} recorded afterwards is not in it. Record the batch again with sweep:record-batch --work ${work.id}`); + await domain.recordSweepBatch(FEED, [late], work.id); // supersedes run and its judgment moves out of the batch + expect((await domain.sweepPresentationStatus(FEED)).ready).toBe(true); expect((await domain.completeWork(FEED, work.id, reclaimed.capabilityToken, { response: "Finished." })).status).toBe("completed"); } finally { runtime.sqlite.close(); From 8ed68a60106177c4580e32592e6f391204607e43 Mon Sep 17 00:00:00 2001 From: Dan Shipper Date: Sun, 13 Sep 2026 00:06:10 -0400 Subject: [PATCH 09/16] fix(sweeps): bind acted-on checks to the card version; proposals only; rejudge hiding; guidance Eighth-review follow-ups: - A dismissal, completion, or approval presents the card version that existed at the time. A card refreshed after the user acted on it (beyond a one-second stamp tolerance) must be resurfaced for review again. - Routine group coverage counts proposals for this sweep only: groups still `proposed`, or created since the sweep. updatedAt is no longer used, since approving or completing an older group refreshes it without presenting anything new. - A card hidden by sweep feedback (sweep.hidden) does not present its judgment. - Re-claim guidance says to rerun sweep:status after re-recording a batch (the list changes), and explains recovery when a named card cannot be updated, such as an immutable reading card. Co-Authored-By: Claude Fable 5.1 --- server/domain.ts | 20 +++++++--- server/operator.ts | 4 +- test/sweep-checkpoints.test.ts | 70 +++++++++++++++++++++++++++++++++- 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/server/domain.ts b/server/domain.ts index b1c86198..220e6d29 100644 --- a/server/domain.ts +++ b/server/domain.ts @@ -3811,18 +3811,27 @@ export class AttentionDomain { const currentPass = feed.config.currentPass; // Why a card does not present a run (undefined when it does): it must be written for the run and be // visible for review now, or have been acted on since the run was recorded. + // An action on a card (dismissal, completion, approval) presents the card version that existed at the time. + // Timestamps in those paths are stamped a hair apart, so "no earlier than the card's latest write" allows a + // one-second tolerance; a card refreshed later than that must be resurfaced for review again. + const ACTED_ON_TOLERANCE_MS = 1_000; + const actedOnCurrentVersion = (actedAt: string, card: Card) => Date.parse(actedAt) + ACTED_ON_TOLERANCE_MS >= Date.parse(card.updatedAt); const hiddenReason = (card: Card, run: SourceRun, forReview: boolean): string | undefined => { const recordedAt = run.completedAt ?? ""; if (!card.sourceRunIds?.includes(run.id)) return `card ${card.id} does not list run ${run.id} in sourceRunIds`; if (card.updatedAt < recordedAt) return `card ${card.id} was last written before this run was recorded`; if (forReview && card.routineActionGroupId) return `card ${card.id} belongs to routine action group ${card.routineActionGroupId}, so it is not individually reviewable; a review judgment needs its own card`; + if (card.sweep?.hidden) return `card ${card.id} is hidden by sweep feedback ${card.sweep.feedbackId}; upsert it again with status to_review_updated if it must be presented`; if (card.status === "to_review_new" || card.status === "to_review_updated") { return card.readyForPass <= currentPass ? undefined : `card ${card.id} is deferred to pass ${card.readyForPass} (current pass ${currentPass})`; } if (card.status === "done") { - return (card.completedAt ?? "") >= recordedAt ? undefined : `card ${card.id} was dismissed or completed before this run and has not been resurfaced (upsert it with status to_review_updated)`; + if ((card.completedAt ?? "") < recordedAt) return `card ${card.id} was dismissed or completed before this run and has not been resurfaced (upsert it with status to_review_updated)`; + return actedOnCurrentVersion(card.completedAt ?? "", card) ? undefined : `card ${card.id} was updated after the user dismissed or completed it, so its current content was never reviewed; upsert it with status to_review_updated`; } - return workItems.some((work) => work.cardId === card.id && work.createdAt >= recordedAt) ? undefined : `card ${card.id} is ${card.status} from before this run with no new approval since`; + const approvals = workItems.filter((work) => work.cardId === card.id && work.createdAt >= recordedAt); + if (!approvals.length) return `card ${card.id} is ${card.status} from before this run with no new approval since`; + return approvals.some((work) => actedOnCurrentVersion(work.createdAt, card)) ? undefined : `card ${card.id} was updated after it was approved, so its current content was never reviewed; upsert it with status to_review_updated`; }; type Judged = { run: SourceRun; gap: SweepPresentationGap; review: boolean; cardId?: string }; const judged: Judged[] = []; @@ -3880,10 +3889,11 @@ export class AttentionDomain { // sweep. Group items do not identify judgments, so this coverage is aggregate: when it falls short, every such // judgment is listed rather than an invented subset. const earliestRecordedAt = runs.map((run) => run.completedAt ?? "").sort()[0] ?? ""; - // A group counts when it was proposed (or re-proposed) since the sweep: recording the batch marks earlier - // proposals stale, so a live group with a fresh updatedAt is one for this sweep even if its id was reused. + // A group counts when it is a proposal for this sweep: still `proposed` (recording the batch marks earlier + // proposals stale, so a live proposal is this sweep's even under a reused id) or created since the sweep. + // updatedAt is not used: approval or completion of an older group refreshes it without presenting anything new. const routineGroupItems = feed.routineActions - .filter((group) => group.status !== "stale" && group.status !== "failed" && group.updatedAt >= earliestRecordedAt) + .filter((group) => group.status !== "stale" && group.status !== "failed" && (group.status === "proposed" || group.createdAt >= earliestRecordedAt)) .reduce((total, group) => total + group.items.filter((item) => !item.cardId || !presenting.has(item.cardId)).length, 0); const routineCoveredByGroups = Math.min(routineGroupItems, routineWithoutCard.length); if (routineGroupItems < routineWithoutCard.length) { diff --git a/server/operator.ts b/server/operator.ts index cc41c3d4..e7a34dd9 100644 --- a/server/operator.ts +++ b/server/operator.ts @@ -263,10 +263,10 @@ export function formatWorkClaimOutput(feedId: string, work: WorkClaimResult, con const pending = context.sweepPresentation; if (pending?.status === "pending" && pending.workId === work.id) { operatorGuidance.completionPrerequisite = pending.unbatchedRuns.length - ? `Batch ${pending.currentBatchId} was recorded by this work, but ${pending.unbatchedRuns.map((run) => run.runId).join(", ")} recorded afterwards ${pending.unbatchedRuns.length === 1 ? "is" : "are"} not in it. Record the batch again with sweep:record-batch --work ${work.id} including ${pending.unbatchedRuns.length === 1 ? "it" : "them"} (or a newer run for the same source), present the judgments listed in pendingPresentation.missing, then work:complete.` + ? `Batch ${pending.currentBatchId} was recorded by this work, but ${pending.unbatchedRuns.map((run) => run.runId).join(", ")} recorded afterwards ${pending.unbatchedRuns.length === 1 ? "is" : "are"} not in it. Record the batch again with sweep:record-batch --work ${work.id} including ${pending.unbatchedRuns.length === 1 ? "it" : "them"} (or a newer run for the same source), then rerun sweep:status --feed ${feedId} for the fresh list of judgments to present, then work:complete.` : pending.ready ? `Batch ${pending.currentBatchId} recorded by this work is fully presented; finish with work:complete.` - : `Batch ${pending.currentBatchId} was already recorded by this work; do not record another. Present the judgments listed in pendingPresentation.missing, then work:complete.`; + : `Batch ${pending.currentBatchId} was already recorded by this work; present the judgments listed in pendingPresentation.missing, rerun sweep:status --feed ${feedId}, then work:complete. If a listed card cannot be updated (for example an immutable reading card), record a corrected source run that names a different cardId and record the batch again including it.`; operatorGuidance.pendingPresentation = pending; } operatorGuidance.sourceRunRule = feedId === "inbox" diff --git a/test/sweep-checkpoints.test.ts b/test/sweep-checkpoints.test.ts index e5b35875..83797c95 100644 --- a/test/sweep-checkpoints.test.ts +++ b/test/sweep-checkpoints.test.ts @@ -608,6 +608,73 @@ test("a card counted for one run's unnamed judgment is not counted again for ano } }); +test("a card dismissed between the run and its refresh does not present the refreshed content", async () => { + const { root, runtime, store, domain } = await setup(); + try { + await domain.upsertCard(FEED, { id: "thread-r", title: "Old content", why: "Seen before.", blocks: [] }); + const work = await claimRecollection(domain); + const run = await domain.recordSourceRun(FEED, SOURCE, [{ thread: "thread-r" }], [{ decision: "review", cardId: "thread-r" }], { cursor: "race" }, work.id); + await domain.recordSweepBatch(FEED, [run], work.id); + // The user dismisses the old card after the run was recorded, then the agent refreshes it without resurfacing. + await domain.dismissCard(FEED, "thread-r"); + await Bun.sleep(1_100); + await domain.upsertCard(FEED, { id: "thread-r", title: "New content", why: "A new message arrived.", blocks: [], sourceRunIds: [run] }); + expect((await store.readCard(FEED, "thread-r")).status).toBe("done"); + const status = await domain.sweepPresentationStatus(FEED); + expect(status.ready).toBe(false); + expect(status.missing[0].reason).toBe("card thread-r was updated after the user dismissed or completed it, so its current content was never reviewed; upsert it with status to_review_updated"); + await domain.upsertCard(FEED, { id: "thread-r", title: "New content", why: "A new message arrived.", blocks: [], sourceRunIds: [run], status: "to_review_updated" }); + expect((await domain.sweepPresentationStatus(FEED)).ready).toBe(true); + // A dismissal of the presented content still counts. + await domain.dismissCard(FEED, "thread-r"); + expect((await domain.sweepPresentationStatus(FEED)).ready).toBe(true); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("a card hidden by sweep feedback does not present its judgment", async () => { + const { root, runtime, store, domain } = await setup(); + try { + const work = await claimRecollection(domain); + const run = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "review", cardId: "hidden-card" }], { cursor: "hidden" }, work.id); + await domain.recordSweepBatch(FEED, [run], work.id); + await domain.upsertCard(FEED, { id: "hidden-card", title: "Hidden", why: "Rejudged away.", blocks: [], sourceRunIds: [run] }); + expect((await domain.sweepPresentationStatus(FEED)).ready).toBe(true); + const card = await store.readCard(FEED, "hidden-card"); + await store.writeCard({ ...card, sweep: { rank: 0, hidden: true, feedbackId: "feedback-1" } }); + const status = await domain.sweepPresentationStatus(FEED); + expect(status.ready).toBe(false); + expect(status.missing[0].reason).toContain("is hidden by sweep feedback feedback-1"); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("an older routine group approved after the run is not new coverage", async () => { + const { root, runtime, store, domain } = await setup(); + try { + await domain.upsertRoutineActionGroup(FEED, { + id: "older", label: "Older", summary: "Proposed before this sweep.", + proposedAction: { label: "Archive", instruction: "Archive these." }, + items: [{ id: "i1", title: "One", reason: "Routine." }], + }); + const work = await claimRecollection(domain); + const run = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "routine_action" }], { cursor: "approved-later" }, work.id); + // Approved (queued) after the run but before the batch, so recording the batch does not stale it. + await domain.approveRoutineActionGroup(FEED, "older"); + expect((await store.readFeed(FEED)).routineActions.find((group) => group.id === "older")?.status).toBe("queued"); + await domain.recordSweepBatch(FEED, [run], work.id); + const status = await domain.sweepPresentationStatus(FEED); + expect(status).toMatchObject({ ready: false, routineGroupItems: 0 }); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + test("two judgments may deliberately share one cardId", async () => { const { root, runtime, store, domain } = await setup(); try { @@ -651,7 +718,7 @@ test("re-claiming an interrupted recollection returns what is still missing", as expect(reclaimed.id).toBe(work.id); const output = formatWorkClaimOutput(FEED, reclaimed, { sweepPresentation: await domain.sweepPresentationStatus(FEED) }); if (!("operatorGuidance" in output)) throw new Error("Expected claim guidance."); - expect(output.operatorGuidance?.completionPrerequisite).toContain("do not record another"); + expect(output.operatorGuidance?.completionPrerequisite).toContain("present the judgments listed in pendingPresentation.missing, rerun sweep:status"); expect(output.operatorGuidance?.pendingPresentation?.missing.map((gap) => gap.cardId)).toEqual(["thread-x"]); expect(output.operatorGuidance?.requiredWriteBack).toContain("cardId"); // A run recorded after the batch changes the instruction: re-record the batch first. @@ -659,6 +726,7 @@ test("re-claiming an interrupted recollection returns what is still missing", as const lateOutput = formatWorkClaimOutput(FEED, reclaimed, { sweepPresentation: await domain.sweepPresentationStatus(FEED) }); if (!("operatorGuidance" in lateOutput)) throw new Error("Expected claim guidance."); expect(lateOutput.operatorGuidance?.completionPrerequisite).toContain(`${late} recorded afterwards is not in it. Record the batch again with sweep:record-batch --work ${work.id}`); + expect(lateOutput.operatorGuidance?.completionPrerequisite).toContain("then rerun sweep:status"); await domain.recordSweepBatch(FEED, [late], work.id); // supersedes run and its judgment moves out of the batch expect((await domain.sweepPresentationStatus(FEED)).ready).toBe(true); expect((await domain.completeWork(FEED, work.id, reclaimed.capabilityToken, { response: "Finished." })).status).toBe("completed"); From c803e7432a2760071541c0d08d6020a52828cfc1 Mon Sep 17 00:00:00 2001 From: Dan Shipper Date: Sun, 13 Sep 2026 00:13:32 -0400 Subject: [PATCH 10/16] fix(routine): a revived group proposal dates from its re-proposal Ninth-review follow-up: re-proposing a stale or failed routine action group under its old id kept the original createdAt, so once the user approved it before recollection completed it stopped counting as this sweep's proposal and completion was refused. Revival is a new proposal cycle and now dates from the re-proposal. Co-Authored-By: Claude Fable 5.1 --- server/domain.ts | 3 ++- test/sweep-checkpoints.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/server/domain.ts b/server/domain.ts index 220e6d29..26399ab7 100644 --- a/server/domain.ts +++ b/server/domain.ts @@ -2150,7 +2150,8 @@ export class AttentionDomain { proposedAction: input.proposedAction, items: input.items.map((item) => ({ ...item, id: item.id.trim(), title: item.title.trim(), reason: item.reason.trim() })), status: "proposed", - createdAt: existing?.createdAt ?? now, + // Reviving a stale or failed group under its old id is a new proposal cycle, so it dates from now. + createdAt: existing && existing.status !== "stale" && existing.status !== "failed" ? existing.createdAt : now, updatedAt: now, }; for (const item of group.items) { diff --git a/test/sweep-checkpoints.test.ts b/test/sweep-checkpoints.test.ts index 83797c95..7e866e64 100644 --- a/test/sweep-checkpoints.test.ts +++ b/test/sweep-checkpoints.test.ts @@ -675,6 +675,33 @@ test("an older routine group approved after the run is not new coverage", async } }); +test("a re-proposed group that the user approves before completion keeps counting", async () => { + const { root, runtime, store, domain } = await setup(); + try { + await domain.upsertRoutineActionGroup(FEED, { + id: "weekly", label: "Weekly", summary: "Last sweep's proposal.", + proposedAction: { label: "Archive", instruction: "Archive the digests." }, + items: [{ id: "old", title: "Old digest", reason: "Routine." }], + }); + const work = await claimRecollection(domain); + const run = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "routine_action" }], { cursor: "revived" }, work.id); + await domain.recordSweepBatch(FEED, [run], work.id); // stales the earlier proposal + await domain.upsertRoutineActionGroup(FEED, { + id: "weekly", label: "Weekly", summary: "This sweep's proposal under the same id.", + proposedAction: { label: "Archive", instruction: "Archive the digests." }, + items: [{ id: "new", title: "New digest", reason: "Routine." }], + }); + expect((await domain.sweepPresentationStatus(FEED)).ready).toBe(true); + await domain.approveRoutineActionGroup(FEED, "weekly"); + expect((await store.readFeed(FEED)).routineActions.find((group) => group.id === "weekly")?.status).toBe("queued"); + expect(await domain.sweepPresentationStatus(FEED)).toMatchObject({ ready: true, routineGroupItems: 1 }); + expect((await domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })).status).toBe("completed"); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + test("two judgments may deliberately share one cardId", async () => { const { root, runtime, store, domain } = await setup(); try { From 24147c78fb0a6b942e93002cf5139d71e250aaeb Mon Sep 17 00:00:00 2001 From: Dan Shipper Date: Sun, 13 Sep 2026 00:17:37 -0400 Subject: [PATCH 11/16] fix(sweeps): routine group coverage requires a group created since the sweep Tenth-review follow-up: an older approved group whose work is cancelled returns to `proposed` and would count again. Since re-proposing a stale group now dates it from the re-proposal, coverage simply requires the group to have been created since the sweep, whatever its status. Co-Authored-By: Claude Fable 5.1 --- server/domain.ts | 9 +++++---- test/sweep-checkpoints.test.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/server/domain.ts b/server/domain.ts index 26399ab7..52c0b180 100644 --- a/server/domain.ts +++ b/server/domain.ts @@ -3890,11 +3890,12 @@ export class AttentionDomain { // sweep. Group items do not identify judgments, so this coverage is aggregate: when it falls short, every such // judgment is listed rather than an invented subset. const earliestRecordedAt = runs.map((run) => run.completedAt ?? "").sort()[0] ?? ""; - // A group counts when it is a proposal for this sweep: still `proposed` (recording the batch marks earlier - // proposals stale, so a live proposal is this sweep's even under a reused id) or created since the sweep. - // updatedAt is not used: approval or completion of an older group refreshes it without presenting anything new. + // A group counts when it was proposed for this sweep, which its createdAt records: recording the batch marks + // earlier proposals stale, and re-proposing a stale group under a reused id dates it from the re-proposal. + // Status alone is not enough (an older approved group returns to `proposed` when its work is cancelled) and + // updatedAt is not used, since approval or completion of an older group refreshes it without presenting anything new. const routineGroupItems = feed.routineActions - .filter((group) => group.status !== "stale" && group.status !== "failed" && (group.status === "proposed" || group.createdAt >= earliestRecordedAt)) + .filter((group) => group.status !== "stale" && group.status !== "failed" && group.createdAt >= earliestRecordedAt) .reduce((total, group) => total + group.items.filter((item) => !item.cardId || !presenting.has(item.cardId)).length, 0); const routineCoveredByGroups = Math.min(routineGroupItems, routineWithoutCard.length); if (routineGroupItems < routineWithoutCard.length) { diff --git a/test/sweep-checkpoints.test.ts b/test/sweep-checkpoints.test.ts index 7e866e64..8774e51f 100644 --- a/test/sweep-checkpoints.test.ts +++ b/test/sweep-checkpoints.test.ts @@ -664,11 +664,14 @@ test("an older routine group approved after the run is not new coverage", async const work = await claimRecollection(domain); const run = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "routine_action" }], { cursor: "approved-later" }, work.id); // Approved (queued) after the run but before the batch, so recording the batch does not stale it. - await domain.approveRoutineActionGroup(FEED, "older"); + const approval = await domain.approveRoutineActionGroup(FEED, "older"); expect((await store.readFeed(FEED)).routineActions.find((group) => group.id === "older")?.status).toBe("queued"); await domain.recordSweepBatch(FEED, [run], work.id); - const status = await domain.sweepPresentationStatus(FEED); - expect(status).toMatchObject({ ready: false, routineGroupItems: 0 }); + expect(await domain.sweepPresentationStatus(FEED)).toMatchObject({ ready: false, routineGroupItems: 0 }); + // Cancelling its work returns the older group to `proposed`; it is still not this sweep's proposal. + await domain.cancelQueuedWork(FEED, approval.id); + expect((await store.readFeed(FEED)).routineActions.find((group) => group.id === "older")?.status).toBe("proposed"); + expect(await domain.sweepPresentationStatus(FEED)).toMatchObject({ ready: false, routineGroupItems: 0 }); } finally { runtime.sqlite.close(); await rm(root, { recursive: true, force: true }); From 1268d41da892e5925c18b2e0dd808e7117338741 Mon Sep 17 00:00:00 2001 From: Dan Shipper Date: Sun, 13 Sep 2026 00:23:03 -0400 Subject: [PATCH 12/16] fix(routine): every explicit group upsert starts a new proposal cycle Eleventh-review follow-up: an older group returned to `proposed` by a cancelled approval kept its old createdAt when the agent explicitly re-proposed it, so it still did not count for the sweep despite the guidance to propose the group. Only proposed, stale, or failed groups can be upserted, and each such upsert is a proposal, so createdAt is now the time of the current proposal. Co-Authored-By: Claude Fable 5.1 --- server/domain.ts | 5 +++-- test/sweep-checkpoints.test.ts | 7 +++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/server/domain.ts b/server/domain.ts index 52c0b180..ea487c40 100644 --- a/server/domain.ts +++ b/server/domain.ts @@ -2150,8 +2150,9 @@ export class AttentionDomain { proposedAction: input.proposedAction, items: input.items.map((item) => ({ ...item, id: item.id.trim(), title: item.title.trim(), reason: item.reason.trim() })), status: "proposed", - // Reviving a stale or failed group under its old id is a new proposal cycle, so it dates from now. - createdAt: existing && existing.status !== "stale" && existing.status !== "failed" ? existing.createdAt : now, + // Every explicit upsert (only proposed, stale, or failed groups can be upserted) starts a new proposal + // cycle, so createdAt is the time of the current proposal; sweep presentation relies on that. + createdAt: now, updatedAt: now, }; for (const item of group.items) { diff --git a/test/sweep-checkpoints.test.ts b/test/sweep-checkpoints.test.ts index 8774e51f..3f0290bd 100644 --- a/test/sweep-checkpoints.test.ts +++ b/test/sweep-checkpoints.test.ts @@ -672,6 +672,13 @@ test("an older routine group approved after the run is not new coverage", async await domain.cancelQueuedWork(FEED, approval.id); expect((await store.readFeed(FEED)).routineActions.find((group) => group.id === "older")?.status).toBe("proposed"); expect(await domain.sweepPresentationStatus(FEED)).toMatchObject({ ready: false, routineGroupItems: 0 }); + // Explicitly re-proposing it under the same id is a new proposal for this sweep. + await domain.upsertRoutineActionGroup(FEED, { + id: "older", label: "Older", summary: "Re-proposed for this sweep.", + proposedAction: { label: "Archive", instruction: "Archive these." }, + items: [{ id: "i1", title: "One", reason: "Routine." }], + }); + expect(await domain.sweepPresentationStatus(FEED)).toMatchObject({ ready: true, routineGroupItems: 1 }); } finally { runtime.sqlite.close(); await rm(root, { recursive: true, force: true }); From dfcd60314e1d4c4cff6e35c6868c2a0dd30697c4 Mon Sep 17 00:00:00 2001 From: Dan Shipper Date: Sun, 13 Sep 2026 00:32:07 -0400 Subject: [PATCH 13/16] fix(sweeps): bind acted-on checks to the event ledger instead of a time window Twelfth-review follow-up: the one-second tolerance could treat content refreshed within a second of a dismissal or approval as reviewed. The feed event ledger is strictly ordered, so a card counts as acted on in its current version exactly when its latest card.dismissed or action.approved event comes after its latest card.created or card.updated event. Tests cover both orderings without sleeping, for dismissals and for approvals. Co-Authored-By: Claude Fable 5.1 --- server/domain.ts | 27 ++++++++++++++++----------- test/sweep-checkpoints.test.ts | 29 ++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/server/domain.ts b/server/domain.ts index ea487c40..da7551de 100644 --- a/server/domain.ts +++ b/server/domain.ts @@ -3811,13 +3811,19 @@ export class AttentionDomain { const workItems = await this.store.readWorkItems(feedId); const runs = await Promise.all(batch.sourceRunIds.map((runId) => this.store.readRun(feedId, runId))); const currentPass = feed.config.currentPass; - // Why a card does not present a run (undefined when it does): it must be written for the run and be - // visible for review now, or have been acted on since the run was recorded. - // An action on a card (dismissal, completion, approval) presents the card version that existed at the time. - // Timestamps in those paths are stamped a hair apart, so "no earlier than the card's latest write" allows a - // one-second tolerance; a card refreshed later than that must be resurfaced for review again. - const ACTED_ON_TOLERANCE_MS = 1_000; - const actedOnCurrentVersion = (actedAt: string, card: Card) => Date.parse(actedAt) + ACTED_ON_TOLERANCE_MS >= Date.parse(card.updatedAt); + // An action on a card (dismissal, approval) presents the card version that existed at the time. The feed + // event ledger is strictly ordered, so a card was acted on in its current version exactly when its latest + // action event comes after its latest content-write event; a card refreshed afterwards must be resurfaced. + const CONTENT_EVENTS = new Set(["card.created", "card.updated"]); + const ACTION_EVENTS = new Set(["card.dismissed", "action.approved"]); + const lastWrite = new Map(); + const lastAction = new Map(); + (await this.store.readEvents(feedId)).forEach((event, index) => { + if (!event.cardId) return; + if (CONTENT_EVENTS.has(event.type)) lastWrite.set(event.cardId, index); + else if (ACTION_EVENTS.has(event.type)) lastAction.set(event.cardId, index); + }); + const actedOnCurrentVersion = (card: Card) => (lastAction.get(card.id) ?? -1) > (lastWrite.get(card.id) ?? -1); const hiddenReason = (card: Card, run: SourceRun, forReview: boolean): string | undefined => { const recordedAt = run.completedAt ?? ""; if (!card.sourceRunIds?.includes(run.id)) return `card ${card.id} does not list run ${run.id} in sourceRunIds`; @@ -3829,11 +3835,10 @@ export class AttentionDomain { } if (card.status === "done") { if ((card.completedAt ?? "") < recordedAt) return `card ${card.id} was dismissed or completed before this run and has not been resurfaced (upsert it with status to_review_updated)`; - return actedOnCurrentVersion(card.completedAt ?? "", card) ? undefined : `card ${card.id} was updated after the user dismissed or completed it, so its current content was never reviewed; upsert it with status to_review_updated`; + return actedOnCurrentVersion(card) ? undefined : `card ${card.id} was updated after the user dismissed or completed it, so its current content was never reviewed; upsert it with status to_review_updated`; } - const approvals = workItems.filter((work) => work.cardId === card.id && work.createdAt >= recordedAt); - if (!approvals.length) return `card ${card.id} is ${card.status} from before this run with no new approval since`; - return approvals.some((work) => actedOnCurrentVersion(work.createdAt, card)) ? undefined : `card ${card.id} was updated after it was approved, so its current content was never reviewed; upsert it with status to_review_updated`; + if (!workItems.some((work) => work.cardId === card.id && work.createdAt >= recordedAt)) return `card ${card.id} is ${card.status} from before this run with no new approval since`; + return actedOnCurrentVersion(card) ? undefined : `card ${card.id} was updated after it was approved, so its current content was never reviewed; upsert it with status to_review_updated`; }; type Judged = { run: SourceRun; gap: SweepPresentationGap; review: boolean; cardId?: string }; const judged: Judged[] = []; diff --git a/test/sweep-checkpoints.test.ts b/test/sweep-checkpoints.test.ts index 3f0290bd..e3e177b2 100644 --- a/test/sweep-checkpoints.test.ts +++ b/test/sweep-checkpoints.test.ts @@ -617,7 +617,6 @@ test("a card dismissed between the run and its refresh does not present the refr await domain.recordSweepBatch(FEED, [run], work.id); // The user dismisses the old card after the run was recorded, then the agent refreshes it without resurfacing. await domain.dismissCard(FEED, "thread-r"); - await Bun.sleep(1_100); await domain.upsertCard(FEED, { id: "thread-r", title: "New content", why: "A new message arrived.", blocks: [], sourceRunIds: [run] }); expect((await store.readCard(FEED, "thread-r")).status).toBe("done"); const status = await domain.sweepPresentationStatus(FEED); @@ -634,6 +633,34 @@ test("a card dismissed between the run and its refresh does not present the refr } }); +test("a card refreshed after its approval does not present the refreshed content", async () => { + const { root, runtime, store, domain } = await setup(); + try { + const work = await claimRecollection(domain); + const run = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "review", cardId: "approve-me" }], { cursor: "approval" }, work.id); + await domain.recordSweepBatch(FEED, [run], work.id); + await domain.upsertCard(FEED, { + id: "approve-me", title: "Approve me", why: "Has an approvable action.", sourceRunIds: [run], + blocks: [{ id: "draft", type: "editable_text", value: "Draft.", editable: true }], + actions: [{ id: "send", label: "Send", behavior: "approve_action", instruction: "Send the draft.", artifactBlockId: "draft", externalMutation: true }], + }); + await domain.runCardAction(FEED, "approve-me", "send"); + expect((await store.readCard(FEED, "approve-me")).status).toBe("queued"); + expect((await domain.sweepPresentationStatus(FEED)).ready).toBe(true); // approved after presentation + await domain.upsertCard(FEED, { + id: "approve-me", title: "Approve me", why: "Refreshed after approval.", sourceRunIds: [run], + blocks: [{ id: "draft", type: "editable_text", value: "Changed draft.", editable: true }], + actions: [{ id: "send", label: "Send", behavior: "approve_action", instruction: "Send the draft.", artifactBlockId: "draft", externalMutation: true }], + }); + const status = await domain.sweepPresentationStatus(FEED); + expect(status.ready).toBe(false); + expect(status.missing[0].reason).toBe("card approve-me was updated after it was approved, so its current content was never reviewed; upsert it with status to_review_updated"); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + test("a card hidden by sweep feedback does not present its judgment", async () => { const { root, runtime, store, domain } = await setup(); try { From 6f3dd03bca8faa474c863ce0c205c0db4a58f1a3 Mon Sep 17 00:00:00 2001 From: Dan Shipper Date: Sun, 13 Sep 2026 00:43:08 -0400 Subject: [PATCH 14/16] fix(sweeps): every user disposition counts as reviewing the card Thirteenth-review follow-up: only dismissals and approvals were recognized as user actions, so a card the user queued an instruction or cleanup for, reacted to, edited, returned to review, or archived through a reading preference could be treated as unreviewed and block completion. All of those dispositions now count (reading preferences through their member card ids). Content an agent writes while completing work the user queued or approved is the outcome of a reviewed item, not a new write. Co-Authored-By: Claude Fable 5.1 --- server/domain.ts | 15 +++++++++++---- test/sweep-checkpoints.test.ts | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/server/domain.ts b/server/domain.ts index da7551de..c91c5396 100644 --- a/server/domain.ts +++ b/server/domain.ts @@ -3811,14 +3811,21 @@ export class AttentionDomain { const workItems = await this.store.readWorkItems(feedId); const runs = await Promise.all(batch.sourceRunIds.map((runId) => this.store.readRun(feedId, runId))); const currentPass = feed.config.currentPass; - // An action on a card (dismissal, approval) presents the card version that existed at the time. The feed - // event ledger is strictly ordered, so a card was acted on in its current version exactly when its latest - // action event comes after its latest content-write event; a card refreshed afterwards must be resurfaced. + // A user disposition on a card (dismissal, approval, queued instruction or cleanup, reaction, block edit, + // return to review, reading preference) presents the card version that existed at the time. The feed event + // ledger is strictly ordered, so a card was acted on in its current version exactly when its latest + // disposition event comes after its latest content-write event (card:upsert); a card refreshed afterwards + // must be resurfaced. Content an agent writes while completing work the user queued or approved is the + // outcome of that reviewed item and does not count as a new write. const CONTENT_EVENTS = new Set(["card.created", "card.updated"]); - const ACTION_EVENTS = new Set(["card.dismissed", "action.approved"]); + const ACTION_EVENTS = new Set(["card.dismissed", "action.approved", "work.queued", "cleanup.queued", "card.reaction_recorded", "card.block_edited", "card.returned_to_review"]); const lastWrite = new Map(); const lastAction = new Map(); (await this.store.readEvents(feedId)).forEach((event, index) => { + if (event.type === "reading.preference_recorded" && isRecord(event.detail) && isRecord(event.detail.beforeCardUpdatedAt)) { + for (const memberId of Object.keys(event.detail.beforeCardUpdatedAt)) lastAction.set(memberId, index); + return; + } if (!event.cardId) return; if (CONTENT_EVENTS.has(event.type)) lastWrite.set(event.cardId, index); else if (ACTION_EVENTS.has(event.type)) lastAction.set(event.cardId, index); diff --git a/test/sweep-checkpoints.test.ts b/test/sweep-checkpoints.test.ts index e3e177b2..d749adbb 100644 --- a/test/sweep-checkpoints.test.ts +++ b/test/sweep-checkpoints.test.ts @@ -661,6 +661,26 @@ test("a card refreshed after its approval does not present the refreshed content } }); +test("a card the user queued an instruction or cleanup for counts as reviewed", async () => { + const { root, runtime, store, domain } = await setup(); + try { + const work = await claimRecollection(domain); + const run = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }, { b: 2 }], [{ decision: "review", cardId: "instructed" }, { decision: "review", cardId: "cleaned" }], { cursor: "dispositions" }, work.id); + await domain.recordSweepBatch(FEED, [run], work.id); + await domain.upsertCard(FEED, { id: "instructed", title: "Instructed", why: "The user will queue an instruction.", blocks: [], sourceRunIds: [run] }); + await domain.upsertCard(FEED, { id: "cleaned", title: "Cleaned", why: "The user will queue source cleanup.", blocks: [], sourceRunIds: [run], actions: [{ id: "archive", label: "Archive", behavior: "default_cleanup" }] }); + await domain.queueInstruction(FEED, "instructed", "Draft a reply to this."); + await domain.queueSourceCleanup(FEED, "cleaned"); + expect((await store.readCard(FEED, "instructed")).status).toBe("queued"); + expect((await store.readCard(FEED, "cleaned")).status).toBe("queued"); + expect((await domain.sweepPresentationStatus(FEED)).ready).toBe(true); + expect((await domain.completeWork(FEED, work.id, work.capabilityToken, { response: "Done." })).status).toBe("completed"); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + test("a card hidden by sweep feedback does not present its judgment", async () => { const { root, runtime, store, domain } = await setup(); try { From 5643c5a7f2cf31ba4215428d900d0f39b7ba2c85 Mon Sep 17 00:00:00 2001 From: Dan Shipper Date: Sun, 13 Sep 2026 00:50:56 -0400 Subject: [PATCH 15/16] fix(sweeps): key routine coverage on the batch; count voice-queued instructions Fourteenth-review follow-ups: - Routine group coverage now requires the group to have been created at or after the batch was recorded, not after the earliest run, so a group created and approved between two runs cannot cover the later run's judgment. A routine judgment's named card that belongs to a group proposed before this sweep is reported as a gap. - Voice-queued card instructions emit their own events, which now count as user dispositions like a queued instruction. Co-Authored-By: Claude Fable 5.1 --- server/domain.ts | 17 ++++++++----- test/sweep-checkpoints.test.ts | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/server/domain.ts b/server/domain.ts index c91c5396..edc9ac62 100644 --- a/server/domain.ts +++ b/server/domain.ts @@ -3818,7 +3818,7 @@ export class AttentionDomain { // must be resurfaced. Content an agent writes while completing work the user queued or approved is the // outcome of that reviewed item and does not count as a new write. const CONTENT_EVENTS = new Set(["card.created", "card.updated"]); - const ACTION_EVENTS = new Set(["card.dismissed", "action.approved", "work.queued", "cleanup.queued", "card.reaction_recorded", "card.block_edited", "card.returned_to_review"]); + const ACTION_EVENTS = new Set(["card.dismissed", "action.approved", "work.queued", "cleanup.queued", "voice.intent_queued", "voice.instruction_submitted", "card.reaction_recorded", "card.block_edited", "card.returned_to_review"]); const lastWrite = new Map(); const lastAction = new Map(); (await this.store.readEvents(feedId)).forEach((event, index) => { @@ -3836,6 +3836,10 @@ export class AttentionDomain { if (!card.sourceRunIds?.includes(run.id)) return `card ${card.id} does not list run ${run.id} in sourceRunIds`; if (card.updatedAt < recordedAt) return `card ${card.id} was last written before this run was recorded`; if (forReview && card.routineActionGroupId) return `card ${card.id} belongs to routine action group ${card.routineActionGroupId}, so it is not individually reviewable; a review judgment needs its own card`; + if (!forReview && card.routineActionGroupId) { + const group = feed.routineActions.find((candidate) => candidate.id === card.routineActionGroupId); + if (group && group.createdAt < batch.createdAt) return `card ${card.id} belongs to routine action group ${group.id}, which was proposed before this sweep; propose a group for this sweep or resurface the card`; + } if (card.sweep?.hidden) return `card ${card.id} is hidden by sweep feedback ${card.sweep.feedbackId}; upsert it again with status to_review_updated if it must be presented`; if (card.status === "to_review_new" || card.status === "to_review_updated") { return card.readyForPass <= currentPass ? undefined : `card ${card.id} is deferred to pass ${card.readyForPass} (current pass ${currentPass})`; @@ -3902,13 +3906,14 @@ export class AttentionDomain { // routine_action judgments without a card may be covered by items of routine action groups proposed since the // sweep. Group items do not identify judgments, so this coverage is aggregate: when it falls short, every such // judgment is listed rather than an invented subset. - const earliestRecordedAt = runs.map((run) => run.completedAt ?? "").sort()[0] ?? ""; // A group counts when it was proposed for this sweep, which its createdAt records: recording the batch marks - // earlier proposals stale, and re-proposing a stale group under a reused id dates it from the re-proposal. - // Status alone is not enough (an older approved group returns to `proposed` when its work is cancelled) and - // updatedAt is not used, since approval or completion of an older group refreshes it without presenting anything new. + // earlier proposals stale, so a proposal for this sweep is created at or after the batch, and re-proposing a + // stale group under a reused id dates it from the re-proposal. Keying on the batch rather than the earliest + // run keeps a group created and approved between two runs from covering the later run's judgments. Status + // alone is not enough (an older approved group returns to `proposed` when its work is cancelled) and updatedAt + // is not used, since approval or completion of an older group refreshes it without presenting anything new. const routineGroupItems = feed.routineActions - .filter((group) => group.status !== "stale" && group.status !== "failed" && group.createdAt >= earliestRecordedAt) + .filter((group) => group.status !== "stale" && group.status !== "failed" && group.createdAt >= batch.createdAt) .reduce((total, group) => total + group.items.filter((item) => !item.cardId || !presenting.has(item.cardId)).length, 0); const routineCoveredByGroups = Math.min(routineGroupItems, routineWithoutCard.length); if (routineGroupItems < routineWithoutCard.length) { diff --git a/test/sweep-checkpoints.test.ts b/test/sweep-checkpoints.test.ts index d749adbb..6d5768da 100644 --- a/test/sweep-checkpoints.test.ts +++ b/test/sweep-checkpoints.test.ts @@ -681,6 +681,50 @@ test("a card the user queued an instruction or cleanup for counts as reviewed", } }); +test("a group created and approved between two runs does not cover the later run's judgment", async () => { + const { root, runtime, domain } = await setup(); + try { + const work = await claimRecollection(domain); + const second = await domain.addSourceFromBrief(FEED, "Read the dispute ledger."); + const a = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "suppress" }], { cursor: "a" }, work.id); + await domain.upsertRoutineActionGroup(FEED, { + id: "between", label: "Between", summary: "Proposed and approved between the runs.", + proposedAction: { label: "Archive", instruction: "Archive these." }, + items: [{ id: "i1", title: "One", reason: "Routine." }], + }); + await domain.approveRoutineActionGroup(FEED, "between"); + const b = await domain.recordSourceRun(FEED, second.id, [{ b: 1 }], [{ decision: "routine_action" }], { cursor: "b" }, work.id); + await domain.recordSweepBatch(FEED, [a, b], work.id); + expect(await domain.sweepPresentationStatus(FEED)).toMatchObject({ ready: false, routineGroupItems: 0 }); + await domain.upsertRoutineActionGroup(FEED, { + id: "for-this-sweep", label: "Now", summary: "Proposed after the batch.", + proposedAction: { label: "Archive", instruction: "Archive these." }, + items: [{ id: "i2", title: "Two", reason: "Routine." }], + }); + expect(await domain.sweepPresentationStatus(FEED)).toMatchObject({ ready: true, routineGroupItems: 1 }); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("a voice instruction on a card counts as reviewing it", async () => { + const { root, runtime, store, domain } = await setup(); + try { + const work = await claimRecollection(domain); + const run = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "review", cardId: "voiced" }], { cursor: "voice" }, work.id); + await domain.recordSweepBatch(FEED, [run], work.id); + await domain.upsertCard(FEED, { id: "voiced", title: "Voiced", why: "The user will dictate an instruction.", blocks: [], sourceRunIds: [run] }); + const result = await domain.submitVoiceInstruction(FEED, { kind: "card", feedId: FEED, cardId: "voiced" }, "Reply and ask for the invoice."); + expect("work" in result).toBe(true); + expect((await store.readCard(FEED, "voiced")).status).toBe("queued"); + expect((await domain.sweepPresentationStatus(FEED)).ready).toBe(true); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + test("a card hidden by sweep feedback does not present its judgment", async () => { const { root, runtime, store, domain } = await setup(); try { From 89dd68e51371fbf3c395c2a52218fadde6d4c189 Mon Sep 17 00:00:00 2001 From: Dan Shipper Date: Sun, 13 Sep 2026 00:57:37 -0400 Subject: [PATCH 16/16] fix(sweeps): order routine group proposals against the batch through the ledger Fifteenth-review follow-up: comparing millisecond timestamps could admit a group created in the same millisecond as the batch. A group now counts as a proposal for this sweep exactly when its latest routine_action.proposed event comes after the batch's sweep.batch_recorded event, falling back to timestamps only for ledgers without those events. A regression forces the timestamp tie. Co-Authored-By: Claude Fable 5.1 --- server/domain.ts | 14 ++++++++++++-- test/sweep-checkpoints.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/server/domain.ts b/server/domain.ts index edc9ac62..8c996026 100644 --- a/server/domain.ts +++ b/server/domain.ts @@ -3821,7 +3821,13 @@ export class AttentionDomain { const ACTION_EVENTS = new Set(["card.dismissed", "action.approved", "work.queued", "cleanup.queued", "voice.intent_queued", "voice.instruction_submitted", "card.reaction_recorded", "card.block_edited", "card.returned_to_review"]); const lastWrite = new Map(); const lastAction = new Map(); + // Routine group proposals are ordered against the batch through the same ledger: timestamps can tie within a + // millisecond, but events cannot. + const lastProposal = new Map(); + let batchRecordedAt = -1; (await this.store.readEvents(feedId)).forEach((event, index) => { + if (event.type === "sweep.batch_recorded" && isRecord(event.detail) && event.detail.batchId === batch.id) batchRecordedAt = index; + if (event.type === "routine_action.proposed" && isRecord(event.detail) && typeof event.detail.groupId === "string") lastProposal.set(event.detail.groupId, index); if (event.type === "reading.preference_recorded" && isRecord(event.detail) && isRecord(event.detail.beforeCardUpdatedAt)) { for (const memberId of Object.keys(event.detail.beforeCardUpdatedAt)) lastAction.set(memberId, index); return; @@ -3830,6 +3836,10 @@ export class AttentionDomain { if (CONTENT_EVENTS.has(event.type)) lastWrite.set(event.cardId, index); else if (ACTION_EVENTS.has(event.type)) lastAction.set(event.cardId, index); }); + const proposedForThisSweep = (group: RoutineActionGroup): boolean => { + const proposal = lastProposal.get(group.id); + return proposal !== undefined && batchRecordedAt >= 0 ? proposal > batchRecordedAt : group.createdAt >= batch.createdAt; + }; const actedOnCurrentVersion = (card: Card) => (lastAction.get(card.id) ?? -1) > (lastWrite.get(card.id) ?? -1); const hiddenReason = (card: Card, run: SourceRun, forReview: boolean): string | undefined => { const recordedAt = run.completedAt ?? ""; @@ -3838,7 +3848,7 @@ export class AttentionDomain { if (forReview && card.routineActionGroupId) return `card ${card.id} belongs to routine action group ${card.routineActionGroupId}, so it is not individually reviewable; a review judgment needs its own card`; if (!forReview && card.routineActionGroupId) { const group = feed.routineActions.find((candidate) => candidate.id === card.routineActionGroupId); - if (group && group.createdAt < batch.createdAt) return `card ${card.id} belongs to routine action group ${group.id}, which was proposed before this sweep; propose a group for this sweep or resurface the card`; + if (group && !proposedForThisSweep(group)) return `card ${card.id} belongs to routine action group ${group.id}, which was proposed before this sweep; propose a group for this sweep or resurface the card`; } if (card.sweep?.hidden) return `card ${card.id} is hidden by sweep feedback ${card.sweep.feedbackId}; upsert it again with status to_review_updated if it must be presented`; if (card.status === "to_review_new" || card.status === "to_review_updated") { @@ -3913,7 +3923,7 @@ export class AttentionDomain { // alone is not enough (an older approved group returns to `proposed` when its work is cancelled) and updatedAt // is not used, since approval or completion of an older group refreshes it without presenting anything new. const routineGroupItems = feed.routineActions - .filter((group) => group.status !== "stale" && group.status !== "failed" && group.createdAt >= batch.createdAt) + .filter((group) => group.status !== "stale" && group.status !== "failed" && proposedForThisSweep(group)) .reduce((total, group) => total + group.items.filter((item) => !item.cardId || !presenting.has(item.cardId)).length, 0); const routineCoveredByGroups = Math.min(routineGroupItems, routineWithoutCard.length); if (routineGroupItems < routineWithoutCard.length) { diff --git a/test/sweep-checkpoints.test.ts b/test/sweep-checkpoints.test.ts index 6d5768da..c096ec64 100644 --- a/test/sweep-checkpoints.test.ts +++ b/test/sweep-checkpoints.test.ts @@ -708,6 +708,28 @@ test("a group created and approved between two runs does not cover the later run } }); +test("a group proposed just before the batch does not count even if their timestamps tie", async () => { + const { root, runtime, store, domain } = await setup(); + try { + const work = await claimRecollection(domain); + const run = await domain.recordSourceRun(FEED, SOURCE, [{ a: 1 }], [{ decision: "routine_action" }], { cursor: "tie" }, work.id); + await domain.upsertRoutineActionGroup(FEED, { + id: "just-before", label: "Before", summary: "Proposed before the batch.", + proposedAction: { label: "Archive", instruction: "Archive these." }, + items: [{ id: "i1", title: "One", reason: "Routine." }], + }); + await domain.approveRoutineActionGroup(FEED, "just-before"); // queued, so recording the batch does not stale it + const batchId = await domain.recordSweepBatch(FEED, [run], work.id); + const batch = await store.readSweepBatch(FEED, batchId); + const group = (await store.readFeed(FEED)).routineActions.find((candidate) => candidate.id === "just-before")!; + await store.writeRoutineActionGroup({ ...group, createdAt: batch.createdAt }); // force the timestamp tie + expect(await domain.sweepPresentationStatus(FEED)).toMatchObject({ ready: false, routineGroupItems: 0 }); + } finally { + runtime.sqlite.close(); + await rm(root, { recursive: true, force: true }); + } +}); + test("a voice instruction on a card counts as reviewing it", async () => { const { root, runtime, store, domain } = await setup(); try {