Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5cce742
Restore task_workspace_lifecycle trimmed to archive/unarchive
ThomasK33 Aug 24, 2026
788c027
Address Codex review: lifecycle race, nested turns, preflight, delete…
ThomasK33 Aug 24, 2026
81ef051
Address Codex round 2: owner-lock nesting, live activity, blocker pre…
ThomasK33 Aug 24, 2026
daa3e8d
Review round 3: exact acknowledged-path equality before interruption;…
ThomasK33 Aug 24, 2026
96ed7d8
Review round 4: fix lifecycle/tree/mutex lock cycle, refuse mutation-…
ThomasK33 Aug 24, 2026
6d5c00b
Review round 5: block Coder-delete policy, coordinate terminal/deskto…
ThomasK33 Aug 24, 2026
46a8409
Review round 6: pair terminal startup with archive admission, refuse …
ThomasK33 Aug 24, 2026
5efb381
Review round 7: pin Coder archive policy through the sink, guard desk…
ThomasK33 Aug 24, 2026
3f8d8e9
Review round 8: pair workflow admission with the archive gate, scope …
ThomasK33 Aug 24, 2026
aacd8c3
Review round 9: acquire archive admission in retryRunFromCheckpointIn…
ThomasK33 Aug 24, 2026
ff3e4af
Review round 10: gate snapshot archives on untrackable native termina…
ThomasK33 Aug 24, 2026
431f3de
Review round 11: apply the archive guard to native terminal opens
ThomasK33 Aug 24, 2026
e1b96bc
Review round 12: persist the native-terminal marker across app restarts
ThomasK33 Aug 24, 2026
e5e66be
Review round 13: gate snapshot archives on untrackable external edito…
ThomasK33 Aug 24, 2026
2e9dce9
Review round 14: detect crash-orphaned background processes; make mar…
ThomasK33 Aug 24, 2026
62db201
Review round 15: fail-closed external-app admissions, stale exit_code…
ThomasK33 Aug 24, 2026
8aee92d
Review round 16: fail closed on unreadable spawn records; roll back r…
ThomasK33 Aug 24, 2026
d79137a
Review round 17: restart-unique spawn dirs, fail-closed activity scan…
ThomasK33 Aug 24, 2026
a995722
Review round 19: sync spawn-ID reservation; staging + completions arc…
ThomasK33 Aug 24, 2026
961673c
Review round 20: fail closed on untracked migrated (pid-0) spawn records
ThomasK33 Aug 24, 2026
196f2d1
Review round 21: reject unknown editor-open IDs, record opens at laun…
ThomasK33 Aug 24, 2026
85118cf
Review round 23: roll back untrackable-app markers on failed launches…
ThomasK33 Aug 24, 2026
22505ec
Review round 24: await background-init settlement in archive, batch-s…
ThomasK33 Aug 24, 2026
a7040d7
Review round 25: register TaskService inits for archive gating, batch…
ThomasK33 Aug 24, 2026
fbfd6a0
Review round 26: count in-flight opens in untrackable-app probes
ThomasK33 Aug 24, 2026
38ba99c
Review round 27: marker-artifact cleanup, client idempotency tokens, …
ThomasK33 Aug 24, 2026
f9f42bf
Review round 28: non-host spawn-record hardening, editor rollback tom…
ThomasK33 Aug 24, 2026
df05891
Review round 29: harden pre-interruption hold, fork archive admission…
ThomasK33 Aug 25, 2026
d763e6c
Review round 30: probe remote spawn records before model-driven Coder…
ThomasK33 Aug 25, 2026
ed25881
Review round 31: pair staged-attachment downloads with archive admission
ThomasK33 Aug 25, 2026
d9c73b7
Review round 32: fail closed on failed bash backgrounding; probe hidd…
ThomasK33 Aug 25, 2026
fe9ecc3
Review round 33: refuse nested-turn interrupt_active cascades; bind a…
ThomasK33 Aug 25, 2026
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
4 changes: 4 additions & 0 deletions docs/agents/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,8 @@ tools:
- task_apply_git_patch
# Plan should not perform destructive workspace cleanup.
- task_remove
# Plan should not mutate owned workspace lifecycle state.
- task_workspace_lifecycle
# Global config and catalog tools stay out of general-purpose agents
- mux_agents_.*
- agent_skill_write
Expand Down Expand Up @@ -523,6 +525,7 @@ tools:
- task_retitle
- task_stop
- task_apply_git_patch
- task_workspace_lifecycle
# No planning tools
- propose_plan
- ask_user_question
Expand Down Expand Up @@ -629,6 +632,7 @@ tools:
- task_retitle
- task_stop
- task_remove
- task_workspace_lifecycle
---

