agentxchain 2.46.0 → 2.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/export.js CHANGED
@@ -6,6 +6,7 @@ import { join, relative, resolve } from 'node:path';
6
6
  import { loadProjectContext, loadProjectState } from './config.js';
7
7
  import { loadCoordinatorConfig, COORDINATOR_CONFIG_FILE } from './coordinator-config.js';
8
8
  import { loadCoordinatorState } from './coordinator-state.js';
9
+ import { normalizeRunProvenance } from './run-provenance.js';
9
10
 
10
11
  const EXPORT_SCHEMA_VERSION = '0.3';
11
12
 
@@ -296,6 +297,7 @@ export function buildRunExport(startDir = process.cwd()) {
296
297
  run_id: state?.run_id || null,
297
298
  status: state?.status || null,
298
299
  phase: state?.phase || null,
300
+ provenance: normalizeRunProvenance(state?.provenance),
299
301
  active_turn_ids: activeTurns,
300
302
  retained_turn_ids: retainedTurns,
301
303
  history_entries: countJsonl(files, '.agentxchain/history.jsonl'),
@@ -20,11 +20,15 @@ import { join, dirname } from 'path';
20
20
  import { randomBytes, createHash } from 'crypto';
21
21
  import { safeWriteJson } from './safe-write.js';
22
22
  import { validateStagedTurnResult } from './turn-result-validator.js';
23
- import { evaluatePhaseExit, evaluateRunCompletion } from './gate-evaluator.js';
23
+ import { evaluatePhaseExit, evaluateRunCompletion, getNextPhase } from './gate-evaluator.js';
24
+ import { evaluateApprovalPolicy } from './approval-policy.js';
24
25
  import { evaluatePolicies } from './policy-evaluator.js';
26
+ import { buildTimeoutBlockedReason, evaluateTimeouts } from './timeout-evaluator.js';
25
27
  import {
26
28
  captureBaseline,
27
29
  observeChanges,
30
+ attributeObservedChangesToTurn,
31
+ buildConflictCandidateFiles,
28
32
  classifyObservedChanges,
29
33
  buildObservedArtifact,
30
34
  normalizeVerification,
@@ -38,6 +42,7 @@ import { runHooks } from './hook-runner.js';
38
42
  import { emitNotifications } from './notification-runner.js';
39
43
  import { writeSessionCheckpoint } from './session-checkpoint.js';
40
44
  import { recordRunHistory } from './run-history.js';
45
+ import { buildDefaultRunProvenance } from './run-provenance.js';
41
46
 
42
47
  // ── Constants ────────────────────────────────────────────────────────────────
43
48
 
@@ -57,6 +62,92 @@ function generateId(prefix) {
57
62
  return `${prefix}_${randomBytes(8).toString('hex')}`;
58
63
  }
59
64
 
65
+ function getInitialPhase(config) {
66
+ return Object.keys(config?.routing || {})[0] || 'planning';
67
+ }
68
+
69
+ function buildInitialPhaseGateStatus(config) {
70
+ return Object.fromEntries(
71
+ [...new Set(
72
+ Object.values(config?.routing || {})
73
+ .map((route) => route?.exit_gate)
74
+ .filter(Boolean)
75
+ )].map((gateId) => [gateId, 'pending'])
76
+ );
77
+ }
78
+
79
+ function buildFreshIdleStateForNewRun(state, config) {
80
+ return {
81
+ schema_version: state?.schema_version || GOVERNED_SCHEMA_VERSION,
82
+ run_id: null,
83
+ project_id: state?.project_id || config?.project?.id || null,
84
+ status: 'idle',
85
+ phase: getInitialPhase(config),
86
+ accepted_integration_ref: null,
87
+ active_turns: {},
88
+ turn_sequence: 0,
89
+ last_completed_turn_id: null,
90
+ blocked_on: null,
91
+ blocked_reason: null,
92
+ escalation: null,
93
+ pending_phase_transition: null,
94
+ pending_run_completion: null,
95
+ queued_phase_transition: null,
96
+ queued_run_completion: null,
97
+ last_gate_failure: null,
98
+ phase_gate_status: buildInitialPhaseGateStatus(config),
99
+ budget_reservations: {},
100
+ budget_status: {
101
+ spent_usd: 0,
102
+ remaining_usd: config?.budget?.per_run_max_usd ?? null,
103
+ },
104
+ };
105
+ }
106
+
107
+ function normalizeGateFailure(value) {
108
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
109
+ return null;
110
+ }
111
+ return {
112
+ gate_type: value.gate_type === 'run_completion' ? 'run_completion' : 'phase_transition',
113
+ gate_id: typeof value.gate_id === 'string' && value.gate_id.length > 0 ? value.gate_id : null,
114
+ phase: typeof value.phase === 'string' && value.phase.length > 0 ? value.phase : 'unknown',
115
+ from_phase: typeof value.from_phase === 'string' && value.from_phase.length > 0 ? value.from_phase : null,
116
+ to_phase: typeof value.to_phase === 'string' && value.to_phase.length > 0 ? value.to_phase : null,
117
+ requested_by_turn: typeof value.requested_by_turn === 'string' && value.requested_by_turn.length > 0 ? value.requested_by_turn : null,
118
+ failed_at: typeof value.failed_at === 'string' && value.failed_at.length > 0 ? value.failed_at : null,
119
+ queued_request: value.queued_request === true,
120
+ reasons: Array.isArray(value.reasons) ? value.reasons.filter((reason) => typeof reason === 'string' && reason.length > 0) : [],
121
+ missing_files: Array.isArray(value.missing_files) ? value.missing_files.filter((path) => typeof path === 'string' && path.length > 0) : [],
122
+ missing_verification: value.missing_verification === true,
123
+ };
124
+ }
125
+
126
+ function buildGateFailureRecord({
127
+ gateType,
128
+ gateResult,
129
+ phase,
130
+ fromPhase = null,
131
+ toPhase = null,
132
+ requestedByTurn = null,
133
+ failedAt,
134
+ queuedRequest,
135
+ }) {
136
+ return normalizeGateFailure({
137
+ gate_type: gateType,
138
+ gate_id: gateResult?.gate_id || null,
139
+ phase,
140
+ from_phase: fromPhase,
141
+ to_phase: toPhase,
142
+ requested_by_turn: requestedByTurn,
143
+ failed_at: failedAt,
144
+ queued_request: queuedRequest,
145
+ reasons: Array.isArray(gateResult?.reasons) ? gateResult.reasons : [],
146
+ missing_files: Array.isArray(gateResult?.missing_files) ? gateResult.missing_files : [],
147
+ missing_verification: gateResult?.missing_verification === true,
148
+ });
149
+ }
150
+
60
151
  function emitBlockedNotification(root, config, state, details = {}, turn = null) {
61
152
  if (!config?.notifications?.webhooks?.length) {
62
153
  return;
@@ -608,6 +699,7 @@ function normalizeV1toV1_1(state) {
608
699
  : {},
609
700
  queued_phase_transition: state.queued_phase_transition ?? null,
610
701
  queued_run_completion: state.queued_run_completion ?? null,
702
+ last_gate_failure: normalizeGateFailure(state.last_gate_failure),
611
703
  };
612
704
  }
613
705
 
@@ -880,8 +972,8 @@ function cleanupTurnArtifacts(root, turnId) {
880
972
  } catch { /* best-effort */ }
881
973
  }
882
974
 
883
- function detectAcceptanceConflict(targetTurn, observedArtifact, historyEntries) {
884
- const observedFiles = [...new Set(getObservedFiles({ observed_artifact: observedArtifact }))];
975
+ function detectAcceptanceConflict(targetTurn, conflictFiles, historyEntries) {
976
+ const observedFiles = [...new Set(Array.isArray(conflictFiles) ? conflictFiles : [])];
885
977
  if (observedFiles.length === 0) {
886
978
  return null;
887
979
  }
@@ -1000,6 +1092,101 @@ function buildBlockedReason({ category, recovery, turnId, blockedAt = new Date()
1000
1092
  };
1001
1093
  }
1002
1094
 
1095
+ function buildTimeoutLedgerEntry(timeoutResult, timestamp, turnId, phase, type = 'timeout') {
1096
+ return {
1097
+ type,
1098
+ scope: timeoutResult.scope,
1099
+ phase: timeoutResult.phase || phase || null,
1100
+ turn_id: turnId || null,
1101
+ limit_minutes: timeoutResult.limit_minutes,
1102
+ elapsed_minutes: timeoutResult.elapsed_minutes,
1103
+ exceeded_by_minutes: timeoutResult.exceeded_by_minutes,
1104
+ action: timeoutResult.action,
1105
+ timestamp,
1106
+ };
1107
+ }
1108
+
1109
+ function attemptTimeoutPhaseSkip({ root, config, updatedState, nextHistoryEntries, historyEntry, timeoutResult, now }) {
1110
+ const currentPhase = updatedState.phase;
1111
+ const nextPhase = getNextPhase(currentPhase, config.routing || {});
1112
+ if (!nextPhase) {
1113
+ return {
1114
+ ok: false,
1115
+ error: `Phase "${currentPhase}" has no next phase to skip to`,
1116
+ };
1117
+ }
1118
+
1119
+ const syntheticTurn = {
1120
+ ...historyEntry,
1121
+ verification: historyEntry.verification || {},
1122
+ phase_transition_request: nextPhase,
1123
+ };
1124
+ const postAcceptanceState = {
1125
+ ...updatedState,
1126
+ history: nextHistoryEntries,
1127
+ };
1128
+ const gateResult = evaluatePhaseExit({
1129
+ state: postAcceptanceState,
1130
+ config,
1131
+ acceptedTurn: syntheticTurn,
1132
+ root,
1133
+ });
1134
+
1135
+ if (gateResult.action === 'advance') {
1136
+ return {
1137
+ ok: true,
1138
+ gateResult,
1139
+ updatedState: {
1140
+ ...updatedState,
1141
+ phase: gateResult.next_phase,
1142
+ phase_entered_at: now,
1143
+ last_gate_failure: null,
1144
+ phase_gate_status: {
1145
+ ...(updatedState.phase_gate_status || {}),
1146
+ [gateResult.gate_id || 'no_gate']: 'passed',
1147
+ },
1148
+ },
1149
+ ledgerEntry: {
1150
+ ...buildTimeoutLedgerEntry(timeoutResult, now, historyEntry.turn_id, currentPhase, 'timeout_skip'),
1151
+ from_phase: currentPhase,
1152
+ to_phase: gateResult.next_phase,
1153
+ gate_id: gateResult.gate_id || null,
1154
+ },
1155
+ };
1156
+ }
1157
+
1158
+ const reasons = gateResult.action === 'awaiting_human_approval'
1159
+ ? [`Gate "${gateResult.gate_id || 'unknown'}" still requires human approval; timeout skip cannot auto-advance`]
1160
+ : Array.isArray(gateResult.reasons) && gateResult.reasons.length > 0
1161
+ ? gateResult.reasons
1162
+ : ['Timeout skip failed for an unknown reason'];
1163
+ const gateFailure = buildGateFailureRecord({
1164
+ gateType: 'phase_transition',
1165
+ gateResult: {
1166
+ ...gateResult,
1167
+ reasons,
1168
+ },
1169
+ phase: currentPhase,
1170
+ fromPhase: currentPhase,
1171
+ toPhase: nextPhase,
1172
+ requestedByTurn: historyEntry.turn_id,
1173
+ failedAt: now,
1174
+ queuedRequest: false,
1175
+ });
1176
+
1177
+ return {
1178
+ ok: false,
1179
+ gateFailure,
1180
+ ledgerEntry: {
1181
+ ...buildTimeoutLedgerEntry(timeoutResult, now, historyEntry.turn_id, currentPhase, 'timeout_skip_failed'),
1182
+ from_phase: currentPhase,
1183
+ to_phase: nextPhase,
1184
+ gate_id: gateResult.gate_id || null,
1185
+ reasons,
1186
+ },
1187
+ };
1188
+ }
1189
+
1003
1190
  function slugifyEscalationReason(reason) {
1004
1191
  if (typeof reason !== 'string') {
1005
1192
  return 'operator';
@@ -1410,6 +1597,15 @@ export function normalizeGovernedStateShape(state) {
1410
1597
  changed = true;
1411
1598
  }
1412
1599
 
1600
+ const normalizedLastGateFailure = normalizeGateFailure(nextState.last_gate_failure);
1601
+ if (JSON.stringify(nextState.last_gate_failure ?? null) !== JSON.stringify(normalizedLastGateFailure)) {
1602
+ nextState = {
1603
+ ...nextState,
1604
+ last_gate_failure: normalizedLastGateFailure,
1605
+ };
1606
+ changed = true;
1607
+ }
1608
+
1413
1609
  return { state: stripLegacyCurrentTurn(nextState), changed };
1414
1610
  }
1415
1611
 
@@ -1613,30 +1809,40 @@ export function reactivateGovernedRun(root, state, details = {}) {
1613
1809
  * @param {object} config - normalized config
1614
1810
  * @returns {{ ok: boolean, error?: string, state?: object }}
1615
1811
  */
1616
- export function initializeGovernedRun(root, config) {
1617
- const state = readState(root);
1812
+ export function initializeGovernedRun(root, config, options = {}) {
1813
+ let state = readState(root);
1618
1814
  if (!state) {
1619
1815
  return { ok: false, error: 'No governed state.json found' };
1620
1816
  }
1621
- if (state.status === 'completed') {
1817
+ const allowTerminalRestart = options.allow_terminal_restart === true
1818
+ && (state.status === 'completed' || state.status === 'blocked');
1819
+ if (state.status === 'completed' && !allowTerminalRestart) {
1622
1820
  return { ok: false, error: 'Cannot initialize run: this run is already completed. Start a new project or reset state.' };
1623
1821
  }
1624
1822
  const allowBlockedBootstrap = state.status === 'blocked' && state.run_id === null && getActiveTurnCount(state) === 0;
1625
- if (state.status !== 'idle' && state.status !== 'paused' && !allowBlockedBootstrap) {
1823
+ if (state.status !== 'idle' && state.status !== 'paused' && !allowBlockedBootstrap && !allowTerminalRestart) {
1626
1824
  return { ok: false, error: `Cannot initialize run: status is "${state.status}", expected "idle", "paused", or pre-run "blocked"` };
1627
1825
  }
1826
+ if (allowTerminalRestart) {
1827
+ state = buildFreshIdleStateForNewRun(state, config);
1828
+ }
1628
1829
 
1629
1830
  const runId = generateId('run');
1831
+ const now = new Date().toISOString();
1832
+ const provenance = buildDefaultRunProvenance(options.provenance);
1630
1833
  const updatedState = {
1631
1834
  ...state,
1632
1835
  run_id: runId,
1836
+ created_at: now,
1837
+ phase_entered_at: now,
1633
1838
  status: 'active',
1634
1839
  blocked_on: null,
1635
1840
  blocked_reason: null,
1636
1841
  budget_status: {
1637
1842
  spent_usd: 0,
1638
1843
  remaining_usd: config.budget?.per_run_max_usd ?? null
1639
- }
1844
+ },
1845
+ provenance,
1640
1846
  };
1641
1847
 
1642
1848
  writeState(root, updatedState);
@@ -2092,7 +2298,9 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2092
2298
  const stagingFile = join(root, resolvedStagingPath);
2093
2299
  const now = new Date().toISOString();
2094
2300
  const baseline = currentTurn.baseline || null;
2095
- const observation = observeChanges(root, baseline);
2301
+ const rawObservation = observeChanges(root, baseline);
2302
+ const historyEntries = readJsonlEntries(root, HISTORY_PATH);
2303
+ const observation = attributeObservedChangesToTurn(rawObservation, currentTurn, historyEntries);
2096
2304
  const role = config.roles?.[turnResult.role];
2097
2305
  const runtimeId = turnResult.runtime_id;
2098
2306
  const runtime = config.runtimes?.[runtimeId];
@@ -2125,7 +2333,6 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2125
2333
  const normalizedVerification = normalizeVerification(turnResult.verification, runtimeType);
2126
2334
  const artifactType = turnResult.artifact?.type || 'review';
2127
2335
  const derivedRef = deriveAcceptedRef(observation, artifactType, state.accepted_integration_ref);
2128
- const historyEntries = readJsonlEntries(root, HISTORY_PATH);
2129
2336
 
2130
2337
  // Policy evaluation — declarative governance rules (spec: POLICY_ENGINE_SPEC.md)
2131
2338
  const policyResult = evaluatePolicies(config.policies || [], {
@@ -2203,7 +2410,11 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2203
2410
  };
2204
2411
  }
2205
2412
 
2206
- const conflict = detectAcceptanceConflict(currentTurn, observedArtifact, historyEntries);
2413
+ const conflict = detectAcceptanceConflict(
2414
+ currentTurn,
2415
+ buildConflictCandidateFiles(rawObservation, observation, turnResult.files_changed || []),
2416
+ historyEntries,
2417
+ );
2207
2418
 
2208
2419
  if (conflict) {
2209
2420
  const detectionCount = (currentTurn.conflict_state?.detection_count || 0) + 1;
@@ -2338,6 +2549,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2338
2549
  cost: turnResult.cost || {},
2339
2550
  accepted_at: now,
2340
2551
  };
2552
+ const nextHistoryEntries = [...historyEntries, historyEntry];
2341
2553
  // Build ledger entries for the journal
2342
2554
  const ledgerEntries = [];
2343
2555
  if (turnResult.decisions && turnResult.decisions.length > 0) {
@@ -2451,6 +2663,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2451
2663
 
2452
2664
  let gateResult = null;
2453
2665
  let completionResult = null;
2666
+ let timeoutResult = null;
2454
2667
  const hasRemainingTurns = Object.keys(remainingTurns).length > 0;
2455
2668
  if (turnResult.status !== 'needs_human') {
2456
2669
  if (hasRemainingTurns) {
@@ -2469,7 +2682,6 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2469
2682
  };
2470
2683
  }
2471
2684
  } else {
2472
- const nextHistoryEntries = [...historyEntries, historyEntry];
2473
2685
  const postAcceptanceState = {
2474
2686
  ...state,
2475
2687
  active_turns: remainingTurns,
@@ -2491,6 +2703,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2491
2703
  if (completionResult.action === 'complete') {
2492
2704
  updatedState.status = 'completed';
2493
2705
  updatedState.completed_at = now;
2706
+ updatedState.last_gate_failure = null;
2494
2707
  if (completionResult.gate_id) {
2495
2708
  updatedState.phase_gate_status = {
2496
2709
  ...(updatedState.phase_gate_status || {}),
@@ -2500,16 +2713,71 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2500
2713
  updatedState.queued_run_completion = null;
2501
2714
  updatedState.queued_phase_transition = null;
2502
2715
  } else if (completionResult.action === 'awaiting_human_approval') {
2503
- updatedState.status = 'paused';
2504
- updatedState.blocked_on = `human_approval:${completionResult.gate_id}`;
2505
- updatedState.blocked_reason = null;
2506
- updatedState.pending_run_completion = {
2507
- gate: completionResult.gate_id,
2508
- requested_by_turn: completionSource.turn_id,
2509
- requested_at: now,
2510
- };
2716
+ // Evaluate approval policy — may auto-approve
2717
+ const approvalResult = evaluateApprovalPolicy({
2718
+ gateResult: completionResult,
2719
+ gateType: 'run_completion',
2720
+ state: { ...updatedState, history: nextHistoryEntries },
2721
+ config,
2722
+ });
2723
+
2724
+ if (approvalResult.action === 'auto_approve') {
2725
+ updatedState.status = 'completed';
2726
+ updatedState.completed_at = now;
2727
+ updatedState.last_gate_failure = null;
2728
+ if (completionResult.gate_id) {
2729
+ updatedState.phase_gate_status = {
2730
+ ...(updatedState.phase_gate_status || {}),
2731
+ [completionResult.gate_id]: 'passed',
2732
+ };
2733
+ }
2734
+ updatedState.queued_run_completion = null;
2735
+ updatedState.queued_phase_transition = null;
2736
+ ledgerEntries.push({
2737
+ type: 'approval_policy',
2738
+ gate_type: 'run_completion',
2739
+ action: 'auto_approve',
2740
+ matched_rule: approvalResult.matched_rule,
2741
+ reason: approvalResult.reason,
2742
+ gate_id: completionResult.gate_id,
2743
+ timestamp: now,
2744
+ });
2745
+ } else {
2746
+ updatedState.status = 'paused';
2747
+ updatedState.blocked_on = `human_approval:${completionResult.gate_id}`;
2748
+ updatedState.blocked_reason = null;
2749
+ updatedState.last_gate_failure = null;
2750
+ updatedState.pending_run_completion = {
2751
+ gate: completionResult.gate_id,
2752
+ requested_by_turn: completionSource.turn_id,
2753
+ requested_at: now,
2754
+ };
2755
+ updatedState.queued_run_completion = null;
2756
+ updatedState.queued_phase_transition = null;
2757
+ }
2758
+ } else if (completionResult.action === 'gate_failed') {
2759
+ const gateFailure = buildGateFailureRecord({
2760
+ gateType: 'run_completion',
2761
+ gateResult: completionResult,
2762
+ phase: state.phase,
2763
+ fromPhase: state.phase,
2764
+ toPhase: null,
2765
+ requestedByTurn: completionSource?.turn_id || state.queued_run_completion?.requested_by_turn || null,
2766
+ failedAt: now,
2767
+ queuedRequest: Boolean(state.queued_run_completion && !turnResult.run_completion_request),
2768
+ });
2769
+ updatedState.last_gate_failure = gateFailure;
2770
+ if (completionResult.gate_id) {
2771
+ updatedState.phase_gate_status = {
2772
+ ...(updatedState.phase_gate_status || {}),
2773
+ [completionResult.gate_id]: 'failed',
2774
+ };
2775
+ }
2511
2776
  updatedState.queued_run_completion = null;
2512
- updatedState.queued_phase_transition = null;
2777
+ ledgerEntries.push({
2778
+ type: 'gate_failure',
2779
+ ...gateFailure,
2780
+ });
2513
2781
  } else if (state.queued_run_completion) {
2514
2782
  updatedState.queued_run_completion = null;
2515
2783
  }
@@ -2531,22 +2799,78 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2531
2799
 
2532
2800
  if (gateResult.action === 'advance') {
2533
2801
  updatedState.phase = gateResult.next_phase;
2802
+ updatedState.phase_entered_at = now;
2803
+ updatedState.last_gate_failure = null;
2534
2804
  updatedState.phase_gate_status = {
2535
2805
  ...(updatedState.phase_gate_status || {}),
2536
2806
  [gateResult.gate_id || 'no_gate']: 'passed',
2537
2807
  };
2538
2808
  updatedState.queued_phase_transition = null;
2539
2809
  } else if (gateResult.action === 'awaiting_human_approval') {
2540
- updatedState.status = 'paused';
2541
- updatedState.blocked_on = `human_approval:${gateResult.gate_id}`;
2542
- updatedState.blocked_reason = null;
2543
- updatedState.pending_phase_transition = {
2544
- from: state.phase,
2545
- to: gateResult.next_phase,
2546
- gate: gateResult.gate_id,
2547
- requested_by_turn: phaseSource.turn_id,
2548
- };
2810
+ // Evaluate approval policy — may auto-approve
2811
+ const approvalResult = evaluateApprovalPolicy({
2812
+ gateResult,
2813
+ gateType: 'phase_transition',
2814
+ state: { ...updatedState, history: nextHistoryEntries },
2815
+ config,
2816
+ });
2817
+
2818
+ if (approvalResult.action === 'auto_approve') {
2819
+ updatedState.phase = gateResult.next_phase;
2820
+ updatedState.phase_entered_at = now;
2821
+ updatedState.last_gate_failure = null;
2822
+ updatedState.phase_gate_status = {
2823
+ ...(updatedState.phase_gate_status || {}),
2824
+ [gateResult.gate_id || 'no_gate']: 'passed',
2825
+ };
2826
+ updatedState.queued_phase_transition = null;
2827
+ ledgerEntries.push({
2828
+ type: 'approval_policy',
2829
+ gate_type: 'phase_transition',
2830
+ action: 'auto_approve',
2831
+ matched_rule: approvalResult.matched_rule,
2832
+ from_phase: state.phase,
2833
+ to_phase: gateResult.next_phase,
2834
+ reason: approvalResult.reason,
2835
+ gate_id: gateResult.gate_id,
2836
+ timestamp: now,
2837
+ });
2838
+ } else {
2839
+ updatedState.status = 'paused';
2840
+ updatedState.blocked_on = `human_approval:${gateResult.gate_id}`;
2841
+ updatedState.blocked_reason = null;
2842
+ updatedState.last_gate_failure = null;
2843
+ updatedState.pending_phase_transition = {
2844
+ from: state.phase,
2845
+ to: gateResult.next_phase,
2846
+ gate: gateResult.gate_id,
2847
+ requested_by_turn: phaseSource.turn_id,
2848
+ };
2849
+ updatedState.queued_phase_transition = null;
2850
+ }
2851
+ } else if (gateResult.action === 'gate_failed') {
2852
+ const gateFailure = buildGateFailureRecord({
2853
+ gateType: 'phase_transition',
2854
+ gateResult,
2855
+ phase: state.phase,
2856
+ fromPhase: state.phase,
2857
+ toPhase: gateResult.transition_request || state.queued_phase_transition?.to || null,
2858
+ requestedByTurn: phaseSource?.turn_id || state.queued_phase_transition?.requested_by_turn || null,
2859
+ failedAt: now,
2860
+ queuedRequest: Boolean(state.queued_phase_transition && !turnResult.phase_transition_request),
2861
+ });
2862
+ updatedState.last_gate_failure = gateFailure;
2863
+ if (gateResult.gate_id) {
2864
+ updatedState.phase_gate_status = {
2865
+ ...(updatedState.phase_gate_status || {}),
2866
+ [gateResult.gate_id]: 'failed',
2867
+ };
2868
+ }
2549
2869
  updatedState.queued_phase_transition = null;
2870
+ ledgerEntries.push({
2871
+ type: 'gate_failure',
2872
+ ...gateFailure,
2873
+ });
2550
2874
  } else if (state.queued_phase_transition) {
2551
2875
  updatedState.queued_phase_transition = null;
2552
2876
  }
@@ -2554,6 +2878,77 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2554
2878
  }
2555
2879
  }
2556
2880
 
2881
+ const timeoutEvaluation = evaluateTimeouts({
2882
+ config,
2883
+ state: updatedState,
2884
+ turn: currentTurn,
2885
+ turnResult,
2886
+ now,
2887
+ });
2888
+ for (const warning of timeoutEvaluation.warnings) {
2889
+ ledgerEntries.push(buildTimeoutLedgerEntry(warning, now, currentTurn.turn_id, updatedState.phase, 'timeout_warning'));
2890
+ }
2891
+
2892
+ if (updatedState.status === 'active' && timeoutEvaluation.exceeded.length > 0) {
2893
+ timeoutResult = timeoutEvaluation.exceeded[0];
2894
+
2895
+ if (timeoutResult.action === 'skip_phase' && timeoutResult.scope === 'phase') {
2896
+ const skipAttempt = attemptTimeoutPhaseSkip({
2897
+ root,
2898
+ config,
2899
+ updatedState,
2900
+ nextHistoryEntries,
2901
+ historyEntry,
2902
+ timeoutResult,
2903
+ now,
2904
+ });
2905
+
2906
+ if (skipAttempt.ok) {
2907
+ updatedState.phase = skipAttempt.updatedState.phase;
2908
+ updatedState.phase_entered_at = skipAttempt.updatedState.phase_entered_at;
2909
+ updatedState.last_gate_failure = skipAttempt.updatedState.last_gate_failure;
2910
+ updatedState.phase_gate_status = skipAttempt.updatedState.phase_gate_status;
2911
+ ledgerEntries.push(skipAttempt.ledgerEntry);
2912
+ } else {
2913
+ const escalatedTimeout = { ...timeoutResult, action: 'escalate' };
2914
+ timeoutResult = escalatedTimeout;
2915
+ updatedState.status = 'blocked';
2916
+ updatedState.blocked_on = `timeout:${escalatedTimeout.scope}`;
2917
+ updatedState.blocked_reason = buildBlockedReason({
2918
+ ...buildTimeoutBlockedReason(escalatedTimeout, {
2919
+ turnRetained: Object.keys(getActiveTurns(updatedState)).length > 0,
2920
+ }),
2921
+ turnId: currentTurn.turn_id,
2922
+ blockedAt: now,
2923
+ });
2924
+ updatedState.last_gate_failure = skipAttempt.gateFailure || null;
2925
+ if (skipAttempt.gateFailure?.gate_id) {
2926
+ updatedState.phase_gate_status = {
2927
+ ...(updatedState.phase_gate_status || {}),
2928
+ [skipAttempt.gateFailure.gate_id]: 'failed',
2929
+ };
2930
+ ledgerEntries.push({
2931
+ type: 'gate_failure',
2932
+ ...skipAttempt.gateFailure,
2933
+ });
2934
+ }
2935
+ ledgerEntries.push(skipAttempt.ledgerEntry);
2936
+ ledgerEntries.push(buildTimeoutLedgerEntry(escalatedTimeout, now, currentTurn.turn_id, updatedState.phase));
2937
+ }
2938
+ } else if (timeoutResult.action === 'escalate') {
2939
+ updatedState.status = 'blocked';
2940
+ updatedState.blocked_on = `timeout:${timeoutResult.scope}`;
2941
+ updatedState.blocked_reason = buildBlockedReason({
2942
+ ...buildTimeoutBlockedReason(timeoutResult, {
2943
+ turnRetained: Object.keys(getActiveTurns(updatedState)).length > 0,
2944
+ }),
2945
+ turnId: currentTurn.turn_id,
2946
+ blockedAt: now,
2947
+ });
2948
+ ledgerEntries.push(buildTimeoutLedgerEntry(timeoutResult, now, currentTurn.turn_id, updatedState.phase));
2949
+ }
2950
+ }
2951
+
2557
2952
  // ── Transaction journal: prepare before committing writes ──────────────
2558
2953
  const transactionId = generateId('txn');
2559
2954
  const journal = {
@@ -2974,9 +3369,11 @@ export function approvePhaseTransition(root, config) {
2974
3369
  const updatedState = {
2975
3370
  ...state,
2976
3371
  phase: transition.to,
3372
+ phase_entered_at: new Date().toISOString(),
2977
3373
  status: 'active',
2978
3374
  blocked_on: null,
2979
3375
  blocked_reason: null,
3376
+ last_gate_failure: null,
2980
3377
  pending_phase_transition: null,
2981
3378
  phase_gate_status: {
2982
3379
  ...(state.phase_gate_status || {}),
@@ -3071,6 +3468,7 @@ export function approveRunCompletion(root, config) {
3071
3468
  completed_at: new Date().toISOString(),
3072
3469
  blocked_on: null,
3073
3470
  blocked_reason: null,
3471
+ last_gate_failure: null,
3074
3472
  pending_run_completion: null,
3075
3473
  phase_gate_status: {
3076
3474
  ...(state.phase_gate_status || {}),