@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,444 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { inspectBuilderFlowSession, syncBuilderFlowSession } from "./builder-flow-runtime.js";
|
|
6
|
+
import { atomicWriteJson, ensureSafeDirectory } from "./lib/fs.js";
|
|
7
|
+
export async function runContinuationDriver(input) {
|
|
8
|
+
assertMaxTurns(input.maxTurns);
|
|
9
|
+
const now = input.now ?? (() => new Date());
|
|
10
|
+
const waitForBarrier = input.waitForBarrier ?? (async () => "pending");
|
|
11
|
+
const authorizeTurn = input.authorizeTurn ?? (async () => { });
|
|
12
|
+
const adapterCommandIdentity = input.adapterCommandIdentity ?? null;
|
|
13
|
+
let snapshot = validateSnapshot(await input.runtime.inspect());
|
|
14
|
+
let state = loadOrCreateState(input.store, snapshot, input.maxTurns, adapterCommandIdentity, now);
|
|
15
|
+
if (state.max_turns !== input.maxTurns)
|
|
16
|
+
throw new Error(`continuation maxTurns ${input.maxTurns} does not match the persisted mission budget ${state.max_turns}`);
|
|
17
|
+
if (state.adapter_command_identity !== adapterCommandIdentity)
|
|
18
|
+
throw new Error("continuation adapter command identity does not match the persisted mission adapter");
|
|
19
|
+
const inspectedOutcome = canonicalOutcome(snapshot);
|
|
20
|
+
if (inspectedOutcome === "done")
|
|
21
|
+
return finishDone(input.store, state, snapshot, now);
|
|
22
|
+
if (inspectedOutcome === "failed")
|
|
23
|
+
return finishFailed(input.store, state, snapshot, now);
|
|
24
|
+
if (state.pending_barrier) {
|
|
25
|
+
const barrier = state.pending_barrier;
|
|
26
|
+
const readiness = await waitForBarrier(barrier);
|
|
27
|
+
if (readiness === "pending") {
|
|
28
|
+
state = saveState(input.store, state, { status: "waiting" }, now);
|
|
29
|
+
return { outcome: "waiting", turns_started: state.turns_started, snapshot, barrier };
|
|
30
|
+
}
|
|
31
|
+
state = saveState(input.store, state, { status: "active", pending_barrier: null }, now);
|
|
32
|
+
appendEvent(input.store, state, snapshot, "resumed", now, { barrier });
|
|
33
|
+
}
|
|
34
|
+
snapshot = validateSnapshot(await input.runtime.synchronize());
|
|
35
|
+
assertMissionIdentity(state, snapshot);
|
|
36
|
+
const initialOutcome = canonicalOutcome(snapshot);
|
|
37
|
+
if (initialOutcome === "done")
|
|
38
|
+
return finishDone(input.store, state, snapshot, now);
|
|
39
|
+
if (initialOutcome === "failed")
|
|
40
|
+
return finishFailed(input.store, state, snapshot, now);
|
|
41
|
+
if (initialOutcome === "waiting") {
|
|
42
|
+
state = saveState(input.store, state, { status: "waiting" }, now);
|
|
43
|
+
return { outcome: "waiting", turns_started: state.turns_started, snapshot };
|
|
44
|
+
}
|
|
45
|
+
while (state.turns_started < input.maxTurns) {
|
|
46
|
+
await authorizeTurn();
|
|
47
|
+
const iteration = state.turns_started + 1;
|
|
48
|
+
state = saveState(input.store, state, { status: "active", turns_started: iteration }, now);
|
|
49
|
+
appendEvent(input.store, state, snapshot, "turn_started", now);
|
|
50
|
+
let result;
|
|
51
|
+
try {
|
|
52
|
+
result = validateTurnResult(await input.runtime.execute(Object.freeze({
|
|
53
|
+
schema_version: "1.0",
|
|
54
|
+
run_id: snapshot.run_id,
|
|
55
|
+
definition_id: snapshot.definition_id,
|
|
56
|
+
current_step: snapshot.current_step,
|
|
57
|
+
iteration,
|
|
58
|
+
max_turns: input.maxTurns,
|
|
59
|
+
next_action: snapshot.next_action ? structuredClone(snapshot.next_action) : null,
|
|
60
|
+
})));
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
appendEvent(input.store, state, snapshot, "turn_failed", now, { summary: boundedErrorMessage(error) });
|
|
64
|
+
snapshot = validateSnapshot(await input.runtime.synchronize());
|
|
65
|
+
assertMissionIdentity(state, snapshot);
|
|
66
|
+
const outcome = canonicalOutcome(snapshot);
|
|
67
|
+
if (outcome === "done")
|
|
68
|
+
return finishDone(input.store, state, snapshot, now);
|
|
69
|
+
if (outcome === "failed")
|
|
70
|
+
return finishFailed(input.store, state, snapshot, now);
|
|
71
|
+
if (outcome === "waiting") {
|
|
72
|
+
state = saveState(input.store, state, { status: "waiting" }, now);
|
|
73
|
+
return { outcome: "waiting", turns_started: state.turns_started, snapshot };
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
appendEvent(input.store, state, snapshot, "turn_completed", now, { summary: result.summary });
|
|
78
|
+
if (result.status === "wait") {
|
|
79
|
+
state = saveState(input.store, state, { status: "waiting", pending_barrier: result.barrier }, now);
|
|
80
|
+
appendEvent(input.store, state, snapshot, "parked", now, { barrier: result.barrier, summary: result.summary });
|
|
81
|
+
const readiness = await waitForBarrier(result.barrier);
|
|
82
|
+
if (readiness === "pending") {
|
|
83
|
+
return { outcome: "waiting", turns_started: state.turns_started, snapshot, barrier: result.barrier };
|
|
84
|
+
}
|
|
85
|
+
state = saveState(input.store, state, { status: "active", pending_barrier: null }, now);
|
|
86
|
+
appendEvent(input.store, state, snapshot, "resumed", now, { barrier: result.barrier });
|
|
87
|
+
}
|
|
88
|
+
snapshot = validateSnapshot(await input.runtime.synchronize());
|
|
89
|
+
assertMissionIdentity(state, snapshot);
|
|
90
|
+
const outcome = canonicalOutcome(snapshot);
|
|
91
|
+
if (outcome === "done")
|
|
92
|
+
return finishDone(input.store, state, snapshot, now);
|
|
93
|
+
if (outcome === "failed")
|
|
94
|
+
return finishFailed(input.store, state, snapshot, now);
|
|
95
|
+
if (outcome === "waiting") {
|
|
96
|
+
state = saveState(input.store, state, { status: "waiting" }, now);
|
|
97
|
+
return { outcome: "waiting", turns_started: state.turns_started, snapshot };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
state = saveState(input.store, state, { status: "budget_exhausted" }, now);
|
|
101
|
+
appendEvent(input.store, state, snapshot, "budget_exhausted", now);
|
|
102
|
+
return { outcome: "budget_exhausted", turns_started: state.turns_started, snapshot };
|
|
103
|
+
}
|
|
104
|
+
export async function driveBuilderFlowSession(input) {
|
|
105
|
+
const sessionDir = path.resolve(input.sessionDir);
|
|
106
|
+
const runtime = {
|
|
107
|
+
inspect: async () => builderSessionSnapshot(await inspectBuilderFlowSession({ sessionDir })),
|
|
108
|
+
synchronize: async () => builderSessionSnapshot(await syncBuilderFlowSession({ sessionDir })),
|
|
109
|
+
execute: input.execute,
|
|
110
|
+
};
|
|
111
|
+
return runContinuationDriver({
|
|
112
|
+
maxTurns: input.maxTurns,
|
|
113
|
+
...(input.adapterCommandIdentity ? { adapterCommandIdentity: input.adapterCommandIdentity } : {}),
|
|
114
|
+
runtime,
|
|
115
|
+
store: createFileContinuationStore(sessionDir),
|
|
116
|
+
...(input.waitForBarrier ? { waitForBarrier: input.waitForBarrier } : {}),
|
|
117
|
+
...(input.authorizeTurn ? { authorizeTurn: input.authorizeTurn } : {}),
|
|
118
|
+
...(input.now ? { now: input.now } : {}),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
export async function withContinuationDriverLock(sessionDirInput, body) {
|
|
122
|
+
const sessionDir = path.resolve(sessionDirInput);
|
|
123
|
+
const driverDir = path.join(sessionDir, "continuation-driver");
|
|
124
|
+
const locksDir = path.join(driverDir, "locks");
|
|
125
|
+
ensureSafeDirectory(sessionDir, locksDir);
|
|
126
|
+
const token = randomUUID();
|
|
127
|
+
const lockFile = path.join(locksDir, `${process.pid}-${token}.lock`);
|
|
128
|
+
const owner = { schema_version: "1.0", pid: process.pid, token, created_at: new Date().toISOString() };
|
|
129
|
+
acquireDriverLock(locksDir, lockFile, owner);
|
|
130
|
+
try {
|
|
131
|
+
return await body();
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
releaseDriverLock(lockFile, token);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export function createFileContinuationStore(sessionDirInput) {
|
|
138
|
+
const sessionDir = path.resolve(sessionDirInput);
|
|
139
|
+
const driverDir = path.join(sessionDir, "continuation-driver");
|
|
140
|
+
const stateFile = path.join(driverDir, "state.json");
|
|
141
|
+
const eventsFile = path.join(driverDir, "events.jsonl");
|
|
142
|
+
ensureSafeDirectory(sessionDir, driverDir);
|
|
143
|
+
return {
|
|
144
|
+
load() {
|
|
145
|
+
if (!fs.existsSync(stateFile)) {
|
|
146
|
+
if (fs.existsSync(eventsFile) && fs.statSync(eventsFile).size > 0)
|
|
147
|
+
throw new Error("continuation driver state is missing while mission events exist");
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
const state = JSON.parse(readRegularFileNoFollow(stateFile, "continuation driver state"));
|
|
151
|
+
reconcileStateWithEvents(state, eventsFile);
|
|
152
|
+
return state;
|
|
153
|
+
},
|
|
154
|
+
save(state) {
|
|
155
|
+
atomicWriteJson(sessionDir, stateFile, state);
|
|
156
|
+
},
|
|
157
|
+
append(event) {
|
|
158
|
+
appendJsonLineNoFollow(sessionDir, eventsFile, event);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function builderSessionSnapshot(result) {
|
|
163
|
+
const nextAction = result.projection.next_action;
|
|
164
|
+
return validateSnapshot({
|
|
165
|
+
run_id: result.run.runId,
|
|
166
|
+
definition_id: result.run.definitionId,
|
|
167
|
+
status: result.run.state.status,
|
|
168
|
+
disposition: builderContinuationDisposition(result.projection.next_action),
|
|
169
|
+
current_step: result.run.state.current_step,
|
|
170
|
+
next_action: nextAction && typeof nextAction === "object" && !Array.isArray(nextAction)
|
|
171
|
+
? structuredClone(nextAction)
|
|
172
|
+
: null,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
function appendJsonLineNoFollow(root, file, value) {
|
|
176
|
+
ensureSafeDirectory(root, path.dirname(file));
|
|
177
|
+
if (fs.existsSync(file))
|
|
178
|
+
assertRegularFile(file, "continuation driver event log");
|
|
179
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
|
|
180
|
+
const fd = fs.openSync(file, fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT | noFollow, 0o600);
|
|
181
|
+
try {
|
|
182
|
+
fs.writeFileSync(fd, `${JSON.stringify(value)}\n`, "utf8");
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
fs.closeSync(fd);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function assertRegularFile(file, label) {
|
|
189
|
+
const stat = fs.lstatSync(file);
|
|
190
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
191
|
+
throw new Error(`${label} must be a regular file`);
|
|
192
|
+
}
|
|
193
|
+
function readRegularFileNoFollow(file, label) {
|
|
194
|
+
assertRegularFile(file, label);
|
|
195
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
|
|
196
|
+
const fd = fs.openSync(file, fsConstants.O_RDONLY | noFollow);
|
|
197
|
+
try {
|
|
198
|
+
if (!fs.fstatSync(fd).isFile())
|
|
199
|
+
throw new Error(`${label} must be a regular file`);
|
|
200
|
+
return fs.readFileSync(fd, "utf8");
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
fs.closeSync(fd);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function acquireDriverLock(locksDir, lockFile, owner) {
|
|
207
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
|
|
208
|
+
const fd = fs.openSync(lockFile, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, 0o600);
|
|
209
|
+
try {
|
|
210
|
+
fs.writeFileSync(fd, `${JSON.stringify(owner)}\n`, "utf8");
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
fs.closeSync(fd);
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
for (const name of fs.readdirSync(locksDir).sort()) {
|
|
217
|
+
const candidate = path.join(locksDir, name);
|
|
218
|
+
if (candidate === lockFile)
|
|
219
|
+
continue;
|
|
220
|
+
let existing;
|
|
221
|
+
try {
|
|
222
|
+
existing = JSON.parse(readRegularFileNoFollow(candidate, "continuation driver lock"));
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
if (error.code === "ENOENT")
|
|
226
|
+
continue;
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
if (existing.schema_version !== "1.0" || typeof existing.pid !== "number" || !Number.isSafeInteger(existing.pid) || existing.pid <= 0 || typeof existing.token !== "string") {
|
|
230
|
+
throw new Error("continuation driver lock is invalid");
|
|
231
|
+
}
|
|
232
|
+
if (processAlive(existing.pid))
|
|
233
|
+
throw new Error(`continuation driver is already running under pid ${existing.pid}`);
|
|
234
|
+
try {
|
|
235
|
+
fs.unlinkSync(candidate);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
if (error.code !== "ENOENT")
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
try {
|
|
245
|
+
fs.unlinkSync(lockFile);
|
|
246
|
+
}
|
|
247
|
+
catch { }
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function releaseDriverLock(lockFile, token) {
|
|
252
|
+
try {
|
|
253
|
+
const owner = JSON.parse(readRegularFileNoFollow(lockFile, "continuation driver lock"));
|
|
254
|
+
if (owner.token === token)
|
|
255
|
+
fs.unlinkSync(lockFile);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (error.code !== "ENOENT")
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function loadOrCreateState(store, snapshot, maxTurns, adapterCommandIdentity, now) {
|
|
263
|
+
const existing = store.load();
|
|
264
|
+
if (existing) {
|
|
265
|
+
validateState(existing);
|
|
266
|
+
assertMissionIdentity(existing, snapshot);
|
|
267
|
+
return existing;
|
|
268
|
+
}
|
|
269
|
+
const state = {
|
|
270
|
+
schema_version: "1.0",
|
|
271
|
+
run_id: snapshot.run_id,
|
|
272
|
+
definition_id: snapshot.definition_id,
|
|
273
|
+
max_turns: maxTurns,
|
|
274
|
+
adapter_command_identity: adapterCommandIdentity,
|
|
275
|
+
status: "active",
|
|
276
|
+
turns_started: 0,
|
|
277
|
+
pending_barrier: null,
|
|
278
|
+
updated_at: now().toISOString(),
|
|
279
|
+
};
|
|
280
|
+
store.save(state);
|
|
281
|
+
appendEvent(store, state, snapshot, "started", now);
|
|
282
|
+
return state;
|
|
283
|
+
}
|
|
284
|
+
function canonicalOutcome(snapshot) {
|
|
285
|
+
return snapshot.disposition;
|
|
286
|
+
}
|
|
287
|
+
function finishDone(store, state, snapshot, now) {
|
|
288
|
+
const done = saveState(store, state, { status: "done", pending_barrier: null }, now);
|
|
289
|
+
appendEvent(store, done, snapshot, "done", now);
|
|
290
|
+
return { outcome: "done", turns_started: done.turns_started, snapshot };
|
|
291
|
+
}
|
|
292
|
+
function finishFailed(store, state, snapshot, now) {
|
|
293
|
+
const failed = saveState(store, state, { status: "failed", pending_barrier: null }, now);
|
|
294
|
+
return { outcome: "failed", turns_started: failed.turns_started, snapshot };
|
|
295
|
+
}
|
|
296
|
+
function saveState(store, current, patch, now) {
|
|
297
|
+
const next = { ...current, ...patch, updated_at: now().toISOString() };
|
|
298
|
+
store.save(next);
|
|
299
|
+
return next;
|
|
300
|
+
}
|
|
301
|
+
function appendEvent(store, state, snapshot, type, now, extra = {}) {
|
|
302
|
+
store.append({
|
|
303
|
+
schema_version: "1.0",
|
|
304
|
+
type,
|
|
305
|
+
run_id: state.run_id,
|
|
306
|
+
definition_id: state.definition_id,
|
|
307
|
+
current_step: snapshot.current_step,
|
|
308
|
+
turns_started: state.turns_started,
|
|
309
|
+
at: now().toISOString(),
|
|
310
|
+
...(extra.barrier ? { barrier: extra.barrier } : {}),
|
|
311
|
+
...(extra.summary ? { summary: extra.summary } : {}),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function validateSnapshot(value) {
|
|
315
|
+
if (!value || typeof value !== "object")
|
|
316
|
+
throw new Error("continuation runtime returned an invalid canonical snapshot");
|
|
317
|
+
for (const field of ["run_id", "definition_id", "status", "current_step"]) {
|
|
318
|
+
if (typeof value[field] !== "string" || value[field].length === 0)
|
|
319
|
+
throw new Error(`continuation snapshot ${field} must be a non-empty string`);
|
|
320
|
+
}
|
|
321
|
+
if (!new Set(["continue", "waiting", "done", "failed"]).has(value.disposition))
|
|
322
|
+
throw new Error("continuation snapshot disposition is invalid");
|
|
323
|
+
if (value.next_action !== null && (typeof value.next_action !== "object" || Array.isArray(value.next_action))) {
|
|
324
|
+
throw new Error("continuation snapshot next_action must be an object or null");
|
|
325
|
+
}
|
|
326
|
+
return structuredClone(value);
|
|
327
|
+
}
|
|
328
|
+
function validateTurnResult(value) {
|
|
329
|
+
if (!value || typeof value !== "object" || (value.status !== "completed" && value.status !== "wait")) {
|
|
330
|
+
throw new Error("continuation adapter must return status completed or wait");
|
|
331
|
+
}
|
|
332
|
+
if (value.status === "wait")
|
|
333
|
+
validateBarrier(value.barrier);
|
|
334
|
+
if (value.summary !== undefined && typeof value.summary !== "string")
|
|
335
|
+
throw new Error("continuation adapter summary must be a string");
|
|
336
|
+
const copy = structuredClone(value);
|
|
337
|
+
if (copy.summary && copy.summary.length > 2_000)
|
|
338
|
+
copy.summary = `${copy.summary.slice(0, 1_997)}...`;
|
|
339
|
+
return copy;
|
|
340
|
+
}
|
|
341
|
+
function validateBarrier(barrier) {
|
|
342
|
+
if (!barrier || typeof barrier !== "object")
|
|
343
|
+
throw new Error("continuation wait result requires a barrier");
|
|
344
|
+
if (barrier.kind === "pid") {
|
|
345
|
+
if (!Number.isSafeInteger(barrier.pid) || barrier.pid <= 0)
|
|
346
|
+
throw new Error("continuation pid barrier requires a positive integer pid");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (barrier.kind === "deadline") {
|
|
350
|
+
if (typeof barrier.at !== "string" || !Number.isFinite(Date.parse(barrier.at)))
|
|
351
|
+
throw new Error("continuation deadline barrier requires an ISO date-time");
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
throw new Error("continuation barrier kind must be pid or deadline");
|
|
355
|
+
}
|
|
356
|
+
function validateState(state) {
|
|
357
|
+
if (state.schema_version !== "1.0")
|
|
358
|
+
throw new Error("continuation driver state schema_version must be 1.0");
|
|
359
|
+
assertMaxTurns(state.max_turns);
|
|
360
|
+
if (state.adapter_command_identity !== null && (typeof state.adapter_command_identity !== "string" || state.adapter_command_identity.length === 0)) {
|
|
361
|
+
throw new Error("continuation driver adapter_command_identity must be a non-empty string or null");
|
|
362
|
+
}
|
|
363
|
+
if (!Number.isSafeInteger(state.turns_started) || state.turns_started < 0)
|
|
364
|
+
throw new Error("continuation driver turns_started must be a non-negative integer");
|
|
365
|
+
if (state.turns_started > state.max_turns)
|
|
366
|
+
throw new Error("continuation driver turns_started cannot exceed max_turns");
|
|
367
|
+
if (!new Set(["active", "waiting", "done", "failed", "budget_exhausted"]).has(state.status))
|
|
368
|
+
throw new Error("continuation driver state status is invalid");
|
|
369
|
+
if (state.status === "budget_exhausted" && state.turns_started !== state.max_turns) {
|
|
370
|
+
throw new Error("continuation driver budget_exhausted state must consume max_turns");
|
|
371
|
+
}
|
|
372
|
+
if (state.pending_barrier)
|
|
373
|
+
validateBarrier(state.pending_barrier);
|
|
374
|
+
}
|
|
375
|
+
function assertMissionIdentity(state, snapshot) {
|
|
376
|
+
if (state.run_id !== snapshot.run_id || state.definition_id !== snapshot.definition_id) {
|
|
377
|
+
throw new Error("continuation driver mission identity does not match the canonical Flow run");
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function assertMaxTurns(value) {
|
|
381
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 100)
|
|
382
|
+
throw new Error("continuation maxTurns must be an integer from 1 through 100");
|
|
383
|
+
}
|
|
384
|
+
function boundedErrorMessage(error) {
|
|
385
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
386
|
+
return message.length <= 2_000 ? message : `${message.slice(0, 1_997)}...`;
|
|
387
|
+
}
|
|
388
|
+
function processAlive(pid) {
|
|
389
|
+
try {
|
|
390
|
+
process.kill(pid, 0);
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
return error.code === "EPERM";
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
function reconcileStateWithEvents(state, eventsFile) {
|
|
398
|
+
if (!fs.existsSync(eventsFile)) {
|
|
399
|
+
if (state.turns_started > 0)
|
|
400
|
+
throw new Error("continuation driver event history is missing for a started mission");
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const lines = readRegularFileNoFollow(eventsFile, "continuation driver event log").split("\n").filter((line) => line.length > 0);
|
|
404
|
+
let highestStartedTurn = 0;
|
|
405
|
+
let eventBarrier = null;
|
|
406
|
+
for (const line of lines) {
|
|
407
|
+
const event = JSON.parse(line);
|
|
408
|
+
if (event.run_id !== state.run_id || event.definition_id !== state.definition_id)
|
|
409
|
+
throw new Error("continuation driver event history has a foreign mission identity");
|
|
410
|
+
if (event.type === "turn_started") {
|
|
411
|
+
if (!Number.isSafeInteger(event.turns_started) || event.turns_started < 1)
|
|
412
|
+
throw new Error("continuation driver event history has an invalid turn count");
|
|
413
|
+
highestStartedTurn = Math.max(highestStartedTurn, event.turns_started);
|
|
414
|
+
}
|
|
415
|
+
if (event.type === "parked") {
|
|
416
|
+
validateBarrier(event.barrier);
|
|
417
|
+
eventBarrier = structuredClone(event.barrier);
|
|
418
|
+
}
|
|
419
|
+
if (event.type === "resumed")
|
|
420
|
+
eventBarrier = null;
|
|
421
|
+
}
|
|
422
|
+
if (highestStartedTurn > state.turns_started)
|
|
423
|
+
throw new Error("continuation driver state rolled back behind its event history");
|
|
424
|
+
if (state.turns_started > highestStartedTurn + 1)
|
|
425
|
+
throw new Error("continuation driver state is ahead of its event history");
|
|
426
|
+
if (!state.pending_barrier && eventBarrier) {
|
|
427
|
+
state.pending_barrier = eventBarrier;
|
|
428
|
+
state.status = "waiting";
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function builderContinuationDisposition(nextAction) {
|
|
432
|
+
if (!nextAction || typeof nextAction !== "object" || Array.isArray(nextAction))
|
|
433
|
+
throw new Error("Builder Flow projection is missing its canonical next action");
|
|
434
|
+
const status = nextAction.status;
|
|
435
|
+
if (status === "continue")
|
|
436
|
+
return "continue";
|
|
437
|
+
if (status === "blocked")
|
|
438
|
+
return "waiting";
|
|
439
|
+
if (status === "done")
|
|
440
|
+
return "done";
|
|
441
|
+
if (status === "failed")
|
|
442
|
+
return "failed";
|
|
443
|
+
throw new Error(`Builder Flow projection has unsupported next-action status: ${String(status)}`);
|
|
444
|
+
}
|
package/build/src/index.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ export { BUILDER_BUILD_FLOW_ID, BUILDER_BUILD_FLOW_RELATIVE_PATH, BuilderBuildRu
|
|
|
2
2
|
export type { BuilderBuildRunResult, BuilderBuildRunIdentityMismatch, BuilderBuildTrustBundleEvidenceInput, EvaluateBuilderBuildRunInput, LoadBuilderBuildRunInput, StartBuilderBuildRunInput, } from "./builder-flow-run-adapter.js";
|
|
3
3
|
export { archiveBuilderFlowSession, cancelBuilderFlowSession, pauseBuilderFlowSession, recoverBuilderFlowSession, releaseBuilderFlowAssignment, resumeBuilderFlowSession, startBuilderFlowSession, syncBuilderFlowSession, } from "./builder-flow-runtime.js";
|
|
4
4
|
export type { BuilderFlowAgentLifecycleInput, BuilderFlowAuthorizedLifecycleInput, BuilderFlowSessionInput, BuilderFlowSessionResult } from "./builder-flow-runtime.js";
|
|
5
|
+
export { createFileContinuationStore, driveBuilderFlowSession, runContinuationDriver, withContinuationDriverLock, } from "./continuation-driver.js";
|
|
6
|
+
export type { ContinuationBarrier, ContinuationDriverEvent, ContinuationDriverOutcome, ContinuationDriverState, ContinuationRuntimePort, ContinuationSnapshot, ContinuationStateStore, ContinuationTurnRequest, ContinuationTurnResult, DriveBuilderFlowSessionInput, RunContinuationDriverInput, } from "./continuation-driver.js";
|
|
5
7
|
export { builderLifecycleAuthorizationPayload, loadBuilderLifecycleAuthorization } from "./builder-lifecycle-authority.js";
|
|
6
8
|
export type { BuilderLifecycleAuthorization } from "./builder-lifecycle-authority.js";
|
|
7
9
|
export { defaultArtifactRootForRead, defaultCodexHome, defaultTelemetryDirForRead, defaultTelemetryDirsForRead, durableFlowAgentsRoot, durableInstallRecordPath, DURABLE_FLOW_AGENTS_DIR, FLOW_AGENTS_RUNTIME_DIR, FLOW_AGENTS_RUNTIME_SUBDIR, firstExistingPath, flowAgentsArtifactRoot, KONTOURAI_DIR, legacyTelemetryDataDir, LEGACY_TELEMETRY_DIR, telemetryDataDir, } from "./lib/local-artifact-root.js";
|
package/build/src/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import * as path from "node:path";
|
|
|
15
15
|
import { loadJson as _loadJson, writeJson as _writeJson } from "./cli/workflow-sidecar.js";
|
|
16
16
|
export { BUILDER_BUILD_FLOW_ID, BUILDER_BUILD_FLOW_RELATIVE_PATH, BuilderBuildRunInputError, BuilderBuildRunIdentityError, evaluateBuilderBuildRun, loadBuilderBuildRun, resolveBuilderBuildFlowDefinitionPath, startBuilderBuildRun, } from "./builder-flow-run-adapter.js";
|
|
17
17
|
export { archiveBuilderFlowSession, cancelBuilderFlowSession, pauseBuilderFlowSession, recoverBuilderFlowSession, releaseBuilderFlowAssignment, resumeBuilderFlowSession, startBuilderFlowSession, syncBuilderFlowSession, } from "./builder-flow-runtime.js";
|
|
18
|
+
export { createFileContinuationStore, driveBuilderFlowSession, runContinuationDriver, withContinuationDriverLock, } from "./continuation-driver.js";
|
|
18
19
|
// Pure serialization contract used by external lifecycle authorities when
|
|
19
20
|
// signing requests. This does not load, create, or mutate a Flow run.
|
|
20
21
|
export { builderLifecycleAuthorizationPayload, loadBuilderLifecycleAuthorization } from "./builder-lifecycle-authority.js";
|
|
@@ -113,6 +113,60 @@ flow_agents workflow archive --authorization-file archive.json
|
|
|
113
113
|
state, and the canonical Flow run without rewriting either store. Its `next_action` is freshly
|
|
114
114
|
derived from the canonical run, so a stale sidecar projection cannot misdirect recovery.
|
|
115
115
|
|
|
116
|
+
## Bounded Continuation Driver
|
|
117
|
+
|
|
118
|
+
`workflow drive` lets the active implementation assignment run multiple Flow steps without a
|
|
119
|
+
human continuation prompt. Flow remains authoritative: the runtime adapter receives the current
|
|
120
|
+
projected action, but its `completed` result means only that one model turn ended. The driver
|
|
121
|
+
returns `done` only after the canonical Flow run is terminal.
|
|
122
|
+
|
|
123
|
+
The adapter command is an explicit JSON argv file whose executable must be an absolute path. Flow
|
|
124
|
+
Agents invokes it directly without a shell, writes one versioned continuation-turn request to stdin, and requires exactly one
|
|
125
|
+
JSON result on stdout:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{ "argv": ["/absolute/path/to/runtime-adapter", "--profile", "builder"] }
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{ "status": "completed", "summary": "Turn ended; synchronize canonical evidence." }
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
An adapter may instead park on a process or deadline. A pending barrier is persisted and does not
|
|
136
|
+
consume another turn when `workflow drive` is invoked again:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{ "status": "wait", "barrier": { "kind": "pid", "pid": 12345 }, "summary": "Waiting for the verification process." }
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
flow_agents workflow drive \
|
|
144
|
+
--session-dir .kontourai/flow-agents/example \
|
|
145
|
+
--adapter-command-file .kontourai/flow-agents/runtime-adapter.json \
|
|
146
|
+
--max-turns 6 \
|
|
147
|
+
--turn-timeout-ms 900000 \
|
|
148
|
+
--barrier-wait-ms 300000 \
|
|
149
|
+
--json
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Driver state and its append-only event stream live under
|
|
153
|
+
`.kontourai/flow-agents/<slug>/continuation-driver/`. The mission turn count survives reinvocation;
|
|
154
|
+
subsequent invocations must use the same `--max-turns` value. The request contains the
|
|
155
|
+
canonical run id, definition id, current step, projected `next_action`, iteration, and budget. It
|
|
156
|
+
does not mutate or replace the runtime system prompt. Adapter errors are recorded as failed turns
|
|
157
|
+
and fail open to canonical resynchronization and the next bounded turn; they cannot bypass the
|
|
158
|
+
persisted mission budget. The Builder Flow projection supplies the canonical continue/wait/done/failed
|
|
159
|
+
disposition, so the generic driver does not duplicate Flow lifecycle semantics. Human-decision and
|
|
160
|
+
paused Flow states park, while remediable blocked and accepted-exception states continue; failed Flow
|
|
161
|
+
runs stop as failed. Assignment ownership is revalidated before every adapter
|
|
162
|
+
turn. A session-scoped process lock rejects concurrent drivers and safely removes unique lock files
|
|
163
|
+
left by exited owners. The adapter argv plus the content digests of its executable and absolute
|
|
164
|
+
regular-file arguments are bound to the mission on first invocation and rechecked before every turn.
|
|
165
|
+
Adapter process groups are terminated after every non-wait result. In addition,
|
|
166
|
+
state rollback or deletion is rejected when its append-only event history proves turns already
|
|
167
|
+
started. These local coordination records detect accidental or in-process rollback; they are not a
|
|
168
|
+
cryptographic boundary against a process that can rewrite the entire artifact directory.
|
|
169
|
+
|
|
116
170
|
## Compatibility Doctor
|
|
117
171
|
|
|
118
172
|
Run doctor through the exact isolated package helper defined above:
|
|
@@ -663,6 +663,13 @@ the read-only `workflow status` projection without attaching evidence or advanci
|
|
|
663
663
|
[`docs/spec/builder-flow-runtime.md`](spec/builder-flow-runtime.md) for the ownership,
|
|
664
664
|
trust-binding, route-back, and artifact-root contract.
|
|
665
665
|
|
|
666
|
+
For unattended runtime re-entry, the active assignment actor may use `workflow drive` with an
|
|
667
|
+
explicit runtime adapter argv file. The driver repeats the same public projection/evidence cycle,
|
|
668
|
+
persists its mission turn budget, and parks on adapter-declared PID or deadline barriers instead of
|
|
669
|
+
spending turns while external work is incomplete. Adapter completion never advances a gate by
|
|
670
|
+
self-report; only trust evidence accepted by canonical Flow can change the current step. See
|
|
671
|
+
[`docs/public-workflow-cli.md`](public-workflow-cli.md#bounded-continuation-driver) for the protocol.
|
|
672
|
+
|
|
666
673
|
If the same Builder slice is interrupted, inspect its canonical status. Resume
|
|
667
674
|
only when the public status reports a paused run:
|
|
668
675
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kontourai/flow-agents",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.9.0",
|
|
4
4
|
"description": "Flow Agents — a Kontour product that applies Flow and Veritas discipline as a portable process layer inside the agent tools you already use: Claude Code, Codex, Kiro, opencode, pi, and GitHub Actions — with framework adapters (AWS Strands preview) on the same policy-engine contract.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -144,9 +144,9 @@
|
|
|
144
144
|
"kit": "npm run build --silent && node build/src/cli.js kit"
|
|
145
145
|
},
|
|
146
146
|
"devDependencies": {
|
|
147
|
-
"@types/node": "^26.1.
|
|
148
|
-
"promptfoo": "^0.121.
|
|
149
|
-
"typescript": "^
|
|
147
|
+
"@types/node": "^26.1.1",
|
|
148
|
+
"promptfoo": "^0.121.18",
|
|
149
|
+
"typescript": "^7.0.2"
|
|
150
150
|
},
|
|
151
151
|
"dependencies": {
|
|
152
152
|
"@kontourai/flow": "^3.1.4"
|
|
@@ -861,7 +861,9 @@ function projectFlowRun(context: SessionContext, run: BuilderFlowRunResult, side
|
|
|
861
861
|
const complete = run.state.status === "completed";
|
|
862
862
|
const paused = run.state.status === "paused";
|
|
863
863
|
const canceled = run.state.status === "canceled";
|
|
864
|
-
const
|
|
864
|
+
const needsDecision = run.state.status === "needs_decision";
|
|
865
|
+
const failed = run.state.status === "failed";
|
|
866
|
+
const action = complete || paused || canceled || needsDecision || failed ? { skills: [], operations: [] } : stepAction(run.definitionId, run.state.current_step);
|
|
865
867
|
if (!action) {
|
|
866
868
|
throw new BuilderBuildRunInputError("kit.flow_step_actions", `does not declare Builder step ${run.state.current_step}`);
|
|
867
869
|
}
|
|
@@ -886,6 +888,10 @@ function projectFlowRun(context: SessionContext, run: BuilderFlowRunResult, side
|
|
|
886
888
|
? { status: "done", summary: "Canonical Flow run was canceled by an authorized external request. Artifacts are retained until separately archived." }
|
|
887
889
|
: paused
|
|
888
890
|
? { status: "blocked", summary: "Canonical Flow run is paused. The current assignment actor may resume it with a reason." }
|
|
891
|
+
: needsDecision
|
|
892
|
+
? { status: "blocked", summary: "Canonical Flow requires an external decision before continuation." }
|
|
893
|
+
: failed
|
|
894
|
+
? { status: "failed", summary: "Canonical Flow run failed; no continuation turn is allowed." }
|
|
889
895
|
: {
|
|
890
896
|
status: "continue",
|
|
891
897
|
summary: `Flow step \`${run.state.current_step}\`: ${skillText}${operationText} ${gateText}${routeText} Then synchronize the recorded evidence.`,
|
|
@@ -896,8 +902,8 @@ function projectFlowRun(context: SessionContext, run: BuilderFlowRunResult, side
|
|
|
896
902
|
const phase = phaseForStep(definition.phase_map, run.state.current_step) ?? sidecar.phase;
|
|
897
903
|
return {
|
|
898
904
|
...sidecar,
|
|
899
|
-
status: complete ? "delivered" : canceled ? "canceled" : paused ? "blocked" : (run.state.transitions.length > 0 ? "in_progress" : sidecar.status),
|
|
900
|
-
phase: complete || canceled ? "done" : phase,
|
|
905
|
+
status: complete ? "delivered" : canceled ? "canceled" : failed ? "failed" : (paused || needsDecision) ? "blocked" : (run.state.transitions.length > 0 ? "in_progress" : sidecar.status),
|
|
906
|
+
phase: complete || canceled || failed ? "done" : phase,
|
|
901
907
|
updated_at: run.state.updated_at,
|
|
902
908
|
flow_run: {
|
|
903
909
|
run_id: run.runId,
|