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 +5 -2
- package/bin/agentxchain.js +16 -0
- package/dashboard/components/timeline.js +29 -0
- package/package.json +1 -1
- package/src/commands/init.js +13 -3
- package/src/commands/run.js +109 -18
- package/src/commands/status.js +53 -1
- package/src/lib/adapters/local-cli-adapter.js +7 -1
- package/src/lib/connector-probe.js +3 -1
- package/src/lib/dashboard/state-reader.js +5 -0
- package/src/lib/dispatch-progress.js +298 -0
- package/src/lib/repo-observer.js +1 -0
- package/src/lib/run-events.js +1 -0
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
|
-
| `
|
|
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
|
|
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
|
package/bin/agentxchain.js
CHANGED
|
@@ -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
package/src/commands/init.js
CHANGED
|
@@ -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 =
|
|
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;
|
package/src/commands/run.js
CHANGED
|
@@ -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) =>
|
|
311
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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);
|
package/src/commands/status.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lib/repo-observer.js
CHANGED