agentxchain 2.155.73 → 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.
Files changed (38) 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 +147 -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 +188 -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 +9 -0
  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/turn-checkpoint.js +21 -0
  35. package/src/lib/turn-result-validator.js +5 -0
  36. package/src/lib/validation.js +11 -3
  37. package/src/lib/verification-replay.js +125 -4
  38. package/src/lib/vision-reader.js +7 -2
@@ -35,8 +35,13 @@ 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';
42
47
 
@@ -52,6 +57,11 @@ const DIAGNOSTIC_STDERR_EXCERPT_LIMIT = 800;
52
57
  const DEFAULT_STARTUP_WATCHDOG_MS = 180_000;
53
58
  const DEFAULT_STARTUP_WATCHDOG_SIGKILL_GRACE_MS = 10_000;
54
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;
55
65
 
56
66
  /**
57
67
  * Launch a local CLI subprocess for a governed turn.
@@ -101,6 +111,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
101
111
  const startupWatchdogMs = startupWatchdogOverrideMs ?? resolveStartupWatchdogMs(config, runtime);
102
112
  const startupWatchdogKillGraceMs = resolveStartupWatchdogKillGraceMs(options.startupWatchdogKillGraceMs);
103
113
  const startupHeartbeatMs = resolveStartupHeartbeatMs(config, runtime, options.startupHeartbeatMs);
114
+ const livenessHeartbeatMs = resolveLivenessHeartbeatMs(config, runtime, options.livenessHeartbeatMs);
104
115
 
105
116
  // Read the dispatch bundle prompt
106
117
  const promptPath = join(root, getDispatchPromptPath(turn.turn_id));
@@ -226,6 +237,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
226
237
  let startupWatchdog = null;
227
238
  let startupSigkillHandle = null;
228
239
  let startupHeartbeat = null;
240
+ let livenessHeartbeat = null;
229
241
  let startupTimedOut = false;
230
242
  let startupFailureType = null;
231
243
  let stdoutBytes = 0;
@@ -235,6 +247,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
235
247
  const settle = (result) => {
236
248
  if (settled) return;
237
249
  settled = true;
250
+ clearLivenessHeartbeat();
238
251
  resolve(result);
239
252
  };
240
253
 
@@ -256,6 +269,48 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
256
269
  }
257
270
  };
258
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
+
259
314
  const armStartupHeartbeat = () => {
260
315
  if (startupHeartbeat || !(startupHeartbeatMs > 0 && Number.isFinite(startupHeartbeatMs))) {
261
316
  return;
@@ -360,6 +415,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
360
415
  }
361
416
  armStartupWatchdog();
362
417
  armStartupHeartbeat();
418
+ armLivenessHeartbeat();
363
419
  });
364
420
 
365
421
  // Collect stdout/stderr
@@ -551,6 +607,26 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
551
607
  error: `Claude local_cli runtime is using an incompatible Node.js version. ${recovery}`,
552
608
  logs,
553
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
+ });
554
630
  } else if (startupTimedOut) {
555
631
  settle({
556
632
  ok: false,
@@ -749,6 +825,19 @@ function resolveStartupHeartbeatMs(config, runtime, override) {
749
825
  return DEFAULT_STARTUP_HEARTBEAT_MS;
750
826
  }
751
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
+
752
841
  function resolveStartupWatchdogKillGraceMs(value) {
753
842
  if (Number.isInteger(value) && value >= 0) {
754
843
  return value;
@@ -827,6 +916,63 @@ function validateLocalCliCommandCompatibility({ command, args = [], runtimeId =
827
916
  };
828
917
  }
829
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
+
830
976
  return { ok: true };
831
977
  }
832
978
 
@@ -953,4 +1099,5 @@ export { resolveCommand };
953
1099
  export { resolvePromptTransport };
954
1100
  export { resolveStartupWatchdogMs };
955
1101
  export { resolveStartupHeartbeatMs };
1102
+ export { resolveLivenessHeartbeatMs };
956
1103
  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
+ }