agentxchain 2.155.66 → 2.155.68

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.155.66",
3
+ "version": "2.155.68",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
 
20
20
  import { spawn } from 'child_process';
21
21
  import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
22
- import { join } from 'path';
22
+ import { delimiter, join } from 'path';
23
23
  import {
24
24
  getDispatchContextPath,
25
25
  getDispatchLogPath,
@@ -33,7 +33,9 @@ import { hasMeaningfulStagedResult } from '../staged-result-proof.js';
33
33
  import {
34
34
  getClaudeSubprocessAuthIssue,
35
35
  hasClaudeAuthenticationFailureText,
36
+ hasClaudeNodeIncompatibilityText,
36
37
  isClaudeLocalCliRuntime,
38
+ resolveClaudeCompatibleNodeBinary,
37
39
  } from '../claude-local-auth.js';
38
40
 
39
41
  const DIAGNOSTIC_ENV_KEYS = [
@@ -135,6 +137,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
135
137
  const spawnEnv = { ...process.env, AGENTXCHAIN_TURN_ID: turn.turn_id };
136
138
  const stdinBytes = transport === 'stdin' ? Buffer.byteLength(fullPrompt, 'utf8') : 0;
137
139
  const diagnosticArgs = redactPromptArgs(args, fullPrompt, transport);
140
+ const spawnSpec = resolveClaudeSpawnSpec(runtime, command, args, spawnEnv);
138
141
  const claudeAuthIssue = await getClaudeSubprocessAuthIssue(runtime, spawnEnv);
139
142
 
140
143
  if (claudeAuthIssue) {
@@ -163,14 +166,17 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
163
166
  appendDiagnostic(logs, 'spawn_prepare', {
164
167
  runtime_id: runtimeId,
165
168
  turn_id: turn.turn_id,
166
- command,
167
- args: diagnosticArgs,
169
+ command: spawnSpec.command,
170
+ args: spawnSpec.args === args ? diagnosticArgs : redactPromptArgs(spawnSpec.args, fullPrompt, transport),
171
+ configured_command: spawnSpec.command === command ? undefined : command,
172
+ configured_args: spawnSpec.command === command ? undefined : diagnosticArgs,
173
+ spawn_wrapper: spawnSpec.wrapper,
168
174
  cwd: runtimeCwd,
169
175
  prompt_transport: transport,
170
176
  stdin_bytes: stdinBytes,
171
177
  env: pickDiagnosticEnv(spawnEnv),
172
178
  });
173
- child = spawn(command, args, {
179
+ child = spawn(spawnSpec.command, spawnSpec.args, {
174
180
  cwd: runtimeCwd,
175
181
  stdio: ['pipe', 'pipe', 'pipe'],
176
182
  env: spawnEnv,
@@ -447,6 +453,22 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
447
453
  error: `Claude local_cli authentication failed. ${recovery}`,
448
454
  logs,
449
455
  });
456
+ } else if (isClaudeLocalCliRuntime(runtime) && hasClaudeNodeRuntimeIncompatibilityOutput(logs)) {
457
+ const recovery = 'Run AgentXchain with Node.js 20.5+ available to the Claude local_cli runtime, then resume continuous mode.';
458
+ settle({
459
+ ok: false,
460
+ blocked: true,
461
+ exitCode,
462
+ timedOut: false,
463
+ aborted: false,
464
+ firstOutputAt,
465
+ classified: {
466
+ error_class: 'claude_node_incompatible',
467
+ recovery,
468
+ },
469
+ error: `Claude local_cli runtime is using an incompatible Node.js version. ${recovery}`,
470
+ logs,
471
+ });
450
472
  } else if (startupTimedOut) {
451
473
  settle({
452
474
  ok: false,
@@ -665,6 +687,55 @@ function hasClaudeAuthFailureOutput(logs) {
665
687
  return logs.some((line) => hasClaudeAuthenticationFailureText(line));
666
688
  }
667
689
 
690
+ function hasClaudeNodeRuntimeIncompatibilityOutput(logs) {
691
+ if (!Array.isArray(logs)) return false;
692
+ return hasClaudeNodeIncompatibilityText(logs.join('\n'));
693
+ }
694
+
695
+ function resolveCommandPath(command, pathValue) {
696
+ if (!command || command.includes('/')) {
697
+ return existsSync(command) ? command : null;
698
+ }
699
+ const parts = String(pathValue || '').split(delimiter).filter(Boolean);
700
+ for (const dir of parts) {
701
+ const candidate = join(dir, command);
702
+ if (existsSync(candidate)) return candidate;
703
+ }
704
+ return null;
705
+ }
706
+
707
+ function resolveClaudeSpawnSpec(runtime, command, args, env) {
708
+ if (command !== 'claude' || !isClaudeLocalCliRuntime(runtime)) {
709
+ return { command, args, wrapper: null };
710
+ }
711
+ const nodeBinary = resolveClaudeCompatibleNodeBinary(env);
712
+ if (!nodeBinary) {
713
+ return { command, args, wrapper: null };
714
+ }
715
+ const claudeEntry = resolveCommandPath(command, env?.PATH);
716
+ if (!claudeEntry) {
717
+ return { command, args, wrapper: null };
718
+ }
719
+ if (!isNodeEntrypoint(claudeEntry)) {
720
+ return { command, args, wrapper: null };
721
+ }
722
+ return {
723
+ command: nodeBinary,
724
+ args: [claudeEntry, ...args],
725
+ wrapper: 'claude_compatible_node',
726
+ };
727
+ }
728
+
729
+ function isNodeEntrypoint(filePath) {
730
+ try {
731
+ const head = readFileSync(filePath, 'utf8').slice(0, 256);
732
+ const firstLine = head.split(/\r?\n/, 1)[0] || '';
733
+ return /^#!.*(?:^|[\/\s])(?:env\s+)?node(?:\s|$)/.test(firstLine);
734
+ } catch {
735
+ return false;
736
+ }
737
+ }
738
+
668
739
  function pickDiagnosticEnv(env) {
669
740
  return Object.fromEntries(
670
741
  DIAGNOSTIC_ENV_KEYS
@@ -1,4 +1,5 @@
1
- import { spawn } from 'node:child_process';
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { accessSync, constants } from 'node:fs';
2
3
 
3
4
  const CLAUDE_ENV_AUTH_KEYS = [
4
5
  'ANTHROPIC_API_KEY',
@@ -11,6 +12,8 @@ const CLAUDE_ENV_AUTH_KEYS = [
11
12
  const DEFAULT_SMOKE_PROBE_TIMEOUT_MS = 10_000;
12
13
  const DEFAULT_SMOKE_PROBE_STDIN = 'ok';
13
14
  const CLAUDE_AUTH_FAILURE_RE = /authentication_failed|authentication_error|invalid authentication credentials|unauthorized|API Error:\s*401/i;
15
+ const CLAUDE_NODE_INCOMPATIBILITY_RE = /TypeError:\s*Object not disposable|Object not disposable[\s\S]{0,2000}Node\.js v(?:1[0-9]|20\.[0-4]\.)/i;
16
+ const CLAUDE_COMPATIBLE_NODE_MIN = { major: 20, minor: 5, patch: 0 };
14
17
 
15
18
  function normalizeCommandTokens(runtime) {
16
19
  if (Array.isArray(runtime?.command)) {
@@ -37,10 +40,77 @@ export function hasClaudeAuthenticationFailureText(text) {
37
40
  return typeof text === 'string' && CLAUDE_AUTH_FAILURE_RE.test(text);
38
41
  }
39
42
 
43
+ export function hasClaudeNodeIncompatibilityText(text) {
44
+ return typeof text === 'string' && CLAUDE_NODE_INCOMPATIBILITY_RE.test(text);
45
+ }
46
+
40
47
  export function hasClaudeBareFlag(runtime) {
41
48
  return normalizeCommandTokens(runtime).includes('--bare');
42
49
  }
43
50
 
51
+ function parseNodeVersion(raw) {
52
+ const match = String(raw || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)/);
53
+ if (!match) return null;
54
+ return {
55
+ major: Number.parseInt(match[1], 10),
56
+ minor: Number.parseInt(match[2], 10),
57
+ patch: Number.parseInt(match[3], 10),
58
+ };
59
+ }
60
+
61
+ export function isClaudeCompatibleNodeVersion(raw) {
62
+ const version = parseNodeVersion(raw);
63
+ if (!version) return false;
64
+ if (version.major !== CLAUDE_COMPATIBLE_NODE_MIN.major) {
65
+ return version.major > CLAUDE_COMPATIBLE_NODE_MIN.major;
66
+ }
67
+ if (version.minor !== CLAUDE_COMPATIBLE_NODE_MIN.minor) {
68
+ return version.minor > CLAUDE_COMPATIBLE_NODE_MIN.minor;
69
+ }
70
+ return version.patch >= CLAUDE_COMPATIBLE_NODE_MIN.patch;
71
+ }
72
+
73
+ function isExecutable(filePath) {
74
+ if (!filePath) return false;
75
+ try {
76
+ accessSync(filePath, constants.X_OK);
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ function candidateNodeVersion(filePath) {
84
+ if (!isExecutable(filePath)) return null;
85
+ const result = spawnSync(filePath, ['--version'], {
86
+ encoding: 'utf8',
87
+ stdio: ['ignore', 'pipe', 'ignore'],
88
+ timeout: 2000,
89
+ });
90
+ if (result.status !== 0) return null;
91
+ return String(result.stdout || '').trim();
92
+ }
93
+
94
+ export function resolveClaudeCompatibleNodeBinary(env = process.env) {
95
+ if (env?.AGENTXCHAIN_CLAUDE_NODE && isExecutable(env.AGENTXCHAIN_CLAUDE_NODE)) {
96
+ return env.AGENTXCHAIN_CLAUDE_NODE;
97
+ }
98
+
99
+ const candidates = [
100
+ process.execPath,
101
+ '/opt/homebrew/opt/node@20/bin/node',
102
+ '/opt/homebrew/bin/node',
103
+ '/usr/local/bin/node',
104
+ ];
105
+ for (const candidate of candidates) {
106
+ const version = candidateNodeVersion(candidate);
107
+ if (isClaudeCompatibleNodeVersion(version)) {
108
+ return candidate;
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+
44
114
  export function getClaudeEnvAuthPresence(env = process.env) {
45
115
  return Object.fromEntries(
46
116
  CLAUDE_ENV_AUTH_KEYS.map((key) => [key, Boolean(env?.[key])]),
@@ -55,12 +55,15 @@ import {
55
55
  import { checkpointAcceptedTurn } from './turn-checkpoint.js';
56
56
  import {
57
57
  hasClaudeAuthenticationFailureText,
58
+ hasClaudeNodeIncompatibilityText,
58
59
  isClaudeLocalCliRuntime,
60
+ resolveClaudeCompatibleNodeBinary,
59
61
  } from './claude-local-auth.js';
60
62
 
61
63
  const CONTINUOUS_SESSION_PATH = '.agentxchain/continuous-session.json';
62
64
  const PRODUCTIVE_TIMEOUT_RETRY_MAX_PER_RUN = 1;
63
65
  const PRODUCTIVE_TIMEOUT_RETRY_DEADLINE_MINUTES = 60;
66
+ const PROVIDER_REQUEST_TIMEOUT_RE = /request timed out|timed out waiting for (?:provider|api)|provider request timed out/i;
64
67
 
65
68
  function getRoadmapReplenishmentTriageHints(root) {
66
69
  const context = loadProjectContext(root);
@@ -315,6 +318,8 @@ function getBlockedCategory(state) {
315
318
 
316
319
  const CLAUDE_AUTH_RECOVERY_ACTION =
317
320
  'Refresh Claude credentials before resuming: export a valid ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN, then run agentxchain step --resume.';
321
+ const CLAUDE_NODE_RECOVERY_ACTION =
322
+ 'Run AgentXchain with Node.js 20.5+ available to the Claude local_cli runtime, then resume continuous mode.';
318
323
 
319
324
  function findRetainedClaudeAuthEscalation(root, state, config) {
320
325
  if (!state || state.status !== 'blocked') return null;
@@ -394,6 +399,104 @@ function maybeReclassifyRetainedClaudeAuthEscalation(context, session, state, lo
394
399
  return nextState;
395
400
  }
396
401
 
402
+ function findRetainedClaudeNodeIncompatGhost(root, state, config) {
403
+ if (!state || state.status !== 'blocked') return null;
404
+ if (state.blocked_reason?.category !== 'ghost_turn') return null;
405
+ const activeTurns = state.active_turns || {};
406
+ const turnId = state.blocked_reason?.turn_id || null;
407
+ const candidateIds = turnId && activeTurns[turnId] ? [turnId] : Object.keys(activeTurns);
408
+ for (const candidateId of candidateIds) {
409
+ const turn = activeTurns[candidateId];
410
+ if (!turn || turn.status !== 'failed_start') continue;
411
+ if (turn.failed_start_reason !== 'stdout_attach_failed') continue;
412
+ const runtime = config?.runtimes?.[turn.runtime_id];
413
+ if (!isClaudeLocalCliRuntime(runtime)) continue;
414
+ const logPath = join(root, getDispatchLogPath(candidateId));
415
+ if (!existsSync(logPath)) continue;
416
+ let logText = '';
417
+ try {
418
+ logText = readFileSync(logPath, 'utf8');
419
+ } catch {
420
+ continue;
421
+ }
422
+ if (!hasClaudeNodeIncompatibilityText(logText)) continue;
423
+ return { turn_id: candidateId, turn, previous_blocked_on: state.blocked_on || null };
424
+ }
425
+ return null;
426
+ }
427
+
428
+ function reclassifyRetainedClaudeNodeIncompatGhost(context, session, state, candidate, nodeBinary, log = console.log) {
429
+ const { root } = context;
430
+ if (nodeBinary) {
431
+ const reissued = reissueTurn(root, context.config, {
432
+ turnId: candidate.turn_id,
433
+ reason: 'auto_retry_claude_node_runtime',
434
+ });
435
+ if (!reissued.ok) {
436
+ log(`Claude Node runtime auto-retry skipped: ${reissued.error}`);
437
+ return null;
438
+ }
439
+ const runId = session.current_run_id || state.run_id || reissued.state?.run_id || null;
440
+ const nextState = clearGhostBlockerAfterReissue(root, reissued.state);
441
+ delete session.ghost_retry;
442
+ Object.assign(session, {
443
+ status: 'running',
444
+ current_run_id: runId,
445
+ });
446
+ writeContinuousSession(root, session);
447
+ emitRunEvent(root, 'auto_retried_ghost', {
448
+ run_id: runId,
449
+ phase: nextState.phase || state.phase || null,
450
+ status: 'active',
451
+ turn: { turn_id: reissued.newTurn.turn_id, role_id: reissued.newTurn.assigned_role || null },
452
+ payload: {
453
+ old_turn_id: candidate.turn_id,
454
+ new_turn_id: reissued.newTurn.turn_id,
455
+ failure_type: 'stdout_attach_failed',
456
+ recovery_class: 'claude_node_runtime_recovered',
457
+ runtime_id: candidate.turn.runtime_id || null,
458
+ node_binary: nodeBinary,
459
+ },
460
+ });
461
+ log(`Claude Node runtime recovered; auto-retried ${candidate.turn_id} -> ${reissued.newTurn.turn_id}.`);
462
+ return {
463
+ ok: true,
464
+ status: 'running',
465
+ action: 'auto_retried_claude_node_runtime',
466
+ run_id: runId,
467
+ old_turn_id: candidate.turn_id,
468
+ new_turn_id: reissued.newTurn.turn_id,
469
+ };
470
+ }
471
+
472
+ const blockedAt = new Date().toISOString();
473
+ const nextState = {
474
+ ...state,
475
+ status: 'blocked',
476
+ blocked_on: 'dispatch:claude_node_incompatible',
477
+ blocked_reason: {
478
+ category: 'dispatch_error',
479
+ blocked_at: blockedAt,
480
+ turn_id: candidate.turn_id,
481
+ reclassified_from: {
482
+ blocked_on: state.blocked_on || null,
483
+ category: state.blocked_reason?.category || null,
484
+ },
485
+ recovery: {
486
+ typed_reason: 'dispatch_error',
487
+ owner: 'human',
488
+ recovery_action: CLAUDE_NODE_RECOVERY_ACTION,
489
+ turn_retained: true,
490
+ detail: `claude_node_incompatible: ${CLAUDE_NODE_RECOVERY_ACTION}`,
491
+ },
492
+ },
493
+ escalation: null,
494
+ };
495
+ writeGovernedState(root, nextState);
496
+ log(`Reclassified retained Claude Node runtime blocker for ${candidate.turn_id} as dispatch:claude_node_incompatible.`);
497
+ return nextState;
498
+ }
499
+
397
500
  const RECOVERABLE_ACTIVE_TURN_STATUSES = new Set(['assigned', 'dispatched', 'starting', 'running']);
398
501
 
399
502
  function hasOnlyRecoverableActiveTurns(activeTurns = {}) {
@@ -537,7 +640,16 @@ function findPrimaryProductiveTimeoutTurn(root, state) {
537
640
  ...(Array.isArray(turn.last_rejection?.validation_errors) ? turn.last_rejection.validation_errors : []),
538
641
  ].join('\n');
539
642
  const looksDeadlineKilled = /code 143|dispatch timed out|timed out/i.test(reason);
540
- if (!looksDeadlineKilled) continue;
643
+ let looksProviderTimedOut = false;
644
+ const logPath = join(root, getDispatchLogPath(candidateId));
645
+ if (!looksDeadlineKilled && existsSync(logPath)) {
646
+ try {
647
+ looksProviderTimedOut = PROVIDER_REQUEST_TIMEOUT_RE.test(readFileSync(logPath, 'utf8'));
648
+ } catch {
649
+ looksProviderTimedOut = false;
650
+ }
651
+ }
652
+ if (!looksDeadlineKilled && !looksProviderTimedOut) continue;
541
653
  if (!turn.first_output_at) continue;
542
654
  const stagingPath = join(root, getTurnStagingResultPath(candidateId));
543
655
  if (existsSync(stagingPath)) continue;
@@ -1800,7 +1912,20 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
1800
1912
  const startupGovernedState = loadProjectState(root, context.config);
1801
1913
  if (startupGovernedState?.status === 'blocked') {
1802
1914
  const reclassifiedState = maybeReclassifyRetainedClaudeAuthEscalation(context, session, startupGovernedState, log);
1803
- const effectiveBlockedState = reclassifiedState || startupGovernedState;
1915
+ let effectiveBlockedState = reclassifiedState || startupGovernedState;
1916
+ const nodeCandidate = findRetainedClaudeNodeIncompatGhost(root, effectiveBlockedState, context.config);
1917
+ if (nodeCandidate) {
1918
+ const nodeRecovery = reclassifyRetainedClaudeNodeIncompatGhost(
1919
+ context,
1920
+ session,
1921
+ effectiveBlockedState,
1922
+ nodeCandidate,
1923
+ resolveClaudeCompatibleNodeBinary(process.env),
1924
+ log,
1925
+ );
1926
+ if (nodeRecovery?.status === 'running') return nodeRecovery;
1927
+ if (nodeRecovery) effectiveBlockedState = nodeRecovery;
1928
+ }
1804
1929
  const retried = await maybeAutoRetryContinuousBlocker(context, session, contOpts, effectiveBlockedState, log);
1805
1930
  if (retried) return retried;
1806
1931
  session.status = 'paused';