@kontourai/flow-agents 3.8.0 → 3.10.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.
Files changed (109) hide show
  1. package/.github/workflows/ci.yml +8 -0
  2. package/CHANGELOG.md +24 -0
  3. package/README.md +1 -1
  4. package/build/src/builder-flow-runtime.js +16 -10
  5. package/build/src/cli/assignment-provider.js +4 -18
  6. package/build/src/cli/continuation-adapter.d.ts +26 -0
  7. package/build/src/cli/continuation-adapter.js +243 -0
  8. package/build/src/cli/validate-workflow-artifacts.js +21 -1
  9. package/build/src/cli/workflow-sidecar.js +15 -20
  10. package/build/src/cli/workflow.js +99 -6
  11. package/build/src/continuation-driver.d.ts +116 -0
  12. package/build/src/continuation-driver.js +527 -0
  13. package/build/src/index.d.ts +2 -0
  14. package/build/src/index.js +1 -0
  15. package/build/src/tools/build-universal-bundles.js +52 -25
  16. package/build/src/tools/validate-source-tree.js +5 -2
  17. package/context/scripts/hooks/lib/continuation-turn-authority.js +386 -0
  18. package/context/scripts/hooks/stop-goal-fit.js +244 -73
  19. package/context/scripts/hooks/workflow-steering.js +24 -15
  20. package/context/scripts/telemetry/lib/pricing.sh +18 -6
  21. package/context/scripts/telemetry/lib/transport.sh +139 -37
  22. package/context/scripts/telemetry/lib/usage.sh +282 -32
  23. package/context/scripts/telemetry/lib/usage_model_guard.sh +42 -0
  24. package/context/scripts/telemetry/telemetry.sh +76 -1
  25. package/docs/coordination-guide.md +21 -5
  26. package/docs/fixture-ownership.md +2 -1
  27. package/docs/public-workflow-cli.md +98 -0
  28. package/docs/spec/runtime-hook-surface.md +8 -4
  29. package/docs/workflow-usage-guide.md +7 -0
  30. package/evals/acceptance/prove-capture-teeth-declared.sh +13 -0
  31. package/evals/acceptance/prove-capture-teeth.sh +13 -0
  32. package/evals/ci/run-baseline.sh +2 -0
  33. package/evals/fixtures/codex-legacy-agents/5273878130bdafc8a024a650bb5b66c9b003f1f859b5dc6e5b588cbf4ab23228.md +25 -0
  34. package/evals/fixtures/telemetry/usage-transcript-adversarial.jsonl +2 -0
  35. package/evals/fixtures/telemetry/usage-transcript-malformed-sandwich.jsonl +3 -0
  36. package/evals/fixtures/telemetry/usage-transcript-negative-tokens.jsonl +1 -0
  37. package/evals/fixtures/telemetry/usage-transcript-oversized-prefix.jsonl +201 -0
  38. package/evals/fixtures/telemetry/usage-transcript-oversized-tokens.jsonl +1 -0
  39. package/evals/fixtures/telemetry/usage-transcript-prefixed-secret-lower.jsonl +1 -0
  40. package/evals/fixtures/telemetry/usage-transcript-prefixed-secret-upper.jsonl +1 -0
  41. package/evals/fixtures/telemetry/usage-transcript-secret-model-jwt.jsonl +1 -0
  42. package/evals/fixtures/telemetry/usage-transcript-secret-model.jsonl +1 -0
  43. package/evals/fixtures/telemetry/usage-transcript-torn-trailing.jsonl +2 -0
  44. package/evals/integration/test_actor_identity.sh +63 -0
  45. package/evals/integration/test_assignment_provider_local_file.sh +34 -0
  46. package/evals/integration/test_builder_entry_enforcement.sh +5 -1
  47. package/evals/integration/test_bundle_install.sh +106 -17
  48. package/evals/integration/test_bundle_lifecycle.sh +17 -3
  49. package/evals/integration/test_captured_fail_reconciliation.sh +11 -0
  50. package/evals/integration/test_ci_actor_identity.sh +2 -2
  51. package/evals/integration/test_command_log_concurrency.sh +11 -0
  52. package/evals/integration/test_command_log_fork_classification.sh +11 -0
  53. package/evals/integration/test_command_log_integrity.sh +11 -0
  54. package/evals/integration/test_current_json_per_actor.sh +416 -21
  55. package/evals/integration/test_enforcer_expects_driven.sh +11 -0
  56. package/evals/integration/test_ensure_session_ownership_guard.sh +39 -0
  57. package/evals/integration/test_evidence_capture_hook.sh +25 -6
  58. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  59. package/evals/integration/test_flowdef_union_floor_regression.sh +11 -0
  60. package/evals/integration/test_gate_bypass_chain.sh +11 -0
  61. package/evals/integration/test_gate_lockdown.sh +10 -0
  62. package/evals/integration/test_goal_fit_ghost_session.sh +11 -0
  63. package/evals/integration/test_goal_fit_hook.sh +257 -9
  64. package/evals/integration/test_goal_fit_rederive.sh +11 -0
  65. package/evals/integration/test_install_merge.sh +141 -3
  66. package/evals/integration/test_liveness_conflict_injection.sh +39 -3
  67. package/evals/integration/test_liveness_heartbeat.sh +46 -9
  68. package/evals/integration/test_phase_map_and_gate_claim.sh +10 -1
  69. package/evals/integration/test_public_workflow_cli.sh +6 -6
  70. package/evals/integration/test_session_resume_roundtrip.sh +30 -0
  71. package/evals/integration/test_stop_hook_release.sh +111 -11
  72. package/evals/integration/test_telemetry_tool_usage.sh +394 -0
  73. package/evals/integration/test_verify_hold.sh +64 -1
  74. package/evals/integration/test_workflow_sidecar_writer.sh +10 -1
  75. package/evals/integration/test_workflow_steering_hook.sh +20 -4
  76. package/evals/run.sh +2 -0
  77. package/evals/static/test_universal_bundles.sh +123 -0
  78. package/package.json +4 -4
  79. package/packaging/README.md +21 -0
  80. package/packaging/codex-legacy-agents-fingerprints.json +33 -0
  81. package/scripts/README.md +2 -1
  82. package/scripts/audit-codex-legacy-agents.js +43 -0
  83. package/scripts/classify-codex-legacy-agents.js +71 -0
  84. package/scripts/hooks/evidence-capture.js +18 -6
  85. package/scripts/hooks/lib/actor-identity.js +62 -29
  86. package/scripts/hooks/lib/continuation-turn-authority.js +386 -0
  87. package/scripts/hooks/lib/current-pointer.js +114 -12
  88. package/scripts/hooks/lib/liveness-heartbeat.js +15 -12
  89. package/scripts/hooks/stop-goal-fit.js +244 -73
  90. package/scripts/hooks/workflow-steering.js +24 -15
  91. package/scripts/install-codex-home.sh +8 -7
  92. package/scripts/telemetry/lib/pricing.sh +18 -6
  93. package/scripts/telemetry/lib/transport.sh +71 -0
  94. package/scripts/telemetry/lib/usage.sh +282 -32
  95. package/scripts/telemetry/lib/usage_model_guard.sh +42 -0
  96. package/scripts/telemetry/telemetry.sh +76 -1
  97. package/src/builder-flow-runtime.ts +9 -3
  98. package/src/cli/assignment-provider.ts +5 -26
  99. package/src/cli/builder-flow-runtime.test.mjs +215 -2
  100. package/src/cli/continuation-adapter.ts +265 -0
  101. package/src/cli/continuation-driver.test.mjs +1303 -0
  102. package/src/cli/public-api.test.mjs +1 -0
  103. package/src/cli/validate-workflow-artifacts.ts +19 -1
  104. package/src/cli/workflow-sidecar.ts +18 -23
  105. package/src/cli/workflow.ts +113 -6
  106. package/src/continuation-driver.ts +638 -0
  107. package/src/index.ts +24 -0
  108. package/src/tools/build-universal-bundles.ts +61 -25
  109. package/src/tools/validate-source-tree.ts +5 -2
