agentxchain 2.128.0 → 2.129.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 +28 -4
- package/package.json +1 -1
- package/scripts/verify-post-publish.sh +55 -5
- package/src/commands/connector.js +17 -2
- package/src/commands/doctor.js +122 -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 +142 -0
- package/src/commands/reissue-turn.js +122 -0
- package/src/commands/reject-turn.js +24 -4
- package/src/commands/restart.js +9 -2
- package/src/commands/resume.js +20 -9
- package/src/commands/status.js +46 -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 +14 -86
- package/src/lib/dispatch-bundle.js +39 -0
- package/src/lib/governed-state.js +474 -10
- package/src/lib/governed-templates.js +1 -0
- package/src/lib/intake.js +221 -77
- package/src/lib/missions.js +56 -4
- package/src/lib/normalized-config.js +50 -15
- package/src/lib/repo-observer.js +7 -2
- package/src/lib/run-events.js +4 -0
- package/src/lib/run-loop.js +5 -0
- package/src/lib/runner-interface.js +2 -0
- package/src/lib/session-checkpoint.js +18 -2
- 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';
|
|
@@ -2059,7 +2061,7 @@ export function initializeGovernedRun(root, config, options = {}) {
|
|
|
2059
2061
|
* @param {string} roleId - the role to assign
|
|
2060
2062
|
* @returns {{ ok: boolean, error?: string, warnings?: string[], state?: object }}
|
|
2061
2063
|
*/
|
|
2062
|
-
export function assignGovernedTurn(root, config, roleId) {
|
|
2064
|
+
export function assignGovernedTurn(root, config, roleId, options = {}) {
|
|
2063
2065
|
let state = readState(root);
|
|
2064
2066
|
if (!state) {
|
|
2065
2067
|
return { ok: false, error: 'No governed state.json found' };
|
|
@@ -2243,6 +2245,9 @@ export function assignGovernedTurn(root, config, roleId) {
|
|
|
2243
2245
|
assigned_sequence: nextSequence,
|
|
2244
2246
|
concurrent_with: concurrentWith,
|
|
2245
2247
|
};
|
|
2248
|
+
if (options.intakeContext) {
|
|
2249
|
+
newTurn.intake_context = options.intakeContext;
|
|
2250
|
+
}
|
|
2246
2251
|
|
|
2247
2252
|
// Attach delegation context if this turn fulfills a pending delegation
|
|
2248
2253
|
const delegationQueue = state.delegation_queue || [];
|
|
@@ -2288,12 +2293,15 @@ export function assignGovernedTurn(root, config, roleId) {
|
|
|
2288
2293
|
phase: updatedState.phase,
|
|
2289
2294
|
status: updatedState.status,
|
|
2290
2295
|
turn: { turn_id: turnId, role_id: roleId },
|
|
2296
|
+
intent_id: options.intakeContext?.intent_id || null,
|
|
2291
2297
|
});
|
|
2292
2298
|
|
|
2293
|
-
// Session checkpoint — non-fatal, written after every successful turn assignment
|
|
2299
|
+
// Session checkpoint — non-fatal, written after every successful turn assignment.
|
|
2300
|
+
// Pass the captured baseline so session.json agrees with state.json (BUG-2 fix).
|
|
2294
2301
|
writeSessionCheckpoint(root, updatedState, 'turn_assigned', {
|
|
2295
2302
|
role: roleId,
|
|
2296
2303
|
dispatch_dir: `.agentxchain/dispatch/turns/${turnId}`,
|
|
2304
|
+
turn_baseline: baseline,
|
|
2297
2305
|
});
|
|
2298
2306
|
|
|
2299
2307
|
const assignedTurn = updatedState.active_turns[turnId];
|
|
@@ -2304,6 +2312,274 @@ export function assignGovernedTurn(root, config, roleId) {
|
|
|
2304
2312
|
return result;
|
|
2305
2313
|
}
|
|
2306
2314
|
|
|
2315
|
+
/**
|
|
2316
|
+
* Refresh a turn's baseline dirty_snapshot to include files that became dirty
|
|
2317
|
+
* between assignment and dispatch. This prevents acceptance from blaming the
|
|
2318
|
+
* turn for operator edits made while the turn was pending (BUG-1 fix).
|
|
2319
|
+
*
|
|
2320
|
+
* Merges new dirty entries into the existing snapshot — existing entries are
|
|
2321
|
+
* preserved, new entries are added. The baseline.clean flag is updated to
|
|
2322
|
+
* reflect the merged snapshot.
|
|
2323
|
+
*
|
|
2324
|
+
* @param {string} root - project root
|
|
2325
|
+
* @param {string} [turnId] - specific turn to refresh (defaults to single active turn)
|
|
2326
|
+
* @returns {{ ok: boolean, refreshed_files?: string[], error?: string }}
|
|
2327
|
+
*/
|
|
2328
|
+
export function refreshTurnBaselineSnapshot(root, turnId) {
|
|
2329
|
+
const state = readState(root);
|
|
2330
|
+
if (!state) return { ok: false, error: 'No governed state found' };
|
|
2331
|
+
|
|
2332
|
+
const activeTurns = getActiveTurns(state);
|
|
2333
|
+
const resolvedTurnId = turnId || Object.keys(activeTurns)[0];
|
|
2334
|
+
if (!resolvedTurnId) return { ok: false, error: 'No active turn to refresh' };
|
|
2335
|
+
|
|
2336
|
+
const turn = activeTurns[resolvedTurnId];
|
|
2337
|
+
if (!turn) return { ok: false, error: `Turn ${resolvedTurnId} not found in active turns` };
|
|
2338
|
+
if (!turn.baseline) return { ok: false, error: 'Turn has no baseline to refresh' };
|
|
2339
|
+
|
|
2340
|
+
const currentSnapshot = captureDirtyWorkspaceSnapshot(root);
|
|
2341
|
+
const existingSnapshot = turn.baseline.dirty_snapshot || {};
|
|
2342
|
+
const merged = { ...existingSnapshot };
|
|
2343
|
+
const refreshedFiles = [];
|
|
2344
|
+
|
|
2345
|
+
for (const [filePath, marker] of Object.entries(currentSnapshot)) {
|
|
2346
|
+
if (!(filePath in merged)) {
|
|
2347
|
+
merged[filePath] = marker;
|
|
2348
|
+
refreshedFiles.push(filePath);
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
if (refreshedFiles.length === 0) {
|
|
2353
|
+
return { ok: true, refreshed_files: [] };
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
// Update the turn's baseline with the merged snapshot
|
|
2357
|
+
const updatedTurn = {
|
|
2358
|
+
...turn,
|
|
2359
|
+
baseline: {
|
|
2360
|
+
...turn.baseline,
|
|
2361
|
+
dirty_snapshot: merged,
|
|
2362
|
+
// Recalculate clean: filter out operational and baseline-exempt paths
|
|
2363
|
+
clean: Object.keys(merged).filter(f => !isOperationalPath(f)).length === 0,
|
|
2364
|
+
},
|
|
2365
|
+
};
|
|
2366
|
+
|
|
2367
|
+
const updatedState = {
|
|
2368
|
+
...state,
|
|
2369
|
+
active_turns: {
|
|
2370
|
+
...activeTurns,
|
|
2371
|
+
[resolvedTurnId]: updatedTurn,
|
|
2372
|
+
},
|
|
2373
|
+
};
|
|
2374
|
+
writeState(root, updatedState);
|
|
2375
|
+
|
|
2376
|
+
return { ok: true, refreshed_files: refreshedFiles };
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
/**
|
|
2380
|
+
* Reissue an active turn against current repo state.
|
|
2381
|
+
*
|
|
2382
|
+
* Invalidates the current turn, archives its state, captures a fresh baseline
|
|
2383
|
+
* from current HEAD/workspace, and creates a new turn with the same role and
|
|
2384
|
+
* phase. Covers baseline drift, runtime drift, authority drift, and operator-
|
|
2385
|
+
* initiated reissue. (BUG-7 fix)
|
|
2386
|
+
*
|
|
2387
|
+
* @param {string} root - project root
|
|
2388
|
+
* @param {object} config - normalized config
|
|
2389
|
+
* @param {object} opts
|
|
2390
|
+
* @param {string} [opts.turnId] - specific turn to reissue
|
|
2391
|
+
* @param {string} [opts.reason] - reason for reissue
|
|
2392
|
+
* @returns {{ ok: boolean, state?: object, newTurn?: object, baselineDelta?: object, error?: string }}
|
|
2393
|
+
*/
|
|
2394
|
+
/**
|
|
2395
|
+
* Detect runtime/authority drift for all active turns against current config.
|
|
2396
|
+
* Returns an array of drift descriptors. Empty array = no drift.
|
|
2397
|
+
*/
|
|
2398
|
+
export function detectActiveTurnBindingDrift(state, config) {
|
|
2399
|
+
const activeTurns = getActiveTurns(state);
|
|
2400
|
+
const drifts = [];
|
|
2401
|
+
|
|
2402
|
+
for (const turn of Object.values(activeTurns)) {
|
|
2403
|
+
const roleId = turn.assigned_role;
|
|
2404
|
+
const role = config.roles?.[roleId];
|
|
2405
|
+
if (!role) continue;
|
|
2406
|
+
|
|
2407
|
+
const currentRuntimeId = role.runtime_id || role.runtime;
|
|
2408
|
+
const currentAuthority = role.write_authority;
|
|
2409
|
+
const turnRuntimeId = turn.runtime_id;
|
|
2410
|
+
|
|
2411
|
+
const runtimeChanged = currentRuntimeId && turnRuntimeId && currentRuntimeId !== turnRuntimeId;
|
|
2412
|
+
const authorityChanged = currentAuthority && turn.write_authority && currentAuthority !== turn.write_authority;
|
|
2413
|
+
|
|
2414
|
+
// Check runtime type change even if runtime_id matches
|
|
2415
|
+
const currentRuntimeType = config.runtimes?.[currentRuntimeId]?.type;
|
|
2416
|
+
const turnRuntimeType = config.runtimes?.[turnRuntimeId]?.type;
|
|
2417
|
+
const runtimeTypeChanged = currentRuntimeType && turnRuntimeType && currentRuntimeType !== turnRuntimeType;
|
|
2418
|
+
|
|
2419
|
+
if (runtimeChanged || authorityChanged || runtimeTypeChanged) {
|
|
2420
|
+
drifts.push({
|
|
2421
|
+
turn_id: turn.turn_id,
|
|
2422
|
+
role_id: roleId,
|
|
2423
|
+
runtime_changed: Boolean(runtimeChanged || runtimeTypeChanged),
|
|
2424
|
+
old_runtime: turnRuntimeId,
|
|
2425
|
+
new_runtime: currentRuntimeId,
|
|
2426
|
+
authority_changed: Boolean(authorityChanged),
|
|
2427
|
+
old_authority: turn.write_authority,
|
|
2428
|
+
new_authority: currentAuthority,
|
|
2429
|
+
recovery_command: `agentxchain reissue-turn --turn ${turn.turn_id} --reason "config rebinding"`,
|
|
2430
|
+
});
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
return drifts;
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
export function reissueTurn(root, config, opts = {}) {
|
|
2438
|
+
const state = readState(root);
|
|
2439
|
+
if (!state) return { ok: false, error: 'No governed state found' };
|
|
2440
|
+
|
|
2441
|
+
const activeTurns = getActiveTurns(state);
|
|
2442
|
+
const turnId = opts.turnId || Object.keys(activeTurns)[0];
|
|
2443
|
+
if (!turnId) return { ok: false, error: 'No active turn to reissue' };
|
|
2444
|
+
|
|
2445
|
+
const oldTurn = activeTurns[turnId];
|
|
2446
|
+
if (!oldTurn) return { ok: false, error: `Turn ${turnId} not found in active turns` };
|
|
2447
|
+
|
|
2448
|
+
const roleId = oldTurn.assigned_role;
|
|
2449
|
+
const role = config.roles?.[roleId];
|
|
2450
|
+
if (!role) return { ok: false, error: `Role "${roleId}" not found in config` };
|
|
2451
|
+
|
|
2452
|
+
const reason = opts.reason || 'operator-initiated reissue';
|
|
2453
|
+
const now = new Date().toISOString();
|
|
2454
|
+
|
|
2455
|
+
// Capture old baseline for delta computation
|
|
2456
|
+
const oldBaseline = oldTurn.baseline || {};
|
|
2457
|
+
const oldRuntimeId = oldTurn.runtime_id;
|
|
2458
|
+
|
|
2459
|
+
// Resolve current runtime binding (may have changed in config)
|
|
2460
|
+
const currentRuntimeId = role.runtime;
|
|
2461
|
+
const currentRuntime = config.runtimes?.[currentRuntimeId];
|
|
2462
|
+
if (!currentRuntime) {
|
|
2463
|
+
return { ok: false, error: `Runtime "${currentRuntimeId}" not found in config for role "${roleId}"` };
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
// Capture fresh baseline
|
|
2467
|
+
const newBaseline = captureBaseline(root);
|
|
2468
|
+
|
|
2469
|
+
// Archive the old turn as a history entry
|
|
2470
|
+
appendJsonl(root, HISTORY_PATH, {
|
|
2471
|
+
turn_id: oldTurn.turn_id,
|
|
2472
|
+
run_id: state.run_id,
|
|
2473
|
+
role: roleId,
|
|
2474
|
+
phase: state.phase,
|
|
2475
|
+
status: 'reissued',
|
|
2476
|
+
summary: `Turn reissued: ${reason}`,
|
|
2477
|
+
files_changed: [],
|
|
2478
|
+
assigned_sequence: oldTurn.assigned_sequence,
|
|
2479
|
+
accepted_at: now,
|
|
2480
|
+
duration_ms: oldTurn.started_at ? Date.now() - new Date(oldTurn.started_at).getTime() : 0,
|
|
2481
|
+
reissue_reason: reason,
|
|
2482
|
+
});
|
|
2483
|
+
|
|
2484
|
+
// Decision ledger entry
|
|
2485
|
+
appendJsonl(root, LEDGER_PATH, {
|
|
2486
|
+
timestamp: now,
|
|
2487
|
+
decision: 'turn_reissued',
|
|
2488
|
+
turn_id: oldTurn.turn_id,
|
|
2489
|
+
role: roleId,
|
|
2490
|
+
phase: state.phase,
|
|
2491
|
+
reason,
|
|
2492
|
+
old_baseline: {
|
|
2493
|
+
head_ref: oldBaseline.head_ref,
|
|
2494
|
+
clean: oldBaseline.clean,
|
|
2495
|
+
},
|
|
2496
|
+
new_baseline: {
|
|
2497
|
+
head_ref: newBaseline.head_ref,
|
|
2498
|
+
clean: newBaseline.clean,
|
|
2499
|
+
},
|
|
2500
|
+
});
|
|
2501
|
+
|
|
2502
|
+
// Create the new turn
|
|
2503
|
+
const newTurnId = `turn_${randomBytes(8).toString('hex')}`;
|
|
2504
|
+
const timeoutMinutes = 20;
|
|
2505
|
+
const nextSequence = (state.turn_sequence || 0) + 1;
|
|
2506
|
+
|
|
2507
|
+
const newTurn = {
|
|
2508
|
+
turn_id: newTurnId,
|
|
2509
|
+
assigned_role: roleId,
|
|
2510
|
+
status: 'running',
|
|
2511
|
+
attempt: (oldTurn.attempt || 1) + 1,
|
|
2512
|
+
started_at: now,
|
|
2513
|
+
deadline_at: new Date(Date.now() + timeoutMinutes * 60 * 1000).toISOString(),
|
|
2514
|
+
runtime_id: currentRuntimeId,
|
|
2515
|
+
baseline: newBaseline,
|
|
2516
|
+
assigned_sequence: nextSequence,
|
|
2517
|
+
concurrent_with: Object.keys(activeTurns).filter(id => id !== turnId),
|
|
2518
|
+
reissued_from: oldTurn.turn_id,
|
|
2519
|
+
};
|
|
2520
|
+
|
|
2521
|
+
// Copy delegation context if present
|
|
2522
|
+
if (oldTurn.delegation_context) {
|
|
2523
|
+
newTurn.delegation_context = oldTurn.delegation_context;
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
// Remove old turn, add new turn
|
|
2527
|
+
const newActiveTurns = { ...activeTurns };
|
|
2528
|
+
delete newActiveTurns[turnId];
|
|
2529
|
+
newActiveTurns[newTurnId] = newTurn;
|
|
2530
|
+
|
|
2531
|
+
const updatedState = {
|
|
2532
|
+
...state,
|
|
2533
|
+
turn_sequence: nextSequence,
|
|
2534
|
+
active_turns: newActiveTurns,
|
|
2535
|
+
};
|
|
2536
|
+
|
|
2537
|
+
writeState(root, updatedState);
|
|
2538
|
+
|
|
2539
|
+
// Emit event
|
|
2540
|
+
emitRunEvent(root, 'turn_reissued', {
|
|
2541
|
+
run_id: state.run_id,
|
|
2542
|
+
phase: state.phase,
|
|
2543
|
+
status: state.status,
|
|
2544
|
+
turn: { turn_id: newTurnId, role_id: roleId },
|
|
2545
|
+
intent_id: oldTurn.intake_context?.intent_id || null,
|
|
2546
|
+
payload: {
|
|
2547
|
+
old_turn_id: oldTurn.turn_id,
|
|
2548
|
+
reason,
|
|
2549
|
+
old_head: oldBaseline.head_ref,
|
|
2550
|
+
new_head: newBaseline.head_ref,
|
|
2551
|
+
old_runtime: oldRuntimeId,
|
|
2552
|
+
new_runtime: currentRuntimeId,
|
|
2553
|
+
},
|
|
2554
|
+
});
|
|
2555
|
+
|
|
2556
|
+
// Session checkpoint
|
|
2557
|
+
writeSessionCheckpoint(root, updatedState, 'turn_reissued', {
|
|
2558
|
+
role: roleId,
|
|
2559
|
+
turn_baseline: newBaseline,
|
|
2560
|
+
});
|
|
2561
|
+
|
|
2562
|
+
// Compute baseline delta for display
|
|
2563
|
+
const baselineDelta = {
|
|
2564
|
+
head_changed: oldBaseline.head_ref !== newBaseline.head_ref,
|
|
2565
|
+
old_head: oldBaseline.head_ref,
|
|
2566
|
+
new_head: newBaseline.head_ref,
|
|
2567
|
+
runtime_changed: oldRuntimeId !== currentRuntimeId,
|
|
2568
|
+
old_runtime: oldRuntimeId,
|
|
2569
|
+
new_runtime: currentRuntimeId,
|
|
2570
|
+
dirty_files_changed: JSON.stringify(oldBaseline.dirty_snapshot || {}) !== JSON.stringify(newBaseline.dirty_snapshot || {}),
|
|
2571
|
+
added_dirty_files: Object.keys(newBaseline.dirty_snapshot || {}).filter(f => !(f in (oldBaseline.dirty_snapshot || {}))),
|
|
2572
|
+
removed_dirty_files: Object.keys(oldBaseline.dirty_snapshot || {}).filter(f => !(f in (newBaseline.dirty_snapshot || {}))),
|
|
2573
|
+
};
|
|
2574
|
+
|
|
2575
|
+
return {
|
|
2576
|
+
ok: true,
|
|
2577
|
+
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
2578
|
+
newTurn,
|
|
2579
|
+
baselineDelta,
|
|
2580
|
+
};
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2307
2583
|
/**
|
|
2308
2584
|
* Estimate the budget for a single turn based on role/runtime configuration.
|
|
2309
2585
|
* Used for DEC-PARALLEL-011 budget reservation.
|
|
@@ -2366,6 +2642,51 @@ export function acceptGovernedTurn(root, config, opts = {}) {
|
|
|
2366
2642
|
}
|
|
2367
2643
|
}
|
|
2368
2644
|
|
|
2645
|
+
/**
|
|
2646
|
+
* Transition an active turn to failed_acceptance and emit the corresponding
|
|
2647
|
+
* event. Called from acceptance failure paths so the turn is never left stuck
|
|
2648
|
+
* in 'running' after the subprocess has exited (BUG-3 + BUG-4 fix).
|
|
2649
|
+
*/
|
|
2650
|
+
function transitionToFailedAcceptance(root, state, turn, reason, details = {}) {
|
|
2651
|
+
const activeTurns = getActiveTurns(state);
|
|
2652
|
+
const updatedTurn = {
|
|
2653
|
+
...turn,
|
|
2654
|
+
status: 'failed_acceptance',
|
|
2655
|
+
failed_at: new Date().toISOString(),
|
|
2656
|
+
failure_reason: reason,
|
|
2657
|
+
};
|
|
2658
|
+
const updatedState = {
|
|
2659
|
+
...state,
|
|
2660
|
+
active_turns: {
|
|
2661
|
+
...activeTurns,
|
|
2662
|
+
[turn.turn_id]: updatedTurn,
|
|
2663
|
+
},
|
|
2664
|
+
};
|
|
2665
|
+
writeState(root, updatedState);
|
|
2666
|
+
|
|
2667
|
+
// BUG-4: emit acceptance_failed event
|
|
2668
|
+
emitRunEvent(root, 'acceptance_failed', {
|
|
2669
|
+
run_id: state.run_id,
|
|
2670
|
+
phase: state.phase,
|
|
2671
|
+
status: state.status,
|
|
2672
|
+
turn: { turn_id: turn.turn_id, role_id: turn.assigned_role },
|
|
2673
|
+
intent_id: turn.intake_context?.intent_id || null,
|
|
2674
|
+
payload: {
|
|
2675
|
+
reason,
|
|
2676
|
+
error_code: details.error_code || 'acceptance_failed',
|
|
2677
|
+
stage: details.stage || 'unknown',
|
|
2678
|
+
...details.extra,
|
|
2679
|
+
},
|
|
2680
|
+
});
|
|
2681
|
+
|
|
2682
|
+
// Session checkpoint for recovery
|
|
2683
|
+
writeSessionCheckpoint(root, updatedState, 'acceptance_failed', {
|
|
2684
|
+
role: turn.assigned_role,
|
|
2685
|
+
});
|
|
2686
|
+
|
|
2687
|
+
return updatedState;
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2369
2690
|
function _acceptGovernedTurnLocked(root, config, opts) {
|
|
2370
2691
|
// Re-read state under lock (a sibling acceptance may have committed)
|
|
2371
2692
|
let state = readState(root);
|
|
@@ -2526,9 +2847,15 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2526
2847
|
}
|
|
2527
2848
|
|
|
2528
2849
|
if (!validation.ok) {
|
|
2850
|
+
const failError = `Validation failed at stage ${validation.stage}: ${validation.errors.join('; ')}`;
|
|
2851
|
+
transitionToFailedAcceptance(root, state, currentTurn, failError, {
|
|
2852
|
+
error_code: 'validation_failed',
|
|
2853
|
+
stage: validation.stage,
|
|
2854
|
+
extra: { errors: validation.errors },
|
|
2855
|
+
});
|
|
2529
2856
|
return {
|
|
2530
2857
|
ok: false,
|
|
2531
|
-
error:
|
|
2858
|
+
error: failError,
|
|
2532
2859
|
validation,
|
|
2533
2860
|
};
|
|
2534
2861
|
}
|
|
@@ -2541,9 +2868,14 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2541
2868
|
if (dec.overrides) {
|
|
2542
2869
|
const overrideCheck = validateOverride(root, { ...dec, role: dec.role || turnResult.role }, config);
|
|
2543
2870
|
if (!overrideCheck.ok) {
|
|
2871
|
+
const overrideError = `Override validation failed: ${overrideCheck.error}`;
|
|
2872
|
+
transitionToFailedAcceptance(root, state, currentTurn, overrideError, {
|
|
2873
|
+
error_code: 'override_validation_failed',
|
|
2874
|
+
stage: 'override_validation',
|
|
2875
|
+
});
|
|
2544
2876
|
return {
|
|
2545
2877
|
ok: false,
|
|
2546
|
-
error:
|
|
2878
|
+
error: overrideError,
|
|
2547
2879
|
error_code: 'override_validation_failed',
|
|
2548
2880
|
};
|
|
2549
2881
|
}
|
|
@@ -2598,9 +2930,15 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2598
2930
|
},
|
|
2599
2931
|
);
|
|
2600
2932
|
if (diffComparison.errors.length > 0) {
|
|
2933
|
+
const mismatchError = `Observed artifact mismatch: ${diffComparison.errors.join('; ')}`;
|
|
2934
|
+
transitionToFailedAcceptance(root, state, currentTurn, mismatchError, {
|
|
2935
|
+
error_code: 'artifact_mismatch',
|
|
2936
|
+
stage: 'artifact_observation',
|
|
2937
|
+
extra: { undeclared_files: diffComparison.errors, warnings: diffComparison.warnings },
|
|
2938
|
+
});
|
|
2601
2939
|
return {
|
|
2602
2940
|
ok: false,
|
|
2603
|
-
error:
|
|
2941
|
+
error: mismatchError,
|
|
2604
2942
|
validation: {
|
|
2605
2943
|
...validation,
|
|
2606
2944
|
ok: false,
|
|
@@ -2612,6 +2950,57 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2612
2950
|
};
|
|
2613
2951
|
}
|
|
2614
2952
|
|
|
2953
|
+
// ── Intent coverage validation (BUG-14) ──────────────────────────────────
|
|
2954
|
+
// When a turn is bound to an injected intent, verify the turn result
|
|
2955
|
+
// addresses each acceptance item. Default: strict for p0, lenient for others.
|
|
2956
|
+
const intakeCtx = currentTurn.intake_context;
|
|
2957
|
+
if (intakeCtx && Array.isArray(intakeCtx.acceptance_contract) && intakeCtx.acceptance_contract.length > 0) {
|
|
2958
|
+
const intentCoverage = evaluateIntentCoverage(turnResult, intakeCtx);
|
|
2959
|
+
const priority = intakeCtx.priority || currentTurn.intake_context?.priority || 'p0';
|
|
2960
|
+
const intentCoverageMode = config.intent_coverage_mode
|
|
2961
|
+
|| (priority === 'p0' ? 'strict' : 'lenient');
|
|
2962
|
+
|
|
2963
|
+
if (intentCoverage.unaddressed.length > 0) {
|
|
2964
|
+
if (intentCoverageMode === 'strict') {
|
|
2965
|
+
const coverageError = `Intent coverage incomplete: ${intentCoverage.unaddressed.length} acceptance item(s) not addressed: ${intentCoverage.unaddressed.join('; ')}`;
|
|
2966
|
+
transitionToFailedAcceptance(root, state, currentTurn, coverageError, {
|
|
2967
|
+
error_code: 'intent_coverage_incomplete',
|
|
2968
|
+
stage: 'intent_coverage',
|
|
2969
|
+
extra: {
|
|
2970
|
+
intent_id: intakeCtx.intent_id,
|
|
2971
|
+
addressed: intentCoverage.addressed,
|
|
2972
|
+
unaddressed: intentCoverage.unaddressed,
|
|
2973
|
+
},
|
|
2974
|
+
});
|
|
2975
|
+
return {
|
|
2976
|
+
ok: false,
|
|
2977
|
+
error: coverageError,
|
|
2978
|
+
validation: {
|
|
2979
|
+
...validation,
|
|
2980
|
+
ok: false,
|
|
2981
|
+
stage: 'intent_coverage',
|
|
2982
|
+
error_class: 'intent_coverage_error',
|
|
2983
|
+
errors: [`Unaddressed acceptance items: ${intentCoverage.unaddressed.join('; ')}`],
|
|
2984
|
+
warnings: [],
|
|
2985
|
+
},
|
|
2986
|
+
};
|
|
2987
|
+
}
|
|
2988
|
+
// Lenient mode — emit warning event but allow acceptance to proceed
|
|
2989
|
+
emitRunEvent(root, 'turn_incomplete_intent_coverage', {
|
|
2990
|
+
run_id: state.run_id,
|
|
2991
|
+
phase: state.phase,
|
|
2992
|
+
status: state.status,
|
|
2993
|
+
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
2994
|
+
intent_id: intakeCtx.intent_id,
|
|
2995
|
+
payload: {
|
|
2996
|
+
addressed: intentCoverage.addressed,
|
|
2997
|
+
unaddressed: intentCoverage.unaddressed,
|
|
2998
|
+
mode: 'lenient',
|
|
2999
|
+
},
|
|
3000
|
+
});
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
|
|
2615
3004
|
const observedArtifact = buildObservedArtifact(observation, baseline);
|
|
2616
3005
|
const normalizedVerification = normalizeVerification(turnResult.verification, runtimeType);
|
|
2617
3006
|
const artifactType = turnResult.artifact?.type || 'review';
|
|
@@ -2632,9 +3021,15 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2632
3021
|
|
|
2633
3022
|
if (policyResult.blocks.length > 0) {
|
|
2634
3023
|
const blockMessages = policyResult.blocks.map((v) => v.message);
|
|
3024
|
+
const policyError = `Policy violation: ${blockMessages.join('; ')}`;
|
|
3025
|
+
transitionToFailedAcceptance(root, state, currentTurn, policyError, {
|
|
3026
|
+
error_code: 'policy_violation',
|
|
3027
|
+
stage: 'policy_evaluation',
|
|
3028
|
+
extra: { violations: policyResult.violations },
|
|
3029
|
+
});
|
|
2635
3030
|
return {
|
|
2636
3031
|
ok: false,
|
|
2637
|
-
error:
|
|
3032
|
+
error: policyError,
|
|
2638
3033
|
error_code: 'policy_violation',
|
|
2639
3034
|
policy_violations: policyResult.violations,
|
|
2640
3035
|
};
|
|
@@ -2848,6 +3243,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2848
3243
|
normalized_verification: normalizedVerification,
|
|
2849
3244
|
...(verificationReplay ? { verification_replay: summarizeVerificationReplay(verificationReplay) } : {}),
|
|
2850
3245
|
artifact: turnResult.artifact || {},
|
|
3246
|
+
intent_id: currentTurn.intake_context?.intent_id || null,
|
|
2851
3247
|
observed_artifact: observedArtifact,
|
|
2852
3248
|
proposed_next_role: turnResult.proposed_next_role,
|
|
2853
3249
|
phase_transition_request: turnResult.phase_transition_request,
|
|
@@ -3558,6 +3954,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3558
3954
|
phase: updatedState.phase,
|
|
3559
3955
|
status: updatedState.status,
|
|
3560
3956
|
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
3957
|
+
intent_id: currentTurn.intake_context?.intent_id || null,
|
|
3561
3958
|
payload: turnAcceptedPayload,
|
|
3562
3959
|
});
|
|
3563
3960
|
|
|
@@ -3576,6 +3973,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3576
3973
|
phase: updatedState.phase,
|
|
3577
3974
|
status: 'blocked',
|
|
3578
3975
|
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
3976
|
+
intent_id: currentTurn.intake_context?.intent_id || null,
|
|
3579
3977
|
payload: { category: updatedState.blocked_reason?.category || 'needs_human' },
|
|
3580
3978
|
});
|
|
3581
3979
|
}
|
|
@@ -3765,15 +4163,19 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
3765
4163
|
conflict_context: conflictContext,
|
|
3766
4164
|
};
|
|
3767
4165
|
|
|
4166
|
+
// BUG-8 fix: ALWAYS refresh the baseline on retry, not just for conflict rejects.
|
|
4167
|
+
// A retry is a new attempt — it must start from current repo state, not the
|
|
4168
|
+
// moment the failed attempt was originally dispatched.
|
|
4169
|
+
const retryStartedAt = new Date().toISOString();
|
|
4170
|
+
retryTurn.baseline = captureBaseline(root);
|
|
4171
|
+
retryTurn.started_at = retryStartedAt;
|
|
4172
|
+
retryTurn.deadline_at = new Date(Date.now() + 20 * 60 * 1000).toISOString();
|
|
4173
|
+
|
|
3768
4174
|
if (isConflictReject) {
|
|
3769
|
-
const retryStartedAt = new Date().toISOString();
|
|
3770
|
-
retryTurn.baseline = captureBaseline(root);
|
|
3771
4175
|
retryTurn.assigned_sequence = Math.max(
|
|
3772
4176
|
state.turn_sequence || 0,
|
|
3773
4177
|
currentTurn.assigned_sequence || 0,
|
|
3774
4178
|
);
|
|
3775
|
-
retryTurn.started_at = retryStartedAt;
|
|
3776
|
-
retryTurn.deadline_at = new Date(Date.now() + 20 * 60 * 1000).toISOString();
|
|
3777
4179
|
retryTurn.concurrent_with = Object.keys(getActiveTurns(state)).filter((turnId) => turnId !== currentTurn.turn_id);
|
|
3778
4180
|
}
|
|
3779
4181
|
|
|
@@ -3796,6 +4198,7 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
3796
4198
|
phase: updatedState.phase,
|
|
3797
4199
|
status: updatedState.status,
|
|
3798
4200
|
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
4201
|
+
intent_id: currentTurn.intake_context?.intent_id || null,
|
|
3799
4202
|
payload: {
|
|
3800
4203
|
attempt: currentAttempt,
|
|
3801
4204
|
retrying: true,
|
|
@@ -3869,6 +4272,7 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
3869
4272
|
phase: updatedState.phase,
|
|
3870
4273
|
status: 'blocked',
|
|
3871
4274
|
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
4275
|
+
intent_id: currentTurn.intake_context?.intent_id || null,
|
|
3872
4276
|
payload: {
|
|
3873
4277
|
attempt: currentAttempt,
|
|
3874
4278
|
retrying: false,
|
|
@@ -3882,6 +4286,7 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
3882
4286
|
run_id: updatedState.run_id,
|
|
3883
4287
|
phase: updatedState.phase,
|
|
3884
4288
|
status: 'blocked',
|
|
4289
|
+
intent_id: currentTurn.intake_context?.intent_id || null,
|
|
3885
4290
|
payload: { category: 'retries_exhausted' },
|
|
3886
4291
|
});
|
|
3887
4292
|
|
|
@@ -4268,6 +4673,65 @@ function deriveNextRecommendedRole(turnResult, state, config) {
|
|
|
4268
4673
|
return routing?.entry_role || null;
|
|
4269
4674
|
}
|
|
4270
4675
|
|
|
4676
|
+
// ── Intent coverage evaluation (BUG-14) ──────────────────────────────────────
|
|
4677
|
+
//
|
|
4678
|
+
// Checks whether a turn result addresses the acceptance items from an injected
|
|
4679
|
+
// intent. Uses a hybrid approach: structural (`intent_response` field) first,
|
|
4680
|
+
// with semantic fallback scanning `summary`, `decisions`, and `files_changed`.
|
|
4681
|
+
|
|
4682
|
+
function evaluateIntentCoverage(turnResult, intakeContext) {
|
|
4683
|
+
const acceptanceItems = intakeContext.acceptance_contract || [];
|
|
4684
|
+
const addressed = [];
|
|
4685
|
+
const unaddressed = [];
|
|
4686
|
+
|
|
4687
|
+
// Build the structural response map if present
|
|
4688
|
+
const responseMap = new Map();
|
|
4689
|
+
if (Array.isArray(turnResult.intent_response)) {
|
|
4690
|
+
for (const entry of turnResult.intent_response) {
|
|
4691
|
+
if (entry && typeof entry.item === 'string' && typeof entry.status === 'string') {
|
|
4692
|
+
responseMap.set(entry.item.toLowerCase().trim(), entry);
|
|
4693
|
+
}
|
|
4694
|
+
}
|
|
4695
|
+
}
|
|
4696
|
+
|
|
4697
|
+
// Build a searchable corpus from the turn result for semantic fallback
|
|
4698
|
+
const corpus = [
|
|
4699
|
+
turnResult.summary || '',
|
|
4700
|
+
...(turnResult.decisions || []).map(d => `${d.statement || ''} ${d.rationale || ''}`),
|
|
4701
|
+
...(turnResult.objections || []).map(o => o.statement || ''),
|
|
4702
|
+
...(turnResult.files_changed || []),
|
|
4703
|
+
...(turnResult.artifacts_created || []),
|
|
4704
|
+
...(Array.isArray(turnResult.intent_response) ? turnResult.intent_response.map(r => `${r.item || ''} ${r.detail || ''}`) : []),
|
|
4705
|
+
].join('\n').toLowerCase();
|
|
4706
|
+
|
|
4707
|
+
for (const item of acceptanceItems) {
|
|
4708
|
+
const normalizedItem = item.toLowerCase().trim();
|
|
4709
|
+
|
|
4710
|
+
// Check 1: Structural — intent_response field with explicit status
|
|
4711
|
+
const structuralEntry = responseMap.get(normalizedItem);
|
|
4712
|
+
if (structuralEntry && ['addressed', 'deferred', 'rejected'].includes(structuralEntry.status)) {
|
|
4713
|
+
addressed.push(item);
|
|
4714
|
+
continue;
|
|
4715
|
+
}
|
|
4716
|
+
|
|
4717
|
+
// Check 2: Semantic fallback — significant keyword overlap
|
|
4718
|
+
const words = normalizedItem.split(/\s+/).filter(w => w.length > 3);
|
|
4719
|
+
if (words.length === 0) {
|
|
4720
|
+
addressed.push(item);
|
|
4721
|
+
continue;
|
|
4722
|
+
}
|
|
4723
|
+
const matchedWords = words.filter(w => corpus.includes(w));
|
|
4724
|
+
const coverage = matchedWords.length / words.length;
|
|
4725
|
+
if (coverage >= 0.5) {
|
|
4726
|
+
addressed.push(item);
|
|
4727
|
+
} else {
|
|
4728
|
+
unaddressed.push(item);
|
|
4729
|
+
}
|
|
4730
|
+
}
|
|
4731
|
+
|
|
4732
|
+
return { addressed, unaddressed };
|
|
4733
|
+
}
|
|
4734
|
+
|
|
4271
4735
|
export {
|
|
4272
4736
|
STATE_PATH,
|
|
4273
4737
|
HISTORY_PATH,
|