agentxchain 2.45.0 → 2.46.2

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.
@@ -5,6 +5,7 @@ import { getActiveTurn, getActiveTurnCount, getActiveTurns } from '../lib/govern
5
5
  import { getContinuityStatus } from '../lib/continuity-status.js';
6
6
  import { getConnectorHealth } from '../lib/connector-health.js';
7
7
  import { deriveWorkflowKitArtifacts } from '../lib/workflow-kit-artifacts.js';
8
+ import { evaluateTimeouts } from '../lib/timeout-evaluator.js';
8
9
 
9
10
  export async function statusCommand(opts) {
10
11
  const context = loadProjectContext();
@@ -192,6 +193,10 @@ function renderGovernedStatus(context, opts) {
192
193
  }
193
194
  }
194
195
 
196
+ if (state?.last_gate_failure) {
197
+ renderLastGateFailure(state.last_gate_failure, config);
198
+ }
199
+
195
200
  const recovery = deriveRecoveryDescriptor(state, config);
196
201
  if (recovery) {
197
202
  console.log('');
@@ -237,6 +242,25 @@ function renderGovernedStatus(context, opts) {
237
242
 
238
243
  renderWorkflowKitArtifactsSection(workflowKitArtifacts);
239
244
 
245
+ if (config.timeouts && state?.status === 'active') {
246
+ const activeTurn = getActiveTurn(state);
247
+ const turnResult = activeTurn ? { role: activeTurn.assigned_role } : undefined;
248
+ const timeoutEval = evaluateTimeouts({ config, state, turn: activeTurn, turnResult, now: new Date().toISOString() });
249
+ const allItems = [...timeoutEval.exceeded, ...timeoutEval.warnings];
250
+ if (allItems.length > 0) {
251
+ console.log('');
252
+ console.log(` ${chalk.dim('Timeouts:')}`);
253
+ for (const item of allItems) {
254
+ const isExceeded = timeoutEval.exceeded.includes(item);
255
+ const elapsed = item.elapsed_minutes != null ? `${item.elapsed_minutes}m` : '?';
256
+ const limit = item.limit_minutes != null ? `${item.limit_minutes}m` : '?';
257
+ const icon = isExceeded ? chalk.red('⚠') : chalk.yellow('◷');
258
+ const label = isExceeded ? chalk.red(`EXCEEDED ${item.scope}`) : chalk.yellow(`${item.scope}`);
259
+ console.log(` ${icon} ${label}: ${elapsed}/${limit} (action: ${item.action || 'n/a'})`);
260
+ }
261
+ }
262
+ }
263
+
240
264
  if (state?.budget_status) {
241
265
  console.log('');
242
266
  console.log(` ${chalk.dim('Budget:')} spent $${formatUsd(state.budget_status.spent_usd)} / remaining $${formatUsd(state.budget_status.remaining_usd)}`);
@@ -363,6 +387,31 @@ function renderWorkflowKitArtifactsSection(wkData) {
363
387
  }
364
388
  }
365
389
 
390
+ function renderLastGateFailure(failure, config) {
391
+ const entryRole = config?.routing?.[failure.phase]?.entry_role || null;
392
+ const suggestedCommand = entryRole ? `agentxchain assign ${entryRole}` : 'agentxchain assign <role>';
393
+ const requestLabel = failure.gate_type === 'run_completion'
394
+ ? 'Run completion'
395
+ : `${failure.from_phase || failure.phase} -> ${failure.to_phase || 'unknown'}`;
396
+
397
+ console.log('');
398
+ console.log(` ${chalk.dim('Gate fail:')} ${chalk.red.bold(failure.gate_type === 'run_completion' ? 'RUN COMPLETION' : 'PHASE TRANSITION')}`);
399
+ console.log(` ${chalk.dim('Gate:')} ${failure.gate_id || 'unknown'}`);
400
+ console.log(` ${chalk.dim('Request:')} ${requestLabel}`);
401
+ console.log(` ${chalk.dim('Source:')} ${failure.queued_request ? 'queued drain request' : 'direct request'}`);
402
+ console.log(` ${chalk.dim('When:')} ${failure.failed_at || 'unknown'}`);
403
+ if (failure.requested_by_turn) {
404
+ console.log(` ${chalk.dim('Turn:')} ${failure.requested_by_turn}`);
405
+ }
406
+ if (Array.isArray(failure.reasons) && failure.reasons.length > 0) {
407
+ console.log(` ${chalk.dim('Reasons:')}`);
408
+ for (const reason of failure.reasons) {
409
+ console.log(` ${chalk.red('•')} ${reason}`);
410
+ }
411
+ }
412
+ console.log(` ${chalk.dim('Action:')} ${chalk.cyan(suggestedCommand)} to keep working in ${failure.phase}`);
413
+ }
414
+
366
415
  function formatPhase(phase) {
367
416
  const colors = { discovery: chalk.blue, build: chalk.green, qa: chalk.yellow, deploy: chalk.magenta, blocked: chalk.red };
368
417
  return (colors[phase] || chalk.white)(phase);
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Approval Policy evaluator — pure library for conditional auto-approval.
3
+ *
4
+ * Sits between the gate evaluator and the state machine. When a gate returns
5
+ * 'awaiting_human_approval', this module evaluates the configured approval_policy
6
+ * to determine whether the gate should auto-approve or pause for human approval.
7
+ *
8
+ * Invariants:
9
+ * - Only evaluates when gateResult.action === 'awaiting_human_approval'
10
+ * - Can only relax the human-approval requirement, never override gate failures
11
+ * - --auto-approve on run overrides this entirely (handled in run.js)
12
+ * - Absent approval_policy → always require_human (backwards compatible)
13
+ */
14
+
15
+ /**
16
+ * Evaluate approval policy for a gate result.
17
+ *
18
+ * @param {object} params
19
+ * @param {object} params.gateResult - from evaluatePhaseExit or evaluateRunCompletion
20
+ * @param {'phase_transition'|'run_completion'} params.gateType
21
+ * @param {object} params.state - current run state
22
+ * @param {object} params.config - normalized config
23
+ * @returns {{ action: 'auto_approve'|'require_human', matched_rule: object|null, reason: string }}
24
+ */
25
+ export function evaluateApprovalPolicy({ gateResult, gateType, state, config }) {
26
+ const policy = config?.approval_policy;
27
+
28
+ // No policy configured → always require human
29
+ if (!policy) {
30
+ return { action: 'require_human', matched_rule: null, reason: 'no approval_policy configured' };
31
+ }
32
+
33
+ if (gateType === 'run_completion') {
34
+ return evaluateRunCompletionPolicy({ gateResult, state, config, policy });
35
+ }
36
+
37
+ return evaluatePhaseTransitionPolicy({ gateResult, state, config, policy });
38
+ }
39
+
40
+ function evaluateRunCompletionPolicy({ gateResult, state, config, policy }) {
41
+ const rc = policy.run_completion;
42
+ if (!rc || !rc.action) {
43
+ return { action: 'require_human', matched_rule: null, reason: 'no run_completion policy' };
44
+ }
45
+
46
+ if (rc.action === 'require_human') {
47
+ return { action: 'require_human', matched_rule: rc, reason: 'run_completion policy requires human approval' };
48
+ }
49
+
50
+ // action === 'auto_approve' — check conditions
51
+ if (rc.when) {
52
+ const conditionResult = checkConditions(rc.when, { gateResult, state, config });
53
+ if (!conditionResult.ok) {
54
+ return { action: 'require_human', matched_rule: rc, reason: conditionResult.reason };
55
+ }
56
+ }
57
+
58
+ return { action: 'auto_approve', matched_rule: rc, reason: 'run_completion policy auto-approved' };
59
+ }
60
+
61
+ function evaluatePhaseTransitionPolicy({ gateResult, state, config, policy }) {
62
+ const pt = policy.phase_transitions;
63
+ if (!pt) {
64
+ return { action: 'require_human', matched_rule: null, reason: 'no phase_transitions policy' };
65
+ }
66
+
67
+ const fromPhase = state.phase;
68
+ const toPhase = gateResult.next_phase;
69
+
70
+ // Check rules (first match wins)
71
+ if (Array.isArray(pt.rules)) {
72
+ for (const rule of pt.rules) {
73
+ if (ruleMatches(rule, fromPhase, toPhase)) {
74
+ if (rule.action === 'require_human') {
75
+ return { action: 'require_human', matched_rule: rule, reason: `rule matched: ${fromPhase} → ${toPhase} requires human` };
76
+ }
77
+ // action === 'auto_approve' — check conditions
78
+ if (rule.when) {
79
+ const conditionResult = checkConditions(rule.when, { gateResult, state, config });
80
+ if (!conditionResult.ok) {
81
+ return { action: 'require_human', matched_rule: rule, reason: conditionResult.reason };
82
+ }
83
+ }
84
+ return { action: 'auto_approve', matched_rule: rule, reason: `rule matched: ${fromPhase} → ${toPhase} auto-approved` };
85
+ }
86
+ }
87
+ }
88
+
89
+ // No rule matched → use default
90
+ const defaultAction = pt.default || 'require_human';
91
+ return { action: defaultAction, matched_rule: null, reason: `default: ${defaultAction}` };
92
+ }
93
+
94
+ function ruleMatches(rule, fromPhase, toPhase) {
95
+ if (rule.from_phase && rule.from_phase !== fromPhase) return false;
96
+ if (rule.to_phase && rule.to_phase !== toPhase) return false;
97
+ return true;
98
+ }
99
+
100
+ /**
101
+ * Check when conditions. Returns { ok, reason }.
102
+ */
103
+ function checkConditions(when, { gateResult, state, config }) {
104
+ // gate_passed: gate structural predicates must have passed
105
+ if (when.gate_passed === true && !gateResult.passed) {
106
+ return { ok: false, reason: 'condition gate_passed not met: gate did not pass structural predicates' };
107
+ }
108
+
109
+ // roles_participated: specified roles must have accepted turns in the current phase
110
+ if (Array.isArray(when.roles_participated) && when.roles_participated.length > 0) {
111
+ const phase = state.phase;
112
+ const history = Array.isArray(state.history) ? state.history : [];
113
+ for (const roleId of when.roles_participated) {
114
+ const participated = history.some(
115
+ turn => turn.phase === phase && turn.role === roleId,
116
+ );
117
+ if (!participated) {
118
+ return { ok: false, reason: `condition roles_participated not met: role "${roleId}" has no accepted turn in phase "${phase}"` };
119
+ }
120
+ }
121
+ }
122
+
123
+ // all_phases_visited: every routing phase must appear in history
124
+ if (when.all_phases_visited === true) {
125
+ const routingPhases = Object.keys(config.routing || {});
126
+ const visitedPhases = new Set(
127
+ (Array.isArray(state.history) ? state.history : []).map(t => t.phase),
128
+ );
129
+ // Also include current phase
130
+ visitedPhases.add(state.phase);
131
+ for (const phase of routingPhases) {
132
+ if (!visitedPhases.has(phase)) {
133
+ return { ok: false, reason: `condition all_phases_visited not met: phase "${phase}" never visited` };
134
+ }
135
+ }
136
+ }
137
+
138
+ return { ok: true, reason: 'all conditions met' };
139
+ }
@@ -4,6 +4,8 @@ import {
4
4
  deriveEscalationRecoveryAction,
5
5
  deriveHookTamperRecoveryAction,
6
6
  deriveNeedsHumanRecoveryAction,
7
+ derivePolicyEscalationDetail,
8
+ derivePolicyEscalationRecoveryAction,
7
9
  getActiveTurnCount,
8
10
  } from './governed-state.js';
9
11
 
@@ -53,6 +55,13 @@ function maybeRefreshRecoveryAction(state, config, persistedRecovery, turnRetain
53
55
  });
54
56
  }
55
57
 
58
+ if (typedReason === 'policy_escalation') {
59
+ return derivePolicyEscalationRecoveryAction(state, config, {
60
+ turnRetained,
61
+ turnId,
62
+ });
63
+ }
64
+
56
65
  if (typedReason === 'conflict_loop' && isLegacyConflictLoopRecoveryAction(currentAction)) {
57
66
  return deriveConflictLoopRecoveryAction(turnId);
58
67
  }
@@ -158,6 +167,32 @@ export function deriveRecoveryDescriptor(state, config = null) {
158
167
  };
159
168
  }