You are in Explore mode (read-only).
Expand Down
15 changes: 15 additions & 0 deletions docs/hooks/tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,21 @@ If a value is too large for the environment, it may be omitted (not set). Xum al

</details>

<details>
<summary>task_workspace_lifecycle (7)</summary>

| Env var | JSON path | Type | Description |
| ----------------------------------------------------------- | ---------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `XUM_TOOL_INPUT_ACKNOWLEDGED_UNTRACKED_PATHS_<KEY>_<INDEX>` | `acknowledged_untracked_paths[<KEY>][<INDEX>]` | string | Archive-only confirmations keyed by resolved workspaceId. Use only paths returned by a previous requires_confirmation result. |
| `XUM_TOOL_INPUT_ACKNOWLEDGED_UNTRACKED_PATHS_<KEY>_COUNT` | `acknowledged_untracked_paths[<KEY>].length` | number | Number of elements in acknowledged_untracked_paths[&lt;KEY&gt;] (Archive-only confirmations keyed by resolved workspaceId. Use only paths returned by a previous requires_confirmation result.) |
| `XUM_TOOL_INPUT_ACTION` | `action` | enum | Reversible lifecycle action: "archive" hides and suspends the workspace without deleting state, "unarchive" restores it. |
| `XUM_TOOL_INPUT_INTERRUPT_ACTIVE` | `interrupt_active` | boolean | Archive only: when true, interrupt active workspace turns for the target before archiving. Ignored by unarchive, which never interrupts. Defaults to false. |
| `XUM_TOOL_INPUT_TARGETS_<INDEX>_TASK_ID` | `targets[<INDEX>].taskId` | string | — |
| `XUM_TOOL_INPUT_TARGETS_<INDEX>_WORKSPACE_ID` | `targets[<INDEX>].workspaceId` | string | — |
| `XUM_TOOL_INPUT_TARGETS_COUNT` | `targets.length` | number | Number of elements in targets (Workspace-turn targets this workspace created via task(kind="workspace"). Provide exactly one of taskId (wst\_...) or workspaceId for each target.) |

</details>

