From 027648be2d6ba5c3995a7ba9887711a61f560951 Mon Sep 17 00:00:00 2001
From: Yash
Date: Mon, 14 Sep 2026 23:24:59 +0000
Subject: [PATCH] fix(harness): recover from definite continuation conflicts
---
.../assistant-continue-conflict-recovery.md | 5 +
packages/harness/docs/assistant-history.md | 5 +
.../web/e2e/assistant-continuation.spec.ts | 154 +++++++++++++++++-
packages/harness/web/src/App.tsx | 5 +-
.../components/AssistantContinueAction.tsx | 65 +++++++-
.../src/components/AssistantHistoryPane.tsx | 7 +-
.../src/lib/assistant-continuation-client.ts | 17 +-
7 files changed, 243 insertions(+), 15 deletions(-)
create mode 100644 .changeset/assistant-continue-conflict-recovery.md
diff --git a/.changeset/assistant-continue-conflict-recovery.md b/.changeset/assistant-continue-conflict-recovery.md
new file mode 100644
index 000000000..46f3c88a8
--- /dev/null
+++ b/.changeset/assistant-continue-conflict-recovery.md
@@ -0,0 +1,5 @@
+---
+"@sapiom/harness": patch
+---
+
+Allow explicit review and a new continuation after a verified lifecycle conflict while preserving uncertain retries.
diff --git a/packages/harness/docs/assistant-history.md b/packages/harness/docs/assistant-history.md
index 81c7a81a7..1941395b6 100644
--- a/packages/harness/docs/assistant-history.md
+++ b/packages/harness/docs/assistant-history.md
@@ -195,3 +195,8 @@ the same Studio. An uncertain Assistant Resume keeps its original revision and
operation ID across its own lifecycle event and Close/Back navigation; Retry
Resume reconciles that request. Only a verified lifecycle conflict permits a new
availability check.
+
+After a verified Continue lifecycle conflict, Review latest record fetches and
+displays saved history without creating work. A separate Start a new continuation
+action replaces the rejected request only if its saved tuple is still unchanged.
+Uncertain failures and reloads continue to reconcile the original operation.
diff --git a/packages/harness/web/e2e/assistant-continuation.spec.ts b/packages/harness/web/e2e/assistant-continuation.spec.ts
index 4acd3e299..948834948 100644
--- a/packages/harness/web/e2e/assistant-continuation.spec.ts
+++ b/packages/harness/web/e2e/assistant-continuation.spec.ts
@@ -42,7 +42,7 @@ const source = (
},
continuationScope: scope,
});
-const record = (revision: number) => ({
+const record = (revision: number, text = "Original saved task") => ({
schemaVersion: 1,
reconstructed: true,
revision,
@@ -66,7 +66,7 @@ const record = (revision: number) => ({
{
id: "part1",
type: "text",
- text: "Original saved task",
+ text,
truncated: false,
},
],
@@ -139,6 +139,7 @@ async function setup(page: Page) {
hold: false,
fail: false,
missingRecord: false,
+ recordText: "Original saved task",
missingNative: false,
prompts: [] as Route[],
messages: [] as any[],
@@ -222,7 +223,7 @@ async function setup(page: Page) {
route.fulfill(
probe.missingRecord
? { status: 404, json: {} }
- : { json: { record: record(probe.revision) } },
+ : { json: { record: record(probe.revision, probe.recordText) } },
),
);
await page.route(
@@ -238,7 +239,7 @@ async function setup(page: Page) {
json: { error: openCodeTransportFailure("native_history_missing") },
});
const attachment = response(
- probe.requests[0]?.request().postDataJSON() ?? {
+ probe.requests.at(-1)?.request().postDataJSON() ?? {
operationId: "22222222-2222-4222-8222-222222222222",
expectedRecordRevision: 1,
},
@@ -911,3 +912,148 @@ for (const field of ["project", "user", "harness", "source-rebound"]) {
},
);
}
+
+const reviewLatest = (page: Page) =>
+ page.getByRole("button", { name: "Review latest record", exact: true });
+const startNew = (page: Page) =>
+ page.getByRole("button", { name: "Start a new continuation", exact: true });
+async function rejectedContinuation(page: Page) {
+ const probe = await setup(page);
+ probe.hold = true;
+ await review(page);
+ await button(page).click();
+ await expect.poll(() => probe.requests.length).toBe(1);
+ const original = probe.requests[0]!.request().postDataJSON();
+ await probe.requests[0]!.fulfill({
+ status: 409,
+ json: { failure: openCodeTransportFailure("lifecycle_changed") },
+ });
+ await expect(reviewLatest(page)).toBeEnabled();
+ return Object.assign(probe, { original });
+}
+
+test("a definite Continue conflict requires refreshed displayed history and an explicit new operation", async ({
+ page,
+}, info) => {
+ const probe = await rejectedContinuation(page);
+ await expect(startNew(page)).toHaveCount(0);
+ await page.screenshot({
+ path: info.outputPath("continue-conflict-before-review.png"),
+ });
+ probe.revision = 2;
+ probe.recordText = "Latest recorded task after the source changed";
+ await project(page, 2, "account-a", [
+ { ...lifecycle(sourceId, 3), lifecycle: "ended" },
+ ]);
+ await expect(reviewLatest(page)).toBeEnabled();
+ await reviewLatest(page).click();
+ await expect(page.getByTestId("assistant-transcript")).toContainText(
+ probe.recordText,
+ );
+ await expect(startNew(page)).toBeEnabled();
+ expect(probe.requests).toHaveLength(1);
+ await page.screenshot({
+ path: info.outputPath("continue-conflict-reviewed.png"),
+ });
+ await startNew(page).click();
+ await expect.poll(() => probe.requests.length).toBe(2);
+ const current = probe.requests[1]!.request().postDataJSON();
+ expect(current).toMatchObject({
+ expectedRevision: 3,
+ expectedRecordRevision: 2,
+ });
+ expect(current.operationId).not.toBe(probe.original.operationId);
+ await probe.requests[1]!.fulfill({ json: probe.response(current) });
+ await expect(page.getByText("Paused", { exact: true })).toBeVisible();
+ expect(
+ probe.calls.filter((url) => /prompt_async|final-response/.test(url)),
+ ).toEqual([]);
+});
+
+for (const mode of ["unknown409", "503", "unconfirmed409"] as const) {
+ test(`Continue keeps the original request after ${mode} without offering replacement`, async ({
+ page,
+ }) => {
+ const probe = await setup(page);
+ probe.hold = true;
+ await review(page);
+ await button(page).click();
+ await expect.poll(() => probe.requests.length).toBe(1);
+ const original = probe.requests[0]!.request().postDataJSON();
+ await probe.requests[0]!.fulfill({
+ status: mode === "503" ? 503 : 409,
+ json: {
+ failure:
+ mode === "unknown409"
+ ? { code: "lifecycle_changed" }
+ : openCodeTransportFailure(
+ mode === "503"
+ ? "lifecycle_changed"
+ : "continuation_unconfirmed",
+ ),
+ },
+ });
+ await expect(button(page)).toHaveText("Retry Continue");
+ await expect(reviewLatest(page)).toHaveCount(0);
+ await expect(startNew(page)).toHaveCount(0);
+ await button(page).click();
+ await expect.poll(() => probe.requests.length).toBe(2);
+ expect(probe.requests[1]!.request().postDataJSON()).toEqual(original);
+ await probe.requests[1]!.fulfill({ status: 503, json: {} });
+ });
+}
+
+test("new Continue cannot replace a receipt changed by another tab", async ({
+ page,
+}) => {
+ const probe = await rejectedContinuation(page);
+ await reviewLatest(page).click();
+ await expect(startNew(page)).toBeEnabled();
+ await page.evaluate(() => {
+ const key = Object.keys(localStorage).find((key) =>
+ key.startsWith("studio.assistant-continue."),
+ )!;
+ localStorage.setItem(
+ key,
+ JSON.stringify({
+ ...JSON.parse(localStorage.getItem(key)!),
+ operationId: crypto.randomUUID(),
+ }),
+ );
+ });
+ await startNew(page).click();
+ await expect(page.getByRole("alert")).toContainText(
+ "saved continuation request changed",
+ );
+ expect(probe.requests).toHaveLength(1);
+});
+
+test("a missing fresh record cannot authorize a new continuation", async ({
+ page,
+}) => {
+ const probe = await rejectedContinuation(page);
+ probe.missingRecord = true;
+ await reviewLatest(page).click();
+ await expect(
+ page.getByText("No Assistant conversation was recorded for this session."),
+ ).toBeVisible();
+ await expect(startNew(page)).toHaveCount(0);
+ expect(probe.requests).toHaveLength(1);
+ probe.missingRecord = false;
+ await reviewLatest(page).click();
+ await expect(startNew(page)).toBeEnabled();
+});
+
+test("reload after a definite Continue conflict still retries the original request first", async ({
+ page,
+}) => {
+ const probe = await rejectedContinuation(page);
+ await page.reload();
+ await project(page);
+ await review(page);
+ await expect(reviewLatest(page)).toHaveCount(0);
+ await button(page).click();
+ await expect.poll(() => probe.requests.length).toBe(2);
+ expect(probe.requests[1]!.request().postDataJSON()).toEqual(probe.original);
+ await probe.requests[1]!.fulfill({ status: 503, json: {} });
+});
diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx
index b55500fac..6d0182071 100644
--- a/packages/harness/web/src/App.tsx
+++ b/packages/harness/web/src/App.tsx
@@ -3403,7 +3403,7 @@ export const App = (): JSX.Element => {
entry={activeAssistantReview.entry}
bootToken={harness.bootToken}
onClose={() => setAssistantReview(null)}
- actions={(record) => <> <> row.harnessSessionId === activeAssistantReview.entry.harnessSessionId)}
bootToken={harness.bootToken}
@@ -3418,7 +3418,8 @@ export const App = (): JSX.Element => {
}}
/> row.harnessSessionId === activeAssistantReview.entry.harnessSessionId && row.revision >= activeAssistantReview.entry.lifecycle.revision) ?? activeAssistantReview.entry.lifecycle }}
- recordRevision={record?.turns.length ? record.revision : null}
+ record={record}
+ onRefreshRecord={refreshRecord}
bootToken={harness.bootToken}
onContinue={async (entry, request, signal) => {
const review = activeAssistantReview;
diff --git a/packages/harness/web/src/components/AssistantContinueAction.tsx b/packages/harness/web/src/components/AssistantContinueAction.tsx
index 6618f2e1d..ee6dd4a76 100644
--- a/packages/harness/web/src/components/AssistantContinueAction.tsx
+++ b/packages/harness/web/src/components/AssistantContinueAction.tsx
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from "react";
import type { AssistantHistoryEntry } from "../../../src/shared/assistant-history";
+import type { ReadableAssistantRecord } from "../lib/assistant-history-client";
+import { isAssistantLifecycleConflict } from "../lib/assistant-resume-client";
import {
completeContinueRequest,
prepareContinueRequest,
@@ -9,12 +11,14 @@ import {
export function AssistantContinueAction({
entry,
- recordRevision,
+ record,
+ onRefreshRecord,
bootToken,
onContinue,
}: {
entry: AssistantHistoryEntry;
- recordRevision: number | null;
+ record: ReadableAssistantRecord | null;
+ onRefreshRecord: () => void;
bootToken: string;
onContinue: (
entry: AssistantHistoryEntry,
@@ -22,6 +26,7 @@ export function AssistantContinueAction({
signal: AbortSignal,
) => Promise;
}) {
+ const recordRevision = record?.turns.length ? record.revision : null;
const [saved, setSaved] = useState(() => {
try {
return savedContinueRequest(entry);
@@ -31,6 +36,11 @@ export function AssistantContinueAction({
});
const [failure, setFailure] = useState(null);
const [busy, setBusy] = useState(false);
+ const [rejected, setRejected] = useState<{
+ request: ContinueRequest;
+ record: ReadableAssistantRecord | null;
+ refreshRequested: boolean;
+ } | null>(null);
const pending = useRef(null);
useEffect(() => {
setBusy(false);
@@ -50,14 +60,27 @@ export function AssistantContinueAction({
bootToken,
entry.continuationScope,
]);
- const run = async () => {
+ useEffect(
+ () => setRejected(null),
+ [entry.harnessSessionId, bootToken, entry.continuationScope],
+ );
+ const reviewed =
+ rejected?.refreshRequested &&
+ record &&
+ record !== rejected.record &&
+ recordRevision !== null;
+ const run = async (startNew = false) => {
if (pending.current) return;
+ if (startNew && !reviewed) return;
+ const replace = startNew ? rejected?.request : undefined;
const abort = new AbortController();
pending.current = abort;
setBusy(true);
setFailure(null);
+ setRejected(null);
+ let request: ContinueRequest | null = null;
try {
- const request = await prepareContinueRequest(entry, recordRevision);
+ request = await prepareContinueRequest(entry, recordRevision, replace);
if (abort.signal.aborted) return;
setSaved(request);
const opened = await onContinue(entry, request, abort.signal);
@@ -68,12 +91,19 @@ export function AssistantContinueAction({
"Your selection or session changed. Return here to retry the saved continuation.",
);
} catch (error) {
- if (!abort.signal.aborted)
+ if (!abort.signal.aborted) {
+ if (request && isAssistantLifecycleConflict(error))
+ setRejected({
+ request,
+ record: record ?? null,
+ refreshRequested: false,
+ });
setFailure(
error instanceof Error
? error.message
: "Continuation is unavailable. Your original history is still available.",
);
+ }
} finally {
if (pending.current === abort) {
pending.current = null;
@@ -108,6 +138,31 @@ export function AssistantContinueAction({
? "No readable Assistant record is available to carry forward."
: "Creates a new conversation from a recorded brief. The brief may be incomplete; the new Assistant stays paused until you send a message."}
+ {rejected && onRefreshRecord && (
+ <>
+
+
+ The previous continuation was rejected because the session changed.
+ Review the latest saved record before starting a new continuation.
+
+ >
+ )}
{failure && (
{failure}
diff --git a/packages/harness/web/src/components/AssistantHistoryPane.tsx b/packages/harness/web/src/components/AssistantHistoryPane.tsx
index 4c8216782..03d34a97b 100644
--- a/packages/harness/web/src/components/AssistantHistoryPane.tsx
+++ b/packages/harness/web/src/components/AssistantHistoryPane.tsx
@@ -34,7 +34,10 @@ export function AssistantHistoryPane({
entry: AssistantHistoryEntry;
bootToken: string;
onClose: () => void;
- actions?: (record: ReadableAssistantRecord | null) => ReactNode;
+ actions?: (
+ record: ReadableAssistantRecord | null,
+ refresh: () => void,
+ ) => ReactNode;
onOpenTerminal?: () => void;
terminalLabel?: string;
terminalNotStarted?: boolean;
@@ -89,7 +92,7 @@ export function AssistantHistoryPane({
)}
- {actions?.(state.record)}
+ {actions?.(state.record, () => setAttempt((value) => value + 1))}
{state.loading ? (
Loading Assistant history…
diff --git a/packages/harness/web/src/lib/assistant-continuation-client.ts b/packages/harness/web/src/lib/assistant-continuation-client.ts
index d7cf11ab8..c378027b4 100644
--- a/packages/harness/web/src/lib/assistant-continuation-client.ts
+++ b/packages/harness/web/src/lib/assistant-continuation-client.ts
@@ -42,9 +42,20 @@ export function savedContinueRequest(
function prepare(
entry: AssistantHistoryEntry,
recordRevision: number | null,
+ rejected?: ContinueRequest,
): ContinueRequest {
const saved = savedContinueRequest(entry);
- if (saved) return saved;
+ if (
+ rejected &&
+ (!saved ||
+ saved.operationId !== rejected.operationId ||
+ saved.expectedRevision !== rejected.expectedRevision ||
+ saved.expectedRecordRevision !== rejected.expectedRecordRevision)
+ )
+ throw new Error(
+ "The saved continuation request changed. Retry its current request before starting another.",
+ );
+ if (saved && !rejected) return saved;
if (recordRevision === null)
throw new Error("No readable Assistant record is available to continue.");
const request = requestSchema.parse({
@@ -65,10 +76,12 @@ function prepare(
export async function prepareContinueRequest(
entry: AssistantHistoryEntry,
recordRevision: number | null,
+ /** Only an explicit new-operation action after a confirmed lifecycle conflict. */
+ rejected?: ContinueRequest,
): Promise {
if (!navigator.locks) throw storageError();
return navigator.locks.request(key(entry), () =>
- prepare(entry, recordRevision),
+ prepare(entry, recordRevision, rejected),
);
}
export function completeContinueRequest(