@kontourai/flow-agents 3.9.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 (106) hide show
  1. package/.github/workflows/ci.yml +8 -0
  2. package/CHANGELOG.md +17 -0
  3. package/README.md +1 -1
  4. package/build/src/cli/assignment-provider.js +4 -18
  5. package/build/src/cli/continuation-adapter.d.ts +10 -8
  6. package/build/src/cli/continuation-adapter.js +22 -4
  7. package/build/src/cli/validate-workflow-artifacts.js +21 -1
  8. package/build/src/cli/workflow-sidecar.js +15 -20
  9. package/build/src/cli/workflow.js +58 -11
  10. package/build/src/continuation-driver.d.ts +27 -3
  11. package/build/src/continuation-driver.js +97 -14
  12. package/build/src/index.d.ts +2 -2
  13. package/build/src/index.js +1 -1
  14. package/build/src/tools/build-universal-bundles.js +52 -25
  15. package/build/src/tools/validate-source-tree.js +5 -2
  16. package/context/scripts/hooks/lib/continuation-turn-authority.js +386 -0
  17. package/context/scripts/hooks/stop-goal-fit.js +244 -73
  18. package/context/scripts/hooks/workflow-steering.js +24 -15
  19. package/context/scripts/telemetry/lib/pricing.sh +18 -6
  20. package/context/scripts/telemetry/lib/transport.sh +139 -37
  21. package/context/scripts/telemetry/lib/usage.sh +282 -32
  22. package/context/scripts/telemetry/lib/usage_model_guard.sh +42 -0
  23. package/context/scripts/telemetry/telemetry.sh +76 -1
  24. package/docs/coordination-guide.md +21 -5
  25. package/docs/fixture-ownership.md +2 -1
  26. package/docs/public-workflow-cli.md +44 -0
  27. package/docs/spec/runtime-hook-surface.md +8 -4
  28. package/evals/acceptance/prove-capture-teeth-declared.sh +13 -0
  29. package/evals/acceptance/prove-capture-teeth.sh +13 -0
  30. package/evals/ci/run-baseline.sh +2 -0
  31. package/evals/fixtures/codex-legacy-agents/5273878130bdafc8a024a650bb5b66c9b003f1f859b5dc6e5b588cbf4ab23228.md +25 -0
  32. package/evals/fixtures/telemetry/usage-transcript-adversarial.jsonl +2 -0
  33. package/evals/fixtures/telemetry/usage-transcript-malformed-sandwich.jsonl +3 -0
  34. package/evals/fixtures/telemetry/usage-transcript-negative-tokens.jsonl +1 -0
  35. package/evals/fixtures/telemetry/usage-transcript-oversized-prefix.jsonl +201 -0
  36. package/evals/fixtures/telemetry/usage-transcript-oversized-tokens.jsonl +1 -0
  37. package/evals/fixtures/telemetry/usage-transcript-prefixed-secret-lower.jsonl +1 -0
  38. package/evals/fixtures/telemetry/usage-transcript-prefixed-secret-upper.jsonl +1 -0
  39. package/evals/fixtures/telemetry/usage-transcript-secret-model-jwt.jsonl +1 -0
  40. package/evals/fixtures/telemetry/usage-transcript-secret-model.jsonl +1 -0
  41. package/evals/fixtures/telemetry/usage-transcript-torn-trailing.jsonl +2 -0
  42. package/evals/integration/test_actor_identity.sh +63 -0
  43. package/evals/integration/test_assignment_provider_local_file.sh +34 -0
  44. package/evals/integration/test_builder_entry_enforcement.sh +5 -1
  45. package/evals/integration/test_bundle_install.sh +106 -17
  46. package/evals/integration/test_bundle_lifecycle.sh +17 -3
  47. package/evals/integration/test_captured_fail_reconciliation.sh +11 -0
  48. package/evals/integration/test_ci_actor_identity.sh +2 -2
  49. package/evals/integration/test_command_log_concurrency.sh +11 -0
  50. package/evals/integration/test_command_log_fork_classification.sh +11 -0
  51. package/evals/integration/test_command_log_integrity.sh +11 -0
  52. package/evals/integration/test_current_json_per_actor.sh +416 -21
  53. package/evals/integration/test_enforcer_expects_driven.sh +11 -0
  54. package/evals/integration/test_ensure_session_ownership_guard.sh +39 -0
  55. package/evals/integration/test_evidence_capture_hook.sh +25 -6
  56. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  57. package/evals/integration/test_flowdef_union_floor_regression.sh +11 -0
  58. package/evals/integration/test_gate_bypass_chain.sh +11 -0
  59. package/evals/integration/test_gate_lockdown.sh +10 -0
  60. package/evals/integration/test_goal_fit_ghost_session.sh +11 -0
  61. package/evals/integration/test_goal_fit_hook.sh +257 -9
  62. package/evals/integration/test_goal_fit_rederive.sh +11 -0
  63. package/evals/integration/test_install_merge.sh +141 -3
  64. package/evals/integration/test_liveness_conflict_injection.sh +39 -3
  65. package/evals/integration/test_liveness_heartbeat.sh +46 -9
  66. package/evals/integration/test_phase_map_and_gate_claim.sh +10 -1
  67. package/evals/integration/test_public_workflow_cli.sh +6 -6
  68. package/evals/integration/test_session_resume_roundtrip.sh +30 -0
  69. package/evals/integration/test_stop_hook_release.sh +111 -11
  70. package/evals/integration/test_telemetry_tool_usage.sh +394 -0
  71. package/evals/integration/test_verify_hold.sh +64 -1
  72. package/evals/integration/test_workflow_sidecar_writer.sh +10 -1
  73. package/evals/integration/test_workflow_steering_hook.sh +20 -4
  74. package/evals/run.sh +2 -0
  75. package/evals/static/test_universal_bundles.sh +123 -0
  76. package/package.json +1 -1
  77. package/packaging/README.md +21 -0
  78. package/packaging/codex-legacy-agents-fingerprints.json +33 -0
  79. package/scripts/README.md +2 -1
  80. package/scripts/audit-codex-legacy-agents.js +43 -0
  81. package/scripts/classify-codex-legacy-agents.js +71 -0
  82. package/scripts/hooks/evidence-capture.js +18 -6
  83. package/scripts/hooks/lib/actor-identity.js +62 -29
  84. package/scripts/hooks/lib/continuation-turn-authority.js +386 -0
  85. package/scripts/hooks/lib/current-pointer.js +114 -12
  86. package/scripts/hooks/lib/liveness-heartbeat.js +15 -12
  87. package/scripts/hooks/stop-goal-fit.js +244 -73
  88. package/scripts/hooks/workflow-steering.js +24 -15
  89. package/scripts/install-codex-home.sh +8 -7
  90. package/scripts/telemetry/lib/pricing.sh +18 -6
  91. package/scripts/telemetry/lib/transport.sh +71 -0
  92. package/scripts/telemetry/lib/usage.sh +282 -32
  93. package/scripts/telemetry/lib/usage_model_guard.sh +42 -0
  94. package/scripts/telemetry/telemetry.sh +76 -1
  95. package/src/cli/assignment-provider.ts +5 -26
  96. package/src/cli/builder-flow-runtime.test.mjs +93 -6
  97. package/src/cli/continuation-adapter.ts +33 -7
  98. package/src/cli/continuation-driver.test.mjs +741 -2
  99. package/src/cli/public-api.test.mjs +1 -0
  100. package/src/cli/validate-workflow-artifacts.ts +19 -1
  101. package/src/cli/workflow-sidecar.ts +18 -23
  102. package/src/cli/workflow.ts +71 -11
  103. package/src/continuation-driver.ts +125 -19
  104. package/src/index.ts +4 -0
  105. package/src/tools/build-universal-bundles.ts +61 -25
  106. package/src/tools/validate-source-tree.ts +5 -2
