agentxchain 2.155.72 → 2.155.73

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/README.md CHANGED
@@ -55,19 +55,15 @@ npx --yes -p agentxchain@latest -c "agentxchain demo"
55
55
 
56
56
  ## Testing
57
57
 
58
- The CLI currently uses two runners on purpose: a 36-file Vitest coexistence slice for fast feedback and `node --test` for the full suite.
58
+ The CLI test suite runs under Vitest. Test files import Vitest directly, and `npm test` is the single CI contract.
59
59
 
60
60
  ```bash
61
- npm run test:vitest
62
- npm run test:node
63
61
  npm test
62
+ npm run test:watch
64
63
  ```
65
64
 
66
- - `npm run test:vitest`: the current 36-file Vitest slice
67
- - `npm run test:node`: full integration, subprocess, and E2E suite
68
- - `npm test`: both runners in sequence; this is the CI requirement today
69
-
70
- Duplicate execution remains intentional for the current 36-file slice until a later slice explicitly changes the redundancy model. For watch mode, run `npx vitest`.
65
+ - `npm test`: full Vitest suite, including integration, subprocess, E2E, and beta scenario coverage
66
+ - `npm run test:watch`: Vitest watch mode for local TDD
71
67
 
72
68
  ## Quick Start
73
69
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.155.72",
3
+ "version": "2.155.73",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,10 +26,8 @@
26
26
  ],
