agentxchain 2.128.0 → 2.130.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 +2 -0
- package/bin/agentxchain.js +38 -4
- package/package.json +1 -1
- package/scripts/verify-post-publish.sh +55 -5
- package/src/commands/accept-turn.js +14 -0
- package/src/commands/checkpoint-turn.js +35 -0
- package/src/commands/connector.js +17 -2
- package/src/commands/doctor.js +151 -1
- package/src/commands/events.js +7 -1
- package/src/commands/init.js +42 -11
- package/src/commands/inject.js +1 -1
- package/src/commands/mission.js +803 -7
- package/src/commands/reissue-turn.js +122 -0
- package/src/commands/reject-turn.js +60 -6
- package/src/commands/restart.js +81 -10
- package/src/commands/resume.js +20 -9
- package/src/commands/run.js +13 -0
- package/src/commands/status.js +58 -4
- package/src/commands/step.js +49 -10
- package/src/commands/validate.js +78 -20
- package/src/lib/cli-version.js +106 -0
- package/src/lib/connector-probe.js +146 -5
- package/src/lib/continuous-run.js +22 -87
- package/src/lib/coordinator-dispatch.js +25 -0
- package/src/lib/dispatch-bundle.js +39 -0
- package/src/lib/governed-state.js +624 -11
- package/src/lib/governed-templates.js +1 -0
- package/src/lib/intake.js +233 -77
- package/src/lib/mission-plans.js +510 -6
- package/src/lib/missions.js +65 -6
- package/src/lib/normalized-config.js +50 -15
- package/src/lib/repo-observer.js +8 -2
- package/src/lib/run-events.js +5 -0
- package/src/lib/run-loop.js +25 -0
- package/src/lib/runner-interface.js +2 -0
- package/src/lib/session-checkpoint.js +18 -2
- package/src/lib/turn-checkpoint.js +221 -0
- package/src/templates/governed/full-local-cli.json +71 -0
|
@@ -26,6 +26,7 @@ import { evaluatePolicies } from './policy-evaluator.js';
|
|
|
26
26
|
import { buildTimeoutBlockedReason, evaluateTimeouts } from './timeout-evaluator.js';
|
|
27
27
|
import {
|
|
28
28
|
captureBaseline,
|
|
29
|
+
captureDirtyWorkspaceSnapshot,
|
|
29
30
|
observeChanges,
|
|
30
31
|
attributeObservedChangesToTurn,
|
|
31
32
|
buildConflictCandidateFiles,
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
compareDeclaredVsObserved,
|
|
36
37
|
deriveAcceptedRef,
|
|
37
38
|
checkCleanBaseline,
|
|
39
|
+
isOperationalPath,
|
|
38
40
|
} from './repo-observer.js';
|
|
39
41
|
import { getMaxConcurrentTurns } from './normalized-config.js';
|
|
40
42
|
import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir, getReviewArtifactPath } from './turn-paths.js';
|
|
@@ -60,6 +62,7 @@ import {
|
|
|
60
62
|
summarizeVerificationReplay,
|
|
61
63
|
} from './verification-replay.js';
|
|
62
64
|
import { executeGateActions } from './gate-actions.js';
|
|
65
|
+
import { detectPendingCheckpoint } from './turn-checkpoint.js';
|
|
63
66
|
|
|
64
67
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
65
68
|
|
|
@@ -403,6 +406,32 @@ export function getActiveTurn(state) {
|
|
|
403
406
|
return turns.length === 1 ? turns[0] : null;
|
|
404
407
|
}
|
|
405
408
|
|
|
409
|
+
/**
|
|
410
|
+
* BUG-18: Detect state/bundle desync — every active turn referenced in
|
|
411
|
+
* state.json must have a corresponding dispatch bundle directory on disk.
|
|
412
|
+
*
|
|
413
|
+
* @param {string} root - project root
|
|
414
|
+
* @param {object} state - governed state
|
|
415
|
+
* @returns {{ ok: boolean, desynced: Array<{ turn_id: string, role: string, expected_path: string }> }}
|
|
416
|
+
*/
|
|
417
|
+
export function detectStateBundleDesync(root, state) {
|
|
418
|
+
const activeTurns = getActiveTurns(state);
|
|
419
|
+
const desynced = [];
|
|
420
|
+
|
|
421
|
+
for (const [turnId, turn] of Object.entries(activeTurns)) {
|
|
422
|
+
const bundleDir = join(root, '.agentxchain', 'dispatch', 'turns', turnId);
|
|
423
|
+
if (!existsSync(bundleDir)) {
|
|
424
|
+
desynced.push({
|
|
425
|
+
turn_id: turnId,
|
|
426
|
+
role: turn.assigned_role || 'unknown',
|
|
427
|
+
expected_path: `.agentxchain/dispatch/turns/${turnId}`,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return { ok: desynced.length === 0, desynced };
|
|
433
|
+
}
|
|
434
|
+
|
|
406
435
|
function resolveRecoveryTurnId(state, preferredTurnId = null) {
|
|
407
436
|
const activeTurns = getActiveTurns(state);
|
|
408
437
|
if (preferredTurnId && activeTurns[preferredTurnId]) {
|
|
@@ -2059,7 +2088,7 @@ export function initializeGovernedRun(root, config, options = {}) {
|
|
|
2059
2088
|
* @param {string} roleId - the role to assign
|
|
2060
2089
|
* @returns {{ ok: boolean, error?: string, warnings?: string[], state?: object }}
|
|
2061
2090
|
*/
|
|
2062
|
-
export function assignGovernedTurn(root, config, roleId) {
|
|
2091
|
+
export function assignGovernedTurn(root, config, roleId, options = {}) {
|
|
2063
2092
|
let state = readState(root);
|
|
2064
2093
|
if (!state) {
|
|
2065
2094
|
return { ok: false, error: 'No governed state.json found' };
|
|
@@ -2161,6 +2190,10 @@ export function assignGovernedTurn(root, config, roleId) {
|
|
|
2161
2190
|
const writeAuthority = role.write_authority || 'review_only';
|
|
2162
2191
|
const cleanCheck = checkCleanBaseline(root, writeAuthority);
|
|
2163
2192
|
if (!cleanCheck.clean) {
|
|
2193
|
+
const pendingCheckpoint = detectPendingCheckpoint(root, cleanCheck.dirty_files || []);
|
|
2194
|
+
if (pendingCheckpoint.required) {
|
|
2195
|
+
return { ok: false, error: pendingCheckpoint.message, error_code: 'checkpoint_required' };
|
|
2196
|
+
}
|
|
2164
2197
|
return { ok: false, error: cleanCheck.reason };
|
|
2165
2198
|
}
|
|
2166
2199
|
|
|
@@ -2243,6 +2276,9 @@ export function assignGovernedTurn(root, config, roleId) {
|
|
|
2243
2276
|
assigned_sequence: nextSequence,
|
|
2244
2277
|
concurrent_with: concurrentWith,
|
|
2245
2278
|
};
|
|
2279
|
+
if (options.intakeContext) {
|
|
2280
|
+
newTurn.intake_context = options.intakeContext;
|
|
2281
|
+
}
|
|
2246
2282
|
|
|
2247
2283
|
// Attach delegation context if this turn fulfills a pending delegation
|
|
2248
2284
|
const delegationQueue = state.delegation_queue || [];
|
|
@@ -2288,12 +2324,15 @@ export function assignGovernedTurn(root, config, roleId) {
|
|
|
2288
2324
|
phase: updatedState.phase,
|
|
2289
2325
|
status: updatedState.status,
|
|
2290
2326
|
turn: { turn_id: turnId, role_id: roleId },
|
|
2327
|
+
intent_id: options.intakeContext?.intent_id || null,
|
|
2291
2328
|
});
|
|
2292
2329
|
|
|
2293
|
-
// Session checkpoint — non-fatal, written after every successful turn assignment
|
|
2330
|
+
// Session checkpoint — non-fatal, written after every successful turn assignment.
|
|
2331
|
+
// Pass the captured baseline so session.json agrees with state.json (BUG-2 fix).
|
|
2294
2332
|
writeSessionCheckpoint(root, updatedState, 'turn_assigned', {
|
|
2295
2333
|
role: roleId,
|
|
2296
2334
|
dispatch_dir: `.agentxchain/dispatch/turns/${turnId}`,
|
|
2335
|
+
turn_baseline: baseline,
|
|
2297
2336
|
});
|
|
2298
2337
|
|
|
2299
2338
|
const assignedTurn = updatedState.active_turns[turnId];
|
|
@@ -2304,6 +2343,274 @@ export function assignGovernedTurn(root, config, roleId) {
|
|
|
2304
2343
|
return result;
|
|
2305
2344
|
}
|
|
2306
2345
|
|
|
2346
|
+
/**
|
|
2347
|
+
* Refresh a turn's baseline dirty_snapshot to include files that became dirty
|
|
2348
|
+
* between assignment and dispatch. This prevents acceptance from blaming the
|
|
2349
|
+
* turn for operator edits made while the turn was pending (BUG-1 fix).
|
|
2350
|
+
*
|
|
2351
|
+
* Merges new dirty entries into the existing snapshot — existing entries are
|
|
2352
|
+
* preserved, new entries are added. The baseline.clean flag is updated to
|
|
2353
|
+
* reflect the merged snapshot.
|
|
2354
|
+
*
|
|
2355
|
+
* @param {string} root - project root
|
|
2356
|
+
* @param {string} [turnId] - specific turn to refresh (defaults to single active turn)
|
|
2357
|
+
* @returns {{ ok: boolean, refreshed_files?: string[], error?: string }}
|
|
2358
|
+
*/
|
|
2359
|
+
export function refreshTurnBaselineSnapshot(root, turnId) {
|
|
2360
|
+
const state = readState(root);
|
|
2361
|
+
if (!state) return { ok: false, error: 'No governed state found' };
|
|
2362
|
+
|
|
2363
|
+
const activeTurns = getActiveTurns(state);
|
|
2364
|
+
const resolvedTurnId = turnId || Object.keys(activeTurns)[0];
|
|
2365
|
+
if (!resolvedTurnId) return { ok: false, error: 'No active turn to refresh' };
|
|
2366
|
+
|
|
2367
|
+
const turn = activeTurns[resolvedTurnId];
|
|
2368
|
+
if (!turn) return { ok: false, error: `Turn ${resolvedTurnId} not found in active turns` };
|
|
2369
|
+
if (!turn.baseline) return { ok: false, error: 'Turn has no baseline to refresh' };
|
|
2370
|
+
|
|
2371
|
+
const currentSnapshot = captureDirtyWorkspaceSnapshot(root);
|
|
2372
|
+
const existingSnapshot = turn.baseline.dirty_snapshot || {};
|
|
2373
|
+
const merged = { ...existingSnapshot };
|
|
2374
|
+
const refreshedFiles = [];
|
|
2375
|
+
|
|
2376
|
+
for (const [filePath, marker] of Object.entries(currentSnapshot)) {
|
|
2377
|
+
if (!(filePath in merged)) {
|
|
2378
|
+
merged[filePath] = marker;
|
|
2379
|
+
refreshedFiles.push(filePath);
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
if (refreshedFiles.length === 0) {
|
|
2384
|
+
return { ok: true, refreshed_files: [] };
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
// Update the turn's baseline with the merged snapshot
|
|
2388
|
+
const updatedTurn = {
|
|
2389
|
+
...turn,
|
|
2390
|
+
baseline: {
|
|
2391
|
+
...turn.baseline,
|
|
2392
|
+
dirty_snapshot: merged,
|
|
2393
|
+
// Recalculate clean: filter out operational and baseline-exempt paths
|
|
2394
|
+
clean: Object.keys(merged).filter(f => !isOperationalPath(f)).length === 0,
|
|
2395
|
+
},
|
|
2396
|
+
};
|
|
2397
|
+
|
|
2398
|
+
const updatedState = {
|
|
2399
|
+
...state,
|
|
2400
|
+
active_turns: {
|
|
2401
|
+
...activeTurns,
|
|
2402
|
+
[resolvedTurnId]: updatedTurn,
|
|
2403
|
+
},
|
|
2404
|
+
};
|
|
2405
|
+
writeState(root, updatedState);
|
|
2406
|
+
|
|
2407
|
+
return { ok: true, refreshed_files: refreshedFiles };
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
/**
|
|
2411
|
+
* Reissue an active turn against current repo state.
|
|
2412
|
+
*
|
|
2413
|
+
* Invalidates the current turn, archives its state, captures a fresh baseline
|
|
2414
|
+
* from current HEAD/workspace, and creates a new turn with the same role and
|
|
2415
|
+
* phase. Covers baseline drift, runtime drift, authority drift, and operator-
|
|
2416
|
+
* initiated reissue. (BUG-7 fix)
|
|
2417
|
+
*
|
|
2418
|
+
* @param {string} root - project root
|
|
2419
|
+
* @param {object} config - normalized config
|
|
2420
|
+
* @param {object} opts
|
|
2421
|
+
* @param {string} [opts.turnId] - specific turn to reissue
|
|
2422
|
+
* @param {string} [opts.reason] - reason for reissue
|
|
2423
|
+
* @returns {{ ok: boolean, state?: object, newTurn?: object, baselineDelta?: object, error?: string }}
|
|
2424
|
+
*/
|
|
2425
|
+
/**
|
|
2426
|
+
* Detect runtime/authority drift for all active turns against current config.
|
|
2427
|
+
* Returns an array of drift descriptors. Empty array = no drift.
|
|
2428
|
+
*/
|
|
2429
|
+
export function detectActiveTurnBindingDrift(state, config) {
|
|
2430
|
+
const activeTurns = getActiveTurns(state);
|
|
2431
|
+
const drifts = [];
|
|
2432
|
+
|
|
2433
|
+
for (const turn of Object.values(activeTurns)) {
|
|
2434
|
+
const roleId = turn.assigned_role;
|
|
2435
|
+
const role = config.roles?.[roleId];
|
|
2436
|
+
if (!role) continue;
|
|
2437
|
+
|
|
2438
|
+
const currentRuntimeId = role.runtime_id || role.runtime;
|
|
2439
|
+
const currentAuthority = role.write_authority;
|
|
2440
|
+
const turnRuntimeId = turn.runtime_id;
|
|
2441
|
+
|
|
2442
|
+
const runtimeChanged = currentRuntimeId && turnRuntimeId && currentRuntimeId !== turnRuntimeId;
|
|
2443
|
+
const authorityChanged = currentAuthority && turn.write_authority && currentAuthority !== turn.write_authority;
|
|
2444
|
+
|
|
2445
|
+
// Check runtime type change even if runtime_id matches
|
|
2446
|
+
const currentRuntimeType = config.runtimes?.[currentRuntimeId]?.type;
|
|
2447
|
+
const turnRuntimeType = config.runtimes?.[turnRuntimeId]?.type;
|
|
2448
|
+
const runtimeTypeChanged = currentRuntimeType && turnRuntimeType && currentRuntimeType !== turnRuntimeType;
|
|
2449
|
+
|
|
2450
|
+
if (runtimeChanged || authorityChanged || runtimeTypeChanged) {
|
|
2451
|
+
drifts.push({
|
|
2452
|
+
turn_id: turn.turn_id,
|
|
2453
|
+
role_id: roleId,
|
|
2454
|
+
runtime_changed: Boolean(runtimeChanged || runtimeTypeChanged),
|
|
2455
|
+
old_runtime: turnRuntimeId,
|
|
2456
|
+
new_runtime: currentRuntimeId,
|
|
2457
|
+
authority_changed: Boolean(authorityChanged),
|
|
2458
|
+
old_authority: turn.write_authority,
|
|
2459
|
+
new_authority: currentAuthority,
|
|
2460
|
+
recovery_command: `agentxchain reissue-turn --turn ${turn.turn_id} --reason "config rebinding"`,
|
|
2461
|
+
});
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
return drifts;
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
export function reissueTurn(root, config, opts = {}) {
|
|
2469
|
+
const state = readState(root);
|
|
2470
|
+
if (!state) return { ok: false, error: 'No governed state found' };
|
|
2471
|
+
|
|
2472
|
+
const activeTurns = getActiveTurns(state);
|
|
2473
|
+
const turnId = opts.turnId || Object.keys(activeTurns)[0];
|
|
2474
|
+
if (!turnId) return { ok: false, error: 'No active turn to reissue' };
|
|
2475
|
+
|
|
2476
|
+
const oldTurn = activeTurns[turnId];
|
|
2477
|
+
if (!oldTurn) return { ok: false, error: `Turn ${turnId} not found in active turns` };
|
|
2478
|
+
|
|
2479
|
+
const roleId = oldTurn.assigned_role;
|
|
2480
|
+
const role = config.roles?.[roleId];
|
|
2481
|
+
if (!role) return { ok: false, error: `Role "${roleId}" not found in config` };
|
|
2482
|
+
|
|
2483
|
+
const reason = opts.reason || 'operator-initiated reissue';
|
|
2484
|
+
const now = new Date().toISOString();
|
|
2485
|
+
|
|
2486
|
+
// Capture old baseline for delta computation
|
|
2487
|
+
const oldBaseline = oldTurn.baseline || {};
|
|
2488
|
+
const oldRuntimeId = oldTurn.runtime_id;
|
|
2489
|
+
|
|
2490
|
+
// Resolve current runtime binding (may have changed in config)
|
|
2491
|
+
const currentRuntimeId = role.runtime;
|
|
2492
|
+
const currentRuntime = config.runtimes?.[currentRuntimeId];
|
|
2493
|
+
if (!currentRuntime) {
|
|
2494
|
+
return { ok: false, error: `Runtime "${currentRuntimeId}" not found in config for role "${roleId}"` };
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
// Capture fresh baseline
|
|
2498
|
+
const newBaseline = captureBaseline(root);
|
|
2499
|
+
|
|
2500
|
+
// Archive the old turn as a history entry
|
|
2501
|
+
appendJsonl(root, HISTORY_PATH, {
|
|
2502
|
+
turn_id: oldTurn.turn_id,
|
|
2503
|
+
run_id: state.run_id,
|
|
2504
|
+
role: roleId,
|
|
2505
|
+
phase: state.phase,
|
|
2506
|
+
status: 'reissued',
|
|
2507
|
+
summary: `Turn reissued: ${reason}`,
|
|
2508
|
+
files_changed: [],
|
|
2509
|
+
assigned_sequence: oldTurn.assigned_sequence,
|
|
2510
|
+
accepted_at: now,
|
|
2511
|
+
duration_ms: oldTurn.started_at ? Date.now() - new Date(oldTurn.started_at).getTime() : 0,
|
|
2512
|
+
reissue_reason: reason,
|
|
2513
|
+
});
|
|
2514
|
+
|
|
2515
|
+
// Decision ledger entry
|
|
2516
|
+
appendJsonl(root, LEDGER_PATH, {
|
|
2517
|
+
timestamp: now,
|
|
2518
|
+
decision: 'turn_reissued',
|
|
2519
|
+
turn_id: oldTurn.turn_id,
|
|
2520
|
+
role: roleId,
|
|
2521
|
+
phase: state.phase,
|
|
2522
|
+
reason,
|
|
2523
|
+
old_baseline: {
|
|
2524
|
+
head_ref: oldBaseline.head_ref,
|
|
2525
|
+
clean: oldBaseline.clean,
|
|
2526
|
+
},
|
|
2527
|
+
new_baseline: {
|
|
2528
|
+
head_ref: newBaseline.head_ref,
|
|
2529
|
+
clean: newBaseline.clean,
|
|
2530
|
+
},
|
|
2531
|
+
});
|
|
2532
|
+
|
|
2533
|
+
// Create the new turn
|
|
2534
|
+
const newTurnId = `turn_${randomBytes(8).toString('hex')}`;
|
|
2535
|
+
const timeoutMinutes = 20;
|
|
2536
|
+
const nextSequence = (state.turn_sequence || 0) + 1;
|
|
2537
|
+
|
|
2538
|
+
const newTurn = {
|
|
2539
|
+
turn_id: newTurnId,
|
|
2540
|
+
assigned_role: roleId,
|
|
2541
|
+
status: 'running',
|
|
2542
|
+
attempt: (oldTurn.attempt || 1) + 1,
|
|
2543
|
+
started_at: now,
|
|
2544
|
+
deadline_at: new Date(Date.now() + timeoutMinutes * 60 * 1000).toISOString(),
|
|
2545
|
+
runtime_id: currentRuntimeId,
|
|
2546
|
+
baseline: newBaseline,
|
|
2547
|
+
assigned_sequence: nextSequence,
|
|
2548
|
+
concurrent_with: Object.keys(activeTurns).filter(id => id !== turnId),
|
|
2549
|
+
reissued_from: oldTurn.turn_id,
|
|
2550
|
+
};
|
|
2551
|
+
|
|
2552
|
+
// Copy delegation context if present
|
|
2553
|
+
if (oldTurn.delegation_context) {
|
|
2554
|
+
newTurn.delegation_context = oldTurn.delegation_context;
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
// Remove old turn, add new turn
|
|
2558
|
+
const newActiveTurns = { ...activeTurns };
|
|
2559
|
+
delete newActiveTurns[turnId];
|
|
2560
|
+
newActiveTurns[newTurnId] = newTurn;
|
|
2561
|
+
|
|
2562
|
+
const updatedState = {
|
|
2563
|
+
...state,
|
|
2564
|
+
turn_sequence: nextSequence,
|
|
2565
|
+
active_turns: newActiveTurns,
|
|
2566
|
+
};
|
|
2567
|
+
|
|
2568
|
+
writeState(root, updatedState);
|
|
2569
|
+
|
|
2570
|
+
// Emit event
|
|
2571
|
+
emitRunEvent(root, 'turn_reissued', {
|
|
2572
|
+
run_id: state.run_id,
|
|
2573
|
+
phase: state.phase,
|
|
2574
|
+
status: state.status,
|
|
2575
|
+
turn: { turn_id: newTurnId, role_id: roleId },
|
|
2576
|
+
intent_id: oldTurn.intake_context?.intent_id || null,
|
|
2577
|
+
payload: {
|
|
2578
|
+
old_turn_id: oldTurn.turn_id,
|
|
2579
|
+
reason,
|
|
2580
|
+
old_head: oldBaseline.head_ref,
|
|
2581
|
+
new_head: newBaseline.head_ref,
|
|
2582
|
+
old_runtime: oldRuntimeId,
|
|
2583
|
+
new_runtime: currentRuntimeId,
|
|
2584
|
+
},
|
|
2585
|
+
});
|
|
2586
|
+
|
|
2587
|
+
// Session checkpoint
|
|
2588
|
+
writeSessionCheckpoint(root, updatedState, 'turn_reissued', {
|
|
2589
|
+
role: roleId,
|
|
2590
|
+
turn_baseline: newBaseline,
|
|
2591
|
+
});
|
|
2592
|
+
|
|
2593
|
+
// Compute baseline delta for display
|
|
2594
|
+
const baselineDelta = {
|
|
2595
|
+
head_changed: oldBaseline.head_ref !== newBaseline.head_ref,
|
|
2596
|
+
old_head: oldBaseline.head_ref,
|
|
2597
|
+
new_head: newBaseline.head_ref,
|
|
2598
|
+
runtime_changed: oldRuntimeId !== currentRuntimeId,
|
|
2599
|
+
old_runtime: oldRuntimeId,
|
|
2600
|
+
new_runtime: currentRuntimeId,
|
|
2601
|
+
dirty_files_changed: JSON.stringify(oldBaseline.dirty_snapshot || {}) !== JSON.stringify(newBaseline.dirty_snapshot || {}),
|
|
2602
|
+
added_dirty_files: Object.keys(newBaseline.dirty_snapshot || {}).filter(f => !(f in (oldBaseline.dirty_snapshot || {}))),
|
|
2603
|
+
removed_dirty_files: Object.keys(oldBaseline.dirty_snapshot || {}).filter(f => !(f in (newBaseline.dirty_snapshot || {}))),
|
|
2604
|
+
};
|
|
2605
|
+
|
|
2606
|
+
return {
|
|
2607
|
+
ok: true,
|
|
2608
|
+
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
2609
|
+
newTurn,
|
|
2610
|
+
baselineDelta,
|
|
2611
|
+
};
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2307
2614
|
/**
|
|
2308
2615
|
* Estimate the budget for a single turn based on role/runtime configuration.
|
|
2309
2616
|
* Used for DEC-PARALLEL-011 budget reservation.
|
|
@@ -2366,6 +2673,51 @@ export function acceptGovernedTurn(root, config, opts = {}) {
|
|
|
2366
2673
|
}
|
|
2367
2674
|
}
|
|
2368
2675
|
|
|
2676
|
+
/**
|
|
2677
|
+
* Transition an active turn to failed_acceptance and emit the corresponding
|
|
2678
|
+
* event. Called from acceptance failure paths so the turn is never left stuck
|
|
2679
|
+
* in 'running' after the subprocess has exited (BUG-3 + BUG-4 fix).
|
|
2680
|
+
*/
|
|
2681
|
+
function transitionToFailedAcceptance(root, state, turn, reason, details = {}) {
|
|
2682
|
+
const activeTurns = getActiveTurns(state);
|
|
2683
|
+
const updatedTurn = {
|
|
2684
|
+
...turn,
|
|
2685
|
+
status: 'failed_acceptance',
|
|
2686
|
+
failed_at: new Date().toISOString(),
|
|
2687
|
+
failure_reason: reason,
|
|
2688
|
+
};
|
|
2689
|
+
const updatedState = {
|
|
2690
|
+
...state,
|
|
2691
|
+
active_turns: {
|
|
2692
|
+
...activeTurns,
|
|
2693
|
+
[turn.turn_id]: updatedTurn,
|
|
2694
|
+
},
|
|
2695
|
+
};
|
|
2696
|
+
writeState(root, updatedState);
|
|
2697
|
+
|
|
2698
|
+
// BUG-4: emit acceptance_failed event
|
|
2699
|
+
emitRunEvent(root, 'acceptance_failed', {
|
|
2700
|
+
run_id: state.run_id,
|
|
2701
|
+
phase: state.phase,
|
|
2702
|
+
status: state.status,
|
|
2703
|
+
turn: { turn_id: turn.turn_id, role_id: turn.assigned_role },
|
|
2704
|
+
intent_id: turn.intake_context?.intent_id || null,
|
|
2705
|
+
payload: {
|
|
2706
|
+
reason,
|
|
2707
|
+
error_code: details.error_code || 'acceptance_failed',
|
|
2708
|
+
stage: details.stage || 'unknown',
|
|
2709
|
+
...details.extra,
|
|
2710
|
+
},
|
|
2711
|
+
});
|
|
2712
|
+
|
|
2713
|
+
// Session checkpoint for recovery
|
|
2714
|
+
writeSessionCheckpoint(root, updatedState, 'acceptance_failed', {
|
|
2715
|
+
role: turn.assigned_role,
|
|
2716
|
+
});
|
|
2717
|
+
|
|
2718
|
+
return updatedState;
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2369
2721
|
function _acceptGovernedTurnLocked(root, config, opts) {
|
|
2370
2722
|
// Re-read state under lock (a sibling acceptance may have committed)
|
|
2371
2723
|
let state = readState(root);
|
|
@@ -2433,7 +2785,25 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2433
2785
|
}
|
|
2434
2786
|
|
|
2435
2787
|
const turnStagingPath = getTurnStagingResultPath(currentTurn.turn_id);
|
|
2436
|
-
|
|
2788
|
+
let resolvedStagingPath = existsSync(join(root, turnStagingPath)) ? turnStagingPath : STAGING_PATH;
|
|
2789
|
+
// BUG-22: verify legacy staging file belongs to the active turn before consuming
|
|
2790
|
+
if (resolvedStagingPath === STAGING_PATH) {
|
|
2791
|
+
try {
|
|
2792
|
+
const legacyAbs = join(root, STAGING_PATH);
|
|
2793
|
+
if (existsSync(legacyAbs)) {
|
|
2794
|
+
const raw = JSON.parse(readFileSync(legacyAbs, 'utf8'));
|
|
2795
|
+
if (raw.turn_id && raw.turn_id !== currentTurn.turn_id) {
|
|
2796
|
+
return {
|
|
2797
|
+
ok: false,
|
|
2798
|
+
error: `Stale staging data: ${STAGING_PATH} contains turn_id "${raw.turn_id}" but active turn is "${currentTurn.turn_id}". Remove the stale file or use the turn-scoped staging path.`,
|
|
2799
|
+
error_code: 'stale_staging',
|
|
2800
|
+
};
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
} catch {
|
|
2804
|
+
// Parse error handled by downstream validation
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2437
2807
|
const stagedTurn = loadHookStagedTurn(root, resolvedStagingPath);
|
|
2438
2808
|
const validationState = attachLegacyCurrentTurnAlias({
|
|
2439
2809
|
...state,
|
|
@@ -2526,9 +2896,15 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2526
2896
|
}
|
|
2527
2897
|
|
|
2528
2898
|
if (!validation.ok) {
|
|
2899
|
+
const failError = `Validation failed at stage ${validation.stage}: ${validation.errors.join('; ')}`;
|
|
2900
|
+
transitionToFailedAcceptance(root, state, currentTurn, failError, {
|
|
2901
|
+
error_code: 'validation_failed',
|
|
2902
|
+
stage: validation.stage,
|
|
2903
|
+
extra: { errors: validation.errors },
|
|
2904
|
+
});
|
|
2529
2905
|
return {
|
|
2530
2906
|
ok: false,
|
|
2531
|
-
error:
|
|
2907
|
+
error: failError,
|
|
2532
2908
|
validation,
|
|
2533
2909
|
};
|
|
2534
2910
|
}
|
|
@@ -2541,9 +2917,14 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2541
2917
|
if (dec.overrides) {
|
|
2542
2918
|
const overrideCheck = validateOverride(root, { ...dec, role: dec.role || turnResult.role }, config);
|
|
2543
2919
|
if (!overrideCheck.ok) {
|
|
2920
|
+
const overrideError = `Override validation failed: ${overrideCheck.error}`;
|
|
2921
|
+
transitionToFailedAcceptance(root, state, currentTurn, overrideError, {
|
|
2922
|
+
error_code: 'override_validation_failed',
|
|
2923
|
+
stage: 'override_validation',
|
|
2924
|
+
});
|
|
2544
2925
|
return {
|
|
2545
2926
|
ok: false,
|
|
2546
|
-
error:
|
|
2927
|
+
error: overrideError,
|
|
2547
2928
|
error_code: 'override_validation_failed',
|
|
2548
2929
|
};
|
|
2549
2930
|
}
|
|
@@ -2598,9 +2979,15 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2598
2979
|
},
|
|
2599
2980
|
);
|
|
2600
2981
|
if (diffComparison.errors.length > 0) {
|
|
2982
|
+
const mismatchError = `Observed artifact mismatch: ${diffComparison.errors.join('; ')}`;
|
|
2983
|
+
transitionToFailedAcceptance(root, state, currentTurn, mismatchError, {
|
|
2984
|
+
error_code: 'artifact_mismatch',
|
|
2985
|
+
stage: 'artifact_observation',
|
|
2986
|
+
extra: { undeclared_files: diffComparison.errors, warnings: diffComparison.warnings },
|
|
2987
|
+
});
|
|
2601
2988
|
return {
|
|
2602
2989
|
ok: false,
|
|
2603
|
-
error:
|
|
2990
|
+
error: mismatchError,
|
|
2604
2991
|
validation: {
|
|
2605
2992
|
...validation,
|
|
2606
2993
|
ok: false,
|
|
@@ -2612,6 +2999,57 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2612
2999
|
};
|
|
2613
3000
|
}
|
|
2614
3001
|
|
|
3002
|
+
// ── Intent coverage validation (BUG-14) ──────────────────────────────────
|
|
3003
|
+
// When a turn is bound to an injected intent, verify the turn result
|
|
3004
|
+
// addresses each acceptance item. Default: strict for p0, lenient for others.
|
|
3005
|
+
const intakeCtx = currentTurn.intake_context;
|
|
3006
|
+
if (intakeCtx && Array.isArray(intakeCtx.acceptance_contract) && intakeCtx.acceptance_contract.length > 0) {
|
|
3007
|
+
const intentCoverage = evaluateIntentCoverage(turnResult, intakeCtx);
|
|
3008
|
+
const priority = intakeCtx.priority || currentTurn.intake_context?.priority || 'p0';
|
|
3009
|
+
const intentCoverageMode = config.intent_coverage_mode
|
|
3010
|
+
|| (priority === 'p0' ? 'strict' : 'lenient');
|
|
3011
|
+
|
|
3012
|
+
if (intentCoverage.unaddressed.length > 0) {
|
|
3013
|
+
if (intentCoverageMode === 'strict') {
|
|
3014
|
+
const coverageError = `Intent coverage incomplete: ${intentCoverage.unaddressed.length} acceptance item(s) not addressed: ${intentCoverage.unaddressed.join('; ')}`;
|
|
3015
|
+
transitionToFailedAcceptance(root, state, currentTurn, coverageError, {
|
|
3016
|
+
error_code: 'intent_coverage_incomplete',
|
|
3017
|
+
stage: 'intent_coverage',
|
|
3018
|
+
extra: {
|
|
3019
|
+
intent_id: intakeCtx.intent_id,
|
|
3020
|
+
addressed: intentCoverage.addressed,
|
|
3021
|
+
unaddressed: intentCoverage.unaddressed,
|
|
3022
|
+
},
|
|
3023
|
+
});
|
|
3024
|
+
return {
|
|
3025
|
+
ok: false,
|
|
3026
|
+
error: coverageError,
|
|
3027
|
+
validation: {
|
|
3028
|
+
...validation,
|
|
3029
|
+
ok: false,
|
|
3030
|
+
stage: 'intent_coverage',
|
|
3031
|
+
error_class: 'intent_coverage_error',
|
|
3032
|
+
errors: [`Unaddressed acceptance items: ${intentCoverage.unaddressed.join('; ')}`],
|
|
3033
|
+
warnings: [],
|
|
3034
|
+
},
|
|
3035
|
+
};
|
|
3036
|
+
}
|
|
3037
|
+
// Lenient mode — emit warning event but allow acceptance to proceed
|
|
3038
|
+
emitRunEvent(root, 'turn_incomplete_intent_coverage', {
|
|
3039
|
+
run_id: state.run_id,
|
|
3040
|
+
phase: state.phase,
|
|
3041
|
+
status: state.status,
|
|
3042
|
+
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
3043
|
+
intent_id: intakeCtx.intent_id,
|
|
3044
|
+
payload: {
|
|
3045
|
+
addressed: intentCoverage.addressed,
|
|
3046
|
+
unaddressed: intentCoverage.unaddressed,
|
|
3047
|
+
mode: 'lenient',
|
|
3048
|
+
},
|
|
3049
|
+
});
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
|
|
2615
3053
|
const observedArtifact = buildObservedArtifact(observation, baseline);
|
|
2616
3054
|
const normalizedVerification = normalizeVerification(turnResult.verification, runtimeType);
|
|
2617
3055
|
const artifactType = turnResult.artifact?.type || 'review';
|
|
@@ -2632,9 +3070,15 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2632
3070
|
|
|
2633
3071
|
if (policyResult.blocks.length > 0) {
|
|
2634
3072
|
const blockMessages = policyResult.blocks.map((v) => v.message);
|
|
3073
|
+
const policyError = `Policy violation: ${blockMessages.join('; ')}`;
|
|
3074
|
+
transitionToFailedAcceptance(root, state, currentTurn, policyError, {
|
|
3075
|
+
error_code: 'policy_violation',
|
|
3076
|
+
stage: 'policy_evaluation',
|
|
3077
|
+
extra: { violations: policyResult.violations },
|
|
3078
|
+
});
|
|
2635
3079
|
return {
|
|
2636
3080
|
ok: false,
|
|
2637
|
-
error:
|
|
3081
|
+
error: policyError,
|
|
2638
3082
|
error_code: 'policy_violation',
|
|
2639
3083
|
policy_violations: policyResult.violations,
|
|
2640
3084
|
};
|
|
@@ -2848,6 +3292,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2848
3292
|
normalized_verification: normalizedVerification,
|
|
2849
3293
|
...(verificationReplay ? { verification_replay: summarizeVerificationReplay(verificationReplay) } : {}),
|
|
2850
3294
|
artifact: turnResult.artifact || {},
|
|
3295
|
+
intent_id: currentTurn.intake_context?.intent_id || null,
|
|
2851
3296
|
observed_artifact: observedArtifact,
|
|
2852
3297
|
proposed_next_role: turnResult.proposed_next_role,
|
|
2853
3298
|
phase_transition_request: turnResult.phase_transition_request,
|
|
@@ -3442,6 +3887,106 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3442
3887
|
}
|
|
3443
3888
|
}
|
|
3444
3889
|
|
|
3890
|
+
// ── BUG-19: Post-acceptance gate reconciliation ────────────────────────
|
|
3891
|
+
// If a previous gate failure is cached in last_gate_failure, re-evaluate
|
|
3892
|
+
// whether the conditions are now satisfied after this turn's artifacts.
|
|
3893
|
+
// This prevents stale gate failures from surviving after a turn fixes them.
|
|
3894
|
+
// Only clear if ALL failure conditions are now resolved.
|
|
3895
|
+
if (updatedState.last_gate_failure && updatedState.status !== 'completed' && updatedState.status !== 'blocked') {
|
|
3896
|
+
const staleGate = updatedState.last_gate_failure;
|
|
3897
|
+
let allConditionsResolved = true;
|
|
3898
|
+
|
|
3899
|
+
// Check if missing_files are now present
|
|
3900
|
+
if (Array.isArray(staleGate.missing_files) && staleGate.missing_files.length > 0) {
|
|
3901
|
+
const stillMissing = staleGate.missing_files.filter(f => !existsSync(join(root, f)));
|
|
3902
|
+
if (stillMissing.length > 0) {
|
|
3903
|
+
allConditionsResolved = false;
|
|
3904
|
+
}
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3907
|
+
// Check if missing_verification is still an issue
|
|
3908
|
+
// Verification failures can only be resolved by the specific gate re-evaluation
|
|
3909
|
+
// during a phase_transition_request, not by post-acceptance reconciliation,
|
|
3910
|
+
// because verification is turn-specific — the prior turn's verification status
|
|
3911
|
+
// is what the gate evaluated, and a different turn's pass doesn't retroactively
|
|
3912
|
+
// fix the prior turn's failure.
|
|
3913
|
+
if (staleGate.missing_verification) {
|
|
3914
|
+
allConditionsResolved = false;
|
|
3915
|
+
}
|
|
3916
|
+
|
|
3917
|
+
// Only clear if there were resolvable conditions and they are all resolved
|
|
3918
|
+
const hadResolvableConditions = Array.isArray(staleGate.missing_files) && staleGate.missing_files.length > 0;
|
|
3919
|
+
if (allConditionsResolved && hadResolvableConditions) {
|
|
3920
|
+
updatedState.last_gate_failure = null;
|
|
3921
|
+
if (staleGate.gate_id) {
|
|
3922
|
+
updatedState.phase_gate_status = {
|
|
3923
|
+
...(updatedState.phase_gate_status || {}),
|
|
3924
|
+
[staleGate.gate_id]: 'cleared_by_reconciliation',
|
|
3925
|
+
};
|
|
3926
|
+
}
|
|
3927
|
+
ledgerEntries.push({
|
|
3928
|
+
type: 'gate_reconciliation',
|
|
3929
|
+
gate_id: staleGate.gate_id,
|
|
3930
|
+
gate_type: staleGate.gate_type,
|
|
3931
|
+
phase: updatedState.phase,
|
|
3932
|
+
reason: 'post_acceptance_reconciliation',
|
|
3933
|
+
previously_missing_files: staleGate.missing_files || [],
|
|
3934
|
+
reconciled_at: now,
|
|
3935
|
+
reconciled_by_turn: currentTurn.turn_id,
|
|
3936
|
+
});
|
|
3937
|
+
}
|
|
3938
|
+
}
|
|
3939
|
+
|
|
3940
|
+
// ── BUG-20: Post-acceptance intent satisfaction ─────────────────────────
|
|
3941
|
+
// When a turn bound to an injected intent is accepted successfully, transition
|
|
3942
|
+
// the intent to 'completed' so it disappears from the pending queue.
|
|
3943
|
+
if (currentTurn.intake_context?.intent_id) {
|
|
3944
|
+
const intentId = currentTurn.intake_context.intent_id;
|
|
3945
|
+
try {
|
|
3946
|
+
const intentPath = join(root, '.agentxchain', 'intake', 'intents', `${intentId}.json`);
|
|
3947
|
+
if (existsSync(intentPath)) {
|
|
3948
|
+
const intent = JSON.parse(readFileSync(intentPath, 'utf8'));
|
|
3949
|
+
if (intent.status === 'executing') {
|
|
3950
|
+
intent.status = 'completed';
|
|
3951
|
+
intent.completed_at = now;
|
|
3952
|
+
intent.run_completed_at = updatedState.completed_at || now;
|
|
3953
|
+
intent.run_final_turn = currentTurn.turn_id;
|
|
3954
|
+
intent.updated_at = now;
|
|
3955
|
+
intent.satisfying_turn = currentTurn.turn_id;
|
|
3956
|
+
if (!Array.isArray(intent.history)) intent.history = [];
|
|
3957
|
+
intent.history.push({
|
|
3958
|
+
from: 'executing',
|
|
3959
|
+
to: 'completed',
|
|
3960
|
+
at: now,
|
|
3961
|
+
turn_id: currentTurn.turn_id,
|
|
3962
|
+
role: currentTurn.assigned_role,
|
|
3963
|
+
run_id: updatedState.run_id,
|
|
3964
|
+
reason: 'turn accepted — acceptance contract satisfied',
|
|
3965
|
+
});
|
|
3966
|
+
writeFileSync(intentPath, JSON.stringify(intent, null, 2));
|
|
3967
|
+
|
|
3968
|
+
// Create observation scaffold (same as resolve path)
|
|
3969
|
+
const obsDir = join(root, '.agentxchain', 'intake', 'observations', intentId);
|
|
3970
|
+
mkdirSync(obsDir, { recursive: true });
|
|
3971
|
+
|
|
3972
|
+
// Emit intent_satisfied event
|
|
3973
|
+
emitRunEvent(root, 'intent_satisfied', {
|
|
3974
|
+
run_id: updatedState.run_id,
|
|
3975
|
+
phase: updatedState.phase,
|
|
3976
|
+
status: updatedState.status,
|
|
3977
|
+
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
3978
|
+
intent_id: intentId,
|
|
3979
|
+
payload: {
|
|
3980
|
+
satisfying_turn: currentTurn.turn_id,
|
|
3981
|
+
},
|
|
3982
|
+
});
|
|
3983
|
+
}
|
|
3984
|
+
}
|
|
3985
|
+
} catch {
|
|
3986
|
+
// Non-fatal — intent satisfaction is advisory
|
|
3987
|
+
}
|
|
3988
|
+
}
|
|
3989
|
+
|
|
3445
3990
|
// ── Transaction journal: prepare before committing writes ──────────────
|
|
3446
3991
|
const transactionId = generateId('txn');
|
|
3447
3992
|
const journal = {
|
|
@@ -3558,6 +4103,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3558
4103
|
phase: updatedState.phase,
|
|
3559
4104
|
status: updatedState.status,
|
|
3560
4105
|
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
4106
|
+
intent_id: currentTurn.intake_context?.intent_id || null,
|
|
3561
4107
|
payload: turnAcceptedPayload,
|
|
3562
4108
|
});
|
|
3563
4109
|
|
|
@@ -3576,6 +4122,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3576
4122
|
phase: updatedState.phase,
|
|
3577
4123
|
status: 'blocked',
|
|
3578
4124
|
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
4125
|
+
intent_id: currentTurn.intake_context?.intent_id || null,
|
|
3579
4126
|
payload: { category: updatedState.blocked_reason?.category || 'needs_human' },
|
|
3580
4127
|
});
|
|
3581
4128
|
}
|
|
@@ -3765,15 +4312,19 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
3765
4312
|
conflict_context: conflictContext,
|
|
3766
4313
|
};
|
|
3767
4314
|
|
|
4315
|
+
// BUG-8 fix: ALWAYS refresh the baseline on retry, not just for conflict rejects.
|
|
4316
|
+
// A retry is a new attempt — it must start from current repo state, not the
|
|
4317
|
+
// moment the failed attempt was originally dispatched.
|
|
4318
|
+
const retryStartedAt = new Date().toISOString();
|
|
4319
|
+
retryTurn.baseline = captureBaseline(root);
|
|
4320
|
+
retryTurn.started_at = retryStartedAt;
|
|
4321
|
+
retryTurn.deadline_at = new Date(Date.now() + 20 * 60 * 1000).toISOString();
|
|
4322
|
+
|
|
3768
4323
|
if (isConflictReject) {
|
|
3769
|
-
const retryStartedAt = new Date().toISOString();
|
|
3770
|
-
retryTurn.baseline = captureBaseline(root);
|
|
3771
4324
|
retryTurn.assigned_sequence = Math.max(
|
|
3772
4325
|
state.turn_sequence || 0,
|
|
3773
4326
|
currentTurn.assigned_sequence || 0,
|
|
3774
4327
|
);
|
|
3775
|
-
retryTurn.started_at = retryStartedAt;
|
|
3776
|
-
retryTurn.deadline_at = new Date(Date.now() + 20 * 60 * 1000).toISOString();
|
|
3777
4328
|
retryTurn.concurrent_with = Object.keys(getActiveTurns(state)).filter((turnId) => turnId !== currentTurn.turn_id);
|
|
3778
4329
|
}
|
|
3779
4330
|
|
|
@@ -3796,6 +4347,7 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
3796
4347
|
phase: updatedState.phase,
|
|
3797
4348
|
status: updatedState.status,
|
|
3798
4349
|
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
4350
|
+
intent_id: currentTurn.intake_context?.intent_id || null,
|
|
3799
4351
|
payload: {
|
|
3800
4352
|
attempt: currentAttempt,
|
|
3801
4353
|
retrying: true,
|
|
@@ -3869,6 +4421,7 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
3869
4421
|
phase: updatedState.phase,
|
|
3870
4422
|
status: 'blocked',
|
|
3871
4423
|
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
4424
|
+
intent_id: currentTurn.intake_context?.intent_id || null,
|
|
3872
4425
|
payload: {
|
|
3873
4426
|
attempt: currentAttempt,
|
|
3874
4427
|
retrying: false,
|
|
@@ -3882,6 +4435,7 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
3882
4435
|
run_id: updatedState.run_id,
|
|
3883
4436
|
phase: updatedState.phase,
|
|
3884
4437
|
status: 'blocked',
|
|
4438
|
+
intent_id: currentTurn.intake_context?.intent_id || null,
|
|
3885
4439
|
payload: { category: 'retries_exhausted' },
|
|
3886
4440
|
});
|
|
3887
4441
|
|
|
@@ -4268,6 +4822,65 @@ function deriveNextRecommendedRole(turnResult, state, config) {
|
|
|
4268
4822
|
return routing?.entry_role || null;
|
|
4269
4823
|
}
|
|
4270
4824
|
|
|
4825
|
+
// ── Intent coverage evaluation (BUG-14) ──────────────────────────────────────
|
|
4826
|
+
//
|
|
4827
|
+
// Checks whether a turn result addresses the acceptance items from an injected
|
|
4828
|
+
// intent. Uses a hybrid approach: structural (`intent_response` field) first,
|
|
4829
|
+
// with semantic fallback scanning `summary`, `decisions`, and `files_changed`.
|
|
4830
|
+
|
|
4831
|
+
function evaluateIntentCoverage(turnResult, intakeContext) {
|
|
4832
|
+
const acceptanceItems = intakeContext.acceptance_contract || [];
|
|
4833
|
+
const addressed = [];
|
|
4834
|
+
const unaddressed = [];
|
|
4835
|
+
|
|
4836
|
+
// Build the structural response map if present
|
|
4837
|
+
const responseMap = new Map();
|
|
4838
|
+
if (Array.isArray(turnResult.intent_response)) {
|
|
4839
|
+
for (const entry of turnResult.intent_response) {
|
|
4840
|
+
if (entry && typeof entry.item === 'string' && typeof entry.status === 'string') {
|
|
4841
|
+
responseMap.set(entry.item.toLowerCase().trim(), entry);
|
|
4842
|
+
}
|
|
4843
|
+
}
|
|
4844
|
+
}
|
|
4845
|
+
|
|
4846
|
+
// Build a searchable corpus from the turn result for semantic fallback
|
|
4847
|
+
const corpus = [
|
|
4848
|
+
turnResult.summary || '',
|
|
4849
|
+
...(turnResult.decisions || []).map(d => `${d.statement || ''} ${d.rationale || ''}`),
|
|
4850
|
+
...(turnResult.objections || []).map(o => o.statement || ''),
|
|
4851
|
+
...(turnResult.files_changed || []),
|
|
4852
|
+
...(turnResult.artifacts_created || []),
|
|
4853
|
+
...(Array.isArray(turnResult.intent_response) ? turnResult.intent_response.map(r => `${r.item || ''} ${r.detail || ''}`) : []),
|
|
4854
|
+
].join('\n').toLowerCase();
|
|
4855
|
+
|
|
4856
|
+
for (const item of acceptanceItems) {
|
|
4857
|
+
const normalizedItem = item.toLowerCase().trim();
|
|
4858
|
+
|
|
4859
|
+
// Check 1: Structural — intent_response field with explicit status
|
|
4860
|
+
const structuralEntry = responseMap.get(normalizedItem);
|
|
4861
|
+
if (structuralEntry && ['addressed', 'deferred', 'rejected'].includes(structuralEntry.status)) {
|
|
4862
|
+
addressed.push(item);
|
|
4863
|
+
continue;
|
|
4864
|
+
}
|
|
4865
|
+
|
|
4866
|
+
// Check 2: Semantic fallback — significant keyword overlap
|
|
4867
|
+
const words = normalizedItem.split(/\s+/).filter(w => w.length > 3);
|
|
4868
|
+
if (words.length === 0) {
|
|
4869
|
+
addressed.push(item);
|
|
4870
|
+
continue;
|
|
4871
|
+
}
|
|
4872
|
+
const matchedWords = words.filter(w => corpus.includes(w));
|
|
4873
|
+
const coverage = matchedWords.length / words.length;
|
|
4874
|
+
if (coverage >= 0.5) {
|
|
4875
|
+
addressed.push(item);
|
|
4876
|
+
} else {
|
|
4877
|
+
unaddressed.push(item);
|
|
4878
|
+
}
|
|
4879
|
+
}
|
|
4880
|
+
|
|
4881
|
+
return { addressed, unaddressed };
|
|
4882
|
+
}
|
|
4883
|
+
|
|
4271
4884
|
export {
|
|
4272
4885
|
STATE_PATH,
|
|
4273
4886
|
HISTORY_PATH,
|