agentxchain 2.46.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.
@@ -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,
@@ -57,6 +61,50 @@ function generateId(prefix) {
57
61
  return `${prefix}_${randomBytes(8).toString('hex')}`;
58
62
  }
59
63
 
64
+ function normalizeGateFailure(value) {
65
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
66
+ return null;
67
+ }
68
+ return {
69
+ gate_type: value.gate_type === 'run_completion' ? 'run_completion' : 'phase_transition',
70
+ gate_id: typeof value.gate_id === 'string' && value.gate_id.length > 0 ? value.gate_id : null,
71
+ phase: typeof value.phase === 'string' && value.phase.length > 0 ? value.phase : 'unknown',
72
+ from_phase: typeof value.from_phase === 'string' && value.from_phase.length > 0 ? value.from_phase : null,
73
+ to_phase: typeof value.to_phase === 'string' && value.to_phase.length > 0 ? value.to_phase : null,
74
+ requested_by_turn: typeof value.requested_by_turn === 'string' && value.requested_by_turn.length > 0 ? value.requested_by_turn : null,
75
+ failed_at: typeof value.failed_at === 'string' && value.failed_at.length > 0 ? value.failed_at : null,
76
+ queued_request: value.queued_request === true,
77
+ reasons: Array.isArray(value.reasons) ? value.reasons.filter((reason) => typeof reason === 'string' && reason.length > 0) : [],
78
+ missing_files: Array.isArray(value.missing_files) ? value.missing_files.filter((path) => typeof path === 'string' && path.length > 0) : [],
79
+ missing_verification: value.missing_verification === true,
80
+ };
81
+ }
82
+
83
+ function buildGateFailureRecord({
84
+ gateType,
85
+ gateResult,
86
+ phase,
87
+ fromPhase = null,
88
+ toPhase = null,
89
+ requestedByTurn = null,
90
+ failedAt,
91
+ queuedRequest,
92
+ }) {
93
+ return normalizeGateFailure({
94
+ gate_type: gateType,
95
+ gate_id: gateResult?.gate_id || null,
96
+ phase,
97
+ from_phase: fromPhase,
98
+ to_phase: toPhase,
99
+ requested_by_turn: requestedByTurn,
100
+ failed_at: failedAt,
101
+ queued_request: queuedRequest,
102
+ reasons: Array.isArray(gateResult?.reasons) ? gateResult.reasons : [],
103
+ missing_files: Array.isArray(gateResult?.missing_files) ? gateResult.missing_files : [],
104
+ missing_verification: gateResult?.missing_verification === true,
105
+ });
106
+ }
107
+
60
108
  function emitBlockedNotification(root, config, state, details = {}, turn = null) {
61
109
  if (!config?.notifications?.webhooks?.length) {
62
110
  return;
@@ -608,6 +656,7 @@ function normalizeV1toV1_1(state) {
608
656
  : {},
609
657
  queued_phase_transition: state.queued_phase_transition ?? null,
610
658
  queued_run_completion: state.queued_run_completion ?? null,
659
+ last_gate_failure: normalizeGateFailure(state.last_gate_failure),
611
660
  };
612
661
  }
613
662
 
@@ -880,8 +929,8 @@ function cleanupTurnArtifacts(root, turnId) {
880
929
  } catch { /* best-effort */ }
881
930
  }
882
931
 
