agentxchain 2.108.0 → 2.110.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.
@@ -521,11 +521,13 @@ program
521
521
  program
522
522
  .command('approve-transition')
523
523
  .description('Approve a pending phase transition that requires human sign-off')
524
+ .option('--dry-run', 'Show configured gate actions without executing approval')
524
525
  .action(approveTransitionCommand);
525
526
 
526
527
  program
527
528
  .command('approve-completion')
528
529
  .description('Approve a pending run completion that requires human sign-off')
530
+ .option('--dry-run', 'Show configured gate actions without executing approval')
529
531
  .action(approveCompletionCommand);
530
532
 
531
533
  program
package/dashboard/app.js CHANGED
@@ -29,8 +29,8 @@ const VIEWS = {
29
29
  delegations: { fetch: ['state', 'history'], render: renderDelegations },
30
30
  ledger: { fetch: ['state', 'ledger', 'coordinatorState', 'coordinatorLedger', 'repoDecisionsSummary'], render: renderLedger },
31
31
  hooks: { fetch: ['audit', 'annotations', 'coordinatorAudit', 'coordinatorAnnotations'], render: renderHooks },
32
- blocked: { fetch: ['state', 'audit', 'coordinatorState', 'coordinatorAudit', 'coordinatorBlockers', 'coordinatorRepoStatusRows'], render: renderBlocked },
33
- gate: { fetch: ['state', 'history', 'coordinatorState', 'coordinatorHistory', 'coordinatorBarriers'], render: renderGate },
32
+ blocked: { fetch: ['state', 'audit', 'coordinatorState', 'coordinatorAudit', 'coordinatorBlockers', 'coordinatorRepoStatusRows', 'gateActions'], render: renderBlocked },
33
+ gate: { fetch: ['state', 'history', 'coordinatorState', 'coordinatorHistory', 'coordinatorBarriers', 'gateActions'], render: renderGate },
34
34
  initiative: { fetch: ['coordinatorState', 'coordinatorBarriers', 'barrierLedger', 'coordinatorBlockers', 'coordinatorRepoStatusRows'], render: renderInitiative },
35
35
  'cross-repo': { fetch: ['coordinatorState', 'coordinatorHistory'], render: renderCrossRepo },
36
36
  blockers: { fetch: ['coordinatorBlockers'], render: renderBlockers },
@@ -62,6 +62,7 @@ const API_MAP = {
62
62
  runHistory: '/api/run-history',
63
63
  timeouts: '/api/timeouts',
64
64
  coordinatorTimeouts: '/api/coordinator/timeouts',
65
+ gateActions: '/api/gate-actions',
65
66
  events: '/api/events?type=turn_conflicted&limit=10',
66
67
  };
67
68
 
@@ -79,11 +80,14 @@ const viewState = {
79
80
  hookName: 'all',
80
81
  },
81
82
  };
83
+ const DASHBOARD_POLL_INTERVAL_MS = 60 * 1000;
82
84
 
83
85
  let activeViewName = null;
84
86
  let activeViewData = null;
85
87
  let dashboardSession = null;
86
88
  let actionInFlight = false;
