diff --git a/apps/server/src/harness/atomic/adapter.ts b/apps/server/src/harness/atomic/adapter.ts index 894bb47d5..82d85d1a5 100644 --- a/apps/server/src/harness/atomic/adapter.ts +++ b/apps/server/src/harness/atomic/adapter.ts @@ -25,7 +25,7 @@ import { import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { fullPlanner, workflowRuns } from "./full"; +import { fullPlanner, settleEndedRuns, workflowRuns } from "./full"; import type { HarnessV1, @@ -709,6 +709,12 @@ export function createAtomicAdapter( try { if (live) { live.release(); + if (full) { + await bounded( + settleEndedRuns(live.session.workflows, full.runs), + "Atomic workflow runs did not settle.", + ).catch(error => console.error("[agent]", error)); + } await bounded(live.session.dispose(), "Atomic session disposal timed out."); } } finally { diff --git a/apps/server/src/harness/atomic/full.test.ts b/apps/server/src/harness/atomic/full.test.ts index 47b30cdba..c6e5a45f8 100644 --- a/apps/server/src/harness/atomic/full.test.ts +++ b/apps/server/src/harness/atomic/full.test.ts @@ -19,6 +19,7 @@ import { registerFullPlanner, resumeOwnedRuns, runCards, + settleEndedRuns, untilUnpaused, workflowRuns, } from "./full"; @@ -174,9 +175,12 @@ async function run( api.registerTool({name: "operator_tool", label: "Operator", description: "Marker", parameters: {type:"object"}, async execute() { return {content:[{type:"text",text:"marker"}], details:{}}; }}); let nudged = false; api.on("agent_end", () => { - if (process.env.CHOPIN_TEST_SELF_STARTED_TURN !== "1" || nudged) return; + let how = process.env.CHOPIN_TEST_SELF_STARTED_TURN; + if (!how || nudged) return; nudged = true; - setTimeout(() => api.sendMessage({ customType: "self-started", content: "SELF-STARTED", display: true }, { triggerTurn: true }), 20); + setTimeout(() => how === "queued" + ? api.sendUserMessage("SELF-STARTED", { deliverAs: "followUp" }) + : api.sendMessage({ customType: "self-started", content: "SELF-STARTED", display: true }, { triggerTurn: true }), 20); }); };`, ); @@ -556,17 +560,21 @@ test( 60_000, ); -test("a turn Atomic starts by itself between the Planner's turns is stopped before it reaches the model", async () => { - process.env.CHOPIN_TEST_SELF_STARTED_TURN = "1"; - try { - let result = await run(true, "plain", { - afterTurn: () => new Promise(resolve => setTimeout(resolve, 500)), - }); - expect(result.requests).toHaveLength(1); - } finally { - delete process.env.CHOPIN_TEST_SELF_STARTED_TURN; - } -}); +// Atomic starts a turn for its own notices, and since 0.9.41 for a message queued on an idle session. +test.each(["notice", "queued"])( + "a turn Atomic starts by itself (%s) between the Planner's turns is stopped before it reaches the model", + async how => { + process.env.CHOPIN_TEST_SELF_STARTED_TURN = how; + try { + let result = await run(true, "plain", { + afterTurn: () => new Promise(resolve => setTimeout(resolve, 500)), + }); + expect(result.requests).toHaveLength(1); + } finally { + delete process.env.CHOPIN_TEST_SELF_STARTED_TURN; + } + }, +); test("worker sessions stay isolated, even beside a full Planner session on the same harness", async () => { let alone = await run(false); @@ -729,6 +737,48 @@ test("Stop and Resume use the session's run control, and a partial pause is repo await expect(pauseOwnedRuns(workflows)).rejects.toThrow("run-2: busy"); }); +test("before a session goes, each run that ended is waited on until its ending is journaled, never a live, paused or blocked one", async () => { + let card = (id: string, status: PlannerRuns["cards"][number]["status"]) => ({ + id, + name: id, + status, + started: 0, + updated: 0, + stages: [], + waiting: 0, + }); + let waited: string[] = []; + let errors: unknown[][] = []; + let error = console.error; + console.error = (...args: unknown[]) => void errors.push(args); + try { + await settleEndedRuns({ + waitForSettled: async runId => { + waited.push(runId); + if (runId === "failed") throw new Error("journal unavailable"); + }, + }, { + active: ["live"], + paused: ["paused"], + cards: [ + card("finished", "finished"), + card("failed", "failed"), + card("stopped", "stopped"), + card("blocked", "blocked"), + card("live", "running"), + card("paused", "paused"), + ], + }); + } finally { + console.error = error; + } + expect(waited.sort()).toEqual(["failed", "finished", "stopped"]); + expect(errors).toHaveLength(1); + expect(String(errors[0]!.join(" "))).toContain("failed"); + expect(String(errors[0]!.join(" "))).toContain("journal unavailable"); + await settleEndedRuns({ waitForSettled: async () => {} }, undefined); +}); + test("an answer to a paused run's question is held until the run resumes, including child runs", async () => { let planner: FullPlanner = { cwd: "/", diff --git a/apps/server/src/harness/atomic/full.ts b/apps/server/src/harness/atomic/full.ts index 62ce275f7..7dfb389b4 100644 --- a/apps/server/src/harness/atomic/full.ts +++ b/apps/server/src/harness/atomic/full.ts @@ -54,6 +54,30 @@ export async function resumeOwnedRuns(workflows: SessionWorkflows): Promise, + runs: PlannerRuns | undefined, +): Promise { + let ended = (runs?.cards ?? []).filter(card => + (card.status === "finished" || card.status === "failed" || card.status === "stopped") + && !runs!.active.includes(card.id) && !runs!.paused.includes(card.id) + ); + await Promise.all( + ended.map(card => + workflows.waitForSettled(card.id).catch(error => + console.error(`[agent] workflow run ${card.id} ended without its ending journaled:`, error) + ) + ), + ); +} + /** * Resolves once the root run owning `runId` is not paused. Atomic leaves a * question open when its run pauses, so an answer given meanwhile is held here diff --git a/bun.lock b/bun.lock index 40c9df897..f5447c734 100644 --- a/bun.lock +++ b/bun.lock @@ -310,7 +310,7 @@ "@ai-sdk/harness": "1.0.146", "@ai-sdk/mcp": "2.0.72", "@ai-sdk/sandbox-just-bash": "1.0.146", - "@bastani/atomic": "0.9.40", + "@bastani/atomic": "0.9.41-alpha.1", "ai": "7.0.135", "typebox": "1.3.7", "zod": "4.4.3", @@ -451,27 +451,27 @@ "@base-ui/utils": ["@base-ui/utils@0.3.2", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.12", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ=="], - "@bastani/atomic": ["@bastani/atomic@0.9.40", "", { "dependencies": { "@bastani/atomic-natives": "0.9.40", "@bastani/pi-ai": "0.9.40", "@earendil-works/pi-agent-core": "1.3.0", "@earendil-works/pi-codemode": "1.3.0", "@earendil-works/pi-tui": "1.3.0", "@modelcontextprotocol/ext-apps": "^1.7.5", "@modelcontextprotocol/sdk": "1.32.1", "@mozilla/readability": "^0.6.0", "@silvia-odwyer/photon-node": "0.3.4", "@typesafe-ai/sdk": "0.7.0", "brace-expansion": "5.0.12", "chalk": "6.0.0", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "grok-mermaid": "0.2.3", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.8", "jiti": "2.7.0", "linkedom": "^0.18.13", "lru-cache": "11.5.3", "markit-ai": "0.5.3", "minimatch": "10.2.6", "open": "^11.0.1", "p-limit": "^7.3.2", "proper-lockfile": "4.1.2", "quickjs-wasi": "3.6.2", "semver": "7.8.5", "turndown": "^7.2.0", "typebox": "1.3.27", "undici": "8.10.2", "unpdf": "1.8.1", "yaml": "2.9.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "atomic": "dist/cli.js" } }, "sha512-BM6WjVuwnbsf3yvq9rENtbyk3zS8VsSgEE+/4CpJec8WgflqwT/gEVZzpMZrkCqniurjHfdCLk9GSTwHrtcB0g=="], + "@bastani/atomic": ["@bastani/atomic@0.9.41-alpha.1", "", { "dependencies": { "@bastani/atomic-natives": "0.9.41-alpha.1", "@bastani/pi-ai": "0.9.41-alpha.1", "@earendil-works/pi-agent-core": "1.3.0", "@earendil-works/pi-codemode": "1.3.0", "@earendil-works/pi-tui": "1.3.0", "@modelcontextprotocol/ext-apps": "^1.7.5", "@modelcontextprotocol/sdk": "1.32.1", "@mozilla/readability": "^0.6.0", "@silvia-odwyer/photon-node": "0.3.4", "@typesafe-ai/sdk": "0.7.0", "brace-expansion": "5.0.12", "chalk": "6.0.0", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "grok-mermaid": "0.2.3", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.8", "jiti": "2.7.0", "linkedom": "^0.18.13", "lru-cache": "11.5.3", "markit-ai": "0.5.3", "minimatch": "10.2.6", "open": "^11.0.1", "p-limit": "^7.3.2", "proper-lockfile": "4.1.2", "quickjs-wasi": "3.6.2", "semver": "7.8.5", "turndown": "^7.2.0", "typebox": "1.3.27", "undici": "8.10.2", "unpdf": "1.8.1", "yaml": "2.9.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "atomic": "dist/cli.js" } }, "sha512-WxeHXJKCvVlCCeF+Z0vGGIuVFjGrZZU2gZZ9MPWgKJMPG4pdKemJ5wIJCFUk5b8/FRhAlgPAr6vSHQNdRHG8FA=="], - "@bastani/atomic-natives": ["@bastani/atomic-natives@0.9.40", "", { "optionalDependencies": { "@bastani/atomic-natives-darwin-arm64": "0.9.40", "@bastani/atomic-natives-darwin-x64": "0.9.40", "@bastani/atomic-natives-linux-arm64-gnu": "0.9.40", "@bastani/atomic-natives-linux-arm64-musl": "0.9.40", "@bastani/atomic-natives-linux-x64-gnu": "0.9.40", "@bastani/atomic-natives-linux-x64-musl": "0.9.40", "@bastani/atomic-natives-win32-arm64-msvc": "0.9.40", "@bastani/atomic-natives-win32-x64-msvc": "0.9.40" } }, "sha512-nAeVIfpMBpx5U87o9woPVvBn/Vp9EUhB56HGW+DewZLLrz8j21MEoKylwxn5ltV9X6LbfCw1cb1ObnlIIaEq9Q=="], + "@bastani/atomic-natives": ["@bastani/atomic-natives@0.9.41-alpha.1", "", { "optionalDependencies": { "@bastani/atomic-natives-darwin-arm64": "0.9.41-alpha.1", "@bastani/atomic-natives-darwin-x64": "0.9.41-alpha.1", "@bastani/atomic-natives-linux-arm64-gnu": "0.9.41-alpha.1", "@bastani/atomic-natives-linux-arm64-musl": "0.9.41-alpha.1", "@bastani/atomic-natives-linux-x64-gnu": "0.9.41-alpha.1", "@bastani/atomic-natives-linux-x64-musl": "0.9.41-alpha.1", "@bastani/atomic-natives-win32-arm64-msvc": "0.9.41-alpha.1", "@bastani/atomic-natives-win32-x64-msvc": "0.9.41-alpha.1" } }, "sha512-KPJKe5N+LRTmvn4Jgww4GxKuRqutZQ03gCvPwYLDE5fn94A9/zL3DCnmLidSTvtuYH9rYSoP7IA9gp2idj2bSA=="], - "@bastani/atomic-natives-darwin-arm64": ["@bastani/atomic-natives-darwin-arm64@0.9.40", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NyQg8Xawg0P8UEqUxT0inj+G3zmsnVm4FIRfebXgdopdEcmK2SGZYjrgilcy9w1RCyPV5cB5Va9Laq1cLrU8Pg=="], + "@bastani/atomic-natives-darwin-arm64": ["@bastani/atomic-natives-darwin-arm64@0.9.41-alpha.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1G6jy/BpHL5ax8u/ZhWYJby5bRwo8M3uiWgfcPLchYjmsTA6n/HetuWkDjEwoAYd/2DnEbMcYpTOaS5MWcFJRg=="], - "@bastani/atomic-natives-darwin-x64": ["@bastani/atomic-natives-darwin-x64@0.9.40", "", { "os": "darwin", "cpu": "x64" }, "sha512-8MbfvP/ukQbNKo71OmkPyz6B8AQSb4pOcxlVTedfqwVM/PWpeuNVZ8QPUPWj9sRqmCNQcAteNVcVsOrJ2i6kzg=="], + "@bastani/atomic-natives-darwin-x64": ["@bastani/atomic-natives-darwin-x64@0.9.41-alpha.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-aPSqjJIeh64+cHu7zYBGbmd1OWjETIk26ZNlPGAMih4u5MEDbzek58LXtR8KE0zEvs1GxS4Z8z4zfdoF/CnWTQ=="], - "@bastani/atomic-natives-linux-arm64-gnu": ["@bastani/atomic-natives-linux-arm64-gnu@0.9.40", "", { "os": "linux", "cpu": "arm64" }, "sha512-XbOJFPPaRFtFtZmolN5Lpw3q7/OAOeZaUbMcHDQK4dCDT/UMeG5DEK5EgDFtOUVyCC01ybvZf3fU802vrUFv/w=="], + "@bastani/atomic-natives-linux-arm64-gnu": ["@bastani/atomic-natives-linux-arm64-gnu@0.9.41-alpha.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-lFsmpi8/bqQwxUoylZmSdaVbMC53g8QyTNxuz3ywKyfZ2YPoFmnnRqVbgZepsCzaJ9SA9uusq6fX2CuAjXeu/Q=="], - "@bastani/atomic-natives-linux-arm64-musl": ["@bastani/atomic-natives-linux-arm64-musl@0.9.40", "", { "os": "linux", "cpu": "arm64" }, "sha512-AgmvlFEJygJg6bmY0ndkjJ0VwTsrxBRddEVjYpuYmZtXPtieYIIevZJhbRSghN3+4TOe+9sQ4ABIey0UMDozMw=="], + "@bastani/atomic-natives-linux-arm64-musl": ["@bastani/atomic-natives-linux-arm64-musl@0.9.41-alpha.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-dsWDdBv9tbtQKuvMwl/hXvicPtazD35IReiZ/FbAgtkRkuYLyF0g6lI3xhW11di1QVsKZrxg+PXIYx5j6EBbzg=="], - "@bastani/atomic-natives-linux-x64-gnu": ["@bastani/atomic-natives-linux-x64-gnu@0.9.40", "", { "os": "linux", "cpu": "x64" }, "sha512-RTS6hrG6cSKRjMm8WLNN74zJgk364m/8Z0Aqjnc67VQ2sT3L7eaKx6kV4qZjJaZxOUHu4W0pyFSaNkOEPtrxtw=="], + "@bastani/atomic-natives-linux-x64-gnu": ["@bastani/atomic-natives-linux-x64-gnu@0.9.41-alpha.1", "", { "os": "linux", "cpu": "x64" }, "sha512-zpYpSr5Az5lCO5XQV0raIGLH0toIIKn5XEWTH1rtOWOwcxPKJDPpqP+IMFU8VGH3q6jOfVstMQZhqHnETRsLTg=="], - "@bastani/atomic-natives-linux-x64-musl": ["@bastani/atomic-natives-linux-x64-musl@0.9.40", "", { "os": "linux", "cpu": "x64" }, "sha512-vjxgSrPgqKeRbtMgOcHDjnAaVdSI0NzUF/w/01iDrmk/hbuhnHV+nJyoXUKgSYMhMWdLwt9QVKpXLC6jf6e6RA=="], + "@bastani/atomic-natives-linux-x64-musl": ["@bastani/atomic-natives-linux-x64-musl@0.9.41-alpha.1", "", { "os": "linux", "cpu": "x64" }, "sha512-clq4YywgeBsLJMWY+qNy+rhEojelHHus8vt2c1hx2VhBz8xdM3bh8+4nWBAbxrEDj2xTTDR1BS+sWJDwpfvMXw=="], - "@bastani/atomic-natives-win32-arm64-msvc": ["@bastani/atomic-natives-win32-arm64-msvc@0.9.40", "", { "os": "win32", "cpu": "arm64" }, "sha512-DusAb2wNSeccvc265Qb+hQdl5QWpg8R2oHIay/15OnpX0hRu5tiVuZaorULSznoAeF/j5e2/xrJQf9X0g2oY5Q=="], + "@bastani/atomic-natives-win32-arm64-msvc": ["@bastani/atomic-natives-win32-arm64-msvc@0.9.41-alpha.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-CTzXyqUQtmptXt6BHr33BvjJ1c0yB5sjlh1WupqMIFl5mVgsN8COZYWTG4yD3MGNgsHbVNKkFEfyFe8081KjZw=="], - "@bastani/atomic-natives-win32-x64-msvc": ["@bastani/atomic-natives-win32-x64-msvc@0.9.40", "", { "os": "win32", "cpu": "x64" }, "sha512-lEvXHXZ4E4VlBlVW8rzyC5963pUWFMnwwjsQVd2/0vSS9TmySMrom9ASe/dHEM1vzrNChphrJaI2ZC6EPZfWFA=="], + "@bastani/atomic-natives-win32-x64-msvc": ["@bastani/atomic-natives-win32-x64-msvc@0.9.41-alpha.1", "", { "os": "win32", "cpu": "x64" }, "sha512-JEG/wkkrb4MUhiOgsFGXesplTTk2rc0XpTCi/yOWwxiQsl64sfrQgB2qWlvcfjh7QMBw4HKcpaoh2oGtY8H7gw=="], - "@bastani/pi-ai": ["@bastani/pi-ai@0.9.40", "", { "dependencies": { "@anthropic-ai/sdk": "0.129.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", "@earendil-works/pi-telemetry": "1.3.0", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "openai": "7.19.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-ibLak2XU/EsjHDU2GJjMnTjbczxvONEDfKx9YDZ2DVPtXiVR/c24FUasxz9tEHOq5mo2A8lhVY0Ts3AVuEDLFQ=="], + "@bastani/pi-ai": ["@bastani/pi-ai@0.9.41-alpha.1", "", { "dependencies": { "@anthropic-ai/sdk": "0.129.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", "@earendil-works/pi-telemetry": "1.3.0", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "openai": "7.19.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-bWL7hqgdDtBZR6od/tItQuWwETe7iZdDb7YAiGZTWPVG5R0MmiD+X7+Aa4qkpkTK3i/pVONgvj6d8azSTcFx7w=="], "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index c8e8e5735..7bac116bd 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -638,7 +638,9 @@ is between turns starts a Planner turn about it, on the kept session, so the Planner reports what happened and what the team needs to do; Chat records the run's end as that turn's reason, and the session is let go after the turn. The adapter stops any turn Atomic starts on its own, such as for its lifecycle -notices, because Chopin would neither show nor record it. +notices or, since Atomic 0.9.41, for a message queued while the session is +idle, because Chopin would neither show nor record it. The Planner's next turn +still starts normally. Chat shows the session's runs as one stack, one row per run: its name, status (running, waiting on Decisions, paused, or ended), and elapsed time. Several runs can be live at once. Runs waiting on Decisions come @@ -664,7 +666,11 @@ document. When a run ends, Chat also records a line saying how it ended and how long it took. The session is let go when its runs finish, when its owner binding ends, or when the document closes; run state is durable in Atomic's workflow journal, so runs interrupted that way can be resumed from a later -session. Chopin keeps that journal at +session. Before disposing the session, the adapter waits, for at most 10 +seconds, on Atomic's `session.workflows.waitForSettled()` for each run that +finished, failed or was stopped, so a session let go right after a run ends +never leaves that run `running` in the journal; a run whose ending could not be +recorded is logged by its ID. Chopin keeps that journal at `$XDG_STATE_HOME/chopin/atomic/workflows.sqlite` under the same per-user state directory as the documents' own directories, because a run resumes only with the journal and working directory it started in. Each Planner session is diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 88efbe696..a4527e92b 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -75,7 +75,7 @@ each owner's GitHub App user token and does not consume `HARNESS_AUTH`; setting `auto` on loopback passes the startup guard but does not enable host login in that adapter. -`HARNESS=atomic` runs Atomic 0.9.40 in the Chopin server process through its +`HARNESS=atomic` runs Atomic 0.9.41-alpha.1 in the Chopin server process through its headless SDK (`createAgentSession()`). It does not spawn the `atomic` CLI or use RPC mode. diff --git a/package.json b/package.json index 40425f8fb..3914df95c 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@ai-sdk/harness": "1.0.146", "@ai-sdk/mcp": "2.0.72", "@ai-sdk/sandbox-just-bash": "1.0.146", - "@bastani/atomic": "0.9.40", + "@bastani/atomic": "0.9.41-alpha.1", "ai": "7.0.135", "typebox": "1.3.7", "zod": "4.4.3"