@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
@@ -34,6 +34,10 @@ jobs:
34
34
  steps:
35
35
  - name: Checkout
36
36
  uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
37
+ with:
38
+ # The Codex legacy-instruction ownership audit independently walks
39
+ # every seed-capable release tag and must not trust the catalog alone.
40
+ fetch-depth: 0
37
41
 
38
42
  - name: Set up Node.js
39
43
  uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -326,6 +330,10 @@ jobs:
326
330
  continue-on-error: true
327
331
  run: bash evals/ci/run-baseline.sh --check telemetry-usage-pipeline-integration
328
332
 
333
+ - name: Telemetry tool usage integration
334
+ continue-on-error: true
335
+ run: bash evals/ci/run-baseline.sh --check telemetry-tool-usage-integration
336
+
329
337
  - name: Economics record integration
330
338
  continue-on-error: true
331
339
  run: bash evals/ci/run-baseline.sh --check economics-record-integration
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.10.0](https://github.com/kontourai/flow-agents/compare/v3.9.0...v3.10.0) (2026-07-13)
4
+
5
+
6
+ ### Features
7
+
8
+ * **telemetry:** per-turn model/tokens/cost on tool events ([#568](https://github.com/kontourai/flow-agents/issues/568) slice 1) ([80ac0e7](https://github.com/kontourai/flow-agents/commit/80ac0e73a524fb1f84f0bffac94fe78ce4883b74))
9
+ * **telemetry:** per-turn model/tokens/cost on tool events ([#568](https://github.com/kontourai/flow-agents/issues/568) slice 1) ([2b6276d](https://github.com/kontourai/flow-agents/commit/2b6276d42b30123a61b3d9a9181ebd77affc0e70))
10
+
11
+
12
+ ### Fixes
13
+
14
+ * **#440:** scope ownership signals to the actor's own per-actor pointer ([#586](https://github.com/kontourai/flow-agents/issues/586)) ([7a8fc82](https://github.com/kontourai/flow-agents/commit/7a8fc82f22201fd916631beff6bb85fe288d461e))
15
+ * **liveness:** identify Codex actors by thread ([#556](https://github.com/kontourai/flow-agents/issues/556)) ([5ac02cb](https://github.com/kontourai/flow-agents/commit/5ac02cbc6f374d0b75bc03c4230f6d255568693a))
16
+ * **packaging:** preserve runtime instruction files ([#575](https://github.com/kontourai/flow-agents/issues/575)) ([f55d288](https://github.com/kontourai/flow-agents/commit/f55d28839bf87f35aaecb5a7aae0ba7fe55f51c7))
17
+ * **packaging:** stop seeding Codex global instructions ([#573](https://github.com/kontourai/flow-agents/issues/573)) ([2d25cb6](https://github.com/kontourai/flow-agents/commit/2d25cb65af2b5403a164a645ae615e2388681640))
18
+ * resolve continuation validator from installed runtime ([#591](https://github.com/kontourai/flow-agents/issues/591)) ([86191c4](https://github.com/kontourai/flow-agents/commit/86191c4e39abcb3123b8823f4604ca7183d0be83))
19
+
3
20
  ## [3.9.0](https://github.com/kontourai/flow-agents/compare/v3.8.0...v3.9.0) (2026-07-12)
4
21
 
5
22
 
package/README.md CHANGED
@@ -91,7 +91,7 @@ npx @kontourai/flow-agents init --runtime pi --dest /path/to/workspace --yes
91
91
 
92
92
  For Codex global installs, omit `--dest` and use `--global`: Flow Agents installs into `CODEX_HOME` when it is set, otherwise `~/.codex`. Pass `--dest` only when you intentionally want an isolated or test-specific Codex home.
93
93
 
94
- Runtime auto-detection is best-effort: it first checks environment markers set by the invoking coding agent (e.g. `CLAUDECODE`, `CODEX_SESSION_ID`, `OPENCODE_SESSION_ID`), then falls back to checking whether exactly one of `~/.claude`, `~/.codex` (or `$CODEX_HOME`), or opencode's global config dir already exists. If neither signal is unambiguous, it defaults to `base`. Pass `--runtime` explicitly to override the detected default at any time.
94
+ Runtime auto-detection is best-effort: it first checks environment markers set by the invoking coding agent (e.g. `CLAUDECODE`, Codex's preferred `CODEX_THREAD_ID`, the backward-compatible `CODEX_SESSION_ID`, or `OPENCODE_SESSION_ID`), then falls back to checking whether exactly one of `~/.claude`, `~/.codex` (or `$CODEX_HOME`), or opencode's global config dir already exists. If neither signal is unambiguous, it defaults to `base`. Pass `--runtime` explicitly to override the detected default at any time. Codex thread identifiers are never written into actor keys verbatim; Flow Agents derives a stable, domain-separated opaque token instead.
95
95
 
96
96
  Working from a checkout (for contributors): `npm install && npm run build`, then `node build/src/cli.js init --dest /path/to/workspace`.
97
97
 
@@ -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";
@@ -81,25 +80,12 @@ function loadActorStruct(args) {
81
80
  }
82
81
  export function resolveCurrentAssignmentActor() {
83
82
  const helper = loadActorIdentityHelper();
84
- const resolved = helper.resolveActor(process.env);
83
+ const resolved = helper.resolveActorIdentity(process.env);
85
84
  if (helper.isUnresolvedActor(resolved.actor))
86
85
  throw new Error("could not resolve an actor identity (no --actor-json and no resolvable environment actor); pass --actor-json explicitly");
87
- // #398: reconstruct the SAME struct resolveActor serialized for a CI actor, mirroring
88
- // resolveEnsureSessionActor (workflow-sidecar.ts) via the shared detectCiActor. Without this the
89
- // else-branch would write `record.actor = {runtime:"unknown", session_id:<the whole triple>}` for a
90
- // CI session — actor_key stays correct (so no false-block), but record.actor is malformed and the
91
- // audit-trail / `assignment-provider status` output for CI sessions would be corrupt.
92
- const ci = resolved.source.startsWith("ci-runtime") ? helper.detectCiActor(process.env) : null;
93
- const runtimeSessionId = resolved.source.startsWith("runtime-session-id") ? helper.runtimeSessionId(process.env) : "";
94
- const ancestrySeed = resolved.source === "process-ancestry" ? helper.ancestorActorSeed() : "";
95
- const actor = resolved.source === "explicit-override"
96
- ? { runtime: "explicit-override", session_id: resolved.actor, host: os.hostname(), human: null }
97
- : ci && ci.session_id
98
- ? { runtime: ci.runtime, session_id: ci.session_id, host: os.hostname(), human: null }
99
- : runtimeSessionId
100
- ? { runtime: helper.detectRuntime(process.env), session_id: runtimeSessionId, host: os.hostname(), human: null }
101
- : { runtime: helper.detectRuntime(process.env), session_id: ancestrySeed ? `anc-${ancestrySeed}` : resolved.actor, host: os.hostname(), human: null };
102
- return { actor, actorKey: resolved.actor };
86
+ if (!resolved.actorStruct)
87
+ throw new Error("actor identity resolved without a canonical actor struct");
88
+ return { actor: { ...resolved.actorStruct, human: resolved.actorStruct.human ?? null }, actorKey: resolved.actor };
103
89
  }
104
90
  export function assignmentFilePath(artifactRoot, subjectId) {
105
91
  const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
@@ -8,17 +8,19 @@ export type ContinuationAdapterCommand = {
8
8
  }>;
9
9
  };
10
10
  export declare function loadContinuationAdapterCommand(commandFileInput: string): ContinuationAdapterCommand;
11
- export declare function executeContinuationAdapter(commandFileInput: string, request: ContinuationTurnRequest, options: {
12
- cwd: string;
13
- timeoutMs: number;
14
- }): Promise<ContinuationTurnResult>;
15
- export declare function executeLoadedContinuationAdapter(command: ContinuationAdapterCommand, request: ContinuationTurnRequest, options: {
16
- cwd: string;
17
- timeoutMs: number;
18
- }): Promise<ContinuationTurnResult>;
11
+ export declare function executeContinuationAdapter(commandFileInput: string, request: ContinuationTurnRequest, options: ContinuationAdapterOptions): Promise<ContinuationTurnResult>;
12
+ export declare function executeLoadedContinuationAdapter(command: ContinuationAdapterCommand, request: ContinuationTurnRequest, options: ContinuationAdapterOptions): Promise<ContinuationTurnResult>;
13
+ export declare function assertLoadedContinuationAdapterIntegrity(command: ContinuationAdapterCommand): void;
19
14
  export declare function waitForContinuationBarrier(barrier: ContinuationBarrier, options: {
20
15
  maxWaitMs: number;
21
16
  pollMs: number;
22
17
  now?: () => number;
23
18
  sleep?: (ms: number) => Promise<void>;
24
19
  }): Promise<"ready" | "pending">;
20
+ type ContinuationAdapterOptions = {
21
+ cwd: string;
22
+ timeoutMs: number;
23
+ continuationTurnSecret?: string;
24
+ continuationRunId?: string;
25
+ };
26
+ export {};
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
4
  import { createHash } from "node:crypto";
5
+ import { ContinuationAdapterTimeoutError } from "../continuation-driver.js";
5
6
  export function loadContinuationAdapterCommand(commandFileInput) {
6
7
  const commandFile = path.resolve(commandFileInput);
7
8
  const command = validateAdapterCommand(JSON.parse(readRegularFileNoFollow(commandFile, "continuation adapter command file")));
@@ -19,13 +20,16 @@ export async function executeContinuationAdapter(commandFileInput, request, opti
19
20
  return executeLoadedContinuationAdapter(command, request, options);
20
21
  }
21
22
  export async function executeLoadedContinuationAdapter(command, request, options) {
23
+ assertLoadedContinuationAdapterIntegrity(command);
24
+ assertPositiveInteger(options.timeoutMs, "continuation adapter timeoutMs", 1, 86_400_000);
25
+ return await spawnAdapter(command, request, options);
26
+ }
27
+ export function assertLoadedContinuationAdapterIntegrity(command) {
22
28
  for (const entry of command.integrity) {
23
29
  if (sha256File(entry.file, "continuation adapter integrity file") !== entry.sha256) {
24
30
  throw new Error(`continuation adapter integrity changed after mission binding: ${entry.file}`);
25
31
  }
26
32
  }
27
- assertPositiveInteger(options.timeoutMs, "continuation adapter timeoutMs", 1, 86_400_000);
28
- return await spawnAdapter(command, request, options);
29
33
  }
30
34
  export async function waitForContinuationBarrier(barrier, options) {
31
35
  assertPositiveInteger(options.maxWaitMs, "continuation barrier maxWaitMs", 0, 86_400_000);
@@ -54,7 +58,7 @@ function spawnAdapter(command, request, options) {
54
58
  return new Promise((resolve, reject) => {
55
59
  const child = spawn(command.argv[0], command.argv.slice(1), {
56
60
  cwd: options.cwd,
57
- env: process.env,
61
+ env: adapterEnvironment(options),
58
62
  detached: process.platform !== "win32",
59
63
  shell: false,
60
64
  stdio: ["pipe", "pipe", "pipe"],
@@ -70,7 +74,7 @@ function spawnAdapter(command, request, options) {
70
74
  const timeout = setTimeout(() => {
71
75
  if (settled)
72
76
  return;
73
- terminationError = new Error(`continuation adapter timed out after ${options.timeoutMs}ms`);
77
+ terminationError = new ContinuationAdapterTimeoutError(options.timeoutMs);
74
78
  terminateProcessGroup(child.pid, "SIGTERM");
75
79
  forcedKill = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 250);
76
80
  }, options.timeoutMs);
@@ -143,6 +147,20 @@ function spawnAdapter(command, request, options) {
143
147
  child.stdin.end(`${JSON.stringify(request)}\n`);
144
148
  });
145
149
  }
150
+ function adapterEnvironment(options) {
151
+ const env = { ...process.env };
152
+ delete env.FLOW_AGENTS_CONTINUATION_TURN_SECRET;
153
+ delete env.FLOW_AGENTS_CONTINUATION_RUN_ID;
154
+ delete env.FLOW_AGENTS_CONTINUATION_TURN_NONCE;
155
+ delete env.FLOW_AGENTS_CONTINUATION_TURN_PUBLIC_KEY_DIGEST;
156
+ delete env.FLOW_AGENTS_CONTINUATION_ACTOR_B64;
157
+ const { continuationTurnSecret: turnSecret, continuationRunId: runId } = options;
158
+ if (!turnSecret || !runId)
159
+ return env;
160
+ env.FLOW_AGENTS_CONTINUATION_TURN_SECRET = turnSecret;
161
+ env.FLOW_AGENTS_CONTINUATION_RUN_ID = runId;
162
+ return env;
163
+ }
146
164
  function validateAdapterCommand(value) {
147
165
  if (!value || typeof value !== "object" || Array.isArray(value))
148
166
  throw new Error("continuation adapter command file must contain an object");
@@ -19,6 +19,10 @@ const sidecarSchemas = {
19
19
  "release.json": "schemas/workflow-release.schema.json",
20
20
  "learning.json": "schemas/workflow-learning.schema.json",
21
21
  };
22
+ // Runtime coordination records live below a session but are not workflow
23
+ // sidecars. Recursing into them would validate continuation-driver/state.json
24
+ // against the public workflow-state schema and turn an active driver into a
25
+ // false Goal Fit hard block.
22
26
  function readText(file) {
23
27
  return fs.readFileSync(file, "utf8");
24
28
  }
@@ -81,11 +85,27 @@ function hasEvidence(text) {
81
85
  function hasExplicitAcceptance(text) {
82
86
  return /explicitly accepted|accepted by user|user accepted|accepted gap/i.test(text);
83
87
  }
88
+ function isPrivateRuntimeChild(dir) {
89
+ if (path.basename(dir) !== "continuation-driver")
90
+ return false;
91
+ const sessionDir = path.dirname(dir);
92
+ const sessionState = path.join(sessionDir, "state.json");
93
+ try {
94
+ const stat = fs.lstatSync(sessionState);
95
+ if (stat.isSymbolicLink() || !stat.isFile())
96
+ return false;
97
+ const state = JSON.parse(readText(sessionState));
98
+ return state.task_slug === path.basename(sessionDir);
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
84
104
  function walk(dir) {
85
105
  const out = [];
86
106
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
87
107
  const p = path.join(dir, entry.name);
88
- if (entry.isDirectory())
108
+ if (entry.isDirectory() && !isPrivateRuntimeChild(p))
89
109
  out.push(...walk(p));
90
110
  else
91
111
  out.push(p);
@@ -288,7 +288,9 @@ function resolveEnsureSessionActor(p) {
288
288
  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.");
289
289
  }
290
290
  const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
291
- const resolved = explicitActor ? { actor: explicitActor, source: "explicit-override" } : helper.resolveActor(process.env);
291
+ const resolved = explicitActor
292
+ ? { actor: explicitActor, source: "explicit-override", actorStruct: { runtime: "explicit-override", session_id: explicitActor, host: os.hostname() } }
293
+ : helper.resolveActorIdentity(process.env);
292
294
  const unresolved = helper.isUnresolvedActor(resolved.actor);
293
295
  const branchActorKey = unresolved ? `unknown-actor-${unknownDisambiguator(resolved.actor)}` : resolved.actor;
294
296
  if (unresolved) {
@@ -297,17 +299,9 @@ function resolveEnsureSessionActor(p) {
297
299
  // the ownership guard entirely when unresolved, rather than claiming under a synthetic identity.
298
300
  return { actorStruct: { runtime: "unresolved", session_id: branchActorKey, host: os.hostname() }, actorKey: resolved.actor, branchActorKey, unresolved: true };
299
301
  }
300
- // #398: the CI-runtime tier must reconstruct the SAME struct resolveActor serialized, or
301
- // `serializeActor(actorStruct)` would diverge from `resolved.actor` (the else-branch would rebuild
302
- // an ANCESTRY struct — detectRuntime→unknown, runtimeSessionId→'' — so the claim's stored
303
- // actor_key would not match the CI actor at publish → self not recognized → false-block, the exact
304
- // bug this issue removes). Uses the SAME helper.detectCiActor as resolveActor, single-sourced.
305
- const ciActor = resolved.source.startsWith("ci-runtime") ? helper.detectCiActor(process.env) : null;
306
- const actorStruct = resolved.source === "explicit-override"
307
- ? { runtime: "explicit-override", session_id: resolved.actor, host: os.hostname() }
308
- : ciActor && ciActor.session_id
309
- ? { runtime: ciActor.runtime, session_id: ciActor.session_id, host: os.hostname() }
310
- : { runtime: helper.detectRuntime(process.env), session_id: helper.runtimeSessionId(process.env) || (() => { const seed = helper.ancestorActorSeed(); return seed ? `anc-${seed}` : ""; })(), host: os.hostname() };
302
+ if (!resolved.actorStruct)
303
+ die("ensure-session could not resolve a canonical actor struct");
304
+ const actorStruct = resolved.actorStruct;
311
305
  const actorKey = helper.serializeActor(actorStruct);
312
306
  return { actorStruct, actorKey, branchActorKey, unresolved: false };
313
307
  }
@@ -1767,14 +1761,15 @@ function updateCurrentAgent(root, dir, agentId, status, timestamp, actorKey) {
1767
1761
  }
1768
1762
  if (actorKey && !loadActorIdentityHelper().isUnresolvedActor(actorKey)) {
1769
1763
  const helper = loadCurrentPointerHelper();
1770
- const perActorFile = helper.perActorCurrentFile(root, actorKey);
1771
- let perActor = null;
1772
- try {
1773
- perActor = fs.existsSync(perActorFile) ? JSON.parse(fs.readFileSync(perActorFile, "utf8")) : null;
1774
- }
1775
- catch {
1776
- perActor = null;
1777
- }
1764
+ // #440 fix-wave 3: read through the legacy-aware path (readOwnCurrentPointer tries the new
1765
+ // collision-resistant filename first, then falls back to the pre-fix-wave-2 legacy filename)
1766
+ // instead of a direct fs read of perActorCurrentFile() alone — a direct read of ONLY the new
1767
+ // filename would silently skip this active_agents/updated_at projection for a pointer a
1768
+ // still-running pre-fix-wave-2 sidecar wrote under the old name. The write below always goes
1769
+ // through writePerActorCurrent (new filename only), so the pointer migrates to the new name
1770
+ // on first touch.
1771
+ const pointer = helper.readOwnCurrentPointer(root, actorKey);
1772
+ const perActor = pointer.payload;
1778
1773
  if (perActor && path.resolve(root, perActor.artifact_dir ?? "") === path.resolve(dir)) {
1779
1774
  helper.writePerActorCurrent(root, actorKey, applyAgentUpdate(perActor));
1780
1775
  }
@@ -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
  export const WORKFLOW_CONTRACT_VERSION = "1.0";
20
20
  const PACKAGE_ROOT = flowAgentsPackageRoot();
21
21
  const REQUIRE = createRequire(import.meta.url);
@@ -81,16 +81,39 @@ async function drive(sessionDir, argv, json) {
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
  });
@@ -100,6 +123,9 @@ async function drive(sessionDir, argv, json) {
100
123
  console.log(`Continuation driver ${result.outcome} after ${result.turns_started} turn(s); canonical Flow is ${result.snapshot.status} at ${result.snapshot.current_step}.`);
101
124
  return 0;
102
125
  }
126
+ function loadContinuationTurnAuthority() {
127
+ return REQUIRE(path.resolve(PACKAGE_ROOT, "scripts", "hooks", "lib", "continuation-turn-authority.js"));
128
+ }
103
129
  async function start(argv) {
104
130
  const parsed = parseArgs(argv);
105
131
  assertOnlyFlags(parsed.flags, new Set(["flow", "work-item", "task-slug", "artifact-root", "source-request", "summary", "title", "criterion", "assignment-provider", "effective-state-json"]), "workflow start");
@@ -553,14 +579,35 @@ function readAssignment(sessionDir, slug) {
553
579
  return readJsonFile(file, "workflow assignment");
554
580
  }
555
581
  function assertMatchingAssignmentActor(sessionDir, slug) {
556
- const assignment = readActiveAssignment(sessionDir, slug);
557
- const caller = resolveCurrentAssignmentActor();
558
- const normalizeActor = (value) => value && typeof value === "object" ? { ...value, human: value.human ?? null } : null;
559
- if (assignment.actor_key !== caller.actorKey || !isDeepStrictEqual(normalizeActor(assignment.actor), normalizeActor(caller.actor))) {
560
- throw new Error("workflow mutation requires the session's active, matching assignment actor");
582
+ const { assignment, caller, matches } = assignmentActorContext(sessionDir, slug);
583
+ if (matches)
584
+ return caller;
585
+ const authority = loadContinuationTurnAuthority().validateSignedActiveTurnAssignmentAuthority({
586
+ sessionDir,
587
+ runId: process.env.FLOW_AGENTS_CONTINUATION_RUN_ID,
588
+ turnSecret: process.env.FLOW_AGENTS_CONTINUATION_TURN_SECRET,
589
+ });
590
+ if (authority.valid && authority.record
591
+ && assignment.actor_key === authority.record.assignment_actor
592
+ && isDeepStrictEqual(normalizeAssignmentActor(assignment.actor), normalizeAssignmentActor(authority.record.assignment_actor_struct))) {
593
+ return { actorKey: authority.record.assignment_actor, actor: normalizeAssignmentActor(authority.record.assignment_actor_struct) };
561
594
  }
595
+ throw new Error("workflow mutation requires the session's active, matching assignment actor");
596
+ }
597
+ function assertOrdinaryMatchingAssignmentActor(sessionDir, slug) {
598
+ const { caller, matches } = assignmentActorContext(sessionDir, slug);
599
+ if (!matches)
600
+ throw new Error("workflow mutation requires the session's active, matching assignment actor");
562
601
  return caller;
563
602
  }
603
+ function assignmentActorContext(sessionDir, slug) {
604
+ const assignment = readActiveAssignment(sessionDir, slug);
605
+ const caller = resolveCurrentAssignmentActor();
606
+ return { assignment, caller, matches: assignment.actor_key === caller.actorKey && isDeepStrictEqual(normalizeAssignmentActor(assignment.actor), normalizeAssignmentActor(caller.actor)) };
607
+ }
608
+ function normalizeAssignmentActor(value) {
609
+ return value && typeof value === "object" ? { ...value, human: value.human ?? null } : null;
610
+ }
564
611
  function readActiveAssignment(sessionDir, slug) {
565
612
  const assignment = readAssignment(sessionDir, slug);
566
613
  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)) {
@@ -30,6 +30,10 @@ export type ContinuationTurnResult = {
30
30
  barrier: ContinuationBarrier;
31
31
  summary?: string;
32
32
  };
33
+ export declare class ContinuationAdapterTimeoutError extends Error {
34
+ readonly code = "CONTINUATION_ADAPTER_TIMEOUT";
35
+ constructor(timeoutMs: number);
36
+ }
33
37
  export type ContinuationDriverState = {
34
38
  schema_version: "1.0";
35
39
  run_id: string;
@@ -38,12 +42,14 @@ export type ContinuationDriverState = {
38
42
  adapter_command_identity: string | null;
39
43
  status: "active" | "waiting" | "done" | "failed" | "budget_exhausted";
40
44
  turns_started: number;
45
+ active_turn_step?: string | null;
46
+ active_turn_public_key_digest?: string | null;
41
47
  pending_barrier: ContinuationBarrier | null;
42
48
  updated_at: string;
43
49
  };
44
50
  export type ContinuationDriverEvent = {
45
51
  schema_version: "1.0";
46
- type: "started" | "turn_started" | "turn_completed" | "turn_failed" | "parked" | "resumed" | "done" | "budget_exhausted";
52
+ type: "started" | "turn_started" | "turn_completed" | "gate_not_advanced" | "turn_failed" | "authority_cleanup_failed" | "parked" | "resumed" | "done" | "budget_exhausted";
47
53
  run_id: string;
48
54
  definition_id: string;
49
55
  current_step: string;
@@ -51,6 +57,7 @@ export type ContinuationDriverEvent = {
51
57
  at: string;
52
58
  barrier?: ContinuationBarrier;
53
59
  summary?: string;
60
+ failure_kind?: "adapter_error" | "timeout";
54
61
  };
55
62
  export interface ContinuationStateStore {
56
63
  load(): ContinuationDriverState | null;
@@ -60,8 +67,23 @@ export interface ContinuationStateStore {
60
67
  export interface ContinuationRuntimePort {
61
68
  inspect(): Promise<ContinuationSnapshot>;
62
69
  synchronize(): Promise<ContinuationSnapshot>;
63
- execute(request: ContinuationTurnRequest): Promise<ContinuationTurnResult>;
70
+ execute(request: ContinuationTurnRequest, context?: ContinuationTurnContext): Promise<ContinuationTurnResult>;
64
71
  }
72
+ export type ContinuationTurnContext = {
73
+ continuationTurnSecret?: string;
74
+ continuationRunId?: string;
75
+ };
76
+ export type ContinuationDriverLockLease = {
77
+ pid: number;
78
+ token: string;
79
+ created_at: string;
80
+ };
81
+ export type ContinuationTurnAuthority = {
82
+ runId: string;
83
+ turnSecret: string;
84
+ publicKeyDigest: string;
85
+ cleanup(): boolean;
86
+ };
65
87
  export type ContinuationDriverOutcome = {
66
88
  outcome: "done" | "waiting" | "failed" | "budget_exhausted";
67
89
  turns_started: number;
@@ -75,6 +97,7 @@ export interface RunContinuationDriverInput {
75
97
  store: ContinuationStateStore;
76
98
  waitForBarrier?: (barrier: ContinuationBarrier) => Promise<"ready" | "pending">;
77
99
  authorizeTurn?: () => Promise<void>;
100
+ issueTurnAuthority?: (request: ContinuationTurnRequest) => Promise<ContinuationTurnAuthority>;
78
101
  now?: () => Date;
79
102
  }
80
103
  export interface DriveBuilderFlowSessionInput {
@@ -84,9 +107,10 @@ export interface DriveBuilderFlowSessionInput {
84
107
  execute: ContinuationRuntimePort["execute"];
85
108
  waitForBarrier?: RunContinuationDriverInput["waitForBarrier"];
86
109
  authorizeTurn?: RunContinuationDriverInput["authorizeTurn"];
110
+ issueTurnAuthority?: RunContinuationDriverInput["issueTurnAuthority"];
87
111
  now?: () => Date;
88
112
  }
89
113
  export declare function runContinuationDriver(input: RunContinuationDriverInput): Promise<ContinuationDriverOutcome>;
90
114
  export declare function driveBuilderFlowSession(input: DriveBuilderFlowSessionInput): Promise<ContinuationDriverOutcome>;
91
- export declare function withContinuationDriverLock<T>(sessionDirInput: string, body: () => Promise<T>): Promise<T>;
115
+ export declare function withContinuationDriverLock<T>(sessionDirInput: string, body: (lease: ContinuationDriverLockLease) => Promise<T>): Promise<T>;
92
116
  export declare function createFileContinuationStore(sessionDirInput: string): ContinuationStateStore;