agentxchain 2.130.0 → 2.131.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.130.0",
3
+ "version": "2.131.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,8 @@
24
24
  "dev": "node bin/agentxchain.js",
25
25
  "test": "npm run test:vitest && npm run test:node",
26
26
  "test:vitest": "vitest run --reporter=verbose",
27
- "test:node": "node --test test/*.test.js",
27
+ "test:beta": "node --test test/beta-tester-scenarios/*.test.js",
28
+ "test:node": "node --test test/*.test.js test/beta-tester-scenarios/*.test.js",
28
29
  "preflight:release": "bash scripts/release-preflight.sh",
29
30
  "preflight:release:strict": "bash scripts/release-preflight.sh --strict",
30
31
  "check:release-alignment": "node scripts/check-release-alignment.mjs",
@@ -12,12 +12,13 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
12
12
  const REPO_ROOT = join(__dirname, '..', '..');
13
13
 
14
14
  function usage() {
15
- console.error('Usage: node cli/scripts/check-release-alignment.mjs [--target-version <semver>] [--scope prebump|current] [--json]');
15
+ console.error('Usage: node cli/scripts/check-release-alignment.mjs [--target-version <semver>] [--scope prebump|current] [--json|--report]');
16
16
  }
17
17
 
18
18
  let targetVersion = null;
19
19
  let scope = RELEASE_ALIGNMENT_SCOPES.CURRENT;
20
20
  let json = false;
21
+ let report = false;
21
22
 
22
23
  for (let index = 2; index < process.argv.length; index += 1) {
23
24
  const arg = process.argv[index];
@@ -45,15 +46,40 @@ for (let index = 2; index < process.argv.length; index += 1) {
45
46
  json = true;
46
47
  continue;
47
48
  }
49
+ if (arg === '--report') {
50
+ report = true;
51
+ continue;
52
+ }
48
53
  console.error(`Error: unknown argument "${arg}"`);
49
54
  usage();
50
55
  process.exit(1);
51
56
  }
52
57
 
58
+ if (json && report) {
59
+ console.error('Error: --json and --report are mutually exclusive');
60
+ usage();
61
+ process.exit(1);
62
+ }
63
+
53
64
  const result = validateReleaseAlignment(REPO_ROOT, { targetVersion, scope });
54
65
 
