agentxchain 2.155.72 → 2.156.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/README.md +4 -8
- package/bin/agentxchain.js +22 -0
- package/dashboard/app.js +54 -0
- package/dashboard/components/org-audit-trail.js +161 -0
- package/dashboard/components/org-history.js +140 -0
- package/dashboard/components/org-overview.js +145 -0
- package/dashboard/components/org-runs.js +168 -0
- package/dashboard/index.html +4 -0
- package/package.json +4 -5
- 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/ci-report.js +80 -0
- package/src/commands/doctor.js +22 -1
- package/src/commands/intake-approve.js +1 -0
- package/src/commands/replay.js +1 -0
- package/src/commands/run.js +50 -0
- package/src/commands/serve.js +64 -0
- package/src/commands/step.js +63 -1
- package/src/commands/verify.js +1 -0
- package/src/lib/adapters/local-cli-adapter.js +326 -2
- package/src/lib/api/execution-worker.js +192 -0
- package/src/lib/api/hosted-runner.js +494 -0
- package/src/lib/api/job-queue.js +152 -0
- package/src/lib/api/org-state-aggregator.js +428 -0
- package/src/lib/api/project-registry.js +148 -0
- package/src/lib/api/protocol-bridge.js +476 -0
- package/src/lib/approval-policy.js +12 -0
- package/src/lib/ci-reporter.js +188 -0
- package/src/lib/claude-local-auth.js +89 -1
- package/src/lib/connector-probe.js +21 -0
- package/src/lib/continuous-run.js +51 -3
- package/src/lib/dashboard/bridge-server.js +10 -5
- package/src/lib/dispatch-bundle.js +7 -3
- package/src/lib/dispatch-progress.js +9 -0
- package/src/lib/governed-state.js +5 -4
- package/src/lib/intake.js +32 -6
- package/src/lib/normalized-config.js +4 -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/scope-overlap.js +214 -0
- package/src/lib/turn-checkpoint.js +33 -3
- package/src/lib/turn-result-validator.js +47 -6
- package/src/lib/validation.js +11 -3
- package/src/lib/verification-replay.js +125 -4
- package/src/lib/vision-reader.js +16 -1
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,8 @@ 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';
|
|
80
|
+
import { checkpointAcceptedTurn } from '../lib/turn-checkpoint.js';
|
|
79
81
|
|
|
80
82
|
export async function stepCommand(opts) {
|
|
81
83
|
const context = loadProjectContext();
|
|
@@ -192,6 +194,7 @@ export async function stepCommand(opts) {
|
|
|
192
194
|
process.exit(1);
|
|
193
195
|
}
|
|
194
196
|
|
|
197
|
+
guardResumeWorkerLiveness(root, targetTurn);
|
|
195
198
|
skipAssignment = true;
|
|
196
199
|
console.log(chalk.yellow(`Resuming active turn: ${targetTurn.turn_id}`));
|
|
197
200
|
} else if (activeCount >= maxConcurrent) {
|
|
@@ -272,6 +275,7 @@ export async function stepCommand(opts) {
|
|
|
272
275
|
process.exit(1);
|
|
273
276
|
}
|
|
274
277
|
|
|
278
|
+
guardResumeWorkerLiveness(root, targetTurn);
|
|
275
279
|
console.log(chalk.yellow(`Re-dispatching blocked turn: ${targetTurn.turn_id}`));
|
|
276
280
|
const reactivated = reactivateGovernedRun(root, state, { via: 'step --resume', notificationConfig: config });
|
|
277
281
|
if (!reactivated.ok) {
|
|
@@ -999,6 +1003,21 @@ export async function stepCommand(opts) {
|
|
|
999
1003
|
}
|
|
1000
1004
|
|
|
1001
1005
|
printAcceptSummary(acceptResult, config);
|
|
1006
|
+
|
|
1007
|
+
// Auto-checkpoint accepted turn so workspace is clean for next assignment
|
|
1008
|
+
if (!opts.noCheckpoint && existsSync(join(root, '.git'))) {
|
|
1009
|
+
const checkpoint = checkpointAcceptedTurn(root, { turnId: turn.turn_id });
|
|
1010
|
+
if (!checkpoint.ok) {
|
|
1011
|
+
console.log(` ${chalk.yellow('Checkpoint:')} accepted but checkpoint failed`);
|
|
1012
|
+
console.log(` ${chalk.dim('Error:')} ${checkpoint.error}`);
|
|
1013
|
+
console.log(` ${chalk.dim('Retry:')} agentxchain checkpoint-turn --turn ${turn.turn_id}`);
|
|
1014
|
+
console.log('');
|
|
1015
|
+
process.exit(1);
|
|
1016
|
+
}
|
|
1017
|
+
if (!checkpoint.skipped) {
|
|
1018
|
+
console.log(` ${chalk.dim('Checkpoint:')} ${checkpoint.checkpoint_sha}`);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1002
1021
|
} else {
|
|
1003
1022
|
// Reject and potentially retry
|
|
1004
1023
|
console.log(chalk.yellow('Validation failed:'));
|
|
@@ -1039,6 +1058,49 @@ export async function stepCommand(opts) {
|
|
|
1039
1058
|
}
|
|
1040
1059
|
}
|
|
1041
1060
|
|
|
1061
|
+
function guardResumeWorkerLiveness(root, turn) {
|
|
1062
|
+
if (!turn || turn.worker_pid == null) {
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
if (isWorkerAlive(turn.worker_pid)) {
|
|
1067
|
+
console.log(chalk.red(`Worker process (PID ${turn.worker_pid}) is still alive.`));
|
|
1068
|
+
console.log(chalk.dim('The previous dispatch appears to still be running.'));
|
|
1069
|
+
console.log(chalk.dim('Wait for it to complete, or kill it first, then retry.'));
|
|
1070
|
+
process.exit(1);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
console.log(chalk.yellow(`Detected crashed worker (PID ${turn.worker_pid}). Re-dispatching turn ${turn.turn_id}...`));
|
|
1074
|
+
cleanupStaleDispatchProgress(root, turn.turn_id);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function isWorkerAlive(pid) {
|
|
1078
|
+
const numericPid = Number(pid);
|
|
1079
|
+
if (!Number.isInteger(numericPid) || numericPid <= 0) {
|
|
1080
|
+
return false;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
try {
|
|
1084
|
+
process.kill(numericPid, 0);
|
|
1085
|
+
return true;
|
|
1086
|
+
} catch {
|
|
1087
|
+
return false;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function cleanupStaleDispatchProgress(root, turnId) {
|
|
1092
|
+
const progressPath = join(root, getDispatchProgressRelativePath(turnId));
|
|
1093
|
+
if (!existsSync(progressPath)) {
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
try {
|
|
1098
|
+
unlinkSync(progressPath);
|
|
1099
|
+
} catch {
|
|
1100
|
+
// Best-effort cleanup: resume can still proceed and rewrite fresh progress.
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1042
1104
|
function printGhostTurnRecovery(ghostTurns) {
|
|
1043
1105
|
console.log(chalk.red.bold('Ghost turn detected — subprocess never started.'));
|
|
1044
1106
|
console.log('');
|
package/src/commands/verify.js
CHANGED
|
@@ -34,7 +34,14 @@ import {
|
|
|
34
34
|
getClaudeSubprocessAuthIssue,
|
|
35
35
|
hasClaudeAuthenticationFailureText,
|
|
36
36
|
hasClaudeNodeIncompatibilityText,
|
|
37
|
+
hasCodexAuthenticationFailureText,
|
|
38
|
+
hasRateLimitOutput,
|
|
37
39
|
isClaudeLocalCliRuntime,
|
|
40
|
+
isCodexLocalCliRuntime,
|
|
41
|
+
isCursorLocalCliRuntime,
|
|
42
|
+
isWindsurfLocalCliRuntime,
|
|
43
|
+
isOpenCodeLocalCliRuntime,
|
|
44
|
+
parseRateLimitResetMs,
|
|
38
45
|
resolveClaudeCompatibleNodeBinary,
|
|
39
46
|
} from '../claude-local-auth.js';
|
|
40
47
|
|
|
@@ -49,6 +56,12 @@ const DIAGNOSTIC_ENV_KEYS = [
|
|
|
49
56
|
const DIAGNOSTIC_STDERR_EXCERPT_LIMIT = 800;
|
|
50
57
|
const DEFAULT_STARTUP_WATCHDOG_MS = 180_000;
|
|
51
58
|
const DEFAULT_STARTUP_WATCHDOG_SIGKILL_GRACE_MS = 10_000;
|
|
59
|
+
const DEFAULT_STARTUP_HEARTBEAT_MS = 30_000;
|
|
60
|
+
// RB-6: liveness heartbeat interval. Unlike the startup heartbeat (which stops
|
|
61
|
+
// at first output), this keeps the dispatch-progress `last_activity_at` fresh
|
|
62
|
+
// for the whole lifetime of an alive subprocess, so a long output-silent turn
|
|
63
|
+
// (e.g. one running a slow test suite) is not misclassified as a stalled turn.
|
|
64
|
+
const DEFAULT_LIVENESS_HEARTBEAT_MS = 60_000;
|
|
52
65
|
|
|
53
66
|
/**
|
|
54
67
|
* Launch a local CLI subprocess for a governed turn.
|
|
@@ -97,6 +110,8 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
97
110
|
}
|
|
98
111
|
const startupWatchdogMs = startupWatchdogOverrideMs ?? resolveStartupWatchdogMs(config, runtime);
|
|
99
112
|
const startupWatchdogKillGraceMs = resolveStartupWatchdogKillGraceMs(options.startupWatchdogKillGraceMs);
|
|
113
|
+
const startupHeartbeatMs = resolveStartupHeartbeatMs(config, runtime, options.startupHeartbeatMs);
|
|
114
|
+
const livenessHeartbeatMs = resolveLivenessHeartbeatMs(config, runtime, options.livenessHeartbeatMs);
|
|
100
115
|
|
|
101
116
|
// Read the dispatch bundle prompt
|
|
102
117
|
const promptPath = join(root, getDispatchPromptPath(turn.turn_id));
|
|
@@ -116,8 +131,28 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
116
131
|
return { ok: false, error: `Cannot resolve CLI command for runtime "${runtimeId}". Expected "command" field in runtime config.` };
|
|
117
132
|
}
|
|
118
133
|
|
|
119
|
-
|
|
120
|
-
|
|
134
|
+
const compatibility = validateLocalCliCommandCompatibility({ command, args, runtimeId });
|
|
135
|
+
if (!compatibility.ok) {
|
|
136
|
+
const logs = [];
|
|
137
|
+
appendDiagnostic(logs, 'command_compatibility_failed', compatibility.diagnostic);
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
blocked: true,
|
|
141
|
+
classified: {
|
|
142
|
+
error_class: compatibility.error_class,
|
|
143
|
+
recovery: compatibility.recovery,
|
|
144
|
+
},
|
|
145
|
+
error: compatibility.error,
|
|
146
|
+
logs,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Compute timeout from explicit dispatch deadline, turn deadline, or default (20 minutes).
|
|
151
|
+
const timeoutMs = options.dispatchTimeoutMs != null
|
|
152
|
+
? options.dispatchTimeoutMs
|
|
153
|
+
: options.dispatchDeadlineAt
|
|
154
|
+
? Math.max(0, new Date(options.dispatchDeadlineAt).getTime() - Date.now())
|
|
155
|
+
: turn.deadline_at
|
|
121
156
|
? Math.max(0, new Date(turn.deadline_at).getTime() - Date.now())
|
|
122
157
|
: 1200000;
|
|
123
158
|
|
|
@@ -201,6 +236,8 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
201
236
|
let firstOutputLatencyMs = null;
|
|
202
237
|
let startupWatchdog = null;
|
|
203
238
|
let startupSigkillHandle = null;
|
|
239
|
+
let startupHeartbeat = null;
|
|
240
|
+
let livenessHeartbeat = null;
|
|
204
241
|
let startupTimedOut = false;
|
|
205
242
|
let startupFailureType = null;
|
|
206
243
|
let stdoutBytes = 0;
|
|
@@ -210,6 +247,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
210
247
|
const settle = (result) => {
|
|
211
248
|
if (settled) return;
|
|
212
249
|
settled = true;
|
|
250
|
+
clearLivenessHeartbeat();
|
|
213
251
|
resolve(result);
|
|
214
252
|
};
|
|
215
253
|
|
|
@@ -224,6 +262,85 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
224
262
|
}
|
|
225
263
|
};
|
|
226
264
|
|
|
265
|
+
const clearStartupHeartbeat = () => {
|
|
266
|
+
if (startupHeartbeat) {
|
|
267
|
+
clearInterval(startupHeartbeat);
|
|
268
|
+
startupHeartbeat = null;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const clearLivenessHeartbeat = () => {
|
|
273
|
+
if (livenessHeartbeat) {
|
|
274
|
+
clearInterval(livenessHeartbeat);
|
|
275
|
+
livenessHeartbeat = null;
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// RB-6: keep `last_activity_at` fresh for the full lifetime of an alive
|
|
280
|
+
// subprocess (NOT cleared at first output, unlike the startup heartbeat), so
|
|
281
|
+
// a long output-silent turn — e.g. one running a slow test suite — is not
|
|
282
|
+
// misclassified as stalled by the stale-turn watchdog. Genuine hangs are
|
|
283
|
+
// still bounded by the per-turn hard timeout.
|
|
284
|
+
const armLivenessHeartbeat = () => {
|
|
285
|
+
if (livenessHeartbeat || !(livenessHeartbeatMs > 0 && Number.isFinite(livenessHeartbeatMs))) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
livenessHeartbeat = setInterval(() => {
|
|
289
|
+
if (settled) {
|
|
290
|
+
clearLivenessHeartbeat();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const payload = {
|
|
294
|
+
liveness_heartbeat_ms: livenessHeartbeatMs,
|
|
295
|
+
pid: child.pid ?? null,
|
|
296
|
+
spawn_confirmed_at: spawnConfirmedAt,
|
|
297
|
+
elapsed_since_spawn_ms: spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs),
|
|
298
|
+
first_output_at: firstOutputAt,
|
|
299
|
+
stdout_bytes: stdoutBytes,
|
|
300
|
+
stderr_bytes: stderrBytes,
|
|
301
|
+
};
|
|
302
|
+
appendDiagnostic(logs, 'liveness_heartbeat', payload);
|
|
303
|
+
if (options.onLivenessHeartbeat) {
|
|
304
|
+
try {
|
|
305
|
+
options.onLivenessHeartbeat(payload);
|
|
306
|
+
} catch {}
|
|
307
|
+
}
|
|
308
|
+
}, livenessHeartbeatMs);
|
|
309
|
+
if (typeof livenessHeartbeat.unref === 'function') {
|
|
310
|
+
livenessHeartbeat.unref();
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const armStartupHeartbeat = () => {
|
|
315
|
+
if (startupHeartbeat || !(startupHeartbeatMs > 0 && Number.isFinite(startupHeartbeatMs))) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
startupHeartbeat = setInterval(() => {
|
|
319
|
+
if (firstOutputAt || settled) {
|
|
320
|
+
clearStartupHeartbeat();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const payload = {
|
|
324
|
+
startup_heartbeat_ms: startupHeartbeatMs,
|
|
325
|
+
startup_watchdog_ms: startupWatchdogMs,
|
|
326
|
+
pid: child.pid ?? null,
|
|
327
|
+
spawn_confirmed_at: spawnConfirmedAt,
|
|
328
|
+
elapsed_since_spawn_ms: spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs),
|
|
329
|
+
stdout_bytes: stdoutBytes,
|
|
330
|
+
stderr_bytes: stderrBytes,
|
|
331
|
+
};
|
|
332
|
+
appendDiagnostic(logs, 'startup_heartbeat', payload);
|
|
333
|
+
if (options.onStartupHeartbeat) {
|
|
334
|
+
try {
|
|
335
|
+
options.onStartupHeartbeat(payload);
|
|
336
|
+
} catch {}
|
|
337
|
+
}
|
|
338
|
+
}, startupHeartbeatMs);
|
|
339
|
+
if (typeof startupHeartbeat.unref === 'function') {
|
|
340
|
+
startupHeartbeat.unref();
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
227
344
|
const armStartupWatchdog = () => {
|
|
228
345
|
if (startupWatchdog || !(startupWatchdogMs > 0 && Number.isFinite(startupWatchdogMs))) {
|
|
229
346
|
return;
|
|
@@ -269,6 +386,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
269
386
|
firstOutputStream = stream;
|
|
270
387
|
firstOutputLatencyMs = spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs);
|
|
271
388
|
clearStartupWatchdog();
|
|
389
|
+
clearStartupHeartbeat();
|
|
272
390
|
appendDiagnostic(logs, 'first_output', {
|
|
273
391
|
at: firstOutputAt,
|
|
274
392
|
stream,
|
|
@@ -296,6 +414,8 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
296
414
|
} catch {}
|
|
297
415
|
}
|
|
298
416
|
armStartupWatchdog();
|
|
417
|
+
armStartupHeartbeat();
|
|
418
|
+
armLivenessHeartbeat();
|
|
299
419
|
});
|
|
300
420
|
|
|
301
421
|
// Collect stdout/stderr
|
|
@@ -369,6 +489,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
369
489
|
const onAbort = () => {
|
|
370
490
|
logs.push('[adapter] Abort signal received. Sending SIGTERM.');
|
|
371
491
|
clearStartupWatchdog();
|
|
492
|
+
clearStartupHeartbeat();
|
|
372
493
|
clearTimeout(timeoutHandle);
|
|
373
494
|
clearTimeout(sigkillHandle);
|
|
374
495
|
clearTimeout(abortSigkillHandle);
|
|
@@ -389,6 +510,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
389
510
|
// Process exit
|
|
390
511
|
child.on('close', (exitCode, killSignal) => {
|
|
391
512
|
clearStartupWatchdog();
|
|
513
|
+
clearStartupHeartbeat();
|
|
392
514
|
clearTimeout(timeoutHandle);
|
|
393
515
|
clearTimeout(sigkillHandle);
|
|
394
516
|
clearTimeout(abortSigkillHandle);
|
|
@@ -453,6 +575,22 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
453
575
|
error: `Claude local_cli authentication failed. ${recovery}`,
|
|
454
576
|
logs,
|
|
455
577
|
});
|
|
578
|
+
} else if (isCodexLocalCliRuntime(runtime) && hasCodexAuthFailureOutput(logs)) {
|
|
579
|
+
const recovery = 'Refresh OpenAI credentials before resuming: export a valid OPENAI_API_KEY, then run agentxchain step --resume.';
|
|
580
|
+
settle({
|
|
581
|
+
ok: false,
|
|
582
|
+
blocked: true,
|
|
583
|
+
exitCode,
|
|
584
|
+
timedOut: false,
|
|
585
|
+
aborted: false,
|
|
586
|
+
firstOutputAt,
|
|
587
|
+
classified: {
|
|
588
|
+
error_class: 'codex_auth_failed',
|
|
589
|
+
recovery,
|
|
590
|
+
},
|
|
591
|
+
error: `Codex local_cli authentication failed. ${recovery}`,
|
|
592
|
+
logs,
|
|
593
|
+
});
|
|
456
594
|
} else if (isClaudeLocalCliRuntime(runtime) && hasClaudeNodeRuntimeIncompatibilityOutput(logs)) {
|
|
457
595
|
const recovery = 'Run AgentXchain with Node.js 20.5+ available to the Claude local_cli runtime, then resume continuous mode.';
|
|
458
596
|
settle({
|
|
@@ -469,6 +607,26 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
469
607
|
error: `Claude local_cli runtime is using an incompatible Node.js version. ${recovery}`,
|
|
470
608
|
logs,
|
|
471
609
|
});
|
|
610
|
+
} else if (hasRateLimitOutput(logs)) {
|
|
611
|
+
const resetMs = parseRateLimitResetMs(logs.join('\n'));
|
|
612
|
+
const resetDetail = resetMs ? ` Resets in ${Math.ceil(resetMs / 1000)}s.` : '';
|
|
613
|
+
const recovery = `Provider rate-limited.${resetDetail} Will retry with backoff.`;
|
|
614
|
+
settle({
|
|
615
|
+
ok: false,
|
|
616
|
+
blocked: true,
|
|
617
|
+
exitCode,
|
|
618
|
+
timedOut: false,
|
|
619
|
+
aborted: false,
|
|
620
|
+
firstOutputAt,
|
|
621
|
+
classified: {
|
|
622
|
+
error_class: 'rate_limited',
|
|
623
|
+
retryable: true,
|
|
624
|
+
recovery,
|
|
625
|
+
reset_ms: resetMs,
|
|
626
|
+
},
|
|
627
|
+
error: `Subprocess rate-limited by provider. ${recovery}`,
|
|
628
|
+
logs,
|
|
629
|
+
});
|
|
472
630
|
} else if (startupTimedOut) {
|
|
473
631
|
settle({
|
|
474
632
|
ok: false,
|
|
@@ -524,6 +682,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
524
682
|
|
|
525
683
|
child.on('error', (err) => {
|
|
526
684
|
clearStartupWatchdog();
|
|
685
|
+
clearStartupHeartbeat();
|
|
527
686
|
clearTimeout(timeoutHandle);
|
|
528
687
|
clearTimeout(sigkillHandle);
|
|
529
688
|
clearTimeout(abortSigkillHandle);
|
|
@@ -653,6 +812,32 @@ function resolveStartupWatchdogMs(config, runtime) {
|
|
|
653
812
|
return DEFAULT_STARTUP_WATCHDOG_MS;
|
|
654
813
|
}
|
|
655
814
|
|
|
815
|
+
function resolveStartupHeartbeatMs(config, runtime, override) {
|
|
816
|
+
if (Number.isInteger(override) && override > 0) {
|
|
817
|
+
return override;
|
|
818
|
+
}
|
|
819
|
+
if (runtime?.type === 'local_cli' && Number.isInteger(runtime?.startup_heartbeat_ms) && runtime.startup_heartbeat_ms > 0) {
|
|
820
|
+
return runtime.startup_heartbeat_ms;
|
|
821
|
+
}
|
|
822
|
+
if (Number.isInteger(config?.run_loop?.startup_heartbeat_ms) && config.run_loop.startup_heartbeat_ms > 0) {
|
|
823
|
+
return config.run_loop.startup_heartbeat_ms;
|
|
824
|
+
}
|
|
825
|
+
return DEFAULT_STARTUP_HEARTBEAT_MS;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function resolveLivenessHeartbeatMs(config, runtime, override) {
|
|
829
|
+
if (Number.isInteger(override) && override > 0) {
|
|
830
|
+
return override;
|
|
831
|
+
}
|
|
832
|
+
if (runtime?.type === 'local_cli' && Number.isInteger(runtime?.liveness_heartbeat_ms) && runtime.liveness_heartbeat_ms > 0) {
|
|
833
|
+
return runtime.liveness_heartbeat_ms;
|
|
834
|
+
}
|
|
835
|
+
if (Number.isInteger(config?.run_loop?.liveness_heartbeat_ms) && config.run_loop.liveness_heartbeat_ms > 0) {
|
|
836
|
+
return config.run_loop.liveness_heartbeat_ms;
|
|
837
|
+
}
|
|
838
|
+
return DEFAULT_LIVENESS_HEARTBEAT_MS;
|
|
839
|
+
}
|
|
840
|
+
|
|
656
841
|
function resolveStartupWatchdogKillGraceMs(value) {
|
|
657
842
|
if (Number.isInteger(value) && value >= 0) {
|
|
658
843
|
return value;
|
|
@@ -660,6 +845,137 @@ function resolveStartupWatchdogKillGraceMs(value) {
|
|
|
660
845
|
return DEFAULT_STARTUP_WATCHDOG_SIGKILL_GRACE_MS;
|
|
661
846
|
}
|
|
662
847
|
|
|
848
|
+
function validateLocalCliCommandCompatibility({ command, args = [], runtimeId = null }) {
|
|
849
|
+
const tokens = [command, ...args].filter((token) => typeof token === 'string');
|
|
850
|
+
const binaryName = command ? command.split('/').filter(Boolean).pop() || command : '';
|
|
851
|
+
const runtimeShape = { command: tokens };
|
|
852
|
+
const outputFormatIndex = tokens.findIndex((token) => token === '--output-format');
|
|
853
|
+
const outputFormatValue = outputFormatIndex >= 0 ? tokens[outputFormatIndex + 1] : null;
|
|
854
|
+
const usesStreamJson = tokens.includes('--output-format=stream-json')
|
|
855
|
+
|| outputFormatValue === 'stream-json';
|
|
856
|
+
const usesPrint = tokens.includes('--print') || tokens.includes('-p');
|
|
857
|
+
const hasVerbose = tokens.includes('--verbose');
|
|
858
|
+
const usesCodex = isCodexLocalCliRuntime(runtimeShape);
|
|
859
|
+
const usesCodexExec = tokens.includes('exec');
|
|
860
|
+
const hasCodexJson = tokens.includes('--json');
|
|
861
|
+
|
|
862
|
+
if (binaryName === 'claude' && usesPrint && usesStreamJson && !hasVerbose) {
|
|
863
|
+
const runtimeLabel = runtimeId ? `Runtime "${runtimeId}"` : 'Claude local_cli runtime';
|
|
864
|
+
const recovery = `${runtimeLabel} uses "claude --print --output-format stream-json" without "--verbose". Add "--verbose" to the command array before dispatching again.`;
|
|
865
|
+
return {
|
|
866
|
+
ok: false,
|
|
867
|
+
error_class: 'local_cli_command_incompatible',
|
|
868
|
+
recovery,
|
|
869
|
+
error: recovery,
|
|
870
|
+
diagnostic: {
|
|
871
|
+
runtime_id: runtimeId,
|
|
872
|
+
binary: binaryName,
|
|
873
|
+
rule: 'claude_print_stream_json_requires_verbose',
|
|
874
|
+
has_print: usesPrint,
|
|
875
|
+
has_stream_json: usesStreamJson,
|
|
876
|
+
has_verbose: hasVerbose,
|
|
877
|
+
recovery,
|
|
878
|
+
},
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (usesCodex && !usesCodexExec) {
|
|
883
|
+
const runtimeLabel = runtimeId ? `Runtime "${runtimeId}"` : 'Codex local_cli runtime';
|
|
884
|
+
const recovery = `${runtimeLabel} uses "codex" without the "exec" subcommand. Governed local runs require "codex exec" for non-interactive execution.`;
|
|
885
|
+
return {
|
|
886
|
+
ok: false,
|
|
887
|
+
error_class: 'local_cli_command_incompatible',
|
|
888
|
+
recovery,
|
|
889
|
+
error: recovery,
|
|
890
|
+
diagnostic: {
|
|
891
|
+
runtime_id: runtimeId,
|
|
892
|
+
binary: binaryName,
|
|
893
|
+
rule: 'codex_requires_exec',
|
|
894
|
+
has_exec: usesCodexExec,
|
|
895
|
+
recovery,
|
|
896
|
+
},
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (usesCodex && usesCodexExec && !hasCodexJson) {
|
|
901
|
+
const runtimeLabel = runtimeId ? `Runtime "${runtimeId}"` : 'Codex local_cli runtime';
|
|
902
|
+
const recovery = `${runtimeLabel} uses "codex exec" without "--json". Add "--json" so subprocess output is machine-readable diagnostics while turn results remain staged on disk.`;
|
|
903
|
+
return {
|
|
904
|
+
ok: false,
|
|
905
|
+
error_class: 'local_cli_command_incompatible',
|
|
906
|
+
recovery,
|
|
907
|
+
error: recovery,
|
|
908
|
+
diagnostic: {
|
|
909
|
+
runtime_id: runtimeId,
|
|
910
|
+
binary: binaryName,
|
|
911
|
+
rule: 'codex_exec_requires_json',
|
|
912
|
+
has_exec: usesCodexExec,
|
|
913
|
+
has_json: hasCodexJson,
|
|
914
|
+
recovery,
|
|
915
|
+
},
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const usesCursor = isCursorLocalCliRuntime(runtimeShape);
|
|
920
|
+
if (usesCursor && !tokens.includes('--background-agent') && !tokens.includes('agent')) {
|
|
921
|
+
const runtimeLabel = runtimeId ? `Runtime "${runtimeId}"` : 'Cursor local_cli runtime';
|
|
922
|
+
const recovery = `${runtimeLabel} uses "cursor" without a background agent flag. Governed local runs require Cursor's agent or background-agent mode for non-interactive execution.`;
|
|
923
|
+
return {
|
|
924
|
+
ok: false,
|
|
925
|
+
error_class: 'local_cli_command_incompatible',
|
|
926
|
+
recovery,
|
|
927
|
+
error: recovery,
|
|
928
|
+
diagnostic: {
|
|
929
|
+
runtime_id: runtimeId,
|
|
930
|
+
binary: binaryName,
|
|
931
|
+
rule: 'cursor_requires_agent_mode',
|
|
932
|
+
has_agent_flag: false,
|
|
933
|
+
recovery,
|
|
934
|
+
},
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const usesWindsurf = isWindsurfLocalCliRuntime(runtimeShape);
|
|
939
|
+
if (usesWindsurf && !tokens.includes('--agent')) {
|
|
940
|
+
const runtimeLabel = runtimeId ? `Runtime "${runtimeId}"` : 'Windsurf local_cli runtime';
|
|
941
|
+
const recovery = `${runtimeLabel} uses "windsurf" without an agent flag. Governed local runs require Windsurf's --agent mode for non-interactive execution.`;
|
|
942
|
+
return {
|
|
943
|
+
ok: false,
|
|
944
|
+
error_class: 'local_cli_command_incompatible',
|
|
945
|
+
recovery,
|
|
946
|
+
error: recovery,
|
|
947
|
+
diagnostic: {
|
|
948
|
+
runtime_id: runtimeId,
|
|
949
|
+
binary: binaryName,
|
|
950
|
+
rule: 'windsurf_requires_agent_mode',
|
|
951
|
+
has_agent_flag: false,
|
|
952
|
+
recovery,
|
|
953
|
+
},
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const usesOpenCode = isOpenCodeLocalCliRuntime(runtimeShape);
|
|
958
|
+
if (usesOpenCode && !tokens.includes('--non-interactive')) {
|
|
959
|
+
const runtimeLabel = runtimeId ? `Runtime "${runtimeId}"` : 'OpenCode local_cli runtime';
|
|
960
|
+
const recovery = `${runtimeLabel} uses "opencode" without --non-interactive. Governed local runs require OpenCode's non-interactive mode for unattended execution.`;
|
|
961
|
+
return {
|
|
962
|
+
ok: false,
|
|
963
|
+
error_class: 'local_cli_command_incompatible',
|
|
964
|
+
recovery,
|
|
965
|
+
error: recovery,
|
|
966
|
+
diagnostic: {
|
|
967
|
+
runtime_id: runtimeId,
|
|
968
|
+
binary: binaryName,
|
|
969
|
+
rule: 'opencode_requires_non_interactive',
|
|
970
|
+
has_non_interactive_flag: false,
|
|
971
|
+
recovery,
|
|
972
|
+
},
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
return { ok: true };
|
|
977
|
+
}
|
|
978
|
+
|
|
663
979
|
/**
|
|
664
980
|
* Check if the staged result file exists and has meaningful content.
|
|
665
981
|
* Delegates to the shared `hasMeaningfulStagedResult` helper so watchdog,
|
|
@@ -687,6 +1003,11 @@ function hasClaudeAuthFailureOutput(logs) {
|
|
|
687
1003
|
return logs.some((line) => hasClaudeAuthenticationFailureText(line));
|
|
688
1004
|
}
|
|
689
1005
|
|
|
1006
|
+
function hasCodexAuthFailureOutput(logs) {
|
|
1007
|
+
if (!Array.isArray(logs)) return false;
|
|
1008
|
+
return logs.some((line) => hasCodexAuthenticationFailureText(line));
|
|
1009
|
+
}
|
|
1010
|
+
|
|
690
1011
|
function hasClaudeNodeRuntimeIncompatibilityOutput(logs) {
|
|
691
1012
|
if (!Array.isArray(logs)) return false;
|
|
692
1013
|
return hasClaudeNodeIncompatibilityText(logs.join('\n'));
|
|
@@ -777,3 +1098,6 @@ function appendDiagnosticExcerpt(existing, chunk, limit) {
|
|
|
777
1098
|
export { resolveCommand };
|
|
778
1099
|
export { resolvePromptTransport };
|
|
779
1100
|
export { resolveStartupWatchdogMs };
|
|
1101
|
+
export { resolveStartupHeartbeatMs };
|
|
1102
|
+
export { resolveLivenessHeartbeatMs };
|
|
1103
|
+
export { validateLocalCliCommandCompatibility };
|