Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/server/src/harness/atomic/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
76 changes: 63 additions & 13 deletions apps/server/src/harness/atomic/full.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
registerFullPlanner,
resumeOwnedRuns,
runCards,
settleEndedRuns,
untilUnpaused,
workflowRuns,
} from "./full";
Expand Down Expand Up @@ -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);
});
};`,
);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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: "/",
Expand Down
24 changes: 24 additions & 0 deletions apps/server/src/harness/atomic/full.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,30 @@ export async function resumeOwnedRuns(workflows: SessionWorkflows): Promise<void
for (let run of await workflows.listRuns({ status: "paused" })) await workflows.resume(run.runId);
}

/**
* Waits until Atomic has written the ending of each run that finished, failed or
* was stopped to the workflow journal, so a session disposed right after a run
* ends never leaves it `running` there. Live, paused and blocked runs are not
* waited on; disposing the session stops them. A run whose ending could not be
* journaled is logged, and the session still goes.
*/
export async function settleEndedRuns(
workflows: Pick<SessionWorkflows, "waitForSettled">,
runs: PlannerRuns | undefined,
): Promise<void> {
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
Expand Down
24 changes: 12 additions & 12 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions docs/hosted-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading