agentxchain 2.126.0 → 2.128.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 CHANGED
@@ -172,7 +172,8 @@ agentxchain step
172
172
  | `template validate` | Prove the template registry, workflow-kit scaffold contract, and planning artifact completeness (`--json` exposes a `workflow_kit` block) |
173
173
  | `verify turn` | Replay a staged turn's declared machine-evidence commands to confirm reproducibility before acceptance |
174
174
  | `replay turn` | Replay an accepted turn's machine-evidence commands from history for audit and drift detection |
175
- | `verify protocol` | Run the shipped protocol conformance suite against a target implementation |
175
+ | `conformance check` | Preferred front door for the shipped protocol conformance suite |
176
+ | `verify protocol` | Compatibility alias for the shipped protocol conformance suite |
176
177
  | `dashboard` | Open the live local governance dashboard in your browser for the current repo/workspace or multi-repo coordinator initiative, including pending gate approvals |
177
178
  | `run [--auto-approve] [--max-turns N] [--dry-run]` | Drive a governed run from start to completion — dispatches turns, handles gates, manages rejection/retry |
178
179
 
@@ -247,9 +248,11 @@ Partial coordinator artifacts are first-class here too: `audit` and `report` kee
247
248
  AgentXchain ships a conformance kit under `.agentxchain-conformance/`. Use it to prove a runner or fork still implements the governed workflow contract:
248
249
 
249
250
  ```bash
250
- agentxchain verify protocol --tier 3 --target .
251
+ agentxchain conformance check --tier 3 --target .
251
252
  ```
252
253
 
254
+ `agentxchain verify protocol` remains available as a compatibility alias.
255
+
253
256
  Useful flags:
254
257
 
255
258
  - `--tier 1|2|3`: maximum conformance tier to verify
@@ -542,6 +542,22 @@ program
542
542
  .option('-j, --json', 'Output as JSON')
543
543
  .action(validateCommand);
544
544
 
545
+ const conformanceCmd = program
546
+ .command('conformance')
547
+ .description('Run protocol conformance checks against local or remote implementations');
548
+
549
+ conformanceCmd
550
+ .command('check')
551
+ .description('Run the shipped protocol conformance fixture suite against a target implementation')
552
+ .option('--tier <tier>', 'Conformance tier to verify (1, 2, or 3)', '1')
553
+ .option('--surface <surface>', 'Restrict verification to a single surface')
554
+ .option('--target <path>', 'Target root containing .agentxchain-conformance/capabilities.json', '.')
555
+ .option('--remote <url>', 'Remote HTTP conformance endpoint base URL')
556
+ .option('--token <token>', 'Bearer token for remote HTTP conformance endpoint')
557
+ .option('--timeout <ms>', 'Per-fixture remote HTTP timeout in milliseconds', '30000')
558
+ .option('--format <format>', 'Output format: text or json', 'text')
559
+ .action(verifyProtocolCommand);
560
+
545
561
  const verifyCmd = program
546
562
  .command('verify')
547
563
  .description('Verify governed turns, export artifacts, and protocol conformance targets');
@@ -95,6 +95,30 @@ function formatPercent(value) {
95
95
  return `${Math.round(value * 100)}%`;
96
96
  }
97
97
 
