From 2ba5ec406db39c0d122e5b5750bf49e2dcf0a0cf Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 14 Sep 2026 23:11:19 +0000 Subject: [PATCH] fix(harness): reconcile exact continuation seed records --- .changeset/recorded-seed-reconciliation.md | 5 + packages/harness/docs/assistant-history.md | 4 + .../src/core/assistant-record-capture.test.ts | 136 +++++++++++++++++- .../src/core/assistant-record-capture.ts | 39 ++++- .../src/core/assistant-record-store.test.ts | 35 +++++ .../src/core/assistant-record-store.ts | 22 ++- 6 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 .changeset/recorded-seed-reconciliation.md diff --git a/.changeset/recorded-seed-reconciliation.md b/.changeset/recorded-seed-reconciliation.md new file mode 100644 index 000000000..8310dabfd --- /dev/null +++ b/.changeset/recorded-seed-reconciliation.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Remove only the provably excluded empty seed turn during reconciliation. Permit a seed-only record to become empty only with a locked proof of the exact prior revision and message, preserving concurrent newer records and real public work. diff --git a/packages/harness/docs/assistant-history.md b/packages/harness/docs/assistant-history.md index 20f11be42..7fd3c2629 100644 --- a/packages/harness/docs/assistant-history.md +++ b/packages/harness/docs/assistant-history.md @@ -177,3 +177,7 @@ Unfinished continuation children remain internal to allocation recovery. General session lists, history, runtime/lifecycle events and direct session actions use the same preparation visibility boundary; direct Terminal start cannot bypass it. Restored pending children are hidden before the server starts accepting requests. + +An exact attested no-reply seed can be removed from an older empty user-only +checkpoint without inflating task counts. Clearing a seed-only record requires +the exact previous revision; compacted tasks and recorded real replies remain. diff --git a/packages/harness/src/core/assistant-record-capture.test.ts b/packages/harness/src/core/assistant-record-capture.test.ts index ed20d6454..5516fd96b 100644 --- a/packages/harness/src/core/assistant-record-capture.test.ts +++ b/packages/harness/src/core/assistant-record-capture.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { createHash } from "node:crypto"; import { AssistantRecordCapture, reconcileAssistantRecord, @@ -10,9 +11,11 @@ import { AssistantRecordStore } from "./assistant-record-store.js"; import { sourceFixture } from "./test-fixtures/assistant-context.js"; import { projectAssistantRecord } from "./assistant-record.js"; import type { HostedOpenCode } from "./opencode-host.js"; +import type { AssistantContinuationView } from "../shared/assistant-continuation.js"; let root: string, store: AssistantRecordStore, capture: AssistantRecordCapture; let abort: AbortController; +let hosted: HostedOpenCode; const binding = { harnessSessionId: "studio-a", contextAuthorityScope: "a".repeat(64), @@ -38,7 +41,7 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "assistant-capture-")); store = new AssistantRecordStore(root); abort = new AbortController(); - const hosted = { + hosted = { ...binding, signal: abort.signal, isCurrent: () => !abort.signal.aborted, @@ -176,3 +179,134 @@ it("bounds native reads and preserves an earlier checkpoint on oversized history expect(capture.unavailable).toBe(true); expect(await store.read(binding)).toEqual(previous); }); + +const seedText = "Recorded context from the completed parent task."; +const continuation: AssistantContinuationView = { + operationId: "11111111-1111-4111-8111-111111111111", + sourceSessionId: "parent", + sourceRecordRevision: 1, + capturedAt: "2026-09-14T00:00:00.000Z", + retainedTurns: 1, + omittedTurns: 0, + seed: { + conversationId: binding.conversationId, + messageId: "msg_seed", + partId: "prt_seed", + text: seedText, + sha256: createHash("sha256").update(seedText).digest("hex"), + }, +}; +function seed() { + const message = native(seedText, "msg_seed")[0]!; + Object.assign(message.parts[0]!, { + id: "prt_seed", + synthetic: true, + ignored: false, + metadata: { + sapiomContinuation: { + operationId: continuation.operationId, + briefHash: continuation.seed.sha256, + }, + }, + }); + return message; +} +function captureWithAttestation() { + let attestation: AssistantContinuationView | null = null; + capture.dispose(); + capture = new AssistantRecordCapture( + hosted, + binding.conversationId, + store, + async () => attestation, + ); + return () => { + attestation = continuation; + }; +} + +it("removes a previously captured seed only after exact attestation and corrects its counts", async () => { + const attest = captureWithAttestation(); + fetch.mockImplementation(async () => Response.json([seed()])); + await capture.checkpoint(); + expect(await store.read(binding)).toMatchObject({ + turnCount: 1, + messageCount: 1, + }); + attest(); + await capture.checkpoint(); + expect(await store.read(binding)).toMatchObject({ + turns: [], + turnCount: 0, + messageCount: 0, + }); + await capture.checkpoint(); + expect(await store.read(binding)).toMatchObject({ + turns: [], + turnCount: 0, + messageCount: 0, + }); +}); + +it("keeps compacted ordinary history while removing the newly attested seed", async () => { + const attest = captureWithAttestation(); + fetch.mockResolvedValueOnce( + Response.json([seed(), ...native("Archived task", "msg_old", 2)]), + ); + await capture.checkpoint(); + attest(); + fetch.mockImplementation(async () => + Response.json([seed(), ...native("Next task", "msg_new", 3)]), + ); + await capture.checkpoint(); + const record = await store.read(binding); + expect(record).toMatchObject({ turnCount: 2, messageCount: 2 }); + expect(record?.turns.map((turn) => turn.id)).toEqual(["msg_old", "msg_new"]); +}); + +it("retains real replies to a seed even when the current native snapshot omits them", async () => { + const attest = captureWithAttestation(); + const answer = native("Real work", "msg_answer", 2)[0]!; + Object.assign(answer.info, { + role: "assistant", + parentID: "msg_seed", + finish: "stop", + time: { created: 2, completed: 3 }, + }); + fetch.mockResolvedValueOnce(Response.json([seed(), answer])); + await capture.checkpoint(); + attest(); + fetch.mockImplementation(async () => Response.json([seed()])); + await capture.checkpoint(); + const record = await store.read(binding); + expect(record).toMatchObject({ turnCount: 1, messageCount: 2 }); + expect(record?.turns[0]?.messages[1]?.parts[0]).toMatchObject({ + text: "Real work", + }); +}); + +it.each(["absent", "changed-marker", "ordinary"])( + "does not remove a prior seed from %s native input", + async (kind) => { + const attest = captureWithAttestation(); + fetch.mockResolvedValueOnce(Response.json([seed()])); + await capture.checkpoint(); + attest(); + const current = seed(); + if (kind === "changed-marker") + Object.assign(current.parts[0]!, { metadata: {} }); + if (kind === "ordinary") + Object.assign(current.parts[0]!, { + synthetic: false, + text: "Human task", + }); + fetch.mockImplementation(async () => + Response.json(kind === "absent" ? [] : [current]), + ); + await capture.checkpoint(); + expect(await store.read(binding)).toMatchObject({ + turnCount: 1, + messageCount: 1, + }); + }, +); diff --git a/packages/harness/src/core/assistant-record-capture.ts b/packages/harness/src/core/assistant-record-capture.ts index 9ade78ae2..a8f4aec5c 100644 --- a/packages/harness/src/core/assistant-record-capture.ts +++ b/packages/harness/src/core/assistant-record-capture.ts @@ -2,6 +2,10 @@ import type { HostedOpenCode } from "./opencode-host.js"; import type { AssistantRecordStore } from "./assistant-record-store.js"; import type { AssistantContinuationView } from "../shared/assistant-continuation.js"; import { awaitAssistantInspection } from "./assistant-lifecycle.js"; +import { + assistantTaskMessages, + type OpenCodeTurnMessage, +} from "../shared/opencode-turn.js"; import type { AssistantRecord, AssistantRecordBinding, @@ -15,11 +19,21 @@ import { export function reconcileAssistantRecord( previous: AssistantRecord | null, next: AssistantRecord, + /** Positively excluded by the exact attested seed matcher on validated native input. */ + excludedSeed?: string, ): AssistantRecord { if (!previous) return next; if (JSON.stringify(previous.binding) !== JSON.stringify(next.binding)) throw new Error("Assistant record binding changed"); - const turns = new Map(previous.turns.map((turn) => [turn.id, turn])); + const priorTurns = previous.turns.filter( + (turn) => + turn.id !== excludedSeed || + // Even an attested seed must not erase previously captured public work. + turn.messages.length !== 1 || + turn.messages[0]!.parts.length !== 0, + ); + const removedSeeds = previous.turns.length - priorTurns.length; + const turns = new Map(priorTurns.map((turn) => [turn.id, turn])); for (const turn of next.turns) { const old = turns.get(turn.id); const messages = new Map( @@ -42,9 +56,13 @@ export function reconcileAssistantRecord( return boundAssistantRecord({ ...next, turns: retained, - turnCount: Math.max(previous.turnCount, next.turnCount, retained.length), + turnCount: Math.max( + previous.turnCount - removedSeeds, + next.turnCount, + retained.length, + ), messageCount: Math.max( - previous.messageCount, + previous.messageCount - removedSeeds, next.messageCount, retained.reduce((count, turn) => count + turn.messages.length, 0), ), @@ -158,11 +176,22 @@ export class AssistantRecordCapture { undefined, continuation, ); + // Projection validated these messages and their exact native binding. + // An absent native turn alone is not proof: it may have been compacted. + const messages = native as OpenCodeTurnMessage[]; + const tasks = new Set(assistantTaskMessages(messages, continuation)); + const excludedSeed = messages.find((message) => !tasks.has(message)) + ?.info?.id; const previous = await this.store.read(this.binding); - const record = reconcileAssistantRecord(previous, next); + const record = reconcileAssistantRecord(previous, next, excludedSeed); signal.throwIfAborted(); if (!this.live()) return; - await this.store.write(record); + await this.store.write( + record, + excludedSeed && previous + ? { messageId: excludedSeed, previousRevision: previous.revision } + : undefined, + ); this.failed = false; } catch { this.failed = true; // Retain the last successful checkpoint; never turn failure into absence. diff --git a/packages/harness/src/core/assistant-record-store.test.ts b/packages/harness/src/core/assistant-record-store.test.ts index bced0ec21..edb4f810d 100644 --- a/packages/harness/src/core/assistant-record-store.test.ts +++ b/packages/harness/src/core/assistant-record-store.test.ts @@ -140,3 +140,38 @@ it("rejects unsafe paths, symlinks, oversized files, and mutated caller-owned re await write; expect((await store.read(binding))!.binding.cwd).toBe(binding.cwd); }); + +it.each([ + "missing-proof", + "wrong-message", + "stale-proof", + "public-work", + "dropped-history", +])("preserves nonempty history when seed exclusion has %s", async (kind) => { + const prior = record(1); + if (kind !== "public-work") prior.turns[0]!.messages[0]!.parts = []; + if (kind === "dropped-history") { + prior.turnCount = 2; + prior.messageCount = 2; + prior.limitations.push("dropped-early-turns"); + } + await store.write(prior); + if (kind === "stale-proof") + await new AssistantRecordStore(root).write({ ...prior, revision: 2 }); + const empty = projectAssistantRecord([], binding, 3); + expect( + await store.write( + empty, + kind === "missing-proof" + ? undefined + : { + messageId: kind === "wrong-message" ? "msg_other" : "msg_user", + previousRevision: 1, + }, + ), + ).toBe(false); + expect(await store.read(binding)).toEqual({ + ...prior, + revision: kind === "stale-proof" ? 2 : 1, + }); +}); diff --git a/packages/harness/src/core/assistant-record-store.ts b/packages/harness/src/core/assistant-record-store.ts index 74c3b8abe..4581003bd 100644 --- a/packages/harness/src/core/assistant-record-store.ts +++ b/packages/harness/src/core/assistant-record-store.ts @@ -61,9 +61,14 @@ export class AssistantRecordStore { } /** Revisions belong to capture scheduling; a late native response cannot win. */ - async write(value: AssistantRecord): Promise { + async write( + value: AssistantRecord, + /** Server-only proof from exact native seed exclusion, tied to the reconciled record. */ + excludedSeed?: { messageId: string; previousRevision: number }, + ): Promise { // Validate and detach caller-owned objects before waiting for the lock. const record = validateAssistantRecord(JSON.parse(JSON.stringify(value))); + const exclusion = excludedSeed ? { ...excludedSeed } : undefined; try { const directory = await this.directory(record.binding); const file = join(directory, "record.json"); @@ -74,10 +79,23 @@ export class AssistantRecordStore { saved === null ? null : validateAssistantRecord(saved, record.binding); + const removesOnlySeed = + exclusion !== undefined && + previous !== null && + previous.revision === exclusion.previousRevision && + previous.turnCount === 1 && + previous.messageCount === 1 && + previous.turns.length === 1 && + previous.turns[0]!.id === exclusion.messageId && + previous.turns[0]!.messages.length === 1 && + previous.turns[0]!.messages[0]!.parts.length === 0 && + record.turnCount === 0; if ( previous !== null && (previous.revision >= record.revision || - (previous.messageCount > 0 && record.messageCount === 0)) + (previous.messageCount > 0 && + record.messageCount === 0 && + !removesOnlySeed)) ) return false; await writeAssistantJson(directory, "record.json", record);