@kontourai/flow-agents 3.7.0 → 3.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/build/src/builder-flow-runtime.js +118 -17
- package/build/src/cli/assignment-provider.js +13 -1
- package/build/src/cli/continuation-adapter.d.ts +24 -0
- package/build/src/cli/continuation-adapter.js +225 -0
- package/build/src/cli/workflow-sidecar.js +29 -11
- package/build/src/cli/workflow.js +67 -11
- package/build/src/continuation-driver.d.ts +92 -0
- package/build/src/continuation-driver.js +444 -0
- package/build/src/index.d.ts +2 -0
- package/build/src/index.js +1 -0
- package/build/src/tools/build-universal-bundles.js +53 -3
- package/docs/getting-started.md +9 -1
- package/docs/public-workflow-cli.md +54 -0
- package/docs/spec/builder-flow-runtime.md +36 -2
- package/docs/workflow-usage-guide.md +7 -0
- package/evals/integration/test_builder_step_producers.sh +37 -4
- package/evals/integration/test_bundle_install.sh +108 -4
- package/evals/integration/test_bundle_lifecycle.sh +4 -6
- package/evals/integration/test_install_merge.sh +18 -8
- package/evals/integration/test_public_workflow_cli.sh +11 -5
- package/evals/static/test_universal_bundles.sh +44 -1
- package/package.json +4 -4
- package/packaging/README.md +1 -1
- package/scripts/README.md +1 -1
- package/scripts/install-codex-home.sh +63 -23
- package/scripts/install-owned-files.js +18 -2
- package/src/builder-flow-runtime.ts +108 -10
- package/src/cli/assignment-provider.ts +13 -1
- package/src/cli/builder-flow-runtime.test.mjs +378 -20
- package/src/cli/continuation-adapter.ts +239 -0
- package/src/cli/continuation-driver.test.mjs +564 -0
- package/src/cli/workflow-sidecar.ts +27 -11
- package/src/cli/workflow.ts +68 -11
- package/src/continuation-driver.ts +532 -0
- package/src/index.ts +20 -0
- package/src/tools/build-universal-bundles.ts +51 -3
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
import { createFileContinuationStore, runContinuationDriver, withContinuationDriverLock } from "../../build/src/continuation-driver.js";
|
|
8
|
+
import { executeContinuationAdapter, executeLoadedContinuationAdapter, loadContinuationAdapterCommand, waitForContinuationBarrier } from "../../build/src/cli/continuation-adapter.js";
|
|
9
|
+
|
|
10
|
+
function snapshot(step, status = "active") {
|
|
11
|
+
const disposition = status === "completed" || status === "canceled"
|
|
12
|
+
? "done"
|
|
13
|
+
: status === "failed"
|
|
14
|
+
? "failed"
|
|
15
|
+
: status === "paused" || status === "needs_decision"
|
|
16
|
+
? "waiting"
|
|
17
|
+
: "continue";
|
|
18
|
+
return {
|
|
19
|
+
run_id: "run-251",
|
|
20
|
+
definition_id: "builder.build",
|
|
21
|
+
status,
|
|
22
|
+
disposition,
|
|
23
|
+
current_step: step,
|
|
24
|
+
next_action: status === "active"
|
|
25
|
+
? { status: "continue", summary: `Execute ${step}`, skills: [`skill-${step}`], operations: [] }
|
|
26
|
+
: { status: "done", summary: "Workflow complete" },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function memoryStore(initial = null) {
|
|
31
|
+
let value = initial;
|
|
32
|
+
const events = [];
|
|
33
|
+
return {
|
|
34
|
+
load: () => value,
|
|
35
|
+
save: (next) => { value = structuredClone(next); },
|
|
36
|
+
append: (event) => { events.push(structuredClone(event)); },
|
|
37
|
+
value: () => value,
|
|
38
|
+
events,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test("driver advances multiple canonical Flow steps without a human continuation", async () => {
|
|
43
|
+
const states = [snapshot("plan"), snapshot("execute"), snapshot("done", "completed")];
|
|
44
|
+
let index = 0;
|
|
45
|
+
const requests = [];
|
|
46
|
+
const store = memoryStore();
|
|
47
|
+
|
|
48
|
+
const result = await runContinuationDriver({
|
|
49
|
+
maxTurns: 4,
|
|
50
|
+
store,
|
|
51
|
+
runtime: {
|
|
52
|
+
inspect: async () => states[index],
|
|
53
|
+
synchronize: async () => states[index],
|
|
54
|
+
execute: async (request) => {
|
|
55
|
+
requests.push(request);
|
|
56
|
+
index += 1;
|
|
57
|
+
return { status: "completed", summary: "turn complete" };
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
assert.equal(result.outcome, "done");
|
|
63
|
+
assert.equal(result.turns_started, 2);
|
|
64
|
+
assert.deepEqual(requests.map((request) => request.current_step), ["plan", "execute"]);
|
|
65
|
+
assert.deepEqual(requests.map((request) => request.next_action.skills), [["skill-plan"], ["skill-execute"]]);
|
|
66
|
+
assert.equal(requests.some((request) => Object.hasOwn(request, "system_prompt")), false);
|
|
67
|
+
assert.equal(store.value().status, "done");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("driver parks on a wait barrier without burning another turn and resumes durably", async () => {
|
|
71
|
+
const barrier = { kind: "deadline", at: "2026-07-12T12:00:00.000Z" };
|
|
72
|
+
const store = memoryStore();
|
|
73
|
+
let executeCalls = 0;
|
|
74
|
+
let ready = false;
|
|
75
|
+
const runtime = {
|
|
76
|
+
inspect: async () => snapshot("verify"),
|
|
77
|
+
synchronize: async () => ready ? snapshot("done", "completed") : snapshot("verify"),
|
|
78
|
+
execute: async () => {
|
|
79
|
+
executeCalls += 1;
|
|
80
|
+
return { status: "wait", barrier, summary: "waiting for CI" };
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const parked = await runContinuationDriver({
|
|
85
|
+
maxTurns: 3,
|
|
86
|
+
store,
|
|
87
|
+
runtime,
|
|
88
|
+
waitForBarrier: async () => "pending",
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
assert.equal(parked.outcome, "waiting");
|
|
92
|
+
assert.equal(parked.turns_started, 1);
|
|
93
|
+
assert.deepEqual(store.value().pending_barrier, barrier);
|
|
94
|
+
assert.equal(executeCalls, 1);
|
|
95
|
+
|
|
96
|
+
ready = true;
|
|
97
|
+
const resumed = await runContinuationDriver({
|
|
98
|
+
maxTurns: 3,
|
|
99
|
+
store,
|
|
100
|
+
runtime,
|
|
101
|
+
waitForBarrier: async () => "ready",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
assert.equal(resumed.outcome, "done");
|
|
105
|
+
assert.equal(resumed.turns_started, 1);
|
|
106
|
+
assert.equal(executeCalls, 1);
|
|
107
|
+
assert.equal(store.value().pending_barrier, null);
|
|
108
|
+
assert.ok(store.events.some((event) => event.type === "parked"));
|
|
109
|
+
assert.ok(store.events.some((event) => event.type === "resumed"));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("driver parks and resumes within one unattended invocation when its trigger fires", async () => {
|
|
113
|
+
const barrier = { kind: "deadline", at: "2026-07-12T12:00:01.000Z" };
|
|
114
|
+
const store = memoryStore();
|
|
115
|
+
let ready = false;
|
|
116
|
+
let executeCalls = 0;
|
|
117
|
+
const result = await runContinuationDriver({
|
|
118
|
+
maxTurns: 2,
|
|
119
|
+
store,
|
|
120
|
+
runtime: {
|
|
121
|
+
inspect: async () => snapshot("verify"),
|
|
122
|
+
synchronize: async () => ready ? snapshot("done", "completed") : snapshot("verify"),
|
|
123
|
+
execute: async () => {
|
|
124
|
+
executeCalls += 1;
|
|
125
|
+
return { status: "wait", barrier, summary: "waiting for trigger" };
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
waitForBarrier: async () => {
|
|
129
|
+
ready = true;
|
|
130
|
+
return "ready";
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
assert.equal(result.outcome, "done");
|
|
135
|
+
assert.equal(result.turns_started, 1);
|
|
136
|
+
assert.equal(executeCalls, 1);
|
|
137
|
+
assert.deepEqual(store.events.filter((event) => event.type === "parked" || event.type === "resumed").map((event) => event.type), ["parked", "resumed"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("driver stops cleanly when the mission turn budget is exhausted", async () => {
|
|
141
|
+
const store = memoryStore();
|
|
142
|
+
let executeCalls = 0;
|
|
143
|
+
const runtime = {
|
|
144
|
+
inspect: async () => snapshot("plan"),
|
|
145
|
+
synchronize: async () => snapshot("plan"),
|
|
146
|
+
execute: async () => {
|
|
147
|
+
executeCalls += 1;
|
|
148
|
+
return { status: "completed" };
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const first = await runContinuationDriver({ maxTurns: 2, store, runtime });
|
|
153
|
+
assert.equal(first.outcome, "budget_exhausted");
|
|
154
|
+
assert.equal(first.turns_started, 2);
|
|
155
|
+
assert.equal(executeCalls, 2);
|
|
156
|
+
|
|
157
|
+
const resumed = await runContinuationDriver({ maxTurns: 2, store, runtime });
|
|
158
|
+
assert.equal(resumed.outcome, "budget_exhausted");
|
|
159
|
+
assert.equal(resumed.turns_started, 2);
|
|
160
|
+
assert.equal(executeCalls, 2);
|
|
161
|
+
assert.equal(store.value().status, "budget_exhausted");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("driver refuses to change a persisted mission budget on resume", async () => {
|
|
165
|
+
const store = memoryStore();
|
|
166
|
+
const runtime = {
|
|
167
|
+
inspect: async () => snapshot("plan"),
|
|
168
|
+
synchronize: async () => snapshot("plan"),
|
|
169
|
+
execute: async () => ({ status: "completed" }),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
await runContinuationDriver({ maxTurns: 1, store, runtime });
|
|
173
|
+
await assert.rejects(
|
|
174
|
+
runContinuationDriver({ maxTurns: 2, store, runtime }),
|
|
175
|
+
/does not match the persisted mission budget 1/,
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("canonical completion clears a stale barrier without waiting", async () => {
|
|
180
|
+
const store = memoryStore({
|
|
181
|
+
schema_version: "1.0",
|
|
182
|
+
run_id: "run-251",
|
|
183
|
+
definition_id: "builder.build",
|
|
184
|
+
max_turns: 3,
|
|
185
|
+
adapter_command_identity: null,
|
|
186
|
+
status: "waiting",
|
|
187
|
+
turns_started: 1,
|
|
188
|
+
pending_barrier: { kind: "pid", pid: process.pid },
|
|
189
|
+
updated_at: "2026-07-12T12:00:00.000Z",
|
|
190
|
+
});
|
|
191
|
+
let waitCalls = 0;
|
|
192
|
+
|
|
193
|
+
const result = await runContinuationDriver({
|
|
194
|
+
maxTurns: 3,
|
|
195
|
+
store,
|
|
196
|
+
runtime: {
|
|
197
|
+
inspect: async () => snapshot("done", "completed"),
|
|
198
|
+
synchronize: async () => assert.fail("terminal inspection must not synchronize"),
|
|
199
|
+
execute: async () => assert.fail("terminal inspection must not execute"),
|
|
200
|
+
},
|
|
201
|
+
waitForBarrier: async () => {
|
|
202
|
+
waitCalls += 1;
|
|
203
|
+
return "pending";
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
assert.equal(result.outcome, "done");
|
|
208
|
+
assert.equal(waitCalls, 0);
|
|
209
|
+
assert.equal(store.value().pending_barrier, null);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("adapter completion cannot declare the workflow done while canonical Flow remains active", async () => {
|
|
213
|
+
const store = memoryStore();
|
|
214
|
+
const runtime = {
|
|
215
|
+
inspect: async () => snapshot("plan"),
|
|
216
|
+
synchronize: async () => snapshot("plan"),
|
|
217
|
+
execute: async () => ({ status: "completed", summary: "I am done" }),
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const result = await runContinuationDriver({ maxTurns: 1, store, runtime });
|
|
221
|
+
assert.equal(result.outcome, "budget_exhausted");
|
|
222
|
+
assert.equal(result.snapshot.status, "active");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("adapter errors fail open to another bounded turn and remain auditable", async () => {
|
|
226
|
+
const store = memoryStore();
|
|
227
|
+
let executeCalls = 0;
|
|
228
|
+
const result = await runContinuationDriver({
|
|
229
|
+
maxTurns: 2,
|
|
230
|
+
store,
|
|
231
|
+
runtime: {
|
|
232
|
+
inspect: async () => snapshot("plan"),
|
|
233
|
+
synchronize: async () => snapshot("plan"),
|
|
234
|
+
execute: async () => {
|
|
235
|
+
executeCalls += 1;
|
|
236
|
+
throw new Error("runtime unavailable");
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
assert.equal(result.outcome, "budget_exhausted");
|
|
242
|
+
assert.equal(executeCalls, 2);
|
|
243
|
+
assert.deepEqual(store.events.filter((event) => event.type === "turn_failed").map((event) => event.summary), [
|
|
244
|
+
"runtime unavailable",
|
|
245
|
+
"runtime unavailable",
|
|
246
|
+
]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("driver binds the adapter command identity for the mission", async () => {
|
|
250
|
+
const store = memoryStore();
|
|
251
|
+
const runtime = {
|
|
252
|
+
inspect: async () => snapshot("plan"),
|
|
253
|
+
synchronize: async () => snapshot("plan"),
|
|
254
|
+
execute: async () => ({ status: "completed" }),
|
|
255
|
+
};
|
|
256
|
+
await runContinuationDriver({ maxTurns: 1, adapterCommandIdentity: "adapter-a", store, runtime });
|
|
257
|
+
await assert.rejects(
|
|
258
|
+
runContinuationDriver({ maxTurns: 1, adapterCommandIdentity: "adapter-b", store, runtime }),
|
|
259
|
+
/adapter command identity does not match/,
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("driver honors adapter dispositions across canonical Flow statuses", async (t) => {
|
|
264
|
+
for (const [status, expected] of [
|
|
265
|
+
["blocked", "budget_exhausted"],
|
|
266
|
+
["needs_decision", "waiting"],
|
|
267
|
+
["paused", "waiting"],
|
|
268
|
+
["canceled", "done"],
|
|
269
|
+
["completed", "done"],
|
|
270
|
+
["accepted_by_exception", "budget_exhausted"],
|
|
271
|
+
["failed", "failed"],
|
|
272
|
+
]) {
|
|
273
|
+
await t.test(status, async () => {
|
|
274
|
+
let executeCalls = 0;
|
|
275
|
+
const result = await runContinuationDriver({
|
|
276
|
+
maxTurns: 1,
|
|
277
|
+
store: memoryStore(),
|
|
278
|
+
runtime: {
|
|
279
|
+
inspect: async () => snapshot("plan", status),
|
|
280
|
+
synchronize: async () => snapshot("plan", status),
|
|
281
|
+
execute: async () => { executeCalls += 1; return { status: "completed" }; },
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
assert.equal(result.outcome, expected);
|
|
285
|
+
assert.equal(executeCalls, expected === "budget_exhausted" ? 1 : 0);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("driver reauthorizes immediately before each adapter turn", async () => {
|
|
291
|
+
let authorizations = 0;
|
|
292
|
+
let executions = 0;
|
|
293
|
+
await assert.rejects(
|
|
294
|
+
runContinuationDriver({
|
|
295
|
+
maxTurns: 3,
|
|
296
|
+
store: memoryStore(),
|
|
297
|
+
authorizeTurn: async () => {
|
|
298
|
+
authorizations += 1;
|
|
299
|
+
if (authorizations === 2) throw new Error("assignment ownership changed");
|
|
300
|
+
},
|
|
301
|
+
runtime: {
|
|
302
|
+
inspect: async () => snapshot("plan"),
|
|
303
|
+
synchronize: async () => snapshot("plan"),
|
|
304
|
+
execute: async () => { executions += 1; return { status: "completed" }; },
|
|
305
|
+
},
|
|
306
|
+
}),
|
|
307
|
+
/assignment ownership changed/,
|
|
308
|
+
);
|
|
309
|
+
assert.equal(authorizations, 2);
|
|
310
|
+
assert.equal(executions, 1);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("explicit adapter argv receives one structured action without a shell", async (t) => {
|
|
314
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "continuation-adapter-"));
|
|
315
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
316
|
+
const adapter = path.join(root, "adapter.mjs");
|
|
317
|
+
const command = path.join(root, "command.json");
|
|
318
|
+
fs.writeFileSync(adapter, `
|
|
319
|
+
let input = "";
|
|
320
|
+
for await (const chunk of process.stdin) input += chunk;
|
|
321
|
+
const request = JSON.parse(input);
|
|
322
|
+
process.stdout.write(JSON.stringify({ status: "completed", summary: process.cwd() + "|" + request.current_step }));
|
|
323
|
+
`);
|
|
324
|
+
fs.writeFileSync(command, JSON.stringify({ argv: [process.execPath, adapter] }));
|
|
325
|
+
const result = await executeContinuationAdapter(command, {
|
|
326
|
+
schema_version: "1.0",
|
|
327
|
+
run_id: "run-251",
|
|
328
|
+
definition_id: "builder.build",
|
|
329
|
+
current_step: "plan",
|
|
330
|
+
iteration: 1,
|
|
331
|
+
max_turns: 3,
|
|
332
|
+
next_action: { status: "continue", skills: ["plan-work"] },
|
|
333
|
+
}, { cwd: root, timeoutMs: 5_000 });
|
|
334
|
+
|
|
335
|
+
assert.deepEqual(result, { status: "completed", summary: `${fs.realpathSync(root)}|plan` });
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("loaded adapter binds executable and script content for every turn", async (t) => {
|
|
339
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "continuation-integrity-"));
|
|
340
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
341
|
+
const adapter = path.join(root, "adapter.mjs");
|
|
342
|
+
const command = path.join(root, "command.json");
|
|
343
|
+
fs.writeFileSync(adapter, 'process.stdout.write(JSON.stringify({ status: "completed" }));\n');
|
|
344
|
+
fs.writeFileSync(command, JSON.stringify({ argv: [process.execPath, adapter] }));
|
|
345
|
+
const loaded = loadContinuationAdapterCommand(command);
|
|
346
|
+
fs.writeFileSync(adapter, 'process.stdout.write(JSON.stringify({ status: "wait" }));\n');
|
|
347
|
+
|
|
348
|
+
await assert.rejects(
|
|
349
|
+
executeLoadedContinuationAdapter(loaded, {
|
|
350
|
+
schema_version: "1.0",
|
|
351
|
+
run_id: "run-251",
|
|
352
|
+
definition_id: "builder.build",
|
|
353
|
+
current_step: "execute",
|
|
354
|
+
iteration: 1,
|
|
355
|
+
max_turns: 3,
|
|
356
|
+
next_action: null,
|
|
357
|
+
}, { cwd: root, timeoutMs: 5_000 }),
|
|
358
|
+
/integrity changed after mission binding/,
|
|
359
|
+
);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("adapter integrity hashes raw file bytes", async (t) => {
|
|
363
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "continuation-binary-integrity-"));
|
|
364
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
365
|
+
const adapter = path.join(root, "adapter.mjs");
|
|
366
|
+
const data = path.join(root, "binary.dat");
|
|
367
|
+
const command = path.join(root, "command.json");
|
|
368
|
+
fs.writeFileSync(adapter, 'process.stdout.write(JSON.stringify({ status: "completed" }));\n');
|
|
369
|
+
fs.writeFileSync(data, Buffer.from([0x80]));
|
|
370
|
+
fs.writeFileSync(command, JSON.stringify({ argv: [process.execPath, adapter, data] }));
|
|
371
|
+
const loaded = loadContinuationAdapterCommand(command);
|
|
372
|
+
fs.writeFileSync(data, Buffer.from([0x81]));
|
|
373
|
+
|
|
374
|
+
await assert.rejects(
|
|
375
|
+
executeLoadedContinuationAdapter(loaded, {
|
|
376
|
+
schema_version: "1.0",
|
|
377
|
+
run_id: "run-251",
|
|
378
|
+
definition_id: "builder.build",
|
|
379
|
+
current_step: "execute",
|
|
380
|
+
iteration: 1,
|
|
381
|
+
max_turns: 3,
|
|
382
|
+
next_action: null,
|
|
383
|
+
}, { cwd: root, timeoutMs: 5_000 }),
|
|
384
|
+
/integrity changed after mission binding/,
|
|
385
|
+
);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("adapter timeout terminates its process group", { skip: process.platform === "win32" }, async (t) => {
|
|
389
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "continuation-timeout-"));
|
|
390
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
391
|
+
const adapter = path.join(root, "adapter.mjs");
|
|
392
|
+
const command = path.join(root, "command.json");
|
|
393
|
+
const marker = path.join(root, "orphaned-child-ran");
|
|
394
|
+
fs.writeFileSync(adapter, `
|
|
395
|
+
import { spawn } from "node:child_process";
|
|
396
|
+
spawn(process.execPath, ["-e", ${JSON.stringify(`setTimeout(() => require("node:fs").writeFileSync(${JSON.stringify(marker)}, "ran"), 300)`) }], { stdio: "ignore" });
|
|
397
|
+
setInterval(() => {}, 1000);
|
|
398
|
+
`);
|
|
399
|
+
fs.writeFileSync(command, JSON.stringify({ argv: [process.execPath, adapter] }));
|
|
400
|
+
|
|
401
|
+
await assert.rejects(
|
|
402
|
+
executeContinuationAdapter(command, {
|
|
403
|
+
schema_version: "1.0",
|
|
404
|
+
run_id: "run-251",
|
|
405
|
+
definition_id: "builder.build",
|
|
406
|
+
current_step: "execute",
|
|
407
|
+
iteration: 1,
|
|
408
|
+
max_turns: 3,
|
|
409
|
+
next_action: null,
|
|
410
|
+
}, { cwd: root, timeoutMs: 50 }),
|
|
411
|
+
/timed out after 50ms/,
|
|
412
|
+
);
|
|
413
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
414
|
+
assert.equal(fs.existsSync(marker), false);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("completed adapter forcibly terminates background descendants", { skip: process.platform === "win32" }, async (t) => {
|
|
418
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "continuation-completed-child-"));
|
|
419
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
420
|
+
const adapter = path.join(root, "adapter.mjs");
|
|
421
|
+
const command = path.join(root, "command.json");
|
|
422
|
+
const marker = path.join(root, "background-child-ran");
|
|
423
|
+
const childSource = `
|
|
424
|
+
process.on("SIGTERM", () => {});
|
|
425
|
+
setTimeout(() => require("node:fs").writeFileSync(${JSON.stringify(marker)}, "ran"), 600);
|
|
426
|
+
setInterval(() => {}, 1000);
|
|
427
|
+
`;
|
|
428
|
+
fs.writeFileSync(adapter, `
|
|
429
|
+
import { spawn } from "node:child_process";
|
|
430
|
+
spawn(process.execPath, ["-e", ${JSON.stringify(childSource)}], { stdio: "ignore" }).unref();
|
|
431
|
+
process.stdout.write(JSON.stringify({ status: "completed" }));
|
|
432
|
+
`);
|
|
433
|
+
fs.writeFileSync(command, JSON.stringify({ argv: [process.execPath, adapter] }));
|
|
434
|
+
|
|
435
|
+
await executeContinuationAdapter(command, {
|
|
436
|
+
schema_version: "1.0",
|
|
437
|
+
run_id: "run-251",
|
|
438
|
+
definition_id: "builder.build",
|
|
439
|
+
current_step: "execute",
|
|
440
|
+
iteration: 1,
|
|
441
|
+
max_turns: 3,
|
|
442
|
+
next_action: null,
|
|
443
|
+
}, { cwd: root, timeoutMs: 5_000 });
|
|
444
|
+
await new Promise((resolve) => setTimeout(resolve, 750));
|
|
445
|
+
assert.equal(fs.existsSync(marker), false);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("deadline and pid barriers report ready or pending without consuming turns", async () => {
|
|
449
|
+
let clock = Date.parse("2026-07-12T12:00:00.000Z");
|
|
450
|
+
const deadline = await waitForContinuationBarrier(
|
|
451
|
+
{ kind: "deadline", at: "2026-07-12T12:00:01.000Z" },
|
|
452
|
+
{ maxWaitMs: 1_000, pollMs: 10, now: () => clock, sleep: async (ms) => { clock += ms; } },
|
|
453
|
+
);
|
|
454
|
+
assert.equal(deadline, "ready");
|
|
455
|
+
|
|
456
|
+
const alive = await waitForContinuationBarrier(
|
|
457
|
+
{ kind: "pid", pid: process.pid },
|
|
458
|
+
{ maxWaitMs: 0, pollMs: 10 },
|
|
459
|
+
);
|
|
460
|
+
assert.equal(alive, "pending");
|
|
461
|
+
|
|
462
|
+
const gone = await waitForContinuationBarrier(
|
|
463
|
+
{ kind: "pid", pid: 2_147_483_647 },
|
|
464
|
+
{ maxWaitMs: 0, pollMs: 10 },
|
|
465
|
+
);
|
|
466
|
+
assert.equal(gone, "ready");
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("file continuation store refuses a symlinked state target", (t) => {
|
|
470
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "continuation-store-"));
|
|
471
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
472
|
+
const store = createFileContinuationStore(root);
|
|
473
|
+
const outside = path.join(root, "outside.json");
|
|
474
|
+
fs.writeFileSync(outside, "{}\n");
|
|
475
|
+
fs.symlinkSync(outside, path.join(root, "continuation-driver", "state.json"));
|
|
476
|
+
assert.throws(() => store.load(), /must be a regular file/);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("file continuation store detects deleted and rolled-back mission state", (t) => {
|
|
480
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "continuation-rollback-"));
|
|
481
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
482
|
+
const store = createFileContinuationStore(root);
|
|
483
|
+
store.save({
|
|
484
|
+
schema_version: "1.0",
|
|
485
|
+
run_id: "run-251",
|
|
486
|
+
definition_id: "builder.build",
|
|
487
|
+
max_turns: 3,
|
|
488
|
+
adapter_command_identity: null,
|
|
489
|
+
status: "active",
|
|
490
|
+
turns_started: 1,
|
|
491
|
+
pending_barrier: null,
|
|
492
|
+
updated_at: "2026-07-12T12:00:00.000Z",
|
|
493
|
+
});
|
|
494
|
+
store.append({
|
|
495
|
+
schema_version: "1.0",
|
|
496
|
+
type: "turn_started",
|
|
497
|
+
run_id: "run-251",
|
|
498
|
+
definition_id: "builder.build",
|
|
499
|
+
current_step: "plan",
|
|
500
|
+
turns_started: 1,
|
|
501
|
+
at: "2026-07-12T12:00:00.000Z",
|
|
502
|
+
});
|
|
503
|
+
const stateFile = path.join(root, "continuation-driver", "state.json");
|
|
504
|
+
fs.unlinkSync(stateFile);
|
|
505
|
+
assert.throws(() => store.load(), /state is missing while mission events exist/);
|
|
506
|
+
|
|
507
|
+
store.save({
|
|
508
|
+
schema_version: "1.0",
|
|
509
|
+
run_id: "run-251",
|
|
510
|
+
definition_id: "builder.build",
|
|
511
|
+
max_turns: 3,
|
|
512
|
+
adapter_command_identity: null,
|
|
513
|
+
status: "active",
|
|
514
|
+
turns_started: 0,
|
|
515
|
+
pending_barrier: null,
|
|
516
|
+
updated_at: "2026-07-12T12:00:00.000Z",
|
|
517
|
+
});
|
|
518
|
+
assert.throws(() => store.load(), /rolled back behind its event history/);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("file continuation store restores a parked barrier from mission history", (t) => {
|
|
522
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "continuation-barrier-rollback-"));
|
|
523
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
524
|
+
const store = createFileContinuationStore(root);
|
|
525
|
+
const barrier = { kind: "pid", pid: process.pid };
|
|
526
|
+
store.save({
|
|
527
|
+
schema_version: "1.0",
|
|
528
|
+
run_id: "run-251",
|
|
529
|
+
definition_id: "builder.build",
|
|
530
|
+
max_turns: 3,
|
|
531
|
+
adapter_command_identity: null,
|
|
532
|
+
status: "active",
|
|
533
|
+
turns_started: 1,
|
|
534
|
+
pending_barrier: null,
|
|
535
|
+
updated_at: "2026-07-12T12:00:00.000Z",
|
|
536
|
+
});
|
|
537
|
+
store.append({ schema_version: "1.0", type: "turn_started", run_id: "run-251", definition_id: "builder.build", current_step: "verify", turns_started: 1, at: "2026-07-12T12:00:00.000Z" });
|
|
538
|
+
store.append({ schema_version: "1.0", type: "parked", run_id: "run-251", definition_id: "builder.build", current_step: "verify", turns_started: 1, at: "2026-07-12T12:00:01.000Z", barrier });
|
|
539
|
+
|
|
540
|
+
const restored = store.load();
|
|
541
|
+
assert.equal(restored.status, "waiting");
|
|
542
|
+
assert.deepEqual(restored.pending_barrier, barrier);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test("session lock rejects concurrent drivers and reclaims a dead owner", async (t) => {
|
|
546
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "continuation-lock-"));
|
|
547
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
548
|
+
|
|
549
|
+
await withContinuationDriverLock(root, async () => {
|
|
550
|
+
await assert.rejects(
|
|
551
|
+
withContinuationDriverLock(root, async () => assert.fail("concurrent driver must not run")),
|
|
552
|
+
/already running under pid/,
|
|
553
|
+
);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
const locksDir = path.join(root, "continuation-driver", "locks");
|
|
557
|
+
const lockFile = path.join(locksDir, "dead.lock");
|
|
558
|
+
fs.writeFileSync(lockFile, JSON.stringify({ schema_version: "1.0", pid: 2_147_483_647, token: "dead", created_at: "2026-07-12T12:00:00.000Z" }));
|
|
559
|
+
let ran = false;
|
|
560
|
+
await withContinuationDriverLock(root, async () => { ran = true; });
|
|
561
|
+
assert.equal(ran, true);
|
|
562
|
+
assert.equal(fs.existsSync(lockFile), false);
|
|
563
|
+
assert.deepEqual(fs.readdirSync(locksDir), []);
|
|
564
|
+
});
|