160
169
 
170
+ if (state.blocked_on.startsWith('policy:')) {
171
+ const policyId = state.blocked_on.slice('policy:'.length).trim() || null;
172
+ return {
173
+ typed_reason: 'policy_escalation',
174
+ owner: 'human',
175
+ recovery_action: derivePolicyEscalationRecoveryAction(state, config, {
176
+ turnRetained,
177
+ turnId: state.blocked_reason?.turn_id ?? null,
178
+ policyId,
179
+ }),
180
+ turn_retained: turnRetained,
181
+ detail: derivePolicyEscalationDetail(state, { policyId }),
182
+ };
183
+ }
184
+
185
+ if (state.blocked_on.startsWith('timeout:')) {
186
+ const scope = state.blocked_on.slice('timeout:'.length).trim() || 'unknown';
187
+ return {
188
+ typed_reason: 'timeout',
189
+ owner: 'operator',
190
+ recovery_action: 'agentxchain resume',
191
+ turn_retained: false,
192
+ detail: `${scope} timeout exceeded`,
193
+ };
194
+ }
195
+
161
196
  return {
162
197
  typed_reason: 'unknown_block',
163
198
  owner: 'human',
@@ -18,8 +18,10 @@ import { readResource } from './state-reader.js';
18
18
  import { FileWatcher } from './file-watcher.js';
19
19
  import { approvePendingDashboardGate } from './actions.js';
20
20
  import { readCoordinatorBlockerSnapshot } from './coordinator-blockers.js';
21
+ import { readCoordinatorTimeoutStatus } from './coordinator-timeout-status.js';
21
22
  import { readWorkflowKitArtifacts } from './workflow-kit-artifacts.js';
22
23
  import { readConnectorHealthSnapshot } from './connectors.js';
24
+ import { readTimeoutStatus } from './timeout-status.js';
23
25
  import { queryRunHistory } from '../run-history.js';
24
26
 
25
27
  const MIME_TYPES = {
@@ -282,6 +284,12 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
282
284
  return;
283
285
  }
284
286
 
287
+ if (pathname === '/api/coordinator/timeouts') {
288
+ const result = readCoordinatorTimeoutStatus(workspacePath);
289
+ writeJson(res, result.status, result.body);
290
+ return;
291
+ }
292
+
285
293
  if (pathname === '/api/workflow-kit-artifacts') {
286
294
  const result = readWorkflowKitArtifacts(workspacePath);
287
295
  writeJson(res, result.status, result.body);
@@ -294,6 +302,12 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
294
302
  return;
295
303
  }
296
304
 
305
+ if (pathname === '/api/timeouts') {
306
+ const result = readTimeoutStatus(workspacePath);
307
+ writeJson(res, result.status, result.body);
308
+ return;
309
+ }
310
+
297
311
  if (pathname === '/api/run-history') {
298
312
  const url = new URL(req.url, `http://${req.headers.host}`);
299
313
  const limit = url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit'), 10) : undefined;
@@ -0,0 +1,139 @@
1
+ import { join } from 'path';
2
+ import { loadProjectContext, loadProjectState } from '../config.js';
3
+ import { loadCoordinatorConfig } from '../coordinator-config.js';
4
+ import { loadCoordinatorState } from '../coordinator-state.js';
5
+ import { readJsonlFile } from './state-reader.js';
6
+ import { buildTimeoutConfigSummary, evaluateDashboardTimeoutPressure, extractTimeoutEvents } from './timeout-status.js';
7
+
8
+ function getCoordinatorConfigErrorResponse(errors) {
9
+ const issueList = Array.isArray(errors) ? errors : [];
10
+ const missing = issueList.some((error) => typeof error === 'string' && error.startsWith('config_missing:'));
11
+
12
+ return {
13
+ ok: false,
14
+ status: missing ? 404 : 422,
15
+ body: {
16
+ ok: false,
17
+ code: missing ? 'coordinator_config_missing' : 'coordinator_config_invalid',
18
+ error: missing
19
+ ? 'Coordinator config not found. Run `agentxchain multi init` first.'
20
+ : 'Coordinator config is invalid.',
21
+ errors: issueList,
22
+ },
23
+ };
24
+ }
25
+
26
+ function emptyLiveTimeouts() {
27
+ return { exceeded: [], warnings: [] };
28
+ }
29
+
30
+ function readRepoTimeoutSnapshot(repoId, repo, repoRun) {
31
+ const context = loadProjectContext(repo.resolved_path);
32
+ if (!context) {
33
+ return {
34
+ repo_id: repoId,
35
+ path: repo.path,
36
+ run_id: repoRun?.run_id ?? null,
37
+ status: repoRun?.status ?? null,
38
+ phase: repoRun?.phase ?? null,
39
+ configured: false,
40
+ config: null,
41
+ live: null,
42
+ events: [],
43
+ error: {
44
+ code: 'repo_config_missing',
45
+ error: `Repo "${repoId}" config could not be loaded.`,
46
+ },
47
+ };
48
+ }
49
+
50
+ const state = loadProjectState(context.root, context.config);
51
+ const agentxchainDir = join(context.root, '.agentxchain');
52
+ const events = extractTimeoutEvents(readJsonlFile(agentxchainDir, 'decision-ledger.jsonl'));
53
+ const configured = Boolean(context.config.timeouts);
54
+
55
+ if (!state) {
56
+ return {
57
+ repo_id: repoId,
58
+ path: repo.path,
59
+ run_id: repoRun?.run_id ?? null,
60
+ status: repoRun?.status ?? null,
61
+ phase: repoRun?.phase ?? null,
62
+ configured,
63
+ config: buildTimeoutConfigSummary(context.config.timeouts, context.config.routing),
64
+ live: configured ? emptyLiveTimeouts() : null,
65
+ events,
66
+ error: {
67
+ code: 'repo_state_missing',
68
+ error: `Repo "${repoId}" governed state is missing.`,
69
+ },
70
+ };
71
+ }
72
+
73
+ const live = configured
74
+ ? evaluateDashboardTimeoutPressure(context.config, state, new Date())
75
+ : null;
76
+
77
+ return {
78
+ repo_id: repoId,
79
+ path: repo.path,
80
+ run_id: state.run_id ?? repoRun?.run_id ?? null,
81
+ status: state.status ?? repoRun?.status ?? null,
82
+ phase: state.phase ?? repoRun?.phase ?? null,
83
+ configured,
84
+ config: buildTimeoutConfigSummary(context.config.timeouts, context.config.routing),
85
+ live,
86
+ events,
87
+ error: null,
88
+ };
89
+ }
90
+
91
+ export function readCoordinatorTimeoutStatus(workspacePath) {
92
+ const configResult = loadCoordinatorConfig(workspacePath);
93
+ if (!configResult.ok) {
94
+ return getCoordinatorConfigErrorResponse(configResult.errors);
95
+ }
96
+
97
+ const coordinatorState = loadCoordinatorState(workspacePath);
98
+ if (!coordinatorState) {
99
+ return {
100
+ ok: false,
101
+ status: 404,
102
+ body: {
103
+ ok: false,
104
+ code: 'coordinator_state_missing',
105
+ error: 'Coordinator state not found. Run `agentxchain multi init` first.',
106
+ },
107
+ };
108
+ }
109
+
110
+ const coordinatorDir = join(workspacePath, '.agentxchain', 'multirepo');
111
+ const coordinatorEvents = extractTimeoutEvents(readJsonlFile(coordinatorDir, 'decision-ledger.jsonl'));
112
+ const repos = configResult.config.repo_order.map((repoId) => (
113
+ readRepoTimeoutSnapshot(repoId, configResult.config.repos[repoId], coordinatorState.repo_runs?.[repoId] ?? null)
114
+ ));
115
+
116
+ const summary = {
117
+ repo_count: repos.length,
118
+ configured_repo_count: repos.filter((repo) => repo.configured).length,
119
+ repos_with_live_exceeded: repos.filter((repo) => (repo.live?.exceeded?.length || 0) > 0).length,
120
+ repos_with_live_warnings: repos.filter((repo) => (repo.live?.warnings?.length || 0) > 0).length,
121
+ repo_event_count: repos.reduce((sum, repo) => sum + repo.events.length, 0),
122
+ coordinator_event_count: coordinatorEvents.length,
123
+ };
124
+
125
+ return {
126
+ ok: true,
127
+ status: 200,
128
+ body: {
129
+ ok: true,
130
+ super_run_id: coordinatorState.super_run_id ?? null,
131
+ status: coordinatorState.status ?? null,
132
+ phase: coordinatorState.phase ?? null,
133
+ blocked_reason: coordinatorState.blocked_reason ?? null,
134
+ summary,
135
+ coordinator_events: coordinatorEvents,
136
+ repos,
137
+ },
138
+ };
139
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Timeout status — computed dashboard endpoint.
3
+ *
4
+ * Reads agentxchain.json config, .agentxchain/state.json, and the decision
5
+ * ledger to derive live timeout pressure and persisted timeout events.
6
+ *
7
+ * See: TIMEOUT_DASHBOARD_SURFACE_SPEC.md
8
+ */
9
+
10
+ import { join } from 'path';
11
+ import { loadProjectContext, loadProjectState } from '../config.js';
12
+ import { evaluateTimeouts } from '../timeout-evaluator.js';
13
+ import { readJsonlFile } from './state-reader.js';
14
+
15
+ /**
16
+ * Extract timeout events from raw decision-ledger entries.
17
+ * Mirrors the shape of report.js extractTimeoutEventDigest but operates
18
+ * on in-memory ledger array rather than export artifacts.
19
+ */
20
+ export function extractTimeoutEvents(ledgerEntries) {
21
+ if (!Array.isArray(ledgerEntries) || ledgerEntries.length === 0) return [];
22
+ return ledgerEntries
23
+ .filter((d) => typeof d?.type === 'string' && d.type.startsWith('timeout'))
24
+ .map((d) => ({
25
+ type: d.type,
26
+ scope: d.scope || null,
27
+ phase: d.phase || null,
28
+ turn_id: d.turn_id || null,
29
+ limit_minutes: typeof d.limit_minutes === 'number' ? d.limit_minutes : null,
30
+ elapsed_minutes: typeof d.elapsed_minutes === 'number' ? d.elapsed_minutes : null,
31
+ exceeded_by_minutes: typeof d.exceeded_by_minutes === 'number' ? d.exceeded_by_minutes : null,
32
+ action: d.action || null,
33
+ timestamp: d.timestamp || null,
34
+ }));
35
+ }
36
+
37
+ /**
38
+ * Flatten per-phase routing timeout overrides into a display-friendly array.
39
+ */
40
+ export function flattenPhaseOverrides(routing) {
41
+ if (!routing) return [];
42
+ const overrides = [];
43
+ for (const [phase, route] of Object.entries(routing)) {
44
+ if (route.timeout_minutes || route.timeout_action) {
45
+ overrides.push({
46
+ phase,
47
+ limit_minutes: typeof route.timeout_minutes === 'number' ? route.timeout_minutes : null,
48
+ action: route.timeout_action || null,
49
+ });
50
+ }
51
+ }
52
+ return overrides;
53
+ }
54
+
55
+ /**
56
+ * Read timeout status for the dashboard.
57
+ *
58
+ * @param {string} workspacePath — project root (parent of .agentxchain/)
59
+ * @returns {{ ok: boolean, status: number, body: object }}
60
+ */
61
+ export function buildTimeoutConfigSummary(timeouts, routing) {
62
+ if (!timeouts) return null;
63
+ return {
64
+ per_turn_minutes: timeouts.per_turn_minutes || null,
65
+ per_phase_minutes: timeouts.per_phase_minutes || null,
66
+ per_run_minutes: timeouts.per_run_minutes || null,
67
+ action: timeouts.action || 'escalate',
68
+ phase_overrides: flattenPhaseOverrides(routing),
69
+ };
70
+ }
71
+
72
+ function emptyLiveTimeouts() {
73
+ return { exceeded: [], warnings: [] };
74
+ }
75
+
76
+ function getActiveTurns(state) {
77
+ if (!state?.active_turns || typeof state.active_turns !== 'object' || Array.isArray(state.active_turns)) {
78
+ return [];
79
+ }
80
+ return Object.values(state.active_turns).filter((turn) => turn && typeof turn === 'object');
81
+ }
82
+
83
+ function annotateTurnTimeout(result, state, turn) {
84
+ return {
85
+ ...result,
86
+ phase: result.phase || state?.phase || null,
87
+ turn_id: turn?.turn_id || null,
88
+ role_id: turn?.assigned_role || null,
89
+ };
90
+ }
91
+
92
+ export function evaluateDashboardTimeoutPressure(config, state, now = new Date()) {
93
+ if (!config?.timeouts || state?.status !== 'active') {
94
+ return emptyLiveTimeouts();
95
+ }
96
+
97
+ const base = evaluateTimeouts({ config, state, now });
98
+ const live = {
99
+ exceeded: [...base.exceeded],
100
+ warnings: [...base.warnings],
101
+ };
102
+
103
+ for (const turn of getActiveTurns(state)) {
104
+ const turnEval = evaluateTimeouts({ config, state, turn, now });
105
+ for (const item of turnEval.exceeded) {
106
+ if (item.scope === 'turn') {
107
+ live.exceeded.push(annotateTurnTimeout(item, state, turn));
108
+ }
109
+ }
110
+ for (const item of turnEval.warnings) {
111
+ if (item.scope === 'turn') {
112
+ live.warnings.push(annotateTurnTimeout(item, state, turn));
113
+ }
114
+ }
115
+ }
116
+
117
+ return live;
118
+ }
119
+
120
+ function loadDashboardTimeoutContext(workspacePath) {
121
+ const context = loadProjectContext(workspacePath);
122
+ if (!context || context.config?.protocol_mode !== 'governed') {
123
+ return {
124
+ ok: false,
125
+ status: 404,
126
+ body: {
127
+ ok: false,
128
+ code: 'config_missing',
129
+ error: 'Project config not found. Run `agentxchain init --governed` first.',
130
+ },
131
+ };
132
+ }
133
+
134
+ const { root, config } = context;
135
+ const state = loadProjectState(root, config);
136
+ const agentxchainDir = join(root, '.agentxchain');
137
+ if (!state) {
138
+ return {
139
+ ok: false,
140
+ status: 404,
141
+ body: {
142
+ ok: false,
143
+ code: 'state_missing',
144
+ error: 'Run state not found. Run `agentxchain init --governed` first.',
145
+ },
146
+ };
147
+ }
148
+
149
+ return {
150
+ ok: true,
151
+ root,
152
+ config,
153
+ state,
154
+ agentxchainDir,
155
+ };
156
+ }
157
+
158
+ export function readTimeoutStatus(workspacePath) {
159
+ const contextResult = loadDashboardTimeoutContext(workspacePath);
160
+ if (!contextResult.ok) {
161
+ return contextResult;
162
+ }
163
+
164
+ const { config, state, agentxchainDir } = contextResult;
165
+ const timeouts = config.timeouts;
166
+ if (!timeouts) {
167
+ return {
168
+ ok: true,
169
+ status: 200,
170
+ body: {
171
+ ok: true,
172
+ configured: false,
173
+ config: null,
174
+ live: null,
175
+ events: [],
176
+ },
177
+ };
178
+ }
179
+
180
+ // Config summary
181
+ const configSummary = buildTimeoutConfigSummary(timeouts, config.routing);
182
+
183
+ // Live timeout evaluation — only meaningful when the run is active
184
+ const live = evaluateDashboardTimeoutPressure(config, state, new Date());
185
+
186
+ // Persisted timeout events from the decision ledger
187
+ const ledger = readJsonlFile(agentxchainDir, 'decision-ledger.jsonl');
188
+ const events = extractTimeoutEvents(ledger);
189
+
190
+ return {
191
+ ok: true,
192
+ status: 200,
193
+ body: {
194
+ ok: true,
195
+ configured: true,
196
+ config: configSummary,
197
+ live,
198
+ events,
199
+ },
200
+ };
201
+ }