agentxchain 2.148.0 → 2.149.2
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 +1 -1
- package/dashboard/components/timeline.js +15 -2
- package/package.json +1 -1
- package/scripts/reproduce-bug-54.mjs +623 -0
- package/src/commands/connector.js +23 -4
- package/src/commands/doctor.js +14 -3
- package/src/commands/init.js +1 -1
- package/src/commands/run.js +18 -3
- package/src/commands/status.js +30 -3
- package/src/commands/step.js +8 -2
- package/src/lib/adapters/local-cli-adapter.js +50 -2
- package/src/lib/claude-local-auth.js +237 -0
- package/src/lib/connector-probe.js +38 -22
- package/src/lib/connector-validate.js +34 -0
- package/src/lib/dispatch-progress.js +32 -6
- package/src/lib/dispatch-streams.js +21 -0
- package/src/lib/governed-state.js +84 -6
- package/src/lib/normalized-config.js +12 -0
- package/src/lib/schemas/agentxchain-config.schema.json +5 -0
- package/src/lib/stale-turn-watchdog.js +31 -6
- package/src/lib/turn-checkpoint.js +58 -11
- package/src/templates/governed/enterprise-app.json +1 -1
- package/src/templates/governed/full-local-cli.json +4 -4
|
@@ -5,6 +5,17 @@ import { DEFAULT_VALIDATE_TIMEOUT_MS, validateConfiguredConnector } from '../lib
|
|
|
5
5
|
import { DEFAULT_TIMEOUT_MS, probeConfiguredConnectors } from '../lib/connector-probe.js';
|
|
6
6
|
import { buildRuntimeCapabilityReport } from '../lib/runtime-capabilities.js';
|
|
7
7
|
|
|
8
|
+
function warningDetail(warning) {
|
|
9
|
+
if (typeof warning === 'string') {
|
|
10
|
+
return warning;
|
|
11
|
+
}
|
|
12
|
+
return warning?.detail || JSON.stringify(warning);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function warningFix(warning) {
|
|
16
|
+
return typeof warning === 'object' && warning?.fix ? warning.fix : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
8
19
|
function printJson(result, exitCode) {
|
|
9
20
|
console.log(JSON.stringify(result, null, 2));
|
|
10
21
|
process.exit(exitCode);
|
|
@@ -49,11 +60,15 @@ function printText(result, exitCode) {
|
|
|
49
60
|
console.log(` ${chalk.dim('Time:')} ${connector.latency_ms}ms`);
|
|
50
61
|
}
|
|
51
62
|
console.log(` ${chalk.dim('Detail:')} ${connector.detail}`);
|
|
63
|
+
if (connector.fix) {
|
|
64
|
+
console.log(` ${chalk.dim('Fix:')} ${connector.fix}`);
|
|
65
|
+
}
|
|
52
66
|
if (Array.isArray(connector.authority_warnings) && connector.authority_warnings.length > 0) {
|
|
53
67
|
for (const warning of connector.authority_warnings) {
|
|
54
|
-
console.log(` ${chalk.yellow('⚠')} ${warning
|
|
55
|
-
|
|
56
|
-
|
|
68
|
+
console.log(` ${chalk.yellow('⚠')} ${warningDetail(warning)}`);
|
|
69
|
+
const fix = warningFix(warning);
|
|
70
|
+
if (fix) {
|
|
71
|
+
console.log(` ${chalk.dim('Fix:')} ${fix}`);
|
|
57
72
|
}
|
|
58
73
|
}
|
|
59
74
|
}
|
|
@@ -161,7 +176,11 @@ function printValidateText(result, exitCode) {
|
|
|
161
176
|
if (Array.isArray(result.warnings) && result.warnings.length > 0) {
|
|
162
177
|
console.log('');
|
|
163
178
|
for (const warning of result.warnings) {
|
|
164
|
-
console.log(` ${chalk.yellow('!')} ${warning}`);
|
|
179
|
+
console.log(` ${chalk.yellow('!')} ${warningDetail(warning)}`);
|
|
180
|
+
const fix = warningFix(warning);
|
|
181
|
+
if (fix) {
|
|
182
|
+
console.log(` ${chalk.dim('Fix:')} ${fix}`);
|
|
183
|
+
}
|
|
165
184
|
}
|
|
166
185
|
}
|
|
167
186
|
|
package/src/commands/doctor.js
CHANGED
|
@@ -21,6 +21,7 @@ import { detectActiveTurnBindingDrift, detectStateBundleDesync } from '../lib/go
|
|
|
21
21
|
import { findPendingApprovedIntents } from '../lib/intake.js';
|
|
22
22
|
import { checkCleanBaseline } from '../lib/repo-observer.js';
|
|
23
23
|
import { probeRuntimeSpawnContext } from '../lib/runtime-spawn-context.js';
|
|
24
|
+
import { getClaudeSubprocessAuthIssue } from '../lib/claude-local-auth.js';
|
|
24
25
|
|
|
25
26
|
export async function doctorCommand(opts = {}) {
|
|
26
27
|
const root = findProjectRoot(process.cwd());
|
|
@@ -58,7 +59,7 @@ export async function doctorCommand(opts = {}) {
|
|
|
58
59
|
|
|
59
60
|
// ── Governed (v4) Doctor ────────────────────────────────────────────────────
|
|
60
61
|
|
|
61
|
-
function governedDoctor(root, rawConfig, opts) {
|
|
62
|
+
async function governedDoctor(root, rawConfig, opts) {
|
|
62
63
|
const checks = [];
|
|
63
64
|
const cliVersionHealth = getCliVersionHealth();
|
|
64
65
|
let stateRunId = null;
|
|
@@ -92,7 +93,7 @@ function governedDoctor(root, rawConfig, opts) {
|
|
|
92
93
|
const runtimes = (normalized && normalized.runtimes) || rawConfig.runtimes || {};
|
|
93
94
|
const rolesByRuntime = buildRolesByRuntime(normalized?.roles || {});
|
|
94
95
|
for (const [rtId, rt] of Object.entries(runtimes)) {
|
|
95
|
-
const check = checkRuntimeReachable(root, rtId, rt, rolesByRuntime[rtId] || []);
|
|
96
|
+
const check = await checkRuntimeReachable(root, rtId, rt, rolesByRuntime[rtId] || []);
|
|
96
97
|
checks.push(check);
|
|
97
98
|
}
|
|
98
99
|
const connectorProbe = getConnectorProbeRecommendation(runtimes);
|
|
@@ -487,7 +488,7 @@ function buildCliVersionCheck(cliVersionHealth) {
|
|
|
487
488
|
};
|
|
488
489
|
}
|
|
489
490
|
|
|
490
|
-
function checkRuntimeReachable(root, rtId, rt, boundRoleEntries = []) {
|
|
491
|
+
async function checkRuntimeReachable(root, rtId, rt, boundRoleEntries = []) {
|
|
491
492
|
const base = { id: `runtime_${rtId}`, name: `Runtime: ${rtId}` };
|
|
492
493
|
|
|
493
494
|
if (!rt || !rt.type) {
|
|
@@ -500,6 +501,16 @@ function checkRuntimeReachable(root, rtId, rt, boundRoleEntries = []) {
|
|
|
500
501
|
|
|
501
502
|
case 'local_cli': {
|
|
502
503
|
const probe = probeRuntimeSpawnContext(root, rt, { runtimeId: rtId });
|
|
504
|
+
if (probe.ok) {
|
|
505
|
+
const claudeAuthIssue = await getClaudeSubprocessAuthIssue(rt);
|
|
506
|
+
if (claudeAuthIssue) {
|
|
507
|
+
return attachRuntimeContract({
|
|
508
|
+
...base,
|
|
509
|
+
level: 'warn',
|
|
510
|
+
detail: `${probe.detail} ${claudeAuthIssue.detail} ${claudeAuthIssue.fix}`,
|
|
511
|
+
}, rtId, rt, boundRoleEntries);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
503
514
|
return attachRuntimeContract({ ...base, level: probe.ok ? 'pass' : 'fail', detail: probe.detail }, rtId, rt, boundRoleEntries);
|
|
504
515
|
}
|
|
505
516
|
|
package/src/commands/init.js
CHANGED
|
@@ -98,7 +98,7 @@ const GOVERNED_ROLES = {
|
|
|
98
98
|
|
|
99
99
|
const DEFAULT_GOVERNED_LOCAL_DEV_RUNTIME = Object.freeze({
|
|
100
100
|
type: 'local_cli',
|
|
101
|
-
command: ['claude', '--print', '--dangerously-skip-permissions'],
|
|
101
|
+
command: ['claude', '--print', '--dangerously-skip-permissions', '--bare'],
|
|
102
102
|
cwd: '.',
|
|
103
103
|
prompt_transport: 'stdin',
|
|
104
104
|
});
|
package/src/commands/run.js
CHANGED
|
@@ -25,6 +25,7 @@ import { validateParentRun } from '../lib/run-history.js';
|
|
|
25
25
|
import { dispatchApiProxy } from '../lib/adapters/api-proxy-adapter.js';
|
|
26
26
|
import {
|
|
27
27
|
dispatchLocalCli,
|
|
28
|
+
resolveStartupWatchdogMs,
|
|
28
29
|
saveDispatchLogs,
|
|
29
30
|
resolvePromptTransport,
|
|
30
31
|
} from '../lib/adapters/local-cli-adapter.js';
|
|
@@ -52,6 +53,7 @@ import { emitRunEvent } from '../lib/run-events.js';
|
|
|
52
53
|
import { checkpointAcceptedTurn } from '../lib/turn-checkpoint.js';
|
|
53
54
|
import { failTurnStartup } from '../lib/stale-turn-watchdog.js';
|
|
54
55
|
import { hasMinimumTurnResultShape } from '../lib/turn-result-shape.js';
|
|
56
|
+
import { isKnownTurnRunningProofStream } from '../lib/dispatch-streams.js';
|
|
55
57
|
|
|
56
58
|
export async function runCommand(opts) {
|
|
57
59
|
const context = loadProjectContext();
|
|
@@ -343,7 +345,10 @@ export async function executeGovernedRun(context, opts = {}) {
|
|
|
343
345
|
});
|
|
344
346
|
};
|
|
345
347
|
|
|
346
|
-
const ensureRunningState = (stream =
|
|
348
|
+
const ensureRunningState = (stream = null, at = new Date().toISOString()) => {
|
|
349
|
+
if (stream != null && !isKnownTurnRunningProofStream(stream)) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
347
352
|
if (runningMarked) return;
|
|
348
353
|
runningMarked = true;
|
|
349
354
|
transitionActiveTurnLifecycle(projectRoot, turn.turn_id, 'running', { stream, at });
|
|
@@ -359,7 +364,16 @@ export async function executeGovernedRun(context, opts = {}) {
|
|
|
359
364
|
};
|
|
360
365
|
|
|
361
366
|
const recordOutputActivity = (stream, text) => {
|
|
362
|
-
|
|
367
|
+
// DEC-BUG54-STDERR-IS-NOT-STARTUP-PROOF-002 (Turn 88) extended to the
|
|
368
|
+
// run-command lifecycle in Turn 89: stderr activity must NOT promote a
|
|
369
|
+
// turn from `starting` to `running`. stdout (or the adapter's
|
|
370
|
+
// onFirstOutput callback, which is stdout/staged_result only post-Turn
|
|
371
|
+
// 88) is the only signal that satisfies the lifecycle transition.
|
|
372
|
+
// stderr is still tracked by the progress tracker for silence detection
|
|
373
|
+
// and operator diagnostics.
|
|
374
|
+
if (stream != null && isKnownTurnRunningProofStream(stream)) {
|
|
375
|
+
ensureRunningState(stream);
|
|
376
|
+
}
|
|
363
377
|
const lines = text.split('\n').length - 1 || 1;
|
|
364
378
|
const wasSilent = tracker.onOutput(stream, lines);
|
|
365
379
|
if (wasSilent) {
|
|
@@ -473,9 +487,10 @@ export async function executeGovernedRun(context, opts = {}) {
|
|
|
473
487
|
|
|
474
488
|
if (adapterResult.startupFailure) {
|
|
475
489
|
const freshState = loadProjectState(projectRoot, cfg) || state;
|
|
490
|
+
const startupThresholdMs = resolveStartupWatchdogMs(cfg, runtime);
|
|
476
491
|
failTurnStartup(projectRoot, freshState, cfg, turn.turn_id, {
|
|
477
492
|
failure_type: adapterResult.startupFailureType || 'no_subprocess_output',
|
|
478
|
-
threshold_ms:
|
|
493
|
+
threshold_ms: startupThresholdMs,
|
|
479
494
|
running_ms: freshState?.active_turns?.[turn.turn_id]?.started_at
|
|
480
495
|
? Math.max(0, Date.now() - new Date(freshState.active_turns[turn.turn_id].started_at).getTime())
|
|
481
496
|
: 0,
|
package/src/commands/status.js
CHANGED
|
@@ -383,7 +383,7 @@ function renderGovernedStatus(context, opts) {
|
|
|
383
383
|
console.log(` ${chalk.dim(' or:')} ${chalk.cyan(`agentxchain accept-turn --turn ${turn.turn_id}`)} — re-attempt acceptance`);
|
|
384
384
|
}
|
|
385
385
|
if (turn.status === 'failed_start') {
|
|
386
|
-
console.log(` ${chalk.dim('Reason:')} ${turn.failed_start_reason
|
|
386
|
+
console.log(` ${chalk.dim('Reason:')} ${normalizeStartupFailureReasonForDisplay(turn.failed_start_reason)}`);
|
|
387
387
|
const recover = turn.recovery_command || `agentxchain reissue-turn --turn ${turn.turn_id} --reason ghost`;
|
|
388
388
|
console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(recover)}`);
|
|
389
389
|
}
|
|
@@ -443,7 +443,7 @@ function renderGovernedStatus(context, opts) {
|
|
|
443
443
|
console.log(` ${chalk.dim(' or:')} ${chalk.cyan(mergeAction.command)}`);
|
|
444
444
|
}
|
|
445
445
|
if (singleActiveTurn.status === 'failed_start') {
|
|
446
|
-
console.log(` ${chalk.dim('Reason:')} ${singleActiveTurn.failed_start_reason
|
|
446
|
+
console.log(` ${chalk.dim('Reason:')} ${normalizeStartupFailureReasonForDisplay(singleActiveTurn.failed_start_reason)}`);
|
|
447
447
|
const recover = singleActiveTurn.recovery_command || `agentxchain reissue-turn --turn ${singleActiveTurn.turn_id} --reason ghost`;
|
|
448
448
|
console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(recover)}`);
|
|
449
449
|
}
|
|
@@ -883,6 +883,24 @@ function pluralizeRepoDecisionCount(count, singular, plural) {
|
|
|
883
883
|
return `${count} ${count === 1 ? singular : plural}`;
|
|
884
884
|
}
|
|
885
885
|
|
|
886
|
+
// BUG-54 vocabulary discipline (`DEC-BUG54-OPERATOR-SUBTYPE-DISPLAY-001`).
|
|
887
|
+
// Operator-facing status surfaces must render a typed startup-failure subtype,
|
|
888
|
+
// not the raw adapter signal `no_subprocess_output`. Public docs
|
|
889
|
+
// (website-v2/docs/cli.mdx) only document `runtime_spawn_failed` and
|
|
890
|
+
// `stdout_attach_failed` as the operator-visible subtypes; the `no_subprocess_output`
|
|
891
|
+
// label is an internal adapter/classification fallback and must not leak to the
|
|
892
|
+
// CLI status display. The adapter semantics for `no_subprocess_output` ("we
|
|
893
|
+
// watched for stdout and saw none inside the startup watchdog window") are
|
|
894
|
+
// identical to the operator subtype `stdout_attach_failed`, so that is the
|
|
895
|
+
// correct display normalization.
|
|
896
|
+
const TYPED_STARTUP_FAILURE_SUBTYPES = new Set(['runtime_spawn_failed', 'stdout_attach_failed']);
|
|
897
|
+
function normalizeStartupFailureReasonForDisplay(rawReason) {
|
|
898
|
+
if (typeof rawReason === 'string' && TYPED_STARTUP_FAILURE_SUBTYPES.has(rawReason)) {
|
|
899
|
+
return rawReason;
|
|
900
|
+
}
|
|
901
|
+
return 'stdout_attach_failed';
|
|
902
|
+
}
|
|
903
|
+
|
|
886
904
|
function filterDispatchProgressForActiveTurns(progressByTurn, activeTurns) {
|
|
887
905
|
const filtered = {};
|
|
888
906
|
if (!progressByTurn || typeof progressByTurn !== 'object') {
|
|
@@ -897,7 +915,7 @@ function filterDispatchProgressForActiveTurns(progressByTurn, activeTurns) {
|
|
|
897
915
|
return filtered;
|
|
898
916
|
}
|
|
899
917
|
|
|
900
|
-
function formatDispatchActivityLine(progress) {
|
|
918
|
+
export function formatDispatchActivityLine(progress) {
|
|
901
919
|
if (!progress || typeof progress !== 'object') return null;
|
|
902
920
|
const lastAct = progress.last_activity_at ? new Date(progress.last_activity_at) : null;
|
|
903
921
|
const agoSec = lastAct && !Number.isNaN(lastAct.getTime())
|
|
@@ -918,6 +936,15 @@ function formatDispatchActivityLine(progress) {
|
|
|
918
936
|
if (progress.activity_type === 'response') {
|
|
919
937
|
return chalk.green('API response received');
|
|
920
938
|
}
|
|
939
|
+
// DEC-BUG54-DIAGNOSTIC-ACTIVITY-TYPE-001 (Turn 91): stderr-only activity
|
|
940
|
+
// must be rendered as yellow "Diagnostic output only" — never as the green
|
|
941
|
+
// "Producing output" signal that previously leaked onto the operator
|
|
942
|
+
// surface for failing-startup subprocesses whose stdout never attached.
|
|
943
|
+
if (progress.activity_type === 'diagnostic_only') {
|
|
944
|
+
const agoLabel = agoSec != null && agoSec > 0 ? `, last ${agoSec}s ago` : '';
|
|
945
|
+
return chalk.yellow('Diagnostic output only') +
|
|
946
|
+
` (${progress.stderr_lines || 0} stderr lines, no stdout yet${agoLabel})`;
|
|
947
|
+
}
|
|
921
948
|
const agoLabel = agoSec != null && agoSec > 0 ? `, last ${agoSec}s ago` : '';
|
|
922
949
|
return chalk.green('Producing output') + ` (${progress.output_lines || 0} lines${agoLabel})`;
|
|
923
950
|
}
|
package/src/commands/step.js
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
} from '../lib/adapters/manual-adapter.js';
|
|
50
50
|
import {
|
|
51
51
|
dispatchLocalCli,
|
|
52
|
+
resolveStartupWatchdogMs,
|
|
52
53
|
saveDispatchLogs,
|
|
53
54
|
resolvePromptTransport,
|
|
54
55
|
} from '../lib/adapters/local-cli-adapter.js';
|
|
@@ -73,6 +74,7 @@ import { shouldSuggestManualQaFallback } from '../lib/manual-qa-fallback.js';
|
|
|
73
74
|
import { evaluateApprovalSlaReminders } from '../lib/notification-runner.js';
|
|
74
75
|
import { consumeNextApprovedIntent } from '../lib/intake.js';
|
|
75
76
|
import { failTurnStartup, reconcileStaleTurns } from '../lib/stale-turn-watchdog.js';
|
|
77
|
+
import { isKnownTurnRunningProofStream } from '../lib/dispatch-streams.js';
|
|
76
78
|
|
|
77
79
|
export async function stepCommand(opts) {
|
|
78
80
|
const context = loadProjectContext();
|
|
@@ -697,7 +699,10 @@ export async function stepCommand(opts) {
|
|
|
697
699
|
state = starting.state;
|
|
698
700
|
}
|
|
699
701
|
};
|
|
700
|
-
const ensureRunningState = (stream =
|
|
702
|
+
const ensureRunningState = (stream = null, at = new Date().toISOString()) => {
|
|
703
|
+
if (stream != null && !isKnownTurnRunningProofStream(stream)) {
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
701
706
|
if (runningMarked) return;
|
|
702
707
|
runningMarked = true;
|
|
703
708
|
const running = transitionActiveTurnLifecycle(root, turn.turn_id, 'running', { stream, at });
|
|
@@ -756,9 +761,10 @@ export async function stepCommand(opts) {
|
|
|
756
761
|
|
|
757
762
|
if (cliResult.startupFailure) {
|
|
758
763
|
const freshState = loadProjectState(root, config) || state;
|
|
764
|
+
const startupThresholdMs = resolveStartupWatchdogMs(config, runtime);
|
|
759
765
|
const failed = failTurnStartup(root, freshState, config, turn.turn_id, {
|
|
760
766
|
failure_type: cliResult.startupFailureType || 'no_subprocess_output',
|
|
761
|
-
threshold_ms:
|
|
767
|
+
threshold_ms: startupThresholdMs,
|
|
762
768
|
running_ms: freshState?.active_turns?.[turn.turn_id]?.started_at
|
|
763
769
|
? Math.max(0, Date.now() - new Date(freshState.active_turns[turn.turn_id].started_at).getTime())
|
|
764
770
|
: 0,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
} from '../turn-paths.js';
|
|
31
31
|
import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
|
|
32
32
|
import { hasMeaningfulStagedResult } from '../staged-result-proof.js';
|
|
33
|
+
import { getClaudeSubprocessAuthIssue } from '../claude-local-auth.js';
|
|
33
34
|
|
|
34
35
|
const DIAGNOSTIC_ENV_KEYS = [
|
|
35
36
|
'PATH',
|
|
@@ -39,6 +40,7 @@ const DIAGNOSTIC_ENV_KEYS = [
|
|
|
39
40
|
'TMPDIR',
|
|
40
41
|
'AGENTXCHAIN_TURN_ID',
|
|
41
42
|
];
|
|
43
|
+
const DIAGNOSTIC_STDERR_EXCERPT_LIMIT = 800;
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
46
|
* Launch a local CLI subprocess for a governed turn.
|
|
@@ -64,7 +66,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
64
66
|
onStderr,
|
|
65
67
|
onSpawnAttached,
|
|
66
68
|
onFirstOutput,
|
|
67
|
-
startupWatchdogMs
|
|
69
|
+
startupWatchdogMs: startupWatchdogOverrideMs,
|
|
68
70
|
turnId,
|
|
69
71
|
} = options;
|
|
70
72
|
|
|
@@ -85,6 +87,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
85
87
|
if (!runtime) {
|
|
86
88
|
return { ok: false, error: `Runtime "${runtimeId}" not found in config` };
|
|
87
89
|
}
|
|
90
|
+
const startupWatchdogMs = startupWatchdogOverrideMs ?? resolveStartupWatchdogMs(config, runtime);
|
|
88
91
|
|
|
89
92
|
// Read the dispatch bundle prompt
|
|
90
93
|
const promptPath = join(root, getDispatchPromptPath(turn.turn_id));
|
|
@@ -125,6 +128,22 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
125
128
|
const spawnEnv = { ...process.env, AGENTXCHAIN_TURN_ID: turn.turn_id };
|
|
126
129
|
const stdinBytes = transport === 'stdin' ? Buffer.byteLength(fullPrompt, 'utf8') : 0;
|
|
127
130
|
const diagnosticArgs = redactPromptArgs(args, fullPrompt, transport);
|
|
131
|
+
const claudeAuthIssue = await getClaudeSubprocessAuthIssue(runtime, spawnEnv);
|
|
132
|
+
|
|
133
|
+
if (claudeAuthIssue) {
|
|
134
|
+
appendDiagnostic(logs, 'claude_auth_preflight_failed', {
|
|
135
|
+
runtime_id: runtimeId,
|
|
136
|
+
turn_id: turn.turn_id,
|
|
137
|
+
auth_env_present: claudeAuthIssue.auth_env_present,
|
|
138
|
+
smoke_probe: claudeAuthIssue.smoke_probe,
|
|
139
|
+
recommendation: claudeAuthIssue.fix,
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
error: `${claudeAuthIssue.detail} ${claudeAuthIssue.fix}`,
|
|
144
|
+
logs,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
128
147
|
|
|
129
148
|
return new Promise((resolve) => {
|
|
130
149
|
if (signal?.aborted) {
|
|
@@ -163,6 +182,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
163
182
|
|
|
164
183
|
let settled = false;
|
|
165
184
|
let firstOutputAt = null;
|
|
185
|
+
let firstOutputStream = null;
|
|
166
186
|
let spawnConfirmedAt = null;
|
|
167
187
|
let spawnConfirmedAtMs = null;
|
|
168
188
|
let firstOutputLatencyMs = null;
|
|
@@ -171,6 +191,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
171
191
|
let startupFailureType = null;
|
|
172
192
|
let stdoutBytes = 0;
|
|
173
193
|
let stderrBytes = 0;
|
|
194
|
+
let stderrExcerpt = '';
|
|
174
195
|
|
|
175
196
|
const settle = (result) => {
|
|
176
197
|
if (settled) return;
|
|
@@ -211,6 +232,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
211
232
|
const recordFirstOutput = (stream) => {
|
|
212
233
|
if (firstOutputAt) return;
|
|
213
234
|
firstOutputAt = new Date().toISOString();
|
|
235
|
+
firstOutputStream = stream;
|
|
214
236
|
firstOutputLatencyMs = spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs);
|
|
215
237
|
clearStartupWatchdog();
|
|
216
238
|
appendDiagnostic(logs, 'first_output', {
|
|
@@ -280,7 +302,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
280
302
|
child.stderr.on('data', (chunk) => {
|
|
281
303
|
const text = chunk.toString();
|
|
282
304
|
stderrBytes += Buffer.byteLength(text);
|
|
283
|
-
|
|
305
|
+
stderrExcerpt = appendDiagnosticExcerpt(stderrExcerpt, text, DIAGNOSTIC_STDERR_EXCERPT_LIMIT);
|
|
284
306
|
logs.push('[stderr] ' + text);
|
|
285
307
|
if (onStderr) onStderr(text);
|
|
286
308
|
});
|
|
@@ -349,14 +371,20 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
349
371
|
pid: child.pid ?? null,
|
|
350
372
|
exit_code: exitCode,
|
|
351
373
|
signal: killSignal,
|
|
374
|
+
exit_signal: killSignal,
|
|
352
375
|
spawn_confirmed_at: spawnConfirmedAt,
|
|
353
376
|
elapsed_since_spawn_ms: spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs),
|
|
354
377
|
first_output_at: firstOutputAt,
|
|
378
|
+
first_output_stream: firstOutputStream,
|
|
355
379
|
startup_latency_ms: firstOutputLatencyMs,
|
|
356
380
|
stdout_bytes: stdoutBytes,
|
|
357
381
|
stderr_bytes: stderrBytes,
|
|
358
382
|
staged_result_ready: hasResult,
|
|
383
|
+
watchdog_fired: startupTimedOut,
|
|
359
384
|
};
|
|
385
|
+
if (stderrExcerpt) {
|
|
386
|
+
exitDiagnostic.stderr_excerpt = stderrExcerpt;
|
|
387
|
+
}
|
|
360
388
|
if (startupTimedOut) {
|
|
361
389
|
exitDiagnostic.startup_failure_type = startupFailureType || 'no_subprocess_output';
|
|
362
390
|
} else if (!spawnConfirmedAt) {
|
|
@@ -543,6 +571,16 @@ function resolvePromptTransport(runtime) {
|
|
|
543
571
|
return hasPlaceholder ? 'argv' : 'dispatch_bundle_only';
|
|
544
572
|
}
|
|
545
573
|
|
|
574
|
+
function resolveStartupWatchdogMs(config, runtime) {
|
|
575
|
+
if (runtime?.type === 'local_cli' && Number.isInteger(runtime?.startup_watchdog_ms) && runtime.startup_watchdog_ms > 0) {
|
|
576
|
+
return runtime.startup_watchdog_ms;
|
|
577
|
+
}
|
|
578
|
+
if (Number.isInteger(config?.run_loop?.startup_watchdog_ms) && config.run_loop.startup_watchdog_ms > 0) {
|
|
579
|
+
return config.run_loop.startup_watchdog_ms;
|
|
580
|
+
}
|
|
581
|
+
return 30_000;
|
|
582
|
+
}
|
|
583
|
+
|
|
546
584
|
/**
|
|
547
585
|
* Check if the staged result file exists and has meaningful content.
|
|
548
586
|
* Delegates to the shared `hasMeaningfulStagedResult` helper so watchdog,
|
|
@@ -595,4 +633,14 @@ function normalizeDiagnosticError(err) {
|
|
|
595
633
|
};
|
|
596
634
|
}
|
|
597
635
|
|
|
636
|
+
function appendDiagnosticExcerpt(existing, chunk, limit) {
|
|
637
|
+
const combined = `${existing}${chunk}`;
|
|
638
|
+
if (combined.length <= limit) {
|
|
639
|
+
return combined;
|
|
640
|
+
}
|
|
641
|
+
return combined.slice(combined.length - limit);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export { resolveCommand };
|
|
598
645
|
export { resolvePromptTransport };
|
|
646
|
+
export { resolveStartupWatchdogMs };
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const CLAUDE_ENV_AUTH_KEYS = [
|
|
4
|
+
'ANTHROPIC_API_KEY',
|
|
5
|
+
'CLAUDE_API_KEY',
|
|
6
|
+
'CLAUDE_CODE_OAUTH_TOKEN',
|
|
7
|
+
'CLAUDE_CODE_USE_VERTEX',
|
|
8
|
+
'CLAUDE_CODE_USE_BEDROCK',
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const DEFAULT_SMOKE_PROBE_TIMEOUT_MS = 10_000;
|
|
12
|
+
const DEFAULT_SMOKE_PROBE_STDIN = 'ok';
|
|
13
|
+
|
|
14
|
+
function normalizeCommandTokens(runtime) {
|
|
15
|
+
if (Array.isArray(runtime?.command)) {
|
|
16
|
+
return runtime.command.flatMap((element) =>
|
|
17
|
+
typeof element === 'string' ? element.trim().split(/\s+/).filter(Boolean) : []
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
if (typeof runtime?.command === 'string' && runtime.command.trim()) {
|
|
21
|
+
return runtime.command.trim().split(/\s+/).filter(Boolean);
|
|
22
|
+
}
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isClaudeLocalCliRuntime(runtime) {
|
|
27
|
+
const tokens = normalizeCommandTokens(runtime);
|
|
28
|
+
if (tokens.length === 0) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
const head = tokens[0].toLowerCase();
|
|
32
|
+
return head === 'claude' || head.endsWith('/claude');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function hasClaudeBareFlag(runtime) {
|
|
36
|
+
return normalizeCommandTokens(runtime).includes('--bare');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getClaudeEnvAuthPresence(env = process.env) {
|
|
40
|
+
return Object.fromEntries(
|
|
41
|
+
CLAUDE_ENV_AUTH_KEYS.map((key) => [key, Boolean(env?.[key])]),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function hasClaudeEnvAuth(env = process.env) {
|
|
46
|
+
return Object.values(getClaudeEnvAuthPresence(env)).some(Boolean);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildClaudeSubprocessAuthIssue(env, smokeProbe = null) {
|
|
50
|
+
const auth_env_present = getClaudeEnvAuthPresence(env);
|
|
51
|
+
return {
|
|
52
|
+
auth_env_present,
|
|
53
|
+
smoke_probe: smokeProbe,
|
|
54
|
+
detail: 'Claude local_cli runtime has no env-based auth and is missing "--bare"; non-interactive subprocesses can hang on macOS keychain reads.',
|
|
55
|
+
fix: 'Export ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN before running AgentXchain, or add "--bare" to the Claude command if you intentionally want env-only auth.',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveSmokeProbeTimeoutMs(env, options = {}) {
|
|
60
|
+
if (Number.isFinite(options?.timeoutMs) && options.timeoutMs > 0) {
|
|
61
|
+
return options.timeoutMs;
|
|
62
|
+
}
|
|
63
|
+
const raw = env?.AGENTXCHAIN_CLAUDE_AUTH_PROBE_TIMEOUT_MS;
|
|
64
|
+
const parsed = Number.parseInt(raw, 10);
|
|
65
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_SMOKE_PROBE_TIMEOUT_MS;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function getClaudeSubprocessAuthIssue(runtime, env = process.env, options = {}) {
|
|
69
|
+
if (!isClaudeLocalCliRuntime(runtime)) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (hasClaudeBareFlag(runtime) || hasClaudeEnvAuth(env)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const smokeProbe = await runClaudeSmokeProbe({
|
|
78
|
+
runtime,
|
|
79
|
+
env,
|
|
80
|
+
timeoutMs: resolveSmokeProbeTimeoutMs(env, options),
|
|
81
|
+
stdinPayload: options?.stdinPayload,
|
|
82
|
+
spawnImpl: options?.spawnImpl,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (smokeProbe.kind === 'stdout_observed' || smokeProbe.kind === 'spawn_error' || smokeProbe.kind === 'skipped') {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (smokeProbe.kind === 'hang' || smokeProbe.kind === 'exit_nonzero' || smokeProbe.kind === 'stderr_only') {
|
|
90
|
+
return buildClaudeSubprocessAuthIssue(env, smokeProbe);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Bounded smoke probe that spawns the runtime's actual Claude command with a
|
|
98
|
+
* tiny prompt on stdin and a watchdog. Returns a classification:
|
|
99
|
+
*
|
|
100
|
+
* { kind: 'stdout_observed' } — real stdout arrived before watchdog;
|
|
101
|
+
* the setup is NOT hanging on auth.
|
|
102
|
+
* { kind: 'hang', elapsed_ms } — watchdog fired with no stdout/stderr
|
|
103
|
+
* bytes; the keychain-hang shape (BUG-54).
|
|
104
|
+
* { kind: 'stderr_only', ... } — process wrote stderr but no stdout
|
|
105
|
+
* before watchdog (auth error or similar).
|
|
106
|
+
* { kind: 'exit_nonzero', ... } — process exited non-zero with no stdout
|
|
107
|
+
* (explicit auth failure — not a hang).
|
|
108
|
+
* { kind: 'spawn_error', ... } — spawn itself failed (ENOENT / EPERM).
|
|
109
|
+
* { kind: 'skipped', reason } — probe disabled or unavailable.
|
|
110
|
+
*
|
|
111
|
+
* This is the positive-case-testable alternative to the static shape-check in
|
|
112
|
+
* `getClaudeSubprocessAuthIssue`: it observes what the subprocess actually
|
|
113
|
+
* does rather than predicting what it *might* do from config shape alone.
|
|
114
|
+
*
|
|
115
|
+
* Added 2026-04-21 for the BUG-56 false-positive fix. See
|
|
116
|
+
* `.planning/BUG_56_FALSE_POSITIVE_RETRO.md` for the decision trail.
|
|
117
|
+
*
|
|
118
|
+
* @param {{ runtime: object, env?: object, timeoutMs?: number, stdinPayload?: string, spawnImpl?: Function }} opts
|
|
119
|
+
* @returns {Promise<object>}
|
|
120
|
+
*/
|
|
121
|
+
export async function runClaudeSmokeProbe(opts) {
|
|
122
|
+
const runtime = opts?.runtime ?? null;
|
|
123
|
+
const env = opts?.env ?? process.env;
|
|
124
|
+
const timeoutMs = Number.isFinite(opts?.timeoutMs) ? opts.timeoutMs : DEFAULT_SMOKE_PROBE_TIMEOUT_MS;
|
|
125
|
+
const stdinPayload = typeof opts?.stdinPayload === 'string' ? opts.stdinPayload : DEFAULT_SMOKE_PROBE_STDIN;
|
|
126
|
+
const spawnImpl = typeof opts?.spawnImpl === 'function' ? opts.spawnImpl : spawn;
|
|
127
|
+
|
|
128
|
+
if (!isClaudeLocalCliRuntime(runtime)) {
|
|
129
|
+
return { kind: 'skipped', reason: 'not_claude_local_cli' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const tokens = normalizeCommandTokens(runtime);
|
|
133
|
+
if (tokens.length === 0) {
|
|
134
|
+
return { kind: 'skipped', reason: 'empty_command' };
|
|
135
|
+
}
|
|
136
|
+
const [command, ...args] = tokens;
|
|
137
|
+
|
|
138
|
+
return new Promise((resolve) => {
|
|
139
|
+
let child;
|
|
140
|
+
try {
|
|
141
|
+
child = spawnImpl(command, args, {
|
|
142
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
143
|
+
env,
|
|
144
|
+
});
|
|
145
|
+
} catch (error) {
|
|
146
|
+
resolve({
|
|
147
|
+
kind: 'spawn_error',
|
|
148
|
+
errno: error?.errno ?? null,
|
|
149
|
+
code: error?.code ?? null,
|
|
150
|
+
message: error?.message ?? String(error),
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!child || typeof child.on !== 'function') {
|
|
156
|
+
resolve({ kind: 'spawn_error', code: 'NO_CHILD_HANDLE', message: 'spawn returned no child handle' });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const start = Date.now();
|
|
161
|
+
let stdoutBytes = 0;
|
|
162
|
+
let stderrBytes = 0;
|
|
163
|
+
let stderrBuf = '';
|
|
164
|
+
let settled = false;
|
|
165
|
+
|
|
166
|
+
const finish = (result) => {
|
|
167
|
+
if (settled) return;
|
|
168
|
+
settled = true;
|
|
169
|
+
try { child.kill('SIGTERM'); } catch { /* ignore */ }
|
|
170
|
+
resolve(result);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const watchdog = setTimeout(() => {
|
|
174
|
+
const elapsed_ms = Date.now() - start;
|
|
175
|
+
if (stdoutBytes > 0) {
|
|
176
|
+
finish({ kind: 'stdout_observed', elapsed_ms });
|
|
177
|
+
} else if (stderrBytes > 0) {
|
|
178
|
+
finish({ kind: 'stderr_only', elapsed_ms, stderr_snippet: stderrBuf.slice(0, 500) });
|
|
179
|
+
} else {
|
|
180
|
+
finish({ kind: 'hang', elapsed_ms });
|
|
181
|
+
}
|
|
182
|
+
}, timeoutMs);
|
|
183
|
+
if (typeof watchdog.unref === 'function') watchdog.unref();
|
|
184
|
+
|
|
185
|
+
child.stdout?.on('data', (chunk) => {
|
|
186
|
+
stdoutBytes += chunk.length;
|
|
187
|
+
if (stdoutBytes > 0 && !settled) {
|
|
188
|
+
clearTimeout(watchdog);
|
|
189
|
+
finish({ kind: 'stdout_observed', elapsed_ms: Date.now() - start });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
child.stderr?.on('data', (chunk) => {
|
|
194
|
+
stderrBytes += chunk.length;
|
|
195
|
+
stderrBuf += chunk.toString('utf8');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
child.on('error', (error) => {
|
|
199
|
+
clearTimeout(watchdog);
|
|
200
|
+
finish({
|
|
201
|
+
kind: 'spawn_error',
|
|
202
|
+
errno: error?.errno ?? null,
|
|
203
|
+
code: error?.code ?? null,
|
|
204
|
+
message: error?.message ?? String(error),
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
child.on('exit', (code, signal) => {
|
|
209
|
+
if (settled) return;
|
|
210
|
+
clearTimeout(watchdog);
|
|
211
|
+
const elapsed_ms = Date.now() - start;
|
|
212
|
+
if (stdoutBytes > 0) {
|
|
213
|
+
finish({ kind: 'stdout_observed', elapsed_ms });
|
|
214
|
+
} else if (code !== 0) {
|
|
215
|
+
finish({
|
|
216
|
+
kind: 'exit_nonzero',
|
|
217
|
+
elapsed_ms,
|
|
218
|
+
exit_code: code,
|
|
219
|
+
exit_signal: signal,
|
|
220
|
+
stderr_snippet: stderrBuf.slice(0, 500),
|
|
221
|
+
});
|
|
222
|
+
} else if (stderrBytes > 0) {
|
|
223
|
+
finish({ kind: 'stderr_only', elapsed_ms, stderr_snippet: stderrBuf.slice(0, 500) });
|
|
224
|
+
} else {
|
|
225
|
+
finish({ kind: 'hang', elapsed_ms });
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
child.stdin?.end(`${stdinPayload}\n`);
|
|
231
|
+
} catch {
|
|
232
|
+
// best-effort; error will surface via 'error' event if real
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export { CLAUDE_ENV_AUTH_KEYS, normalizeCommandTokens };
|