agentxchain 2.134.1 → 2.135.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/package.json +1 -1
- package/src/commands/doctor.js +3 -1
- package/src/commands/restart.js +1 -1
- package/src/commands/resume.js +3 -1
- package/src/commands/status.js +1 -1
- package/src/commands/step.js +3 -1
- package/src/lib/continuous-run.js +2 -2
- package/src/lib/dispatch-bundle.js +20 -19
- package/src/lib/governed-state.js +100 -0
- package/src/lib/intake.js +121 -5
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -61,6 +61,7 @@ export async function doctorCommand(opts = {}) {
|
|
|
61
61
|
function governedDoctor(root, rawConfig, opts) {
|
|
62
62
|
const checks = [];
|
|
63
63
|
const cliVersionHealth = getCliVersionHealth();
|
|
64
|
+
let stateRunId = null;
|
|
64
65
|
|
|
65
66
|
checks.push(buildCliVersionCheck(cliVersionHealth));
|
|
66
67
|
|
|
@@ -109,6 +110,7 @@ function governedDoctor(root, rawConfig, opts) {
|
|
|
109
110
|
if (existsSync(statePath)) {
|
|
110
111
|
try {
|
|
111
112
|
const stateData = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
113
|
+
stateRunId = stateData.run_id || null;
|
|
112
114
|
if (stateData.schema_version) {
|
|
113
115
|
checks.push({ id: 'state_health', name: 'State health', level: 'pass', detail: `schema_version: ${stateData.schema_version}, status: ${stateData.status || 'unknown'}` });
|
|
114
116
|
} else {
|
|
@@ -354,7 +356,7 @@ function governedDoctor(root, rawConfig, opts) {
|
|
|
354
356
|
|
|
355
357
|
// 11. Pending intake intents (BUG-15 — informational)
|
|
356
358
|
{
|
|
357
|
-
const pendingIntents = findPendingApprovedIntents(root);
|
|
359
|
+
const pendingIntents = findPendingApprovedIntents(root, { run_id: stateRunId });
|
|
358
360
|
if (pendingIntents.length > 0) {
|
|
359
361
|
const summary = pendingIntents.map(pi => `[${pi.priority}] ${pi.intent_id}`).join(', ');
|
|
360
362
|
checks.push({
|
package/src/commands/restart.js
CHANGED
|
@@ -342,7 +342,7 @@ export async function restartCommand(opts) {
|
|
|
342
342
|
if (activeTurnCount === 0) {
|
|
343
343
|
// BUG-21 fix: consume approved intents (same as resume path) so intent_id
|
|
344
344
|
// propagates into turn metadata and all lifecycle events.
|
|
345
|
-
const consumed = consumeNextApprovedIntent(root, { role: roleId });
|
|
345
|
+
const consumed = consumeNextApprovedIntent(root, { role: roleId, run_id: state?.run_id || null });
|
|
346
346
|
let assignedState;
|
|
347
347
|
let turnId;
|
|
348
348
|
let assignedRole = roleId;
|
package/src/commands/resume.js
CHANGED
|
@@ -267,7 +267,9 @@ export async function resumeCommand(opts) {
|
|
|
267
267
|
}
|
|
268
268
|
|
|
269
269
|
const shouldBindIntent = opts.intent !== false;
|
|
270
|
-
const consumed = shouldBindIntent
|
|
270
|
+
const consumed = shouldBindIntent
|
|
271
|
+
? consumeNextApprovedIntent(root, { role: roleId, run_id: state?.run_id || null })
|
|
272
|
+
: { ok: false };
|
|
271
273
|
if (consumed.ok) {
|
|
272
274
|
state = loadProjectState(root, config);
|
|
273
275
|
if (!state) {
|
package/src/commands/status.js
CHANGED
|
@@ -147,7 +147,7 @@ function renderGovernedStatus(context, opts) {
|
|
|
147
147
|
const workflowKitArtifacts = deriveWorkflowKitArtifacts(root, config, state);
|
|
148
148
|
const humanEscalation = findCurrentHumanEscalation(root, state);
|
|
149
149
|
const preemptionMarker = readPreemptionMarker(root);
|
|
150
|
-
const pendingIntents = findPendingApprovedIntents(root);
|
|
150
|
+
const pendingIntents = findPendingApprovedIntents(root, { run_id: stateRunId || null });
|
|
151
151
|
const continuousSession = readContinuousSession(root);
|
|
152
152
|
const gateActionAttempt = state?.pending_phase_transition
|
|
153
153
|
? summarizeLatestGateActionAttempt(root, 'phase_transition', state.pending_phase_transition.gate)
|
package/src/commands/step.js
CHANGED
|
@@ -316,7 +316,9 @@ export async function stepCommand(opts) {
|
|
|
316
316
|
}
|
|
317
317
|
|
|
318
318
|
const shouldBindIntent = opts.intent !== false;
|
|
319
|
-
const consumed = shouldBindIntent
|
|
319
|
+
const consumed = shouldBindIntent
|
|
320
|
+
? consumeNextApprovedIntent(root, { role: roleId, run_id: state?.run_id || null })
|
|
321
|
+
: { ok: false };
|
|
320
322
|
if (consumed.ok) {
|
|
321
323
|
state = loadProjectState(root, config);
|
|
322
324
|
if (!state) {
|
|
@@ -350,8 +350,8 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
350
350
|
return { ok: false, status: 'failed', action: 'vision_missing', stop_reason: `VISION.md not found at ${absVisionPath}` };
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
-
// Step 1: Check intake queue for pending work
|
|
354
|
-
const queued = findNextDispatchableIntent(root);
|
|
353
|
+
// Step 1: Check intake queue for pending work (BUG-34: scope to current run)
|
|
354
|
+
const queued = findNextDispatchableIntent(root, { run_id: session.current_run_id });
|
|
355
355
|
let targetIntentId = null;
|
|
356
356
|
let visionObjective = null;
|
|
357
357
|
|
|
@@ -332,25 +332,8 @@ function renderPrompt(role, roleId, turn, state, config, root) {
|
|
|
332
332
|
lines.push('');
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
lines.push('');
|
|
338
|
-
if (turn.intake_context.charter) {
|
|
339
|
-
lines.push(turn.intake_context.charter);
|
|
340
|
-
lines.push('');
|
|
341
|
-
}
|
|
342
|
-
if (Array.isArray(turn.intake_context.acceptance_contract) && turn.intake_context.acceptance_contract.length > 0) {
|
|
343
|
-
lines.push('Acceptance contract:');
|
|
344
|
-
turn.intake_context.acceptance_contract.forEach((requirement, index) => {
|
|
345
|
-
lines.push(`${index + 1}. ${requirement}`);
|
|
346
|
-
});
|
|
347
|
-
lines.push('');
|
|
348
|
-
}
|
|
349
|
-
lines.push('You must explicitly address every acceptance item in your turn summary, artifacts, or verification evidence. Do not treat this as background context.');
|
|
350
|
-
lines.push('');
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Retry context
|
|
335
|
+
// BUG-35: retry context must appear BEFORE the injected intent so the agent
|
|
336
|
+
// sees the blocker (gate failure) first and the repair guidance (intent) second.
|
|
354
337
|
if (turn.attempt > 1 && turn.last_rejection) {
|
|
355
338
|
lines.push('## Previous Attempt Failed');
|
|
356
339
|
lines.push('');
|
|
@@ -369,6 +352,24 @@ function renderPrompt(role, roleId, turn, state, config, root) {
|
|
|
369
352
|
lines.push('');
|
|
370
353
|
}
|
|
371
354
|
|
|
355
|
+
if (turn.intake_context) {
|
|
356
|
+
lines.push('### Active Injected Intent — respond to this as your primary charter');
|
|
357
|
+
lines.push('');
|
|
358
|
+
if (turn.intake_context.charter) {
|
|
359
|
+
lines.push(turn.intake_context.charter);
|
|
360
|
+
lines.push('');
|
|
361
|
+
}
|
|
362
|
+
if (Array.isArray(turn.intake_context.acceptance_contract) && turn.intake_context.acceptance_contract.length > 0) {
|
|
363
|
+
lines.push('Acceptance contract:');
|
|
364
|
+
turn.intake_context.acceptance_contract.forEach((requirement, index) => {
|
|
365
|
+
lines.push(`${index + 1}. ${requirement}`);
|
|
366
|
+
});
|
|
367
|
+
lines.push('');
|
|
368
|
+
}
|
|
369
|
+
lines.push('You must explicitly address every acceptance item in your turn summary, artifacts, or verification evidence. Do not treat this as background context.');
|
|
370
|
+
lines.push('');
|
|
371
|
+
}
|
|
372
|
+
|
|
372
373
|
if (turn.conflict_context) {
|
|
373
374
|
lines.push('## File Conflict - Retry Required');
|
|
374
375
|
lines.push('');
|
|
@@ -2154,6 +2154,42 @@ export function initializeGovernedRun(root, config, options = {}) {
|
|
|
2154
2154
|
};
|
|
2155
2155
|
|
|
2156
2156
|
writeState(root, updatedState);
|
|
2157
|
+
|
|
2158
|
+
// BUG-34: retroactive migration — archive stale intents from prior runs.
|
|
2159
|
+
// Intents with an approved_run_id from a DIFFERENT run are archived.
|
|
2160
|
+
// Intents with no approved_run_id are adopted into the current run
|
|
2161
|
+
// (they were created while the project was idle or pre-run).
|
|
2162
|
+
try {
|
|
2163
|
+
const intentsDir = join(root, '.agentxchain', 'intake', 'intents');
|
|
2164
|
+
if (existsSync(intentsDir)) {
|
|
2165
|
+
const DISPATCHABLE = new Set(['planned', 'approved']);
|
|
2166
|
+
const intNow = new Date().toISOString();
|
|
2167
|
+
for (const f of readdirSync(intentsDir).filter(x => x.endsWith('.json') && !x.startsWith('.tmp-'))) {
|
|
2168
|
+
const ip = join(intentsDir, f);
|
|
2169
|
+
try {
|
|
2170
|
+
const intent = JSON.parse(readFileSync(ip, 'utf8'));
|
|
2171
|
+
if (!intent || !DISPATCHABLE.has(intent.status)) continue;
|
|
2172
|
+
if (intent.cross_run_durable === true) continue;
|
|
2173
|
+
if (intent.approved_run_id === runId) continue;
|
|
2174
|
+
|
|
2175
|
+
if (intent.approved_run_id && intent.approved_run_id !== runId) {
|
|
2176
|
+
// Intent from a different run — archive it
|
|
2177
|
+
intent.status = 'suppressed';
|
|
2178
|
+
intent.updated_at = intNow;
|
|
2179
|
+
intent.archived_reason = `stale: approved under run ${intent.approved_run_id}, archived on run ${runId} initialization`;
|
|
2180
|
+
if (!intent.history) intent.history = [];
|
|
2181
|
+
intent.history.push({ from: 'approved', to: 'suppressed', at: intNow, reason: intent.archived_reason });
|
|
2182
|
+
} else if (!intent.approved_run_id) {
|
|
2183
|
+
// Legacy intent with no run binding — adopt into current run
|
|
2184
|
+
intent.approved_run_id = runId;
|
|
2185
|
+
intent.updated_at = intNow;
|
|
2186
|
+
}
|
|
2187
|
+
safeWriteJson(ip, intent);
|
|
2188
|
+
} catch { /* non-fatal per-intent */ }
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
} catch { /* non-fatal — intent migration is best-effort */ }
|
|
2192
|
+
|
|
2157
2193
|
emitRunEvent(root, 'run_started', {
|
|
2158
2194
|
run_id: runId,
|
|
2159
2195
|
phase: updatedState.phase,
|
|
@@ -3107,6 +3143,70 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3107
3143
|
}
|
|
3108
3144
|
}
|
|
3109
3145
|
|
|
3146
|
+
// ── Gate semantic coverage validation (BUG-36) ────────────────────────────
|
|
3147
|
+
// When a turn proposes a phase transition, pre-evaluate the gate. If the gate
|
|
3148
|
+
// would fail AND the failing files are not in files_changed, reject the turn
|
|
3149
|
+
// early — the agent didn't do the work required for the transition.
|
|
3150
|
+
if (turnResult.phase_transition_request) {
|
|
3151
|
+
const preGateResult = evaluatePhaseExit({
|
|
3152
|
+
state,
|
|
3153
|
+
config,
|
|
3154
|
+
acceptedTurn: turnResult,
|
|
3155
|
+
root,
|
|
3156
|
+
});
|
|
3157
|
+
|
|
3158
|
+
if (preGateResult.action === 'gate_failed') {
|
|
3159
|
+
// Gate is failing. Check if any of the failing reasons reference files
|
|
3160
|
+
// that this turn didn't modify.
|
|
3161
|
+
const declaredFiles = new Set((turnResult.files_changed || []).map(f => f.replace(/^\.\//, '')));
|
|
3162
|
+
const exitGateId = preGateResult.gate_id || 'unknown_gate';
|
|
3163
|
+
|
|
3164
|
+
// Extract file paths from gate failure reasons and missing_files
|
|
3165
|
+
const failingFiles = [
|
|
3166
|
+
...(preGateResult.missing_files || []),
|
|
3167
|
+
];
|
|
3168
|
+
|
|
3169
|
+
// Also extract file paths from failure reasons (e.g., ".planning/IMPLEMENTATION_NOTES.md: ...")
|
|
3170
|
+
for (const reason of (preGateResult.reasons || [])) {
|
|
3171
|
+
const fileMatch = reason.match(/(?:Required file missing|file): ([^\s,]+)/);
|
|
3172
|
+
if (fileMatch) failingFiles.push(fileMatch[1]);
|
|
3173
|
+
// Also catch paths at the start of semantic failure messages
|
|
3174
|
+
const semanticMatch = reason.match(/^([^\s:]+\.md):/);
|
|
3175
|
+
if (semanticMatch) failingFiles.push(semanticMatch[1]);
|
|
3176
|
+
}
|
|
3177
|
+
|
|
3178
|
+
const uniqueFailingFiles = [...new Set(failingFiles.map(f => f.replace(/^\.\//, '')))];
|
|
3179
|
+
const uncoveredFiles = uniqueFailingFiles.filter(f => !declaredFiles.has(f));
|
|
3180
|
+
|
|
3181
|
+
const gateSemanticMode = config.gate_semantic_coverage_mode || 'strict';
|
|
3182
|
+
if (uncoveredFiles.length > 0 && gateSemanticMode === 'strict') {
|
|
3183
|
+
const coverageError = `Gate "${exitGateId}" is failing on ${uncoveredFiles.join(', ')}. Your turn did not modify ${uncoveredFiles.length === 1 ? 'that file' : 'those files'}. Either edit the file(s) to satisfy the gate, or remove the phase transition request.`;
|
|
3184
|
+
transitionToFailedAcceptance(root, state, currentTurn, coverageError, {
|
|
3185
|
+
error_code: 'gate_semantic_coverage',
|
|
3186
|
+
stage: 'gate_semantic_coverage',
|
|
3187
|
+
extra: {
|
|
3188
|
+
gate_id: exitGateId,
|
|
3189
|
+
uncovered_files: uncoveredFiles,
|
|
3190
|
+
declared_files: [...declaredFiles],
|
|
3191
|
+
gate_reasons: preGateResult.reasons,
|
|
3192
|
+
},
|
|
3193
|
+
});
|
|
3194
|
+
return {
|
|
3195
|
+
ok: false,
|
|
3196
|
+
error: coverageError,
|
|
3197
|
+
validation: {
|
|
3198
|
+
...validation,
|
|
3199
|
+
ok: false,
|
|
3200
|
+
stage: 'gate_semantic_coverage',
|
|
3201
|
+
error_class: 'gate_coverage_error',
|
|
3202
|
+
errors: uncoveredFiles.map(f => `Gate "${exitGateId}" is failing on "${f}". Your turn did not modify that file.`),
|
|
3203
|
+
warnings: [],
|
|
3204
|
+
},
|
|
3205
|
+
};
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3110
3210
|
const observedArtifact = buildObservedArtifact(observation, baseline);
|
|
3111
3211
|
const normalizedVerification = normalizeVerification(turnResult.verification, runtimeType);
|
|
3112
3212
|
const artifactType = turnResult.artifact?.type || 'review';
|
package/src/lib/intake.js
CHANGED
|
@@ -504,15 +504,35 @@ export function intakeStatus(root, intentId) {
|
|
|
504
504
|
return { ok: true, summary, exitCode: 0 };
|
|
505
505
|
}
|
|
506
506
|
|
|
507
|
-
export function findNextDispatchableIntent(root) {
|
|
507
|
+
export function findNextDispatchableIntent(root, options = {}) {
|
|
508
508
|
const dirs = intakeDirs(root);
|
|
509
509
|
if (!existsSync(dirs.intents)) {
|
|
510
510
|
return { ok: false, error: 'no intents directory' };
|
|
511
511
|
}
|
|
512
512
|
|
|
513
|
-
const
|
|
513
|
+
const scopeRunId = options.run_id || null;
|
|
514
|
+
|
|
515
|
+
let intents = readJsonDir(dirs.intents)
|
|
514
516
|
.filter((intent) => intent && DISPATCHABLE_STATUSES.has(intent.status));
|
|
515
517
|
|
|
518
|
+
// BUG-34: when run_id scoping is active, filter out intents that belong to
|
|
519
|
+
// a different run. An intent belongs to the current run if:
|
|
520
|
+
// (a) it has approved_run_id matching the current run, OR
|
|
521
|
+
// (b) it has no approved_run_id AND is marked cross_run_durable, OR
|
|
522
|
+
// (c) it was injected in the current run (approved_run_id matches)
|
|
523
|
+
// Legacy intents (no approved_run_id, no cross_run_durable) are excluded
|
|
524
|
+
// because they are stale leftovers from prior runs.
|
|
525
|
+
if (scopeRunId) {
|
|
526
|
+
intents = intents.filter((intent) => {
|
|
527
|
+
if (intent.approved_run_id === scopeRunId) return true;
|
|
528
|
+
if (intent.cross_run_durable === true) return true;
|
|
529
|
+
// Legacy intent with no run binding — stale, skip it
|
|
530
|
+
if (!intent.approved_run_id) return false;
|
|
531
|
+
// Intent bound to a different run — stale, skip it
|
|
532
|
+
return false;
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
516
536
|
if (intents.length === 0) {
|
|
517
537
|
return { ok: false, error: 'no dispatchable intents' };
|
|
518
538
|
}
|
|
@@ -550,12 +570,23 @@ export function findNextDispatchableIntent(root) {
|
|
|
550
570
|
* Return all approved-but-unconsumed intents sorted by priority (BUG-15).
|
|
551
571
|
* Used by `status` to surface the pending intent queue.
|
|
552
572
|
*/
|
|
553
|
-
export function findPendingApprovedIntents(root) {
|
|
573
|
+
export function findPendingApprovedIntents(root, options = {}) {
|
|
554
574
|
const dirs = intakeDirs(root);
|
|
555
575
|
if (!existsSync(dirs.intents)) return [];
|
|
556
576
|
|
|
577
|
+
const scopeRunId = options.run_id || null;
|
|
578
|
+
|
|
557
579
|
return readJsonDir(dirs.intents)
|
|
558
|
-
.filter((intent) =>
|
|
580
|
+
.filter((intent) => {
|
|
581
|
+
if (!intent || intent.status !== 'approved') return false;
|
|
582
|
+
// BUG-34: run_id scoping — same logic as findNextDispatchableIntent
|
|
583
|
+
if (scopeRunId) {
|
|
584
|
+
if (intent.approved_run_id === scopeRunId) return true;
|
|
585
|
+
if (intent.cross_run_durable === true) return true;
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
return true;
|
|
589
|
+
})
|
|
559
590
|
.sort((a, b) => {
|
|
560
591
|
const aPriority = PRIORITY_RANK[a.priority] ?? Number.MAX_SAFE_INTEGER;
|
|
561
592
|
const bPriority = PRIORITY_RANK[b.priority] ?? Number.MAX_SAFE_INTEGER;
|
|
@@ -574,6 +605,59 @@ export function findPendingApprovedIntents(root) {
|
|
|
574
605
|
}));
|
|
575
606
|
}
|
|
576
607
|
|
|
608
|
+
/**
|
|
609
|
+
* BUG-34: Archive stale intents from prior runs.
|
|
610
|
+
* Called during run initialization to prevent cross-run intent leakage.
|
|
611
|
+
* Transitions approved/planned intents that don't belong to the new run into
|
|
612
|
+
* 'suppressed' status with an archival reason.
|
|
613
|
+
*
|
|
614
|
+
* @param {string} root
|
|
615
|
+
* @param {string} newRunId - the run_id of the newly initialized run
|
|
616
|
+
* @returns {{ archived: number }}
|
|
617
|
+
*/
|
|
618
|
+
export function archiveStaleIntents(root, newRunId) {
|
|
619
|
+
const dirs = intakeDirs(root);
|
|
620
|
+
if (!existsSync(dirs.intents)) return { archived: 0, adopted: 0 };
|
|
621
|
+
|
|
622
|
+
const now = nowISO();
|
|
623
|
+
let archived = 0;
|
|
624
|
+
let adopted = 0;
|
|
625
|
+
|
|
626
|
+
const files = readdirSync(dirs.intents).filter(f => f.endsWith('.json') && !f.startsWith('.tmp-'));
|
|
627
|
+
for (const file of files) {
|
|
628
|
+
const intentPath = join(dirs.intents, file);
|
|
629
|
+
let intent;
|
|
630
|
+
try {
|
|
631
|
+
intent = JSON.parse(readFileSync(intentPath, 'utf8'));
|
|
632
|
+
} catch {
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (!intent || !DISPATCHABLE_STATUSES.has(intent.status)) continue;
|
|
637
|
+
if (intent.cross_run_durable === true) continue;
|
|
638
|
+
if (intent.approved_run_id === newRunId) continue;
|
|
639
|
+
|
|
640
|
+
if (intent.approved_run_id && intent.approved_run_id !== newRunId) {
|
|
641
|
+
// Intent from a different run — archive it
|
|
642
|
+
intent.status = 'suppressed';
|
|
643
|
+
intent.updated_at = now;
|
|
644
|
+
intent.archived_reason = `stale: approved under run ${intent.approved_run_id}, archived on run ${newRunId} initialization`;
|
|
645
|
+
if (!intent.history) intent.history = [];
|
|
646
|
+
intent.history.push({ from: 'approved', to: 'suppressed', at: now, reason: intent.archived_reason });
|
|
647
|
+
safeWriteJson(intentPath, intent);
|
|
648
|
+
archived++;
|
|
649
|
+
} else if (!intent.approved_run_id) {
|
|
650
|
+
// Legacy intent with no run binding — adopt into current run
|
|
651
|
+
intent.approved_run_id = newRunId;
|
|
652
|
+
intent.updated_at = now;
|
|
653
|
+
safeWriteJson(intentPath, intent);
|
|
654
|
+
adopted++;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return { archived, adopted };
|
|
659
|
+
}
|
|
660
|
+
|
|
577
661
|
/**
|
|
578
662
|
* Unified intent consumption entry point (BUG-16).
|
|
579
663
|
* Both manual (resume/step --resume) and continuous/scheduler paths should call
|
|
@@ -584,7 +668,22 @@ export function findPendingApprovedIntents(root) {
|
|
|
584
668
|
* @returns {{ ok: boolean, intentId?: string, intent?: object, error?: string }}
|
|
585
669
|
*/
|
|
586
670
|
export function consumeNextApprovedIntent(root, options = {}) {
|
|
587
|
-
|
|
671
|
+
let runId = options.run_id || null;
|
|
672
|
+
if (!runId) {
|
|
673
|
+
try {
|
|
674
|
+
const context = loadProjectContext(root);
|
|
675
|
+
const state = context ? loadProjectState(root, context.config) : null;
|
|
676
|
+
runId = state?.run_id || null;
|
|
677
|
+
} catch {
|
|
678
|
+
runId = null;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (runId && options.auto_archive_stale !== false) {
|
|
683
|
+
archiveStaleIntents(root, runId);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const queued = findNextDispatchableIntent(root, { run_id: runId });
|
|
588
687
|
if (!queued.ok) {
|
|
589
688
|
return { ok: false, error: queued.error || 'no dispatchable intents' };
|
|
590
689
|
}
|
|
@@ -717,6 +816,23 @@ export function approveIntent(root, intentId, options = {}) {
|
|
|
717
816
|
const reason = options.reason || (previousStatus === 'blocked' ? 're-approved after block resolution' : 'approved for planning');
|
|
718
817
|
const now = nowISO();
|
|
719
818
|
|
|
819
|
+
// BUG-34: stamp the current run_id on approval so the intent is scoped to
|
|
820
|
+
// the run that approved it. Intents without approved_run_id are treated as
|
|
821
|
+
// legacy/unbound and filtered out by run-scoped queries.
|
|
822
|
+
if (!intent.approved_run_id) {
|
|
823
|
+
const statePath = join(root, '.agentxchain', 'state.json');
|
|
824
|
+
if (existsSync(statePath)) {
|
|
825
|
+
try {
|
|
826
|
+
const state = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
827
|
+
if (state.run_id) {
|
|
828
|
+
intent.approved_run_id = state.run_id;
|
|
829
|
+
}
|
|
830
|
+
} catch {
|
|
831
|
+
// non-fatal — stamp is best-effort during approval
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
720
836
|
intent.status = 'approved';
|
|
721
837
|
intent.approved_by = approver;
|
|
722
838
|
intent.updated_at = now;
|