agentxchain 2.134.1 → 2.135.1
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 +2 -0
- package/package.json +1 -1
- package/src/commands/doctor.js +3 -1
- package/src/commands/mission.js +250 -103
- 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/context-section-parser.js +52 -0
- package/src/lib/continuous-run.js +2 -2
- package/src/lib/dispatch-bundle.js +20 -19
- package/src/lib/gate-evaluator.js +18 -1
- package/src/lib/governed-state.js +235 -1
- package/src/lib/intake.js +132 -5
- package/src/lib/normalized-config.js +5 -0
package/bin/agentxchain.js
CHANGED
|
@@ -530,6 +530,8 @@ missionPlanCmd
|
|
|
530
530
|
.option('-m, --mission <mission_id>', 'Explicit mission ID')
|
|
531
531
|
.option('--max-waves <n>', 'Maximum number of dependency waves (default: 10)')
|
|
532
532
|
.option('--continue-on-failure', 'Skip failed workstreams and keep launching ready ones')
|
|
533
|
+
.option('--auto-retry', 'Coordinator-only: retry one retryable repo-local failure within the same autopilot session')
|
|
534
|
+
.option('--max-retries <n>', 'Coordinator-only: maximum auto-retries per workstream/repo pair in one autopilot session (default: 1)')
|
|
533
535
|
.option('--cooldown <seconds>', 'Pause between waves in seconds (default: 5)')
|
|
534
536
|
.option('--auto-approve', 'Auto-approve run gates during execution')
|
|
535
537
|
.option('-j, --json', 'Output as JSON')
|
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/mission.js
CHANGED
|
@@ -190,6 +190,121 @@ function buildCoordinatorProjectionWarning(message) {
|
|
|
190
190
|
};
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
function formatAutoRetryBudgetError(workstreamId, repoId, maxRetries) {
|
|
194
|
+
return `Auto-retry budget exhausted for ${workstreamId}/${repoId} (max ${maxRetries} per autopilot session).`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function executeCoordinatorRetryRun(root, mission, workstreamId, coordinatorConfig, opts = {}) {
|
|
198
|
+
const retry = retryCoordinatorWorkstream(
|
|
199
|
+
root,
|
|
200
|
+
mission,
|
|
201
|
+
opts.planId,
|
|
202
|
+
workstreamId,
|
|
203
|
+
coordinatorConfig,
|
|
204
|
+
{
|
|
205
|
+
reason: opts.reason || `mission plan retry ${workstreamId}`,
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
if (!retry.ok) {
|
|
209
|
+
return { ok: false, error: retry.error, plan: opts.plan || null };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const executor = opts._executeGovernedRun || executeGovernedRun;
|
|
213
|
+
const childLog = opts.json ? noop : (opts._log || console.log);
|
|
214
|
+
const repoContext = loadProjectContext(retry.retryResult.repo_path);
|
|
215
|
+
if (!repoContext) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
error: `Cannot load project context for retried repo at ${retry.retryResult.repo_path}.`,
|
|
219
|
+
plan: retry.plan,
|
|
220
|
+
retryResult: retry.retryResult,
|
|
221
|
+
launchRecord: retry.launchRecord,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const runOpts = {
|
|
226
|
+
autoApprove: !!opts.autoApprove,
|
|
227
|
+
log: childLog,
|
|
228
|
+
provenance: {
|
|
229
|
+
trigger: opts.trigger || 'manual',
|
|
230
|
+
created_by: 'operator',
|
|
231
|
+
trigger_reason: opts.triggerReasonPrefix
|
|
232
|
+
? `${opts.triggerReasonPrefix} repo:${retry.retryResult.repo_id}`
|
|
233
|
+
: `mission:${mission.mission_id} workstream:${workstreamId} coordinator-retry:${retry.retryResult.repo_id}`,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const retryWarnings = [];
|
|
238
|
+
let execution;
|
|
239
|
+
try {
|
|
240
|
+
execution = await executor(repoContext, runOpts);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
const syncedRetryFailure = synchronizeCoordinatorPlanState(root, mission, retry.plan);
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
error: `Coordinator retry execution failed: ${error.message}`,
|
|
246
|
+
plan: syncedRetryFailure.ok ? syncedRetryFailure.plan : retry.plan,
|
|
247
|
+
retryResult: retry.retryResult,
|
|
248
|
+
launchRecord: retry.launchRecord,
|
|
249
|
+
warnings: retryWarnings,
|
|
250
|
+
reconciliation_required: false,
|
|
251
|
+
exit_code: 1,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if ((execution?.exitCode ?? 0) === 0) {
|
|
256
|
+
const projection = projectAcceptedCoordinatorTurn(
|
|
257
|
+
mission.coordinator.workspace_path,
|
|
258
|
+
coordinatorConfig,
|
|
259
|
+
retry.retryResult.repo_id,
|
|
260
|
+
retry.retryResult.reissued_turn_id,
|
|
261
|
+
workstreamId,
|
|
262
|
+
loadCoordinatorState(mission.coordinator.workspace_path),
|
|
263
|
+
);
|
|
264
|
+
if (!projection.ok) {
|
|
265
|
+
const warning = buildCoordinatorProjectionWarning(projection.error);
|
|
266
|
+
retryWarnings.push(warning);
|
|
267
|
+
console.error(chalk.yellow(`Coordinator retry projection warning: ${warning.message}`));
|
|
268
|
+
emitRunEvent(mission.coordinator.workspace_path, 'coordinator_retry_projection_warning', {
|
|
269
|
+
run_id: mission.coordinator.super_run_id,
|
|
270
|
+
phase: coordinatorConfig?.workstreams?.[workstreamId]?.phase || null,
|
|
271
|
+
status: 'active',
|
|
272
|
+
payload: {
|
|
273
|
+
workstream_id: workstreamId,
|
|
274
|
+
repo_id: retry.retryResult.repo_id,
|
|
275
|
+
reissued_turn_id: retry.retryResult.reissued_turn_id,
|
|
276
|
+
warning_code: warning.code,
|
|
277
|
+
warning_message: warning.message,
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const syncedRetry = synchronizeCoordinatorPlanState(root, mission, retry.plan);
|
|
284
|
+
const retriedPlan = syncedRetry.ok ? syncedRetry.plan : retry.plan;
|
|
285
|
+
const retriedWorkstream = retriedPlan.workstreams.find((ws) => ws.workstream_id === workstreamId);
|
|
286
|
+
const retriedLaunchRecord = retriedPlan.launch_records.find(
|
|
287
|
+
(record) => record.workstream_id === workstreamId && record.dispatch_mode === 'coordinator',
|
|
288
|
+
) || retry.launchRecord;
|
|
289
|
+
const retryCount = retriedLaunchRecord?.repo_dispatches?.filter(
|
|
290
|
+
(dispatch) => dispatch.repo_id === retry.retryResult.repo_id && dispatch.is_retry,
|
|
291
|
+
).length || 0;
|
|
292
|
+
|
|
293
|
+
const success = (execution?.exitCode ?? 0) === 0;
|
|
294
|
+
return {
|
|
295
|
+
ok: success,
|
|
296
|
+
error: success ? null : `Coordinator retry execution ended with exit code ${execution?.exitCode ?? 1}.`,
|
|
297
|
+
plan: retriedPlan,
|
|
298
|
+
workstream: retriedWorkstream,
|
|
299
|
+
launchRecord: retriedLaunchRecord,
|
|
300
|
+
retryResult: retry.retryResult,
|
|
301
|
+
warnings: retryWarnings,
|
|
302
|
+
reconciliation_required: retryWarnings.length > 0,
|
|
303
|
+
exit_code: execution?.exitCode ?? 0,
|
|
304
|
+
retry_count: retryCount,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
193
308
|
export async function missionListCommand(opts) {
|
|
194
309
|
const root = findProjectRoot(opts.dir || process.cwd());
|
|
195
310
|
if (!root) {
|
|
@@ -608,100 +723,28 @@ export async function missionPlanLaunchCommand(planTarget, opts) {
|
|
|
608
723
|
}
|
|
609
724
|
|
|
610
725
|
if (opts.retry) {
|
|
611
|
-
const
|
|
726
|
+
const retryExecution = await executeCoordinatorRetryRun(
|
|
612
727
|
root,
|
|
613
728
|
mission,
|
|
614
|
-
plan.plan_id,
|
|
615
729
|
opts.workstream,
|
|
616
730
|
coordinatorConfigResult.config,
|
|
617
731
|
{
|
|
732
|
+
...opts,
|
|
733
|
+
planId: plan.plan_id,
|
|
734
|
+
plan,
|
|
618
735
|
reason: `mission plan retry ${opts.workstream}`,
|
|
619
|
-
},
|
|
620
|
-
);
|
|
621
|
-
if (!retry.ok) {
|
|
622
|
-
console.error(chalk.red(retry.error));
|
|
623
|
-
process.exit(1);
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
const executor = opts._executeGovernedRun || executeGovernedRun;
|
|
627
|
-
const childLog = opts.json ? noop : (opts._log || console.log);
|
|
628
|
-
const repoContext = loadProjectContext(retry.retryResult.repo_path);
|
|
629
|
-
if (!repoContext) {
|
|
630
|
-
console.error(chalk.red(`Cannot load project context for retried repo at ${retry.retryResult.repo_path}.`));
|
|
631
|
-
process.exit(1);
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
const runOpts = {
|
|
635
|
-
autoApprove: !!opts.autoApprove,
|
|
636
|
-
log: childLog,
|
|
637
|
-
provenance: {
|
|
638
736
|
trigger: 'manual',
|
|
639
|
-
|
|
640
|
-
trigger_reason: `mission:${mission.mission_id} workstream:${opts.workstream} coordinator-retry:${retry.retryResult.repo_id}`,
|
|
737
|
+
triggerReasonPrefix: `mission:${mission.mission_id} workstream:${opts.workstream} coordinator-retry`,
|
|
641
738
|
},
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
const retryWarnings = [];
|
|
646
|
-
try {
|
|
647
|
-
execution = await executor(repoContext, runOpts);
|
|
648
|
-
} catch (error) {
|
|
649
|
-
const syncedRetryFailure = synchronizeCoordinatorPlanState(root, mission, retry.plan);
|
|
650
|
-
console.error(chalk.red(`Coordinator retry execution failed: ${error.message}`));
|
|
651
|
-
if (opts.json) {
|
|
652
|
-
console.log(JSON.stringify({
|
|
653
|
-
dispatch_mode: 'coordinator',
|
|
654
|
-
retry: true,
|
|
655
|
-
mission_id: mission.mission_id,
|
|
656
|
-
plan_id: retry.plan.plan_id,
|
|
657
|
-
workstream_id: opts.workstream,
|
|
658
|
-
repo_id: retry.retryResult.repo_id,
|
|
659
|
-
retried_repo_turn_id: retry.retryResult.failed_turn_id,
|
|
660
|
-
repo_turn_id: retry.retryResult.reissued_turn_id,
|
|
661
|
-
workstream_status: syncedRetryFailure.ok
|
|
662
|
-
? syncedRetryFailure.plan.workstreams.find((ws) => ws.workstream_id === opts.workstream)?.launch_status || 'needs_attention'
|
|
663
|
-
: 'needs_attention',
|
|
664
|
-
launch_record: retry.launchRecord,
|
|
665
|
-
error: error.message,
|
|
666
|
-
}, null, 2));
|
|
667
|
-
}
|
|
739
|
+
);
|
|
740
|
+
if (!retryExecution.retryResult) {
|
|
741
|
+
console.error(chalk.red(retryExecution.error));
|
|
668
742
|
process.exit(1);
|
|
669
743
|
}
|
|
670
744
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
coordinatorConfigResult.config,
|
|
675
|
-
retry.retryResult.repo_id,
|
|
676
|
-
retry.retryResult.reissued_turn_id,
|
|
677
|
-
opts.workstream,
|
|
678
|
-
loadCoordinatorState(mission.coordinator.workspace_path),
|
|
679
|
-
);
|
|
680
|
-
if (!projection.ok) {
|
|
681
|
-
const warning = buildCoordinatorProjectionWarning(projection.error);
|
|
682
|
-
retryWarnings.push(warning);
|
|
683
|
-
console.error(chalk.yellow(`Coordinator retry projection warning: ${warning.message}`));
|
|
684
|
-
emitRunEvent(mission.coordinator.workspace_path, 'coordinator_retry_projection_warning', {
|
|
685
|
-
run_id: mission.coordinator.super_run_id,
|
|
686
|
-
phase: coordinatorConfigResult.config?.workstreams?.[opts.workstream]?.phase || null,
|
|
687
|
-
status: 'active',
|
|
688
|
-
payload: {
|
|
689
|
-
workstream_id: opts.workstream,
|
|
690
|
-
repo_id: retry.retryResult.repo_id,
|
|
691
|
-
reissued_turn_id: retry.retryResult.reissued_turn_id,
|
|
692
|
-
warning_code: warning.code,
|
|
693
|
-
warning_message: warning.message,
|
|
694
|
-
},
|
|
695
|
-
});
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
const syncedRetry = synchronizeCoordinatorPlanState(root, mission, retry.plan);
|
|
700
|
-
const retriedPlan = syncedRetry.ok ? syncedRetry.plan : retry.plan;
|
|
701
|
-
const retriedWorkstream = retriedPlan.workstreams.find((ws) => ws.workstream_id === opts.workstream);
|
|
702
|
-
const retriedLaunchRecord = retriedPlan.launch_records.find(
|
|
703
|
-
(record) => record.workstream_id === opts.workstream && record.dispatch_mode === 'coordinator',
|
|
704
|
-
) || retry.launchRecord;
|
|
745
|
+
const retriedPlan = retryExecution.plan;
|
|
746
|
+
const retriedWorkstream = retryExecution.workstream;
|
|
747
|
+
const retriedLaunchRecord = retryExecution.launchRecord;
|
|
705
748
|
|
|
706
749
|
if (opts.json) {
|
|
707
750
|
console.log(JSON.stringify({
|
|
@@ -711,41 +754,41 @@ export async function missionPlanLaunchCommand(planTarget, opts) {
|
|
|
711
754
|
plan_id: retriedPlan.plan_id,
|
|
712
755
|
workstream_id: opts.workstream,
|
|
713
756
|
super_run_id: mission.coordinator.super_run_id,
|
|
714
|
-
repo_id:
|
|
715
|
-
retried_repo_turn_id:
|
|
716
|
-
repo_turn_id:
|
|
717
|
-
role:
|
|
718
|
-
bundle_path:
|
|
719
|
-
context_ref:
|
|
757
|
+
repo_id: retryExecution.retryResult.repo_id,
|
|
758
|
+
retried_repo_turn_id: retryExecution.retryResult.failed_turn_id,
|
|
759
|
+
repo_turn_id: retryExecution.retryResult.reissued_turn_id,
|
|
760
|
+
role: retryExecution.retryResult.role,
|
|
761
|
+
bundle_path: retryExecution.retryResult.bundle_path,
|
|
762
|
+
context_ref: retryExecution.retryResult.context_ref,
|
|
720
763
|
workstream_status: retriedWorkstream?.launch_status || 'launched',
|
|
721
764
|
launch_record: retriedLaunchRecord,
|
|
722
|
-
exit_code:
|
|
723
|
-
warnings:
|
|
724
|
-
reconciliation_required:
|
|
765
|
+
exit_code: retryExecution.exit_code ?? 0,
|
|
766
|
+
warnings: retryExecution.warnings || [],
|
|
767
|
+
reconciliation_required: retryExecution.reconciliation_required || false,
|
|
725
768
|
}, null, 2));
|
|
726
|
-
if (
|
|
727
|
-
process.exit(
|
|
769
|
+
if (!retryExecution.ok) {
|
|
770
|
+
process.exit(retryExecution.exit_code || 1);
|
|
728
771
|
}
|
|
729
772
|
return;
|
|
730
773
|
}
|
|
731
774
|
|
|
732
|
-
console.log(chalk.green(`Retried coordinator workstream ${chalk.bold(opts.workstream)} in ${chalk.bold(
|
|
775
|
+
console.log(chalk.green(`Retried coordinator workstream ${chalk.bold(opts.workstream)} in ${chalk.bold(retryExecution.retryResult.repo_id)}`));
|
|
733
776
|
console.log('');
|
|
734
777
|
console.log(chalk.dim(` Mission: ${mission.mission_id}`));
|
|
735
778
|
console.log(chalk.dim(` Plan: ${retriedPlan.plan_id}`));
|
|
736
779
|
console.log(chalk.dim(` Super Run: ${mission.coordinator.super_run_id}`));
|
|
737
|
-
console.log(chalk.dim(` Repo: ${
|
|
738
|
-
console.log(chalk.dim(` Old Turn: ${
|
|
739
|
-
console.log(chalk.dim(` New Turn: ${
|
|
780
|
+
console.log(chalk.dim(` Repo: ${retryExecution.retryResult.repo_id}`));
|
|
781
|
+
console.log(chalk.dim(` Old Turn: ${retryExecution.retryResult.failed_turn_id}`));
|
|
782
|
+
console.log(chalk.dim(` New Turn: ${retryExecution.retryResult.reissued_turn_id}`));
|
|
740
783
|
console.log(chalk.dim(` Workstream: ${retriedWorkstream?.launch_status || 'launched'}`));
|
|
741
|
-
if (
|
|
742
|
-
console.log(chalk.yellow(` Warning: ${
|
|
784
|
+
if ((retryExecution.warnings || []).length > 0) {
|
|
785
|
+
console.log(chalk.yellow(` Warning: ${retryExecution.warnings[0].message}`));
|
|
743
786
|
}
|
|
744
787
|
console.log('');
|
|
745
788
|
renderPlan(retriedPlan);
|
|
746
|
-
if (
|
|
747
|
-
console.error(chalk.red(
|
|
748
|
-
process.exit(
|
|
789
|
+
if (!retryExecution.ok) {
|
|
790
|
+
console.error(chalk.red(retryExecution.error));
|
|
791
|
+
process.exit(retryExecution.exit_code || 1);
|
|
749
792
|
}
|
|
750
793
|
return;
|
|
751
794
|
}
|
|
@@ -1119,6 +1162,11 @@ export async function missionPlanAutopilotCommand(planTarget, opts) {
|
|
|
1119
1162
|
process.exit(1);
|
|
1120
1163
|
}
|
|
1121
1164
|
|
|
1165
|
+
if (opts.autoRetry && !mission.coordinator) {
|
|
1166
|
+
console.error(chalk.red('--auto-retry is only supported for coordinator-bound missions.'));
|
|
1167
|
+
process.exit(1);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1122
1170
|
if (mission.coordinator) {
|
|
1123
1171
|
return coordinatorAutopilot(planTarget, opts, context, mission);
|
|
1124
1172
|
}
|
|
@@ -1766,14 +1814,25 @@ async function coordinatorAutopilot(planTarget, opts, context, mission) {
|
|
|
1766
1814
|
|
|
1767
1815
|
const maxWaves = Math.max(1, parseInt(opts.maxWaves, 10) || 10);
|
|
1768
1816
|
const cooldownSeconds = Math.max(0, parseInt(opts.cooldown, 10) || 5);
|
|
1817
|
+
const autoRetryEnabled = !!opts.autoRetry;
|
|
1818
|
+
const parsedMaxRetries = Number.parseInt(opts.maxRetries, 10);
|
|
1819
|
+
const maxRetries = autoRetryEnabled
|
|
1820
|
+
? (Number.isFinite(parsedMaxRetries) ? parsedMaxRetries : 1)
|
|
1821
|
+
: 0;
|
|
1822
|
+
if (autoRetryEnabled && maxRetries < 1) {
|
|
1823
|
+
console.error(chalk.red('--max-retries must be >= 1 when --auto-retry is set.'));
|
|
1824
|
+
process.exit(1);
|
|
1825
|
+
}
|
|
1769
1826
|
const sleep = opts._sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1770
1827
|
|
|
1771
1828
|
const waves = [];
|
|
1772
1829
|
let totalLaunched = 0;
|
|
1773
1830
|
let totalCompleted = 0;
|
|
1774
1831
|
let totalFailed = 0;
|
|
1832
|
+
let totalRetries = 0;
|
|
1775
1833
|
let terminalReason = null;
|
|
1776
1834
|
let interrupted = false;
|
|
1835
|
+
const retryCounts = new Map();
|
|
1777
1836
|
|
|
1778
1837
|
const onSigint = () => { interrupted = true; };
|
|
1779
1838
|
process.on('SIGINT', onSigint);
|
|
@@ -1848,6 +1907,92 @@ async function coordinatorAutopilot(planTarget, opts, context, mission) {
|
|
|
1848
1907
|
totalLaunched++;
|
|
1849
1908
|
|
|
1850
1909
|
if (result.status === 'needs_attention') {
|
|
1910
|
+
if (autoRetryEnabled && result.repo_id) {
|
|
1911
|
+
const retryKey = `${ws.workstream_id}:${result.repo_id}`;
|
|
1912
|
+
const usedRetries = retryCounts.get(retryKey) || 0;
|
|
1913
|
+
|
|
1914
|
+
if (usedRetries < maxRetries) {
|
|
1915
|
+
retryCounts.set(retryKey, usedRetries + 1);
|
|
1916
|
+
|
|
1917
|
+
const retryExecution = await executeCoordinatorRetryRun(
|
|
1918
|
+
root,
|
|
1919
|
+
mission,
|
|
1920
|
+
ws.workstream_id,
|
|
1921
|
+
coord.config,
|
|
1922
|
+
{
|
|
1923
|
+
...opts,
|
|
1924
|
+
planId: plan.plan_id,
|
|
1925
|
+
plan,
|
|
1926
|
+
trigger: 'autopilot',
|
|
1927
|
+
triggerReasonPrefix: `mission:${mission.mission_id} workstream:${ws.workstream_id} autopilot:wave-${waveNum} auto-retry`,
|
|
1928
|
+
reason: `mission autopilot auto-retry ${ws.workstream_id}/${result.repo_id}`,
|
|
1929
|
+
},
|
|
1930
|
+
);
|
|
1931
|
+
|
|
1932
|
+
if (retryExecution.plan) {
|
|
1933
|
+
plan = retryExecution.plan;
|
|
1934
|
+
}
|
|
1935
|
+
if (retryExecution.retryResult) {
|
|
1936
|
+
totalRetries++;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
if (retryExecution.ok) {
|
|
1940
|
+
totalCompleted++;
|
|
1941
|
+
waveResults.push({
|
|
1942
|
+
workstream_id: ws.workstream_id,
|
|
1943
|
+
status: 'dispatched',
|
|
1944
|
+
repo_id: retryExecution.retryResult.repo_id,
|
|
1945
|
+
turn_id: retryExecution.retryResult.reissued_turn_id,
|
|
1946
|
+
retried: true,
|
|
1947
|
+
retry_count: retryExecution.retry_count,
|
|
1948
|
+
retried_repo_turn_id: retryExecution.retryResult.failed_turn_id,
|
|
1949
|
+
warnings: retryExecution.warnings || [],
|
|
1950
|
+
reconciliation_required: retryExecution.reconciliation_required || false,
|
|
1951
|
+
});
|
|
1952
|
+
if (!opts.json) {
|
|
1953
|
+
console.log(chalk.green(`→ ${retryExecution.retryResult.repo_id} retried ✓`));
|
|
1954
|
+
}
|
|
1955
|
+
continue;
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
waveHadFailure = true;
|
|
1959
|
+
totalFailed++;
|
|
1960
|
+
waveResults.push({
|
|
1961
|
+
workstream_id: ws.workstream_id,
|
|
1962
|
+
status: 'needs_attention',
|
|
1963
|
+
repo_id: retryExecution.retryResult?.repo_id || result.repo_id,
|
|
1964
|
+
turn_id: retryExecution.retryResult?.reissued_turn_id || result.turn_id,
|
|
1965
|
+
retried: Boolean(retryExecution.retryResult),
|
|
1966
|
+
retry_count: retryExecution.retry_count || usedRetries + 1,
|
|
1967
|
+
retried_repo_turn_id: retryExecution.retryResult?.failed_turn_id || result.turn_id,
|
|
1968
|
+
error: retryExecution.error,
|
|
1969
|
+
warnings: retryExecution.warnings || [],
|
|
1970
|
+
reconciliation_required: retryExecution.reconciliation_required || false,
|
|
1971
|
+
});
|
|
1972
|
+
if (!opts.json) {
|
|
1973
|
+
console.log(chalk.red(`→ ${result.repo_id} auto-retry failed ✗ (${retryExecution.error})`));
|
|
1974
|
+
}
|
|
1975
|
+
continue;
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
waveHadFailure = true;
|
|
1979
|
+
totalFailed++;
|
|
1980
|
+
const budgetError = formatAutoRetryBudgetError(ws.workstream_id, result.repo_id, maxRetries);
|
|
1981
|
+
waveResults.push({
|
|
1982
|
+
workstream_id: ws.workstream_id,
|
|
1983
|
+
status: 'needs_attention',
|
|
1984
|
+
repo_id: result.repo_id,
|
|
1985
|
+
turn_id: result.turn_id,
|
|
1986
|
+
retry_budget_exhausted: true,
|
|
1987
|
+
retry_count: usedRetries,
|
|
1988
|
+
error: budgetError,
|
|
1989
|
+
});
|
|
1990
|
+
if (!opts.json) {
|
|
1991
|
+
console.log(chalk.red(`→ ${result.repo_id} needs_attention ✗ (${budgetError})`));
|
|
1992
|
+
}
|
|
1993
|
+
continue;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1851
1996
|
waveHadFailure = true;
|
|
1852
1997
|
totalFailed++;
|
|
1853
1998
|
waveResults.push({ workstream_id: ws.workstream_id, status: 'needs_attention', repo_id: result.repo_id, turn_id: result.turn_id });
|
|
@@ -1917,6 +2062,7 @@ async function coordinatorAutopilot(planTarget, opts, context, mission) {
|
|
|
1917
2062
|
total_launched: totalLaunched,
|
|
1918
2063
|
completed: totalCompleted,
|
|
1919
2064
|
failed: totalFailed,
|
|
2065
|
+
total_retries: totalRetries,
|
|
1920
2066
|
terminal_reason: terminalReason,
|
|
1921
2067
|
},
|
|
1922
2068
|
};
|
|
@@ -1930,6 +2076,7 @@ async function coordinatorAutopilot(planTarget, opts, context, mission) {
|
|
|
1930
2076
|
console.log(` Launched: ${totalLaunched}`);
|
|
1931
2077
|
console.log(` Completed: ${totalCompleted}`);
|
|
1932
2078
|
console.log(` Failed: ${totalFailed}`);
|
|
2079
|
+
console.log(` Retries: ${totalRetries}`);
|
|
1933
2080
|
console.log(` Outcome: ${formatTerminalReason(terminalReason)}`);
|
|
1934
2081
|
if (terminalReason === 'plan_completed') {
|
|
1935
2082
|
console.log(chalk.green('\n Plan completed successfully.'));
|
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) {
|
|
@@ -10,6 +10,8 @@ const SECTION_DEFINITIONS = [
|
|
|
10
10
|
{ id: 'last_turn_summary', header: null, required: false },
|
|
11
11
|
{ id: 'last_turn_decisions', header: null, required: false },
|
|
12
12
|
{ id: 'last_turn_objections', header: null, required: false },
|
|
13
|
+
{ id: 'last_turn_files_changed', header: null, required: false },
|
|
14
|
+
{ id: 'last_turn_changed_file_previews', header: null, required: false },
|
|
13
15
|
{ id: 'last_turn_verification', header: null, required: false },
|
|
14
16
|
{ id: 'decision_history', header: 'Decision History', required: false },
|
|
15
17
|
{ id: 'blockers', header: 'Blockers', required: true },
|
|
@@ -54,6 +56,8 @@ export function parseContextSections(contextMd) {
|
|
|
54
56
|
summaryLines,
|
|
55
57
|
decisionsLines,
|
|
56
58
|
objectionsLines,
|
|
59
|
+
filesChangedLines,
|
|
60
|
+
changedFilePreviewLines,
|
|
57
61
|
verificationLines,
|
|
58
62
|
} = splitLastAcceptedTurn(lastAcceptedTurnBody);
|
|
59
63
|
|
|
@@ -61,6 +65,8 @@ export function parseContextSections(contextMd) {
|
|
|
61
65
|
pushSection(parsedSections, 'last_turn_summary', summaryLines);
|
|
62
66
|
pushSection(parsedSections, 'last_turn_decisions', decisionsLines);
|
|
63
67
|
pushSection(parsedSections, 'last_turn_objections', objectionsLines);
|
|
68
|
+
pushSection(parsedSections, 'last_turn_files_changed', filesChangedLines);
|
|
69
|
+
pushSection(parsedSections, 'last_turn_changed_file_previews', changedFilePreviewLines);
|
|
64
70
|
pushSection(parsedSections, 'last_turn_verification', verificationLines);
|
|
65
71
|
}
|
|
66
72
|
|
|
@@ -91,6 +97,8 @@ export function renderContextSections(sections) {
|
|
|
91
97
|
sectionMap.get('last_turn_summary')?.content,
|
|
92
98
|
sectionMap.get('last_turn_decisions')?.content,
|
|
93
99
|
sectionMap.get('last_turn_objections')?.content,
|
|
100
|
+
sectionMap.get('last_turn_files_changed')?.content,
|
|
101
|
+
sectionMap.get('last_turn_changed_file_previews')?.content,
|
|
94
102
|
sectionMap.get('last_turn_verification')?.content,
|
|
95
103
|
]);
|
|
96
104
|
|
|
@@ -157,6 +165,8 @@ function splitLastAcceptedTurn(lines) {
|
|
|
157
165
|
let summaryLines = [];
|
|
158
166
|
let decisionsLines = [];
|
|
159
167
|
let objectionsLines = [];
|
|
168
|
+
let filesChangedLines = [];
|
|
169
|
+
let changedFilePreviewLines = [];
|
|
160
170
|
let verificationLines = [];
|
|
161
171
|
|
|
162
172
|
let inVerification = false;
|
|
@@ -200,6 +210,20 @@ function splitLastAcceptedTurn(lines) {
|
|
|
200
210
|
continue;
|
|
201
211
|
}
|
|
202
212
|
|
|
213
|
+
if (line.startsWith('### Files Changed')) {
|
|
214
|
+
const { blockLines, nextIndex } = consumeLevel3Block(lines, index);
|
|
215
|
+
filesChangedLines = blockLines;
|
|
216
|
+
index = nextIndex - 1;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (line.startsWith('### Changed File Previews')) {
|
|
221
|
+
const { blockLines, nextIndex } = consumeLevel3Block(lines, index);
|
|
222
|
+
changedFilePreviewLines = blockLines;
|
|
223
|
+
index = nextIndex - 1;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
203
227
|
headerLines.push(line);
|
|
204
228
|
}
|
|
205
229
|
|
|
@@ -208,6 +232,8 @@ function splitLastAcceptedTurn(lines) {
|
|
|
208
232
|
summaryLines: trimBlankLines(summaryLines),
|
|
209
233
|
decisionsLines: trimBlankLines(decisionsLines),
|
|
210
234
|
objectionsLines: trimBlankLines(objectionsLines),
|
|
235
|
+
filesChangedLines: trimBlankLines(filesChangedLines),
|
|
236
|
+
changedFilePreviewLines: trimBlankLines(changedFilePreviewLines),
|
|
211
237
|
verificationLines: trimBlankLines(verificationLines),
|
|
212
238
|
};
|
|
213
239
|
}
|
|
@@ -232,6 +258,32 @@ function consumeIndentedBlock(lines, startIndex) {
|
|
|
232
258
|
};
|
|
233
259
|
}
|
|
234
260
|
|
|
261
|
+
function consumeLevel3Block(lines, startIndex) {
|
|
262
|
+
const blockLines = [lines[startIndex]];
|
|
263
|
+
let index = startIndex + 1;
|
|
264
|
+
let inCodeBlock = false;
|
|
265
|
+
|
|
266
|
+
while (index < lines.length) {
|
|
267
|
+
const line = lines[index];
|
|
268
|
+
|
|
269
|
+
if (line.startsWith('```')) {
|
|
270
|
+
inCodeBlock = !inCodeBlock;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!inCodeBlock && line.startsWith('### ')) {
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
blockLines.push(line);
|
|
278
|
+
index += 1;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
blockLines: trimBlankLines(blockLines),
|
|
283
|
+
nextIndex: index,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
235
287
|
function pushSection(target, id, lines) {
|
|
236
288
|
const normalizedLines = trimBlankLines(lines || []);
|
|
237
289
|
if (!normalizedLines.length) return;
|
|
@@ -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('');
|
|
@@ -85,6 +85,15 @@ function addMissingFile(result, filePath) {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
function addFailingFile(result, filePath) {
|
|
89
|
+
if (!filePath) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!result.failing_files.includes(filePath)) {
|
|
93
|
+
result.failing_files.push(filePath);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
88
97
|
function prefixSemanticReason(filePath, reason) {
|
|
89
98
|
if (!reason || reason.includes(filePath)) {
|
|
90
99
|
return reason;
|
|
@@ -111,6 +120,7 @@ function evaluateGateArtifacts({ root, config, gateDef, phase, result, state })
|
|
|
111
120
|
if (!existsSync(absPath)) {
|
|
112
121
|
if (artifact.required) {
|
|
113
122
|
addMissingFile(result, artifact.path);
|
|
123
|
+
addFailingFile(result, artifact.path);
|
|
114
124
|
failures.push(`Required file missing: ${artifact.path}`);
|
|
115
125
|
}
|
|
116
126
|
continue;
|
|
@@ -119,6 +129,7 @@ function evaluateGateArtifacts({ root, config, gateDef, phase, result, state })
|
|
|
119
129
|
if (artifact.useLegacySemantics) {
|
|
120
130
|
const semanticCheck = evaluateWorkflowGateSemantics(root, artifact.path);
|
|
121
131
|
if (semanticCheck && !semanticCheck.ok) {
|
|
132
|
+
addFailingFile(result, artifact.path);
|
|
122
133
|
failures.push(semanticCheck.reason);
|
|
123
134
|
}
|
|
124
135
|
}
|
|
@@ -130,12 +141,14 @@ function evaluateGateArtifacts({ root, config, gateDef, phase, result, state })
|
|
|
130
141
|
semantics_config: semantic.semantics_config,
|
|
131
142
|
});
|
|
132
143
|
if (semanticCheck && !semanticCheck.ok) {
|
|
144
|
+
addFailingFile(result, artifact.path);
|
|
133
145
|
failures.push(prefixSemanticReason(artifact.path, semanticCheck.reason));
|
|
134
146
|
}
|
|
135
147
|
}
|
|
136
148
|
|
|
137
149
|
// Charter enforcement: verify owning role participated in this phase
|
|
138
150
|
if (artifact.owned_by && !hasRoleParticipationInPhase(state, phase, artifact.owned_by)) {
|
|
151
|
+
addFailingFile(result, artifact.path);
|
|
139
152
|
failures.push(
|
|
140
153
|
`"${artifact.path}" requires participation from role "${artifact.owned_by}" in phase "${phase}", but no accepted turn from that role was found`,
|
|
141
154
|
);
|
|
@@ -161,11 +174,12 @@ function evaluateGateArtifacts({ root, config, gateDef, phase, result, state })
|
|
|
161
174
|
* @property {boolean} blocked_by_human_approval - gate passed structurally but needs human sign-off
|
|
162
175
|
* @property {string[]} reasons - human-readable failure reasons
|
|
163
176
|
* @property {string[]} missing_files - files required by gate but not found
|
|
177
|
+
* @property {string[]} failing_files - files tied to gate failures
|
|
164
178
|
* @property {boolean} missing_verification - verification required but not passed
|
|
165
179
|
* @property {string|null} next_phase - the target phase if transition was requested and gate passed
|
|
166
180
|
* @property {string|null} transition_request - the raw phase_transition_request value
|
|
167
181
|
* @property {'no_request'|'unknown_phase'|'gate_failed'|'advance'|'awaiting_human_approval'|'no_gate'} action
|
|
168
|
-
|
|
182
|
+
*/
|
|
169
183
|
export function evaluatePhaseExit({ state, config, acceptedTurn, root }) {
|
|
170
184
|
const currentPhase = state.phase;
|
|
171
185
|
const transitionRequest = acceptedTurn.phase_transition_request || null;
|
|
@@ -176,6 +190,7 @@ export function evaluatePhaseExit({ state, config, acceptedTurn, root }) {
|
|
|
176
190
|
blocked_by_human_approval: false,
|
|
177
191
|
reasons: [],
|
|
178
192
|
missing_files: [],
|
|
193
|
+
failing_files: [],
|
|
179
194
|
missing_verification: false,
|
|
180
195
|
next_phase: null,
|
|
181
196
|
transition_request: transitionRequest,
|
|
@@ -303,6 +318,7 @@ export function evaluatePhaseExit({ state, config, acceptedTurn, root }) {
|
|
|
303
318
|
* @property {boolean} blocked_by_human_approval - gate passed structurally but needs human sign-off
|
|
304
319
|
* @property {string[]} reasons - human-readable failure reasons
|
|
305
320
|
* @property {string[]} missing_files - files required by gate but not found
|
|
321
|
+
* @property {string[]} failing_files - files tied to gate failures
|
|
306
322
|
* @property {boolean} missing_verification - verification required but not passed
|
|
307
323
|
* @property {'no_request'|'not_final_phase'|'gate_failed'|'complete'|'awaiting_human_approval'} action
|
|
308
324
|
*/
|
|
@@ -313,6 +329,7 @@ export function evaluateRunCompletion({ state, config, acceptedTurn, root }) {
|
|
|
313
329
|
blocked_by_human_approval: false,
|
|
314
330
|
reasons: [],
|
|
315
331
|
missing_files: [],
|
|
332
|
+
failing_files: [],
|
|
316
333
|
missing_verification: false,
|
|
317
334
|
action: 'no_request',
|
|
318
335
|
};
|
|
@@ -2049,6 +2049,7 @@ export function reactivateGovernedRun(root, state, details = {}) {
|
|
|
2049
2049
|
|
|
2050
2050
|
const now = new Date().toISOString();
|
|
2051
2051
|
const wasEscalation = state.status === 'blocked' && typeof state.blocked_on === 'string' && state.blocked_on.startsWith('escalation:');
|
|
2052
|
+
const wasNonProgress = state.blocked_on === 'non_progress';
|
|
2052
2053
|
const humanEscalation = findCurrentHumanEscalation(root, state);
|
|
2053
2054
|
const nextState = {
|
|
2054
2055
|
...state,
|
|
@@ -2058,6 +2059,12 @@ export function reactivateGovernedRun(root, state, details = {}) {
|
|
|
2058
2059
|
escalation: null,
|
|
2059
2060
|
};
|
|
2060
2061
|
|
|
2062
|
+
// BUG-38: reset non-progress tracking when acknowledging non-progress block
|
|
2063
|
+
if (wasNonProgress || details.acknowledge_non_progress) {
|
|
2064
|
+
nextState.non_progress_signature = null;
|
|
2065
|
+
nextState.non_progress_count = 0;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2061
2068
|
writeState(root, nextState);
|
|
2062
2069
|
|
|
2063
2070
|
if (humanEscalation) {
|
|
@@ -2154,13 +2161,76 @@ export function initializeGovernedRun(root, config, options = {}) {
|
|
|
2154
2161
|
};
|
|
2155
2162
|
|
|
2156
2163
|
writeState(root, updatedState);
|
|
2164
|
+
|
|
2165
|
+
// BUG-34 + BUG-39: retroactive migration — archive stale intents from prior runs.
|
|
2166
|
+
// Intents with an approved_run_id from a DIFFERENT run are archived (BUG-34).
|
|
2167
|
+
// BUG-39: Intents with approved_run_id: null are pre-BUG-34 legacy files that
|
|
2168
|
+
// must be archived with status "archived_migration", NOT adopted into the current
|
|
2169
|
+
// run. Silently adopting them caused continuous mode to pick up stale intents.
|
|
2170
|
+
const archivedMigrationIntentIds = [];
|
|
2171
|
+
try {
|
|
2172
|
+
const intentsDir = join(root, '.agentxchain', 'intake', 'intents');
|
|
2173
|
+
if (existsSync(intentsDir)) {
|
|
2174
|
+
const DISPATCHABLE = new Set(['planned', 'approved']);
|
|
2175
|
+
const intNow = new Date().toISOString();
|
|
2176
|
+
for (const f of readdirSync(intentsDir).filter(x => x.endsWith('.json') && !x.startsWith('.tmp-'))) {
|
|
2177
|
+
const ip = join(intentsDir, f);
|
|
2178
|
+
try {
|
|
2179
|
+
const intent = JSON.parse(readFileSync(ip, 'utf8'));
|
|
2180
|
+
if (!intent || !DISPATCHABLE.has(intent.status)) continue;
|
|
2181
|
+
if (intent.cross_run_durable === true) continue;
|
|
2182
|
+
if (intent.approved_run_id === runId) continue;
|
|
2183
|
+
|
|
2184
|
+
const prevStatus = intent.status;
|
|
2185
|
+
if (intent.approved_run_id && intent.approved_run_id !== runId) {
|
|
2186
|
+
// Intent from a different run — archive it (BUG-34)
|
|
2187
|
+
intent.status = 'suppressed';
|
|
2188
|
+
intent.updated_at = intNow;
|
|
2189
|
+
intent.archived_reason = `stale: approved under run ${intent.approved_run_id}, archived on run ${runId} initialization`;
|
|
2190
|
+
if (!intent.history) intent.history = [];
|
|
2191
|
+
intent.history.push({ from: prevStatus, to: 'suppressed', at: intNow, reason: intent.archived_reason });
|
|
2192
|
+
} else if (!intent.approved_run_id) {
|
|
2193
|
+
// BUG-39: pre-BUG-34 legacy intent with no run binding — archive it
|
|
2194
|
+
// with explicit migration reason. Do NOT adopt into current run.
|
|
2195
|
+
intent.status = 'archived_migration';
|
|
2196
|
+
intent.updated_at = intNow;
|
|
2197
|
+
intent.archived_reason = `pre-BUG-34 intent with no run scope; archived during v${updatedState.protocol_version || '2.x'} migration on run ${runId}`;
|
|
2198
|
+
if (!intent.history) intent.history = [];
|
|
2199
|
+
intent.history.push({ from: prevStatus, to: 'archived_migration', at: intNow, reason: intent.archived_reason });
|
|
2200
|
+
if (intent.intent_id) archivedMigrationIntentIds.push(intent.intent_id);
|
|
2201
|
+
}
|
|
2202
|
+
safeWriteJson(ip, intent);
|
|
2203
|
+
} catch { /* non-fatal per-intent */ }
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
} catch { /* non-fatal — intent migration is best-effort */ }
|
|
2207
|
+
|
|
2208
|
+
// BUG-39: emit intents_migrated event when pre-BUG-34 intents were archived
|
|
2209
|
+
if (archivedMigrationIntentIds.length > 0) {
|
|
2210
|
+
emitRunEvent(root, 'intents_migrated', {
|
|
2211
|
+
run_id: runId,
|
|
2212
|
+
phase: updatedState.phase,
|
|
2213
|
+
status: 'active',
|
|
2214
|
+
payload: {
|
|
2215
|
+
archived_count: archivedMigrationIntentIds.length,
|
|
2216
|
+
archived_intent_ids: archivedMigrationIntentIds,
|
|
2217
|
+
reason: 'pre-BUG-34 intents with approved_run_id: null archived during run initialization',
|
|
2218
|
+
},
|
|
2219
|
+
});
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2157
2222
|
emitRunEvent(root, 'run_started', {
|
|
2158
2223
|
run_id: runId,
|
|
2159
2224
|
phase: updatedState.phase,
|
|
2160
2225
|
status: 'active',
|
|
2161
2226
|
payload: { provenance: provenance || {} },
|
|
2162
2227
|
});
|
|
2163
|
-
return
|
|
2228
|
+
// BUG-39: return migration notice so callers can display it
|
|
2229
|
+
const migrationNotice = archivedMigrationIntentIds.length > 0
|
|
2230
|
+
? `Archived ${archivedMigrationIntentIds.length} pre-BUG-34 intent(s). Review: agentxchain intake status --archived.`
|
|
2231
|
+
: null;
|
|
2232
|
+
|
|
2233
|
+
return { ok: true, state: attachLegacyCurrentTurnAlias(updatedState), migration_notice: migrationNotice };
|
|
2164
2234
|
}
|
|
2165
2235
|
|
|
2166
2236
|
/**
|
|
@@ -3107,6 +3177,58 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3107
3177
|
}
|
|
3108
3178
|
}
|
|
3109
3179
|
|
|
3180
|
+
// ── Gate semantic coverage validation (BUG-36) ────────────────────────────
|
|
3181
|
+
// When a turn proposes a phase transition, pre-evaluate the gate. If the gate
|
|
3182
|
+
// would fail AND the failing files are not in files_changed, reject the turn
|
|
3183
|
+
// early — the agent didn't do the work required for the transition.
|
|
3184
|
+
if (turnResult.phase_transition_request) {
|
|
3185
|
+
const preGateResult = evaluatePhaseExit({
|
|
3186
|
+
state,
|
|
3187
|
+
config,
|
|
3188
|
+
acceptedTurn: turnResult,
|
|
3189
|
+
root,
|
|
3190
|
+
});
|
|
3191
|
+
|
|
3192
|
+
if (preGateResult.action === 'gate_failed') {
|
|
3193
|
+
// Gate is failing. Check if any of the failing reasons reference files
|
|
3194
|
+
// that this turn didn't modify.
|
|
3195
|
+
const declaredFiles = new Set((turnResult.files_changed || []).map(f => f.replace(/^\.\//, '')));
|
|
3196
|
+
const exitGateId = preGateResult.gate_id || 'unknown_gate';
|
|
3197
|
+
const uniqueFailingFiles = [...new Set(
|
|
3198
|
+
(preGateResult.failing_files || preGateResult.missing_files || [])
|
|
3199
|
+
.map(f => f.replace(/^\.\//, ''))
|
|
3200
|
+
)];
|
|
3201
|
+
const uncoveredFiles = uniqueFailingFiles.filter(f => !declaredFiles.has(f));
|
|
3202
|
+
|
|
3203
|
+
const gateSemanticMode = config.gate_semantic_coverage_mode || 'strict';
|
|
3204
|
+
if (uncoveredFiles.length > 0 && gateSemanticMode === 'strict') {
|
|
3205
|
+
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.`;
|
|
3206
|
+
transitionToFailedAcceptance(root, state, currentTurn, coverageError, {
|
|
3207
|
+
error_code: 'gate_semantic_coverage',
|
|
3208
|
+
stage: 'gate_semantic_coverage',
|
|
3209
|
+
extra: {
|
|
3210
|
+
gate_id: exitGateId,
|
|
3211
|
+
uncovered_files: uncoveredFiles,
|
|
3212
|
+
declared_files: [...declaredFiles],
|
|
3213
|
+
gate_reasons: preGateResult.reasons,
|
|
3214
|
+
},
|
|
3215
|
+
});
|
|
3216
|
+
return {
|
|
3217
|
+
ok: false,
|
|
3218
|
+
error: coverageError,
|
|
3219
|
+
validation: {
|
|
3220
|
+
...validation,
|
|
3221
|
+
ok: false,
|
|
3222
|
+
stage: 'gate_semantic_coverage',
|
|
3223
|
+
error_class: 'gate_coverage_error',
|
|
3224
|
+
errors: uncoveredFiles.map(f => `Gate "${exitGateId}" is failing on "${f}". Your turn did not modify that file.`),
|
|
3225
|
+
warnings: [],
|
|
3226
|
+
},
|
|
3227
|
+
};
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3110
3232
|
const observedArtifact = buildObservedArtifact(observation, baseline);
|
|
3111
3233
|
const normalizedVerification = normalizeVerification(turnResult.verification, runtimeType);
|
|
3112
3234
|
const artifactType = turnResult.artifact?.type || 'review';
|
|
@@ -4107,6 +4229,118 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
4107
4229
|
}
|
|
4108
4230
|
}
|
|
4109
4231
|
|
|
4232
|
+
// ── BUG-38: Non-progress convergence guard ─────────────────────────────
|
|
4233
|
+
// Track whether consecutive accepted turns leave the same gate failure
|
|
4234
|
+
// intact without modifying the gated files. When the count reaches the
|
|
4235
|
+
// configurable threshold, block the run to prevent infinite loops.
|
|
4236
|
+
// Proactively evaluates the current phase exit gate on every accepted turn
|
|
4237
|
+
// — not just when a transition is requested — to detect stalled patterns.
|
|
4238
|
+
if (updatedState.status === 'active') {
|
|
4239
|
+
const declaredFiles = new Set((turnResult.files_changed || []).map(f => f.replace(/^\.\//, '')));
|
|
4240
|
+
const npThreshold = config.run_loop?.non_progress_threshold ?? 3;
|
|
4241
|
+
|
|
4242
|
+
// Proactively evaluate the exit gate for the current phase.
|
|
4243
|
+
// evaluatePhaseExit requires a phase_transition_request, so we synthesize
|
|
4244
|
+
// one pointing to the next phase from routing for probing purposes.
|
|
4245
|
+
let proactiveGateFailure = null;
|
|
4246
|
+
try {
|
|
4247
|
+
const currentRouting = config.routing?.[updatedState.phase];
|
|
4248
|
+
if (currentRouting?.exit_gate) {
|
|
4249
|
+
// Find the next phase to use as synthetic transition target
|
|
4250
|
+
const phases = config.phases || [];
|
|
4251
|
+
const currentIdx = phases.findIndex(p => p.id === updatedState.phase);
|
|
4252
|
+
const nextPhaseId = currentIdx >= 0 && currentIdx < phases.length - 1
|
|
4253
|
+
? phases[currentIdx + 1].id
|
|
4254
|
+
: (currentRouting.allowed_next_roles ? Object.keys(config.routing).find(k => k !== updatedState.phase) : null);
|
|
4255
|
+
|
|
4256
|
+
if (nextPhaseId) {
|
|
4257
|
+
const probeTurn = { ...turnResult, phase_transition_request: nextPhaseId };
|
|
4258
|
+
const probeResult = evaluatePhaseExit({
|
|
4259
|
+
state: updatedState,
|
|
4260
|
+
config,
|
|
4261
|
+
acceptedTurn: probeTurn,
|
|
4262
|
+
root,
|
|
4263
|
+
});
|
|
4264
|
+
if (probeResult.action === 'gate_failed') {
|
|
4265
|
+
proactiveGateFailure = probeResult;
|
|
4266
|
+
}
|
|
4267
|
+
}
|
|
4268
|
+
}
|
|
4269
|
+
} catch { /* non-fatal — probe is advisory */ }
|
|
4270
|
+
|
|
4271
|
+
// Fall back to last_gate_failure if proactive evaluation didn't find one
|
|
4272
|
+
const effectiveGateFailure = proactiveGateFailure || updatedState.last_gate_failure;
|
|
4273
|
+
|
|
4274
|
+
if (effectiveGateFailure && (effectiveGateFailure.gate_id || effectiveGateFailure.gate_type)) {
|
|
4275
|
+
const gateId = effectiveGateFailure.gate_id || effectiveGateFailure.gate_type || 'unknown';
|
|
4276
|
+
// Compute gate failure signature: gate_id + sorted failing files + sorted reasons
|
|
4277
|
+
const failingFiles = [...(effectiveGateFailure.missing_files || effectiveGateFailure.failing_files || [])].sort();
|
|
4278
|
+
const reasons = [...(effectiveGateFailure.reasons || [])].sort();
|
|
4279
|
+
const signature = `${gateId}::${failingFiles.join(',')}::${reasons.join(',')}`;
|
|
4280
|
+
|
|
4281
|
+
// Check if any of the gated files were modified by this turn
|
|
4282
|
+
const gatedFilesModified = failingFiles.some(f => declaredFiles.has(f.replace(/^\.\//, '')));
|
|
4283
|
+
|
|
4284
|
+
const prevSignature = state.non_progress_signature || null;
|
|
4285
|
+
const prevCount = state.non_progress_count || 0;
|
|
4286
|
+
|
|
4287
|
+
if (signature === prevSignature && !gatedFilesModified) {
|
|
4288
|
+
// Same gate failure, gated files untouched — increment counter
|
|
4289
|
+
const newCount = prevCount + 1;
|
|
4290
|
+
updatedState.non_progress_signature = signature;
|
|
4291
|
+
updatedState.non_progress_count = newCount;
|
|
4292
|
+
|
|
4293
|
+
if (newCount >= npThreshold) {
|
|
4294
|
+
// Threshold reached — block the run
|
|
4295
|
+
updatedState.status = 'blocked';
|
|
4296
|
+
updatedState.blocked_on = 'non_progress';
|
|
4297
|
+
updatedState.blocked_reason = buildBlockedReason({
|
|
4298
|
+
category: 'non_progress',
|
|
4299
|
+
recovery: {
|
|
4300
|
+
typed_reason: `Non-progress detected: ${newCount} accepted turns have not reduced gate failure "${gateId}".`,
|
|
4301
|
+
recovery_action: 'agentxchain resume --acknowledge-non-progress',
|
|
4302
|
+
detail: `Gate "${gateId}" has been failing on ${failingFiles.join(', ')} for ${newCount} consecutive turns. The gated file(s) were never modified.`,
|
|
4303
|
+
},
|
|
4304
|
+
turnId: currentTurn.turn_id,
|
|
4305
|
+
blockedAt: now,
|
|
4306
|
+
});
|
|
4307
|
+
|
|
4308
|
+
ledgerEntries.push({
|
|
4309
|
+
type: 'non_progress_block',
|
|
4310
|
+
gate_id: gateId,
|
|
4311
|
+
consecutive_turns: newCount,
|
|
4312
|
+
threshold: npThreshold,
|
|
4313
|
+
failing_files: failingFiles,
|
|
4314
|
+
signature,
|
|
4315
|
+
timestamp: now,
|
|
4316
|
+
});
|
|
4317
|
+
|
|
4318
|
+
emitRunEvent(root, 'run_stalled', {
|
|
4319
|
+
run_id: updatedState.run_id,
|
|
4320
|
+
phase: updatedState.phase,
|
|
4321
|
+
status: 'blocked',
|
|
4322
|
+
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
4323
|
+
payload: {
|
|
4324
|
+
consecutive_non_progress_turns: newCount,
|
|
4325
|
+
threshold: npThreshold,
|
|
4326
|
+
gate_id: gateId,
|
|
4327
|
+
failing_files: failingFiles,
|
|
4328
|
+
signature,
|
|
4329
|
+
},
|
|
4330
|
+
});
|
|
4331
|
+
}
|
|
4332
|
+
} else {
|
|
4333
|
+
// Gate failure changed or gated files were modified — reset counter
|
|
4334
|
+
updatedState.non_progress_signature = signature;
|
|
4335
|
+
updatedState.non_progress_count = 1;
|
|
4336
|
+
}
|
|
4337
|
+
} else {
|
|
4338
|
+
// No gate failure — reset non-progress tracking
|
|
4339
|
+
updatedState.non_progress_signature = null;
|
|
4340
|
+
updatedState.non_progress_count = 0;
|
|
4341
|
+
}
|
|
4342
|
+
}
|
|
4343
|
+
|
|
4110
4344
|
// ── Transaction journal: prepare before committing writes ──────────────
|
|
4111
4345
|
const transactionId = generateId('txn');
|
|
4112
4346
|
const journal = {
|
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,64 @@ 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
|
+
// BUG-39: pre-BUG-34 legacy intent with no run binding — archive it
|
|
651
|
+
// with explicit migration reason. Do NOT adopt into current run.
|
|
652
|
+
const prevStatus = intent.status;
|
|
653
|
+
intent.status = 'archived_migration';
|
|
654
|
+
intent.updated_at = now;
|
|
655
|
+
intent.archived_reason = `pre-BUG-34 intent with no run scope; archived during migration on run ${newRunId}`;
|
|
656
|
+
if (!intent.history) intent.history = [];
|
|
657
|
+
intent.history.push({ from: prevStatus, to: 'archived_migration', at: now, reason: intent.archived_reason });
|
|
658
|
+
safeWriteJson(intentPath, intent);
|
|
659
|
+
archived++;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return { archived, adopted };
|
|
664
|
+
}
|
|
665
|
+
|
|
577
666
|
/**
|
|
578
667
|
* Unified intent consumption entry point (BUG-16).
|
|
579
668
|
* Both manual (resume/step --resume) and continuous/scheduler paths should call
|
|
@@ -584,7 +673,22 @@ export function findPendingApprovedIntents(root) {
|
|
|
584
673
|
* @returns {{ ok: boolean, intentId?: string, intent?: object, error?: string }}
|
|
585
674
|
*/
|
|
586
675
|
export function consumeNextApprovedIntent(root, options = {}) {
|
|
587
|
-
|
|
676
|
+
let runId = options.run_id || null;
|
|
677
|
+
if (!runId) {
|
|
678
|
+
try {
|
|
679
|
+
const context = loadProjectContext(root);
|
|
680
|
+
const state = context ? loadProjectState(root, context.config) : null;
|
|
681
|
+
runId = state?.run_id || null;
|
|
682
|
+
} catch {
|
|
683
|
+
runId = null;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (runId && options.auto_archive_stale !== false) {
|
|
688
|
+
archiveStaleIntents(root, runId);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const queued = findNextDispatchableIntent(root, { run_id: runId });
|
|
588
692
|
if (!queued.ok) {
|
|
589
693
|
return { ok: false, error: queued.error || 'no dispatchable intents' };
|
|
590
694
|
}
|
|
@@ -717,6 +821,29 @@ export function approveIntent(root, intentId, options = {}) {
|
|
|
717
821
|
const reason = options.reason || (previousStatus === 'blocked' ? 're-approved after block resolution' : 'approved for planning');
|
|
718
822
|
const now = nowISO();
|
|
719
823
|
|
|
824
|
+
// BUG-34/39: stamp the current run_id on approval so the intent is scoped
|
|
825
|
+
// to the run that approved it. When approval happens before any governed
|
|
826
|
+
// run exists, mark the intent as cross_run_durable so the next run init
|
|
827
|
+
// preserves this freshly approved work instead of archiving it as legacy
|
|
828
|
+
// migration debt.
|
|
829
|
+
if (!intent.approved_run_id) {
|
|
830
|
+
const statePath = join(root, '.agentxchain', 'state.json');
|
|
831
|
+
if (existsSync(statePath)) {
|
|
832
|
+
try {
|
|
833
|
+
const state = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
834
|
+
if (state.run_id) {
|
|
835
|
+
intent.approved_run_id = state.run_id;
|
|
836
|
+
} else {
|
|
837
|
+
intent.cross_run_durable = true;
|
|
838
|
+
}
|
|
839
|
+
} catch {
|
|
840
|
+
// non-fatal — stamp is best-effort during approval
|
|
841
|
+
}
|
|
842
|
+
} else {
|
|
843
|
+
intent.cross_run_durable = true;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
720
847
|
intent.status = 'approved';
|
|
721
848
|
intent.approved_by = approver;
|
|
722
849
|
intent.updated_at = now;
|
|
@@ -1178,6 +1178,11 @@ export function normalizeV4(raw) {
|
|
|
1178
1178
|
state: '.agentxchain/state.json',
|
|
1179
1179
|
log: null,
|
|
1180
1180
|
},
|
|
1181
|
+
// Passthrough fields — these are runtime behavior overrides that don't
|
|
1182
|
+
// need normalization but must survive the config pipeline.
|
|
1183
|
+
...(raw.gate_semantic_coverage_mode ? { gate_semantic_coverage_mode: raw.gate_semantic_coverage_mode } : {}),
|
|
1184
|
+
...(raw.intent_coverage_mode ? { intent_coverage_mode: raw.intent_coverage_mode } : {}),
|
|
1185
|
+
...(raw.run_loop ? { run_loop: raw.run_loop } : {}),
|
|
1181
1186
|
compat: {
|
|
1182
1187
|
next_owner_source: 'state-json',
|
|
1183
1188
|
lock_based_coordination: false,
|