agentxchain 0.8.8 → 2.2.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/README.md +136 -136
- package/bin/agentxchain.js +186 -5
- package/dashboard/app.js +305 -0
- package/dashboard/components/blocked.js +145 -0
- package/dashboard/components/cross-repo.js +126 -0
- package/dashboard/components/gate.js +311 -0
- package/dashboard/components/hooks.js +177 -0
- package/dashboard/components/initiative.js +147 -0
- package/dashboard/components/ledger.js +165 -0
- package/dashboard/components/timeline.js +222 -0
- package/dashboard/index.html +352 -0
- package/package.json +14 -6
- package/scripts/live-api-proxy-preflight-smoke.sh +531 -0
- package/scripts/publish-from-tag.sh +88 -0
- package/scripts/release-postflight.sh +231 -0
- package/scripts/release-preflight.sh +167 -0
- package/src/commands/accept-turn.js +160 -0
- package/src/commands/approve-completion.js +80 -0
- package/src/commands/approve-transition.js +85 -0
- package/src/commands/dashboard.js +70 -0
- package/src/commands/init.js +516 -0
- package/src/commands/migrate.js +348 -0
- package/src/commands/multi.js +549 -0
- package/src/commands/plugin.js +157 -0
- package/src/commands/reject-turn.js +204 -0
- package/src/commands/resume.js +389 -0
- package/src/commands/status.js +196 -3
- package/src/commands/step.js +947 -0
- package/src/commands/template-list.js +33 -0
- package/src/commands/template-set.js +279 -0
- package/src/commands/validate.js +20 -11
- package/src/commands/verify.js +71 -0
- package/src/lib/adapters/api-proxy-adapter.js +1076 -0
- package/src/lib/adapters/local-cli-adapter.js +337 -0
- package/src/lib/adapters/manual-adapter.js +169 -0
- package/src/lib/blocked-state.js +94 -0
- package/src/lib/config.js +97 -1
- package/src/lib/context-compressor.js +121 -0
- package/src/lib/context-section-parser.js +220 -0
- package/src/lib/coordinator-acceptance.js +428 -0
- package/src/lib/coordinator-config.js +461 -0
- package/src/lib/coordinator-dispatch.js +276 -0
- package/src/lib/coordinator-gates.js +487 -0
- package/src/lib/coordinator-hooks.js +239 -0
- package/src/lib/coordinator-recovery.js +523 -0
- package/src/lib/coordinator-state.js +365 -0
- package/src/lib/cross-repo-context.js +247 -0
- package/src/lib/dashboard/bridge-server.js +284 -0
- package/src/lib/dashboard/file-watcher.js +93 -0
- package/src/lib/dashboard/state-reader.js +96 -0
- package/src/lib/dispatch-bundle.js +568 -0
- package/src/lib/dispatch-manifest.js +252 -0
- package/src/lib/gate-evaluator.js +285 -0
- package/src/lib/governed-state.js +2139 -0
- package/src/lib/governed-templates.js +145 -0
- package/src/lib/hook-runner.js +788 -0
- package/src/lib/normalized-config.js +539 -0
- package/src/lib/plugin-config-schema.js +192 -0
- package/src/lib/plugins.js +692 -0
- package/src/lib/protocol-conformance.js +291 -0
- package/src/lib/reference-conformance-adapter.js +858 -0
- package/src/lib/repo-observer.js +597 -0
- package/src/lib/repo.js +0 -31
- package/src/lib/schema.js +121 -0
- package/src/lib/schemas/turn-result.schema.json +205 -0
- package/src/lib/token-budget.js +206 -0
- package/src/lib/token-counter.js +27 -0
- package/src/lib/turn-paths.js +67 -0
- package/src/lib/turn-result-validator.js +496 -0
- package/src/lib/validation.js +137 -0
- package/src/templates/governed/api-service.json +31 -0
- package/src/templates/governed/cli-tool.json +30 -0
- package/src/templates/governed/generic.json +10 -0
- package/src/templates/governed/web-app.json +30 -0
|
@@ -0,0 +1,2139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Governed state writers — the accept/reject turn cycle.
|
|
3
|
+
*
|
|
4
|
+
* These are library primitives, not CLI commands. They implement the
|
|
5
|
+
* orchestrator-owned write path from the frozen spec (§39):
|
|
6
|
+
*
|
|
7
|
+
* - initializeGovernedRun() — create a run envelope from idle state
|
|
8
|
+
* - assignGovernedTurn() — assign a turn to a role
|
|
9
|
+
* - acceptGovernedTurn() — validate staged result, promote to accepted state
|
|
10
|
+
* - rejectGovernedTurn() — preserve rejected artifact, increment retry or escalate
|
|
11
|
+
*
|
|
12
|
+
* Design rules:
|
|
13
|
+
* - Only these functions may mutate state.json, history.jsonl, decision-ledger.jsonl
|
|
14
|
+
* - Accept does NOT auto-assign the next turn (§39.1)
|
|
15
|
+
* - Reject does NOT append to history or decision ledger (§39.2)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync, readdirSync, rmSync } from 'fs';
|
|
19
|
+
import { join, dirname } from 'path';
|
|
20
|
+
import { randomBytes } from 'crypto';
|
|
21
|
+
import { safeWriteJson } from './safe-write.js';
|
|
22
|
+
import { validateStagedTurnResult } from './turn-result-validator.js';
|
|
23
|
+
import { evaluatePhaseExit, evaluateRunCompletion } from './gate-evaluator.js';
|
|
24
|
+
import {
|
|
25
|
+
captureBaseline,
|
|
26
|
+
observeChanges,
|
|
27
|
+
classifyObservedChanges,
|
|
28
|
+
buildObservedArtifact,
|
|
29
|
+
normalizeVerification,
|
|
30
|
+
compareDeclaredVsObserved,
|
|
31
|
+
deriveAcceptedRef,
|
|
32
|
+
checkCleanBaseline,
|
|
33
|
+
} from './repo-observer.js';
|
|
34
|
+
import { getMaxConcurrentTurns } from './normalized-config.js';
|
|
35
|
+
import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir } from './turn-paths.js';
|
|
36
|
+
import { runHooks } from './hook-runner.js';
|
|
37
|
+
|
|
38
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
const STATE_PATH = '.agentxchain/state.json';
|
|
41
|
+
const HISTORY_PATH = '.agentxchain/history.jsonl';
|
|
42
|
+
const LEDGER_PATH = '.agentxchain/decision-ledger.jsonl';
|
|
43
|
+
const STAGING_PATH = '.agentxchain/staging/turn-result.json';
|
|
44
|
+
const TALK_PATH = 'TALK.md';
|
|
45
|
+
const ACCEPTANCE_LOCK_PATH = '.agentxchain/locks/accept-turn.lock';
|
|
46
|
+
const ACCEPTANCE_JOURNAL_DIR = '.agentxchain/transactions/accept';
|
|
47
|
+
const STALE_LOCK_TIMEOUT_MS = 30_000;
|
|
48
|
+
const GOVERNED_SCHEMA_VERSION = '1.1';
|
|
49
|
+
|
|
50
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
function generateId(prefix) {
|
|
53
|
+
return `${prefix}_${randomBytes(8).toString('hex')}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeActiveTurns(activeTurns) {
|
|
57
|
+
if (!activeTurns || typeof activeTurns !== 'object' || Array.isArray(activeTurns)) {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return Object.fromEntries(
|
|
62
|
+
Object.entries(activeTurns).filter(([, turn]) => turn && typeof turn === 'object' && !Array.isArray(turn)),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function stripLegacyCurrentTurn(state) {
|
|
67
|
+
if (!state || typeof state !== 'object') {
|
|
68
|
+
return state;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { current_turn, ...rest } = state;
|
|
72
|
+
return {
|
|
73
|
+
...rest,
|
|
74
|
+
active_turns: normalizeActiveTurns(rest.active_turns),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function getActiveTurns(state) {
|
|
79
|
+
return normalizeActiveTurns(state?.active_turns);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getActiveTurnCount(state) {
|
|
83
|
+
return Object.keys(getActiveTurns(state)).length;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getActiveTurn(state) {
|
|
87
|
+
const turns = Object.values(getActiveTurns(state));
|
|
88
|
+
return turns.length === 1 ? turns[0] : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getActiveTurnOrThrow(state) {
|
|
92
|
+
const turns = Object.values(getActiveTurns(state));
|
|
93
|
+
if (turns.length === 0) {
|
|
94
|
+
throw new Error('No active turn is available in governed state.');
|
|
95
|
+
}
|
|
96
|
+
if (turns.length > 1) {
|
|
97
|
+
throw new Error('Multiple active turns are present; this command requires explicit turn targeting.');
|
|
98
|
+
}
|
|
99
|
+
return turns[0];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function attachLegacyCurrentTurnAlias(state) {
|
|
103
|
+
if (!state || typeof state !== 'object') {
|
|
104
|
+
return state;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const existing = Object.getOwnPropertyDescriptor(state, 'current_turn');
|
|
108
|
+
if (existing && existing.enumerable === false) {
|
|
109
|
+
return state;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
Object.defineProperty(state, 'current_turn', {
|
|
113
|
+
configurable: true,
|
|
114
|
+
enumerable: false,
|
|
115
|
+
get() {
|
|
116
|
+
return getActiveTurn(state);
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return state;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function normalizeV1toV1_1(state) {
|
|
124
|
+
const hadLegacyCurrentTurn = Object.prototype.hasOwnProperty.call(state, 'current_turn');
|
|
125
|
+
const activeTurns = normalizeActiveTurns(state.active_turns);
|
|
126
|
+
const legacyTurn = hadLegacyCurrentTurn ? state.current_turn : null;
|
|
127
|
+
|
|
128
|
+
if (legacyTurn && typeof legacyTurn === 'object' && legacyTurn.turn_id && !activeTurns[legacyTurn.turn_id]) {
|
|
129
|
+
activeTurns[legacyTurn.turn_id] = {
|
|
130
|
+
...legacyTurn,
|
|
131
|
+
assigned_sequence: 1,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let turnSequence = Number.isInteger(state.turn_sequence) && state.turn_sequence >= 0
|
|
136
|
+
? state.turn_sequence
|
|
137
|
+
: Object.keys(activeTurns).length > 0 ? 1 : 0;
|
|
138
|
+
|
|
139
|
+
if (Object.keys(activeTurns).length > 0 && turnSequence < 1) {
|
|
140
|
+
turnSequence = 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const normalizedActiveTurns = Object.fromEntries(
|
|
144
|
+
Object.entries(activeTurns).map(([turnId, turn]) => [
|
|
145
|
+
turnId,
|
|
146
|
+
{
|
|
147
|
+
...turn,
|
|
148
|
+
assigned_sequence: Number.isInteger(turn.assigned_sequence) && turn.assigned_sequence >= 1
|
|
149
|
+
? turn.assigned_sequence
|
|
150
|
+
: 1,
|
|
151
|
+
},
|
|
152
|
+
]),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
...state,
|
|
157
|
+
schema_version: GOVERNED_SCHEMA_VERSION,
|
|
158
|
+
active_turns: normalizedActiveTurns,
|
|
159
|
+
turn_sequence: turnSequence,
|
|
160
|
+
budget_reservations:
|
|
161
|
+
state.budget_reservations && typeof state.budget_reservations === 'object' && !Array.isArray(state.budget_reservations)
|
|
162
|
+
? state.budget_reservations
|
|
163
|
+
: {},
|
|
164
|
+
queued_phase_transition: state.queued_phase_transition ?? null,
|
|
165
|
+
queued_run_completion: state.queued_run_completion ?? null,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function readState(root) {
|
|
170
|
+
const filePath = join(root, STATE_PATH);
|
|
171
|
+
if (!existsSync(filePath)) return null;
|
|
172
|
+
try {
|
|
173
|
+
const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
174
|
+
const { state, changed } = normalizeGovernedStateShape(parsed);
|
|
175
|
+
if (changed) {
|
|
176
|
+
safeWriteJson(filePath, stripLegacyCurrentTurn(state));
|
|
177
|
+
}
|
|
178
|
+
return attachLegacyCurrentTurnAlias(state);
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function writeState(root, state) {
|
|
185
|
+
safeWriteJson(join(root, STATE_PATH), stripLegacyCurrentTurn(state));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function appendJsonl(root, relPath, entry) {
|
|
189
|
+
const filePath = join(root, relPath);
|
|
190
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
191
|
+
const line = JSON.stringify(entry) + '\n';
|
|
192
|
+
writeFileSync(filePath, line, { flag: 'a' });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function appendTalk(root, section) {
|
|
196
|
+
const filePath = join(root, TALK_PATH);
|
|
197
|
+
let existing = '';
|
|
198
|
+
if (existsSync(filePath)) {
|
|
199
|
+
existing = readFileSync(filePath, 'utf8');
|
|
200
|
+
}
|
|
201
|
+
const prefix = existing.endsWith('\n') || existing === '' ? '' : '\n';
|
|
202
|
+
writeFileSync(filePath, existing + prefix + section + '\n');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function loadHookStagedTurn(root, stagingRel) {
|
|
206
|
+
const stagingAbs = join(root, stagingRel);
|
|
207
|
+
if (!existsSync(stagingAbs)) {
|
|
208
|
+
return { turnResult: null };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let raw;
|
|
212
|
+
try {
|
|
213
|
+
raw = readFileSync(stagingAbs, 'utf8');
|
|
214
|
+
} catch (err) {
|
|
215
|
+
return { turnResult: null, read_error: err.message };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
return { turnResult: JSON.parse(raw) };
|
|
220
|
+
} catch (err) {
|
|
221
|
+
return { turnResult: null, parse_error: err.message };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function readJsonlEntries(root, relPath) {
|
|
226
|
+
const filePath = join(root, relPath);
|
|
227
|
+
if (!existsSync(filePath)) {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const content = readFileSync(filePath, 'utf8').trim();
|
|
232
|
+
if (!content) {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return content
|
|
237
|
+
.split('\n')
|
|
238
|
+
.filter(Boolean)
|
|
239
|
+
.map((line, index) => {
|
|
240
|
+
try {
|
|
241
|
+
const entry = JSON.parse(line);
|
|
242
|
+
const acceptedSequence = Number.isInteger(entry.accepted_sequence) && entry.accepted_sequence >= 1
|
|
243
|
+
? entry.accepted_sequence
|
|
244
|
+
: index + 1;
|
|
245
|
+
return {
|
|
246
|
+
...entry,
|
|
247
|
+
accepted_sequence: acceptedSequence,
|
|
248
|
+
assigned_sequence: Number.isInteger(entry.assigned_sequence) && entry.assigned_sequence >= 1
|
|
249
|
+
? entry.assigned_sequence
|
|
250
|
+
: acceptedSequence,
|
|
251
|
+
concurrent_with: Array.isArray(entry.concurrent_with) ? entry.concurrent_with : [],
|
|
252
|
+
};
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
.filter(Boolean);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function getObservedFiles(entry) {
|
|
261
|
+
if (Array.isArray(entry?.observed_artifact?.files_changed)) {
|
|
262
|
+
return entry.observed_artifact.files_changed;
|
|
263
|
+
}
|
|
264
|
+
if (Array.isArray(entry?.files_changed)) {
|
|
265
|
+
return entry.files_changed;
|
|
266
|
+
}
|
|
267
|
+
return [];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function resolveTurnTarget(state, turnId) {
|
|
271
|
+
const activeTurns = getActiveTurns(state);
|
|
272
|
+
|
|
273
|
+
if (turnId) {
|
|
274
|
+
const turn = activeTurns[turnId];
|
|
275
|
+
if (!turn) {
|
|
276
|
+
return { ok: false, error: `No active turn found for --turn ${turnId}`, error_code: 'not_found' };
|
|
277
|
+
}
|
|
278
|
+
return { ok: true, turn };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const activeEntries = Object.values(activeTurns);
|
|
282
|
+
if (activeEntries.length === 0) {
|
|
283
|
+
return { ok: false, error: 'No active turn to accept', error_code: 'not_found' };
|
|
284
|
+
}
|
|
285
|
+
if (activeEntries.length > 1) {
|
|
286
|
+
return {
|
|
287
|
+
ok: false,
|
|
288
|
+
error: 'Multiple active turns are present. Re-run with --turn <turn_id>.',
|
|
289
|
+
error_code: 'target_required',
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { ok: true, turn: activeEntries[0] };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── Acceptance Lock ─────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
function isProcessRunning(pid) {
|
|
299
|
+
try {
|
|
300
|
+
process.kill(pid, 0);
|
|
301
|
+
return true;
|
|
302
|
+
} catch {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function acquireAcceptanceLock(root) {
|
|
308
|
+
const lockPath = join(root, ACCEPTANCE_LOCK_PATH);
|
|
309
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
310
|
+
|
|
311
|
+
if (existsSync(lockPath)) {
|
|
312
|
+
try {
|
|
313
|
+
const existing = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
314
|
+
const acquiredAt = new Date(existing.acquired_at).getTime();
|
|
315
|
+
const elapsed = Date.now() - acquiredAt;
|
|
316
|
+
const ownerAlive = existing.owner_pid && isProcessRunning(existing.owner_pid);
|
|
317
|
+
|
|
318
|
+
if (ownerAlive && elapsed < STALE_LOCK_TIMEOUT_MS) {
|
|
319
|
+
return { ok: false, error: `Acceptance lock held by PID ${existing.owner_pid}`, error_code: 'lock_timeout' };
|
|
320
|
+
}
|
|
321
|
+
// Stale lock — reclaim it
|
|
322
|
+
} catch {
|
|
323
|
+
// Corrupt lock file — reclaim it
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const lock = {
|
|
328
|
+
owner_pid: process.pid,
|
|
329
|
+
acquired_at: new Date().toISOString(),
|
|
330
|
+
};
|
|
331
|
+
writeFileSync(lockPath, JSON.stringify(lock, null, 2));
|
|
332
|
+
return { ok: true };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function releaseAcceptanceLock(root) {
|
|
336
|
+
const lockPath = join(root, ACCEPTANCE_LOCK_PATH);
|
|
337
|
+
try {
|
|
338
|
+
if (existsSync(lockPath)) {
|
|
339
|
+
const existing = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
340
|
+
if (existing.owner_pid === process.pid) {
|
|
341
|
+
unlinkSync(lockPath);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
} catch {
|
|
345
|
+
// Best-effort cleanup
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── Acceptance Transaction Journal ──────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
function writeAcceptanceJournal(root, journal) {
|
|
352
|
+
const journalDir = join(root, ACCEPTANCE_JOURNAL_DIR);
|
|
353
|
+
mkdirSync(journalDir, { recursive: true });
|
|
354
|
+
const journalPath = join(journalDir, `${journal.transaction_id}.json`);
|
|
355
|
+
safeWriteJson(journalPath, journal);
|
|
356
|
+
return journalPath;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function commitAcceptanceJournal(root, transactionId) {
|
|
360
|
+
const journalPath = join(root, ACCEPTANCE_JOURNAL_DIR, `${transactionId}.json`);
|
|
361
|
+
try {
|
|
362
|
+
if (existsSync(journalPath)) {
|
|
363
|
+
unlinkSync(journalPath);
|
|
364
|
+
}
|
|
365
|
+
} catch {
|
|
366
|
+
// Best-effort
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function replayPreparedJournals(root) {
|
|
371
|
+
const journalDir = join(root, ACCEPTANCE_JOURNAL_DIR);
|
|
372
|
+
if (!existsSync(journalDir)) return [];
|
|
373
|
+
|
|
374
|
+
const replayed = [];
|
|
375
|
+
let files;
|
|
376
|
+
try {
|
|
377
|
+
files = readdirSync(journalDir).filter(f => f.endsWith('.json'));
|
|
378
|
+
} catch {
|
|
379
|
+
return [];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
for (const file of files) {
|
|
383
|
+
const journalPath = join(journalDir, file);
|
|
384
|
+
let journal;
|
|
385
|
+
try {
|
|
386
|
+
journal = JSON.parse(readFileSync(journalPath, 'utf8'));
|
|
387
|
+
} catch {
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (journal.status !== 'prepared') continue;
|
|
392
|
+
|
|
393
|
+
const state = readState(root);
|
|
394
|
+
if (!state) continue;
|
|
395
|
+
|
|
396
|
+
const activeTurns = getActiveTurns(state);
|
|
397
|
+
const turnAlreadyRemoved = !activeTurns[journal.turn_id];
|
|
398
|
+
const sequenceAlreadyApplied = (state.turn_sequence || 0) >= journal.accepted_sequence;
|
|
399
|
+
|
|
400
|
+
if (turnAlreadyRemoved && sequenceAlreadyApplied) {
|
|
401
|
+
// State commit succeeded but cleanup may be incomplete — finish cleanup
|
|
402
|
+
cleanupTurnArtifacts(root, journal.turn_id);
|
|
403
|
+
commitAcceptanceJournal(root, journal.transaction_id);
|
|
404
|
+
replayed.push({ transaction_id: journal.transaction_id, action: 'cleanup_only' });
|
|
405
|
+
} else {
|
|
406
|
+
// State commit did not complete — replay from journal
|
|
407
|
+
if (journal.history_entry) {
|
|
408
|
+
appendJsonl(root, HISTORY_PATH, journal.history_entry);
|
|
409
|
+
}
|
|
410
|
+
if (journal.ledger_entries) {
|
|
411
|
+
for (const entry of journal.ledger_entries) {
|
|
412
|
+
appendJsonl(root, LEDGER_PATH, entry);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (journal.next_state) {
|
|
416
|
+
writeState(root, journal.next_state);
|
|
417
|
+
}
|
|
418
|
+
cleanupTurnArtifacts(root, journal.turn_id);
|
|
419
|
+
commitAcceptanceJournal(root, journal.transaction_id);
|
|
420
|
+
replayed.push({ transaction_id: journal.transaction_id, action: 'full_replay' });
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return replayed;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function cleanupTurnArtifacts(root, turnId) {
|
|
427
|
+
const stagingDir = join(root, getTurnStagingDir(turnId));
|
|
428
|
+
const dispatchDir = join(root, getDispatchTurnDir(turnId));
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
if (existsSync(stagingDir)) rmSync(stagingDir, { recursive: true });
|
|
432
|
+
} catch { /* best-effort */ }
|
|
433
|
+
try {
|
|
434
|
+
if (existsSync(dispatchDir)) rmSync(dispatchDir, { recursive: true });
|
|
435
|
+
} catch { /* best-effort */ }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function detectAcceptanceConflict(targetTurn, observedArtifact, historyEntries) {
|
|
439
|
+
const observedFiles = [...new Set(getObservedFiles({ observed_artifact: observedArtifact }))];
|
|
440
|
+
if (observedFiles.length === 0) {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const observedFileSet = new Set(observedFiles);
|
|
445
|
+
const acceptedSince = [];
|
|
446
|
+
const conflictingFiles = new Set();
|
|
447
|
+
|
|
448
|
+
for (const entry of historyEntries) {
|
|
449
|
+
if ((entry.accepted_sequence || 0) <= (targetTurn.assigned_sequence || 0)) {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const overlap = [...new Set(getObservedFiles(entry).filter(file => observedFileSet.has(file)))];
|
|
454
|
+
if (overlap.length === 0) {
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
overlap.forEach(file => conflictingFiles.add(file));
|
|
459
|
+
acceptedSince.push({
|
|
460
|
+
turn_id: entry.turn_id,
|
|
461
|
+
role: entry.role,
|
|
462
|
+
accepted_sequence: entry.accepted_sequence,
|
|
463
|
+
files_changed: overlap,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (acceptedSince.length === 0) {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const conflicting = [...conflictingFiles];
|
|
472
|
+
const overlapRatio = observedFiles.length > 0 ? conflicting.length / observedFiles.length : 0;
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
type: 'file_conflict',
|
|
476
|
+
conflicting_turn: {
|
|
477
|
+
turn_id: targetTurn.turn_id,
|
|
478
|
+
role: targetTurn.assigned_role,
|
|
479
|
+
attempt: targetTurn.attempt,
|
|
480
|
+
files_changed: observedFiles,
|
|
481
|
+
},
|
|
482
|
+
accepted_since: acceptedSince,
|
|
483
|
+
conflicting_files: conflicting,
|
|
484
|
+
non_conflicting_files: observedFiles.filter(file => !conflictingFiles.has(file)),
|
|
485
|
+
overlap_ratio: overlapRatio,
|
|
486
|
+
suggested_resolution: overlapRatio < 0.5 ? 'reject_and_reassign' : 'human_merge',
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function buildConflictContext(turn) {
|
|
491
|
+
const conflictError = turn?.conflict_state?.conflict_error;
|
|
492
|
+
if (!conflictError) {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const acceptedTurnsSince = Array.isArray(conflictError.accepted_since)
|
|
497
|
+
? conflictError.accepted_since.map((entry) => ({
|
|
498
|
+
turn_id: entry.turn_id,
|
|
499
|
+
role: entry.role,
|
|
500
|
+
files_changed: Array.isArray(entry.files_changed) ? entry.files_changed : [],
|
|
501
|
+
}))
|
|
502
|
+
: [];
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
prior_attempt_turn_id: turn.turn_id,
|
|
506
|
+
prior_attempt_number: turn.attempt,
|
|
507
|
+
conflict_type: conflictError.type || 'file_conflict',
|
|
508
|
+
conflicting_files: Array.isArray(conflictError.conflicting_files) ? conflictError.conflicting_files : [],
|
|
509
|
+
accepted_turns_since: acceptedTurnsSince,
|
|
510
|
+
non_conflicting_files_preserved: Array.isArray(conflictError.non_conflicting_files)
|
|
511
|
+
? conflictError.non_conflicting_files
|
|
512
|
+
: [],
|
|
513
|
+
guidance: 'Rebase the rejected work on top of the current workspace state and preserve non-conflicting changes.',
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function buildConflictDetail(conflict) {
|
|
518
|
+
if (!conflict?.conflicting_files?.length) {
|
|
519
|
+
return 'Resolve the retained file conflict, then resume the turn.';
|
|
520
|
+
}
|
|
521
|
+
return `Conflicting files: ${conflict.conflicting_files.join(', ')}`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function hasBlockingActiveTurn(activeTurns) {
|
|
525
|
+
return Object.values(activeTurns || {}).some((turn) => turn?.status === 'failed' || turn?.status === 'conflicted');
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function findHistoryTurnRequest(historyEntries, turnId, kind) {
|
|
529
|
+
if (!turnId) {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const entry = [...historyEntries].reverse().find(item => item.turn_id === turnId);
|
|
534
|
+
if (!entry) {
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (kind === 'run_completion') {
|
|
539
|
+
return { ...entry, run_completion_request: true };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (kind === 'phase_transition') {
|
|
543
|
+
return { ...entry, phase_transition_request: entry.phase_transition_request || null };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return entry;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function buildBlockedReason({ category, recovery, turnId, blockedAt = new Date().toISOString() }) {
|
|
550
|
+
return {
|
|
551
|
+
category,
|
|
552
|
+
recovery,
|
|
553
|
+
blocked_at: blockedAt,
|
|
554
|
+
turn_id: turnId ?? null,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function canApprovePendingGate(state) {
|
|
559
|
+
return state?.status === 'paused' || state?.status === 'blocked';
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function deriveHookRecovery(state, { phase, hookName, detail, errorCode, turnId, turnRetained }) {
|
|
563
|
+
const isTamper = errorCode?.includes('_tamper');
|
|
564
|
+
const pendingPhaseTransition = state?.pending_phase_transition;
|
|
565
|
+
const pendingRunCompletion = state?.pending_run_completion;
|
|
566
|
+
|
|
567
|
+
if (phase === 'before_gate' && pendingPhaseTransition) {
|
|
568
|
+
return {
|
|
569
|
+
typed_reason: isTamper ? 'hook_tamper' : 'pending_phase_transition',
|
|
570
|
+
owner: 'human',
|
|
571
|
+
recovery_action: isTamper
|
|
572
|
+
? 'Disable or fix the hook, verify protected files, then rerun agentxchain approve-transition'
|
|
573
|
+
: 'agentxchain approve-transition',
|
|
574
|
+
turn_retained: false,
|
|
575
|
+
detail: pendingPhaseTransition.gate || detail || hookName || phase,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (phase === 'before_gate' && pendingRunCompletion) {
|
|
580
|
+
return {
|
|
581
|
+
typed_reason: isTamper ? 'hook_tamper' : 'pending_run_completion',
|
|
582
|
+
owner: 'human',
|
|
583
|
+
recovery_action: isTamper
|
|
584
|
+
? 'Disable or fix the hook, verify protected files, then rerun agentxchain approve-completion'
|
|
585
|
+
: 'agentxchain approve-completion',
|
|
586
|
+
turn_retained: false,
|
|
587
|
+
detail: pendingRunCompletion.gate || detail || hookName || phase,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return {
|
|
592
|
+
typed_reason: isTamper ? 'hook_tamper' : 'hook_block',
|
|
593
|
+
owner: 'human',
|
|
594
|
+
recovery_action: isTamper
|
|
595
|
+
? 'Disable or fix the hook, verify protected files, then run agentxchain step --resume'
|
|
596
|
+
: `Fix or reconfigure hook "${hookName}", then rerun agentxchain accept-turn${turnId ? ` --turn ${turnId}` : ''}`,
|
|
597
|
+
turn_retained: Boolean(turnRetained),
|
|
598
|
+
detail: detail || hookName || phase,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function blockRunForHookIssue(root, state, { phase, turnId, hookName, detail, errorCode, turnRetained }) {
|
|
603
|
+
const blockedAt = new Date().toISOString();
|
|
604
|
+
const typedReason = errorCode?.includes('_tamper') ? 'hook_tamper' : 'hook_block';
|
|
605
|
+
const recovery = deriveHookRecovery(state, {
|
|
606
|
+
phase,
|
|
607
|
+
hookName,
|
|
608
|
+
detail,
|
|
609
|
+
errorCode,
|
|
610
|
+
turnId,
|
|
611
|
+
turnRetained,
|
|
612
|
+
});
|
|
613
|
+
const blockedState = {
|
|
614
|
+
...state,
|
|
615
|
+
status: 'blocked',
|
|
616
|
+
blocked_on: `hook:${phase}:${hookName || 'unknown'}`,
|
|
617
|
+
blocked_reason: buildBlockedReason({
|
|
618
|
+
category: typedReason,
|
|
619
|
+
recovery,
|
|
620
|
+
turnId,
|
|
621
|
+
blockedAt,
|
|
622
|
+
}),
|
|
623
|
+
};
|
|
624
|
+
writeState(root, blockedState);
|
|
625
|
+
return attachLegacyCurrentTurnAlias(blockedState);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Fire on_escalation hooks (advisory-only) after blocked state is persisted.
|
|
630
|
+
* These hooks are for external notification (Slack, PagerDuty, etc.).
|
|
631
|
+
* They cannot block or mutate state. Failures are logged to hook-audit.jsonl only.
|
|
632
|
+
*
|
|
633
|
+
* IMPORTANT: Do not call this from blockRunForHookIssue() — that would create
|
|
634
|
+
* a circular invocation where a hook failure triggers another hook.
|
|
635
|
+
*/
|
|
636
|
+
function _fireOnEscalationHooks(root, hooksConfig, payload) {
|
|
637
|
+
try {
|
|
638
|
+
const hookResult = runHooks(root, hooksConfig, 'on_escalation', payload, {
|
|
639
|
+
run_id: payload.run_id,
|
|
640
|
+
turn_id: payload.failed_turn_id,
|
|
641
|
+
});
|
|
642
|
+
// Advisory-only: result is logged in hook-audit.jsonl by runHooks().
|
|
643
|
+
// We do not act on the result — on_escalation cannot block.
|
|
644
|
+
return hookResult;
|
|
645
|
+
} catch (err) {
|
|
646
|
+
// Swallow errors — on_escalation must not prevent the blocked state from
|
|
647
|
+
// being returned to the caller. The error is already in hook-audit.jsonl
|
|
648
|
+
// if runHooks got far enough to write it.
|
|
649
|
+
return { ok: true, results: [], swallowed_error: err.message };
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function normalizeRecoveryDescriptor(recovery, turnRetained, detail) {
|
|
654
|
+
if (!recovery || typeof recovery !== 'object') {
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
typed_reason: typeof recovery.typed_reason === 'string' ? recovery.typed_reason : 'unknown_block',
|
|
660
|
+
owner: typeof recovery.owner === 'string' ? recovery.owner : 'human',
|
|
661
|
+
recovery_action: typeof recovery.recovery_action === 'string'
|
|
662
|
+
? recovery.recovery_action
|
|
663
|
+
: 'Inspect state.json and resolve manually before rerunning agentxchain step',
|
|
664
|
+
turn_retained: typeof recovery.turn_retained === 'boolean' ? recovery.turn_retained : Boolean(turnRetained),
|
|
665
|
+
detail: recovery.detail ?? detail ?? null,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function inferBlockedReasonFromState(state) {
|
|
670
|
+
if (!state || typeof state !== 'object') {
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (typeof state.blocked_on !== 'string' || !state.blocked_on.trim()) {
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const turnRetained = getActiveTurnCount(state) > 0;
|
|
679
|
+
const activeTurn = getActiveTurn(state);
|
|
680
|
+
|
|
681
|
+
if (state.blocked_on.startsWith('human:')) {
|
|
682
|
+
const detail = state.blocked_on.slice('human:'.length) || null;
|
|
683
|
+
return buildBlockedReason({
|
|
684
|
+
category: 'needs_human',
|
|
685
|
+
recovery: {
|
|
686
|
+
typed_reason: 'needs_human',
|
|
687
|
+
owner: 'human',
|
|
688
|
+
recovery_action: 'Resolve the stated issue, then run agentxchain step --resume',
|
|
689
|
+
turn_retained: turnRetained,
|
|
690
|
+
detail,
|
|
691
|
+
},
|
|
692
|
+
turnId: activeTurn?.turn_id ?? state.last_completed_turn_id ?? null,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (state.blocked_on.startsWith('escalation:')) {
|
|
697
|
+
return buildBlockedReason({
|
|
698
|
+
category: 'retries_exhausted',
|
|
699
|
+
recovery: {
|
|
700
|
+
typed_reason: 'retries_exhausted',
|
|
701
|
+
owner: 'human',
|
|
702
|
+
recovery_action: 'Resolve the escalation, then run agentxchain step --resume',
|
|
703
|
+
turn_retained: turnRetained,
|
|
704
|
+
detail: state.blocked_on,
|
|
705
|
+
},
|
|
706
|
+
turnId: activeTurn?.turn_id ?? null,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (state.blocked_on.startsWith('dispatch:')) {
|
|
711
|
+
const detail = state.blocked_on.slice('dispatch:'.length) || null;
|
|
712
|
+
return buildBlockedReason({
|
|
713
|
+
category: 'dispatch_error',
|
|
714
|
+
recovery: {
|
|
715
|
+
typed_reason: 'dispatch_error',
|
|
716
|
+
owner: 'human',
|
|
717
|
+
recovery_action: 'Resolve the dispatch issue, then run agentxchain step --resume',
|
|
718
|
+
turn_retained: turnRetained,
|
|
719
|
+
detail,
|
|
720
|
+
},
|
|
721
|
+
turnId: activeTurn?.turn_id ?? null,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
export function normalizeGovernedStateShape(state) {
|
|
729
|
+
if (!state || typeof state !== 'object') {
|
|
730
|
+
return { state, changed: false };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
let nextState = state;
|
|
734
|
+
let changed = false;
|
|
735
|
+
|
|
736
|
+
if (nextState.schema_version !== GOVERNED_SCHEMA_VERSION || 'current_turn' in nextState || !('active_turns' in nextState)) {
|
|
737
|
+
nextState = normalizeV1toV1_1(nextState);
|
|
738
|
+
changed = true;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const hasApprovalPause = Boolean(state.pending_phase_transition || state.pending_run_completion);
|
|
742
|
+
const legacyBlockedPause =
|
|
743
|
+
state.status === 'paused' &&
|
|
744
|
+
!hasApprovalPause &&
|
|
745
|
+
typeof state.blocked_on === 'string' &&
|
|
746
|
+
(state.blocked_on.startsWith('human:') || state.blocked_on.startsWith('escalation:'));
|
|
747
|
+
|
|
748
|
+
if (legacyBlockedPause) {
|
|
749
|
+
nextState = {
|
|
750
|
+
...nextState,
|
|
751
|
+
status: 'blocked',
|
|
752
|
+
};
|
|
753
|
+
changed = true;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (nextState.status === 'blocked') {
|
|
757
|
+
const inferred = inferBlockedReasonFromState(nextState);
|
|
758
|
+
const normalizedRecovery = normalizeRecoveryDescriptor(
|
|
759
|
+
nextState.blocked_reason?.recovery,
|
|
760
|
+
getActiveTurn(nextState),
|
|
761
|
+
nextState.blocked_reason?.recovery?.detail ?? inferred?.recovery?.detail ?? nextState.blocked_on ?? null,
|
|
762
|
+
);
|
|
763
|
+
|
|
764
|
+
if (!nextState.blocked_reason && inferred) {
|
|
765
|
+
nextState = {
|
|
766
|
+
...nextState,
|
|
767
|
+
blocked_reason: inferred,
|
|
768
|
+
};
|
|
769
|
+
changed = true;
|
|
770
|
+
} else if (
|
|
771
|
+
nextState.blocked_reason &&
|
|
772
|
+
normalizedRecovery &&
|
|
773
|
+
JSON.stringify(nextState.blocked_reason.recovery) !== JSON.stringify(normalizedRecovery)
|
|
774
|
+
) {
|
|
775
|
+
nextState = {
|
|
776
|
+
...nextState,
|
|
777
|
+
blocked_reason: {
|
|
778
|
+
...nextState.blocked_reason,
|
|
779
|
+
recovery: normalizedRecovery,
|
|
780
|
+
},
|
|
781
|
+
};
|
|
782
|
+
changed = true;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (nextState.status !== 'blocked' && 'blocked_reason' in nextState && nextState.blocked_reason != null) {
|
|
787
|
+
nextState = {
|
|
788
|
+
...nextState,
|
|
789
|
+
blocked_reason: null,
|
|
790
|
+
};
|
|
791
|
+
changed = true;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return { state: stripLegacyCurrentTurn(nextState), changed };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
export function markRunBlocked(root, details) {
|
|
798
|
+
const state = readState(root);
|
|
799
|
+
if (!state) {
|
|
800
|
+
return { ok: false, error: 'No governed state.json found' };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const blockedAt = details.blockedAt || new Date().toISOString();
|
|
804
|
+
const turnId = details.turnId ?? getActiveTurn(state)?.turn_id ?? null;
|
|
805
|
+
const blockedReason = buildBlockedReason({
|
|
806
|
+
category: details.category,
|
|
807
|
+
recovery: details.recovery,
|
|
808
|
+
turnId,
|
|
809
|
+
blockedAt,
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
const updatedState = {
|
|
813
|
+
...state,
|
|
814
|
+
status: 'blocked',
|
|
815
|
+
blocked_on: details.blockedOn,
|
|
816
|
+
blocked_reason: blockedReason,
|
|
817
|
+
escalation: details.escalation ?? state.escalation ?? null,
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
writeState(root, updatedState);
|
|
821
|
+
|
|
822
|
+
// Fire on_escalation hooks (advisory-only) after blocked state is persisted.
|
|
823
|
+
// Only fire for non-hook-caused blocks to prevent circular invocations.
|
|
824
|
+
if (details.hooksConfig?.on_escalation?.length > 0) {
|
|
825
|
+
const activeTurn = getActiveTurn(updatedState);
|
|
826
|
+
_fireOnEscalationHooks(root, details.hooksConfig, {
|
|
827
|
+
blocked_reason: details.category || 'unknown',
|
|
828
|
+
recovery_action: details.recovery?.recovery_action || 'unknown',
|
|
829
|
+
failed_turn_id: turnId || null,
|
|
830
|
+
failed_role: activeTurn?.assigned_role || null,
|
|
831
|
+
attempt_count: activeTurn?.attempt || 0,
|
|
832
|
+
last_error: details.recovery?.detail || details.blockedOn || 'unknown',
|
|
833
|
+
run_id: updatedState.run_id,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return { ok: true, state: updatedState };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ── Core Operations ──────────────────────────────────────────────────────────
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Initialize a governed run from idle state.
|
|
844
|
+
* Creates a run_id and sets status to 'active'.
|
|
845
|
+
*
|
|
846
|
+
* @param {string} root - project root directory
|
|
847
|
+
* @param {object} config - normalized config
|
|
848
|
+
* @returns {{ ok: boolean, error?: string, state?: object }}
|
|
849
|
+
*/
|
|
850
|
+
export function initializeGovernedRun(root, config) {
|
|
851
|
+
const state = readState(root);
|
|
852
|
+
if (!state) {
|
|
853
|
+
return { ok: false, error: 'No governed state.json found' };
|
|
854
|
+
}
|
|
855
|
+
if (state.status === 'completed') {
|
|
856
|
+
return { ok: false, error: 'Cannot initialize run: this run is already completed. Start a new project or reset state.' };
|
|
857
|
+
}
|
|
858
|
+
const allowBlockedBootstrap = state.status === 'blocked' && state.run_id === null && getActiveTurnCount(state) === 0;
|
|
859
|
+
if (state.status !== 'idle' && state.status !== 'paused' && !allowBlockedBootstrap) {
|
|
860
|
+
return { ok: false, error: `Cannot initialize run: status is "${state.status}", expected "idle", "paused", or pre-run "blocked"` };
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const runId = generateId('run');
|
|
864
|
+
const updatedState = {
|
|
865
|
+
...state,
|
|
866
|
+
run_id: runId,
|
|
867
|
+
status: 'active',
|
|
868
|
+
blocked_on: null,
|
|
869
|
+
blocked_reason: null,
|
|
870
|
+
budget_status: {
|
|
871
|
+
spent_usd: 0,
|
|
872
|
+
remaining_usd: config.budget?.per_run_max_usd ?? null
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
writeState(root, updatedState);
|
|
877
|
+
return { ok: true, state: attachLegacyCurrentTurnAlias(updatedState) };
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Assign a turn to a role.
|
|
882
|
+
* Supports parallel assignment up to max_concurrent_turns for the current phase.
|
|
883
|
+
*
|
|
884
|
+
* Guards (DEC-PARALLEL-006, DEC-PARALLEL-007, DEC-PARALLEL-011):
|
|
885
|
+
* - No assignment while run is blocked
|
|
886
|
+
* - Same role cannot hold two active turns
|
|
887
|
+
* - Concurrency limit per phase is respected
|
|
888
|
+
* - Budget reservation is created per turn
|
|
889
|
+
*
|
|
890
|
+
* @param {string} root - project root directory
|
|
891
|
+
* @param {object} config - normalized config
|
|
892
|
+
* @param {string} roleId - the role to assign
|
|
893
|
+
* @returns {{ ok: boolean, error?: string, warnings?: string[], state?: object }}
|
|
894
|
+
*/
|
|
895
|
+
export function assignGovernedTurn(root, config, roleId) {
|
|
896
|
+
const state = readState(root);
|
|
897
|
+
if (!state) {
|
|
898
|
+
return { ok: false, error: 'No governed state.json found' };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// DEC-PARALLEL-007: No new assignment while run is blocked
|
|
902
|
+
if (state.status === 'blocked') {
|
|
903
|
+
return { ok: false, error: 'Cannot assign turn: run is blocked. Resolve the blocked state before assigning new turns.' };
|
|
904
|
+
}
|
|
905
|
+
if (state.status !== 'active') {
|
|
906
|
+
return { ok: false, error: `Cannot assign turn: status is "${state.status}", expected "active"` };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const role = config.roles?.[roleId];
|
|
910
|
+
if (!role) {
|
|
911
|
+
return { ok: false, error: `Unknown role: "${roleId}"` };
|
|
912
|
+
}
|
|
913
|
+
const runtimeId = role.runtime_id || role.runtime;
|
|
914
|
+
if (!runtimeId) {
|
|
915
|
+
return { ok: false, error: `Role "${roleId}" has no runtime identifier` };
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Concurrency checks
|
|
919
|
+
const activeTurns = getActiveTurns(state);
|
|
920
|
+
const activeCount = Object.keys(activeTurns).length;
|
|
921
|
+
const maxConcurrent = getMaxConcurrentTurns(config, state.phase);
|
|
922
|
+
|
|
923
|
+
// When max_concurrent_turns = 1 (sequential mode), preserve backward-compatible
|
|
924
|
+
// error message before any parallel-specific checks
|
|
925
|
+
if (maxConcurrent === 1 && activeCount >= 1) {
|
|
926
|
+
const existing = Object.values(activeTurns)[0];
|
|
927
|
+
return { ok: false, error: `Turn already assigned: ${existing.turn_id} to ${existing.assigned_role}` };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// DEC-PARALLEL-006: One active turn per role at a time
|
|
931
|
+
const existingRoleTurn = Object.values(activeTurns).find(t => t.assigned_role === roleId);
|
|
932
|
+
if (existingRoleTurn) {
|
|
933
|
+
return { ok: false, error: `Role "${roleId}" already has an active turn: ${existingRoleTurn.turn_id}` };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Concurrency limit
|
|
937
|
+
if (activeCount >= maxConcurrent) {
|
|
938
|
+
return { ok: false, error: `Cannot assign turn: ${activeCount} active turn(s) already at capacity (max_concurrent_turns = ${maxConcurrent})` };
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// DEC-PARALLEL-011: Budget reservation
|
|
942
|
+
const warnings = [];
|
|
943
|
+
const reservations = { ...(state.budget_reservations || {}) };
|
|
944
|
+
const turnId = generateId('turn');
|
|
945
|
+
const estimatedCost = estimateTurnBudget(config, roleId);
|
|
946
|
+
|
|
947
|
+
if (estimatedCost > 0 && state.budget_status?.remaining_usd != null) {
|
|
948
|
+
const alreadyReserved = Object.values(reservations).reduce((sum, r) => sum + (r.reserved_usd || 0), 0);
|
|
949
|
+
const available = state.budget_status.remaining_usd - alreadyReserved;
|
|
950
|
+
if (estimatedCost > available) {
|
|
951
|
+
return { ok: false, error: `Cannot assign turn: estimated cost $${estimatedCost.toFixed(2)} exceeds available budget $${available.toFixed(2)} (after reservations)` };
|
|
952
|
+
}
|
|
953
|
+
reservations[turnId] = {
|
|
954
|
+
reserved_usd: estimatedCost,
|
|
955
|
+
role_id: roleId,
|
|
956
|
+
created_at: new Date().toISOString(),
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// DEC-PARALLEL-008: Advisory overlap warning (declared_file_scope)
|
|
961
|
+
if (role.declared_file_scope && activeCount > 0) {
|
|
962
|
+
const roleScope = new Set(Array.isArray(role.declared_file_scope) ? role.declared_file_scope : []);
|
|
963
|
+
for (const existingTurn of Object.values(activeTurns)) {
|
|
964
|
+
const existingRole = config.roles?.[existingTurn.assigned_role];
|
|
965
|
+
if (existingRole?.declared_file_scope) {
|
|
966
|
+
const existingScope = new Set(Array.isArray(existingRole.declared_file_scope) ? existingRole.declared_file_scope : []);
|
|
967
|
+
const overlap = [...roleScope].filter(f => existingScope.has(f));
|
|
968
|
+
if (overlap.length > 0) {
|
|
969
|
+
warnings.push(`Advisory: declared_file_scope overlap with turn ${existingTurn.turn_id} (${existingTurn.assigned_role}): ${overlap.join(', ')}`);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// v1 clean-baseline rule: authoritative/proposed turns require a clean working tree
|
|
976
|
+
const writeAuthority = role.write_authority || 'review_only';
|
|
977
|
+
const cleanCheck = checkCleanBaseline(root, writeAuthority);
|
|
978
|
+
if (!cleanCheck.clean) {
|
|
979
|
+
return { ok: false, error: cleanCheck.reason };
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const hooksConfig = config.hooks || {};
|
|
983
|
+
if (hooksConfig.before_assignment && hooksConfig.before_assignment.length > 0) {
|
|
984
|
+
const historyLength = readJsonlEntries(root, HISTORY_PATH).length;
|
|
985
|
+
const beforeAssignmentPayload = {
|
|
986
|
+
role_id: roleId,
|
|
987
|
+
role_config: role,
|
|
988
|
+
phase: state.phase,
|
|
989
|
+
active_turns: Object.values(activeTurns).map((turn) => ({
|
|
990
|
+
turn_id: turn.turn_id,
|
|
991
|
+
role_id: turn.assigned_role,
|
|
992
|
+
status: turn.status,
|
|
993
|
+
attempt: turn.attempt,
|
|
994
|
+
})),
|
|
995
|
+
history_length: historyLength,
|
|
996
|
+
};
|
|
997
|
+
const beforeAssignmentHooks = runHooks(root, hooksConfig, 'before_assignment', beforeAssignmentPayload, {
|
|
998
|
+
run_id: state.run_id,
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
if (!beforeAssignmentHooks.ok) {
|
|
1002
|
+
const hookName = beforeAssignmentHooks.blocker?.hook_name
|
|
1003
|
+
|| beforeAssignmentHooks.results?.find((entry) => entry.hook_name)?.hook_name
|
|
1004
|
+
|| 'unknown';
|
|
1005
|
+
const detail = beforeAssignmentHooks.blocker?.message
|
|
1006
|
+
|| beforeAssignmentHooks.tamper?.message
|
|
1007
|
+
|| `before_assignment hook "${hookName}" halted assignment`;
|
|
1008
|
+
|
|
1009
|
+
if (beforeAssignmentHooks.tamper) {
|
|
1010
|
+
const blockedState = blockRunForHookIssue(root, state, {
|
|
1011
|
+
phase: 'before_assignment',
|
|
1012
|
+
turnId: null,
|
|
1013
|
+
hookName,
|
|
1014
|
+
detail,
|
|
1015
|
+
errorCode: beforeAssignmentHooks.tamper.error_code,
|
|
1016
|
+
turnRetained: activeCount > 0,
|
|
1017
|
+
});
|
|
1018
|
+
return {
|
|
1019
|
+
ok: false,
|
|
1020
|
+
error: detail,
|
|
1021
|
+
error_code: beforeAssignmentHooks.tamper.error_code,
|
|
1022
|
+
state: blockedState,
|
|
1023
|
+
hookResults: beforeAssignmentHooks,
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
return {
|
|
1028
|
+
ok: false,
|
|
1029
|
+
error: detail,
|
|
1030
|
+
error_code: 'hook_blocked',
|
|
1031
|
+
state: attachLegacyCurrentTurnAlias(state),
|
|
1032
|
+
hookResults: beforeAssignmentHooks,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Capture baseline snapshot for observed diff at acceptance time
|
|
1038
|
+
const baseline = captureBaseline(root);
|
|
1039
|
+
|
|
1040
|
+
const now = new Date().toISOString();
|
|
1041
|
+
const timeoutMinutes = 20;
|
|
1042
|
+
const nextSequence = (state.turn_sequence || 0) + 1;
|
|
1043
|
+
|
|
1044
|
+
// Record which turns are concurrent siblings (for conflict detection context)
|
|
1045
|
+
const concurrentWith = Object.keys(activeTurns);
|
|
1046
|
+
|
|
1047
|
+
const updatedState = {
|
|
1048
|
+
...state,
|
|
1049
|
+
turn_sequence: nextSequence,
|
|
1050
|
+
budget_reservations: reservations,
|
|
1051
|
+
active_turns: {
|
|
1052
|
+
...activeTurns,
|
|
1053
|
+
[turnId]: {
|
|
1054
|
+
turn_id: turnId,
|
|
1055
|
+
assigned_role: roleId,
|
|
1056
|
+
status: 'running',
|
|
1057
|
+
attempt: 1,
|
|
1058
|
+
started_at: now,
|
|
1059
|
+
deadline_at: new Date(Date.now() + timeoutMinutes * 60 * 1000).toISOString(),
|
|
1060
|
+
runtime_id: runtimeId,
|
|
1061
|
+
baseline,
|
|
1062
|
+
assigned_sequence: nextSequence,
|
|
1063
|
+
concurrent_with: concurrentWith,
|
|
1064
|
+
},
|
|
1065
|
+
},
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
writeState(root, updatedState);
|
|
1069
|
+
const result = { ok: true, state: attachLegacyCurrentTurnAlias(updatedState) };
|
|
1070
|
+
if (warnings.length > 0) {
|
|
1071
|
+
result.warnings = warnings;
|
|
1072
|
+
}
|
|
1073
|
+
return result;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Estimate the budget for a single turn based on role/runtime configuration.
|
|
1078
|
+
* Used for DEC-PARALLEL-011 budget reservation.
|
|
1079
|
+
*
|
|
1080
|
+
* @param {object} config - normalized config
|
|
1081
|
+
* @param {string} roleId - the role being assigned
|
|
1082
|
+
* @returns {number} estimated cost in USD (0 if not estimable)
|
|
1083
|
+
*/
|
|
1084
|
+
function estimateTurnBudget(config, roleId) {
|
|
1085
|
+
// Use per_turn_max_usd as the reservation estimate
|
|
1086
|
+
if (config.budget?.per_turn_max_usd != null && config.budget.per_turn_max_usd > 0) {
|
|
1087
|
+
return config.budget.per_turn_max_usd;
|
|
1088
|
+
}
|
|
1089
|
+
return 0;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Accept a governed turn.
|
|
1094
|
+
*
|
|
1095
|
+
* 1. Load current state
|
|
1096
|
+
* 2. Validate .agentxchain/staging/turn-result.json
|
|
1097
|
+
* 3. Append accepted entry to history.jsonl
|
|
1098
|
+
* 4. Append decisions to decision-ledger.jsonl
|
|
1099
|
+
* 5. Append prose section to TALK.md
|
|
1100
|
+
* 6. Update state.json
|
|
1101
|
+
* 7. Clear staging file
|
|
1102
|
+
*
|
|
1103
|
+
* Does NOT auto-assign the next turn.
|
|
1104
|
+
*
|
|
1105
|
+
* @param {string} root - project root directory
|
|
1106
|
+
* @param {object} config - normalized config
|
|
1107
|
+
* @param {object} [opts]
|
|
1108
|
+
* @param {string} [opts.turnId] - explicit target turn when multiple turns are active
|
|
1109
|
+
* @returns {{ ok: boolean, error?: string, error_code?: string, validation?: object, state?: object }}
|
|
1110
|
+
*/
|
|
1111
|
+
export function acceptGovernedTurn(root, config, opts = {}) {
|
|
1112
|
+
// Replay any prepared journals from previous crashes before starting
|
|
1113
|
+
replayPreparedJournals(root);
|
|
1114
|
+
|
|
1115
|
+
// Pre-lock target resolution (quick fail for obviously invalid requests)
|
|
1116
|
+
const preState = readState(root);
|
|
1117
|
+
if (!preState) {
|
|
1118
|
+
return { ok: false, error: 'No governed state.json found' };
|
|
1119
|
+
}
|
|
1120
|
+
const preResolution = resolveTurnTarget(preState, opts.turnId);
|
|
1121
|
+
if (!preResolution.ok) {
|
|
1122
|
+
return preResolution;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// Acquire acceptance lock — serializes concurrent acceptance attempts
|
|
1126
|
+
const lockResult = acquireAcceptanceLock(root);
|
|
1127
|
+
if (!lockResult.ok) {
|
|
1128
|
+
return lockResult;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
try {
|
|
1132
|
+
return _acceptGovernedTurnLocked(root, config, opts);
|
|
1133
|
+
} finally {
|
|
1134
|
+
releaseAcceptanceLock(root);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function _acceptGovernedTurnLocked(root, config, opts) {
|
|
1139
|
+
// Re-read state under lock (a sibling acceptance may have committed)
|
|
1140
|
+
let state = readState(root);
|
|
1141
|
+
if (!state) {
|
|
1142
|
+
return { ok: false, error: 'No governed state.json found' };
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const targetResolution = resolveTurnTarget(state, opts.turnId);
|
|
1146
|
+
if (!targetResolution.ok) {
|
|
1147
|
+
return targetResolution;
|
|
1148
|
+
}
|
|
1149
|
+
let currentTurn = targetResolution.turn;
|
|
1150
|
+
|
|
1151
|
+
const resolutionMode = opts.resolutionMode || 'standard';
|
|
1152
|
+
if (resolutionMode !== 'standard' && resolutionMode !== 'human_merge') {
|
|
1153
|
+
return {
|
|
1154
|
+
ok: false,
|
|
1155
|
+
error: `Unknown resolution mode "${resolutionMode}"`,
|
|
1156
|
+
error_code: 'protocol_error',
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (resolutionMode === 'human_merge') {
|
|
1161
|
+
if (!currentTurn.conflict_state) {
|
|
1162
|
+
return {
|
|
1163
|
+
ok: false,
|
|
1164
|
+
error: 'human_merge resolution requires a conflicted active turn.',
|
|
1165
|
+
error_code: 'protocol_error',
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if (currentTurn.conflict_state.status !== 'human_merging') {
|
|
1170
|
+
appendJsonl(root, LEDGER_PATH, {
|
|
1171
|
+
timestamp: new Date().toISOString(),
|
|
1172
|
+
decision: 'conflict_resolution_selected',
|
|
1173
|
+
turn_id: currentTurn.turn_id,
|
|
1174
|
+
attempt: currentTurn.attempt,
|
|
1175
|
+
role: currentTurn.assigned_role,
|
|
1176
|
+
phase: state.phase,
|
|
1177
|
+
conflict: {
|
|
1178
|
+
conflicting_files: currentTurn.conflict_state.conflict_error?.conflicting_files || [],
|
|
1179
|
+
accepted_since_turn_ids: (currentTurn.conflict_state.conflict_error?.accepted_since || []).map((entry) => entry.turn_id),
|
|
1180
|
+
overlap_ratio: currentTurn.conflict_state.conflict_error?.overlap_ratio ?? 0,
|
|
1181
|
+
},
|
|
1182
|
+
resolution_chosen: 'human_merge',
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
state = {
|
|
1186
|
+
...state,
|
|
1187
|
+
active_turns: {
|
|
1188
|
+
...getActiveTurns(state),
|
|
1189
|
+
[currentTurn.turn_id]: {
|
|
1190
|
+
...currentTurn,
|
|
1191
|
+
status: 'conflicted',
|
|
1192
|
+
conflict_state: {
|
|
1193
|
+
...currentTurn.conflict_state,
|
|
1194
|
+
status: 'human_merging',
|
|
1195
|
+
},
|
|
1196
|
+
},
|
|
1197
|
+
},
|
|
1198
|
+
};
|
|
1199
|
+
writeState(root, state);
|
|
1200
|
+
currentTurn = state.active_turns[currentTurn.turn_id];
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const turnStagingPath = getTurnStagingResultPath(currentTurn.turn_id);
|
|
1205
|
+
const resolvedStagingPath = existsSync(join(root, turnStagingPath)) ? turnStagingPath : STAGING_PATH;
|
|
1206
|
+
const stagedTurn = loadHookStagedTurn(root, resolvedStagingPath);
|
|
1207
|
+
const validationState = attachLegacyCurrentTurnAlias({
|
|
1208
|
+
...state,
|
|
1209
|
+
active_turns: {
|
|
1210
|
+
[currentTurn.turn_id]: currentTurn,
|
|
1211
|
+
},
|
|
1212
|
+
});
|
|
1213
|
+
const hooksConfig = config.hooks || {};
|
|
1214
|
+
|
|
1215
|
+
if (hooksConfig.before_validation && hooksConfig.before_validation.length > 0) {
|
|
1216
|
+
const beforeValidationPayload = {
|
|
1217
|
+
turn_id: currentTurn.turn_id,
|
|
1218
|
+
role_id: currentTurn.assigned_role,
|
|
1219
|
+
staging_path: resolvedStagingPath,
|
|
1220
|
+
turn_result: stagedTurn.turnResult ?? null,
|
|
1221
|
+
...(stagedTurn.parse_error ? { parse_error: stagedTurn.parse_error } : {}),
|
|
1222
|
+
...(stagedTurn.read_error ? { read_error: stagedTurn.read_error } : {}),
|
|
1223
|
+
};
|
|
1224
|
+
const beforeValidationHooks = runHooks(root, hooksConfig, 'before_validation', beforeValidationPayload, {
|
|
1225
|
+
run_id: state.run_id,
|
|
1226
|
+
turn_id: currentTurn.turn_id,
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
if (!beforeValidationHooks.ok) {
|
|
1230
|
+
const hookName = beforeValidationHooks.blocker?.hook_name
|
|
1231
|
+
|| beforeValidationHooks.results?.find((entry) => entry.hook_name)?.hook_name
|
|
1232
|
+
|| 'unknown';
|
|
1233
|
+
const detail = beforeValidationHooks.blocker?.message
|
|
1234
|
+
|| beforeValidationHooks.tamper?.message
|
|
1235
|
+
|| `before_validation hook "${hookName}" halted acceptance`;
|
|
1236
|
+
const blockedState = blockRunForHookIssue(root, state, {
|
|
1237
|
+
phase: 'before_validation',
|
|
1238
|
+
turnId: currentTurn.turn_id,
|
|
1239
|
+
hookName,
|
|
1240
|
+
detail,
|
|
1241
|
+
errorCode: beforeValidationHooks.tamper?.error_code || 'hook_blocked',
|
|
1242
|
+
turnRetained: true,
|
|
1243
|
+
});
|
|
1244
|
+
return {
|
|
1245
|
+
ok: false,
|
|
1246
|
+
error: detail,
|
|
1247
|
+
error_code: beforeValidationHooks.tamper?.error_code || 'hook_blocked',
|
|
1248
|
+
state: blockedState,
|
|
1249
|
+
hookResults: beforeValidationHooks,
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
const validation = validateStagedTurnResult(root, validationState, config, { stagingPath: resolvedStagingPath });
|
|
1255
|
+
if (hooksConfig.after_validation && hooksConfig.after_validation.length > 0) {
|
|
1256
|
+
const afterValidationPayload = {
|
|
1257
|
+
turn_id: currentTurn.turn_id,
|
|
1258
|
+
role_id: currentTurn.assigned_role,
|
|
1259
|
+
validation_ok: validation.ok,
|
|
1260
|
+
validation_stage: validation.stage,
|
|
1261
|
+
errors: validation.errors,
|
|
1262
|
+
warnings: validation.warnings,
|
|
1263
|
+
turn_result: validation.turnResult ?? stagedTurn.turnResult ?? null,
|
|
1264
|
+
};
|
|
1265
|
+
const afterValidationHooks = runHooks(root, hooksConfig, 'after_validation', afterValidationPayload, {
|
|
1266
|
+
run_id: state.run_id,
|
|
1267
|
+
turn_id: currentTurn.turn_id,
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
if (!afterValidationHooks.ok) {
|
|
1271
|
+
const hookName = afterValidationHooks.blocker?.hook_name
|
|
1272
|
+
|| afterValidationHooks.results?.find((entry) => entry.hook_name)?.hook_name
|
|
1273
|
+
|| 'unknown';
|
|
1274
|
+
const detail = afterValidationHooks.blocker?.message
|
|
1275
|
+
|| afterValidationHooks.tamper?.message
|
|
1276
|
+
|| `after_validation hook "${hookName}" halted acceptance`;
|
|
1277
|
+
const blockedState = blockRunForHookIssue(root, state, {
|
|
1278
|
+
phase: 'after_validation',
|
|
1279
|
+
turnId: currentTurn.turn_id,
|
|
1280
|
+
hookName,
|
|
1281
|
+
detail,
|
|
1282
|
+
errorCode: afterValidationHooks.tamper?.error_code || 'hook_blocked',
|
|
1283
|
+
turnRetained: true,
|
|
1284
|
+
});
|
|
1285
|
+
return {
|
|
1286
|
+
ok: false,
|
|
1287
|
+
error: detail,
|
|
1288
|
+
error_code: afterValidationHooks.tamper?.error_code || 'hook_blocked',
|
|
1289
|
+
state: blockedState,
|
|
1290
|
+
hookResults: afterValidationHooks,
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
if (!validation.ok) {
|
|
1296
|
+
return {
|
|
1297
|
+
ok: false,
|
|
1298
|
+
error: `Validation failed at stage ${validation.stage}: ${validation.errors.join('; ')}`,
|
|
1299
|
+
validation,
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
const turnResult = validation.turnResult;
|
|
1304
|
+
const stagingFile = join(root, resolvedStagingPath);
|
|
1305
|
+
const now = new Date().toISOString();
|
|
1306
|
+
const baseline = currentTurn.baseline || null;
|
|
1307
|
+
const observation = observeChanges(root, baseline);
|
|
1308
|
+
const role = config.roles?.[turnResult.role];
|
|
1309
|
+
const runtimeId = turnResult.runtime_id;
|
|
1310
|
+
const runtime = config.runtimes?.[runtimeId];
|
|
1311
|
+
const runtimeType = runtime?.type || 'manual';
|
|
1312
|
+
const writeAuthority = role?.write_authority || 'review_only';
|
|
1313
|
+
const diffComparison = compareDeclaredVsObserved(
|
|
1314
|
+
turnResult.files_changed || [],
|
|
1315
|
+
observation.files_changed,
|
|
1316
|
+
writeAuthority,
|
|
1317
|
+
);
|
|
1318
|
+
if (diffComparison.errors.length > 0) {
|
|
1319
|
+
return {
|
|
1320
|
+
ok: false,
|
|
1321
|
+
error: `Observed artifact mismatch: ${diffComparison.errors.join('; ')}`,
|
|
1322
|
+
validation: {
|
|
1323
|
+
...validation,
|
|
1324
|
+
ok: false,
|
|
1325
|
+
stage: 'artifact_observation',
|
|
1326
|
+
error_class: 'artifact_error',
|
|
1327
|
+
errors: diffComparison.errors,
|
|
1328
|
+
warnings: diffComparison.warnings,
|
|
1329
|
+
},
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
const observedArtifact = buildObservedArtifact(observation, baseline);
|
|
1334
|
+
const normalizedVerification = normalizeVerification(turnResult.verification, runtimeType);
|
|
1335
|
+
const artifactType = turnResult.artifact?.type || 'review';
|
|
1336
|
+
const derivedRef = deriveAcceptedRef(observation, artifactType, state.accepted_integration_ref);
|
|
1337
|
+
const historyEntries = readJsonlEntries(root, HISTORY_PATH);
|
|
1338
|
+
const conflict = detectAcceptanceConflict(currentTurn, observedArtifact, historyEntries);
|
|
1339
|
+
|
|
1340
|
+
if (conflict) {
|
|
1341
|
+
const detectionCount = (currentTurn.conflict_state?.detection_count || 0) + 1;
|
|
1342
|
+
const conflictState = {
|
|
1343
|
+
detected_at: now,
|
|
1344
|
+
detection_count: detectionCount,
|
|
1345
|
+
status: 'pending_operator',
|
|
1346
|
+
conflict_error: conflict,
|
|
1347
|
+
};
|
|
1348
|
+
const updatedState = {
|
|
1349
|
+
...state,
|
|
1350
|
+
active_turns: {
|
|
1351
|
+
...getActiveTurns(state),
|
|
1352
|
+
[currentTurn.turn_id]: {
|
|
1353
|
+
...currentTurn,
|
|
1354
|
+
status: 'conflicted',
|
|
1355
|
+
conflict_state: conflictState,
|
|
1356
|
+
},
|
|
1357
|
+
},
|
|
1358
|
+
};
|
|
1359
|
+
|
|
1360
|
+
if (detectionCount >= 3) {
|
|
1361
|
+
updatedState.status = 'blocked';
|
|
1362
|
+
updatedState.blocked_on = `human:conflict_loop:${currentTurn.turn_id}`;
|
|
1363
|
+
updatedState.blocked_reason = buildBlockedReason({
|
|
1364
|
+
category: 'conflict_loop',
|
|
1365
|
+
recovery: {
|
|
1366
|
+
typed_reason: 'conflict_loop',
|
|
1367
|
+
owner: 'human',
|
|
1368
|
+
recovery_action: `Serialize the conflicting work, then run agentxchain step --resume --turn ${currentTurn.turn_id}`,
|
|
1369
|
+
turn_retained: true,
|
|
1370
|
+
detail: buildConflictDetail(conflict),
|
|
1371
|
+
},
|
|
1372
|
+
turnId: currentTurn.turn_id,
|
|
1373
|
+
blockedAt: now,
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
appendJsonl(root, LEDGER_PATH, {
|
|
1378
|
+
timestamp: now,
|
|
1379
|
+
decision: 'conflict_detected',
|
|
1380
|
+
turn_id: currentTurn.turn_id,
|
|
1381
|
+
attempt: currentTurn.attempt,
|
|
1382
|
+
role: currentTurn.assigned_role,
|
|
1383
|
+
phase: state.phase,
|
|
1384
|
+
conflict: {
|
|
1385
|
+
conflicting_files: conflict.conflicting_files,
|
|
1386
|
+
accepted_since_turn_ids: conflict.accepted_since.map(entry => entry.turn_id),
|
|
1387
|
+
overlap_ratio: conflict.overlap_ratio,
|
|
1388
|
+
},
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
writeState(root, updatedState);
|
|
1392
|
+
return {
|
|
1393
|
+
ok: false,
|
|
1394
|
+
error: `Acceptance conflict detected for turn ${currentTurn.turn_id}`,
|
|
1395
|
+
error_code: 'conflict',
|
|
1396
|
+
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
1397
|
+
conflict,
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
if (hooksConfig.before_acceptance && hooksConfig.before_acceptance.length > 0) {
|
|
1402
|
+
const classified = classifyObservedChanges(root, observation, baseline);
|
|
1403
|
+
const beforeAcceptancePayload = {
|
|
1404
|
+
turn_id: currentTurn.turn_id,
|
|
1405
|
+
role_id: currentTurn.assigned_role,
|
|
1406
|
+
turn_result: turnResult,
|
|
1407
|
+
observed_changes: classified,
|
|
1408
|
+
conflict_detected: false,
|
|
1409
|
+
};
|
|
1410
|
+
const beforeAcceptanceHooks = runHooks(root, hooksConfig, 'before_acceptance', beforeAcceptancePayload, {
|
|
1411
|
+
run_id: state.run_id,
|
|
1412
|
+
turn_id: currentTurn.turn_id,
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
if (!beforeAcceptanceHooks.ok) {
|
|
1416
|
+
const hookName = beforeAcceptanceHooks.blocker?.hook_name
|
|
1417
|
+
|| beforeAcceptanceHooks.results?.find((entry) => entry.hook_name)?.hook_name
|
|
1418
|
+
|| 'unknown';
|
|
1419
|
+
const detail = beforeAcceptanceHooks.blocker?.message
|
|
1420
|
+
|| beforeAcceptanceHooks.tamper?.message
|
|
1421
|
+
|| `before_acceptance hook "${hookName}" halted acceptance`;
|
|
1422
|
+
const blockedState = blockRunForHookIssue(root, state, {
|
|
1423
|
+
phase: 'before_acceptance',
|
|
1424
|
+
turnId: currentTurn.turn_id,
|
|
1425
|
+
hookName,
|
|
1426
|
+
detail,
|
|
1427
|
+
errorCode: beforeAcceptanceHooks.tamper?.error_code || 'hook_blocked',
|
|
1428
|
+
turnRetained: true,
|
|
1429
|
+
});
|
|
1430
|
+
return {
|
|
1431
|
+
ok: false,
|
|
1432
|
+
error: detail,
|
|
1433
|
+
error_code: beforeAcceptanceHooks.tamper?.error_code || 'hook_blocked',
|
|
1434
|
+
state: blockedState,
|
|
1435
|
+
hookResults: beforeAcceptanceHooks,
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
const acceptedSequence = (state.turn_sequence || 0) + 1;
|
|
1441
|
+
const historyEntry = {
|
|
1442
|
+
turn_id: turnResult.turn_id,
|
|
1443
|
+
run_id: turnResult.run_id,
|
|
1444
|
+
role: turnResult.role,
|
|
1445
|
+
runtime_id: turnResult.runtime_id,
|
|
1446
|
+
status: turnResult.status,
|
|
1447
|
+
summary: turnResult.summary,
|
|
1448
|
+
decisions: turnResult.decisions || [],
|
|
1449
|
+
objections: turnResult.objections || [],
|
|
1450
|
+
files_changed: turnResult.files_changed || [],
|
|
1451
|
+
artifacts_created: turnResult.artifacts_created || [],
|
|
1452
|
+
verification: turnResult.verification || {},
|
|
1453
|
+
normalized_verification: normalizedVerification,
|
|
1454
|
+
artifact: turnResult.artifact || {},
|
|
1455
|
+
observed_artifact: observedArtifact,
|
|
1456
|
+
proposed_next_role: turnResult.proposed_next_role,
|
|
1457
|
+
phase_transition_request: turnResult.phase_transition_request,
|
|
1458
|
+
run_completion_request: Boolean(turnResult.run_completion_request),
|
|
1459
|
+
assigned_sequence: Number.isInteger(currentTurn.assigned_sequence) ? currentTurn.assigned_sequence : acceptedSequence,
|
|
1460
|
+
accepted_sequence: acceptedSequence,
|
|
1461
|
+
concurrent_with: Array.isArray(currentTurn.concurrent_with) ? currentTurn.concurrent_with : [],
|
|
1462
|
+
cost: turnResult.cost || {},
|
|
1463
|
+
accepted_at: now,
|
|
1464
|
+
};
|
|
1465
|
+
// Build ledger entries for the journal
|
|
1466
|
+
const ledgerEntries = [];
|
|
1467
|
+
if (turnResult.decisions && turnResult.decisions.length > 0) {
|
|
1468
|
+
for (const decision of turnResult.decisions) {
|
|
1469
|
+
ledgerEntries.push({
|
|
1470
|
+
id: decision.id,
|
|
1471
|
+
turn_id: turnResult.turn_id,
|
|
1472
|
+
role: turnResult.role,
|
|
1473
|
+
phase: state.phase,
|
|
1474
|
+
category: decision.category,
|
|
1475
|
+
statement: decision.statement,
|
|
1476
|
+
rationale: decision.rationale,
|
|
1477
|
+
objections_against: [],
|
|
1478
|
+
status: 'accepted',
|
|
1479
|
+
overridden_by: null,
|
|
1480
|
+
created_at: now,
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
const turnNumber = turnResult.turn_id.replace(/^turn_/, '').slice(0, 8);
|
|
1486
|
+
const talkSection = `## Turn ${turnNumber} — ${turnResult.role} (${state.phase})\n\n- **Status:** ${turnResult.status}\n- **Summary:** ${turnResult.summary}\n${turnResult.decisions?.length ? turnResult.decisions.map(d => `- **Decision ${d.id}:** ${d.statement}`).join('\n') + '\n' : ''}${turnResult.objections?.length ? turnResult.objections.map(o => `- **Objection ${o.id} (${o.severity}):** ${o.statement}`).join('\n') + '\n' : ''}- **Proposed next:** ${turnResult.proposed_next_role || 'human'}\n\n---\n`;
|
|
1487
|
+
|
|
1488
|
+
const remainingTurns = { ...getActiveTurns(state) };
|
|
1489
|
+
delete remainingTurns[currentTurn.turn_id];
|
|
1490
|
+
const remainingReservations = { ...(state.budget_reservations || {}) };
|
|
1491
|
+
delete remainingReservations[currentTurn.turn_id];
|
|
1492
|
+
const costUsd = turnResult.cost?.usd || 0;
|
|
1493
|
+
const updatedState = {
|
|
1494
|
+
...state,
|
|
1495
|
+
turn_sequence: acceptedSequence,
|
|
1496
|
+
last_completed_turn_id: currentTurn.turn_id,
|
|
1497
|
+
active_turns: remainingTurns,
|
|
1498
|
+
budget_reservations: remainingReservations,
|
|
1499
|
+
blocked_on: turnResult.status === 'needs_human' ? `human:${turnResult.needs_human_reason || 'unspecified'}` : null,
|
|
1500
|
+
blocked_reason: null,
|
|
1501
|
+
escalation: null,
|
|
1502
|
+
accepted_integration_ref: derivedRef,
|
|
1503
|
+
next_recommended_role: deriveNextRecommendedRole(turnResult, state, config),
|
|
1504
|
+
budget_status: {
|
|
1505
|
+
spent_usd: (state.budget_status?.spent_usd || 0) + costUsd,
|
|
1506
|
+
remaining_usd: state.budget_status?.remaining_usd != null
|
|
1507
|
+
? state.budget_status.remaining_usd - costUsd
|
|
1508
|
+
: null,
|
|
1509
|
+
},
|
|
1510
|
+
};
|
|
1511
|
+
|
|
1512
|
+
if (updatedState.status === 'blocked' && !hasBlockingActiveTurn(remainingTurns)) {
|
|
1513
|
+
updatedState.status = 'active';
|
|
1514
|
+
updatedState.blocked_on = null;
|
|
1515
|
+
updatedState.blocked_reason = null;
|
|
1516
|
+
updatedState.escalation = null;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
if (turnResult.status === 'needs_human') {
|
|
1520
|
+
updatedState.status = 'blocked';
|
|
1521
|
+
updatedState.blocked_reason = buildBlockedReason({
|
|
1522
|
+
category: 'needs_human',
|
|
1523
|
+
recovery: {
|
|
1524
|
+
typed_reason: 'needs_human',
|
|
1525
|
+
owner: 'human',
|
|
1526
|
+
recovery_action: 'Resolve the stated issue, then run agentxchain step --resume',
|
|
1527
|
+
turn_retained: false,
|
|
1528
|
+
detail: turnResult.needs_human_reason || 'unspecified',
|
|
1529
|
+
},
|
|
1530
|
+
turnId: turnResult.turn_id,
|
|
1531
|
+
blockedAt: now,
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
let gateResult = null;
|
|
1536
|
+
let completionResult = null;
|
|
1537
|
+
const hasRemainingTurns = Object.keys(remainingTurns).length > 0;
|
|
1538
|
+
if (turnResult.status !== 'needs_human') {
|
|
1539
|
+
if (hasRemainingTurns) {
|
|
1540
|
+
if (turnResult.run_completion_request && !updatedState.queued_run_completion) {
|
|
1541
|
+
updatedState.queued_run_completion = {
|
|
1542
|
+
requested_by_turn: turnResult.turn_id,
|
|
1543
|
+
requested_at: now,
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
if (turnResult.phase_transition_request && !updatedState.queued_phase_transition) {
|
|
1547
|
+
updatedState.queued_phase_transition = {
|
|
1548
|
+
from: state.phase,
|
|
1549
|
+
to: turnResult.phase_transition_request,
|
|
1550
|
+
requested_by_turn: turnResult.turn_id,
|
|
1551
|
+
requested_at: now,
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
} else {
|
|
1555
|
+
const postAcceptanceState = {
|
|
1556
|
+
...state,
|
|
1557
|
+
active_turns: remainingTurns,
|
|
1558
|
+
turn_sequence: acceptedSequence,
|
|
1559
|
+
};
|
|
1560
|
+
const nextHistoryEntries = [...historyEntries, historyEntry];
|
|
1561
|
+
const completionSource = turnResult.run_completion_request
|
|
1562
|
+
? turnResult
|
|
1563
|
+
: findHistoryTurnRequest(nextHistoryEntries, state.queued_run_completion?.requested_by_turn, 'run_completion');
|
|
1564
|
+
|
|
1565
|
+
if (completionSource?.run_completion_request) {
|
|
1566
|
+
completionResult = evaluateRunCompletion({
|
|
1567
|
+
state: postAcceptanceState,
|
|
1568
|
+
config,
|
|
1569
|
+
acceptedTurn: completionSource,
|
|
1570
|
+
root,
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
if (completionResult.action === 'complete') {
|
|
1574
|
+
updatedState.status = 'completed';
|
|
1575
|
+
updatedState.completed_at = now;
|
|
1576
|
+
if (completionResult.gate_id) {
|
|
1577
|
+
updatedState.phase_gate_status = {
|
|
1578
|
+
...(updatedState.phase_gate_status || {}),
|
|
1579
|
+
[completionResult.gate_id]: 'passed',
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
updatedState.queued_run_completion = null;
|
|
1583
|
+
updatedState.queued_phase_transition = null;
|
|
1584
|
+
} else if (completionResult.action === 'awaiting_human_approval') {
|
|
1585
|
+
updatedState.status = 'paused';
|
|
1586
|
+
updatedState.blocked_on = `human_approval:${completionResult.gate_id}`;
|
|
1587
|
+
updatedState.blocked_reason = null;
|
|
1588
|
+
updatedState.pending_run_completion = {
|
|
1589
|
+
gate: completionResult.gate_id,
|
|
1590
|
+
requested_by_turn: completionSource.turn_id,
|
|
1591
|
+
requested_at: now,
|
|
1592
|
+
};
|
|
1593
|
+
updatedState.queued_run_completion = null;
|
|
1594
|
+
updatedState.queued_phase_transition = null;
|
|
1595
|
+
} else if (state.queued_run_completion) {
|
|
1596
|
+
updatedState.queued_run_completion = null;
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
if (updatedState.status !== 'blocked' && updatedState.status !== 'paused' && updatedState.status !== 'completed') {
|
|
1601
|
+
const phaseSource = turnResult.phase_transition_request
|
|
1602
|
+
? turnResult
|
|
1603
|
+
: findHistoryTurnRequest(nextHistoryEntries, state.queued_phase_transition?.requested_by_turn, 'phase_transition');
|
|
1604
|
+
|
|
1605
|
+
// Always evaluate phase exit when the run drains — even without a request,
|
|
1606
|
+
// evaluatePhaseExit returns { action: 'no_request' } which callers depend on.
|
|
1607
|
+
gateResult = evaluatePhaseExit({
|
|
1608
|
+
state: postAcceptanceState,
|
|
1609
|
+
config,
|
|
1610
|
+
acceptedTurn: phaseSource || turnResult,
|
|
1611
|
+
root,
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
if (gateResult.action === 'advance') {
|
|
1615
|
+
updatedState.phase = gateResult.next_phase;
|
|
1616
|
+
updatedState.phase_gate_status = {
|
|
1617
|
+
...(updatedState.phase_gate_status || {}),
|
|
1618
|
+
[gateResult.gate_id || 'no_gate']: 'passed',
|
|
1619
|
+
};
|
|
1620
|
+
updatedState.queued_phase_transition = null;
|
|
1621
|
+
} else if (gateResult.action === 'awaiting_human_approval') {
|
|
1622
|
+
updatedState.status = 'paused';
|
|
1623
|
+
updatedState.blocked_on = `human_approval:${gateResult.gate_id}`;
|
|
1624
|
+
updatedState.blocked_reason = null;
|
|
1625
|
+
updatedState.pending_phase_transition = {
|
|
1626
|
+
from: state.phase,
|
|
1627
|
+
to: gateResult.next_phase,
|
|
1628
|
+
gate: gateResult.gate_id,
|
|
1629
|
+
requested_by_turn: phaseSource.turn_id,
|
|
1630
|
+
};
|
|
1631
|
+
updatedState.queued_phase_transition = null;
|
|
1632
|
+
} else if (state.queued_phase_transition) {
|
|
1633
|
+
updatedState.queued_phase_transition = null;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
// ── Transaction journal: prepare before committing writes ──────────────
|
|
1640
|
+
const transactionId = generateId('txn');
|
|
1641
|
+
const journal = {
|
|
1642
|
+
transaction_id: transactionId,
|
|
1643
|
+
kind: 'accept_turn',
|
|
1644
|
+
run_id: state.run_id,
|
|
1645
|
+
turn_id: currentTurn.turn_id,
|
|
1646
|
+
phase: state.phase,
|
|
1647
|
+
status: 'prepared',
|
|
1648
|
+
prepared_at: now,
|
|
1649
|
+
accepted_sequence: acceptedSequence,
|
|
1650
|
+
history_entry: historyEntry,
|
|
1651
|
+
ledger_entries: ledgerEntries,
|
|
1652
|
+
next_state: stripLegacyCurrentTurn(updatedState),
|
|
1653
|
+
};
|
|
1654
|
+
writeAcceptanceJournal(root, journal);
|
|
1655
|
+
|
|
1656
|
+
// ── Commit order: history → ledger → talk → state → cleanup → journal ─
|
|
1657
|
+
appendJsonl(root, HISTORY_PATH, historyEntry);
|
|
1658
|
+
for (const entry of ledgerEntries) {
|
|
1659
|
+
appendJsonl(root, LEDGER_PATH, entry);
|
|
1660
|
+
}
|
|
1661
|
+
appendTalk(root, talkSection);
|
|
1662
|
+
writeState(root, updatedState);
|
|
1663
|
+
|
|
1664
|
+
// Cleanup turn-scoped artifacts
|
|
1665
|
+
cleanupTurnArtifacts(root, currentTurn.turn_id);
|
|
1666
|
+
try {
|
|
1667
|
+
unlinkSync(stagingFile);
|
|
1668
|
+
} catch {}
|
|
1669
|
+
|
|
1670
|
+
// Journal committed — remove it
|
|
1671
|
+
commitAcceptanceJournal(root, transactionId);
|
|
1672
|
+
|
|
1673
|
+
// ── Post-acceptance hooks (advisory only — cannot block) ──────────────
|
|
1674
|
+
let hookResults = null;
|
|
1675
|
+
if (hooksConfig.after_acceptance && hooksConfig.after_acceptance.length > 0) {
|
|
1676
|
+
const hookPayload = {
|
|
1677
|
+
turn_id: currentTurn.turn_id,
|
|
1678
|
+
role_id: currentTurn.assigned_role,
|
|
1679
|
+
history_entry_index: acceptedSequence - 1,
|
|
1680
|
+
accepted_integration_ref: derivedRef,
|
|
1681
|
+
decisions_count: (turnResult.decisions || []).length,
|
|
1682
|
+
objections_count: (turnResult.objections || []).length,
|
|
1683
|
+
run_status: updatedState.status,
|
|
1684
|
+
phase: updatedState.phase,
|
|
1685
|
+
};
|
|
1686
|
+
hookResults = runHooks(root, hooksConfig, 'after_acceptance', hookPayload, {
|
|
1687
|
+
run_id: state.run_id,
|
|
1688
|
+
turn_id: currentTurn.turn_id,
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
if (!hookResults.ok) {
|
|
1692
|
+
const hookName = hookResults.results?.find((entry) => entry.hook_name)?.hook_name || 'unknown';
|
|
1693
|
+
const detail = hookResults.tamper?.message || `after_acceptance hook "${hookName}" failed after commit`;
|
|
1694
|
+
const blockedState = blockRunForHookIssue(root, updatedState, {
|
|
1695
|
+
phase: 'after_acceptance',
|
|
1696
|
+
turnId: currentTurn.turn_id,
|
|
1697
|
+
hookName,
|
|
1698
|
+
detail,
|
|
1699
|
+
errorCode: hookResults.tamper?.error_code || 'hook_post_commit_error',
|
|
1700
|
+
turnRetained: Object.keys(getActiveTurns(updatedState)).length > 0,
|
|
1701
|
+
});
|
|
1702
|
+
return {
|
|
1703
|
+
ok: false,
|
|
1704
|
+
error: `Turn accepted, but post-commit hook handling failed: ${detail}`,
|
|
1705
|
+
error_code: hookResults.tamper?.error_code || 'hook_post_commit_error',
|
|
1706
|
+
state: blockedState,
|
|
1707
|
+
validation,
|
|
1708
|
+
accepted: historyEntry,
|
|
1709
|
+
gateResult,
|
|
1710
|
+
completionResult,
|
|
1711
|
+
hookResults,
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
return {
|
|
1717
|
+
ok: true,
|
|
1718
|
+
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
1719
|
+
validation,
|
|
1720
|
+
accepted: historyEntry,
|
|
1721
|
+
gateResult,
|
|
1722
|
+
completionResult,
|
|
1723
|
+
hookResults,
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/**
|
|
1728
|
+
* Reject a governed turn.
|
|
1729
|
+
*
|
|
1730
|
+
* 1. Preserve the invalid staged artifact under .agentxchain/dispatch/rejected/
|
|
1731
|
+
* 2. Increment current_turn.attempt or escalate if retries exhausted
|
|
1732
|
+
* 3. Clear staging file
|
|
1733
|
+
*
|
|
1734
|
+
* Does NOT append to history.jsonl or decision-ledger.jsonl.
|
|
1735
|
+
*
|
|
1736
|
+
* @param {string} root - project root directory
|
|
1737
|
+
* @param {object} config - normalized config
|
|
1738
|
+
* @param {object} validationResult - validation failure details. Accepts either
|
|
1739
|
+
* `{ failed_stage, errors }` or the raw
|
|
1740
|
+
* validator shape `{ stage, errors }`.
|
|
1741
|
+
* @param {string} [reason] - human-readable rejection reason
|
|
1742
|
+
* @returns {{ ok: boolean, error?: string, state?: object, escalated?: boolean }}
|
|
1743
|
+
*/
|
|
1744
|
+
export function rejectGovernedTurn(root, config, validationResult, reasonOrOptions, opts = {}) {
|
|
1745
|
+
const state = readState(root);
|
|
1746
|
+
if (!state) {
|
|
1747
|
+
return { ok: false, error: 'No governed state.json found' };
|
|
1748
|
+
}
|
|
1749
|
+
const normalizedOpts = typeof reasonOrOptions === 'object' && reasonOrOptions !== null && !Array.isArray(reasonOrOptions)
|
|
1750
|
+
? reasonOrOptions
|
|
1751
|
+
: { ...opts, reason: reasonOrOptions };
|
|
1752
|
+
const targetResolution = resolveTurnTarget(state, normalizedOpts.turnId);
|
|
1753
|
+
if (!targetResolution.ok) {
|
|
1754
|
+
return targetResolution.error_code === 'target_required'
|
|
1755
|
+
? {
|
|
1756
|
+
ok: false,
|
|
1757
|
+
error: 'Multiple active turns are present. Re-run reject-turn with --turn <turn_id>.',
|
|
1758
|
+
error_code: 'target_required',
|
|
1759
|
+
}
|
|
1760
|
+
: targetResolution;
|
|
1761
|
+
}
|
|
1762
|
+
const currentTurn = targetResolution.turn;
|
|
1763
|
+
|
|
1764
|
+
const maxRetries = config.rules?.max_turn_retries ?? 2;
|
|
1765
|
+
const currentAttempt = currentTurn.attempt || 1;
|
|
1766
|
+
const canRetry = currentAttempt < maxRetries;
|
|
1767
|
+
const conflictContext = buildConflictContext(currentTurn);
|
|
1768
|
+
const isConflictReject = Boolean(conflictContext);
|
|
1769
|
+
|
|
1770
|
+
// Preserve rejected artifact
|
|
1771
|
+
const rejectedDir = join(root, '.agentxchain', 'dispatch', 'rejected');
|
|
1772
|
+
mkdirSync(rejectedDir, { recursive: true });
|
|
1773
|
+
|
|
1774
|
+
// Resolve staging path: prefer turn-scoped, fall back to flat
|
|
1775
|
+
const turnStagingRej = getTurnStagingResultPath(currentTurn.turn_id);
|
|
1776
|
+
const resolvedStagingRej = existsSync(join(root, turnStagingRej)) ? turnStagingRej : STAGING_PATH;
|
|
1777
|
+
const stagingFile = join(root, resolvedStagingRej);
|
|
1778
|
+
if (existsSync(stagingFile)) {
|
|
1779
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1780
|
+
const rejectedFile = join(rejectedDir, `${currentTurn.turn_id}-attempt-${currentAttempt}-${timestamp}.json`);
|
|
1781
|
+
try {
|
|
1782
|
+
const content = readFileSync(stagingFile, 'utf8');
|
|
1783
|
+
writeFileSync(rejectedFile, content);
|
|
1784
|
+
} catch {}
|
|
1785
|
+
try { unlinkSync(stagingFile); } catch {}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
// Write rejection context for the next retry
|
|
1789
|
+
const rejectionContext = {
|
|
1790
|
+
turn_id: currentTurn.turn_id,
|
|
1791
|
+
attempt: currentAttempt,
|
|
1792
|
+
rejected_at: new Date().toISOString(),
|
|
1793
|
+
reason: normalizedOpts.reason || (isConflictReject ? 'file_conflict' : 'Validation failed'),
|
|
1794
|
+
validation_errors: validationResult?.errors || [],
|
|
1795
|
+
failed_stage: validationResult?.failed_stage || validationResult?.stage || (isConflictReject ? 'conflict' : 'unknown'),
|
|
1796
|
+
};
|
|
1797
|
+
|
|
1798
|
+
if (conflictContext) {
|
|
1799
|
+
rejectionContext.conflict_context = conflictContext;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
if (isConflictReject) {
|
|
1803
|
+
appendJsonl(root, LEDGER_PATH, {
|
|
1804
|
+
timestamp: rejectionContext.rejected_at,
|
|
1805
|
+
decision: 'conflict_rejected',
|
|
1806
|
+
turn_id: currentTurn.turn_id,
|
|
1807
|
+
attempt: currentAttempt,
|
|
1808
|
+
role: currentTurn.assigned_role,
|
|
1809
|
+
phase: state.phase,
|
|
1810
|
+
conflict: {
|
|
1811
|
+
conflicting_files: currentTurn.conflict_state.conflict_error?.conflicting_files || [],
|
|
1812
|
+
accepted_since_turn_ids: (currentTurn.conflict_state.conflict_error?.accepted_since || []).map((entry) => entry.turn_id),
|
|
1813
|
+
overlap_ratio: currentTurn.conflict_state.conflict_error?.overlap_ratio ?? 0,
|
|
1814
|
+
},
|
|
1815
|
+
resolution_chosen: 'reject_and_reassign',
|
|
1816
|
+
operator_reason: normalizedOpts.reason || null,
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
if (canRetry) {
|
|
1821
|
+
const retryTurn = {
|
|
1822
|
+
...currentTurn,
|
|
1823
|
+
attempt: currentAttempt + 1,
|
|
1824
|
+
status: 'retrying',
|
|
1825
|
+
last_rejection: rejectionContext,
|
|
1826
|
+
conflict_state: null,
|
|
1827
|
+
conflict_context: conflictContext,
|
|
1828
|
+
};
|
|
1829
|
+
|
|
1830
|
+
if (isConflictReject) {
|
|
1831
|
+
const retryStartedAt = new Date().toISOString();
|
|
1832
|
+
retryTurn.baseline = captureBaseline(root);
|
|
1833
|
+
retryTurn.assigned_sequence = Math.max(
|
|
1834
|
+
state.turn_sequence || 0,
|
|
1835
|
+
currentTurn.assigned_sequence || 0,
|
|
1836
|
+
);
|
|
1837
|
+
retryTurn.started_at = retryStartedAt;
|
|
1838
|
+
retryTurn.deadline_at = new Date(Date.now() + 20 * 60 * 1000).toISOString();
|
|
1839
|
+
retryTurn.concurrent_with = Object.keys(getActiveTurns(state)).filter((turnId) => turnId !== currentTurn.turn_id);
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// Increment attempt and keep the turn assigned
|
|
1843
|
+
const updatedState = {
|
|
1844
|
+
...state,
|
|
1845
|
+
queued_phase_transition:
|
|
1846
|
+
isConflictReject && state.queued_phase_transition?.requested_by_turn === currentTurn.turn_id
|
|
1847
|
+
? null
|
|
1848
|
+
: state.queued_phase_transition,
|
|
1849
|
+
active_turns: {
|
|
1850
|
+
...getActiveTurns(state),
|
|
1851
|
+
[currentTurn.turn_id]: retryTurn,
|
|
1852
|
+
},
|
|
1853
|
+
};
|
|
1854
|
+
|
|
1855
|
+
writeState(root, updatedState);
|
|
1856
|
+
return {
|
|
1857
|
+
ok: true,
|
|
1858
|
+
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
1859
|
+
escalated: false,
|
|
1860
|
+
turn: updatedState.active_turns[currentTurn.turn_id],
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
// Retries exhausted — escalate
|
|
1865
|
+
const updatedState = {
|
|
1866
|
+
...state,
|
|
1867
|
+
status: 'blocked',
|
|
1868
|
+
active_turns: {
|
|
1869
|
+
...getActiveTurns(state),
|
|
1870
|
+
[currentTurn.turn_id]: {
|
|
1871
|
+
...currentTurn,
|
|
1872
|
+
status: 'failed',
|
|
1873
|
+
last_rejection: rejectionContext,
|
|
1874
|
+
conflict_state: null,
|
|
1875
|
+
conflict_context: conflictContext,
|
|
1876
|
+
},
|
|
1877
|
+
},
|
|
1878
|
+
blocked_on: `escalation:retries-exhausted:${currentTurn.assigned_role}`,
|
|
1879
|
+
blocked_reason: buildBlockedReason({
|
|
1880
|
+
category: 'retries_exhausted',
|
|
1881
|
+
recovery: {
|
|
1882
|
+
typed_reason: 'retries_exhausted',
|
|
1883
|
+
owner: 'human',
|
|
1884
|
+
recovery_action: 'Resolve the escalation, then run agentxchain step --resume',
|
|
1885
|
+
turn_retained: true,
|
|
1886
|
+
detail: `escalation:retries-exhausted:${currentTurn.assigned_role}`,
|
|
1887
|
+
},
|
|
1888
|
+
turnId: currentTurn.turn_id,
|
|
1889
|
+
}),
|
|
1890
|
+
escalation: {
|
|
1891
|
+
from_role: currentTurn.assigned_role,
|
|
1892
|
+
from_turn_id: currentTurn.turn_id,
|
|
1893
|
+
reason: `Turn rejected ${currentAttempt} times. Retries exhausted.`,
|
|
1894
|
+
validation_errors: validationResult?.errors || [],
|
|
1895
|
+
escalated_at: new Date().toISOString()
|
|
1896
|
+
}
|
|
1897
|
+
};
|
|
1898
|
+
|
|
1899
|
+
writeState(root, updatedState);
|
|
1900
|
+
|
|
1901
|
+
// Fire on_escalation hooks (advisory-only) after blocked state is persisted.
|
|
1902
|
+
const hooksConfig = config?.hooks || {};
|
|
1903
|
+
if (hooksConfig.on_escalation?.length > 0) {
|
|
1904
|
+
_fireOnEscalationHooks(root, hooksConfig, {
|
|
1905
|
+
blocked_reason: 'retries_exhausted',
|
|
1906
|
+
recovery_action: 'Resolve the escalation, then run agentxchain step --resume',
|
|
1907
|
+
failed_turn_id: currentTurn.turn_id,
|
|
1908
|
+
failed_role: currentTurn.assigned_role,
|
|
1909
|
+
attempt_count: currentAttempt,
|
|
1910
|
+
last_error: validationResult?.errors?.[0] || 'retries_exhausted',
|
|
1911
|
+
run_id: updatedState.run_id,
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
return {
|
|
1916
|
+
ok: true,
|
|
1917
|
+
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
1918
|
+
escalated: true,
|
|
1919
|
+
turn: updatedState.active_turns[currentTurn.turn_id],
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
/**
|
|
1924
|
+
* Approve a pending phase transition.
|
|
1925
|
+
*
|
|
1926
|
+
* When a gate with requires_human_approval passes structurally,
|
|
1927
|
+
* the run pauses with a pending_phase_transition. This function
|
|
1928
|
+
* advances the phase after explicit human approval.
|
|
1929
|
+
*
|
|
1930
|
+
* Runs `before_gate` hooks when config is provided. A blocking hook
|
|
1931
|
+
* or tamper detection aborts the approval and blocks the run.
|
|
1932
|
+
*
|
|
1933
|
+
* @param {string} root - project root directory
|
|
1934
|
+
* @param {object} [config] - normalized config (optional; required for hook support)
|
|
1935
|
+
* @returns {{ ok: boolean, error?: string, error_code?: string, state?: object, transition?: object, hookResults?: object }}
|
|
1936
|
+
*/
|
|
1937
|
+
export function approvePhaseTransition(root, config) {
|
|
1938
|
+
const state = readState(root);
|
|
1939
|
+
if (!state) {
|
|
1940
|
+
return { ok: false, error: 'No governed state.json found' };
|
|
1941
|
+
}
|
|
1942
|
+
if (!state.pending_phase_transition) {
|
|
1943
|
+
return { ok: false, error: 'No pending phase transition to approve' };
|
|
1944
|
+
}
|
|
1945
|
+
if (!canApprovePendingGate(state)) {
|
|
1946
|
+
return { ok: false, error: `Cannot approve transition: status is "${state.status}", expected "paused" or "blocked"` };
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
const transition = state.pending_phase_transition;
|
|
1950
|
+
|
|
1951
|
+
// ── before_gate hooks ──────────────────────────────────────────────
|
|
1952
|
+
const hooksConfig = config?.hooks || {};
|
|
1953
|
+
if (hooksConfig.before_gate && hooksConfig.before_gate.length > 0) {
|
|
1954
|
+
const historyLength = readJsonlEntries(root, HISTORY_PATH).length;
|
|
1955
|
+
const gatePayload = {
|
|
1956
|
+
gate_type: 'phase_transition',
|
|
1957
|
+
current_phase: transition.from,
|
|
1958
|
+
target_phase: transition.to,
|
|
1959
|
+
gate_config: transition,
|
|
1960
|
+
history_length: historyLength,
|
|
1961
|
+
};
|
|
1962
|
+
const gateHooks = runHooks(root, hooksConfig, 'before_gate', gatePayload, {
|
|
1963
|
+
run_id: state.run_id,
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
if (!gateHooks.ok) {
|
|
1967
|
+
const hookName = gateHooks.blocker?.hook_name
|
|
1968
|
+
|| gateHooks.results?.find((entry) => entry.hook_name)?.hook_name
|
|
1969
|
+
|| 'unknown';
|
|
1970
|
+
const detail = gateHooks.blocker?.message
|
|
1971
|
+
|| gateHooks.tamper?.message
|
|
1972
|
+
|| `before_gate hook "${hookName}" blocked phase transition`;
|
|
1973
|
+
const blockedState = blockRunForHookIssue(root, state, {
|
|
1974
|
+
phase: 'before_gate',
|
|
1975
|
+
turnId: transition.requested_by_turn || null,
|
|
1976
|
+
hookName,
|
|
1977
|
+
detail,
|
|
1978
|
+
errorCode: gateHooks.tamper?.error_code || 'hook_blocked',
|
|
1979
|
+
turnRetained: false,
|
|
1980
|
+
});
|
|
1981
|
+
return {
|
|
1982
|
+
ok: false,
|
|
1983
|
+
error: detail,
|
|
1984
|
+
error_code: gateHooks.tamper?.error_code || 'hook_blocked',
|
|
1985
|
+
state: blockedState,
|
|
1986
|
+
hookResults: gateHooks,
|
|
1987
|
+
};
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
const updatedState = {
|
|
1992
|
+
...state,
|
|
1993
|
+
phase: transition.to,
|
|
1994
|
+
status: 'active',
|
|
1995
|
+
blocked_on: null,
|
|
1996
|
+
blocked_reason: null,
|
|
1997
|
+
pending_phase_transition: null,
|
|
1998
|
+
phase_gate_status: {
|
|
1999
|
+
...(state.phase_gate_status || {}),
|
|
2000
|
+
[transition.gate]: 'passed',
|
|
2001
|
+
},
|
|
2002
|
+
};
|
|
2003
|
+
|
|
2004
|
+
writeState(root, updatedState);
|
|
2005
|
+
|
|
2006
|
+
return {
|
|
2007
|
+
ok: true,
|
|
2008
|
+
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
2009
|
+
transition,
|
|
2010
|
+
};
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
/**
|
|
2014
|
+
* Approve a pending run completion.
|
|
2015
|
+
*
|
|
2016
|
+
* When the final phase gate with requires_human_approval passes structurally,
|
|
2017
|
+
* the run pauses with a pending_run_completion. This function marks the run
|
|
2018
|
+
* as completed after explicit human approval.
|
|
2019
|
+
*
|
|
2020
|
+
* Runs `before_gate` hooks when config is provided. A blocking hook
|
|
2021
|
+
* or tamper detection aborts the approval and blocks the run.
|
|
2022
|
+
*
|
|
2023
|
+
* @param {string} root - project root directory
|
|
2024
|
+
* @param {object} [config] - normalized config (optional; required for hook support)
|
|
2025
|
+
* @returns {{ ok: boolean, error?: string, error_code?: string, state?: object, completion?: object, hookResults?: object }}
|
|
2026
|
+
*/
|
|
2027
|
+
export function approveRunCompletion(root, config) {
|
|
2028
|
+
const state = readState(root);
|
|
2029
|
+
if (!state) {
|
|
2030
|
+
return { ok: false, error: 'No governed state.json found' };
|
|
2031
|
+
}
|
|
2032
|
+
if (!state.pending_run_completion) {
|
|
2033
|
+
return { ok: false, error: 'No pending run completion to approve' };
|
|
2034
|
+
}
|
|
2035
|
+
if (!canApprovePendingGate(state)) {
|
|
2036
|
+
return { ok: false, error: `Cannot approve completion: status is "${state.status}", expected "paused" or "blocked"` };
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
const completion = state.pending_run_completion;
|
|
2040
|
+
|
|
2041
|
+
// ── before_gate hooks ──────────────────────────────────────────────
|
|
2042
|
+
const hooksConfig = config?.hooks || {};
|
|
2043
|
+
if (hooksConfig.before_gate && hooksConfig.before_gate.length > 0) {
|
|
2044
|
+
const historyLength = readJsonlEntries(root, HISTORY_PATH).length;
|
|
2045
|
+
const gatePayload = {
|
|
2046
|
+
gate_type: 'run_completion',
|
|
2047
|
+
current_phase: state.phase,
|
|
2048
|
+
target_phase: null,
|
|
2049
|
+
gate_config: completion,
|
|
2050
|
+
history_length: historyLength,
|
|
2051
|
+
};
|
|
2052
|
+
const gateHooks = runHooks(root, hooksConfig, 'before_gate', gatePayload, {
|
|
2053
|
+
run_id: state.run_id,
|
|
2054
|
+
});
|
|
2055
|
+
|
|
2056
|
+
if (!gateHooks.ok) {
|
|
2057
|
+
const hookName = gateHooks.blocker?.hook_name
|
|
2058
|
+
|| gateHooks.results?.find((entry) => entry.hook_name)?.hook_name
|
|
2059
|
+
|| 'unknown';
|
|
2060
|
+
const detail = gateHooks.blocker?.message
|
|
2061
|
+
|| gateHooks.tamper?.message
|
|
2062
|
+
|| `before_gate hook "${hookName}" blocked run completion`;
|
|
2063
|
+
const blockedState = blockRunForHookIssue(root, state, {
|
|
2064
|
+
phase: 'before_gate',
|
|
2065
|
+
turnId: completion.requested_by_turn || null,
|
|
2066
|
+
hookName,
|
|
2067
|
+
detail,
|
|
2068
|
+
errorCode: gateHooks.tamper?.error_code || 'hook_blocked',
|
|
2069
|
+
turnRetained: false,
|
|
2070
|
+
});
|
|
2071
|
+
return {
|
|
2072
|
+
ok: false,
|
|
2073
|
+
error: detail,
|
|
2074
|
+
error_code: gateHooks.tamper?.error_code || 'hook_blocked',
|
|
2075
|
+
state: blockedState,
|
|
2076
|
+
hookResults: gateHooks,
|
|
2077
|
+
};
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
const updatedState = {
|
|
2082
|
+
...state,
|
|
2083
|
+
status: 'completed',
|
|
2084
|
+
completed_at: new Date().toISOString(),
|
|
2085
|
+
blocked_on: null,
|
|
2086
|
+
blocked_reason: null,
|
|
2087
|
+
pending_run_completion: null,
|
|
2088
|
+
phase_gate_status: {
|
|
2089
|
+
...(state.phase_gate_status || {}),
|
|
2090
|
+
[completion.gate]: 'passed',
|
|
2091
|
+
},
|
|
2092
|
+
};
|
|
2093
|
+
|
|
2094
|
+
writeState(root, updatedState);
|
|
2095
|
+
|
|
2096
|
+
return {
|
|
2097
|
+
ok: true,
|
|
2098
|
+
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
2099
|
+
completion,
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// ── Routing Helpers ─────────────────────────────────────────────────────────
|
|
2104
|
+
|
|
2105
|
+
/**
|
|
2106
|
+
* Derive the next recommended role after an accepted turn.
|
|
2107
|
+
*
|
|
2108
|
+
* Rules:
|
|
2109
|
+
* - If proposed_next_role is routing-legal for the current phase and not 'human', use it
|
|
2110
|
+
* - Otherwise, use the current phase entry_role
|
|
2111
|
+
* - After escalation or human pause, clear recommendation (returns null)
|
|
2112
|
+
*
|
|
2113
|
+
* @param {object} turnResult — the accepted turn result
|
|
2114
|
+
* @param {object} state — the current state
|
|
2115
|
+
* @param {object} config — normalized config
|
|
2116
|
+
* @returns {string|null}
|
|
2117
|
+
*/
|
|
2118
|
+
function deriveNextRecommendedRole(turnResult, state, config) {
|
|
2119
|
+
if (turnResult.status === 'needs_human' || turnResult.status === 'blocked') {
|
|
2120
|
+
return null;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
const proposed = turnResult.proposed_next_role;
|
|
2124
|
+
if (!proposed || proposed === 'human') return null;
|
|
2125
|
+
|
|
2126
|
+
// Check if proposed is routing-legal for the current phase
|
|
2127
|
+
const phase = state.phase;
|
|
2128
|
+
const routing = config.routing?.[phase];
|
|
2129
|
+
if (routing?.allowed_next_roles) {
|
|
2130
|
+
if (routing.allowed_next_roles.includes(proposed)) {
|
|
2131
|
+
return proposed;
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
// Fall back to phase entry_role
|
|
2136
|
+
return routing?.entry_role || null;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
export { STATE_PATH, HISTORY_PATH, LEDGER_PATH, STAGING_PATH, TALK_PATH };
|