@@ -1,5 +1,4 @@
1
1
  import * as fs from "node:fs";
2
- import * as os from "node:os";
3
2
  import * as path from "node:path";
4
3
  import { randomBytes } from "node:crypto";
5
4
  import { createRequire } from "node:module";
@@ -118,25 +117,19 @@ const CLAIM_COMMENT_MARKER_DEFAULT = "<!-- flow-agents:assignment-claim -->";
118
117
  */
119
118
  function loadActorIdentityHelper(): {
120
119
  resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
120
+ resolveActorIdentity: (env: NodeJS.ProcessEnv) => { actor: string; source: string; actorStruct: ActorStruct | null };
121
121
  serializeActor: (actor: Partial<ActorStruct> | undefined) => string;
122
122
  isUnresolvedActor: (actor: string) => boolean;
123
123
  sanitizeSegment: (value: unknown) => string;
124
- detectRuntime: (env: NodeJS.ProcessEnv) => string;
125
- runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
126
- ancestorActorSeed: () => string;
127
- detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
128
124
  } {
129
125
  const _req = createRequire(import.meta.url);
130
126
  const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
131
127
  return _req(helperPath) as {
132
128
  resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
129
+ resolveActorIdentity: (env: NodeJS.ProcessEnv) => { actor: string; source: string; actorStruct: ActorStruct | null };
133
130
  serializeActor: (actor: Partial<ActorStruct> | undefined) => string;
134
131
  isUnresolvedActor: (actor: string) => boolean;
135
132
  sanitizeSegment: (value: unknown) => string;
136
- detectRuntime: (env: NodeJS.ProcessEnv) => string;
137
- runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
138
- ancestorActorSeed: () => string;
139
- detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
140
133
  };
141
134
  }
142
135
 
