agentxchain 2.110.0 → 2.112.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.
@@ -44,6 +44,7 @@ import {
44
44
  getDispatchTurnDir,
45
45
  getTurnStagingResultPath,
46
46
  } from '../lib/turn-paths.js';
47
+ import { resolveChainOptions, executeChainedRun } from '../lib/run-chain.js';
47
48
 
48
49
  export async function runCommand(opts) {
49
50
  const context = loadProjectContext();
@@ -52,6 +53,16 @@ export async function runCommand(opts) {
52
53
  process.exit(1);
53
54
  }
54
55
 
56
+ const chainOpts = resolveChainOptions(opts, context.config);
57
+ if (chainOpts.enabled) {
58
+ console.log(chalk.cyan.bold('agentxchain run --chain'));
59
+ const chainParts = [`max ${chainOpts.maxChains} continuations`, `on: ${chainOpts.chainOn.join(',')}`, `cooldown: ${chainOpts.cooldownSeconds}s`];
60
+ if (chainOpts.mission) chainParts.push(`mission: ${chainOpts.mission}`);
61
+ console.log(chalk.dim(` Chain mode: enabled (${chainParts.join(', ')})`));
62
+ const { exitCode } = await executeChainedRun(context, opts, chainOpts, executeGovernedRun);
63
+ process.exit(exitCode);
64
+ }
65
+
55
66
  const execution = await executeGovernedRun(context, opts);
56
67
  process.exit(execution.exitCode);
57
68
  }
