agentxchain 2.155.73 → 2.157.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.
Files changed (39) hide show
  1. package/bin/agentxchain.js +22 -0
  2. package/dashboard/app.js +54 -0
  3. package/dashboard/components/org-audit-trail.js +161 -0
  4. package/dashboard/components/org-history.js +140 -0
  5. package/dashboard/components/org-overview.js +145 -0
  6. package/dashboard/components/org-runs.js +168 -0
  7. package/dashboard/index.html +4 -0
  8. package/package.json +2 -1
  9. package/src/commands/ci-report.js +80 -0
  10. package/src/commands/doctor.js +22 -1
  11. package/src/commands/intake-approve.js +1 -0
  12. package/src/commands/replay.js +1 -0
  13. package/src/commands/run.js +47 -0
  14. package/src/commands/serve.js +64 -0
  15. package/src/commands/step.js +16 -0
  16. package/src/commands/verify.js +1 -0
  17. package/src/lib/adapters/local-cli-adapter.js +184 -0
  18. package/src/lib/api/execution-worker.js +192 -0
  19. package/src/lib/api/hosted-runner.js +494 -0
  20. package/src/lib/api/job-queue.js +152 -0
  21. package/src/lib/api/org-state-aggregator.js +428 -0
  22. package/src/lib/api/project-registry.js +148 -0
  23. package/src/lib/api/protocol-bridge.js +476 -0
  24. package/src/lib/approval-policy.js +12 -0
  25. package/src/lib/ci-reporter.js +200 -0
  26. package/src/lib/claude-local-auth.js +75 -1
  27. package/src/lib/connector-probe.js +21 -0
  28. package/src/lib/continuous-run.js +75 -4
  29. package/src/lib/dashboard/bridge-server.js +10 -5
  30. package/src/lib/governed-state.js +1 -1
  31. package/src/lib/intake.js +32 -6
  32. package/src/lib/normalized-config.js +2 -0
  33. package/src/lib/scope-overlap.js +214 -0
  34. package/src/lib/stream-json-cost-parser.js +169 -0
  35. package/src/lib/turn-checkpoint.js +21 -0
  36. package/src/lib/turn-result-validator.js +25 -0
  37. package/src/lib/validation.js +11 -3
  38. package/src/lib/verification-replay.js +125 -4
  39. package/src/lib/vision-reader.js +7 -2
@@ -35,10 +35,19 @@ import {
35
35
  hasClaudeAuthenticationFailureText,
36
36
  hasClaudeNodeIncompatibilityText,
37
37
  hasCodexAuthenticationFailureText,
38
+ hasRateLimitOutput,
38
39
  isClaudeLocalCliRuntime,
39
40
  isCodexLocalCliRuntime,
41
+ isCursorLocalCliRuntime,
42
+ isWindsurfLocalCliRuntime,
43
+ isOpenCodeLocalCliRuntime,
44
+ parseRateLimitResetMs,
40
45
  resolveClaudeCompatibleNodeBinary,
41
46
  } from '../claude-local-auth.js';
47
+ import {
48
+ createStreamJsonCostParser,
49
+ buildCostFromStreamJson,
50
+ } from '../stream-json-cost-parser.js';
42
51
 