883
- function detectAcceptanceConflict(targetTurn, observedArtifact, historyEntries) {
884
- const observedFiles = [...new Set(getObservedFiles({ observed_artifact: observedArtifact }))];
932
+ function detectAcceptanceConflict(targetTurn, conflictFiles, historyEntries) {
933
+ const observedFiles = [...new Set(Array.isArray(conflictFiles) ? conflictFiles : [])];
885
934
  if (observedFiles.length === 0) {
886
935
  return null;
887
936
  }
@@ -1000,6 +1049,101 @@ function buildBlockedReason({ category, recovery, turnId, blockedAt = new Date()
1000
1049
  };
1001
1050
  }
1002
1051
 
1052
+ function buildTimeoutLedgerEntry(timeoutResult, timestamp, turnId, phase, type = 'timeout') {
1053
+ return {
1054
+ type,
1055
+ scope: timeoutResult.scope,
1056
+ phase: timeoutResult.phase || phase || null,
1057
+ turn_id: turnId || null,
1058
+ limit_minutes: timeoutResult.limit_minutes,
1059
+ elapsed_minutes: timeoutResult.elapsed_minutes,
1060
+ exceeded_by_minutes: timeoutResult.exceeded_by_minutes,
1061
+ action: timeoutResult.action,
1062
+ timestamp,
1063
+ };
1064
+ }
1065
+
1066
+ function attemptTimeoutPhaseSkip({ root, config, updatedState, nextHistoryEntries, historyEntry, timeoutResult, now }) {
1067
+ const currentPhase = updatedState.phase;
1068
+ const nextPhase = getNextPhase(currentPhase, config.routing || {});
1069
+ if (!nextPhase) {
1070
+ return {
1071
+ ok: false,
1072
+ error: `Phase "${currentPhase}" has no next phase to skip to`,
1073
+ };
1074
+ }
1075
+
1076
+ const syntheticTurn = {
1077
+ ...historyEntry,
1078
+ verification: historyEntry.verification || {},
1079
+ phase_transition_request: nextPhase,
1080
+ };
1081
+ const postAcceptanceState = {
1082
+ ...updatedState,
1083
+ history: nextHistoryEntries,
1084
+ };
1085
+ const gateResult = evaluatePhaseExit({
1086
+ state: postAcceptanceState,
1087
+ config,
1088
+ acceptedTurn: syntheticTurn,
1089
+ root,
1090
+ });
1091
+
1092
+ if (gateResult.action === 'advance') {
1093
+ return {
1094
+ ok: true,
1095
+ gateResult,
1096
+ updatedState: {
1097
+ ...updatedState,
1098
+ phase: gateResult.next_phase,
1099
+ phase_entered_at: now,
1100
+ last_gate_failure: null,
1101
+ phase_gate_status: {
1102
+ ...(updatedState.phase_gate_status || {}),
1103
+ [gateResult.gate_id || 'no_gate']: 'passed',
1104
+ },
1105
+ },
1106
+ ledgerEntry: {
1107
+ ...buildTimeoutLedgerEntry(timeoutResult, now, historyEntry.turn_id, currentPhase, 'timeout_skip'),
1108
+ from_phase: currentPhase,
1109
+ to_phase: gateResult.next_phase,
1110
+ gate_id: gateResult.gate_id || null,
1111
+ },
1112
+ };
1113
+ }
1114
+
1115
+ const reasons = gateResult.action === 'awaiting_human_approval'
1116
+ ? [`Gate "${gateResult.gate_id || 'unknown'}" still requires human approval; timeout skip cannot auto-advance`]
1117
+ : Array.isArray(gateResult.reasons) && gateResult.reasons.length > 0
1118
+ ? gateResult.reasons
1119
+ : ['Timeout skip failed for an unknown reason'];
1120
+ const gateFailure = buildGateFailureRecord({
1121
+ gateType: 'phase_transition',
1122
+ gateResult: {
1123
+ ...gateResult,
1124
+ reasons,
1125
+ },
1126
+ phase: currentPhase,
1127
+ fromPhase: currentPhase,
1128
+ toPhase: nextPhase,
1129
+ requestedByTurn: historyEntry.turn_id,
1130
+ failedAt: now,
1131
+ queuedRequest: false,
1132
+ });
1133
+
1134
+ return {
1135
+ ok: false,
1136
+ gateFailure,
1137
+ ledgerEntry: {
1138
+ ...buildTimeoutLedgerEntry(timeoutResult, now, historyEntry.turn_id, currentPhase, 'timeout_skip_failed'),
1139
+ from_phase: currentPhase,
1140
+ to_phase: nextPhase,
1141
+ gate_id: gateResult.gate_id || null,
1142
+ reasons,
1143
+ },
1144
+ };
1145
+ }
1146
+
1003
1147
  function slugifyEscalationReason(reason) {
1004
1148
  if (typeof reason !== 'string') {
1005
1149
  return 'operator';
@@ -1410,6 +1554,15 @@ export function normalizeGovernedStateShape(state) {
1410
1554
  changed = true;
1411
1555
  }
1412
1556
 
1557
+ const normalizedLastGateFailure = normalizeGateFailure(nextState.last_gate_failure);
1558
+ if (JSON.stringify(nextState.last_gate_failure ?? null) !== JSON.stringify(normalizedLastGateFailure)) {
1559
+ nextState = {
1560
+ ...nextState,
1561
+ last_gate_failure: normalizedLastGateFailure,
1562
+ };
1563
+ changed = true;
1564
+ }
1565
+
1413
1566
  return { state: stripLegacyCurrentTurn(nextState), changed };
1414
1567
  }
1415
1568
 
@@ -1627,9 +1780,12 @@ export function initializeGovernedRun(root, config) {
1627
1780
  }
1628
1781
 
1629
1782
  const runId = generateId('run');
1783
+ const now = new Date().toISOString();
1630
1784
  const updatedState = {
1631
1785
  ...state,
1632
1786
  run_id: runId,
1787
+ created_at: now,
1788
+ phase_entered_at: now,
1633
1789
  status: 'active',
1634
1790
  blocked_on: null,
1635
1791
  blocked_reason: null,
@@ -2092,7 +2248,9 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2092
2248
  const stagingFile = join(root, resolvedStagingPath);
2093
2249
  const now = new Date().toISOString();
2094
2250
  const baseline = currentTurn.baseline || null;
2095
- const observation = observeChanges(root, baseline);
2251
+ const rawObservation = observeChanges(root, baseline);
2252
+ const historyEntries = readJsonlEntries(root, HISTORY_PATH);
2253
+ const observation = attributeObservedChangesToTurn(rawObservation, currentTurn, historyEntries);
2096
2254
  const role = config.roles?.[turnResult.role];
2097
2255
  const runtimeId = turnResult.runtime_id;
2098
2256
  const runtime = config.runtimes?.[runtimeId];
@@ -2125,7 +2283,6 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2125
2283
  const normalizedVerification = normalizeVerification(turnResult.verification, runtimeType);
2126
2284
  const artifactType = turnResult.artifact?.type || 'review';
2127
2285
  const derivedRef = deriveAcceptedRef(observation, artifactType, state.accepted_integration_ref);
2128
- const historyEntries = readJsonlEntries(root, HISTORY_PATH);
2129
2286
 
2130
2287
  // Policy evaluation — declarative governance rules (spec: POLICY_ENGINE_SPEC.md)
2131
2288
  const policyResult = evaluatePolicies(config.policies || [], {
@@ -2203,7 +2360,11 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2203
2360
  };
2204
2361
  }
2205
2362
 
2206
- const conflict = detectAcceptanceConflict(currentTurn, observedArtifact, historyEntries);
2363
+ const conflict = detectAcceptanceConflict(
2364
+ currentTurn,
2365
+ buildConflictCandidateFiles(rawObservation, observation, turnResult.files_changed || []),
2366
+ historyEntries,
2367
+ );
2207
2368
 
2208
2369
  if (conflict) {
2209
2370
  const detectionCount = (currentTurn.conflict_state?.detection_count || 0) + 1;
@@ -2338,6 +2499,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2338
2499
  cost: turnResult.cost || {},
2339
2500
  accepted_at: now,
2340
2501
  };
2502
+ const nextHistoryEntries = [...historyEntries, historyEntry];
2341
2503
  // Build ledger entries for the journal
2342
2504
  const ledgerEntries = [];
2343
2505
  if (turnResult.decisions && turnResult.decisions.length > 0) {
@@ -2451,6 +2613,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2451
2613
 
2452
2614
  let gateResult = null;
2453
2615
  let completionResult = null;
2616
+ let timeoutResult = null;
2454
2617
  const hasRemainingTurns = Object.keys(remainingTurns).length > 0;
2455
2618
  if (turnResult.status !== 'needs_human') {
2456
2619
  if (hasRemainingTurns) {
@@ -2469,7 +2632,6 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2469
2632
  };
2470
2633
  }
2471
2634
  } else {
2472
- const nextHistoryEntries = [...historyEntries, historyEntry];
2473
2635
  const postAcceptanceState = {
2474
2636
  ...state,
2475
2637
  active_turns: remainingTurns,
@@ -2491,6 +2653,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2491
2653
  if (completionResult.action === 'complete') {
2492
2654
  updatedState.status = 'completed';
2493
2655
  updatedState.completed_at = now;
2656
+ updatedState.last_gate_failure = null;
2494
2657
  if (completionResult.gate_id) {
2495
2658
  updatedState.phase_gate_status = {
2496
2659
  ...(updatedState.phase_gate_status || {}),
@@ -2500,16 +2663,71 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2500
2663
  updatedState.queued_run_completion = null;
2501
2664
  updatedState.queued_phase_transition = null;
2502
2665
  } 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
- };
2666
+ // Evaluate approval policy — may auto-approve
2667
+ const approvalResult = evaluateApprovalPolicy({
2668
+ gateResult: completionResult,
2669
+ gateType: 'run_completion',
2670
+ state: { ...updatedState, history: nextHistoryEntries },
2671
+ config,
2672
+ });
2673
+
2674
+ if (approvalResult.action === 'auto_approve') {
2675
+ updatedState.status = 'completed';
2676
+ updatedState.completed_at = now;
2677
+ updatedState.last_gate_failure = null;
2678
+ if (completionResult.gate_id) {
2679
+ updatedState.phase_gate_status = {
2680
+ ...(updatedState.phase_gate_status || {}),
2681
+ [completionResult.gate_id]: 'passed',
2682
+ };
2683
+ }
2684
+ updatedState.queued_run_completion = null;
2685
+ updatedState.queued_phase_transition = null;
2686
+ ledgerEntries.push({
2687
+ type: 'approval_policy',
2688
+ gate_type: 'run_completion',
2689
+ action: 'auto_approve',
2690
+ matched_rule: approvalResult.matched_rule,
2691
+ reason: approvalResult.reason,
2692
+ gate_id: completionResult.gate_id,
2693
+ timestamp: now,
2694
+ });
2695
+ } else {
2696
+ updatedState.status = 'paused';
2697
+ updatedState.blocked_on = `human_approval:${completionResult.gate_id}`;
2698
+ updatedState.blocked_reason = null;
2699
+ updatedState.last_gate_failure = null;
2700
+ updatedState.pending_run_completion = {
2701
+ gate: completionResult.gate_id,
2702
+ requested_by_turn: completionSource.turn_id,
2703
+ requested_at: now,
2704
+ };
2705
+ updatedState.queued_run_completion = null;
2706
+ updatedState.queued_phase_transition = null;
2707
+ }
2708
+ } else if (completionResult.action === 'gate_failed') {
2709
+ const gateFailure = buildGateFailureRecord({
2710
+ gateType: 'run_completion',
2711
+ gateResult: completionResult,
2712
+ phase: state.phase,
2713
+ fromPhase: state.phase,
2714
+ toPhase: null,
2715
+ requestedByTurn: completionSource?.turn_id || state.queued_run_completion?.requested_by_turn || null,
2716
+ failedAt: now,
2717
+ queuedRequest: Boolean(state.queued_run_completion && !turnResult.run_completion_request),
2718
+ });
2719
+ updatedState.last_gate_failure = gateFailure;
2720
+ if (completionResult.gate_id) {
2721
+ updatedState.phase_gate_status = {
2722
+ ...(updatedState.phase_gate_status || {}),
2723
+ [completionResult.gate_id]: 'failed',
2724
+ };
2725
+ }
2511
2726
  updatedState.queued_run_completion = null;
2512
- updatedState.queued_phase_transition = null;
2727
+ ledgerEntries.push({
2728
+ type: 'gate_failure',
2729
+ ...gateFailure,
2730
+ });
2513
2731
  } else if (state.queued_run_completion) {
2514
2732
  updatedState.queued_run_completion = null;
2515
2733
  }
@@ -2531,22 +2749,78 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2531
2749
 
2532
2750
  if (gateResult.action === 'advance') {
2533
2751
  updatedState.phase = gateResult.next_phase;
2752
+ updatedState.phase_entered_at = now;
2753
+ updatedState.last_gate_failure = null;
2534
2754
  updatedState.phase_gate_status = {
2535
2755
  ...(updatedState.phase_gate_status || {}),
2536
2756
  [gateResult.gate_id || 'no_gate']: 'passed',
2537
2757
  };
2538
2758
  updatedState.queued_phase_transition = null;
2539
2759
  } 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
- };
2760
+ // Evaluate approval policy — may auto-approve
2761
+ const approvalResult = evaluateApprovalPolicy({
2762
+ gateResult,
2763
+ gateType: 'phase_transition',
2764
+ state: { ...updatedState, history: nextHistoryEntries },
2765
+ config,
2766
+ });
2767
+
2768
+ if (approvalResult.action === 'auto_approve') {
2769
+ updatedState.phase = gateResult.next_phase;
2770
+ updatedState.phase_entered_at = now;
2771
+ updatedState.last_gate_failure = null;
2772
+ updatedState.phase_gate_status = {
2773
+ ...(updatedState.phase_gate_status || {}),
2774
+ [gateResult.gate_id || 'no_gate']: 'passed',
2775
+ };
2776
+ updatedState.queued_phase_transition = null;
2777
+ ledgerEntries.push({
2778
+ type: 'approval_policy',
2779
+ gate_type: 'phase_transition',
2780
+ action: 'auto_approve',
2781
+ matched_rule: approvalResult.matched_rule,
2782
+ from_phase: state.phase,
2783
+ to_phase: gateResult.next_phase,
2784
+ reason: approvalResult.reason,
2785
+ gate_id: gateResult.gate_id,
2786
+ timestamp: now,
2787
+ });
2788
+ } else {
2789
+ updatedState.status = 'paused';
2790
+ updatedState.blocked_on = `human_approval:${gateResult.gate_id}`;
2791
+ updatedState.blocked_reason = null;
2792
+ updatedState.last_gate_failure = null;
2793
+ updatedState.pending_phase_transition = {
2794
+ from: state.phase,
2795
+ to: gateResult.next_phase,
2796
+ gate: gateResult.gate_id,
2797
+ requested_by_turn: phaseSource.turn_id,
2798
+ };
2799
+ updatedState.queued_phase_transition = null;
2800
+ }
2801
+ } else if (gateResult.action === 'gate_failed') {
2802
+ const gateFailure = buildGateFailureRecord({
2803
+ gateType: 'phase_transition',
2804
+ gateResult,
2805
+ phase: state.phase,
2806
+ fromPhase: state.phase,
2807
+ toPhase: gateResult.transition_request || state.queued_phase_transition?.to || null,
2808
+ requestedByTurn: phaseSource?.turn_id || state.queued_phase_transition?.requested_by_turn || null,
2809
+ failedAt: now,
2810
+ queuedRequest: Boolean(state.queued_phase_transition && !turnResult.phase_transition_request),
2811
+ });
2812
+ updatedState.last_gate_failure = gateFailure;
2813
+ if (gateResult.gate_id) {
2814
+ updatedState.phase_gate_status = {
2815
+ ...(updatedState.phase_gate_status || {}),
2816
+ [gateResult.gate_id]: 'failed',
2817
+ };
2818
+ }
2549
2819
  updatedState.queued_phase_transition = null;
2820
+ ledgerEntries.push({
2821
+ type: 'gate_failure',
2822
+ ...gateFailure,
2823
+ });
2550
2824
  } else if (state.queued_phase_transition) {
2551
2825
  updatedState.queued_phase_transition = null;
2552
2826
  }
@@ -2554,6 +2828,77 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2554
2828
  }
2555
2829
  }
2556
2830
 
2831
+ const timeoutEvaluation = evaluateTimeouts({
2832
+ config,
2833
+ state: updatedState,
2834
+ turn: currentTurn,
2835
+ turnResult,
2836
+ now,
2837
+ });
2838
+ for (const warning of timeoutEvaluation.warnings) {
2839
+ ledgerEntries.push(buildTimeoutLedgerEntry(warning, now, currentTurn.turn_id, updatedState.phase, 'timeout_warning'));
2840
+ }
2841
+
2842
+ if (updatedState.status === 'active' && timeoutEvaluation.exceeded.length > 0) {
2843
+ timeoutResult = timeoutEvaluation.exceeded[0];
2844
+
2845
+ if (timeoutResult.action === 'skip_phase' && timeoutResult.scope === 'phase') {
2846
+ const skipAttempt = attemptTimeoutPhaseSkip({
2847
+ root,
2848
+ config,
2849
+ updatedState,
2850
+ nextHistoryEntries,
2851
+ historyEntry,
2852
+ timeoutResult,
2853
+ now,
2854
+ });
2855
+
2856
+ if (skipAttempt.ok) {
2857
+ updatedState.phase = skipAttempt.updatedState.phase;
2858
+ updatedState.phase_entered_at = skipAttempt.updatedState.phase_entered_at;
2859
+ updatedState.last_gate_failure = skipAttempt.updatedState.last_gate_failure;
2860
+ updatedState.phase_gate_status = skipAttempt.updatedState.phase_gate_status;
2861
+ ledgerEntries.push(skipAttempt.ledgerEntry);
2862
+ } else {
2863
+ const escalatedTimeout = { ...timeoutResult, action: 'escalate' };
2864
+ timeoutResult = escalatedTimeout;
2865
+ updatedState.status = 'blocked';
2866
+ updatedState.blocked_on = `timeout:${escalatedTimeout.scope}`;
2867
+ updatedState.blocked_reason = buildBlockedReason({
2868
+ ...buildTimeoutBlockedReason(escalatedTimeout, {
2869
+ turnRetained: Object.keys(getActiveTurns(updatedState)).length > 0,
2870
+ }),
2871
+ turnId: currentTurn.turn_id,
2872
+ blockedAt: now,
2873
+ });
2874
+ updatedState.last_gate_failure = skipAttempt.gateFailure || null;
2875
+ if (skipAttempt.gateFailure?.gate_id) {
2876
+ updatedState.phase_gate_status = {
2877
+ ...(updatedState.phase_gate_status || {}),
2878
+ [skipAttempt.gateFailure.gate_id]: 'failed',
2879
+ };
2880
+ ledgerEntries.push({
2881
+ type: 'gate_failure',
2882
+ ...skipAttempt.gateFailure,
2883
+ });
2884
+ }
2885
+ ledgerEntries.push(skipAttempt.ledgerEntry);
2886
+ ledgerEntries.push(buildTimeoutLedgerEntry(escalatedTimeout, now, currentTurn.turn_id, updatedState.phase));
2887
+ }
2888
+ } else if (timeoutResult.action === 'escalate') {
2889
+ updatedState.status = 'blocked';
2890
+ updatedState.blocked_on = `timeout:${timeoutResult.scope}`;
2891
+ updatedState.blocked_reason = buildBlockedReason({
2892
+ ...buildTimeoutBlockedReason(timeoutResult, {
2893
+ turnRetained: Object.keys(getActiveTurns(updatedState)).length > 0,
2894
+ }),
2895
+ turnId: currentTurn.turn_id,
2896
+ blockedAt: now,
2897
+ });
2898
+ ledgerEntries.push(buildTimeoutLedgerEntry(timeoutResult, now, currentTurn.turn_id, updatedState.phase));
2899
+ }
2900
+ }
2901
+
2557
2902
  // ── Transaction journal: prepare before committing writes ──────────────
2558
2903
  const transactionId = generateId('txn');
2559
2904
  const journal = {
@@ -2974,9 +3319,11 @@ export function approvePhaseTransition(root, config) {
2974
3319
  const updatedState = {
2975
3320
  ...state,
2976
3321
  phase: transition.to,
3322
+ phase_entered_at: new Date().toISOString(),
2977
3323
  status: 'active',
2978
3324
  blocked_on: null,
2979
3325
  blocked_reason: null,
3326
+ last_gate_failure: null,
2980
3327
  pending_phase_transition: null,
2981
3328
  phase_gate_status: {
2982
3329
  ...(state.phase_gate_status || {}),
@@ -3071,6 +3418,7 @@ export function approveRunCompletion(root, config) {
3071
3418
  completed_at: new Date().toISOString(),
3072
3419
  blocked_on: null,
3073
3420
  blocked_reason: null,
3421
+ last_gate_failure: null,
3074
3422
  pending_run_completion: null,
3075
3423
  phase_gate_status: {
3076
3424
  ...(state.phase_gate_status || {}),