<details>
<summary>timeline_event (2)</summary>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const FALLBACK_AGENTS: AgentDefinitionDescriptor[] = [
"task_retitle",
"task_stop",
"task_remove",
"task_workspace_lifecycle",
"task_apply_git_patch",
"propose_plan",
"ask_user_question",
Expand Down
281 changes: 276 additions & 5 deletions src/browser/utils/openInEditor.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, mock, test } from "bun:test";
import type { APIClient } from "@/browser/contexts/API";
import { openInEditor } from "./openInEditor";
import type { RuntimeConfig } from "@/common/types/runtime";
Expand Down Expand Up @@ -31,8 +31,11 @@ describe("openInEditor", () => {

type OpenCall = [url: string, target?: string];

// Electron-like window (`api` present via preload): deep links launch directly through
// window.open with no placeholder. Browser-mode behavior is covered separately below.
function createMockWindow(calls: OpenCall[]) {
return {
api: {},
localStorage: { getItem: () => null },
open: (url: string, target?: string) => {
calls.push([url, target]);
Expand All @@ -41,6 +44,44 @@ describe("openInEditor", () => {
};
}

// Browser-mode window (no `api`): window.open returns a placeholder that records
// navigations and close() calls, mirroring a real popup.
function createBrowserModeWindow(calls: OpenCall[], opts?: { popupBlocked?: boolean }) {
const placeholder = {
closed: false,
navigations: [] as string[],
location: {},
close(): void {
this.closed = true;
},
};
Object.defineProperty(placeholder.location, "href", {
set(value: string) {
placeholder.navigations.push(value);
},
});
const windowValue = {
localStorage: { getItem: () => null },
location: { hostname: "localhost" },
open: (url: string, target?: string) => {
calls.push([url, target]);
return opts?.popupBlocked ? null : placeholder;
},
};
return { windowValue, placeholder };
}

// Editor opens must be recorded on the backend before any launch (archive safety), so
// every launch-path test needs an api stub whose recording succeeds.
function createApiStub(extra?: Record<string, unknown>): APIClient {
return {
general: {
recordEditorOpen: () => Promise.resolve({ success: true }),
},
...extra,
} as unknown as APIClient;
}

test("opens SSH file deep link (does not fall back to parent dir)", async () => {
const calls: OpenCall[] = [];

Expand All @@ -52,7 +93,7 @@ describe("openInEditor", () => {

const result = await withWindow(createMockWindow(calls), () =>
openInEditor({
api: null,
api: createApiStub(),
workspaceId,
targetPath: filePath,
runtimeConfig,
Expand All @@ -77,7 +118,7 @@ describe("openInEditor", () => {
configPath: ".devcontainer/devcontainer.json",
};

const api = {
const api = createApiStub({
workspace: {
getDevcontainerInfo: () =>
Promise.resolve({
Expand All @@ -86,7 +127,7 @@ describe("openInEditor", () => {
hostWorkspacePath: "/Users/me/projects/myapp",
}),
},
} as unknown as APIClient;
});

const result = await withWindow(createMockWindow(calls), () =>
openInEditor({
Expand Down Expand Up @@ -117,7 +158,7 @@ describe("openInEditor", () => {

const result = await withWindow(createMockWindow(calls), () =>
openInEditor({
api: null,
api: createApiStub(),
workspaceId,
targetPath: filePath,
runtimeConfig,
Expand All @@ -133,4 +174,234 @@ describe("openInEditor", () => {
expect(url.endsWith(filePath)).toBe(false);
expect(url.endsWith(`/${parentDir}`)).toBe(true);
});

test("does not record the open when a deterministic compatibility check refuses", async () => {
const calls: OpenCall[] = [];
const recordEditorOpen = mock(() => Promise.resolve({ success: true }));
const api = { general: { recordEditorOpen } } as unknown as APIClient;

// Zed + Docker is refused deterministically with no launch; recording first would leave
// a sticky durable marker permanently refusing snapshot archives of the workspace.
const windowWithZed = {
api: {},
localStorage: { getItem: () => JSON.stringify({ editor: "zed" }) },
open: (url: string, target?: string) => {
calls.push([url, target]);
return null;
},
};
const result = await withWindow(windowWithZed, () =>
openInEditor({
api,
workspaceId,
targetPath: filePath,
runtimeConfig: { type: "docker", image: "node:20", containerName: "mux-ws" },
isFile: true,
})
);

expect(result.success).toBe(false);
expect(recordEditorOpen).not.toHaveBeenCalled();
expect(calls.length).toBe(0);
});

test("refuses to launch while disconnected (open cannot be recorded)", async () => {
const calls: OpenCall[] = [];

// api is null while the UI reconnects, but backend agents keep running: an unrecorded
// launch could race a concurrent archive, so the open must fail closed.
const result = await withWindow(createMockWindow(calls), () =>
openInEditor({
api: null,
workspaceId,
targetPath: filePath,
runtimeConfig: { type: "ssh", host: "devbox", srcBaseDir: "~/xum" },
isFile: true,
})
);

expect(result.success).toBe(false);
expect(calls.length).toBe(0);
});

test("refuses to launch when recording the open fails", async () => {
const calls: OpenCall[] = [];

const recordEditorOpen = mock(() => Promise.reject(new Error("connection lost")));
const rollbackEditorOpen = mock(() => Promise.resolve({ success: true }));
const api = {
general: { recordEditorOpen, rollbackEditorOpen },
} as unknown as APIClient;

const result = await withWindow(createMockWindow(calls), () =>
openInEditor({
api,
workspaceId,
targetPath: filePath,
runtimeConfig: { type: "ssh", host: "devbox", srcBaseDir: "~/xum" },
isFile: true,
})
);

expect(result.success).toBe(false);
expect(calls.length).toBe(0);
// An ambiguous RPC failure may have committed the reservation backend-side; the
// client-generated token enables best-effort reconciliation.
const recordCall = recordEditorOpen.mock.calls[0] as unknown as [
{ workspaceId: string; launchToken: string },
];
expect(rollbackEditorOpen).toHaveBeenCalledWith({
workspaceId,
launchToken: recordCall[0].launchToken,
});
});

test("browser mode: opens a placeholder synchronously and navigates it to the deep link", async () => {
const calls: OpenCall[] = [];
const { windowValue, placeholder } = createBrowserModeWindow(calls);
// Resolving this recording RPC yields the microtask queue, exactly the await that would
// outlast the click's transient user activation if window.open ran after it.
const recordEditorOpen = mock(() => Promise.resolve({ success: true }));
const api = { general: { recordEditorOpen } } as unknown as APIClient;

const result = await withWindow(windowValue, () =>
openInEditor({
api,
workspaceId,
targetPath: filePath,
runtimeConfig: { type: "ssh", host: "devbox", srcBaseDir: "~/xum" },
isFile: true,
})
);

expect(result.success).toBe(true);
// The only window.open call is the synchronous placeholder; the deep link reaches the
// already-open window via navigation, immune to popup blocking.
expect(calls).toEqual([["about:blank", "_blank"]]);
expect(placeholder.navigations.length).toBe(1);
expect(placeholder.navigations[0]).toContain("ssh-remote+devbox");
expect(placeholder.closed).toBe(false);
});

test("browser mode: closes the placeholder when the open is refused", async () => {
const calls: OpenCall[] = [];
const { windowValue, placeholder } = createBrowserModeWindow(calls);
const api = {
general: {
recordEditorOpen: () => Promise.resolve({ success: false, error: "being archived" }),
},
} as unknown as APIClient;

const result = await withWindow(windowValue, () =>
openInEditor({
api,
workspaceId,
targetPath: filePath,
runtimeConfig: { type: "ssh", host: "devbox", srcBaseDir: "~/xum" },
isFile: true,
})
);

expect(result.success).toBe(false);
// A refused open must not leave a stray blank tab behind.
expect(placeholder.navigations.length).toBe(0);
expect(placeholder.closed).toBe(true);
});

test("browser mode: rolls back the recorded open when the placeholder closes during recording", async () => {
const calls: OpenCall[] = [];
const { windowValue, placeholder } = createBrowserModeWindow(calls);
// Simulates the user closing the blank tab while the recording RPC is in flight: the
// navigation would target a dead WindowProxy, so the durable marker must be rolled back.
const recordEditorOpen = mock(() => {
placeholder.closed = true;
return Promise.resolve({ success: true });
});
const rollbackEditorOpen = mock(() => Promise.resolve({ success: true }));
const api = { general: { recordEditorOpen, rollbackEditorOpen } } as unknown as APIClient;

const result = await withWindow(windowValue, () =>
openInEditor({
api,
workspaceId,
targetPath: filePath,
runtimeConfig: { type: "ssh", host: "devbox", srcBaseDir: "~/xum" },
isFile: true,
})
);

expect(result.success).toBe(false);
expect(result.error).toContain("closed");
// The client-generated token given to recordEditorOpen is the one redeemed.
const recordCall = recordEditorOpen.mock.calls[0] as unknown as [
{ workspaceId: string; launchToken: string },
];
expect(rollbackEditorOpen).toHaveBeenCalledWith({
workspaceId,
launchToken: recordCall[0].launchToken,
});
expect(placeholder.navigations.length).toBe(0);
});

test("browser mode: refuses without recording when the placeholder closed before admission", async () => {
const calls: OpenCall[] = [];
const { windowValue, placeholder } = createBrowserModeWindow(calls);
const recordEditorOpen = mock(() => Promise.resolve({ success: true }));
// The devcontainer-info await runs before admission; the user closes the tab during it.
const api = {
general: { recordEditorOpen },
workspace: {
getDevcontainerInfo: () => {
placeholder.closed = true;
return Promise.resolve({
containerName: "jovial_newton",
containerWorkspacePath: "/workspaces/myapp",
hostWorkspacePath: "/Users/me/projects/myapp",
});
},
},
} as unknown as APIClient;

const result = await withWindow(windowValue, () =>
openInEditor({
api,
workspaceId,
targetPath: "/Users/me/projects/myapp/src/app.ts",
runtimeConfig: { type: "devcontainer", configPath: ".devcontainer/devcontainer.json" },
isFile: true,
})
);

// Closed before admission: refused with no marker recorded, so nothing needs rollback.
expect(result.success).toBe(false);
expect(result.error).toContain("closed");
expect(recordEditorOpen).not.toHaveBeenCalled();
expect(placeholder.navigations.length).toBe(0);
});

test("browser mode: refuses before recording when the placeholder is popup-blocked", async () => {
const calls: OpenCall[] = [];
const { windowValue, placeholder } = createBrowserModeWindow(calls, { popupBlocked: true });
const recordEditorOpen = mock(() => Promise.resolve({ success: true }));
const api = { general: { recordEditorOpen } } as unknown as APIClient;

const result = await withWindow(windowValue, () =>
openInEditor({
api,
workspaceId,
targetPath: filePath,
runtimeConfig: { type: "ssh", host: "devbox", srcBaseDir: "~/xum" },
isFile: true,
})
);

// A blocked placeholder means the post-await launch would be silently blocked too;
// succeeding would persist a sticky editor-open marker for an editor that never opened,
// permanently refusing model-driven archives — so the open is refused before recording.
expect(result.success).toBe(false);
expect(result.error).toContain("popup");
expect(recordEditorOpen).not.toHaveBeenCalled();
expect(calls).toEqual([["about:blank", "_blank"]]);
expect(placeholder.navigations.length).toBe(0);
});
});
Loading
Loading