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/assistant-resume-commit-reconciliation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sapiom/harness": patch
---

Report uncertain Assistant metadata commits and reconcile exact Resume execution state.
5 changes: 5 additions & 0 deletions packages/harness/docs/assistant-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
41 changes: 41 additions & 0 deletions packages/harness/src/core/assistant-resume-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fs>("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) => {
Expand Down
9 changes: 9 additions & 0 deletions packages/harness/src/core/assistant-session-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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();
Comment on lines +134 to +135

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Cleanup masks unconfirmed commits

When temporary-file removal fails after uncertain publication, writeAssistantJson replaces AssistantStorageCommitUnconfirmedError with the cleanup error. Callers cannot identify and reconcile the possibly committed operation.

Learn more

A rejection from an awaited finally block replaces the error already thrown by its try or catch. Here, storage has already published the destination and failed to confirm rollback. Another filesystem failure during fs.rm therefore removes the only signal that the commit is uncertain. This is especially reachable during the same degraded-storage condition that caused publication or rollback to fail.

Example: The destination rename succeeds, directory sync fails with EIO, rollback also fails, and removing .rollback then fails with EIO. The caller receives the removal error without ASSISTANT_STORAGE_COMMIT_UNCONFIRMED, although the new generation remains readable.

Recommended fix: Make temporary cleanup best-effort when an earlier error is pending, or capture cleanup failures without replacing AssistantStorageCommitUnconfirmedError. Preserve cleanup failures only when no primary operation error exists.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

throw error;
} finally {
await Promise.all(
Expand Down
9 changes: 5 additions & 4 deletions packages/harness/src/core/assistant-session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}> {
Expand Down Expand Up @@ -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) ||
Expand Down
Loading