@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.
@@ -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
+ }
@@ -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";
@@ -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.8.0",
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.0",
148
- "promptfoo": "^0.121.17",
149
- "typescript": "^6.0.3"
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 action = complete || paused || canceled ? { skills: [], operations: [] } : stepAction(run.definitionId, run.state.current_step);
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,