agentxchain 2.45.0 → 2.46.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,10 +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';
25
+ import { evaluatePolicies } from './policy-evaluator.js';
26
+ import { buildTimeoutBlockedReason, evaluateTimeouts } from './timeout-evaluator.js';
24
27
  import {
25
28
  captureBaseline,
26
29
  observeChanges,
30
+ attributeObservedChangesToTurn,
31
+ buildConflictCandidateFiles,
27
32
  classifyObservedChanges,
28
33
  buildObservedArtifact,
29
34
  normalizeVerification,
@@ -56,6 +61,50 @@ function generateId(prefix) {
56
61
  return `${prefix}_${randomBytes(8).toString('hex')}`;
57
62
  }
58
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
+
59
108
  function emitBlockedNotification(root, config, state, details = {}, turn = null) {
60
109
  if (!config?.notifications?.webhooks?.length) {
61
110
  return;
@@ -350,6 +399,58 @@ export function deriveDispatchRecoveryAction(state, config, options = {}) {
350
399
  return `Resolve the dispatch issue, then run ${command}`;
351
400
  }
352
401
 
402
+ function normalizePolicyId(policyId) {
403
+ if (typeof policyId !== 'string') {
404
+ return null;
405
+ }
406
+ const trimmed = policyId.trim();
407
+ return trimmed || null;
408
+ }
409
+
410
+ function getPolicyIdFromBlockedState(state) {
411
+ if (typeof state?.blocked_on !== 'string' || !state.blocked_on.startsWith('policy:')) {
412
+ return null;
413
+ }
414
+ return normalizePolicyId(state.blocked_on.slice('policy:'.length));
415
+ }
416
+
417
+ export function derivePolicyEscalationDetail(state, options = {}) {
418
+ if (typeof options.detail === 'string' && options.detail.trim()) {
419
+ return options.detail.trim();
420
+ }
421
+
422
+ if (typeof state?.blocked_reason === 'string' && state.blocked_reason.trim()) {
423
+ return state.blocked_reason.trim();
424
+ }
425
+
426
+ const policyId = normalizePolicyId(options.policyId) || getPolicyIdFromBlockedState(state);
427
+ return policyId ? `Policy "${policyId}" triggered` : (state?.blocked_on || 'Policy escalation');
428
+ }
429
+
430
+ export function derivePolicyEscalationRecoveryAction(state, config, options = {}) {
431
+ const command = deriveBlockedRecoveryCommand(state, config, {
432
+ turnRetained: options.turnRetained,
433
+ turnId: options.turnId,
434
+ });
435
+ const policyId = normalizePolicyId(options.policyId) || getPolicyIdFromBlockedState(state);
436
+ return policyId
437
+ ? `Resolve policy "${policyId}" condition, then run ${command}`
438
+ : `Resolve the policy condition, then run ${command}`;
439
+ }
440
+
441
+ export function readTurnCostUsd(turnResult) {
442
+ if (!turnResult || typeof turnResult !== 'object') {
443
+ return null;
444
+ }
445
+ if (typeof turnResult.cost?.usd === 'number') {
446
+ return turnResult.cost.usd;
447
+ }
448
+ if (typeof turnResult.cost?.total_usd === 'number') {
449
+ return turnResult.cost.total_usd;
450
+ }
451
+ return null;
452
+ }
453
+
353
454
  export function deriveHookTamperRecoveryAction(state, config, options = {}) {
354
455
  const command = deriveBlockedRecoveryCommand(state, config, {
355
456
  turnRetained: options.turnRetained,
@@ -555,6 +656,7 @@ function normalizeV1toV1_1(state) {
555
656
  : {},
556
657
  queued_phase_transition: state.queued_phase_transition ?? null,
557
658
  queued_run_completion: state.queued_run_completion ?? null,
659
+ last_gate_failure: normalizeGateFailure(state.last_gate_failure),
558
660
  };
559
661
  }
560
662
 
@@ -827,8 +929,8 @@ function cleanupTurnArtifacts(root, turnId) {
827
929
  } catch { /* best-effort */ }
828
930
  }
829
931
 
830
- function detectAcceptanceConflict(targetTurn, observedArtifact, historyEntries) {
831
- const observedFiles = [...new Set(getObservedFiles({ observed_artifact: observedArtifact }))];
932
+ function detectAcceptanceConflict(targetTurn, conflictFiles, historyEntries) {
933
+ const observedFiles = [...new Set(Array.isArray(conflictFiles) ? conflictFiles : [])];
832
934
  if (observedFiles.length === 0) {
833
935
  return null;
834
936
  }
@@ -947,6 +1049,101 @@ function buildBlockedReason({ category, recovery, turnId, blockedAt = new Date()
947
1049
  };
948
1050
  }
949
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
+
950
1147
  function slugifyEscalationReason(reason) {
951
1148
  if (typeof reason !== 'string') {
952
1149
  return 'operator';
@@ -1149,6 +1346,13 @@ export function reconcileRecoveryActionsWithConfig(state, config) {
1149
1346
  turnId,
1150
1347
  });
1151
1348
  }
1349
+ } else if (typedReason === 'policy_escalation') {
1350
+ nextAction = derivePolicyEscalationRecoveryAction(state, config, {
1351
+ turnRetained,
1352
+ turnId,
1353
+ policyId: getPolicyIdFromBlockedState(state),
1354
+ });
1355
+ shouldRefresh = currentAction !== nextAction;
1152
1356
  } else if (typedReason === 'conflict_loop') {
1153
1357
  shouldRefresh = isLegacyConflictLoopRecoveryAction(currentAction);
1154
1358
  if (shouldRefresh) {
@@ -1262,6 +1466,25 @@ function inferBlockedReasonFromState(state) {
1262
1466
  });
1263
1467
  }
1264
1468
 
1469
+ if (state.blocked_on.startsWith('policy:')) {
1470
+ const policyId = getPolicyIdFromBlockedState(state);
1471
+ return buildBlockedReason({
1472
+ category: 'policy_escalation',
1473
+ recovery: {
1474
+ typed_reason: 'policy_escalation',
1475
+ owner: 'human',
1476
+ recovery_action: derivePolicyEscalationRecoveryAction(state, null, {
1477
+ turnRetained,
1478
+ turnId: activeTurn?.turn_id ?? state.blocked_reason?.turn_id ?? null,
1479
+ policyId,
1480
+ }),
1481
+ turn_retained: turnRetained,
1482
+ detail: derivePolicyEscalationDetail(state, { policyId }),
1483
+ },
1484
+ turnId: activeTurn?.turn_id ?? state.blocked_reason?.turn_id ?? null,
1485
+ });
1486
+ }
1487
+
1265
1488
  return null;
1266
1489
  }
1267
1490
 
@@ -1331,6 +1554,15 @@ export function normalizeGovernedStateShape(state) {
1331
1554
  changed = true;
1332
1555
  }
1333
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
+
1334
1566
  return { state: stripLegacyCurrentTurn(nextState), changed };
1335
1567
  }
1336
1568
 
@@ -1548,9 +1780,12 @@ export function initializeGovernedRun(root, config) {
1548
1780
  }
1549
1781
 
1550
1782
  const runId = generateId('run');
1783
+ const now = new Date().toISOString();
1551
1784
  const updatedState = {
1552
1785
  ...state,
1553
1786
  run_id: runId,
1787
+ created_at: now,
1788
+ phase_entered_at: now,
1554
1789
  status: 'active',
1555
1790
  blocked_on: null,
1556
1791
  blocked_reason: null,
@@ -2013,7 +2248,9 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2013
2248
  const stagingFile = join(root, resolvedStagingPath);
2014
2249
  const now = new Date().toISOString();
2015
2250
  const baseline = currentTurn.baseline || null;
2016
- 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);
2017
2254
  const role = config.roles?.[turnResult.role];
2018
2255
  const runtimeId = turnResult.runtime_id;
2019
2256
  const runtime = config.runtimes?.[runtimeId];
@@ -2046,8 +2283,88 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2046
2283
  const normalizedVerification = normalizeVerification(turnResult.verification, runtimeType);
2047
2284
  const artifactType = turnResult.artifact?.type || 'review';
2048
2285
  const derivedRef = deriveAcceptedRef(observation, artifactType, state.accepted_integration_ref);
2049
- const historyEntries = readJsonlEntries(root, HISTORY_PATH);
2050
- const conflict = detectAcceptanceConflict(currentTurn, observedArtifact, historyEntries);
2286
+
2287
+ // Policy evaluation — declarative governance rules (spec: POLICY_ENGINE_SPEC.md)
2288
+ const policyResult = evaluatePolicies(config.policies || [], {
2289
+ currentPhase: state.phase,
2290
+ turnRole: turnResult.role,
2291
+ turnStatus: turnResult.status,
2292
+ turnCostUsd: readTurnCostUsd(turnResult),
2293
+ history: historyEntries,
2294
+ });
2295
+
2296
+ if (policyResult.blocks.length > 0) {
2297
+ const blockMessages = policyResult.blocks.map((v) => v.message);
2298
+ return {
2299
+ ok: false,
2300
+ error: `Policy violation: ${blockMessages.join('; ')}`,
2301
+ error_code: 'policy_violation',
2302
+ policy_violations: policyResult.violations,
2303
+ };
2304
+ }
2305
+
2306
+ if (policyResult.escalations.length > 0) {
2307
+ const escalationMessages = policyResult.escalations.map((v) => v.message);
2308
+ const policyId = policyResult.escalations[0].policy_id;
2309
+ const turnRetained = getActiveTurnCount(state) > 0;
2310
+ const recovery = {
2311
+ typed_reason: 'policy_escalation',
2312
+ owner: 'human',
2313
+ recovery_action: derivePolicyEscalationRecoveryAction(state, config, {
2314
+ turnRetained,
2315
+ turnId: currentTurn.turn_id,
2316
+ policyId,
2317
+ }),
2318
+ turn_retained: turnRetained,
2319
+ detail: derivePolicyEscalationDetail(state, {
2320
+ policyId,
2321
+ detail: escalationMessages.join('; '),
2322
+ }),
2323
+ };
2324
+ const blockedState = {
2325
+ ...state,
2326
+ status: 'blocked',
2327
+ blocked_on: `policy:${policyId}`,
2328
+ blocked_reason: buildBlockedReason({
2329
+ category: 'policy_escalation',
2330
+ recovery,
2331
+ turnId: currentTurn.turn_id,
2332
+ blockedAt: now,
2333
+ }),
2334
+ };
2335
+ writeState(root, blockedState);
2336
+ recordRunHistory(root, blockedState, config, 'blocked');
2337
+ emitBlockedNotification(root, config, blockedState, {
2338
+ category: 'policy_escalation',
2339
+ blockedOn: blockedState.blocked_on,
2340
+ recovery,
2341
+ }, currentTurn);
2342
+ appendJsonl(root, LEDGER_PATH, {
2343
+ timestamp: now,
2344
+ decision: 'policy_escalation',
2345
+ turn_id: currentTurn.turn_id,
2346
+ role: turnResult.role,
2347
+ phase: state.phase,
2348
+ violations: policyResult.escalations.map((v) => ({
2349
+ policy_id: v.policy_id,
2350
+ rule: v.rule,
2351
+ message: v.message,
2352
+ })),
2353
+ });
2354
+ return {
2355
+ ok: false,
2356
+ error: `Policy escalation: ${escalationMessages.join('; ')}`,
2357
+ error_code: 'policy_escalation',
2358
+ state: attachLegacyCurrentTurnAlias(blockedState),
2359
+ policy_violations: policyResult.violations,
2360
+ };
2361
+ }
2362
+
2363
+ const conflict = detectAcceptanceConflict(
2364
+ currentTurn,
2365
+ buildConflictCandidateFiles(rawObservation, observation, turnResult.files_changed || []),
2366
+ historyEntries,
2367
+ );
2051
2368
 
2052
2369
  if (conflict) {
2053
2370
  const detectionCount = (currentTurn.conflict_state?.detection_count || 0) + 1;
@@ -2182,6 +2499,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2182
2499
  cost: turnResult.cost || {},
2183
2500
  accepted_at: now,
2184
2501
  };
2502
+ const nextHistoryEntries = [...historyEntries, historyEntry];
2185
2503
  // Build ledger entries for the journal
2186
2504
  const ledgerEntries = [];
2187
2505
  if (turnResult.decisions && turnResult.decisions.length > 0) {
@@ -2295,6 +2613,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2295
2613
 
2296
2614
  let gateResult = null;
2297
2615
  let completionResult = null;
2616
+ let timeoutResult = null;
2298
2617
  const hasRemainingTurns = Object.keys(remainingTurns).length > 0;
2299
2618
  if (turnResult.status !== 'needs_human') {
2300
2619
  if (hasRemainingTurns) {
@@ -2313,7 +2632,6 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2313
2632
  };
2314
2633
  }
2315
2634
  } else {
2316
- const nextHistoryEntries = [...historyEntries, historyEntry];
2317
2635
  const postAcceptanceState = {
2318
2636
  ...state,
2319
2637
  active_turns: remainingTurns,
@@ -2335,6 +2653,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2335
2653
  if (completionResult.action === 'complete') {
2336
2654
  updatedState.status = 'completed';
2337
2655
  updatedState.completed_at = now;
2656
+ updatedState.last_gate_failure = null;
2338
2657
  if (completionResult.gate_id) {
2339
2658
  updatedState.phase_gate_status = {
2340
2659
  ...(updatedState.phase_gate_status || {}),
@@ -2344,16 +2663,71 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2344
2663
  updatedState.queued_run_completion = null;
2345
2664
  updatedState.queued_phase_transition = null;
2346
2665
  } else if (completionResult.action === 'awaiting_human_approval') {
2347
- updatedState.status = 'paused';
2348
- updatedState.blocked_on = `human_approval:${completionResult.gate_id}`;
2349
- updatedState.blocked_reason = null;
2350
- updatedState.pending_run_completion = {
2351
- gate: completionResult.gate_id,
2352
- requested_by_turn: completionSource.turn_id,
2353
- requested_at: now,
2354
- };
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
+ }
2355
2726
  updatedState.queued_run_completion = null;
2356
- updatedState.queued_phase_transition = null;
2727
+ ledgerEntries.push({
2728
+ type: 'gate_failure',
2729
+ ...gateFailure,
2730
+ });
2357
2731
  } else if (state.queued_run_completion) {
2358
2732
  updatedState.queued_run_completion = null;
2359
2733
  }
@@ -2375,22 +2749,78 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2375
2749
 
2376
2750
  if (gateResult.action === 'advance') {
2377
2751
  updatedState.phase = gateResult.next_phase;
2752
+ updatedState.phase_entered_at = now;
2753
+ updatedState.last_gate_failure = null;
2378
2754
  updatedState.phase_gate_status = {
2379
2755
  ...(updatedState.phase_gate_status || {}),
2380
2756
  [gateResult.gate_id || 'no_gate']: 'passed',
2381
2757
  };
2382
2758
  updatedState.queued_phase_transition = null;
2383
2759
  } else if (gateResult.action === 'awaiting_human_approval') {
2384
- updatedState.status = 'paused';
2385
- updatedState.blocked_on = `human_approval:${gateResult.gate_id}`;
2386
- updatedState.blocked_reason = null;
2387
- updatedState.pending_phase_transition = {
2388
- from: state.phase,
2389
- to: gateResult.next_phase,
2390
- gate: gateResult.gate_id,
2391
- requested_by_turn: phaseSource.turn_id,
2392
- };
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
+ }
2393
2819
  updatedState.queued_phase_transition = null;
2820
+ ledgerEntries.push({
2821
+ type: 'gate_failure',
2822
+ ...gateFailure,
2823
+ });
2394
2824
  } else if (state.queued_phase_transition) {
2395
2825
  updatedState.queued_phase_transition = null;
2396
2826
  }
@@ -2398,6 +2828,77 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2398
2828
  }
2399
2829
  }
2400
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
+
2401
2902
  // ── Transaction journal: prepare before committing writes ──────────────
2402
2903
  const transactionId = generateId('txn');
2403
2904
  const journal = {
@@ -2527,6 +3028,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2527
3028
  completionResult,
2528
3029
  hookResults,
2529
3030
  ...(budgetWarning ? { budget_warning: budgetWarning } : {}),
3031
+ ...(policyResult.warnings.length > 0 ? { policy_warnings: policyResult.warnings } : {}),
2530
3032
  };
2531
3033
  }
2532
3034
 
@@ -2817,9 +3319,11 @@ export function approvePhaseTransition(root, config) {
2817
3319
  const updatedState = {
2818
3320
  ...state,
2819
3321
  phase: transition.to,
3322
+ phase_entered_at: new Date().toISOString(),
2820
3323
  status: 'active',
2821
3324
  blocked_on: null,
2822
3325
  blocked_reason: null,
3326
+ last_gate_failure: null,
2823
3327
  pending_phase_transition: null,
2824
3328
  phase_gate_status: {
2825
3329
  ...(state.phase_gate_status || {}),
@@ -2914,6 +3418,7 @@ export function approveRunCompletion(root, config) {
2914
3418
  completed_at: new Date().toISOString(),
2915
3419
  blocked_on: null,
2916
3420
  blocked_reason: null,
3421
+ last_gate_failure: null,
2917
3422
  pending_run_completion: null,
2918
3423
  phase_gate_status: {
2919
3424
  ...(state.phase_gate_status || {}),