89
+ let pollInFlight = false;
90
+ let pollTimer = null;
87
91
  const liveObserverState = {
88
92
  connected: false,
89
93
  lastRefreshAt: null,
@@ -146,7 +150,7 @@ async function pickInitialView() {
146
150
  }
147
151
 
148
152
  function buildRenderData(viewName, data) {
149
- const liveMeta = viewName === 'timeline'
153
+ const liveMeta = (viewName === 'timeline' || viewName === 'timeouts')
150
154
  ? buildLiveMeta({
151
155
  connected: liveObserverState.connected,
152
156
  lastRefreshAt: liveObserverState.lastRefreshAt,
@@ -274,6 +278,39 @@ function rerenderActiveView() {
274
278
  renderView(activeViewName, activeViewData);
275
279
  }
276
280
 
281
+ async function pollDashboard({ refreshView = false } = {}) {
282
+ if (pollInFlight) return;
283
+ pollInFlight = true;
284
+ try {
285
+ await fetch('/api/poll', { cache: 'no-store' });
286
+ if (refreshView) {
287
+ await loadView(currentView());
288
+ }
289
+ } catch {
290
+ // Best-effort heartbeat only
291
+ } finally {
292
+ pollInFlight = false;
293
+ }
294
+ }
295
+
296
+ function startDashboardPolling() {
297
+ if (pollTimer) return;
298
+
299
+ const tick = () => {
300
+ if (document.visibilityState === 'hidden') return;
301
+ if (actionInFlight) return;
302
+ void pollDashboard({ refreshView: true });
303
+ };
304
+
305
+ pollTimer = setInterval(tick, DASHBOARD_POLL_INTERVAL_MS);
306
+
307
+ document.addEventListener('visibilitychange', () => {
308
+ if (document.visibilityState === 'visible') {
309
+ void pollDashboard({ refreshView: true });
310
+ }
311
+ });
312
+ }
313
+
277
314
  // ── WebSocket connection ──────────────────────────────────────────────────
278
315
 
279
316
  let ws = null;
@@ -291,7 +328,9 @@ function connect() {
291
328
  statusLabel.textContent = 'Connected';
292
329
  reconnectDelay = 1000;
293
330
  liveObserverState.connected = true;
294
- loadView(currentView());
331
+ void pollDashboard().finally(() => {
332
+ loadView(currentView());
333
+ });
295
334
  };
296
335
 
297
336
  ws.onmessage = (event) => {
@@ -501,5 +540,6 @@ function fallbackSelect(el) {
501
540
 
502
541
  Promise.all([pickInitialView(), loadSession()]).finally(() => {
503
542
  updateNav();
543
+ startDashboardPolling();
504
544
  connect();
505
545
  });
@@ -92,6 +92,56 @@ function formatCoordinatorRepoCardMeta(row) {
92
92
  return parts.join(' | ') || '-';
93
93
  }
94
94
 
95
+ function getGateActionDryRunCommand(gateActions, state) {
96
+ const gateType = gateActions?.latest_attempt?.gate_type
97
+ || (state?.pending_run_completion ? 'run_completion' : null)
98
+ || (state?.pending_phase_transition ? 'phase_transition' : null);
99
+
100
+ return gateType === 'run_completion'
101
+ ? 'agentxchain approve-completion --dry-run'
102
+ : 'agentxchain approve-transition --dry-run';
103
+ }
104
+
105
+ function renderGateActionFailure(gateActions, state) {
106
+ if (!gateActions?.latest_attempt || gateActions.latest_attempt.status !== 'failed') return '';
107
+
108
+ const attempt = gateActions.latest_attempt;
109
+ const actions = Array.isArray(attempt.actions) ? attempt.actions : [];
110
+ const dryRunCommand = getGateActionDryRunCommand(gateActions, state);
111
+
112
+ let html = `<div class="section"><h3>Gate Action Failure</h3>`;
113
+ html += `<dl class="detail-list">`;
114
+ html += `<dt>Attempt</dt><dd class="mono">${esc(attempt.attempt_id || '-')}</dd>`;
115
+ html += `<dt>Gate</dt><dd class="mono">${esc(attempt.gate_id || '-')}</dd>`;
116
+ html += `<dt>Attempted At</dt><dd class="mono">${esc(attempt.attempted_at || '-')}</dd>`;
117
+ html += `</dl>`;
118
+
119
+ if (actions.length > 0) {
120
+ html += `<div class="annotation-list">`;
121
+ for (const action of actions) {
122
+ const label = action.action_label || action.command || `action ${action.action_index || '?'}`;
123
+ const outcome = action.status === 'failed'
124
+ ? (action.timed_out ? `⏱ timed out after ${action.timeout_ms}ms` : '❌ failed')
125
+ : '✅ succeeded';
126
+ const exitStr = action.timed_out ? '' : (action.exit_code != null ? ` (exit ${action.exit_code})` : '');
127
+ html += `<div class="annotation-card">
128
+ <span class="mono">${esc(String(action.action_index || '?'))}.</span>
129
+ <span>${esc(label)}</span>
130
+ <span>${esc(outcome)}${esc(exitStr)}</span>
131
+ </div>`;
132
+ if (action.status === 'failed' && action.stderr_tail) {
133
+ html += `<pre class="recovery-command mono">${esc(action.stderr_tail)}</pre>`;
134
+ }
135
+ }
136
+ html += `</div>`;
137
+ }
138
+
139
+ html += `<p class="recovery-hint">Re-run with dry-run first:</p>`;
140
+ html += `<pre class="recovery-command mono" data-copy="${esc(dryRunCommand)}">${esc(dryRunCommand)}</pre>`;
141
+ html += `</div>`;
142
+ return html;
143
+ }
144
+
95
145
  export function render({
96
146
  state,
97
147
  audit = [],
@@ -99,6 +149,7 @@ export function render({
99
149
  coordinatorAudit = [],
100
150
  coordinatorBlockers = null,
101
151
  coordinatorRepoStatusRows = null,
152
+ gateActions = null,
102
153
  }) {
103
154
  const activeState = state?.status === 'blocked' ? state : coordinatorState;
104
155
  const activeAudit = activeState === state ? audit : coordinatorAudit;
@@ -168,6 +219,12 @@ export function render({
168
219
  </div>`;
169
220
  }
170
221
 
222
+ // Gate-action failure detail (only for gate_action_failed blocks)
223
+ const category = String(reason).toLowerCase();
224
+ if (category.includes('gate_action_failed') && !isCoordinator) {
225
+ html += renderGateActionFailure(gateActions, activeState);
226
+ }
227
+
171
228
  if (runtimeGuidance.length > 0) {
172
229
  html += `<div class="section"><h3>Runtime Guidance</h3><div class="annotation-list">`;
173
230
  for (const entry of runtimeGuidance) {
@@ -210,12 +210,58 @@ function aggregateCoordinatorEvidence(entries) {
210
210
  return { summaries, decisions, objections: [], risks: [], files };
211
211
  }
212
212
 
213
+ function renderGateActionsSection(gateActions) {
214
+ if (!gateActions) return '';
215
+
216
+ const configured = Array.isArray(gateActions.configured) ? gateActions.configured : [];
217
+ const attempt = gateActions.latest_attempt || null;
218
+
219
+ if (configured.length === 0 && !attempt) return '';
220
+
221
+ let html = `<div class="gate-support"><p><strong>Gate Actions:</strong></p>`;
222
+
223
+ if (configured.length > 0) {
224
+ html += `<ul>`;
225
+ for (const action of configured) {
226
+ const label = action.label || action.run || `action ${action.index || '?'}`;
227
+ html += `<li><div><span class="mono">${esc(String(action.index || '?'))}.</span> ${esc(label)}</div>`;
228
+ if (action.run) {
229
+ html += `<div class="mono">${esc(action.run)}</div>`;
230
+ }
231
+ html += `</li>`;
232
+ }
233
+ html += `</ul>`;
234
+ }
235
+
236
+ if (attempt) {
237
+ const statusLabel = attempt.status === 'failed' ? '❌ Failed' : '✅ Succeeded';
238
+ html += `<p><strong>Last Attempt:</strong> ${esc(statusLabel)} at ${esc(attempt.attempted_at || 'unknown')}</p>`;
239
+ if (Array.isArray(attempt.actions) && attempt.actions.length > 0) {
240
+ html += `<ul>`;
241
+ for (const a of attempt.actions) {
242
+ const aLabel = a.action_label || a.command || `action ${a.action_index || '?'}`;
243
+ const outcome = a.status === 'failed'
244
+ ? (a.timed_out ? '⏱' : '❌')
245
+ : '✅';
246
+ const timeoutStr = a.timed_out ? ` timed out after ${a.timeout_ms}ms` : '';
247
+ const exitStr = a.timed_out ? '' : (a.exit_code != null ? ` (exit ${a.exit_code})` : '');
248
+ html += `<li>${outcome} ${esc(aLabel)}${esc(exitStr)}${esc(timeoutStr)}</li>`;
249
+ }
250
+ html += `</ul>`;
251
+ }
252
+ }
253
+
254
+ html += `</div>`;
255
+ return html;
256
+ }
257
+
213
258
  export function render({
214
259
  state,
215
260
  history = [],
216
261
  coordinatorState = null,
217
262
  coordinatorHistory = [],
218
263
  coordinatorBarriers = {},
264
+ gateActions = null,
219
265
  }) {
220
266
  const repoPendingTransition = state?.pending_phase_transition || null;
221
267
  const repoPendingCompletion = state?.pending_run_completion || null;
@@ -284,6 +330,9 @@ export function render({
284
330
  )).join('')}</ul></div>`;
285
331
  }
286
332
  }
333
+ if (!isCoordinator) {
334
+ html += renderGateActionsSection(gateActions);
335
+ }
287
336
  html += renderApproveControls({
288
337
  buttonLabel: isCoordinator ? 'Approve Coordinator Gate' : 'Approve Transition',
289
338
  cliCommand: isCoordinator ? 'agentxchain multi approve-gate' : 'agentxchain approve-transition',
@@ -329,6 +378,9 @@ export function render({
329
378
  if (evidence.files.length > 0) {
330
379
  html += `<div class="gate-support"><p><strong>Files Changed:</strong></p><ul>${evidence.files.map(f => `<li class="mono">${esc(f)}</li>`).join('')}</ul></div>`;
331
380
  }
381
+ if (!isCoordinator) {
382
+ html += renderGateActionsSection(gateActions);
383
+ }
332
384
  html += renderApproveControls({
333
385
  buttonLabel: isCoordinator ? 'Approve Coordinator Gate' : 'Approve Completion',
334
386
  cliCommand: isCoordinator ? 'agentxchain multi approve-gate' : 'agentxchain approve-completion',
@@ -4,9 +4,13 @@
4
4
  * Pure render function: takes data from /api/timeouts, returns HTML.
5
5
  * All evaluation is server-side. This view renders the snapshot.
6
6
  *
7
+ * Receives liveMeta from the live-observer for websocket freshness display.
8
+ *
7
9
  * See: TIMEOUT_DASHBOARD_SURFACE_SPEC.md
8
10
  */
9
11
 
12
+ import { renderLiveStatus } from './live-status.js';
13
+
10
14
  function esc(str) {
11
15
  if (!str) return '';
12
16
  return String(str)
@@ -82,14 +86,22 @@ function renderConfigTable(config) {
82
86
  }
83
87
 
84
88
  function renderLivePressure(live) {
89
+ const liveContext = live?.context || null;
85
90
  const hasExceeded = live.exceeded && live.exceeded.length > 0;
86
91
  const hasWarnings = live.warnings && live.warnings.length > 0;
92
+ const approvalNote = liveContext?.awaiting_approval
93
+ ? `<p style="color:var(--yellow)">Approval wait does not mutate timeout state, but phase/run clocks continue until the next accepted turn.${liveContext.requested_at ? ` Requested: <code>${esc(liveContext.requested_at)}</code>.` : ''}</p>`
94
+ : '';
87
95
 
88
96
  if (!hasExceeded && !hasWarnings) {
89
- return `<div class="section"><h3>Live Pressure</h3><p style="color:var(--green)">No timeouts exceeded or approaching limits.</p></div>`;
97
+ const message = liveContext?.awaiting_approval
98
+ ? `<p style="color:var(--text-dim)">No current phase/run timeout pressure during this approval wait.</p>`
99
+ : `<p style="color:var(--green)">No timeouts exceeded or approaching limits.</p>`;
100
+ return `<div class="section"><h3>Live Pressure</h3>${approvalNote}${message}</div>`;
90
101
  }
91
102
 
92
103
  let html = `<div class="section"><h3>Live Pressure</h3>
104
+ ${approvalNote}
93
105
  <table class="data-table">
94
106
  <thead><tr><th>Status</th><th>Scope</th><th>Turn</th><th>Phase</th><th>Elapsed</th><th>Limit</th><th>Exceeded By</th><th>Action</th></tr></thead>
95
107
  <tbody>`;
@@ -157,7 +169,7 @@ function renderEvents(events) {
157
169
  return html;
158
170
  }
159
171
 
160
- export function render({ timeouts }) {
172
+ export function render({ timeouts, liveMeta }) {
161
173
  if (!timeouts) {
162
174
  return `<div class="placeholder"><h2>Timeouts</h2><p>No timeout data available. Ensure a governed run is active.</p></div>`;
163
175
  }
@@ -175,6 +187,9 @@ export function render({ timeouts }) {
175
187
 
176
188
  let html = `<div class="timeouts-view">`;
177
189
 
190
+ // Freshness banner — timeout data is time-sensitive so operators need visibility
191
+ html += renderLiveStatus(liveMeta);
192
+
178
193
  // Header
179
194
  html += `<div class="run-header"><div class="run-meta">`;
180
195
  html += `<span class="phase-label"><strong>Timeouts</strong></span>`;
@@ -190,7 +205,10 @@ export function render({ timeouts }) {
190
205
 
191
206
  // Live pressure
192
207
  if (timeouts.live) {
193
- html += renderLivePressure(timeouts.live);
208
+ html += renderLivePressure({
209
+ ...timeouts.live,
210
+ context: timeouts.live_context || null,
211
+ });
194
212
  }
195
213
 
196
214
  // Persisted events
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.108.0",
3
+ "version": "2.110.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,11 +37,31 @@ export async function approveCompletionCommand(opts) {
37
37
  console.log(` ${chalk.dim('Turn:')} ${pc.requested_by_turn}`);
38
38
  console.log('');
39
39
 
40
- const result = approveRunCompletion(root, config);
40
+ const result = approveRunCompletion(root, config, { dryRun: opts.dryRun });
41
+
42
+ if (result.dry_run) {
43
+ console.log(chalk.cyan(' Dry Run: gate approval preview only'));
44
+ if (result.gate_actions?.length > 0) {
45
+ console.log(` ${chalk.dim('Gate actions:')} ${result.gate_actions.length}`);
46
+ for (const action of result.gate_actions) {
47
+ const timeoutHint = action.timeout_ms && action.timeout_ms !== 900_000 ? chalk.dim(` [timeout: ${action.timeout_ms}ms]`) : '';
48
+ console.log(` ${action.index}. ${action.label || action.run}${timeoutHint}`);
49
+ if (action.label) {
50
+ console.log(` ${chalk.dim(action.run)}`);
51
+ }
52
+ }
53
+ } else {
54
+ console.log(` ${chalk.dim('Gate actions:')} none configured`);
55
+ }
56
+ console.log('');
57
+ return;
58
+ }
41
59
 
42
60
  if (!result.ok) {
43
61
  if (result.error_code?.startsWith('hook_') || result.error_code === 'hook_blocked') {
44
62
  printGateHookFailure(result, 'run_completion', pc);
63
+ } else if (result.error_code === 'gate_action_failed') {
64
+ printGateActionFailure(result, pc);
45
65
  } else {
46
66
  console.log(chalk.red(` Failed: ${result.error}`));
47
67
  }
@@ -49,6 +69,9 @@ export async function approveCompletionCommand(opts) {
49
69
  }
50
70
 
51
71
  console.log(chalk.green(' \u2713 Run completed'));
72
+ if (result.gateActionRun?.actions?.length > 0) {
73
+ console.log(` ${chalk.dim('Gate actions:')} ${result.gateActionRun.actions.length} completed`);
74
+ }
52
75
  console.log(chalk.dim(` Completed at: ${result.state.completed_at}`));
53
76
  console.log('');
54
77
  }
@@ -78,3 +101,24 @@ function printGateHookFailure(result, gateType, gateInfo) {
78
101
  }
79
102
  console.log('');
80
103
  }
104
+
105
+ function printGateActionFailure(result, gateInfo) {
106
+ const failure = result.gateActionRun?.failed_action;
107
+ const exitLabel = failure?.timed_out
108
+ ? `timeout after ${failure.timeout_ms}ms`
109
+ : failure?.exit_code ?? failure?.signal ?? 'unknown';
110
+ const stderrOrError = failure?.stderr_tail || failure?.spawn_error || null;
111
+
112
+ console.log('');
113
+ console.log(chalk.yellow(' Run Completion Blocked By Gate Action'));
114
+ console.log(chalk.dim(' ' + '-'.repeat(44)));
115
+ console.log('');
116
+ console.log(` ${chalk.dim('Gate:')} ${gateInfo.gate}`);
117
+ console.log(` ${chalk.dim('Action:')} ${failure?.action_label || failure?.command || '(unknown)'}`);
118
+ console.log(` ${chalk.dim('Exit:')} ${exitLabel}`);
119
+ if (stderrOrError) {
120
+ console.log(` ${chalk.dim('stderr:')} ${stderrOrError}`);
121
+ }
122
+ console.log(` ${chalk.dim('Retry:')} agentxchain approve-completion`);
123
+ console.log('');
124
+ }
@@ -36,11 +36,31 @@ export async function approveTransitionCommand(opts) {
36
36
  console.log(` ${chalk.dim('Turn:')} ${pt.requested_by_turn}`);
37
37
  console.log('');
38
38
 
39
- const result = approvePhaseTransition(root, config);
39
+ const result = approvePhaseTransition(root, config, { dryRun: opts.dryRun });
40
+
41
+ if (result.dry_run) {
42
+ console.log(chalk.cyan(' Dry Run: gate approval preview only'));
43
+ if (result.gate_actions?.length > 0) {
44
+ console.log(` ${chalk.dim('Gate actions:')} ${result.gate_actions.length}`);
45
+ for (const action of result.gate_actions) {
46
+ const timeoutHint = action.timeout_ms && action.timeout_ms !== 900_000 ? chalk.dim(` [timeout: ${action.timeout_ms}ms]`) : '';
47
+ console.log(` ${action.index}. ${action.label || action.run}${timeoutHint}`);
48
+ if (action.label) {
49
+ console.log(` ${chalk.dim(action.run)}`);
50
+ }
51
+ }
52
+ } else {
53
+ console.log(` ${chalk.dim('Gate actions:')} none configured`);
54
+ }
55
+ console.log('');
56
+ return;
57
+ }
40
58
 
41
59
  if (!result.ok) {
42
60
  if (result.error_code?.startsWith('hook_') || result.error_code === 'hook_blocked') {
43
61
  printGateHookFailure(result, 'phase_transition', pt);
62
+ } else if (result.error_code === 'gate_action_failed') {
63
+ printGateActionFailure(result, 'phase_transition', pt);
44
64
  } else {
45
65
  console.log(chalk.red(` Failed: ${result.error}`));
46
66
  }
@@ -48,6 +68,9 @@ export async function approveTransitionCommand(opts) {
48
68
  }
49
69
 
50
70
  console.log(chalk.green(` ✓ Phase advanced: ${pt.from} → ${pt.to}`));
71
+ if (result.gateActionRun?.actions?.length > 0) {
72
+ console.log(` ${chalk.dim('Gate actions:')} ${result.gateActionRun.actions.length} completed`);
73
+ }
51
74
  console.log(chalk.dim(` Run status: ${result.state.status}`));
52
75
  console.log('');
53
76
  console.log(chalk.dim(` Next: agentxchain step (to run the first turn in ${pt.to} phase)`));
@@ -83,3 +106,28 @@ function printGateHookFailure(result, gateType, gateInfo) {
83
106
  }
84
107
  console.log('');
85
108
  }
109
+
110
+ function printGateActionFailure(result, gateType, gateInfo) {
111
+ const failure = result.gateActionRun?.failed_action;
112
+ const exitLabel = failure?.timed_out
113
+ ? `timeout after ${failure.timeout_ms}ms`
114
+ : failure?.exit_code ?? failure?.signal ?? 'unknown';
115
+ const stderrOrError = failure?.stderr_tail || failure?.spawn_error || null;
116
+
117
+ console.log('');
118
+ console.log(chalk.yellow(` ${gateType === 'phase_transition' ? 'Phase Transition' : 'Run Completion'} Blocked By Gate Action`));
119
+ console.log(chalk.dim(' ' + '-'.repeat(44)));
120
+ console.log('');
121
+ if (gateType === 'phase_transition') {
122
+ console.log(` ${chalk.dim('From:')} ${gateInfo.from}`);
123
+ console.log(` ${chalk.dim('To:')} ${gateInfo.to}`);
124
+ }
125
+ console.log(` ${chalk.dim('Gate:')} ${gateInfo.gate}`);
126
+ console.log(` ${chalk.dim('Action:')} ${failure?.action_label || failure?.command || '(unknown)'}`);
127
+ console.log(` ${chalk.dim('Exit:')} ${exitLabel}`);
128
+ if (stderrOrError) {
129
+ console.log(` ${chalk.dim('stderr:')} ${stderrOrError}`);
130
+ }
131
+ console.log(` ${chalk.dim('Retry:')} ${gateType === 'phase_transition' ? 'agentxchain approve-transition' : 'agentxchain approve-completion'}`);
132
+ console.log('');
133
+ }
@@ -14,9 +14,11 @@ 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
16
  import { evaluateTimeouts } from '../lib/timeout-evaluator.js';
17
+ import { evaluateApprovalSlaReminders } from '../lib/notification-runner.js';
17
18
  import { summarizeRunProvenance } from '../lib/run-provenance.js';
18
19
  import { readRecentRunEventSummary } from '../lib/recent-event-summary.js';
19
20
  import { deriveConflictedTurnResolutionActions } from '../lib/conflict-actions.js';
21
+ import { summarizeLatestGateActionAttempt } from '../lib/gate-actions.js';
20
22
  import { getDashboardPid, getDashboardSession } from './dashboard.js';
21
23
 
22
24
  export async function statusCommand(opts) {
@@ -125,6 +127,14 @@ function renderGovernedStatus(context, opts) {
125
127
  const repoDecisionSummary = summarizeRepoDecisions(readRepoDecisions(root), config);
126
128
 
127
129
  const workflowKitArtifacts = deriveWorkflowKitArtifacts(root, config, state);
130
+ const gateActionAttempt = state?.pending_phase_transition
131
+ ? summarizeLatestGateActionAttempt(root, 'phase_transition', state.pending_phase_transition.gate)
132
+ : state?.pending_run_completion
133
+ ? summarizeLatestGateActionAttempt(root, 'run_completion', state.pending_run_completion.gate)
134
+ : null;
135
+
136
+ // Fire approval SLA reminders as a side effect (webhook-only, no CLI output)
137
+ evaluateApprovalSlaReminders(root, config, state);
128
138
 
129
139
  if (opts.json) {
130
140
  const dashPid = getDashboardPid(root);
@@ -152,6 +162,7 @@ function renderGovernedStatus(context, opts) {
152
162
  next_actions: nextActions,
153
163
  connector_health: connectorHealth,
154
164
  recent_event_summary: recentEventSummary,
165
+ gate_action_attempt: gateActionAttempt,
155
166
  workflow_kit_artifacts: workflowKitArtifacts,
156
167
  dashboard_session: dashboardSessionObj,
157
168
  }, null, 2));
@@ -197,6 +208,7 @@ function renderGovernedStatus(context, opts) {
197
208
  const activeTurnCount = getActiveTurnCount(state);
198
209
  const activeTurns = getActiveTurns(state);
199
210
  const singleActiveTurn = getActiveTurn(state);
211
+ const approvalPending = Boolean(state?.pending_phase_transition || state?.pending_run_completion);
200
212
  if (activeTurnCount > 1) {
201
213
  console.log(` ${chalk.dim('Turns:')} ${activeTurnCount} active`);
202
214
  for (const turn of Object.values(activeTurns)) {
@@ -249,8 +261,14 @@ function renderGovernedStatus(context, opts) {
249
261
  if (singleActiveTurn.status === 'conflicted' && singleActiveTurn.conflict_state) {
250
262
  const cs = singleActiveTurn.conflict_state;
251
263
  const files = cs.conflict_error?.conflicting_files || [];
264
+ const count = cs.detection_count || 1;
252
265
  const [reassignAction, mergeAction] = deriveConflictedTurnResolutionActions(singleActiveTurn.turn_id);
253
- console.log(` ${chalk.dim('Conflict:')} ${chalk.red(`${files.length} file(s) conflicting`)} — detection #${cs.detection_count || 1}`);
266
+ console.log(` ${chalk.dim('Conflict:')} ${chalk.red(`${files.length} file(s) conflicting`)} — detection #${count}`);
267
+ if (cs.conflict_error?.overlap_ratio != null) {
268
+ console.log(` ${chalk.dim('Overlap:')} ${(cs.conflict_error.overlap_ratio * 100).toFixed(0)}%`);
269
+ }
270
+ const suggestion = cs.conflict_error?.suggested_resolution || 'reject_and_reassign';
271
+ console.log(` ${chalk.dim('Suggest:')} ${suggestion}`);
254
272
  console.log(` ${chalk.dim('Resolve:')} ${chalk.cyan(reassignAction.command)}`);
255
273
  console.log(` ${chalk.dim(' or:')} ${chalk.cyan(mergeAction.command)}`);
256
274
  }
@@ -321,12 +339,33 @@ function renderGovernedStatus(context, opts) {
321
339
  const pt = state.pending_phase_transition;
322
340
  console.log(` ${chalk.dim('Pending:')} ${formatGovernedPhase(pt.from)} → ${formatGovernedPhase(pt.to)}`);
323
341
  console.log(` ${chalk.dim('Gate:')} ${pt.gate} (requires human approval)`);
342
+ if (pt.requested_at) {
343
+ console.log(` ${chalk.dim('Requested:')} ${pt.requested_at} (${timeSince(pt.requested_at)} ago)`);
344
+ }
324
345
  }
325
346
 
326
347
  if (state?.pending_run_completion) {
327
348
  const pc = state.pending_run_completion;
328
349
  console.log(` ${chalk.dim('Pending:')} ${chalk.bold('Run Completion')}`);
329
350
  console.log(` ${chalk.dim('Gate:')} ${pc.gate} (requires human approval)`);
351
+ if (pc.requested_at) {
352
+ console.log(` ${chalk.dim('Requested:')} ${pc.requested_at} (${timeSince(pc.requested_at)} ago)`);
353
+ }
354
+ }
355
+
356
+ if (gateActionAttempt) {
357
+ console.log(` ${chalk.dim('Gate actions:')} ${gateActionAttempt.status} at ${gateActionAttempt.attempted_at || 'unknown time'}`);
358
+ for (const action of gateActionAttempt.actions) {
359
+ const label = action.action_label || action.command || `action ${action.action_index || '?'}`;
360
+ const outcome = action.status === 'failed'
361
+ ? (action.timed_out ? chalk.red(`timed out after ${action.timeout_ms}ms`) : chalk.red('failed'))
362
+ : chalk.green('succeeded');
363
+ const exit = action.timed_out ? '' : (action.exit_code == null ? '' : ` (exit ${action.exit_code})`);
364
+ console.log(` ${action.action_index || '?'}. ${label} — ${outcome}${exit}`);
365
+ if (action.status === 'failed' && action.stderr_tail) {
366
+ console.log(` ${chalk.dim(action.stderr_tail)}`);
367
+ }
368
+ }
330
369
  }
331
370
 
332
371
  if (state?.status === 'completed') {
@@ -368,14 +407,20 @@ function renderGovernedStatus(context, opts) {
368
407
 
369
408
  renderWorkflowKitArtifactsSection(workflowKitArtifacts);
370
409
 
371
- if (config.timeouts && state?.status === 'active') {
372
- const activeTurn = getActiveTurn(state);
410
+ if (config.timeouts && (state?.status === 'active' || approvalPending)) {
411
+ const activeTurn = state?.status === 'active' ? getActiveTurn(state) : null;
373
412
  const turnResult = activeTurn ? { role: activeTurn.assigned_role } : undefined;
374
413
  const timeoutEval = evaluateTimeouts({ config, state, turn: activeTurn, turnResult, now: new Date().toISOString() });
375
414
  const allItems = [...timeoutEval.exceeded, ...timeoutEval.warnings];
376
- if (allItems.length > 0) {
415
+ if (allItems.length > 0 || approvalPending) {
377
416
  console.log('');
378
417
  console.log(` ${chalk.dim('Timeouts:')}`);
418
+ if (approvalPending) {
419
+ console.log(` ${chalk.yellow('◷')} approval wait does not mutate timeout state; phase/run clocks keep ticking until the next accepted turn`);
420
+ }
421
+ if (approvalPending && allItems.length === 0) {
422
+ console.log(` ${chalk.dim('No current phase/run timeout pressure.')}`);
423
+ }
379
424
  for (const item of allItems) {
380
425
  const isExceeded = timeoutEval.exceeded.includes(item);
381
426
  const elapsed = item.elapsed_minutes != null ? `${item.elapsed_minutes}m` : '?';
@@ -637,6 +682,7 @@ function formatRunStatus(status) {
637
682
 
638
683
  function timeSince(iso) {
639
684
  const ms = Date.now() - new Date(iso).getTime();
685
+ if (!Number.isFinite(ms) || ms < 0) return '0s';
640
686
  const sec = Math.floor(ms / 1000);
641
687
  if (sec < 60) return `${sec}s`;
642
688
  const min = Math.floor(sec / 60);
@@ -67,6 +67,7 @@ import { runHooks } from '../lib/hook-runner.js';
67
67
  import { finalizeDispatchManifest, verifyDispatchManifest } from '../lib/dispatch-manifest.js';
68
68
  import { resolveGovernedRole } from '../lib/role-resolution.js';
69
69
  import { shouldSuggestManualQaFallback } from '../lib/manual-qa-fallback.js';
70
+ import { evaluateApprovalSlaReminders } from '../lib/notification-runner.js';
70
71
 
71
72
  export async function stepCommand(opts) {
72
73
  const context = loadProjectContext();
@@ -169,6 +170,7 @@ export async function stepCommand(opts) {
169
170
 
170
171
  if (!skipAssignment) {
171
172
  if (state.pending_phase_transition || state.pending_run_completion) {
173
+ evaluateApprovalSlaReminders(root, config, state);
172
174
  printRecoverySummary(state, 'This run is awaiting approval.', config);
173
175
  process.exit(1);
174
176
  }