diff --git a/.claude/agents/codebase-analyzer.md b/.claude/agents/codebase-analyzer.md index 3ca46c2ea..413334778 100644 --- a/.claude/agents/codebase-analyzer.md +++ b/.claude/agents/codebase-analyzer.md @@ -12,6 +12,13 @@ mcpServers: type: stdio command: uvx args: ["--from", "git+https://github.com/ast-grep/ast-grep-mcp", "ast-grep-server"] + env: + # Neutralize host git config so uvx's `git clone` is hermetic — no + # insteadOf rewrites, GPG signing, or hooks bleed in. /dev/null works + # on POSIX directly and on Windows via Git-for-Windows MSYS path + # translation; on native Windows git the path is missing → empty config. + GIT_CONFIG_GLOBAL: /dev/null + GIT_CONFIG_SYSTEM: /dev/null --- You are a specialist at understanding HOW code works. Your job is to analyze implementation details, trace data flow, and explain technical workings with precise file:line references. diff --git a/.claude/agents/codebase-locator.md b/.claude/agents/codebase-locator.md index e0b7daea4..51f72ea2a 100644 --- a/.claude/agents/codebase-locator.md +++ b/.claude/agents/codebase-locator.md @@ -12,6 +12,9 @@ mcpServers: type: stdio command: uvx args: ["--from", "git+https://github.com/ast-grep/ast-grep-mcp", "ast-grep-server"] + env: + GIT_CONFIG_GLOBAL: /dev/null + GIT_CONFIG_SYSTEM: /dev/null --- You are a specialist at finding WHERE code lives in a codebase. Your job is to locate relevant files and organize them by purpose, NOT to analyze their contents. diff --git a/.claude/agents/codebase-pattern-finder.md b/.claude/agents/codebase-pattern-finder.md index a66460584..82906eef4 100644 --- a/.claude/agents/codebase-pattern-finder.md +++ b/.claude/agents/codebase-pattern-finder.md @@ -12,6 +12,9 @@ mcpServers: type: stdio command: uvx args: ["--from", "git+https://github.com/ast-grep/ast-grep-mcp", "ast-grep-server"] + env: + GIT_CONFIG_GLOBAL: /dev/null + GIT_CONFIG_SYSTEM: /dev/null --- You are a specialist at finding code patterns and examples in the codebase. Your job is to locate similar implementations that can serve as templates or inspiration for new work. diff --git a/.claude/agents/codebase-research-analyzer.md b/.claude/agents/codebase-research-analyzer.md index 4f636472c..d0dbb5354 100644 --- a/.claude/agents/codebase-research-analyzer.md +++ b/.claude/agents/codebase-research-analyzer.md @@ -12,6 +12,9 @@ mcpServers: type: stdio command: uvx args: ["--from", "git+https://github.com/ast-grep/ast-grep-mcp", "ast-grep-server"] + env: + GIT_CONFIG_GLOBAL: /dev/null + GIT_CONFIG_SYSTEM: /dev/null --- You are a specialist at extracting HIGH-VALUE insights from thoughts documents. Your job is to deeply analyze documents and return only the most relevant, actionable information while filtering out noise. diff --git a/.claude/agents/codebase-research-locator.md b/.claude/agents/codebase-research-locator.md index a0cbbc4cb..0c7f31120 100644 --- a/.claude/agents/codebase-research-locator.md +++ b/.claude/agents/codebase-research-locator.md @@ -12,6 +12,9 @@ mcpServers: type: stdio command: uvx args: ["--from", "git+https://github.com/ast-grep/ast-grep-mcp", "ast-grep-server"] + env: + GIT_CONFIG_GLOBAL: /dev/null + GIT_CONFIG_SYSTEM: /dev/null --- You are a specialist at finding documents in the research/ directory. Your job is to locate relevant research documents and categorize them, NOT to analyze their contents in depth. diff --git a/.github/agents/codebase-analyzer.md b/.github/agents/codebase-analyzer.md index 68cede33b..df4579183 100644 --- a/.github/agents/codebase-analyzer.md +++ b/.github/agents/codebase-analyzer.md @@ -12,6 +12,13 @@ mcp-servers: type: stdio command: uvx args: ["--from", "git+https://github.com/ast-grep/ast-grep-mcp", "ast-grep-server"] + env: + # Neutralize host git config so uvx's `git clone` is hermetic — no + # insteadOf rewrites, GPG signing, or hooks bleed in. /dev/null works + # on POSIX directly and on Windows via Git-for-Windows MSYS path + # translation; on native Windows git the path is missing → empty config. + GIT_CONFIG_GLOBAL: /dev/null + GIT_CONFIG_SYSTEM: /dev/null --- You are a specialist at understanding HOW code works. Your job is to analyze implementation details, trace data flow, and explain technical workings with precise file:line references. diff --git a/.github/agents/codebase-locator.md b/.github/agents/codebase-locator.md index 2579cfa75..7bbe93c3a 100644 --- a/.github/agents/codebase-locator.md +++ b/.github/agents/codebase-locator.md @@ -12,6 +12,9 @@ mcp-servers: type: stdio command: uvx args: ["--from", "git+https://github.com/ast-grep/ast-grep-mcp", "ast-grep-server"] + env: + GIT_CONFIG_GLOBAL: /dev/null + GIT_CONFIG_SYSTEM: /dev/null --- You are a specialist at finding WHERE code lives in a codebase. Your job is to locate relevant files and organize them by purpose, NOT to analyze their contents. diff --git a/.github/agents/codebase-pattern-finder.md b/.github/agents/codebase-pattern-finder.md index 373a12628..189e88d3c 100644 --- a/.github/agents/codebase-pattern-finder.md +++ b/.github/agents/codebase-pattern-finder.md @@ -18,6 +18,9 @@ mcp-servers: type: stdio command: uvx args: ["--from", "git+https://github.com/ast-grep/ast-grep-mcp", "ast-grep-server"] + env: + GIT_CONFIG_GLOBAL: /dev/null + GIT_CONFIG_SYSTEM: /dev/null --- You are a specialist at finding code patterns and examples in the codebase. Your job is to locate similar implementations that can serve as templates or inspiration for new work. diff --git a/.github/agents/codebase-research-analyzer.md b/.github/agents/codebase-research-analyzer.md index 193295e5c..bd089657a 100644 --- a/.github/agents/codebase-research-analyzer.md +++ b/.github/agents/codebase-research-analyzer.md @@ -12,6 +12,9 @@ mcp-servers: type: stdio command: uvx args: ["--from", "git+https://github.com/ast-grep/ast-grep-mcp", "ast-grep-server"] + env: + GIT_CONFIG_GLOBAL: /dev/null + GIT_CONFIG_SYSTEM: /dev/null --- You are a specialist at extracting HIGH-VALUE insights from thoughts documents. Your job is to deeply analyze documents and return only the most relevant, actionable information while filtering out noise. diff --git a/.github/agents/codebase-research-locator.md b/.github/agents/codebase-research-locator.md index cbf890eb7..b116b4e9a 100644 --- a/.github/agents/codebase-research-locator.md +++ b/.github/agents/codebase-research-locator.md @@ -12,6 +12,9 @@ mcp-servers: type: stdio command: uvx args: ["--from", "git+https://github.com/ast-grep/ast-grep-mcp", "ast-grep-server"] + env: + GIT_CONFIG_GLOBAL: /dev/null + GIT_CONFIG_SYSTEM: /dev/null --- You are a specialist at finding documents in the research/ directory. Your job is to locate relevant research documents and categorize them, NOT to analyze their contents in depth. diff --git a/.opencode/opencode.json b/.opencode/opencode.json index 7c5812f8f..749028e32 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -21,12 +21,25 @@ }, "codegraph": { "type": "local", - "command": ["codegraph", "serve", "--mcp"], + "command": [ + "codegraph", + "serve", + "--mcp" + ], "enabled": true }, "ast-grep": { "type": "local", - "command": ["uvx", "--from", "git+https://github.com/ast-grep/ast-grep-mcp", "ast-grep-server"], + "command": [ + "uvx", + "--from", + "git+https://github.com/ast-grep/ast-grep-mcp", + "ast-grep-server" + ], + "environment": { + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_SYSTEM": "/dev/null" + }, "enabled": true } }, @@ -36,22 +49,40 @@ }, "agent": { "codebase-locator": { - "tools": { "codegraph*": true, "ast-grep*": true } + "tools": { + "codegraph*": true, + "ast-grep*": true + } }, "codebase-pattern-finder": { - "tools": { "codegraph*": true, "ast-grep*": true } + "tools": { + "codegraph*": true, + "ast-grep*": true + } }, "codebase-analyzer": { - "tools": { "codegraph*": true, "ast-grep*": true } + "tools": { + "codegraph*": true, + "ast-grep*": true + } }, "codebase-online-researcher": { - "tools": { "codegraph*": true, "ast-grep*": false } + "tools": { + "codegraph*": true, + "ast-grep*": false + } }, "codebase-research-locator": { - "tools": { "codegraph*": true, "ast-grep*": true } + "tools": { + "codegraph*": true, + "ast-grep*": true + } }, "codebase-research-analyzer": { - "tools": { "codegraph*": true, "ast-grep*": true } + "tools": { + "codegraph*": true, + "ast-grep*": true + } } }, "permission": "allow", diff --git a/examples/hello-world/.opencode/opencode.json b/examples/hello-world/.opencode/opencode.json new file mode 100644 index 000000000..80d805cf8 --- /dev/null +++ b/examples/hello-world/.opencode/opencode.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "azure-devops": { + "type": "local", + "command": [ + "bunx", + "-y", + "@azure-devops/mcp", + "" + ], + "enabled": false + }, + "github-mcp-server": { + "type": "remote", + "url": "https://api.githubcopilot.com/mcp", + "enabled": true, + "headers": { + "Authorization": "Bearer {env:GH_TOKEN}" + } + } + }, + "permission": "allow", + "instructions": [ + "~/.atomic/AGENTS.md" + ] +} diff --git a/package.json b/package.json index 953998e2d..1084f8681 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "test": "bun test --parallel packages tests", "test:coverage": "bun test --coverage packages tests", "typecheck": "bun --filter '*' typecheck", - "lint": "oxlint --config=oxlint.json packages && bun run scripts/lint-custom-workflows.ts && bun run --filter @bastani/atomic-sdk lint:mcp && bun run --filter @bastani/atomic-sdk lint:file-discovery", + "lint": "oxlint --config=oxlint.json packages && bun run scripts/lint-custom-workflows.ts && bun run --filter @bastani/atomic-sdk lint:mcp && bun run --filter @bastani/atomic-sdk lint:file-discovery && bun run lint:offload-await", + "lint:offload-await": "bun run scripts/lint-offload-await.ts", "lint:fix": "oxlint --config=oxlint.json --fix packages", "prepare": "[ -n \"$CI\" ] || prek install || true" }, diff --git a/packages/atomic-sdk/package.json b/packages/atomic-sdk/package.json index 8e8bf6d89..67ca4fe45 100644 --- a/packages/atomic-sdk/package.json +++ b/packages/atomic-sdk/package.json @@ -30,6 +30,7 @@ "./providers/copilot": "./src/providers/copilot.ts", "./providers/claude-stop-hook": "./src/providers/claude-stop-hook.ts", "./providers/claude-inflight-hook": "./src/providers/claude-inflight-hook.ts", + "./lib/telemetry": "./src/lib/telemetry/index.ts", "./lib/atomic-temp": "./src/lib/atomic-temp.ts", "./lib/spawn": "./src/lib/spawn.ts", "./lib/terminal-env": "./src/lib/terminal-env.ts", diff --git a/packages/atomic-sdk/src/components/header.tsx b/packages/atomic-sdk/src/components/header.tsx index e8b69a71e..9f7d98737 100644 --- a/packages/atomic-sdk/src/components/header.tsx +++ b/packages/atomic-sdk/src/components/header.tsx @@ -35,7 +35,7 @@ export function Header() { const storeVersion = useStoreVersion(store); const counts = useMemo(() => { - const c: Record = { complete: 0, running: 0, pending: 0, error: 0, awaiting_input: 0 }; + const c: Record = { complete: 0, running: 0, pending: 0, error: 0, awaiting_input: 0, offloaded: 0, resuming: 0 }; for (const s of store.sessions) c[s.status]++; return c; }, [storeVersion]); @@ -79,6 +79,8 @@ export function Header() { + + ); diff --git a/packages/atomic-sdk/src/components/orchestrator-panel-context.test.tsx b/packages/atomic-sdk/src/components/orchestrator-panel-context.test.tsx new file mode 100644 index 000000000..f16007495 --- /dev/null +++ b/packages/atomic-sdk/src/components/orchestrator-panel-context.test.tsx @@ -0,0 +1,94 @@ +/** @jsxImportSource @opentui/react */ +/** + * Tests for OrchestratorPanel.attachOffloadManager — setter-based wiring. + */ + +import { test, expect, mock } from "bun:test"; +import { OrchestratorPanel } from "./orchestrator-panel.tsx"; +import type { OffloadManager } from "../runtime/offload-manager.ts"; +import type { CliRenderer } from "@opentui/core"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeStubRenderer(): CliRenderer { + // Minimal stub satisfying the surface used by createWithRenderer. + // Note: React's scheduler dispatches async reconciler work after render; + // this stub intentionally omits low-level renderer internals (getChildren, + // etc.) so those async tasks may throw unhandled errors. The synchronous + // test assertions below still pass correctly. + return { + themeMode: null, + width: 80, + height: 24, + widthMethod: "terminal", + root: { + children: [], + getChildren: () => [], + requestRender: mock(() => {}), + add: mock(() => {}), + remove: mock(() => {}), + } as unknown as CliRenderer["root"], + setBackgroundColor: mock(() => {}), + requestRender: mock(() => {}), + addInputHandler: mock(() => {}), + removeInputHandler: mock(() => {}), + on: mock(() => ({}) as unknown as CliRenderer), + once: mock(() => ({}) as unknown as CliRenderer), + off: mock(() => ({}) as unknown as CliRenderer), + emit: mock(() => false), + destroy: mock(() => {}), + resetTerminalBgColor: mock(() => {}), + setFrameCallback: mock(() => {}), + removeFrameCallback: mock(() => {}), + clearFrameCallbacks: mock(() => {}), + requestLive: mock(() => {}), + dropLive: mock(() => {}), + } as unknown as CliRenderer; +} + +function makeStubOffloadManager(): OffloadManager { + return { + registerSession: mock(async () => {}), + offloadSession: mock(async () => {}), + onWorkflowCompletion: mock(async () => {}), + requestResume: mock(async () => {}), + getStatus: mock(() => "alive" as const), + }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test("OrchestratorPanel exposes attachOffloadManager method", () => { + const renderer = makeStubRenderer(); + const panel = OrchestratorPanel.createWithRenderer(renderer, { tmuxSession: "test-session" }); + expect(typeof panel.attachOffloadManager).toBe("function"); + panel.destroy(); +}); + +test("attachOffloadManager does not throw when called with valid manager", () => { + const renderer = makeStubRenderer(); + const panel = OrchestratorPanel.createWithRenderer(renderer, { tmuxSession: "test-session" }); + const mgr = makeStubOffloadManager(); + expect(() => panel.attachOffloadManager(mgr)).not.toThrow(); + panel.destroy(); +}); + +test("attachOffloadManager is idempotent — calling twice does not throw", () => { + const renderer = makeStubRenderer(); + const panel = OrchestratorPanel.createWithRenderer(renderer, { tmuxSession: "test-session" }); + const mgr = makeStubOffloadManager(); + expect(() => { + panel.attachOffloadManager(mgr); + panel.attachOffloadManager(mgr); + }).not.toThrow(); + panel.destroy(); +}); + +test("attachOffloadManager returns void", () => { + const renderer = makeStubRenderer(); + const panel = OrchestratorPanel.createWithRenderer(renderer, { tmuxSession: "test-session" }); + const mgr = makeStubOffloadManager(); + const result = panel.attachOffloadManager(mgr); + expect(result).toBeUndefined(); + panel.destroy(); +}); diff --git a/packages/atomic-sdk/src/components/orchestrator-panel-contexts.test.tsx b/packages/atomic-sdk/src/components/orchestrator-panel-contexts.test.tsx new file mode 100644 index 000000000..36b070894 --- /dev/null +++ b/packages/atomic-sdk/src/components/orchestrator-panel-contexts.test.tsx @@ -0,0 +1,60 @@ +import { test, expect, mock } from "bun:test"; +import { OffloadManagerContext } from "./orchestrator-panel-contexts.ts"; +import type { OffloadManager } from "../runtime/offload-manager.ts"; + +// ─── OffloadManagerContext ───────────────────────────────────────────────── + +test("OffloadManagerContext default value is null", () => { + // createContext(null) — the _currentValue internal field holds the default + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((OffloadManagerContext as any)._currentValue).toBeNull(); +}); + +test("OffloadManagerContext is a React context object", () => { + expect(OffloadManagerContext).toBeDefined(); + expect(typeof OffloadManagerContext.Provider).toBe("object"); + expect(typeof OffloadManagerContext.Consumer).toBe("object"); +}); + +// ─── useOffloadManager ───────────────────────────────────────────────────── + +test("useOffloadManager throws when called outside React component", () => { + // import lazily to avoid top-level module issues with react hook rules + const { useOffloadManager } = require("./orchestrator-panel-contexts.ts"); + expect(() => useOffloadManager()).toThrow(); +}); + +// ─── useOffloadManager with provider value ──────────────────────────────── + +test("useOffloadManager returns value from OffloadManagerContext.Provider", () => { + // Test by mocking React's useContext to return a known value, then verifying + // useOffloadManager returns it (white-box: hook is a thin useContext wrapper) + const mockManager: OffloadManager = { + registerSession: mock(async () => {}), + offloadSession: mock(async () => {}), + onWorkflowCompletion: mock(async () => {}), + requestResume: mock(async () => {}), + getStatus: mock(() => "alive" as const), + }; + + // Temporarily replace useContext from react with a stub returning our mock + mock.module("react", () => { + const real = require("react"); + return { + ...real, + useContext: (ctx: unknown) => { + if (ctx === OffloadManagerContext) return mockManager; + return real.useContext(ctx); + }, + }; + }); + + // Reload the module so it picks up the mocked react + const { useOffloadManager: freshUseOffloadManager } = require("./orchestrator-panel-contexts.ts"); + + const result = freshUseOffloadManager(); + expect(result).toBe(mockManager); + + // Restore real react + mock.restore(); +}); diff --git a/packages/atomic-sdk/src/components/orchestrator-panel-contexts.ts b/packages/atomic-sdk/src/components/orchestrator-panel-contexts.ts index 2dea1a32c..12ab0e8bc 100644 --- a/packages/atomic-sdk/src/components/orchestrator-panel-contexts.ts +++ b/packages/atomic-sdk/src/components/orchestrator-panel-contexts.ts @@ -3,6 +3,7 @@ import { createContext, useContext, useSyncExternalStore } from "react"; import type { PanelStore } from "./orchestrator-panel-store.ts"; import type { GraphTheme } from "./graph-theme.ts"; +import type { OffloadManager } from "../runtime/offload-manager.ts"; export const StoreContext = createContext(null); export const ThemeContext = createContext(null); @@ -33,3 +34,13 @@ export function useStoreVersion(store: PanelStore): number { () => store.version, ); } + +export const OffloadManagerContext = createContext(null); + +export function useOffloadManager(): OffloadManager { + const ctx = useContext(OffloadManagerContext); + if (!ctx) { + throw new Error("useOffloadManager must be used within OffloadManagerContext.Provider"); + } + return ctx; +} diff --git a/packages/atomic-sdk/src/components/orchestrator-panel-store.test.ts b/packages/atomic-sdk/src/components/orchestrator-panel-store.test.ts index b3fc7d533..9cf7660e4 100644 --- a/packages/atomic-sdk/src/components/orchestrator-panel-store.test.ts +++ b/packages/atomic-sdk/src/components/orchestrator-panel-store.test.ts @@ -803,6 +803,111 @@ describe("PanelStore", () => { }); }); + // ── setSessionStatus ─────────────────────────────────────────────────────── + + describe("setSessionStatus", () => { + beforeEach(() => { + store.setWorkflowInfo("wf", "claude", [{ name: "worker", parents: [] }], "prompt"); + store.startSession("worker"); + store.completeSession("worker"); + }); + + test("complete → offloaded sets status to offloaded", () => { + store.setSessionStatus("worker", "offloaded"); + const s = store.sessions.find((s) => s.name === "worker")!; + expect(s.status).toBe("offloaded"); + }); + + test("offloaded → resuming sets status to resuming", () => { + store.setSessionStatus("worker", "offloaded"); + store.setSessionStatus("worker", "resuming"); + const s = store.sessions.find((s) => s.name === "worker")!; + expect(s.status).toBe("resuming"); + }); + + test("resuming → complete sets status to complete", () => { + store.setSessionStatus("worker", "offloaded"); + store.setSessionStatus("worker", "resuming"); + store.setSessionStatus("worker", "complete"); + const s = store.sessions.find((s) => s.name === "worker")!; + expect(s.status).toBe("complete"); + }); + + test("resuming → offloaded (recoverable error path) sets status to offloaded", () => { + store.setSessionStatus("worker", "offloaded"); + store.setSessionStatus("worker", "resuming"); + store.setSessionStatus("worker", "offloaded"); + const s = store.sessions.find((s) => s.name === "worker")!; + expect(s.status).toBe("offloaded"); + }); + + test("bumps version by exactly 1 per call", () => { + const before = store.version; + store.setSessionStatus("worker", "offloaded"); + expect(store.version).toBe(before + 1); + }); + + test("notifies subscribed listeners", () => { + const listener = mock(() => {}); + store.subscribe(listener); + store.setSessionStatus("worker", "offloaded"); + expect(listener).toHaveBeenCalledTimes(1); + }); + + test("does not emit when session not found", () => { + const before = store.version; + store.setSessionStatus("nonexistent", "offloaded"); + expect(store.version).toBe(before); + }); + + test("does not notify listeners when session not found", () => { + const listener = mock(() => {}); + store.subscribe(listener); + store.setSessionStatus("nonexistent", "offloaded"); + expect(listener).toHaveBeenCalledTimes(0); + }); + + test("resumeSession (HIL) still only transitions awaiting_input → running", () => { + // set to awaiting_input first via awaitingInput helper + store.startSession("worker"); // re-start since it was completed in beforeEach + store.awaitingInput("worker"); + store.resumeSession("worker"); + const s = store.sessions.find((s) => s.name === "worker")!; + expect(s.status).toBe("running"); + }); + + test("resumeSession does not transition offloaded → running", () => { + store.setSessionStatus("worker", "offloaded"); + const before = store.version; + store.resumeSession("worker"); + const s = store.sessions.find((s) => s.name === "worker")!; + expect(s.status).toBe("offloaded"); + expect(store.version).toBe(before); + }); + + test("setSessionStatus does not interfere with resumeSession HIL path", () => { + // Both methods co-exist without collision + // Use a fresh store with two named sessions + const s2 = new PanelStore(); + s2.setWorkflowInfo("wf2", "claude", [ + { name: "hil-worker", parents: [] }, + { name: "bg-worker", parents: [] }, + ], "p"); + s2.startSession("hil-worker"); + s2.awaitingInput("hil-worker"); + s2.startSession("bg-worker"); + s2.completeSession("bg-worker"); + // setSessionStatus on bg-worker + s2.setSessionStatus("bg-worker", "offloaded"); + // HIL resume still works on hil-worker + s2.resumeSession("hil-worker"); + const hil = s2.sessions.find((s) => s.name === "hil-worker")!; + expect(hil.status).toBe("running"); + const offloaded = s2.sessions.find((s) => s.name === "bg-worker")!; + expect(offloaded.status).toBe("offloaded"); + }); + }); + // ── setViewMode ──────────────────────────────────────────────────────────── describe("setViewMode", () => { @@ -842,6 +947,118 @@ describe("PanelStore", () => { expect(store.viewMode).toBe("attached"); expect(store.activeAgentId).toBe(""); }); + + test('setViewMode("resuming", "stage-a") sets viewMode and activeAgentId', () => { + store.setViewMode("resuming", "stage-a"); + expect(store.viewMode).toBe("resuming"); + expect(store.activeAgentId).toBe("stage-a"); + }); + + test('setViewMode("resuming") without agentId clears activeAgentId', () => { + store.setViewMode("attached", "old-agent"); + store.setViewMode("resuming"); + expect(store.viewMode).toBe("resuming"); + expect(store.activeAgentId).toBe(""); + }); + + test('setViewMode("resuming", "stage-a") bumps version', () => { + const before = store.version; + store.setViewMode("resuming", "stage-a"); + expect(store.version).toBe(before + 1); + }); + }); + + // ── showToast ────────────────────────────────────────────────────────────── + + describe("showToast", () => { + test("appends toast to toasts array", () => { + store.showToast("hello world"); + expect(store.toasts).toHaveLength(1); + expect(store.toasts[0]!.message).toBe("hello world"); + }); + + test("assigns monotonically increasing id", () => { + store.showToast("first"); + store.showToast("second"); + expect(store.toasts[0]!.id).toBeLessThan(store.toasts[1]!.id); + }); + + test("sets createdAt to a positive epoch timestamp", () => { + store.showToast("ts test"); + expect(store.toasts[0]!.createdAt).toBeGreaterThan(0); + }); + + test("bumps version by exactly 1", () => { + const before = store.version; + store.showToast("bump"); + expect(store.version).toBe(before + 1); + }); + + test("notifies subscribed listener", () => { + const listener = mock(() => {}); + store.subscribe(listener); + store.showToast("notify"); + expect(listener).toHaveBeenCalledTimes(1); + }); + + test("multiple showToast calls accumulate", () => { + store.showToast("a"); + store.showToast("b"); + store.showToast("c"); + expect(store.toasts).toHaveLength(3); + }); + }); + + // ── dismissToast ─────────────────────────────────────────────────────────── + + describe("dismissToast", () => { + test("removes toast by id", () => { + store.showToast("to remove"); + const id = store.toasts[0]!.id; + store.dismissToast(id); + expect(store.toasts).toHaveLength(0); + }); + + test("removes only the targeted toast", () => { + store.showToast("keep"); + store.showToast("remove"); + const removeId = store.toasts[1]!.id; + store.dismissToast(removeId); + expect(store.toasts).toHaveLength(1); + expect(store.toasts[0]!.message).toBe("keep"); + }); + + test("bumps version when toast removed", () => { + store.showToast("bump"); + const id = store.toasts[0]!.id; + const before = store.version; + store.dismissToast(id); + expect(store.version).toBe(before + 1); + }); + + test("notifies listener when toast removed", () => { + store.showToast("notify"); + const id = store.toasts[0]!.id; + const listener = mock(() => {}); + store.subscribe(listener); + store.dismissToast(id); + expect(listener).toHaveBeenCalledTimes(1); + }); + + test("is no-op for unknown id (no emit)", () => { + store.showToast("existing"); + const before = store.version; + store.dismissToast(99999); + expect(store.version).toBe(before); + expect(store.toasts).toHaveLength(1); + }); + + test("does not notify listener for unknown id", () => { + const listener = mock(() => {}); + store.subscribe(listener); + store.dismissToast(99999); + expect(listener).toHaveBeenCalledTimes(0); + }); }); }); diff --git a/packages/atomic-sdk/src/components/orchestrator-panel-store.ts b/packages/atomic-sdk/src/components/orchestrator-panel-store.ts index d0ebe5af8..3e39402d7 100644 --- a/packages/atomic-sdk/src/components/orchestrator-panel-store.ts +++ b/packages/atomic-sdk/src/components/orchestrator-panel-store.ts @@ -5,6 +5,18 @@ import type { SessionData, SessionStatus, PanelSession, ViewMode } from "./orche type Listener = () => void; +export type ToastKind = "info" | "warning" | "error"; + +export interface ToastEntry { + id: number; + message: string; + kind: ToastKind; + createdAt: number; +} + +/** Default time-to-live for auto-dismissed toasts (ms). */ +export const TOAST_DEFAULT_TTL_MS = 5000; + export class PanelStore { version = 0; workflowName = ""; @@ -22,9 +34,14 @@ export class PanelStore { /** Current view mode — graph overview or attached to a specific agent. */ viewMode: ViewMode = "graph"; - /** ID of the agent currently attached to (only meaningful when viewMode === "attached"). */ + /** ID of the agent currently attached to (only meaningful when viewMode === "attached" or "resuming"). */ activeAgentId = ""; + /** Active toast notifications. */ + toasts: ToastEntry[] = []; + private nextToastId = 1; + private toastTimers = new Map>(); + private listeners = new Set(); subscribe = (fn: Listener): (() => void) => { @@ -106,6 +123,13 @@ export class PanelStore { } } + setSessionStatus(name: string, status: SessionData["status"]): void { + const session = this.sessions.find((s) => s.name === name); + if (!session) return; + session.status = status; + this.emit(); + } + addSession(session: SessionData): void { this.sessions.push(session); this.emit(); @@ -143,13 +167,44 @@ export class PanelStore { } /** - * Switch between graph and attached view modes. - * When switching to "attached", provide the agent ID to attach to. + * Switch between graph, attached, and resuming view modes. + * When switching to "attached" or "resuming", provide the agent ID. * Switching to "graph" clears the active agent. */ setViewMode(mode: ViewMode, agentId?: string): void { this.viewMode = mode; - this.activeAgentId = mode === "attached" && agentId ? agentId : ""; + this.activeAgentId = (mode === "attached" || mode === "resuming") && agentId ? agentId : ""; + this.emit(); + } + + /** + * Show a toast notification that auto-dismisses after `ttlMs`. + * + * Pass `ttlMs: 0` to disable auto-dismiss (caller owns the lifetime). + * The internal timer is `unref()`'d so it never blocks process exit. + */ + showToast(message: string, kind: ToastKind = "error", ttlMs = TOAST_DEFAULT_TTL_MS): void { + const id = this.nextToastId++; + this.toasts.push({ id, message, kind, createdAt: Date.now() }); + if (ttlMs > 0) { + const timer = setTimeout(() => this.dismissToast(id), ttlMs); + // Don't keep the event loop alive solely for a toast timeout. + const unref = (timer as { unref?: () => void }).unref; + if (typeof unref === "function") unref.call(timer); + this.toastTimers.set(id, timer); + } + this.emit(); + } + + dismissToast(id: number): void { + const idx = this.toasts.findIndex((t) => t.id === id); + if (idx < 0) return; + this.toasts.splice(idx, 1); + const timer = this.toastTimers.get(id); + if (timer !== undefined) { + clearTimeout(timer); + this.toastTimers.delete(id); + } this.emit(); } diff --git a/packages/atomic-sdk/src/components/orchestrator-panel-types.ts b/packages/atomic-sdk/src/components/orchestrator-panel-types.ts index e9f4c5ef9..6e47dda45 100644 --- a/packages/atomic-sdk/src/components/orchestrator-panel-types.ts +++ b/packages/atomic-sdk/src/components/orchestrator-panel-types.ts @@ -1,8 +1,8 @@ // ─── Orchestrator Panel Types ───────────────────── -export type SessionStatus = "pending" | "running" | "complete" | "error" | "awaiting_input"; +export type SessionStatus = "pending" | "running" | "complete" | "error" | "awaiting_input" | "offloaded" | "resuming"; -export type ViewMode = "graph" | "attached"; +export type ViewMode = "graph" | "attached" | "resuming"; export interface PanelSession { name: string; diff --git a/packages/atomic-sdk/src/components/orchestrator-panel.tsx b/packages/atomic-sdk/src/components/orchestrator-panel.tsx index 3d69c81d3..b23fbead0 100644 --- a/packages/atomic-sdk/src/components/orchestrator-panel.tsx +++ b/packages/atomic-sdk/src/components/orchestrator-panel.tsx @@ -10,7 +10,8 @@ import { resolveTheme } from "../runtime/theme.ts"; import { deriveGraphTheme } from "./graph-theme.ts"; import type { GraphTheme } from "./graph-theme.ts"; import { PanelStore } from "./orchestrator-panel-store.ts"; -import { StoreContext, ThemeContext, TmuxSessionContext } from "./orchestrator-panel-contexts.ts"; +import { OffloadManagerContext, StoreContext, ThemeContext, TmuxSessionContext } from "./orchestrator-panel-contexts.ts"; +import type { OffloadManager } from "../runtime/offload-manager.ts"; import type { PanelSession, PanelOptions, SessionData } from "./orchestrator-panel-types.ts"; import { SessionGraphPanel } from "./session-graph-panel.tsx"; import { ErrorBoundary } from "./error-boundary.tsx"; @@ -35,6 +36,8 @@ export class OrchestratorPanel { private unsubscribeDiagnostics: (() => void) | null = null; private graphTheme: GraphTheme; private tmuxSession: string; + private offloadManager: OffloadManager | null = null; + private rerender: () => void = () => {}; private constructor( renderer: CliRenderer, @@ -57,33 +60,40 @@ export class OrchestratorPanel { ? store.subscribe(() => this.diagnostics?.capture("store-update")) : null; - createRoot(renderer).render( - - - - ( - { + root.render( + + + + + ( + + + + {`Fatal render error: ${err.message}`} + + + + )} > - - - {`Fatal render error: ${err.message}`} - - - - )} - > - - - - - , - ); + {offloadManager ? : null} + + + + + , + ); + }; + this.rerender = () => renderTree(this.offloadManager); + renderTree(null); requestRendererBackgroundRepaint(this.renderer); this.diagnostics?.capture("post-mount"); } @@ -247,6 +257,27 @@ export class OrchestratorPanel { return this.store.subscribe(fn); } + /** + * Expose the internal PanelStore for consumers that need live mutable + * access (e.g. OffloadManager). Prefer `getSnapshot()` for read-only + * snapshots. + */ + getPanelStore(): PanelStore { + return this.store; + } + + /** + * Attach the {@link OffloadManager} after both panel and manager are + * constructed (the manager's deps include panel.getPanelStore(), + * so they cannot be wired in a single constructor). Re-renders the + * tree so the {@link OffloadManagerContext} provider reflects the + * new value. Idempotent — calling twice with the same manager is fine. + */ + attachOffloadManager(manager: OffloadManager): void { + this.offloadManager = manager; + this.rerender(); + } + /** * Read-only snapshot of the fields needed by the on-disk status * writer. Defined here (not in PanelStore) because the store keeps diff --git a/packages/atomic-sdk/src/components/session-graph-panel.test.tsx b/packages/atomic-sdk/src/components/session-graph-panel.test.tsx new file mode 100644 index 000000000..7e920c14d --- /dev/null +++ b/packages/atomic-sdk/src/components/session-graph-panel.test.tsx @@ -0,0 +1,586 @@ +/** @jsxImportSource @opentui/react */ +/** + * Tests for SessionGraphPanel RFC §5.5 resume gate logic. + * + * Test strategy: + * - `decideAttachAction` pure helper: exhaustive unit tests (no mocks needed). + * - Async `doAttach` branching: thin harness that reproduces the exact + * conditional logic without mounting the full OpenTUI component tree. + * - Focus-poll: same thin-harness approach for the interval callback logic. + */ + +import { test, expect, describe, mock, beforeEach } from "bun:test"; +import { decideAttachAction } from "./session-graph-panel.tsx"; +import { PanelStore } from "./orchestrator-panel-store.ts"; +import { errorMessage } from "../errors.ts"; +import type { OffloadManager } from "../runtime/offload-manager.ts"; + +// ─── decideAttachAction ─────────────────────────────────────────────────────── + +describe("decideAttachAction", () => { + test("id=orchestrator → graphView regardless of offloadStatus", () => { + expect(decideAttachAction("orchestrator", "alive")).toEqual({ kind: "graphView" }); + expect(decideAttachAction("orchestrator", "offloaded")).toEqual({ kind: "graphView" }); + expect(decideAttachAction("orchestrator", "resuming")).toEqual({ kind: "graphView" }); + }); + + test("status=alive → switchClient", () => { + expect(decideAttachAction("agent-1", "alive")).toEqual({ kind: "switchClient" }); + }); + + test("status=offloaded → resume", () => { + expect(decideAttachAction("agent-1", "offloaded")).toEqual({ kind: "resume" }); + }); + + test("status=resuming → resume (coalesces onto in-flight op)", () => { + expect(decideAttachAction("agent-1", "resuming")).toEqual({ kind: "resume" }); + }); +}); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeOffloadManager(overrides: Partial = {}): OffloadManager { + return { + registerSession: mock(async () => {}), + offloadSession: mock(async () => {}), + onWorkflowCompletion: mock(async () => {}), + requestResume: mock(async () => {}), + getStatus: mock(() => "alive" as const), + ...overrides, + }; +} + +/** + * Thin reproducer of the doAttach async logic from session-graph-panel.tsx. + * + * Mirrors the exact conditional structure without any React/OpenTUI overhead. + * Keeps tests fast and deterministic. + */ +async function runDoAttach(opts: { + id: string; + nodeExists: boolean; + offloadManager: OffloadManager; + store: PanelStore; + tmuxRun: (args: string[]) => void; + tmuxSession: string; + setFocusedId: (id: string) => void; +}): Promise { + const { id, nodeExists, offloadManager, store, tmuxRun: mockTmuxRun, tmuxSession, setFocusedId } = opts; + + // Mirrors layout.map[id] lookup + if (!nodeExists) return; + + // Mirrors session guard + const session = store.sessions.find((s) => s.name === id); + if (!session || session.status === "pending") return; + + // Mirrors orchestrator guard + if (id === "orchestrator") { + store.setViewMode("graph"); + return; + } + + setFocusedId(id); + + const status = offloadManager.getStatus(id); + if (status === "offloaded" || status === "resuming") { + store.setViewMode("resuming", id); + try { + await offloadManager.requestResume(id); + store.setViewMode("attached", id); + } catch (err) { + store.showToast(`Failed to resume ${id}: ${errorMessage(err)}`); + store.setViewMode("graph"); + } + return; + } + + store.setViewMode("attached", id); + mockTmuxRun(["switch-client", "-t", `${tmuxSession}:${id}`]); // offload-exempt: status === "alive" +} + +// ─── doAttach async branching ───────────────────────────────────────────────── + +describe("doAttach — offloaded path", () => { + let store: PanelStore; + let tmuxRunMock: ReturnType; + + beforeEach(() => { + store = new PanelStore(); + store.setWorkflowInfo("wf", "claude", [{ name: "agent-1", parents: [] }], "prompt"); + store.startSession("agent-1"); + store.setSessionStatus("agent-1", "offloaded"); + tmuxRunMock = mock(() => {}); + }); + + test("(a) requestResume called BEFORE any switch-client when node is offloaded", async () => { + const callOrder: string[] = []; + const mgr = makeOffloadManager({ + getStatus: mock(() => "offloaded" as const), + requestResume: mock(async () => { + callOrder.push("requestResume"); + }), + }); + const tmuxRunCapture = mock((..._args: unknown[]) => { + callOrder.push("switch-client"); + }); + + await runDoAttach({ + id: "agent-1", + nodeExists: true, + offloadManager: mgr, + store, + tmuxRun: tmuxRunCapture, + tmuxSession: "test-session", + setFocusedId: () => {}, + }); + + expect(callOrder).toContain("requestResume"); + // switch-client must NOT appear at all — OffloadManager does selectWindow on success + expect(callOrder).not.toContain("switch-client"); + // requestResume must come first if both appeared + if (callOrder.includes("switch-client")) { + expect(callOrder.indexOf("requestResume")).toBeLessThan(callOrder.indexOf("switch-client")); + } + }); + + test("viewMode transitions to 'resuming' during resume, then 'attached' on success", async () => { + const viewModes: string[] = []; + const origSetViewMode = store.setViewMode.bind(store); + store.setViewMode = mock((mode, id?) => { + viewModes.push(mode); + origSetViewMode(mode, id); + }); + + const mgr = makeOffloadManager({ + getStatus: mock(() => "offloaded" as const), + requestResume: mock(async () => {}), + }); + + await runDoAttach({ + id: "agent-1", + nodeExists: true, + offloadManager: mgr, + store, + tmuxRun: () => {}, + tmuxSession: "test-session", + setFocusedId: () => {}, + }); + + expect(viewModes).toContain("resuming"); + expect(viewModes[viewModes.length - 1]).toBe("attached"); + }); + + test("(b) requestResume rejection sets toast and leaves viewMode === 'graph'", async () => { + const mgr = makeOffloadManager({ + getStatus: mock(() => "offloaded" as const), + requestResume: mock(async () => { + throw new Error("tmux window gone"); + }), + }); + + await runDoAttach({ + id: "agent-1", + nodeExists: true, + offloadManager: mgr, + store, + tmuxRun: () => {}, + tmuxSession: "test-session", + setFocusedId: () => {}, + }); + + expect(store.viewMode).toBe("graph"); + expect(store.toasts).toHaveLength(1); + expect(store.toasts[0]!.message).toMatch(/^Failed to resume agent-1:/); + expect(store.toasts[0]!.message).toContain("tmux window gone"); + }); + + test("(b) no switch-client on resume failure", async () => { + const mgr = makeOffloadManager({ + getStatus: mock(() => "offloaded" as const), + requestResume: mock(async () => { + throw new Error("fail"); + }), + }); + + await runDoAttach({ + id: "agent-1", + nodeExists: true, + offloadManager: mgr, + store, + tmuxRun: tmuxRunMock, + tmuxSession: "test-session", + setFocusedId: () => {}, + }); + + expect(tmuxRunMock).not.toHaveBeenCalled(); + }); +}); + +describe("doAttach — alive path", () => { + let store: PanelStore; + let tmuxRunMock: ReturnType; + + beforeEach(() => { + store = new PanelStore(); + store.setWorkflowInfo("wf", "claude", [{ name: "agent-1", parents: [] }], "prompt"); + store.startSession("agent-1"); + tmuxRunMock = mock(() => {}); + }); + + test("(d) status=alive: switch-client issued, no requestResume", async () => { + const mgr = makeOffloadManager({ + getStatus: mock(() => "alive" as const), + }); + + await runDoAttach({ + id: "agent-1", + nodeExists: true, + offloadManager: mgr, + store, + tmuxRun: tmuxRunMock, + tmuxSession: "test-session", + setFocusedId: () => {}, + }); + + expect(tmuxRunMock).toHaveBeenCalledWith(["switch-client", "-t", "test-session:agent-1"]); + expect(mgr.requestResume).not.toHaveBeenCalled(); + }); + + test("(d) viewMode set to 'attached' on alive path", async () => { + const mgr = makeOffloadManager({ + getStatus: mock(() => "alive" as const), + }); + + await runDoAttach({ + id: "agent-1", + nodeExists: true, + offloadManager: mgr, + store, + tmuxRun: tmuxRunMock, + tmuxSession: "test-session", + setFocusedId: () => {}, + }); + + expect(store.viewMode).toBe("attached"); + expect(store.activeAgentId).toBe("agent-1"); + }); +}); + +// ─── focus-poll resume trigger ──────────────────────────────────────────────── + +/** + * Thin reproducer of the focus-poll `check` callback logic. + * Mirrors the exact conditional from the useEffect in session-graph-panel.tsx + * including the R3 tri-state fix (offloaded / resuming / alive). + */ +function runFocusPollCheck(opts: { + tmuxOutput: string; // e.g. "1 agent-1" + offloadManager: OffloadManager; + store: PanelStore; +}): void { + const { tmuxOutput, offloadManager, store } = opts; + const output = tmuxOutput.trim(); + const spaceIdx = output.indexOf(" "); + const idx = spaceIdx >= 0 ? output.slice(0, spaceIdx) : output; + const windowName = spaceIdx >= 0 ? output.slice(spaceIdx + 1) : ""; + + if (idx === "0") { + if (store.viewMode !== "graph") { + store.setViewMode("graph"); + } + } else { + // Mirror of session-graph-panel.tsx focus poll: "offloaded" and "resuming" + // both render as "resuming"; only "alive" flips to "attached" (R3 fix). + const targetStatus = offloadManager.getStatus(windowName); + const desiredMode = targetStatus === "alive" ? "attached" : "resuming"; + if (store.viewMode !== desiredMode || store.activeAgentId !== windowName) { + store.setViewMode(desiredMode, windowName); + } + if (targetStatus === "offloaded") { + void offloadManager.requestResume(windowName).catch(() => {}); + } + } +} + +describe("focus-poll", () => { + let store: PanelStore; + + beforeEach(() => { + store = new PanelStore(); + store.setWorkflowInfo("wf", "claude", [{ name: "agent-1", parents: [] }], "prompt"); + store.startSession("agent-1"); + }); + + test("(c) poll detects offloaded window → invokes requestResume", () => { + const mgr = makeOffloadManager({ + getStatus: mock((name: string) => (name === "agent-1" ? "offloaded" : "alive") as "offloaded" | "alive"), + requestResume: mock(async () => {}), + }); + + runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); + + expect(mgr.requestResume).toHaveBeenCalledWith("agent-1"); + expect(store.viewMode).toBe("resuming"); + expect(store.activeAgentId).toBe("agent-1"); + }); + + test("(c) poll on offloaded window sets viewMode to 'resuming'", () => { + const mgr = makeOffloadManager({ + getStatus: mock(() => "offloaded" as const), + }); + + runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); + + expect(store.viewMode).toBe("resuming"); + expect(store.activeAgentId).toBe("agent-1"); + }); + + test("poll on alive window sets viewMode to 'attached'", () => { + const mgr = makeOffloadManager({ + getStatus: mock(() => "alive" as const), + }); + + runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); + + expect(store.viewMode).toBe("attached"); + expect(store.activeAgentId).toBe("agent-1"); + expect(mgr.requestResume).not.toHaveBeenCalled(); + }); + + test("poll on window index 0 sets viewMode to 'graph'", () => { + store.setViewMode("attached", "agent-1"); + const mgr = makeOffloadManager(); + + runFocusPollCheck({ tmuxOutput: "0 orchestrator", offloadManager: mgr, store }); + + expect(store.viewMode).toBe("graph"); + expect(mgr.requestResume).not.toHaveBeenCalled(); + }); + + test("poll on already-resuming window does not re-call setViewMode", () => { + store.setViewMode("resuming", "agent-1"); + const setViewModeSpy = mock(store.setViewMode.bind(store)); + store.setViewMode = setViewModeSpy; + + const mgr = makeOffloadManager({ + getStatus: mock(() => "offloaded" as const), + }); + + runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); + + // Should NOT call setViewMode again since it's already "resuming" + same agentId + expect(setViewModeSpy).not.toHaveBeenCalled(); + }); +}); + +// ─── focus-leave offload trigger (chrome-tab semantics) ────────────────────── + +/** + * Thin reproducer of the focus-poll's focus-leave branch from + * session-graph-panel.tsx. Mirrors the offloadSession call order so we can + * assert the eligibility-check ordering (setViewMode runs first so the + * manager sees the updated activeAgentId). + */ +function runFocusLeaveCheck(opts: { + prevName: string; + currentName: string; + offloadManager: OffloadManager; +}): void { + const { prevName, currentName, offloadManager } = opts; + if (prevName !== "" && prevName !== currentName && prevName !== "orchestrator") { + void offloadManager.offloadSession(prevName).catch(() => {}); + } +} + +describe("focus-leave — Chrome-tab offload semantics", () => { + test("user navigates from stage to orchestrator → offloadSession on stage", () => { + const mgr = makeOffloadManager(); + runFocusLeaveCheck({ prevName: "agent-1", currentName: "orchestrator", offloadManager: mgr }); + expect(mgr.offloadSession).toHaveBeenCalledWith("agent-1"); + }); + + test("user navigates between stages → offloadSession on previous stage", () => { + const mgr = makeOffloadManager(); + runFocusLeaveCheck({ prevName: "agent-1", currentName: "agent-2", offloadManager: mgr }); + expect(mgr.offloadSession).toHaveBeenCalledWith("agent-1"); + }); + + test("user stays on the same window → no offloadSession call", () => { + const mgr = makeOffloadManager(); + runFocusLeaveCheck({ prevName: "agent-1", currentName: "agent-1", offloadManager: mgr }); + expect(mgr.offloadSession).not.toHaveBeenCalled(); + }); + + test("first poll tick (prev empty) → no offloadSession call", () => { + const mgr = makeOffloadManager(); + runFocusLeaveCheck({ prevName: "", currentName: "agent-1", offloadManager: mgr }); + expect(mgr.offloadSession).not.toHaveBeenCalled(); + }); + + test("user navigates from orchestrator to stage → no offloadSession (orchestrator excluded)", () => { + const mgr = makeOffloadManager(); + runFocusLeaveCheck({ prevName: "orchestrator", currentName: "agent-1", offloadManager: mgr }); + expect(mgr.offloadSession).not.toHaveBeenCalled(); + }); +}); + +// ─── focus-poll R3 resuming branch ─────────────────────────────────────────── + +describe("focus-poll R3 — resuming branch", () => { + let store: PanelStore; + + beforeEach(() => { + store = new PanelStore(); + store.setWorkflowInfo("wf", "claude", [{ name: "agent-1", parents: [] }], "prompt"); + store.startSession("agent-1"); + }); + + // Assertion 1a: viewMode reflects "resuming" when getStatus returns "resuming" + test("status=resuming → viewMode is 'resuming'", () => { + const mgr = makeOffloadManager({ + getStatus: mock(() => "resuming" as const), + }); + + runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); + + expect(store.viewMode).toBe("resuming"); + }); + + // Assertion 1b: activeAgentId is set to windowName + test("status=resuming → activeAgentId equals windowName", () => { + const mgr = makeOffloadManager({ + getStatus: mock(() => "resuming" as const), + }); + + runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); + + expect(store.activeAgentId).toBe("agent-1"); + }); + + // Assertion 1c (RFC invariant I3): viewMode NEVER flips to "attached" while status is "resuming" + test("status=resuming — viewMode never becomes 'attached' across multiple poll ticks", () => { + const mgr = makeOffloadManager({ + getStatus: mock(() => "resuming" as const), + }); + + // Simulate 5 consecutive poll ticks + for (let tick = 0; tick < 5; tick++) { + runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); + expect(store.viewMode).not.toBe("attached"); + } + + expect(store.viewMode).toBe("resuming"); + expect(store.activeAgentId).toBe("agent-1"); + }); + + // Assertion 2: requestResume is NEVER called when status is "resuming" + test("status=resuming → requestResume NOT called (resume already in flight)", () => { + const mgr = makeOffloadManager({ + getStatus: mock(() => "resuming" as const), + requestResume: mock(async () => {}), + }); + + runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); + + expect(mgr.requestResume).not.toHaveBeenCalled(); + }); + + // Assertion 2 extended: zero calls across multiple ticks + test("status=resuming — requestResume called zero times across multiple poll ticks", () => { + const mgr = makeOffloadManager({ + getStatus: mock(() => "resuming" as const), + requestResume: mock(async () => {}), + }); + + for (let tick = 0; tick < 5; tick++) { + runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); + } + + expect(mgr.requestResume).not.toHaveBeenCalled(); + }); + + // Assertion 2 also: status=resuming does NOT call setViewMode when already correct + test("status=resuming — no redundant setViewMode when state already matches", () => { + store.setViewMode("resuming", "agent-1"); + const setViewModeSpy = mock(store.setViewMode.bind(store)); + store.setViewMode = setViewModeSpy; + + const mgr = makeOffloadManager({ + getStatus: mock(() => "resuming" as const), + }); + + runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); + + expect(setViewModeSpy).not.toHaveBeenCalled(); + }); + + // Assertion 3 (optional completeness): offloaded branch still triggers requestResume + test("status=offloaded → requestResume called exactly once per tick", () => { + const mgr = makeOffloadManager({ + getStatus: mock(() => "offloaded" as const), + requestResume: mock(async () => {}), + }); + + runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); + + expect(mgr.requestResume).toHaveBeenCalledTimes(1); + expect(mgr.requestResume).toHaveBeenCalledWith("agent-1"); + }); + + test("status=offloaded — requestResume called once per tick across multiple ticks", () => { + const mgr = makeOffloadManager({ + getStatus: mock(() => "offloaded" as const), + requestResume: mock(async () => {}), + }); + + for (let tick = 0; tick < 3; tick++) { + runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); + } + + // 3 ticks × 1 call each = 3 total + expect(mgr.requestResume).toHaveBeenCalledTimes(3); + }); + + // Assertion 4 (optional): alive branch flips to "attached" + test("status=alive → viewMode becomes 'attached'", () => { + const mgr = makeOffloadManager({ + getStatus: mock(() => "alive" as const), + }); + + runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); + + expect(store.viewMode).toBe("attached"); + expect(store.activeAgentId).toBe("agent-1"); + expect(mgr.requestResume).not.toHaveBeenCalled(); + }); + + // Boundary: resuming → alive transition across ticks (status changes mid-sequence) + test("status transitions resuming→alive: viewMode follows correctly", () => { + let callCount = 0; + const mgr = makeOffloadManager({ + getStatus: mock(() => { + callCount++; + // First 2 ticks: resuming; 3rd tick: alive (resume completed) + return callCount <= 2 ? ("resuming" as const) : ("alive" as const); + }), + requestResume: mock(async () => {}), + }); + + // Ticks 1 & 2: resuming + runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); + expect(store.viewMode).toBe("resuming"); + expect(mgr.requestResume).not.toHaveBeenCalled(); + + runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); + expect(store.viewMode).toBe("resuming"); + expect(mgr.requestResume).not.toHaveBeenCalled(); + + // Tick 3: alive — now safe to attach + runFocusPollCheck({ tmuxOutput: "1 agent-1", offloadManager: mgr, store }); + expect(store.viewMode).toBe("attached"); + expect(store.activeAgentId).toBe("agent-1"); + // Still zero requestResume calls throughout + expect(mgr.requestResume).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/atomic-sdk/src/components/session-graph-panel.tsx b/packages/atomic-sdk/src/components/session-graph-panel.tsx index 7b4cabe5b..241cc2f60 100644 --- a/packages/atomic-sdk/src/components/session-graph-panel.tsx +++ b/packages/atomic-sdk/src/components/session-graph-panel.tsx @@ -24,7 +24,9 @@ import { useGraphTheme, useStoreVersion, TmuxSessionContext, + useOffloadManager, } from "./orchestrator-panel-contexts.ts"; +import { errorMessage } from "../errors.ts"; import { computeLayout, NODE_W, NODE_H, type LayoutNode } from "./layout.ts"; import { buildConnector, buildMergeConnector } from "./connectors.ts"; import type { ConnectorResult } from "./connectors.ts"; @@ -32,6 +34,8 @@ import { NodeCard } from "./node-card.tsx"; import { Edge } from "./edge.tsx"; import { Header } from "./header.tsx"; import { CompactSwitcher } from "./compact-switcher.tsx"; +import { ToastStack } from "./toast.tsx"; +import type { ViewMode } from "./orchestrator-panel-types.ts"; /** Interval (ms) between pulse animation frames — ~60fps feel. */ const PULSE_INTERVAL_MS = 60; @@ -40,10 +44,38 @@ const PULSE_FRAME_COUNT = 32; /** Timeout (ms) for "gg" double-tap to jump to root node. */ const GG_DOUBLE_TAP_MS = 300; +// ─── RFC §5.5 pure decision helper ─────────────────────────────────────────── + +/** + * Decide what action to take when the user attempts to attach to a session. + * Pure function — no side effects, fully testable in isolation. + * + * Returns: + * { kind: "skip" } — node not found or session pending + * { kind: "graphView" } — id === "orchestrator" + * { kind: "resume" } — session is offloaded or resuming + * { kind: "switchClient" } — session is alive; issue switch-client + */ +export type AttachDecision = + | { kind: "skip" } + | { kind: "graphView" } + | { kind: "resume" } + | { kind: "switchClient" }; + +export function decideAttachAction( + id: string, + offloadStatus: "alive" | "offloaded" | "resuming", +): AttachDecision { + if (id === "orchestrator") return { kind: "graphView" }; + if (offloadStatus === "offloaded" || offloadStatus === "resuming") return { kind: "resume" }; + return { kind: "switchClient" }; +} + export function SessionGraphPanel() { const store = useStore(); const theme = useGraphTheme(); const tmuxSession = useContext(TmuxSessionContext); + const offloadManager = useOffloadManager(); useRenderer(); const { width: termW, height: termH } = useTerminalDimensions(); @@ -103,7 +135,7 @@ export function SessionGraphPanel() { }, [hasRunning]); const doAttach = useCallback( - (id: string) => { + async (id: string) => { const n = layout.map[id]; if (!n) return; // Only attach to started sessions (not pending) @@ -117,10 +149,28 @@ export function SessionGraphPanel() { } setFocusedId(id); + + // RFC §5.5 — gate switch-client on resume completion when offloaded. + const status = offloadManager.getStatus(id); + if (status === "offloaded" || status === "resuming") { + store.setViewMode("resuming", id); + try { + await offloadManager.requestResume(id); + store.setViewMode("attached", id); + // Resume succeeded — OffloadManager already issued selectWindow. + } catch (err) { + // OffloadManager already setSessionStatus(name, "offloaded") + emitted event. + store.showToast(`Failed to resume ${id}: ${errorMessage(err)}`); + // Stay on graph; do NOT issue switch-client against a dead window. + store.setViewMode("graph"); + } + return; + } + store.setViewMode("attached", id); - tmuxRun(["switch-client", "-t", `${tmuxSession}:${n.name}`]); + tmuxRun(["switch-client", "-t", `${tmuxSession}:${n.name}`]); // offload-exempt: status === "alive" }, - [layout.map, tmuxSession], + [layout.map, tmuxSession, offloadManager], ); const returnToGraph = useCallback(() => { @@ -202,7 +252,7 @@ export function SessionGraphPanel() { if (key.name === "return") { const agent = store.sessions[switcherSel]; closeSwitcher(); - if (agent) doAttach(agent.name); + if (agent) void doAttach(agent.name); return; } return; // Swallow all other keys while switcher is open @@ -246,7 +296,7 @@ export function SessionGraphPanel() { } // Enter: attach to focused node's tmux window if (key.name === "return") { - doAttach(focusedIdRef.current); + void doAttach(focusedIdRef.current); return; } @@ -339,6 +389,11 @@ export function SessionGraphPanel() { [storeVersion], ); + // Last logical window the user was focused on, tracked across poll ticks + // so we can detect focus-leave transitions and fire offload on the pane + // they just exited (Chrome-tab semantics — RFC §5.5 R4). + const prevActiveRef = useRef(""); + useEffect(() => { if (!hasStartedAgent) return; @@ -353,18 +408,51 @@ export function SessionGraphPanel() { const idx = spaceIdx >= 0 ? output.slice(0, spaceIdx) : output; const windowName = spaceIdx >= 0 ? output.slice(spaceIdx + 1) : ""; + // Logical name: window index 0 is always the orchestrator regardless + // of its tmux window name. + const currentName = idx === "0" ? "orchestrator" : windowName; + + // Update viewMode FIRST so offloadSession (called below) reads the + // already-updated activeAgentId. Without this ordering, the focus + // guard inside isEligibleForOffload would see the stale prev name + // and skip the offload. if (idx === "0") { if (store.viewMode !== "graph") { store.setViewMode("graph"); } - } else if (store.viewMode !== "attached" || store.activeAgentId !== windowName) { - store.setViewMode("attached", windowName); + } else { + // Map offload status → panel viewMode. "offloaded" and "resuming" both + // render as "resuming"; only "alive" flips to "attached" (RFC §5.5 R3). + const targetStatus = offloadManager.getStatus(windowName); + const desiredMode: ViewMode = targetStatus === "alive" ? "attached" : "resuming"; + if (store.viewMode !== desiredMode || store.activeAgentId !== windowName) { + store.setViewMode(desiredMode, windowName); + } + // Kick off resume only when actually offloaded; "resuming" means a + // prior tick already started one (requestResume coalesces but skip + // the redundant call), and "alive" needs no action. + if (targetStatus === "offloaded") { + void offloadManager.requestResume(windowName).catch(() => { + // OffloadManager already emitted RESUME_FAILED + reset status to "offloaded". + }); + } + } + + // Focus-leave: user just navigated AWAY from a stage pane. Offload it + // if eligible (non-headless, status === "complete"). The manager's own + // eligibility check filters out running/headless/already-offloaded + // sessions, so we can fire unconditionally. + const prevName = prevActiveRef.current; + if (prevName !== "" && prevName !== currentName && prevName !== "orchestrator") { + void offloadManager.offloadSession(prevName).catch(() => {}); } + + prevActiveRef.current = currentName; }; const id = setInterval(check, 500); return () => clearInterval(id); - }, [tmuxSession, hasStartedAgent]); + }, [tmuxSession, hasStartedAgent, offloadManager]); return ( @@ -435,6 +523,9 @@ export function SessionGraphPanel() { {/* Compact agent switcher overlay */} {switcherOpen ? : null} + + {/* Toast notifications — top-right, auto-dismiss (RFC §5.5) */} + ); } diff --git a/packages/atomic-sdk/src/components/status-helpers.test.ts b/packages/atomic-sdk/src/components/status-helpers.test.ts new file mode 100644 index 000000000..5c30a55f9 --- /dev/null +++ b/packages/atomic-sdk/src/components/status-helpers.test.ts @@ -0,0 +1,93 @@ +import { test, expect, describe } from "bun:test"; +import { statusColor, statusLabel, statusIcon } from "./status-helpers.ts"; +import type { GraphTheme } from "./graph-theme.ts"; + +// ─── Sentinel theme ────────────────────────────────────────────────────────── + +const theme: GraphTheme = { + background: "", + backgroundElement: "", + text: "", + textMuted: "", + textDim: "TEXTDIM", + primary: "", + success: "SUCCESS", + error: "ERROR", + warning: "WARNING", + info: "INFO", + mauve: "", + border: "", + borderActive: "", +}; + +// ─── statusColor ───────────────────────────────────────────────────────────── + +describe("statusColor", () => { + test("offloaded returns theme.textDim", () => { + expect(statusColor("offloaded", theme)).toBe("TEXTDIM"); + }); + + test("resuming returns theme.warning", () => { + expect(statusColor("resuming", theme)).toBe("WARNING"); + }); + + test("running returns theme.warning (regression)", () => { + expect(statusColor("running", theme)).toBe("WARNING"); + }); + + test("complete returns theme.success (regression)", () => { + expect(statusColor("complete", theme)).toBe("SUCCESS"); + }); + + test("unknown status returns theme.textDim (fallback)", () => { + expect(statusColor("unknown", theme)).toBe("TEXTDIM"); + }); +}); + +// ─── statusLabel ───────────────────────────────────────────────────────────── + +describe("statusLabel", () => { + test("offloaded returns 'offloaded'", () => { + expect(statusLabel("offloaded")).toBe("offloaded"); + }); + + test("resuming returns 'resuming…'", () => { + expect(statusLabel("resuming")).toBe("resuming…"); + }); + + test("running returns 'running' (regression)", () => { + expect(statusLabel("running")).toBe("running"); + }); + + test("complete returns 'done' (regression)", () => { + expect(statusLabel("complete")).toBe("done"); + }); + + test("unknown status returns the input string (fallback)", () => { + expect(statusLabel("unknown")).toBe("unknown"); + }); +}); + +// ─── statusIcon ────────────────────────────────────────────────────────────── + +describe("statusIcon", () => { + test("offloaded returns '◌'", () => { + expect(statusIcon("offloaded")).toBe("◌"); + }); + + test("resuming returns '◐'", () => { + expect(statusIcon("resuming")).toBe("◐"); + }); + + test("running returns '●' (regression)", () => { + expect(statusIcon("running")).toBe("●"); + }); + + test("complete returns '✓' (regression)", () => { + expect(statusIcon("complete")).toBe("✓"); + }); + + test("unknown status returns '○' (fallback)", () => { + expect(statusIcon("unknown")).toBe("○"); + }); +}); diff --git a/packages/atomic-sdk/src/components/status-helpers.ts b/packages/atomic-sdk/src/components/status-helpers.ts index a87c64a99..58b965a50 100644 --- a/packages/atomic-sdk/src/components/status-helpers.ts +++ b/packages/atomic-sdk/src/components/status-helpers.ts @@ -1,28 +1,38 @@ // ─── Status Helpers ─────────────────────────────── import type { GraphTheme } from "./graph-theme.ts"; +import type { SessionStatus } from "./orchestrator-panel-types.ts"; + +interface StatusEntry { + color: (theme: GraphTheme) => string; + label: string; + icon: string; +} + +const STATUS_TABLE: Record = { + running: { color: (t) => t.warning, label: "running", icon: "●" }, + complete: { color: (t) => t.success, label: "done", icon: "✓" }, + pending: { color: (t) => t.textDim, label: "waiting", icon: "○" }, + error: { color: (t) => t.error, label: "failed", icon: "✗" }, + awaiting_input: { color: (t) => t.info, label: "input needed", icon: "?" }, + offloaded: { color: (t) => t.textDim, label: "offloaded", icon: "◌" }, + resuming: { color: (t) => t.warning, label: "resuming…", icon: "◐" }, +}; + +function lookup(status: string): StatusEntry | undefined { + return STATUS_TABLE[status as SessionStatus]; +} export function statusColor(status: string, theme: GraphTheme): string { - return ( - { - running: theme.warning, - complete: theme.success, - pending: theme.textDim, - error: theme.error, - awaiting_input: theme.info, - }[status] ?? theme.textDim - ); + return lookup(status)?.color(theme) ?? theme.textDim; } export function statusLabel(status: string): string { - return ( - { running: "running", complete: "done", pending: "waiting", error: "failed", awaiting_input: "input needed" }[status] ?? - status - ); + return lookup(status)?.label ?? status; } export function statusIcon(status: string): string { - return { running: "●", complete: "✓", pending: "○", error: "✗", awaiting_input: "?" }[status] ?? "○"; + return lookup(status)?.icon ?? "○"; } // ─── Duration ───────────────────────────────────── diff --git a/packages/atomic-sdk/src/components/toast.tsx b/packages/atomic-sdk/src/components/toast.tsx new file mode 100644 index 000000000..c286dfd7a --- /dev/null +++ b/packages/atomic-sdk/src/components/toast.tsx @@ -0,0 +1,92 @@ +/** @jsxImportSource @opentui/react */ +/** + * Toast — top-right notification card, nvim-style. + * + * Renders a stack of severity-colored cards anchored to the top-right of + * the parent container. The PanelStore is the source of truth — this + * component only renders; auto-dismissal is owned by the store's setTimeout. + */ + +import { useStore, useGraphTheme, useStoreVersion } from "./orchestrator-panel-contexts.ts"; +import type { GraphTheme } from "./graph-theme.ts"; +import type { ToastEntry, ToastKind } from "./orchestrator-panel-store.ts"; + +const TOAST_WIDTH = 56; +const MAX_VISIBLE_TOASTS = 3; +const MAX_MESSAGE_CHARS = TOAST_WIDTH - 4; // minus border (2) + padding (2) + +interface SeverityStyle { + color: string; + icon: string; + label: string; +} + +function severityStyle(kind: ToastKind, theme: GraphTheme): SeverityStyle { + switch (kind) { + case "error": + return { color: theme.error, icon: "✗", label: "ERROR" }; + case "warning": + return { color: theme.warning, icon: "⚠", label: "WARN" }; + case "info": + return { color: theme.info, icon: "ℹ", label: "INFO" }; + } +} + +/** Truncate to fit a single line; preserves the head of the message. */ +function clip(message: string, max: number): string { + if (message.length <= max) return message; + return `${message.slice(0, max - 1)}…`; +} + +interface ToastCardProps { + entry: ToastEntry; +} + +function ToastCard({ entry }: ToastCardProps) { + const theme = useGraphTheme(); + const { color, icon, label } = severityStyle(entry.kind, theme); + const bg = theme.backgroundElement; + + return ( + + + {icon} + {label} + + + {clip(entry.message, MAX_MESSAGE_CHARS)} + + + ); +} + +/** + * Toast stack — top-right anchored. Newest at the top (reverse chronological). + * Renders nothing when no toasts are active. + */ +export function ToastStack() { + const store = useStore(); + useStoreVersion(store); + + if (store.toasts.length === 0) return null; + + // Show only the most recent N toasts; newest on top of the stack. + const visible = store.toasts.slice(-MAX_VISIBLE_TOASTS).slice().reverse(); + + return ( + + {visible.map((entry) => ( + + ))} + + ); +} diff --git a/packages/atomic-sdk/src/index.ts b/packages/atomic-sdk/src/index.ts index 21a779f6e..334ee66c8 100644 --- a/packages/atomic-sdk/src/index.ts +++ b/packages/atomic-sdk/src/index.ts @@ -87,6 +87,11 @@ export type { RunWorkflowResult, } from "./primitives/run.ts"; +// ─── Telemetry ─────────────────────────────────────────────────────────────── +export type { TelemetrySink } from "./runtime/executor.ts"; +export { setExecutorTelemetrySinks } from "./runtime/executor.ts"; +export { getProductionTelemetrySink } from "./lib/telemetry/index.ts"; + // ─── Session management ───────────────────────────────────────────────────── export { listSessions, @@ -107,3 +112,10 @@ export type { ListSessionsOptions, SessionPrimitiveDeps, } from "./primitives/sessions.ts"; + +// ─── Offload / resume ──────────────────────────────────────────────────────── +export { filterSpawnEnv, persistResume } from "./runtime/offload-manager.ts"; +export type { + OffloadManager, + OffloadManagerDeps, +} from "./runtime/offload-manager.ts"; diff --git a/packages/atomic-sdk/src/lib/telemetry/index.test.ts b/packages/atomic-sdk/src/lib/telemetry/index.test.ts new file mode 100644 index 000000000..58c7383ff --- /dev/null +++ b/packages/atomic-sdk/src/lib/telemetry/index.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"; +import { promises as fs } from "node:fs"; +import { join } from "node:path"; +import { mkdtempSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; + +import { getProductionTelemetrySink } from "./index.ts"; + +let baseDir: string; + +beforeEach(() => { + baseDir = mkdtempSync(join(tmpdir(), "telemetry-")); +}); + +afterEach(() => { + rmSync(baseDir, { recursive: true, force: true }); +}); + +/** Wait for any pending async work scheduled inside `emit`. */ +async function flushMicrotasks(): Promise { + // Two ticks: one for ensureDir's mkdir resolution, one for the appendFile chain. + await Promise.resolve(); + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 10)); +} + +describe("getProductionTelemetrySink", () => { + test("appends a JSON line to //telemetry.jsonl", async () => { + const runId = "run-1"; + const sink = getProductionTelemetrySink(runId, baseDir); + + sink.emit("offload.scheduled", { count: 3 }); + await flushMicrotasks(); + + const path = join(baseDir, runId, "telemetry.jsonl"); + const contents = await fs.readFile(path, "utf8"); + const lines = contents.trim().split("\n"); + expect(lines).toHaveLength(1); + + const entry = JSON.parse(lines[0] ?? "{}"); + expect(entry.event).toBe("offload.scheduled"); + expect(entry.payload).toEqual({ count: 3 }); + expect(typeof entry.ts).toBe("number"); + }); + + test("appends multiple events without collision", async () => { + // emit is fire-and-forget, so the appendFile order across concurrent + // emits isn't guaranteed once mkdir resolves. The contract is "all + // events land in the file, one line each" — not write-order parity. + const sink = getProductionTelemetrySink("run-2", baseDir); + + sink.emit("a", { i: 1 }); + sink.emit("b", { i: 2 }); + sink.emit("c", { i: 3 }); + await flushMicrotasks(); + + const path = join(baseDir, "run-2", "telemetry.jsonl"); + const lines = (await fs.readFile(path, "utf8")).trim().split("\n"); + expect(lines).toHaveLength(3); + const events = lines.map((l) => JSON.parse(l).event).sort(); + expect(events).toEqual(["a", "b", "c"]); + }); + + test("creates the runId subdirectory lazily on first emit", async () => { + const runId = "run-3"; + const sink = getProductionTelemetrySink(runId, baseDir); + const dirPath = join(baseDir, runId); + + // No directory yet — sink construction is side-effect-free. + expect(existsSync(dirPath)).toBe(false); + + sink.emit("ping", {}); + await flushMicrotasks(); + + expect(existsSync(dirPath)).toBe(true); + }); + + test("catch branch warns on appendFile failure (does not throw)", async () => { + // mkdir succeeds, but appendFile fails — exercises the .catch() lambda. + const sink = getProductionTelemetrySink("run-4", baseDir); + + const originalAppend = fs.appendFile; + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = mock((msg: string) => { + warnings.push(msg); + }); + + // Force appendFile to reject. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (fs as any).appendFile = mock(async () => { + throw new Error("disk full"); + }); + + try { + // emit returns void synchronously — failure is observed via console.warn. + expect(() => sink.emit("evt", {})).not.toThrow(); + await flushMicrotasks(); + + expect(warnings.length).toBeGreaterThanOrEqual(1); + expect(warnings.some((w) => w.includes("[telemetry]") && w.includes("disk full"))).toBe( + true, + ); + } finally { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (fs as any).appendFile = originalAppend; + console.warn = originalWarn; + } + }); +}); diff --git a/packages/atomic-sdk/src/lib/telemetry/index.ts b/packages/atomic-sdk/src/lib/telemetry/index.ts new file mode 100644 index 000000000..78f338cb7 --- /dev/null +++ b/packages/atomic-sdk/src/lib/telemetry/index.ts @@ -0,0 +1,38 @@ +import { promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import { join, dirname } from "node:path"; +import type { TelemetrySink } from "../../runtime/executor.ts"; + +export type { TelemetrySink } from "../../runtime/executor.ts"; + +/** + * Returns a TelemetrySink that appends JSON-lines to + * ~/.atomic/sessions//telemetry.jsonl with mode 0o600 (RFC §5.11). + * + * @param runId - Unique workflow run identifier; becomes the directory name. + * @param baseDir - Override the base directory (default: ~/.atomic/sessions). + * Pass a tmpdir in tests to avoid touching the real home dir. + */ +export function getProductionTelemetrySink( + runId: string, + baseDir: string = join(homedir(), ".atomic", "sessions"), +): TelemetrySink { + const path = join(baseDir, runId, "telemetry.jsonl"); + + let dirReady: Promise | null = null; + const ensureDir = (): Promise => { + dirReady ??= fs.mkdir(dirname(path), { recursive: true }).then(() => undefined); + return dirReady; + }; + + return { + emit(event: string, payload: Record): void { + const line = JSON.stringify({ ts: Date.now(), event, payload }) + "\n"; + ensureDir() + .then(() => fs.appendFile(path, line, { mode: 0o600, encoding: "utf8" })) + .catch((err: unknown) => { + console.warn(`[telemetry] append to ${path} failed: ${String(err)}`); + }); + }, + }; +} diff --git a/packages/atomic-sdk/src/providers/claude.buildResume.test.ts b/packages/atomic-sdk/src/providers/claude.buildResume.test.ts new file mode 100644 index 000000000..e49d7024f --- /dev/null +++ b/packages/atomic-sdk/src/providers/claude.buildResume.test.ts @@ -0,0 +1,79 @@ +/** + * Snapshot tests for buildClaudeResumeArgs. + * + * Verifies the exact argv array shape. Hook settings path is injected by the + * caller (from ensureWorkflowHookSettings()) — this file exercises the pure + * argv builder only. + */ + +import { test, expect, describe } from "bun:test"; +import { buildClaudeResumeArgs } from "./claude.ts"; + +type ClaudeMeta = Parameters[0]; + +const FIXTURE_CHAT_FLAGS: string[] = [ + "--allow-dangerously-skip-permissions", + "--dangerously-skip-permissions", +]; +const FIXTURE_META: ClaudeMeta = { + agentSessionId: "9f3a8f1d-1c0e-4b1f-9a2f-5e7d8b0e1a23", + chatFlags: FIXTURE_CHAT_FLAGS, +}; +const FIXTURE_HOOK_PATH = "/dev/null/fake-settings.json"; + +describe("buildClaudeResumeArgs()", () => { + test("returns array with --resume flag at index 0", () => { + const args = buildClaudeResumeArgs(FIXTURE_META, FIXTURE_HOOK_PATH); + expect(args[0]).toBe("--resume"); + }); + + test("places agentSessionId at index 1", () => { + const args = buildClaudeResumeArgs(FIXTURE_META, FIXTURE_HOOK_PATH); + expect(args[1]).toBe(FIXTURE_META.agentSessionId); + }); + + test("threads supplied chatFlags verbatim", () => { + const args = buildClaudeResumeArgs(FIXTURE_META, FIXTURE_HOOK_PATH); + for (const flag of FIXTURE_CHAT_FLAGS) { + expect(args).toContain(flag); + } + }); + + test("includes --settings flag followed by the injected path", () => { + const args = buildClaudeResumeArgs(FIXTURE_META, FIXTURE_HOOK_PATH); + const settingsIdx = args.indexOf("--settings"); + expect(settingsIdx).toBeGreaterThan(-1); + expect(args[settingsIdx + 1]).toBe(FIXTURE_HOOK_PATH); + }); + + test("exact structure: [--resume, , ...chatFlags, --settings, ]", () => { + const args = buildClaudeResumeArgs(FIXTURE_META, FIXTURE_HOOK_PATH); + expect(args).toEqual([ + "--resume", + FIXTURE_META.agentSessionId, + ...FIXTURE_CHAT_FLAGS, + "--settings", + FIXTURE_HOOK_PATH, + ]); + }); + + test("different agentSessionId produces different resume arg", () => { + const args1 = buildClaudeResumeArgs( + { agentSessionId: "uuid-aaa", chatFlags: FIXTURE_CHAT_FLAGS }, + FIXTURE_HOOK_PATH, + ); + const args2 = buildClaudeResumeArgs( + { agentSessionId: "uuid-bbb", chatFlags: FIXTURE_CHAT_FLAGS }, + FIXTURE_HOOK_PATH, + ); + expect(args1[1]).toBe("uuid-aaa"); + expect(args2[1]).toBe("uuid-bbb"); + }); + + test("injected hook path reflected verbatim in --settings position", () => { + const customPath = "/tmp/my-settings-abc123.json"; + const args = buildClaudeResumeArgs(FIXTURE_META, customPath); + const settingsIdx = args.indexOf("--settings"); + expect(args[settingsIdx + 1]).toBe(customPath); + }); +}); diff --git a/packages/atomic-sdk/src/providers/claude.buildResumeArgs.test.ts b/packages/atomic-sdk/src/providers/claude.buildResumeArgs.test.ts new file mode 100644 index 000000000..d249ca714 --- /dev/null +++ b/packages/atomic-sdk/src/providers/claude.buildResumeArgs.test.ts @@ -0,0 +1,120 @@ +/** + * RFC §5.4 tests for buildClaudeResumeArgs (pure argv builder) and + * ensureWorkflowHookSettings (side-effecting writer). + */ + +import { test, expect, describe } from "bun:test"; +import { statSync, readFileSync } from "node:fs"; +import { buildClaudeResumeArgs, ensureWorkflowHookSettings } from "./claude.ts"; + +type ClaudeMeta = Parameters[0]; +function meta(agentSessionId: string, chatFlags: string[] = []): ClaudeMeta { + return { agentSessionId, chatFlags }; +} + +describe("buildClaudeResumeArgs — pure argv builder", () => { + test("returns argv with injected hook path", () => { + const hookSettingsPath = "/dev/null/fake-settings.json"; + const args = buildClaudeResumeArgs(meta("uuid-fixture"), hookSettingsPath); + + const resumeIdx = args.indexOf("--resume"); + expect(resumeIdx).toBeGreaterThan(-1); + expect(args[resumeIdx + 1]).toBe("uuid-fixture"); + + const settingsIdx = args.indexOf("--settings"); + expect(settingsIdx).toBeGreaterThan(-1); + expect(args[settingsIdx + 1]).toBe("/dev/null/fake-settings.json"); + + // Order: --resume pair comes before --settings pair + expect(resumeIdx).toBeLessThan(settingsIdx); + }); + + test("is referentially transparent — same inputs, same outputs, no I/O", () => { + const hookSettingsPath = "/dev/null/fake-settings.json"; + + // Non-existent path must not throw (proves no I/O) + let args1: string[]; + let args2: string[]; + expect(() => { + args1 = buildClaudeResumeArgs(meta("uuid-fixture"), hookSettingsPath); + args2 = buildClaudeResumeArgs(meta("uuid-fixture"), hookSettingsPath); + }).not.toThrow(); + + expect(args1!).toEqual(args2!); + }); + + // RFC §5.4 — empty agentSessionId guards + test('throws "empty agentSessionId on resume" when agentSessionId is empty string', () => { + expect(() => + buildClaudeResumeArgs(meta(""), "/dev/null/fake-settings.json"), + ).toThrow("empty agentSessionId on resume"); + }); + + test('throws "empty agentSessionId on resume" when agentSessionId is null', () => { + expect(() => + buildClaudeResumeArgs( + { agentSessionId: null as unknown as string, chatFlags: [] }, + "/dev/null/fake-settings.json", + ), + ).toThrow("empty agentSessionId on resume"); + }); + + // RFC §5.4 §3 — no sentinel Enter token in valid resume args + test("valid agentSessionId: returned args do not contain the string Enter", () => { + const args = buildClaudeResumeArgs(meta("uuid-fixture"), "/dev/null/fake-settings.json"); + expect(args).not.toContain("Enter"); + }); + + // RFC §5.4 — chatFlags threading + + test("chatFlags: [] (empty array) produces no extra flags between resume id and --settings", () => { + const args = buildClaudeResumeArgs(meta("abc-123", []), "/hooks.json"); + expect(args).toEqual(["--resume", "abc-123", "--settings", "/hooks.json"]); + }); + + test("chatFlags: ['--model', 'opus'] → flags appear between resume id and --settings", () => { + const args = buildClaudeResumeArgs(meta("abc-123", ["--model", "opus"]), "/hooks.json"); + expect(args).toEqual([ + "--resume", + "abc-123", + "--model", + "opus", + "--settings", + "/hooks.json", + ]); + }); + + test("chatFlags: ['--add-dir', '/some/path'] → preserved verbatim", () => { + const args = buildClaudeResumeArgs( + meta("abc-123", ["--add-dir", "/some/path"]), + "/hooks.json", + ); + expect(args).toEqual([ + "--resume", + "abc-123", + "--add-dir", + "/some/path", + "--settings", + "/hooks.json", + ]); + }); +}); + +describe("ensureWorkflowHookSettings — side-effecting writer", () => { + test("writes settings file with 0o600 mode and valid JSON hook contents", () => { + const path = ensureWorkflowHookSettings(); + + const stat = statSync(path); + expect(stat.mode & 0o777).toBe(0o600); + + const contents = readFileSync(path, "utf-8"); + const parsed = JSON.parse(contents) as { hooks?: unknown }; + expect(parsed).toHaveProperty("hooks"); + }); + + test("returns same path on repeated calls (content-addressed, idempotent)", () => { + const path1 = ensureWorkflowHookSettings(); + const path2 = ensureWorkflowHookSettings(); + expect(path1).toBe(path2); + }); +}); diff --git a/packages/atomic-sdk/src/providers/claude.claudeOffloadCleanup.test.ts b/packages/atomic-sdk/src/providers/claude.claudeOffloadCleanup.test.ts new file mode 100644 index 000000000..679ebc367 --- /dev/null +++ b/packages/atomic-sdk/src/providers/claude.claudeOffloadCleanup.test.ts @@ -0,0 +1,133 @@ +/** + * Tests for claudeOffloadCleanup. + * + * Each test uses a tmpdir and injects a custom dirs object so claudeHookDirs() + * is never called with the real HOME (os.homedir() ignores runtime HOME changes). + */ + +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, mkdir, writeFile, access, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { claudeOffloadCleanup } from "./claude.ts"; + +/** Minimal shape matching ReturnType. */ +type HookDirs = Parameters[1]; + +let tmpHome: string; + +function makeDirs(base: string): NonNullable { + const inflightBase = join(base, "claude-inflight"); + return { + marker: join(base, "claude-stop"), + queue: join(base, "claude-queue"), + release: join(base, "claude-release"), + hil: join(base, "claude-hil"), + pid: join(base, "claude-pid"), + ready: join(base, "claude-ready"), + inflight: inflightBase, + inflightRoots: join(inflightBase, ".session-roots"), + }; +} + +beforeEach(async () => { + tmpHome = await mkdtemp(join(tmpdir(), "atomic-test-home-")); +}); + +afterEach(async () => { + try { + await rm(tmpHome, { recursive: true, force: true }); + } catch { /* ignore */ } +}); + +async function fileExists(p: string): Promise { + try { + await access(p); + return true; + } catch { + return false; + } +} + +describe("claudeOffloadCleanup()", () => { + test("empty agentSessionId returns all cleared:false, no throw", async () => { + const result = await claudeOffloadCleanup(""); + expect(result.readyCleared).toBe(false); + expect(result.stopCleared).toBe(false); + expect(result.pidCleared).toBe(false); + expect(result.inflightCleared).toBe(false); + expect(result.failures).toBe(0); + }); + + test("markers exist: all four removed, all cleared true, failures=0", async () => { + const id = "test-session-abc"; + const dirs = makeDirs(tmpHome); + + // Create the marker files/dirs + await mkdir(dirs.ready, { recursive: true }); + await mkdir(dirs.marker, { recursive: true }); + await mkdir(dirs.pid, { recursive: true }); + const inflightSessionDir = join(dirs.inflight, id); + await mkdir(inflightSessionDir, { recursive: true }); + + await writeFile(join(dirs.ready, id), ""); + await writeFile(join(dirs.marker, id), ""); + await writeFile(join(dirs.pid, id), "1234"); + await writeFile(join(inflightSessionDir, "some-agent"), ""); + + const result = await claudeOffloadCleanup(id, dirs); + + expect(result.readyCleared).toBe(true); + expect(result.stopCleared).toBe(true); + expect(result.pidCleared).toBe(true); + expect(result.inflightCleared).toBe(true); + expect(result.failures).toBe(0); + + // Files/dirs are actually gone + expect(await fileExists(join(dirs.ready, id))).toBe(false); + expect(await fileExists(join(dirs.marker, id))).toBe(false); + expect(await fileExists(join(dirs.pid, id))).toBe(false); + expect(await fileExists(inflightSessionDir)).toBe(false); + }); + + test("ENOENT for all targets: no throw, all cleared true (post-condition: absent)", async () => { + const id = "session-missing-files"; + const dirs = makeDirs(tmpHome); + // Don't create any dirs/files — pure ENOENT path + const result = await claudeOffloadCleanup(id, dirs); + + expect(result.readyCleared).toBe(true); + expect(result.stopCleared).toBe(true); + expect(result.pidCleared).toBe(true); + expect(result.inflightCleared).toBe(true); + expect(result.failures).toBe(0); + }); + + test("non-ENOENT error on one unlink: failures>0, no throw, others still attempted", async () => { + const id = "session-partial-fail"; + const dirs = makeDirs(tmpHome); + + // Create all parent dirs and files + await mkdir(dirs.ready, { recursive: true }); + await mkdir(dirs.marker, { recursive: true }); + await mkdir(dirs.pid, { recursive: true }); + + await writeFile(join(dirs.marker, id), ""); + await writeFile(join(dirs.pid, id), "1234"); + + // Replace the ready marker with a directory so unlink throws EISDIR (not ENOENT) + await mkdir(join(dirs.ready, id), { recursive: true }); + // Put a file inside so rm --force won't silently succeed on the unlink path + await writeFile(join(dirs.ready, id, "inner"), ""); + + const result = await claudeOffloadCleanup(id, dirs); + + // The ready marker unlink should fail (EISDIR is not ENOENT) + expect(result.failures).toBeGreaterThan(0); + expect(result.readyCleared).toBe(false); + // Other markers should still be attempted (stop and pid cleared) + expect(result.stopCleared).toBe(true); + expect(result.pidCleared).toBe(true); + // No throw — function must return normally + }); +}); diff --git a/packages/atomic-sdk/src/providers/claude.ts b/packages/atomic-sdk/src/providers/claude.ts index 18fc3c86b..2396acac8 100644 --- a/packages/atomic-sdk/src/providers/claude.ts +++ b/packages/atomic-sdk/src/providers/claude.ts @@ -25,8 +25,9 @@ import { type Options as SDKOptions, } from "@anthropic-ai/claude-agent-sdk"; import { respawnPane } from "../runtime/tmux.ts"; +import type { OffloadResumeMetadata } from "../runtime/offload-types.ts"; import { escBash } from "../runtime/executor.ts"; -import { watch, unlink, mkdir, writeFile } from "node:fs/promises"; +import { watch, unlink, mkdir, rm, writeFile } from "node:fs/promises"; import { existsSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { getDevCliPkgRoot } from "../lib/workspace-paths.ts"; @@ -250,7 +251,11 @@ const WORKFLOW_HOOK_SETTINGS = JSON.stringify({ hooks: { SessionStart: [ { - matcher: "startup", + // Match both fresh starts AND `--resume` so `claude-ready/` is + // written for both spawn paths. Without `resume`, offload→resume + // hangs `waitForClaudeReady` until its timeout because the hook + // never fires after `claude --resume `. + matcher: "startup|resume", hooks: [ { type: "command", @@ -429,7 +434,7 @@ async function spawnClaudeWithPrompt( chatFlags: string[], sessionId: string, ): Promise { - const settingsPath = workflowHookSettingsPath(); + const settingsPath = ensureWorkflowHookSettings(); const argvPrompt = `"${escBash(readPromptInstruction(promptFile))}"`; const cmd = [ "claude", @@ -459,16 +464,18 @@ async function spawnClaudeWithPrompt( await waitForReadyMarker(sessionId); } -function workflowHookSettingsPath(): string { +/** + * Write the workflow hook-settings JSON to a content-addressed temp file + * and return its absolute path. Idempotent: the path is hash-stable and the + * file is rewritten with mode 0o600 each call. + */ +export function ensureWorkflowHookSettings(): string { const path = atomicContentTempPath( "claude-settings-atomic", ".json", WORKFLOW_HOOK_SETTINGS, ); - writeFileSync(path, WORKFLOW_HOOK_SETTINGS, { - encoding: "utf-8", - mode: 0o600, - }); + writeFileSync(path, WORKFLOW_HOOK_SETTINGS, { encoding: "utf-8", mode: 0o600 }); return path; } @@ -477,7 +484,7 @@ function workflowHookSettingsPath(): string { * `~/.atomic/claude-ready/`. * * `atomic _claude-session-start-hook` is registered in - * {@link WORKFLOW_HOOK_SETTINGS} with matcher `startup`; the Claude CLI + * {@link WORKFLOW_HOOK_SETTINGS} with matcher `startup|resume`; the Claude CLI * dispatches it during spawn, before the first API call and before the JSONL * transcript is created. Waiting on the resulting marker file gives us a * positive "Claude is alive" signal instead of racing the transcript writer. @@ -1422,6 +1429,143 @@ export class HeadlessClaudeSessionWrapper { async disconnect(): Promise {} } +// --------------------------------------------------------------------------- +// Resume adapter +// --------------------------------------------------------------------------- + +/** + * Pure: produce the `claude --resume ` argv. Caller threads the + * settings path from `ensureWorkflowHookSettings()`. No I/O, no throws on + * filesystem state. + * + * `meta.chatFlags` is the effective merged spawn-time flag set captured by + * `OffloadManager.registerSession` (RFC §5.4). It is required by the schema + * — there is no legacy fallback. + * + * Produces: + * ["--resume", "", ...meta.chatFlags, "--settings", ""] + * + * Placement: `--resume` before the chat flags so Claude Code's last-wins + * flag semantics leave our `--settings` authoritative. + */ +export function buildClaudeResumeArgs( + meta: Pick, + hookSettingsPath: string, +): string[] { + if (meta.agentSessionId === "" || meta.agentSessionId == null) { + throw new Error("empty agentSessionId on resume"); + } + return [ + "--resume", + meta.agentSessionId, + ...meta.chatFlags, + "--settings", + hookSettingsPath, + ]; +} + +// --------------------------------------------------------------------------- +// Offload cleanup +// --------------------------------------------------------------------------- + +/** + * Result of a best-effort cleanup of all per-session marker files/dirs + * created during a Claude session's lifetime. + */ +export interface ClaudeMarkerCleanupResult { + /** `~/.atomic/claude-ready/` unlinked (or was already absent). */ + readyCleared: boolean; + /** `~/.atomic/claude-stop/` unlinked (or was already absent). */ + stopCleared: boolean; + /** `~/.atomic/claude-pid/` unlinked (or was already absent). */ + pidCleared: boolean; + /** `~/.atomic/claude-inflight//` removed recursively (or was already absent). */ + inflightCleared: boolean; + /** Number of non-ENOENT errors encountered. */ + failures: number; +} + +/** + * Best-effort cleanup of all per-session marker files/dirs for a Claude + * session. Safe to call multiple times — ENOENT is treated as "already + * cleared" (post-condition holds). Non-ENOENT errors are logged and counted + * in `result.failures` but never rethrown. + * + * Returns immediately with all `cleared: false` when `agentSessionId` is + * empty (RFC §Q12 guard). + * + * @param agentSessionId - Claude session UUID to clean up. + * @param _dirs - Override hook dirs (used by tests to isolate to a tmpdir). + * @internal The `_dirs` parameter is unstable; tests only. + */ +export async function claudeOffloadCleanup( + agentSessionId: string, + _dirs?: ReturnType, +): Promise { + // RFC §Q12: early-return on empty id + if (!agentSessionId) { + return { + readyCleared: false, + stopCleared: false, + pidCleared: false, + inflightCleared: false, + failures: 0, + }; + } + + const dirs = _dirs ?? claudeHookDirs(); + + const tryUnlink = async (filePath: string): Promise => { + try { + await unlink(filePath); + return true; + } catch (e: unknown) { + if (e instanceof Error && "code" in e && (e as NodeJS.ErrnoException).code === "ENOENT") { + return true; // Post-condition holds: file is absent + } + console.error(`[claudeOffloadCleanup] Failed to unlink ${filePath}:`, e); + return false; + } + }; + + const tryRmRecursive = async (dirPath: string): Promise => { + try { + await rm(dirPath, { recursive: true, force: true }); + return true; + } catch (e: unknown) { + if (e instanceof Error && "code" in e && (e as NodeJS.ErrnoException).code === "ENOENT") { + return true; // Already absent + } + console.error(`[claudeOffloadCleanup] Failed to remove ${dirPath}:`, e); + return false; + } + }; + + const results = await Promise.allSettled([ + tryUnlink(join(dirs.ready, agentSessionId)), + tryUnlink(join(dirs.marker, agentSessionId)), + tryUnlink(join(dirs.pid, agentSessionId)), + tryRmRecursive(join(dirs.inflight, agentSessionId)), + ]); + + const [readyResult, stopResult, pidResult, inflightResult] = results; + + const readyCleared = readyResult.status === "fulfilled" && readyResult.value; + const stopCleared = stopResult.status === "fulfilled" && stopResult.value; + const pidCleared = pidResult.status === "fulfilled" && pidResult.value; + const inflightCleared = inflightResult.status === "fulfilled" && inflightResult.value; + + // Count unexpected rejections (tryUnlink/tryRmRecursive never throw, so + // allSettled rejections would only come from truly unexpected errors in the + // async wrapper itself). + const failures = + [readyResult, stopResult, pidResult, inflightResult].filter( + (r) => r.status === "rejected" || (r.status === "fulfilled" && !r.value), + ).length; + + return { readyCleared, stopCleared, pidCleared, inflightCleared, failures }; +} + // --------------------------------------------------------------------------- // Static source validation // --------------------------------------------------------------------------- diff --git a/packages/atomic-sdk/src/providers/copilot.buildResume.test.ts b/packages/atomic-sdk/src/providers/copilot.buildResume.test.ts new file mode 100644 index 000000000..66b4fed45 --- /dev/null +++ b/packages/atomic-sdk/src/providers/copilot.buildResume.test.ts @@ -0,0 +1,62 @@ +/** + * Snapshot tests for buildCopilotResumeArgs. + * + * Key invariant: Copilot CLI requires `=` syntax (--resume=), NOT + * space-separated (--resume ). This file makes that constraint explicit. + */ + +import { test, expect, describe } from "bun:test"; +import { buildCopilotResumeArgs } from "./copilot.ts"; + +type CopilotMeta = Parameters[0]; + +const FIXTURE_META: CopilotMeta = { + agentSessionId: "cop-session-abc123def456", + chatFlags: [], +}; + +describe("buildCopilotResumeArgs()", () => { + test("returns exact array with server-mode prefix and --resume=", () => { + const args = buildCopilotResumeArgs(FIXTURE_META); + expect(args).toEqual(["--ui-server", "--port", "0", `--resume=${FIXTURE_META.agentSessionId}`]); + }); + + test("array length is 4 when chatFlags empty", () => { + const args = buildCopilotResumeArgs(FIXTURE_META); + expect(args).toHaveLength(4); + }); + + test("uses = syntax (not space-separated) for --resume", () => { + const args = buildCopilotResumeArgs(FIXTURE_META); + const resumeToken = args.find((a) => a.startsWith("--resume")); + expect(resumeToken).toBeDefined(); + expect(resumeToken).toContain("="); + // Must NOT produce a bare --resume entry (space-separated form) + expect(args).not.toContain("--resume"); + }); + + test("--resume= token is present", () => { + const args = buildCopilotResumeArgs(FIXTURE_META); + expect(args.some((a) => a.startsWith("--resume="))).toBe(true); + }); + + test("agentSessionId follows = without extra whitespace", () => { + const args = buildCopilotResumeArgs(FIXTURE_META); + expect(args).toContain(`--resume=${FIXTURE_META.agentSessionId}`); + }); + + test("different agentSessionId produces correct = form", () => { + const args = buildCopilotResumeArgs({ agentSessionId: "other-cop-id", chatFlags: [] }); + expect(args).toEqual(["--ui-server", "--port", "0", "--resume=other-cop-id"]); + }); + + test("server-mode flags --ui-server --port 0 precede --resume", () => { + const args = buildCopilotResumeArgs(FIXTURE_META); + const uiIdx = args.indexOf("--ui-server"); + const portIdx = args.indexOf("--port"); + const resumeIdx = args.findIndex((a) => a.startsWith("--resume=")); + expect(uiIdx).toBeGreaterThanOrEqual(0); + expect(portIdx).toBeGreaterThan(uiIdx); + expect(resumeIdx).toBeGreaterThan(portIdx); + }); +}); diff --git a/packages/atomic-sdk/src/providers/copilot.buildResumeArgs.test.ts b/packages/atomic-sdk/src/providers/copilot.buildResumeArgs.test.ts new file mode 100644 index 000000000..52142acb1 --- /dev/null +++ b/packages/atomic-sdk/src/providers/copilot.buildResumeArgs.test.ts @@ -0,0 +1,59 @@ +/** + * RFC §5.4 tests for buildCopilotResumeArgs — empty agentSessionId guards + * + chatFlags pass-through. + */ + +import { test, expect, describe } from "bun:test"; +import { buildCopilotResumeArgs } from "./copilot.ts"; + +type CopilotMeta = Parameters[0]; +function meta(agentSessionId: string, chatFlags: string[] = []): CopilotMeta { + return { agentSessionId, chatFlags }; +} + +describe("buildCopilotResumeArgs() — empty agentSessionId guards (RFC §5.4)", () => { + // Guard: empty string + test('throws "empty agentSessionId on resume" when agentSessionId is empty string', () => { + expect(() => + buildCopilotResumeArgs(meta("")), + ).toThrow("empty agentSessionId on resume"); + }); + + // Guard: null + test('throws "empty agentSessionId on resume" when agentSessionId is null', () => { + expect(() => + buildCopilotResumeArgs({ + agentSessionId: null as unknown as string, + chatFlags: [], + }), + ).toThrow("empty agentSessionId on resume"); + }); + + // RFC §5.4 §3 — no sentinel Enter token + test("valid agentSessionId: returned args do not contain the string Enter", () => { + const args = buildCopilotResumeArgs(meta("cop-session-valid-001")); + expect(args).not.toContain("Enter"); + }); + + // RFC §5.4 — chatFlags threading + + test("chatFlags: [] (empty array) produces server-mode prefix + --resume=id", () => { + const args = buildCopilotResumeArgs(meta("cop-123", [])); + expect(args).toEqual(["--ui-server", "--port", "0", "--resume=cop-123"]); + }); + + test("chatFlags: ['--model', 'opus'] → appended after --resume=id", () => { + const args = buildCopilotResumeArgs(meta("cop-123", ["--model", "opus"])); + expect(args).toEqual(["--ui-server", "--port", "0", "--resume=cop-123", "--model", "opus"]); + }); + + test("chatFlags: ['--add-dir', '/some/path'] → preserved verbatim", () => { + const args = buildCopilotResumeArgs(meta("cop-123", ["--add-dir", "/some/path"])); + expect(args).toEqual(["--ui-server", "--port", "0", "--resume=cop-123", "--add-dir", "/some/path"]); + }); + + test("chatFlags: ['--deny-tool', 'shell(git)'] → SCM-disable extra preserved", () => { + const args = buildCopilotResumeArgs(meta("cop-123", ["--deny-tool", "shell(git)"])); + expect(args).toEqual(["--ui-server", "--port", "0", "--resume=cop-123", "--deny-tool", "shell(git)"]); + }); +}); diff --git a/packages/atomic-sdk/src/providers/copilot.ts b/packages/atomic-sdk/src/providers/copilot.ts index 250f3ed73..2f182bd63 100644 --- a/packages/atomic-sdk/src/providers/copilot.ts +++ b/packages/atomic-sdk/src/providers/copilot.ts @@ -12,6 +12,7 @@ import type { SessionConfig as CopilotSessionConfig, } from "@github/copilot-sdk"; import { normalizedTerminalEnv } from "../lib/terminal-env.ts"; +import type { OffloadResumeMetadata } from "../runtime/offload-types.ts"; import { getCommandPath } from "../services/system/detect.ts"; import { createProviderValidator } from "../types.ts"; @@ -164,6 +165,34 @@ export function mergeCopilotSystemMessage( return { ...existing, content: merged }; } +// --------------------------------------------------------------------------- +// Resume adapter +// --------------------------------------------------------------------------- + +/** + * Build the `copilot` CLI argv fragment needed to resume an offloaded session. + * + * `meta.chatFlags` is the effective merged spawn-time flag set captured by + * `OffloadManager.registerSession` (RFC §5.4). It is required by the schema — + * there is no legacy fallback. + * + * Produces: ["--ui-server", "--port", "0", "--resume=", ...meta.chatFlags] + * + * `--ui-server --port 0` mirrors the original spawn (executor.ts buildSpawnArgs + * for `copilot`) — without it the resumed CLI starts in interactive mode and + * never binds a TCP port, so `waitForServer` would time out at 15s. + * + * Note: Copilot CLI requires `=` syntax (not space-separated) per spec §5.4. + */ +export function buildCopilotResumeArgs( + meta: Pick, +): string[] { + if (meta.agentSessionId === "" || meta.agentSessionId == null) { + throw new Error("empty agentSessionId on resume"); + } + return ["--ui-server", "--port", "0", `--resume=${meta.agentSessionId}`, ...meta.chatFlags]; +} + /** * Validate a Copilot workflow source file for common mistakes. */ diff --git a/packages/atomic-sdk/src/providers/opencode.buildResume.test.ts b/packages/atomic-sdk/src/providers/opencode.buildResume.test.ts new file mode 100644 index 000000000..15cc73b04 --- /dev/null +++ b/packages/atomic-sdk/src/providers/opencode.buildResume.test.ts @@ -0,0 +1,52 @@ +/** + * Snapshot tests for buildOpencodeResumeArgs. + */ + +import { test, expect, describe } from "bun:test"; +import { buildOpencodeResumeArgs } from "./opencode.ts"; + +type OpencodeMeta = Parameters[0]; + +const FIXTURE_META: OpencodeMeta = { + agentSessionId: "oc-session-7f3a2c1d-abcd-1234-5678-000000000001", + chatFlags: [], +}; + +describe("buildOpencodeResumeArgs()", () => { + test("returns exact array with server-mode prefix and [--session, ]", () => { + const args = buildOpencodeResumeArgs(FIXTURE_META); + expect(args).toEqual(["--port", "0", "--session", FIXTURE_META.agentSessionId]); + }); + + test("array length is 4 when chatFlags empty", () => { + const args = buildOpencodeResumeArgs(FIXTURE_META); + expect(args).toHaveLength(4); + }); + + test("--session token is present (not --session-id or --resume)", () => { + const args = buildOpencodeResumeArgs(FIXTURE_META); + expect(args).toContain("--session"); + expect(args).not.toContain("--session-id"); + expect(args).not.toContain("--resume"); + }); + + test("agentSessionId follows --session", () => { + const args = buildOpencodeResumeArgs(FIXTURE_META); + const sessionIdx = args.indexOf("--session"); + expect(args[sessionIdx + 1]).toBe(FIXTURE_META.agentSessionId); + }); + + test("different agentSessionId produces correct args", () => { + const args = buildOpencodeResumeArgs({ agentSessionId: "other-session", chatFlags: [] }); + expect(args).toEqual(["--port", "0", "--session", "other-session"]); + }); + + test("server-mode flag --port 0 precedes --session", () => { + const args = buildOpencodeResumeArgs(FIXTURE_META); + const portIdx = args.indexOf("--port"); + const sessionIdx = args.indexOf("--session"); + expect(portIdx).toBe(0); + expect(args[portIdx + 1]).toBe("0"); + expect(sessionIdx).toBeGreaterThan(portIdx); + }); +}); diff --git a/packages/atomic-sdk/src/providers/opencode.buildResumeArgs.test.ts b/packages/atomic-sdk/src/providers/opencode.buildResumeArgs.test.ts new file mode 100644 index 000000000..9448c3988 --- /dev/null +++ b/packages/atomic-sdk/src/providers/opencode.buildResumeArgs.test.ts @@ -0,0 +1,54 @@ +/** + * RFC §5.4 tests for buildOpencodeResumeArgs — empty agentSessionId guards + * + chatFlags pass-through. + */ + +import { test, expect, describe } from "bun:test"; +import { buildOpencodeResumeArgs } from "./opencode.ts"; + +type OpencodeMeta = Parameters[0]; +function meta(agentSessionId: string, chatFlags: string[] = []): OpencodeMeta { + return { agentSessionId, chatFlags }; +} + +describe("buildOpencodeResumeArgs() — empty agentSessionId guards (RFC §5.4)", () => { + // Guard: empty string + test('throws "empty agentSessionId on resume" when agentSessionId is empty string', () => { + expect(() => + buildOpencodeResumeArgs(meta("")), + ).toThrow("empty agentSessionId on resume"); + }); + + // Guard: null + test('throws "empty agentSessionId on resume" when agentSessionId is null', () => { + expect(() => + buildOpencodeResumeArgs({ + agentSessionId: null as unknown as string, + chatFlags: [], + }), + ).toThrow("empty agentSessionId on resume"); + }); + + // RFC §5.4 §3 — no sentinel Enter token + test("valid agentSessionId: returned args do not contain the string Enter", () => { + const args = buildOpencodeResumeArgs(meta("oc-session-valid-001")); + expect(args).not.toContain("Enter"); + }); + + // RFC §5.4 — chatFlags threading + + test("chatFlags: [] (empty array) produces server-mode prefix + ['--session', id]", () => { + const args = buildOpencodeResumeArgs(meta("oc-123", [])); + expect(args).toEqual(["--port", "0", "--session", "oc-123"]); + }); + + test("chatFlags: ['--model', 'opus'] → appended after session id", () => { + const args = buildOpencodeResumeArgs(meta("oc-123", ["--model", "opus"])); + expect(args).toEqual(["--port", "0", "--session", "oc-123", "--model", "opus"]); + }); + + test("chatFlags: ['--add-dir', '/some/path'] → preserved verbatim", () => { + const args = buildOpencodeResumeArgs(meta("oc-123", ["--add-dir", "/some/path"])); + expect(args).toEqual(["--port", "0", "--session", "oc-123", "--add-dir", "/some/path"]); + }); +}); diff --git a/packages/atomic-sdk/src/providers/opencode.ts b/packages/atomic-sdk/src/providers/opencode.ts index db6dd3323..10f9bc460 100644 --- a/packages/atomic-sdk/src/providers/opencode.ts +++ b/packages/atomic-sdk/src/providers/opencode.ts @@ -7,6 +7,7 @@ * `question` tool out of headless stages. */ +import type { OffloadResumeMetadata } from "../runtime/offload-types.ts"; import { createProviderValidator } from "../types.ts"; /** @@ -67,6 +68,32 @@ export async function withHeadlessOpencodeEnv( } } +// --------------------------------------------------------------------------- +// Resume adapter +// --------------------------------------------------------------------------- + +/** + * Build the `opencode` CLI argv fragment needed to resume an offloaded session. + * + * `meta.chatFlags` is the effective merged spawn-time flag set captured by + * `OffloadManager.registerSession` (RFC §5.4). It is required by the schema — + * there is no legacy fallback. + * + * Produces: ["--port", "0", "--session", "", ...meta.chatFlags] + * + * `--port 0` mirrors the original spawn (executor.ts buildSpawnArgs for + * `opencode`) — without it the resumed server doesn't bind a TCP port and + * `waitForServer` times out at 15s. + */ +export function buildOpencodeResumeArgs( + meta: Pick, +): string[] { + if (meta.agentSessionId == null || meta.agentSessionId === "") { + throw new Error("empty agentSessionId on resume"); + } + return ["--port", "0", "--session", meta.agentSessionId, ...meta.chatFlags]; +} + /** * Validate an OpenCode workflow source file for common mistakes. */ diff --git a/packages/atomic-sdk/src/runtime/executor.buildPaneCommand.test.ts b/packages/atomic-sdk/src/runtime/executor.buildPaneCommand.test.ts new file mode 100644 index 000000000..4f5675126 --- /dev/null +++ b/packages/atomic-sdk/src/runtime/executor.buildPaneCommand.test.ts @@ -0,0 +1,152 @@ +/** + * RFC §8.3 v8 — buildPaneCommand parity & server-flag lock-in tests. + * + * These tests are split from executor.test.ts to isolate the RFC §8.3 v8 + * requirements. They verify: + * 1. Per-agent parity: chatFlags matches the canonical AGENT_CLI defaults. + * 2. Server flags appear at the correct slice positions in chatFlags. + * 3. Extra flags are appended at the tail of chatFlags (not buried). + * 4. For copilot/opencode, command string contains chatFlags verbatim — + * the v7 P1 regression (chatFlags vs mergedChatFlags mismatch) would fail #4. + */ + +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { buildPaneCommand } from "./executor.ts"; + +// --------------------------------------------------------------------------- +// Known canonical defaults (mirrors AGENT_CLI in executor.ts). +// Must be kept in sync when AGENT_CLI changes. +// --------------------------------------------------------------------------- +const COPILOT_DEFAULT_CHAT_FLAGS = ["--add-dir", ".", "--yolo", "--experimental"]; +const OPENCODE_DEFAULT_CHAT_FLAGS: string[] = []; +// Claude chatFlags: canonical spawn-argv tail. NOT an exec argv (command is the +// shell, not claude). chatFlags is persisted into metadata.json#resume.chatFlags +// for byte-identical re-spawn on resume. +const CLAUDE_DEFAULT_CHAT_FLAGS = [ + "--allow-dangerously-skip-permissions", + "--dangerously-skip-permissions", +]; + +// --------------------------------------------------------------------------- +// §1 Parity tests — chatFlags matches AGENT_CLI canonical defaults +// --------------------------------------------------------------------------- +describe("buildPaneCommand parity — chatFlags matches AGENT_CLI defaults", () => { + test("copilot: chatFlags equals canonical default flags (no overrides)", () => { + const { chatFlags } = buildPaneCommand("copilot"); + expect(chatFlags).toEqual(COPILOT_DEFAULT_CHAT_FLAGS); + }); + + test("opencode: chatFlags equals canonical default flags (no overrides)", () => { + const { chatFlags } = buildPaneCommand("opencode"); + expect(chatFlags).toEqual(OPENCODE_DEFAULT_CHAT_FLAGS); + }); + + test("claude: chatFlags equals canonical default flags (no overrides)", () => { + // NOTE: For claude, `command` is the resolved shell path, NOT `claude`. + // chatFlags is the canonical spawn-argv tail used at resume time. + // Parity invariant: chatFlags matches AGENT_CLI.claude.chatFlags. + const { chatFlags } = buildPaneCommand("claude"); + expect(chatFlags).toEqual(CLAUDE_DEFAULT_CHAT_FLAGS); + }); +}); + +// --------------------------------------------------------------------------- +// §2 Server-flag inclusion — positions in chatFlags (not just command string) +// --------------------------------------------------------------------------- +describe("buildPaneCommand server-flag inclusion in chatFlags", () => { + test("copilot: chatFlags starts with ['--ui-server', '--port', '0']", () => { + // RFC §8.3: copilot server flags prepend chatFlags + // NOTE: copilot server flags are prepended in command construction but + // NOT in chatFlags — chatFlags holds defaults/overrides only. + // Server flags appear in command string. This test verifies command string. + const { command, chatFlags } = buildPaneCommand("copilot"); + expect(command).toContain("--ui-server"); + expect(command).toContain("--port"); + expect(command).toContain("0"); + // The server flags are part of the command but NOT in chatFlags + // (chatFlags = spawn-argv persisted for resume, not the full tmux pane line) + expect(chatFlags).toEqual(COPILOT_DEFAULT_CHAT_FLAGS); + }); + + test("opencode: command contains ['--port', '0'] prefix (not --ui-server)", () => { + const { command, chatFlags } = buildPaneCommand("opencode"); + expect(command).toContain("--port"); + expect(command).toContain("0"); + expect(command).not.toContain("--ui-server"); + expect(chatFlags).toEqual(OPENCODE_DEFAULT_CHAT_FLAGS); + }); + + test("claude: chatFlags does NOT contain '--ui-server' or '--port'", () => { + const { chatFlags } = buildPaneCommand("claude"); + expect(chatFlags).not.toContain("--ui-server"); + expect(chatFlags).not.toContain("--port"); + }); +}); + +// --------------------------------------------------------------------------- +// §3 Extra flags appended at tail of chatFlags +// --------------------------------------------------------------------------- +describe("buildPaneCommand extra flags appended at chatFlags tail", () => { + test("copilot: chatFlags ends with --my-extra and starts with default flags", () => { + const { chatFlags } = buildPaneCommand("copilot", {}, ["--my-extra"]); + // Tail: extra flag is last + expect(chatFlags[chatFlags.length - 1]).toBe("--my-extra"); + // Head: default flags preserved at front + expect(chatFlags.slice(0, COPILOT_DEFAULT_CHAT_FLAGS.length)).toEqual( + COPILOT_DEFAULT_CHAT_FLAGS, + ); + }); + + test("opencode: chatFlags ends with --my-extra", () => { + const { chatFlags } = buildPaneCommand("opencode", {}, ["--my-extra"]); + expect(chatFlags[chatFlags.length - 1]).toBe("--my-extra"); + }); + + test("claude: chatFlags ends with --my-extra and starts with default flags", () => { + const { chatFlags } = buildPaneCommand("claude", {}, ["--my-extra"]); + expect(chatFlags[chatFlags.length - 1]).toBe("--my-extra"); + expect(chatFlags.slice(0, CLAUDE_DEFAULT_CHAT_FLAGS.length)).toEqual( + CLAUDE_DEFAULT_CHAT_FLAGS, + ); + }); +}); + +// --------------------------------------------------------------------------- +// §4 command contains chatFlags — v7 P1 regression guard +// (chatFlags vs mergedChatFlags mismatch: ...chatFlags instead of ...mergedChatFlags) +// --------------------------------------------------------------------------- +describe("buildPaneCommand command string contains chatFlags (v7 P1 regression guard)", () => { + test("copilot: command includes chatFlags joined as string", () => { + const { command, chatFlags } = buildPaneCommand("copilot"); + if (chatFlags.length > 0) { + expect(command).toContain(chatFlags.join(" ")); + } else { + // No flags to check — pass trivially + expect(true).toBe(true); + } + }); + + test("opencode: command includes chatFlags joined as string", () => { + const { command, chatFlags } = buildPaneCommand("opencode"); + if (chatFlags.length > 0) { + expect(command).toContain(chatFlags.join(" ")); + } else { + // opencode default chatFlags is empty — verify command starts with binary + expect(command).toMatch(/opencode/); + } + }); + + test("copilot with extra flags: command includes all of chatFlags (extra appended)", () => { + const { command, chatFlags } = buildPaneCommand("copilot", {}, ["--extra-regression-check"]); + // v7 bug: chatFlags would be stale defaults, mergedChatFlags had extras — + // command used ...chatFlags so extra never appeared. Now both match. + expect(command).toContain(chatFlags.join(" ")); + expect(chatFlags).toContain("--extra-regression-check"); + }); + + test("opencode with extra flags: command includes all of chatFlags (extra appended)", () => { + const { command, chatFlags } = buildPaneCommand("opencode", {}, ["--extra-regression-check"]); + expect(command).toContain(chatFlags.join(" ")); + expect(chatFlags).toContain("--extra-regression-check"); + }); +}); diff --git a/packages/atomic-sdk/src/runtime/executor.loggedKillWindow.test.ts b/packages/atomic-sdk/src/runtime/executor.loggedKillWindow.test.ts new file mode 100644 index 000000000..967e113c0 --- /dev/null +++ b/packages/atomic-sdk/src/runtime/executor.loggedKillWindow.test.ts @@ -0,0 +1,192 @@ +/** + * Tests for loggedKillWindow: reserved-name rejection emits telemetry + warn. + * + * Uses the production seam (setExecutorTelemetrySinks) to capture telemetry + * and warn calls without touching real sinks or real tmux. + */ + +import { test, expect, describe, afterEach } from "bun:test"; +import { + _loggedKillWindowForTest, + setExecutorTelemetrySinks, + type TelemetrySink, +} from "./executor.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeSinks(): { + telemetry: TelemetrySink & { calls: Array<{ event: string; payload: Record }> }; + warnCalls: string[]; + warn: (msg: string) => void; +} { + const calls: Array<{ event: string; payload: Record }> = []; + const warnCalls: string[] = []; + const telemetry: TelemetrySink & { calls: typeof calls } = { + calls, + emit(event: string, payload: Record): void { + calls.push({ event, payload }); + }, + }; + const warn = (msg: string): void => { warnCalls.push(msg); }; + return { telemetry, warnCalls, warn }; +} + +afterEach(() => { + // Restore default sinks after every test. + setExecutorTelemetrySinks({}); +}); + +// --------------------------------------------------------------------------- +// Case 1: reserved name "orchestrator" — stage-error origin +// --------------------------------------------------------------------------- + +describe('loggedKillWindow — reserved name "orchestrator"', () => { + test("does not throw", async () => { + const { telemetry, warn } = makeSinks(); + setExecutorTelemetrySinks({ telemetry, warn }); + + const result = await _loggedKillWindowForTest("any-session", "orchestrator", "stage-error"); + expect(result).toBeUndefined(); + }); + + test("emits telemetry once with correct event and payload", async () => { + const { telemetry, warn } = makeSinks(); + setExecutorTelemetrySinks({ telemetry, warn }); + + await _loggedKillWindowForTest("any-session", "orchestrator", "stage-error"); + + expect(telemetry.calls).toHaveLength(1); + const call0 = telemetry.calls[0]!; + expect(call0.event).toBe("workflow.tmux.kill_window_rejected"); + expect(call0.payload.windowName).toBe("orchestrator"); + expect(call0.payload.origin).toBe("stage-error"); + expect(call0.payload.error).toContain("reserved"); + }); + + test("calls warn once with windowName, origin, and error message", async () => { + const { telemetry, warnCalls, warn } = makeSinks(); + setExecutorTelemetrySinks({ telemetry, warn }); + + await _loggedKillWindowForTest("any-session", "orchestrator", "stage-error"); + + expect(warnCalls).toHaveLength(1); + expect(warnCalls[0]).toContain("orchestrator"); + expect(warnCalls[0]).toContain("stage-error"); + expect(warnCalls[0]).toContain("reserved"); + }); +}); + +// --------------------------------------------------------------------------- +// Case 2: reserved name "0" — abort-cleanup origin +// --------------------------------------------------------------------------- + +describe('loggedKillWindow — reserved name "0"', () => { + test("does not throw", async () => { + const { telemetry, warn } = makeSinks(); + setExecutorTelemetrySinks({ telemetry, warn }); + + const result = await _loggedKillWindowForTest("any-session", "0", "abort-cleanup"); + expect(result).toBeUndefined(); + }); + + test("emits telemetry with windowName='0' and origin='abort-cleanup'", async () => { + const { telemetry, warn } = makeSinks(); + setExecutorTelemetrySinks({ telemetry, warn }); + + await _loggedKillWindowForTest("any-session", "0", "abort-cleanup"); + + expect(telemetry.calls).toHaveLength(1); + const callA = telemetry.calls[0]!; + expect(callA.payload.windowName).toBe("0"); + expect(callA.payload.origin).toBe("abort-cleanup"); + expect(callA.payload.error).toContain("reserved"); + }); + + test("calls warn with '0', 'abort-cleanup', and error fragment", async () => { + const { telemetry, warnCalls, warn } = makeSinks(); + setExecutorTelemetrySinks({ telemetry, warn }); + + await _loggedKillWindowForTest("any-session", "0", "abort-cleanup"); + + expect(warnCalls).toHaveLength(1); + expect(warnCalls[0]).toContain("0"); + expect(warnCalls[0]).toContain("abort-cleanup"); + expect(warnCalls[0]).toContain("reserved"); + }); +}); + +// --------------------------------------------------------------------------- +// Case 3: empty windowName — treated as reserved +// --------------------------------------------------------------------------- + +describe("loggedKillWindow — empty windowName", () => { + test("does not throw", async () => { + const { telemetry, warn } = makeSinks(); + setExecutorTelemetrySinks({ telemetry, warn }); + + const result = await _loggedKillWindowForTest("any-session", "", "stage-error"); + expect(result).toBeUndefined(); + }); + + test("emits telemetry with windowName=''", async () => { + const { telemetry, warn } = makeSinks(); + setExecutorTelemetrySinks({ telemetry, warn }); + + await _loggedKillWindowForTest("any-session", "", "stage-error"); + + expect(telemetry.calls).toHaveLength(1); + expect(telemetry.calls[0]!.payload.windowName).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- +// Case 4: "already dead" path — tmux.killWindow swallows internally, no telemetry +// --------------------------------------------------------------------------- + +describe("loggedKillWindow — already-dead window (tmux swallows internally)", () => { + test("telemetry NOT called", async () => { + // killWindow for a non-reserved name resolves even if the underlying + // tmuxExec fails (window already dead). The wrapper sees no rejection. + // We need a non-reserved name that reaches killWindow without tmux running. + // killWindow catches tmuxExec failures internally and returns normally. + // So passing a non-reserved name should always resolve (no tmux binary needed). + const { telemetry, warn } = makeSinks(); + setExecutorTelemetrySinks({ telemetry, warn }); + + // "work-session-pane" is not reserved; killWindow will try tmuxExec and + // swallow any error (no server running), returning normally. + await _loggedKillWindowForTest("any-session", "work-pane", "stage-error"); + + expect(telemetry.calls).toHaveLength(0); + }); + + test("warn NOT called", async () => { + const { telemetry, warnCalls, warn } = makeSinks(); + setExecutorTelemetrySinks({ telemetry, warn }); + + await _loggedKillWindowForTest("any-session", "work-pane", "abort-cleanup"); + + expect(warnCalls).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Case 5: happy path — successful kill (tmuxExec succeeds) +// --------------------------------------------------------------------------- + +describe("loggedKillWindow — happy path (successful kill)", () => { + test("returns undefined without telemetry or warn", async () => { + const { telemetry, warnCalls, warn } = makeSinks(); + setExecutorTelemetrySinks({ telemetry, warn }); + + // Non-reserved name; if tmux is not running tmuxExec failure is swallowed by killWindow itself. + const result = await _loggedKillWindowForTest("any-session", "worker-1", "stage-error"); + + expect(result).toBeUndefined(); + expect(telemetry.calls).toHaveLength(0); + expect(warnCalls).toHaveLength(0); + }); +}); + diff --git a/packages/atomic-sdk/src/runtime/executor.offload-wiring.test.ts b/packages/atomic-sdk/src/runtime/executor.offload-wiring.test.ts new file mode 100644 index 000000000..58be4bcb0 --- /dev/null +++ b/packages/atomic-sdk/src/runtime/executor.offload-wiring.test.ts @@ -0,0 +1,492 @@ +/** + * Contract tests for RFC §5.2.4 executor offload-wiring invariants. + * + * Tests target `persistAndRegisterStage` — the exported helper extracted from + * `createSessionRunner` that owns the Bun.write → registerSession sequence. + * + * Four invariants: + * 1. Order — Bun.write(metadata.json) happens-before registerSession. + * 2. Awaited — registerSession is fully awaited; continuation is blocked. + * 3. Rejection observability — rejection swallowed, console.warn fired, stage continues. + * 4. Headless skip — headless:true still awaits registerSession; write still precedes it. + */ + +import { test, expect, mock, spyOn, beforeEach, afterEach, describe } from "bun:test"; +import { join } from "node:path"; +import { persistAndRegisterStage, defaultWaitForAgentReady } from "./executor.ts"; +import type { OffloadManager } from "./offload-manager.ts"; + +// ─── shared helpers ─────────────────────────────────────────────────────────── + +const STAGE_DIR = "/tmp/test-stage-abc123"; +const STAGE_NAME = "my-stage"; + +function makeMetadata(overrides?: Partial[1]>) { + return { + name: STAGE_NAME, + description: "test stage", + agent: "claude" as const, + paneId: "pane-1", + serverUrl: "http://localhost:4242", + port: 4242, + startedAt: new Date().toISOString(), + ...overrides, + }; +} + +function makeRegisterInput(overrides?: Partial[3]>) { + return { + name: STAGE_NAME, + runId: "run-001", + stageDir: STAGE_DIR, + agent: "claude" as const, + agentSessionId: "sess-abc", + tmuxSession: "atomic-session", + tmuxWindow: STAGE_NAME, + spawnEnv: { PATH: "/usr/bin" }, + spawnCwd: "/home/user/project", + chatFlags: [] as string[], + headless: false, + ...overrides, + }; +} + +// ─── setup/teardown ─────────────────────────────────────────────────────────── + +let bunWriteSpy: ReturnType; +let unhandledRejections: unknown[] = []; +const unhandledHandler = (reason: unknown) => { + unhandledRejections.push(reason); +}; + +beforeEach(() => { + // Suppress real disk I/O for Bun.write — default mock resolves immediately. + bunWriteSpy = spyOn(Bun, "write").mockImplementation(() => + Promise.resolve(0), + ); + unhandledRejections = []; + process.on("unhandledRejection", unhandledHandler); +}); + +afterEach(() => { + bunWriteSpy.mockRestore(); + process.removeListener("unhandledRejection", unhandledHandler); +}); + +// ─── 1. Order ───────────────────────────────────────────────────────────────── + +test("§5.2.4 invariant 1 — Bun.write(metadata.json) is called BEFORE registerSession", async () => { + const calls: string[] = []; + let order = 0; + + // Async Bun.write mock so ordering is observable even with await chains. + bunWriteSpy.mockImplementation((_path: unknown, _data: unknown) => { + return new Promise((resolve) => { + calls.push(`write:${++order}`); + // Resolve on next microtask to make the ordering non-trivial. + Promise.resolve().then(() => resolve(0)); + }); + }); + + const mockOffloadManager: OffloadManager = { + registerSession: mock(async () => { + calls.push(`register:${++order}`); + }), + offloadSession: mock(async () => {}), + onWorkflowCompletion: mock(async () => {}), + requestResume: mock(async () => {}), + getStatus: mock(() => "alive" as const), + }; + + await persistAndRegisterStage( + STAGE_DIR, + makeMetadata(), + mockOffloadManager, + makeRegisterInput(), + ); + + // write must appear before register in the calls array + const writeIdx = calls.findIndex((c) => c.startsWith("write:")); + const registerIdx = calls.findIndex((c) => c.startsWith("register:")); + + expect(writeIdx).toBeGreaterThanOrEqual(0); + expect(registerIdx).toBeGreaterThanOrEqual(0); + expect(writeIdx).toBeLessThan(registerIdx); +}); + +test("§5.2.4 invariant 1 — Bun.write path ends with metadata.json", async () => { + let capturedPath: string | undefined; + + bunWriteSpy.mockImplementation((pathOrUrl: unknown, _data: unknown) => { + if (typeof pathOrUrl === "string") capturedPath = pathOrUrl; + return Promise.resolve(0); + }); + + const mockOffloadManager: OffloadManager = { + registerSession: mock(async () => {}), + offloadSession: mock(async () => {}), + onWorkflowCompletion: mock(async () => {}), + requestResume: mock(async () => {}), + getStatus: mock(() => "alive" as const), + }; + + await persistAndRegisterStage( + STAGE_DIR, + makeMetadata(), + mockOffloadManager, + makeRegisterInput(), + ); + + expect(capturedPath).toBeDefined(); + expect(capturedPath!.endsWith("metadata.json")).toBe(true); + expect(capturedPath).toBe(join(STAGE_DIR, "metadata.json")); +}); + +// ─── 2. Awaited ─────────────────────────────────────────────────────────────── + +test("§5.2.4 invariant 2 — registerSession is fully awaited before persistAndRegisterStage resolves", async () => { + let registerSessionResolve!: () => void; + let registerSessionSettled = false; + + const delayedRegisterSession = () => + new Promise((resolve) => { + registerSessionResolve = () => { + registerSessionSettled = true; + resolve(); + }; + }); + + const mockOffloadManager: OffloadManager = { + registerSession: mock(delayedRegisterSession), + offloadSession: mock(async () => {}), + onWorkflowCompletion: mock(async () => {}), + requestResume: mock(async () => {}), + getStatus: mock(() => "alive" as const), + }; + + let persistResolved = false; + const persistPromise = persistAndRegisterStage( + STAGE_DIR, + makeMetadata(), + mockOffloadManager, + makeRegisterInput(), + ).then(() => { + persistResolved = true; + }); + + // Drain microtasks — Bun.write resolves, but registerSession hasn't yet. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + // persistAndRegisterStage must NOT have resolved yet. + expect(persistResolved).toBe(false); + expect(registerSessionSettled).toBe(false); + + // Now release the delayed registerSession. + registerSessionResolve(); + await persistPromise; + + expect(persistResolved).toBe(true); + expect(registerSessionSettled).toBe(true); +}); + +// ─── 3. Rejection observability ────────────────────────────────────────────── + +test("§5.2.4 invariant 3 — rejected registerSession is swallowed and console.warn fires with stage name + error message", async () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + + const errorMsg = "metadata.json not found at /tmp/foo"; + const mockOffloadManager: OffloadManager = { + registerSession: mock(() => Promise.reject(new Error(errorMsg))), + offloadSession: mock(async () => {}), + onWorkflowCompletion: mock(async () => {}), + requestResume: mock(async () => {}), + getStatus: mock(() => "alive" as const), + }; + + let continuationExecuted = false; + + // persistAndRegisterStage must not throw even though registerSession rejects. + await persistAndRegisterStage( + STAGE_DIR, + makeMetadata(), + mockOffloadManager, + makeRegisterInput(), + ); + continuationExecuted = true; + + // (a) continuation ran + expect(continuationExecuted).toBe(true); + + // (b) console.warn called once with expected content + expect(warnSpy).toHaveBeenCalledTimes(1); + const warnArg = (warnSpy.mock.calls[0] as unknown[])[0] as string; + expect(warnArg).toContain(`[offload] registerSession failed for stage ${STAGE_NAME}`); + expect(warnArg).toContain(errorMsg); + + // (c) no unhandledRejection + // Yield to event loop to let any dangling rejections surface. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(unhandledRejections).toHaveLength(0); + + warnSpy.mockRestore(); +}); + +test("§5.2.4 invariant 3 — registerSession rejection does not bubble as thrown error", async () => { + spyOn(console, "warn").mockImplementation(() => {}); + + const mockOffloadManager: OffloadManager = { + registerSession: mock(() => Promise.reject(new Error("boom"))), + offloadSession: mock(async () => {}), + onWorkflowCompletion: mock(async () => {}), + requestResume: mock(async () => {}), + getStatus: mock(() => "alive" as const), + }; + + // Must not throw + let caught: unknown = null; + let resolved: unknown = "unset"; + try { + resolved = await persistAndRegisterStage( + STAGE_DIR, + makeMetadata(), + mockOffloadManager, + makeRegisterInput(), + ); + } catch (err) { + caught = err; + } + expect(caught).toBeNull(); + expect(resolved).toBeUndefined(); +}); + +// ─── 4. Headless skip ──────────────────────────────────────────────────────── + +test("§5.2.4 invariant 4 — headless:true still awaits registerSession fully", async () => { + let registerSessionResolve!: () => void; + let registerSessionSettled = false; + + const delayedRegisterSession = () => + new Promise((resolve) => { + registerSessionResolve = () => { + registerSessionSettled = true; + resolve(); + }; + }); + + const mockOffloadManager: OffloadManager = { + registerSession: mock(delayedRegisterSession), + offloadSession: mock(async () => {}), + onWorkflowCompletion: mock(async () => {}), + requestResume: mock(async () => {}), + getStatus: mock(() => "alive" as const), + }; + + let persistResolved = false; + const persistPromise = persistAndRegisterStage( + STAGE_DIR, + makeMetadata(), + mockOffloadManager, + makeRegisterInput({ headless: true }), + ).then(() => { + persistResolved = true; + }); + + // Drain microtasks + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(persistResolved).toBe(false); + expect(registerSessionSettled).toBe(false); + + registerSessionResolve(); + await persistPromise; + + expect(persistResolved).toBe(true); + expect(registerSessionSettled).toBe(true); +}); + +test("§5.2.4 invariant 4 — headless:true: Bun.write still called before registerSession", async () => { + const calls: string[] = []; + let order = 0; + + bunWriteSpy.mockImplementation((_path: unknown, _data: unknown) => { + return new Promise((resolve) => { + calls.push(`write:${++order}`); + Promise.resolve().then(() => resolve(0)); + }); + }); + + const mockOffloadManager: OffloadManager = { + registerSession: mock(async () => { + calls.push(`register:${++order}`); + }), + offloadSession: mock(async () => {}), + onWorkflowCompletion: mock(async () => {}), + requestResume: mock(async () => {}), + getStatus: mock(() => "alive" as const), + }; + + await persistAndRegisterStage( + STAGE_DIR, + makeMetadata(), + mockOffloadManager, + makeRegisterInput({ headless: true }), + ); + + const writeIdx = calls.findIndex((c) => c.startsWith("write:")); + const registerIdx = calls.findIndex((c) => c.startsWith("register:")); + + expect(writeIdx).toBeGreaterThanOrEqual(0); + expect(registerIdx).toBeGreaterThanOrEqual(0); + expect(writeIdx).toBeLessThan(registerIdx); +}); + +// ─── defaultWaitForAgentReady — RFC §5.2.2(d) readiness probes ─────────────── +// +// Tests verify RESUME_TIMEOUT_ is thrown when the agent session cannot +// be confirmed within the deadline. Module mocks stand in for real tmux panes +// and SDK servers so the tests are fast and self-contained. + +// Snapshot real modules BEFORE any mock.module call so afterEach can restore. +const _tmuxModOW = await import("./tmux.ts"); +const _realTmuxSnapshotOW = { ..._tmuxModOW }; +const _portModOW = await import("./port-discovery.ts"); +const _realPortSnapshotOW = { ..._portModOW }; +let _realOcSdkSnapshotOW: Record | null = null; +try { + const _ocMod = await import("@opencode-ai/sdk/v2"); + _realOcSdkSnapshotOW = { ..._ocMod }; +} catch { /* not installed in all environments */ } +let _realCopilotSdkSnapshotOW: Record | null = null; +try { + const _copilotMod = await import("@github/copilot-sdk"); + _realCopilotSdkSnapshotOW = { ..._copilotMod }; +} catch { /* not installed in all environments */ } + +describe("defaultWaitForAgentReady — readiness probes (RFC §5.2.2(d))", () => { + let originalSleep: typeof Bun.sleep; + let realDateNow: typeof Date.now; + + beforeEach(() => { + originalSleep = Bun.sleep; + // Make Bun.sleep instant so probe retry loops resolve immediately. + (globalThis as { Bun: { sleep: (ms: number) => Promise } }).Bun.sleep = + () => Promise.resolve(); + + // Mock tmux so waitForServer sees a "ready" pane with a PID. + mock.module("./tmux.ts", () => ({ + capturePane: () => "line1\nline2\nline3\n", + getPanePid: () => 99999, + spawnMuxAttach: () => {}, + })); + + // Mock port discovery to return a port immediately. + mock.module("./port-discovery.ts", () => ({ + getListeningPortForPid: async () => 54321, + PORT_DISCOVERY_TIMEOUT_MS: 100, + })); + }); + + afterEach(() => { + (globalThis as { Bun: { sleep: typeof Bun.sleep } }).Bun.sleep = originalSleep; + mock.module("./tmux.ts", () => _realTmuxSnapshotOW); + mock.module("./port-discovery.ts", () => _realPortSnapshotOW); + if (_realOcSdkSnapshotOW !== null) { + mock.module("@opencode-ai/sdk/v2", () => _realOcSdkSnapshotOW!); + } + if (_realCopilotSdkSnapshotOW !== null) { + mock.module("@github/copilot-sdk", () => _realCopilotSdkSnapshotOW!); + } + if (realDateNow) { + Date.now = realDateNow; + realDateNow = undefined as unknown as typeof Date.now; + } + }); + + test("opencode: throws RESUME_TIMEOUT_OPENCODE when session.get never succeeds", async () => { + // OpenCode session.get returns no data (session not yet registered). + mock.module("@opencode-ai/sdk/v2", () => ({ + createOpencodeClient: () => ({ + session: { + get: () => Promise.resolve({ data: null, error: { message: "not found" } }), + }, + }), + })); + + // Jump Date.now past AGENT_READY_TIMEOUT_MS (10_000ms) after a few checks. + realDateNow = Date.now; + let calls = 0; + Date.now = () => { + calls++; + return calls > 5 ? realDateNow() + 20_000 : realDateNow(); + }; + + let caught: unknown = null; + try { + await defaultWaitForAgentReady("opencode", "sess-oc-123", "atomic-wf:review"); + } catch (err) { + caught = err; + } + expect((caught as Error).message).toBe("RESUME_TIMEOUT_OPENCODE"); + }); + + test("opencode: resolves immediately when session.get succeeds on first try", async () => { + mock.module("@opencode-ai/sdk/v2", () => ({ + createOpencodeClient: () => ({ + session: { + // SDK v2 shape: { sessionID } — returns a RequestResult-like object. + get: () => Promise.resolve({ data: { id: "sess-oc-ok" }, error: null }), + }, + }), + })); + + // Should resolve without throwing. + await defaultWaitForAgentReady("opencode", "sess-oc-ok", "atomic-wf:review"); + }); + + test("copilot: throws RESUME_TIMEOUT_COPILOT when getSessionMetadata returns undefined", async () => { + // listSessions() is called by waitForServer's internal Copilot probe. + // getSessionMetadata() is called by defaultWaitForAgentReady to verify the + // specific resumed session is registered. + mock.module("@github/copilot-sdk", () => ({ + CopilotClient: class { + start() { return Promise.resolve(); } + stop() { return Promise.resolve([]); } + listSessions() { return Promise.resolve([]); } + getSessionMetadata(_id: string) { return Promise.resolve(undefined); } + }, + })); + + let caught: unknown = null; + try { + await defaultWaitForAgentReady("copilot", "sess-cp-456", "atomic-wf:review"); + } catch (err) { + caught = err; + } + expect((caught as Error).message).toBe("RESUME_TIMEOUT_COPILOT"); + }); + + test("copilot: resolves when getSessionMetadata returns session metadata", async () => { + mock.module("@github/copilot-sdk", () => ({ + CopilotClient: class { + start() { return Promise.resolve(); } + stop() { return Promise.resolve([]); } + listSessions() { return Promise.resolve([]); } + getSessionMetadata(_id: string) { + return Promise.resolve({ + sessionId: "sess-cp-ok", + startTime: new Date(), + modifiedTime: new Date(), + isRemote: false, + }); + } + }, + })); + + // Should resolve without throwing. + await defaultWaitForAgentReady("copilot", "sess-cp-ok", "atomic-wf:review"); + }); +}); diff --git a/packages/atomic-sdk/src/runtime/executor.test.ts b/packages/atomic-sdk/src/runtime/executor.test.ts index eaa258acf..79098e34c 100644 --- a/packages/atomic-sdk/src/runtime/executor.test.ts +++ b/packages/atomic-sdk/src/runtime/executor.test.ts @@ -1117,9 +1117,9 @@ describe("buildPaneCommand", () => { expect(command).toContain("--extra-flag"); }); - test("extraChatFlags not appended to opencode command", () => { + test("extraChatFlags appended to opencode command", () => { const { command } = buildPaneCommand("opencode", {}, ["--extra-flag"]); - expect(command).not.toContain("--extra-flag"); + expect(command).toContain("--extra-flag"); }); test("copilot: respects COPILOT_CLI_PATH env var for binary resolution", () => { @@ -1383,7 +1383,7 @@ describe("executeWorkflow — resolver pre-flight", () => { capturePane: () => "", getPanePid: () => null, killSession: () => {}, - killWindow: () => {}, + killWindow: () => Promise.resolve(), createWindow: () => "%1", })); diff --git a/packages/atomic-sdk/src/runtime/executor.ts b/packages/atomic-sdk/src/runtime/executor.ts index c24f1803a..a7cd7cb91 100644 --- a/packages/atomic-sdk/src/runtime/executor.ts +++ b/packages/atomic-sdk/src/runtime/executor.ts @@ -21,7 +21,7 @@ import { join } from "node:path"; import { homedir } from "node:os"; -import { writeFile } from "node:fs/promises"; +import { writeFile, stat as fsStat } from "node:fs/promises"; import { statSync, accessSync, constants as fsConstants } from "node:fs"; import type { WorkflowDefinition, @@ -63,15 +63,21 @@ import { ClaudeSessionWrapper, HeadlessClaudeClientWrapper, HeadlessClaudeSessionWrapper, + buildClaudeResumeArgs, + claudeOffloadCleanup, + ensureWorkflowHookSettings, } from "../providers/claude.ts"; -import { withHeadlessOpencodeEnv } from "../providers/opencode.ts"; -import { resolveCopilotCliPath } from "../providers/copilot.ts"; +import { withHeadlessOpencodeEnv, buildOpencodeResumeArgs } from "../providers/opencode.ts"; +import { resolveCopilotCliPath, buildCopilotResumeArgs } from "../providers/copilot.ts"; +import { createOffloadManager, type OffloadManager } from "./offload-manager.ts"; +import { shellQuote } from "./shell-quote.ts"; import { OrchestratorPanel } from "./panel.tsx"; import { GraphFrontierTracker } from "./graph-inference.ts"; import { buildSnapshot, writeSnapshot } from "./status-writer.ts"; import { errorMessage } from "../errors.ts"; import { createPainter } from "../theme/colors.ts"; import { atomicTempEnv } from "../lib/atomic-temp.ts"; +import { getProductionTelemetrySink } from "../lib/telemetry/index.ts"; /** Maximum time (ms) for the SDK probe to succeed after port is discovered. */ export const SERVER_PROBE_TIMEOUT_MS = 60_000; @@ -120,6 +126,169 @@ function assertNever(value: never): never { // Re-export for backward compatibility (tests import from here) export { errorMessage } from "../errors.ts"; +// --------------------------------------------------------------------------- +// Telemetry stub used by loggedKillWindow. +// +// atomic-sdk has no real telemetry sink yet; the shape mirrors +// packages/atomic/src/lib/telemetry/offload-events.ts so the call site is +// identical to the eventual real implementation. +// --------------------------------------------------------------------------- + +export interface TelemetrySink { + emit(event: string, payload: Record): void; +} + +const _defaultTelemetry: TelemetrySink = { emit: () => {} }; +const _defaultWarn = (msg: string): void => console.warn(msg); + +let _telemetrySink: TelemetrySink = _defaultTelemetry; +let _warnSink: (msg: string) => void = _defaultWarn; + +/** + * Production seam for injecting telemetry + warn sinks (RFC §5.11). + * Also used in tests to swap sinks without touching real infrastructure. + * Call with `{}` to restore defaults. + */ +export function setExecutorTelemetrySinks( + sinks: Partial<{ telemetry: TelemetrySink; warn: (msg: string) => void }>, +): void { + _telemetrySink = sinks.telemetry ?? _defaultTelemetry; + _warnSink = sinks.warn ?? _defaultWarn; +} + +/** + * Kill a tmux window and surface any rejection via warn + telemetry. + * + * Reserved-name rejections (orchestrator-name leak, fixture leak) are bug + * conditions that must be observable — they must NOT be silently swallowed. + */ +async function loggedKillWindow( + sessionName: string, + windowName: string, + origin: "stage-error" | "abort-cleanup", +): Promise { + try { + await tmux.killWindow(sessionName, windowName); + } catch (err) { + const msg = errorMessage(err); + _warnSink(`killWindow rejected for ${windowName} (${origin}): ${msg}`); + _telemetrySink.emit("workflow.tmux.kill_window_rejected", { + windowName, + origin, + error: msg, + }); + } +} + +/** Exported for unit testing only. Not part of the public API. */ +export const _loggedKillWindowForTest = loggedKillWindow; + +// --------------------------------------------------------------------------- +// Agent readiness wait — wired into OffloadManager via deps.waitForReady. +// --------------------------------------------------------------------------- + +/** Polling interval for the Claude SessionStart marker file (ms). */ +const CLAUDE_READY_POLL_MS = 200; +/** Total time to wait for the agent to signal readiness post-resume (ms). */ +const AGENT_READY_TIMEOUT_MS = 10_000; + +/** + * Claude readiness probe — poll the SessionStart hook marker file. + * Rejects markers whose mtime < startMs (stale from a prior session). + * Throws `RESUME_TIMEOUT_CLAUDE` after {@link AGENT_READY_TIMEOUT_MS}. + * + * @param agentSessionId - The agent session ID used as the marker filename. + * @param startMs - Resume-attempt start time (default: Date.now()). Markers + * written before this time are treated as stale and skipped (RFC §5.5). + * @param markerBaseDir - Base directory for marker files. Defaults to + * `~/.atomic/claude-ready`. Injectable for unit testing only. + */ +async function waitForClaudeReady( + agentSessionId: string, + startMs: number = Date.now(), + markerBaseDir: string = join(homedir(), ".atomic", "claude-ready"), +): Promise { + const marker = join(markerBaseDir, agentSessionId); + const deadline = Date.now() + AGENT_READY_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + const st = await fsStat(marker); + if (st.mtimeMs >= startMs) return; + // Stale marker (pre-resume). Continue polling. + } catch { + // Marker not yet written — continue polling. + } + await Bun.sleep(CLAUDE_READY_POLL_MS); + } + throw new Error("RESUME_TIMEOUT_CLAUDE"); +} + +/** Exported for unit testing only. Not part of the public API. */ +export const _waitForClaudeReadyForTest = waitForClaudeReady; + +/** + * OpenCode readiness probe — wait for HTTP server, then poll `session.get` + * until the resumed session ID is registered. + * Throws `RESUME_TIMEOUT_OPENCODE` after {@link AGENT_READY_TIMEOUT_MS}. + */ +async function waitForOpencodeReady(agentSessionId: string, paneId: string): Promise { + const serverUrl = await waitForServer("opencode", paneId); + const { createOpencodeClient } = await import("@opencode-ai/sdk/v2"); + const client = createOpencodeClient({ baseUrl: serverUrl }); + const deadline = Date.now() + AGENT_READY_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + const result = await client.session.get({ sessionID: agentSessionId }); + if (result.data) return; + } catch { + // Network error — keep polling. + } + await Bun.sleep(CLAUDE_READY_POLL_MS); + } + throw new Error("RESUME_TIMEOUT_OPENCODE"); +} + +/** + * Copilot readiness probe — wait for HTTP server, then confirm the resumed + * session is registered via `getSessionMetadata`. Single-shot (not a poll) + * since `waitForServer` already verified the SDK can connect. + * Throws `RESUME_TIMEOUT_COPILOT` if the session is not registered. + */ +async function waitForCopilotReady(agentSessionId: string, paneId: string): Promise { + const serverUrl = await waitForServer("copilot", paneId); + const { CopilotClient } = await import("@github/copilot-sdk"); + const probe = new CopilotClient({ cliUrl: serverUrl }); + await probe.start(); + try { + const metadata = await probe.getSessionMetadata(agentSessionId); + if (!metadata) throw new Error("RESUME_TIMEOUT_COPILOT"); + } finally { + await probe.stop(); + } +} + +/** + * Default `waitForReady` impl wired into OffloadManager. Dispatches to a + * per-agent probe; each probe owns its own timeout and throws + * `RESUME_TIMEOUT_` on failure (RFC §9 Q11). + */ +export async function defaultWaitForAgentReady( + agent: AgentType, + agentSessionId: string, + paneId: string, +): Promise { + switch (agent) { + case "claude": + return waitForClaudeReady(agentSessionId); + case "opencode": + return waitForOpencodeReady(agentSessionId, paneId); + case "copilot": + return waitForCopilotReady(agentSessionId, paneId); + default: + return assertNever(agent); + } +} + /** Runtime guard for deserialized SavedMessage objects. */ function isValidSavedMessage(msg: unknown): msg is SavedMessage { if (!msg || typeof msg !== "object") return false; @@ -296,7 +465,7 @@ export function buildPaneCommand( agent: AgentType, overrides: ProviderOverrides = {}, extraChatFlags: string[] = [], -): { command: string; envVars: Record } { +): { command: string; envVars: Record; chatFlags: string[] } { const { cmd, chatFlags: defaultFlags, @@ -308,6 +477,10 @@ export function buildPaneCommand( ? { ...defaultEnvVars, ...overrides.envVars } : defaultEnvVars; const mergedEnvVars = { ...envVars, ...claudeTempEnv, ...overrides.envVars }; + // Effective spawn-time chatFlags: defaults/overrides plus extras (e.g. Copilot + // SCM-disable). Persisted into metadata.json#resume.chatFlags so resume re-spawns + // with byte-identical argv. + const mergedChatFlags = [...chatFlags, ...extraChatFlags]; const resolvedCmd = quotePathIfNeeded(resolveCliBinary(cmd)); @@ -324,16 +497,17 @@ export function buildPaneCommand( "--ui-server", "--port", "0", - ...chatFlags, - ...extraChatFlags, + ...mergedChatFlags, ].join(" "), envVars: mergedEnvVars, + chatFlags: mergedChatFlags, }; } case "opencode": return { - command: [resolvedCmd, "--port", "0", ...chatFlags].join(" "), + command: [resolvedCmd, "--port", "0", ...mergedChatFlags].join(" "), envVars: mergedEnvVars, + chatFlags: mergedChatFlags, }; case "claude": { // Claude is started via createClaudeSession() in the workflow's run(). @@ -348,6 +522,7 @@ export function buildPaneCommand( return { command: quotePathIfNeeded(resolvedShell), envVars: mergedEnvVars, + chatFlags: mergedChatFlags, }; } default: @@ -1338,6 +1513,10 @@ interface SharedRunnerState { completedRegistry: Map; /** Sessions that already failed before completing successfully. */ failedRegistry: Set; + /** Offload manager for pane offload/resume tracking (RFC §5.2). */ + offloadManager: OffloadManager; + /** Workflow run ID (from ATOMIC_WF_ID env var). */ + workflowRunId: string; } /** @@ -1614,6 +1793,57 @@ async function cleanupProvider( } } +// ── §5.2.4 offload-wiring helper ───────────────────────────────────────────── + +/** + * Persist `metadata.json` to `sessionDir` then register the session with + * the `OffloadManager`. + * + * Invariants (RFC §5.2.4): + * 1. `Bun.write` resolves fully before `registerSession` is invoked. + * 2. `registerSession` is awaited (not fire-and-forget). + * 3. A rejected `registerSession` is swallowed with a `console.warn`; the + * caller continues normally (the pane still runs, resume is just unavailable). + * + * Exported for contract-testing only — production callers use + * `createSessionRunner` which calls this function internally. + */ +export async function persistAndRegisterStage( + sessionDir: string, + metadata: { + name: string; + description: string; + agent: AgentType; + paneId: string; + serverUrl: string; + port: number; + startedAt: string; + }, + offloadManager: OffloadManager, + registerInput: { + name: string; + runId: string; + stageDir: string; + agent: AgentType; + agentSessionId: string; + tmuxSession: string; + tmuxWindow: string; + spawnEnv: Record; + spawnCwd: string; + chatFlags: string[]; + headless: boolean; + }, +): Promise { + await Bun.write(join(sessionDir, "metadata.json"), JSON.stringify(metadata, null, 2)); + try { + await offloadManager.registerSession(registerInput); + } catch (err) { + console.warn( + `[offload] registerSession failed for stage ${registerInput.name}: ${errorMessage(err)}`, + ); + } +} + /** * Create a `ctx.stage()` function bound to a parent name for graph edges. * @@ -1687,7 +1917,7 @@ function createSessionRunner( try { // ── 6. Build pane command (OS allocates port via --port 0) ── - const { command: paneCmd, envVars: paneEnvVars } = buildPaneCommand( + const { command: paneCmd, envVars: paneEnvVars, chatFlags: stageChatFlags } = buildPaneCommand( shared.agent, shared.providerOverrides, shared.extraChatFlags, @@ -1928,22 +2158,35 @@ function createSessionRunner( stage: createSessionRunner(shared, name) as SessionContext["stage"], }; - // ── Write session metadata ── - await Bun.write( - join(sessionDir, "metadata.json"), - JSON.stringify( - { - name, - description: options.description ?? "", - agent: shared.agent, - paneId, - serverUrl, - port: serverUrl ? Number(serverUrl.split(":").pop()) : 0, - startedAt: new Date().toISOString(), - }, - null, - 2, - ), + // ── Write session metadata + register with OffloadManager (RFC §5.2.4) ── + // persistAndRegisterStage guarantees Bun.write completes before + // registerSession is called, registerSession is awaited, and a rejection + // is swallowed with console.warn so the stage continues regardless. + await persistAndRegisterStage( + sessionDir, + { + name, + description: options.description ?? "", + agent: shared.agent, + paneId, + serverUrl, + port: serverUrl ? Number(serverUrl.split(":").pop()) : 0, + startedAt: new Date().toISOString(), + }, + shared.offloadManager, + { + name, + runId: shared.workflowRunId, + stageDir: sessionDir, + agent: shared.agent, + agentSessionId: resolveProviderSessionId(shared.agent, providerSession), + tmuxSession: shared.tmuxSessionName, + tmuxWindow: name, + spawnEnv: paneEnvVars, + spawnCwd: shared.projectRoot, + chatFlags: stageChatFlags, + headless: isHeadless, + }, ); // ── 14. Run user callback ── @@ -1980,6 +2223,15 @@ function createSessionRunner( shared.panel.backgroundTaskFinished(); } else { shared.panel.sessionSuccess(name); + // Per-stage offload: kill the tmux pane + agent CLI as soon as the + // stage callback resolves. Without this, idle agent CLIs from earlier + // stages accumulate memory until the entire workflow finishes. + // Wrapped so an offload failure can't block stage cleanup. + try { + await shared.offloadManager.offloadSession(name); + } catch (err) { + console.warn(`[offload] offloadSession failed for ${name}: ${errorMessage(err)}`); + } } const result: SessionResult = { name, sessionId, sessionDir, paneId }; shared.completedRegistry.set(name, result); @@ -2002,9 +2254,7 @@ function createSessionRunner( // Kill the tmux window if one was created (visible stages and headless OpenCode). // Headless Claude/Copilot have virtual paneIds ("headless-...") — no window to kill. if (paneId && !paneId.startsWith("headless-")) { - try { - tmux.killWindow(shared.tmuxSessionName, name); - } catch {} + await loggedKillWindow(shared.tmuxSessionName, name, "stage-error"); } // Ensure the done promise settles and the active entry is cleared. shared.activeRegistry.delete(name); @@ -2043,6 +2293,15 @@ export async function runOrchestrator( ): Promise { const { workflowRunId, tmuxSessionName, agent, cwd } = validateOrchestratorEnv(); + + // RFC §5.11 — register the real telemetry sink so all WORKFLOW_OFFLOAD_* + // events reach disk at ~/.atomic/sessions//telemetry.jsonl. Tests + // override via setExecutorTelemetrySinks before runOrchestrator is invoked. + // `warn` keeps its default (console.warn) — no override needed here. + setExecutorTelemetrySinks({ + telemetry: getProductionTelemetrySink(workflowRunId), + }); + // A bare prompt string is still useful for the panel header and the // session-dir metadata.json — both just want something displayable. // Free-form workflows store their single positional prompt under the @@ -2115,6 +2374,35 @@ export async function runOrchestrator( const signalHandler = () => shutdown(1); process.on("SIGINT", signalHandler); + // Build OffloadManager with live panel store and tmux/provider deps. + const offloadManager = createOffloadManager({ + panelStore: panel.getPanelStore(), + tmux: { + killWindow: tmux.killWindow, + createWindow: async (session, window, command, cwd, envVars) => { + tmux.createWindow(session, window, command, cwd, envVars); + }, + selectWindow: async (session, window) => { + tmux.selectWindow(`${session}:${window}`); + }, + }, + providers: { + claude: { buildResumeArgs: buildClaudeResumeArgs }, + opencode: { buildResumeArgs: buildOpencodeResumeArgs }, + copilot: { buildResumeArgs: buildCopilotResumeArgs }, + }, + hookSettingsPath: ensureWorkflowHookSettings, + shellQuote, + waitForReady: defaultWaitForAgentReady, + now: Date.now, + emit: (event, payload) => _telemetrySink.emit(event, payload), + claudeOffloadCleanup, + }); + + // RFC §5.6 — wire the offload manager into the panel so the React tree's + // OffloadManagerContext.Provider is non-null before any stage renders. + panel.attachOffloadManager(offloadManager); + // Shared state for all session runners const shared: SharedRunnerState = { tmuxSessionName, @@ -2128,6 +2416,8 @@ export async function runOrchestrator( activeRegistry: new Map(), completedRegistry: new Map(), failedRegistry: new Set(), + offloadManager, + workflowRunId, }; try { @@ -2171,6 +2461,14 @@ export async function runOrchestrator( }); await Promise.race([definition.run(workflowCtx), abortPromise]); + // Notify OffloadManager that all stages have completed (RFC §5.11). Wrap + // to keep an offload failure from halting orchestrator teardown. + try { + await shared.offloadManager.onWorkflowCompletion(); + } catch (err) { + console.warn(`offload onWorkflowCompletion failed: ${errorMessage(err)}`); + } + panel.showCompletion(definition.name, sessionsBaseDir); await panel.waitForExit(); shutdown(0); @@ -2179,11 +2477,9 @@ export async function runOrchestrator( // Headless Claude/Copilot have virtual paneIds ("headless-...") — their // SDK-managed processes are cleaned up by cleanupProvider(). for (const [, active] of shared.activeRegistry) { - try { - if (active.paneId && !active.paneId.startsWith("headless-")) { - tmux.killWindow(tmuxSessionName, active.name); - } - } catch {} + if (active.paneId && !active.paneId.startsWith("headless-")) { + await loggedKillWindow(tmuxSessionName, active.name, "abort-cleanup"); + } } if (error instanceof WorkflowAbortError) { diff --git a/packages/atomic-sdk/src/runtime/executor.waitForClaudeReady.test.ts b/packages/atomic-sdk/src/runtime/executor.waitForClaudeReady.test.ts new file mode 100644 index 000000000..f1d1271ba --- /dev/null +++ b/packages/atomic-sdk/src/runtime/executor.waitForClaudeReady.test.ts @@ -0,0 +1,106 @@ +/** + * Tests for waitForClaudeReady RFC §5.5 belt-and-suspenders mtime guard. + * + * Uses the _waitForClaudeReadyForTest seam (3rd param: markerBaseDir) to + * exercise the function with a temp directory instead of ~/.atomic/claude-ready. + * + * Three behaviours under test: + * 1. Stale marker (mtime < startMs) is skipped — wait does NOT resolve early. + * 2. Fresh marker (mtime >= startMs) resolves immediately. + * 3. No marker → eventually written → resolves after write. + */ + +import { test, expect, describe } from "bun:test"; +import { mkdtempSync, utimesSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { _waitForClaudeReadyForTest } from "./executor.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeTempMarkerDir(): string { + return mkdtempSync(join(tmpdir(), "claude-ready-test-")); +} + +// --------------------------------------------------------------------------- +// Case 1: Stale marker — written before startMs — must NOT resolve +// --------------------------------------------------------------------------- + +describe("waitForClaudeReady — stale marker rejected", () => { + test("does not resolve within 1 s when marker mtime is in the past", async () => { + const dir = makeTempMarkerDir(); + const id = "sess-stale-001"; + const markerPath = join(dir, id); + + // Write marker with mtime 10 seconds in the past. + writeFileSync(markerPath, ""); + const pastTs = new Date(Date.now() - 10_000); + utimesSync(markerPath, pastTs, pastTs); + + // startMs = now → marker is stale + const startMs = Date.now(); + + // Race: waitForClaudeReady against a 800 ms timer. + // The timer should win (wait does NOT resolve for stale marker). + const timerWon = await Promise.race([ + _waitForClaudeReadyForTest(id, startMs, dir).then(() => false), + Bun.sleep(800).then(() => true), + ]); + + expect(timerWon).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Case 2: Fresh marker — mtime >= startMs — must resolve immediately +// --------------------------------------------------------------------------- + +describe("waitForClaudeReady — fresh marker accepted", () => { + test("resolves quickly when marker mtime is after startMs", async () => { + const dir = makeTempMarkerDir(); + const id = "sess-fresh-002"; + const markerPath = join(dir, id); + + // Write marker with current mtime. + writeFileSync(markerPath, ""); + // startMs is 5 seconds before marker — marker is fresh. + const startMs = Date.now() - 5_000; + + const start = Date.now(); + await _waitForClaudeReadyForTest(id, startMs, dir); + const elapsed = Date.now() - start; + + // Should resolve well within 500 ms. + expect(elapsed).toBeLessThan(500); + }); +}); + +// --------------------------------------------------------------------------- +// Case 3: No marker → written after 200 ms → resolves +// --------------------------------------------------------------------------- + +describe("waitForClaudeReady — marker written mid-wait", () => { + test("resolves after marker is created post-start", async () => { + const dir = makeTempMarkerDir(); + const id = "sess-late-003"; + const markerPath = join(dir, id); + + const startMs = Date.now(); + + // Write marker after 250 ms with a fresh mtime. + const writeHandle = Bun.sleep(250).then(() => { + writeFileSync(markerPath, ""); + // mtime defaults to now — fresh relative to startMs. + }); + + const raceResult = await Promise.race([ + _waitForClaudeReadyForTest(id, startMs, dir).then(() => "resolved" as const), + Bun.sleep(3_000).then(() => "timeout" as const), + ]); + + await writeHandle; // ensure no dangling promise + expect(raceResult).toBe("resolved"); + }); +}); diff --git a/packages/atomic-sdk/src/runtime/offload-manager.bodies.test.ts b/packages/atomic-sdk/src/runtime/offload-manager.bodies.test.ts new file mode 100644 index 000000000..afef50370 --- /dev/null +++ b/packages/atomic-sdk/src/runtime/offload-manager.bodies.test.ts @@ -0,0 +1,659 @@ +/** + * State-transition integration tests for OffloadManager bodies. + * Spec: specs/2026-05-08-workflow-pane-offload-and-resume.md §8.3 + * + * Tests: onWorkflowCompletion (skip headless, skip active, skip non-complete, + * happy path, idempotency) + requestResume (unknown, alive, happy path, + * schema mismatch, createWindow failure). + */ + +import { test, expect, describe, mock } from "bun:test"; +import { mkdtempSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createOffloadManager } from "./offload-manager.ts"; +import type { OffloadManagerDeps } from "./offload-manager.ts"; +import type { MetadataJsonWithResume } from "./offload-types.ts"; +import type { SessionData } from "../components/orchestrator-panel-types.ts"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const FIXED_NOW = 1_717_804_800_000; +const TMUX_SESSION = "atomic-wf-claude-test-1"; + +const IMMUTABLES: Omit = { + name: "review", + description: "Review stage", + agent: "claude" as const, + paneId: "%7", + serverUrl: "", + port: 0, + startedAt: new Date(FIXED_NOW).toISOString(), +}; + +function makeStageDir(): string { + const dir = mkdtempSync(join(tmpdir(), "offload-bodies-")); + // RFC §8.3: fixtures must NOT pre-populate metadata.json#resume. + // registerSession seeds the resume block; makeStageDir only writes immutables. + writeFileSync( + join(dir, "metadata.json"), + JSON.stringify( + { ...IMMUTABLES } satisfies Omit, + null, + 2, + ), + { mode: 0o600 }, + ); + return dir; +} + +function readMetadata(stageDir: string): MetadataJsonWithResume { + return JSON.parse(readFileSync(join(stageDir, "metadata.json"), "utf8")) as MetadataJsonWithResume; +} + +// --------------------------------------------------------------------------- +// Mock factory +// --------------------------------------------------------------------------- + +type EmitCall = { event: string; payload: Record }; + +/** Mutable panel-store backing state that `OffloadManagerDeps.panelStore` reads from. */ +interface MutablePanelStore { + sessions: SessionData[]; + activeAgentId: string; + setSessionStatus: ReturnType; +} + +interface TestContext { + deps: OffloadManagerDeps; + panelStore: MutablePanelStore; + emitCalls: EmitCall[]; + stageDir: string; +} + +function makeTestDeps(stageDirOverride?: string): TestContext { + const emitCalls: EmitCall[] = []; + const stageDir = stageDirOverride ?? makeStageDir(); + + // Mutable backing store — tests mutate this; OffloadManagerDeps reads from it. + const panelStore: MutablePanelStore = { + sessions: [], + activeAgentId: "", + setSessionStatus: mock(() => {}), + }; + + const deps: OffloadManagerDeps = { + // Cast to satisfy the readonly interface; tests mutate via panelStore ref. + panelStore: panelStore as unknown as OffloadManagerDeps["panelStore"], + tmux: { + killWindow: mock(async () => {}), + createWindow: mock(async () => {}), + selectWindow: mock(async () => {}), + }, + providers: { + claude: { buildResumeArgs: mock(() => ["--resume", "sess-abc"]) }, + opencode: { buildResumeArgs: mock(() => ["--session", "sess-abc"]) }, + copilot: { buildResumeArgs: mock(() => ["--session", "sess-abc"]) }, + }, + hookSettingsPath: mock(() => "/tmp/hook-settings.json"), + shellQuote: mock((argv: readonly string[]) => argv.join(" ")), + waitForReady: mock(async () => {}), + now: mock(() => FIXED_NOW), + emit: mock((event: string, payload: Record) => { + emitCalls.push({ event, payload }); + }), + }; + + return { deps, panelStore, emitCalls, stageDir }; +} + +function makeSessionInput(name: string, stageDir: string, overrides: Partial<{ + headless: boolean; + agent: "claude" | "opencode" | "copilot"; + chatFlags: string[]; +}> = {}) { + return { + name, + runId: "run-1", + stageDir, + agent: overrides.agent ?? ("claude" as const), + agentSessionId: "sess-abc", + tmuxSession: TMUX_SESSION, + tmuxWindow: name, + spawnEnv: { CLAUDECODE: "1" }, + spawnCwd: "/home/user/project", + headless: overrides.headless ?? false, + chatFlags: overrides.chatFlags ?? [], + }; +} + +// --------------------------------------------------------------------------- +// 1. onWorkflowCompletion skips headless sessions +// --------------------------------------------------------------------------- + +describe("onWorkflowCompletion: filter logic", () => { + test("skips headless session — no kill, no WORKFLOW_OFFLOAD_COMPLETED", async () => { + const { deps, panelStore, emitCalls, stageDir } = makeTestDeps(); + // panelStore has a matching "complete" entry so eligibility would pass but for headless flag + panelStore.sessions = [{ name: "review", status: "complete", parents: [], startedAt: null, endedAt: null }]; + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("review", stageDir, { headless: true })); + + await mgr.onWorkflowCompletion(); + + expect(deps.tmux.killWindow).not.toHaveBeenCalled(); + expect(panelStore.setSessionStatus).not.toHaveBeenCalledWith("review", "offloaded"); + expect(emitCalls.some((c) => c.event === "workflow.offload.completed")).toBe(false); + // SCHEDULED may emit with count:0 + const scheduled = emitCalls.find((c) => c.event === "workflow.offload.scheduled"); + if (scheduled !== undefined) { + expect(scheduled.payload.count).toBe(0); + } + }); + + // --------------------------------------------------------------------------- + // 2. onWorkflowCompletion skips the user's currently-focused pane + // + // Chrome-tab semantics: never offload the pane the user is reading. They + // must navigate away before the focus poller fires offloadSession, or + // workflow teardown reaps the pane. Single-stage workflows offload only + // after the user returns to the orchestrator window. + // --------------------------------------------------------------------------- + + test("skips focused session (panelStore.activeAgentId matches name)", async () => { + const { deps, panelStore, stageDir } = makeTestDeps(); + panelStore.activeAgentId = "review"; + panelStore.sessions = [{ name: "review", status: "complete", parents: [], startedAt: null, endedAt: null }]; + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("review", stageDir)); + + await mgr.onWorkflowCompletion(); + + expect(deps.tmux.killWindow).not.toHaveBeenCalled(); + expect(panelStore.setSessionStatus).not.toHaveBeenCalledWith("review", "offloaded"); + }); + + test("offloadSession skips focused session", async () => { + const { deps, panelStore, stageDir } = makeTestDeps(); + panelStore.activeAgentId = "review"; + panelStore.sessions = [{ name: "review", status: "complete", parents: [], startedAt: null, endedAt: null }]; + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("review", stageDir)); + + await mgr.offloadSession("review"); + + expect(deps.tmux.killWindow).not.toHaveBeenCalled(); + expect(mgr.getStatus("review")).toBe("alive"); + }); + + test("offloadSession offloads after user navigates away (activeAgentId changes)", async () => { + const { deps, panelStore, stageDir } = makeTestDeps(); + panelStore.activeAgentId = "review"; + panelStore.sessions = [{ name: "review", status: "complete", parents: [], startedAt: null, endedAt: null }]; + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("review", stageDir)); + + // First call: user is still on the pane → skipped. + await mgr.offloadSession("review"); + expect(deps.tmux.killWindow).not.toHaveBeenCalled(); + + // User navigates away — focus poller updates activeAgentId. + panelStore.activeAgentId = ""; + + // Second call: pane now eligible → offloaded. + await mgr.offloadSession("review"); + expect(deps.tmux.killWindow).toHaveBeenCalledTimes(1); + expect(mgr.getStatus("review")).toBe("offloaded"); + }); + + // --------------------------------------------------------------------------- + // 3. onWorkflowCompletion skips sessions with non-complete panel status + // --------------------------------------------------------------------------- + + test("skips session with panelStore status !== 'complete'", async () => { + const { deps, panelStore, stageDir } = makeTestDeps(); + panelStore.sessions = [{ name: "review", status: "running", parents: [], startedAt: null, endedAt: null }]; + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("review", stageDir)); + + await mgr.onWorkflowCompletion(); + + expect(deps.tmux.killWindow).not.toHaveBeenCalled(); + }); + + // --------------------------------------------------------------------------- + // 4. onWorkflowCompletion happy path — eligible session is offloaded + // --------------------------------------------------------------------------- + + test("offloads eligible session: killWindow + setSessionStatus('offloaded') + WORKFLOW_OFFLOAD_COMPLETED emitted", async () => { + const { deps, panelStore, emitCalls, stageDir } = makeTestDeps(); + panelStore.sessions = [{ name: "review", status: "complete", parents: [], startedAt: null, endedAt: null }]; + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("review", stageDir)); + + await mgr.onWorkflowCompletion(); + + // tmux.killWindow called exactly once with correct session+window + expect(deps.tmux.killWindow).toHaveBeenCalledTimes(1); + expect(deps.tmux.killWindow).toHaveBeenCalledWith(TMUX_SESSION, "review"); + + // status updated to offloaded + expect(panelStore.setSessionStatus).toHaveBeenCalledWith("review", "offloaded"); + + // WORKFLOW_OFFLOAD_COMPLETED emitted with correct payload + const completed = emitCalls.find((c) => c.event === "workflow.offload.completed"); + expect(completed).toBeDefined(); + expect(completed?.payload.runId).toBe("run-1"); + expect(completed?.payload.name).toBe("review"); + expect(completed?.payload.agent).toBe("claude"); + + // internal state is offloaded + expect(mgr.getStatus("review")).toBe("offloaded"); + + // metadata.json has offloadedAt set to the value returned by deps.now() + const meta = readMetadata(stageDir); + expect(typeof meta.resume?.offloadedAt).toBe("number"); + expect(meta.resume?.offloadedAt).toBe(FIXED_NOW); + }); + + // --------------------------------------------------------------------------- + // 5. onWorkflowCompletion idempotency — calling twice doesn't double-kill + // --------------------------------------------------------------------------- + + test("idempotent: two concurrent onWorkflowCompletion calls kill pane exactly once", async () => { + const { deps, panelStore, stageDir } = makeTestDeps(); + panelStore.sessions = [{ name: "review", status: "complete", parents: [], startedAt: null, endedAt: null }]; + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("review", stageDir)); + + // Fire both concurrently — getOrStartOp should dedup + await Promise.all([mgr.onWorkflowCompletion(), mgr.onWorkflowCompletion()]); + + expect(deps.tmux.killWindow).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// 5b. offloadSession — per-stage offload triggered as each stage completes +// --------------------------------------------------------------------------- + +describe("offloadSession: per-stage offload", () => { + test("offloads a single completed stage immediately", async () => { + const { deps, panelStore, stageDir } = makeTestDeps(); + panelStore.sessions = [{ name: "describe", status: "complete", parents: [], startedAt: null, endedAt: null }]; + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("describe", stageDir)); + + await mgr.offloadSession("describe"); + + expect(deps.tmux.killWindow).toHaveBeenCalledTimes(1); + expect(deps.tmux.killWindow).toHaveBeenCalledWith(TMUX_SESSION, "describe"); + expect(panelStore.setSessionStatus).toHaveBeenCalledWith("describe", "offloaded"); + expect(mgr.getStatus("describe")).toBe("offloaded"); + }); + + test("no-op for unknown session", async () => { + const { deps } = makeTestDeps(); + const mgr = createOffloadManager(deps); + await mgr.offloadSession("does-not-exist"); + expect(deps.tmux.killWindow).not.toHaveBeenCalled(); + }); + + test("no-op for headless session", async () => { + const { deps, panelStore, stageDir } = makeTestDeps(); + panelStore.sessions = [{ name: "bg", status: "complete", parents: [], startedAt: null, endedAt: null }]; + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("bg", stageDir, { headless: true })); + + await mgr.offloadSession("bg"); + + expect(deps.tmux.killWindow).not.toHaveBeenCalled(); + }); + + test("no-op when already offloaded (idempotent)", async () => { + const { deps, panelStore, stageDir } = makeTestDeps(); + panelStore.sessions = [{ name: "describe", status: "complete", parents: [], startedAt: null, endedAt: null }]; + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("describe", stageDir)); + + await mgr.offloadSession("describe"); + await mgr.offloadSession("describe"); + + expect(deps.tmux.killWindow).toHaveBeenCalledTimes(1); + }); + + test("subsequent onWorkflowCompletion skips already-offloaded sessions", async () => { + const { deps, panelStore, stageDir } = makeTestDeps(); + panelStore.sessions = [{ name: "describe", status: "complete", parents: [], startedAt: null, endedAt: null }]; + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("describe", stageDir)); + + await mgr.offloadSession("describe"); + await mgr.onWorkflowCompletion(); + + // Killed exactly once across both calls. + expect(deps.tmux.killWindow).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// 6. requestResume returns early when session is unknown +// --------------------------------------------------------------------------- + +describe("requestResume: guard conditions", () => { + test("no-op for unknown session name", async () => { + const { deps, emitCalls } = makeTestDeps(); + const mgr = createOffloadManager(deps); + + const result = await mgr.requestResume("missing"); + + expect(result).toBeUndefined(); + expect(emitCalls).toHaveLength(0); + expect(deps.tmux.createWindow).not.toHaveBeenCalled(); + }); + + // --------------------------------------------------------------------------- + // 7. requestResume returns early when state is alive + // --------------------------------------------------------------------------- + + test("no-op when session state is alive", async () => { + const { deps, emitCalls, stageDir } = makeTestDeps(); + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("review", stageDir)); + + // Clear emits from registration before checking resume behavior. + emitCalls.length = 0; + + const result = await mgr.requestResume("review"); + + expect(result).toBeUndefined(); + // No resume-related events emitted. + expect(emitCalls.every((c) => !c.event.includes("resume"))).toBe(true); + expect(deps.tmux.createWindow).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// 8. requestResume happy path — resumes offloaded session +// --------------------------------------------------------------------------- + +describe("requestResume: happy path", () => { + test("resumes offloaded session: createWindow + selectWindow + status complete + RESUME_SUCCEEDED", async () => { + const { deps, panelStore, emitCalls, stageDir } = makeTestDeps(); + panelStore.sessions = [{ name: "review", status: "complete", parents: [], startedAt: null, endedAt: null }]; + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("review", stageDir)); + + // Offload first via public API + await mgr.onWorkflowCompletion(); + expect(mgr.getStatus("review")).toBe("offloaded"); + + // Clear call tracking for resume assertions + emitCalls.length = 0; + + await mgr.requestResume("review"); + + // createWindow called with 5 args: session, window, command, cwd, envVars + expect(deps.tmux.createWindow).toHaveBeenCalledTimes(1); + const [session, window, command, cwd, envVars] = (deps.tmux.createWindow as ReturnType).mock.calls[0] as [string, string, string, string, Record]; + expect(session).toBe(TMUX_SESSION); + expect(window).toBe("review"); + // command is shellQuote(["claude", "--resume", "sess-abc"]) — mock joins with space + expect(command).toBe("claude --resume sess-abc"); + expect(cwd).toBe("/home/user/project"); + // envVars is the in-memory (unfiltered) spawnEnv + expect(envVars).toEqual({ CLAUDECODE: "1" }); + + // waitForReady awaited before selectWindow (3rd arg is tmux target paneId) + expect(deps.waitForReady).toHaveBeenCalledWith("claude", "sess-abc", `${TMUX_SESSION}:review`); + + // selectWindow called + expect(deps.tmux.selectWindow).toHaveBeenCalledWith(TMUX_SESSION, "review"); + + // panel status set to complete + expect(panelStore.setSessionStatus).toHaveBeenCalledWith("review", "complete"); + + // RESUME_SUCCEEDED emitted + const succeeded = emitCalls.find((c) => c.event === "workflow.offload.resume.succeeded"); + expect(succeeded).toBeDefined(); + expect(succeeded?.payload.name).toBe("review"); + expect(succeeded?.payload.agent).toBe("claude"); + expect(succeeded?.payload.runId).toBe("run-1"); + + // state restored to alive + expect(mgr.getStatus("review")).toBe("alive"); + }); +}); + +// --------------------------------------------------------------------------- +// 9. requestResume schemaVersion mismatch +// --------------------------------------------------------------------------- + +describe("requestResume: error rollback", () => { + test("schema version mismatch: RESUME_FAILED with errorCode SCHEMA_MISMATCH + state rollback to offloaded", async () => { + const stageDir = makeStageDir(); + const { deps, panelStore, emitCalls } = makeTestDeps(stageDir); + panelStore.sessions = [{ name: "review", status: "complete", parents: [], startedAt: null, endedAt: null }]; + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("review", stageDir)); + + // Offload via public API + await mgr.onWorkflowCompletion(); + expect(mgr.getStatus("review")).toBe("offloaded"); + + // Now corrupt schema version in metadata.json + const meta = readMetadata(stageDir); + writeFileSync( + join(stageDir, "metadata.json"), + JSON.stringify( + { + ...meta, + resume: { ...meta.resume, schemaVersion: 2 }, + }, + null, + 2, + ), + { mode: 0o600 }, + ); + + emitCalls.length = 0; + + // requestResume should throw (doResume rethrows) + let err: unknown; + try { + await mgr.requestResume("review"); + } catch (e) { + err = e; + } + + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toBe("SCHEMA_MISMATCH"); + + // RESUME_FAILED emitted with correct errorCode + const failed = emitCalls.find((c) => c.event === "workflow.offload.resume.failed"); + expect(failed).toBeDefined(); + expect(failed?.payload.errorCode).toBe("SCHEMA_MISMATCH"); + expect(failed?.payload.name).toBe("review"); + + // panel status rolled back to offloaded + expect(panelStore.setSessionStatus).toHaveBeenCalledWith("review", "offloaded"); + + // internal state rolled back + expect(mgr.getStatus("review")).toBe("offloaded"); + }); + + // --------------------------------------------------------------------------- + // 10. requestResume mid-resume failure (createWindow throws) + // --------------------------------------------------------------------------- + + test("createWindow failure: RESUME_FAILED with errorCode RESUME_FAILED + state rollback to offloaded", async () => { + const stageDir = makeStageDir(); + const { deps, panelStore, emitCalls } = makeTestDeps(stageDir); + panelStore.sessions = [{ name: "review", status: "complete", parents: [], startedAt: null, endedAt: null }]; + + // Make createWindow throw after the first call (registration uses no createWindow; + // only the resume path does). Replace the mock after makeTestDeps so only the + // resume call is affected. + deps.tmux.createWindow = mock(async () => { + throw new Error("tmux: create-window failed"); + }) as unknown as OffloadManagerDeps["tmux"]["createWindow"]; + + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("review", stageDir)); + + // Offload + await mgr.onWorkflowCompletion(); + expect(mgr.getStatus("review")).toBe("offloaded"); + + emitCalls.length = 0; + + let err: unknown; + try { + await mgr.requestResume("review"); + } catch (e) { + err = e; + } + + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain("tmux: create-window failed"); + + // RESUME_FAILED emitted + const failed = emitCalls.find((c) => c.event === "workflow.offload.resume.failed"); + expect(failed).toBeDefined(); + expect(failed?.payload.errorCode).toBe("RESUME_FAILED"); + + // state rolled back + expect(mgr.getStatus("review")).toBe("offloaded"); + expect(panelStore.setSessionStatus).toHaveBeenCalledWith("review", "offloaded"); + }); +}); + +// --------------------------------------------------------------------------- +// 11. registerSession persists chatFlags into metadata.json#resume.chatFlags +// --------------------------------------------------------------------------- + +describe("registerSession: chatFlags persistence", () => { + test("chatFlags are written to metadata.json#resume.chatFlags", async () => { + const { deps, stageDir } = makeTestDeps(); + const mgr = createOffloadManager(deps); + const flags = ["--model", "claude-opus-4-5", "--tools", "all"]; + await mgr.registerSession(makeSessionInput("review", stageDir, { chatFlags: flags })); + + const meta = readMetadata(stageDir); + expect(meta.resume?.chatFlags).toEqual(flags); + }); + + test("empty chatFlags are persisted as empty array", async () => { + const { deps, stageDir } = makeTestDeps(); + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("review", stageDir, { chatFlags: [] })); + + const meta = readMetadata(stageDir); + expect(meta.resume?.chatFlags).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// 12. doResume forwards chatFlags from disk meta to provider builder +// --------------------------------------------------------------------------- + +describe("doResume: chatFlags forwarded to provider builder", () => { + test("meta.chatFlags from disk is passed to claude buildResumeArgs", async () => { + const stageDir = makeStageDir(); + const capturedMetas: Array<{ agentSessionId: string; chatFlags: string[] }> = []; + + const { deps, panelStore, emitCalls } = makeTestDeps(stageDir); + // Override claude buildResumeArgs to capture meta + deps.providers.claude.buildResumeArgs = mock( + (meta: { agentSessionId: string; chatFlags: string[] }) => { + capturedMetas.push({ agentSessionId: meta.agentSessionId, chatFlags: meta.chatFlags }); + return ["--resume", meta.agentSessionId]; + }, + ) as unknown as typeof deps.providers.claude.buildResumeArgs; + + panelStore.sessions = [{ name: "review", status: "complete", parents: [], startedAt: null, endedAt: null }]; + const mgr = createOffloadManager(deps); + const flags = ["--model", "claude-opus-4-5"]; + await mgr.registerSession(makeSessionInput("review", stageDir, { chatFlags: flags })); + + // Offload to set up offloaded state + await mgr.onWorkflowCompletion(); + expect(mgr.getStatus("review")).toBe("offloaded"); + + emitCalls.length = 0; + + await mgr.requestResume("review"); + + expect(capturedMetas).toHaveLength(1); + expect(capturedMetas[0]!.chatFlags).toEqual(flags); + expect(capturedMetas[0]!.agentSessionId).toBe("sess-abc"); + }); + +}); + +// --------------------------------------------------------------------------- +// 13. doResume clock — latencyMs derived from injected deps.now() +// --------------------------------------------------------------------------- + +describe("doResume: clock discipline (RFC §5.10)", () => { + test("WORKFLOW_OFFLOAD_RESUME_SUCCEEDED latencyMs equals exact difference between two deps.now() calls", async () => { + const stageDir = makeStageDir(); + + // deps.now() is called: (1) at startMs, (2) at latencyMs computation. + // We step by 50 ms between calls so expected latency = 50. + let t = 1_000_000; + const STEP = 50; + const nowMock = mock(() => { + const v = t; + t += STEP; + return v; + }); + + const emitCalls: EmitCall[] = []; + const panelStoreBacking: MutablePanelStore = { + sessions: [{ name: "review", status: "complete", parents: [], startedAt: null, endedAt: null }], + activeAgentId: "", + setSessionStatus: mock(() => {}), + }; + + const deps: OffloadManagerDeps = { + panelStore: panelStoreBacking as unknown as OffloadManagerDeps["panelStore"], + tmux: { + killWindow: mock(async () => {}), + createWindow: mock(async () => {}), + selectWindow: mock(async () => {}), + }, + providers: { + claude: { buildResumeArgs: mock(() => ["--resume", "sess-abc"]) }, + opencode: { buildResumeArgs: mock(() => ["--session", "sess-abc"]) }, + copilot: { buildResumeArgs: mock(() => ["--session", "sess-abc"]) }, + }, + hookSettingsPath: mock(() => "/tmp/hook-settings.json"), + shellQuote: mock((argv: readonly string[]) => argv.join(" ")), + waitForReady: mock(async () => {}), + now: nowMock, + emit: mock((event: string, payload: Record) => { + emitCalls.push({ event, payload }); + }), + }; + + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("review", stageDir)); + await mgr.onWorkflowCompletion(); + + emitCalls.length = 0; + + // Reset t so we control the exact window for doResume's two now() calls. + // registerSession + killOnePane consumed some now() calls; reset to known base. + t = 5_000; + + await mgr.requestResume("review"); + + const succeeded = emitCalls.find((c) => c.event === "workflow.offload.resume.succeeded"); + expect(succeeded).toBeDefined(); + // startMs = 5_000 (first call), end = 5_050 (second call) → latencyMs = 50 + expect(succeeded?.payload.latencyMs).toBe(STEP); + }); +}); diff --git a/packages/atomic-sdk/src/runtime/offload-manager.claudeMarkerCleanup.test.ts b/packages/atomic-sdk/src/runtime/offload-manager.claudeMarkerCleanup.test.ts new file mode 100644 index 000000000..34139b326 --- /dev/null +++ b/packages/atomic-sdk/src/runtime/offload-manager.claudeMarkerCleanup.test.ts @@ -0,0 +1,236 @@ +/** + * Tests for claudeOffloadCleanup wiring inside OffloadManager.killOnePane. + * Spec: specs/2026-05-08-workflow-pane-offload-and-resume.md §5.4 + */ + +import { test, expect, mock, describe } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createOffloadManager } from "./offload-manager.ts"; +import type { OffloadManagerDeps } from "./offload-manager.ts"; +import type { AgentKind } from "./offload-types.ts"; +import type { SessionData } from "../components/orchestrator-panel-types.ts"; +import type { MetadataJsonWithResume } from "./offload-types.ts"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const FIXED_NOW = 1_717_804_800_000; +const TMUX_SESSION = "atomic-wf-cleanup-test-1"; + +const IMMUTABLES: Omit = { + name: "build", + description: "Build stage", + agent: "claude" as const, + paneId: "%9", + serverUrl: "", + port: 0, + startedAt: new Date(FIXED_NOW).toISOString(), +}; + +function makeStageDir(agentOverride: AgentKind = "claude"): string { + const dir = mkdtempSync(join(tmpdir(), "offload-cleanup-")); + writeFileSync( + join(dir, "metadata.json"), + JSON.stringify( + { ...IMMUTABLES, agent: agentOverride } satisfies Omit, + null, + 2, + ), + { mode: 0o600 }, + ); + return dir; +} + +type EmitCall = { event: string; payload: Record }; + +interface MutablePanelStore { + sessions: SessionData[]; + activeAgentId: string; + setSessionStatus: ReturnType; +} + +const CLEAN_RESULT = { + readyCleared: true, + stopCleared: true, + pidCleared: true, + inflightCleared: true, + failures: 0, +}; + +function makeDeps( + agentOverride: AgentKind = "claude", + stageDirOverride?: string, +): { + deps: OffloadManagerDeps; + panelStore: MutablePanelStore; + emitCalls: EmitCall[]; + stageDir: string; + callOrder: string[]; +} { + const emitCalls: EmitCall[] = []; + const callOrder: string[] = []; + const stageDir = stageDirOverride ?? makeStageDir(agentOverride); + + const panelStore: MutablePanelStore = { + sessions: [{ name: "build", status: "complete", parents: [], startedAt: null, endedAt: null }], + activeAgentId: "", + setSessionStatus: mock(() => {}), + }; + + const claudeCleanupMock = mock(async (_id: string) => { + callOrder.push("cleanup"); + return { ...CLEAN_RESULT }; + }); + + const killWindowMock = mock(async (_session: string, _window: string) => { + callOrder.push("killWindow"); + }); + + const deps: OffloadManagerDeps = { + panelStore: panelStore as unknown as OffloadManagerDeps["panelStore"], + tmux: { + killWindow: killWindowMock, + createWindow: mock(async () => {}), + selectWindow: mock(async () => {}), + }, + providers: { + claude: { buildResumeArgs: mock(() => []) }, + opencode: { buildResumeArgs: mock(() => []) }, + copilot: { buildResumeArgs: mock(() => []) }, + }, + hookSettingsPath: mock(() => "/tmp/hook-settings.json"), + shellQuote: mock((argv: readonly string[]) => argv.join(" ")), + waitForReady: mock(async (_agent: AgentKind, _id: string, _pane: string) => {}), + now: mock(() => FIXED_NOW), + emit: mock((event: string, payload: Record) => { + emitCalls.push({ event, payload }); + }), + claudeOffloadCleanup: claudeCleanupMock, + }; + + return { deps, panelStore, emitCalls, stageDir, callOrder }; +} + +function makeSessionInput( + name: string, + stageDir: string, + agent: AgentKind = "claude", +) { + return { + name, + runId: "run-abc", + stageDir, + agent, + agentSessionId: "sess-xyz", + tmuxSession: TMUX_SESSION, + tmuxWindow: name, + spawnEnv: { CLAUDECODE: "1" }, + spawnCwd: "/home/user/project", + headless: false, + chatFlags: [], + }; +} + +// --------------------------------------------------------------------------- +// 1. claude branch: cleanup called before killWindow, telemetry emitted +// --------------------------------------------------------------------------- + +describe("killOnePane: claude marker cleanup", () => { + test("calls claudeOffloadCleanup before killWindow, emits WORKFLOW_OFFLOAD_CLAUDE_MARKER_CLEANUP", async () => { + const { deps, emitCalls, stageDir, callOrder } = makeDeps("claude"); + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("build", stageDir, "claude")); + + await mgr.onWorkflowCompletion(); + + // cleanup called once with the correct agentSessionId + expect(deps.claudeOffloadCleanup).toHaveBeenCalledTimes(1); + expect((deps.claudeOffloadCleanup as ReturnType).mock.calls[0]![0]).toBe("sess-xyz"); + + // cleanup called BEFORE killWindow + expect(callOrder).toEqual(["cleanup", "killWindow"]); + + // WORKFLOW_OFFLOAD_CLAUDE_MARKER_CLEANUP emitted with full payload + const cleanupEvent = emitCalls.find( + (c) => c.event === "workflow.offload.claude_marker_cleanup", + ); + expect(cleanupEvent).toBeDefined(); + expect(cleanupEvent!.payload).toMatchObject({ + runId: "run-abc", + name: "build", + agentSessionId: "sess-xyz", + readyCleared: true, + stopCleared: true, + pidCleared: true, + inflightCleared: true, + failures: 0, + }); + }); + + // --------------------------------------------------------------------------- + // 2. non-claude branch: cleanup NOT called, event NOT emitted + // --------------------------------------------------------------------------- + + test("opencode session: claudeOffloadCleanup not called, no WORKFLOW_OFFLOAD_CLAUDE_MARKER_CLEANUP", async () => { + const stageDir = makeStageDir("opencode"); + const { deps, emitCalls } = makeDeps("opencode", stageDir); + // Patch metadata.json agent field to opencode (fixture helper already does this) + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("build", stageDir, "opencode")); + + await mgr.onWorkflowCompletion(); + + expect(deps.claudeOffloadCleanup).not.toHaveBeenCalled(); + expect(emitCalls.some((c) => c.event === "workflow.offload.claude_marker_cleanup")).toBe(false); + }); + + // --------------------------------------------------------------------------- + // 3. failures recorded in payload + // --------------------------------------------------------------------------- + + test("failures in cleanup result are forwarded in telemetry payload", async () => { + const { deps, emitCalls, stageDir } = makeDeps("claude"); + // Override mock to return failures: 2 + deps.claudeOffloadCleanup = mock(async (_id: string) => ({ + readyCleared: false, + stopCleared: false, + pidCleared: true, + inflightCleared: true, + failures: 2, + })); + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("build", stageDir, "claude")); + + await mgr.onWorkflowCompletion(); + + const cleanupEvent = emitCalls.find( + (c) => c.event === "workflow.offload.claude_marker_cleanup", + ); + expect(cleanupEvent).toBeDefined(); + expect(cleanupEvent!.payload.failures).toBe(2); + + // Kill still proceeded + expect(deps.tmux.killWindow).toHaveBeenCalledTimes(1); + }); + + // --------------------------------------------------------------------------- + // 4. cleanup throws: kill still proceeds + // --------------------------------------------------------------------------- + + test("if claudeOffloadCleanup throws, killWindow still called", async () => { + const { deps, stageDir } = makeDeps("claude"); + deps.claudeOffloadCleanup = mock(async (_id: string) => { + throw new Error("boom"); + }); + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput("build", stageDir, "claude")); + + // Must not throw + await mgr.onWorkflowCompletion(); + + expect(deps.tmux.killWindow).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/atomic-sdk/src/runtime/offload-manager.deps.types.test.ts b/packages/atomic-sdk/src/runtime/offload-manager.deps.types.test.ts new file mode 100644 index 000000000..4acbe576b --- /dev/null +++ b/packages/atomic-sdk/src/runtime/offload-manager.deps.types.test.ts @@ -0,0 +1,17 @@ +import { test } from "bun:test"; +import { PanelStore } from "../components/orchestrator-panel-store.ts"; +import type { OffloadManagerDeps } from "./offload-manager.ts"; + +// Compile-time structural assignability — fails to compile if PanelStore +// drifts from OffloadManagerDeps["panelStore"]. +function _assertPanelStoreSatisfiesDeps(): void { + const store = new PanelStore(); + const _check: OffloadManagerDeps["panelStore"] = store; + void _check; +} +void _assertPanelStoreSatisfiesDeps; + +test("PanelStore structurally satisfies OffloadManagerDeps['panelStore'] (compile-time)", () => { + // The real assertion is the type-only check above. This test exists to + // ensure the file is loaded by `bun test` and `bun typecheck`. +}); diff --git a/packages/atomic-sdk/src/runtime/offload-manager.doResume-rollback.test.ts b/packages/atomic-sdk/src/runtime/offload-manager.doResume-rollback.test.ts new file mode 100644 index 000000000..813e787e0 --- /dev/null +++ b/packages/atomic-sdk/src/runtime/offload-manager.doResume-rollback.test.ts @@ -0,0 +1,251 @@ +/** + * doResume rollback-discipline tests — R2 implementation (RFC §5.2.3, §8.4). + * + * Group 1 (task #9): R2 rollback fires when waitForReady throws RESUME_TIMEOUT. + * Group 2 (task #1): Rollback failure emits WORKFLOW_OFFLOAD_RESUME_ROLLBACK_FAILED + * while preserving the original error in WORKFLOW_OFFLOAD_RESUME_FAILED. + */ + +import { test, expect, describe, mock } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createOffloadManager } from "./offload-manager.ts"; +import type { OffloadManagerDeps } from "./offload-manager.ts"; +import type { MetadataJsonWithResume } from "./offload-types.ts"; +import type { SessionData } from "../components/orchestrator-panel-types.ts"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const FIXED_NOW = 1_717_804_800_000; +const TMUX_SESSION = "atomic-wf-claude-resume-test"; + +const IMMUTABLES: Omit = { + name: "review", + description: "Review stage", + agent: "claude" as const, + paneId: "%5", + serverUrl: "", + port: 0, + startedAt: new Date(FIXED_NOW).toISOString(), +}; + +function makeStageDir(): string { + const dir = mkdtempSync(join(tmpdir(), "offload-rollback-")); + writeFileSync( + join(dir, "metadata.json"), + JSON.stringify({ ...IMMUTABLES } satisfies Omit, null, 2), + { mode: 0o600 }, + ); + return dir; +} + +// --------------------------------------------------------------------------- +// Mock-deps factory +// --------------------------------------------------------------------------- + +type EmitCall = { event: string; payload: Record }; + +interface MutablePanelStore { + sessions: SessionData[]; + activeAgentId: string; + setSessionStatus: ReturnType; +} + +function makeDeps(stageDirOverride?: string): { + deps: OffloadManagerDeps; + panelStore: MutablePanelStore; + emitCalls: EmitCall[]; + stageDir: string; +} { + const emitCalls: EmitCall[] = []; + const stageDir = stageDirOverride ?? makeStageDir(); + + const panelStore: MutablePanelStore = { + sessions: [], + activeAgentId: "", + setSessionStatus: mock(() => {}), + }; + + const deps: OffloadManagerDeps = { + panelStore: panelStore as unknown as OffloadManagerDeps["panelStore"], + tmux: { + killWindow: mock(async () => {}), + createWindow: mock(async () => {}), + selectWindow: mock(async () => {}), + }, + providers: { + claude: { buildResumeArgs: mock(() => ["--resume", "sess-abc"]) }, + opencode: { buildResumeArgs: mock(() => ["--session", "sess-abc"]) }, + copilot: { buildResumeArgs: mock(() => ["--session", "sess-abc"]) }, + }, + hookSettingsPath: mock(() => "/tmp/hook-settings.json"), + shellQuote: mock((argv: readonly string[]) => argv.join(" ")), + waitForReady: mock(async () => {}), + now: mock(() => FIXED_NOW), + emit: mock((event: string, payload: Record) => { + emitCalls.push({ event, payload }); + }), + }; + + return { deps, panelStore, emitCalls, stageDir }; +} + +function makeSessionInput(name: string, stageDir: string) { + return { + name, + runId: "run-1", + stageDir, + agent: "claude" as const, + agentSessionId: "sess-abc", + tmuxSession: TMUX_SESSION, + tmuxWindow: name, + spawnEnv: { CLAUDECODE: "1" }, + spawnCwd: "/home/user/project", + headless: false, + chatFlags: [], + }; +} + +/** + * Drive a session from "alive" → "offloaded" via public API, + * returning the manager ready for resume testing. + */ +async function setupOffloaded( + name: string, + deps: OffloadManagerDeps, + panelStore: MutablePanelStore, + stageDir: string, +) { + panelStore.sessions = [ + { name, status: "complete", parents: [], startedAt: null, endedAt: null }, + ]; + const mgr = createOffloadManager(deps); + await mgr.registerSession(makeSessionInput(name, stageDir)); + await mgr.onWorkflowCompletion(); + expect(mgr.getStatus(name)).toBe("offloaded"); + return mgr; +} + +// --------------------------------------------------------------------------- +// Group 1 (task #9) — R2 rollback fires when waitForReady throws +// --------------------------------------------------------------------------- + +describe("doResume: R2 rollback on waitForReady failure (task #9)", () => { + test( + "waitForReady throws RESUME_TIMEOUT_CLAUDE → killWindow called once, status offloaded, RESUME_FAILED emitted", + async () => { + const { deps, panelStore, emitCalls, stageDir } = makeDeps(); + + // Stub waitForReady to throw after createWindow resolves. + deps.waitForReady = mock( + async (_agent: string, _agentSessionId: string, _paneId: string) => { + throw new Error("RESUME_TIMEOUT_CLAUDE"); + }, + ) as unknown as OffloadManagerDeps["waitForReady"]; + + const mgr = await setupOffloaded("review", deps, panelStore, stageDir); + emitCalls.length = 0; + (panelStore.setSessionStatus as ReturnType).mockClear(); + (deps.tmux.killWindow as ReturnType).mockClear(); + + let thrownErr: unknown; + try { + await mgr.requestResume("review"); + } catch (e) { + thrownErr = e; + } + + // requestResume rethrows — error propagates. + expect(thrownErr).toBeInstanceOf(Error); + expect((thrownErr as Error).message).toBe("RESUME_TIMEOUT_CLAUDE"); + + // createWindow was called once (before waitForReady). + expect(deps.tmux.createWindow).toHaveBeenCalledTimes(1); + const firstCall = ((deps.tmux.createWindow as ReturnType).mock.calls as [string, string, ...unknown[]][])[0]!; + const [cwSession, cwWindow] = firstCall; + expect(cwSession).toBe(TMUX_SESSION); + expect(cwWindow).toBe("review"); + + // R2 rollback: killWindow called exactly once with matching session+window. + expect(deps.tmux.killWindow).toHaveBeenCalledTimes(1); + expect(deps.tmux.killWindow).toHaveBeenCalledWith(TMUX_SESSION, "review"); + + // Final state: rolled back to offloaded. + expect(mgr.getStatus("review")).toBe("offloaded"); + + // WORKFLOW_OFFLOAD_RESUME_FAILED emitted with correct error and errorCode. + const failedEvent = emitCalls.find((c) => c.event === "workflow.offload.resume.failed"); + expect(failedEvent).toBeDefined(); + expect(failedEvent!.payload.errorCode).toBe("RESUME_TIMEOUT"); + expect(failedEvent!.payload.error).toBe("RESUME_TIMEOUT_CLAUDE"); + expect(failedEvent!.payload.name).toBe("review"); + + // WORKFLOW_OFFLOAD_RESUME_ROLLBACK_FAILED must NOT be emitted (rollback succeeded). + const rollbackFailed = emitCalls.find( + (c) => c.event === "workflow.offload.resume.rollback_failed", + ); + expect(rollbackFailed).toBeUndefined(); + }, + ); +}); + +// --------------------------------------------------------------------------- +// Group 2 (task #1) — Rollback failure telemetry +// --------------------------------------------------------------------------- + +describe("doResume: rollback failure telemetry (task #1)", () => { + test( + "waitForReady throws RESUME_TIMEOUT_CLAUDE AND killWindow throws → ROLLBACK_FAILED emitted, " + + "original error preserved in RESUME_FAILED, status offloaded", + async () => { + const { deps, panelStore, emitCalls, stageDir } = makeDeps(); + + // waitForReady throws the primary error. + deps.waitForReady = mock( + async (_agent: string, _agentSessionId: string, _paneId: string) => { + throw new Error("RESUME_TIMEOUT_CLAUDE"); + }, + ) as unknown as OffloadManagerDeps["waitForReady"]; + + const mgr = await setupOffloaded("review", deps, panelStore, stageDir); + emitCalls.length = 0; + (panelStore.setSessionStatus as ReturnType).mockClear(); + (deps.tmux.killWindow as ReturnType).mockClear(); + + // killWindow also throws during rollback. + deps.tmux.killWindow = mock(async () => { + throw new Error("KILL_WINDOW_FAILED"); + }) as unknown as OffloadManagerDeps["tmux"]["killWindow"]; + + let thrownErr: unknown; + try { + await mgr.requestResume("review"); + } catch (e) { + thrownErr = e; + } + + // requestResume rethrows the ORIGINAL error, not the rollback error. + expect(thrownErr).toBeInstanceOf(Error); + expect((thrownErr as Error).message).toBe("RESUME_TIMEOUT_CLAUDE"); + + // WORKFLOW_OFFLOAD_RESUME_FAILED: error field is the ORIGINAL error. + const failedEvent = emitCalls.find((c) => c.event === "workflow.offload.resume.failed"); + expect(failedEvent).toBeDefined(); + expect(failedEvent!.payload.error).toBe("RESUME_TIMEOUT_CLAUDE"); + expect(failedEvent!.payload.errorCode).toBe("RESUME_TIMEOUT"); + + // WORKFLOW_OFFLOAD_RESUME_ROLLBACK_FAILED also emitted with the rollback error. + const rollbackFailed = emitCalls.find( + (c) => c.event === "workflow.offload.resume.rollback_failed", + ); + expect(rollbackFailed).toBeDefined(); + expect(rollbackFailed!.payload.error).toBe("KILL_WINDOW_FAILED"); + + // Final state: rolled back to offloaded even when rollback itself failed. + expect(mgr.getStatus("review")).toBe("offloaded"); + }, + ); +}); diff --git a/packages/atomic-sdk/src/runtime/offload-manager.eligibility.test.ts b/packages/atomic-sdk/src/runtime/offload-manager.eligibility.test.ts new file mode 100644 index 000000000..671edfa00 --- /dev/null +++ b/packages/atomic-sdk/src/runtime/offload-manager.eligibility.test.ts @@ -0,0 +1,179 @@ +/** + * Tests for isEligibleForOffload. + * Spec: specs/2026-05-08-workflow-pane-offload-and-resume.md §5.2, RFC §3.1 + * + * Tests: + * 1. All three v1 providers (claude/opencode/copilot) offload by default. + * 2. Headless session is skipped (headless check fires first). + */ + +import { test, expect, mock, describe } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createOffloadManager } from "./offload-manager.ts"; +import type { OffloadManagerDeps } from "./offload-manager.ts"; +import type { AgentKind } from "./offload-types.ts"; +import type { SessionData } from "../components/orchestrator-panel-types.ts"; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +type EmitCall = { event: string; payload: Record }; + +interface MutablePanelStore { + sessions: SessionData[]; + activeAgentId: string; + setSessionStatus: ReturnType; +} + +const TMUX_SESSION = "atomic-wf-elg-test-1"; + +function makeDeps(): { deps: OffloadManagerDeps; panelStore: MutablePanelStore; emitCalls: EmitCall[] } { + const emitCalls: EmitCall[] = []; + const panelStore: MutablePanelStore = { + sessions: [], + activeAgentId: "", + setSessionStatus: mock(() => {}), + }; + const deps: OffloadManagerDeps = { + panelStore: panelStore as unknown as OffloadManagerDeps["panelStore"], + tmux: { + killWindow: mock(async () => {}), + createWindow: mock(async () => {}), + selectWindow: mock(async () => {}), + }, + providers: { + claude: { buildResumeArgs: mock(() => []) }, + opencode: { buildResumeArgs: mock(() => []) }, + copilot: { buildResumeArgs: mock(() => []) }, + }, + hookSettingsPath: mock(() => "/tmp/hook-settings.json"), + shellQuote: mock((argv: readonly string[]) => argv.join(" ")), + waitForReady: mock(async (_agent: AgentKind, _agentSessionId: string, _paneId: string) => {}), + now: mock(() => Date.now()), + emit: mock((event: string, payload: Record) => { + emitCalls.push({ event, payload }); + }), + }; + return { deps, panelStore, emitCalls }; +} + +/** + * Create a temp stageDir with a minimal metadata.json for persistResume. + * registerSession (headless:false) reads/writes this file. + */ +function makeStageDir(name: string, agent: string): string { + const dir = mkdtempSync(join(tmpdir(), `offload-elg-${agent}-`)); + writeFileSync( + join(dir, "metadata.json"), + JSON.stringify({ + name, + description: `${agent} stage`, + agent, + paneId: "%1", + serverUrl: "", + port: 0, + startedAt: new Date().toISOString(), + }), + { mode: 0o600 }, + ); + return dir; +} + +function pushSession(panelStore: MutablePanelStore, name: string): void { + panelStore.sessions.push({ + name, + status: "complete", + parents: [], + startedAt: null, + endedAt: null, + }); +} + +// --------------------------------------------------------------------------- +// Test 1 — All three providers offload by default (RFC §3.1 positive path) +// --------------------------------------------------------------------------- + +describe("isEligibleForOffload: all v1 providers are eligible by default", () => { + test("claude, opencode, copilot each emit WORKFLOW_OFFLOAD_COMPLETED and transition to offloaded", async () => { + const { deps, panelStore, emitCalls } = makeDeps(); + const mgr = createOffloadManager(deps); + const agents: AgentKind[] = ["claude", "opencode", "copilot"]; + + for (const agent of agents) { + const name = `pane-${agent}`; + const stageDir = makeStageDir(name, agent); + await mgr.registerSession({ + name, + runId: "run-multi", + stageDir, + agent, + agentSessionId: `sess-${agent}`, + tmuxSession: TMUX_SESSION, + tmuxWindow: name, + spawnEnv: {}, + spawnCwd: "/home/user/project", + chatFlags: [], + headless: false, + }); + pushSession(panelStore, name); + } + + panelStore.activeAgentId = ""; + + await mgr.onWorkflowCompletion(); + + // killWindow called three times — once per agent. + expect(deps.tmux.killWindow).toHaveBeenCalledTimes(3); + + // Each call used the correct session+window args. + const killCalls = (deps.tmux.killWindow as ReturnType).mock.calls as [string, string][]; + for (const agent of agents) { + const name = `pane-${agent}`; + expect(killCalls.some(([s, w]) => s === TMUX_SESSION && w === name)).toBe(true); + } + + // WORKFLOW_OFFLOAD_COMPLETED emitted exactly three times. + const completedEvents = emitCalls.filter((c) => c.event === "workflow.offload.completed"); + expect(completedEvents).toHaveLength(3); + + // All three sessions transitioned to "offloaded". + for (const agent of agents) { + expect(mgr.getStatus(`pane-${agent}`)).toBe("offloaded"); + } + }); +}); + +// --------------------------------------------------------------------------- +// Test 2 — Headless session is skipped (RFC §5.2.2) +// --------------------------------------------------------------------------- + +describe("isEligibleForOffload: headless session is skipped", () => { + test("headless claude session: killWindow not called", async () => { + const { deps, panelStore } = makeDeps(); + const mgr = createOffloadManager(deps); + + mgr.registerSession({ + name: "stage-headless", + runId: "run-headless", + stageDir: "/tmp/nonexistent-headless", + agent: "claude", + agentSessionId: "sess-headless", + tmuxSession: TMUX_SESSION, + tmuxWindow: "stage-headless", + spawnEnv: {}, + spawnCwd: "/home/user/project", + chatFlags: [], + headless: true, + }); + pushSession(panelStore, "stage-headless"); + panelStore.activeAgentId = ""; + + await mgr.onWorkflowCompletion(); + + // Headless gate fired — no kill. + expect(deps.tmux.killWindow).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/atomic-sdk/src/runtime/offload-manager.persistResume.test.ts b/packages/atomic-sdk/src/runtime/offload-manager.persistResume.test.ts new file mode 100644 index 000000000..e74610bd9 --- /dev/null +++ b/packages/atomic-sdk/src/runtime/offload-manager.persistResume.test.ts @@ -0,0 +1,272 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, writeFileSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { MetadataJsonWithResume } from "./offload-types.ts"; +import { persistResume } from "./offload-manager.ts"; + +// ─── fixtures ──────────────────────────────────────────────────────────────── + +const IMMUTABLES = { + name: "review", + description: "Review code changes", + agent: "claude" as const, + paneId: "%7", + serverUrl: "", + port: 0, + startedAt: new Date(1_717_804_800_000).toISOString(), +} satisfies Omit; + +function makeStageDir(): string { + return mkdtempSync(join(tmpdir(), "atomic-test-")); +} + +function writeMetadata(stageDir: string, data: MetadataJsonWithResume): void { + writeFileSync(join(stageDir, "metadata.json"), JSON.stringify(data, null, 2)); +} + +function readMetadata(stageDir: string): MetadataJsonWithResume { + const raw = require("node:fs").readFileSync(join(stageDir, "metadata.json"), "utf8"); + return JSON.parse(raw) as MetadataJsonWithResume; +} + +// ─── tracer bullet: basic write ─────────────────────────────────────────────── + +test("persistResume writes resume sub-object into metadata.json", async () => { + const dir = makeStageDir(); + writeMetadata(dir, { ...IMMUTABLES }); + + await persistResume(dir, { + agentSessionId: "abc-123", + tmuxSessionName: "atomic-aabbccdd", + tmuxWindowName: "review", + spawnEnv: { CLAUDECODE: "1" }, + spawnCwd: "/home/user/project", + lastPrompt: "Fix the bug", + lastSeenAt: 1_717_804_900_000, + offloadedAt: null, + }); + + const meta = readMetadata(dir); + expect(meta.resume).toBeDefined(); + expect(meta.resume?.schemaVersion).toBe(1); + expect(meta.resume?.agentSessionId).toBe("abc-123"); + expect(meta.resume?.offloadedAt).toBeNull(); +}); + +// ─── immutables are preserved ───────────────────────────────────────────────── + +test("immutable top-level fields unchanged after persistResume", async () => { + const dir = makeStageDir(); + writeMetadata(dir, { ...IMMUTABLES }); + + const before = readMetadata(dir); + + await persistResume(dir, { + agentSessionId: "xyz", + tmuxSessionName: "s", + tmuxWindowName: "w", + spawnEnv: {}, + spawnCwd: "/", + lastPrompt: "p", + lastSeenAt: 0, + offloadedAt: null, + }); + + const after = readMetadata(dir); + + expect(after.name).toBe(before.name); + expect(after.description).toBe(before.description); + expect(after.agent).toBe(before.agent); + expect(after.paneId).toBe(before.paneId); + expect(after.serverUrl).toBe(before.serverUrl); + expect(after.port).toBe(before.port); + expect(after.startedAt).toBe(before.startedAt); +}); + +// ─── patch wins on merge ────────────────────────────────────────────────────── + +test("patch fields overwrite existing resume fields", async () => { + const dir = makeStageDir(); + writeMetadata(dir, { + ...IMMUTABLES, + resume: { + schemaVersion: 1, + agentSessionId: "old-id", + tmuxSessionName: "old-session", + tmuxWindowName: "old-win", + spawnEnv: {}, + spawnCwd: "/old", + chatFlags: [], + lastPrompt: "old prompt", + lastSeenAt: 1000, + offloadedAt: null, + }, + }); + + await persistResume(dir, { agentSessionId: "new-id", lastSeenAt: 9999 }); + + const meta = readMetadata(dir); + expect(meta.resume?.agentSessionId).toBe("new-id"); + expect(meta.resume?.lastSeenAt).toBe(9999); + // untouched field retained + expect(meta.resume?.tmuxSessionName).toBe("old-session"); +}); + +// ─── schema mismatch ───────────────────────────────────────────────────────── + +test("throws on unsupported schemaVersion", async () => { + const dir = makeStageDir(); + const badMeta = { + ...IMMUTABLES, + resume: { + schemaVersion: 2, + agentSessionId: "", + tmuxSessionName: "", + tmuxWindowName: "", + spawnEnv: {}, + spawnCwd: "", + lastPrompt: "", + lastSeenAt: 0, + offloadedAt: null, + }, + }; + writeFileSync(join(dir, "metadata.json"), JSON.stringify(badMeta)); + + let caught: unknown = null; + try { + await persistResume(dir, { lastSeenAt: 1 }); + } catch (err) { + caught = err; + } + expect((caught as Error).message).toBe("unsupported resume schemaVersion: 2"); +}); + +// ─── missing metadata.json ──────────────────────────────────────────────────── + +test("throws when metadata.json is missing", async () => { + const dir = makeStageDir(); + // no metadata.json written + + const metaPath = join(dir, "metadata.json"); + let caught: unknown = null; + try { + await persistResume(dir, { lastSeenAt: 1 }); + } catch (err) { + caught = err; + } + expect((caught as Error).message).toBe(`metadata.json not found at ${metaPath}`); +}); + +// ─── file mode 0o600 ───────────────────────────────────────────────────────── + +test("written file has mode 0o600", async () => { + const dir = makeStageDir(); + writeMetadata(dir, { ...IMMUTABLES }); + + await persistResume(dir, { + agentSessionId: "id", + tmuxSessionName: "s", + tmuxWindowName: "w", + spawnEnv: {}, + spawnCwd: "/", + lastPrompt: "p", + lastSeenAt: 0, + offloadedAt: null, + }); + + const mode = statSync(join(dir, "metadata.json")).mode & 0o777; + expect(mode).toBe(0o600); +}); + +// ─── concurrency: 100 concurrent calls serialize, no lost writes ────────────── + +test("100 concurrent persistResume calls for same stageDir all complete", async () => { + const dir = makeStageDir(); + writeMetadata(dir, { ...IMMUTABLES }); + + // Seed initial resume so merges have a base + await persistResume(dir, { + agentSessionId: "seed", + tmuxSessionName: "s", + tmuxWindowName: "w", + spawnEnv: {}, + spawnCwd: "/", + lastPrompt: "seed", + lastSeenAt: 0, + offloadedAt: null, + }); + + const N = 100; + const promises = Array.from({ length: N }, (_, i) => + persistResume(dir, { lastSeenAt: i + 1 }), + ); + + await Promise.all(promises); + + // All completed — final file is valid JSON with schemaVersion 1 + const meta = readMetadata(dir); + expect(meta.resume?.schemaVersion).toBe(1); + // lastSeenAt should be one of 1..100 (last writer wins, serialized) + expect(meta.resume?.lastSeenAt).toBeGreaterThanOrEqual(1); + expect(meta.resume?.lastSeenAt).toBeLessThanOrEqual(N); + // All immutables intact + expect(meta.name).toBe(IMMUTABLES.name); + expect(meta.agent).toBe(IMMUTABLES.agent); +}); + +// ─── _resumeDefaults includes chatFlags: [] ────────────────────────────────── + +test("_resumeDefaults produces chatFlags: [] when no patch is supplied", async () => { + const dir = makeStageDir(); + // Write metadata with no resume block so _resumeDefaults is applied + writeMetadata(dir, { ...IMMUTABLES }); + + // Apply a minimal patch that does NOT include chatFlags + await persistResume(dir, { agentSessionId: "sess-001" }); + + const meta = readMetadata(dir); + // chatFlags should default to [] from _resumeDefaults + expect(meta.resume?.chatFlags).toEqual([]); +}); + +test("_resumeDefaults chatFlags: [] is overridden when patch supplies chatFlags", async () => { + const dir = makeStageDir(); + writeMetadata(dir, { ...IMMUTABLES }); + + await persistResume(dir, { chatFlags: ["--model", "claude-opus-4-5"] }); + + const meta = readMetadata(dir); + expect(meta.resume?.chatFlags).toEqual(["--model", "claude-opus-4-5"]); +}); + +// ─── concurrent calls for different stageDirs don't interfere ──────────────── + +test("concurrent persistResume for different stageDirs complete independently", async () => { + const dirs = Array.from({ length: 5 }, () => { + const d = makeStageDir(); + writeMetadata(d, { ...IMMUTABLES }); + return d; + }); + + const promises = dirs.map((d, i) => + persistResume(d, { + agentSessionId: `id-${i}`, + tmuxSessionName: `s-${i}`, + tmuxWindowName: `w-${i}`, + spawnEnv: {}, + spawnCwd: "/", + lastPrompt: `prompt-${i}`, + lastSeenAt: i, + offloadedAt: null, + }), + ); + + await Promise.all(promises); + + for (let i = 0; i < dirs.length; i++) { + const meta = readMetadata(dirs[i]!); + expect(meta.resume?.agentSessionId).toBe(`id-${i}`); + expect(meta.resume?.lastSeenAt).toBe(i); + } +}); diff --git a/packages/atomic-sdk/src/runtime/offload-manager.skeleton.test.ts b/packages/atomic-sdk/src/runtime/offload-manager.skeleton.test.ts new file mode 100644 index 000000000..310e8daf0 --- /dev/null +++ b/packages/atomic-sdk/src/runtime/offload-manager.skeleton.test.ts @@ -0,0 +1,121 @@ +/** + * State-machine unit tests for the OffloadManager skeleton. + * Spec: specs/2026-05-08-workflow-pane-offload-and-resume.md §5.2, task #12 + */ + +import { test, expect, mock } from "bun:test"; +import { createOffloadManager } from "./offload-manager.ts"; +import type { OffloadManagerDeps } from "./offload-manager.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeDeps(): OffloadManagerDeps { + return { + panelStore: { + sessions: [], + activeAgentId: "", + setSessionStatus: mock(() => {}), + }, + tmux: { + killWindow: mock(async () => {}), + createWindow: mock(async () => {}), + selectWindow: mock(async () => {}), + }, + providers: { + claude: { buildResumeArgs: mock(() => []) }, + opencode: { buildResumeArgs: mock(() => []) }, + copilot: { buildResumeArgs: mock(() => []) }, + }, + hookSettingsPath: mock(() => "/tmp/hook-settings.json"), + shellQuote: mock((argv: readonly string[]) => argv.join(" ")), + waitForReady: mock(async () => {}), + now: mock(() => Date.now()), + emit: mock(() => {}), + }; +} + +function makeSessionInput(name = "review") { + return { + name, + runId: "run-1", + stageDir: "/tmp/stage/review", + agent: "claude" as const, + agentSessionId: "sess-abc", + tmuxSession: "atomic-wf-claude-test-1", + tmuxWindow: name, + spawnEnv: { CLAUDECODE: "1" }, + spawnCwd: "/home/user/project", + chatFlags: [], + // Use headless:true for state-machine tests so they skip disk I/O and + // remain self-contained without needing a real stageDir on disk. + headless: true, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test("registerSession then getStatus returns 'alive'", () => { + const mgr = createOffloadManager(makeDeps()); + mgr.registerSession(makeSessionInput("review")); + expect(mgr.getStatus("review")).toBe("alive"); +}); + +test("getStatus of unknown name returns 'alive' (defensive)", () => { + const mgr = createOffloadManager(makeDeps()); + expect(mgr.getStatus("nonexistent")).toBe("alive"); +}); + +test("onWorkflowCompletion resolves (no eligible panes — headless session)", async () => { + const deps = makeDeps(); + const mgr = createOffloadManager(deps); + // headless:true sessions are skipped + mgr.registerSession({ ...makeSessionInput("review"), headless: true }); + const result = await mgr.onWorkflowCompletion(); + expect(result).toBeUndefined(); +}); + +test("requestResume resolves immediately for unknown name", async () => { + const mgr = createOffloadManager(makeDeps()); + const result = await mgr.requestResume("nonexistent"); + expect(result).toBeUndefined(); +}); + +test("requestResume resolves immediately when session is alive", async () => { + const mgr = createOffloadManager(makeDeps()); + mgr.registerSession(makeSessionInput("review")); + const result = await mgr.requestResume("review"); + expect(result).toBeUndefined(); +}); + +test("multiple registerSession calls coexist independently", () => { + const mgr = createOffloadManager(makeDeps()); + mgr.registerSession(makeSessionInput("stage-a")); + mgr.registerSession(makeSessionInput("stage-b")); + expect(mgr.getStatus("stage-a")).toBe("alive"); + expect(mgr.getStatus("stage-b")).toBe("alive"); + expect(mgr.getStatus("stage-c")).toBe("alive"); // unknown → defensive alive +}); + +test("idempotency: two concurrent calls for same op-name share the underlying promise (op invoked once)", async () => { + const { _testOnlyGetOrStartOp } = await import("./offload-manager.ts"); + + // Use an isolated per-test queue so module-level state doesn't bleed between tests. + const testQueue = new Map>(); + + let invokeCount = 0; + const op = async () => { + invokeCount++; + await Promise.resolve(); + }; + + const p1 = _testOnlyGetOrStartOp("x", op, testQueue); + const p2 = _testOnlyGetOrStartOp("x", op, testQueue); + + expect(p1).toBe(p2); // same promise object — deduplication in effect + await Promise.all([p1, p2]); + expect(invokeCount).toBe(1); // op only ran once — not double-invoked +}); diff --git a/packages/atomic-sdk/src/runtime/offload-manager.test.ts b/packages/atomic-sdk/src/runtime/offload-manager.test.ts new file mode 100644 index 000000000..9d3758753 --- /dev/null +++ b/packages/atomic-sdk/src/runtime/offload-manager.test.ts @@ -0,0 +1,218 @@ +/** + * Regression tests for offload-manager.ts Bug 1 (cascade) and Bug 2 (null-safe schema guard). + * RFC: specs/2026-05-08-workflow-pane-offload-and-resume.md §5.1, §5.2 + */ + +import { test, expect, describe } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { MetadataJsonWithResume } from "./offload-types.ts"; +import { persistResume } from "./offload-manager.ts"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const IMMUTABLES: Omit = { + name: "regression", + description: "regression test stage", + agent: "claude" as const, + paneId: "%1", + serverUrl: "", + port: 0, + startedAt: new Date(1_717_804_800_000).toISOString(), +}; + +function makeStageDir(): string { + return mkdtempSync(join(tmpdir(), "offload-mgr-")); +} + +function writeMetadata(stageDir: string, data: object): void { + writeFileSync(join(stageDir, "metadata.json"), JSON.stringify(data, null, 2)); +} + +function readMetadata(stageDir: string): MetadataJsonWithResume { + const raw = readFileSync(join(stageDir, "metadata.json"), "utf8"); + return JSON.parse(raw) as MetadataJsonWithResume; +} + +// --------------------------------------------------------------------------- +// Bug 1 — persistResume chain swallows queued writes +// --------------------------------------------------------------------------- + +describe("Bug 1: cascade isolation", () => { + /** + * Cascade detection via Error object identity. + * + * With the old `.then()` chain: when p1 rejects, `_doPersist` for p2 is + * NEVER called — p2's `next` promise inherits p1's rejection reason + * (same Error object reference). + * + * With the fix (`.catch(() => undefined).then()`): p2 runs its own + * `_doPersist` and creates a NEW Error object — different reference. + * + * Both p1 and p2 fail with the same message text ("not found"), but the + * Error instances are distinct iff the fix is present. + */ + test("persistResume rejection does not cascade to next queued caller", async () => { + const dir = makeStageDir(); + // No metadata.json — both calls will fail with their own "not found" errors. + + const p1 = persistResume(dir, { lastSeenAt: 1 }); + // Enqueue p2 on the same mutex chain synchronously. + const p2 = persistResume(dir, { lastSeenAt: 2 }); + + let err1: Error | undefined; + let err2: Error | undefined; + await p1.catch((e: Error) => { err1 = e; }); + await p2.catch((e: Error) => { err2 = e; }); + + // Both fail (file absent). + expect(err1?.message).toMatch(/metadata\.json not found/); + expect(err2?.message).toMatch(/metadata\.json not found/); + + // Key assertion: distinct Error instances prove p2 ran its own _doPersist. + // With cascade bug: err1 === err2 (same object propagated). + // With fix: err1 !== err2 (each call creates its own Error). + expect(err1).not.toBe(err2); + }); + + /** + * "Own outcome" variant: p1 rejects (schema mismatch); p2 also rejects + * independently with its own schema-mismatch Error instance. + * Asserts: p1 error !== p2 error (different objects, same message text). + */ + test("persistResume in-flight caller observes only own outcome", async () => { + const dir = makeStageDir(); + // Write a file with schemaVersion:99 — both callers fail schema check. + writeFileSync( + join(dir, "metadata.json"), + JSON.stringify({ ...IMMUTABLES, resume: { schemaVersion: 99 } }), + ); + + const p1 = persistResume(dir, { lastSeenAt: 10 }); + const p2 = persistResume(dir, { lastSeenAt: 99 }); + + let err1: Error | undefined; + let err2: Error | undefined; + await p1.catch((e: Error) => { err1 = e; }); + await p2.catch((e: Error) => { err2 = e; }); + + // Both callers fail with schema mismatch. + expect(err1?.message).toMatch(/unsupported resume schemaVersion/); + expect(err2?.message).toMatch(/unsupported resume schemaVersion/); + + // Each caller created its own Error — not the same object (no cascade). + expect(err1).not.toBe(err2); + }); +}); + +// --------------------------------------------------------------------------- +// Bug 2 — _doPersist null-safe schema guard +// --------------------------------------------------------------------------- + +describe("Bug 2: null-safe schema guard", () => { + test("_doPersist throws schema-mismatch on resume === null", async () => { + const dir = makeStageDir(); + // Write metadata.json with literal `"resume": null` + writeFileSync( + join(dir, "metadata.json"), + JSON.stringify({ ...IMMUTABLES, resume: null }), + ); + + await expect(persistResume(dir, { lastSeenAt: 1 })).rejects.toThrow( + /unsupported resume schemaVersion/, + ); + }); + + test.each([ + ["array", []], + ["number", 42], + ["string", "x"], + ])("_doPersist throws schema-mismatch on resume === %s", async (_label, value) => { + const dir = makeStageDir(); + // Write metadata with resume set to the invalid value. + writeFileSync( + join(dir, "metadata.json"), + JSON.stringify({ ...IMMUTABLES, resume: value }), + ); + + await expect(persistResume(dir, { lastSeenAt: 1 })).rejects.toThrow( + /unsupported resume schemaVersion/, + ); + }); + + test("_doPersist passes when resume === undefined (key absent)", async () => { + const dir = makeStageDir(); + // Write metadata without any resume key. + writeMetadata(dir, { ...IMMUTABLES }); + + await expect( + persistResume(dir, { + agentSessionId: "sess-1", + tmuxSessionName: "s", + tmuxWindowName: "w", + spawnEnv: {}, + spawnCwd: "/", + lastPrompt: "p", + lastSeenAt: 5, + offloadedAt: null, + }), + ).resolves.toBeUndefined(); + + const meta = readMetadata(dir); + expect(meta.resume?.schemaVersion).toBe(1); + expect(meta.resume?.agentSessionId).toBe("sess-1"); + }); + + test("_doPersist passes when resume is valid schemaVersion:1 object", async () => { + const dir = makeStageDir(); + writeMetadata(dir, { + ...IMMUTABLES, + resume: { + schemaVersion: 1, + agentSessionId: "old", + tmuxSessionName: "s", + tmuxWindowName: "w", + spawnEnv: {}, + spawnCwd: "/", + lastPrompt: "p", + lastSeenAt: 0, + offloadedAt: null, + }, + }); + + await expect(persistResume(dir, { lastSeenAt: 77 })).resolves.toBeUndefined(); + + const meta = readMetadata(dir); + expect(meta.resume?.lastSeenAt).toBe(77); + expect(meta.resume?.schemaVersion).toBe(1); + }); + + test("_doPersist throws schema-mismatch when schemaVersion is 2", async () => { + const dir = makeStageDir(); + writeFileSync( + join(dir, "metadata.json"), + JSON.stringify({ + ...IMMUTABLES, + resume: { + schemaVersion: 2, + agentSessionId: "", + tmuxSessionName: "", + tmuxWindowName: "", + spawnEnv: {}, + spawnCwd: "", + lastPrompt: "", + lastSeenAt: 0, + offloadedAt: null, + }, + }), + ); + + await expect(persistResume(dir, { lastSeenAt: 1 })).rejects.toThrow( + /unsupported resume schemaVersion/, + ); + }); +}); diff --git a/packages/atomic-sdk/src/runtime/offload-manager.ts b/packages/atomic-sdk/src/runtime/offload-manager.ts new file mode 100644 index 000000000..78173e2e1 --- /dev/null +++ b/packages/atomic-sdk/src/runtime/offload-manager.ts @@ -0,0 +1,581 @@ +/** + * OffloadManager — workflow pane offload & resume state machine. + * Spec: specs/2026-05-08-workflow-pane-offload-and-resume.md §5.2 + */ + +import { promises as fs } from "node:fs"; +import { join } from "node:path"; +import type { OffloadResumeMetadata, MetadataJsonWithResume, AgentKind } from "./offload-types.ts"; +import type { SessionData } from "../components/orchestrator-panel-types.ts"; +import { claudeOffloadCleanup as _realClaudeOffloadCleanup } from "../providers/claude.ts"; +import type { ClaudeMarkerCleanupResult } from "../providers/claude.ts"; + +// Telemetry event-name constants — kept in sync with +// packages/atomic/src/lib/telemetry/offload-events.ts (avoids cross-package dep). +const WORKFLOW_OFFLOAD_SCHEDULED = "workflow.offload.scheduled" as const; +const WORKFLOW_OFFLOAD_COMPLETED = "workflow.offload.completed" as const; +const WORKFLOW_OFFLOAD_RESUME_ATTEMPTED = "workflow.offload.resume.attempted" as const; +const WORKFLOW_OFFLOAD_RESUME_SUCCEEDED = "workflow.offload.resume.succeeded" as const; +const WORKFLOW_OFFLOAD_RESUME_FAILED = "workflow.offload.resume.failed" as const; +const WORKFLOW_OFFLOAD_REGISTER_PERSISTED = "workflow.offload.register.persisted" as const; +const WORKFLOW_OFFLOAD_RESUME_ROLLBACK_FAILED = "workflow.offload.resume.rollback_failed" as const; +const WORKFLOW_OFFLOAD_CLAUDE_MARKER_CLEANUP = "workflow.offload.claude_marker_cleanup" as const; + +// ─── filterSpawnEnv ───────────────────────────────────────────────────────── + +const SPAWN_ENV_EXACT_ALLOW: ReadonlySet = new Set([ + "CLAUDECODE", + "PATH", + "HOME", + "LANG", + "SHELL", +]); +const SPAWN_ENV_PREFIX_ALLOW: readonly string[] = ["ATOMIC_", "LC_", "OPENCODE_", "COPILOT_"]; +const SPAWN_ENV_EXACT_DENY: ReadonlySet = new Set([ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "OPENAI_API_KEY", + "GITHUB_TOKEN", + "GH_TOKEN", +]); +const SPAWN_ENV_SUFFIX_DENY = /_(API_KEY|AUTH_TOKEN|SECRET|TOKEN|PASSWORD)$/i; + +/** + * Allowlist filter applied to `spawnEnv` before persisting to disk. + * + * The in-memory spawnEnv (used for actual tmux exec) retains ALL keys so + * tokens stripped from disk are re-injected at resume time (RFC §7.1). + */ +export function filterSpawnEnv(env: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (SPAWN_ENV_EXACT_DENY.has(key) || SPAWN_ENV_SUFFIX_DENY.test(key)) continue; + if (SPAWN_ENV_EXACT_ALLOW.has(key) || SPAWN_ENV_PREFIX_ALLOW.some((p) => key.startsWith(p))) { + result[key] = value; + } + } + return result; +} + +// ─── persistResume ────────────────────────────────────────────────────────── + +/** + * Per-stageDir mutex map. Each entry holds the tail of the promise chain + * for that stage; a new call appends onto the tail so concurrent writers + * for the same stage serialize. + */ +const _stageMutex = new Map>(); + +/** Defaults applied when the metadata has no `resume` block yet. */ +const _resumeDefaults: Omit = { + agentSessionId: "", + tmuxSessionName: "", + tmuxWindowName: "", + spawnEnv: {}, + spawnCwd: "", + chatFlags: [], + lastPrompt: "", + lastSeenAt: 0, + offloadedAt: null, +}; + +/** + * True iff `value` is a v1 `OffloadResumeMetadata` plain object. + * Used by both `_doPersist` (to gate writes) and `doResume` (to gate spawn). + */ +function isValidResumeBlock(value: unknown): value is OffloadResumeMetadata { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + (value as { schemaVersion?: unknown }).schemaVersion === 1 + ); +} + +/** + * Extract a useful `schemaVersion` value for the schema-mismatch error message. + * For non-object inputs, returns the value itself so the operator sees `null`, + * `42`, etc. instead of `undefined`. + */ +function describeInvalidResumeBlock(value: unknown): unknown { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + return (value as { schemaVersion?: unknown }).schemaVersion; + } + return value; +} + +/** + * Atomically read-modify-write the `resume` sub-object of + * `${stageDir}/metadata.json` under a per-stageDir in-process mutex. + * + * Guarantees: + * - Concurrent calls for the same `stageDir` are serialized. + * - Top-level immutable fields (`name`, `description`, `agent`, `paneId`, + * `serverUrl`, `port`, `startedAt`) are written back verbatim. + * - `patch` fields always win; other existing `resume` fields are retained. + * - File is written atomically via a `.tmp` rename and mode 0o600. + * + * @throws Error("metadata.json not found at ") if the file is missing. + * @throws Error("unsupported resume schemaVersion: ") if existing + * `resume.schemaVersion` is not 1. + */ +export async function persistResume( + stageDir: string, + patch: Partial, +): Promise { + const metaPath = join(stageDir, "metadata.json"); + + // Mutex-order writes via tail-chaining. `.catch` isolates each link from + // the previous link's outcome so a queued caller's failure doesn't poison + // the chain (each call still observes its own outcome via `next`). + const prev = _stageMutex.get(stageDir) ?? Promise.resolve(); + const next: Promise = prev + .catch(() => undefined) + .then(() => _doPersist(metaPath, patch)); + + // Register the new tail synchronously so callers arriving after this point + // append correctly. The trailing `.catch` swallows the cleanup chain's + // mirrored rejection — the caller still observes failure via `next`. + _stageMutex.set(stageDir, next); + next + .finally(() => { + if (_stageMutex.get(stageDir) === next) _stageMutex.delete(stageDir); + }) + .catch(() => {}); + + return next; +} + +async function _doPersist( + metaPath: string, + patch: Partial, +): Promise { + let raw: string; + try { + raw = await fs.readFile(metaPath, "utf8"); + } catch { + throw new Error(`metadata.json not found at ${metaPath}`); + } + + const existing = JSON.parse(raw) as MetadataJsonWithResume; + + // The `resume` slot must be either absent or a v1 plain object. Anything + // else (null, primitive, array, foreign schemaVersion) is a schema mismatch. + if (existing.resume !== undefined && !isValidResumeBlock(existing.resume)) { + throw new Error( + `unsupported resume schemaVersion: ${describeInvalidResumeBlock(existing.resume)}`, + ); + } + + // Merge precedence: defaults < existing.resume < patch; schemaVersion + // always pinned to 1. Spreading `undefined` is a JS no-op. + const nextResume: OffloadResumeMetadata = { + ..._resumeDefaults, + ...existing.resume, + ...patch, + schemaVersion: 1, + }; + + // Top-level fields (immutable per write-once contract) echoed verbatim; + // only `resume` mutates. + const nextMeta: MetadataJsonWithResume = { + name: existing.name, + description: existing.description, + agent: existing.agent, + paneId: existing.paneId, + serverUrl: existing.serverUrl, + port: existing.port, + startedAt: existing.startedAt, + resume: nextResume, + }; + + // Atomic write: 0o600 tmp file + rename over the destination. + const tmpPath = `${metaPath}.tmp`; + await fs.writeFile(tmpPath, JSON.stringify(nextMeta, null, 2), { + mode: 0o600, + encoding: "utf8", + }); + await fs.rename(tmpPath, metaPath); +} + +// ─── Public interfaces ────────────────────────────────────────────────────── + +export interface OffloadManager { + registerSession(input: { + name: string; + runId: string; + stageDir: string; + agent: AgentKind; + agentSessionId: string; + tmuxSession: string; + tmuxWindow: string; + spawnEnv: Record; + spawnCwd: string; + lastPrompt?: string; + headless: boolean; + /** Effective merged chatFlags used at original spawn time. Persisted into the resume block. */ + chatFlags: string[]; + }): Promise; + /** + * Offload a single stage as soon as its callback completes. + * No-op if the session is unknown, headless, not complete, or already offloaded. + * Idempotent — coalesces with `onWorkflowCompletion` via the per-name op queue. + */ + offloadSession(name: string): Promise; + onWorkflowCompletion(): Promise; + requestResume(name: string): Promise; + getStatus(name: string): "alive" | "offloaded" | "resuming"; +} + +export interface OffloadManagerDeps { + panelStore: { + /** Live array reference — caller must not mutate. */ + readonly sessions: readonly SessionData[]; + /** Empty-string sentinel for "no agent attached" — never null. */ + readonly activeAgentId: string; + setSessionStatus(name: string, status: SessionData["status"]): void; + }; + tmux: { + killWindow(session: string, window: string): Promise; + createWindow( + session: string, + window: string, + command: string, + cwd: string, + envVars: Record, + ): Promise; + selectWindow(session: string, window: string): Promise; + }; + providers: { + claude: { + buildResumeArgs( + meta: Pick, + hookSettingsPath: string, + ): string[]; + }; + opencode: { + buildResumeArgs( + meta: Pick, + ): string[]; + }; + copilot: { + buildResumeArgs( + meta: Pick, + ): string[]; + }; + }; + /** Resolve Claude hook-settings path lazily; only called on Claude resume. */ + hookSettingsPath(): string; + /** + * Join agent binary + argv into a single shell command string for + * `tmux new-window`. Each arg is single-quoted with embedded single-quotes + * escaped via `'\''` (RFC §9 Q12). Exposed on deps for testability. + */ + shellQuote(argv: readonly string[]): string; + /** + * Wait for the agent process to signal readiness post-resume. + * The default production impl polls a per-agent marker (Claude: + * `~/.atomic/claude-ready/`; OpenCode/Copilot: SDK probes). + * Tests pass an immediate-resolve mock to bypass real I/O. + * Implementations own their own timeout policy. + */ + waitForReady(agent: AgentKind, agentSessionId: string, paneId: string): Promise; + now(): number; + /** Telemetry sink — `event` is one of WORKFLOW_OFFLOAD_* constants. */ + emit(event: string, payload: Record): void; + /** + * Best-effort cleanup of Claude per-session marker files after offload. + * Optional — defaults to the real `claudeOffloadCleanup` from providers/claude.ts. + * Tests inject a mock to avoid real filesystem I/O. + */ + claudeOffloadCleanup?: (agentSessionId: string) => Promise; +} + +// ─── Internal state ───────────────────────────────────────────────────────── + +type SessionState = "alive" | "offloaded" | "resuming"; + +interface RegisteredSession { + name: string; + runId: string; + stageDir: string; + agent: AgentKind; + agentSessionId: string; + tmuxSession: string; + tmuxWindow: string; + spawnEnv: Record; + spawnCwd: string; + lastPrompt: string; + headless: boolean; + chatFlags: string[]; + state: SessionState; +} + +// ─── Idempotency primitive ────────────────────────────────────────────────── + +/** + * Idempotency primitive: if an operation is already running for `name` in + * `queue`, return the same Promise. Otherwise start a new one, register it, + * and clear it from the map when it settles (success or failure). + * + * Exported as `_testOnlyGetOrStartOp` for unit testing only. Production + * callers use the instance-level wrapper returned by `createOffloadManager`. + */ +export function _testOnlyGetOrStartOp( + name: string, + op: () => Promise, + queue: Map>, +): Promise { + const existing = queue.get(name); + if (existing !== undefined) return existing; + + const promise = op().finally(() => { + if (queue.get(name) === promise) queue.delete(name); + }); + queue.set(name, promise); + return promise; +} + +// ─── Factory ──────────────────────────────────────────────────────────────── + +/** + * Build the `[binary, ...argv]` for an agent resume command. + * Returned argv is fed straight to `deps.shellQuote` and then to + * `deps.tmux.createWindow` — the binary is execed directly, no shell. + */ +function buildResumeCommand( + sess: RegisteredSession, + deps: OffloadManagerDeps, + meta: Pick, +): string[] { + switch (sess.agent) { + case "claude": + return ["claude", ...deps.providers.claude.buildResumeArgs(meta, deps.hookSettingsPath())]; + case "opencode": + return ["opencode", ...deps.providers.opencode.buildResumeArgs(meta)]; + case "copilot": + return ["copilot", ...deps.providers.copilot.buildResumeArgs(meta)]; + default: + throw new Error(`unsupported agent kind: ${sess.agent}`); + } +} + +export function createOffloadManager(deps: OffloadManagerDeps): OffloadManager { + const sessions = new Map(); + // Per-pane operation queue scoped to this manager so concurrent test + // instances do not share state. + const opQueue = new Map>(); + + function getOrStartOp(name: string, op: () => Promise): Promise { + return _testOnlyGetOrStartOp(name, op, opQueue); + } + + /** Offload a single registered session. */ + async function killOnePane(sess: RegisteredSession): Promise { + const ts = deps.now(); + // Patch is ONLY timestamps — snapshot fields already on disk from registerSession. + await persistResume(sess.stageDir, { offloadedAt: ts, lastSeenAt: ts }); + + // RFC §5.4: claude-specific marker cleanup before killing the window. + if (sess.agent === "claude") { + const cleanupFn = deps.claudeOffloadCleanup ?? _realClaudeOffloadCleanup; + try { + const { readyCleared, stopCleared, pidCleared, inflightCleared, failures } = + await cleanupFn(sess.agentSessionId); + deps.emit(WORKFLOW_OFFLOAD_CLAUDE_MARKER_CLEANUP, { + runId: sess.runId, + name: sess.name, + agentSessionId: sess.agentSessionId, + readyCleared, + stopCleared, + pidCleared, + inflightCleared, + failures, + }); + } catch { + // Cleanup must never abort kill — errors are swallowed. + } + } + + await deps.tmux.killWindow(sess.tmuxSession, sess.tmuxWindow); + deps.panelStore.setSessionStatus(sess.name, "offloaded"); + sess.state = "offloaded"; + deps.emit(WORKFLOW_OFFLOAD_COMPLETED, { + runId: sess.runId, + name: sess.name, + agent: sess.agent, + }); + } + + /** + * True iff `sess` is eligible for offload right now. + * + * Chrome-tab semantics: never offload the user's currently-focused pane. + * They're reading it. Offload fires when they navigate away (focus poller + * detects the transition) or at workflow completion for unfocused panes. + * Single-stage workflows: the user must navigate to the orchestrator + * window to release the only pane for offload — by design, the user + * controls when their active view goes dormant. + */ + function isEligibleForOffload(sess: RegisteredSession): boolean { + if (sess.headless) return false; + const { activeAgentId } = deps.panelStore; + if (activeAgentId !== "" && activeAgentId === sess.name) return false; + const panelEntry = deps.panelStore.sessions.find((s) => s.name === sess.name); + return panelEntry?.status === "complete"; + } + + /** Re-spawn an offloaded session (RFC §5.2.3). */ + async function doResume(sess: RegisteredSession): Promise { + const startMs = deps.now(); + const baseEvent = { runId: sess.runId, name: sess.name, agent: sess.agent }; + sess.state = "resuming"; + deps.panelStore.setSessionStatus(sess.name, "resuming"); + deps.emit(WORKFLOW_OFFLOAD_RESUME_ATTEMPTED, baseEvent); + + let windowCreated = false; + + try { + // (a) Read + validate metadata. + const metaPath = join(sess.stageDir, "metadata.json"); + const parsed = JSON.parse(await fs.readFile(metaPath, "utf8")) as MetadataJsonWithResume; + if (!isValidResumeBlock(parsed.resume)) throw new Error("SCHEMA_MISMATCH"); + const meta = { + agentSessionId: parsed.resume.agentSessionId, + chatFlags: parsed.resume.chatFlags, + }; + + // (b)/(c) Build the command and quote for tmux. + const cmd = deps.shellQuote(buildResumeCommand(sess, deps, meta)); + + // (d) Recreate the tmux window with the resume command and unfiltered + // in-memory spawnEnv (tokens re-injected from memory, not from disk). + await deps.tmux.createWindow( + sess.tmuxSession, + sess.tmuxWindow, + cmd, + sess.spawnCwd, + sess.spawnEnv, + ); + windowCreated = true; + + // (e) Wait for agent readiness — impl owns its own timeout. + // paneId is the tmux target "session:window" used by waitForServer + // to resolve the agent's PID and discover its listening port. + const paneId = `${sess.tmuxSession}:${sess.tmuxWindow}`; + await deps.waitForReady(sess.agent, meta.agentSessionId, paneId); + + // (f) Only after readiness: switch focus. + await deps.tmux.selectWindow(sess.tmuxSession, sess.tmuxWindow); + + // (g) Success. + sess.state = "alive"; + deps.panelStore.setSessionStatus(sess.name, "complete"); + deps.emit(WORKFLOW_OFFLOAD_RESUME_SUCCEEDED, { + ...baseEvent, + latencyMs: deps.now() - startMs, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + let errorCode: "SCHEMA_MISMATCH" | "RESUME_TIMEOUT" | "RESUME_FAILED"; + if (msg === "SCHEMA_MISMATCH") { + errorCode = "SCHEMA_MISMATCH"; + } else if (msg.startsWith("RESUME_TIMEOUT_")) { + errorCode = "RESUME_TIMEOUT"; + } else { + errorCode = "RESUME_FAILED"; + } + + // Best-effort tmux rollback: kill the newly-created window if it exists. + if (windowCreated) { + try { + await deps.tmux.killWindow(sess.tmuxSession, sess.tmuxWindow); + } catch (rollbackErr) { + deps.emit(WORKFLOW_OFFLOAD_RESUME_ROLLBACK_FAILED, { + ...baseEvent, + error: rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr), + }); + } + } + + // Best-effort error persistence — never mask the original failure. + try { + await persistResume(sess.stageDir, { error: msg }); + } catch {} + + sess.state = "offloaded"; + deps.panelStore.setSessionStatus(sess.name, "offloaded"); + deps.emit(WORKFLOW_OFFLOAD_RESUME_FAILED, { ...baseEvent, errorCode, error: msg }); + throw err; + } + } + + return { + async registerSession(input): Promise { + const lastPrompt = input.lastPrompt ?? ""; + sessions.set(input.name, { ...input, lastPrompt, state: "alive" }); + + // Headless sessions: register in-memory only — skip disk write. + if (input.headless) return; + + // Seed snapshot to disk. Use the op queue under a register-specific key + // so killOnePane (keyed by `name`) does not collide here, but per-stageDir + // mutex inside persistResume still serializes the actual disk writes. + await getOrStartOp(`register:${input.name}`, async () => { + await persistResume(input.stageDir, { + agentSessionId: input.agentSessionId, + tmuxSessionName: input.tmuxSession, + tmuxWindowName: input.tmuxWindow, + spawnEnv: filterSpawnEnv(input.spawnEnv), + spawnCwd: input.spawnCwd, + chatFlags: input.chatFlags, + lastPrompt, + lastSeenAt: deps.now(), + offloadedAt: null, + }); + deps.emit(WORKFLOW_OFFLOAD_REGISTER_PERSISTED, { + runId: input.runId, + name: input.name, + agent: input.agent, + }); + }); + }, + + getStatus(name) { + return sessions.get(name)?.state ?? "alive"; + }, + + async offloadSession(name: string): Promise { + const sess = sessions.get(name); + if (!sess) return; + if (sess.state !== "alive") return; + if (!isEligibleForOffload(sess)) return; + return getOrStartOp(name, () => killOnePane(sess)); + }, + + async onWorkflowCompletion(): Promise { + // Filter to sessions still alive — per-stage offload may have already + // killed most/all of them. Workflow completion is the final sweep. + const eligible = Array.from(sessions.values()).filter( + (sess) => sess.state === "alive" && isEligibleForOffload(sess), + ); + + deps.emit(WORKFLOW_OFFLOAD_SCHEDULED, { + runId: eligible[0]?.runId ?? "", + count: eligible.length, + }); + + await Promise.all( + eligible.map((sess) => getOrStartOp(sess.name, () => killOnePane(sess))), + ); + }, + + async requestResume(name: string): Promise { + const sess = sessions.get(name); + if (!sess || sess.state === "alive") return; + // "offloaded" → start resume; "resuming" → coalesce onto in-flight op. + const op = sess.state === "offloaded" ? () => doResume(sess) : () => Promise.resolve(); + return getOrStartOp(name, op); + }, + }; +} diff --git a/packages/atomic-sdk/src/runtime/offload-types.test.ts b/packages/atomic-sdk/src/runtime/offload-types.test.ts new file mode 100644 index 000000000..a958b41d2 --- /dev/null +++ b/packages/atomic-sdk/src/runtime/offload-types.test.ts @@ -0,0 +1,135 @@ +import { test, expect } from "bun:test"; +import type { MetadataJsonWithResume, OffloadResumeMetadata } from "./offload-types.ts"; + +// ─── fixtures ──────────────────────────────────────────────────────────────── + +const validResume: OffloadResumeMetadata = { + schemaVersion: 1, + agentSessionId: "9f3a8f1d-1c0e-4b1f-9a2f-5e7d8b0e1a23", + tmuxSessionName: "atomic-7f3a2c1d", + tmuxWindowName: "review", + spawnEnv: { CLAUDECODE: "1" }, + spawnCwd: "/home/user/projects/foo", + chatFlags: [], + lastPrompt: "Look at the diff and propose fixes", + lastSeenAt: 1_717_804_900_000, + offloadedAt: null, +}; + +const baseMetadata: MetadataJsonWithResume = { + name: "review", + description: "Review code changes", + agent: "claude", + paneId: "%7", + serverUrl: "", + port: 0, + startedAt: new Date(1_717_804_800_000).toISOString(), +}; + +// ─── serialization round-trips ─────────────────────────────────────────────── + +test("OffloadResumeMetadata survives JSON round-trip", () => { + const serialized = JSON.stringify(validResume); + const parsed = JSON.parse(serialized) as OffloadResumeMetadata; + + expect(parsed.schemaVersion).toBe(1); + expect(parsed.agentSessionId).toBe(validResume.agentSessionId); + expect(parsed.tmuxSessionName).toBe(validResume.tmuxSessionName); + expect(parsed.tmuxWindowName).toBe(validResume.tmuxWindowName); + expect(parsed.spawnEnv).toEqual(validResume.spawnEnv); + expect(parsed.spawnCwd).toBe(validResume.spawnCwd); + expect(parsed.lastPrompt).toBe(validResume.lastPrompt); + expect(parsed.lastSeenAt).toBe(validResume.lastSeenAt); + expect(parsed.offloadedAt).toBeNull(); + expect(parsed.error).toBeUndefined(); +}); + +test("OffloadResumeMetadata with offloadedAt timestamp survives round-trip", () => { + const offloaded: OffloadResumeMetadata = { + ...validResume, + offloadedAt: 1_717_805_000_000, + }; + + const parsed = JSON.parse(JSON.stringify(offloaded)) as OffloadResumeMetadata; + + expect(parsed.offloadedAt).toBe(1_717_805_000_000); +}); + +test("OffloadResumeMetadata with error field survives round-trip", () => { + const withError: OffloadResumeMetadata = { + ...validResume, + offloadedAt: 1_717_805_000_000, + error: "ENOENT: agent binary not found", + }; + + const parsed = JSON.parse(JSON.stringify(withError)) as OffloadResumeMetadata; + + expect(parsed.error).toBe("ENOENT: agent binary not found"); +}); + +// ─── MetadataJsonWithResume — immutable top-level fields survive ───────────── + +test("top-level immutable fields survive when resume is added", () => { + const withResume: MetadataJsonWithResume = { + ...baseMetadata, + resume: validResume, + }; + + const parsed = JSON.parse(JSON.stringify(withResume)) as MetadataJsonWithResume; + + // Top-level immutable fields unchanged + expect(parsed.name).toBe("review"); + expect(parsed.description).toBe("Review code changes"); + expect(parsed.agent).toBe("claude"); + expect(parsed.paneId).toBe("%7"); + expect(parsed.serverUrl).toBe(""); + expect(parsed.port).toBe(0); + expect(parsed.startedAt).toBe(baseMetadata.startedAt); + + // resume sub-object present and correct + expect(parsed.resume).toBeDefined(); + expect(parsed.resume?.schemaVersion).toBe(1); + expect(parsed.resume?.agentSessionId).toBe(validResume.agentSessionId); +}); + +test("top-level immutable fields survive when resume is updated", () => { + const original: MetadataJsonWithResume = { ...baseMetadata, resume: validResume }; + + // Simulate a read-modify-write patch that updates offloadedAt + const parsed = JSON.parse(JSON.stringify(original)) as MetadataJsonWithResume; + if (parsed.resume) { + parsed.resume = { ...parsed.resume, offloadedAt: 1_717_805_500_000 }; + } + + const reparsed = JSON.parse(JSON.stringify(parsed)) as MetadataJsonWithResume; + + // Top-level untouched + expect(reparsed.name).toBe("review"); + expect(reparsed.agent).toBe("claude"); + expect(reparsed.startedAt).toBe(baseMetadata.startedAt); + + // resume updated + expect(reparsed.resume?.offloadedAt).toBe(1_717_805_500_000); +}); + +test("metadata without resume is still valid MetadataJsonWithResume", () => { + const parsed = JSON.parse(JSON.stringify(baseMetadata)) as MetadataJsonWithResume; + + expect(parsed.resume).toBeUndefined(); + expect(parsed.name).toBe("review"); +}); + +test("spawnEnv survives with multiple keys", () => { + const multiEnv: OffloadResumeMetadata = { + ...validResume, + spawnEnv: { CLAUDECODE: "1", NO_COLOR: "1", TERM: "xterm-256color" }, + }; + + const parsed = JSON.parse(JSON.stringify(multiEnv)) as OffloadResumeMetadata; + + expect(parsed.spawnEnv).toEqual({ + CLAUDECODE: "1", + NO_COLOR: "1", + TERM: "xterm-256color", + }); +}); diff --git a/packages/atomic-sdk/src/runtime/offload-types.ts b/packages/atomic-sdk/src/runtime/offload-types.ts new file mode 100644 index 000000000..16b85c44b --- /dev/null +++ b/packages/atomic-sdk/src/runtime/offload-types.ts @@ -0,0 +1,88 @@ +/** + * Types for the workflow-pane offload & resume feature. + * + * Spec: specs/2026-05-08-workflow-pane-offload-and-resume.md §5.1, §5.9 + */ + +import type { AgentType } from "../types.ts"; + +/** + * Re-export so callers can use `AgentKind` from this module without importing + * from two places. Aligned with the existing {@link AgentType} alias of + * {@link AgentKey} — "claude" | "opencode" | "copilot". + */ +export type { AgentType as AgentKind }; + +/** + * The `resume` sub-object written to `metadata.json` by {@link OffloadManager}. + * + * Immutability contract: + * - `schemaVersion` is always 1 (literal) — bump the number when the shape + * changes in a backward-incompatible way. + * - `offloadedAt` is `null` while the agent process is alive; set to + * `Date.now()` the moment the process is killed. + * - All other fields are populated once at session registration time and then + * left untouched until the `error` field is written on resume failure. + * + * RFC §5.1: `chatFlags` carries the effective merged chat flags used at + * original spawn time; they are threaded into the resume command so the + * re-spawned process runs under the same flag set (e.g. `--model`, `--tools`). + */ +export interface OffloadResumeMetadata { + /** Always 1 — used by readers to gate on forward compatibility. */ + schemaVersion: 1; + /** Agent-native session ID fed to `--resume`/`--session` at re-spawn. */ + agentSessionId: string; + /** tmux session name the pane lives in (e.g. "atomic-7f3a2c1d"). */ + tmuxSessionName: string; + /** tmux window name for this stage pane (e.g. "review"). */ + tmuxWindowName: string; + /** Snapshot of env vars injected when the agent was originally spawned. */ + spawnEnv: Record; + /** Working directory used when the agent was originally spawned. */ + spawnCwd: string; + /** Effective merged chatFlags used at original spawn time. Threaded into the resume command. */ + chatFlags: string[]; + /** The last user-visible prompt sent to the agent before offload. */ + lastPrompt: string; + /** + * Epoch-ms timestamp of the last focus event seen for this pane. + * Used to prioritise which offloaded pane to resume first. + */ + lastSeenAt: number; + /** + * Epoch-ms timestamp of when the agent process was killed, or `null` if the + * agent is still running (i.e. not yet offloaded). + */ + offloadedAt: number | null; + /** Set on resume failure; contains the error message / stack trace. */ + error?: string; +} + +/** + * Shape of the per-stage `metadata.json` file once the offload feature is + * active. The top-level immutable fields are written once at stage start + * (`executor.ts:1932`); the optional `resume` sub-object is added / mutated + * only by {@link OffloadManager}. + * + * Older readers that do not know about `resume` will simply ignore the extra + * key — forward-compatible by design. + */ +export interface MetadataJsonWithResume { + /** Human-readable stage name (matches the key passed to `ctx.stage()`). */ + name: string; + /** Optional one-line description passed to `ctx.stage()`. */ + description: string; + /** Which agent CLI is running in this stage pane. */ + agent: AgentType; + /** tmux pane ID string (e.g. "%7"). */ + paneId: string; + /** MCP server URL if the agent exposes one, otherwise empty string. */ + serverUrl: string; + /** MCP server port number, or 0 when `serverUrl` is absent. */ + port: number; + /** ISO-8601 timestamp of when the stage was started. */ + startedAt: string; + /** Offload/resume sub-object — absent until the stage is registered with OffloadManager. */ + resume?: OffloadResumeMetadata; +} diff --git a/packages/atomic-sdk/src/runtime/shell-quote.test.ts b/packages/atomic-sdk/src/runtime/shell-quote.test.ts new file mode 100644 index 000000000..09bba2dc3 --- /dev/null +++ b/packages/atomic-sdk/src/runtime/shell-quote.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; + +import { shellQuote } from "./shell-quote.ts"; + +describe("shellQuote", () => { + test("single-quotes a plain argument", () => { + expect(shellQuote(["claude"])).toBe("'claude'"); + }); + + test("single-quotes each argument and joins with a single space", () => { + expect(shellQuote(["claude", "--resume", "abc"])).toBe("'claude' '--resume' 'abc'"); + }); + + test("preserves literal whitespace inside an argument", () => { + expect(shellQuote(["claude", "--resume", "id with spaces"])).toBe( + "'claude' '--resume' 'id with spaces'", + ); + }); + + test("escapes embedded single quotes via the '\\'' sequence", () => { + // Input: a single-quote char inside the argument + // Output: closing quote, literal escaped quote, reopening quote + expect(shellQuote(["it's"])).toBe("'it'\\''s'"); + }); + + test("escapes multiple embedded single quotes independently", () => { + expect(shellQuote(["a'b'c"])).toBe("'a'\\''b'\\''c'"); + }); + + test("returns an empty string for an empty argv", () => { + expect(shellQuote([])).toBe(""); + }); + + test("quotes an empty-string argument as ''", () => { + expect(shellQuote([""])).toBe("''"); + expect(shellQuote(["claude", ""])).toBe("'claude' ''"); + }); + + test("preserves shell metacharacters verbatim inside the quotes", () => { + // None of `$`, `;`, `|`, `&`, `>`, `<`, `*`, backtick get evaluated under + // single quotes — the helper just wraps them literally. + expect(shellQuote(["echo $HOME && rm -rf /"])).toBe("'echo $HOME && rm -rf /'"); + expect(shellQuote(["a;b|c&d"])).toBe("'a;b|c&d'"); + }); + + test("accepts a readonly argv (compile-time check)", () => { + const argv: readonly string[] = ["claude", "--port", "0"] as const; + expect(shellQuote(argv)).toBe("'claude' '--port' '0'"); + }); +}); diff --git a/packages/atomic-sdk/src/runtime/shell-quote.ts b/packages/atomic-sdk/src/runtime/shell-quote.ts new file mode 100644 index 000000000..d89a2d238 --- /dev/null +++ b/packages/atomic-sdk/src/runtime/shell-quote.ts @@ -0,0 +1,19 @@ +/** + * Minimal POSIX shell-quoting helper. + * + * Single-quotes each argument so the result is safe to pass as a single + * command string to `tmux new-window -e` / `Bun.spawn(["sh", "-c", cmd])`. + * Embedded single quotes are escaped via the classic `'\''` sequence. + * + * Defense-in-depth: argv contents come from controlled adapters whose + * output is constrained, but quoting is cheap (RFC §9 Q12). + * + * @example + * shellQuote(["claude", "--resume", "id with spaces"]); + * // → "claude '--resume' 'id with spaces'" + */ +export function shellQuote(argv: readonly string[]): string { + return argv + .map((arg) => `'${arg.replace(/'/g, "'\\''")}'`) + .join(" "); +} diff --git a/packages/atomic-sdk/src/runtime/tmux.killWindow.test.ts b/packages/atomic-sdk/src/runtime/tmux.killWindow.test.ts new file mode 100644 index 000000000..9413438cc --- /dev/null +++ b/packages/atomic-sdk/src/runtime/tmux.killWindow.test.ts @@ -0,0 +1,119 @@ +/** + * Unit tests for tmux.killWindow. + * + * Integration tests (those that actually invoke tmux) are skipped when the + * tmux binary is not on PATH. All tests are isolated to a dedicated session + * name that is torn down in afterAll. + */ + +import { test, expect, describe, afterAll } from "bun:test"; +import { + killWindow, + tmuxRun, + killSession, + getMuxBinary, + RESERVED_WINDOW_NAMES, +} from "./tmux.ts"; + +const hasTmux = !!Bun.which("tmux"); + +// Unique session name to avoid collisions with real sessions. +const TEST_SESSION = `atomic-test-kw-${Math.random().toString(36).slice(2, 10)}`; + +// --------------------------------------------------------------------------- +// Guard: reserved window names and empty name +// --------------------------------------------------------------------------- + +describe("killWindow — reserved window guard", () => { + test("rejects when windowName is '0'", async () => { + await expect(killWindow("any-session", "0")).rejects.toThrow( + /refuses to kill reserved window: 0/, + ); + }); + + test("rejects when windowName is 'orchestrator'", async () => { + await expect(killWindow("any-session", "orchestrator")).rejects.toThrow( + /refuses to kill reserved window: orchestrator/, + ); + }); + + test("rejects when windowName is empty string", async () => { + await expect(killWindow("any-session", "")).rejects.toThrow( + /refuses to kill reserved window: /, + ); + }); +}); + +// --------------------------------------------------------------------------- +// RESERVED_WINDOW_NAMES invariant +// --------------------------------------------------------------------------- + +describe("RESERVED_WINDOW_NAMES", () => { + test("contains '0' and 'orchestrator'", () => { + expect(RESERVED_WINDOW_NAMES.has("0")).toBe(true); + expect(RESERVED_WINDOW_NAMES.has("orchestrator")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Integration: real tmux session +// --------------------------------------------------------------------------- + +describe("killWindow — integration", () => { + if (!hasTmux) { + test("skipped: tmux not on PATH", () => { + expect(getMuxBinary()).toBeNull(); + }); + return; + } + + // bun:test has no async describe setup, so create the session synchronously + // here and tear it down in afterAll. + const WINDOW_KEEP = "keep-me"; + const WINDOW_KILL = "kill-me"; + + const sessionResult = tmuxRun([ + "new-session", + "-d", + "-s", + TEST_SESSION, + "-n", + WINDOW_KEEP, + ]); + + let windowResult: string | null = null; + if (sessionResult.ok) { + const r = tmuxRun(["new-window", "-d", "-t", TEST_SESSION, "-n", WINDOW_KILL, "-P", "-F", "#{pane_id}", "sleep infinity"]); + windowResult = r.ok ? r.stdout : null; + } + + afterAll(() => { + killSession(TEST_SESSION); + }); + + test("session and second window are created successfully", () => { + expect(sessionResult.ok).toBe(true); + expect(windowResult).not.toBeNull(); + }); + + test("killWindow removes the target window", async () => { + if (!sessionResult.ok) return; // session setup failed; skip + + await killWindow(TEST_SESSION, WINDOW_KILL); + + const listResult = tmuxRun(["list-windows", "-t", TEST_SESSION, "-F", "#{window_name}"]); + expect(listResult.ok).toBe(true); + if (!listResult.ok) return; + + const windows = listResult.stdout.split("\n").filter(Boolean); + expect(windows).not.toContain(WINDOW_KILL); + expect(windows).toContain(WINDOW_KEEP); + }); + + test("killWindow resolves even when window no longer exists (idempotent)", async () => { + if (!sessionResult.ok) return; + + // WINDOW_KILL was already killed in the previous test; calling again should not throw. + await expect(killWindow(TEST_SESSION, WINDOW_KILL)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/atomic-sdk/src/runtime/tmux.ts b/packages/atomic-sdk/src/runtime/tmux.ts index 46dd85548..dfad27435 100644 --- a/packages/atomic-sdk/src/runtime/tmux.ts +++ b/packages/atomic-sdk/src/runtime/tmux.ts @@ -450,12 +450,33 @@ export function killSession(sessionName: string): void { } } -/** Kill a specific tmux window within a session. Silences errors if already dead. */ -export function killWindow(sessionName: string, windowName: string): void { +/** + * Window names that `killWindow` must never accept. Mirror this set when + * `executor.ts` introduces new top-level windows (e.g., a future "logs" + * window). The set is the single source of truth — the guard message + * derives from it. + */ +export const RESERVED_WINDOW_NAMES: ReadonlySet = new Set([ + "0", // tmux default index — defensive against legacy/test fixtures + "orchestrator", // matches executor.ts:619 createSession(..., "orchestrator", ...) +]); + +/** + * Kill a specific tmux window within a session. + * + * Throws if `windowName` is empty or is in `RESERVED_WINDOW_NAMES`. + * Resolves when the window is gone; silences errors if already dead. + */ +export async function killWindow(sessionName: string, windowName: string): Promise { + if (!windowName || RESERVED_WINDOW_NAMES.has(windowName)) { + throw new Error( + `refuses to kill reserved window: ${windowName || ""}`, + ); + } try { tmuxExec(["kill-window", "-t", `${sessionName}:${windowName}`]); } catch { - // Window may already be dead + // Window may already be dead. } } diff --git a/packages/atomic-sdk/src/workflows/builtin/ralph/claude/index.ts b/packages/atomic-sdk/src/workflows/builtin/ralph/claude/index.ts index 062511bdf..3c3a6a1cc 100644 --- a/packages/atomic-sdk/src/workflows/builtin/ralph/claude/index.ts +++ b/packages/atomic-sdk/src/workflows/builtin/ralph/claude/index.ts @@ -20,6 +20,15 @@ * graph focused on stages the user cares about and lets the SDK enforce the * schema without TUI round-trips. * + * The reviewer's persona is injected via `systemPrompt.append` (see + * {@link REVIEWER_PERSONA_BODY}) rather than `agent: "reviewer"`. The + * Claude Agent SDK silently disables `outputFormat` whenever an `agent` + * is also passed, which leaves `lastStructuredOutput` undefined and + * causes {@link hasActionableFindings} to fall through to its + * conservative non-empty-raw-text branch — i.e., the loop never + * converges on a clean review. `systemPrompt` keeps the persona while + * letting `outputFormat` actually fire. + * * Run: atomic workflow -n ralph -a claude "" */ @@ -40,6 +49,7 @@ import { } from "../helpers/prompts.ts"; import { hasActionableFindings } from "../helpers/review.ts"; import { captureBranchChangeset } from "../helpers/git.ts"; +import { REVIEWER_PERSONA_BODY, REVIEWER_TOOLS } from "../helpers/claude-reviewer.ts"; const DEFAULT_MAX_LOOPS = 10; @@ -236,7 +246,12 @@ export default defineWorkflow({ {}, async (s) => { const result = await s.session.query(reviewPrompt, { - agent: "reviewer", + systemPrompt: { + type: "preset", + preset: "claude_code", + append: REVIEWER_PERSONA_BODY, + }, + tools: [...REVIEWER_TOOLS], permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, outputFormat: { diff --git a/packages/atomic-sdk/src/workflows/builtin/ralph/helpers/claude-reviewer.ts b/packages/atomic-sdk/src/workflows/builtin/ralph/helpers/claude-reviewer.ts new file mode 100644 index 000000000..fc3412be0 --- /dev/null +++ b/packages/atomic-sdk/src/workflows/builtin/ralph/helpers/claude-reviewer.ts @@ -0,0 +1,114 @@ +/** + * Reviewer persona body for the Claude ralph workflow. + * + * Why this exists instead of `agent: "reviewer"` (loading + * `.claude/agents/reviewer.md`): the Claude Agent SDK silently disables + * `outputFormat: { type: "json_schema", schema }` when an `agent: ` + * is also passed to `query()` — the persona's system prompt takes the + * main thread and the schema-enforcement turn never runs, so + * `result.structured_output` is absent and `lastStructuredOutput` is + * `undefined`. The reviewer's verdict therefore never reaches + * {@link hasActionableFindings} and the loop iterates indefinitely. + * + * Passing this body via + * `systemPrompt: { type: "preset", preset: "claude_code", append: REVIEWER_PERSONA_BODY }` + * preserves the persona instructions while letting `outputFormat` fire, + * so structured output is captured and the loop converges. + * + * Mirrors the prose of `.claude/agents/reviewer.md` minus the YAML + * frontmatter (Claude SDK's `systemPrompt.append` does not parse + * frontmatter, and tool restrictions are unused here since the reviewer + * runs headless with `bypassPermissions`). The closing OUTPUT section + * differs from the disk file: schema enforcement is delegated to the + * SDK's structured-output channel, so the model is told to fill the + * fields rather than emit a fenced JSON template. + */ +/** + * Built-in tool whitelist for the reviewer stage. Mirrors the `tools:` + * frontmatter of `.claude/agents/reviewer.md` so the persona stays + * read-only — Bash for verification commands, Agent for delegating to + * sub-agents, the Read/Grep/Glob trio for code inspection, the Task* + * family for task-list cross-reference, and Web* for documentation + * lookups. Edit/Write/NotebookEdit are intentionally excluded; the + * reviewer should never modify the changeset it is reviewing. + * + * Passed as `tools: [...]` (the SDK's base-set whitelist) on the + * reviewer `query()` call. Without it, the headless wrapper inherits + * the SDK's default tool set, which includes Edit/Write. + */ +export const REVIEWER_TOOLS = [ + "Bash", + "Agent", + "Glob", + "Grep", + "Read", + "TodoWrite", + "TaskCreate", + "TaskList", + "TaskGet", + "TaskUpdate", + "WebFetch", + "WebSearch", +] as const; + +export const REVIEWER_PERSONA_BODY = `# Review guidelines: + +You are acting as a reviewer for a proposed code change made by another engineer. + +Below are some default guidelines for determining whether the original author would appreciate the issue being flagged. + +These are not the final word in determining whether an issue is a bug. In many cases, you will encounter other, more specific guidelines. These may be present elsewhere in a developer message, a user message, a file, or even elsewhere in this system message. +Those guidelines should be considered to override these general instructions. + +Here are the general guidelines for determining whether something is a bug and should be flagged. + +1. It meaningfully impacts the accuracy, performance, security, or maintainability of the code. +2. The bug is discrete and actionable (i.e. not a general issue with the codebase or a combination of multiple issues). +3. Fixing the bug does not demand a level of rigor that is not present in the rest of the codebase (e.g. one doesn't need very detailed comments and input validation in a repository of one-off scripts in personal projects) +4. The bug was introduced in the commit (pre-existing bugs should not be flagged). +5. The author of the original PR would likely fix the issue if they were made aware of it. +6. The bug does not rely on unstated assumptions about the codebase or author's intent. +7. It is not enough to speculate that a change may disrupt another part of the codebase, to be considered a bug, one must identify the other parts of the code that are provably affected. +8. The bug is clearly not just an intentional change by the original author. +9. Use the repository's \`AGENTS.md\` and/or \`CLAUDE.md\` files (if present) for guidance on style, conventions, testing expectations, and architectural patterns. Your review should respect these project-level norms — flag deviations only when they conflict with correctness or security, not personal preference. + +When flagging a bug, you will also provide an accompanying comment. Once again, these guidelines are not the final word on how to construct a comment -- defer to any subsequent guidelines that you encounter. + +1. The comment should be clear about why the issue is a bug. +2. The comment should appropriately communicate the severity of the issue. It should not claim that an issue is more severe than it actually is. +3. The comment should be brief. The body should be at most 1 paragraph. It should not introduce line breaks within the natural language flow unless it is necessary for the code fragment. +4. The comment should not include any chunks of code longer than 3 lines. Any code chunks should be wrapped in markdown inline code tags or a code block. +5. The comment should clearly and explicitly communicate the scenarios, environments, or inputs that are necessary for the bug to arise. The comment should immediately indicate that the issue's severity depends on these factors. +6. The comment's tone should be matter-of-fact and not accusatory or overly positive. It should read as a helpful AI assistant suggestion without sounding too much like a human reviewer. +7. The comment should be written such that the original author can immediately grasp the idea without close reading. +8. The comment should avoid excessive flattery and comments that are not helpful to the original author. The comment should avoid phrasing like "Great job ...", "Thanks for ...". + +Below are some more detailed guidelines that you should apply to this specific review. + +HOW MANY FINDINGS TO RETURN: + +Output all findings that the original author would fix if they knew about it. If there is no finding that a person would definitely love to see and fix, prefer outputting no findings. Do not stop at the first qualifying finding. Continue until you've listed every qualifying finding. + +GUIDELINES: + +- Ignore trivial style unless it obscures meaning or violates documented standards. +- Use one comment per distinct issue (or a multi-line range if necessary). +- Use \`\`\`suggestion blocks ONLY for concrete replacement code (minimal lines; no commentary inside the block). +- In every \`\`\`suggestion block, preserve the exact leading whitespace of the replaced lines (spaces vs tabs, number of spaces). +- Do NOT introduce or remove outer indentation levels unless that is the actual fix. + +The comments will be presented in the code review as inline comments. You should avoid providing unnecessary location details in the comment body. Always keep the line range as short as possible for interpreting the issue. Avoid ranges longer than 5–10 lines; instead, choose the most suitable subrange that pinpoints the problem. + +At the beginning of the finding title, tag the bug with priority level. For example "[P1] Un-padding slices along wrong tensor dimensions". [P0] – Drop everything to fix. Blocking release, operations, or major usage. Only use for universal issues that do not depend on any assumptions about the inputs. · [P1] – Urgent. Should be addressed in the next cycle · [P2] – Normal. To be fixed eventually · [P3] – Low. Nice to have. + +Additionally, include a numeric priority field in the JSON output for each finding: set "priority" to 0 for P0, 1 for P1, 2 for P2, or 3 for P3. If a priority cannot be determined, omit the field or use null. + +At the end of your findings, output an "overall correctness" verdict of whether or not the patch should be considered "correct". +Correct implies that existing code and tests will not break, and the patch is free of bugs and other blocking issues. +Ignore non-blocking issues such as style, formatting, typos, documentation, and other nits. + +FORMATTING GUIDELINES: +The finding description should be one paragraph. + +OUTPUT: +Your review is captured via the SDK's structured output channel — the schema is enforced for you. Fill each field with accurate, well-reasoned data; do not also emit the JSON in plain text. The \`code_location\` field is required for every finding and must include a repo-relative \`file_path\` and a \`line_range\` (\`start\`/\`end\`). Keep line ranges as short as possible (avoid >5–10 lines) and ensure they overlap with the diff. Do not generate a PR fix.`; diff --git a/packages/atomic/src/lib/telemetry/getProductionTelemetrySink.test.ts b/packages/atomic/src/lib/telemetry/getProductionTelemetrySink.test.ts new file mode 100644 index 000000000..dab761efd --- /dev/null +++ b/packages/atomic/src/lib/telemetry/getProductionTelemetrySink.test.ts @@ -0,0 +1,88 @@ +import { test, expect, describe } from "bun:test"; +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getProductionTelemetrySink } from "./index.ts"; + +describe("getProductionTelemetrySink", () => { + test("creates telemetry.jsonl with correct content after two emits", async () => { + // Skip on Windows (no mode 0o600 support) + if (process.platform === "win32") return; + + const baseDir = await fs.mkdtemp(join(tmpdir(), "telemetry-test-")); + const runId = "test-run-abc123"; + const sink = getProductionTelemetrySink(runId, baseDir); + + sink.emit("test.event.one", { key: "value1" }); + sink.emit("test.event.two", { key: "value2" }); + + // Poll until file has content (up to 1s) + const filePath = join(baseDir, runId, "telemetry.jsonl"); + const deadline = Date.now() + 1000; + let content = ""; + while (Date.now() < deadline) { + try { + content = await fs.readFile(filePath, "utf8"); + if (content.trim().split("\n").length >= 2) break; + } catch { + // file may not exist yet + } + await Bun.sleep(20); + } + + const lines = content.trim().split("\n"); + expect(lines).toHaveLength(2); + + // fire-and-forget — order not guaranteed; sort by event name for stable assertions + const parsed = lines.map((l) => JSON.parse(l)).sort( + (a: { event: string }, b: { event: string }) => a.event.localeCompare(b.event), + ); + + const parsed0 = parsed[0]!; + expect(parsed0.event).toBe("test.event.one"); + expect(parsed0.payload).toEqual({ key: "value1" }); + expect(typeof parsed0.ts).toBe("number"); + + const parsed1 = parsed[1]!; + expect(parsed1.event).toBe("test.event.two"); + expect(parsed1.payload).toEqual({ key: "value2" }); + + // Assert file mode 0o600 + const stat = await fs.stat(filePath); + expect(stat.mode & 0o777).toBe(0o600); + + // Cleanup + await fs.rm(baseDir, { recursive: true, force: true }); + }); + + test("multiple sinks with same runId append to same file", async () => { + if (process.platform === "win32") return; + + const baseDir = await fs.mkdtemp(join(tmpdir(), "telemetry-test2-")); + const runId = "shared-run"; + + const sink1 = getProductionTelemetrySink(runId, baseDir); + const sink2 = getProductionTelemetrySink(runId, baseDir); + + sink1.emit("event.from.sink1", { x: 1 }); + sink2.emit("event.from.sink2", { x: 2 }); + + const filePath = join(baseDir, runId, "telemetry.jsonl"); + const deadline = Date.now() + 1000; + let content = ""; + while (Date.now() < deadline) { + try { + content = await fs.readFile(filePath, "utf8"); + if (content.trim().split("\n").length >= 2) break; + } catch { + // not yet + } + await Bun.sleep(20); + } + + const lines = content.trim().split("\n"); + expect(lines).toHaveLength(2); + + await fs.rm(baseDir, { recursive: true, force: true }); + }); +}); diff --git a/packages/atomic/src/lib/telemetry/index.ts b/packages/atomic/src/lib/telemetry/index.ts new file mode 100644 index 000000000..3225c7fe8 --- /dev/null +++ b/packages/atomic/src/lib/telemetry/index.ts @@ -0,0 +1,2 @@ +export { getProductionTelemetrySink } from "@bastani/atomic-sdk"; +export type { TelemetrySink } from "@bastani/atomic-sdk"; diff --git a/packages/atomic/src/lib/telemetry/offload-events.test.ts b/packages/atomic/src/lib/telemetry/offload-events.test.ts new file mode 100644 index 000000000..66ec82f53 --- /dev/null +++ b/packages/atomic/src/lib/telemetry/offload-events.test.ts @@ -0,0 +1,54 @@ +import { test, expect } from "bun:test"; +import { + WORKFLOW_OFFLOAD_SCHEDULED, + WORKFLOW_OFFLOAD_COMPLETED, + WORKFLOW_OFFLOAD_RESUME_ATTEMPTED, + WORKFLOW_OFFLOAD_RESUME_SUCCEEDED, + WORKFLOW_OFFLOAD_RESUME_FAILED, + WORKFLOW_OFFLOAD_RESUME_LATENCY_MS, + WORKFLOW_OFFLOAD_RESUME_ROLLBACK_FAILED, + WORKFLOW_OFFLOAD_REGISTER_PERSISTED, + WORKFLOW_OFFLOAD_CLAUDE_MARKER_CLEANUP, +} from "./offload-events.ts"; + +test("WORKFLOW_OFFLOAD_SCHEDULED equals spec string", () => { + expect(WORKFLOW_OFFLOAD_SCHEDULED).toBe("workflow.offload.scheduled"); +}); + +test("WORKFLOW_OFFLOAD_COMPLETED equals spec string", () => { + expect(WORKFLOW_OFFLOAD_COMPLETED).toBe("workflow.offload.completed"); +}); + +test("WORKFLOW_OFFLOAD_RESUME_ATTEMPTED equals spec string", () => { + expect(WORKFLOW_OFFLOAD_RESUME_ATTEMPTED).toBe("workflow.offload.resume.attempted"); +}); + +test("WORKFLOW_OFFLOAD_RESUME_SUCCEEDED equals spec string", () => { + expect(WORKFLOW_OFFLOAD_RESUME_SUCCEEDED).toBe("workflow.offload.resume.succeeded"); +}); + +test("WORKFLOW_OFFLOAD_RESUME_FAILED equals spec string", () => { + expect(WORKFLOW_OFFLOAD_RESUME_FAILED).toBe("workflow.offload.resume.failed"); +}); + +test("WORKFLOW_OFFLOAD_RESUME_LATENCY_MS equals spec string", () => { + expect(WORKFLOW_OFFLOAD_RESUME_LATENCY_MS).toBe("workflow.offload.resume.latency_ms"); +}); + +test("WORKFLOW_OFFLOAD_RESUME_ROLLBACK_FAILED equals spec string", () => { + expect(WORKFLOW_OFFLOAD_RESUME_ROLLBACK_FAILED).toBe( + "workflow.offload.resume.rollback_failed", + ); +}); + +test("WORKFLOW_OFFLOAD_REGISTER_PERSISTED equals spec string", () => { + expect(WORKFLOW_OFFLOAD_REGISTER_PERSISTED).toBe( + "workflow.offload.register.persisted", + ); +}); + +test("WORKFLOW_OFFLOAD_CLAUDE_MARKER_CLEANUP equals spec string", () => { + expect(WORKFLOW_OFFLOAD_CLAUDE_MARKER_CLEANUP).toBe( + "workflow.offload.claude_marker_cleanup", + ); +}); diff --git a/packages/atomic/src/lib/telemetry/offload-events.ts b/packages/atomic/src/lib/telemetry/offload-events.ts new file mode 100644 index 000000000..9058055a7 --- /dev/null +++ b/packages/atomic/src/lib/telemetry/offload-events.ts @@ -0,0 +1,149 @@ +/** + * Telemetry event-name constants and payload shapes for the workflow + * offload & resume feature (RFC: specs/2026-05-08-workflow-pane-offload-and-resume.md, + * § 7.2 Observability Strategy). + * + * No emit call sites live here — this module is a pure registry. + * Consumers wire these constants into their telemetry emit calls. + */ + +import type { AgentType } from "@bastani/atomic-sdk"; + +/** + * Alias for AgentType scoped to telemetry payloads. + * Matches the agent-kind vocabulary used in offload RFC (§ 7.2). + */ +export type AgentKind = AgentType; + +// ─── Event-name constants ──────────────────────────────────────────────────── + +/** Fired once per workflow run when panes are scheduled for offload. */ +export const WORKFLOW_OFFLOAD_SCHEDULED = "workflow.offload.scheduled" as const; + +/** Fired per pane when the underlying agent process has been killed and tmux + * window reaped successfully. */ +export const WORKFLOW_OFFLOAD_COMPLETED = "workflow.offload.completed" as const; + +/** Fired when the user navigates to an offloaded pane and resume is attempted. */ +export const WORKFLOW_OFFLOAD_RESUME_ATTEMPTED = + "workflow.offload.resume.attempted" as const; + +/** Fired when the agent process has re-spawned and the pane is ready. */ +export const WORKFLOW_OFFLOAD_RESUME_SUCCEEDED = + "workflow.offload.resume.succeeded" as const; + +/** Fired when the resume attempt fails (e.g., missing session ID, spawn error). */ +export const WORKFLOW_OFFLOAD_RESUME_FAILED = + "workflow.offload.resume.failed" as const; + +/** Fired when doResume's rollback path (best-effort tmux.killWindow after a + * failed resume) itself throws — pathological tmux state (R2 fix observability). */ +export const WORKFLOW_OFFLOAD_RESUME_ROLLBACK_FAILED = + "workflow.offload.resume.rollback_failed" as const; + +/** Fired by OffloadManager.registerSession after metadata persisted. */ +export const WORKFLOW_OFFLOAD_REGISTER_PERSISTED = + "workflow.offload.register.persisted" as const; + +/** Fired per pane after Claude per-session marker reap during offload (RFC §7.2 v8). */ +export const WORKFLOW_OFFLOAD_CLAUDE_MARKER_CLEANUP = + "workflow.offload.claude_marker_cleanup" as const; + +/** Fired with the measured latency (ms) from focus event → pane ready. */ +export const WORKFLOW_OFFLOAD_RESUME_LATENCY_MS = + "workflow.offload.resume.latency_ms" as const; + +// ─── Payload interfaces ────────────────────────────────────────────────────── + +/** Payload for {@link WORKFLOW_OFFLOAD_SCHEDULED}. */ +export interface WorkflowOffloadScheduledPayload { + /** Unique identifier for the workflow run. */ + runId: string; + /** Number of panes scheduled for offload in this run. */ + count: number; +} + +/** Payload for {@link WORKFLOW_OFFLOAD_COMPLETED}. */ +export interface WorkflowOffloadCompletedPayload { + /** Unique identifier for the workflow run. */ + runId: string; + /** Stage name that was offloaded. */ + name: string; + /** Agent provider that was running in the pane. */ + agent: AgentKind; +} + +/** Payload for {@link WORKFLOW_OFFLOAD_RESUME_ATTEMPTED}. */ +export interface WorkflowOffloadResumeAttemptedPayload { + /** Unique identifier for the workflow run. */ + runId: string; + /** Stage name being resumed. */ + name: string; + /** Agent provider being re-spawned. */ + agent: AgentKind; +} + +/** Payload for {@link WORKFLOW_OFFLOAD_RESUME_SUCCEEDED}. */ +export interface WorkflowOffloadResumeSucceededPayload { + /** Unique identifier for the workflow run. */ + runId: string; + /** Stage name that was successfully resumed. */ + name: string; + /** Agent provider that was re-spawned. */ + agent: AgentKind; +} + +/** Payload for {@link WORKFLOW_OFFLOAD_RESUME_FAILED}. */ +export interface WorkflowOffloadResumeFailedPayload { + /** Unique identifier for the workflow run. */ + runId: string; + /** Stage name for which resume failed. */ + name: string; + /** Agent provider that failed to re-spawn. */ + agent: AgentKind; + /** Machine-readable error code (e.g., "MISSING_SESSION_ID", "SPAWN_ERROR"). */ + errorCode: string; +} + +/** Payload for {@link WORKFLOW_OFFLOAD_RESUME_LATENCY_MS}. */ +export interface WorkflowOffloadResumeLatencyPayload { + /** Unique identifier for the workflow run. */ + runId: string; + /** Stage name that was resumed. */ + name: string; + /** Agent provider that was re-spawned. */ + agent: AgentKind; + /** Elapsed time in milliseconds from focus event to pane-ready. */ + latencyMs: number; +} + +/** Payload for {@link WORKFLOW_OFFLOAD_RESUME_ROLLBACK_FAILED}. */ +export interface WorkflowOffloadResumeRollbackFailedPayload { + /** Unique identifier for the workflow run. */ + runId: string; + /** Stage name whose rollback failed. */ + name: string; + /** Agent provider being re-spawned. */ + agent: AgentKind; + /** Error message from the failed tmux.killWindow rollback. */ + error: string; +} + +/** Payload for {@link WORKFLOW_OFFLOAD_REGISTER_PERSISTED}. */ +export interface WorkflowOffloadRegisterPersistedPayload { + runId: string; + name: string; + agent: AgentKind; +} + +/** Payload for {@link WORKFLOW_OFFLOAD_CLAUDE_MARKER_CLEANUP}. */ +export interface WorkflowOffloadClaudeMarkerCleanupPayload { + runId: string; + name: string; + agentSessionId: string; + readyCleared: boolean; + stopCleared: boolean; + pidCleared: boolean; + inflightCleared: boolean; + failures: number; +} diff --git a/scripts/lint-offload-await.test.ts b/scripts/lint-offload-await.test.ts new file mode 100644 index 000000000..4483ee963 --- /dev/null +++ b/scripts/lint-offload-await.test.ts @@ -0,0 +1,133 @@ +import { test, expect } from "bun:test"; +import { checkAwaitOrCatch, checkSwitchClientGate } from "./lint-offload-await.ts"; + +// ── checkAwaitOrCatch ───────────────────────────────────────────────────────── + +test("checkAwaitOrCatch flags bare requestResume", () => { + const lines = ['offloadManager.requestResume("foo")']; + const v = checkAwaitOrCatch("x.ts", lines, "offloadManager.requestResume(", "requestResume-await"); + expect(v).toHaveLength(1); + expect(v[0]!.rule).toBe("requestResume-await"); + expect(v[0]!.line).toBe(1); +}); + +test("checkAwaitOrCatch flags bare registerSession", () => { + const lines = ['offloadManager.registerSession("foo", {})']; + const v = checkAwaitOrCatch("x.ts", lines, "offloadManager.registerSession(", "registerSession-await"); + expect(v).toHaveLength(1); +}); + +test("checkAwaitOrCatch allows await", () => { + const lines = ['await offloadManager.requestResume("foo")']; + expect( + checkAwaitOrCatch("x.ts", lines, "offloadManager.requestResume(", "requestResume-await"), + ).toHaveLength(0); +}); + +test("checkAwaitOrCatch allows void prefix", () => { + const lines = ['void offloadManager.requestResume("foo")']; + expect( + checkAwaitOrCatch("x.ts", lines, "offloadManager.requestResume(", "requestResume-await"), + ).toHaveLength(0); +}); + +test("checkAwaitOrCatch allows comment line", () => { + const lines = ['// offloadManager.requestResume("foo") — not called yet']; + expect( + checkAwaitOrCatch("x.ts", lines, "offloadManager.requestResume(", "requestResume-await"), + ).toHaveLength(0); +}); + +test("checkAwaitOrCatch allows .catch within window", () => { + const lines = [ + 'offloadManager.requestResume("foo")', + " .then(() => 1)", + " .catch(() => 2);", + ]; + expect( + checkAwaitOrCatch("x.ts", lines, "offloadManager.requestResume(", "requestResume-await"), + ).toHaveLength(0); +}); + +test("checkAwaitOrCatch flags when .catch is beyond 5-line window", () => { + const lines = [ + 'offloadManager.requestResume("foo")', + " .then(() => 1)", + " .then(() => 2)", + " .then(() => 3)", + " .then(() => 4)", + " .then(() => 5)", + " .catch(() => 6);", + ]; + const v = checkAwaitOrCatch("x.ts", lines, "offloadManager.requestResume(", "requestResume-await"); + expect(v).toHaveLength(1); +}); + +test("checkAwaitOrCatch handles multiple violations across lines", () => { + const lines = [ + 'offloadManager.requestResume("a")', + "// some comment", + 'offloadManager.requestResume("b")', + ]; + const v = checkAwaitOrCatch("x.ts", lines, "offloadManager.requestResume(", "requestResume-await"); + expect(v).toHaveLength(2); + expect(v[0]!.line).toBe(1); + expect(v[1]!.line).toBe(3); +}); + +// ── checkSwitchClientGate ───────────────────────────────────────────────────── + +test("checkSwitchClientGate flags bare switch-client", () => { + const lines = ['tmuxRun(["switch-client", "-t", "foo:bar"])']; + expect(checkSwitchClientGate("x.tsx", lines)).toHaveLength(1); +}); + +test("checkSwitchClientGate allows offload-exempt annotation on same line", () => { + const lines = [ + 'tmuxRun(["switch-client", "-t", "foo:bar"]); // offload-exempt: orchestrator window 0', + ]; + expect(checkSwitchClientGate("x.tsx", lines)).toHaveLength(0); +}); + +test("checkSwitchClientGate allows offload-exempt annotation on previous line", () => { + const lines = [ + "// offload-exempt: status checked above", + 'tmuxRun(["switch-client", "-t", "foo:bar"]);', + ]; + expect(checkSwitchClientGate("x.tsx", lines)).toHaveLength(0); +}); + +test("checkSwitchClientGate allows preceding getStatus check", () => { + const lines = [ + "const status = offloadManager.getStatus(id);", + 'if (status === "alive") {', + ' tmuxRun(["switch-client", "-t", "foo:bar"]);', + "}", + ]; + expect(checkSwitchClientGate("x.tsx", lines)).toHaveLength(0); +}); + +test("checkSwitchClientGate allows preceding requestResume check", () => { + const lines = [ + "await offloadManager.requestResume(id);", + 'tmuxRun(["switch-client", "-t", "foo:bar"]);', + ]; + expect(checkSwitchClientGate("x.tsx", lines)).toHaveLength(0); +}); + +test("checkSwitchClientGate flags when gate is beyond 20-line window", () => { + const lines: string[] = ["offloadManager.getStatus(id);"]; + for (let i = 0; i < 20; i++) lines.push(`const x${i} = ${i};`); + lines.push('tmuxRun(["switch-client", "-t", "foo:bar"]);'); + expect(checkSwitchClientGate("x.tsx", lines)).toHaveLength(1); +}); + +test("checkSwitchClientGate returns violation with correct metadata", () => { + const lines = [' tmuxRun(["switch-client", "-t", "foo:bar"]);']; + const v = checkSwitchClientGate("my-file.tsx", lines); + expect(v).toHaveLength(1); + expect(v[0]!.file).toBe("my-file.tsx"); + expect(v[0]!.line).toBe(1); + expect(v[0]!.rule).toBe("switch-client-gate"); + expect(v[0]!.text).toBe('tmuxRun(["switch-client", "-t", "foo:bar"]);'); +}); diff --git a/scripts/lint-offload-await.ts b/scripts/lint-offload-await.ts new file mode 100644 index 000000000..d42d9704f --- /dev/null +++ b/scripts/lint-offload-await.ts @@ -0,0 +1,162 @@ +#!/usr/bin/env bun +/// +/** + * Enforce await/catch/void discipline on offload-related async calls. + * + * Rule A — registerSession must be awaited / .catch-chained / void-prefixed (executor.ts) + * Rule A2 — requestResume must be awaited / .catch-chained / void-prefixed (executor.ts + components) + * Rule B — tmuxRun(["switch-client", …) must be preceded by getStatus/requestResume + * OR carry a `// offload-exempt: ` annotation on the same or previous line. + * + * RFC: specs/2026-05-08-workflow-pane-offload-and-resume.md §5.5 / §8.3 + */ + +import { join } from "node:path"; + +export const REPO_ROOT = join(import.meta.dir, ".."); +export const EXECUTOR = join( + REPO_ROOT, + "packages", + "atomic-sdk", + "src", + "runtime", + "executor.ts", +); +export const COMPONENTS_GLOB = "packages/atomic-sdk/src/components/**/*.{ts,tsx}"; + +export interface Violation { + file: string; + line: number; + text: string; + rule: string; +} + +/** + * Rule A / A2 — a line containing `pattern` must be: + * • trimmed line starts with `await ` + * • trimmed line starts with `void ` + * • trimmed line starts with `//` (comment) + * • `.catch(` appears within the next 5 lines (inclusive) + */ +export function checkAwaitOrCatch( + file: string, + lines: string[], + pattern: string, + rule: string, +): Violation[] { + const out: Violation[] = []; + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]!; + const trimmed = raw.trim(); + if (!trimmed.includes(pattern)) continue; + if (trimmed.startsWith("//")) continue; + if (trimmed.startsWith("await ")) continue; + if (trimmed.startsWith("void ")) continue; + const hasCatch = lines.slice(i, i + 6).some((l) => l.includes(".catch(")); + if (hasCatch) continue; + out.push({ file, line: i + 1, text: trimmed, rule }); + } + return out; +} + +/** + * Rule B — tmuxRun(["switch-client", …) must be either: + * • annotated with `// offload-exempt:` on the same line or the line above, OR + * • preceded (within 20 lines) by offloadManager.getStatus( or offloadManager.requestResume( + */ +export function checkSwitchClientGate(file: string, lines: string[]): Violation[] { + const PATTERN = 'tmuxRun(["switch-client"'; + const out: Violation[] = []; + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]!; + if (!raw.includes(PATTERN)) continue; + const sameLineExempt = raw.includes("// offload-exempt:"); + const prevLineExempt = i > 0 && (lines[i - 1] ?? "").includes("// offload-exempt:"); + if (sameLineExempt || prevLineExempt) continue; + const window = lines.slice(Math.max(0, i - 20), i); + if ( + window.some( + (l) => + l.includes("offloadManager.getStatus(") || + l.includes("offloadManager.requestResume("), + ) + ) + continue; + out.push({ file, line: i + 1, text: raw.trim(), rule: "switch-client-gate" }); + } + return out; +} + +// ── Main (only runs when script is executed directly, not when imported) ─────── + +if (import.meta.main) { + const violations: Violation[] = []; + + // Rule A — registerSession in executor.ts + { + let text = ""; + try { + text = await Bun.file(EXECUTOR).text(); + } catch (err) { + console.error(`lint-offload-await: cannot read ${EXECUTOR}: ${err}`); + process.exit(2); + } + const lines = text.split("\n"); + violations.push( + ...checkAwaitOrCatch(EXECUTOR, lines, "offloadManager.registerSession(", "registerSession-await"), + ); + } + + // Rule A2 — requestResume across executor.ts + components + { + const componentFiles = Array.from( + new Bun.Glob(COMPONENTS_GLOB).scanSync(REPO_ROOT), + ).map((p) => join(REPO_ROOT, p)); + const targets = [EXECUTOR, ...componentFiles]; + for (const file of targets) { + let text: string; + try { + text = await Bun.file(file).text(); + } catch { + continue; + } + const lines = text.split("\n"); + violations.push( + ...checkAwaitOrCatch(file, lines, "offloadManager.requestResume(", "requestResume-await"), + ); + } + } + + // Rule B — switch-client gate in components + { + for (const rel of new Bun.Glob(COMPONENTS_GLOB).scanSync(REPO_ROOT)) { + const file = join(REPO_ROOT, rel); + let text: string; + try { + text = await Bun.file(file).text(); + } catch { + continue; + } + const lines = text.split("\n"); + violations.push(...checkSwitchClientGate(file, lines)); + } + } + + if (violations.length > 0) { + console.error("\nlint-offload-await: FAIL"); + for (const v of violations) { + console.error(` [${v.rule}] ${v.file}:${v.line} → ${v.text}`); + } + console.error("\n Fix per RFC §5.5 / §8.3:"); + console.error( + " • registerSession / requestResume must be awaited, .catch-chained, or `void`-prefixed.", + ); + console.error( + ' • tmuxRun(["switch-client", …]) must be preceded by an offloadManager.getStatus(...) / requestResume(...) check, or carry a `// offload-exempt: ` comment.\n', + ); + process.exit(1); + } + + console.log("lint-offload-await: OK"); + process.exit(0); +} diff --git a/specs/2026-05-08-workflow-pane-offload-and-resume.md b/specs/2026-05-08-workflow-pane-offload-and-resume.md new file mode 100644 index 000000000..169483afa --- /dev/null +++ b/specs/2026-05-08-workflow-pane-offload-and-resume.md @@ -0,0 +1,383 @@ +# Workflow Pane Offload & Resume (Chrome-Tab-Style) + +| Document Metadata | Details | +| ---------------------- | ------------ | +| Author(s) | Norin Lavaee | +| Status | Draft (WIP) | +| Team / Owner | Atomic CLI | +| Created / Last Updated | 2026-05-08 | + +## 1. Executive Summary + +Long-running Atomic workflows hold a tmux pane + a live agent CLI process (Claude Code, OpenCode, Copilot) for every non-headless stage, even after the stage's callback returns. These idle CLIs each consume hundreds of MB of RAM. This RFC proposes a Chrome-tab-style offload mechanism: when a non-headless workflow stage finishes (or is left unfocused), kill the underlying agent process and free its tmux pane resources, persisting only the agent-native session ID. When the user navigates back to that pane, transparently re-spawn the agent with `--resume ` so the user sees the original transcript and can continue the conversation. + +The feature does **not** apply to headless stages (those are already torn down at completion) and does **not** apply to actively running stages (the user expects work to continue when they switch away). The result: large, multi-stage workflow runs stop accumulating ghost agent processes, and a dormant workflow only costs disk space. + +## 2. Context and Motivation + +### 2.1 Current State + +- Each non-headless stage runs `tmux.createWindow(...)` (`packages/atomic-sdk/src/runtime/executor.ts:1717`) and spawns a real `claude` / `opencode` / `copilot` CLI in that window. +- After `s.send(prompt)` returns, the stage callback typically completes — `activeRegistry.delete(name)` runs and the entry moves to `completedRegistry` (`executor.ts:1986`) — but the **agent CLI keeps running** in tmux, holding its own context window, model HTTP keep-alive, MCP servers, and any preloaded skills/tools. +- A workflow with N stages keeps N idle agent CLIs alive until `runOrchestrator`'s `shutdown` (`executor.ts:2093`) calls `tmux.killSession(...)`, which only fires when the entire tmux session is being torn down. +- Atomic already persists per-stage state to `~/.atomic/sessions//-/` (status.json, metadata.json, messages.json, inbox.md), so on-disk recoverability is already partially in place. See [`2026-02-25-workflow-sdk-design.md`](../research/docs/2026-02-25-workflow-sdk-design.md). + +### 2.2 The Problem + +- **User Impact:** Multi-stage workflows (e.g., Ralph DAG runs with 6+ stages) are unusable on lower-RAM machines because every Claude/Copilot/OpenCode pane holds its full context. +- **Resource Impact:** A 5-stage non-headless workflow easily holds 1–2 GB of resident agent memory after the workflow run completes, even when the user is no longer interacting with any pane. +- **Technical Debt:** There is no notion of "this pane is dormant" — the panel store models only `running | awaiting_input | complete | error` (`packages/atomic-sdk/src/components/orchestrator-panel-store.ts:85-107`). Every completed stage looks the same regardless of whether its CLI is still in memory. +- **Cross-doc:** Related interrupt/destroy bugs are documented in [`2026-03-25-workflow-interrupt-resume-bugs.md`](../research/docs/2026-03-25-workflow-interrupt-resume-bugs.md), which calls out that the conductor's `finally` block destroys session state unconditionally — the same teardown discipline we want to invert here. + +## 3. Goals and Non-Goals + +### 3.1 Functional Goals + +- [ ] When a non-headless workflow run reaches `completionReached`, idle/unfocused panes have their underlying agent CLI killed and their tmux window reaped. +- [ ] When the user navigates to a previously-offloaded pane (via the session graph, `Enter` on a node, or a tmux switch-client), the agent CLI is re-spawned with the agent's native `--resume` flag against the persisted session ID, transparently to the user. +- [ ] The agent's transcript (history, tool calls, prior messages) is preserved across the offload/resume cycle by relying on the agent CLI's own resume mechanism — Atomic does not re-implement transcript replay. +- [ ] Per-provider `--resume` wiring exists for Claude (`claude --resume `), OpenCode (`opencode --session ` or equivalent), and Copilot (`copilot --resume ` or equivalent — see Section 9). +- [ ] The pane lifecycle states grow a new value `offloaded` (visible in `SessionData.status`), distinct from `complete`. +- [ ] Active (still-streaming) stages are exempt from offload, even if the user has navigated to a different pane. +- [ ] Offload is unconditional for non-headless workflows once the workflow run reaches `completionReached`. There is no opt-out flag at any level — the goal is to make offload a system-wide invariant of completed runs. + +### 3.2 Non-Goals (Out of Scope) + +- [ ] We will NOT offload headless stages — they already terminate cleanly at the end of `createSessionRunner` (`executor.ts:1986`) and have no tmux pane. +- [ ] We will NOT offload mid-run stages just because the user navigated away. Active workflows continue regardless of pane focus, per the user's requirement "active workflows will run regardless if we are detached". +- [ ] We will NOT introduce a memory-pressure-driven eviction policy in v1 (e.g., LRU eviction at N panes). This may come later (Section 9, Q5). +- [ ] We will NOT preserve sub-agent (Task tool) sessions for offload. Sub-agents are short-lived and tied to their parent stage's run. +- [ ] We will NOT change the existing `~/.atomic/sessions//` on-disk schema in a backwards-incompatible way; new fields are additive only. +- [ ] We will NOT support cross-machine resume (you cannot offload on machine A and resume on machine B). The agent CLI's own transcript file (e.g., `~/.claude/projects//.jsonl`) is local-only. + +## 4. Proposed Solution (High-Level Design) + +### 4.1 System Architecture Diagram + +```mermaid +%%{init: {'theme':'base'}}%% +flowchart TB + subgraph User["User Interaction"] + U(("◉ User")) + end + + subgraph TUI["Atomic TUI (orchestrator-panel)"] + SG["SessionGraphPanel
(focus poller)"] + Store["PanelStore
+ status: 'offloaded'
+ offloadResumeMeta"] + end + + subgraph Runtime["packages/atomic-sdk/src/runtime"] + Exec["executor.ts
activeRegistry/
completedRegistry"] + Off["offload-manager.ts
(NEW)"] + Tmux["tmux.ts
+ killWindow()"] + end + + subgraph Provider["Providers"] + Claude["claude.ts
spawn(--resume id)"] + OC["opencode.ts
session.create({sessionId})"] + Cop["copilot.ts
spawn(--resume id)"] + end + + Disk[("◆ ~/.atomic/sessions/<runId>/
<name>-<stageId>/
+ resume.json (NEW)")] + + U -->|navigate to pane| SG + SG -->|focus event| Store + Store -->|requestResume(name)| Off + Exec -->|onCompletion| Off + Off -->|spawn agent --resume id| Provider + Off -->|killWindow| Tmux + Off <-->|read/write| Disk + Provider -->|writes transcript| Disk +``` + +### 4.2 Architectural Pattern + +- **State machine + lazy materialization.** Each non-headless stage's pane has a lifecycle: `running → complete → offloaded → resuming → complete (re-attached)`. Offload is an idempotent transition that releases process resources while preserving the disk-side resume token. +- **Single-source-of-truth on disk.** The agent's own session-id file (Claude's UUID JSONL, OpenCode's session row, Copilot's session record) is the *authoritative* transcript store. Atomic only persists the *pointer* to it (resume metadata). This avoids reimplementing transcript replay. +- **Reactive resume trigger via existing focus poller.** `SessionGraphPanel` already polls tmux every 500ms (`session-graph-panel.tsx:342-367`) for `display-message` to detect window focus changes. We extend this poller to fire `offloadManager.requestResume(name)` when the user enters an offloaded pane. + +### 4.3 Key Components + +| Component | Responsibility | Location (existing or NEW) | +| ---------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `OffloadManager` | Tracks offloadable panes, performs kill, executes resume spawns, idempotent guard | NEW: `packages/atomic-sdk/src/runtime/offload-manager.ts` | +| `OffloadMetadata` (disk format) | Records agent-native session ID, last-known prompt, tmux window name, agent kind | EXTEND: `~/.atomic/sessions//-/metadata.json` — new `resume` sub-object | +| `tmux.killWindow(session, name)` | Kill a single window without tearing down the whole tmux session | EXTEND: `packages/atomic-sdk/src/runtime/tmux.ts` (currently only has `killSession`) | +| Provider `spawnWithResume` adapter | Per-provider mapping from `(sessionId, prompt?) → spawn args` | EXTEND: each of `providers/{claude,opencode,copilot}.ts` | +| `SessionData.status = 'offloaded'` | New panel-store status, distinct from `complete` | EXTEND: `packages/atomic-sdk/src/components/orchestrator-panel-types.ts` + `orchestrator-panel-store.ts` | +| Focus poller hook | Detects "user just entered a pane that is offloaded" | EXTEND: `packages/atomic-sdk/src/components/session-graph-panel.tsx` (existing 500ms `setInterval`) | + +## 5. Detailed Design + +### 5.1 Disk Format: Extended `metadata.json` + +The existing `metadata.json` (currently write-once at stage start, `executor.ts:1932`) is extended to be mutable for offload-related fields only. The original immutable fields (`name`, `description`, `agent`, `paneId`, `serverUrl`, `port`, `startedAt`) keep their write-once contract; new mutable fields are added under a `resume` sub-object so the immutability boundary stays clear. + +```json +{ + "name": "review", + "description": "Review code changes", + "agent": "claude", + "paneId": "%7", + "serverUrl": "", + "port": null, + "startedAt": 1717804800000, + "resume": { + "schemaVersion": 1, + "agentSessionId": "9f3a8f1d-1c0e-4b1f-9a2f-5e7d8b0e1a23", + "tmuxSessionName": "atomic-7f3a2c1d", + "tmuxWindowName": "review", + "spawnEnv": { "CLAUDECODE": "1" }, + "spawnCwd": "/home/user/projects/foo", + "lastPrompt": "Look at the diff and propose fixes", + "lastSeenAt": 1717804900000, + "offloadedAt": null + } +} +``` + +- **`resume.agentSessionId`** is the value Atomic feeds back into the agent's resume flag. For Claude it's the UUID currently held in `PaneState.claudeSessionId` (`providers/claude.ts:391`). For OpenCode it's the `session.id` returned by `oc.client.session.create(...)` (`executor.ts:1499`). For Copilot it's `session.sessionId` from `client.createSession(...)` (`executor.ts:1493`). +- **`resume.offloadedAt`** is `null` while the agent is alive, set to a timestamp the moment the agent process is killed. +- **Mutation discipline:** writes to the `resume` object go through a single `OffloadManager.persistResume(stageDir, patch)` helper that does read-modify-write under a per-stage in-process mutex. The top-level immutable fields are never touched after stage start, preserving the write-once contract for everything outside `resume`. +- **Forward compatibility:** older readers (e.g., `atomic workflow read`) ignore unknown top-level keys; the `resume` block is invisible to them. Newer code that reads `metadata.json` checks for `resume.schemaVersion` before consuming. + +### 5.2 `OffloadManager` API + +```ts +// packages/atomic-sdk/src/runtime/offload-manager.ts +export interface OffloadManager { + /** Called when a stage's session is fully initialized; persists resume metadata. */ + registerSession(input: { + name: string; + runId: string; + stageDir: string; + agent: AgentKind; + agentSessionId: string; + tmuxSession: string; + tmuxWindow: string; + spawnEnv: Record; + spawnCwd: string; + }): void; + + /** Called when the workflow's `completionReached` flips true. Schedules eligible panes for offload. */ + onWorkflowCompletion(): Promise; + + /** Idempotent: if `name` is offloaded, re-spawn the agent and reattach. If alive, no-op. */ + requestResume(name: string): Promise; + + /** Inspect current state for the panel store. */ + getStatus(name: string): "alive" | "offloaded" | "resuming"; +} +``` + +#### 5.2.1 Offload procedure (`onWorkflowCompletion → killOnePane`) + +1. Read `activeRegistry` + `completedRegistry`. Skip any name that is still in `activeRegistry` — that pane is still running. Skip any name whose stage was `headless` (no tmux window). Skip the pane the user is currently focused on (`PanelStore.activeAgentId`). +2. For each eligible pane: + a. Persist `metadata.json#resume` with `offloadedAt = Date.now()`. + b. Call `tmux.killWindow(tmuxSession, tmuxWindow)` (new helper, see 5.3) — this terminates the agent CLI gracefully via SIGHUP from tmux. + c. Update `PanelStore`: `setSessionStatus(name, "offloaded")`. +3. Concurrency: serialize per-pane via a `Map>` to avoid double-kill races between the completion handler and the focus poller. + +#### 5.2.2 Resume procedure (`requestResume`) + +1. If `getStatus(name) === "alive"` → return. (Idempotent: focus poller fires on every focus change.) +2. If `getStatus(name) === "resuming"` → await the existing in-flight promise. +3. Otherwise: + a. Load `metadata.json#resume`. Reject with a user-visible error if the file is missing or schema-incompatible. + b. Re-create the tmux window: `tmux.createWindow(session, windowName, cwd)`. + c. Per-agent: build resume command via the provider adapter (Section 5.4) and spawn into the pane via `tmux send-keys`. + d. Wait for the readiness signal. For Claude this is the `claude-ready/` marker file written by the SessionStart hook (`providers/claude.ts:249`). For OpenCode/Copilot, wait for the SDK to register the session via `client.session.get(id)` returning successfully (port discovery via `waitForServer`, mirroring `executor.ts:1432-1497`). + e. Update `PanelStore`: `setSessionStatus(name, "complete")` (re-attached and ready for input). + f. **Auto-switch (resume UX):** the focus event is *not* allowed to flip tmux to an offloaded pane immediately. Instead, the graph node renders a "Resuming…" indicator in place; only once readiness in step (d) confirms does `OffloadManager` call `tmux select-window -t :` to bring the user into the live pane. The user stays on the graph view until the agent CLI is ready to accept input — they never see an empty pane. +4. On any error: write `metadata.json#resume.error = `, set status back to `offloaded`, surface a toast in the TUI ("Failed to resume ; try again?"). The user remains on the graph view. + +#### 5.2.3 Why a separate manager rather than inlining into `executor.ts`? + +`executor.ts` is already 2k+ lines and conflates three concerns (orchestration, per-stage runner, registry bookkeeping). A separate `OffloadManager` lets us unit-test the offload state machine in isolation and gives Ralph (which currently bypasses parts of `WorkflowSDK`, see [`2026-02-25-workflow-sdk-design.md`](../research/docs/2026-02-25-workflow-sdk-design.md)) a single integration point. + +### 5.3 `tmux.killWindow` Helper + +Today `packages/atomic-sdk/src/runtime/tmux.ts:445` exposes only `killSession(name)` (kills the whole tmux session). We add: + +```ts +export function killWindow(sessionName: string, windowName: string): Promise { + return tmuxRun(["kill-window", "-t", `${sessionName}:${windowName}`]).then(() => {}); +} +``` + +Behavior: +- tmux kills the pane; the agent CLI receives SIGHUP. Claude's Stop hook may or may not fire (best-effort) — we do not depend on it. +- The orchestrator pane (window 0) is never targeted; offload runs only against stage windows. + +### 5.4 Per-Provider Resume Adapter + +Each provider gets a new exported helper: + +```ts +// providers/claude.ts +export function buildClaudeResumeArgs(meta: OffloadMetadata): string[] { + // claude --resume [...standard chat flags] --settings + return ["--resume", meta.agentSessionId, ...standardClaudeFlags(), "--settings", hooksPath]; +} +``` + +| Provider | Resume command | Notes | +| -------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Claude | `claude --resume ` | Confirmed: standard Claude Code behavior. Replays JSONL transcript at `~/.claude/projects//.jsonl`. | +| OpenCode | `opencode --session ` | Confirmed: native session-rehydration flag in the OpenCode CLI. | +| Copilot | `copilot --resume=` | Confirmed: Copilot CLI's resume flag (note `=` syntax). | + +For OpenCode: the existing per-run shared server pattern (`executor.ts:1499` calls `oc.client.session.create()` against a single `oc` server per workflow run) means offload kills the whole `opencode` process. Resume re-spawns the CLI with `--session `, which boots a fresh server, rehydrates the named session, and re-attaches the SDK client. This is a per-provider implementation nuance, not a feature gap — all three providers reach the same end state. + +### 5.5 Pane Focus Detection (and Resume Trigger) + +Existing code (`packages/atomic-sdk/src/components/session-graph-panel.tsx:342-367`) already polls tmux every 500ms via `display-message` and stores the focused window in `PanelStore.activeAgentId`. The focus poll alone is *not* sufficient for offload-aware resume because by the time the poll detects the change, tmux has already switched the user to an empty pane. Instead, we move the resume trigger upstream into the `doAttach` keypress handler (`session-graph-panel.tsx:105-123`): + +```ts +// pseudocode for doAttach +const status = offloadManager.getStatus(name); +if (status === "offloaded" || status === "resuming") { + store.setViewMode("resuming", name); + await offloadManager.requestResume(name); // includes auto-switch on success +} else { + store.setViewMode("attached", name); + void tmuxRun(["switch-client", "-t", `${session}:${name}`]); +} +``` + +The 500ms tmux focus poll remains as a fallback for cases where the user changed windows by tmux keybinding rather than the graph UI (e.g., `prefix + n`). When the poll detects a transition into an offloaded window, it calls `requestResume(name)` and adds a `setSessionStatus(name, "resuming")` for visibility. (Edge case: the user is briefly inside an empty tmux window while the resume runs — that's the rare path; the primary flow goes through `doAttach`.) + +### 5.6 Lifecycle State Machine + +```mermaid +stateDiagram-v2 + [*] --> Pending + Pending --> Running: stage starts + Running --> AwaitingInput: HIL hook + AwaitingInput --> Running: user replied + Running --> Complete: stage callback returned + Running --> Error: stage threw + Complete --> Offloaded: workflow.completionReached && pane unfocused + Offloaded --> Resuming: user navigated to pane + Resuming --> Complete: resume succeeded + Resuming --> Offloaded: resume failed (recoverable) + Offloaded --> [*]: workflow torn down + Complete --> [*]: workflow torn down + Error --> [*]: workflow torn down +``` + +**Important:** the existing `resumeSession(name)` method in `orchestrator-panel-store.ts:101` is a HIL helper (status `awaiting_input → running`). It does NOT reflect process-level resume. We will keep that method unchanged and add a separate `setSessionStatus(name, status)` for offload transitions to avoid name collision. + +### 5.7 No Opt-Out + +Offload is unconditional for non-headless workflows. There is intentionally no `defineWorkflow({ offload: false })` flag, no per-stage knob, and no global config setting. The rationale: offload is correctness-preserving (the agent's own `--resume` machinery owns transcript fidelity), and adding opt-outs would split the codebase into two paths that have to stay in sync forever. If real-world friction emerges later, an opt-out can be added — but the v1 surface stays minimal. + +### 5.8 API Interfaces + +No new public CLI commands. Internal SDK additions only: + +- `SessionData.status` gains `"offloaded" | "resuming"` +- `tmux.killWindow(session, window): Promise` +- `OffloadManager` (Section 5.2) +- `metadata.json` gains a mutable `resume` sub-object (Section 5.1) + +### 5.9 Data Model / Schema + +`metadata.json#resume` schema as in 5.1. Existing files (`metadata.json`, `status.json`, `messages.json`, `inbox.md`) remain unchanged. `status.json` may be extended to include a per-session `offloadedAt` field for easier debugging via `atomic workflow read`, but this is additive. + +### 5.10 Algorithms and State Management + +- **Offload eligibility check** — runs at workflow `completionReached` time: + ``` + for each (name, session) in panel.sessions: + if session.headless: skip + if name == panel.activeAgentId: skip // user is on this pane + if session.status not in {"complete"}: skip // running/error stays + schedule offload + ``` +- **Resume idempotency** — guarded via `Map>` in `OffloadManager`. The 500ms focus poller may fire multiple `requestResume(name)` calls for the same pane; only the first triggers a real spawn. +- **Concurrency model** — offload and resume are async but per-pane serialized. Multiple panes can offload concurrently. Multiple panes can resume concurrently (rare; user can only focus one at a time, but a "resume all" admin action could exist later — Section 9, Q7). + +## 6. Alternatives Considered + +| Option | Pros | Cons | Reason for Rejection | +| --------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| **A. Keep all panes alive** (status quo) | Zero implementation cost, instant resume | Massive memory footprint | This is the problem we're solving. | +| **B. Pause via SIGSTOP instead of kill** | Resume is instantaneous (SIGCONT) | RAM is still resident; tmux pane still allocated; HTTP keep-alives expire; not portable | Doesn't actually free memory — the goal is RAM reduction, not just CPU. | +| **C. Replay transcript ourselves on resume** | Fully provider-agnostic; no need to wire `--resume` per provider | Reimplements every agent's history loader; loses tool-call IDs; provider may reject replays | Brittle and duplicative — agent CLIs already have `--resume` built in. | +| **D. Single global "offload after N minutes idle" timer** | Simple, no per-pane focus tracking | Aggressive: offloads panes the user is reading | Conflicts with "as long as the user is in a workflow pane it stays alive." | +| **E. Offload manager (selected)** | Surgical, opt-out, leverages agent-native resume, integrates with existing focus poll | More moving parts; per-provider resume wiring needed | **Selected:** matches the user's stated semantics exactly and the cost is bounded. | + +## 7. Cross-Cutting Concerns + +### 7.1 Security and Privacy + +- `metadata.json#resume` contains the agent session ID, last prompt, and cwd. The session ID is not authentication material in any of the three providers (the local transcript file is the authoritative store and only the local user can read it), but `lastPrompt` may include user-typed sensitive content. +- File permissions: write `metadata.json#resume` with `0o600`, matching how Claude's existing transcript files are stored. +- No new network exposure. The OpenCode HTTP server is bound to `127.0.0.1` and a random port (`executor.ts:322`), unchanged. + +### 7.2 Observability Strategy + +- Telemetry events (already routed via `packages/atomic/src/lib/telemetry/`): + - `workflow.offload.scheduled` (count of panes scheduled per workflow) + - `workflow.offload.completed` (per-pane, with agent kind) + - `workflow.offload.resume.attempted` / `.succeeded` / `.failed` (with error code) + - `workflow.offload.resume.latency_ms` (time from focus event → pane ready) +- Logs land in `~/.atomic/sessions//orchestrator.log` (existing destination). +- A new entry in the panel store is rendered next to the pane name in the session graph TUI (`offloaded` icon). + +### 7.3 Scalability and Capacity Planning + +- Memory: a 6-stage workflow goes from ~1.5 GB resident to ~250 MB resident (only the focused pane + orchestrator). +- Disk: each `metadata.json#resume` is < 4 KB; even 1000 historical workflow runs = 4 MB. Existing `~/.atomic/sessions/` cleanup logic (if any — Section 9, Q8) governs lifetime. +- Resume latency budget: + - Claude: ~1.5s (process spawn + JSONL replay + hook readiness marker) + - OpenCode: ~2s (server respawn + session re-fetch) + - Copilot: ~1.5s (process spawn + transcript load) +- Bottleneck: tmux `send-keys` for re-pasting the resume command — negligible (< 50ms). + +## 8. Migration, Rollout, and Testing + +### 8.1 Deployment Strategy + +- **Phase 1 — Plumbing.** Land `OffloadMetadata`, `tmux.killWindow`, per-provider `buildResumeArgs`. No behavior change yet (offload disabled by default via feature flag). +- **Phase 2 — Manual offload.** Add a `Ctrl+O` keybinding in the session graph panel that manually offloads the focused pane. Validate Claude's `--resume` round-trip end-to-end with real workflows. +- **Phase 3 — Auto offload.** Flip the default for Claude only. OpenCode and Copilot remain manual until their resume semantics are validated (Section 9, Q1). +- **Phase 4 — All providers.** Roll out to OpenCode + Copilot. Per-workflow opt-out documented. + +### 8.2 Data Migration Plan + +- No backfill: pre-existing workflow runs (without `metadata.json#resume`) are non-resumable. They render as `complete` with a tooltip "(legacy run, cannot resume)". +- Schema versioning: `resume.json.schemaVersion` starts at 1; bumps trigger graceful skip of incompatible files. + +### 8.3 Test Plan + +- **Unit tests** (`bun test`): + - `OffloadManager` state transitions (offload / resume / idempotent / error path) + - `buildClaudeResumeArgs` / `buildOpencodeResumeArgs` / `buildCopilotResumeArgs` snapshots + - `tmux.killWindow` against a fixture tmux server + - `metadata.json#resume` schema serialization round-trip +- **Integration tests** (gated on `CLAUDECODE=1` env, real binaries): + - Run a 2-stage non-headless Claude workflow, navigate away from stage 1, verify pane is killed and `metadata.json#resume` exists. + - Navigate back to stage 1, verify pane re-spawns and message history is intact. + - Repeat for OpenCode (after Q1 resolved) and Copilot. +- **End-to-end (UI smoke)**: + - Launch a real workflow, exercise offload via Ctrl+O (Phase 2), verify the panel store status change renders correctly. + - Verify focused pane is never offloaded (race condition with completion event). +- **Regression**: + - Existing interrupt/resume flows ([`2026-03-25-workflow-interrupt-resume-bugs.md`](../research/docs/2026-03-25-workflow-interrupt-resume-bugs.md)) — confirm offload doesn't tangle with HIL `awaiting_input → running` transitions. + - Headless workflows: assert no `metadata.json#resume` is ever written. + +## 9. Open Questions / Unresolved Issues + +- [x] **Q1 — OpenCode resume semantics.** RESOLVED: OpenCode supports `opencode --session ` natively. Offload kills the per-run `opencode` server; resume re-spawns it with the session flag, which rehydrates the named session before the SDK client reconnects. +- [x] **Q2 — Copilot resume flag.** RESOLVED: `copilot --resume=` (note the `=` syntax). Per-provider adapter uses this exact form. +- [x] **Q3 — Disk format.** RESOLVED: extend `metadata.json` with a `resume` sub-object. Mutation is funneled through `OffloadManager.persistResume(stageDir, patch)` with a per-stage in-process mutex; the original immutable top-level fields stay write-once. +- [x] **Q4 — Resume UX during the spawn gap.** RESOLVED: stay on the graph view with a "Resuming…" indicator on the node; auto-`tmux select-window` only after readiness confirmed. User never sees an empty tmux pane on the primary flow (graph-driven attach). +- [x] **Q5 — Eviction policy.** RESOLVED: completion-only offload for v1. Mid-run idle offload and LRU eviction are explicitly out of scope; revisit only if real-world memory complaints persist after Phase 4. +- [x] **Q6 — Opt-out.** RESOLVED: no opt-out at any level. Offload is unconditional for non-headless workflows on `completionReached`. The `WorkflowDefinition.offload` flag and global config are explicitly NOT introduced. +- [x] **Q7 — Bulk resume.** RESOLVED: skipped for v1. User-driven resume only. +- [x] **Q8 — On-disk lifetime.** RESOLVED: out of scope. Pre-existing problem unrelated to offload; tracked separately. +- [x] **Q9 — Claude signal-file cleanup.** RESOLVED: offload proactively writes `claude-release/` and unlinks stale `claude-stop/`, `claude-pid/`, `claude-inflight//` markers (`providers/claude.ts:843` reads these). Resume re-creates the marker structure via the SessionStart hook the same way fresh spawns do. +- [x] **Q10 — `metadata.json#resume.lastPrompt` privacy.** RESOLVED: kept. Already lives in `messages.json` adjacent to it; both files written with mode `0o600`. No incremental privacy surface. diff --git a/tests/sdk/components/test-helpers.tsx b/tests/sdk/components/test-helpers.tsx index d2cec3f74..dae735245 100644 --- a/tests/sdk/components/test-helpers.tsx +++ b/tests/sdk/components/test-helpers.tsx @@ -5,11 +5,13 @@ import { createRoot } from "@opentui/react"; import { act, type ReactNode } from "react"; import { PanelStore } from "../../../packages/atomic-sdk/src/components/orchestrator-panel-store.ts"; import { + OffloadManagerContext, StoreContext, ThemeContext, TmuxSessionContext, } from "../../../packages/atomic-sdk/src/components/orchestrator-panel-contexts.ts"; import type { GraphTheme } from "../../../packages/atomic-sdk/src/components/graph-theme.ts"; +import type { OffloadManager } from "../../../packages/atomic-sdk/src/runtime/offload-manager.ts"; export type ReactTestSetup = Awaited>; @@ -190,22 +192,38 @@ export async function renderReact( return testSetup; } +/** + * No-op OffloadManager stub for tests that don't exercise offload behavior. + * SessionGraphPanel reads `getStatus` and conditionally calls `requestResume`; + * stubs that return "alive" / resolve void let the panel render normally. + */ +const NOOP_OFFLOAD_MANAGER: OffloadManager = { + registerSession: () => Promise.resolve(), + onWorkflowCompletion: () => Promise.resolve(), + requestResume: () => Promise.resolve(), + getStatus: () => "alive", +}; + export function TestProviders({ store, theme, tmuxSession, + offloadManager, children, }: { store: PanelStore; theme?: GraphTheme; tmuxSession?: string; + offloadManager?: OffloadManager; children: ReactNode; }) { return ( - {children} + + {children} +