43
52
  const DIAGNOSTIC_ENV_KEYS = [
44
53
  'PATH',
@@ -52,6 +61,11 @@ const DIAGNOSTIC_STDERR_EXCERPT_LIMIT = 800;
52
61
  const DEFAULT_STARTUP_WATCHDOG_MS = 180_000;
53
62
  const DEFAULT_STARTUP_WATCHDOG_SIGKILL_GRACE_MS = 10_000;
54
63
  const DEFAULT_STARTUP_HEARTBEAT_MS = 30_000;
64
+ // RB-6: liveness heartbeat interval. Unlike the startup heartbeat (which stops
65
+ // at first output), this keeps the dispatch-progress `last_activity_at` fresh
66
+ // for the whole lifetime of an alive subprocess, so a long output-silent turn
67
+ // (e.g. one running a slow test suite) is not misclassified as a stalled turn.
68
+ const DEFAULT_LIVENESS_HEARTBEAT_MS = 60_000;
55
69
 
56
70
  /**
57
71
  * Launch a local CLI subprocess for a governed turn.
@@ -101,6 +115,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
101
115
  const startupWatchdogMs = startupWatchdogOverrideMs ?? resolveStartupWatchdogMs(config, runtime);
102
116
  const startupWatchdogKillGraceMs = resolveStartupWatchdogKillGraceMs(options.startupWatchdogKillGraceMs);
103
117
  const startupHeartbeatMs = resolveStartupHeartbeatMs(config, runtime, options.startupHeartbeatMs);
118
+ const livenessHeartbeatMs = resolveLivenessHeartbeatMs(config, runtime, options.livenessHeartbeatMs);
104
119
 
105
120
  // Read the dispatch bundle prompt
106
121
  const promptPath = join(root, getDispatchPromptPath(turn.turn_id));
@@ -136,6 +151,16 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
136
151
  };
137
152
  }
138
153
 
154
+ // Create stream-json cost parser for Claude runtimes with stream-json output.
155
+ // Only Claude runtimes emit parseable NDJSON with result events; other runtimes
156
+ // (Codex, Cursor, Windsurf, OpenCode) have different stdout formats.
157
+ const tokens = [command, ...args].filter((t) => typeof t === 'string');
158
+ const usesStreamJson = tokens.includes('--output-format=stream-json')
159
+ || (tokens.includes('--output-format') && tokens[tokens.indexOf('--output-format') + 1] === 'stream-json');
160
+ const costParser = isClaudeLocalCliRuntime(runtime) && usesStreamJson
161
+ ? createStreamJsonCostParser()
162
+ : null;
163
+
139
164
  // Compute timeout from explicit dispatch deadline, turn deadline, or default (20 minutes).
140
165
  const timeoutMs = options.dispatchTimeoutMs != null
141
166
  ? options.dispatchTimeoutMs
@@ -226,6 +251,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
226
251
  let startupWatchdog = null;
227
252
  let startupSigkillHandle = null;
228
253
  let startupHeartbeat = null;
254
+ let livenessHeartbeat = null;
229
255
  let startupTimedOut = false;
230
256
  let startupFailureType = null;
231
257
  let stdoutBytes = 0;
@@ -235,6 +261,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
235
261
  const settle = (result) => {
236
262
  if (settled) return;
237
263
  settled = true;
264
+ clearLivenessHeartbeat();
238
265
  resolve(result);
239
266
  };
240
267
 
@@ -256,6 +283,48 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
256
283
  }
257
284
  };
258
285
 
286
+ const clearLivenessHeartbeat = () => {
287
+ if (livenessHeartbeat) {
288
+ clearInterval(livenessHeartbeat);
289
+ livenessHeartbeat = null;
290
+ }
291
+ };
292
+
293
+ // RB-6: keep `last_activity_at` fresh for the full lifetime of an alive
294
+ // subprocess (NOT cleared at first output, unlike the startup heartbeat), so
295
+ // a long output-silent turn — e.g. one running a slow test suite — is not
296
+ // misclassified as stalled by the stale-turn watchdog. Genuine hangs are
297
+ // still bounded by the per-turn hard timeout.
298
+ const armLivenessHeartbeat = () => {
299
+ if (livenessHeartbeat || !(livenessHeartbeatMs > 0 && Number.isFinite(livenessHeartbeatMs))) {
300
+ return;
301
+ }
302
+ livenessHeartbeat = setInterval(() => {
303
+ if (settled) {
304
+ clearLivenessHeartbeat();
305
+ return;
306
+ }
307
+ const payload = {
308
+ liveness_heartbeat_ms: livenessHeartbeatMs,
309
+ pid: child.pid ?? null,
310
+ spawn_confirmed_at: spawnConfirmedAt,
311
+ elapsed_since_spawn_ms: spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs),
312
+ first_output_at: firstOutputAt,
313
+ stdout_bytes: stdoutBytes,
314
+ stderr_bytes: stderrBytes,
315
+ };
316
+ appendDiagnostic(logs, 'liveness_heartbeat', payload);
317
+ if (options.onLivenessHeartbeat) {
318
+ try {
319
+ options.onLivenessHeartbeat(payload);
320
+ } catch {}
321
+ }
322
+ }, livenessHeartbeatMs);
323
+ if (typeof livenessHeartbeat.unref === 'function') {
324
+ livenessHeartbeat.unref();
325
+ }
326
+ };
327
+
259
328
  const armStartupHeartbeat = () => {
260
329
  if (startupHeartbeat || !(startupHeartbeatMs > 0 && Number.isFinite(startupHeartbeatMs))) {
261
330
  return;
@@ -360,6 +429,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
360
429
  }
361
430
  armStartupWatchdog();
362
431
  armStartupHeartbeat();
432
+ armLivenessHeartbeat();
363
433
  });
364
434
 
365
435
  // Collect stdout/stderr
@@ -369,6 +439,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
369
439
  stdoutBytes += Buffer.byteLength(text);
370
440
  recordFirstOutput('stdout');
371
441
  logs.push(text);
442
+ if (costParser) costParser.push(text);
372
443
  if (onStdout) onStdout(text);
373
444
  });
374
445
  }
@@ -502,6 +573,28 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
502
573
  appendDiagnostic(logs, 'process_exit', exitDiagnostic);
503
574
 
504
575
  if (hasResult) {
576
+ // Enrich cost from stream-json if available (best-effort, never blocks settle)
577
+ if (costParser) {
578
+ try {
579
+ const parsedCost = costParser.getResult();
580
+ if (parsedCost) {
581
+ const stagingPath = join(root, getTurnStagingResultPath(turn.turn_id));
582
+ const raw = readFileSync(stagingPath, 'utf8');
583
+ const turnResult = JSON.parse(raw);
584
+ turnResult.cost = buildCostFromStreamJson(parsedCost, config);
585
+ writeFileSync(stagingPath, JSON.stringify(turnResult, null, 2) + '\n');
586
+ appendDiagnostic(logs, 'stream_json_cost_enriched', {
587
+ input_tokens: parsedCost.input_tokens,
588
+ output_tokens: parsedCost.output_tokens,
589
+ cost_usd: parsedCost.cost_usd,
590
+ model: parsedCost.model,
591
+ computed_usd: turnResult.cost.usd,
592
+ });
593
+ }
594
+ } catch {
595
+ // Cost enrichment is best-effort — if it fails, agent-reported cost is preserved
596
+ }
597
+ }
505
598
  settle({ ok: true, exitCode, timedOut: false, aborted: false, logs, firstOutputAt });
506
599
  } else if (isClaudeLocalCliRuntime(runtime) && hasClaudeAuthFailureOutput(logs)) {
507
600
  const recovery = 'Refresh Claude credentials before resuming: export a valid ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN, then run agentxchain step --resume.';
@@ -551,6 +644,26 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
551
644
  error: `Claude local_cli runtime is using an incompatible Node.js version. ${recovery}`,
552
645
  logs,
553
646
  });
647
+ } else if (hasRateLimitOutput(logs)) {
648
+ const resetMs = parseRateLimitResetMs(logs.join('\n'));
649
+ const resetDetail = resetMs ? ` Resets in ${Math.ceil(resetMs / 1000)}s.` : '';
650
+ const recovery = `Provider rate-limited.${resetDetail} Will retry with backoff.`;
651
+ settle({
652
+ ok: false,
653
+ blocked: true,
654
+ exitCode,
655
+ timedOut: false,
656
+ aborted: false,
657
+ firstOutputAt,
658
+ classified: {
659
+ error_class: 'rate_limited',
660
+ retryable: true,
661
+ recovery,
662
+ reset_ms: resetMs,
663
+ },
664
+ error: `Subprocess rate-limited by provider. ${recovery}`,
665
+ logs,
666
+ });
554
667
  } else if (startupTimedOut) {
555
668
  settle({
556
669
  ok: false,
@@ -749,6 +862,19 @@ function resolveStartupHeartbeatMs(config, runtime, override) {
749
862
  return DEFAULT_STARTUP_HEARTBEAT_MS;
750
863
  }
751
864
 
865
+ function resolveLivenessHeartbeatMs(config, runtime, override) {
866
+ if (Number.isInteger(override) && override > 0) {
867
+ return override;
868
+ }
869
+ if (runtime?.type === 'local_cli' && Number.isInteger(runtime?.liveness_heartbeat_ms) && runtime.liveness_heartbeat_ms > 0) {
870
+ return runtime.liveness_heartbeat_ms;
871
+ }
872
+ if (Number.isInteger(config?.run_loop?.liveness_heartbeat_ms) && config.run_loop.liveness_heartbeat_ms > 0) {
873
+ return config.run_loop.liveness_heartbeat_ms;
874
+ }
875
+ return DEFAULT_LIVENESS_HEARTBEAT_MS;
876
+ }
877
+
752
878
  function resolveStartupWatchdogKillGraceMs(value) {
753
879
  if (Number.isInteger(value) && value >= 0) {
754
880
  return value;
@@ -827,6 +953,63 @@ function validateLocalCliCommandCompatibility({ command, args = [], runtimeId =
827
953
  };
828
954
  }
829
955
 
956
+ const usesCursor = isCursorLocalCliRuntime(runtimeShape);
957
+ if (usesCursor && !tokens.includes('--background-agent') && !tokens.includes('agent')) {
958
+ const runtimeLabel = runtimeId ? `Runtime "${runtimeId}"` : 'Cursor local_cli runtime';
959
+ 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.`;
960
+ return {
961
+ ok: false,
962
+ error_class: 'local_cli_command_incompatible',
963
+ recovery,
964
+ error: recovery,
965
+ diagnostic: {
966
+ runtime_id: runtimeId,
967
+ binary: binaryName,
968
+ rule: 'cursor_requires_agent_mode',
969
+ has_agent_flag: false,
970
+ recovery,
971
+ },
972
+ };
973
+ }
974
+
975
+ const usesWindsurf = isWindsurfLocalCliRuntime(runtimeShape);
976
+ if (usesWindsurf && !tokens.includes('--agent')) {
977
+ const runtimeLabel = runtimeId ? `Runtime "${runtimeId}"` : 'Windsurf local_cli runtime';
978
+ const recovery = `${runtimeLabel} uses "windsurf" without an agent flag. Governed local runs require Windsurf's --agent mode for non-interactive execution.`;
979
+ return {
980
+ ok: false,
981
+ error_class: 'local_cli_command_incompatible',
982
+ recovery,
983
+ error: recovery,
984
+ diagnostic: {
985
+ runtime_id: runtimeId,
986
+ binary: binaryName,
987
+ rule: 'windsurf_requires_agent_mode',
988
+ has_agent_flag: false,
989
+ recovery,
990
+ },
991
+ };
992
+ }
993
+
994
+ const usesOpenCode = isOpenCodeLocalCliRuntime(runtimeShape);
995
+ if (usesOpenCode && !tokens.includes('--non-interactive')) {
996
+ const runtimeLabel = runtimeId ? `Runtime "${runtimeId}"` : 'OpenCode local_cli runtime';
997
+ const recovery = `${runtimeLabel} uses "opencode" without --non-interactive. Governed local runs require OpenCode's non-interactive mode for unattended execution.`;
998
+ return {
999
+ ok: false,
1000
+ error_class: 'local_cli_command_incompatible',
1001
+ recovery,
1002
+ error: recovery,
1003
+ diagnostic: {
1004
+ runtime_id: runtimeId,
1005
+ binary: binaryName,
1006
+ rule: 'opencode_requires_non_interactive',
1007
+ has_non_interactive_flag: false,
1008
+ recovery,
1009
+ },
1010
+ };
1011
+ }
1012
+
830
1013
  return { ok: true };
831
1014
  }
832
1015
 
@@ -953,4 +1136,5 @@ export { resolveCommand };
953
1136
  export { resolvePromptTransport };
954
1137
  export { resolveStartupWatchdogMs };
955
1138
  export { resolveStartupHeartbeatMs };
1139
+ export { resolveLivenessHeartbeatMs };
956
1140
  export { validateLocalCliCommandCompatibility };
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Execution Worker — in-process worker that polls the job queue and executes
3
+ * governed turns via run-loop + api_proxy adapter composition.
4
+ *
5
+ * Design rules:
6
+ * - Composes run-loop directly (protocol parity invariant)
7
+ * - Single turn per job (maxTurns: 1)
8
+ * - Heartbeat-based liveness per execution plane spec rule 3
9
+ * - Structured execution events per spec rule 6
10
+ *
11
+ * @module execution-worker
12
+ */
13
+
14
+ import { randomUUID } from 'node:crypto';
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+
18
+ import { runLoop } from '../run-loop.js';
19
+ import { dispatchApiProxy } from '../adapters/api-proxy-adapter.js';
20
+ import { getTurnStagingResultPath } from '../runner-interface.js';
21
+
22
+ const DEFAULT_POLL_INTERVAL_MS = 2000;
23
+ const HEARTBEAT_INTERVAL_MS = 30_000;
24
+
25
+ /**
26
+ * Create an execution worker.
27
+ * @param {object} options
28
+ * @param {string} options.root - project root directory
29
+ * @param {object} options.config - normalized config
30
+ * @param {object} options.queue - job queue instance
31
+ * @param {function} [options.onEvent] - structured event callback
32
+ * @param {string} [options.workerId] - worker identifier
33
+ * @param {number} [options.pollIntervalMs=2000] - queue poll interval
34
+ * @returns {{ start(): void, stop(): void, getStatus(): object }}
35
+ */
36
+ export function createExecutionWorker(options) {
37
+ const {
38
+ root,
39
+ config,
40
+ queue,
41
+ onEvent,
42
+ workerId = `worker-${randomUUID().slice(0, 8)}`,
43
+ pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
44
+ } = options;
45
+
46
+ let running = false;
47
+ let pollTimer = null;
48
+ let currentJob = null;
49
+ let currentAbort = null;
50
+
51
+ function emit(type, data = {}) {
52
+ if (!onEvent) return;
53
+ try {
54
+ onEvent({ type, worker_id: workerId, timestamp: new Date().toISOString(), ...data });
55
+ } catch {
56
+ // swallow event handler errors
57
+ }
58
+ }
59
+
60
+ async function executeJob(job, lease) {
61
+ currentJob = job;
62
+ const abortController = new AbortController();
63
+ currentAbort = abortController;
64
+
65
+ emit('execution_started', { job_id: job.job_id, run_id: job.run_id, role: job.role });
66
+
67
+ // Heartbeat interval
68
+ const heartbeatTimer = setInterval(() => {
69
+ const alive = queue.heartbeat(lease.lease_id);
70
+ if (!alive) {
71
+ abortController.abort();
72
+ }
73
+ }, HEARTBEAT_INTERVAL_MS);
74
+
75
+ try {
76
+ const result = await runLoop(root, config, {
77
+ selectRole(state) {
78
+ return job.role;
79
+ },
80
+
81
+ async dispatch(context) {
82
+ const adapterResult = await dispatchApiProxy(root, context.state, config, {
83
+ turnId: context.turn.turn_id,
84
+ signal: abortController.signal,
85
+ });
86
+
87
+ if (!adapterResult.ok) {
88
+ return { accept: false, reason: adapterResult.error || 'api_proxy dispatch failed' };
89
+ }
90
+
91
+ // api_proxy stages to disk; read back for run-loop acceptance
92
+ const stagingFile = join(root, getTurnStagingResultPath(context.turn.turn_id));
93
+ if (!existsSync(stagingFile)) {
94
+ return { accept: false, reason: 'adapter completed but no staged result found' };
95
+ }
96
+
97
+ let turnResult;
98
+ try {
99
+ turnResult = JSON.parse(readFileSync(stagingFile, 'utf8'));
100
+ } catch (err) {
101
+ return { accept: false, reason: `failed to parse staged result: ${err.message}` };
102
+ }
103
+
104
+ return { accept: true, turnResult };
105
+ },
106
+
107
+ approveGate() {
108
+ return true; // auto-approve in hosted mode
109
+ },
110
+
111
+ onEvent(event) {
112
+ emit('execution_progress', { job_id: job.job_id, event });
113
+ },
114
+ }, { maxTurns: 1 });
115
+
116
+ clearInterval(heartbeatTimer);
117
+
118
+ const success = result.ok;
119
+ queue.finalize(lease.lease_id, success ? 'completed' : 'failed');
120
+
121
+ emit(success ? 'execution_completed' : 'execution_interrupted', {
122
+ job_id: job.job_id,
123
+ run_id: job.run_id,
124
+ result_status: result.stop_reason || (success ? 'completed' : 'failed'),
125
+ turns_executed: result.turnsExecuted,
126
+ });
127
+
128
+ return result;
129
+ } catch (err) {
130
+ clearInterval(heartbeatTimer);
131
+ queue.finalize(lease.lease_id, 'failed');
132
+
133
+ emit('execution_interrupted', {
134
+ job_id: job.job_id,
135
+ run_id: job.run_id,
136
+ error: err.message,
137
+ });
138
+
139
+ return null;
140
+ } finally {
141
+ currentJob = null;
142
+ currentAbort = null;
143
+ }
144
+ }
145
+
146
+ async function poll() {
147
+ if (!running) return;
148
+
149
+ const claimed = queue.claim(workerId);
150
+ if (claimed) {
151
+ await executeJob(claimed.job, claimed.lease);
152
+ }
153
+
154
+ // Expire stale leases periodically
155
+ queue.expireStaleLeases();
156
+
157
+ // Schedule next poll
158
+ if (running) {
159
+ pollTimer = setTimeout(poll, pollIntervalMs);
160
+ }
161
+ }
162
+
163
+ function start() {
164
+ if (running) return;
165
+ running = true;
166
+ emit('worker_started', {});
167
+ // Start polling immediately
168
+ pollTimer = setTimeout(poll, 0);
169
+ }
170
+
171
+ function stop() {
172
+ running = false;
173
+ if (pollTimer) {
174
+ clearTimeout(pollTimer);
175
+ pollTimer = null;
176
+ }
177
+ if (currentAbort) {
178
+ currentAbort.abort();
179
+ }
180
+ emit('worker_stopped', {});
181
+ }
182
+
183
+ function getStatus() {
184
+ return {
185
+ worker_id: workerId,
186
+ running,
187
+ current_job: currentJob ? currentJob.job_id : null,
188
+ };
189
+ }
190
+
191
+ return { start, stop, getStatus };
192
+ }