55
66
  if (json) {
56
67
  console.log(JSON.stringify(result, null, 2));
68
+ } else if (report) {
69
+ const readyCount = result.surfaceResults.filter((surface) => surface.ok).length;
70
+ const needsUpdateCount = result.surfaceResults.length - readyCount;
71
+ console.log(
72
+ `Release alignment report for ${result.targetVersion} (${result.scope}, ${result.checkedSurfaceCount} surfaces).`,
73
+ );
74
+ for (const surface of result.surfaceResults) {
75
+ console.log(`- [${surface.ok ? 'ready' : 'needs update'}] (${surface.surface_id}) ${surface.label}`);
76
+ for (const error of surface.errors) {
77
+ console.log(` - ${error}`);
78
+ }
79
+ }
80
+ console.log(
81
+ `Summary: ${readyCount} ready, ${needsUpdateCount} need update.`,
82
+ );
57
83
  } else if (result.ok) {
58
84
  console.log(`Release alignment OK for ${result.targetVersion} (${result.scope}, ${result.checkedSurfaceCount} surfaces).`);
59
85
  } else {
@@ -10,11 +10,13 @@ cd "$CLI_DIR"
10
10
 
11
11
  STRICT_MODE=0
12
12
  PUBLISH_GATE=0
13
+ DRY_RUN=0
13
14
  TARGET_VERSION="2.0.0"
14
15
 
15
16
  usage() {
16
- echo "Usage: bash scripts/release-preflight.sh [--strict] [--publish-gate] [--target-version <semver>]" >&2
17
+ echo "Usage: bash scripts/release-preflight.sh [--strict] [--publish-gate] [--dry-run] [--target-version <semver>]" >&2
17
18
  echo " --publish-gate Run only release-critical checks (no full test suite). Use in CI publish workflows." >&2
19
+ echo " --dry-run Preview manual release-alignment surfaces without running the full gate." >&2
18
20
  }
19
21
 
20
22
  while [[ $# -gt 0 ]]; do
@@ -28,6 +30,10 @@ while [[ $# -gt 0 ]]; do
28
30
  STRICT_MODE=1
29
31
  shift
30
32
  ;;
33
+ --dry-run)
34
+ DRY_RUN=1
35
+ shift
36
+ ;;
31
37
  --target-version)
32
38
  if [[ -z "${2:-}" ]]; then
33
39
  echo "Error: --target-version requires a semver argument" >&2
@@ -49,6 +55,12 @@ while [[ $# -gt 0 ]]; do
49
55
  esac
50
56
  done
51
57
 
58
+ if [[ "$DRY_RUN" -eq 1 && "$STRICT_MODE" -eq 1 ]]; then
59
+ echo "Error: --dry-run cannot be combined with --strict or --publish-gate" >&2
60
+ usage
61
+ exit 1
62
+ fi
63
+
52
64
  PASS=0
53
65
  FAIL=0
54
66
  WARN=0
@@ -84,6 +96,31 @@ else
84
96
  fi
85
97
  echo ""
86
98
 
99
+ if [[ "$DRY_RUN" -eq 1 ]]; then
100
+ echo "Release Preflight Preview"
101
+ echo "========================="
102
+ echo "Mode: DRY RUN (manual release-alignment surfaces only; no git/npm gate checks executed)"
103
+ echo ""
104
+ ALIGNMENT_SCRIPT="${SCRIPT_DIR}/check-release-alignment.mjs"
105
+ if [[ ! -f "$ALIGNMENT_SCRIPT" ]]; then
106
+ echo "Error: release alignment preview requires ${ALIGNMENT_SCRIPT}" >&2
107
+ exit 1
108
+ fi
109
+ if run_and_capture ALIGNMENT_REPORT node "$ALIGNMENT_SCRIPT" --scope prebump --target-version "$TARGET_VERSION" --report; then
110
+ ALIGNMENT_STATUS=0
111
+ else
112
+ ALIGNMENT_STATUS=$?
113
+ fi
114
+ printf '%s\n' "$ALIGNMENT_REPORT"
115
+ echo ""
116
+ if [[ "$ALIGNMENT_STATUS" -eq 0 ]]; then
117
+ echo "PREVIEW COMPLETE: manual release-alignment surfaces are ready for ${TARGET_VERSION}."
118
+ else
119
+ echo "PREVIEW COMPLETE: manual release-alignment surfaces still need updates before a real preflight/tag push."
120
+ fi
121
+ exit 0
122
+ fi
123
+
87
124
  # 1. Clean working tree
88
125
  echo "[1/7] Git status"
89
126
  if git diff --quiet HEAD 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then
@@ -110,24 +147,37 @@ if [[ "$PUBLISH_GATE" -eq 1 ]]; then
110
147
  echo "[3/7] Release-gate tests (targeted subset)"
111
148
  # In publish-gate mode, run only release-critical tests to avoid CI hangs.
112
149
  # The full test suite is a pre-tag responsibility, not a publish-time gate.
113
- GATE_TESTS=(
150
+ GATE_TEST_PATTERNS=(
114
151
  test/release-preflight.test.js
115
152
  test/release-docs-content.test.js
116
153
  test/release-notes-gate.test.js
117
154
  test/release-identity-hardening.test.js
118
155
  test/normalized-config.test.js
119
156
  test/conformance.test.js
157
+ test/beta-tester-scenarios/*.test.js
120
158
  )
121
159
  GATE_TEST_ARGS=()
122
- for t in "${GATE_TESTS[@]}"; do
123
- if [[ -f "$t" ]]; then
160
+ shopt -s nullglob
161
+ for pattern in "${GATE_TEST_PATTERNS[@]}"; do
162
+ for t in $pattern; do
124
163
  GATE_TEST_ARGS+=("$t")
125
- fi
164
+ done
126
165
  done
166
+ shopt -u nullglob
127
167
  if [[ ${#GATE_TEST_ARGS[@]} -eq 0 ]]; then
128
168
  fail "No release-gate test files found"
129
169
  else
130
- if run_and_capture TEST_OUTPUT env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 node --test "${GATE_TEST_ARGS[@]}"; then
170
+ BETA_TEST_COUNT=0
171
+ for t in "${GATE_TEST_ARGS[@]}"; do
172
+ if [[ "$t" == test/beta-tester-scenarios/*.test.js ]]; then
173
+ BETA_TEST_COUNT=$((BETA_TEST_COUNT + 1))
174
+ fi
175
+ done
176
+ if [[ "$BETA_TEST_COUNT" -eq 0 ]]; then
177
+ fail "No beta-tester scenario tests found for release-gate verification"
178
+ TEST_OUTPUT=""
179
+ TEST_STATUS=1
180
+ elif run_and_capture TEST_OUTPUT env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 node --test "${GATE_TEST_ARGS[@]}"; then
131
181
  TEST_STATUS=0
132
182
  else
133
183
  TEST_STATUS=$?
@@ -105,6 +105,7 @@ export async function connectorCheckCommand(runtimeId, options = {}) {
105
105
  const result = await probeConfiguredConnectors(context.config, {
106
106
  runtimeId: runtimeId || null,
107
107
  timeoutMs,
108
+ root: context.root,
108
109
  onProbeStart: options.json ? null : (probeRuntimeId, runtime) => {
109
110
  console.log(` ${chalk.dim('…')} Probing ${chalk.bold(probeRuntimeId)} ${chalk.dim(`(${runtime.type})`)}`);
110
111
  },
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
- import { execFileSync, execSync } from 'child_process';
2
+ import { execFileSync } from 'child_process';
3
3
  import { join } from 'path';
4
4
  import chalk from 'chalk';
5
5
  import { loadConfig, loadLock, findProjectRoot, loadProjectState } from '../lib/config.js';
@@ -20,6 +20,7 @@ import {
20
20
  import { detectActiveTurnBindingDrift, detectStateBundleDesync } from '../lib/governed-state.js';
21
21
  import { findPendingApprovedIntents } from '../lib/intake.js';
22
22
  import { checkCleanBaseline } from '../lib/repo-observer.js';
23
+ import { probeRuntimeSpawnContext } from '../lib/runtime-spawn-context.js';
23
24
 
24
25
  export async function doctorCommand(opts = {}) {
25
26
  const root = findProjectRoot(process.cwd());
@@ -90,7 +91,7 @@ function governedDoctor(root, rawConfig, opts) {
90
91
  const runtimes = (normalized && normalized.runtimes) || rawConfig.runtimes || {};
91
92
  const rolesByRuntime = buildRolesByRuntime(normalized?.roles || {});
92
93
  for (const [rtId, rt] of Object.entries(runtimes)) {
93
- const check = checkRuntimeReachable(rtId, rt, rolesByRuntime[rtId] || []);
94
+ const check = checkRuntimeReachable(root, rtId, rt, rolesByRuntime[rtId] || []);
94
95
  checks.push(check);
95
96
  }
96
97
  const connectorProbe = getConnectorProbeRecommendation(runtimes);
@@ -484,7 +485,7 @@ function buildCliVersionCheck(cliVersionHealth) {
484
485
  };
485
486
  }
486
487
 
487
- function checkRuntimeReachable(rtId, rt, boundRoleEntries = []) {
488
+ function checkRuntimeReachable(root, rtId, rt, boundRoleEntries = []) {
488
489
  const base = { id: `runtime_${rtId}`, name: `Runtime: ${rtId}` };
489
490
 
490
491
  if (!rt || !rt.type) {
@@ -496,14 +497,8 @@ function checkRuntimeReachable(rtId, rt, boundRoleEntries = []) {
496
497
  return attachRuntimeContract({ ...base, level: 'pass', detail: 'Manual runtime (no binary needed)' }, rtId, rt, boundRoleEntries);
497
498
 
498
499
  case 'local_cli': {
499
- const cmd = Array.isArray(rt.command) ? rt.command[0] : (typeof rt.command === 'string' ? rt.command.split(/\s+/)[0] : null);
500
- if (!cmd) return attachRuntimeContract({ ...base, level: 'warn', detail: 'No command configured' }, rtId, rt, boundRoleEntries);
501
- try {
502
- execSync(`command -v ${cmd}`, { stdio: 'ignore' });
503
- return attachRuntimeContract({ ...base, level: 'pass', detail: `${cmd} binary found` }, rtId, rt, boundRoleEntries);
504
- } catch {
505
- return attachRuntimeContract({ ...base, level: 'fail', detail: `${cmd} not found in PATH` }, rtId, rt, boundRoleEntries);
506
- }
500
+ const probe = probeRuntimeSpawnContext(root, rt, { runtimeId: rtId });
501
+ return attachRuntimeContract({ ...base, level: probe.ok ? 'pass' : 'fail', detail: probe.detail }, rtId, rt, boundRoleEntries);
507
502
  }
508
503
 
509
504
  case 'api_proxy': {
@@ -523,14 +518,8 @@ function checkRuntimeReachable(rtId, rt, boundRoleEntries = []) {
523
518
  if (transport === 'streamable_http') {
524
519
  return attachRuntimeContract({ ...base, level: 'warn', detail: 'Remote MCP endpoint (cannot verify at doctor time)' }, rtId, rt, boundRoleEntries);
525
520
  }
526
- const cmd = Array.isArray(rt.command) ? rt.command[0] : (typeof rt.command === 'string' ? rt.command.split(/\s+/)[0] : null);
527
- if (!cmd) return attachRuntimeContract({ ...base, level: 'warn', detail: 'No MCP command configured' }, rtId, rt, boundRoleEntries);
528
- try {
529
- execSync(`command -v ${cmd}`, { stdio: 'ignore' });
530
- return attachRuntimeContract({ ...base, level: 'pass', detail: `${cmd} binary found` }, rtId, rt, boundRoleEntries);
531
- } catch {
532
- return attachRuntimeContract({ ...base, level: 'fail', detail: `${cmd} not found in PATH` }, rtId, rt, boundRoleEntries);
533
- }
521
+ const probe = probeRuntimeSpawnContext(root, rt, { runtimeId: rtId });
522
+ return attachRuntimeContract({ ...base, level: probe.ok ? 'pass' : 'fail', detail: probe.detail }, rtId, rt, boundRoleEntries);
534
523
  }
535
524
 
536
525
  case 'remote_agent':
@@ -22,6 +22,7 @@ import {
22
22
  getWorkstreamStatusSummary,
23
23
  launchCoordinatorWorkstream,
24
24
  launchWorkstream,
25
+ retryCoordinatorWorkstream,
25
26
  retryWorkstream,
26
27
  markWorkstreamOutcome,
27
28
  loadAllPlans,
@@ -541,11 +542,6 @@ export async function missionPlanLaunchCommand(planTarget, opts) {
541
542
  }
542
543
 
543
544
  if (mission.coordinator && plan.coordinator_scope) {
544
- if (opts.retry) {
545
- console.error(chalk.red('--retry is not supported for coordinator-bound mission plans yet.'));
546
- process.exit(1);
547
- }
548
-
549
545
  const coordinatorConfigResult = loadCoordinatorConfig(mission.coordinator.workspace_path);
550
546
  if (!coordinatorConfigResult.ok) {
551
547
  console.error(chalk.red('Coordinator config validation failed:'));
@@ -565,6 +561,113 @@ export async function missionPlanLaunchCommand(planTarget, opts) {
565
561
  process.exit(1);
566
562
  }
567
563
 
564
+ if (opts.retry) {
565
+ const retry = retryCoordinatorWorkstream(
566
+ root,
567
+ mission,
568
+ plan.plan_id,
569
+ opts.workstream,
570
+ coordinatorConfigResult.config,
571
+ {
572
+ reason: `mission plan retry ${opts.workstream}`,
573
+ },
574
+ );
575
+ if (!retry.ok) {
576
+ console.error(chalk.red(retry.error));
577
+ process.exit(1);
578
+ }
579
+
580
+ const executor = opts._executeGovernedRun || executeGovernedRun;
581
+ const repoContext = loadProjectContext(retry.retryResult.repo_path);
582
+ if (!repoContext) {
583
+ console.error(chalk.red(`Cannot load project context for retried repo at ${retry.retryResult.repo_path}.`));
584
+ process.exit(1);
585
+ }
586
+
587
+ const runOpts = {
588
+ autoApprove: !!opts.autoApprove,
589
+ provenance: {
590
+ trigger: 'manual',
591
+ created_by: 'operator',
592
+ trigger_reason: `mission:${mission.mission_id} workstream:${opts.workstream} coordinator-retry:${retry.retryResult.repo_id}`,
593
+ },
594
+ };
595
+
596
+ let execution;
597
+ try {
598
+ execution = await executor(repoContext, runOpts);
599
+ } catch (error) {
600
+ const syncedRetryFailure = synchronizeCoordinatorPlanState(root, mission, retry.plan);
601
+ console.error(chalk.red(`Coordinator retry execution failed: ${error.message}`));
602
+ if (opts.json) {
603
+ console.log(JSON.stringify({
604
+ dispatch_mode: 'coordinator',
605
+ retry: true,
606
+ mission_id: mission.mission_id,
607
+ plan_id: retry.plan.plan_id,
608
+ workstream_id: opts.workstream,
609
+ repo_id: retry.retryResult.repo_id,
610
+ retried_repo_turn_id: retry.retryResult.failed_turn_id,
611
+ repo_turn_id: retry.retryResult.reissued_turn_id,
612
+ workstream_status: syncedRetryFailure.ok
613
+ ? syncedRetryFailure.plan.workstreams.find((ws) => ws.workstream_id === opts.workstream)?.launch_status || 'needs_attention'
614
+ : 'needs_attention',
615
+ launch_record: retry.launchRecord,
616
+ error: error.message,
617
+ }, null, 2));
618
+ }
619
+ process.exit(1);
620
+ }
621
+
622
+ const syncedRetry = synchronizeCoordinatorPlanState(root, mission, retry.plan);
623
+ const retriedPlan = syncedRetry.ok ? syncedRetry.plan : retry.plan;
624
+ const retriedWorkstream = retriedPlan.workstreams.find((ws) => ws.workstream_id === opts.workstream);
625
+ const retriedLaunchRecord = retriedPlan.launch_records.find(
626
+ (record) => record.workstream_id === opts.workstream && record.dispatch_mode === 'coordinator',
627
+ ) || retry.launchRecord;
628
+
629
+ if (opts.json) {
630
+ console.log(JSON.stringify({
631
+ dispatch_mode: 'coordinator',
632
+ retry: true,
633
+ mission_id: mission.mission_id,
634
+ plan_id: retriedPlan.plan_id,
635
+ workstream_id: opts.workstream,
636
+ super_run_id: mission.coordinator.super_run_id,
637
+ repo_id: retry.retryResult.repo_id,
638
+ retried_repo_turn_id: retry.retryResult.failed_turn_id,
639
+ repo_turn_id: retry.retryResult.reissued_turn_id,
640
+ role: retry.retryResult.role,
641
+ bundle_path: retry.retryResult.bundle_path,
642
+ context_ref: retry.retryResult.context_ref,
643
+ workstream_status: retriedWorkstream?.launch_status || 'launched',
644
+ launch_record: retriedLaunchRecord,
645
+ exit_code: execution?.exitCode ?? 0,
646
+ }, null, 2));
647
+ if ((execution?.exitCode ?? 0) !== 0) {
648
+ process.exit(execution.exitCode);
649
+ }
650
+ return;
651
+ }
652
+
653
+ console.log(chalk.green(`Retried coordinator workstream ${chalk.bold(opts.workstream)} in ${chalk.bold(retry.retryResult.repo_id)}`));
654
+ console.log('');
655
+ console.log(chalk.dim(` Mission: ${mission.mission_id}`));
656
+ console.log(chalk.dim(` Plan: ${retriedPlan.plan_id}`));
657
+ console.log(chalk.dim(` Super Run: ${mission.coordinator.super_run_id}`));
658
+ console.log(chalk.dim(` Repo: ${retry.retryResult.repo_id}`));
659
+ console.log(chalk.dim(` Old Turn: ${retry.retryResult.failed_turn_id}`));
660
+ console.log(chalk.dim(` New Turn: ${retry.retryResult.reissued_turn_id}`));
661
+ console.log(chalk.dim(` Workstream: ${retriedWorkstream?.launch_status || 'launched'}`));
662
+ console.log('');
663
+ renderPlan(retriedPlan);
664
+ if ((execution?.exitCode ?? 0) !== 0) {
665
+ console.error(chalk.red(`Coordinator retry execution ended with exit code ${execution.exitCode}.`));
666
+ process.exit(execution.exitCode);
667
+ }
668
+ return;
669
+ }
670
+
568
671
  const assignment = selectAssignmentForWorkstream(
569
672
  mission.coordinator.workspace_path,
570
673
  coordinatorState,
@@ -1,10 +1,9 @@
1
- import { execFileSync } from 'node:child_process';
2
-
3
1
  import {
4
2
  buildProviderHeaders,
5
3
  buildProviderRequest,
6
4
  PROVIDER_ENDPOINTS,
7
5
  } from './adapters/api-proxy-adapter.js';
6
+ import { probeRuntimeSpawnContext } from './runtime-spawn-context.js';
8
7
 
9
8
  const PROBEABLE_RUNTIME_TYPES = new Set(['local_cli', 'api_proxy', 'mcp', 'remote_agent']);
10
9
  const DEFAULT_TIMEOUT_MS = 8_000;
@@ -35,8 +34,8 @@ const KNOWN_CLI_AUTHORITY_FLAGS = [
35
34
  * Maps binary name to expected transport.
36
35
  */
37
36
  const KNOWN_CLI_TRANSPORTS = {
38
- claude: 'stdin',
39
- codex: 'argv',
37
+ claude: ['stdin'],
38
+ codex: ['argv', 'stdin'],
40
39
  };
41
40
 
42
41
  function formatCommand(command, args = []) {
@@ -78,11 +77,6 @@ function commandHead(runtime) {
78
77
  return null;
79
78
  }
80
79
 
81
- function resolveBinary(command) {
82
- const resolver = process.platform === 'win32' ? 'where' : 'which';
83
- execFileSync(resolver, [command], { stdio: 'ignore' });
84
- }
85
-
86
80
  function resolveProviderEndpoint(runtime) {
87
81
  if (typeof runtime?.base_url === 'string' && runtime.base_url.trim()) {
88
82
  return runtime.base_url.trim();
@@ -153,7 +147,7 @@ async function probeHttp({ url, method = 'GET', headers = {}, body, timeoutMs })
153
147
  }
154
148
  }
155
149
 
156
- async function probeLocalCommand(runtimeId, runtime, probeKindLabel) {
150
+ async function probeLocalCommand(runtimeId, runtime, probeKindLabel, options = {}) {
157
151
  const head = commandHead(runtime);
158
152
  const base = {
159
153
  runtime_id: runtimeId,
@@ -170,22 +164,22 @@ async function probeLocalCommand(runtimeId, runtime, probeKindLabel) {
170
164
  };
171
165
  }
172
166
 
173
- try {
174
- resolveBinary(head);
167
+ const spawnProbe = probeRuntimeSpawnContext(options.root || process.cwd(), runtime, { runtimeId });
168
+ if (spawnProbe.ok) {
175
169
  return {
176
170
  ...base,
177
171
  level: 'pass',
178
- command: head,
179
- detail: `${head} is available on PATH`,
180
- };
181
- } catch {
182
- return {
183
- ...base,
184
- level: 'fail',
185
- command: head,
186
- detail: `${head} was not found on PATH`,
172
+ command: spawnProbe.command || head,
173
+ detail: spawnProbe.detail,
187
174
  };
188
175
  }
176
+
177
+ return {
178
+ ...base,
179
+ level: 'fail',
180
+ command: spawnProbe.command || head,
181
+ detail: spawnProbe.detail,
182
+ };
189
183
  }
190
184
 
191
185
  async function probeApiProxy(runtimeId, runtime, timeoutMs) {
@@ -331,6 +325,27 @@ function analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles) {
331
325
  if (boundRoles.length === 0) return { warnings };
332
326
 
333
327
  const authoritativeRoles = boundRoles.filter((r) => r.write_authority === 'authoritative');
328
+ const isCodex = binaryName === 'codex' || binaryName.endsWith('/codex');
329
+
330
+ if (isCodex) {
331
+ if (commandTokens[1] !== 'exec') {
332
+ warnings.push({
333
+ probe_kind: 'command_intent',
334
+ level: 'warn',
335
+ detail: 'OpenAI Codex CLI governed local runs should use the non-interactive "exec" subcommand. Top-level "codex" is the interactive entrypoint.',
336
+ fix: 'Use ["codex", "exec", "--dangerously-bypass-approvals-and-sandbox", "{prompt}"]',
337
+ });
338
+ }
339
+
340
+ if (commandTokens.includes('--quiet')) {
341
+ warnings.push({
342
+ probe_kind: 'command_intent',
343
+ level: 'warn',
344
+ detail: 'OpenAI Codex CLI rejects "--quiet" in governed local_cli commands on the current CLI. The command exits before the turn starts.',
345
+ fix: 'Remove "--quiet" and use ["codex", "exec", "--dangerously-bypass-approvals-and-sandbox", "{prompt}"]',
346
+ });
347
+ }
348
+ }
334
349
 
335
350
  // Check known CLI authority flags
336
351
  const knownCli = KNOWN_CLI_AUTHORITY_FLAGS.find((entry) => binaryName === entry.binary || binaryName.endsWith(`/${entry.binary}`));
@@ -359,7 +374,7 @@ function analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles) {
359
374
 
360
375
  // Prompt transport validation
361
376
  const transport = runtime.prompt_transport || 'dispatch_bundle_only';
362
- const knownTransport = KNOWN_CLI_TRANSPORTS[binaryName];
377
+ const knownTransports = KNOWN_CLI_TRANSPORTS[binaryName];
363
378
 
364
379
  if (transport === 'argv' && !commandTokens.some((token) => token.includes('{prompt}'))) {
365
380
  warnings.push({
@@ -370,13 +385,13 @@ function analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles) {
370
385
  });
371
386
  }
372
387
 
373
- if (knownTransport && transport !== knownTransport && transport !== 'dispatch_bundle_only') {
388
+ if (knownTransports && !knownTransports.includes(transport) && transport !== 'dispatch_bundle_only') {
374
389
  const transportLabel = knownCli ? knownCli.label : binaryName;
375
390
  warnings.push({
376
391
  probe_kind: 'transport_intent',
377
392
  level: 'warn',
378
- detail: `${transportLabel} typically uses "${knownTransport}" transport, but this runtime is configured with "${transport}"`,
379
- fix: `Set prompt_transport to "${knownTransport}" or "dispatch_bundle_only"`,
393
+ detail: `${transportLabel} typically uses ${knownTransports.map((value) => `"${value}"`).join(' or ')} transport, but this runtime is configured with "${transport}"`,
394
+ fix: `Set prompt_transport to ${knownTransports.map((value) => `"${value}"`).join(' or ')} or "dispatch_bundle_only"`,
380
395
  });
381
396
  }
382
397
 
@@ -414,7 +429,7 @@ export async function probeConnectorRuntime(runtimeId, runtime, options = {}) {
414
429
  }
415
430
 
416
431
  if (runtime.type === 'local_cli') {
417
- const result = await probeLocalCommand(runtimeId, runtime, 'command_presence');
432
+ const result = await probeLocalCommand(runtimeId, runtime, 'command_presence', options);
418
433
  // Add authority-intent and transport analysis when roles are available
419
434
  if (roles) {
420
435
  const { warnings } = analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles);
@@ -437,7 +452,7 @@ export async function probeConnectorRuntime(runtimeId, runtime, options = {}) {
437
452
  if (runtime.transport === 'streamable_http') {
438
453
  return probeHttpRuntime(runtimeId, runtime, timeoutMs);
439
454
  }
440
- return probeLocalCommand(runtimeId, runtime, 'command_presence');
455
+ return probeLocalCommand(runtimeId, runtime, 'command_presence', options);
441
456
  }
442
457
 
443
458
  return probeHttpRuntime(runtimeId, runtime, timeoutMs);
@@ -22,6 +22,7 @@ import { dispatchMcp } from './adapters/mcp-adapter.js';
22
22
  import { dispatchRemoteAgent } from './adapters/remote-agent-adapter.js';
23
23
  import { getDispatchPromptPath, getTurnStagingResultPath } from './turn-paths.js';
24
24
  import { validateStagedTurnResult } from './turn-result-validator.js';
25
+ import { probeRuntimeSpawnContext } from './runtime-spawn-context.js';
25
26
 
26
27
  const VALIDATABLE_RUNTIME_TYPES = new Set(['local_cli', 'api_proxy', 'mcp', 'remote_agent']);
27
28
  const DEFAULT_VALIDATE_TIMEOUT_MS = 120_000;
@@ -130,6 +131,26 @@ export async function validateConfiguredConnector(sourceRoot, options = {}) {
130
131
  };
131
132
  }
132
133
 
134
+ if (runtime.type === 'local_cli' || (runtime.type === 'mcp' && (runtime.transport || 'stdio') !== 'streamable_http')) {
135
+ const spawnProbe = probeRuntimeSpawnContext(scratchRoot, scratchContext.config.runtimes[runtimeId], { runtimeId });
136
+ if (!spawnProbe.ok) {
137
+ return {
138
+ ok: false,
139
+ exitCode: 1,
140
+ overall: 'fail',
141
+ runtime_id: runtimeId,
142
+ runtime_type: runtime.type,
143
+ role_id: roleSelection.roleId,
144
+ timeout_ms: timeoutMs,
145
+ warnings,
146
+ dispatch: null,
147
+ validation: null,
148
+ error: spawnProbe.detail,
149
+ scratch_root: scratchRoot,
150
+ };
151
+ }
152
+ }
153
+
133
154
  const initResult = initializeGovernedRun(scratchRoot, scratchContext.config, {
134
155
  provenance: {
135
156
  trigger: 'connector_validate',
@@ -85,6 +85,17 @@ function buildPlanSummary(plan) {
85
85
  completed_at: lr.completed_at || null,
86
86
  status: lr.status,
87
87
  terminal_reason: lr.terminal_reason || null,
88
+ dispatch_mode: lr.dispatch_mode || null,
89
+ ...(lr.dispatch_mode === 'coordinator' && Array.isArray(lr.repo_dispatches) ? {
90
+ repo_dispatches: lr.repo_dispatches.map((rd) => ({
91
+ repo_id: rd.repo_id,
92
+ repo_turn_id: rd.repo_turn_id,
93
+ role: rd.role,
94
+ dispatched_at: rd.dispatched_at,
95
+ ...(rd.is_retry ? { is_retry: true, retry_of: rd.retry_of } : {}),
96
+ ...(rd.retried_at ? { retried_at: rd.retried_at, retry_reason: rd.retry_reason } : {}),
97
+ })),
98
+ } : {}),
88
99
  })),
89
100
  };
90
101
  }
@@ -2488,7 +2488,8 @@ export function reissueTurn(root, config, opts = {}) {
2488
2488
  const oldRuntimeId = oldTurn.runtime_id;
2489
2489
 
2490
2490
  // Resolve current runtime binding (may have changed in config)
2491
- const currentRuntimeId = role.runtime;
2491
+ // BUG-25 fix: normalized config uses runtime_id, raw config uses runtime
2492
+ const currentRuntimeId = role.runtime_id || role.runtime;
2492
2493
  const currentRuntime = config.runtimes?.[currentRuntimeId];
2493
2494
  if (!currentRuntime) {
2494
2495
  return { ok: false, error: `Runtime "${currentRuntimeId}" not found in config for role "${roleId}"` };
@@ -6,11 +6,15 @@
6
6
  * Plans are NOT protocol-normative.
7
7
  */
8
8
 
9
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
9
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
10
10
  import { randomUUID } from 'crypto';
11
11
  import { join } from 'path';
12
12
  import { loadChainReport } from './chain-reports.js';
13
- import { readBarriers, readCoordinatorHistory } from './coordinator-state.js';
13
+ import { writeDispatchBundle } from './dispatch-bundle.js';
14
+ import { loadProjectContext, loadProjectState } from './config.js';
15
+ import { reissueTurn } from './governed-state.js';
16
+ import { emitRunEvent } from './run-events.js';
17
+ import { readBarriers, readCoordinatorHistory, recordCoordinatorDecision } from './coordinator-state.js';
14
18
  import { loadCoordinatorConfig } from './coordinator-config.js';
15
19
 
16
20
  // ── Plan artifact directory ──────────────────────────────────────────────────
@@ -486,6 +490,7 @@ function isAcceptedRepoHistoryEntry(entry) {
486
490
  }
487
491
 
488
492
  const REPO_FAILURE_STATUSES = new Set(['failed_acceptance', 'failed', 'rejected', 'retrying', 'conflicted']);
493
+ const RETRYABLE_COORDINATOR_FAILURE_STATUSES = new Set(['failed', 'failed_acceptance']);
489
494
 
490
495
  function getLatestRepoDispatches(launchRecord) {
491
496
  const latestByRepo = new Map();
@@ -554,6 +559,76 @@ function buildCoordinatorRepoFailures(coordinatorConfig, launchRecord) {
554
559
  return failures;
555
560
  }
556
561
 
562
+ function appendCoordinatorHistoryEntry(workspacePath, entry) {
563
+ const historyPath = join(workspacePath, '.agentxchain', 'multirepo', 'history.jsonl');
564
+ mkdirSync(join(workspacePath, '.agentxchain', 'multirepo'), { recursive: true });
565
+ appendFileSync(historyPath, `${JSON.stringify(entry)}\n`);
566
+ }
567
+
568
+ function findDependentDispatchAfter(plan, workstreamId, timestamp) {
569
+ const since = new Date(timestamp || 0).getTime();
570
+ if (Number.isNaN(since)) return null;
571
+
572
+ for (const candidate of plan.workstreams || []) {
573
+ if (!Array.isArray(candidate.depends_on) || !candidate.depends_on.includes(workstreamId)) {
574
+ continue;
575
+ }
576
+
577
+ for (const record of plan.launch_records || []) {
578
+ if (record?.workstream_id !== candidate.workstream_id) {
579
+ continue;
580
+ }
581
+
582
+ if (record.dispatch_mode === 'coordinator') {
583
+ for (const dispatch of record.repo_dispatches || []) {
584
+ const dispatchedAt = new Date(dispatch?.dispatched_at || 0).getTime();
585
+ if (!Number.isNaN(dispatchedAt) && dispatchedAt > since) {
586
+ return {
587
+ workstream_id: candidate.workstream_id,
588
+ repo_id: dispatch.repo_id || null,
589
+ dispatched_at: dispatch.dispatched_at || null,
590
+ };
591
+ }
592
+ }
593
+ continue;
594
+ }
595
+
596
+ const launchedAt = new Date(record.launched_at || 0).getTime();
597
+ if (!Number.isNaN(launchedAt) && launchedAt > since) {
598
+ return {
599
+ workstream_id: candidate.workstream_id,
600
+ repo_id: null,
601
+ dispatched_at: record.launched_at || null,
602
+ };
603
+ }
604
+ }
605
+ }
606
+
607
+ return null;
608
+ }
609
+
610
+ function loadRepoTurnForRetry(repoPath, repoTurnId) {
611
+ const context = loadProjectContext(repoPath);
612
+ if (!context) {
613
+ return { ok: false, error: `Repo at "${repoPath}" is not a loadable governed project.` };
614
+ }
615
+
616
+ const state = loadProjectState(context.root, context.config);
617
+ if (!state) {
618
+ return { ok: false, error: `Repo at "${repoPath}" has no governed state.` };
619
+ }
620
+
621
+ const activeTurn = state.active_turns?.[repoTurnId] || null;
622
+ if (!activeTurn) {
623
+ return {
624
+ ok: false,
625
+ error: `Repo turn "${repoTurnId}" is no longer active. Use repo-local recovery instead of coordinator --retry.`,
626
+ };
627
+ }
628
+
629
+ return { ok: true, root: context.root, config: context.config, state, activeTurn };
630
+ }
631
+
557
632
  function buildCoordinatorWorkstreamProgress(coordinatorConfig, history, barriers, workstreamId) {
558
633
  const coordinatorWorkstream = coordinatorConfig?.workstreams?.[workstreamId];
559
634
  if (!coordinatorWorkstream) {
@@ -904,6 +979,188 @@ export function launchCoordinatorWorkstream(root, mission, planId, workstreamId,
904
979
  return { ok: true, plan: synced.ok ? synced.plan : plan, workstream: ws, launchRecord };
905
980
  }
906
981
 
982
+ export function retryCoordinatorWorkstream(root, mission, planId, workstreamId, coordinatorConfig, options = {}) {
983
+ const plan = loadPlan(root, mission.mission_id, planId);
984
+ if (!plan) {
985
+ return { ok: false, error: `Plan not found: ${planId}` };
986
+ }
987
+ if (!mission?.coordinator?.workspace_path || !mission?.coordinator?.super_run_id) {
988
+ return { ok: false, error: 'Mission is not bound to a coordinator run.' };
989
+ }
990
+
991
+ const synced = synchronizeCoordinatorPlanState(root, mission, plan);
992
+ const workingPlan = synced.ok ? synced.plan : plan;
993
+ const ws = workingPlan.workstreams.find((candidate) => candidate.workstream_id === workstreamId);
994
+ if (!ws) {
995
+ return { ok: false, error: `Workstream not found: ${workstreamId}` };
996
+ }
997
+ if (ws.launch_status !== 'needs_attention') {
998
+ return {
999
+ ok: false,
1000
+ error: `Workstream ${workstreamId} is not in needs_attention state. Nothing to retry.`,
1001
+ };
1002
+ }
1003
+
1004
+ const launchRecord = getLatestCoordinatorLaunchRecord(workingPlan, workstreamId);
1005
+ if (!launchRecord) {
1006
+ return { ok: false, error: `No coordinator launch record found for workstream ${workstreamId}.` };
1007
+ }
1008
+
1009
+ const latestDispatchesByRepo = new Map(
1010
+ getLatestRepoDispatches(launchRecord).map((dispatch) => [dispatch.repo_id, dispatch]),
1011
+ );
1012
+ const repoFailures = buildCoordinatorRepoFailures(coordinatorConfig, launchRecord);
1013
+ launchRecord.repo_failures = repoFailures;
1014
+
1015
+ if (repoFailures.length === 0) {
1016
+ return { ok: false, error: `Workstream ${workstreamId} has no retryable repo failures.` };
1017
+ }
1018
+
1019
+ const retryableFailures = [];
1020
+ const blockedFailures = [];
1021
+
1022
+ for (const failure of repoFailures) {
1023
+ if (!RETRYABLE_COORDINATOR_FAILURE_STATUSES.has(failure.failure_status)) {
1024
+ blockedFailures.push(`${failure.repo_id} (${failure.failure_status})`);
1025
+ continue;
1026
+ }
1027
+ retryableFailures.push(failure);
1028
+ }
1029
+
1030
+ if (retryableFailures.length === 0) {
1031
+ return {
1032
+ ok: false,
1033
+ error: `No retryable repo failures in workstream ${workstreamId}. Manual intervention required: ${blockedFailures.join(', ')}`,
1034
+ };
1035
+ }
1036
+
1037
+ if (retryableFailures.length > 1) {
1038
+ return {
1039
+ ok: false,
1040
+ error: `Workstream ${workstreamId} has multiple retryable repo failures. Recover them repo-locally one at a time before relaunching the coordinator workstream.`,
1041
+ };
1042
+ }
1043
+
1044
+ const failure = retryableFailures[0];
1045
+ const failedDispatch = latestDispatchesByRepo.get(failure.repo_id);
1046
+ if (!failedDispatch) {
1047
+ return { ok: false, error: `Missing launch metadata for failed repo ${failure.repo_id}.` };
1048
+ }
1049
+
1050
+ const downstreamBlocker = findDependentDispatchAfter(workingPlan, workstreamId, failedDispatch.dispatched_at);
1051
+ if (downstreamBlocker) {
1052
+ return {
1053
+ ok: false,
1054
+ error: `Cannot retry workstream ${workstreamId}: dependent workstream "${downstreamBlocker.workstream_id}" has already dispatched since the failed repo turn.`,
1055
+ };
1056
+ }
1057
+
1058
+ const repoPath = coordinatorConfig?.repos?.[failure.repo_id]?.resolved_path;
1059
+ if (!repoPath) {
1060
+ return { ok: false, error: `Coordinator config has no resolved_path for repo "${failure.repo_id}".` };
1061
+ }
1062
+
1063
+ const repoTurn = loadRepoTurnForRetry(repoPath, failure.repo_turn_id);
1064
+ if (!repoTurn.ok) {
1065
+ return repoTurn;
1066
+ }
1067
+
1068
+ const reason = options.reason || `coordinator retry: ${workstreamId}/${failure.repo_id}`;
1069
+ const reissued = reissueTurn(repoTurn.root, repoTurn.config, {
1070
+ turnId: failure.repo_turn_id,
1071
+ reason,
1072
+ });
1073
+ if (!reissued.ok) {
1074
+ return { ok: false, error: reissued.error };
1075
+ }
1076
+
1077
+ const bundleResult = writeDispatchBundle(repoTurn.root, reissued.state, repoTurn.config, {
1078
+ turnId: reissued.newTurn.turn_id,
1079
+ });
1080
+ if (!bundleResult.ok) {
1081
+ return { ok: false, error: `Turn reissued but dispatch bundle failed: ${bundleResult.error}` };
1082
+ }
1083
+
1084
+ const now = new Date().toISOString();
1085
+ failedDispatch.retried_at = now;
1086
+ failedDispatch.retry_reason = failure.failure_status;
1087
+ launchRecord.repo_dispatches.push({
1088
+ repo_id: failure.repo_id,
1089
+ repo_turn_id: reissued.newTurn.turn_id,
1090
+ role: reissued.newTurn.assigned_role,
1091
+ dispatched_at: now,
1092
+ bundle_path: bundleResult.bundlePath,
1093
+ context_ref: failedDispatch.context_ref || null,
1094
+ is_retry: true,
1095
+ retry_of: failure.repo_turn_id,
1096
+ });
1097
+ launchRecord.status = 'launched';
1098
+ launchRecord.repo_failures = [];
1099
+ ws.launch_status = 'launched';
1100
+
1101
+ const otherNeedsAttention = (workingPlan.workstreams || []).some(
1102
+ (candidate) => candidate.workstream_id !== workstreamId && candidate.launch_status === 'needs_attention',
1103
+ );
1104
+ workingPlan.status = otherNeedsAttention ? 'needs_attention' : 'approved';
1105
+ workingPlan.updated_at = now;
1106
+ writePlanArtifact(root, mission.mission_id, workingPlan);
1107
+
1108
+ appendCoordinatorHistoryEntry(mission.coordinator.workspace_path, {
1109
+ type: 'coordinator_retry',
1110
+ timestamp: now,
1111
+ super_run_id: mission.coordinator.super_run_id,
1112
+ workstream_id: workstreamId,
1113
+ repo_id: failure.repo_id,
1114
+ failed_turn_id: failure.repo_turn_id,
1115
+ reissued_turn_id: reissued.newTurn.turn_id,
1116
+ retry_reason: failure.failure_status,
1117
+ });
1118
+ recordCoordinatorDecision(mission.coordinator.workspace_path, {
1119
+ super_run_id: mission.coordinator.super_run_id,
1120
+ phase: coordinatorConfig?.workstreams?.[workstreamId]?.phase || null,
1121
+ }, {
1122
+ category: 'retry',
1123
+ statement: `Retried ${failure.repo_id} for workstream ${workstreamId}`,
1124
+ repo_id: failure.repo_id,
1125
+ repo_turn_id: reissued.newTurn.turn_id,
1126
+ workstream_id: workstreamId,
1127
+ reason: failure.failure_status,
1128
+ context_ref: failedDispatch.context_ref || null,
1129
+ });
1130
+ emitRunEvent(mission.coordinator.workspace_path, 'coordinator_retry', {
1131
+ run_id: mission.coordinator.super_run_id,
1132
+ phase: coordinatorConfig?.workstreams?.[workstreamId]?.phase || null,
1133
+ status: 'active',
1134
+ turn: { turn_id: reissued.newTurn.turn_id, role_id: reissued.newTurn.assigned_role },
1135
+ payload: {
1136
+ workstream_id: workstreamId,
1137
+ repo_id: failure.repo_id,
1138
+ failed_turn_id: failure.repo_turn_id,
1139
+ reissued_turn_id: reissued.newTurn.turn_id,
1140
+ retry_reason: failure.failure_status,
1141
+ retry_count: launchRecord.repo_dispatches.filter((dispatch) => dispatch.repo_id === failure.repo_id && dispatch.is_retry).length,
1142
+ },
1143
+ });
1144
+
1145
+ const afterRetry = synchronizeCoordinatorPlanState(root, mission, workingPlan);
1146
+ return {
1147
+ ok: true,
1148
+ plan: afterRetry.ok ? afterRetry.plan : workingPlan,
1149
+ workstream: ws,
1150
+ launchRecord,
1151
+ retryResult: {
1152
+ repo_id: failure.repo_id,
1153
+ failed_turn_id: failure.repo_turn_id,
1154
+ reissued_turn_id: reissued.newTurn.turn_id,
1155
+ role: reissued.newTurn.assigned_role,
1156
+ repo_path: repoTurn.root,
1157
+ bundle_path: bundleResult.bundlePath,
1158
+ context_ref: failedDispatch.context_ref || null,
1159
+ retry_reason: failure.failure_status,
1160
+ },
1161
+ };
1162
+ }
1163
+
907
1164
  /**
908
1165
  * Record the outcome of a launched workstream after its chain completes.
909
1166
  *
@@ -311,9 +311,23 @@ export function validateReleaseAlignment(repoRoot, { targetVersion, scope = RELE
311
311
  const context = getReleaseAlignmentContext(repoRoot, { targetVersion });
312
312
  const surfaces = RELEASE_ALIGNMENT_SURFACES.filter((surface) => surface.scopes.includes(scope));
313
313
  const errors = [];
314
+ const surfaceResults = [];
314
315
 
315
316
  for (const surface of surfaces) {
316
- const surfaceErrors = surface.check(context, repoRoot) || [];
317
+ let surfaceErrors = [];
318
+ try {
319
+ surfaceErrors = surface.check(context, repoRoot) || [];
320
+ } catch (error) {
321
+ surfaceErrors = [
322
+ error instanceof Error ? error.message : String(error),
323
+ ];
324
+ }
325
+ surfaceResults.push({
326
+ surface_id: surface.id,
327
+ label: surface.label,
328
+ ok: surfaceErrors.length === 0,
329
+ errors: surfaceErrors,
330
+ });
317
331
  for (const error of surfaceErrors) {
318
332
  errors.push({
319
333
  surface_id: surface.id,
@@ -331,6 +345,12 @@ export function validateReleaseAlignment(repoRoot, { targetVersion, scope = RELE
331
345
  aggregateEvidenceLine: context.aggregateEvidenceLine,
332
346
  checkedSurfaceCount: surfaces.length,
333
347
  checkedSurfaceIds: surfaces.map((surface) => surface.id),
348
+ checkedSurfaces: surfaces.map((surface) => ({
349
+ id: surface.id,
350
+ label: surface.label,
351
+ scopes: [...surface.scopes],
352
+ })),
353
+ surfaceResults,
334
354
  errors,
335
355
  };
336
356
  }
@@ -21,6 +21,7 @@ export const VALID_RUN_EVENTS = [
21
21
  'acceptance_failed',
22
22
  'turn_reissued',
23
23
  'turn_checkpointed',
24
+ 'coordinator_retry',
24
25
  'run_blocked',
25
26
  'run_completed',
26
27
  'escalation_raised',
@@ -0,0 +1,163 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { basename, join } from 'node:path';
4
+
5
+ const DEFAULT_PROBE_TIMEOUT_MS = 500;
6
+ const PROMPT_PLACEHOLDER = 'AgentXchain spawn-context probe';
7
+
8
+ function resolveLocalCliPromptTransport(runtime) {
9
+ const valid = new Set(['argv', 'stdin', 'dispatch_bundle_only']);
10
+ if (valid.has(runtime?.prompt_transport)) {
11
+ return runtime.prompt_transport;
12
+ }
13
+
14
+ const parts = Array.isArray(runtime?.command)
15
+ ? runtime.command
16
+ : [runtime?.command, ...(Array.isArray(runtime?.args) ? runtime.args : [])];
17
+ const hasPrompt = parts.some((part) => typeof part === 'string' && part.includes('{prompt}'));
18
+ return hasPrompt ? 'argv' : 'dispatch_bundle_only';
19
+ }
20
+
21
+ function resolveLocalCliInvocation(runtime) {
22
+ if (!runtime?.command) {
23
+ return { command: null, args: [] };
24
+ }
25
+
26
+ const transport = resolveLocalCliPromptTransport(runtime);
27
+
28
+ if (Array.isArray(runtime.command)) {
29
+ const first = runtime.command[0] || '';
30
+ const headParts = typeof first === 'string' && first.includes(' ') ? first.split(/\s+/) : [first];
31
+ const [command, ...headArgs] = headParts;
32
+ const rest = [...headArgs, ...runtime.command.slice(1)];
33
+ const args = transport === 'argv'
34
+ ? rest.map((arg) => arg === '{prompt}' ? PROMPT_PLACEHOLDER : arg)
35
+ : rest.filter((arg) => arg !== '{prompt}');
36
+ return { command, args };
37
+ }
38
+
39
+ const args = transport === 'argv'
40
+ ? (runtime.args || []).map((arg) => arg === '{prompt}' ? PROMPT_PLACEHOLDER : arg)
41
+ : (runtime.args || []).filter((arg) => arg !== '{prompt}');
42
+ return { command: runtime.command, args };
43
+ }
44
+
45
+ function resolveMcpInvocation(runtime) {
46
+ if (!runtime?.command) {
47
+ return { command: null, args: [] };
48
+ }
49
+
50
+ if (Array.isArray(runtime.command)) {
51
+ const [command, ...args] = runtime.command;
52
+ return { command, args };
53
+ }
54
+
55
+ return {
56
+ command: runtime.command,
57
+ args: Array.isArray(runtime.args) ? runtime.args : [],
58
+ };
59
+ }
60
+
61
+ function resolveInvocation(runtime) {
62
+ if (runtime?.type === 'local_cli') {
63
+ return resolveLocalCliInvocation(runtime);
64
+ }
65
+ return resolveMcpInvocation(runtime);
66
+ }
67
+
68
+ function buildResolutionFix(command) {
69
+ const commandValue = String(command || '');
70
+ const commandBase = basename(commandValue);
71
+
72
+ if (commandBase === 'codex' || commandBase === 'codex.exe') {
73
+ return 'Set "command" to the absolute path, e.g. "/Applications/Codex.app/Contents/Resources/codex", or add Codex to PATH in the dispatch spawn context.';
74
+ }
75
+ if (commandValue.includes('~')) {
76
+ return 'Expand "~" to an absolute path in "command". Shell expansion does not apply to governed dispatch.';
77
+ }
78
+ return 'Set "command" to an absolute path or add it to PATH in the dispatch spawn context.';
79
+ }
80
+
81
+ export function probeRuntimeSpawnContext(root, runtime, options = {}) {
82
+ const runtimeId = options.runtimeId || null;
83
+ const cwd = runtime?.cwd ? join(root, runtime.cwd) : root;
84
+ const { command, args } = resolveInvocation(runtime);
85
+
86
+ if (!command) {
87
+ return {
88
+ ok: false,
89
+ runtime_id: runtimeId,
90
+ command: null,
91
+ cwd,
92
+ detail: 'No command configured for the dispatch spawn context.',
93
+ };
94
+ }
95
+
96
+ if (!existsSync(cwd)) {
97
+ return {
98
+ ok: false,
99
+ runtime_id: runtimeId,
100
+ command,
101
+ cwd,
102
+ detail: `Runtime cwd "${runtime.cwd}" does not exist in the dispatch spawn context.`,
103
+ };
104
+ }
105
+
106
+ const probe = spawnSync(command, args, {
107
+ cwd,
108
+ env: { ...process.env, AGENTXCHAIN_SPAWN_PROBE: '1' },
109
+ stdio: 'ignore',
110
+ timeout: options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS,
111
+ windowsHide: true,
112
+ });
113
+
114
+ if (probe.error) {
115
+ const errorCode = probe.error.code || 'spawn_error';
116
+ if (errorCode === 'ETIMEDOUT') {
117
+ return {
118
+ ok: true,
119
+ runtime_id: runtimeId,
120
+ command,
121
+ cwd,
122
+ timed_out: true,
123
+ detail: `"${command}" launched in the dispatch spawn context but exceeded the short probe timeout. Treating this as resolvable.`,
124
+ };
125
+ }
126
+ if (errorCode === 'ENOENT') {
127
+ return {
128
+ ok: false,
129
+ runtime_id: runtimeId,
130
+ command,
131
+ cwd,
132
+ error_code: errorCode,
133
+ detail: `"${command}" is not resolvable in the dispatch spawn context. ${buildResolutionFix(command)}`,
134
+ };
135
+ }
136
+ if (errorCode === 'EACCES') {
137
+ return {
138
+ ok: false,
139
+ runtime_id: runtimeId,
140
+ command,
141
+ cwd,
142
+ error_code: errorCode,
143
+ detail: `"${command}" exists but is not executable in the dispatch spawn context. Mark it executable or point "command" at the real executable path.`,
144
+ };
145
+ }
146
+ return {
147
+ ok: false,
148
+ runtime_id: runtimeId,
149
+ command,
150
+ cwd,
151
+ error_code: errorCode,
152
+ detail: `Dispatch spawn probe failed for "${command}": ${probe.error.message}`,
153
+ };
154
+ }
155
+
156
+ return {
157
+ ok: true,
158
+ runtime_id: runtimeId,
159
+ command,
160
+ cwd,
161
+ detail: `"${command}" is resolvable in the dispatch spawn context.`,
162
+ };
163
+ }