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.
- package/dashboard/app.js +6 -0
- package/dashboard/components/coordinator-timeouts.js +220 -0
- package/dashboard/components/timeouts.js +201 -0
- package/dashboard/index.html +2 -0
- package/package.json +1 -1
- package/scripts/publish-from-tag.sh +33 -4
- package/src/commands/accept-turn.js +30 -0
- package/src/commands/init.js +8 -1
- package/src/commands/migrate.js +1 -0
- package/src/commands/status.js +49 -0
- package/src/lib/approval-policy.js +139 -0
- package/src/lib/blocked-state.js +35 -0
- package/src/lib/dashboard/bridge-server.js +14 -0
- package/src/lib/dashboard/coordinator-timeout-status.js +139 -0
- package/src/lib/dashboard/timeout-status.js +201 -0
- package/src/lib/governed-state.js +530 -25
- package/src/lib/governed-templates.js +2 -0
- package/src/lib/normalized-config.js +132 -0
- package/src/lib/policy-evaluator.js +330 -0
- package/src/lib/reference-conformance-adapter.js +1 -0
- package/src/lib/repo-observer.js +132 -1
- package/src/lib/report.js +323 -6
- package/src/lib/schema.js +47 -0
- package/src/lib/timeout-evaluator.js +234 -0
- package/src/templates/governed/enterprise-app.json +20 -0
|
@@ -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,
|
|
831
|
-
const observedFiles = [...new Set(
|
|
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
|
|
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
|
-
|
|
2050
|
-
|
|
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
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
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 || {}),
|