nx 23.3.0-beta.7 → 23.3.0-beta.9

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.
Files changed (138) hide show
  1. package/README.md +1 -1
  2. package/dist/nx-cloud-internals.d.ts +1 -1
  3. package/dist/nx-cloud-internals.js +4 -4
  4. package/dist/src/command-line/affected/affected.js +20 -1
  5. package/dist/src/command-line/affected/command-object.js +1 -1
  6. package/dist/src/command-line/migrate/agentic/master/run-master-session.d.ts +6 -2
  7. package/dist/src/command-line/migrate/agentic/master/run-master-session.js +88 -14
  8. package/dist/src/command-line/migrate/command-object.d.ts +1 -0
  9. package/dist/src/command-line/migrate/command-object.js +6 -1
  10. package/dist/src/command-line/migrate/migrate-analytics.d.ts +34 -9
  11. package/dist/src/command-line/migrate/migrate-analytics.js +71 -10
  12. package/dist/src/command-line/migrate/migrate-config.d.ts +5 -2
  13. package/dist/src/command-line/migrate/migrate-config.js +19 -11
  14. package/dist/src/command-line/migrate/migrate.d.ts +2 -0
  15. package/dist/src/command-line/migrate/migrate.js +152 -45
  16. package/dist/src/command-line/migrate/resolve-package-version.js +2 -19
  17. package/dist/src/command-line/migrate/run/broker.d.ts +14 -2
  18. package/dist/src/command-line/migrate/run/broker.js +95 -34
  19. package/dist/src/command-line/migrate/run/existing-run-report.d.ts +55 -0
  20. package/dist/src/command-line/migrate/run/existing-run-report.js +225 -0
  21. package/dist/src/command-line/migrate/run/give-up.d.ts +41 -0
  22. package/dist/src/command-line/migrate/run/give-up.js +148 -0
  23. package/dist/src/command-line/migrate/run/index.d.ts +6 -4
  24. package/dist/src/command-line/migrate/run/index.js +15 -2
  25. package/dist/src/command-line/migrate/run/issues.d.ts +14 -0
  26. package/dist/src/command-line/migrate/run/issues.js +59 -0
  27. package/dist/src/command-line/migrate/run/orchestrator.d.ts +40 -1
  28. package/dist/src/command-line/migrate/run/orchestrator.js +509 -160
  29. package/dist/src/command-line/migrate/run/run-id.d.ts +0 -8
  30. package/dist/src/command-line/migrate/run/run-id.js +0 -30
  31. package/dist/src/command-line/migrate/run/run-state.d.ts +11 -9
  32. package/dist/src/command-line/migrate/run/run-state.js +25 -12
  33. package/dist/src/command-line/migrate/run/runbook.js +9 -4
  34. package/dist/src/command-line/migrate/run/state-lock.d.ts +31 -0
  35. package/dist/src/command-line/migrate/run/state-lock.js +126 -0
  36. package/dist/src/command-line/migrate/run/state-machine.d.ts +15 -0
  37. package/dist/src/command-line/migrate/run/state-machine.js +137 -10
  38. package/dist/src/command-line/migrate/run/util.d.ts +1 -0
  39. package/dist/src/command-line/migrate/run/util.js +21 -0
  40. package/dist/src/command-line/migrate/run/worker.js +3 -0
  41. package/dist/src/command-line/migrate/step-actions.d.ts +1 -1
  42. package/dist/src/command-line/migrate/step-actions.js +7 -1
  43. package/dist/src/command-line/migrate/text.d.ts +6 -0
  44. package/dist/src/command-line/migrate/text.js +25 -0
  45. package/dist/src/command-line/release/utils/release-graph.js +1 -1
  46. package/dist/src/command-line/release/utils/remote-release-clients/github.js +2 -5
  47. package/dist/src/command-line/release/utils/remote-release-clients/remote-release-client.d.ts +2 -2
  48. package/dist/src/command-line/release/utils/remote-release-clients/remote-release-client.js +4 -5
  49. package/dist/src/command-line/release/version/version-actions.d.ts +3 -0
  50. package/dist/src/command-line/reset/reset.js +10 -4
  51. package/dist/src/command-line/show/command-object.js +1 -1
  52. package/dist/src/command-line/show/projects.js +22 -0
  53. package/dist/src/command-line/yargs-utils/shared-options.d.ts +4 -0
  54. package/dist/src/command-line/yargs-utils/shared-options.js +16 -0
  55. package/dist/src/config/task-graph.d.ts +1 -1
  56. package/dist/src/config/workspace-json-project-json.d.ts +4 -4
  57. package/dist/src/daemon/client/client.d.ts +3 -3
  58. package/dist/src/daemon/client/client.js +4 -4
  59. package/dist/src/daemon/message-types/select-affected-tasks.d.ts +3 -0
  60. package/dist/src/daemon/message-types/ultracache-configuration-version.d.ts +8 -0
  61. package/dist/src/daemon/server/handle-hash-tasks.d.ts +2 -2
  62. package/dist/src/daemon/server/handle-hash-tasks.js +3 -3
  63. package/dist/src/daemon/server/handle-select-affected-tasks.js +5 -2
  64. package/dist/src/daemon/server/ultracache-configuration-state.d.ts +7 -0
  65. package/dist/src/daemon/server/{io-snapshots-state.js → ultracache-configuration-state.js} +5 -4
  66. package/dist/src/devkit-internals.d.ts +3 -1
  67. package/dist/src/devkit-internals.js +8 -3
  68. package/dist/src/hasher/create-task-hasher.d.ts +3 -3
  69. package/dist/src/hasher/create-task-hasher.js +6 -6
  70. package/dist/src/hasher/hash-task.d.ts +2 -2
  71. package/dist/src/hasher/hash-task.js +6 -5
  72. package/dist/src/hasher/native-task-hasher-impl.d.ts +7 -7
  73. package/dist/src/hasher/native-task-hasher-impl.js +19 -19
  74. package/dist/src/hasher/task-hasher.d.ts +14 -14
  75. package/dist/src/hasher/task-hasher.js +13 -13
  76. package/dist/src/native/index.d.ts +151 -102
  77. package/dist/src/native/native-bindings.js +4 -5
  78. package/dist/src/native/nx.wasm32-wasi.debug.wasm +0 -0
  79. package/dist/src/native/nx.wasm32-wasi.wasm +0 -0
  80. package/dist/src/nx-cloud/generators/connect-to-nx-cloud/connect-to-nx-cloud.js +15 -8
  81. package/dist/src/nx-cloud/update-manager.d.ts +3 -3
  82. package/dist/src/nx-cloud/update-manager.js +19 -13
  83. package/dist/src/nx-cloud/utilities/client.d.ts +1 -1
  84. package/dist/src/nx-cloud/utilities/client.js +1 -1
  85. package/dist/src/nx-cloud/utilities/is-workspace-claimed.js +2 -1
  86. package/dist/src/nx-cloud/utilities/nx-cloud-http-client.d.ts +5 -0
  87. package/dist/src/nx-cloud/utilities/nx-cloud-http-client.js +16 -0
  88. package/dist/src/nx-cloud/utilities/url-shorten.js +17 -10
  89. package/dist/src/plugins/js/project-graph/build-dependencies/target-project-locator.js +22 -18
  90. package/dist/src/plugins/js/utils/register.js +11 -3
  91. package/dist/src/project-graph/affected/affected-reasons.d.ts +116 -0
  92. package/dist/src/project-graph/affected/affected-reasons.js +459 -0
  93. package/dist/src/project-graph/affected/affected-tasks.d.ts +23 -10
  94. package/dist/src/project-graph/affected/affected-tasks.js +203 -31
  95. package/dist/src/project-graph/affected/print-explanation.d.ts +11 -0
  96. package/dist/src/project-graph/affected/print-explanation.js +44 -0
  97. package/dist/src/tasks-runner/default-tasks-runner.js +1 -1
  98. package/dist/src/tasks-runner/init-tasks-runner.d.ts +5 -5
  99. package/dist/src/tasks-runner/init-tasks-runner.js +8 -15
  100. package/dist/src/tasks-runner/run-command.d.ts +7 -7
  101. package/dist/src/tasks-runner/run-command.js +19 -23
  102. package/dist/src/tasks-runner/task-orchestrator.d.ts +7 -3
  103. package/dist/src/tasks-runner/task-orchestrator.js +9 -8
  104. package/dist/src/tasks-runner/tasks-runner.d.ts +2 -0
  105. package/dist/src/ultracache/config.d.ts +21 -0
  106. package/dist/src/ultracache/config.js +29 -0
  107. package/dist/src/ultracache/fetch.d.ts +21 -0
  108. package/dist/src/{io-snapshots → ultracache}/fetch.js +9 -9
  109. package/dist/src/ultracache/overrides.d.ts +17 -0
  110. package/dist/src/{io-snapshots → ultracache}/overrides.js +14 -13
  111. package/dist/src/ultracache/report.d.ts +13 -0
  112. package/dist/src/{io-snapshots → ultracache}/report.js +15 -21
  113. package/dist/src/ultracache/store.d.ts +40 -0
  114. package/dist/src/{io-snapshots → ultracache}/store.js +45 -45
  115. package/dist/src/utils/ab-testing.js +11 -12
  116. package/dist/src/utils/command-line-utils.d.ts +1 -0
  117. package/dist/src/utils/create-cli-options.d.ts +6 -0
  118. package/dist/src/utils/create-cli-options.js +29 -0
  119. package/dist/src/utils/db-connection.d.ts +1 -0
  120. package/dist/src/utils/db-connection.js +1 -0
  121. package/dist/src/utils/git-utils.d.ts +7 -0
  122. package/dist/src/utils/git-utils.js +21 -9
  123. package/dist/src/utils/http-client.d.ts +27 -0
  124. package/dist/src/utils/http-client.js +193 -0
  125. package/package.json +12 -38
  126. package/dist/src/daemon/message-types/io-snapshot-version.d.ts +0 -5
  127. package/dist/src/daemon/server/io-snapshots-state.d.ts +0 -7
  128. package/dist/src/io-snapshots/config.d.ts +0 -23
  129. package/dist/src/io-snapshots/config.js +0 -32
  130. package/dist/src/io-snapshots/fetch.d.ts +0 -21
  131. package/dist/src/io-snapshots/outputs.d.ts +0 -12
  132. package/dist/src/io-snapshots/outputs.js +0 -33
  133. package/dist/src/io-snapshots/overrides.d.ts +0 -17
  134. package/dist/src/io-snapshots/report.d.ts +0 -13
  135. package/dist/src/io-snapshots/store.d.ts +0 -37
  136. package/dist/src/nx-cloud/utilities/axios.d.ts +0 -4
  137. package/dist/src/nx-cloud/utilities/axios.js +0 -17
  138. /package/dist/src/daemon/message-types/{io-snapshot-version.js → ultracache-configuration-version.js} +0 -0
