From 66f71aa01ee2fdc0369cb7869a425604bbd74b81 Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 14 Sep 2026 22:24:29 +0000 Subject: [PATCH] fix(harness): reconcile uncertain Assistant Resume commits --- .../assistant-resume-commit-reconciliation.md | 5 +++ packages/harness/docs/assistant-history.md | 5 +++ .../src/core/assistant-resume-store.test.ts | 41 +++++++++++++++++++ .../src/core/assistant-session-files.ts | 9 ++++ .../src/core/assistant-session-store.ts | 9 ++-- 5 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 .changeset/assistant-resume-commit-reconciliation.md diff --git a/.changeset/assistant-resume-commit-reconciliation.md b/.changeset/assistant-resume-commit-reconciliation.md new file mode 100644 index 000000000..2a1ec43ff --- /dev/null +++ b/.changeset/assistant-resume-commit-reconciliation.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Report uncertain Assistant metadata commits and reconcile exact Resume execution state. diff --git a/packages/harness/docs/assistant-history.md b/packages/harness/docs/assistant-history.md index c4f269971..0eaafd2a2 100644 --- a/packages/harness/docs/assistant-history.md +++ b/packages/harness/docs/assistant-history.md @@ -138,3 +138,8 @@ Compaction provenance follows native control-message semantics: user compaction parts or text parts with literal synthetic and continuation markers. Tool/file parts and truthy non-boolean markers cannot inherit an earlier accepted context, completion token, Resume preflight or recovery request. + +If publication and rollback both fail, metadata reports an unconfirmed commit; +the new valid generation may already be readable and the previous generation is +retained. Resume reconciles its exact durable operation proof after restart. +An original-base replay cannot change the operation's committed execution state. diff --git a/packages/harness/src/core/assistant-resume-store.test.ts b/packages/harness/src/core/assistant-resume-store.test.ts index 5b6203c8b..429cac7a6 100644 --- a/packages/harness/src/core/assistant-resume-store.test.ts +++ b/packages/harness/src/core/assistant-resume-store.test.ts @@ -92,6 +92,47 @@ it("reconciles crash-after-commit with a paused CAS from the proved committed re }); }); +it("rejects an exact original-base replay that requests different execution", async () => { + const saved = await store.commitResume(id, 1, request, "paused"); + await expect(store.commitResume(id, 1, request, "enabled")).rejects.toThrow( + "changed", + ); + expect(await store.lifecycle(id)).toEqual(saved); +}); + +it("reconciles the original Resume proof after publication and rollback both fail", async () => { + const actual = await vi.importActual("node:fs/promises"); + let published = false; + vi.mocked(fs.rename).mockImplementation(async (from, to) => { + if (String(from).endsWith(".rollback")) + throw Object.assign(new Error("rollback refused"), { code: "EIO" }); + await actual.rename(from, to); + if (to === document()) published = true; + }); + vi.mocked(fs.open).mockImplementation(async (file, flags, mode) => { + if (published && flags === "r") + throw Object.assign(new Error("durability unknown"), { code: "EIO" }); + return actual.open(file, flags, mode); + }); + await expect( + store.commitResume(id, 1, request, "paused"), + ).rejects.toMatchObject({ code: "ASSISTANT_STORAGE_COMMIT_UNCONFIRMED" }); + vi.mocked(fs.open).mockImplementation(actual.open); + vi.mocked(fs.rename).mockImplementation(actual.rename); + const previous = JSON.parse(await readFile(`${document()}.previous`, "utf8")); + expect(previous).toMatchObject({ revision: 1, lifecycle: "ended" }); + const restarted = new AssistantSessionStore(root); + expect(await restarted.commitResume(id, 1, request, "paused")).toMatchObject({ + revision: 2, + lifecycle: "open", + execution: "paused", + }); + expect((await restarted.resumeState(id)).resumeOperation).toMatchObject({ + operationId: request.operationId, + committedRevision: 2, + }); +}); + it.each(["ending", "ended", "enabled"] as const)( "preserves proof but rejects operation replay after a later %s transition", async (next) => { diff --git a/packages/harness/src/core/assistant-session-files.ts b/packages/harness/src/core/assistant-session-files.ts index bfd851a85..3dd7961fb 100644 --- a/packages/harness/src/core/assistant-session-files.ts +++ b/packages/harness/src/core/assistant-session-files.ts @@ -10,6 +10,11 @@ export class AssistantStorageError extends Error { super("Assistant history storage is unavailable"); } } +/** Publication may be readable despite a failed durability/rollback acknowledgement. + * Callers must reconcile their operation proof; absence of success is not rollback. */ +export class AssistantStorageCommitUnconfirmedError extends AssistantStorageError { + readonly code = "ASSISTANT_STORAGE_COMMIT_UNCONFIRMED"; +} /** Only server-derived identifiers may select private directories. Reject symlink escapes. */ export async function assistantDirectory( @@ -114,16 +119,20 @@ export async function writeAssistantJson( published = true; await syncDirectory(directory); } catch (error) { + let restored = false; if (published && retained) { // The backup remains recoverable even if the filesystem also refuses rollback. try { await fs.link(previous, rollback); await fs.rename(rollback, destination); await syncDirectory(directory); + restored = true; } catch { /* Preserve both generations for a later successful read. */ } } + if (published && !restored) + throw new AssistantStorageCommitUnconfirmedError(); throw error; } finally { await Promise.all( diff --git a/packages/harness/src/core/assistant-session-store.ts b/packages/harness/src/core/assistant-session-store.ts index cf6a89559..531462153 100644 --- a/packages/harness/src/core/assistant-session-store.ts +++ b/packages/harness/src/core/assistant-session-store.ts @@ -110,9 +110,7 @@ export class AssistantSessionStore { } /** Read the public header and private operation proof from one atomic generation. */ - async resumeState( - id: string, - ): Promise<{ + async resumeState(id: string): Promise<{ lifecycle: AssistantLifecycle | null; resumeOperation: AssistantResumeOperation | null; }> { @@ -191,8 +189,11 @@ export class AssistantSessionStore { current?.lifecycle !== "open") ) throw new AssistantSessionRevisionError(); - if (retry && expected === request.expectedRevision) + if (retry && expected === request.expectedRevision) { + if (current!.execution !== execution) + throw new AssistantSessionRevisionError(); return publicLifecycle(current!); + } if ( revision !== expected || (!retry && revision !== request.expectedRevision) ||