@@ -209,24 +202,10 @@ function loadActorStruct(args: ParsedArgs): { actor: ActorStruct; actorKey?: str
209
202
 
210
203
  export function resolveCurrentAssignmentActor(): { actor: ActorStruct; actorKey: string } {
211
204
  const helper = loadActorIdentityHelper();
212
- const resolved = helper.resolveActor(process.env);
205
+ const resolved = helper.resolveActorIdentity(process.env);
213
206
  if (helper.isUnresolvedActor(resolved.actor)) throw new Error("could not resolve an actor identity (no --actor-json and no resolvable environment actor); pass --actor-json explicitly");
214
- // #398: reconstruct the SAME struct resolveActor serialized for a CI actor, mirroring
215
- // resolveEnsureSessionActor (workflow-sidecar.ts) via the shared detectCiActor. Without this the
216
- // else-branch would write `record.actor = {runtime:"unknown", session_id:<the whole triple>}` for a
217
- // CI session — actor_key stays correct (so no false-block), but record.actor is malformed and the
218
- // audit-trail / `assignment-provider status` output for CI sessions would be corrupt.
219
- const ci = resolved.source.startsWith("ci-runtime") ? helper.detectCiActor(process.env) : null;
220
- const runtimeSessionId = resolved.source.startsWith("runtime-session-id") ? helper.runtimeSessionId(process.env) : "";
221
- const ancestrySeed = resolved.source === "process-ancestry" ? helper.ancestorActorSeed() : "";
222
- const actor: ActorStruct = resolved.source === "explicit-override"
223
- ? { runtime: "explicit-override", session_id: resolved.actor, host: os.hostname(), human: null }
224
- : ci && ci.session_id
225
- ? { runtime: ci.runtime, session_id: ci.session_id, host: os.hostname(), human: null }
226
- : runtimeSessionId
227
- ? { runtime: helper.detectRuntime(process.env), session_id: runtimeSessionId, host: os.hostname(), human: null }
228
- : { runtime: helper.detectRuntime(process.env), session_id: ancestrySeed ? `anc-${ancestrySeed}` : resolved.actor, host: os.hostname(), human: null };
229
- return { actor, actorKey: resolved.actor };
207
+ if (!resolved.actorStruct) throw new Error("actor identity resolved without a canonical actor struct");
208
+ return { actor: { ...resolved.actorStruct, human: resolved.actorStruct.human ?? null }, actorKey: resolved.actor };
230
209
  }
231
210
 
232
211
  export function assignmentFilePath(artifactRoot: string, subjectId: string): string {
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { createHash, generateKeyPairSync, sign } from "node:crypto";
7
+ import { spawnSync } from "node:child_process";
7
8
 
8
9
  import { FLOW_RUN_EVIDENCE_MANIFEST_PATH, runDir } from "@kontourai/flow";
9
10
  import {
@@ -18,9 +19,11 @@ import {
18
19
  syncBuilderFlowSession,
19
20
  } from "../../build/src/builder-flow-runtime.js";
20
21
  import { builderLifecycleAuthorizationPayload, loadBuilderLifecycleAuthorization, recordAuthorizationConsumed } from "../../build/src/builder-lifecycle-authority.js";
22
+ import { driveBuilderFlowSession } from "../../build/src/continuation-driver.js";
21
23
  import { cancelBuilderBuildRun } from "../../build/src/builder-flow-run-adapter.js";
22
24
  import { performLocalClaim, performLocalRelease, readLocalAssignmentStatus, resolveCurrentAssignmentActor } from "../../build/src/cli/assignment-provider.js";
23
25
  import { main as builderRunMain } from "../../build/src/cli/builder-run.js";
26
+ import { main as workflowMain } from "../../build/src/cli/workflow.js";
24
27
  import { buildTrustBundle, inferExecutedTestCount, main as workflowSidecarMain } from "../../build/src/cli/workflow-sidecar.js";
25
28
 
26
29
  const SUBJECT = "local:work-item/runtime-projection";
@@ -30,6 +33,20 @@ const ACTOR = { runtime: "codex", session_id: "runtime-projection", host: "test-
30
33
  const ACTOR_KEY = "codex:runtime-projection:test-host";
31
34
  const AUTHORITY_KEY_ID = "runtime-test";
32
35
  const AUTHORITY_KEYS = generateKeyPairSync("ed25519");
36
+ const AMBIENT_IDENTITY_ENV_KEYS = [
37
+ "FLOW_AGENTS_ACTOR",
38
+ "CODEX_THREAD_ID",
39
+ "CODEX_SESSION_ID",
40
+ "CLAUDE_CODE_SESSION_ID",
41
+ "OPENCODE_SESSION_ID",
42
+ "PI_SESSION_ID",
43
+ "GITHUB_ACTIONS",
44
+ "GITLAB_CI",
45
+ "CIRCLECI",
46
+ "JENKINS_URL",
47
+ "TF_BUILD",
48
+ "BUILDKITE",
49
+ ];
33
50
 
34
51
  function makeSession(slug = "runtime-projection") {
35
52
  const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-builder-runtime-"));
@@ -88,7 +105,7 @@ function claimSessionAssignment(session) {
88
105
  ttlSeconds: 1800,
89
106
  actorKey: ACTOR_KEY,
90
107
  branch: `agent/${session.slug}`,
91
- artifactDir: session.sessionDir,
108
+ artifactDir: session.slug,
92
109
  workItemRef: SUBJECT,
93
110
  reason: "test",
94
111
  });
@@ -100,7 +117,7 @@ function claimAmbientSessionAssignment(session) {
100
117
  ttlSeconds: 1800,
101
118
  actorKey: ambient.actorKey,
102
119
  branch: `agent/${session.slug}`,
103
- artifactDir: session.sessionDir,
120
+ artifactDir: session.slug,
104
121
  workItemRef: SUBJECT,
105
122
  reason: "test",
106
123
  });
@@ -365,6 +382,202 @@ test("small-model client can start and advance from projected actions without ch
365
382
  assert.equal(duplicate.run.manifest.evidence.length, advanced.run.manifest.evidence.length);
366
383
  });
367
384
 
385
+ test("Builder projection preserves Flow continuation semantics for exceptional statuses", async () => {
386
+ const session = makeSession("continuation-status-projection");
387
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
388
+ const stateFile = path.join(runDir(session.slug, session.projectRoot), "state.json");
389
+ const original = readJson(stateFile);
390
+ for (const [status, expected] of [
391
+ ["blocked", "continue"],
392
+ ["accepted_by_exception", "continue"],
393
+ ["needs_decision", "blocked"],
394
+ ["failed", "failed"],
395
+ ]) {
396
+ writeJson(stateFile, { ...original, status, next_action: `fixture ${status}` });
397
+ const recovered = await recoverBuilderFlowSession({ sessionDir: session.sessionDir });
398
+ assert.equal(recovered.projection.next_action.status, expected, status);
399
+ }
400
+ });
401
+
402
+ test("continuation driver advances two real FlowDefinition steps unattended", async () => {
403
+ const session = makeSession("continuation-driver-two-steps");
404
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
405
+ const visited = [];
406
+
407
+ const result = await driveBuilderFlowSession({
408
+ sessionDir: session.sessionDir,
409
+ maxTurns: 2,
410
+ execute: async (request) => {
411
+ visited.push(request.current_step);
412
+ if (request.current_step === "pull-work") {
413
+ writeBundle(session.sessionDir, [bundleClaim({
414
+ expectation: "selected-work",
415
+ claimType: "builder.pull-work.selected",
416
+ subjectType: "work-item",
417
+ })]);
418
+ } else if (request.current_step === "design-probe") {
419
+ writeBundle(session.sessionDir, [
420
+ bundleClaim({
421
+ expectation: "pickup-probe-readiness",
422
+ claimType: "builder.design-probe.pickup-readiness",
423
+ subjectType: "work-item",
424
+ }),
425
+ bundleClaim({
426
+ expectation: "probe-decisions-or-accepted-gaps",
427
+ claimType: "builder.design-probe.decisions",
428
+ subjectType: "decision",
429
+ }),
430
+ ]);
431
+ } else {
432
+ assert.fail(`unexpected continuation step ${request.current_step}`);
433
+ }
434
+ return { status: "completed", summary: `completed ${request.current_step}` };
435
+ },
436
+ });
437
+
438
+ assert.equal(result.outcome, "budget_exhausted");
439
+ assert.equal(result.turns_started, 2);
440
+ assert.deepEqual(visited, ["pull-work", "design-probe"]);
441
+ assert.equal(result.snapshot.current_step, "plan");
442
+ assert.equal(result.snapshot.next_action.skills[0], "plan-work");
443
+ const driverState = readJson(path.join(session.sessionDir, "continuation-driver", "state.json"));
444
+ assert.equal(driverState.status, "budget_exhausted");
445
+ assert.equal(driverState.turns_started, 2);
446
+ const events = fs.readFileSync(path.join(session.sessionDir, "continuation-driver", "events.jsonl"), "utf8").trim().split("\n").map(JSON.parse);
447
+ assert.deepEqual(events.filter((event) => event.type === "turn_started").map((event) => event.current_step), ["pull-work", "design-probe"]);
448
+ });
449
+
450
+ test("public workflow evidence accepts only live signed turn authority after ordinary ancestry resolution mismatches", async () => {
451
+ const session = makeSession("continuation-driver-cli");
452
+ claimAmbientSessionAssignment(session);
453
+ fs.writeFileSync(path.join(session.projectRoot, "AGENTS.md"), "# Test Repo\n");
454
+ fs.writeFileSync(path.join(session.sessionDir, `${session.slug}--deliver.md`), "# Continuation\n\nstatus: executing\ntype: deliver\n");
455
+ fs.writeFileSync(path.join(session.sessionDir, `${session.slug}--pull-work.md`), "# Pull Work\n\nSelected continuation fixture.\n");
456
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
457
+ const unrelatedSlug = "unrelated-pointer-session";
458
+ const unrelatedDir = path.join(session.artifactRoot, unrelatedSlug);
459
+ fs.mkdirSync(unrelatedDir, { recursive: true });
460
+ fs.writeFileSync(path.join(unrelatedDir, `${unrelatedSlug}--deliver.md`), "# Unrelated\n\nstatus: executing\ntype: deliver\n");
461
+ writeJson(path.join(unrelatedDir, "state.json"), {
462
+ schema_version: "1.0", task_slug: unrelatedSlug, status: "executing", phase: "execution",
463
+ updated_at: new Date().toISOString(), next_action: { status: "continue", summary: "Unrelated work" },
464
+ });
465
+ writeJson(path.join(session.artifactRoot, "current.json"), { active_slug: unrelatedSlug, artifact_dir: unrelatedSlug });
466
+ const adapter = path.join(session.projectRoot, "adapter.mjs");
467
+ const commandFile = path.join(session.projectRoot, "adapter-command.json");
468
+ const authorityFile = path.join(session.sessionDir, "continuation-driver", "active-turn.json");
469
+ fs.writeFileSync(adapter, `
470
+ import fs from "node:fs";
471
+ import { spawnSync } from "node:child_process";
472
+ let input = "";
473
+ for await (const chunk of process.stdin) input += chunk;
474
+ const request = JSON.parse(input);
475
+ fs.writeFileSync("adapter-request.json", input);
476
+ const evidenceEnv = { ...process.env };
477
+ for (const key of ${JSON.stringify(AMBIENT_IDENTITY_ENV_KEYS)}) delete evidenceEnv[key];
478
+ const ordinaryChild = ${JSON.stringify(path.resolve(import.meta.dirname, "../../scripts/hooks/lib/actor-identity.js"))};
479
+ const childIdentity = (await import("node:module")).createRequire(import.meta.url)(ordinaryChild).resolveActorIdentity(evidenceEnv);
480
+ const record = (expectation) => {
481
+ const evidence = { kind: "artifact", file: ${JSON.stringify(`.kontourai/flow-agents/${session.slug}/${session.slug}--pull-work.md`)}, summary: "adapter child records " + expectation };
482
+ return spawnSync(process.execPath, [${JSON.stringify(path.resolve(import.meta.dirname, "../../build/src/cli.js"))}, "workflow", "evidence", "--session-dir", ${JSON.stringify(session.sessionDir)}, "--expectation", expectation, "--status", "pass", "--summary", "adapter child records " + expectation, "--evidence-ref-json", JSON.stringify(evidence), "--json"], { cwd: ${JSON.stringify(session.projectRoot)}, encoding: "utf8", env: evidenceEnv });
483
+ };
484
+ const evidenceRuns = request.current_step === "pull-work"
485
+ ? [record("selected-work")]
486
+ : [record("pickup-probe-readiness"), record("probe-decisions-or-accepted-gaps")];
487
+ if (evidenceRuns.some((result) => result.status !== 0)) {
488
+ fs.writeFileSync("adapter-evidence-error.json", JSON.stringify(evidenceRuns.map((result) => ({ status: result.status, stderr: result.stderr, stdout: result.stdout }))));
489
+ throw new Error("public workflow evidence rejected signed adapter authority: " + evidenceRuns.map((result) => result.stderr).join("\\n"));
490
+ }
491
+ const lifecycleRuns = [
492
+ ["pause", "--reason", "capability must not pause"],
493
+ ["resume"],
494
+ ["release", "--reason", "capability must not release"],
495
+ ["cancel"],
496
+ ["archive"],
497
+ ].map((args) => spawnSync(process.execPath, [${JSON.stringify(path.resolve(import.meta.dirname, "../../build/src/cli.js"))}, "workflow", ...args, "--session-dir", ${JSON.stringify(session.sessionDir)}], { cwd: ${JSON.stringify(session.projectRoot)}, encoding: "utf8", env: evidenceEnv }));
498
+ const currentPointer = (await import("node:module")).createRequire(import.meta.url)(${JSON.stringify(path.resolve(import.meta.dirname, "../../scripts/hooks/lib/current-pointer.js"))});
499
+ currentPointer.writePerActorCurrent(${JSON.stringify(session.artifactRoot)}, childIdentity.actor, { active_slug: ${JSON.stringify(unrelatedSlug)}, artifact_dir: ${JSON.stringify(unrelatedSlug)} });
500
+ fs.writeFileSync(${JSON.stringify(path.join(session.artifactRoot, "current.json"))}, JSON.stringify({ active_slug: ${JSON.stringify(unrelatedSlug)}, artifact_dir: ${JSON.stringify(unrelatedSlug)} }));
501
+ const unrelatedAssignment = ${JSON.stringify(path.join(session.artifactRoot, "assignment", `${unrelatedSlug}.json`))};
502
+ fs.mkdirSync((await import("node:path")).dirname(unrelatedAssignment), { recursive: true });
503
+ fs.writeFileSync(unrelatedAssignment, JSON.stringify({ schema_version: "1.0", role: "AssignmentClaimRecord", subject_id: ${JSON.stringify(unrelatedSlug)}, actor: childIdentity.actorStruct, actor_key: childIdentity.actor, artifact_dir: ${JSON.stringify(unrelatedSlug)}, status: "claimed" }));
504
+ const canonicalState = JSON.parse(fs.readFileSync(${JSON.stringify(path.join(runDir(session.slug, session.projectRoot), "state.json"))}, "utf8"));
505
+ const authorityValidation = (await import("node:module")).createRequire(import.meta.url)(${JSON.stringify(path.resolve(import.meta.dirname, "../../scripts/hooks/lib/continuation-turn-authority.js"))}).validateActiveTurnAuthority({ sessionDir: ${JSON.stringify(session.sessionDir)}, runId: process.env.FLOW_AGENTS_CONTINUATION_RUN_ID, turnSecret: process.env.FLOW_AGENTS_CONTINUATION_TURN_SECRET, canonicalState });
506
+ const hookEnv = { ...process.env, FLOW_AGENTS_GOAL_FIT_MODE: "block" };
507
+ const hook = spawnSync(process.execPath, [${JSON.stringify(path.resolve(import.meta.dirname, "../../scripts/hooks/stop-goal-fit.js"))}], { cwd: ${JSON.stringify(session.projectRoot)}, input: JSON.stringify({ hook_event_name: "Stop", cwd: ${JSON.stringify(session.projectRoot)} }), encoding: "utf8", env: hookEnv });
508
+ fs.writeFileSync("adapter-authority.json", JSON.stringify({ turnSecret: process.env.FLOW_AGENTS_CONTINUATION_TURN_SECRET || null, runId: process.env.FLOW_AGENTS_CONTINUATION_RUN_ID || null, childIdentity, evidenceRuns: evidenceRuns.map((result) => ({ status: result.status, stderr: result.stderr })), lifecycleRuns: lifecycleRuns.map((result) => ({ status: result.status, stderr: result.stderr })), unrelatedAssignment: JSON.parse(fs.readFileSync(unrelatedAssignment, "utf8")), unrelatedHandoffExists: fs.existsSync(${JSON.stringify(path.join(unrelatedDir, "handoff.json"))}), active: fs.existsSync(${JSON.stringify(authorityFile)}), authorityValidation, hookStatus: hook.status, hookStderr: hook.stderr }));
509
+ if (hook.status !== 0) throw new Error("Stop hook blocked canonical continuation: " + hook.stderr);
510
+ process.stdout.write(JSON.stringify({ status: "completed", summary: "adapter advanced " + request.current_step }));
511
+ `);
512
+ writeJson(commandFile, { argv: [process.execPath, adapter] });
513
+
514
+ const rc = await workflowMain([
515
+ "drive",
516
+ "--session-dir", session.sessionDir,
517
+ "--adapter-command-file", commandFile,
518
+ "--max-turns", "2",
519
+ "--turn-timeout-ms", "5000",
520
+ "--barrier-wait-ms", "0",
521
+ "--json",
522
+ ]);
523
+
524
+ assert.equal(rc, 0);
525
+ const request = readJson(path.join(session.projectRoot, "adapter-request.json"));
526
+ assert.equal(request.current_step, "design-probe", fs.existsSync(path.join(session.projectRoot, "adapter-evidence-error.json")) ? fs.readFileSync(path.join(session.projectRoot, "adapter-evidence-error.json"), "utf8") : "adapter did not advance canonical Flow");
527
+ assert.deepEqual(request.next_action.skills, ["pickup-probe"]);
528
+ assert.equal(Object.hasOwn(request, "system_prompt"), false);
529
+ const authority = readJson(path.join(session.projectRoot, "adapter-authority.json"));
530
+ assert.match(authority.turnSecret, /^[A-Za-z0-9_-]{43}$/);
531
+ assert.equal(authority.runId, session.slug);
532
+ assert.equal(authority.childIdentity.source, "process-ancestry");
533
+ assert.ok(authority.evidenceRuns.every((result) => result.status === 0), JSON.stringify(authority.evidenceRuns));
534
+ assert.ok(authority.lifecycleRuns.every((result) => result.status !== 0), JSON.stringify(authority.lifecycleRuns));
535
+ assert.equal(authority.unrelatedAssignment.status, "claimed", "Stop does not release the unrelated per-actor/global-pointer session");
536
+ assert.equal(authority.unrelatedHandoffExists, false, "Stop does not relax or hand off the unrelated session");
537
+ assert.equal(authority.active, true);
538
+ assert.equal(authority.authorityValidation.valid, true, authority.authorityValidation.reason);
539
+ assert.equal(authority.hookStatus, 0, `the blocking Stop hook returns control during the signed adapter turn: ${authority.hookStderr}`);
540
+ assert.match(authority.hookStderr, /continuation driver active turn is authorized/);
541
+ assert.equal(fs.existsSync(authorityFile), false, "adapter turn authority is removed after the child exits");
542
+ const driverState = readJson(path.join(session.sessionDir, "continuation-driver", "state.json"));
543
+ assert.equal(driverState.status, "budget_exhausted");
544
+ const canonical = await recoverBuilderFlowSession({ sessionDir: session.sessionDir });
545
+ assert.equal(canonical.run.state.status, "active");
546
+ assert.equal(canonical.run.state.current_step, "plan");
547
+ const events = fs.readFileSync(path.join(session.sessionDir, "continuation-driver", "events.jsonl"), "utf8").trim().split("\n").map(JSON.parse);
548
+ assert.deepEqual(events.filter((event) => event.type === "turn_started").map((event) => event.current_step), ["pull-work", "design-probe"]);
549
+ assert.equal(events.some((event) => event.type === "gate_not_advanced"), false);
550
+ const replayEnv = { ...process.env, FLOW_AGENTS_CONTINUATION_TURN_SECRET: authority.turnSecret, FLOW_AGENTS_CONTINUATION_RUN_ID: authority.runId };
551
+ for (const key of AMBIENT_IDENTITY_ENV_KEYS) delete replayEnv[key];
552
+ const replay = spawnSync(process.execPath, [path.resolve(import.meta.dirname, "../../build/src/cli.js"), "workflow", "evidence", "--session-dir", session.sessionDir, "--expectation", "implementation-plan", "--status", "pass", "--summary", "replayed turn capability", "--evidence-ref-json", JSON.stringify({ kind: "artifact", file: "adapter.mjs", summary: "replay fixture" })], { cwd: session.projectRoot, encoding: "utf8", env: replayEnv });
553
+ assert.notEqual(replay.status, 0, "copied capability values fail after active-turn cleanup");
554
+ replayEnv.FLOW_AGENTS_CONTINUATION_TURN_SECRET = "A".repeat(43);
555
+ const forged = spawnSync(process.execPath, [path.resolve(import.meta.dirname, "../../build/src/cli.js"), "workflow", "evidence", "--session-dir", session.sessionDir, "--expectation", "implementation-plan", "--status", "pass", "--summary", "forged turn capability", "--evidence-ref-json", JSON.stringify({ kind: "artifact", file: "adapter.mjs", summary: "forgery fixture" })], { cwd: session.projectRoot, encoding: "utf8", env: replayEnv });
556
+ assert.notEqual(forged.status, 0, "fake nonce and digest without a signed active turn fail ownership");
557
+ });
558
+
559
+ test("public workflow drive rejects a non-owner before adapter execution", async () => {
560
+ const session = makeSession("continuation-driver-owner");
561
+ claimSessionAssignment(session);
562
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
563
+ const marker = path.join(session.projectRoot, "adapter-ran");
564
+ const adapter = path.join(session.projectRoot, "adapter.mjs");
565
+ const commandFile = path.join(session.projectRoot, "adapter-command.json");
566
+ fs.writeFileSync(adapter, `
567
+ import fs from "node:fs";
568
+ fs.writeFileSync(${JSON.stringify(marker)}, "ran");
569
+ process.stdout.write(JSON.stringify({ status: "completed" }));
570
+ `);
571
+ writeJson(commandFile, { argv: [process.execPath, adapter] });
572
+
573
+ await assert.rejects(
574
+ workflowMain(["drive", "--session-dir", session.sessionDir, "--adapter-command-file", commandFile]),
575
+ /active, matching assignment actor/,
576
+ );
577
+ assert.equal(fs.existsSync(marker), false);
578
+ assert.equal(fs.existsSync(path.join(session.sessionDir, "continuation-driver")), false);
579
+ });
580
+
368
581
  test("sync attaches the staged trust.bundle bytes when the session bundle is replaced", async () => {
369
582
  const session = makeSession("snapshot-attachment");
370
583
  await startBuilderFlowSession({ sessionDir: session.sessionDir });
@@ -0,0 +1,265 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { createHash } from "node:crypto";
5
+ import { ContinuationAdapterTimeoutError } from "../continuation-driver.js";
6
+ import type { ContinuationBarrier, ContinuationTurnRequest, ContinuationTurnResult } from "../continuation-driver.js";
7
+
8
+ export type ContinuationAdapterCommand = {
9
+ argv: string[];
10
+ identity: string;
11
+ integrity: Array<{ file: string; sha256: string }>;
12
+ };
13
+
14
+ export function loadContinuationAdapterCommand(commandFileInput: string): ContinuationAdapterCommand {
15
+ const commandFile = path.resolve(commandFileInput);
16
+ const command = validateAdapterCommand(JSON.parse(readRegularFileNoFollow(commandFile, "continuation adapter command file")) as unknown);
17
+ if (!path.isAbsolute(command.argv[0]!)) throw new Error("continuation adapter executable must be an absolute path");
18
+ const integrity = [...new Set(command.argv.filter((entry) => path.isAbsolute(entry) && regularFileExists(entry)))]
19
+ .map((file) => ({ file, sha256: sha256File(file, "continuation adapter integrity file") }));
20
+ if (!integrity.some((entry) => entry.file === command.argv[0])) throw new Error("continuation adapter executable must be a regular file");
21
+ const identity = createHash("sha256").update(JSON.stringify({ ...command, integrity })).digest("hex");
22
+ return { ...command, identity, integrity };
23
+ }
24
+
25
+ export async function executeContinuationAdapter(
26
+ commandFileInput: string,
27
+ request: ContinuationTurnRequest,
28
+ options: ContinuationAdapterOptions,
29
+ ): Promise<ContinuationTurnResult> {
30
+ const command = loadContinuationAdapterCommand(commandFileInput);
31
+ return executeLoadedContinuationAdapter(command, request, options);
32
+ }
33
+
34
+ export async function executeLoadedContinuationAdapter(
35
+ command: ContinuationAdapterCommand,
36
+ request: ContinuationTurnRequest,
37
+ options: ContinuationAdapterOptions,
38
+ ): Promise<ContinuationTurnResult> {
39
+ assertLoadedContinuationAdapterIntegrity(command);
40
+ assertPositiveInteger(options.timeoutMs, "continuation adapter timeoutMs", 1, 86_400_000);
41
+ return await spawnAdapter(command, request, options);
42
+ }
43
+
44
+ export function assertLoadedContinuationAdapterIntegrity(command: ContinuationAdapterCommand): void {
45
+ for (const entry of command.integrity) {
46
+ if (sha256File(entry.file, "continuation adapter integrity file") !== entry.sha256) {
47
+ throw new Error(`continuation adapter integrity changed after mission binding: ${entry.file}`);
48
+ }
49
+ }
50
+ }
51
+
52
+ export async function waitForContinuationBarrier(
53
+ barrier: ContinuationBarrier,
54
+ options: { maxWaitMs: number; pollMs: number; now?: () => number; sleep?: (ms: number) => Promise<void> },
55
+ ): Promise<"ready" | "pending"> {
56
+ assertPositiveInteger(options.maxWaitMs, "continuation barrier maxWaitMs", 0, 86_400_000);
57
+ assertPositiveInteger(options.pollMs, "continuation barrier pollMs", 1, 60_000);
58
+ const now = options.now ?? Date.now;
59
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
60
+ const stopAt = now() + options.maxWaitMs;
61
+
62
+ if (barrier.kind === "deadline") {
63
+ const deadline = Date.parse(barrier.at);
64
+ if (deadline <= now()) return "ready";
65
+ const waitMs = Math.min(deadline - now(), Math.max(0, stopAt - now()));
66
+ if (waitMs > 0) await sleep(waitMs);
67
+ return Date.parse(barrier.at) <= now() ? "ready" : "pending";
68
+ }
69
+
70
+ while (pidAlive(barrier.pid)) {
71
+ const remaining = stopAt - now();
72
+ if (remaining <= 0) return "pending";
73
+ await sleep(Math.min(options.pollMs, remaining));
74
+ }
75
+ return "ready";
76
+ }
77
+
78
+ function spawnAdapter(
79
+ command: ContinuationAdapterCommand,
80
+ request: ContinuationTurnRequest,
81
+ options: ContinuationAdapterOptions,
82
+ ): Promise<ContinuationTurnResult> {
83
+ return new Promise((resolve, reject) => {
84
+ const child = spawn(command.argv[0]!, command.argv.slice(1), {
85
+ cwd: options.cwd,
86
+ env: adapterEnvironment(options),
87
+ detached: process.platform !== "win32",
88
+ shell: false,
89
+ stdio: ["pipe", "pipe", "pipe"],
90
+ });
91
+ const stdout: Buffer[] = [];
92
+ const stderr: Buffer[] = [];
93
+ let stdoutBytes = 0;
94
+ let stderrBytes = 0;
95
+ let settled = false;
96
+ let forcedKill: NodeJS.Timeout | undefined;
97
+ let terminationError: Error | undefined;
98
+ const maxBytes = 4 * 1024 * 1024;
99
+ const timeout = setTimeout(() => {
100
+ if (settled) return;
101
+ terminationError = new ContinuationAdapterTimeoutError(options.timeoutMs);
102
+ terminateProcessGroup(child.pid, "SIGTERM");
103
+ forcedKill = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 250);
104
+ }, options.timeoutMs);
105
+
106
+ child.stdout.on("data", (chunk: Buffer) => {
107
+ stdoutBytes += chunk.length;
108
+ if (stdoutBytes > maxBytes) {
109
+ if (!terminationError) {
110
+ terminationError = new Error("continuation adapter stdout exceeded 4 MiB");
111
+ clearTimeout(timeout);
112
+ terminateProcessGroup(child.pid, "SIGTERM");
113
+ forcedKill = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 250);
114
+ }
115
+ return;
116
+ }
117
+ stdout.push(chunk);
118
+ });
119
+ child.stderr.on("data", (chunk: Buffer) => {
120
+ stderrBytes += chunk.length;
121
+ if (stderrBytes <= maxBytes) stderr.push(chunk);
122
+ });
123
+ child.on("error", (error) => {
124
+ if (settled) return;
125
+ settled = true;
126
+ clearTimeout(timeout);
127
+ if (forcedKill) clearTimeout(forcedKill);
128
+ reject(error);
129
+ });
130
+ child.on("close", (code, signal) => {
131
+ if (settled) return;
132
+ settled = true;
133
+ clearTimeout(timeout);
134
+ if (forcedKill) clearTimeout(forcedKill);
135
+ if (terminationError) {
136
+ reject(terminationError);
137
+ return;
138
+ }
139
+ const stderrText = Buffer.concat(stderr).toString("utf8").trim();
140
+ if (code !== 0) {
141
+ scheduleProcessGroupTermination(child.pid);
142
+ reject(new Error(`continuation adapter exited ${code ?? signal ?? "unknown"}${stderrText ? `: ${stderrText}` : ""}`));
143
+ return;
144
+ }
145
+ const output = Buffer.concat(stdout).toString("utf8").trim();
146
+ try {
147
+ const result = JSON.parse(output) as ContinuationTurnResult;
148
+ if (!isValidWaitResult(result)) scheduleProcessGroupTermination(child.pid);
149
+ resolve(result);
150
+ } catch {
151
+ scheduleProcessGroupTermination(child.pid);
152
+ reject(new Error("continuation adapter must emit exactly one JSON result on stdout"));
153
+ }
154
+ });
155
+ child.stdin.on("error", (error) => {
156
+ if ((error as NodeJS.ErrnoException).code !== "EPIPE" && !settled) {
157
+ settled = true;
158
+ clearTimeout(timeout);
159
+ if (forcedKill) clearTimeout(forcedKill);
160
+ scheduleProcessGroupTermination(child.pid);
161
+ reject(error);
162
+ }
163
+ });
164
+ child.stdin.end(`${JSON.stringify(request)}\n`);
165
+ });
166
+ }
167
+
168
+ type ContinuationAdapterOptions = {
169
+ cwd: string;
170
+ timeoutMs: number;
171
+ continuationTurnSecret?: string;
172
+ continuationRunId?: string;
173
+ };
174
+
175
+ function adapterEnvironment(options: ContinuationAdapterOptions): NodeJS.ProcessEnv {
176
+ const env = { ...process.env };
177
+ delete env.FLOW_AGENTS_CONTINUATION_TURN_SECRET;
178
+ delete env.FLOW_AGENTS_CONTINUATION_RUN_ID;
179
+ delete env.FLOW_AGENTS_CONTINUATION_TURN_NONCE;
180
+ delete env.FLOW_AGENTS_CONTINUATION_TURN_PUBLIC_KEY_DIGEST;
181
+ delete env.FLOW_AGENTS_CONTINUATION_ACTOR_B64;
182
+ const { continuationTurnSecret: turnSecret, continuationRunId: runId } = options;
183
+ if (!turnSecret || !runId) return env;
184
+ env.FLOW_AGENTS_CONTINUATION_TURN_SECRET = turnSecret;
185
+ env.FLOW_AGENTS_CONTINUATION_RUN_ID = runId;
186
+ return env;
187
+ }
188
+
189
+ function validateAdapterCommand(value: unknown): Pick<ContinuationAdapterCommand, "argv"> {
190
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("continuation adapter command file must contain an object");
191
+ const argv = (value as { argv?: unknown }).argv;
192
+ if (!Array.isArray(argv) || argv.length === 0 || argv.length > 128 || argv.some((entry) => typeof entry !== "string" || entry.length === 0 || entry.includes("\0"))) {
193
+ throw new Error("continuation adapter command argv must contain 1 through 128 non-empty strings");
194
+ }
195
+ return { argv: [...argv] as string[] };
196
+ }
197
+
198
+ function assertRegularFile(file: string, label: string): void {
199
+ const stat = fs.lstatSync(file);
200
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`${label} must be a regular file`);
201
+ }
202
+
203
+ function readRegularFileNoFollow(file: string, label: string): string {
204
+ return readRegularFileBufferNoFollow(file, label).toString("utf8");
205
+ }
206
+
207
+ function readRegularFileBufferNoFollow(file: string, label: string): Buffer {
208
+ assertRegularFile(file, label);
209
+ const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
210
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
211
+ try {
212
+ if (!fs.fstatSync(fd).isFile()) throw new Error(`${label} must be a regular file`);
213
+ return fs.readFileSync(fd);
214
+ } finally {
215
+ fs.closeSync(fd);
216
+ }
217
+ }
218
+
219
+ function assertPositiveInteger(value: number, label: string, min: number, max: number): void {
220
+ if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error(`${label} must be an integer from ${min} through ${max}`);
221
+ }
222
+
223
+ function pidAlive(pid: number): boolean {
224
+ try {
225
+ process.kill(pid, 0);
226
+ return true;
227
+ } catch (error) {
228
+ return (error as NodeJS.ErrnoException).code === "EPERM";
229
+ }
230
+ }
231
+
232
+ function terminateProcessGroup(pid: number | undefined, signal: NodeJS.Signals): void {
233
+ if (!pid) return;
234
+ try {
235
+ process.kill(process.platform === "win32" ? pid : -pid, signal);
236
+ } catch (error) {
237
+ if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
238
+ }
239
+ }
240
+
241
+ function isValidWaitResult(result: ContinuationTurnResult): boolean {
242
+ if (result?.status !== "wait" || !result.barrier || typeof result.barrier !== "object") return false;
243
+ if (result.barrier.kind === "pid") return Number.isSafeInteger(result.barrier.pid) && result.barrier.pid > 0;
244
+ return result.barrier.kind === "deadline" && typeof result.barrier.at === "string" && Number.isFinite(Date.parse(result.barrier.at));
245
+ }
246
+
247
+ function scheduleProcessGroupTermination(pid: number | undefined): void {
248
+ terminateProcessGroup(pid, "SIGTERM");
249
+ const forcedKill = setTimeout(() => terminateProcessGroup(pid, "SIGKILL"), 250);
250
+ forcedKill.unref();
251
+ }
252
+
253
+ function regularFileExists(file: string): boolean {
254
+ try {
255
+ const stat = fs.lstatSync(file);
256
+ return !stat.isSymbolicLink() && stat.isFile();
257
+ } catch (error) {
258
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
259
+ throw error;
260
+ }
261
+ }
262
+
263
+ function sha256File(file: string, label: string): string {
264
+ return createHash("sha256").update(readRegularFileBufferNoFollow(file, label)).digest("hex");
265
+ }