@@ -1,12 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runOrchestratorInit = runOrchestratorInit;
4
+ exports.runOrchestratorResume = runOrchestratorResume;
5
+ exports.holdRunToContinue = holdRunToContinue;
6
+ exports.activeRunToReplace = activeRunToReplace;
7
+ exports.releaseRunToHandOff = releaseRunToHandOff;
4
8
  exports.runOrchestratorReconcile = runOrchestratorReconcile;
5
9
  exports.completionWarnings = completionWarnings;
6
10
  const child_process_1 = require("child_process");
7
11
  const crypto_1 = require("crypto");
8
12
  const fs_1 = require("fs");
9
13
  const path_1 = require("path");
14
+ const native_1 = require("../../../native");
10
15
  const fileutils_1 = require("../../../utils/fileutils");
11
16
  const atomic_write_1 = require("./atomic-write");
12
17
  const clean_retry_1 = require("./clean-retry");
@@ -25,6 +30,7 @@ const run_id_1 = require("./run-id");
25
30
  const run_state_1 = require("./run-state");
26
31
  const state_lock_1 = require("./state-lock");
27
32
  const state_machine_1 = require("./state-machine");
33
+ const give_up_1 = require("./give-up");
28
34
  const migration_shape_1 = require("../migration-shape");
29
35
  const util_1 = require("./util");
30
36
  const text_1 = require("../text");
@@ -32,6 +38,7 @@ const agent_output_1 = require("./agent-output");
32
38
  const agent_work_payload_1 = require("./agent-work-payload");
33
39
  const issues_1 = require("./issues");
34
40
  const runbook_1 = require("./runbook");
41
+ const existing_run_report_1 = require("./existing-run-report");
35
42
  const package_manager_1 = require("../../../utils/package-manager");
36
43
  // The dark migrate orchestrator: drives a durable run one dispense at a time.
37
44
  // An outer AI agent runs each dispensed command and re-invokes `nx migrate
@@ -44,11 +51,8 @@ const HANG_THRESHOLD_MS = 15 * 60 * 1000;
44
51
  // 'no-progress' action. A still-running worker is exempt until the hang
45
52
  // threshold: waiting on a live worker is not looping.
46
53
  const NO_PROGRESS_THRESHOLD = 3;
47
- // Rearms of one step before its failed/died dispense escalates: the retry
48
- // guidance flips against retrying and the preselected `next` is withheld, so
49
- // an agent that follows `next` blindly cannot retry forever. An explicit
50
- // retry is still honored; the cap never refuses.
51
- const REARM_ESCALATION_CAP = 3;
54
+ // Two rearms per step: one for the diagnosed fix and one for its correction.
55
+ const REARM_ESCALATION_CAP = 2;
52
56
  const INIT_CONTINUE_HINT = 're-run the command, or unset NX_MIGRATE_ORCHESTRATOR to use the standard migrate flow.';
53
57
  function continueRunHint(runId) {
54
58
  return `re-run the command to continue run '${runId}'.`;
@@ -84,30 +88,50 @@ function refuseUnsafeScratchExposure(exposure, thenWhat) {
84
88
  }
85
89
  }