@@ -37,6 +37,7 @@ test("public API retains the documented native-host compatibility surface", asyn
37
37
  "startBuilderBuildRun", "evaluateBuilderBuildRun", "startBuilderFlowSession",
38
38
  "pauseBuilderFlowSession", "resumeBuilderFlowSession", "cancelBuilderFlowSession",
39
39
  "archiveBuilderFlowSession", "recoverBuilderFlowSession", "releaseBuilderFlowAssignment",
40
+ "ContinuationAdapterTimeoutError",
40
41
  "writeJson", "appendJsonl", "sidecarBase", "writeState", "writeSidecar",
41
42
  ]) {
42
43
  assert.equal(typeof lib[name], "function", `${name} must remain package-root exported`);
@@ -22,6 +22,10 @@ const sidecarSchemas: Record<string, string> = {
22
22
  "release.json": "schemas/workflow-release.schema.json",
23
23
  "learning.json": "schemas/workflow-learning.schema.json",
24
24
  };
25
+ // Runtime coordination records live below a session but are not workflow
26
+ // sidecars. Recursing into them would validate continuation-driver/state.json
27
+ // against the public workflow-state schema and turn an active driver into a
28
+ // false Goal Fit hard block.
25
29
 
26
30
  function readText(file: string): string {
27
31
  return fs.readFileSync(file, "utf8");
@@ -91,11 +95,25 @@ function hasExplicitAcceptance(text: string): boolean {
91
95
  return /explicitly accepted|accepted by user|user accepted|accepted gap/i.test(text);
92
96
  }
93
97
 
98
+ function isPrivateRuntimeChild(dir: string): boolean {
99
+ if (path.basename(dir) !== "continuation-driver") return false;
100
+ const sessionDir = path.dirname(dir);
101
+ const sessionState = path.join(sessionDir, "state.json");
102
+ try {
103
+ const stat = fs.lstatSync(sessionState);
104
+ if (stat.isSymbolicLink() || !stat.isFile()) return false;
105
+ const state = JSON.parse(readText(sessionState)) as { task_slug?: unknown };
106
+ return state.task_slug === path.basename(sessionDir);
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
94
112
  function walk(dir: string): string[] {
95
113
  const out: string[] = [];
96
114
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
97
115
  const p = path.join(dir, entry.name);
98
- if (entry.isDirectory()) out.push(...walk(p));
116
+ if (entry.isDirectory() && !isPrivateRuntimeChild(p)) out.push(...walk(p));
99
117
  else out.push(p);
100
118
  }
101
119
  return out;
@@ -275,7 +275,9 @@ function resolveEnsureSessionActor(p: ReturnType<typeof parseArgs>): { actorStru
275
275
  die("ensure-session --actor value strips to empty under the allowed actor charset ([A-Za-z0-9_.-]) — pass a value containing at least one letter, digit, underscore, period, or hyphen.");
276
276
  }
277
277
  const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
278
- const resolved = explicitActor ? { actor: explicitActor, source: "explicit-override" } : helper.resolveActor(process.env);
278
+ const resolved = explicitActor
279
+ ? { actor: explicitActor, source: "explicit-override", actorStruct: { runtime: "explicit-override", session_id: explicitActor, host: os.hostname() } }
280
+ : helper.resolveActorIdentity(process.env);
279
281
  const unresolved = helper.isUnresolvedActor(resolved.actor);
280
282
  const branchActorKey = unresolved ? `unknown-actor-${unknownDisambiguator(resolved.actor)}` : resolved.actor;
281
283
 
@@ -286,17 +288,8 @@ function resolveEnsureSessionActor(p: ReturnType<typeof parseArgs>): { actorStru
286
288
  return { actorStruct: { runtime: "unresolved", session_id: branchActorKey, host: os.hostname() }, actorKey: resolved.actor, branchActorKey, unresolved: true };
287
289
  }
288
290
 
289
- // #398: the CI-runtime tier must reconstruct the SAME struct resolveActor serialized, or
290
- // `serializeActor(actorStruct)` would diverge from `resolved.actor` (the else-branch would rebuild
291
- // an ANCESTRY struct — detectRuntime→unknown, runtimeSessionId→'' — so the claim's stored
292
- // actor_key would not match the CI actor at publish → self not recognized → false-block, the exact
293
- // bug this issue removes). Uses the SAME helper.detectCiActor as resolveActor, single-sourced.
294
- const ciActor = resolved.source.startsWith("ci-runtime") ? helper.detectCiActor(process.env) : null;
295
- const actorStruct: ActorStruct = resolved.source === "explicit-override"
296
- ? { runtime: "explicit-override", session_id: resolved.actor, host: os.hostname() }
297
- : ciActor && ciActor.session_id
298
- ? { runtime: ciActor.runtime, session_id: ciActor.session_id, host: os.hostname() }
299
- : { runtime: helper.detectRuntime(process.env), session_id: helper.runtimeSessionId(process.env) || (() => { const seed = helper.ancestorActorSeed(); return seed ? `anc-${seed}` : ""; })(), host: os.hostname() };
291
+ if (!resolved.actorStruct) die("ensure-session could not resolve a canonical actor struct");
292
+ const actorStruct: ActorStruct = resolved.actorStruct;
300
293
  const actorKey = helper.serializeActor(actorStruct);
301
294
  return { actorStruct, actorKey, branchActorKey, unresolved: false };
302
295
  }
@@ -1613,6 +1606,7 @@ function resolveFirstStep(flowId: string, repoRoot: string): string | null {
1613
1606
  function loadCurrentPointerHelper(): {
1614
1607
  perActorCurrentFile: (flowAgentsDir: string, actorKey: string) => string;
1615
1608
  readCurrentPointer: (flowAgentsDir: string, actorKey?: string) => { payload: AnyObj | null; source: "per-actor" | "legacy" | "none"; file: string | null };
1609
+ readOwnCurrentPointer: (flowAgentsDir: string, actorKey?: string) => { payload: AnyObj | null; source: "per-actor" | "legacy" | "none"; file: string | null };
1616
1610
  writePerActorCurrent: (flowAgentsDir: string, actorKey: string, payload: AnyObj) => void;
1617
1611
  } {
1618
1612
  const _req = createRequire(import.meta.url);
@@ -1620,6 +1614,7 @@ function loadCurrentPointerHelper(): {
1620
1614
  return _req(helperPath) as {
1621
1615
  perActorCurrentFile: (flowAgentsDir: string, actorKey: string) => string;
1622
1616
  readCurrentPointer: (flowAgentsDir: string, actorKey?: string) => { payload: AnyObj | null; source: "per-actor" | "legacy" | "none"; file: string | null };
1617
+ readOwnCurrentPointer: (flowAgentsDir: string, actorKey?: string) => { payload: AnyObj | null; source: "per-actor" | "legacy" | "none"; file: string | null };
1623
1618
  writePerActorCurrent: (flowAgentsDir: string, actorKey: string, payload: AnyObj) => void;
1624
1619
  };
1625
1620
  }
@@ -1758,9 +1753,15 @@ function updateCurrentAgent(root: string, dir: string, agentId: string, status:
1758
1753
 
1759
1754
  if (actorKey && !loadActorIdentityHelper().isUnresolvedActor(actorKey)) {
1760
1755
  const helper = loadCurrentPointerHelper();
1761
- const perActorFile = helper.perActorCurrentFile(root, actorKey);
1762
- let perActor: AnyObj | null = null;
1763
- try { perActor = fs.existsSync(perActorFile) ? (JSON.parse(fs.readFileSync(perActorFile, "utf8")) as AnyObj) : null; } catch { perActor = null; }
1756
+ // #440 fix-wave 3: read through the legacy-aware path (readOwnCurrentPointer tries the new
1757
+ // collision-resistant filename first, then falls back to the pre-fix-wave-2 legacy filename)
1758
+ // instead of a direct fs read of perActorCurrentFile() alone a direct read of ONLY the new
1759
+ // filename would silently skip this active_agents/updated_at projection for a pointer a
1760
+ // still-running pre-fix-wave-2 sidecar wrote under the old name. The write below always goes
1761
+ // through writePerActorCurrent (new filename only), so the pointer migrates to the new name
1762
+ // on first touch.
1763
+ const pointer = helper.readOwnCurrentPointer(root, actorKey);
1764
+ const perActor = pointer.payload;
1764
1765
  if (perActor && path.resolve(root, perActor.artifact_dir ?? "") === path.resolve(dir)) {
1765
1766
  helper.writePerActorCurrent(root, actorKey, applyAgentUpdate(perActor));
1766
1767
  }
@@ -6088,6 +6089,7 @@ export const LIVENESS_TERMINAL = new Set(["delivered", "canceled", "accepted", "
6088
6089
  */
6089
6090
  function loadActorIdentityHelper(): {
6090
6091
  resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
6092
+ resolveActorIdentity: (env: NodeJS.ProcessEnv) => { actor: string; source: string; actorStruct: ActorStruct | null };
6091
6093
  sanitizeSegment: (value: unknown) => string;
6092
6094
  isUnresolvedActor: (actor: string) => boolean;
6093
6095
  // #291 Wave 2 Task 2.1: widened (additive only — every existing caller above keeps using only
@@ -6097,22 +6099,15 @@ function loadActorIdentityHelper(): {
6097
6099
  // assignment-claim identity (a DIFFERENT identity concept from the flat actorKey used for
6098
6100
  // branch-naming/liveness — see resolveEnsureSessionActor's doc comment for why both exist).
6099
6101
  serializeActor: (actor: Partial<ActorStruct> | undefined) => string;
6100
- detectRuntime: (env: NodeJS.ProcessEnv) => string;
6101
- runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
6102
- detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
6103
- ancestorActorSeed: () => string;
6104
6102
  } {
6105
6103
  const _req = createRequire(import.meta.url);
6106
6104
  const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
6107
6105
  return _req(helperPath) as {
6108
6106
  resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
6107
+ resolveActorIdentity: (env: NodeJS.ProcessEnv) => { actor: string; source: string; actorStruct: ActorStruct | null };
6109
6108
  sanitizeSegment: (value: unknown) => string;
6110
6109
  isUnresolvedActor: (actor: string) => boolean;
6111
6110
  serializeActor: (actor: Partial<ActorStruct> | undefined) => string;
6112
- detectRuntime: (env: NodeJS.ProcessEnv) => string;
6113
- runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
6114
- detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
6115
- ancestorActorSeed: () => string;
6116
6111
  };
6117
6112
  }
6118
6113
  function resolveLivenessActor(): string {
@@ -15,7 +15,7 @@ import { flagBool, flagList, flagString, parseArgs } from "../lib/args.js";
15
15
  import { main as builderRun } from "./builder-run.js";
16
16
  import { currentWorkflowSessionDir, isMeaningfulTestCommand, mainFromPublicWorkflow, WORKFLOW_WRITER_CONTRACT_VERSION } from "./workflow-sidecar.js";
17
17
  import { resolveCurrentAssignmentActor, withSubjectLock } from "./assignment-provider.js";
18
- import { executeLoadedContinuationAdapter, loadContinuationAdapterCommand, waitForContinuationBarrier } from "./continuation-adapter.js";
18
+ import { assertLoadedContinuationAdapterIntegrity, executeLoadedContinuationAdapter, loadContinuationAdapterCommand, waitForContinuationBarrier } from "./continuation-adapter.js";
19
19
 
20
20
  type JsonRecord = Record<string, unknown>;
21
21
 
@@ -81,16 +81,39 @@ async function drive(sessionDir: string, argv: string[], json: boolean): Promise
81
81
  const barrierWaitMs = integerFlag(parsed.flags, "barrier-wait-ms", 300_000, 0, 86_400_000);
82
82
  const barrierPollMs = integerFlag(parsed.flags, "barrier-poll-ms", 1_000, 1, 60_000);
83
83
  const { slug, projectRoot } = readBoundSession(sessionDir);
84
- assertMatchingAssignmentActor(sessionDir, slug);
84
+ assertOrdinaryMatchingAssignmentActor(sessionDir, slug);
85
85
  const adapterCommand = loadContinuationAdapterCommand(adapterCommandFile);
86
- const result = await withContinuationDriverLock(sessionDir, async () => {
87
- assertMatchingAssignmentActor(sessionDir, slug);
86
+ const result = await withContinuationDriverLock(sessionDir, async (lock) => {
87
+ assertOrdinaryMatchingAssignmentActor(sessionDir, slug);
88
88
  return driveBuilderFlowSession({
89
89
  sessionDir,
90
90
  maxTurns,
91
91
  adapterCommandIdentity: adapterCommand.identity,
92
- authorizeTurn: async () => { assertMatchingAssignmentActor(sessionDir, slug); },
93
- execute: async (request) => executeLoadedContinuationAdapter(adapterCommand, request, { cwd: projectRoot, timeoutMs: turnTimeoutMs }),
92
+ authorizeTurn: async () => { assertOrdinaryMatchingAssignmentActor(sessionDir, slug); },
93
+ issueTurnAuthority: async (request) => {
94
+ const currentAssignment = assertOrdinaryMatchingAssignmentActor(sessionDir, slug);
95
+ assertLoadedContinuationAdapterIntegrity(adapterCommand);
96
+ const authority = loadContinuationTurnAuthority();
97
+ return authority.issueActiveTurnAuthority({
98
+ sessionDir,
99
+ runId: request.run_id,
100
+ definitionId: request.definition_id,
101
+ currentStep: request.current_step,
102
+ iteration: request.iteration,
103
+ maxTurns: request.max_turns,
104
+ adapterCommandIdentity: adapterCommand.identity,
105
+ assignmentActor: currentAssignment.actorKey,
106
+ assignmentActorStruct: currentAssignment.actor,
107
+ lock,
108
+ timeoutMs: turnTimeoutMs,
109
+ });
110
+ },
111
+ execute: async (request, context) => executeLoadedContinuationAdapter(adapterCommand, request, {
112
+ cwd: projectRoot,
113
+ timeoutMs: turnTimeoutMs,
114
+ continuationTurnSecret: context?.continuationTurnSecret,
115
+ continuationRunId: context?.continuationRunId,
116
+ }),
94
117
  waitForBarrier: async (barrier) => waitForContinuationBarrier(barrier, { maxWaitMs: barrierWaitMs, pollMs: barrierPollMs }),
95
118
  });
96
119
  });
@@ -99,6 +122,16 @@ async function drive(sessionDir: string, argv: string[], json: boolean): Promise
99
122
  return 0;
100
123
  }
101
124
 
125
+ function loadContinuationTurnAuthority(): {
126
+ issueActiveTurnAuthority(input: Record<string, unknown>): { runId: string; turnSecret: string; publicKeyDigest: string; cleanup(): boolean };
127
+ validateSignedActiveTurnAssignmentAuthority(input: Record<string, unknown>): { valid: boolean; record?: { assignment_actor: string; assignment_actor_struct: Record<string, unknown> } };
128
+ } {
129
+ return REQUIRE(path.resolve(PACKAGE_ROOT, "scripts", "hooks", "lib", "continuation-turn-authority.js")) as {
130
+ issueActiveTurnAuthority(input: Record<string, unknown>): { runId: string; turnSecret: string; publicKeyDigest: string; cleanup(): boolean };
131
+ validateSignedActiveTurnAssignmentAuthority(input: Record<string, unknown>): { valid: boolean; record?: { assignment_actor: string; assignment_actor_struct: Record<string, unknown> } };
132
+ };
133
+ }
134
+
102
135
  async function start(argv: string[]): Promise<number> {
103
136
  const parsed = parseArgs(argv);
104
137
  assertOnlyFlags(parsed.flags, new Set(["flow", "work-item", "task-slug", "artifact-root", "source-request", "summary", "title", "criterion", "assignment-provider", "effective-state-json"]), "workflow start");
@@ -518,15 +551,42 @@ function readAssignment(sessionDir: string, slug: string): JsonRecord {
518
551
  }
519
552
 
520
553
  function assertMatchingAssignmentActor(sessionDir: string, slug: string): ReturnType<typeof resolveCurrentAssignmentActor> {
521
- const assignment = readActiveAssignment(sessionDir, slug);
522
- const caller = resolveCurrentAssignmentActor();
523
- const normalizeActor = (value: unknown): JsonRecord | null => value && typeof value === "object" ? { ...(value as JsonRecord), human: (value as JsonRecord).human ?? null } : null;
524
- if (assignment.actor_key !== caller.actorKey || !isDeepStrictEqual(normalizeActor(assignment.actor), normalizeActor(caller.actor))) {
525
- throw new Error("workflow mutation requires the session's active, matching assignment actor");
554
+ const { assignment, caller, matches } = assignmentActorContext(sessionDir, slug);
555
+ if (matches) return caller;
556
+
557
+ const authority = loadContinuationTurnAuthority().validateSignedActiveTurnAssignmentAuthority({
558
+ sessionDir,
559
+ runId: process.env.FLOW_AGENTS_CONTINUATION_RUN_ID,
560
+ turnSecret: process.env.FLOW_AGENTS_CONTINUATION_TURN_SECRET,
561
+ });
562
+ if (authority.valid && authority.record
563
+ && assignment.actor_key === authority.record.assignment_actor
564
+ && isDeepStrictEqual(normalizeAssignmentActor(assignment.actor), normalizeAssignmentActor(authority.record.assignment_actor_struct))) {
565
+ return { actorKey: authority.record.assignment_actor, actor: normalizeAssignmentActor(authority.record.assignment_actor_struct)! as ReturnType<typeof resolveCurrentAssignmentActor>["actor"] };
526
566
  }
567
+ throw new Error("workflow mutation requires the session's active, matching assignment actor");
568
+ }
569
+
570
+ function assertOrdinaryMatchingAssignmentActor(sessionDir: string, slug: string): ReturnType<typeof resolveCurrentAssignmentActor> {
571
+ const { caller, matches } = assignmentActorContext(sessionDir, slug);
572
+ if (!matches) throw new Error("workflow mutation requires the session's active, matching assignment actor");
527
573
  return caller;
528
574
  }
529
575
 
576
+ function assignmentActorContext(sessionDir: string, slug: string): {
577
+ assignment: JsonRecord;
578
+ caller: ReturnType<typeof resolveCurrentAssignmentActor>;
579
+ matches: boolean;
580
+ } {
581
+ const assignment = readActiveAssignment(sessionDir, slug);
582
+ const caller = resolveCurrentAssignmentActor();
583
+ return { assignment, caller, matches: assignment.actor_key === caller.actorKey && isDeepStrictEqual(normalizeAssignmentActor(assignment.actor), normalizeAssignmentActor(caller.actor)) };
584
+ }
585
+
586
+ function normalizeAssignmentActor(value: unknown): JsonRecord | null {
587
+ return value && typeof value === "object" ? { ...(value as JsonRecord), human: (value as JsonRecord).human ?? null } : null;
588
+ }
589
+
530
590
  function readActiveAssignment(sessionDir: string, slug: string): JsonRecord {
531
591
  const assignment = readAssignment(sessionDir, slug);
532
592
  if (assignment.status !== "claimed" || assignment.artifact_dir !== slug || typeof assignment.actor_key !== "string" || !assignment.actor_key || !assignment.actor || typeof assignment.actor !== "object" || Array.isArray(assignment.actor)) {
@@ -32,6 +32,15 @@ export type ContinuationTurnResult =
32
32
  | { status: "completed"; summary?: string }
33
33
  | { status: "wait"; barrier: ContinuationBarrier; summary?: string };
34
34
 
35
+ export class ContinuationAdapterTimeoutError extends Error {
36
+ readonly code = "CONTINUATION_ADAPTER_TIMEOUT";
37
+
38
+ constructor(timeoutMs: number) {
39
+ super(`continuation adapter timed out after ${timeoutMs}ms`);
40
+ this.name = "ContinuationAdapterTimeoutError";
41
+ }
42
+ }
43
+
35
44
  export type ContinuationDriverState = {
36
45
  schema_version: "1.0";
37
46
  run_id: string;
@@ -40,13 +49,17 @@ export type ContinuationDriverState = {
40
49
  adapter_command_identity: string | null;
41
50
  status: "active" | "waiting" | "done" | "failed" | "budget_exhausted";
42
51
  turns_started: number;
52
+ // Added after schema 1.0 shipped. Legacy state files may omit it.
53
+ active_turn_step?: string | null;
54
+ // Anchors the ephemeral signer outside active-turn.json. Legacy state files may omit it.
55
+ active_turn_public_key_digest?: string | null;
43
56
  pending_barrier: ContinuationBarrier | null;
44
57
  updated_at: string;
45
58
  };
46
59
 
47
60
  export type ContinuationDriverEvent = {
48
61
  schema_version: "1.0";
49
- type: "started" | "turn_started" | "turn_completed" | "turn_failed" | "parked" | "resumed" | "done" | "budget_exhausted";
62
+ type: "started" | "turn_started" | "turn_completed" | "gate_not_advanced" | "turn_failed" | "authority_cleanup_failed" | "parked" | "resumed" | "done" | "budget_exhausted";
50
63
  run_id: string;
51
64
  definition_id: string;
52
65
  current_step: string;
@@ -54,6 +67,7 @@ export type ContinuationDriverEvent = {
54
67
  at: string;
55
68
  barrier?: ContinuationBarrier;
56
69
  summary?: string;
70
+ failure_kind?: "adapter_error" | "timeout";
57
71
  };
58
72
 
59
73
  export interface ContinuationStateStore {
@@ -65,9 +79,27 @@ export interface ContinuationStateStore {
65
79
  export interface ContinuationRuntimePort {
66
80
  inspect(): Promise<ContinuationSnapshot>;
67
81
  synchronize(): Promise<ContinuationSnapshot>;
68
- execute(request: ContinuationTurnRequest): Promise<ContinuationTurnResult>;
82
+ execute(request: ContinuationTurnRequest, context?: ContinuationTurnContext): Promise<ContinuationTurnResult>;
69
83
  }
70
84
 
85
+ export type ContinuationTurnContext = {
86
+ continuationTurnSecret?: string;
87
+ continuationRunId?: string;
88
+ };
89
+
90
+ export type ContinuationDriverLockLease = {
91
+ pid: number;
92
+ token: string;
93
+ created_at: string;
94
+ };
95
+
96
+ export type ContinuationTurnAuthority = {
97
+ runId: string;
98
+ turnSecret: string;
99
+ publicKeyDigest: string;
100
+ cleanup(): boolean;
101
+ };
102
+
71
103
  export type ContinuationDriverOutcome = {
72
104
  outcome: "done" | "waiting" | "failed" | "budget_exhausted";
73
105
  turns_started: number;
@@ -82,6 +114,7 @@ export interface RunContinuationDriverInput {
82
114
  store: ContinuationStateStore;
83
115
  waitForBarrier?: (barrier: ContinuationBarrier) => Promise<"ready" | "pending">;
84
116
  authorizeTurn?: () => Promise<void>;
117
+ issueTurnAuthority?: (request: ContinuationTurnRequest) => Promise<ContinuationTurnAuthority>;
85
118
  now?: () => Date;
86
119
  }
87
120
 
@@ -92,6 +125,7 @@ export interface DriveBuilderFlowSessionInput {
92
125
  execute: ContinuationRuntimePort["execute"];
93
126
  waitForBarrier?: RunContinuationDriverInput["waitForBarrier"];
94
127
  authorizeTurn?: RunContinuationDriverInput["authorizeTurn"];
128
+ issueTurnAuthority?: RunContinuationDriverInput["issueTurnAuthority"];
95
129
  now?: () => Date;
96
130
  }
97
131
 
@@ -100,6 +134,7 @@ export async function runContinuationDriver(input: RunContinuationDriverInput):
100
134
  const now = input.now ?? (() => new Date());
101
135
  const waitForBarrier = input.waitForBarrier ?? (async () => "pending" as const);
102
136
  const authorizeTurn = input.authorizeTurn ?? (async () => {});
137
+ const issueTurnAuthority = input.issueTurnAuthority;
103
138
  const adapterCommandIdentity = input.adapterCommandIdentity ?? null;
104
139
  let snapshot = validateSnapshot(await input.runtime.inspect());
105
140
  let state = loadOrCreateState(input.store, snapshot, input.maxTurns, adapterCommandIdentity, now);
@@ -114,10 +149,10 @@ export async function runContinuationDriver(input: RunContinuationDriverInput):
114
149
  const barrier = state.pending_barrier;
115
150
  const readiness = await waitForBarrier(barrier);
116
151
  if (readiness === "pending") {
117
- state = saveState(input.store, state, { status: "waiting" }, now);
152
+ state = saveState(input.store, state, { status: "waiting", active_turn_step: null, active_turn_public_key_digest: null }, now);
118
153
  return { outcome: "waiting", turns_started: state.turns_started, snapshot, barrier };
119
154
  }
120
- state = saveState(input.store, state, { status: "active", pending_barrier: null }, now);
155
+ state = saveState(input.store, state, { status: "active", active_turn_step: null, active_turn_public_key_digest: null, pending_barrier: null }, now);
121
156
  appendEvent(input.store, state, snapshot, "resumed", now, { barrier });
122
157
  }
123
158
 
@@ -127,18 +162,26 @@ export async function runContinuationDriver(input: RunContinuationDriverInput):
127
162
  if (initialOutcome === "done") return finishDone(input.store, state, snapshot, now);
128
163
  if (initialOutcome === "failed") return finishFailed(input.store, state, snapshot, now);
129
164
  if (initialOutcome === "waiting") {
130
- state = saveState(input.store, state, { status: "waiting" }, now);
165
+ state = saveState(input.store, state, { status: "waiting", active_turn_step: null, active_turn_public_key_digest: null }, now);
131
166
  return { outcome: "waiting", turns_started: state.turns_started, snapshot };
132
167
  }
133
168
 
134
169
  while (state.turns_started < input.maxTurns) {
135
170
  await authorizeTurn();
136
171
  const iteration = state.turns_started + 1;
137
- state = saveState(input.store, state, { status: "active", turns_started: iteration }, now);
172
+ state = saveState(input.store, state, {
173
+ status: "active",
174
+ turns_started: iteration,
175
+ active_turn_step: snapshot.current_step,
176
+ active_turn_public_key_digest: null,
177
+ }, now);
138
178
  appendEvent(input.store, state, snapshot, "turn_started", now);
139
179
  let result: ContinuationTurnResult;
180
+ let request: ContinuationTurnRequest;
181
+ let authority: ContinuationTurnAuthority | undefined;
182
+ let terminalized = false;
140
183
  try {
141
- result = validateTurnResult(await input.runtime.execute(Object.freeze({
184
+ request = Object.freeze({
142
185
  schema_version: "1.0",
143
186
  run_id: snapshot.run_id,
144
187
  definition_id: snapshot.definition_id,
@@ -146,19 +189,53 @@ export async function runContinuationDriver(input: RunContinuationDriverInput):
146
189
  iteration,
147
190
  max_turns: input.maxTurns,
148
191
  next_action: snapshot.next_action ? structuredClone(snapshot.next_action) : null,
149
- })));
192
+ });
193
+ authority = issueTurnAuthority ? await issueTurnAuthority(request) : undefined;
194
+ if (authority) {
195
+ state = saveState(input.store, state, { active_turn_public_key_digest: authority.publicKeyDigest }, now);
196
+ }
197
+ result = validateTurnResult(await input.runtime.execute(request, authority ? {
198
+ continuationTurnSecret: authority.turnSecret,
199
+ continuationRunId: authority.runId,
200
+ } : undefined));
150
201
  } catch (error) {
151
- appendEvent(input.store, state, snapshot, "turn_failed", now, { summary: boundedErrorMessage(error) });
202
+ const summary = boundedErrorMessage(error);
203
+ appendEvent(input.store, state, snapshot, "turn_failed", now, { summary, failure_kind: error instanceof ContinuationAdapterTimeoutError ? "timeout" : "adapter_error" });
152
204
  snapshot = validateSnapshot(await input.runtime.synchronize());
153
205
  assertMissionIdentity(state, snapshot);
154
206
  const outcome = canonicalOutcome(snapshot);
155
- if (outcome === "done") return finishDone(input.store, state, snapshot, now);
156
- if (outcome === "failed") return finishFailed(input.store, state, snapshot, now);
207
+ if (outcome === "done") {
208
+ terminalized = true;
209
+ return finishDone(input.store, state, snapshot, now);
210
+ }
211
+ if (outcome === "failed") {
212
+ terminalized = true;
213
+ return finishFailed(input.store, state, snapshot, now);
214
+ }
157
215
  if (outcome === "waiting") {
158
216
  state = saveState(input.store, state, { status: "waiting" }, now);
159
217
  return { outcome: "waiting", turns_started: state.turns_started, snapshot };
160
218
  }
161
219
  continue;
220
+ } finally {
221
+ let cleanupFailure: string | undefined;
222
+ try {
223
+ if (authority && !authority.cleanup()) cleanupFailure = "continuation turn authority cleanup returned false";
224
+ } catch (error) {
225
+ cleanupFailure = boundedErrorMessage(error);
226
+ } finally {
227
+ if (!terminalized) state = saveState(input.store, state, {
228
+ active_turn_step: null,
229
+ active_turn_public_key_digest: null,
230
+ }, now);
231
+ if (cleanupFailure) {
232
+ try {
233
+ appendEvent(input.store, state, snapshot, "authority_cleanup_failed", now, { summary: cleanupFailure });
234
+ } catch {
235
+ // Audit is best effort. A cleanup extension must not replace the adapter outcome.
236
+ }
237
+ }
238
+ }
162
239
  }
163
240
  appendEvent(input.store, state, snapshot, "turn_completed", now, { summary: result.summary });
164
241
 
@@ -175,6 +252,9 @@ export async function runContinuationDriver(input: RunContinuationDriverInput):
175
252
 
176
253
  snapshot = validateSnapshot(await input.runtime.synchronize());
177
254
  assertMissionIdentity(state, snapshot);
255
+ if (snapshot.status === "active" && snapshot.current_step === request.current_step) {
256
+ appendEvent(input.store, state, snapshot, "gate_not_advanced", now, { summary: "adapter completed without canonical gate advancement" });
257
+ }
178
258
  const outcome = canonicalOutcome(snapshot);
179
259
  if (outcome === "done") return finishDone(input.store, state, snapshot, now);
180
260
  if (outcome === "failed") return finishFailed(input.store, state, snapshot, now);
@@ -184,7 +264,11 @@ export async function runContinuationDriver(input: RunContinuationDriverInput):
184
264
  }
185
265
  }
186
266
 
187
- state = saveState(input.store, state, { status: "budget_exhausted" }, now);
267
+ state = saveState(input.store, state, {
268
+ status: "budget_exhausted",
269
+ active_turn_step: null,
270
+ active_turn_public_key_digest: null,
271
+ }, now);
188
272
  appendEvent(input.store, state, snapshot, "budget_exhausted", now);
189
273
  return { outcome: "budget_exhausted", turns_started: state.turns_started, snapshot };
190
274
  }
@@ -203,21 +287,23 @@ export async function driveBuilderFlowSession(input: DriveBuilderFlowSessionInpu
203
287
  store: createFileContinuationStore(sessionDir),
204
288
  ...(input.waitForBarrier ? { waitForBarrier: input.waitForBarrier } : {}),
205
289
  ...(input.authorizeTurn ? { authorizeTurn: input.authorizeTurn } : {}),
290
+ ...(input.issueTurnAuthority ? { issueTurnAuthority: input.issueTurnAuthority } : {}),
206
291
  ...(input.now ? { now: input.now } : {}),
207
292
  });
208
293
  }
209
294
 
210
- export async function withContinuationDriverLock<T>(sessionDirInput: string, body: () => Promise<T>): Promise<T> {
295
+ export async function withContinuationDriverLock<T>(sessionDirInput: string, body: (lease: ContinuationDriverLockLease) => Promise<T>): Promise<T> {
211
296
  const sessionDir = path.resolve(sessionDirInput);
212
297
  const driverDir = path.join(sessionDir, "continuation-driver");
213
298
  const locksDir = path.join(driverDir, "locks");
214
299
  ensureSafeDirectory(sessionDir, locksDir);
215
300
  const token = randomUUID();
216
301
  const lockFile = path.join(locksDir, `${process.pid}-${token}.lock`);
217
- const owner = { schema_version: "1.0" as const, pid: process.pid, token, created_at: new Date().toISOString() };
302
+ const lease: ContinuationDriverLockLease = { pid: process.pid, token, created_at: new Date().toISOString() };
303
+ const owner = { schema_version: "1.0" as const, ...lease };
218
304
  acquireDriverLock(locksDir, lockFile, owner);
219
305
  try {
220
- return await body();
306
+ return await body(lease);
221
307
  } finally {
222
308
  releaseDriverLock(lockFile, token);
223
309
  }
@@ -362,6 +448,8 @@ function loadOrCreateState(
362
448
  adapter_command_identity: adapterCommandIdentity,
363
449
  status: "active",
364
450
  turns_started: 0,
451
+ active_turn_step: null,
452
+ active_turn_public_key_digest: null,
365
453
  pending_barrier: null,
366
454
  updated_at: now().toISOString(),
367
455
  };
@@ -375,20 +463,30 @@ function canonicalOutcome(snapshot: ContinuationSnapshot): "done" | "waiting" |
375
463
  }
376
464
 
377
465
  function finishDone(store: ContinuationStateStore, state: ContinuationDriverState, snapshot: ContinuationSnapshot, now: () => Date): ContinuationDriverOutcome {
378
- const done = saveState(store, state, { status: "done", pending_barrier: null }, now);
466
+ const done = saveState(store, state, {
467
+ status: "done",
468
+ active_turn_step: null,
469
+ active_turn_public_key_digest: null,
470
+ pending_barrier: null,
471
+ }, now);
379
472
  appendEvent(store, done, snapshot, "done", now);
380
473
  return { outcome: "done", turns_started: done.turns_started, snapshot };
381
474
  }
382
475
 
383
476
  function finishFailed(store: ContinuationStateStore, state: ContinuationDriverState, snapshot: ContinuationSnapshot, now: () => Date): ContinuationDriverOutcome {
384
- const failed = saveState(store, state, { status: "failed", pending_barrier: null }, now);
477
+ const failed = saveState(store, state, {
478
+ status: "failed",
479
+ active_turn_step: null,
480
+ active_turn_public_key_digest: null,
481
+ pending_barrier: null,
482
+ }, now);
385
483
  return { outcome: "failed", turns_started: failed.turns_started, snapshot };
386
484
  }
387
485
 
388
486
  function saveState(
389
487
  store: ContinuationStateStore,
390
488
  current: ContinuationDriverState,
391
- patch: Partial<Pick<ContinuationDriverState, "status" | "turns_started" | "pending_barrier">>,
489
+ patch: Partial<Pick<ContinuationDriverState, "status" | "turns_started" | "active_turn_step" | "active_turn_public_key_digest" | "pending_barrier">>,
392
490
  now: () => Date,
393
491
  ): ContinuationDriverState {
394
492
  const next = { ...current, ...patch, updated_at: now().toISOString() };
@@ -402,7 +500,7 @@ function appendEvent(
402
500
  snapshot: ContinuationSnapshot,
403
501
  type: ContinuationDriverEvent["type"],
404
502
  now: () => Date,
405
- extra: Pick<ContinuationDriverEvent, "barrier" | "summary"> = {},
503
+ extra: Pick<ContinuationDriverEvent, "barrier" | "summary" | "failure_kind"> = {},
406
504
  ): void {
407
505
  store.append({
408
506
  schema_version: "1.0",
@@ -414,6 +512,7 @@ function appendEvent(
414
512
  at: now().toISOString(),
415
513
  ...(extra.barrier ? { barrier: extra.barrier } : {}),
416
514
  ...(extra.summary ? { summary: extra.summary } : {}),
515
+ ...(extra.failure_kind ? { failure_kind: extra.failure_kind } : {}),
417
516
  });
418
517
  }
419
518
 
@@ -461,6 +560,13 @@ function validateState(state: ContinuationDriverState): void {
461
560
  }
462
561
  if (!Number.isSafeInteger(state.turns_started) || state.turns_started < 0) throw new Error("continuation driver turns_started must be a non-negative integer");
463
562
  if (state.turns_started > state.max_turns) throw new Error("continuation driver turns_started cannot exceed max_turns");
563
+ if (state.active_turn_step !== undefined && state.active_turn_step !== null && (typeof state.active_turn_step !== "string" || state.active_turn_step.length === 0)) {
564
+ throw new Error("continuation driver active_turn_step must be a non-empty string or null");
565
+ }
566
+ if (state.active_turn_public_key_digest !== undefined && state.active_turn_public_key_digest !== null
567
+ && (typeof state.active_turn_public_key_digest !== "string" || !/^[a-f0-9]{64}$/.test(state.active_turn_public_key_digest))) {
568
+ throw new Error("continuation driver active_turn_public_key_digest must be a SHA-256 hex digest or null");
569
+ }
464
570
  if (!new Set(["active", "waiting", "done", "failed", "budget_exhausted"]).has(state.status)) throw new Error("continuation driver state status is invalid");
465
571
  if (state.status === "budget_exhausted" && state.turns_started !== state.max_turns) {
466
572
  throw new Error("continuation driver budget_exhausted state must consume max_turns");
package/src/index.ts CHANGED
@@ -46,6 +46,7 @@ export {
46
46
  export type { BuilderFlowAgentLifecycleInput, BuilderFlowAuthorizedLifecycleInput, BuilderFlowSessionInput, BuilderFlowSessionResult } from "./builder-flow-runtime.js";
47
47
 
48
48
  export {
49
+ ContinuationAdapterTimeoutError,
49
50
  createFileContinuationStore,
50
51
  driveBuilderFlowSession,
51
52
  runContinuationDriver,
@@ -54,11 +55,14 @@ export {
54
55
  export type {
55
56
  ContinuationBarrier,
56
57
  ContinuationDriverEvent,
58
+ ContinuationDriverLockLease,
57
59
  ContinuationDriverOutcome,
58
60
  ContinuationDriverState,
59
61
  ContinuationRuntimePort,
60
62
  ContinuationSnapshot,
61
63
  ContinuationStateStore,
64
+ ContinuationTurnAuthority,
65
+ ContinuationTurnContext,
62
66
  ContinuationTurnRequest,
63
67
  ContinuationTurnResult,
64
68
  DriveBuilderFlowSessionInput,