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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.135.0",
3
+ "version": "2.135.1",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 retry = retryCoordinatorWorkstream(
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
- created_by: 'operator',
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
- let execution;
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
- if ((execution?.exitCode ?? 0) === 0) {
672
- const projection = projectAcceptedCoordinatorTurn(
673
- mission.coordinator.workspace_path,
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: retry.retryResult.repo_id,
715
- retried_repo_turn_id: retry.retryResult.failed_turn_id,
716
- repo_turn_id: retry.retryResult.reissued_turn_id,
717
- role: retry.retryResult.role,
718
- bundle_path: retry.retryResult.bundle_path,
719
- context_ref: retry.retryResult.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: execution?.exitCode ?? 0,
723
- warnings: retryWarnings,
724
- reconciliation_required: retryWarnings.length > 0,
765
+ exit_code: retryExecution.exit_code ?? 0,
766
+ warnings: retryExecution.warnings || [],
767
+ reconciliation_required: retryExecution.reconciliation_required || false,
725
768
  }, null, 2));
726
- if ((execution?.exitCode ?? 0) !== 0) {
727
- process.exit(execution.exitCode);
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(retry.retryResult.repo_id)}`));
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: ${retry.retryResult.repo_id}`));
738
- console.log(chalk.dim(` Old Turn: ${retry.retryResult.failed_turn_id}`));
739
- console.log(chalk.dim(` New Turn: ${retry.retryResult.reissued_turn_id}`));
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 (retryWarnings.length > 0) {
742
- console.log(chalk.yellow(` Warning: ${retryWarnings[0].message}`));
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 ((execution?.exitCode ?? 0) !== 0) {
747
- console.error(chalk.red(`Coordinator retry execution ended with exit code ${execution.exitCode}.`));
748
- process.exit(execution.exitCode);
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 no approved_run_id are adopted into the current run
2161
- // (they were created while the project was idle or pre-run).
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: 'approved', to: 'suppressed', at: intNow, reason: intent.archived_reason });
2191
+ intent.history.push({ from: prevStatus, to: 'suppressed', at: intNow, reason: intent.archived_reason });
2182
2192
  } else if (!intent.approved_run_id) {
2183
- // Legacy intent with no run binding — adopt into current run
2184
- intent.approved_run_id = runId;
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 { ok: true, state: attachLegacyCurrentTurnAlias(updatedState) };
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
- // Extract file paths from gate failure reasons and missing_files
3165
- const failingFiles = [
3166
- ...(preGateResult.missing_files || []),
3167
- ];
3168
-
3169
- // Also extract file paths from failure reasons (e.g., ".planning/IMPLEMENTATION_NOTES.md: ...")
3170
- for (const reason of (preGateResult.reasons || [])) {
3171
- const fileMatch = reason.match(/(?:Required file missing|file): ([^\s,]+)/);
3172
- if (fileMatch) failingFiles.push(fileMatch[1]);
3173
- // Also catch paths at the start of semantic failure messages
3174
- const semanticMatch = reason.match(/^([^\s:]+\.md):/);
3175
- if (semanticMatch) failingFiles.push(semanticMatch[1]);
3176
- }
3177
-
3178
- const uniqueFailingFiles = [...new Set(failingFiles.map(f => f.replace(/^\.\//, '')))];
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
- // Legacy intent with no run binding — adopt into current run
651
- intent.approved_run_id = newRunId;
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
- adopted++;
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 to
820
- // the run that approved it. Intents without approved_run_id are treated as
821
- // legacy/unbound and filtered out by run-scoped queries.
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,