86
90
  async function runOrchestratorInit(input) {
87
- const { root, migrationsJson, createCommits, commitPrefix, skipInstall, installedNxVersion, validate, emitAgentInstructions = true, } = input;
88
- const planHash = (0, run_id_1.computePlanHash)(migrationsJson);
89
- // An active run means a prior init already happened (e.g. it crashed before
90
- // the agent's first reconcile); starting a second run would compete with it.
91
- // Same plan: resume it. Different plan: refuse rather than guess which plan
92
- // the agent means. NewerRunStateFormatError propagates.
93
- const active = findActiveRunForPlan(root, planHash);
94
- // Dispensed commands interpolate migration ids verbatim, so every init
95
- // (fresh or resumed) validates the incoming plan's ids. After the mismatch
96
- // check: a plan that will be refused anyway should get the more actionable
97
- // mismatch error, not this one.
91
+ const { root, migrationsJson, migrationsPath, createCommits, commitPrefix, skipInstall, installedNxVersion, validate, emitAgentInstructions = true, onExistingRun = 'report', replaceRunId, confirmStart, } = input;
98
92
  const migrations = (migrationsJson.migrations ?? []);
99
93
  const sorted = (0, sort_migrations_1.sortMigrations)(migrations.slice(), {
100
94
  hoistHandoffGitignore: true,
101
95
  });
102
- for (const m of sorted) {
103
- const id = `${m.package}:${m.name}`;
96
+ const plannedIds = sorted.map((m) => `${m.package}:${m.name}`);
97
+ // An active run means a prior init already happened (e.g. it crashed before
98
+ // the agent's first reconcile); starting a second run would compete with it,
99
+ // and whether to continue it or replace it is the user's call, not a guess
100
+ // from the plans. Reported before the migration id check below: this plan
101
+ // is not being started. NewerRunStateFormatError propagates.
102
+ const active = onExistingRun === 'start-fresh'
103
+ ? activeRunToReplace(root, replaceRunId)
104
+ : findActiveRunForInit(root);
105
+ if (active &&
106
+ (onExistingRun === 'report' ||
107
+ (replaceRunId !== undefined && active.runId !== replaceRunId))) {
108
+ return reportExistingRun(root, active.runId, active.state, plannedIds, migrationsPath, emitAgentInstructions, replaceRunId);
109
+ }
110
+ // Held through confirmStart so a concurrent start-fresh cannot delete the
111
+ // run while the user is asked. The refusals run here first, before the
112
+ // prompt, the .gitignore fallback and the checkpoint; the locked pass below
113
+ // is authoritative. Skipped without the scratch dir: the lock would create it.
114
+ if (active) {
115
+ (0, state_lock_1.holdRunActivity)(root, active.runId);
116
+ }
117
+ if ((0, fs_1.existsSync)((0, run_state_1.migrateRunsDir)(root))) {
118
+ (0, state_lock_1.withRunCreationLock)(root, () => {
119
+ refuseLiveReservation(root);
120
+ if (active) {
121
+ refuseUndeletableRun(root, active.runId, active.state);
122
+ }
123
+ });
124
+ }
125
+ // Dispensed commands interpolate migration ids verbatim, so every init
126
+ // validates the incoming plan's ids. Before the delete below: a run must
127
+ // not be thrown away for a plan that cannot start.
128
+ for (const id of plannedIds) {
104
129
  if (!run_state_1.SHELL_SAFE_VALUE.test(id)) {
105
130
  throw new Error(`The migration id '${id}' contains characters that are not shell-safe. Orchestrated runs require shell-safe migration ids.`);
106
131
  }
107
132
  }
108
- const policy = { createCommits, skipInstall };
109
- if (active) {
110
- return resumeRun(root, active.runId, active.state, policy, emitAgentInstructions);
133
+ if (confirmStart && !(await confirmStart())) {
134
+ return { kind: 'refused' };
111
135
  }
112
136
  const runId = (0, run_id_1.createRunId)();
113
137
  const dir = (0, run_state_1.runDir)(root, runId);
@@ -144,12 +168,48 @@ async function runOrchestratorInit(input) {
144
168
  // .gitignore, Lerna without nx.json).
145
169
  refuseUnsafeScratchExposure((0, git_utils_1.getPathCommitExposure)(types_1.MIGRATE_RUNS_RELATIVE_DIR, root), INIT_CONTINUE_HINT);
146
170
  }
147
- // Checkpoint pre-existing working-tree state BEFORE the run dir exists, so
148
- // the checkpoint's `git add -A` can't track this run's scratch and a clean
149
- // tree stays uncommitted (writing run.json would otherwise dirty it and fire
150
- // a spurious checkpoint). A crash between here and createRun leaves the
151
- // committed changes orphaned but never lost; the next init re-checkpoints a
152
- // now-clean tree as a no-op.
171
+ // One creation-lock section: two inits cannot both create a run, and a
172
+ // start-fresh's delete and the new directory's reservation (no run.json
173
+ // yet) land together. Git stays outside the lock: a hook re-entering nx
174
+ // migrate would wait on this process. The .gitignore fallback is idempotent.
175
+ let deleted = false;
176
+ const reserved = (0, state_lock_1.withRunCreationLock)(root, () => {
177
+ refuseLiveReservation(root);
178
+ const nowActive = findActiveRunForInit(root, active !== null);
179
+ if (nowActive && nowActive.runId !== active?.runId) {
180
+ return nowActive;
181
+ }
182
+ if (active) {
183
+ // Completed meanwhile (a reconcile finished it): nothing to replace.
184
+ if (!nowActive) {
185
+ throw new Error(noActiveRunToReplace(active.runId));
186
+ }
187
+ deleted = deleteRunRecord(root, active.runId);
188
+ }
189
+ // The snapshot must exist before run.json makes the run discoverable: a
190
+ // crash in between must not leave an active run without its plan.
191
+ (0, fs_1.mkdirSync)(dir, { recursive: true });
192
+ (0, fileutils_1.writeJsonFile)((0, path_1.join)(dir, PLAN_SNAPSHOT_0), migrationsJson);
193
+ // Held from here until run.json is written below and past it: a run must
194
+ // never be discoverable, or reserved, without a holder.
195
+ (0, state_lock_1.registerRunActivity)(dir);
196
+ return null;
197
+ });
198
+ if (reserved) {
199
+ // A run created concurrently since the check above. Reported, not
200
+ // deleted, even under 'start-fresh': that consent covered the run the
201
+ // user saw, not one that appeared while this init was running.
202
+ return reportExistingRun(root, reserved.runId, reserved.state, plannedIds, migrationsPath, emitAgentInstructions);
203
+ }
204
+ if (deleted) {
205
+ removeDeletedRunDir(root, active.runId);
206
+ }
207
+ // Checkpoint pre-existing working-tree state BEFORE run.json exists, so a
208
+ // clean tree stays uncommitted (writing run.json would otherwise dirty it
209
+ // and fire a spurious checkpoint). A crash between here and createRun
210
+ // leaves the committed changes orphaned but never lost, and the reserved
211
+ // directory unlocked, which discovery ignores; the next init re-checkpoints
212
+ // a now-clean tree as a no-op.
153
213
  const checkpoint = createCommits ? checkpointEntry(root, commitPrefix) : null;
154
214
  // The preflight checkpoint swallows its own failures, so the tree itself is
155
215
  // the only reliable signal: anything still uncommitted here predates every
@@ -157,6 +217,7 @@ async function runOrchestratorInit(input) {
157
217
  // failed probe counts as dirty: mistaking it for clean would let a later
158
218
  // retry-clean reset destroy the very work this flag exists to protect.
159
219
  const checkpointFailed = createCommits && (0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean';
220
+ const branch = (0, git_utils_1.getGitCurrentBranch)(root);
160
221
  const state = {
161
222
  formatVersion: run_state_1.CURRENT_RUN_STATE_FORMAT_VERSION,
162
223
  runId,
@@ -168,10 +229,10 @@ async function runOrchestratorInit(input) {
168
229
  ...(skipInstall ? { skipInstall: true } : {}),
169
230
  validate: validate !== false,
170
231
  runbookPath: runbook_1.RUNBOOK_FILE_NAME,
232
+ ...(branch ? { branch } : {}),
171
233
  rounds: [
172
234
  {
173
235
  index: 0,
174
- planHash,
175
236
  planSnapshot: PLAN_SNAPSHOT_0,
176
237
  },
177
238
  ],
@@ -180,39 +241,77 @@ async function runOrchestratorInit(input) {
180
241
  ...(checkpointFailed ? { checkpointFailed: true } : {}),
181
242
  analytics: { startEmitted: false, completeEmitted: false },
182
243
  };
183
- // The check/create boundary runs under the creation lock: without it, two
184
- // concurrent inits could both observe no active run above and create
185
- // competing runs against the same workspace. The git side effects above
186
- // stay outside the lock (locked sections must remain synchronous); a losing
187
- // init's checkpoint commit is the same orphan shape as the crash window
188
- // above, and the fallback's .gitignore edit is idempotent.
189
- const winner = (0, state_lock_1.withRunCreationLock)(root, () => {
190
- const nowActive = findActiveRunForPlan(root, planHash);
191
- if (nowActive) {
192
- return nowActive;
193
- }
194
- // The snapshot must exist before run.json makes the run discoverable: a
195
- // crash in between must not leave an active run without its plan.
196
- (0, fs_1.mkdirSync)(dir, { recursive: true });
197
- (0, fileutils_1.writeJsonFile)((0, path_1.join)(dir, PLAN_SNAPSHOT_0), migrationsJson);
198
- // The runbook gets the same crash guarantee: a discoverable run always
199
- // has the runbook a resume re-emits from disk. 'wx' creates without
200
- // following links, so nothing pre-planted at the path can redirect it.
244
+ (0, state_lock_1.withRunCreationLock)(root, () => {
245
+ // The runbook gets the snapshot's crash guarantee: a discoverable run
246
+ // always has the runbook a resume re-emits from disk. 'wx' creates
247
+ // without following links, so nothing pre-planted at the path can
248
+ // redirect it.
201
249
  (0, fs_1.writeFileSync)((0, path_1.join)(dir, runbook_1.RUNBOOK_FILE_NAME), (0, runbook_1.renderRunbook)(runbookContext(root, runId, state)), { flag: 'wx' });
202
250
  (0, run_state_1.createRun)(root, state);
203
- return null;
204
251
  });
205
- if (winner) {
206
- return resumeRun(root, winner.runId, winner.state, policy, emitAgentInstructions);
207
- }
208
252
  return finishInit(root, dir, runId, state, 'created', emitAgentInstructions);
209
253
  }
210
- // Reads the newest active run, refusing one whose plan differs from the
211
- // incoming plan; null when no run is active. Uninterpretable run dirs refuse
212
- // a fresh start (one of them could be an active run this init would compete
213
- // with) but only warn when a healthy active run is being resumed.
214
- // NewerRunStateFormatError propagates from the read.
215
- function findActiveRunForPlan(root, planHash) {
254
+ /**
255
+ * Continues the active run `runId` names: the `--run-migrations --run-id`
256
+ * shape on both paths. Refuses as holdRunToContinue does.
257
+ */
258
+ function runOrchestratorResume(input) {
259
+ const { root, runId, policy, emitAgentInstructions = true } = input;
260
+ const state = holdRunToContinue(root, runId);
261
+ return resumeRun(root, runId, state, policy, emitAgentInstructions);
262
+ }
263
+ /**
264
+ * Holds the active run `runId` names and returns its state. A continue calls
265
+ * this before the install and the agent selection: a concurrent start-fresh
266
+ * must not delete the run meanwhile. Throws when the id names no run or a
267
+ * finished one, and while another process holds the run: continuing would
268
+ * open a second session over a live one. Under WASM nothing is held and no
269
+ * holder refuses.
270
+ */
271
+ function holdRunToContinue(root, runId) {
272
+ if (!run_id_1.RUN_ID_SAFE.test(runId)) {
273
+ throw new Error(`Invalid run id '${runId}'.`);
274
+ }
275
+ const dir = (0, run_state_1.runDir)(root, runId);
276
+ if (!(0, run_state_1.hasRunState)(dir)) {
277
+ throw new Error(`No migrate run '${runId}' was found under ${types_1.MIGRATE_RUNS_RELATIVE_DIR}.`);
278
+ }
279
+ const state = (0, run_state_1.readRunState)(dir);
280
+ if (state.status !== 'active') {
281
+ throw new Error(`Migrate run '${runId}' is already complete; there is nothing to continue.`);
282
+ }
283
+ (0, state_lock_1.holdRunActivity)(root, runId, true);
284
+ return state;
285
+ }
286
+ // The active run a start-fresh naming `runId` finds, read-only: init's first
287
+ // check, which the CLI also runs before the install. None refuses, since a
288
+ // completed run means the plan already ran. A different active run is returned
289
+ // too: init reports it instead of deleting it.
290
+ function activeRunToReplace(root, runId) {
291
+ if (runId !== undefined && !run_id_1.RUN_ID_SAFE.test(runId)) {
292
+ throw new Error(`Invalid run id '${runId}'.`);
293
+ }
294
+ const active = findActiveRunForInit(root, true);
295
+ if (!active) {
296
+ throw new Error(noActiveRunToReplace(runId));
297
+ }
298
+ return active;
299
+ }
300
+ /**
301
+ * Drops the hold holdRunToContinue took, for a wrapper that hands the
302
+ * continue to the workspace-local nx: the child takes its own hold, and an
303
+ * exclusive one refuses while this process still holds.
304
+ */
305
+ function releaseRunToHandOff(root, runId) {
306
+ (0, state_lock_1.releaseRunActivity)((0, run_state_1.runDir)(root, runId));
307
+ }
308
+ // Reads the newest active run; null when no run is active. Uninterpretable
309
+ // run dirs refuse a fresh start (one of them could be an active run this
310
+ // init would compete with) but only warn when a healthy active run is found,
311
+ // unless `refuseUninterpretable`: a start-fresh would delete that run and
312
+ // then find only the unreadable ones. NewerRunStateFormatError propagates
313
+ // from the read.
314
+ function findActiveRunForInit(root, refuseUninterpretable = false) {
216
315
  const { active, uninterpretable } = (0, run_state_1.findActiveRun)(root);
217
316
  if (uninterpretable.length > 0) {
218
317
  const noun = uninterpretable.length === 1 ? 'directory' : 'directories';
@@ -221,11 +320,11 @@ function findActiveRunForPlan(root, planHash) {
221
320
  // Sanitized here rather than left to the gateway: these same lines are
222
321
  // joined into the throw below, which leaves through handleErrors.
223
322
  const details = uninterpretable.map((u) => `${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${(0, text_1.singleLine)(u.dirName)}: ${(0, text_1.singleLine)(u.reason)}`);
224
- if (!active) {
323
+ if (!active || refuseUninterpretable) {
225
324
  throw new Error([
226
325
  `Whether a migrate run is still active could not be determined; starting a new run could re-apply migrations an unfinished run already applied.`,
227
326
  ...details,
228
- `Fix or remove the listed ${noun} (removing a run directory abandons that run; migrations it already applied remain applied), then re-run the command.`,
327
+ `Fix or remove the listed ${noun}, then re-run the command.`,
229
328
  ].join('\n'));
230
329
  }
231
330
  (0, agent_output_1.warnToAgent)({
@@ -233,22 +332,123 @@ function findActiveRunForPlan(root, planHash) {
233
332
  bodyLines: details,
234
333
  });
235
334
  }
236
- if (active && (0, state_machine_1.latestRound)(active.state)?.planHash !== planHash) {
237
- throw new Error(`A migrate run '${active.runId}' is already active with a different plan. ` +
238
- `Finish it first by running \`${reconcileCommand(root, active.runId)}\`, ` +
239
- `or remove ${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${active.runId} to abandon it.`);
240
- }
241
335
  return active;
242
336
  }
243
- // Shared resume tail for an active run found before or under the creation
244
- // lock, so the two discovery points cannot drift apart.
337
+ // A run directory without run.json but with a live activity lock is an init
338
+ // between reserving the directory and writing run.json: the checkpoint commit
339
+ // is in flight and no run may start alongside it. Refused at once, never
340
+ // waited for: a git hook re-entering nx migrate during that commit would
341
+ // otherwise wait on the process waiting for the hook. An unlocked one is the
342
+ // remains of an init that died, which discovery ignores like any other
343
+ // directory without run.json. Call under the creation lock.
344
+ function refuseLiveReservation(root) {
345
+ for (const entry of (0, fs_1.readdirSync)((0, run_state_1.migrateRunsDir)(root), {
346
+ withFileTypes: true,
347
+ })) {
348
+ if (!entry.isDirectory())
349
+ continue;
350
+ const dir = (0, path_1.join)((0, run_state_1.migrateRunsDir)(root), entry.name);
351
+ if ((0, run_state_1.hasRunState)(dir) || !(0, state_lock_1.hasAnyLiveRunActivity)(dir))
352
+ continue;
353
+ throw new Error(`Another nx migrate process is starting a run (${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${(0, text_1.singleLine)(entry.name)}). Wait for it to finish, then re-run the command.`);
354
+ }
355
+ }
356
+ // The report for an init that found a run already active. On the agent path
357
+ // the report and both ways forward go to stdout, in a block of its own kind:
358
+ // an `error` block would read as a crash, and this is a decision for the
359
+ // user. Held before the facts are read: a concurrent start-fresh must not
360
+ // delete the run under the report or the prompt after it.
361
+ function reportExistingRun(root, runId, state, plannedIds, migrationsPath, emitAgentInstructions, replaceRunId) {
362
+ (0, state_lock_1.holdRunActivity)(root, runId);
363
+ const facts = (0, existing_run_report_1.collectExistingRunFacts)(root, runId, state, plannedIds);
364
+ if (replaceRunId !== undefined) {
365
+ facts.replacedRunId = replaceRunId;
366
+ }
367
+ (0, migrate_analytics_1.reportMigrateOrchestratorExistingRun)({
368
+ ...(0, state_machine_1.runTallies)(state),
369
+ activity: facts.otherHolders === 'unknown'
370
+ ? 'unknown'
371
+ : facts.otherHolders.length > 0
372
+ ? 'held'
373
+ : 'idle',
374
+ });
375
+ if (emitAgentInstructions) {
376
+ const report = (0, existing_run_report_1.renderExistingRunReport)(facts, (0, existing_run_report_1.renderExistingRunCommands)(root, facts, migrationsPath));
377
+ (0, agent_output_1.logToAgent)(report);
378
+ const lines = (0, agent_output_1.safeLines)([
379
+ report.title,
380
+ ...report.bodyLines,
381
+ ``,
382
+ `No migration step ran in this response. Show this report to the user and let them choose; run neither command until they do.`,
383
+ ]);
384
+ (0, agent_output_1.emitStepBlock)(runId, '-', 'existing-run', {
385
+ instructions: lines.join('\n'),
386
+ });
387
+ }
388
+ return { kind: 'existing-run', runId, facts };
389
+ }
390
+ // Throws when the run's record must not be deleted: a worker the run
391
+ // dispensed or another nx migrate process is still acting on it, or another
392
+ // run is active alongside it and would be reported in place of the
393
+ // replacement. `state` is the run state the worker check reads.
394
+ function refuseUndeletableRun(root, runId, state) {
395
+ if (native_1.IS_WASM) {
396
+ throw new Error(`Not deleting migrate run '${runId}': without a native file lock nx cannot tell whether another nx migrate process is acting on it. Make sure none is, remove ${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${runId}, then re-run the command.`);
397
+ }
398
+ const holders = (0, state_lock_1.liveRunActivityPids)((0, run_state_1.runDir)(root, runId));
399
+ if (holders === 'unknown' || holders.length > 0) {
400
+ throw (0, state_lock_1.heldRunError)('deleting', runId, holders);
401
+ }
402
+ const workers = (0, existing_run_report_1.liveWorkers)(state);
403
+ if (workers.length > 0) {
404
+ throw new Error(`Not deleting migrate run '${runId}': ${(0, existing_run_report_1.workersLine)(workers)}. ` +
405
+ `Wait for it to finish, then re-run the command. If that pid is not an nx migrate worker, stop it or remove ${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${runId}, then re-run the command.`);
406
+ }
407
+ const others = (0, run_state_1.findActiveRun)(root).activeRunIds.filter((id) => id !== runId);
408
+ if (others.length > 0) {
409
+ throw new Error(`Not deleting migrate run '${runId}': other migrate runs are active on disk (${others.join(', ')}), and a new run cannot start alongside them. Remove ${types_1.MIGRATE_RUNS_RELATIVE_DIR}/<run id> for each one that should not be continued, then re-run the command.`);
410
+ }
411
+ }
412
+ function noActiveRunToReplace(runId) {
413
+ return `Not starting fresh: ${runId === undefined ? 'no migrate run' : `no migrate run '${runId}'`} is active, so there is nothing to replace. To start a run, re-run the command without --start-fresh and --run-id.`;
414
+ }
415
+ // Removes run.json under the state lock, so no reconcile or worker can start
416
+ // against a directory about to disappear. The refusals run again on fresh
417
+ // state: the early pass predates the prompt. False when already gone.
418
+ function deleteRunRecord(root, runId) {
419
+ const dir = (0, run_state_1.runDir)(root, runId);
420
+ return (0, state_lock_1.withRunStateLock)(dir, () => {
421
+ if (!(0, run_state_1.hasRunState)(dir))
422
+ return false;
423
+ refuseUndeletableRun(root, runId, (0, run_state_1.readRunState)(dir));
424
+ // The hold's lock file is inside the directory removeDeletedRunDir removes.
425
+ (0, state_lock_1.releaseRunActivity)(dir);
426
+ (0, fs_1.rmSync)((0, path_1.join)(dir, run_state_1.RUN_STATE_FILE_NAME), { force: true });
427
+ return true;
428
+ });
429
+ }
430
+ // The rest of a deleted run's directory, removed outside the locks and best
431
+ // effort: what is left holds no run.
432
+ function removeDeletedRunDir(root, runId) {
433
+ try {
434
+ (0, fs_1.rmSync)((0, run_state_1.runDir)(root, runId), { recursive: true, force: true });
435
+ }
436
+ catch (e) {
437
+ (0, agent_output_1.warnToAgent)({
438
+ title: `Could not remove the rest of ${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${runId}: ${(0, util_1.summarizeError)(e)}. It no longer holds a run; remove it when convenient.`,
439
+ });
440
+ }
441
+ (0, agent_output_1.logToAgent)({ title: `Deleted the record of migrate run ${runId}.` });
442
+ }
443
+ // The continue tail behind runOrchestratorResume: the runbook, checkpoint and
444
+ // analytics steps a paused run needs before it can be driven again.
245
445
  function resumeRun(root, runId, state, policy, emitAgentInstructions) {
246
446
  // Refused before the checkpoint retry below, which commits.
247
447
  if (state.createCommits !== policy.createCommits ||
248
448
  (state.skipInstall === true) !== policy.skipInstall) {
249
449
  throw new Error(`Nx did not resume the active migrate run because its recorded install and commit policy differs from this invocation's. ` +
250
- `Nx takes that policy from --create-commits and --skip-install, and from nx.json "migrate.createCommits" when the commit flag is omitted. ` +
251
- `Re-run with explicit flags for the policy run '${runId}' started with, or remove ${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${runId} to abandon it.`);
450
+ `Nx takes that policy from --create-commits and --skip-install on the command line. ` +
451
+ `Re-run with the flags run '${runId}' recorded (its report shows them). To start fresh, re-run with --start-fresh --run-id=${runId}, keeping the same --run-migrations[=<path>] argument used to start the run.`);
252
452
  }
253
453
  const dir = (0, run_state_1.runDir)(root, runId);
254
454
  // Ignore/index state can change while a durable run is paused (a checkout,
@@ -272,23 +472,16 @@ function resumeRun(root, runId, state, policy, emitAgentInstructions) {
272
472
  return finishInit(root, dir, runId, resumed, 'resumed', emitAgentInstructions, runbook);
273
473
  }
274
474
  function announceResume(runId, state) {
275
- const applied = state.steps.filter((s) => s.status === 'succeeded').length;
276
- const skipped = state.steps.filter((s) => s.status === 'skipped').length;
277
- const remaining = state.steps.length - applied - skipped;
278
- // A subset of `remaining`, called out separately: a run is resumed most often
279
- // because one of these is waiting on a decision, and the count alone would
280
- // read as work that has not been reached yet.
281
- const stalled = state.steps.filter((s) => s.status === 'failed' || s.status === 'died').length;
282
475
  (0, agent_output_1.logToAgent)({
283
476
  title: `nx migrate: resuming run ${runId}`,
284
477
  bodyLines: [
285
478
  ` started: ${state.createdAt}`,
286
- ` progress: ${applied} applied, ${skipped} skipped, ${remaining} remaining${stalled > 0 ? ` (${stalled} awaiting a decision)` : ''}`,
479
+ ` progress: ${(0, existing_run_report_1.progressLine)((0, state_machine_1.tallySteps)(state))}`,
287
480
  ],
288
481
  });
289
482
  }
290
483
  // Resume-only checkpoint retry, gated on checkpointFailed: a fresh init always
291
- // evaluates the checkpoint before the run dir exists, so an unflagged run
484
+ // evaluates the checkpoint before run.json exists, so an unflagged run
292
485
  // without a checkpoint entry started from a clean tree and there is nothing to
293
486
  // capture (retrying there would commit the run's own scratch instead). Skipped
294
487
  // once any migration step has advanced (a late checkpoint would absorb an
@@ -382,6 +575,9 @@ runbook) {
382
575
  });
383
576
  }
384
577
  }
578
+ if (origin === 'resumed') {
579
+ (0, migrate_analytics_1.reportMigrateOrchestratorResume)((0, state_machine_1.runTallies)(current));
580
+ }
385
581
  const content = runbook ?? ensureRunbook(root, dir, runId, current);
386
582
  if (content === null) {
387
583
  return { kind: 'refused' };
@@ -438,7 +634,7 @@ function ensureRunbook(root, dir, runId, state) {
438
634
  if (state.nxVersion !== versions_1.nxVersion) {
439
635
  const reason = [
440
636
  `The runbook for run '${runId}' is missing from ${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${runId}, and this nx (${versions_1.nxVersion}) cannot re-render the one nx ${(0, text_1.singleLine)(state.nxVersion)} wrote.`,
441
- `Restore the file (or, if the run was created before runbooks existed, re-run with the nx version that created it), or remove ${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${runId} to abandon the run (migrations it already applied remain applied).`,
637
+ `Restore the file (or, if the run was created before runbooks existed, re-run with the nx version that created it). To start fresh, re-run with --start-fresh --run-id=${runId}, keeping the same --run-migrations[=<path>] argument used to start the run.`,
442
638
  ];
443
639
  (0, agent_output_1.warnToAgent)({ title: reason[0], bodyLines: [reason[1]] });
444
640
  (0, agent_output_1.emitStepBlock)(runId, '-', 'error', {
@@ -484,6 +680,24 @@ function runbookContext(root, runId, state) {
484
680
  validate: state.validate !== false,
485
681
  };
486
682
  }
683
+ // Only the newest recorded commit is checked, so the warning stops on its own
684
+ // once the next step commits on the new history; checking every ledger entry
685
+ // would repeat it on every reconcile after a rebase.
686
+ function warnIfHistoryRewound(root, runId, state) {
687
+ const commits = (0, existing_run_report_1.recordedCommits)(state);
688
+ const newest = commits[commits.length - 1]?.sha;
689
+ if (!newest)
690
+ return;
691
+ const head = (0, git_utils_1.getLatestCommitSha)(root);
692
+ if (!head || (0, git_utils_1.getAncestorStatus)(newest, head, root) !== 'not-ancestor') {
693
+ return;
694
+ }
695
+ const facts = (0, existing_run_report_1.collectExistingRunFacts)(root, runId, state);
696
+ (0, agent_output_1.warnToAgent)({
697
+ title: `The newest commit migrate run ${runId} recorded is not reachable from HEAD. Continuing the run as asked.`,
698
+ bodyLines: (0, existing_run_report_1.renderExistingRunReport)(facts).bodyLines,
699
+ });
700
+ }
487
701
  async function runOrchestratorReconcile(input) {
488
702
  const { root, runId, stepAction } = input;
489
703
  if (!run_id_1.RUN_ID_SAFE.test(runId)) {
@@ -496,8 +710,10 @@ async function runOrchestratorReconcile(input) {
496
710
  // does something else entirely.
497
711
  throw new Error(`No migrate run '${runId}' was found under ${types_1.MIGRATE_RUNS_RELATIVE_DIR}.`);
498
712
  }
713
+ (0, state_lock_1.holdRunActivity)(root, runId);
499
714
  // Version refusal (NewerRunStateFormatError) propagates.
500
715
  let state = (0, run_state_1.readRunState)(dir);
716
+ warnIfHistoryRewound(root, runId, state);
501
717
  // Ignore/index state can change while a durable run is paused (a checkout,
502
718
  // a .gitignore edit, a forced add); re-verify before foldHandoffs, which
503
719
  // can itself commit a settled prompt step.
@@ -521,9 +737,9 @@ async function runOrchestratorReconcile(input) {
521
737
  state = detectDeaths(dir, state);
522
738
  // (c) apply the decision relay to the single failed/died step.
523
739
  if (stepAction) {
524
- // Owns the tree reservation a clean retry's reset, an adopt's commit or a
525
- // skip's install takes, released once the transition that records it is
526
- // written.
740
+ // Owns the tree reservation a clean retry's reset, an adopt's commit, a
741
+ // give-up or a skip's install takes, released once the transition that
742
+ // records it is written.
527
743
  const scope = {};
528
744
  try {
529
745
  const result = await applyReconcileStepAction(root, dir, state, stepAction, scope);
@@ -532,21 +748,31 @@ async function runOrchestratorReconcile(input) {
532
748
  return; // no transition was written
533
749
  }
534
750
  const target = result.targetStep;
751
+ // Unless a reset ran, give-up.ts commits the partial result under the
752
+ // migration's name and settles the step in one operation; a failed
753
+ // install or commit settles it with the debt recorded.
754
+ if (stepAction === 'unresolved' &&
755
+ state.createCommits &&
756
+ !result.resetTree) {
757
+ const outcome = await (0, broker_1.giveUpStepTree)(root, dir, target, state.skipInstall === true, reconcileCommand(root, runId), scope);
758
+ if (outcome.kind === 'refused') {
759
+ emitError(root, runId, outcome.reason);
760
+ return;
761
+ }
762
+ (0, give_up_1.warnAboutGiveUp)(target, outcome);
763
+ // Settled already; the dispense refuses a tree still held.
764
+ scope.lease?.release();
765
+ scope.lease = undefined;
766
+ advanceAndDispense(root, dir, runId);
767
+ return;
768
+ }
535
769
  // The commit is a git side effect, so it runs before the lock (locked
536
770
  // sections stay synchronous); the transition and any unrecorded entry then
537
771
  // land in one write. Adopt and skip keep the tree, so the install they owe
538
772
  // runs here: the next dispense would take the changed deps as its baseline.
539
- // Retries owe nothing: the rearmed attempt reconciles itself.
540
- const { entry, installFailed, recorded } = stepAction === 'adopt'
541
- ? state.createCommits
542
- ? await commitForStep(root, dir, state, target, scope)
543
- : {
544
- entry: null,
545
- installFailed: await installFailedForStep(root, dir, state, target, 'action-install', scope),
546
- }
547
- : stepAction === 'skip'
548
- ? await retainedTreeSideEffects(root, dir, state, target, 'action-install', scope)
549
- : { entry: null, installFailed: false };
773
+ // Giving up keeps it the same way when the run does not commit. Retries
774
+ // owe nothing: the rearmed attempt reconciles itself.
775
+ const { entry, installFailed, recorded } = await stepActionSideEffects(root, dir, state, target, stepAction, result.resetTree, scope);
550
776
  // A rearm starts a fresh attempt; drop the stale handoff before the rearm
551
777
  // is persisted so a crash in between can't refold the old outcome into the
552
778
  // new attempt. Losing the handoff without the rearm is safe: the step is
@@ -566,6 +792,7 @@ async function runOrchestratorReconcile(input) {
566
792
  // rejection surfaces through emitError instead of being written over. The
567
793
  // bound attempt keeps the snapshot checks above honest.
568
794
  let freshRejection;
795
+ let unresolvedArchiveError;
569
796
  const written = (0, state_lock_1.updateRunState)(dir, (fresh) => {
570
797
  // A plain retry takes no reservation of its own, so this is where it
571
798
  // learns that a live process still commits or installs for the step.
@@ -594,9 +821,16 @@ async function runOrchestratorReconcile(input) {
594
821
  freshRejection = reapplied.reason;
595
822
  return null;
596
823
  }
824
+ let settled = reapplied.state;
825
+ // The unresolved status and its issue are persisted together.
826
+ if (stepAction === 'unresolved') {
827
+ const recorded = (0, give_up_1.recordUnresolvedIssue)(dir, settled, target.id);
828
+ unresolvedArchiveError = recorded.archiveError;
829
+ settled = recorded.state;
830
+ }
597
831
  const next = installFailed
598
- ? (0, state_machine_1.markInstallFailed)(reapplied.state, target.id)
599
- : reapplied.state;
832
+ ? (0, state_machine_1.markInstallFailed)(settled, target.id)
833
+ : settled;
600
834
  // An adopted commit absorbs uncovered failed steps the same way a fold
601
835
  // commit does, so it carries their resolved issues too. A session's
602
836
  // parent records its own commits as it answers.
@@ -610,6 +844,9 @@ async function runOrchestratorReconcile(input) {
610
844
  : freshRejection);
611
845
  return;
612
846
  }
847
+ if (unresolvedArchiveError !== undefined) {
848
+ (0, give_up_1.warnUnresolvedNotArchived)(target.migrationId, unresolvedArchiveError);
849
+ }
613
850
  state = written;
614
851
  }
615
852
  finally {
@@ -920,18 +1157,23 @@ async function applyReconcileStepAction(root, dir, state, action, scope) {
920
1157
  if (held) {
921
1158
  return { kind: 'error', reason: (0, broker_1.treeBusyMessage)(held) };
922
1159
  }
1160
+ if ((action === 'retry' || action === 'retry-clean') &&
1161
+ rearmCapReached(step)) {
1162
+ return {
1163
+ kind: 'error',
1164
+ reason: `Cannot apply action '${action}' to step '${step.id}': ${rearmCapLine(step, (0, state_machine_1.commitMayBeInHistory)(state, step))}`,
1165
+ };
1166
+ }
923
1167
  // A retry-clean the dispense would not have offered must be refused here
924
1168
  // too, or a hand-crafted reconcile could reset a tree with no restore point
925
1169
  // and destroy prior steps' work.
926
1170
  if (action === 'retry-clean') {
927
1171
  const head = (0, git_utils_1.getLatestCommitSha)(root);
928
- const fallback = step.status === 'died'
929
- ? (0, state_machine_1.commitMayBeInHistory)(state, step)
930
- ? `Use 'adopt' instead.`
931
- : `Use 'adopt' or 'skip' instead.`
932
- : (0, state_machine_1.commitMayBeInHistory)(state, step)
933
- ? `Use 'retry' instead.`
934
- : `Use 'retry' or 'skip' instead.`;
1172
+ const fallback = `Use ${actionList([
1173
+ ...(step.status === 'died' ? [] : ['retry']),
1174
+ 'adopt',
1175
+ ...((0, state_machine_1.commitMayBeInHistory)(state, step) ? [] : ['skip', 'unresolved']),
1176
+ ])} instead.`;
935
1177
  if (!(0, clean_retry_1.canOfferCleanRetry)(root, state, step, head)) {
936
1178
  return {
937
1179
  kind: 'error',
@@ -964,6 +1206,35 @@ async function applyReconcileStepAction(root, dir, state, action, scope) {
964
1206
  };
965
1207
  }
966
1208
  }
1209
+ // Giving up discards what a generator that never completed left behind
1210
+ // when the same restore point a clean retry needs exists, through the same
1211
+ // reserved reset. When there is no such point the tree is kept: what an
1212
+ // agent authored is never discarded, and a reset here would have no
1213
+ // verified target.
1214
+ const resetTree = action === 'unresolved' &&
1215
+ unresolvedResetsTree(state, step, (0, clean_retry_1.canOfferCleanRetry)(root, state, step, (0, git_utils_1.getLatestCommitSha)(root)));
1216
+ if (resetTree) {
1217
+ try {
1218
+ await (0, broker_1.resetStepTree)(dir, step, () => (0, clean_retry_1.resetForCleanRetry)(root, dir, step.id), scope);
1219
+ }
1220
+ catch (e) {
1221
+ if (e instanceof broker_1.BrokerStaleRequestError ||
1222
+ e instanceof broker_1.BrokerUnavailableError ||
1223
+ e instanceof broker_1.TreeBusyError) {
1224
+ throw e;
1225
+ }
1226
+ return {
1227
+ kind: 'error',
1228
+ reason: `Cannot apply action 'unresolved' to step '${step.id}': the reset to ${step.gitRefBefore} failed: ${e instanceof Error ? e.message : String(e)}`,
1229
+ };
1230
+ }
1231
+ if ((0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean') {
1232
+ return {
1233
+ kind: 'error',
1234
+ reason: `Cannot apply action 'unresolved' to step '${step.id}': the working tree is not verifiably clean after the reset to ${step.gitRefBefore}. Inspect it with \`git status\`, then re-run it.`,
1235
+ };
1236
+ }
1237
+ }
967
1238
  // A failed generator can have written to the tree before throwing, and a
968
1239
  // plain retry reruns it, so a pre-marker retry is accepted only when git
969
1240
  // can see nothing of the failed attempt in the tree. The state machine is
@@ -975,7 +1246,7 @@ async function applyReconcileStepAction(root, dir, state, action, scope) {
975
1246
  if (safety.kind === 'unsafe') {
976
1247
  return {
977
1248
  kind: 'error',
978
- reason: `Cannot apply action 'retry' to step '${step.id}': ${safety.reason} Use 'retry-clean' where offered${(0, state_machine_1.commitMayBeInHistory)(state, step) ? '' : `, or 'skip'`}.`,
1249
+ reason: `Cannot apply action 'retry' to step '${step.id}': ${safety.reason} Use 'retry-clean' where offered${(0, state_machine_1.commitMayBeInHistory)(state, step) ? '' : `, 'skip' or 'unresolved'`}, or 'adopt'.`,
979
1250
  };
980
1251
  }
981
1252
  if (safety.kind === 'warned') {
@@ -994,20 +1265,67 @@ async function applyReconcileStepAction(root, dir, state, action, scope) {
994
1265
  if (applied.kind === 'error') {
995
1266
  return applied;
996
1267
  }
997
- return { kind: 'ok', state: applied.state, targetStep: step };
1268
+ return { kind: 'ok', state: applied.state, targetStep: step, resetTree };
1269
+ }
1270
+ function actionList(actions) {
1271
+ const quoted = actions.map((a) => `'${a}'`);
1272
+ return quoted.length === 1
1273
+ ? quoted[0]
1274
+ : `${quoted.slice(0, -1).join(', ')} or ${quoted[quoted.length - 1]}`;
1275
+ }
1276
+ // The reset runs before the transition, so it is withheld where the
1277
+ // transition refuses the give-up.
1278
+ function unresolvedResetsTree(state, step, cleanRetry) {
1279
+ return (generatorPending(step) && !(0, state_machine_1.commitMayBeInHistory)(state, step) && cleanRetry);
1280
+ }
1281
+ // Git and install side effects run here, before the transition and outside the
1282
+ // synchronous state lock.
1283
+ async function stepActionSideEffects(root, dir, state, step, action, resetTree, scope) {
1284
+ switch (action) {
1285
+ case 'adopt':
1286
+ // A died worker's unrecorded commit request is reused. A failed step, or
1287
+ // a recorded commit, needs an adopt request of its own, or the old answer
1288
+ // would be replayed.
1289
+ return state.createCommits
1290
+ ? commitForStep(root, dir, state, step, scope, step.status === 'failed' ||
1291
+ (0, state_machine_1.coveringLandedEntries)(state, step.id).length > 0
1292
+ ? 'adopt'
1293
+ : undefined)
1294
+ : {
1295
+ entry: null,
1296
+ installFailed: await installFailedForStep(root, dir, state, step, 'action-install', scope),
1297
+ };
1298
+ case 'unresolved':
1299
+ // A reset restored the tree the step was dispensed against, so nothing
1300
+ // is left to install or to record as debt.
1301
+ if (resetTree)
1302
+ return { entry: null, installFailed: false };
1303
+ // A run that commits settles the step in giveUpStepTree instead.
1304
+ return retainedTreeSideEffects(root, dir, state, step, 'action-install', scope);
1305
+ case 'skip':
1306
+ return retainedTreeSideEffects(root, dir, state, step, 'action-install', scope);
1307
+ case 'retry':
1308
+ case 'retry-clean':
1309
+ return { entry: null, installFailed: false };
1310
+ default: {
1311
+ const exhaustive = action;
1312
+ throw new Error(`Unhandled step action '${exhaustive}'.`);
1313
+ }
1314
+ }
998
1315
  }
999
- // Commits the working tree left by a folded prompt outcome or an adopted
1000
- // death. The caller persists `entry` with the step transition unless
1001
- // `recorded` says a session's parent already did; null when nothing to
1316
+ // Commits the working tree left by a folded prompt outcome or an adopted step
1317
+ // (failed or died). The caller persists `entry` with the step transition
1318
+ // unless `recorded` says a session's parent already did; null when nothing to
1002
1319
  // commit. A crash between the git commit and the state write leaves that
1003
- // commit in history and out of the ledger; the refold then sees a clean tree.
1004
- async function commitForStep(root, dir, state, step, scope) {
1320
+ // commit in history and out of the ledger, with the failures it absorbed
1321
+ // uncovered; completion rechecks the tree before warning about that debt.
1322
+ async function commitForStep(root, dir, state, step, scope, commitAs) {
1005
1323
  const { name } = (0, state_machine_1.splitMigrationId)(step.migrationId);
1006
1324
  const absorbedStepIds = (0, state_machine_1.uncoveredFailedStepIds)(state).filter((id) => id !== step.id);
1007
1325
  const skipInstall = state.skipInstall === true;
1008
1326
  let commit;
1009
1327
  try {
1010
- commit = await (0, broker_1.commitStepTree)(dir, step, absorbedStepIds, () => (0, migrate_commits_1.commitMigrationIfRequested)(root, { name }, true, state.commitPrefix, () => (0, util_1.installDepsChangedSinceDispense)(root, dir, step, skipInstall, reconcileCommand(root, state.runId)), (0, state_machine_1.stepsToPendingMigrations)(state, absorbedStepIds)), scope);
1328
+ commit = await (0, broker_1.commitStepTree)(dir, step, absorbedStepIds, () => (0, migrate_commits_1.commitMigrationIfRequested)(root, { name }, true, state.commitPrefix, () => (0, util_1.installDepsChangedSinceDispense)(root, dir, step, skipInstall, reconcileCommand(root, state.runId)), (0, state_machine_1.stepsToPendingMigrations)(state, absorbedStepIds)), scope, commitAs);
1011
1329
  }
1012
1330
  catch (e) {
1013
1331
  // Nothing to record: the step moved on, another process holds the tree, or
@@ -1063,7 +1381,7 @@ function advanceAndDispense(root, dir, runId) {
1063
1381
  // operation with a live worker keeps still-running, which offers none.
1064
1382
  const held = (0, broker_1.liveTreeOperation)(state);
1065
1383
  if (held && !isOwnOperation(step, held)) {
1066
- emitHeld(root, runId, step, held, noProgress);
1384
+ emitHeld(root, runId, state, step, held, noProgress);
1067
1385
  return;
1068
1386
  }
1069
1387
  switch (step.status) {
@@ -1081,13 +1399,14 @@ function advanceAndDispense(root, dir, runId) {
1081
1399
  emitDied(root, runId, state, step, noProgress);
1082
1400
  break;
1083
1401
  case 'running':
1084
- emitStillRunning(root, runId, step, held, noProgress);
1402
+ emitStillRunning(root, runId, state, step, held, noProgress);
1085
1403
  break;
1086
1404
  case 'awaiting-prompt-outcome':
1087
1405
  emitAwaitPrompt(root, dir, runId, step, noProgress);
1088
1406
  break;
1089
1407
  case 'succeeded':
1090
1408
  case 'skipped':
1409
+ case 'unresolved':
1091
1410
  // firstActionableStep already excludes these via TERMINAL_STEP_STATUSES;
1092
1411
  // landing here means an already-terminal step slipped through
1093
1412
  // unclassified rather than being left to stall the run silently.
@@ -1183,14 +1502,19 @@ function dispenseNextStep(root, dir, runId, state, step, noProgress) {
1183
1502
  return;
1184
1503
  }
1185
1504
  if (held) {
1186
- emitHeld(root, runId, step, held, noProgress);
1505
+ emitHeld(root, runId, current, step, held, noProgress);
1187
1506
  return;
1188
1507
  }
1189
- emitNextStep(root, runId, current, current.steps.find((s) => s.id === step.id), noProgress);
1508
+ const dispensed = current.steps.find((s) => s.id === step.id);
1509
+ (0, migrate_analytics_1.reportMigrateOrchestratorStepDispensed)({
1510
+ attempt: dispensed.attempt,
1511
+ ordinal: (0, state_machine_1.runTallies)(current).dispenseCount,
1512
+ });
1513
+ emitNextStep(root, runId, current, dispensed, noProgress);
1190
1514
  }
1191
1515
  function emitNextStep(root, runId, state, step, noProgress) {
1192
1516
  const migrationId = step.migrationId;
1193
- emit(root, runId, step, 'next-step', {
1517
+ emit(root, runId, state, step, 'next-step', {
1194
1518
  command: workerCommand(root, migrationId, runId),
1195
1519
  next: reconcileCommand(root, runId),
1196
1520
  instructionLines: [
@@ -1207,6 +1531,8 @@ function emitRetryFailed(root, runId, state, step, noProgress) {
1207
1531
  const head = (0, git_utils_1.getLatestCommitSha)(root);
1208
1532
  const tree = dirtyTreeSummary(root);
1209
1533
  const cleanRetry = (0, clean_retry_1.canOfferCleanRetry)(root, state, step, head);
1534
+ const landed = lastCoveringLandedEntry(state, step);
1535
+ const committed = (0, state_machine_1.commitMayBeInHistory)(state, step);
1210
1536
  // A failure recorded before the generator marker can still have written to
1211
1537
  // the tree (a direct fs or exec side effect, or a crash mid-flush); a
1212
1538
  // marker means only the handed-back half (a prompt or a validation pass)
@@ -1223,27 +1549,31 @@ function emitRetryFailed(root, runId, state, step, noProgress) {
1223
1549
  ` current HEAD: ${head ?? '(unknown)'}`,
1224
1550
  ` working tree: ${tree === null ? '(unknown)' : tree ? `\n${tree}` : '(clean)'}`,
1225
1551
  ``,
1226
- ...(capReached ? [rearmCapLine(step), ``] : []),
1552
+ retryBudgetLine(step, committed),
1553
+ ``,
1227
1554
  `Decide how to proceed and re-run reconcile with one of:`,
1228
- retryOptionLine(retrySafety, reconcileCommand(root, runId, 'retry')),
1555
+ ...(capReached
1556
+ ? []
1557
+ : [retryOptionLine(retrySafety, reconcileCommand(root, runId, 'retry'))]),
1229
1558
  ];
1230
- if (cleanRetry) {
1559
+ if (cleanRetry && !capReached) {
1231
1560
  lines.push(` retry-clean: reset the tree to ${step.gitRefBefore ?? 'the pre-migration ref'} (discarding its uncommitted tracked changes and the untracked files git does not ignore, ${types_1.MIGRATE_RUNS_RELATIVE_DIR} kept) and retry from that clean state by running: ${reconcileCommand(root, runId, 'retry-clean')}`);
1232
1561
  }
1562
+ lines.push(landed
1563
+ ? ` adopt: keep the landed commit${landed.sha ? ` ${landed.sha}` : ''} and the current working-tree state as the migration's result, then run: ${reconcileCommand(root, runId, 'adopt')}`
1564
+ : ` adopt: ${committed
1565
+ ? "a commit of this migration's changes was started and never recorded"
1566
+ : 'the migration was applied by hand'}; keep the current working-tree state as its result, then run: ${reconcileCommand(root, runId, 'adopt')}`);
1233
1567
  // Refused by the state machine: the migration is, or may be, committed.
1234
- if (!(0, state_machine_1.commitMayBeInHistory)(state, step)) {
1235
- lines.push(` skip: ${reconcileCommand(root, runId, 'skip')}`);
1568
+ if (!committed) {
1569
+ lines.push(` skip: ${reconcileCommand(root, runId, 'skip')}`, unresolvedOptionLine(root, runId, state, step, unresolvedResetsTree(state, step, cleanRetry)));
1236
1570
  }
1237
1571
  if (pending) {
1238
1572
  lines.push(UNVERIFIABLE_WRITES_LINE);
1239
1573
  }
1240
- // A step whose generator may still run gets no `next`, whichever retry the
1241
- // checks above would accept: git can vouch for the tracked tree only, and
1242
- // an agent that follows `next` blindly must not rerun a generator over
1243
- // writes nothing here could see. Choosing a retry has to be explicit. The
1244
- // rearm cap withholds it too: each rearm resets the response streak, so a
1245
- // blindly-followed retry `next` would loop past every escalation.
1246
- emit(root, runId, step, 'retry-failed', {
1574
+ // No preselected retry while the generator may still run: git cannot vouch
1575
+ // for every write. Past the cap no retry is offered or accepted.
1576
+ emit(root, runId, state, step, 'retry-failed', {
1247
1577
  ...(pending || capReached
1248
1578
  ? {}
1249
1579
  : { next: reconcileCommand(root, runId, 'retry') }),
@@ -1255,11 +1585,21 @@ function emitRetryFailed(root, runId, state, step, noProgress) {
1255
1585
  function rearmCapReached(step) {
1256
1586
  return step.attempt - 1 >= REARM_ESCALATION_CAP;
1257
1587
  }
1258
- // No availability promise: which retry forms remain is the option list's to
1259
- // say (a pre-marker death may offer none), and the cap itself withholds only
1260
- // the preselected continuation.
1261
- function rearmCapLine(step) {
1262
- return `This migration has already been retried ${step.attempt - 1} times without completing. Repeating an unchanged retry is unlikely to end differently: fix the underlying problem first, choose one of the non-retry options below, or ask the user how to proceed.`;
1588
+ function retryBudgetLine(step, committed) {
1589
+ if (rearmCapReached(step))
1590
+ return rearmCapLine(step, committed);
1591
+ const left = REARM_ESCALATION_CAP - (step.attempt - 1);
1592
+ return `Retries left for this migration: ${left}. Diagnose the failure first and retry only with a plausible fix in hand; ${left === 1
1593
+ ? 'this is the last one, so ask the user before using it'
1594
+ : 'ask the user before using the last one'}. When no user can answer, ${committed
1595
+ ? 'adopt only once you have inspected the tree and finished the migration; otherwise stop and report'
1596
+ : 'give the step up with unresolved and continue'}.`;
1597
+ }
1598
+ // Opens a capped dispense and is the reason a retry past the cap is refused.
1599
+ function rearmCapLine(step, committed) {
1600
+ return `This migration has already been retried ${step.attempt - 1} times without completing, and no further retry is accepted. ${committed
1601
+ ? 'Adopt only once you have inspected the tree and finished the migration; otherwise ask the user how to proceed, or stop and report.'
1602
+ : 'Choose adopt, skip or unresolved, or ask the user how to proceed.'}`;
1263
1603
  }
1264
1604
  // Whether the step's generator half may still have to run: it exists and no
1265
1605
  // attempt has recorded running it. Only then can a retry apply a generator
@@ -1320,12 +1660,31 @@ function assessPreMarkerRetry(root, step) {
1320
1660
  }
1321
1661
  return { kind: 'safe' };
1322
1662
  }
1663
+ // Whichever attempt landed it, the commit is this migration's result.
1664
+ function lastCoveringLandedEntry(state, step) {
1665
+ const entries = (0, state_machine_1.coveringLandedEntries)(state, step.id);
1666
+ return entries.length > 0 ? entries[entries.length - 1] : null;
1667
+ }
1668
+ // The give-up option, worded for what happens to the tree: the reserved
1669
+ // reset, a partial commit, or the tree left as it stands.
1670
+ function unresolvedOptionLine(root, runId, state, step, resetTree) {
1671
+ const command = reconcileCommand(root, runId, 'unresolved');
1672
+ const recorded = `The failure is recorded as a run issue and listed in the completion report.`;
1673
+ if (resetTree) {
1674
+ return ` unresolved: give up on this migration, resetting the tree to ${step.gitRefBefore ?? 'the pre-migration ref'} (discarding what the failed attempt left, ${types_1.MIGRATE_RUNS_RELATIVE_DIR} kept), then run: ${command}. ${recorded}`;
1675
+ }
1676
+ if (state.createCommits) {
1677
+ return ` unresolved: give up on this migration; its partial changes are committed under its name, marked unresolved, and the run moves on. Then run: ${command}. ${recorded}`;
1678
+ }
1679
+ return ` unresolved: give up on this migration, leaving the tree as it stands, and move on. Then run: ${command}. ${recorded}`;
1680
+ }
1323
1681
  function emitDied(root, runId, state, step, noProgress) {
1324
1682
  const migrationId = step.migrationId;
1325
1683
  const ref = step.gitRefBefore;
1326
1684
  const head = (0, git_utils_1.getLatestCommitSha)(root);
1327
1685
  const tree = dirtyTreeSummary(root);
1328
1686
  const cleanRetry = (0, clean_retry_1.canOfferCleanRetry)(root, state, step, head);
1687
+ const committed = (0, state_machine_1.commitMayBeInHistory)(state, step);
1329
1688
  const resume = !generatorPending(step);
1330
1689
  const capReached = rearmCapReached(step);
1331
1690
  const lines = [
@@ -1334,49 +1693,46 @@ function emitDied(root, runId, state, step, noProgress) {
1334
1693
  ` current HEAD: ${head ?? '(unknown)'}`,
1335
1694
  ` working tree: ${tree === null ? '(unknown)' : tree ? `\n${tree}` : '(clean)'}`,
1336
1695
  ``,
1337
- ...(capReached ? [rearmCapLine(step), ``] : []),
1696
+ retryBudgetLine(step, committed),
1697
+ ``,
1338
1698
  ];
1339
1699
  const options = [];
1340
- if (resume) {
1700
+ if (resume && !capReached) {
1341
1701
  options.push(` retry: keep everything this migration already produced (its commit, if any, and the current tree) and run only the part that did not complete, then run: ${reconcileCommand(root, runId, 'retry')}`);
1342
1702
  }
1343
- if (cleanRetry) {
1703
+ if (cleanRetry && !capReached) {
1344
1704
  options.push(` retry-clean: reset the tree to ${ref ?? 'the pre-migration ref'} (discarding its uncommitted tracked changes and the untracked files git does not ignore, ${types_1.MIGRATE_RUNS_RELATIVE_DIR} kept) and retry from that clean state by running: ${reconcileCommand(root, runId, 'retry-clean')}`);
1345
1705
  }
1346
- else {
1706
+ else if (!capReached) {
1347
1707
  lines.push(`A clean retry is unavailable: ${(0, clean_retry_1.cleanRetryUnavailableReason)(root, state, step, head)}`);
1348
1708
  }
1349
1709
  options.push(` adopt: keep the current working-tree state as this migration's result, then run: ${reconcileCommand(root, runId, 'adopt')}`);
1350
1710
  // Refused by the state machine: the migration is, or may be, committed.
1351
- if (!(0, state_machine_1.commitMayBeInHistory)(state, step)) {
1352
- options.push(` skip: leave the tree as it stands and move on without this migration, then run: ${reconcileCommand(root, runId, 'skip')}`);
1711
+ if (!committed) {
1712
+ options.push(` skip: leave the tree as it stands and move on without this migration, then run: ${reconcileCommand(root, runId, 'skip')}`, unresolvedOptionLine(root, runId, state, step, unresolvedResetsTree(state, step, cleanRetry)));
1353
1713
  }
1354
1714
  lines.push(`Choose exactly one:`);
1355
1715
  lines.push(...options);
1356
1716
  if (!resume) {
1357
1717
  lines.push(UNVERIFIABLE_WRITES_LINE);
1358
1718
  }
1359
- // `retry` is preselected wherever it is legal: it is the only resolution that
1360
- // neither discards work nor records a result the run never produced. While
1361
- // the generator may still run there is no `next` at all: a reset cannot be
1362
- // verified against writes git does not see, and adopting records a result
1363
- // nothing checked, so an agent that follows `next` blindly must land on
1364
- // neither. The rearm cap withholds it for the same reason as in
1365
- // emitRetryFailed.
1366
- emit(root, runId, step, 'died', {
1719
+ // `retry` is preselected only when no generator remains to run and the cap
1720
+ // allows it: a reset cannot be verified against writes git does not see, and
1721
+ // adopting records a result nothing checked, so neither is automatic.
1722
+ emit(root, runId, state, step, 'died', {
1367
1723
  ...(resume && !capReached
1368
1724
  ? { next: reconcileCommand(root, runId, 'retry') }
1369
1725
  : {}),
1370
1726
  instructionLines: lines,
1371
1727
  }, noProgress);
1372
1728
  }
1373
- function emitHeld(root, runId, step, held, noProgress) {
1374
- emit(root, runId, step, 'held', {
1729
+ function emitHeld(root, runId, state, step, held, noProgress) {
1730
+ emit(root, runId, state, step, 'held', {
1375
1731
  next: reconcileCommand(root, runId),
1376
1732
  instructionLines: [(0, broker_1.treeBusyMessage)(held)],
1377
1733
  }, noProgress);
1378
1734
  }
1379
- function emitStillRunning(root, runId, step, held, noProgress) {
1735
+ function emitStillRunning(root, runId, state, step, held, noProgress) {
1380
1736
  const migrationId = step.migrationId;
1381
1737
  const ageMs = step.startedAt ? Date.now() - Date.parse(step.startedAt) : 0;
1382
1738
  const lines = [
@@ -1395,7 +1751,7 @@ function emitStillRunning(root, runId, step, held, noProgress) {
1395
1751
  ? `${hung} Report this to the user: they can quit this session, then press Ctrl+C in the terminal to end ${parentOperation}, and resume the run afterwards.`
1396
1752
  : `${hung} Verify pid ${step.pid}; either keep waiting, or kill it so the next reconcile can classify it as died.`);
1397
1753
  }
1398
- emit(root, runId, step, 'still-running', {
1754
+ emit(root, runId, state, step, 'still-running', {
1399
1755
  next: reconcileCommand(root, runId),
1400
1756
  instructionLines: lines,
1401
1757
  }, noProgress);
@@ -1493,7 +1849,7 @@ function emitAwaitPrompt(root, dir, runId, step, noProgress) {
1493
1849
  if (rejection.length > 0) {
1494
1850
  lines.push('', ...rejection);
1495
1851
  }
1496
- emit(root, runId, step, 'await-prompt', {
1852
+ emit(root, runId, claimed, step, 'await-prompt', {
1497
1853
  next: reconcileCommand(root, runId),
1498
1854
  instructionLines: lines,
1499
1855
  }, noProgress);
@@ -1622,9 +1978,6 @@ function completionWarnings(root, runId, state) {
1622
1978
  }
1623
1979
  function completeRun(root, dir, runId, state) {
1624
1980
  let current = state;
1625
- const completed = current.steps.filter((s) => s.status === 'succeeded').length;
1626
- const skipped = current.steps.filter((s) => s.status === 'skipped').length;
1627
- const dispenseCount = current.steps.reduce((n, s) => n + s.dispenseCount, 0);
1628
1981
  // Persist the terminal status and claim the watermark in one fresh-state
1629
1982
  // write before emitting: a crash between the write and the output can't
1630
1983
  // double-count the completion, and of two concurrent reconciles exactly one
@@ -1644,11 +1997,7 @@ function completeRun(root, dir, runId, state) {
1644
1997
  });
1645
1998
  }
1646
1999
  if (shouldEmit) {
1647
- (0, migrate_analytics_1.reportMigrateOrchestratorComplete)({
1648
- completed,
1649
- skipped,
1650
- dispenseCount,
1651
- });
2000
+ (0, migrate_analytics_1.reportMigrateOrchestratorComplete)((0, state_machine_1.runTallies)(current));
1652
2001
  }
1653
2002
  const warnings = completionWarnings(root, runId, current);
1654
2003
  for (const lines of warnings) {
@@ -1656,8 +2005,7 @@ function completeRun(root, dir, runId, state) {
1656
2005
  }
1657
2006
  const instructionLines = [
1658
2007
  `Migrate run ${runId} is complete.`,
1659
- ` applied: ${completed}`,
1660
- ` skipped: ${skipped}`,
2008
+ ...(0, state_machine_1.completionSummaryLines)(current),
1661
2009
  ...warnings.flat(),
1662
2010
  ];
1663
2011
  (0, agent_output_1.logToAgent)({ title: 'nx migrate: complete', bodyLines: instructionLines });
@@ -1665,7 +2013,7 @@ function completeRun(root, dir, runId, state) {
1665
2013
  instructions: instructionLines.join('\n'),
1666
2014
  });
1667
2015
  }
1668
- function emit(root, runId, step, action, payload, noProgress) {
2016
+ function emit(root, runId, state, step, action, payload, noProgress) {
1669
2017
  // At the escalation threshold the whole response becomes the 'no-progress'
1670
2018
  // action, with the step's own instructions kept below so acting on them stays
1671
2019
  // possible. Never a step status: nothing durable changed, and a status would
@@ -1690,6 +2038,7 @@ function emit(root, runId, step, action, payload, noProgress) {
1690
2038
  (0, migrate_analytics_1.reportMigrateOrchestratorDispense)({
1691
2039
  action: effectiveAction,
1692
2040
  attempt: step.attempt,
2041
+ ordinal: (0, state_machine_1.runTallies)(state).dispenseCount,
1693
2042
  });
1694
2043
  }
1695
2044
  function noProgressLines(step, streak) {