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

Check the selected workspace before Assistant history actions start, and preserve saved operation retries when a workspace selection changes.
8 changes: 8 additions & 0 deletions packages/harness/docs/assistant-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,11 @@ canonical record path. History, inspection, Resume and mixed Terminal records
keep both identities, and reject a changed or retargeted path. Refreshes through
aliases share canonical publication ordering, so an empty response clears stale
rows without hiding failures in a different workspace.

Inspection, Resume and Continue send the selected workspace pair as an optional
transport precondition, checked before native work. A `workspace_changed` 409
requires closing the record and selecting it again after Past sessions refreshes;
it does not prove that an earlier unacknowledged operation was never prepared.
Legacy requests remain accepted. The original saved operation UUID/revisions and
receipt fingerprints are unchanged. Explicit re-selection can refresh Resume's
launch spelling only with the same verified canonical binding and retry scope.
91 changes: 91 additions & 0 deletions packages/harness/src/server/assistant-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,97 @@ it("returns the current raw/canonical proof on same-ID alias Resume", async () =
});
});

it.each(["inspect", "resume", "continue"])(
"rejects a stale selected workspace before %s can start",
async (action) => {
const current = { cwd: "/alias-b", canonicalCwd: savedEntry.cwd };
session.cwd = current.cwd;
entry.mockResolvedValue({ ...savedEntry, workspace: current });
for (const expectedWorkspace of [
{ ...current, cwd: "/alias-a" },
{ ...current, canonicalCwd: "/previous-target" },
]) {
const response = await post(action, {
expectedRevision: 4,
expectedWorkspace,
...(action !== "inspect" ? { operationId: op } : {}),
...(action === "continue" ? { expectedRecordRevision: 7 } : {}),
});
expect(response.status).toBe(409);
expect((await response.json()).error.code).toBe("workspace_changed");
}
expect(inspect).not.toHaveBeenCalled();
expect(resume).not.toHaveBeenCalled();
expect(continuation).not.toHaveBeenCalled();
},
);

it("rejects a raw session rebind between history verification and action admission", async () => {
const expectedWorkspace = { cwd: session.cwd, canonicalCwd: savedEntry.cwd };
entry.mockImplementationOnce(async () => {
session.cwd = "/rebound";
return { ...savedEntry, workspace: expectedWorkspace };
});
expect(
(await post("inspect", { expectedRevision: 4, expectedWorkspace })).status,
).toBe(409);
expect(inspect).not.toHaveBeenCalled();
});

it.each([
null,
{},
{ cwd: 1, canonicalCwd: "/workspace" },
{ cwd: "/workspace", canonicalCwd: "relative" },
{ cwd: "/workspace", canonicalCwd: "/workspace", extra: true },
])(
"rejects malformed selected workspace %j before action admission",
async (expectedWorkspace) => {
expect(
(await post("inspect", { expectedRevision: 4, expectedWorkspace }))
.status,
).toBe(400);
expect(entry).not.toHaveBeenCalled();
expect(inspect).not.toHaveBeenCalled();
},
);

it("keeps prepared Continue retries on the original durable tuple, with or without a workspace precondition", async () => {
const request = {
expectedRevision: 4,
operationId: op,
expectedRecordRevision: 7,
};
const expectedWorkspace = { cwd: "/alias-a", canonicalCwd: savedEntry.cwd };
session.cwd = expectedWorkspace.cwd;
const current = {
...savedEntry,
workspace: expectedWorkspace,
lifecycle: { ...lifecycle, revision: 9 },
};
entry.mockResolvedValue(current);
const first = await post("continue", { ...request, expectedWorkspace });
expect(first.status).toBe(200);
// The first ACK may have been lost: stale intent blocks only this retry,
// never claims the durable prepared child does not exist or erases its tuple.
entry.mockResolvedValue({
...current,
workspace: { ...expectedWorkspace, cwd: "/alias-b" },
});
session.cwd = "/alias-b";
expect(
(await post("continue", { ...request, expectedWorkspace })).status,
).toBe(409);
expect(continuation).toHaveBeenCalledTimes(1);
entry.mockResolvedValue(current);
session.cwd = expectedWorkspace.cwd;
for (const body of [request, { ...request, expectedWorkspace }])
expect((await post("continue", body)).status).toBe(200);
expect(continuation.mock.calls).toEqual(
Array.from({ length: 3 }, () => ["studio-a", request]),
);
});

