agentxchain 2.129.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/bin/agentxchain.js +10 -0
- package/package.json +1 -1
- package/src/commands/accept-turn.js +14 -0
- package/src/commands/checkpoint-turn.js +35 -0
- package/src/commands/doctor.js +31 -2
- package/src/commands/mission.js +661 -7
- package/src/commands/reject-turn.js +36 -2
- package/src/commands/restart.js +72 -8
- package/src/commands/run.js +13 -0
- package/src/commands/status.js +13 -1
- package/src/lib/continuous-run.js +8 -1
- package/src/lib/coordinator-dispatch.js +25 -0
- package/src/lib/governed-state.js +150 -1
- package/src/lib/intake.js +12 -0
- package/src/lib/mission-plans.js +510 -6
- package/src/lib/missions.js +9 -2
- package/src/lib/repo-observer.js +1 -0
- package/src/lib/run-events.js +1 -0
- package/src/lib/run-loop.js +20 -0
- package/src/lib/turn-checkpoint.js +221 -0
|
@@ -153,8 +153,21 @@ function buildRejectionValidation(root, state, config, opts) {
|
|
|
153
153
|
[resolution.turn.turn_id]: resolution.turn,
|
|
154
154
|
},
|
|
155
155
|
};
|
|
156
|
+
const stagingPath = resolveStagingPath(root, resolution.turn.turn_id);
|
|
157
|
+
// BUG-22: If resolveStagingPath returns null, a stale result from another turn
|
|
158
|
+
// was detected. Reject with a clear diagnostic instead of consuming it.
|
|
159
|
+
if (stagingPath === null) {
|
|
160
|
+
return {
|
|
161
|
+
ok: true,
|
|
162
|
+
turn: resolution.turn,
|
|
163
|
+
validationResult: {
|
|
164
|
+
errors: [`Stale staging data: .agentxchain/staging/turn-result.json belongs to a different turn. Clean up with: rm .agentxchain/staging/turn-result.json`],
|
|
165
|
+
failed_stage: 'stale_staging',
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
156
169
|
const validation = validateStagedTurnResult(root, projectedState, config, {
|
|
157
|
-
stagingPath
|
|
170
|
+
stagingPath,
|
|
158
171
|
});
|
|
159
172
|
if (!validation.ok) {
|
|
160
173
|
return {
|
|
@@ -213,8 +226,29 @@ function resolveTargetTurn(state, turnId) {
|
|
|
213
226
|
}
|
|
214
227
|
|
|
215
228
|
function resolveStagingPath(root, turnId) {
|
|
229
|
+
// BUG-22: Prefer turn-scoped staging path. Only fall back to legacy global
|
|
230
|
+
// staging if the global result's turn_id matches the active turn.
|
|
216
231
|
const turnScopedPath = getTurnStagingResultPath(turnId);
|
|
217
|
-
|
|
232
|
+
if (existsSync(join(root, turnScopedPath))) {
|
|
233
|
+
return turnScopedPath;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const legacyPath = '.agentxchain/staging/turn-result.json';
|
|
237
|
+
const legacyAbs = join(root, legacyPath);
|
|
238
|
+
if (existsSync(legacyAbs)) {
|
|
239
|
+
try {
|
|
240
|
+
const raw = JSON.parse(require('fs').readFileSync(legacyAbs, 'utf8'));
|
|
241
|
+
if (raw.turn_id && raw.turn_id !== turnId) {
|
|
242
|
+
// Stale result from a different turn — do not consume
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
// Parse error — let the validator handle it
|
|
247
|
+
}
|
|
248
|
+
return legacyPath;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return legacyPath; // File doesn't exist — validator will report "not found"
|
|
218
252
|
}
|
|
219
253
|
|
|
220
254
|
function printDispatchBundleWarnings(bundleResult) {
|
package/src/commands/restart.js
CHANGED
|
@@ -19,10 +19,15 @@ import {
|
|
|
19
19
|
getActiveTurns,
|
|
20
20
|
getActiveTurnCount,
|
|
21
21
|
reactivateGovernedRun,
|
|
22
|
+
detectStateBundleDesync,
|
|
22
23
|
STATE_PATH,
|
|
23
24
|
HISTORY_PATH,
|
|
24
25
|
LEDGER_PATH,
|
|
25
26
|
} from '../lib/governed-state.js';
|
|
27
|
+
import { writeDispatchBundle } from '../lib/dispatch-bundle.js';
|
|
28
|
+
import { getDispatchTurnDir } from '../lib/turn-paths.js';
|
|
29
|
+
import { consumeNextApprovedIntent } from '../lib/intake.js';
|
|
30
|
+
import { loadProjectState } from '../lib/config.js';
|
|
26
31
|
import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
|
|
27
32
|
import { deriveRecommendedContinuityAction } from '../lib/continuity-status.js';
|
|
28
33
|
import { readSessionCheckpoint, writeSessionCheckpoint, captureBaselineRef, SESSION_PATH } from '../lib/session-checkpoint.js';
|
|
@@ -213,6 +218,25 @@ export async function restartCommand(opts) {
|
|
|
213
218
|
process.exit(1);
|
|
214
219
|
}
|
|
215
220
|
|
|
221
|
+
// ── BUG-18: State/bundle integrity check ─────────────────────────────────
|
|
222
|
+
const desync = detectStateBundleDesync(root, state);
|
|
223
|
+
if (!desync.ok) {
|
|
224
|
+
console.log(chalk.red('State/bundle integrity failure detected:'));
|
|
225
|
+
for (const entry of desync.desynced) {
|
|
226
|
+
console.log(chalk.red(` Active turn ${entry.turn_id} (${entry.role}) has no dispatch bundle at ${entry.expected_path}`));
|
|
227
|
+
}
|
|
228
|
+
console.log('');
|
|
229
|
+
console.log(chalk.dim('This is a ghost turn — state references an active turn but the dispatch files are missing.'));
|
|
230
|
+
console.log(chalk.dim('Recovery options:'));
|
|
231
|
+
for (const entry of desync.desynced) {
|
|
232
|
+
console.log(` ${chalk.cyan(`agentxchain reissue-turn --turn ${entry.turn_id} --reason "missing dispatch bundle"`)}`);
|
|
233
|
+
}
|
|
234
|
+
console.log(` ${chalk.cyan('agentxchain reject-turn --reason "ghost turn — missing dispatch bundle"')}`);
|
|
235
|
+
console.log('');
|
|
236
|
+
console.log(chalk.dim('Run `agentxchain doctor` for a full diagnostic.'));
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
|
|
216
240
|
// ── Repo-drift detection ────────────────────────────────────────────────
|
|
217
241
|
const driftWarnings = [];
|
|
218
242
|
if (checkpoint?.baseline_ref) {
|
|
@@ -316,21 +340,61 @@ export async function restartCommand(opts) {
|
|
|
316
340
|
|
|
317
341
|
// Assign next turn if no active turn exists
|
|
318
342
|
if (activeTurnCount === 0) {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
343
|
+
// BUG-21 fix: consume approved intents (same as resume path) so intent_id
|
|
344
|
+
// propagates into turn metadata and all lifecycle events.
|
|
345
|
+
const consumed = consumeNextApprovedIntent(root, { role: roleId });
|
|
346
|
+
let assignedState;
|
|
347
|
+
let turnId;
|
|
348
|
+
let assignedRole = roleId;
|
|
349
|
+
|
|
350
|
+
if (consumed.ok) {
|
|
351
|
+
// Intake path handled the turn assignment with intakeContext
|
|
352
|
+
assignedState = loadProjectState(root, config);
|
|
353
|
+
if (!assignedState) {
|
|
354
|
+
console.log(chalk.red('Failed to reload governed state after intake binding.'));
|
|
355
|
+
process.exit(1);
|
|
356
|
+
}
|
|
357
|
+
turnId = consumed.turn_id;
|
|
358
|
+
assignedRole = consumed.role || roleId;
|
|
359
|
+
console.log(chalk.green(`Bound approved intent to next turn: ${consumed.intentId}`));
|
|
360
|
+
} else {
|
|
361
|
+
// No approved intents — plain assignment
|
|
362
|
+
const assignment = assignGovernedTurn(root, config, roleId);
|
|
363
|
+
if (!assignment.ok) {
|
|
364
|
+
console.log(chalk.red(`Failed to assign turn: ${assignment.error}`));
|
|
365
|
+
process.exit(1);
|
|
366
|
+
}
|
|
367
|
+
for (const warning of assignment.warnings || []) {
|
|
368
|
+
console.log(chalk.yellow(`Warning: ${warning}`));
|
|
369
|
+
}
|
|
370
|
+
assignedState = assignment.state;
|
|
371
|
+
turnId = assignment.turn?.turn_id || assignment.turn?.id;
|
|
372
|
+
assignedRole = assignment.turn?.assigned_role || roleId;
|
|
323
373
|
}
|
|
324
|
-
|
|
325
|
-
|
|
374
|
+
|
|
375
|
+
// BUG-17 fix: write dispatch bundle AFTER state assignment succeeds.
|
|
376
|
+
// The bundle must exist on disk before we report success, otherwise the
|
|
377
|
+
// operator sees a "ghost turn" in state with no dispatch directory.
|
|
378
|
+
if (turnId) {
|
|
379
|
+
const bundleResult = writeDispatchBundle(root, assignedState, config, { turnId });
|
|
380
|
+
if (!bundleResult.ok) {
|
|
381
|
+
console.log(chalk.red(`Turn assigned but dispatch bundle write failed: ${bundleResult.error}`));
|
|
382
|
+
console.log(chalk.dim('The turn is assigned in state but has no dispatch context.'));
|
|
383
|
+
console.log(chalk.dim('Run `agentxchain reissue-turn` to reissue with a fresh bundle.'));
|
|
384
|
+
process.exit(1);
|
|
385
|
+
}
|
|
386
|
+
for (const bw of bundleResult.warnings || []) {
|
|
387
|
+
console.log(chalk.yellow(`Dispatch bundle warning: ${bw}`));
|
|
388
|
+
}
|
|
326
389
|
}
|
|
327
390
|
|
|
328
391
|
// assignGovernedTurn already writes a checkpoint at turn_assigned
|
|
329
392
|
|
|
330
393
|
console.log(chalk.green(`✓ Restarted run ${state.run_id}`));
|
|
331
394
|
console.log(chalk.dim(` Phase: ${phase}`));
|
|
332
|
-
console.log(chalk.dim(` Turn: ${
|
|
333
|
-
console.log(chalk.dim(` Role: ${
|
|
395
|
+
console.log(chalk.dim(` Turn: ${turnId || 'assigned'}`));
|
|
396
|
+
console.log(chalk.dim(` Role: ${assignedRole || 'routing default'}`));
|
|
397
|
+
console.log(chalk.dim(` Dispatch: ${getDispatchTurnDir(turnId || 'unknown')}/`));
|
|
334
398
|
if (checkpoint) {
|
|
335
399
|
console.log(chalk.dim(` Last checkpoint: ${checkpoint.checkpoint_reason} at ${checkpoint.last_checkpoint_at}`));
|
|
336
400
|
}
|
package/src/commands/run.js
CHANGED
|
@@ -48,6 +48,7 @@ import { resolveChainOptions, executeChainedRun } from '../lib/run-chain.js';
|
|
|
48
48
|
import { resolveContinuousOptions, executeContinuousRun } from '../lib/continuous-run.js';
|
|
49
49
|
import { createDispatchProgressTracker } from '../lib/dispatch-progress.js';
|
|
50
50
|
import { emitRunEvent } from '../lib/run-events.js';
|
|
51
|
+
import { checkpointAcceptedTurn } from '../lib/turn-checkpoint.js';
|
|
51
52
|
|
|
52
53
|
export async function runCommand(opts) {
|
|
53
54
|
const context = loadProjectContext();
|
|
@@ -148,6 +149,7 @@ export async function executeGovernedRun(context, opts = {}) {
|
|
|
148
149
|
|
|
149
150
|
const maxTurns = opts.maxTurns || 50;
|
|
150
151
|
const autoApprove = !!opts.autoApprove;
|
|
152
|
+
const autoCheckpoint = opts.autoCheckpoint === true;
|
|
151
153
|
const verbose = !!opts.verbose;
|
|
152
154
|
const overrideResolution = opts.role
|
|
153
155
|
? resolveGovernedRole({ override: opts.role, state: null, config })
|
|
@@ -499,6 +501,17 @@ export async function executeGovernedRun(context, opts = {}) {
|
|
|
499
501
|
return approved;
|
|
500
502
|
},
|
|
501
503
|
|
|
504
|
+
async afterAccept({ turn }) {
|
|
505
|
+
if (!autoCheckpoint) {
|
|
506
|
+
return { ok: true };
|
|
507
|
+
}
|
|
508
|
+
const checkpoint = checkpointAcceptedTurn(root, { turnId: turn.turn_id });
|
|
509
|
+
if (!checkpoint.ok) {
|
|
510
|
+
return { ok: false, error: checkpoint.error || `checkpoint failed for ${turn.turn_id}` };
|
|
511
|
+
}
|
|
512
|
+
return { ok: true };
|
|
513
|
+
},
|
|
514
|
+
|
|
502
515
|
onEvent(event) {
|
|
503
516
|
switch (event.type) {
|
|
504
517
|
case 'turn_assigned':
|
package/src/commands/status.js
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
deriveRecoveryDescriptor,
|
|
9
9
|
deriveRuntimeBlockedGuidance,
|
|
10
10
|
} from '../lib/blocked-state.js';
|
|
11
|
-
import { getActiveTurn, getActiveTurnCount, getActiveTurns, detectActiveTurnBindingDrift } from '../lib/governed-state.js';
|
|
11
|
+
import { getActiveTurn, getActiveTurnCount, getActiveTurns, detectActiveTurnBindingDrift, detectStateBundleDesync } from '../lib/governed-state.js';
|
|
12
12
|
import { getContinuityStatus } from '../lib/continuity-status.js';
|
|
13
13
|
import { getConnectorHealth } from '../lib/connector-health.js';
|
|
14
14
|
import { readRepoDecisions, summarizeRepoDecisions } from '../lib/repo-decisions.js';
|
|
@@ -182,6 +182,7 @@ function renderGovernedStatus(context, opts) {
|
|
|
182
182
|
workflow_kit_artifacts: workflowKitArtifacts,
|
|
183
183
|
dashboard_session: dashboardSessionObj,
|
|
184
184
|
binding_drift: detectActiveTurnBindingDrift(state, config),
|
|
185
|
+
bundle_integrity: detectStateBundleDesync(root, state),
|
|
185
186
|
}, null, 2));
|
|
186
187
|
return;
|
|
187
188
|
}
|
|
@@ -284,6 +285,17 @@ function renderGovernedStatus(context, opts) {
|
|
|
284
285
|
renderConnectorHealthStatus(connectorHealth);
|
|
285
286
|
renderRecentEventSummary(recentEventSummary);
|
|
286
287
|
|
|
288
|
+
// BUG-18: State/bundle integrity check
|
|
289
|
+
const desync = detectStateBundleDesync(root, state);
|
|
290
|
+
if (!desync.ok) {
|
|
291
|
+
console.log(chalk.red.bold(' ⚠ Ghost turn(s) detected — dispatch bundle missing'));
|
|
292
|
+
for (const entry of desync.desynced) {
|
|
293
|
+
console.log(chalk.red(` ${entry.turn_id} (${entry.role}): ${entry.expected_path} not found`));
|
|
294
|
+
}
|
|
295
|
+
console.log(chalk.dim(' Run `agentxchain reissue-turn` to recover, or `agentxchain doctor` for diagnostics.'));
|
|
296
|
+
console.log('');
|
|
297
|
+
}
|
|
298
|
+
|
|
287
299
|
const activeTurnCount = getActiveTurnCount(state);
|
|
288
300
|
const singleActiveTurn = getActiveTurn(state);
|
|
289
301
|
const approvalPending = Boolean(state?.pending_phase_transition || state?.pending_run_completion);
|
|
@@ -239,6 +239,7 @@ export function resolveContinuousOptions(opts, config) {
|
|
|
239
239
|
triageApproval: opts.triageApproval ?? configCont.triage_approval ?? 'auto',
|
|
240
240
|
cooldownSeconds: opts.cooldownSeconds ?? configCont.cooldown_seconds ?? 5,
|
|
241
241
|
perSessionMaxUsd: opts.sessionBudget ?? configCont.per_session_max_usd ?? null,
|
|
242
|
+
autoCheckpoint: opts.autoCheckpoint ?? configCont.auto_checkpoint ?? true,
|
|
242
243
|
};
|
|
243
244
|
}
|
|
244
245
|
|
|
@@ -307,7 +308,12 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
307
308
|
|
|
308
309
|
let execution;
|
|
309
310
|
try {
|
|
310
|
-
execution = await executeGovernedRun(context, {
|
|
311
|
+
execution = await executeGovernedRun(context, {
|
|
312
|
+
autoApprove: true,
|
|
313
|
+
autoCheckpoint: contOpts.autoCheckpoint,
|
|
314
|
+
report: true,
|
|
315
|
+
log,
|
|
316
|
+
});
|
|
311
317
|
} catch (err) {
|
|
312
318
|
session.status = 'failed';
|
|
313
319
|
writeContinuousSession(root, session);
|
|
@@ -413,6 +419,7 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
413
419
|
try {
|
|
414
420
|
execution = await executeGovernedRun(context, {
|
|
415
421
|
autoApprove: true,
|
|
422
|
+
autoCheckpoint: contOpts.autoCheckpoint,
|
|
416
423
|
report: true,
|
|
417
424
|
log,
|
|
418
425
|
});
|
|
@@ -213,6 +213,31 @@ export function selectNextAssignment(workspacePath, state, config) {
|
|
|
213
213
|
return firstFailure || { ok: false, reason: 'no_assignable_workstream', detail: 'No workstream is assignable in the current phase' };
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
export function selectAssignmentForWorkstream(workspacePath, state, config, workstreamId) {
|
|
217
|
+
const workstream = config.workstreams?.[workstreamId];
|
|
218
|
+
if (!workstream) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
reason: 'workstream_missing',
|
|
222
|
+
detail: `Unknown workstream "${workstreamId}"`,
|
|
223
|
+
workstream_id: workstreamId,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (workstream.phase !== state.phase) {
|
|
228
|
+
return {
|
|
229
|
+
ok: false,
|
|
230
|
+
reason: 'phase_mismatch',
|
|
231
|
+
detail: `Workstream "${workstreamId}" is in phase "${workstream.phase}", but coordinator is currently in phase "${state.phase}"`,
|
|
232
|
+
workstream_id: workstreamId,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const history = readCoordinatorHistory(workspacePath);
|
|
237
|
+
const barriers = readBarriers(workspacePath);
|
|
238
|
+
return evaluateWorkstream(workspacePath, state, config, workstreamId, history, barriers);
|
|
239
|
+
}
|
|
240
|
+
|
|
216
241
|
export function dispatchCoordinatorTurn(workspacePath, state, config, assignment) {
|
|
217
242
|
if (!assignment?.ok) {
|
|
218
243
|
return { ok: false, error: 'Assignment is required before dispatch' };
|
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
summarizeVerificationReplay,
|
|
63
63
|
} from './verification-replay.js';
|
|
64
64
|
import { executeGateActions } from './gate-actions.js';
|
|
65
|
+
import { detectPendingCheckpoint } from './turn-checkpoint.js';
|
|
65
66
|
|
|
66
67
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
67
68
|
|
|
@@ -405,6 +406,32 @@ export function getActiveTurn(state) {
|
|
|
405
406
|
return turns.length === 1 ? turns[0] : null;
|
|
406
407
|
}
|
|
407
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
|
+
|
|
408
435
|
function resolveRecoveryTurnId(state, preferredTurnId = null) {
|
|
409
436
|
const activeTurns = getActiveTurns(state);
|
|
410
437
|
if (preferredTurnId && activeTurns[preferredTurnId]) {
|
|
@@ -2163,6 +2190,10 @@ export function assignGovernedTurn(root, config, roleId, options = {}) {
|
|
|
2163
2190
|
const writeAuthority = role.write_authority || 'review_only';
|
|
2164
2191
|
const cleanCheck = checkCleanBaseline(root, writeAuthority);
|
|
2165
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
|
+
}
|
|
2166
2197
|
return { ok: false, error: cleanCheck.reason };
|
|
2167
2198
|
}
|
|
2168
2199
|
|
|
@@ -2754,7 +2785,25 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2754
2785
|
}
|
|
2755
2786
|
|
|
2756
2787
|
const turnStagingPath = getTurnStagingResultPath(currentTurn.turn_id);
|
|
2757
|
-
|
|
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
|
+
}
|
|
2758
2807
|
const stagedTurn = loadHookStagedTurn(root, resolvedStagingPath);
|
|
2759
2808
|
const validationState = attachLegacyCurrentTurnAlias({
|
|
2760
2809
|
...state,
|
|
@@ -3838,6 +3887,106 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3838
3887
|
}
|
|
3839
3888
|
}
|
|
3840
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
|
+
|
|
3841
3990
|
// ── Transaction journal: prepare before committing writes ──────────────
|
|
3842
3991
|
const transactionId = generateId('txn');
|
|
3843
3992
|
const journal = {
|
package/src/lib/intake.js
CHANGED
|
@@ -1155,6 +1155,18 @@ export function resolveIntent(root, intentId) {
|
|
|
1155
1155
|
|
|
1156
1156
|
const { intent, intentPath, dirs } = loadedIntent;
|
|
1157
1157
|
|
|
1158
|
+
if (intent.status === 'completed') {
|
|
1159
|
+
return {
|
|
1160
|
+
ok: true,
|
|
1161
|
+
intent,
|
|
1162
|
+
previous_status: 'completed',
|
|
1163
|
+
new_status: 'completed',
|
|
1164
|
+
run_outcome: 'completed',
|
|
1165
|
+
no_change: true,
|
|
1166
|
+
exitCode: 0,
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1158
1170
|
if (intent.status !== 'executing' && intent.status !== 'blocked') {
|
|
1159
1171
|
return {
|
|
1160
1172
|
ok: false,
|