agentxchain 2.124.0 → 2.126.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.
@@ -121,7 +121,7 @@ import { historyCommand } from '../src/commands/history.js';
121
121
  import { decisionsCommand } from '../src/commands/decisions.js';
122
122
  import { diffCommand } from '../src/commands/diff.js';
123
123
  import { eventsCommand } from '../src/commands/events.js';
124
- import { connectorCheckCommand } from '../src/commands/connector.js';
124
+ import { connectorCheckCommand, connectorValidateCommand } from '../src/commands/connector.js';
125
125
  import { scheduleDaemonCommand, scheduleListCommand, scheduleRunDueCommand, scheduleStatusCommand } from '../src/commands/schedule.js';
126
126
  import { chainLatestCommand, chainListCommand, chainShowCommand } from '../src/commands/chain.js';
127
127
  import { missionAttachChainCommand, missionListCommand, missionPlanApproveCommand, missionPlanAutopilotCommand, missionPlanCommand, missionPlanLaunchCommand, missionPlanListCommand, missionPlanShowCommand, missionShowCommand, missionStartCommand } from '../src/commands/mission.js';
@@ -297,6 +297,15 @@ connectorCmd
297
297
  .option('--timeout <ms>', 'Per-probe timeout in milliseconds', '8000')
298
298
  .action(connectorCheckCommand);
299
299
 
300
+ connectorCmd
301
+ .command('validate <runtime_id>')
302
+ .description('Dispatch one synthetic governed turn through a runtime and validate the staged turn result')
303
+ .option('--role <role_id>', 'Validate a specific role binding for the runtime')
304
+ .option('-j, --json', 'Output as JSON')
305
+ .option('--timeout <ms>', 'Synthetic dispatch timeout in milliseconds', '120000')
306
+ .option('--keep-artifacts', 'Keep the scratch validation workspace even on success')
307
+ .action(connectorValidateCommand);
308
+
300
309
  program
301
310
  .command('demo')
302
311
  .description('Run a complete governed lifecycle demo (no API keys required)')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.124.0",
3
+ "version": "2.126.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
 
3
3
  import { loadProjectContext } from '../lib/config.js';
4
+ import { DEFAULT_VALIDATE_TIMEOUT_MS, validateConfiguredConnector } from '../lib/connector-validate.js';
4
5
  import { DEFAULT_TIMEOUT_MS, probeConfiguredConnectors } from '../lib/connector-probe.js';
5
6
 
6
7
  function printJson(result, exitCode) {
@@ -120,3 +121,95 @@ export async function connectorCheckCommand(runtimeId, options = {}) {
120
121
 
121
122
  printText(payload, result.exitCode);
122
123
  }
124
+
125
+ function printValidateText(result, exitCode) {
126
+ console.log('');
127
+ console.log(chalk.bold(' AgentXchain Connector Validate'));
128
+ console.log(chalk.dim(' ' + '─'.repeat(47)));
129
+ console.log(` ${chalk.dim(`Runtime:`)} ${result.runtime_id} (${result.runtime_type})`);
130
+ console.log(` ${chalk.dim(`Role:`)} ${result.role_id}`);
131
+ console.log(` ${chalk.dim(`Timeout:`)} ${result.timeout_ms}ms`);
132
+ console.log('');
133
+
134
+ const badge = result.overall === 'pass'
135
+ ? chalk.green('PASS')
136
+ : result.overall === 'error'
137
+ ? chalk.red('ERROR')
138
+ : chalk.red('FAIL');
139
+ const summary = result.overall === 'pass'
140
+ ? 'Synthetic governed dispatch produced a valid turn result'
141
+ : (result.error || result.dispatch?.error || result.validation?.errors?.[0] || 'Connector validation failed');
142
+ console.log(` ${badge} ${summary}`);
143
+
144
+ if (Array.isArray(result.warnings) && result.warnings.length > 0) {
145
+ console.log('');
146
+ for (const warning of result.warnings) {
147
+ console.log(` ${chalk.yellow('!')} ${warning}`);
148
+ }
149
+ }
150
+
151
+ if (result.dispatch) {
152
+ console.log('');
153
+ console.log(` ${chalk.dim('Dispatch:')} ${result.dispatch.ok ? chalk.green('ok') : chalk.red('failed')}`);
154
+ if (result.dispatch.error) {
155
+ console.log(` ${chalk.dim('Detail:')} ${result.dispatch.error}`);
156
+ }
157
+ }
158
+
159
+ if (result.validation) {
160
+ console.log(` ${chalk.dim('Validator:')} ${result.validation.ok ? chalk.green('ok') : chalk.red(result.validation.stage || 'failed')}`);
161
+ if (Array.isArray(result.validation.errors) && result.validation.errors.length > 0) {
162
+ console.log(` ${chalk.dim('Errors:')} ${result.validation.errors.join(' | ')}`);
163
+ }
164
+ }
165
+
166
+ if (typeof result.cost_usd === 'number') {
167
+ console.log(` ${chalk.dim('Cost:')} $${result.cost_usd.toFixed(3)}`);
168
+ }
169
+
170
+ if (result.scratch_root) {
171
+ console.log('');
172
+ console.log(` ${chalk.dim('Scratch:')} ${result.scratch_root}`);
173
+ }
174
+
175
+ console.log('');
176
+ process.exit(exitCode);
177
+ }
178
+
179
+ export async function connectorValidateCommand(runtimeId, options = {}) {
180
+ const context = loadProjectContext();
181
+ if (!context) {
182
+ const payload = { overall: 'error', error: 'No governed agentxchain.json found.' };
183
+ if (options.json) {
184
+ printJson(payload, 2);
185
+ return;
186
+ }
187
+ console.error(chalk.red('No governed agentxchain.json found. Run this inside a governed project.'));
188
+ process.exit(2);
189
+ }
190
+
191
+ const timeoutMs = Number.parseInt(options.timeout || DEFAULT_VALIDATE_TIMEOUT_MS, 10);
192
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
193
+ const payload = { overall: 'error', error: 'Timeout must be a positive integer.' };
194
+ if (options.json) {
195
+ printJson(payload, 2);
196
+ return;
197
+ }
198
+ console.error(chalk.red('Timeout must be a positive integer.'));
199
+ process.exit(2);
200
+ }
201
+
202
+ const result = await validateConfiguredConnector(context.root, {
203
+ runtimeId,
204
+ roleId: options.role || null,
205
+ timeoutMs,
206
+ keepArtifacts: options.keepArtifacts === true,
207
+ });
208
+
209
+ if (options.json) {
210
+ printJson(result, result.exitCode ?? 1);
211
+ return;
212
+ }
213
+
214
+ printValidateText(result, result.exitCode ?? 1);
215
+ }
@@ -423,7 +423,7 @@ function getConnectorProbeRecommendation(runtimes) {
423
423
  return {
424
424
  recommended: true,
425
425
  runtimeIds,
426
- detail: 'run `agentxchain connector check` to live-probe api / remote HTTP runtimes before the first governed turn.',
426
+ detail: 'run `agentxchain connector check` to live-probe api / remote HTTP runtimes, then `agentxchain connector validate <runtime_id>` to prove one binding produces valid governed turn results.',
427
427
  };
428
428
  }
