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