agentxchain 2.108.0 → 2.109.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,54 @@ 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' ? '❌ failed' : '✅ succeeded';
124
+ const exitStr = action.exit_code != null ? ` (exit ${action.exit_code})` : '';
125
+ html += `<div class="annotation-card">
126
+ <span class="mono">${esc(String(action.action_index || '?'))}.</span>
127
+ <span>${esc(label)}</span>
128
+ <span>${esc(outcome)}${esc(exitStr)}</span>
129
+ </div>`;
130
+ if (action.status === 'failed' && action.stderr_tail) {
131
+ html += `<pre class="recovery-command mono">${esc(action.stderr_tail)}</pre>`;
132
+ }
133
+ }
134
+ html += `</div>`;
135
+ }
136
+
137
+ html += `<p class="recovery-hint">Re-run with dry-run first:</p>`;
138
+ html += `<pre class="recovery-command mono" data-copy="${esc(dryRunCommand)}">${esc(dryRunCommand)}</pre>`;
139
+ html += `</div>`;
140
+ return html;
141
+ }
142
+
95
143
  export function render({
96
144
  state,
97
145
  audit = [],
@@ -99,6 +147,7 @@ export function render({
99
147
  coordinatorAudit = [],
100
148
  coordinatorBlockers = null,
101
149
  coordinatorRepoStatusRows = null,
150
+ gateActions = null,
102
151
  }) {
103
152
  const activeState = state?.status === 'blocked' ? state : coordinatorState;
104
153
  const activeAudit = activeState === state ? audit : coordinatorAudit;
@@ -168,6 +217,12 @@ export function render({
168
217
  </div>`;
169
218
  }
170
219
 
220
+ // Gate-action failure detail (only for gate_action_failed blocks)
221
+ const category = String(reason).toLowerCase();
222
+ if (category.includes('gate_action_failed') && !isCoordinator) {
223
+ html += renderGateActionFailure(gateActions, activeState);
224
+ }
225
+
171
226
  if (runtimeGuidance.length > 0) {
172
227
  html += `<div class="section"><h3>Runtime Guidance</h3><div class="annotation-list">`;
173
228
  for (const entry of runtimeGuidance) {
@@ -210,12 +210,55 @@ 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
+ const exitStr = a.exit_code != null ? ` (exit ${a.exit_code})` : '';
245
+ html += `<li>${outcome} ${esc(aLabel)}${esc(exitStr)}</li>`;
246
+ }
247
+ html += `</ul>`;
248
+ }
249
+ }
250
+
251
+ html += `</div>`;
252
+ return html;
253
+ }
254
+
213
255
  export function render({
214
256
  state,
215
257
  history = [],
216
258
  coordinatorState = null,
217
259
  coordinatorHistory = [],
218
260
  coordinatorBarriers = {},
261
+ gateActions = null,
219
262
  }) {
220
263
  const repoPendingTransition = state?.pending_phase_transition || null;
221
264
  const repoPendingCompletion = state?.pending_run_completion || null;
@@ -284,6 +327,9 @@ export function render({
284
327
  )).join('')}</ul></div>`;
285
328
  }
286
329
  }
330
+ if (!isCoordinator) {
331
+ html += renderGateActionsSection(gateActions);
332
+ }
287
333
  html += renderApproveControls({
288
334
  buttonLabel: isCoordinator ? 'Approve Coordinator Gate' : 'Approve Transition',
289
335
  cliCommand: isCoordinator ? 'agentxchain multi approve-gate' : 'agentxchain approve-transition',
@@ -329,6 +375,9 @@ export function render({
329
375
  if (evidence.files.length > 0) {
330
376
  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
377
  }
378
+ if (!isCoordinator) {
379
+ html += renderGateActionsSection(gateActions);
380
+ }
332
381
  html += renderApproveControls({
333
382
  buttonLabel: isCoordinator ? 'Approve Coordinator Gate' : 'Approve Completion',
334
383
  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.109.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,11 +37,30 @@ 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
+ console.log(` ${action.index}. ${action.label || action.run}`);
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
+ }
41
58
 
42
59
  if (!result.ok) {
43
60
  if (result.error_code?.startsWith('hook_') || result.error_code === 'hook_blocked') {
44
61
  printGateHookFailure(result, 'run_completion', pc);
62
+ } else if (result.error_code === 'gate_action_failed') {
63
+ printGateActionFailure(result, pc);
45
64
  } else {
46
65
  console.log(chalk.red(` Failed: ${result.error}`));
47
66
  }
@@ -49,6 +68,9 @@ export async function approveCompletionCommand(opts) {
49
68
  }
50
69
 
51
70
  console.log(chalk.green(' \u2713 Run completed'));
71
+ if (result.gateActionRun?.actions?.length > 0) {
72
+ console.log(` ${chalk.dim('Gate actions:')} ${result.gateActionRun.actions.length} completed`);
73
+ }
52
74
  console.log(chalk.dim(` Completed at: ${result.state.completed_at}`));
53
75
  console.log('');
54
76
  }
@@ -78,3 +100,20 @@ function printGateHookFailure(result, gateType, gateInfo) {
78
100
  }
79
101
  console.log('');
80
102
  }
103
+
104
+ function printGateActionFailure(result, gateInfo) {
105
+ const failure = result.gateActionRun?.failed_action;
106
+
107
+ console.log('');
108
+ console.log(chalk.yellow(' Run Completion Blocked By Gate Action'));
109
+ console.log(chalk.dim(' ' + '-'.repeat(44)));
110
+ console.log('');
111
+ console.log(` ${chalk.dim('Gate:')} ${gateInfo.gate}`);
112
+ console.log(` ${chalk.dim('Action:')} ${failure?.action_label || failure?.command || '(unknown)'}`);
113
+ console.log(` ${chalk.dim('Exit:')} ${failure?.exit_code ?? failure?.signal ?? 'unknown'}`);
114
+ if (failure?.stderr_tail) {
115
+ console.log(` ${chalk.dim('stderr:')} ${failure.stderr_tail}`);
116
+ }
117
+ console.log(` ${chalk.dim('Retry:')} agentxchain approve-completion`);
118
+ console.log('');
119
+ }
@@ -36,11 +36,30 @@ 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
+ console.log(` ${action.index}. ${action.label || action.run}`);
47
+ if (action.label) {
48
+ console.log(` ${chalk.dim(action.run)}`);
49
+ }
50
+ }
51
+ } else {
52
+ console.log(` ${chalk.dim('Gate actions:')} none configured`);
53
+ }
54
+ console.log('');
55
+ return;
56
+ }
40
57
 
41
58
  if (!result.ok) {
42
59
  if (result.error_code?.startsWith('hook_') || result.error_code === 'hook_blocked') {
43
60
  printGateHookFailure(result, 'phase_transition', pt);
61
+ } else if (result.error_code === 'gate_action_failed') {
62
+ printGateActionFailure(result, 'phase_transition', pt);
44
63
  } else {
45
64
  console.log(chalk.red(` Failed: ${result.error}`));
46
65
  }
@@ -48,6 +67,9 @@ export async function approveTransitionCommand(opts) {
48
67
  }
49
68
 
50
69
  console.log(chalk.green(` ✓ Phase advanced: ${pt.from} → ${pt.to}`));
70
+ if (result.gateActionRun?.actions?.length > 0) {
71
+ console.log(` ${chalk.dim('Gate actions:')} ${result.gateActionRun.actions.length} completed`);
72
+ }
51
73
  console.log(chalk.dim(` Run status: ${result.state.status}`));
52
74
  console.log('');
53
75
  console.log(chalk.dim(` Next: agentxchain step (to run the first turn in ${pt.to} phase)`));
@@ -83,3 +105,24 @@ function printGateHookFailure(result, gateType, gateInfo) {
83
105
  }
84
106
  console.log('');
85
107
  }
108
+
109
+ function printGateActionFailure(result, gateType, gateInfo) {
110
+ const failure = result.gateActionRun?.failed_action;
111
+
112
+ console.log('');
113
+ console.log(chalk.yellow(` ${gateType === 'phase_transition' ? 'Phase Transition' : 'Run Completion'} Blocked By Gate Action`));
114
+ console.log(chalk.dim(' ' + '-'.repeat(44)));
115
+ console.log('');
116
+ if (gateType === 'phase_transition') {
117
+ console.log(` ${chalk.dim('From:')} ${gateInfo.from}`);
118
+ console.log(` ${chalk.dim('To:')} ${gateInfo.to}`);
119
+ }
120
+ console.log(` ${chalk.dim('Gate:')} ${gateInfo.gate}`);
121
+ console.log(` ${chalk.dim('Action:')} ${failure?.action_label || failure?.command || '(unknown)'}`);
122
+ console.log(` ${chalk.dim('Exit:')} ${failure?.exit_code ?? failure?.signal ?? 'unknown'}`);
123
+ if (failure?.stderr_tail) {
124
+ console.log(` ${chalk.dim('stderr:')} ${failure.stderr_tail}`);
125
+ }
126
+ console.log(` ${chalk.dim('Retry:')} ${gateType === 'phase_transition' ? 'agentxchain approve-transition' : 'agentxchain approve-completion'}`);
127
+ console.log('');
128
+ }
@@ -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
+ ? chalk.red('failed')
362
+ : chalk.green('succeeded');
363
+ const exit = 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
  }