agentxchain 2.135.0 → 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/mission.js +250 -103
- package/src/lib/context-section-parser.js +52 -0
- package/src/lib/gate-evaluator.js +18 -1
- package/src/lib/governed-state.js +159 -25
- package/src/lib/intake.js +17 -6
- 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/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.'));
|
|
@@ -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;
|
|
@@ -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) {
|
|
@@ -2155,10 +2162,12 @@ export function initializeGovernedRun(root, config, options = {}) {
|
|
|
2155
2162
|
|
|
2156
2163
|
writeState(root, updatedState);
|
|
2157
2164
|
|
|
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
|
|
2161
|
-
//
|
|
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 = [];
|
|
2162
2171
|
try {
|
|
2163
2172
|
const intentsDir = join(root, '.agentxchain', 'intake', 'intents');
|
|
2164
2173
|
if (existsSync(intentsDir)) {
|
|
@@ -2172,17 +2181,23 @@ export function initializeGovernedRun(root, config, options = {}) {
|
|
|
2172
2181
|
if (intent.cross_run_durable === true) continue;
|
|
2173
2182
|
if (intent.approved_run_id === runId) continue;
|
|
2174
2183
|
|
|
2184
|
+
const prevStatus = intent.status;
|
|
2175
2185
|
if (intent.approved_run_id && intent.approved_run_id !== runId) {
|
|
2176
|
-
// Intent from a different run — archive it
|
|
2186
|
+
// Intent from a different run — archive it (BUG-34)
|
|
2177
2187
|
intent.status = 'suppressed';
|
|
2178
2188
|
intent.updated_at = intNow;
|
|
2179
2189
|
intent.archived_reason = `stale: approved under run ${intent.approved_run_id}, archived on run ${runId} initialization`;
|
|
2180
2190
|
if (!intent.history) intent.history = [];
|
|
2181
|
-
intent.history.push({ from:
|
|
2191
|
+
intent.history.push({ from: prevStatus, to: 'suppressed', at: intNow, reason: intent.archived_reason });
|
|
2182
2192
|
} else if (!intent.approved_run_id) {
|
|
2183
|
-
//
|
|
2184
|
-
|
|
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';
|
|
2185
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);
|
|
2186
2201
|
}
|
|
2187
2202
|
safeWriteJson(ip, intent);
|
|
2188
2203
|
} catch { /* non-fatal per-intent */ }
|
|
@@ -2190,13 +2205,32 @@ export function initializeGovernedRun(root, config, options = {}) {
|
|
|
2190
2205
|
}
|
|
2191
2206
|
} catch { /* non-fatal — intent migration is best-effort */ }
|
|
2192
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
|
+
|
|
2193
2222
|
emitRunEvent(root, 'run_started', {
|
|
2194
2223
|
run_id: runId,
|
|
2195
2224
|
phase: updatedState.phase,
|
|
2196
2225
|
status: 'active',
|
|
2197
2226
|
payload: { provenance: provenance || {} },
|
|
2198
2227
|
});
|
|
2199
|
-
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 };
|
|
2200
2234
|
}
|
|
2201
2235
|
|
|
2202
2236
|
/**
|
|
@@ -3160,22 +3194,10 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3160
3194
|
// that this turn didn't modify.
|
|
3161
3195
|
const declaredFiles = new Set((turnResult.files_changed || []).map(f => f.replace(/^\.\//, '')));
|
|
3162
3196
|
const exitGateId = preGateResult.gate_id || 'unknown_gate';
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
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(/^\.\//, '')))];
|
|
3197
|
+
const uniqueFailingFiles = [...new Set(
|
|
3198
|
+
(preGateResult.failing_files || preGateResult.missing_files || [])
|
|
3199
|
+
.map(f => f.replace(/^\.\//, ''))
|
|
3200
|
+
)];
|
|
3179
3201
|
const uncoveredFiles = uniqueFailingFiles.filter(f => !declaredFiles.has(f));
|
|
3180
3202
|
|
|
3181
3203
|
const gateSemanticMode = config.gate_semantic_coverage_mode || 'strict';
|
|
@@ -4207,6 +4229,118 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
4207
4229
|
}
|
|
4208
4230
|
}
|
|
4209
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
|
+
|
|
4210
4344
|
// ── Transaction journal: prepare before committing writes ──────────────
|
|
4211
4345
|
const transactionId = generateId('txn');
|
|
4212
4346
|
const journal = {
|
package/src/lib/intake.js
CHANGED
|
@@ -647,11 +647,16 @@ export function archiveStaleIntents(root, newRunId) {
|
|
|
647
647
|
safeWriteJson(intentPath, intent);
|
|
648
648
|
archived++;
|
|
649
649
|
} else if (!intent.approved_run_id) {
|
|
650
|
-
//
|
|
651
|
-
|
|
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';
|
|
652
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 });
|
|
653
658
|
safeWriteJson(intentPath, intent);
|
|
654
|
-
|
|
659
|
+
archived++;
|
|
655
660
|
}
|
|
656
661
|
}
|
|
657
662
|
|
|
@@ -816,9 +821,11 @@ export function approveIntent(root, intentId, options = {}) {
|
|
|
816
821
|
const reason = options.reason || (previousStatus === 'blocked' ? 're-approved after block resolution' : 'approved for planning');
|
|
817
822
|
const now = nowISO();
|
|
818
823
|
|
|
819
|
-
// BUG-34: stamp the current run_id on approval so the intent is scoped
|
|
820
|
-
// the run that approved it.
|
|
821
|
-
//
|
|
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.
|
|
822
829
|
if (!intent.approved_run_id) {
|
|
823
830
|
const statePath = join(root, '.agentxchain', 'state.json');
|
|
824
831
|
if (existsSync(statePath)) {
|
|
@@ -826,10 +833,14 @@ export function approveIntent(root, intentId, options = {}) {
|
|
|
826
833
|
const state = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
827
834
|
if (state.run_id) {
|
|
828
835
|
intent.approved_run_id = state.run_id;
|
|
836
|
+
} else {
|
|
837
|
+
intent.cross_run_durable = true;
|
|
829
838
|
}
|
|
830
839
|
} catch {
|
|
831
840
|
// non-fatal — stamp is best-effort during approval
|
|
832
841
|
}
|
|
842
|
+
} else {
|
|
843
|
+
intent.cross_run_durable = true;
|
|
833
844
|
}
|
|
834
845
|
}
|
|
835
846
|
|
|
@@ -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,
|