it.each(["inspect", "resume"])(
"rejects %s when a raw alias rebind leaves the canonical binding unchanged",
async (action) => {
Expand Down
36 changes: 31 additions & 5 deletions packages/harness/src/server/assistant-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,16 @@ import { openCodeTransportFailure } from "../shared/opencode-errors.js";
import { createBootTokenMiddleware } from "./auth.js";
import {
assistantHistoryMatches,
assistantHistoryWorkspaceSchema,
assistantWorkspace,
sameAssistantWorkspace,
} from "../shared/assistant-history.js";

const inspectionRequest = z
.object({ expectedRevision: z.number().int().nonnegative().safe() })
.object({
expectedRevision: z.number().int().nonnegative().safe(),
expectedWorkspace: assistantHistoryWorkspaceSchema.optional(),
})
.strict();
const resumeRequest = inspectionRequest
.extend({ operationId: z.string().uuid() })
Expand Down Expand Up @@ -79,6 +84,7 @@ function failure(res: Response, error: unknown) {
? 403
: [
"lifecycle_changed",
"workspace_changed",
"session_ended",
"cleanup_unconfirmed",
"continuation_unconfirmed",
Expand Down Expand Up @@ -139,6 +145,23 @@ export function createAssistantLifecycleRouter(options: {
res.sendStatus(404);
return;
}
// Compare selected intent before any native work. This transport
// precondition is deliberately not part of a durable operation tuple.
const expectedWorkspace = parsed.data.expectedWorkspace;
if (expectedWorkspace) {
const current = assistantWorkspace(entry);
const session = options.getSession(id);
if (
expectedWorkspace.cwd !== current.cwd ||
expectedWorkspace.canonicalCwd !== entry.cwd ||
!session ||
session.id !== id ||
session.cwd !== expectedWorkspace.cwd
)
throw new OpenCodeTransportError(
openCodeTransportFailure("workspace_changed"),
);
}
if (action === "inspect") {
if (entry.lifecycle.revision !== parsed.data.expectedRevision)
throw changed();
Expand Down Expand Up @@ -192,10 +215,13 @@ export function createAssistantLifecycleRouter(options: {
throw new OpenCodeTransportError(
openCodeTransportFailure("context_unavailable"),
);
const prepared = await options.continue(
id,
continueRequest.parse(parsed.data),
);
const { expectedRevision, operationId, expectedRecordRevision } =
continueRequest.parse(parsed.data);
const prepared = await options.continue(id, {
expectedRevision,
operationId,
expectedRecordRevision,
});
res.json({
session: sessionView(prepared.session),
attachment: prepared.attachment,
Expand Down
8 changes: 8 additions & 0 deletions packages/harness/src/shared/opencode-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const openCodeTransportErrorCodes = [
"context_unavailable",
"transport_unavailable",
"lifecycle_changed",
"workspace_changed",
"session_ended",
"execution_paused",
"cleanup_unconfirmed",
Expand Down Expand Up @@ -50,6 +51,13 @@ export interface OpenCodeStudioErrorEvent {
}

const fixedFailures = {
workspace_changed: {
code: "workspace_changed",
message:
"This session's workspace changed. Close this record, reopen Past sessions, and select it again after the list refreshes.",
retryable: true,
action: "reconnect",
},
continuation_unconfirmed: {
code: "continuation_unconfirmed",
message:
Expand Down
47 changes: 46 additions & 1 deletion packages/harness/web/e2e/assistant-continuation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ test("Continue opens a distinct paused child from the displayed record and prese
expect(body).toEqual({
expectedRevision: 2,
expectedRecordRevision: 1,
expectedWorkspace: { cwd, canonicalCwd: cwd },
operationId: expect.stringMatching(/^[a-f0-9-]{36}$/),
});
await project(page, 2, "account-a", [lifecycle(childId)]);
Expand Down Expand Up @@ -469,7 +470,51 @@ test("lost Continue response survives refresh, host restart and a newer record w
),
);
expect(persisted).toHaveLength(1);
expect(JSON.parse(persisted[0]![1])).toEqual(request);
const { expectedWorkspace, ...tuple } = request;
expect(expectedWorkspace).toEqual({ cwd, canonicalCwd: cwd });
expect(JSON.parse(persisted[0]![1])).toEqual(tuple);
});

test("workspace conflict preserves uncertain Continue without offering a new operation", async ({
page,
}, info) => {
const probe = await setup(page);
probe.fail = true;
await review(page);
await button(page).click();
await expect(page.getByRole("alert")).toContainText("Retry this operation");
const original = probe.requests[0]!.request().postDataJSON();
probe.fail = false;
probe.hold = true;
await button(page).click();
await expect.poll(() => probe.requests.length).toBe(2);
await probe.requests[1]!.fulfill({
status: 409,
json: { error: openCodeTransportFailure("workspace_changed") },
});
await expect(page.getByRole("alert")).toContainText(
"select it again after the list refreshes",
);
await expect(button(page)).toHaveText("Retry Continue");
await expect(
page.getByRole("button", { name: "Review latest record", exact: true }),
).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Start a new continuation", exact: true }),
).toHaveCount(0);
expect(probe.requests[1]!.request().postDataJSON()).toEqual(original);
const persisted = await page.evaluate(() =>
Object.entries(localStorage).filter(([key]) =>
key.startsWith("studio.assistant-continue."),
),
);
const { expectedWorkspace, ...tuple } = original;
expect(expectedWorkspace).toEqual({ cwd, canonicalCwd: cwd });
expect(persisted).toHaveLength(1);
expect(JSON.parse(persisted[0]![1])).toEqual(tuple);
await page.screenshot({
path: info.outputPath("continue-workspace-conflict.png"),
});
});

for (const invalid of ["source", "record", "hash", "child", "lease"]) {
Expand Down
110 changes: 109 additions & 1 deletion packages/harness/web/e2e/assistant-history.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,12 @@ async function setup(page: Page, mode = "valid") {
mode === "alias" ? { cwd, canonicalCwd: `/private${cwd}` } : undefined;
const savedEntry = (id: string) =>
workspace
? { ...entry(id), cwd: workspace.canonicalCwd, workspace }
? {
...entry(id),
cwd: workspace.canonicalCwd,
workspace,
continuationScope: "a".repeat(64),
}
: entry(id);
const streams = new Set<ServerResponse>();
const events = createServer((req, res) => {
Expand Down Expand Up @@ -1174,3 +1179,106 @@ test("a slow sibling alias cannot replace a newer canonical history bucket", asy
page.getByTestId("assistant-history-ordered-alias"),
).toContainText("Fresh alias history");
});

test("workspace conflict retains lost-ACK Resume and refreshes only its proof after explicit re-selection", async ({
page,
}, info) => {
const probe = await setup(page, "alias");
probe.holdResume = true;
await review(page);
await checkResume(page).click();
await resumeButton(page).click();
await expect.poll(() => probe.resumes.length).toBe(1);
const original = probe.resumes[0]!.request().postDataJSON();
await publish(page, {
type: "assistant.state",
snapshot: {
hostInstanceId: "history-host",
authorityRevision: "account-a",
revision: 2,
enabled: true,
sessions: [],
lifecycles: [probe.resumed("assistant-only").attachment.lifecycle],
},
});
await probe.resumes[0]!.fulfill({ status: 503, json: {} });
await expect(page.getByRole("alert")).toContainText(
"Retry to check the same operation",
);
const workspace = {
cwd: "/Users/demo/refreshed-alias",
canonicalCwd: `/private${cwd}`,
};
const retries: Route[] = [];
probe.resumedRevision = 4;
await page.route("**/api/sessions/*/assistant/resume", (route) => {
retries.push(route);
if (route.request().postDataJSON().expectedWorkspace.cwd !== workspace.cwd)
return route.fulfill({
status: 409,
json: { error: openCodeTransportFailure("workspace_changed") },
});
const result = probe.resumed("assistant-only");
return route.fulfill({
json: {
...result,
workspace,
session: { ...result.session, cwd: workspace.cwd },
},
});
});
await resumeButton(page).click();
await expect(page.getByRole("alert")).toContainText(
"select it again after the list refreshes",
);
await expect(
page.getByRole("button", { name: "Retry Resume", exact: true }),
).toBeVisible();
expect(retries[0]!.request().postDataJSON()).toEqual(original);
await page.screenshot({
path: info.outputPath("workspace-selection-conflict.png"),
});
await page
.getByTestId("assistant-history-pane")
.getByRole("button", { name: "Close", exact: true })
.click();
await page.route("**/api/sessions/assistant-history?**", (route) => {
const query = new URL(route.request().url()).searchParams.get("cwd")!;
return route.fulfill({
json:
query === cwd
? {
workspace: { cwd, canonicalCwd: workspace.canonicalCwd },
entries: [
{
...entry("assistant-only"),
cwd: workspace.canonicalCwd,
workspace,
continuationScope: "a".repeat(64),
lifecycle: {
...entry("assistant-only").lifecycle,
lifecycle: "open",
revision: 3,
},
},
],
}
: { entries: [] },
});
});
await rows(page);
await expect(page.getByText("Loading…", { exact: true })).toHaveCount(0);
await page.getByTestId("assistant-history-assistant-only").click();
await resumeButton(page).click();
await expect(page.getByText("Paused", { exact: true })).toBeVisible();
expect(retries[1]!.request().postDataJSON()).toEqual({
...original,
expectedWorkspace: workspace,
});
await page.screenshot({
path: info.outputPath("workspace-selection-retry.png"),
});
expect(
probe.calls.filter((url) => /prompt_async|final-response/.test(url)),
).toEqual([]);
});
Loading
Loading