@@ -167,8 +178,7 @@ export async function executeGovernedRun(context, opts = {}) {
167
178
  let aborted = false;
168
179
  let sigintCount = 0;
169
180
  const controller = new AbortController();
170
-
171
- process.on('SIGINT', () => {
181
+ const onSigint = () => {
172
182
  sigintCount++;
173
183
  if (sigintCount >= 2) {
174
184
  process.exit(130);
@@ -176,37 +186,39 @@ export async function executeGovernedRun(context, opts = {}) {
176
186
  aborted = true;
177
187
  controller.abort();
178
188
  log(chalk.yellow('\nSIGINT received — finishing current turn, then stopping.'));
179
- });
189
+ };
190
+ process.on('SIGINT', onSigint);
180
191
 
181
- // ── Run header ──────────────────────────────────────────────────────────
182
- log(chalk.cyan.bold('agentxchain run'));
183
- log(chalk.dim(` Max turns: ${maxTurns} Gate mode: ${autoApprove ? 'auto-approve' : 'interactive'}`));
184
- if (provenance) {
185
- const provenanceSummary = summarizeRunProvenance(provenance);
186
- if (provenanceSummary) {
187
- log(` ${chalk.dim('Origin:')} ${chalk.magenta(provenanceSummary)}`);
192
+ try {
193
+ // ── Run header ──────────────────────────────────────────────────────────
194
+ log(chalk.cyan.bold('agentxchain run'));
195
+ log(chalk.dim(` Max turns: ${maxTurns} Gate mode: ${autoApprove ? 'auto-approve' : 'interactive'}`));
196
+ if (provenance) {
197
+ const provenanceSummary = summarizeRunProvenance(provenance);
198
+ if (provenanceSummary) {
199
+ log(` ${chalk.dim('Origin:')} ${chalk.magenta(provenanceSummary)}`);
200
+ }
188
201
  }
189
- }
190
- if (inheritedContext) {
191
- const ic = inheritedContext;
192
- const phasesCount = ic.parent_phases_completed?.length || 0;
193
- const decisionsCount = ic.recent_decisions?.length || 0;
194
- const turnsCount = ic.recent_accepted_turns?.length || 0;
195
- const parts = [];
196
- if (phasesCount) parts.push(`${phasesCount} phase${phasesCount !== 1 ? 's' : ''}`);
197
- if (decisionsCount) parts.push(`${decisionsCount} decision${decisionsCount !== 1 ? 's' : ''}`);
198
- if (turnsCount) parts.push(`${turnsCount} turn${turnsCount !== 1 ? 's' : ''}`);
199
- const detail = parts.length ? `${parts.join(', ')}` : '';
200
- log(` ${chalk.dim('Inherits:')} ${chalk.magenta(`parent ${ic.parent_run_id} (${ic.parent_status || 'unknown'})${detail}`)}`);
201
- }
202
- log('');
202
+ if (inheritedContext) {
203
+ const ic = inheritedContext;
204
+ const phasesCount = ic.parent_phases_completed?.length || 0;
205
+ const decisionsCount = ic.recent_decisions?.length || 0;
206
+ const turnsCount = ic.recent_accepted_turns?.length || 0;
207
+ const parts = [];
208
+ if (phasesCount) parts.push(`${phasesCount} phase${phasesCount !== 1 ? 's' : ''}`);
209
+ if (decisionsCount) parts.push(`${decisionsCount} decision${decisionsCount !== 1 ? 's' : ''}`);
210
+ if (turnsCount) parts.push(`${turnsCount} turn${turnsCount !== 1 ? 's' : ''}`);
211
+ const detail = parts.length ? ` ${parts.join(', ')}` : '';
212
+ log(` ${chalk.dim('Inherits:')} ${chalk.magenta(`parent ${ic.parent_run_id} (${ic.parent_status || 'unknown'})${detail}`)}`);
213
+ }
214
+ log('');
203
215
 
204
- // ── Track first-call for --role override ────────────────────────────────
205
- let firstSelectRole = true;
206
- let qaMissingCredentialsFallback = null;
216
+ // ── Track first-call for --role override ────────────────────────────────
217
+ let firstSelectRole = true;
218
+ let qaMissingCredentialsFallback = null;
207
219
 
208
- // ── Callbacks ───────────────────────────────────────────────────────────
209
- const callbacks = {
220
+ // ── Callbacks ───────────────────────────────────────────────────────────
221
+ const callbacks = {
210
222
  selectRole(state, cfg) {
211
223
  if (aborted) return null;
212
224
 
@@ -407,84 +419,87 @@ export async function executeGovernedRun(context, opts = {}) {
407
419
  break;
408
420
  }
409
421
  },
410
- };
411
-
412
- // ── Execute ─────────────────────────────────────────────────────────────
413
- const runLoopOpts = {
414
- maxTurns,
415
- startNewRunFromCompleted: true,
416
- startNewRunFromBlocked: opts.allowBlockedRestart ?? Boolean(provenance),
417
- };
418
- if (provenance) runLoopOpts.provenance = provenance;
419
- if (inheritedContext) runLoopOpts.inheritedContext = inheritedContext;
420
- const result = await runLoop(root, config, callbacks, runLoopOpts);
422
+ };
421
423
 
422
- // ── Summary ─────────────────────────────────────────────────────────────
423
- log('');
424
- log(chalk.dim('─── Run Summary ───'));
425
- log(` Status: ${result.ok ? chalk.green('completed') : chalk.yellow(result.stop_reason)}`);
426
- log(` Turns: ${result.turns_executed}`);
427
- log(` Gates: ${result.gates_approved} approved`);
428
- log(` Errors: ${result.errors.length ? chalk.red(result.errors.length) : 'none'}`);
429
-
430
- if (result.errors.length) {
431
- for (const err of result.errors) {
432
- log(chalk.red(` ${err}`));
424
+ // ── Execute ─────────────────────────────────────────────────────────────
425
+ const runLoopOpts = {
426
+ maxTurns,
427
+ startNewRunFromCompleted: true,
428
+ startNewRunFromBlocked: opts.allowBlockedRestart ?? Boolean(provenance),
429
+ };
430
+ if (provenance) runLoopOpts.provenance = provenance;
431
+ if (inheritedContext) runLoopOpts.inheritedContext = inheritedContext;
432
+ const result = await runLoop(root, config, callbacks, runLoopOpts);
433
+
434
+ // ── Summary ─────────────────────────────────────────────────────────────
435
+ log('');
436
+ log(chalk.dim('─── Run Summary ───'));
437
+ log(` Status: ${result.ok ? chalk.green('completed') : chalk.yellow(result.stop_reason)}`);
438
+ log(` Turns: ${result.turns_executed}`);
439
+ log(` Gates: ${result.gates_approved} approved`);
440
+ log(` Errors: ${result.errors.length ? chalk.red(result.errors.length) : 'none'}`);
441
+
442
+ if (result.errors.length) {
443
+ for (const err of result.errors) {
444
+ log(chalk.red(` ${err}`));
445
+ }
433
446
  }
434
- }
435
447
 
436
- if (qaMissingCredentialsFallback) {
437
- printManualQaFallback(log);
438
- }
448
+ if (qaMissingCredentialsFallback) {
449
+ printManualQaFallback(log);
450
+ }
439
451
 
440
- // Recovery guidance for blocked/rejected states
441
- if (result.state && (result.stop_reason === 'blocked' || result.stop_reason === 'reject_exhausted' || result.stop_reason === 'dispatch_error')) {
442
- const recovery = deriveRecoveryDescriptor(result.state, config);
443
- if (recovery) {
444
- log('');
445
- log(chalk.yellow(` Recovery: ${recovery.typed_reason}`));
446
- log(chalk.dim(` Action: ${recovery.recovery_action}`));
447
- if (recovery.detail) {
448
- log(chalk.dim(` Detail: ${recovery.detail}`));
452
+ // Recovery guidance for blocked/rejected states
453
+ if (result.state && (result.stop_reason === 'blocked' || result.stop_reason === 'reject_exhausted' || result.stop_reason === 'dispatch_error')) {
454
+ const recovery = deriveRecoveryDescriptor(result.state, config);
455
+ if (recovery) {
456
+ log('');
457
+ log(chalk.yellow(` Recovery: ${recovery.typed_reason}`));
458
+ log(chalk.dim(` Action: ${recovery.recovery_action}`));
459
+ if (recovery.detail) {
460
+ log(chalk.dim(` Detail: ${recovery.detail}`));
461
+ }
449
462
  }
450
463
  }
451
- }
452
-
453
- // ── Auto governance report ──────────────────────────────────────────────
454
- if (opts.report !== false && result.state) {
455
- try {
456
- const reportsDir = join(root, '.agentxchain', 'reports');
457
- mkdirSync(reportsDir, { recursive: true });
458
-
459
- const exportResult = buildRunExport(root);
460
- if (exportResult.ok) {
461
- const runId = result.state.run_id || 'unknown';
462
- const exportPath = join(reportsDir, `export-${runId}.json`);
463
- writeFileSync(exportPath, JSON.stringify(exportResult.export, null, 2));
464
-
465
- const reportResult = buildGovernanceReport(exportResult.export, { input: exportPath });
466
- const reportPath = join(reportsDir, `report-${runId}.md`);
467
- writeFileSync(reportPath, formatGovernanceReportMarkdown(reportResult.report));
468
464
 
469
- log('');
470
- log(chalk.dim(` Governance report: .agentxchain/reports/report-${runId}.md`));
471
- } else {
472
- log(chalk.dim(` Governance report skipped: ${exportResult.error}`));
465
+ // ── Auto governance report ──────────────────────────────────────────────
466
+ if (opts.report !== false && result.state) {
467
+ try {
468
+ const reportsDir = join(root, '.agentxchain', 'reports');
469
+ mkdirSync(reportsDir, { recursive: true });
470
+
471
+ const exportResult = buildRunExport(root);
472
+ if (exportResult.ok) {
473
+ const runId = result.state.run_id || 'unknown';
474
+ const exportPath = join(reportsDir, `export-${runId}.json`);
475
+ writeFileSync(exportPath, JSON.stringify(exportResult.export, null, 2));
476
+
477
+ const reportResult = buildGovernanceReport(exportResult.export, { input: exportPath });
478
+ const reportPath = join(reportsDir, `report-${runId}.md`);
479
+ writeFileSync(reportPath, formatGovernanceReportMarkdown(reportResult.report));
480
+
481
+ log('');
482
+ log(chalk.dim(` Governance report: .agentxchain/reports/report-${runId}.md`));
483
+ } else {
484
+ log(chalk.dim(` Governance report skipped: ${exportResult.error}`));
485
+ }
486
+ } catch (err) {
487
+ log(chalk.dim(` Governance report failed: ${err.message}`));
473
488
  }
474
- } catch (err) {
475
- log(chalk.dim(` Governance report failed: ${err.message}`));
476
489
  }
477
- }
478
490
 
479
- // ── Exit code ───────────────────────────────────────────────────────────
480
- const successReasons = new Set(['completed', 'gate_held', 'caller_stopped', 'max_turns_reached']);
481
- return {
482
- exitCode: result.ok || successReasons.has(result.stop_reason) ? 0 : 1,
483
- result,
484
- skipped: false,
485
- skipReason: null,
486
- provenance: provenance || null,
487
- };
491
+ // ── Exit code ───────────────────────────────────────────────────────────
492
+ const successReasons = new Set(['completed', 'gate_held', 'caller_stopped', 'max_turns_reached']);
493
+ return {
494
+ exitCode: result.ok || successReasons.has(result.stop_reason) ? 0 : 1,
495
+ result,
496
+ skipped: false,
497
+ skipReason: null,
498
+ provenance: provenance || null,
499
+ };
500
+ } finally {
501
+ process.removeListener('SIGINT', onSigint);
502
+ }
488
503
  }
489
504
 
490
505
  // ── Helpers ───────────────────────────────────────────────────────────────
@@ -0,0 +1,54 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ export function getChainReportsDir(root) {
5
+ return join(root, '.agentxchain', 'reports');
6
+ }
7
+
8
+ export function loadAllChainReports(root) {
9
+ const reportsDir = getChainReportsDir(root);
10
+ if (!existsSync(reportsDir)) return [];
11
+
12
+ const files = readdirSync(reportsDir)
13
+ .filter((file) => file.startsWith('chain-') && file.endsWith('.json'))
14
+ .sort()
15
+ .reverse();
16
+
17
+ const reports = [];
18
+ for (const file of files) {
19
+ try {
20
+ const content = readFileSync(join(reportsDir, file), 'utf8');
21
+ reports.push(JSON.parse(content));
22
+ } catch {
23
+ // Advisory artifact only. Skip malformed files instead of failing the surface.
24
+ }
25
+ }
26
+
27
+ reports.sort((a, b) => {
28
+ const aTime = a.started_at ? new Date(a.started_at).getTime() : 0;
29
+ const bTime = b.started_at ? new Date(b.started_at).getTime() : 0;
30
+ return bTime - aTime;
31
+ });
32
+
33
+ return reports;
34
+ }
35
+
36
+ export function loadLatestChainReport(root) {
37
+ const reports = loadAllChainReports(root);
38
+ return reports.length > 0 ? reports[0] : null;
39
+ }
40
+
41
+ export function loadChainReport(root, chainId) {
42
+ const reportsDir = getChainReportsDir(root);
43
+ const exactPath = join(reportsDir, `${chainId}.json`);
44
+ if (existsSync(exactPath)) {
45
+ try {
46
+ return JSON.parse(readFileSync(exactPath, 'utf8'));
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ const reports = loadAllChainReports(root);
53
+ return reports.find((report) => report.chain_id === chainId) || null;
54
+ }
@@ -29,6 +29,8 @@ import { queryRunHistory } from '../run-history.js';
29
29
  import { loadProjectContext, loadProjectState } from '../config.js';
30
30
  import { evaluateApprovalSlaReminders } from '../notification-runner.js';
31
31
  import { readGateActionSnapshot } from './gate-action-reader.js';
32
+ import { readChainReportSnapshot } from './chain-report-reader.js';
33
+ import { readMissionSnapshot } from './mission-reader.js';
32
34
 
33
35
  const MIME_TYPES = {
34
36
  '.html': 'text/html; charset=utf-8',
@@ -462,6 +464,20 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847,
462
464
  return;
463
465
  }
464
466
 
467
+ if (pathname === '/api/chain-reports') {
468
+ const limit = url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit'), 10) : undefined;
469
+ const result = readChainReportSnapshot(workspacePath, { limit });
470
+ writeJson(res, result.status, result.body);
471
+ return;
472
+ }
473
+
474
+ if (pathname === '/api/missions') {
475
+ const limit = url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit'), 10) : undefined;
476
+ const result = readMissionSnapshot(workspacePath, { limit });
477
+ writeJson(res, result.status, result.body);
478
+ return;
479
+ }
480
+
465
481
  if (pathname === '/api/gate-actions') {
466
482
  const result = readGateActionSnapshot(workspacePath);
467
483
  writeJson(res, result.status, result.body);
@@ -0,0 +1,15 @@
1
+ import { loadAllChainReports, loadLatestChainReport } from '../chain-reports.js';
2
+
3
+ export function readChainReportSnapshot(workspacePath, { limit } = {}) {
4
+ const reports = loadAllChainReports(workspacePath);
5
+ const effectiveLimit = Number.isInteger(limit) && limit > 0 ? limit : reports.length;
6
+
7
+ return {
8
+ ok: true,
9
+ status: 200,
10
+ body: {
11
+ latest: loadLatestChainReport(workspacePath),
12
+ reports: reports.slice(0, effectiveLimit),
13
+ },
14
+ };
15
+ }
@@ -8,7 +8,7 @@
8
8
  import { watch, existsSync } from 'fs';
9
9
  import { basename, join } from 'path';
10
10
  import { EventEmitter } from 'events';
11
- import { WATCH_DIRECTORIES, resourceForRelativePath } from './state-reader.js';
11
+ import { WATCH_DIRECTORIES, resourcesForRelativePath } from './state-reader.js';
12
12
 
13
13
  const DEBOUNCE_MS = 100;
14
14
 
@@ -40,24 +40,26 @@ export class FileWatcher extends EventEmitter {
40
40
  if (!filename || this.#closed) return;
41
41
  const base = basename(filename);
42
42
  const relativePath = relativeDir ? `${relativeDir}/${base}` : base;
43
- const resource = resourceForRelativePath(relativePath);
43
+ const resources = resourcesForRelativePath(relativePath);
44
44
 
45
- if (!resource) {
45
+ if (resources.length === 0) {
46
46
  if (!relativeDir && base === 'multirepo') {
47
47
  this.#watchPath('multirepo');
48
48
  }
49
49
  return;
50
50
  }
51
51
 
52
- if (this.#debounceTimers.has(resource)) {
53
- clearTimeout(this.#debounceTimers.get(resource));
54
- }
55
- this.#debounceTimers.set(resource, setTimeout(() => {
56
- this.#debounceTimers.delete(resource);
57
- if (!this.#closed) {
58
- this.emit('invalidate', { resource });
52
+ for (const resource of resources) {
53
+ if (this.#debounceTimers.has(resource)) {
54
+ clearTimeout(this.#debounceTimers.get(resource));
59
55
  }
60
- }, DEBOUNCE_MS));
56
+ this.#debounceTimers.set(resource, setTimeout(() => {
57
+ this.#debounceTimers.delete(resource);
58
+ if (!this.#closed) {
59
+ this.emit('invalidate', { resource });
60
+ }
61
+ }, DEBOUNCE_MS));
62
+ }
61
63
  });
62
64
 
63
65
  watcher.on('error', (err) => {
@@ -0,0 +1,14 @@
1
+ import { buildMissionListSummary, loadLatestMissionSnapshot } from '../missions.js';
2
+
3
+ export function readMissionSnapshot(workspacePath, { limit } = {}) {
4
+ const missions = buildMissionListSummary(workspacePath, limit);
5
+
6
+ return {
7
+ ok: true,
8
+ status: 200,
9
+ body: {
10
+ latest: loadLatestMissionSnapshot(workspacePath),
11
+ missions,
12
+ },
13
+ };
14
+ }
@@ -62,14 +62,28 @@ FILE_TO_RESOURCE[normalizeRelativePath(REPO_DECISIONS_FILE)] = '/api/repo-decisi
62
62
  export const WATCH_DIRECTORIES = [
63
63
  '',
64
64
  MULTIREPO_DIR,
65
+ 'missions',
66
+ 'reports',
65
67
  ];
66
68
 
67
69
  export function normalizeRelativePath(filePath) {
68
70
  return normalize(filePath).replace(/\\/g, '/').replace(/^\.\/+/, '');
69
71
  }
70
72
 
73
+ export function resourcesForRelativePath(filePath) {
74
+ const normalized = normalizeRelativePath(filePath);
75
+ if (normalized.startsWith('missions/') && normalized.endsWith('.json')) {
76
+ return ['/api/missions'];
77
+ }
78
+ if (normalized.startsWith('reports/chain-') && normalized.endsWith('.json')) {
79
+ return ['/api/chain-reports', '/api/missions'];
80
+ }
81
+ return FILE_TO_RESOURCE[normalized] ? [FILE_TO_RESOURCE[normalized]] : [];
82
+ }
83
+
71
84
  export function resourceForRelativePath(filePath) {
72
- return FILE_TO_RESOURCE[normalizeRelativePath(filePath)] || null;
85
+ const resources = resourcesForRelativePath(filePath);
86
+ return resources[0] || null;
73
87
  }
74
88
 
75
89
  /**
@@ -216,6 +216,9 @@ function normalizeRunExport(artifact) {
216
216
  budget_warn_mode: budgetStatus.warn_mode === true,
217
217
  budget_exhausted: budgetStatus.exhausted === true,
218
218
  phase_gate_status: normalizeGateStatusMap(phaseGateStatus),
219
+ blocked_category: state.blocked_reason?.category || null,
220
+ blocked_gate_action_timed_out: state.blocked_reason?.gate_action?.timed_out === true,
221
+ blocked_gate_action_timeout_ms: typeof state.blocked_reason?.gate_action?.timeout_ms === 'number' ? state.blocked_reason.gate_action.timeout_ms : null,
219
222
  delegation_missing_decisions: normalizeDelegationMissingMap(summary.delegation_summary),
220
223
  };
221
224
  }
@@ -563,11 +566,17 @@ function detectRunRegressions(left, right) {
563
566
  const leftGate = (left.phase_gate_status || {})[gateId] || null;
564
567
  const rightGate = (right.phase_gate_status || {})[gateId] || null;
565
568
  if (leftGate && rightGate && GATE_PASSED_STATES.has(leftGate) && GATE_FAILED_STATES.has(rightGate)) {
569
+ let causeDetail = '';
570
+ if (right.blocked_category === 'gate_action_failed') {
571
+ causeDetail = right.blocked_gate_action_timed_out
572
+ ? ` (gate action timed out after ${right.blocked_gate_action_timeout_ms}ms)`
573
+ : ' (gate action failed)';
574
+ }
566
575
  regressions.push({
567
576
  id: `REG-GATE-${String(++counter).padStart(3, '0')}`,
568
577
  category: 'gate',
569
578
  severity: 'error',
570
- message: `Gate "${gateId}" regressed from ${leftGate} to ${rightGate}`,
579
+ message: `Gate "${gateId}" regressed from ${leftGate} to ${rightGate}${causeDetail}`,
571
580
  field: `phase_gate_status.${gateId}`,
572
581
  left: leftGate,
573
582
  right: rightGate,