98
+ function formatDispatchActivity(progress) {
99
+ if (!progress || typeof progress !== 'object') return null;
100
+ const lastActivityAt = progress.last_activity_at ? new Date(progress.last_activity_at) : null;
101
+ const agoSec = lastActivityAt && !Number.isNaN(lastActivityAt.getTime())
102
+ ? Math.round((Date.now() - lastActivityAt.getTime()) / 1000)
103
+ : null;
104
+
105
+ if (progress.activity_type === 'silent') {
106
+ const silentSince = progress.silent_since ? new Date(progress.silent_since) : null;
107
+ const silentSec = silentSince && !Number.isNaN(silentSince.getTime())
108
+ ? Math.round((Date.now() - silentSince.getTime()) / 1000)
109
+ : agoSec;
110
+ return `Silent for ${silentSec ?? 0}s (${progress.output_lines || 0} lines total, last output ${agoSec ?? 0}s ago)`;
111
+ }
112
+ if (progress.activity_type === 'request') {
113
+ return `API request in flight (${agoSec ?? 0}s ago)`;
114
+ }
115
+ if (progress.activity_type === 'response') {
116
+ return 'API response received';
117
+ }
118
+ const agoLabel = agoSec != null && agoSec > 0 ? `, last ${agoSec}s ago` : '';
119
+ return `Producing output (${progress.output_lines || 0} lines${agoLabel})`;
120
+ }
121
+
98
122
  function statusBadge(status) {
99
123
  const colors = {
100
124
  running: 'var(--green)',
@@ -411,6 +435,9 @@ export function render({ state, continuity, history, events = null, annotations,
411
435
 
412
436
  const turnCount = Array.isArray(history) ? history.length : 0;
413
437
  const activeTurns = state.active_turns ? Object.values(state.active_turns) : [];
438
+ const dispatchProgress = state.dispatch_progress && typeof state.dispatch_progress === 'object'
439
+ ? state.dispatch_progress
440
+ : {};
414
441
 
415
442
  let html = `<div class="timeline-view">`;
416
443
 
@@ -436,6 +463,7 @@ export function render({ state, continuity, history, events = null, annotations,
436
463
  for (const turn of activeTurns) {
437
464
  const elapsedMs = computeElapsed(turn.started_at);
438
465
  const elapsedStr = formatDuration(elapsedMs);
466
+ const activity = formatDispatchActivity(dispatchProgress[turn.turn_id]);
439
467
  html += `<div class="turn-card active">
440
468
  <div class="turn-header">
441
469
  ${roleBadge(getRole(turn))}
@@ -445,6 +473,7 @@ export function render({ state, continuity, history, events = null, annotations,
445
473
  </div>
446
474
  ${renderDelegationContext(turn.delegation_context)}
447
475
  ${renderDelegationReview(turn.delegation_review)}
476
+ ${activity ? `<div class="turn-detail"><span class="detail-label">Activity:</span> ${esc(activity)}</div>` : ''}
448
477
  </div>`;
449
478
  }
450
479
  html += `</div></div>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.126.0",
3
+ "version": "2.128.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -441,10 +441,20 @@ function commandHasPromptPlaceholder(parts = []) {
441
441
  return parts.some((part) => typeof part === 'string' && part.includes('{prompt}'));
442
442
  }
443
443
 
444
+ function normalizeGovernedDevCommand(parts = []) {
445
+ if (!Array.isArray(parts) || parts.length === 0) {
446
+ return null;
447
+ }
448
+ const [head, ...rest] = parts;
449
+ const normalizedHead = String(head || '').trim().split(/\s+/).filter(Boolean);
450
+ const normalizedRest = rest
451
+ .map((part) => String(part).trim())
452
+ .filter(Boolean);
453
+ return [...normalizedHead, ...normalizedRest];
454
+ }
455
+
444
456
  function resolveGovernedLocalDevRuntime(opts = {}) {
445
- const customCommand = Array.isArray(opts.devCommand)
446
- ? opts.devCommand.map((part) => String(part).trim()).filter(Boolean)
447
- : null;
457
+ const customCommand = normalizeGovernedDevCommand(opts.devCommand);
448
458
  const explicitTransport = typeof opts.devPromptTransport === 'string' && opts.devPromptTransport.trim()
449
459
  ? opts.devPromptTransport.trim()
450
460
  : null;
@@ -46,6 +46,8 @@ import {
46
46
  } from '../lib/turn-paths.js';
47
47
  import { resolveChainOptions, executeChainedRun } from '../lib/run-chain.js';
48
48
  import { resolveContinuousOptions, executeContinuousRun } from '../lib/continuous-run.js';
49
+ import { createDispatchProgressTracker } from '../lib/dispatch-progress.js';
50
+ import { emitRunEvent } from '../lib/run-events.js';
49
51
 
50
52
  export async function runCommand(opts) {
51
53
  const context = loadProjectContext();
@@ -191,6 +193,19 @@ export async function executeGovernedRun(context, opts = {}) {
191
193
  const supported = rtType !== 'manual';
192
194
  log(` ${supported ? chalk.green('✓') : chalk.red('✗')} ${rid} → ${rtType}${supported ? '' : ' (not supported in run mode)'}`);
193
195
  }
196
+ // Warn if the first-dispatched role in the current phase is manual
197
+ if (roleId) {
198
+ const firstRole = config.roles?.[roleId];
199
+ const firstRtId = firstRole?.runtime;
200
+ const firstRt = config.runtimes?.[firstRtId];
201
+ const firstRtType = firstRt?.type || firstRole?.runtime_class || 'manual';
202
+ if (firstRtType === 'manual') {
203
+ log('');
204
+ log(chalk.yellow(` ⚠ The current phase's first role (${roleId}) is manual.`));
205
+ log(chalk.yellow(` "run" will block immediately. Complete manual turns via "agentxchain step" first,`));
206
+ log(chalk.yellow(` or configure ${roleId} with an automatable runtime.`));
207
+ }
208
+ }
194
209
  return { exitCode: 0, result: null };
195
210
  }
196
211
 
@@ -299,6 +314,10 @@ export async function executeGovernedRun(context, opts = {}) {
299
314
  }
300
315
 
301
316
  // ── Route to adapter ──────────────────────────────────────────────
317
+ const tracker = createDispatchProgressTracker(projectRoot, turn, {
318
+ adapter_type: runtimeType,
319
+ });
320
+
302
321
  const adapterOpts = {
303
322
  signal: combineAbortSignals(controller.signal, ctx.dispatchAbortSignal),
304
323
  onStatus: (msg) => log(chalk.dim(` ${msg}`)),
@@ -306,31 +325,103 @@ export async function executeGovernedRun(context, opts = {}) {
306
325
  turnId: turn.turn_id,
307
326
  };
308
327
 
328
+ const recordOutputActivity = (stream, text) => {
329
+ const lines = text.split('\n').length - 1 || 1;
330
+ const wasSilent = tracker.onOutput(stream, lines);
331
+ if (wasSilent) {
332
+ const progressState = tracker.getState();
333
+ emitRunEvent(projectRoot, 'dispatch_progress', {
334
+ run_id: state.run_id,
335
+ phase: state.phase,
336
+ status: state.status,
337
+ turn: { turn_id: turn.turn_id, assigned_role: roleId },
338
+ payload: {
339
+ milestone: 'output_resumed',
340
+ output_lines: progressState.output_lines,
341
+ elapsed_seconds: Math.round((Date.now() - new Date(progressState.started_at)) / 1000),
342
+ silent_seconds: 0,
343
+ },
344
+ });
345
+ }
346
+ };
347
+
309
348
  if (verbose) {
310
- adapterOpts.onStdout = (text) => process.stdout.write(chalk.dim(text));
311
- adapterOpts.onStderr = (text) => process.stderr.write(chalk.yellow(text));
349
+ adapterOpts.onStdout = (text) => {
350
+ process.stdout.write(chalk.dim(text));
351
+ recordOutputActivity('stdout', text);
352
+ };
353
+ adapterOpts.onStderr = (text) => {
354
+ process.stderr.write(chalk.yellow(text));
355
+ recordOutputActivity('stderr', text);
356
+ };
357
+ } else {
358
+ // Even in non-verbose mode, track output activity for progress visibility
359
+ adapterOpts.onStdout = (text) => {
360
+ recordOutputActivity('stdout', text);
361
+ };
362
+ adapterOpts.onStderr = (text) => {
363
+ recordOutputActivity('stderr', text);
364
+ };
312
365
  }
313
366
 
314
367
  let adapterResult;
315
368
 
316
- if (runtimeType === 'api_proxy') {
317
- log(chalk.dim(` Dispatching to API proxy: ${runtime?.provider || '?'} / ${runtime?.model || '?'}`));
318
- adapterResult = await dispatchApiProxy(projectRoot, state, cfg, adapterOpts);
319
- } else if (runtimeType === 'mcp') {
320
- const transport = resolveMcpTransport(runtime);
321
- log(chalk.dim(` Dispatching to MCP ${transport}: ${describeMcpRuntimeTarget(runtime)}`));
322
- adapterResult = await dispatchMcp(projectRoot, state, cfg, adapterOpts);
323
- } else if (runtimeType === 'local_cli') {
324
- const transport = runtime ? resolvePromptTransport(runtime) : 'dispatch_bundle_only';
325
- log(chalk.dim(` Dispatching to local CLI: ${runtime?.command || '(default)'} transport: ${transport}`));
326
- adapterResult = await dispatchLocalCli(projectRoot, state, cfg, adapterOpts);
327
- } else if (runtimeType === 'remote_agent') {
328
- log(chalk.dim(` Dispatching to remote agent: ${describeRemoteAgentTarget(runtime)}`));
329
- adapterResult = await dispatchRemoteAgent(projectRoot, state, cfg, adapterOpts);
330
- } else {
331
- return { accept: false, reason: `unknown runtime type "${runtimeType}"` };
369
+ // Emit dispatch_progress started event and begin tracking
370
+ tracker.start();
371
+ emitRunEvent(projectRoot, 'dispatch_progress', {
372
+ run_id: state.run_id,
373
+ phase: state.phase,
374
+ status: state.status,
375
+ turn: { turn_id: turn.turn_id, assigned_role: roleId },
376
+ payload: { milestone: 'started', output_lines: 0, elapsed_seconds: 0, silent_seconds: 0 },
377
+ });
378
+
379
+ try {
380
+ if (runtimeType === 'api_proxy') {
381
+ log(chalk.dim(` Dispatching to API proxy: ${runtime?.provider || '?'} / ${runtime?.model || '?'}`));
382
+ tracker.requestStarted();
383
+ adapterResult = await dispatchApiProxy(projectRoot, state, cfg, adapterOpts);
384
+ if (adapterResult.ok) tracker.responseReceived();
385
+ } else if (runtimeType === 'mcp') {
386
+ const transport = resolveMcpTransport(runtime);
387
+ log(chalk.dim(` Dispatching to MCP ${transport}: ${describeMcpRuntimeTarget(runtime)}`));
388
+ tracker.requestStarted();
389
+ adapterResult = await dispatchMcp(projectRoot, state, cfg, adapterOpts);
390
+ if (adapterResult.ok) tracker.responseReceived();
391
+ } else if (runtimeType === 'local_cli') {
392
+ const transport = runtime ? resolvePromptTransport(runtime) : 'dispatch_bundle_only';
393
+ log(chalk.dim(` Dispatching to local CLI: ${runtime?.command || '(default)'} transport: ${transport}`));
394
+ adapterResult = await dispatchLocalCli(projectRoot, state, cfg, adapterOpts);
395
+ } else if (runtimeType === 'remote_agent') {
396
+ log(chalk.dim(` Dispatching to remote agent: ${describeRemoteAgentTarget(runtime)}`));
397
+ tracker.requestStarted();
398
+ adapterResult = await dispatchRemoteAgent(projectRoot, state, cfg, adapterOpts);
399
+ if (adapterResult.ok) tracker.responseReceived();
400
+ } else {
401
+ tracker.fail();
402
+ return { accept: false, reason: `unknown runtime type "${runtimeType}"` };
403
+ }
404
+ } catch (err) {
405
+ tracker.fail();
406
+ emitRunEvent(projectRoot, 'dispatch_progress', {
407
+ run_id: state.run_id, phase: state.phase, status: state.status,
408
+ turn: { turn_id: turn.turn_id, assigned_role: roleId },
409
+ payload: { milestone: 'failed', output_lines: tracker.getState().output_lines, elapsed_seconds: Math.round((Date.now() - new Date(tracker.getState().started_at)) / 1000), silent_seconds: 0 },
410
+ });
411
+ throw err;
332
412
  }
333
413
 
414
+ // Emit completion/failure progress event and clean up tracker
415
+ const progressState = tracker.getState();
416
+ const elapsedSec = Math.round((Date.now() - new Date(progressState.started_at)) / 1000);
417
+ const milestone = adapterResult.ok ? 'completed' : (adapterResult.timedOut ? 'timed_out' : 'failed');
418
+ if (adapterResult.ok) { tracker.complete(); } else { tracker.fail(); }
419
+ emitRunEvent(projectRoot, 'dispatch_progress', {
420
+ run_id: state.run_id, phase: state.phase, status: state.status,
421
+ turn: { turn_id: turn.turn_id, assigned_role: roleId },
422
+ payload: { milestone, output_lines: progressState.output_lines, elapsed_seconds: elapsedSec, silent_seconds: progressState.silent_since ? Math.round((Date.now() - new Date(progressState.silent_since)) / 1000) : 0 },
423
+ });
424
+
334
425
  // Save adapter logs
335
426
  if (adapterResult.logs?.length) {
336
427
  saveDispatchLogs(projectRoot, turn.turn_id, adapterResult.logs);
@@ -23,6 +23,7 @@ import { findCurrentHumanEscalation } from '../lib/human-escalations.js';
23
23
  import { getDashboardPid, getDashboardSession } from './dashboard.js';
24
24
  import { readPreemptionMarker } from '../lib/intake.js';
25
25
  import { readContinuousSession } from '../lib/continuous-run.js';
26
+ import { readAllDispatchProgress } from '../lib/dispatch-progress.js';
26
27
 
27
28
  export async function statusCommand(opts) {
28
29
  const context = loadStatusContext();
@@ -142,6 +143,9 @@ function renderGovernedStatus(context, opts) {
142
143
  // Fire approval SLA reminders as a side effect (webhook-only, no CLI output)
143
144
  evaluateApprovalSlaReminders(root, config, state);
144
145
 
146
+ const activeTurns = getActiveTurns(state);
147
+ const dispatchProgress = filterDispatchProgressForActiveTurns(readAllDispatchProgress(root), activeTurns);
148
+
145
149
  if (opts.json) {
146
150
  const dashPid = getDashboardPid(root);
147
151
  const dashSession = getDashboardSession(root);
@@ -168,6 +172,7 @@ function renderGovernedStatus(context, opts) {
168
172
  next_actions: nextActions,
169
173
  connector_health: connectorHealth,
170
174
  recent_event_summary: recentEventSummary,
175
+ dispatch_progress: dispatchProgress,
171
176
  human_escalation: humanEscalation,
172
177
  preemption_marker: preemptionMarker,
173
178
  continuous_session: continuousSession,
@@ -262,7 +267,6 @@ function renderGovernedStatus(context, opts) {
262
267
  renderRecentEventSummary(recentEventSummary);
263
268
 
264
269
  const activeTurnCount = getActiveTurnCount(state);
265
- const activeTurns = getActiveTurns(state);
266
270
  const singleActiveTurn = getActiveTurn(state);
267
271
  const approvalPending = Boolean(state?.pending_phase_transition || state?.pending_run_completion);
268
272
  if (activeTurnCount > 1) {
@@ -296,6 +300,10 @@ function renderGovernedStatus(context, opts) {
296
300
  }
297
301
  }
298
302
  console.log(` ${marker} ${turn.turn_id} — ${chalk.bold(turn.assigned_role)} (${statusLabel}) [attempt ${turn.attempt}]${elapsedTag}${budgetTag}`);
303
+ const activityLine = formatDispatchActivityLine(dispatchProgress[turn.turn_id]);
304
+ if (activityLine) {
305
+ console.log(` ${chalk.dim('Activity:')} ${activityLine}`);
306
+ }
299
307
  if (turn.status === 'conflicted' && turn.conflict_state) {
300
308
  const cs = turn.conflict_state;
301
309
  const files = cs.conflict_error?.conflicting_files || [];
@@ -341,6 +349,11 @@ function renderGovernedStatus(context, opts) {
341
349
  }
342
350
  }
343
351
  }
352
+ // Dispatch progress activity line (DEC-DISPATCH-PROGRESS-001)
353
+ const activityLine = formatDispatchActivityLine(dispatchProgress[singleActiveTurn.turn_id]);
354
+ if (activityLine) {
355
+ console.log(` ${chalk.dim('Activity:')} ${activityLine}`);
356
+ }
344
357
  if (singleActiveTurn.status === 'conflicted' && singleActiveTurn.conflict_state) {
345
358
  const cs = singleActiveTurn.conflict_state;
346
359
  const files = cs.conflict_error?.conflicting_files || [];
@@ -743,6 +756,45 @@ function pluralizeRepoDecisionCount(count, singular, plural) {
743
756
  return `${count} ${count === 1 ? singular : plural}`;
744
757
  }
745
758
 
759
+ function filterDispatchProgressForActiveTurns(progressByTurn, activeTurns) {
760
+ const filtered = {};
761
+ if (!progressByTurn || typeof progressByTurn !== 'object') {
762
+ return filtered;
763
+ }
764
+ for (const turn of Object.values(activeTurns || {})) {
765
+ const turnId = turn?.turn_id;
766
+ if (turnId && progressByTurn[turnId]) {
767
+ filtered[turnId] = progressByTurn[turnId];
768
+ }
769
+ }
770
+ return filtered;
771
+ }
772
+
773
+ function formatDispatchActivityLine(progress) {
774
+ if (!progress || typeof progress !== 'object') return null;
775
+ const lastAct = progress.last_activity_at ? new Date(progress.last_activity_at) : null;
776
+ const agoSec = lastAct && !Number.isNaN(lastAct.getTime())
777
+ ? Math.round((Date.now() - lastAct.getTime()) / 1000)
778
+ : null;
779
+
780
+ if (progress.activity_type === 'silent') {
781
+ const silentAt = progress.silent_since ? new Date(progress.silent_since) : null;
782
+ const silentSec = silentAt && !Number.isNaN(silentAt.getTime())
783
+ ? Math.round((Date.now() - silentAt.getTime()) / 1000)
784
+ : agoSec;
785
+ return chalk.yellow(`Silent for ${silentSec}s`) +
786
+ ` (${progress.output_lines || 0} lines total, last output ${agoSec ?? 0}s ago)`;
787
+ }
788
+ if (progress.activity_type === 'request') {
789
+ return chalk.cyan('API request in flight') + ` (${agoSec ?? 0}s ago)`;
790
+ }
791
+ if (progress.activity_type === 'response') {
792
+ return chalk.green('API response received');
793
+ }
794
+ const agoLabel = agoSec != null && agoSec > 0 ? `, last ${agoSec}s ago` : '';
795
+ return chalk.green('Producing output') + ` (${progress.output_lines || 0} lines${agoLabel})`;
796
+ }
797
+
746
798
  function renderLastGateFailure(failure, config) {
747
799
  const entryRole = config?.routing?.[failure.phase]?.entry_role || null;
748
800
  const suggestedCommand = entryRole ? `agentxchain step --role ${entryRole}` : 'agentxchain step --role <role>';
@@ -278,7 +278,13 @@ function resolveCommand(runtime, fullPrompt) {
278
278
 
279
279
  // Shape 1: command is an array
280
280
  if (Array.isArray(runtime.command)) {
281
- const [cmd, ...rest] = runtime.command;
281
+ // Normalize: if the first element contains spaces (e.g., ["echo test"]), split it
282
+ // into binary + args. Only the first element is split — later elements may contain
283
+ // legitimate spaces (e.g., script text for `node -e "..."`).
284
+ const first = runtime.command[0] || '';
285
+ const headParts = typeof first === 'string' && first.includes(' ') ? first.split(/\s+/) : [first];
286
+ const [cmd, ...headArgs] = headParts;
287
+ const rest = [...headArgs, ...runtime.command.slice(1)];
282
288
  const args = transport === 'argv'
283
289
  ? rest.map(arg => arg === '{prompt}' ? fullPrompt : arg)
284
290
  : rest.filter(arg => arg !== '{prompt}');
@@ -38,7 +38,9 @@ function formatTarget(runtime) {
38
38
 
39
39
  function commandHead(runtime) {
40
40
  if (Array.isArray(runtime?.command)) {
41
- return runtime.command[0] || null;
41
+ const first = runtime.command[0] || null;
42
+ if (first && first.includes(' ')) return first.split(/\s+/)[0];
43
+ return first;
42
44
  }
43
45
  if (typeof runtime?.command === 'string' && runtime.command.trim()) {
44
46
  return runtime.command.trim().split(/\s+/)[0];
@@ -15,6 +15,7 @@ import {
15
15
  import { loadProjectContext } from '../config.js';
16
16
  import { getContinuityStatus } from '../continuity-status.js';
17
17
  import { readRepoDecisions, summarizeRepoDecisions } from '../repo-decisions.js';
18
+ import { readAllDispatchProgress } from '../dispatch-progress.js';
18
19
 
19
20
  const STATE_FILE = 'state.json';
20
21
  const SESSION_FILE = 'session.json';
@@ -80,6 +81,9 @@ export function normalizeRelativePath(filePath) {
80
81
 
81
82
  export function resourcesForRelativePath(filePath) {
82
83
  const normalized = normalizeRelativePath(filePath);
84
+ if (/^dispatch-progress-[^/]+\.json$/.test(normalized)) {
85
+ return ['/api/state'];
86
+ }
83
87
  if (normalized.startsWith('missions/plans/') && normalized.endsWith('.json')) {
84
88
  return ['/api/plans', '/api/missions'];
85
89
  }
@@ -136,6 +140,7 @@ function enrichGovernedState(agentxchainDir, state) {
136
140
  ...state,
137
141
  runtime_guidance: deriveRuntimeBlockedGuidance(state, context.config),
138
142
  next_actions: deriveGovernedRunNextActions(state, context.config),
143
+ dispatch_progress: readAllDispatchProgress(workspacePath),
139
144
  };
140
145
  }
141
146
 
@@ -0,0 +1,298 @@
1
+ /**
2
+ * dispatch-progress.js — Real-time adapter dispatch progress tracking.
3
+ *
4
+ * Writes `.agentxchain/dispatch-progress.json` during in-flight adapter
5
+ * dispatch so operators can distinguish "adapter is working" from "adapter
6
+ * is hung" via `agentxchain status` and the dashboard file-watcher.
7
+ *
8
+ * DEC-DISPATCH-PROGRESS-001: progress writes are best-effort and never
9
+ * block or delay the governed turn.
10
+ */
11
+
12
+ import { writeFileSync, unlinkSync, readFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
13
+ import { join, dirname, basename } from 'node:path';
14
+
15
+ export const LEGACY_DISPATCH_PROGRESS_PATH = '.agentxchain/dispatch-progress.json';
16
+ export const DISPATCH_PROGRESS_FILE_PREFIX = '.agentxchain/dispatch-progress-';
17
+
18
+ export function getDispatchProgressRelativePath(turnId) {
19
+ return `${DISPATCH_PROGRESS_FILE_PREFIX}${turnId}.json`;
20
+ }
21
+
22
+ function getDispatchProgressFilePath(root, turnId) {
23
+ return join(root, getDispatchProgressRelativePath(turnId));
24
+ }
25
+
26
+ function listDispatchProgressFiles(root) {
27
+ const agentxchainDir = join(root, '.agentxchain');
28
+ if (!existsSync(agentxchainDir)) return [];
29
+ try {
30
+ return readdirSync(agentxchainDir)
31
+ .filter((entry) => entry.startsWith('dispatch-progress-') && entry.endsWith('.json'))
32
+ .map((entry) => join(agentxchainDir, entry));
33
+ } catch {
34
+ return [];
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Create a dispatch progress tracker for a single turn.
40
+ *
41
+ * Usage:
42
+ * const tracker = createDispatchProgressTracker(root, turn, runtime);
43
+ * tracker.start();
44
+ * // ... during dispatch:
45
+ * tracker.onOutput('stdout', lineCount);
46
+ * tracker.onOutput('stderr', lineCount);
47
+ * // ... when done:
48
+ * tracker.complete(); // or tracker.fail();
49
+ *
50
+ * @param {string} root - project root
51
+ * @param {object} turn - turn object with turn_id, runtime_id, assigned_role
52
+ * @param {object} options
53
+ * @param {string} options.adapter_type - 'local_cli' | 'api_proxy' | 'mcp' | 'remote_agent'
54
+ * @param {number} [options.pid] - subprocess PID (local_cli only)
55
+ * @param {number} [options.writeIntervalMs=1000] - min interval between file writes
56
+ * @param {number} [options.silenceThresholdMs=30000] - silence detection threshold
57
+ * @returns {DispatchProgressTracker}
58
+ */
59
+ export function createDispatchProgressTracker(root, turn, options = {}) {
60
+ const {
61
+ adapter_type = 'local_cli',
62
+ pid = null,
63
+ writeIntervalMs = 1000,
64
+ silenceThresholdMs = 30000,
65
+ } = options;
66
+
67
+ const filePath = getDispatchProgressFilePath(root, turn.turn_id);
68
+
69
+ let state = {
70
+ turn_id: turn.turn_id,
71
+ runtime_id: turn.runtime_id || null,
72
+ adapter_type,
73
+ started_at: null,
74
+ last_activity_at: null,
75
+ activity_type: 'output',
76
+ activity_summary: 'Dispatch starting',
77
+ output_lines: 0,
78
+ stderr_lines: 0,
79
+ silent_since: null,
80
+ pid,
81
+ };
82
+
83
+ let lastWriteAt = 0;
84
+ let silenceTimer = null;
85
+ let dirty = false;
86
+
87
+ function writeProgress() {
88
+ try {
89
+ const dir = dirname(filePath);
90
+ if (!existsSync(dir)) {
91
+ mkdirSync(dir, { recursive: true });
92
+ }
93
+ writeFileSync(filePath, JSON.stringify(state, null, 2) + '\n');
94
+ lastWriteAt = Date.now();
95
+ dirty = false;
96
+ } catch {
97
+ // Best-effort — never interrupt dispatch.
98
+ }
99
+ }
100
+
101
+ function maybeWrite() {
102
+ if (!dirty) return;
103
+ const now = Date.now();
104
+ if (now - lastWriteAt >= writeIntervalMs) {
105
+ writeProgress();
106
+ }
107
+ }
108
+
109
+ function resetSilenceTimer() {
110
+ if (silenceTimer) clearTimeout(silenceTimer);
111
+ silenceTimer = setTimeout(() => {
112
+ state.activity_type = 'silent';
113
+ state.silent_since = state.silent_since || new Date().toISOString();
114
+ state.activity_summary = `No output for ${Math.round(silenceThresholdMs / 1000)}s`;
115
+ dirty = true;
116
+ writeProgress();
117
+ }, silenceThresholdMs);
118
+ }
119
+
120
+ return {
121
+ /** Start tracking — call once at dispatch start. */
122
+ start() {
123
+ const now = new Date().toISOString();
124
+ state.started_at = now;
125
+ state.last_activity_at = now;
126
+ state.activity_type = 'output';
127
+ state.activity_summary = 'Subprocess started';
128
+ dirty = true;
129
+ writeProgress();
130
+ if (adapter_type === 'local_cli') {
131
+ resetSilenceTimer();
132
+ }
133
+ },
134
+
135
+ /** Record output activity from the subprocess. */
136
+ onOutput(stream, lineCount = 1) {
137
+ const now = new Date().toISOString();
138
+ const wasSilent = state.activity_type === 'silent';
139
+ state.last_activity_at = now;
140
+ state.activity_type = 'output';
141
+ state.silent_since = null;
142
+ if (stream === 'stderr') {
143
+ state.stderr_lines += lineCount;
144
+ } else {
145
+ state.output_lines += lineCount;
146
+ }
147
+ state.activity_summary = `Producing output (${state.output_lines} lines)`;
148
+ dirty = true;
149
+ maybeWrite();
150
+ if (adapter_type === 'local_cli') {
151
+ resetSilenceTimer();
152
+ }
153
+ return wasSilent; // caller can use this to emit a "resumed" event
154
+ },
155
+
156
+ /** Mark as API request in flight (api_proxy, mcp, remote_agent). */
157
+ requestStarted() {
158
+ state.activity_type = 'request';
159
+ state.activity_summary = 'API request in flight';
160
+ state.last_activity_at = new Date().toISOString();
161
+ dirty = true;
162
+ writeProgress();
163
+ },
164
+
165
+ /** Mark API response received. */
166
+ responseReceived() {
167
+ state.activity_type = 'response';
168
+ state.activity_summary = 'API response received';
169
+ state.last_activity_at = new Date().toISOString();
170
+ dirty = true;
171
+ writeProgress();
172
+ },
173
+
174
+ /** Update PID after spawn (local_cli). */
175
+ setPid(newPid) {
176
+ state.pid = newPid;
177
+ dirty = true;
178
+ maybeWrite();
179
+ },
180
+
181
+ /** Get current progress state snapshot. */
182
+ getState() {
183
+ return { ...state };
184
+ },
185
+
186
+ /** Clean up — dispatch completed successfully. */
187
+ complete() {
188
+ if (silenceTimer) clearTimeout(silenceTimer);
189
+ deleteProgressFile(root, turn.turn_id);
190
+ },
191
+
192
+ /** Clean up — dispatch failed. */
193
+ fail() {
194
+ if (silenceTimer) clearTimeout(silenceTimer);
195
+ deleteProgressFile(root, turn.turn_id);
196
+ },
197
+
198
+ /** Clean up timers without deleting file (for abort paths). */
199
+ dispose() {
200
+ if (silenceTimer) clearTimeout(silenceTimer);
201
+ },
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Delete the dispatch progress file.
207
+ * @param {string} root - project root
208
+ */
209
+ export function deleteProgressFile(root, turnId = null) {
210
+ try {
211
+ if (turnId) {
212
+ const filePath = getDispatchProgressFilePath(root, turnId);
213
+ if (existsSync(filePath)) {
214
+ unlinkSync(filePath);
215
+ }
216
+ return;
217
+ }
218
+
219
+ const legacyPath = join(root, LEGACY_DISPATCH_PROGRESS_PATH);
220
+ if (existsSync(legacyPath)) {
221
+ unlinkSync(legacyPath);
222
+ }
223
+ for (const filePath of listDispatchProgressFiles(root)) {
224
+ if (existsSync(filePath)) {
225
+ unlinkSync(filePath);
226
+ }
227
+ }
228
+ } catch {
229
+ // Best-effort.
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Read the current dispatch progress file.
235
+ * Returns null if no file exists or it's malformed.
236
+ *
237
+ * @param {string} root - project root
238
+ * @returns {object|null}
239
+ */
240
+ export function readDispatchProgress(root, turnId = null) {
241
+ try {
242
+ let filePath;
243
+ if (turnId) {
244
+ filePath = getDispatchProgressFilePath(root, turnId);
245
+ if (!existsSync(filePath)) return null;
246
+ } else {
247
+ const files = listDispatchProgressFiles(root);
248
+ if (files.length === 0) {
249
+ const legacyPath = join(root, LEGACY_DISPATCH_PROGRESS_PATH);
250
+ if (!existsSync(legacyPath)) return null;
251
+ filePath = legacyPath;
252
+ } else if (files.length === 1) {
253
+ filePath = files[0];
254
+ } else {
255
+ return null;
256
+ }
257
+ }
258
+ const raw = readFileSync(filePath, 'utf8');
259
+ const data = JSON.parse(raw);
260
+ if (!data.turn_id || !data.started_at) return null;
261
+ return data;
262
+ } catch {
263
+ return null;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Read all current per-turn dispatch progress files.
269
+ *
270
+ * @param {string} root - project root
271
+ * @returns {Record<string, object>}
272
+ */
273
+ export function readAllDispatchProgress(root) {
274
+ const progressByTurn = {};
275
+
276
+ for (const filePath of listDispatchProgressFiles(root)) {
277
+ try {
278
+ const raw = readFileSync(filePath, 'utf8');
279
+ const data = JSON.parse(raw);
280
+ const turnId = typeof data?.turn_id === 'string' && data.turn_id.length > 0
281
+ ? data.turn_id
282
+ : basename(filePath).replace(/^dispatch-progress-/, '').replace(/\.json$/, '');
283
+ if (!turnId || !data?.started_at) continue;
284
+ progressByTurn[turnId] = data;
285
+ } catch {
286
+ // Ignore malformed files.
287
+ }
288
+ }
289
+
290
+ if (Object.keys(progressByTurn).length === 0) {
291
+ const legacy = readDispatchProgress(root);
292
+ if (legacy?.turn_id) {
293
+ progressByTurn[legacy.turn_id] = legacy;
294
+ }
295
+ }
296
+
297
+ return progressByTurn;
298
+ }
@@ -24,6 +24,7 @@ import { join } from 'path';
24
24
 
25
25
  const OPERATIONAL_PATH_PREFIXES = [
26
26
  '.agentxchain/dispatch/',
27
+ '.agentxchain/dispatch-progress-',
27
28
  '.agentxchain/staging/',
28
29
  '.agentxchain/intake/',
29
30
  '.agentxchain/locks/',
@@ -28,6 +28,7 @@ export const VALID_RUN_EVENTS = [
28
28
  'budget_exceeded_warn',
29
29
  'human_escalation_raised',
30
30
  'human_escalation_resolved',
31
+ 'dispatch_progress',
31
32
  ];
32
33
 
33
34
  /**