@kontourai/flow-agents 3.8.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 +7 -0
- package/build/src/builder-flow-runtime.js +16 -10
- package/build/src/cli/continuation-adapter.d.ts +24 -0
- package/build/src/cli/continuation-adapter.js +225 -0
- package/build/src/cli/workflow.js +47 -1
- 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/docs/public-workflow-cli.md +54 -0
- package/docs/workflow-usage-guide.md +7 -0
- package/package.json +4 -4
- package/src/builder-flow-runtime.ts +9 -3
- package/src/cli/builder-flow-runtime.test.mjs +128 -2
- package/src/cli/continuation-adapter.ts +239 -0
- package/src/cli/continuation-driver.test.mjs +564 -0
- package/src/cli/workflow.ts +48 -1
- package/src/continuation-driver.ts +532 -0
- package/src/index.ts +20 -0
|
@@ -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
|
+
});
|
package/src/cli/workflow.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { validateDefinition } from "@kontourai/flow";
|
|
8
8
|
import { loadBuilderFlowRun } from "../builder-flow-run-adapter.js";
|
|
9
|
+
import { driveBuilderFlowSession, withContinuationDriverLock } from "../continuation-driver.js";
|
|
9
10
|
import { inspectBuilderFlowSession, recoverBuilderFlowSession, syncBuilderFlowSession } from "../builder-flow-runtime.js";
|
|
10
11
|
import { flowAgentsPackageRoot, flowAgentsPackageVersion } from "../lib/package-version.js";
|
|
11
12
|
import { pinnedFlowAgentsCommand } from "../lib/pinned-cli-command.js";
|
|
@@ -14,6 +15,7 @@ import { flagBool, flagList, flagString, parseArgs } from "../lib/args.js";
|
|
|
14
15
|
import { main as builderRun } from "./builder-run.js";
|
|
15
16
|
import { currentWorkflowSessionDir, isMeaningfulTestCommand, mainFromPublicWorkflow, WORKFLOW_WRITER_CONTRACT_VERSION } from "./workflow-sidecar.js";
|
|
16
17
|
import { resolveCurrentAssignmentActor, withSubjectLock } from "./assignment-provider.js";
|
|
18
|
+
import { executeLoadedContinuationAdapter, loadContinuationAdapterCommand, waitForContinuationBarrier } from "./continuation-adapter.js";
|
|
17
19
|
|
|
18
20
|
type JsonRecord = Record<string, unknown>;
|
|
19
21
|
|
|
@@ -22,7 +24,7 @@ const PACKAGE_ROOT = flowAgentsPackageRoot();
|
|
|
22
24
|
const REQUIRE = createRequire(import.meta.url);
|
|
23
25
|
const PACKAGE_METADATA = readJsonFile(path.join(PACKAGE_ROOT, "package.json"), "Flow Agents package metadata");
|
|
24
26
|
const CLI_VERSION = flowAgentsPackageVersion();
|
|
25
|
-
const PUBLIC_VERBS = ["start", "status", "evidence", "critique", "pause", "resume", "release", "cancel", "archive", "doctor"] as const;
|
|
27
|
+
const PUBLIC_VERBS = ["start", "status", "evidence", "critique", "drive", "pause", "resume", "release", "cancel", "archive", "doctor"] as const;
|
|
26
28
|
|
|
27
29
|
function usage(): void {
|
|
28
30
|
console.log(`Usage: flow-agents workflow <verb> [options]
|
|
@@ -32,6 +34,7 @@ Public workflow verbs:
|
|
|
32
34
|
status Show the current canonical run and projected next action.
|
|
33
35
|
evidence Record evidence for the current Flow gate and synchronize it.
|
|
34
36
|
critique Record review critique directly into the current trust bundle.
|
|
37
|
+
drive Continue the canonical run through an explicit runtime adapter.
|
|
35
38
|
pause Pause the current run as its assignment actor.
|
|
36
39
|
resume Resume the current paused run as its assignment actor.
|
|
37
40
|
release Release the current assignment without canceling the run.
|
|
@@ -61,12 +64,41 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
61
64
|
if (verb === "status") return status(sessionDir, flagBool(parsed.flags, "json"));
|
|
62
65
|
if (verb === "evidence") return evidence(sessionDir, argv.slice(1), flagBool(parsed.flags, "json"));
|
|
63
66
|
if (verb === "critique") return critique(sessionDir, argv.slice(1), flagBool(parsed.flags, "json"));
|
|
67
|
+
if (verb === "drive") return drive(sessionDir, argv.slice(1), flagBool(parsed.flags, "json"));
|
|
64
68
|
|
|
65
69
|
const forwarded = stripPublicFlags(argv.slice(1), new Set(["artifact-root", "session-dir", "json"]));
|
|
66
70
|
if (verb === "release" && !flagString(parsed.flags, "reason")) throw new Error("workflow release requires --reason <text>");
|
|
67
71
|
return builderRun([verb === "release" ? "release-assignment" : verb, "--session-dir", sessionDir, ...forwarded]);
|
|
68
72
|
}
|
|
69
73
|
|
|
74
|
+
async function drive(sessionDir: string, argv: string[], json: boolean): Promise<number> {
|
|
75
|
+
const parsed = parseArgs(argv);
|
|
76
|
+
assertOnlyFlags(parsed.flags, new Set(["artifact-root", "session-dir", "json", "adapter-command-file", "max-turns", "turn-timeout-ms", "barrier-wait-ms", "barrier-poll-ms"]), "workflow drive");
|
|
77
|
+
const adapterCommandFile = flagString(parsed.flags, "adapter-command-file");
|
|
78
|
+
if (!adapterCommandFile) throw new Error("workflow drive requires --adapter-command-file <path>");
|
|
79
|
+
const maxTurns = integerFlag(parsed.flags, "max-turns", 4, 1, 100);
|
|
80
|
+
const turnTimeoutMs = integerFlag(parsed.flags, "turn-timeout-ms", 900_000, 1, 86_400_000);
|
|
81
|
+
const barrierWaitMs = integerFlag(parsed.flags, "barrier-wait-ms", 300_000, 0, 86_400_000);
|
|
82
|
+
const barrierPollMs = integerFlag(parsed.flags, "barrier-poll-ms", 1_000, 1, 60_000);
|
|
83
|
+
const { slug, projectRoot } = readBoundSession(sessionDir);
|
|
84
|
+
assertMatchingAssignmentActor(sessionDir, slug);
|
|
85
|
+
const adapterCommand = loadContinuationAdapterCommand(adapterCommandFile);
|
|
86
|
+
const result = await withContinuationDriverLock(sessionDir, async () => {
|
|
87
|
+
assertMatchingAssignmentActor(sessionDir, slug);
|
|
88
|
+
return driveBuilderFlowSession({
|
|
89
|
+
sessionDir,
|
|
90
|
+
maxTurns,
|
|
91
|
+
adapterCommandIdentity: adapterCommand.identity,
|
|
92
|
+
authorizeTurn: async () => { assertMatchingAssignmentActor(sessionDir, slug); },
|
|
93
|
+
execute: async (request) => executeLoadedContinuationAdapter(adapterCommand, request, { cwd: projectRoot, timeoutMs: turnTimeoutMs }),
|
|
94
|
+
waitForBarrier: async (barrier) => waitForContinuationBarrier(barrier, { maxWaitMs: barrierWaitMs, pollMs: barrierPollMs }),
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
if (json) console.log(JSON.stringify(result));
|
|
98
|
+
else console.log(`Continuation driver ${result.outcome} after ${result.turns_started} turn(s); canonical Flow is ${result.snapshot.status} at ${result.snapshot.current_step}.`);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
70
102
|
async function start(argv: string[]): Promise<number> {
|
|
71
103
|
const parsed = parseArgs(argv);
|
|
72
104
|
assertOnlyFlags(parsed.flags, new Set(["flow", "work-item", "task-slug", "artifact-root", "source-request", "summary", "title", "criterion", "assignment-provider", "effective-state-json"]), "workflow start");
|
|
@@ -570,6 +602,21 @@ function assertOnlyFlags(flags: ReturnType<typeof parseArgs>["flags"], allowed:
|
|
|
570
602
|
if (unsupported) throw new Error(`${command} does not support --${unsupported}`);
|
|
571
603
|
}
|
|
572
604
|
|
|
605
|
+
function integerFlag(
|
|
606
|
+
flags: ReturnType<typeof parseArgs>["flags"],
|
|
607
|
+
name: string,
|
|
608
|
+
fallback: number,
|
|
609
|
+
min: number,
|
|
610
|
+
max: number,
|
|
611
|
+
): number {
|
|
612
|
+
const raw = flagString(flags, name);
|
|
613
|
+
if (raw === undefined) return fallback;
|
|
614
|
+
if (!/^(0|[1-9][0-9]*)$/.test(raw)) throw new Error(`workflow drive --${name} must be an integer from ${min} through ${max}`);
|
|
615
|
+
const value = Number(raw);
|
|
616
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error(`workflow drive --${name} must be an integer from ${min} through ${max}`);
|
|
617
|
+
return value;
|
|
618
|
+
}
|
|
619
|
+
|
|
573
620
|
function isWithin(candidate: string, root: string): boolean {
|
|
574
621
|
const relative = path.relative(root, candidate);
|
|
575
622
|
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|