Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/recorded-seed-reconciliation.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions packages/harness/docs/assistant-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
136 changes: 135 additions & 1 deletion packages/harness/src/core/assistant-record-capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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),
Expand All @@ -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,
Expand Down Expand Up @@ -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,
});
},
);
39 changes: 34 additions & 5 deletions packages/harness/src/core/assistant-record-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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),
),
Expand Down Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions packages/harness/src/core/assistant-record-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
22 changes: 20 additions & 2 deletions packages/harness/src/core/assistant-record-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,14 @@ export class AssistantRecordStore {
}

/** Revisions belong to capture scheduling; a late native response cannot win. */
async write(value: AssistantRecord): Promise<boolean> {
async write(
value: AssistantRecord,
/** Server-only proof from exact native seed exclusion, tied to the reconciled record. */
excludedSeed?: { messageId: string; previousRevision: number },
): Promise<boolean> {
// 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");
Expand All @@ -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);
Expand Down
Loading