27
27
  "scripts": {
28
28
  "dev": "node bin/agentxchain.js",
29
- "test": "npm run test:vitest && npm run test:node",
30
- "test:vitest": "vitest run --reporter=verbose",
31
- "test:beta": "node --test test/beta-tester-scenarios/*.test.js",
32
- "test:node": "node --test --test-timeout=60000 --test-concurrency=4 test/*.test.js test/beta-tester-scenarios/*.test.js",
29
+ "test": "vitest run --reporter=verbose",
30
+ "test:watch": "vitest --reporter=verbose",
33
31
  "preflight:release": "bash scripts/release-preflight.sh",
34
32
  "preflight:release:strict": "bash scripts/release-preflight.sh --strict",
35
33
  "check:release-alignment": "node scripts/check-release-alignment.mjs",
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join, relative, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
7
+ const CLI_ROOT = join(SCRIPT_DIR, '..');
8
+ const TEST_ROOT = process.argv[2] ? resolve(process.argv[2]) : join(CLI_ROOT, 'test');
9
+
10
+ function walk(dir) {
11
+ const entries = readdirSync(dir, { withFileTypes: true });
12
+ const files = [];
13
+ for (const entry of entries) {
14
+ const absolute = join(dir, entry.name);
15
+ if (entry.isDirectory()) {
16
+ files.push(...walk(absolute));
17
+ } else if (entry.isFile() && entry.name.endsWith('.test.js')) {
18
+ files.push(absolute);
19
+ }
20
+ }
21
+ return files;
22
+ }
23
+
24
+ function parseNamedSpecifiers(specifiers) {
25
+ return specifiers
26
+ .split(',')
27
+ .map((part) => part.trim())
28
+ .filter(Boolean);
29
+ }
30
+
31
+ function migrateNamedSpecifiers(specifiers) {
32
+ let importsBefore = false;
33
+ let importsAfter = false;
34
+ const migrated = parseNamedSpecifiers(specifiers).map((specifier) => {
35
+ if (specifier === 'before') {
36
+ importsBefore = true;
37
+ return 'beforeAll';
38
+ }
39
+ if (specifier === 'after') {
40
+ importsAfter = true;
41
+ return 'afterAll';
42
+ }
43
+ return specifier;
44
+ });
45
+ return {
46
+ importsBefore,
47
+ importsAfter,
48
+ importLine: `import { ${migrated.join(', ')} } from 'vitest';`,
49
+ };
50
+ }
51
+
52
+ function migrateSource(source) {
53
+ let importsBefore = false;
54
+ let importsAfter = false;
55
+ let changed = false;
56
+
57
+ let next = source.replace(
58
+ /^import\s+test\s+from\s+['"]node:test['"];$/gm,
59
+ () => {
60
+ changed = true;
61
+ return "import { test } from 'vitest';";
62
+ },
63
+ );
64
+
65
+ next = next.replace(
66
+ /^import\s+\{([^}]+)\}\s+from\s+['"]node:test['"];$/gm,
67
+ (_match, specifiers) => {
68
+ const migrated = migrateNamedSpecifiers(specifiers);
69
+ importsBefore ||= migrated.importsBefore;
70
+ importsAfter ||= migrated.importsAfter;
71
+ changed = true;
72
+ return migrated.importLine;
73
+ },
74
+ );
75
+
76
+ if (importsBefore) {
77
+ next = next.replace(/\bbefore(?=\s*\()/g, 'beforeAll');
78
+ }
79
+ if (importsAfter) {
80
+ next = next.replace(/\bafter(?=\s*\()/g, 'afterAll');
81
+ }
82
+
83
+ return { changed, source: next };
84
+ }
85
+
86
+ const changedFiles = [];
87
+ for (const file of walk(TEST_ROOT)) {
88
+ const source = readFileSync(file, 'utf8');
89
+ const migrated = migrateSource(source);
90
+ if (!migrated.changed || migrated.source === source) continue;
91
+ writeFileSync(file, migrated.source);
92
+ changedFiles.push(relative(CLI_ROOT, file));
93
+ }
94
+
95
+ console.log(`Migrated ${changedFiles.length} test files from node:test to vitest.`);
96
+ for (const file of changedFiles) {
97
+ console.log(file);
98
+ }
@@ -78,7 +78,7 @@ FAIL=0
78
78
  TARBALL_URL=""
79
79
  REGISTRY_CHECKSUM=""
80
80
  PACKAGE_NAME="$(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json', 'utf8')).name)")"
81
- PACKAGE_BIN_NAME="$(node -e "const pkg = JSON.parse(require('fs').readFileSync('package.json', 'utf8')); if (typeof pkg.bin === 'string') { console.log(pkg.name); process.exit(0); } const names = Object.keys(pkg.bin || {}); if (names.length !== 1) { console.error('package.json bin must declare exactly one entry'); process.exit(1); } console.log(names[0]);")"
81
+ PACKAGE_BIN_NAME="$(node -e "const pkg = JSON.parse(require('fs').readFileSync('package.json', 'utf8')); if (typeof pkg.bin === 'string') { console.log(pkg.name); process.exit(0); } const bins = pkg.bin || {}; if (bins[pkg.name]) { console.log(pkg.name); process.exit(0); } const names = Object.keys(bins); if (names.length === 1) { console.log(names[0]); process.exit(0); } console.error('package.json bin must declare the primary package bin'); process.exit(1);")"
82
82
  RUNNER_INTERFACE_VERSION_EXPECTED="$(node --input-type=module -e "import('./src/lib/runner-interface.js').then((mod) => { console.log(mod.RUNNER_INTERFACE_VERSION); }).catch((error) => { console.error(error.message); process.exit(1); });")"
83
83
  ADAPTER_INTERFACE_VERSION_EXPECTED="$(node --input-type=module -e "import('./src/lib/adapter-interface.js').then((mod) => { console.log(mod.ADAPTER_INTERFACE_VERSION); }).catch((error) => { console.error(error.message); process.exit(1); });")"
84
84
 
@@ -179,15 +179,15 @@ if [[ "$PUBLISH_GATE" -eq 1 ]]; then
179
179
  fail "No beta-tester scenario tests found for release-gate verification"
180
180
  TEST_OUTPUT=""
181
181
  TEST_STATUS=1
182
- elif run_and_capture TEST_OUTPUT env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 node --test "${GATE_TEST_ARGS[@]}"; then
182
+ elif run_and_capture TEST_OUTPUT env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 npm test -- "${GATE_TEST_ARGS[@]}"; then
183
183
  TEST_STATUS=0
184
184
  else
185
185
  TEST_STATUS=$?
186
186
  fi
187
- NODE_PASS="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^ℹ tests / { print $3; exit }')"
188
- NODE_FAIL="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^ℹ fail / { print $3; exit }')"
189
- if [ "$TEST_STATUS" -eq 0 ] && [ "${NODE_FAIL:-0}" = "0" ]; then
190
- pass "${NODE_PASS:-?} release-gate tests passed, 0 failures"
187
+ VITEST_PASS="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^[[:space:]]*Tests[[:space:]]+[0-9]+[[:space:]]+passed/ { for (i = 1; i <= NF; i++) if ($i ~ /^[0-9]+$/) { print $i; exit } }')"
188
+ VITEST_FAIL="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^[[:space:]]*Tests[[:space:]]+[0-9]+[[:space:]]+failed/ { for (i = 1; i <= NF; i++) if ($i ~ /^[0-9]+$/) { print $i; exit } }')"
189
+ if [ "$TEST_STATUS" -eq 0 ] && [ "${VITEST_FAIL:-0}" = "0" ]; then
190
+ pass "${VITEST_PASS:-?} release-gate tests passed, 0 failures"
191
191
  else
192
192
  fail "Release-gate tests failed"
193
193
  printf '%s\n' "$TEST_OUTPUT" | tail -20
@@ -21,7 +21,7 @@ TARGET_VERSION=""
21
21
 
22
22
  FORMULA_PATH="${CLI_DIR}/homebrew/agentxchain.rb"
23
23
  PACKAGE_NAME="$(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).name)")"
24
- PACKAGE_BIN_NAME="$(node -e "const pkg = JSON.parse(require('fs').readFileSync('package.json','utf8')); if (typeof pkg.bin === 'string') { console.log(pkg.name); process.exit(0); } const names = Object.keys(pkg.bin || {}); if (names.length !== 1) { console.error('package.json bin must declare exactly one entry'); process.exit(1); } console.log(names[0]);")"
24
+ PACKAGE_BIN_NAME="$(node -e "const pkg = JSON.parse(require('fs').readFileSync('package.json','utf8')); if (typeof pkg.bin === 'string') { console.log(pkg.name); process.exit(0); } const bins = pkg.bin || {}; if (bins[pkg.name]) { console.log(pkg.name); process.exit(0); } const names = Object.keys(bins); if (names.length === 1) { console.log(names[0]); process.exit(0); } console.error('package.json bin must declare the primary package bin'); process.exit(1);")"
25
25
 
26
26
  formula_url() {
27
27
  local formula_path="$1"
@@ -362,6 +362,9 @@ export async function executeGovernedRun(context, opts = {}) {
362
362
  turnId: turn.turn_id,
363
363
  onSpawnAttached: ({ pid, at }) => ensureStartingState(pid, at),
364
364
  onFirstOutput: ({ at, stream }) => ensureRunningState(stream, at),
365
+ onStartupHeartbeat: ({ elapsed_since_spawn_ms }) => {
366
+ tracker.heartbeat(`Adapter keepalive (${Math.round((elapsed_since_spawn_ms || 0) / 1000)}s since spawn)`);
367
+ },
365
368
  };
366
369
 
367
370
  const recordOutputActivity = (stream, text) => {
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  import chalk from 'chalk';
25
- import { existsSync, readFileSync } from 'fs';
25
+ import { existsSync, readFileSync, unlinkSync } from 'fs';
26
26
  import { join } from 'path';
27
27
  import { loadProjectContext, loadProjectState } from '../lib/config.js';
28
28
  import {
@@ -76,6 +76,7 @@ import { evaluateApprovalSlaReminders } from '../lib/notification-runner.js';
76
76
  import { consumeNextApprovedIntent } from '../lib/intake.js';
77
77
  import { failTurnStartup, reconcileStaleTurns } from '../lib/stale-turn-watchdog.js';
78
78
  import { isKnownTurnRunningProofStream } from '../lib/dispatch-streams.js';
79
+ import { getDispatchProgressRelativePath } from '../lib/dispatch-progress.js';
79
80
 
80
81
  export async function stepCommand(opts) {
81
82
  const context = loadProjectContext();
@@ -192,6 +193,7 @@ export async function stepCommand(opts) {
192
193
  process.exit(1);
193
194
  }
194
195
 
196
+ guardResumeWorkerLiveness(root, targetTurn);
195
197
  skipAssignment = true;
196
198
  console.log(chalk.yellow(`Resuming active turn: ${targetTurn.turn_id}`));
197
199
  } else if (activeCount >= maxConcurrent) {
@@ -272,6 +274,7 @@ export async function stepCommand(opts) {
272
274
  process.exit(1);
273
275
  }
274
276
 
277
+ guardResumeWorkerLiveness(root, targetTurn);
275
278
  console.log(chalk.yellow(`Re-dispatching blocked turn: ${targetTurn.turn_id}`));
276
279
  const reactivated = reactivateGovernedRun(root, state, { via: 'step --resume', notificationConfig: config });
277
280
  if (!reactivated.ok) {
@@ -1039,6 +1042,49 @@ export async function stepCommand(opts) {
1039
1042
  }
1040
1043
  }
1041
1044
 
1045
+ function guardResumeWorkerLiveness(root, turn) {
1046
+ if (!turn || turn.worker_pid == null) {
1047
+ return;
1048
+ }
1049
+
1050
+ if (isWorkerAlive(turn.worker_pid)) {
1051
+ console.log(chalk.red(`Worker process (PID ${turn.worker_pid}) is still alive.`));
1052
+ console.log(chalk.dim('The previous dispatch appears to still be running.'));
1053
+ console.log(chalk.dim('Wait for it to complete, or kill it first, then retry.'));
1054
+ process.exit(1);
1055
+ }
1056
+
1057
+ console.log(chalk.yellow(`Detected crashed worker (PID ${turn.worker_pid}). Re-dispatching turn ${turn.turn_id}...`));
1058
+ cleanupStaleDispatchProgress(root, turn.turn_id);
1059
+ }
1060
+
1061
+ function isWorkerAlive(pid) {
1062
+ const numericPid = Number(pid);
1063
+ if (!Number.isInteger(numericPid) || numericPid <= 0) {
1064
+ return false;
1065
+ }
1066
+
1067
+ try {
1068
+ process.kill(numericPid, 0);
1069
+ return true;
1070
+ } catch {
1071
+ return false;
1072
+ }
1073
+ }
1074
+
1075
+ function cleanupStaleDispatchProgress(root, turnId) {
1076
+ const progressPath = join(root, getDispatchProgressRelativePath(turnId));
1077
+ if (!existsSync(progressPath)) {
1078
+ return;
1079
+ }
1080
+
1081
+ try {
1082
+ unlinkSync(progressPath);
1083
+ } catch {
1084
+ // Best-effort cleanup: resume can still proceed and rewrite fresh progress.
1085
+ }
1086
+ }
1087
+
1042
1088
  function printGhostTurnRecovery(ghostTurns) {
1043
1089
  console.log(chalk.red.bold('Ghost turn detected — subprocess never started.'));
1044
1090
  console.log('');
@@ -34,7 +34,9 @@ import {
34
34
  getClaudeSubprocessAuthIssue,
35
35
  hasClaudeAuthenticationFailureText,
36
36
  hasClaudeNodeIncompatibilityText,
37
+ hasCodexAuthenticationFailureText,
37
38
  isClaudeLocalCliRuntime,
39
+ isCodexLocalCliRuntime,
38
40
  resolveClaudeCompatibleNodeBinary,
39
41
  } from '../claude-local-auth.js';
40
42
 
@@ -49,6 +51,7 @@ const DIAGNOSTIC_ENV_KEYS = [
49
51
  const DIAGNOSTIC_STDERR_EXCERPT_LIMIT = 800;
50
52
  const DEFAULT_STARTUP_WATCHDOG_MS = 180_000;
51
53
  const DEFAULT_STARTUP_WATCHDOG_SIGKILL_GRACE_MS = 10_000;
54
+ const DEFAULT_STARTUP_HEARTBEAT_MS = 30_000;
52
55
 
53
56
  /**
54
57
  * Launch a local CLI subprocess for a governed turn.
@@ -97,6 +100,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
97
100
  }
98
101
  const startupWatchdogMs = startupWatchdogOverrideMs ?? resolveStartupWatchdogMs(config, runtime);
99
102
  const startupWatchdogKillGraceMs = resolveStartupWatchdogKillGraceMs(options.startupWatchdogKillGraceMs);
103
+ const startupHeartbeatMs = resolveStartupHeartbeatMs(config, runtime, options.startupHeartbeatMs);
100
104
 
101
105
  // Read the dispatch bundle prompt
102
106
  const promptPath = join(root, getDispatchPromptPath(turn.turn_id));
@@ -116,8 +120,28 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
116
120
  return { ok: false, error: `Cannot resolve CLI command for runtime "${runtimeId}". Expected "command" field in runtime config.` };
117
121
  }
118
122
 
119
- // Compute timeout from deadline or default (20 minutes)
120
- const timeoutMs = turn.deadline_at
123
+ const compatibility = validateLocalCliCommandCompatibility({ command, args, runtimeId });
124
+ if (!compatibility.ok) {
125
+ const logs = [];
126
+ appendDiagnostic(logs, 'command_compatibility_failed', compatibility.diagnostic);
127
+ return {
128
+ ok: false,
129
+ blocked: true,
130
+ classified: {
131
+ error_class: compatibility.error_class,
132
+ recovery: compatibility.recovery,
133
+ },
134
+ error: compatibility.error,
135
+ logs,
136
+ };
137
+ }
138
+
139
+ // Compute timeout from explicit dispatch deadline, turn deadline, or default (20 minutes).
140
+ const timeoutMs = options.dispatchTimeoutMs != null
141
+ ? options.dispatchTimeoutMs
142
+ : options.dispatchDeadlineAt
143
+ ? Math.max(0, new Date(options.dispatchDeadlineAt).getTime() - Date.now())
144
+ : turn.deadline_at
121
145
  ? Math.max(0, new Date(turn.deadline_at).getTime() - Date.now())
122
146
  : 1200000;
123
147
 
@@ -201,6 +225,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
201
225
  let firstOutputLatencyMs = null;
202
226
  let startupWatchdog = null;
203
227
  let startupSigkillHandle = null;
228
+ let startupHeartbeat = null;
204
229
  let startupTimedOut = false;
205
230
  let startupFailureType = null;
206
231
  let stdoutBytes = 0;
@@ -224,6 +249,43 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
224
249
  }
225
250
  };
226
251
 
252
+ const clearStartupHeartbeat = () => {
253
+ if (startupHeartbeat) {
254
+ clearInterval(startupHeartbeat);
255
+ startupHeartbeat = null;
256
+ }
257
+ };
258
+
259
+ const armStartupHeartbeat = () => {
260
+ if (startupHeartbeat || !(startupHeartbeatMs > 0 && Number.isFinite(startupHeartbeatMs))) {
261
+ return;
262
+ }
263
+ startupHeartbeat = setInterval(() => {
264
+ if (firstOutputAt || settled) {
265
+ clearStartupHeartbeat();
266
+ return;
267
+ }
268
+ const payload = {
269
+ startup_heartbeat_ms: startupHeartbeatMs,
270
+ startup_watchdog_ms: startupWatchdogMs,
271
+ pid: child.pid ?? null,
272
+ spawn_confirmed_at: spawnConfirmedAt,
273
+ elapsed_since_spawn_ms: spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs),
274
+ stdout_bytes: stdoutBytes,
275
+ stderr_bytes: stderrBytes,
276
+ };
277
+ appendDiagnostic(logs, 'startup_heartbeat', payload);
278
+ if (options.onStartupHeartbeat) {
279
+ try {
280
+ options.onStartupHeartbeat(payload);
281
+ } catch {}
282
+ }
283
+ }, startupHeartbeatMs);
284
+ if (typeof startupHeartbeat.unref === 'function') {
285
+ startupHeartbeat.unref();
286
+ }
287
+ };
288
+
227
289
  const armStartupWatchdog = () => {
228
290
  if (startupWatchdog || !(startupWatchdogMs > 0 && Number.isFinite(startupWatchdogMs))) {
229
291
  return;
@@ -269,6 +331,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
269
331
  firstOutputStream = stream;
270
332
  firstOutputLatencyMs = spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs);
271
333
  clearStartupWatchdog();
334
+ clearStartupHeartbeat();
272
335
  appendDiagnostic(logs, 'first_output', {
273
336
  at: firstOutputAt,
274
337
  stream,
@@ -296,6 +359,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
296
359
  } catch {}
297
360
  }
298
361
  armStartupWatchdog();
362
+ armStartupHeartbeat();
299
363
  });
300
364
 
301
365
  // Collect stdout/stderr
@@ -369,6 +433,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
369
433
  const onAbort = () => {
370
434
  logs.push('[adapter] Abort signal received. Sending SIGTERM.');
371
435
  clearStartupWatchdog();
436
+ clearStartupHeartbeat();
372
437
  clearTimeout(timeoutHandle);
373
438
  clearTimeout(sigkillHandle);
374
439
  clearTimeout(abortSigkillHandle);
@@ -389,6 +454,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
389
454
  // Process exit
390
455
  child.on('close', (exitCode, killSignal) => {
391
456
  clearStartupWatchdog();
457
+ clearStartupHeartbeat();
392
458
  clearTimeout(timeoutHandle);
393
459
  clearTimeout(sigkillHandle);
394
460
  clearTimeout(abortSigkillHandle);
@@ -453,6 +519,22 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
453
519
  error: `Claude local_cli authentication failed. ${recovery}`,
454
520
  logs,
455
521
  });
522
+ } else if (isCodexLocalCliRuntime(runtime) && hasCodexAuthFailureOutput(logs)) {
523
+ const recovery = 'Refresh OpenAI credentials before resuming: export a valid OPENAI_API_KEY, then run agentxchain step --resume.';
524
+ settle({
525
+ ok: false,
526
+ blocked: true,
527
+ exitCode,
528
+ timedOut: false,
529
+ aborted: false,
530
+ firstOutputAt,
531
+ classified: {
532
+ error_class: 'codex_auth_failed',
533
+ recovery,
534
+ },
535
+ error: `Codex local_cli authentication failed. ${recovery}`,
536
+ logs,
537
+ });
456
538
  } else if (isClaudeLocalCliRuntime(runtime) && hasClaudeNodeRuntimeIncompatibilityOutput(logs)) {
457
539
  const recovery = 'Run AgentXchain with Node.js 20.5+ available to the Claude local_cli runtime, then resume continuous mode.';
458
540
  settle({
@@ -524,6 +606,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
524
606
 
525
607
  child.on('error', (err) => {
526
608
  clearStartupWatchdog();
609
+ clearStartupHeartbeat();
527
610
  clearTimeout(timeoutHandle);
528
611
  clearTimeout(sigkillHandle);
529
612
  clearTimeout(abortSigkillHandle);
@@ -653,6 +736,19 @@ function resolveStartupWatchdogMs(config, runtime) {
653
736
  return DEFAULT_STARTUP_WATCHDOG_MS;
654
737
  }
655
738
 
739
+ function resolveStartupHeartbeatMs(config, runtime, override) {
740
+ if (Number.isInteger(override) && override > 0) {
741
+ return override;
742
+ }
743
+ if (runtime?.type === 'local_cli' && Number.isInteger(runtime?.startup_heartbeat_ms) && runtime.startup_heartbeat_ms > 0) {
744
+ return runtime.startup_heartbeat_ms;
745
+ }
746
+ if (Number.isInteger(config?.run_loop?.startup_heartbeat_ms) && config.run_loop.startup_heartbeat_ms > 0) {
747
+ return config.run_loop.startup_heartbeat_ms;
748
+ }
749
+ return DEFAULT_STARTUP_HEARTBEAT_MS;
750
+ }
751
+
656
752
  function resolveStartupWatchdogKillGraceMs(value) {
657
753
  if (Number.isInteger(value) && value >= 0) {
658
754
  return value;
@@ -660,6 +756,80 @@ function resolveStartupWatchdogKillGraceMs(value) {
660
756
  return DEFAULT_STARTUP_WATCHDOG_SIGKILL_GRACE_MS;
661
757
  }
662
758
 
759
+ function validateLocalCliCommandCompatibility({ command, args = [], runtimeId = null }) {
760
+ const tokens = [command, ...args].filter((token) => typeof token === 'string');
761
+ const binaryName = command ? command.split('/').filter(Boolean).pop() || command : '';
762
+ const runtimeShape = { command: tokens };
763
+ const outputFormatIndex = tokens.findIndex((token) => token === '--output-format');
764
+ const outputFormatValue = outputFormatIndex >= 0 ? tokens[outputFormatIndex + 1] : null;
765
+ const usesStreamJson = tokens.includes('--output-format=stream-json')
766
+ || outputFormatValue === 'stream-json';
767
+ const usesPrint = tokens.includes('--print') || tokens.includes('-p');
768
+ const hasVerbose = tokens.includes('--verbose');
769
+ const usesCodex = isCodexLocalCliRuntime(runtimeShape);
770
+ const usesCodexExec = tokens.includes('exec');
771
+ const hasCodexJson = tokens.includes('--json');
772
+
773
+ if (binaryName === 'claude' && usesPrint && usesStreamJson && !hasVerbose) {
774
+ const runtimeLabel = runtimeId ? `Runtime "${runtimeId}"` : 'Claude local_cli runtime';
775
+ const recovery = `${runtimeLabel} uses "claude --print --output-format stream-json" without "--verbose". Add "--verbose" to the command array before dispatching again.`;
776
+ return {
777
+ ok: false,
778
+ error_class: 'local_cli_command_incompatible',
779
+ recovery,
780
+ error: recovery,
781
+ diagnostic: {
782
+ runtime_id: runtimeId,
783
+ binary: binaryName,
784
+ rule: 'claude_print_stream_json_requires_verbose',
785
+ has_print: usesPrint,
786
+ has_stream_json: usesStreamJson,
787
+ has_verbose: hasVerbose,
788
+ recovery,
789
+ },
790
+ };
791
+ }
792
+
793
+ if (usesCodex && !usesCodexExec) {
794
+ const runtimeLabel = runtimeId ? `Runtime "${runtimeId}"` : 'Codex local_cli runtime';
795
+ const recovery = `${runtimeLabel} uses "codex" without the "exec" subcommand. Governed local runs require "codex exec" for non-interactive execution.`;
796
+ return {
797
+ ok: false,
798
+ error_class: 'local_cli_command_incompatible',
799
+ recovery,
800
+ error: recovery,
801
+ diagnostic: {
802
+ runtime_id: runtimeId,
803
+ binary: binaryName,
804
+ rule: 'codex_requires_exec',
805
+ has_exec: usesCodexExec,
806
+ recovery,
807
+ },
808
+ };
809
+ }
810
+
811
+ if (usesCodex && usesCodexExec && !hasCodexJson) {
812
+ const runtimeLabel = runtimeId ? `Runtime "${runtimeId}"` : 'Codex local_cli runtime';
813
+ const recovery = `${runtimeLabel} uses "codex exec" without "--json". Add "--json" so subprocess output is machine-readable diagnostics while turn results remain staged on disk.`;
814
+ return {
815
+ ok: false,
816
+ error_class: 'local_cli_command_incompatible',
817
+ recovery,
818
+ error: recovery,
819
+ diagnostic: {
820
+ runtime_id: runtimeId,
821
+ binary: binaryName,
822
+ rule: 'codex_exec_requires_json',
823
+ has_exec: usesCodexExec,
824
+ has_json: hasCodexJson,
825
+ recovery,
826
+ },
827
+ };
828
+ }
829
+
830
+ return { ok: true };
831
+ }
832
+
663
833
  /**
664
834
  * Check if the staged result file exists and has meaningful content.
665
835
  * Delegates to the shared `hasMeaningfulStagedResult` helper so watchdog,
@@ -687,6 +857,11 @@ function hasClaudeAuthFailureOutput(logs) {
687
857
  return logs.some((line) => hasClaudeAuthenticationFailureText(line));
688
858
  }
689
859
 
860
+ function hasCodexAuthFailureOutput(logs) {
861
+ if (!Array.isArray(logs)) return false;
862
+ return logs.some((line) => hasCodexAuthenticationFailureText(line));
863
+ }
864
+
690
865
  function hasClaudeNodeRuntimeIncompatibilityOutput(logs) {
691
866
  if (!Array.isArray(logs)) return false;
692
867
  return hasClaudeNodeIncompatibilityText(logs.join('\n'));
@@ -777,3 +952,5 @@ function appendDiagnosticExcerpt(existing, chunk, limit) {
777
952
  export { resolveCommand };
778
953
  export { resolvePromptTransport };
779
954
  export { resolveStartupWatchdogMs };
955
+ export { resolveStartupHeartbeatMs };
956
+ export { validateLocalCliCommandCompatibility };
@@ -12,6 +12,7 @@ const CLAUDE_ENV_AUTH_KEYS = [
12
12
  const DEFAULT_SMOKE_PROBE_TIMEOUT_MS = 10_000;
13
13
  const DEFAULT_SMOKE_PROBE_STDIN = 'ok';
14
14
  const CLAUDE_AUTH_FAILURE_RE = /authentication_failed|authentication_error|invalid authentication credentials|unauthorized|API Error:\s*401/i;
15
+ const CODEX_AUTH_FAILURE_RE = /unauthorized|invalid api key|invalid_api_key|authentication failed|authentication_failed|openai[\s\S]{0,200}401|api[_ -]?key[\s\S]{0,200}invalid|401[\s\S]{0,200}(openai|invalid|unauthorized)/i;
15
16
  const CLAUDE_NODE_INCOMPATIBILITY_RE =
16
17
  /TypeError:\s*Object not disposable|TypeError\(["']Object not disposable["']\)|Object not disposable[\s\S]{0,2000}Node\.js v(?:1[0-9]|20\.[0-4]\.)/i;
17
18
  const CLAUDE_COMPATIBLE_NODE_MIN = { major: 20, minor: 5, patch: 0 };
@@ -37,10 +38,23 @@ export function isClaudeLocalCliRuntime(runtime) {
37
38
  return head === 'claude' || head.endsWith('/claude');
38
39
  }
39
40
 
41
+ export function isCodexLocalCliRuntime(runtime) {
42
+ const tokens = normalizeCommandTokens(runtime);
43
+ if (tokens.length === 0) {
44
+ return false;
45
+ }
46
+ const head = tokens[0].toLowerCase();
47
+ return head === 'codex' || head.endsWith('/codex');
48
+ }
49
+
40
50
  export function hasClaudeAuthenticationFailureText(text) {
41
51
  return typeof text === 'string' && CLAUDE_AUTH_FAILURE_RE.test(text);
42
52
  }
43
53
 
54
+ export function hasCodexAuthenticationFailureText(text) {
55
+ return typeof text === 'string' && CODEX_AUTH_FAILURE_RE.test(text);
56
+ }
57
+
44
58
  export function hasClaudeNodeIncompatibilityText(text) {
45
59
  return typeof text === 'string' && CLAUDE_NODE_INCOMPATIBILITY_RE.test(text);
46
60
  }