429
429
 
@@ -990,6 +990,10 @@ async function initGoverned(opts) {
990
990
  console.log(` ${chalk.bold('agentxchain doctor')} ${chalk.dim('# verify runtimes, config, and readiness')}`);
991
991
  if (hasLiveConnectorProbe) {
992
992
  console.log(` ${chalk.bold('agentxchain connector check')} ${chalk.dim('# live-probe configured runtimes before the first turn')}`);
993
+ const firstNonManualRtId = Object.entries(config.runtimes || {}).find(([, rt]) => rt?.type && rt.type !== 'manual')?.[0];
994
+ if (firstNonManualRtId) {
995
+ console.log(` ${chalk.bold(`agentxchain connector validate ${firstNonManualRtId}`)} ${chalk.dim('# prove the runtime produces valid governed turn results')}`);
996
+ }
993
997
  }
994
998
  console.log(` ${chalk.bold('git add -A')} ${chalk.dim('# stage the governed scaffold')}`);
995
999
  console.log(` ${chalk.bold('git commit -m "initial governed scaffold"')} ${chalk.dim('# checkpoint the starting state')}`);
@@ -300,7 +300,7 @@ export async function executeGovernedRun(context, opts = {}) {
300
300
 
301
301
  // ── Route to adapter ──────────────────────────────────────────────
302
302
  const adapterOpts = {
303
- signal: controller.signal,
303
+ signal: combineAbortSignals(controller.signal, ctx.dispatchAbortSignal),
304
304
  onStatus: (msg) => log(chalk.dim(` ${msg}`)),
305
305
  verifyManifest: true,
306
306
  turnId: turn.turn_id,
@@ -557,3 +557,28 @@ function printManualQaFallback(log = console.log) {
557
557
  log(chalk.dim(' - Then recover the retained QA turn with: agentxchain step --resume'));
558
558
  log(chalk.dim(' - Guide: https://agentxchain.dev/docs/getting-started'));
559
559
  }
560
+
561
+ function combineAbortSignals(primarySignal, secondarySignal) {
562
+ if (!secondarySignal) {
563
+ return primarySignal;
564
+ }
565
+ if (!primarySignal) {
566
+ return secondarySignal;
567
+ }
568
+ if (typeof AbortSignal.any === 'function') {
569
+ return AbortSignal.any([primarySignal, secondarySignal]);
570
+ }
571
+
572
+ const combined = new AbortController();
573
+ const forward = (signal) => {
574
+ if (!signal) return;
575
+ if (signal.aborted) {
576
+ combined.abort(signal.reason);
577
+ return;
578
+ }
579
+ signal.addEventListener('abort', () => combined.abort(signal.reason), { once: true });
580
+ };
581
+ forward(primarySignal);
582
+ forward(secondarySignal);
583
+ return combined.signal;
584
+ }
@@ -13,7 +13,7 @@ import { getContinuityStatus } from '../lib/continuity-status.js';
13
13
  import { getConnectorHealth } from '../lib/connector-health.js';
14
14
  import { readRepoDecisions, summarizeRepoDecisions } from '../lib/repo-decisions.js';
15
15
  import { deriveWorkflowKitArtifacts } from '../lib/workflow-kit-artifacts.js';
16
- import { evaluateTimeouts } from '../lib/timeout-evaluator.js';
16
+ import { evaluateTimeouts, computeTimeoutBudget } from '../lib/timeout-evaluator.js';
17
17
  import { evaluateApprovalSlaReminders } from '../lib/notification-runner.js';
18
18
  import { summarizeRunProvenance } from '../lib/run-provenance.js';
19
19
  import { readRecentRunEventSummary } from '../lib/recent-event-summary.js';
@@ -283,7 +283,19 @@ function renderGovernedStatus(context, opts) {
283
283
  elapsedTag = m > 0 ? ` — ${m}m ${s % 60}s` : ` — ${s}s`;
284
284
  }
285
285
  }
286
- console.log(` ${marker} ${turn.turn_id} ${chalk.bold(turn.assigned_role)} (${statusLabel}) [attempt ${turn.attempt}]${elapsedTag}`);
286
+ let budgetTag = '';
287
+ if (config.timeouts?.per_turn_minutes && turn.started_at) {
288
+ const tb = computeTimeoutBudget({ config, state, turn, now: new Date() });
289
+ const tBudget = tb.find((b) => b.scope === 'turn');
290
+ if (tBudget) {
291
+ if (tBudget.exceeded) {
292
+ budgetTag = ` ${chalk.red('[TIMEOUT]')}`;
293
+ } else {
294
+ budgetTag = ` ${chalk.dim(`[${tBudget.remaining_minutes}m left]`)}`;
295
+ }
296
+ }
297
+ }
298
+ console.log(` ${marker} ${turn.turn_id} — ${chalk.bold(turn.assigned_role)} (${statusLabel}) [attempt ${turn.attempt}]${elapsedTag}${budgetTag}`);
287
299
  if (turn.status === 'conflicted' && turn.conflict_state) {
288
300
  const cs = turn.conflict_state;
289
301
  const files = cs.conflict_error?.conflicting_files || [];
@@ -314,6 +326,21 @@ function renderGovernedStatus(context, opts) {
314
326
  console.log(` ${chalk.dim('Elapsed:')} ${elapsed}`);
315
327
  }
316
328
  }
329
+ // Turn-level timeout budget inline with turn info
330
+ if (config.timeouts?.per_turn_minutes && singleActiveTurn.started_at) {
331
+ const turnBudgets = computeTimeoutBudget({ config, state, turn: singleActiveTurn, now: new Date() });
332
+ const turnBudget = turnBudgets.find((b) => b.scope === 'turn');
333
+ if (turnBudget) {
334
+ if (turnBudget.exceeded) {
335
+ console.log(` ${chalk.dim('Budget:')} ${chalk.red(`EXCEEDED — was ${turnBudget.limit_minutes}m, over by ${turnBudget.elapsed_minutes - turnBudget.limit_minutes}m`)}`);
336
+ } else {
337
+ const remMins = Math.floor(turnBudget.remaining_seconds / 60);
338
+ const remSecs = turnBudget.remaining_seconds % 60;
339
+ const remLabel = remMins > 0 ? `${remMins}m ${remSecs}s` : `${remSecs}s`;
340
+ console.log(` ${chalk.dim('Budget:')} ${chalk.green(`${remLabel} remaining`)} of ${turnBudget.limit_minutes}m (deadline ${new Date(turnBudget.deadline_iso).toLocaleTimeString()})`);
341
+ }
342
+ }
343
+ }
317
344
  if (singleActiveTurn.status === 'conflicted' && singleActiveTurn.conflict_state) {
318
345
  const cs = singleActiveTurn.conflict_state;
319
346
  const files = cs.conflict_error?.conflicting_files || [];
@@ -474,19 +501,22 @@ function renderGovernedStatus(context, opts) {
474
501
  renderWorkflowKitArtifactsSection(workflowKitArtifacts);
475
502
 
476
503
  if (config.timeouts && (state?.status === 'active' || approvalPending)) {
504
+ const nowDate = new Date();
477
505
  const activeTurn = state?.status === 'active' ? getActiveTurn(state) : null;
478
506
  const turnResult = activeTurn ? { role: activeTurn.assigned_role } : undefined;
479
- const timeoutEval = evaluateTimeouts({ config, state, turn: activeTurn, turnResult, now: new Date().toISOString() });
507
+ const timeoutEval = evaluateTimeouts({ config, state, turn: activeTurn, turnResult, now: nowDate.toISOString() });
480
508
  const allItems = [...timeoutEval.exceeded, ...timeoutEval.warnings];
481
- if (allItems.length > 0 || approvalPending) {
509
+ // Compute full budget for phase/run scopes (turn budget is shown inline with turn info above)
510
+ const budgets = computeTimeoutBudget({ config, state, turn: activeTurn, now: nowDate })
511
+ .filter((b) => b.scope !== 'turn'); // turn budget already shown inline
512
+
513
+ if (allItems.length > 0 || budgets.length > 0 || approvalPending) {
482
514
  console.log('');
483
515
  console.log(` ${chalk.dim('Timeouts:')}`);
484
516
  if (approvalPending) {
485
517
  console.log(` ${chalk.yellow('◷')} approval wait does not mutate timeout state; phase/run clocks keep ticking until the next accepted turn`);
486
518
  }
487
- if (approvalPending && allItems.length === 0) {
488
- console.log(` ${chalk.dim('No current phase/run timeout pressure.')}`);
489
- }
519
+ // Show exceeded/warned items
490
520
  for (const item of allItems) {
491
521
  const isExceeded = timeoutEval.exceeded.includes(item);
492
522
  const elapsed = item.elapsed_minutes != null ? `${item.elapsed_minutes}m` : '?';
@@ -495,6 +525,17 @@ function renderGovernedStatus(context, opts) {
495
525
  const label = isExceeded ? chalk.red(`EXCEEDED ${item.scope}`) : chalk.yellow(`${item.scope}`);
496
526
  console.log(` ${icon} ${label}: ${elapsed}/${limit} (action: ${item.action || 'n/a'})`);
497
527
  }
528
+ // Show remaining budget for non-exceeded phase/run scopes
529
+ const exceededScopes = new Set(allItems.map((i) => `${i.scope}:${i.phase || ''}`));
530
+ for (const b of budgets) {
531
+ const key = `${b.scope}:${b.phase || ''}`;
532
+ if (exceededScopes.has(key)) continue; // already shown as exceeded above
533
+ const scopeLabel = b.scope === 'phase' ? `phase (${b.phase})` : b.scope;
534
+ console.log(` ${chalk.green('✓')} ${scopeLabel}: ${b.elapsed_minutes}m/${b.limit_minutes}m — ${chalk.green(`${b.remaining_minutes}m remaining`)}`);
535
+ }
536
+ if (approvalPending && allItems.length === 0 && budgets.length === 0) {
537
+ console.log(` ${chalk.dim('No current phase/run timeout pressure.')}`);
538
+ }
498
539
  }
499
540
  }
500
541
 
@@ -3,6 +3,7 @@ import { join } from 'path';
3
3
  import chalk from 'chalk';
4
4
  import { loadProjectContext, loadProjectState } from '../lib/config.js';
5
5
  import { getActiveTurnCount, getActiveTurns } from '../lib/governed-state.js';
6
+ import { computeTimeoutBudget } from '../lib/timeout-evaluator.js';
6
7
  import {
7
8
  getDispatchAssignmentPath,
8
9
  getDispatchContextPath,
@@ -54,11 +55,11 @@ export function turnShowCommand(turnId, opts) {
54
55
  }
55
56
 
56
57
  if (opts.json) {
57
- console.log(JSON.stringify(buildTurnPayload(selectedTurnId, turn, state, artifacts, assignment), null, 2));
58
+ console.log(JSON.stringify(buildTurnPayload(selectedTurnId, turn, state, artifacts, assignment, context.config), null, 2));
58
59
  return;
59
60
  }
60
61
 
61
- printTurnSummary(selectedTurnId, turn, state, artifacts, assignment);
62
+ printTurnSummary(selectedTurnId, turn, state, artifacts, assignment, context.config);
62
63
  }
63
64
 
64
65
  function requireGovernedContext() {
@@ -118,9 +119,9 @@ function buildArtifactIndex(root, turnId) {
118
119
  );
119
120
  }
120
121
 
121
- function buildTurnPayload(turnId, turn, state, artifacts, assignment) {
122
+ function buildTurnPayload(turnId, turn, state, artifacts, assignment, config) {
122
123
  const elapsedMs = getElapsedMs(turn.started_at);
123
- return {
124
+ const payload = {
124
125
  turn_id: turnId,
125
126
  run_id: state.run_id || assignment?.run_id || null,
126
127
  phase: state.phase || assignment?.phase || null,
@@ -140,9 +141,17 @@ function buildTurnPayload(turnId, turn, state, artifacts, assignment) {
140
141
  }]),
141
142
  ),
142
143
  };
144
+ // Add timeout budget if configured
145
+ if (config?.timeouts) {
146
+ const budgets = computeTimeoutBudget({ config, state, turn, now: new Date() });
147
+ if (budgets.length > 0) {
148
+ payload.timeout_budget = budgets;
149
+ }
150
+ }
151
+ return payload;
143
152
  }
144
153
 
145
- function printTurnSummary(turnId, turn, state, artifacts, assignment) {
154
+ function printTurnSummary(turnId, turn, state, artifacts, assignment, config) {
146
155
  const elapsedMs = getElapsedMs(turn.started_at);
147
156
  console.log('');
148
157
  console.log(chalk.bold(` Turn: ${chalk.cyan(turnId)}`));
@@ -159,6 +168,21 @@ function printTurnSummary(turnId, turn, state, artifacts, assignment) {
159
168
  if (elapsedMs != null) {
160
169
  console.log(` ${chalk.dim('Elapsed:')} ${formatElapsed(elapsedMs)}`);
161
170
  }
171
+ // Timeout budget per scope
172
+ if (config?.timeouts) {
173
+ const budgets = computeTimeoutBudget({ config, state, turn, now: new Date() });
174
+ for (const b of budgets) {
175
+ const scopeLabel = b.scope === 'phase' ? `phase (${b.phase})` : b.scope;
176
+ if (b.exceeded) {
177
+ console.log(` ${chalk.dim('Timeout:')} ${chalk.red(`${scopeLabel} EXCEEDED`)} — was ${b.limit_minutes}m, over by ${b.elapsed_minutes - b.limit_minutes}m`);
178
+ } else {
179
+ const remMins = Math.floor(b.remaining_seconds / 60);
180
+ const remSecs = b.remaining_seconds % 60;
181
+ const remLabel = remMins > 0 ? `${remMins}m ${remSecs}s` : `${remSecs}s`;
182
+ console.log(` ${chalk.dim('Timeout:')} ${scopeLabel} — ${chalk.green(`${remLabel} remaining`)} of ${b.limit_minutes}m`);
183
+ }
184
+ }
185
+ }
162
186
  console.log(` ${chalk.dim('Dispatch:')} ${getDispatchTurnDir(turnId)}`);
163
187
  if (assignment?.staging_result_path) {
164
188
  console.log(` ${chalk.dim('Staging:')} ${assignment.staging_result_path}`);
@@ -0,0 +1,504 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import {
3
+ cpSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readlinkSync,
8
+ readdirSync,
9
+ rmSync,
10
+ symlinkSync,
11
+ writeFileSync,
12
+ } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join, dirname } from 'node:path';
15
+
16
+ import { loadProjectContext } from './config.js';
17
+ import { writeDispatchBundle } from './dispatch-bundle.js';
18
+ import { getActiveTurn, initializeGovernedRun, assignGovernedTurn, readTurnCostUsd } from './governed-state.js';
19
+ import { dispatchApiProxy } from './adapters/api-proxy-adapter.js';
20
+ import { dispatchLocalCli, saveDispatchLogs } from './adapters/local-cli-adapter.js';
21
+ import { dispatchMcp } from './adapters/mcp-adapter.js';
22
+ import { dispatchRemoteAgent } from './adapters/remote-agent-adapter.js';
23
+ import { getDispatchPromptPath, getTurnStagingResultPath } from './turn-paths.js';
24
+ import { validateStagedTurnResult } from './turn-result-validator.js';
25
+
26
+ const VALIDATABLE_RUNTIME_TYPES = new Set(['local_cli', 'api_proxy', 'mcp', 'remote_agent']);
27
+ const DEFAULT_VALIDATE_TIMEOUT_MS = 120_000;
28
+
29
+ export async function validateConfiguredConnector(sourceRoot, options = {}) {
30
+ const sourceContext = loadProjectContext(sourceRoot);
31
+ if (!sourceContext) {
32
+ return {
33
+ ok: false,
34
+ exitCode: 2,
35
+ overall: 'error',
36
+ error: 'No governed agentxchain.json found.',
37
+ };
38
+ }
39
+
40
+ if (sourceContext.config.protocol_mode !== 'governed') {
41
+ return {
42
+ ok: false,
43
+ exitCode: 2,
44
+ overall: 'error',
45
+ error: 'connector validate only supports governed projects.',
46
+ };
47
+ }
48
+
49
+ const runtimeId = typeof options.runtimeId === 'string' ? options.runtimeId.trim() : '';
50
+ if (!runtimeId) {
51
+ return {
52
+ ok: false,
53
+ exitCode: 2,
54
+ overall: 'error',
55
+ error: 'Runtime id is required. Usage: agentxchain connector validate <runtime_id>',
56
+ };
57
+ }
58
+
59
+ const timeoutMs = Number.parseInt(options.timeoutMs ?? DEFAULT_VALIDATE_TIMEOUT_MS, 10);
60
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
61
+ return {
62
+ ok: false,
63
+ exitCode: 2,
64
+ overall: 'error',
65
+ error: 'Timeout must be a positive integer.',
66
+ };
67
+ }
68
+
69
+ const runtime = sourceContext.config.runtimes?.[runtimeId];
70
+ if (!runtime) {
71
+ return {
72
+ ok: false,
73
+ exitCode: 2,
74
+ overall: 'error',
75
+ error: `Unknown connector runtime "${runtimeId}"`,
76
+ };
77
+ }
78
+ if (runtime.type === 'manual') {
79
+ return {
80
+ ok: false,
81
+ exitCode: 2,
82
+ overall: 'error',
83
+ error: `Runtime "${runtimeId}" is manual. connector validate only supports automated runtimes.`,
84
+ };
85
+ }
86
+ if (!VALIDATABLE_RUNTIME_TYPES.has(runtime.type)) {
87
+ return {
88
+ ok: false,
89
+ exitCode: 2,
90
+ overall: 'error',
91
+ error: `Runtime "${runtimeId}" of type "${runtime.type}" cannot be validated by connector validate.`,
92
+ };
93
+ }
94
+
95
+ const roleSelection = resolveValidationRole(sourceContext.config, runtimeId, options.roleId);
96
+ if (!roleSelection.ok) {
97
+ return {
98
+ ok: false,
99
+ exitCode: 2,
100
+ overall: 'error',
101
+ error: roleSelection.error,
102
+ };
103
+ }
104
+
105
+ const tempBase = mkdtempSync(join(tmpdir(), 'axc-connector-validate-'));
106
+ const scratchRoot = join(tempBase, 'workspace');
107
+ const warnings = [...roleSelection.warnings];
108
+ let keepArtifacts = options.keepArtifacts === true;
109
+ let dispatch = null;
110
+ let validation = null;
111
+ let costUsd = null;
112
+
113
+ try {
114
+ copyRepoForValidation(sourceRoot, scratchRoot);
115
+ initializeScratchGit(scratchRoot);
116
+
117
+ const scratchContext = loadProjectContext(scratchRoot);
118
+ if (!scratchContext) {
119
+ return {
120
+ ok: false,
121
+ exitCode: 1,
122
+ overall: 'fail',
123
+ runtime_id: runtimeId,
124
+ runtime_type: runtime.type,
125
+ role_id: roleSelection.roleId,
126
+ timeout_ms: timeoutMs,
127
+ warnings,
128
+ error: 'Failed to load governed config inside scratch workspace.',
129
+ scratch_root: scratchRoot,
130
+ };
131
+ }
132
+
133
+ const initResult = initializeGovernedRun(scratchRoot, scratchContext.config, {
134
+ provenance: {
135
+ trigger: 'connector_validate',
136
+ runtime_id: runtimeId,
137
+ role_id: roleSelection.roleId,
138
+ },
139
+ });
140
+ if (!initResult.ok) {
141
+ return {
142
+ ok: false,
143
+ exitCode: 1,
144
+ overall: 'fail',
145
+ runtime_id: runtimeId,
146
+ runtime_type: runtime.type,
147
+ role_id: roleSelection.roleId,
148
+ timeout_ms: timeoutMs,
149
+ warnings,
150
+ error: initResult.error,
151
+ scratch_root: scratchRoot,
152
+ };
153
+ }
154
+
155
+ const assignResult = assignGovernedTurn(scratchRoot, scratchContext.config, roleSelection.roleId);
156
+ if (!assignResult.ok) {
157
+ return {
158
+ ok: false,
159
+ exitCode: 1,
160
+ overall: 'fail',
161
+ runtime_id: runtimeId,
162
+ runtime_type: runtime.type,
163
+ role_id: roleSelection.roleId,
164
+ timeout_ms: timeoutMs,
165
+ warnings,
166
+ error: assignResult.error,
167
+ scratch_root: scratchRoot,
168
+ };
169
+ }
170
+
171
+ const state = assignResult.state;
172
+ const turn = getActiveTurn(state);
173
+ if (!turn) {
174
+ return {
175
+ ok: false,
176
+ exitCode: 1,
177
+ overall: 'fail',
178
+ runtime_id: runtimeId,
179
+ runtime_type: runtime.type,
180
+ role_id: roleSelection.roleId,
181
+ timeout_ms: timeoutMs,
182
+ warnings,
183
+ error: 'Synthetic validation turn was not assigned.',
184
+ scratch_root: scratchRoot,
185
+ };
186
+ }
187
+
188
+ const bundleResult = writeDispatchBundle(scratchRoot, state, scratchContext.config, { turnId: turn.turn_id });
189
+ if (!bundleResult.ok) {
190
+ return {
191
+ ok: false,
192
+ exitCode: 1,
193
+ overall: 'fail',
194
+ runtime_id: runtimeId,
195
+ runtime_type: runtime.type,
196
+ role_id: roleSelection.roleId,
197
+ timeout_ms: timeoutMs,
198
+ warnings,
199
+ error: bundleResult.error,
200
+ scratch_root: scratchRoot,
201
+ };
202
+ }
203
+ if (Array.isArray(bundleResult.warnings)) {
204
+ warnings.push(...bundleResult.warnings);
205
+ }
206
+
207
+ appendValidationPrompt(scratchRoot, scratchContext.config, state, turn);
208
+ configureRuntimeValidationTimeout(scratchContext.config, runtimeId, timeoutMs);
209
+
210
+ const signal = AbortSignal.timeout(timeoutMs);
211
+ const adapterResult = await dispatchValidationTurn(
212
+ scratchRoot,
213
+ state,
214
+ scratchContext.config,
215
+ runtimeId,
216
+ { turnId: turn.turn_id, signal },
217
+ );
218
+
219
+ dispatch = {
220
+ ok: adapterResult.ok,
221
+ error: adapterResult.error || null,
222
+ timed_out: adapterResult.timedOut === true,
223
+ aborted: adapterResult.aborted === true,
224
+ log_count: Array.isArray(adapterResult.logs) ? adapterResult.logs.length : 0,
225
+ };
226
+
227
+ if (Array.isArray(adapterResult.logs) && adapterResult.logs.length > 0) {
228
+ saveDispatchLogs(scratchRoot, turn.turn_id, adapterResult.logs);
229
+ }
230
+
231
+ if (!adapterResult.ok) {
232
+ keepArtifacts = true;
233
+ return {
234
+ ok: false,
235
+ exitCode: 1,
236
+ overall: 'fail',
237
+ runtime_id: runtimeId,
238
+ runtime_type: runtime.type,
239
+ role_id: roleSelection.roleId,
240
+ timeout_ms: timeoutMs,
241
+ warnings,
242
+ dispatch,
243
+ validation: null,
244
+ scratch_root: scratchRoot,
245
+ };
246
+ }
247
+
248
+ validation = validateStagedTurnResult(scratchRoot, state, scratchContext.config, {
249
+ stagingPath: getTurnStagingResultPath(turn.turn_id),
250
+ });
251
+ costUsd = validation?.turnResult ? readTurnCostUsd(validation.turnResult) : null;
252
+
253
+ if (!validation.ok) {
254
+ keepArtifacts = true;
255
+ return {
256
+ ok: false,
257
+ exitCode: 1,
258
+ overall: 'fail',
259
+ runtime_id: runtimeId,
260
+ runtime_type: runtime.type,
261
+ role_id: roleSelection.roleId,
262
+ timeout_ms: timeoutMs,
263
+ warnings,
264
+ dispatch,
265
+ validation: {
266
+ ok: false,
267
+ stage: validation.stage,
268
+ error_class: validation.error_class,
269
+ errors: validation.errors,
270
+ warnings: validation.warnings,
271
+ },
272
+ cost_usd: costUsd,
273
+ scratch_root: scratchRoot,
274
+ };
275
+ }
276
+
277
+ return {
278
+ ok: true,
279
+ exitCode: 0,
280
+ overall: 'pass',
281
+ runtime_id: runtimeId,
282
+ runtime_type: runtime.type,
283
+ role_id: roleSelection.roleId,
284
+ timeout_ms: timeoutMs,
285
+ warnings,
286
+ dispatch,
287
+ validation: {
288
+ ok: true,
289
+ stage: null,
290
+ error_class: null,
291
+ errors: [],
292
+ warnings: validation.warnings,
293
+ },
294
+ cost_usd: costUsd,
295
+ scratch_root: keepArtifacts ? scratchRoot : null,
296
+ };
297
+ } catch (error) {
298
+ keepArtifacts = true;
299
+ return {
300
+ ok: false,
301
+ exitCode: 1,
302
+ overall: 'fail',
303
+ runtime_id: runtimeId,
304
+ runtime_type: runtime.type,
305
+ role_id: roleSelection.roleId,
306
+ timeout_ms: timeoutMs,
307
+ warnings,
308
+ dispatch,
309
+ validation,
310
+ error: error.message,
311
+ scratch_root: scratchRoot,
312
+ };
313
+ } finally {
314
+ if (!keepArtifacts) {
315
+ try {
316
+ rmSync(tempBase, { recursive: true, force: true });
317
+ } catch {}
318
+ }
319
+ }
320
+ }
321
+
322
+ function resolveValidationRole(config, runtimeId, requestedRoleId) {
323
+ const matchingRoles = Object.entries(config.roles || {})
324
+ .filter(([, role]) => (role.runtime_id || role.runtime) === runtimeId)
325
+ .map(([roleId]) => roleId)
326
+ .sort((a, b) => a.localeCompare(b, 'en'));
327
+
328
+ if (matchingRoles.length === 0) {
329
+ return { ok: false, error: `No roles are bound to runtime "${runtimeId}".` };
330
+ }
331
+
332
+ if (requestedRoleId) {
333
+ if (!config.roles?.[requestedRoleId]) {
334
+ return { ok: false, error: `Unknown role "${requestedRoleId}".` };
335
+ }
336
+ const boundRuntimeId = config.roles[requestedRoleId].runtime_id || config.roles[requestedRoleId].runtime;
337
+ if (boundRuntimeId !== runtimeId) {
338
+ return {
339
+ ok: false,
340
+ error: `Role "${requestedRoleId}" is not bound to runtime "${runtimeId}".`,
341
+ };
342
+ }
343
+ return { ok: true, roleId: requestedRoleId, warnings: [] };
344
+ }
345
+
346
+ const warnings = [];
347
+ if (matchingRoles.length > 1) {
348
+ warnings.push(
349
+ `Runtime "${runtimeId}" is shared by multiple roles (${matchingRoles.join(', ')}). ` +
350
+ `Validated the first binding "${matchingRoles[0]}". Use --role to target another binding.`,
351
+ );
352
+ }
353
+
354
+ return {
355
+ ok: true,
356
+ roleId: matchingRoles[0],
357
+ warnings,
358
+ };
359
+ }
360
+
361
+ function copyRepoForValidation(sourceRoot, scratchRoot) {
362
+ mkdirSync(scratchRoot, { recursive: true });
363
+ copyTree(sourceRoot, scratchRoot);
364
+ }
365
+
366
+ function copyTree(sourcePath, destPath) {
367
+ mkdirSync(destPath, { recursive: true });
368
+ for (const entry of readdirSync(sourcePath, { withFileTypes: true })) {
369
+ if (entry.name === '.git' || entry.name === '.agentxchain') {
370
+ continue;
371
+ }
372
+
373
+ const sourceEntry = join(sourcePath, entry.name);
374
+ const destEntry = join(destPath, entry.name);
375
+ const stats = lstatSync(sourceEntry);
376
+
377
+ if (stats.isSymbolicLink()) {
378
+ symlinkSync(readlinkSync(sourceEntry), destEntry);
379
+ continue;
380
+ }
381
+
382
+ if (stats.isDirectory()) {
383
+ if (entry.name === 'node_modules') {
384
+ symlinkSync(sourceEntry, destEntry, 'dir');
385
+ continue;
386
+ }
387
+ copyTree(sourceEntry, destEntry);
388
+ continue;
389
+ }
390
+
391
+ mkdirSync(dirname(destEntry), { recursive: true });
392
+ cpSync(sourceEntry, destEntry, { force: true, preserveTimestamps: true });
393
+ }
394
+ }
395
+
396
+ function initializeScratchGit(root) {
397
+ execFileSync('git', ['init', '-q'], { cwd: root, stdio: 'ignore' });
398
+ execFileSync('git', ['checkout', '-q', '-b', 'main'], { cwd: root, stdio: 'ignore' });
399
+ execFileSync('git', ['add', '-A'], { cwd: root, stdio: 'ignore' });
400
+ execFileSync('git', ['commit', '-q', '-m', 'connector validation baseline'], {
401
+ cwd: root,
402
+ stdio: 'ignore',
403
+ env: {
404
+ ...process.env,
405
+ GIT_AUTHOR_NAME: 'AgentXchain Validator',
406
+ GIT_AUTHOR_EMAIL: 'noreply@agentxchain.dev',
407
+ GIT_COMMITTER_NAME: 'AgentXchain Validator',
408
+ GIT_COMMITTER_EMAIL: 'noreply@agentxchain.dev',
409
+ },
410
+ });
411
+ }
412
+
413
+ function appendValidationPrompt(root, config, state, turn) {
414
+ const promptPath = join(root, getDispatchPromptPath(turn.turn_id));
415
+ const role = config.roles?.[turn.assigned_role];
416
+ const reviewOnly = role?.write_authority === 'review_only';
417
+ const validationTurn = {
418
+ schema_version: '1.0',
419
+ run_id: state.run_id,
420
+ turn_id: turn.turn_id,
421
+ role: turn.assigned_role,
422
+ runtime_id: turn.runtime_id,
423
+ status: 'completed',
424
+ summary: 'Connector validation synthetic turn completed without modifying product files.',
425
+ decisions: [
426
+ {
427
+ id: 'DEC-900',
428
+ category: 'process',
429
+ statement: 'The runtime emitted a schema-valid connector validation result.',
430
+ rationale: 'Synthetic dispatch completed and staged a governed turn result.',
431
+ },
432
+ ],
433
+ objections: reviewOnly ? [
434
+ {
435
+ id: 'OBJ-900',
436
+ severity: 'low',
437
+ statement: 'Validation objection: this is a synthetic proof turn, not delivery work.',
438
+ status: 'acknowledged',
439
+ },
440
+ ] : [],
441
+ files_changed: [],
442
+ verification: {
443
+ status: 'skipped',
444
+ evidence_summary: 'Synthetic connector validation turn; no repo work requested.',
445
+ },
446
+ artifact: {
447
+ type: 'review',
448
+ ref: null,
449
+ },
450
+ proposed_next_role: 'human',
451
+ };
452
+
453
+ const lines = [
454
+ '',
455
+ '## Connector Validation Override',
456
+ '',
457
+ 'This dispatch is a connector-validation proof turn, not real product work.',
458
+ '',
459
+ 'You must follow these extra constraints:',
460
+ '',
461
+ '1. Do not edit any product files.',
462
+ '2. Write exactly one governed turn result to the staged result path already provided in ASSIGNMENT.json.',
463
+ '3. Use `files_changed: []` and `artifact.type: "review"`.',
464
+ '4. Do not request a phase transition or run completion.',
465
+ '5. Set `proposed_next_role: "human"`.',
466
+ reviewOnly ? '6. Include at least one objection because this role is review_only.' : '6. Objections may be empty because this role is not review_only.',
467
+ '',
468
+ 'Use this JSON shape as your starting point and replace nothing except if you need equivalent wording:',
469
+ '',
470
+ '```json',
471
+ JSON.stringify(validationTurn, null, 2),
472
+ '```',
473
+ '',
474
+ ];
475
+
476
+ writeFileSync(promptPath, lines.join('\n'), { flag: 'a' });
477
+ }
478
+
479
+ function configureRuntimeValidationTimeout(config, runtimeId, timeoutMs) {
480
+ if (config?.runtimes?.[runtimeId] && config.runtimes[runtimeId].type === 'remote_agent') {
481
+ config.runtimes[runtimeId] = {
482
+ ...config.runtimes[runtimeId],
483
+ timeout_ms: timeoutMs,
484
+ };
485
+ }
486
+ }
487
+
488
+ async function dispatchValidationTurn(root, state, config, runtimeId, options = {}) {
489
+ const runtime = config.runtimes?.[runtimeId];
490
+ switch (runtime?.type) {
491
+ case 'local_cli':
492
+ return dispatchLocalCli(root, state, config, options);
493
+ case 'api_proxy':
494
+ return dispatchApiProxy(root, state, config, options);
495
+ case 'mcp':
496
+ return dispatchMcp(root, state, config, options);
497
+ case 'remote_agent':
498
+ return dispatchRemoteAgent(root, state, config, options);
499
+ default:
500
+ return { ok: false, error: `Unsupported runtime type "${runtime?.type || 'unknown'}"` };
501
+ }
502
+ }
503
+
504
+ export { DEFAULT_VALIDATE_TIMEOUT_MS, VALIDATABLE_RUNTIME_TYPES };
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { join } from 'path';
11
11
  import { loadProjectContext, loadProjectState } from '../config.js';
12
- import { evaluateTimeouts } from '../timeout-evaluator.js';
12
+ import { evaluateTimeouts, computeTimeoutBudget } from '../timeout-evaluator.js';
13
13
  import { readJsonlFile } from './state-reader.js';
14
14
 
15
15
  /**
@@ -209,7 +209,15 @@ export function readTimeoutStatus(workspacePath) {
209
209
  const configSummary = buildTimeoutConfigSummary(timeouts, config.routing);
210
210
 
211
211
  // Live timeout evaluation — only meaningful when the run is active
212
- const live = evaluateDashboardTimeoutPressure(config, state, new Date());
212
+ const nowDate = new Date();
213
+ const live = evaluateDashboardTimeoutPressure(config, state, nowDate);
214
+
215
+ // Compute remaining budget for all configured scopes
216
+ const activeTurnsList = getActiveTurns(state);
217
+ const primaryTurn = activeTurnsList.length === 1 ? activeTurnsList[0] : null;
218
+ const budget = (state?.status === 'active' || Boolean(state?.pending_phase_transition || state?.pending_run_completion))
219
+ ? computeTimeoutBudget({ config, state, turn: primaryTurn, now: nowDate })
220
+ : [];
213
221
 
214
222
  // Persisted timeout events from the decision ledger
215
223
  const ledger = readJsonlFile(agentxchainDir, 'decision-ledger.jsonl');
@@ -223,6 +231,7 @@ export function readTimeoutStatus(workspacePath) {
223
231
  configured: true,
224
232
  config: configSummary,
225
233
  live,
234
+ budget,
226
235
  live_context: buildLiveContext(state),
227
236
  events,
228
237
  },
@@ -12,7 +12,7 @@
12
12
  * - Never calls process dot exit
13
13
  * - No stdout/stderr
14
14
  * - No adapter dispatch (caller provides dispatch callback)
15
- * - Imports only from runner-interface.js
15
+ * - Governed lifecycle operations import through runner-interface.js
16
16
  */
17
17
 
18
18
  import {
@@ -21,6 +21,7 @@ import {
21
21
  assignTurn,
22
22
  acceptTurn,
23
23
  rejectTurn,
24
+ markRunBlocked,
24
25
  writeDispatchBundle,
25
26
  getTurnStagingResultPath,
26
27
  approvePhaseGate,
@@ -33,10 +34,11 @@ import {
33
34
  } from './runner-interface.js';
34
35
 
35
36
  import { runAdmissionControl } from './admission-control.js';
36
- import { mkdirSync, writeFileSync } from 'fs';
37
+ import { appendFileSync, mkdirSync, writeFileSync } from 'fs';
37
38
  import { join, dirname } from 'path';
38
39
  import { evaluateApprovalSlaReminders } from './notification-runner.js';
39
40
  import { readPreemptionMarker } from './intake.js';
41
+ import { buildTimeoutBlockedReason, evaluateTimeouts } from './timeout-evaluator.js';
40
42
 
41
43
  const DEFAULT_MAX_TURNS = 50;
42
44
 
@@ -214,6 +216,7 @@ async function executeSequentialTurn(root, config, state, callbacks, emit, error
214
216
  async function executeParallelTurns(root, config, state, maxConcurrent, callbacks, emit, errors) {
215
217
  const history = [];
216
218
  let acceptedCount = 0;
219
+ const timedOutDispatches = [];
217
220
 
218
221
  // ── Collect active turns that need dispatch (retries) ────────────────
219
222
  const activeTurns = getActiveTurns(state);
@@ -332,7 +335,7 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
332
335
  const dispatchResults = await Promise.allSettled(
333
336
  contexts.map(async (ctx) => {
334
337
  try {
335
- return { ctx, result: await callbacks.dispatch(ctx) };
338
+ return { ctx, result: await dispatchWithTimeout(ctx, config, callbacks.dispatch) };
336
339
  } catch (err) {
337
340
  return { ctx, result: { accept: false, reason: `dispatch threw: ${err.message}` } };
338
341
  }
@@ -350,6 +353,11 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
350
353
  const { turn } = ctx;
351
354
  const roleId = turn.assigned_role;
352
355
 
356
+ if (dispatchResult?.timed_out === true) {
357
+ timedOutDispatches.push({ ctx, dispatchResult });
358
+ continue;
359
+ }
360
+
353
361
  if (dispatchResult.accept) {
354
362
  const absStaging = join(root, ctx.stagingPath);
355
363
  mkdirSync(dirname(absStaging), { recursive: true });
@@ -405,6 +413,13 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
405
413
  }
406
414
  }
407
415
 
416
+ if (timedOutDispatches.length > 0) {
417
+ const timedOut = timedOutDispatches[0];
418
+ const blocked = persistDispatchTimeout(root, config, timedOut.ctx.turn, timedOut.dispatchResult.timeout_result, errors);
419
+ emit({ type: 'blocked', state: blocked.state, reason: 'timeout:turn' });
420
+ return { terminal: true, ok: false, stop_reason: 'blocked', history, acceptedCount };
421
+ }
422
+
408
423
  // ── Stall detection: if no turns were accepted and no new roles were ──
409
424
  // ── assignable, terminate to avoid infinite re-dispatch loops. ────────
410
425
  if (acceptedCount === 0 && history.length > 0) {
@@ -445,12 +460,18 @@ async function dispatchAndProcess(root, config, turn, assignState, callbacks, em
445
460
 
446
461
  let dispatchResult;
447
462
  try {
448
- dispatchResult = await callbacks.dispatch(context);
463
+ dispatchResult = await dispatchWithTimeout(context, config, callbacks.dispatch);
449
464
  } catch (err) {
450
465
  errors.push(`dispatch threw for ${roleId}: ${err.message}`);
451
466
  return { terminal: true, ok: false, stop_reason: 'dispatch_error', history };
452
467
  }
453
468
 
469
+ if (dispatchResult?.timed_out === true) {
470
+ const blocked = persistDispatchTimeout(root, config, turn, dispatchResult.timeout_result, errors);
471
+ emit({ type: 'blocked', state: blocked.state, reason: 'timeout:turn' });
472
+ return { terminal: true, ok: false, stop_reason: 'blocked', history };
473
+ }
474
+
454
475
  if (dispatchResult.accept) {
455
476
  const absStaging = join(root, stagingPath);
456
477
  mkdirSync(dirname(absStaging), { recursive: true });
@@ -574,3 +595,107 @@ function makeResult(ok, stop_reason, state, turns_executed, turn_history, gates_
574
595
  function noop() {}
575
596
 
576
597
  export { DEFAULT_MAX_TURNS };
598
+
599
+ function buildTimeoutLedgerEntry(timeoutResult, timestamp, turnId, phase) {
600
+ return {
601
+ type: 'timeout',
602
+ scope: timeoutResult.scope,
603
+ phase: timeoutResult.phase || phase || null,
604
+ turn_id: turnId || null,
605
+ limit_minutes: timeoutResult.limit_minutes,
606
+ elapsed_minutes: timeoutResult.elapsed_minutes,
607
+ exceeded_by_minutes: timeoutResult.exceeded_by_minutes,
608
+ action: timeoutResult.action,
609
+ timestamp,
610
+ };
611
+ }
612
+
613
+ function appendJsonl(root, relPath, value) {
614
+ appendFileSync(join(root, relPath), `${JSON.stringify(value)}\n`);
615
+ }
616
+
617
+ function getDispatchTimeoutResult(config, state, turn, now = new Date()) {
618
+ const evaluation = evaluateTimeouts({ config, state, turn, now });
619
+ return evaluation.exceeded.find((entry) => entry.scope === 'turn' && entry.action === 'escalate') || null;
620
+ }
621
+
622
+ async function dispatchWithTimeout(context, config, dispatchFn) {
623
+ const timeoutResult = getDispatchTimeoutResult(config, context.state, context.turn);
624
+ if (!timeoutResult) {
625
+ return await dispatchFn(context);
626
+ }
627
+
628
+ const remainingMs = Math.max(
629
+ 0,
630
+ timeoutResult.limit_minutes * 60 * 1000
631
+ - Math.max(0, new Date() - new Date(context.turn.started_at || context.turn.assigned_at || new Date())),
632
+ );
633
+ const abortController = new AbortController();
634
+ if (remainingMs === 0) {
635
+ abortController.abort(new Error(`Turn timeout exceeded after ${timeoutResult.limit_minutes}m`));
636
+ return {
637
+ timed_out: true,
638
+ timeout_result: timeoutResult,
639
+ };
640
+ }
641
+ const enrichedContext = {
642
+ ...context,
643
+ dispatchTimeoutMs: remainingMs,
644
+ dispatchDeadlineAt: new Date(Date.now() + remainingMs).toISOString(),
645
+ dispatchAbortSignal: abortController.signal,
646
+ };
647
+
648
+ let timer = null;
649
+ const dispatchPromise = Promise.resolve(dispatchFn(enrichedContext))
650
+ .then((result) => ({ kind: 'result', result }))
651
+ .catch((error) => ({ kind: 'error', error }));
652
+ const timeoutPromise = new Promise((resolve) => {
653
+ timer = setTimeout(() => {
654
+ abortController.abort(new Error(`Turn timeout exceeded after ${timeoutResult.limit_minutes}m`));
655
+ resolve({ kind: 'timeout', timeoutResult });
656
+ }, remainingMs);
657
+ });
658
+
659
+ const winner = await Promise.race([dispatchPromise, timeoutPromise]);
660
+ clearTimeout(timer);
661
+
662
+ if (winner.kind === 'timeout') {
663
+ return {
664
+ timed_out: true,
665
+ timeout_result: winner.timeoutResult,
666
+ };
667
+ }
668
+
669
+ if (winner.kind === 'error') {
670
+ throw winner.error;
671
+ }
672
+
673
+ return winner.result;
674
+ }
675
+
676
+ function persistDispatchTimeout(root, config, turn, timeoutResult, errors) {
677
+ const blockedAt = new Date().toISOString();
678
+ const blockedReason = buildTimeoutBlockedReason(timeoutResult, { turnRetained: true });
679
+ const blocked = markRunBlocked(root, {
680
+ blockedOn: `timeout:${timeoutResult.scope}`,
681
+ category: blockedReason.category,
682
+ recovery: blockedReason.recovery,
683
+ turnId: turn.turn_id,
684
+ blockedAt,
685
+ notificationConfig: config,
686
+ });
687
+
688
+ if (!blocked.ok) {
689
+ errors.push(`markRunBlocked(timeout): ${blocked.error}`);
690
+ return { state: loadState(root, config) };
691
+ }
692
+
693
+ try {
694
+ appendJsonl(root, '.agentxchain/decision-ledger.jsonl', buildTimeoutLedgerEntry(timeoutResult, blockedAt, turn.turn_id, blocked.state?.phase));
695
+ } catch (err) {
696
+ errors.push(`timeout ledger append failed: ${err.message}`);
697
+ }
698
+
699
+ errors.push(`dispatch timed out for ${turn.assigned_role} after ${timeoutResult.limit_minutes}m`);
700
+ return blocked;
701
+ }
@@ -210,6 +210,93 @@ export function validateTimeoutsConfig(timeouts, routing) {
210
210
  return { ok: errors.length === 0, errors };
211
211
  }
212
212
 
213
+ /**
214
+ * Compute remaining timeout budget for an active turn/phase/run.
215
+ *
216
+ * Unlike evaluateTimeouts() which only returns items when exceeded,
217
+ * this returns budget info for ALL configured timeout scopes regardless
218
+ * of whether the deadline has passed.
219
+ *
220
+ * @param {object} options
221
+ * @param {object} options.config - Normalized config with optional `timeouts` section
222
+ * @param {object} options.state - Current governed state
223
+ * @param {object} [options.turn] - Active turn metadata
224
+ * @param {Date|string} [options.now] - Override for current time (testing)
225
+ * @returns {Array<TimeoutBudget>} Array of { scope, limit_minutes, elapsed_minutes, remaining_minutes, deadline_iso, exceeded, action }
226
+ */
227
+ export function computeTimeoutBudget({ config, state, turn = null, now = new Date() }) {
228
+ const timeouts = config?.timeouts;
229
+ if (!timeouts) return [];
230
+
231
+ const nowMs = typeof now === 'string' ? new Date(now).getTime() : now.getTime();
232
+ const budgets = [];
233
+
234
+ // Per-turn budget
235
+ if (timeouts.per_turn_minutes && turn) {
236
+ const startedAt = turn.started_at || turn.assigned_at;
237
+ if (startedAt) {
238
+ const dispatchMs = new Date(startedAt).getTime();
239
+ const limitMs = timeouts.per_turn_minutes * 60 * 1000;
240
+ const elapsedMs = nowMs - dispatchMs;
241
+ const remainingMs = limitMs - elapsedMs;
242
+ budgets.push({
243
+ scope: 'turn',
244
+ limit_minutes: timeouts.per_turn_minutes,
245
+ elapsed_minutes: Math.round(elapsedMs / 60000),
246
+ remaining_minutes: Math.max(0, Math.round(remainingMs / 60000)),
247
+ remaining_seconds: Math.max(0, Math.round(remainingMs / 1000)),
248
+ deadline_iso: new Date(dispatchMs + limitMs).toISOString(),
249
+ exceeded: elapsedMs > limitMs,
250
+ action: resolveAction(timeouts.action, 'turn'),
251
+ });
252
+ }
253
+ }
254
+
255
+ // Per-phase budget
256
+ const phaseLimit = resolvePhaseLimit(timeouts, config.routing, state.phase);
257
+ const phaseAction = resolvePhaseAction(timeouts, config.routing, state.phase);
258
+ if (phaseLimit) {
259
+ const phaseEnteredAt = findPhaseEntryTime(state);
260
+ if (phaseEnteredAt) {
261
+ const entryMs = new Date(phaseEnteredAt).getTime();
262
+ const limitMs = phaseLimit * 60 * 1000;
263
+ const elapsedMs = nowMs - entryMs;
264
+ const remainingMs = limitMs - elapsedMs;
265
+ budgets.push({
266
+ scope: 'phase',
267
+ phase: state.phase,
268
+ limit_minutes: phaseLimit,
269
+ elapsed_minutes: Math.round(elapsedMs / 60000),
270
+ remaining_minutes: Math.max(0, Math.round(remainingMs / 60000)),
271
+ remaining_seconds: Math.max(0, Math.round(remainingMs / 1000)),
272
+ deadline_iso: new Date(entryMs + limitMs).toISOString(),
273
+ exceeded: elapsedMs > limitMs,
274
+ action: phaseAction,
275
+ });
276
+ }
277
+ }
278
+
279
+ // Per-run budget
280
+ if (timeouts.per_run_minutes && state.created_at) {
281
+ const createMs = new Date(state.created_at).getTime();
282
+ const limitMs = timeouts.per_run_minutes * 60 * 1000;
283
+ const elapsedMs = nowMs - createMs;
284
+ const remainingMs = limitMs - elapsedMs;
285
+ budgets.push({
286
+ scope: 'run',
287
+ limit_minutes: timeouts.per_run_minutes,
288
+ elapsed_minutes: Math.round(elapsedMs / 60000),
289
+ remaining_minutes: Math.max(0, Math.round(remainingMs / 60000)),
290
+ remaining_seconds: Math.max(0, Math.round(remainingMs / 1000)),
291
+ deadline_iso: new Date(createMs + limitMs).toISOString(),
292
+ exceeded: elapsedMs > limitMs,
293
+ action: resolveAction(timeouts.action, 'run'),
294
+ });
295
+ }
296
+
297
+ return budgets;
298
+ }
299
+
213
300
  /**
214
301
  * Build a blocked_reason descriptor for a timeout.
215
302
  */