agentxchain 2.149.2 → 2.150.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.149.2",
3
+ "version": "2.150.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  "test": "npm run test:vitest && npm run test:node",
29
29
  "test:vitest": "vitest run --reporter=verbose",
30
30
  "test:beta": "node --test test/beta-tester-scenarios/*.test.js",
31
- "test:node": "node --test test/*.test.js test/beta-tester-scenarios/*.test.js",
31
+ "test:node": "node --test --test-timeout=60000 --test-concurrency=4 test/*.test.js test/beta-tester-scenarios/*.test.js",
32
32
  "preflight:release": "bash scripts/release-preflight.sh",
33
33
  "preflight:release:strict": "bash scripts/release-preflight.sh --strict",
34
34
  "check:release-alignment": "node scripts/check-release-alignment.mjs",
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env bash
2
+ # Prepublish gate — local-first quality floor before `git tag`.
3
+ #
4
+ # Replaces per-commit remote CI coverage that `.github/workflows/ci.yml` will
5
+ # drop when CICD-SHRINK lands. Runs the same checks the GitHub-hosted runners
6
+ # ran, in-process on the agent's box, before any tag or publish-workflow is
7
+ # triggered. See .planning/CICD_REDUCTION_PLAN.md §7.
8
+ #
9
+ # Usage:
10
+ # bash cli/scripts/prepublish-gate.sh <target-version>
11
+ #
12
+ # Exit 0 + prints "PREPUBLISH GATE PASSED for <version>" → safe to tag/push.
13
+ # Exit non-zero → do NOT tag, do NOT push. Fix the failure locally first.
14
+ #
15
+ # Discipline rule (CICD-SHRINK acceptance, new in DEC-RELEASE-CUT-AND-PUSH-AS-ATOMIC-001):
16
+ # the release-cut turn MUST include this script's "PREPUBLISH GATE PASSED" line
17
+ # in the turn's Evidence block before `git tag` is created.
18
+
19
+ set -uo pipefail
20
+
21
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
22
+ CLI_DIR="${SCRIPT_DIR}/.."
23
+ cd "$CLI_DIR"
24
+
25
+ usage() {
26
+ echo "Usage: bash cli/scripts/prepublish-gate.sh <target-version>" >&2
27
+ echo " <target-version> Semver string (e.g., 2.150.0)." >&2
28
+ }
29
+
30
+ if [[ $# -ne 1 ]]; then
31
+ usage
32
+ exit 1
33
+ fi
34
+
35
+ TARGET_VERSION="$1"
36
+ if ! [[ "$TARGET_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
37
+ echo "Error: invalid semver '${TARGET_VERSION}'" >&2
38
+ usage
39
+ exit 1
40
+ fi
41
+
42
+ echo "Prepublish gate — target version ${TARGET_VERSION}"
43
+ echo "================================================="
44
+ echo "cwd: ${CLI_DIR}"
45
+ echo ""
46
+
47
+ STEP_STATUS=0
48
+ step_fail() {
49
+ echo ""
50
+ echo " FAIL: $1"
51
+ STEP_STATUS=1
52
+ }
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Step 1 — Full test suite (replaces per-push CI).
56
+ #
57
+ # Runs via `npm test` so Vitest + Node test phases are both exercised, and the
58
+ # Node phase inherits the `--test-timeout=60000 --test-concurrency=4` caps from
59
+ # package.json (DEC-BUG57-FAILFAST-NODE-TEST-001). We pass `--test-timeout`
60
+ # explicitly too so the gate cannot be silently weakened by a future
61
+ # package.json change. The node runner honors the last `--test-timeout` wins.
62
+ # ---------------------------------------------------------------------------
63
+ echo "[1/4] Full test suite — cd cli && npm test -- --test-timeout=60000"
64
+ if npm test -- --test-timeout=60000; then
65
+ echo " PASS: full test suite green"
66
+ else
67
+ step_fail "full test suite failed"
68
+ fi
69
+ echo ""
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Step 2 — Release preflight in publish-gate mode.
73
+ #
74
+ # `release-preflight.sh --publish-gate` enforces strict mode (clean tree,
75
+ # bumped package.json, CHANGELOG entry present, release-alignment manifest
76
+ # aligned, pack dry-run succeeds) and runs only the release-gate-critical
77
+ # test subset. Step 1 already ran the full suite; step 2 enforces the
78
+ # release-specific invariants.
79
+ # ---------------------------------------------------------------------------
80
+ echo "[2/4] Release preflight — bash scripts/release-preflight.sh --publish-gate"
81
+ if bash "${SCRIPT_DIR}/release-preflight.sh" --publish-gate --target-version "${TARGET_VERSION}"; then
82
+ echo " PASS: release-preflight gate green"
83
+ else
84
+ step_fail "release-preflight gate failed"
85
+ fi
86
+ echo ""
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Step 3 — npm pack --dry-run (claim-reality coverage).
90
+ #
91
+ # Proves the tarball the publish workflow will upload is reproducible from
92
+ # HEAD. The publish workflow itself re-runs this; we catch pack failures here
93
+ # before the tag is even created, so a broken `files:` glob or missing dist
94
+ # artifact never reaches remote CI.
95
+ # ---------------------------------------------------------------------------
96
+ echo "[3/4] Pack dry-run — npm pack --dry-run"
97
+ if npm pack --dry-run >/dev/null 2>&1; then
98
+ echo " PASS: npm pack --dry-run succeeded"
99
+ else
100
+ step_fail "npm pack --dry-run failed (rerun with streamed output for details)"
101
+ npm pack --dry-run 2>&1 | tail -20 || true
102
+ fi
103
+ echo ""
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Step 4 — Release-alignment manifest (17-surface drift gate).
107
+ #
108
+ # `check-release-alignment.mjs` owns the shared manifest of every surface that
109
+ # must reference the target version (CHANGELOG, website release pages,
110
+ # capabilities.json, implementor guide, launch evidence, onboarding docs,
111
+ # marketing drafts, llms.txt, homebrew mirror, package.json). Step 2 runs the
112
+ # same check, but we invoke it directly so the final status line is visible
113
+ # in the gate's own output — agents reading this log get an explicit
114
+ # alignment-pass signal without having to scrape the preflight's mid-run
115
+ # block.
116
+ # ---------------------------------------------------------------------------
117
+ echo "[4/4] Release alignment — node scripts/check-release-alignment.mjs --scope current"
118
+ if node "${SCRIPT_DIR}/check-release-alignment.mjs" --scope current --target-version "${TARGET_VERSION}"; then
119
+ echo " PASS: release alignment green"
120
+ else
121
+ step_fail "release alignment failed"
122
+ fi
123
+ echo ""
124
+
125
+ echo "================================================="
126
+ if [[ "${STEP_STATUS}" -ne 0 ]]; then
127
+ echo "PREPUBLISH GATE FAILED for ${TARGET_VERSION} — do NOT tag, do NOT push."
128
+ exit 1
129
+ fi
130
+
131
+ echo "PREPUBLISH GATE PASSED for ${TARGET_VERSION} — safe to tag and push."
132
+ exit 0
@@ -342,12 +342,12 @@ else
342
342
  PREFLIGHT_FAILED=0
343
343
 
344
344
  # 8.5a. Full test suite with release env vars
345
- if env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 npm test >/dev/null 2>&1; then
345
+ if env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 npm test -- --test-timeout=60000 >/dev/null 2>&1; then
346
346
  echo " OK: test suite passed"
347
347
  else
348
348
  echo " FAIL: test suite failed" >&2
349
349
  echo " Re-running with output for diagnostics..." >&2
350
- env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 npm test 2>&1 | tail -30 >&2
350
+ env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 npm test -- --test-timeout=60000 2>&1 | tail -30 >&2
351
351
  PREFLIGHT_FAILED=1
352
352
  fi
353
353
 
@@ -57,20 +57,27 @@ function extractSummaryParagraph(text, version) {
57
57
  }
58
58
 
59
59
  function extractAggregateEvidenceLine(text) {
60
- const matches = [...text.matchAll(/^-\s+.*\b(\d+)\s+tests\b.*\b0 failures\b.*$/gm)];
60
+ const matches = [...text.matchAll(/^-\s+(.*\b(\d+)\s+tests\b.*\b0 failures\b.*)$/gm)];
61
61
  if (matches.length === 0) {
62
62
  throw new Error('Concrete aggregate evidence line missing from governed release page');
63
63
  }
64
64
 
65
65
  const aggregate = matches.reduce((best, match) => {
66
- const count = Number(match[1]);
66
+ const count = Number(match[2]);
67
67
  if (!best || count > best.count) {
68
- return { count, line: match[0] };
68
+ return { count, line: match[1] };
69
69
  }
70
70
  return best;
71
71
  }, null);
72
72
 
73
- return aggregate.line.replace(/\*\*/g, '').replace(/`/g, '').replace(/,/g, '').trim();
73
+ const line = aggregate.line.replace(/\*\*/g, '').replace(/`/g, '').replace(/,/g, '').trim();
74
+ const evidenceMatch = line.match(/\b\d+\s+tests\b.*\b0 failures\b.*/);
75
+ if (!evidenceMatch) {
76
+ throw new Error('Concrete aggregate evidence line missing from governed release page');
77
+ }
78
+ const prefix = line.slice(0, evidenceMatch.index).trim();
79
+ const evidence = evidenceMatch[0].trim();
80
+ return `- ${evidence}${prefix ? ` — ${prefix.replace(/[→-]\s*$/, '').trim()}` : ''}`;
74
81
  }
75
82
 
76
83
  function getPreviousVersionTag(repoRoot, version) {
@@ -58,7 +58,7 @@
58
58
  * purpose), and does NOT require the governed dispatcher to be running.
59
59
  */
60
60
 
61
- import { spawn } from 'child_process';
61
+ import { spawn, spawnSync } from 'child_process';
62
62
  import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'fs';
63
63
  import { join, resolve } from 'path';
64
64
  import { fileURLToPath } from 'url';
@@ -315,20 +315,6 @@ async function runOneAttempt({
315
315
  attempt.spawn_attached_elapsed_ms = now - t0;
316
316
  });
317
317
 
318
- if (child.stdin) {
319
- child.stdin.on('error', (err) => {
320
- // Capture but do not fail — adapter behavior matches: stdin EPIPE is
321
- // logged and the spawn continues to play out via close/error events.
322
- attempt.stderr += `[repro:stdin_error] ${err?.code || ''} ${err?.message || ''}\n`;
323
- });
324
- try {
325
- if (transport === 'stdin') child.stdin.write(fullPrompt);
326
- child.stdin.end();
327
- } catch (err) {
328
- attempt.stderr += `[repro:stdin_throw] ${err?.code || ''} ${err?.message || ''}\n`;
329
- }
330
- }
331
-
332
318
  if (child.stdout) {
333
319
  child.stdout.on('data', (chunk) => {
334
320
  const text = chunk.toString();
@@ -343,6 +329,20 @@ async function runOneAttempt({
343
329
  });
344
330
  }
345
331
 
332
+ if (child.stdin) {
333
+ child.stdin.on('error', (err) => {
334
+ // Capture but do not fail — adapter behavior matches: stdin EPIPE is
335
+ // logged and the spawn continues to play out via close/error events.
336
+ attempt.stderr += `[repro:stdin_error] ${err?.code || ''} ${err?.message || ''}\n`;
337
+ });
338
+ try {
339
+ if (transport === 'stdin') child.stdin.write(fullPrompt);
340
+ child.stdin.end();
341
+ } catch (err) {
342
+ attempt.stderr += `[repro:stdin_throw] ${err?.code || ''} ${err?.message || ''}\n`;
343
+ }
344
+ }
345
+
346
346
  if (child.stderr) {
347
347
  child.stderr.on('data', (chunk) => {
348
348
  const text = chunk.toString();
@@ -496,6 +496,7 @@ async function main() {
496
496
  const stdinBytes = transport === 'stdin' ? Buffer.byteLength(fullPrompt) : 0;
497
497
  const diagnosticArgs = redactArgs(args, fullPrompt, transport);
498
498
  const envSnapshot = snapshotEnv(spawnEnv);
499
+ const commandProbe = probeCommand(command, runtimeCwd, spawnEnv);
499
500
 
500
501
  const header = {
501
502
  repro_version: 1,
@@ -510,6 +511,7 @@ async function main() {
510
511
  stdin_bytes: stdinBytes,
511
512
  prompt_source: promptSource,
512
513
  env_snapshot: envSnapshot,
514
+ command_probe: commandProbe,
513
515
  watchdog_ms: opts.noWatchdog ? null : watchdogMs,
514
516
  no_watchdog: opts.noWatchdog,
515
517
  attempts_planned: opts.attempts,
@@ -529,6 +531,11 @@ async function main() {
529
531
  console.error(`[repro] prompt : ${promptSource.kind} (${promptSource.length_bytes} bytes)`);
530
532
  console.error(`[repro] watchdog_ms : ${header.watchdog_ms ?? 'disabled'}`);
531
533
  console.error(`[repro] auth env : ${JSON.stringify(envSnapshot.auth_env_present)}`);
534
+ if (commandProbe.kind === 'claude_version') {
535
+ console.error(`[repro] claude probe : status=${commandProbe.status ?? '-'} signal=${commandProbe.signal ?? '-'} stdout=${JSON.stringify(commandProbe.stdout || '')}`);
536
+ } else {
537
+ console.error(`[repro] command probe: ${commandProbe.kind} (${commandProbe.reason})`);
538
+ }
532
539
  console.error(`[repro] attempts : ${header.attempts_planned}`);
533
540
  console.error('');
534
541
 
@@ -617,6 +624,65 @@ function summarize(attempts) {
617
624
  };
618
625
  }
619
626
 
627
+ function probeCommand(command, cwd, env) {
628
+ if (!isClaudeCommand(command)) {
629
+ return {
630
+ kind: 'skipped',
631
+ reason: 'not a claude command',
632
+ };
633
+ }
634
+ try {
635
+ const result = spawnSync(command, ['--version'], {
636
+ cwd,
637
+ env,
638
+ encoding: 'utf8',
639
+ timeout: 10_000,
640
+ maxBuffer: 1024 * 1024,
641
+ });
642
+ return {
643
+ kind: 'claude_version',
644
+ command,
645
+ args: ['--version'],
646
+ timeout_ms: 10_000,
647
+ status: result.status,
648
+ signal: result.signal,
649
+ stdout: result.stdout || '',
650
+ stderr: result.stderr || '',
651
+ error: result.error ? {
652
+ code: result.error.code ?? null,
653
+ errno: result.error.errno ?? null,
654
+ syscall: result.error.syscall ?? null,
655
+ message: result.error.message || String(result.error),
656
+ } : null,
657
+ timed_out: result.error?.code === 'ETIMEDOUT',
658
+ };
659
+ } catch (err) {
660
+ return {
661
+ kind: 'claude_version',
662
+ command,
663
+ args: ['--version'],
664
+ timeout_ms: 10_000,
665
+ status: null,
666
+ signal: null,
667
+ stdout: '',
668
+ stderr: '',
669
+ error: {
670
+ code: err?.code ?? null,
671
+ errno: err?.errno ?? null,
672
+ syscall: err?.syscall ?? null,
673
+ message: err?.message || String(err),
674
+ },
675
+ timed_out: err?.code === 'ETIMEDOUT',
676
+ };
677
+ }
678
+ }
679
+
680
+ function isClaudeCommand(command) {
681
+ if (typeof command !== 'string') return false;
682
+ const normalized = command.replace(/\\/g, '/');
683
+ return normalized === 'claude' || normalized.endsWith('/claude');
684
+ }
685
+
620
686
  main().catch((err) => {
621
687
  console.error(`[repro] fatal: ${err?.stack || err?.message || err}`);
622
688
  process.exit(99);
@@ -1377,7 +1377,9 @@ export async function missionPlanAutopilotCommand(planTarget, opts) {
1377
1377
  }
1378
1378
 
1379
1379
  if (waveNum === maxWaves) {
1380
- terminalReason = 'wave_limit_reached';
1380
+ terminalReason = totalFailed > 0
1381
+ ? (continueOnFailure ? 'plan_incomplete' : 'failure_stopped')
1382
+ : 'wave_limit_reached';
1381
1383
  break;
1382
1384
  }
1383
1385
 
@@ -2030,7 +2032,9 @@ async function coordinatorAutopilot(planTarget, opts, context, mission) {
2030
2032
  }
2031
2033
 
2032
2034
  if (waveNum === maxWaves) {
2033
- terminalReason = 'wave_limit_reached';
2035
+ terminalReason = totalFailed > 0
2036
+ ? (continueOnFailure ? 'plan_incomplete' : 'failure_stopped')
2037
+ : 'wave_limit_reached';
2034
2038
  break;
2035
2039
  }
2036
2040
 
@@ -219,7 +219,7 @@ export async function restartCommand(opts) {
219
219
  process.exit(1);
220
220
  }
221
221
 
222
- if (state.status === 'blocked') {
222
+ if (state.status === 'blocked' && !state.pending_phase_transition && !state.pending_run_completion) {
223
223
  console.log(chalk.red('Run is blocked. Resolve the blocker first.'));
224
224
  const recovery = deriveRecoveryDescriptor(state, config);
225
225
  if (recovery) {
@@ -120,7 +120,14 @@ function buildArtifactIndex(root, turnId) {
120
120
  }
121
121
 
122
122
  function buildTurnPayload(turnId, turn, state, artifacts, assignment, config) {
123
- const elapsedMs = getElapsedMs(turn.started_at);
123
+ // Effective start time for display: BUG-51 hardening clears `started_at`
124
+ // when a turn transitions to `dispatched` so ghost-turn detection can key
125
+ // off `dispatched_at`. For manual runtimes (no subprocess), no later
126
+ // `starting` transition re-sets `started_at`, so fall back to
127
+ // `dispatched_at` (operator-dispatched = effective start) and
128
+ // `assigned_at` as a last resort so the timing surface stays populated.
129
+ const effectiveStartedAt = turn.started_at || turn.dispatched_at || turn.assigned_at || null;
130
+ const elapsedMs = getElapsedMs(effectiveStartedAt);
124
131
  const payload = {
125
132
  turn_id: turnId,
126
133
  run_id: state.run_id || assignment?.run_id || null,
@@ -129,7 +136,7 @@ function buildTurnPayload(turnId, turn, state, artifacts, assignment, config) {
129
136
  runtime: turn.runtime_id,
130
137
  status: turn.status,
131
138
  attempt: turn.attempt,
132
- started_at: turn.started_at || null,
139
+ started_at: effectiveStartedAt,
133
140
  elapsed_ms: elapsedMs,
134
141
  dispatch_dir: getDispatchTurnDir(turnId),
135
142
  staging_result_path: assignment?.staging_result_path || null,
@@ -152,7 +159,9 @@ function buildTurnPayload(turnId, turn, state, artifacts, assignment, config) {
152
159
  }
153
160
 
154
161
  function printTurnSummary(turnId, turn, state, artifacts, assignment, config) {
155
- const elapsedMs = getElapsedMs(turn.started_at);
162
+ // See buildTurnPayload for rationale on fallback ordering.
163
+ const effectiveStartedAt = turn.started_at || turn.dispatched_at || turn.assigned_at || null;
164
+ const elapsedMs = getElapsedMs(effectiveStartedAt);
156
165
  console.log('');
157
166
  console.log(chalk.bold(` Turn: ${chalk.cyan(turnId)}`));
158
167
  console.log(chalk.dim(' ' + '─'.repeat(44)));
@@ -162,8 +171,8 @@ function printTurnSummary(turnId, turn, state, artifacts, assignment, config) {
162
171
  console.log(` ${chalk.dim('Runtime:')} ${turn.runtime_id}`);
163
172
  console.log(` ${chalk.dim('Status:')} ${turn.status}`);
164
173
  console.log(` ${chalk.dim('Attempt:')} ${turn.attempt}`);
165
- if (turn.started_at) {
166
- console.log(` ${chalk.dim('Started:')} ${turn.started_at}`);
174
+ if (effectiveStartedAt) {
175
+ console.log(` ${chalk.dim('Started:')} ${effectiveStartedAt}`);
167
176
  }
168
177
  if (elapsedMs != null) {
169
178
  console.log(` ${chalk.dim('Elapsed:')} ${formatElapsed(elapsedMs)}`);
@@ -264,7 +264,29 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
264
264
  armStartupWatchdog();
265
265
  });
266
266
 
267
- // Deliver prompt via stdin if transport is "stdin"; otherwise close immediately
267
+ // Collect stdout/stderr
268
+ if (child.stdout) {
269
+ child.stdout.on('data', (chunk) => {
270
+ const text = chunk.toString();
271
+ stdoutBytes += Buffer.byteLength(text);
272
+ recordFirstOutput('stdout');
273
+ logs.push(text);
274
+ if (onStdout) onStdout(text);
275
+ });
276
+ }
277
+
278
+ if (child.stderr) {
279
+ child.stderr.on('data', (chunk) => {
280
+ const text = chunk.toString();
281
+ stderrBytes += Buffer.byteLength(text);
282
+ stderrExcerpt = appendDiagnosticExcerpt(stderrExcerpt, text, DIAGNOSTIC_STDERR_EXCERPT_LIMIT);
283
+ logs.push('[stderr] ' + text);
284
+ if (onStderr) onStderr(text);
285
+ });
286
+ }
287
+
288
+ // Deliver prompt only after output listeners are registered. This removes
289
+ // the remaining adapter-side ordering risk for fast stdin-driven children.
268
290
  if (child.stdin) {
269
291
  child.stdin.on('error', (err) => {
270
292
  appendDiagnostic(logs, 'stdin_error', {
@@ -287,27 +309,6 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
287
309
  }
288
310
  }
289
311
 
290
- // Collect stdout/stderr
291
- if (child.stdout) {
292
- child.stdout.on('data', (chunk) => {
293
- const text = chunk.toString();
294
- stdoutBytes += Buffer.byteLength(text);
295
- recordFirstOutput('stdout');
296
- logs.push(text);
297
- if (onStdout) onStdout(text);
298
- });
299
- }
300
-
301
- if (child.stderr) {
302
- child.stderr.on('data', (chunk) => {
303
- const text = chunk.toString();
304
- stderrBytes += Buffer.byteLength(text);
305
- stderrExcerpt = appendDiagnosticExcerpt(stderrExcerpt, text, DIAGNOSTIC_STDERR_EXCERPT_LIMIT);
306
- logs.push('[stderr] ' + text);
307
- if (onStderr) onStderr(text);
308
- });
309
- }
310
-
311
312
  // Timeout handling per §20.4
312
313
  let timeoutHandle;
313
314
  let sigkillHandle;
@@ -1383,6 +1383,10 @@ function classifyAcceptanceOverlap(targetTurn, conflictFiles, historyEntries, co
1383
1383
  const forwardRevisionTurns = new Map();
1384
1384
 
1385
1385
  for (const entry of historyEntries) {
1386
+ if (targetTurn?.run_id && entry.run_id && entry.run_id !== targetTurn.run_id) {
1387
+ continue;
1388
+ }
1389
+
1386
1390
  if ((entry.accepted_sequence || 0) <= (targetTurn.assigned_sequence || 0)) {
1387
1391
  continue;
1388
1392
  }
@@ -2096,6 +2100,24 @@ function inferApprovalPauseFromState(state, config) {
2096
2100
  };
2097
2101
  }
2098
2102
 
2103
+ function shouldNormalizeApprovalPauseBlock(state, inferred) {
2104
+ if (!state || typeof state !== 'object') {
2105
+ return false;
2106
+ }
2107
+
2108
+ const blockedOn = typeof state.blocked_on === 'string' ? state.blocked_on : '';
2109
+ const recovery = state.blocked_reason?.recovery;
2110
+ const typedReason = recovery?.typed_reason || null;
2111
+
2112
+ if (blockedOn.startsWith('human_approval:')) {
2113
+ return true;
2114
+ }
2115
+
2116
+ return state.status === 'paused'
2117
+ && state.blocked_reason != null
2118
+ && (!typedReason || typedReason === inferred.typedReason);
2119
+ }
2120
+
2099
2121
  export function reconcileApprovalPausesWithConfig(state, config) {
2100
2122
  if (!state || typeof state !== 'object' || !config) {
2101
2123
  return { state, changed: false };
@@ -2117,7 +2139,7 @@ export function reconcileApprovalPausesWithConfig(state, config) {
2117
2139
  changed = true;
2118
2140
  }
2119
2141
 
2120
- if (nextState.status === 'blocked' || nextState.blocked_reason != null) {
2142
+ if (shouldNormalizeApprovalPauseBlock(nextState, inferred)) {
2121
2143
  nextState = {
2122
2144
  ...nextState,
2123
2145
  status: 'paused',
@@ -3034,12 +3056,20 @@ export function assignGovernedTurn(root, config, roleId, options = {}) {
3034
3056
  const concurrentWith = Object.keys(activeTurns);
3035
3057
 
3036
3058
  // Build the new turn object
3059
+ // `started_at` is seeded at assignment so that direct assign→accept flows
3060
+ // (non-dispatched, non-subprocess turns) still carry timing into history and
3061
+ // the turn_accepted event payload (per TURN_TIMING_OBSERVABILITY_SPEC.md).
3062
+ // BUG-51's dispatched-lifecycle path explicitly deletes this and the
3063
+ // starting/running transitions re-set it, so dispatch-driven turns still
3064
+ // reflect true subprocess-startup timing.
3037
3065
  const newTurn = {
3038
3066
  turn_id: turnId,
3067
+ run_id: state.run_id,
3039
3068
  assigned_role: roleId,
3040
3069
  status: 'assigned',
3041
3070
  attempt: 1,
3042
3071
  assigned_at: now,
3072
+ started_at: now,
3043
3073
  deadline_at: new Date(Date.now() + timeoutMinutes * 60 * 1000).toISOString(),
3044
3074
  runtime_id: runtimeId,
3045
3075
  baseline,
@@ -489,8 +489,8 @@ function isAcceptedRepoHistoryEntry(entry) {
489
489
  return Boolean(entry?.accepted_at) || entry?.status === 'accepted';
490
490
  }
491
491
 
492
- const REPO_FAILURE_STATUSES = new Set(['failed_acceptance', 'failed', 'rejected', 'retrying', 'conflicted']);
493
- const RETRYABLE_COORDINATOR_FAILURE_STATUSES = new Set(['failed', 'failed_acceptance']);
492
+ const REPO_FAILURE_STATUSES = new Set(['failed_acceptance', 'failed', 'failed_start', 'rejected', 'retrying', 'conflicted']);
493
+ const RETRYABLE_COORDINATOR_FAILURE_STATUSES = new Set(['failed', 'failed_acceptance', 'failed_start']);
494
494
 
495
495
  function getLatestRepoDispatches(launchRecord) {
496
496
  const latestByRepo = new Map();
@@ -708,6 +708,18 @@ function synchronizeCoordinatorWorkstreamStatuses(root, plan, coordinatorConfig,
708
708
  continue;
709
709
  }
710
710
 
711
+ if (launchRecord?.status === 'failed' || (launchRecord?.terminal_reason && launchRecord.terminal_reason !== 'completed')) {
712
+ if (ws.launch_status !== 'needs_attention') {
713
+ ws.launch_status = 'needs_attention';
714
+ changed = true;
715
+ }
716
+ if (plan.status !== 'needs_attention') {
717
+ plan.status = 'needs_attention';
718
+ changed = true;
719
+ }
720
+ continue;
721
+ }
722
+
711
723
  if ((launchRecord?.repo_dispatches?.length || 0) > 0 || progress.accepted_repo_count > 0) {
712
724
  if (ws.launch_status !== 'launched') {
713
725
  ws.launch_status = 'launched';