nx 23.2.1 → 23.3.0-beta.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.
Files changed (149) hide show
  1. package/dist/src/command-line/format/format.js +2 -18
  2. package/dist/src/command-line/graph/graph.js +42 -3
  3. package/dist/src/command-line/init/implementation/format.js +2 -3
  4. package/dist/src/command-line/migrate/agentic/definitions.js +75 -19
  5. package/dist/src/command-line/migrate/agentic/format-command.d.ts +14 -0
  6. package/dist/src/command-line/migrate/agentic/format-command.js +44 -0
  7. package/dist/src/command-line/migrate/agentic/handoff-gitignore.d.ts +3 -3
  8. package/dist/src/command-line/migrate/agentic/handoff-gitignore.js +3 -3
  9. package/dist/src/command-line/migrate/agentic/handoff.d.ts +27 -17
  10. package/dist/src/command-line/migrate/agentic/handoff.js +107 -24
  11. package/dist/src/command-line/migrate/agentic/instruction-files.d.ts +21 -0
  12. package/dist/src/command-line/migrate/agentic/instruction-files.js +36 -0
  13. package/dist/src/command-line/migrate/agentic/prompts/fragments.d.ts +16 -0
  14. package/dist/src/command-line/migrate/agentic/prompts/fragments.js +70 -0
  15. package/dist/src/command-line/migrate/agentic/prompts/generic-validation.js +4 -3
  16. package/dist/src/command-line/migrate/agentic/prompts/system-prompt.d.ts +25 -1
  17. package/dist/src/command-line/migrate/agentic/prompts/system-prompt.js +57 -34
  18. package/dist/src/command-line/migrate/agentic/run-step.js +28 -4
  19. package/dist/src/command-line/migrate/agentic/runner.d.ts +17 -8
  20. package/dist/src/command-line/migrate/agentic/runner.js +110 -55
  21. package/dist/src/command-line/migrate/agentic/types.d.ts +23 -8
  22. package/dist/src/command-line/migrate/agentic/types.js +10 -7
  23. package/dist/src/command-line/migrate/migrate-commits.d.ts +1 -0
  24. package/dist/src/command-line/migrate/migrate-commits.js +9 -0
  25. package/dist/src/command-line/migrate/migrate-output.d.ts +3 -7
  26. package/dist/src/command-line/migrate/migrate-output.js +3 -7
  27. package/dist/src/command-line/migrate/migrate.js +21 -8
  28. package/dist/src/command-line/migrate/run/agent-output.d.ts +10 -0
  29. package/dist/src/command-line/migrate/run/agent-output.js +16 -2
  30. package/dist/src/command-line/migrate/run/agent-work-payload.d.ts +37 -0
  31. package/dist/src/command-line/migrate/run/agent-work-payload.js +142 -0
  32. package/dist/src/command-line/migrate/run/atomic-write.d.ts +8 -0
  33. package/dist/src/command-line/migrate/run/atomic-write.js +17 -0
  34. package/dist/src/command-line/migrate/run/index.d.ts +1 -1
  35. package/dist/src/command-line/migrate/run/issues.d.ts +106 -0
  36. package/dist/src/command-line/migrate/run/issues.js +980 -0
  37. package/dist/src/command-line/migrate/run/orchestrator.d.ts +1 -0
  38. package/dist/src/command-line/migrate/run/orchestrator.js +662 -105
  39. package/dist/src/command-line/migrate/run/run-state.d.ts +43 -8
  40. package/dist/src/command-line/migrate/run/run-state.js +184 -20
  41. package/dist/src/command-line/migrate/run/runbook.d.ts +17 -0
  42. package/dist/src/command-line/migrate/run/runbook.js +97 -0
  43. package/dist/src/command-line/migrate/run/state-lock.d.ts +2 -2
  44. package/dist/src/command-line/migrate/run/state-lock.js +2 -2
  45. package/dist/src/command-line/migrate/run/state-machine.d.ts +5 -1
  46. package/dist/src/command-line/migrate/run/state-machine.js +27 -1
  47. package/dist/src/command-line/migrate/run/worker.js +206 -56
  48. package/dist/src/command-line/nx-cloud/connect/connect-to-nx-cloud.js +5 -1
  49. package/dist/src/command-line/release/publish.js +11 -4
  50. package/dist/src/command-line/release/utils/remote-release-clients/github.js +3 -3
  51. package/dist/src/command-line/release/utils/remote-release-clients/gitlab.js +3 -3
  52. package/dist/src/command-line/release/utils/remote-release-clients/remote-release-client.d.ts +2 -0
  53. package/dist/src/command-line/release/utils/remote-release-clients/remote-release-client.js +35 -0
  54. package/dist/src/command-line/yargs-utils/shared-options.d.ts +1 -1
  55. package/dist/src/command-line/yargs-utils/shared-options.js +22 -4
  56. package/dist/src/config/workspace-json-project-json.d.ts +2 -0
  57. package/dist/src/core/graph/main.js +1 -1
  58. package/dist/src/daemon/client/client.d.ts +4 -2
  59. package/dist/src/daemon/client/client.js +20 -11
  60. package/dist/src/daemon/message-types/run-tasks-execution-hooks.d.ts +3 -2
  61. package/dist/src/daemon/server/handle-hash-tasks.d.ts +8 -2
  62. package/dist/src/daemon/server/handle-hash-tasks.js +16 -3
  63. package/dist/src/daemon/server/handle-outputs-changes.d.ts +2 -2
  64. package/dist/src/daemon/server/handle-outputs-changes.js +21 -6
  65. package/dist/src/daemon/server/handle-tasks-execution-hooks.d.ts +3 -2
  66. package/dist/src/daemon/server/outputs-tracking.d.ts +6 -0
  67. package/dist/src/daemon/server/outputs-tracking.js +18 -0
  68. package/dist/src/daemon/server/project-graph-incremental-recomputation.d.ts +22 -7
  69. package/dist/src/daemon/server/project-graph-incremental-recomputation.js +99 -69
  70. package/dist/src/daemon/server/restart-checks.d.ts +22 -0
  71. package/dist/src/daemon/server/restart-checks.js +77 -0
  72. package/dist/src/daemon/server/server.js +28 -18
  73. package/dist/src/daemon/server/shutdown-utils.d.ts +0 -5
  74. package/dist/src/daemon/server/shutdown-utils.js +3 -26
  75. package/dist/src/hasher/hash-task.js +20 -16
  76. package/dist/src/hasher/native-task-hasher-impl.d.ts +8 -0
  77. package/dist/src/hasher/native-task-hasher-impl.js +46 -3
  78. package/dist/src/hasher/task-hasher.d.ts +9 -0
  79. package/dist/src/hasher/task-hasher.js +14 -0
  80. package/dist/src/native/index.d.ts +202 -21
  81. package/dist/src/native/index.js +3 -3
  82. package/dist/src/native/native-bindings.js +3 -1
  83. package/dist/src/native/nx.wasm32-wasi.debug.wasm +0 -0
  84. package/dist/src/native/nx.wasm32-wasi.wasm +0 -0
  85. package/dist/src/plugins/js/lock-file/pnpm-parser.js +1 -1
  86. package/dist/src/plugins/js/lock-file/utils/pnpm-normalizer.d.ts +2 -0
  87. package/dist/src/plugins/js/lock-file/utils/pnpm-normalizer.js +22 -0
  88. package/dist/src/plugins/js/utils/register.js +2 -1
  89. package/dist/src/project-graph/plugins/isolation/isolated-plugin.js +2 -1
  90. package/dist/src/project-graph/plugins/isolation/messaging.d.ts +3 -2
  91. package/dist/src/project-graph/plugins/isolation/plugin-worker.js +5 -1
  92. package/dist/src/project-graph/plugins/loaded-nx-plugin.d.ts +3 -2
  93. package/dist/src/project-graph/plugins/loaded-nx-plugin.js +5 -1
  94. package/dist/src/project-graph/plugins/task-results-stub.d.ts +50 -0
  95. package/dist/src/project-graph/plugins/task-results-stub.js +111 -0
  96. package/dist/src/project-graph/plugins/tasks-execution-hooks.d.ts +3 -2
  97. package/dist/src/project-graph/plugins/tasks-execution-hooks.js +2 -1
  98. package/dist/src/project-graph/project-graph.js +2 -0
  99. package/dist/src/tasks-runner/batch/run-batch.js +15 -0
  100. package/dist/src/tasks-runner/cache.d.ts +35 -0
  101. package/dist/src/tasks-runner/cache.js +40 -0
  102. package/dist/src/tasks-runner/create-task-graph.js +1 -0
  103. package/dist/src/tasks-runner/default-tasks-runner.js +1 -1
  104. package/dist/src/tasks-runner/forked-process-task-runner.d.ts +1 -1
  105. package/dist/src/tasks-runner/forked-process-task-runner.js +8 -7
  106. package/dist/src/tasks-runner/init-tasks-runner.js +5 -1
  107. package/dist/src/tasks-runner/is-tui-enabled.js +4 -3
  108. package/dist/src/tasks-runner/life-cycle.d.ts +7 -0
  109. package/dist/src/tasks-runner/life-cycle.js +7 -0
  110. package/dist/src/tasks-runner/life-cycles/summary-terminal-output-life-cycle.d.ts +35 -0
  111. package/dist/src/tasks-runner/life-cycles/summary-terminal-output-life-cycle.js +146 -0
  112. package/dist/src/tasks-runner/prune-task-graph.d.ts +17 -0
  113. package/dist/src/tasks-runner/prune-task-graph.js +45 -0
  114. package/dist/src/tasks-runner/run-command.js +14 -3
  115. package/dist/src/tasks-runner/running-tasks/batch-process.d.ts +93 -19
  116. package/dist/src/tasks-runner/running-tasks/batch-process.js +184 -88
  117. package/dist/src/tasks-runner/task-orchestrator.d.ts +64 -2
  118. package/dist/src/tasks-runner/task-orchestrator.js +232 -46
  119. package/dist/src/tasks-runner/terminal-output-path.d.ts +11 -0
  120. package/dist/src/tasks-runner/terminal-output-path.js +18 -0
  121. package/dist/src/tasks-runner/utils.js +5 -2
  122. package/dist/src/utils/ab-testing.d.ts +3 -0
  123. package/dist/src/utils/ab-testing.js +11 -3
  124. package/dist/src/utils/chunkify.d.ts +1 -7
  125. package/dist/src/utils/chunkify.js +2 -8
  126. package/dist/src/utils/command-line-utils.d.ts +19 -0
  127. package/dist/src/utils/command-line-utils.js +10 -3
  128. package/dist/src/utils/formatters/index.d.ts +6 -0
  129. package/dist/src/utils/formatters/index.js +5 -0
  130. package/dist/src/utils/formatters/prettier.d.ts +0 -8
  131. package/dist/src/utils/formatters/prettier.js +4 -26
  132. package/dist/src/utils/installed-nx-version.js +2 -48
  133. package/dist/src/utils/min-release-age/packument.js +1 -1
  134. package/dist/src/utils/output.d.ts +22 -3
  135. package/dist/src/utils/output.js +28 -5
  136. package/dist/src/utils/package-json.d.ts +25 -0
  137. package/dist/src/utils/package-json.js +41 -6
  138. package/dist/src/utils/package-manager.d.ts +6 -0
  139. package/dist/src/utils/package-manager.js +10 -0
  140. package/dist/src/utils/provenance.js +20 -4
  141. package/dist/src/utils/workspace-context.d.ts +47 -3
  142. package/dist/src/utils/workspace-context.js +162 -10
  143. package/package.json +12 -12
  144. package/schemas/nx-schema.json +4 -0
  145. package/schemas/project-schema.json +4 -0
  146. package/dist/src/daemon/server/file-watching/route-workspace-changes.d.ts +0 -9
  147. package/dist/src/daemon/server/file-watching/route-workspace-changes.js +0 -76
  148. package/dist/src/daemon/server/watcher.d.ts +0 -18
  149. package/dist/src/daemon/server/watcher.js +0 -126
@@ -3,13 +3,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runOrchestratorInit = runOrchestratorInit;
4
4
  exports.runOrchestratorReconcile = runOrchestratorReconcile;
5
5
  const child_process_1 = require("child_process");
6
+ const crypto_1 = require("crypto");
6
7
  const fs_1 = require("fs");
7
8
  const path_1 = require("path");
8
9
  const fileutils_1 = require("../../../utils/fileutils");
10
+ const atomic_write_1 = require("./atomic-write");
9
11
  const git_utils_1 = require("../../../utils/git-utils");
10
12
  const versions_1 = require("../../../utils/versions");
11
13
  const handoff_1 = require("../agentic/handoff");
14
+ const format_command_1 = require("../agentic/format-command");
12
15
  const handoff_gitignore_1 = require("../agentic/handoff-gitignore");
16
+ const fragments_1 = require("../agentic/prompts/fragments");
13
17
  const types_1 = require("../agentic/types");
14
18
  const migrate_commits_1 = require("../migrate-commits");
15
19
  const migrate_analytics_1 = require("../migrate-analytics");
@@ -22,6 +26,10 @@ const migration_shape_1 = require("../migration-shape");
22
26
  const util_1 = require("./util");
23
27
  const text_1 = require("../text");
24
28
  const agent_output_1 = require("./agent-output");
29
+ const agent_work_payload_1 = require("./agent-work-payload");
30
+ const issues_1 = require("./issues");
31
+ const runbook_1 = require("./runbook");
32
+ const package_manager_1 = require("../../../utils/package-manager");
25
33
  // The dark migrate orchestrator: drives a durable run one dispense at a time.
26
34
  // An outer AI agent runs each dispensed command and re-invokes `nx migrate
27
35
  // --run-id=<id>` to reconcile; there is no long-lived process.
@@ -29,8 +37,15 @@ const PLAN_SNAPSHOT_0 = 'plan-0.json';
29
37
  // A running worker older than this may be hung; the still-running dispense
30
38
  // escalates so the agent can verify or kill it.
31
39
  const HANG_THRESHOLD_MS = 15 * 60 * 1000;
32
- // Steps in these statuses are done; every other status needs a dispense.
33
- const TERMINAL_STATUSES = new Set(['succeeded', 'skipped']);
40
+ // Identical responses in a row before the dispense escalates to a
41
+ // 'no-progress' action. A still-running worker is exempt until the hang
42
+ // threshold: waiting on a live worker is not looping.
43
+ const NO_PROGRESS_THRESHOLD = 3;
44
+ // Rearms of one step before its failed/died dispense escalates: the retry
45
+ // guidance flips against retrying and the preselected `next` is withheld, so
46
+ // an agent that follows `next` blindly cannot retry forever. An explicit
47
+ // retry is still honored; the cap never refuses.
48
+ const REARM_ESCALATION_CAP = 3;
34
49
  const INIT_CONTINUE_HINT = 're-run the command, or unset NX_MIGRATE_ORCHESTRATOR to use the standard migrate flow.';
35
50
  function continueRunHint(runId) {
36
51
  return `re-run the command to continue run '${runId}'.`;
@@ -66,7 +81,7 @@ function refuseUnsafeScratchExposure(exposure, thenWhat) {
66
81
  }
67
82
  }
68
83
  async function runOrchestratorInit(input) {
69
- const { root, migrationsJson, createCommits, commitPrefix, skipInstall, installedNxVersion, } = input;
84
+ const { root, migrationsJson, createCommits, commitPrefix, skipInstall, installedNxVersion, validate, } = input;
70
85
  const planHash = (0, run_id_1.computePlanHash)(migrationsJson);
71
86
  // An active run means a prior init already happened (e.g. it crashed before
72
87
  // the agent's first reconcile); starting a second run would compete with it.
@@ -148,6 +163,8 @@ async function runOrchestratorInit(input) {
148
163
  createCommits,
149
164
  commitPrefix,
150
165
  ...(skipInstall ? { skipInstall: true } : {}),
166
+ validate: validate !== false,
167
+ runbookPath: runbook_1.RUNBOOK_FILE_NAME,
151
168
  rounds: [
152
169
  {
153
170
  index: 0,
@@ -175,6 +192,10 @@ async function runOrchestratorInit(input) {
175
192
  // crash in between must not leave an active run without its plan.
176
193
  (0, fs_1.mkdirSync)(dir, { recursive: true });
177
194
  (0, fileutils_1.writeJsonFile)((0, path_1.join)(dir, PLAN_SNAPSHOT_0), migrationsJson);
195
+ // The runbook gets the same crash guarantee: a discoverable run always
196
+ // has the runbook a resume re-emits from disk. 'wx' creates without
197
+ // following links, so nothing pre-planted at the path can redirect it.
198
+ (0, fs_1.writeFileSync)((0, path_1.join)(dir, runbook_1.RUNBOOK_FILE_NAME), (0, runbook_1.renderRunbook)(runbookContext(root, runId, state)), { flag: 'wx' });
178
199
  (0, run_state_1.createRun)(root, state);
179
200
  return null;
180
201
  });
@@ -182,7 +203,7 @@ async function runOrchestratorInit(input) {
182
203
  resumeRun(root, winner.runId, winner.state);
183
204
  return;
184
205
  }
185
- finishInit(root, dir, runId, state);
206
+ finishInit(root, dir, runId, state, 'created');
186
207
  }
187
208
  // Reads the newest active run, refusing one whose plan differs from the
188
209
  // incoming plan; null when no run is active. Uninterpretable run dirs refuse
@@ -228,15 +249,19 @@ function resumeRun(root, runId, state) {
228
249
  if (state.createCommits) {
229
250
  assertScratchDirSafeForCommits(root, continueRunHint(runId));
230
251
  }
252
+ // Read, repair, or refuse the runbook before the checkpoint retry and the
253
+ // analytics watermark: an invocation that cannot provide the run's
254
+ // contract must not first change git history or durable run state.
255
+ const runbook = ensureRunbook(root, dir, runId, state);
256
+ if (runbook === null) {
257
+ return;
258
+ }
231
259
  // A run flagged checkpointFailed gets one more chance to capture the
232
260
  // pre-existing tree state before its first migration commit absorbs it.
233
261
  const resumed = ensureCheckpoint(root, dir, state);
234
262
  announceResume(runId, resumed);
235
- finishInit(root, dir, runId, resumed);
263
+ finishInit(root, dir, runId, resumed, 'resumed', runbook);
236
264
  }
237
- // The dispense that follows says nothing about the steps already behind it, so
238
- // a resumed run is otherwise indistinguishable from a fresh one that happens
239
- // to start partway down the plan.
240
265
  function announceResume(runId, state) {
241
266
  const applied = state.steps.filter((s) => s.status === 'succeeded').length;
242
267
  const skipped = state.steps.filter((s) => s.status === 'skipped').length;
@@ -303,9 +328,13 @@ function checkpointEntry(root, commitPrefix) {
303
328
  }
304
329
  return null;
305
330
  }
306
- // Shared tail of a fresh and a resumed init: emit the init analytics once per
307
- // run (watermark-guarded) and emit the current dispense.
308
- function finishInit(root, dir, runId, state) {
331
+ // No migration step is dispensed here: the agent reads the runbook first and
332
+ // asks for the run's current step by reconciling, so the contract always lands
333
+ // before the first command does.
334
+ function finishInit(root, dir, runId, state, origin,
335
+ // The runbook bytes when the caller already ensured them (the resume path,
336
+ // which must fail before its git and state side effects).
337
+ runbook) {
309
338
  let current = state;
310
339
  if (!current.analytics.startEmitted) {
311
340
  // Claim the watermark on the fresh state first: of two concurrent inits
@@ -327,7 +356,96 @@ function finishInit(root, dir, runId, state) {
327
356
  });
328
357
  }
329
358
  }
330
- advanceAndDispense(root, dir, runId, current);
359
+ const content = runbook ?? ensureRunbook(root, dir, runId, current);
360
+ if (content === null) {
361
+ return;
362
+ }
363
+ (0, agent_output_1.emitRunbookBlock)(runId, content);
364
+ const instructionLines = [
365
+ `Nx ${origin} migrate run ${runId}. No migration step ran in this response.`,
366
+ `Read the runbook above; it is the contract for driving this run. Then run the "next" command to get the run's current step.`,
367
+ ...runbookFooterLines(root, runId),
368
+ ];
369
+ const lines = (0, agent_output_1.safeLines)(instructionLines);
370
+ (0, agent_output_1.logToAgent)({ title: `nx migrate: run ${origin}`, bodyLines: lines });
371
+ (0, agent_output_1.emitStepBlock)(runId, '-', 'initialized', {
372
+ next: reconcileCommand(root, runId),
373
+ instructions: lines.join('\n'),
374
+ });
375
+ }
376
+ // A missing runbook is re-rendered only by the nx version that created the
377
+ // run: its content is version-locked, and a different nx re-rendering it would
378
+ // silently hand the agent a contract the run was not created under. Returns
379
+ // null after emitting the exit-0 refusal instead.
380
+ function ensureRunbook(root, dir, runId, state) {
381
+ const filePath = (0, path_1.join)(dir, state.runbookPath ?? runbook_1.RUNBOOK_FILE_NAME);
382
+ const stat = lstatRunbook(filePath);
383
+ if (stat?.isFile()) {
384
+ // The read is the proof the entry is usable: read errors (an unreadable
385
+ // mode, an I/O failure) propagate rather than letting an unreadable
386
+ // runbook pass the guard.
387
+ return (0, handoff_1.readInspectedFile)(filePath, stat, `${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${runId}/${runbook_1.RUNBOOK_FILE_NAME} was replaced while being read; ${continueRunHint(runId)}`);
388
+ }
389
+ if (stat) {
390
+ // A directory here is a corrupted run dir; erasing its contents as a side
391
+ // effect of a reconcile is not this code's call to make.
392
+ if (stat.isDirectory()) {
393
+ throw new Error(`${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${runId}/${runbook_1.RUNBOOK_FILE_NAME} is a directory, not the runbook file nx wrote there. Remove it, then ${continueRunHint(runId)}`);
394
+ }
395
+ // Any other non-regular entry (a symlink most likely) is cleared before the
396
+ // rename publish below: POSIX rename replaces it in place, but not every
397
+ // platform guarantees that, and a removal failure must propagate rather
398
+ // than leave the entry standing.
399
+ (0, fs_1.rmSync)(filePath, { force: true });
400
+ }
401
+ if (state.nxVersion !== versions_1.nxVersion) {
402
+ const reason = [
403
+ `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.`,
404
+ `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).`,
405
+ ];
406
+ (0, agent_output_1.warnToAgent)({ title: reason[0], bodyLines: [reason[1]] });
407
+ (0, agent_output_1.emitStepBlock)(runId, '-', 'error', {
408
+ instructions: reason.join('\n'),
409
+ });
410
+ return null;
411
+ }
412
+ const content = (0, runbook_1.renderRunbook)(runbookContext(root, runId, state));
413
+ // A crashed repair leaves the path missing rather than truncated. 'wx'
414
+ // creates without following links, so anything planted at the temp name
415
+ // fails the repair instead of redirecting it.
416
+ (0, atomic_write_1.publishFileAtomically)(filePath, (tmpPath) => (0, fs_1.writeFileSync)(tmpPath, content, { flag: 'wx' }));
417
+ (0, state_lock_1.updateRunState)(dir, (fresh) => fresh.runbookPath ? null : { ...fresh, runbookPath: runbook_1.RUNBOOK_FILE_NAME });
418
+ (0, agent_output_1.warnToAgent)({
419
+ title: `The runbook for run '${runId}' was missing; it has been re-rendered.`,
420
+ });
421
+ return content;
422
+ }
423
+ // lstat that treats only a missing entry as null; other inspection failures
424
+ // (permissions, I/O) propagate rather than masquerading as "missing". Bigint
425
+ // stats so the inode identity compared above cannot lose precision.
426
+ function lstatRunbook(filePath) {
427
+ try {
428
+ return (0, fs_1.lstatSync)(filePath, { bigint: true });
429
+ }
430
+ catch (e) {
431
+ if (e?.code === 'ENOENT') {
432
+ return null;
433
+ }
434
+ throw e;
435
+ }
436
+ }
437
+ function runbookContext(root, runId, state) {
438
+ return {
439
+ runId,
440
+ packageManager: (0, package_manager_1.detectPackageManager)(root),
441
+ nxInvocation: `${(0, util_1.pmExecPrefix)(root)} nx`,
442
+ pmExec: (0, util_1.pmExecPrefix)(root),
443
+ reconcileCommand: reconcileCommand(root, runId),
444
+ createCommits: state.createCommits,
445
+ // The same `!== false` read the flag itself gets, so a run recorded
446
+ // without the field renders validation on.
447
+ validate: state.validate !== false,
448
+ };
331
449
  }
332
450
  async function runOrchestratorReconcile(input) {
333
451
  const { root, runId, stepAction } = input;
@@ -349,6 +467,17 @@ async function runOrchestratorReconcile(input) {
349
467
  if (state.createCommits) {
350
468
  assertScratchDirSafeForCommits(root, continueRunHint(runId));
351
469
  }
470
+ // The runbook is the run's persisted contract; read, repair, or refuse it
471
+ // before any fold or dispense, so the run can never advance while the
472
+ // contract is unavailable. A completed run, and an active run whose steps are
473
+ // all terminal, are exempt: no work can advance, the only thing left is the
474
+ // self-contained terminal response, and hiding it behind a missing contract
475
+ // would leave the run active forever.
476
+ if (state.status !== 'completed' &&
477
+ firstActionableStep(state) !== undefined &&
478
+ ensureRunbook(root, dir, runId, state) === null) {
479
+ return;
480
+ }
352
481
  // (a) fold handoffs into prompt outcomes (committing completed ones).
353
482
  state = await foldHandoffs(root, dir, state);
354
483
  // (b) reclassify running steps whose worker process is gone.
@@ -391,7 +520,22 @@ async function runOrchestratorReconcile(input) {
391
520
  // new attempt. Losing the handoff without the rearm is safe: the step is
392
521
  // still failed/died and the agent re-issues the action.
393
522
  if (stepAction === 'retry' || stepAction === 'retry-clean') {
394
- removeHandoff(dir, target.migrationId);
523
+ removeHandoff(dir, target.id);
524
+ // A reset-backed retry that dropped the generator marker reruns the
525
+ // generator, so payloads stored by earlier attempts describe a run
526
+ // whose tree was reset away; remove them. Hygiene, not the correctness
527
+ // boundary: the lineage bound persisted with the next marker
528
+ // (generatorCompletedAtAttempt) is what keeps a later retry from
529
+ // re-handing a copy this best-effort removal missed. A plain retry
530
+ // keeps the files: its lineage is unbroken, and a retained retry
531
+ // re-hands the newest copy. Removed with the handoff, before the rearm
532
+ // is persisted: losing them without the rearm only costs a later
533
+ // emission its stored copy.
534
+ if (stepAction === 'retry-clean' &&
535
+ result.state.steps.find((s) => s.id === target.id)
536
+ .generatorCompleted !== true) {
537
+ (0, agent_work_payload_1.removeAgentWorkPayloads)(dir, target.id, target.attempt);
538
+ }
395
539
  }
396
540
  // Re-validate the transition against the fresh disk state: if a concurrent
397
541
  // reconcile already resolved this step, surface the state machine's own
@@ -400,6 +544,7 @@ async function runOrchestratorReconcile(input) {
400
544
  // against `state`, and a step that was re-armed and failed again in
401
545
  // between is a different attempt those checks never saw.
402
546
  let freshRejection;
547
+ let reopenedIssueUpdates = [];
403
548
  const written = (0, state_lock_1.updateRunState)(dir, (fresh) => {
404
549
  const reapplied = (0, state_machine_1.applyStepEvent)(fresh, {
405
550
  type: 'stepAction',
@@ -411,10 +556,23 @@ async function runOrchestratorReconcile(input) {
411
556
  freshRejection = reapplied.reason;
412
557
  return null;
413
558
  }
559
+ // The reset this action requires discarded the failed attempt's tree,
560
+ // so resolutions that attempt claimed and no landed commit carries are
561
+ // reverted with the rearm, in the same write.
562
+ let rearmed = reapplied.state;
563
+ if (stepAction === 'retry-clean') {
564
+ const reopened = (0, issues_1.reopenResolutionsForStep)(rearmed, target.id);
565
+ rearmed = reopened.state;
566
+ reopenedIssueUpdates = reopened.updates;
567
+ }
414
568
  const next = installFailed
415
- ? (0, state_machine_1.markInstallFailed)(reapplied.state, target.id)
416
- : reapplied.state;
417
- return entry ? appendCommit(next, entry) : next;
569
+ ? (0, state_machine_1.markInstallFailed)(rearmed, target.id)
570
+ : rearmed;
571
+ // An adopted commit absorbs uncovered failed steps the same way a fold
572
+ // commit does, so it carries their resolved issues too.
573
+ return entry
574
+ ? appendCommit(next, (0, issues_1.attachIssueIdsToCommitEntry)(next, entry))
575
+ : next;
418
576
  });
419
577
  if (freshRejection) {
420
578
  emitError(root, runId, entry?.kind === 'landed' && entry.sha
@@ -422,10 +580,35 @@ async function runOrchestratorReconcile(input) {
422
580
  : freshRejection);
423
581
  return;
424
582
  }
583
+ if (reopenedIssueUpdates.length > 0) {
584
+ // Best-effort: run.json records the reverted dispositions and stays
585
+ // authoritative, so a failed append loses only the archive's trail record
586
+ // of the revert. The sink survives a throw: a shell rebuilt before the
587
+ // failure is durable and reads healthy on retry, so this pass must warn
588
+ // it.
589
+ const revertApplication = {
590
+ state: written,
591
+ newIssues: [],
592
+ updates: reopenedIssueUpdates,
593
+ };
594
+ const revertReconstructedIds = [];
595
+ try {
596
+ (0, issues_1.archiveIssues)(dir, revertApplication, revertReconstructedIds);
597
+ }
598
+ catch (e) {
599
+ (0, agent_output_1.warnToAgent)({
600
+ title: `The reverted issue resolutions for ${target.migrationId} could not be archived (${(0, util_1.summarizeError)(e)}).`,
601
+ bodyLines: [
602
+ `run.json stays authoritative for the dispositions; the archived files under the run's issues directory miss the revert records, so their last entries may still read resolved.`,
603
+ ],
604
+ });
605
+ }
606
+ warnReconstructedArchives(revertReconstructedIds);
607
+ }
425
608
  state = written;
426
609
  }
427
610
  // (d) choose and emit the next dispense.
428
- advanceAndDispense(root, dir, runId, state);
611
+ advanceAndDispense(root, dir, runId);
429
612
  }
430
613
  function buildSteps(sortedMigrations) {
431
614
  return sortedMigrations.map((m, index) => ({
@@ -439,6 +622,16 @@ function buildSteps(sortedMigrations) {
439
622
  }));
440
623
  }
441
624
  // --- reconcile phases -------------------------------------------------------
625
+ // The runbook points every later consumer at issues/<id>.json for the full
626
+ // details, so a rebuild from run-state fields (the reported detail is gone
627
+ // with the lost file) must not stay silent.
628
+ function warnReconstructedArchives(issueIds) {
629
+ if (issueIds.length === 0)
630
+ return;
631
+ (0, agent_output_1.warnToAgent)({
632
+ title: `The archived details for ${issueIds.join(', ')} were missing or unreadable and were rebuilt from the run state; the originally reported detail is lost.`,
633
+ });
634
+ }
442
635
  async function foldHandoffs(root, dir, state) {
443
636
  let current = state;
444
637
  // Step ids are fixed for the life of a run, so the ids come from the caller's
@@ -448,23 +641,80 @@ async function foldHandoffs(root, dir, state) {
448
641
  const step = current.steps.find((s) => s.id === id);
449
642
  if (step.status !== 'awaiting-prompt-outcome')
450
643
  continue;
451
- const result = (0, handoff_1.readHandoffWithReason)(handoffPath(dir, (0, state_machine_1.splitMigrationId)(step.migrationId)));
644
+ const result = readStepHandoff(dir, step.id);
452
645
  if (!result.ok)
453
646
  continue; // still awaiting; the dispense asks to settle it
454
- const promptOutcome = handoffToPromptOutcome(result.handoff);
455
- // The commit and the install are side effects, so they happen before the
456
- // fold, outside the lock; the transition and its ledger entry then land in
457
- // one fresh-state write. A crash cannot leave the step settled with its
458
- // commit forgotten.
647
+ let promptOutcome = handoffToPromptOutcome(result.handoff);
648
+ // A skipped handoff completes a step whose generator changed the tree.
649
+ // Prompt-only and no-op hybrid steps remain skipped so they cannot commit
650
+ // unrelated pending diffs.
651
+ if (generatorChangesApplied(step) && promptOutcome.status === 'skipped') {
652
+ promptOutcome = { ...promptOutcome, status: 'completed' };
653
+ }
654
+ // Archive under the lock before committing or marking the step terminal.
655
+ // Failure leaves the handoff retryable instead of landing an unrecorded
656
+ // commit or a ledger entry without detail; replayed archive writes are
657
+ // idempotent.
658
+ let ready = false;
659
+ let archiveError = null;
660
+ const reconstructedIssueIds = [];
661
+ (0, state_lock_1.withRunStateLock)(dir, () => {
662
+ const fresh = (0, run_state_1.readRunState)(dir);
663
+ const applied = (0, state_machine_1.applyStepEvent)(fresh, {
664
+ type: 'foldPromptOutcome',
665
+ stepId: step.id,
666
+ attempt: step.attempt,
667
+ promptOutcome,
668
+ });
669
+ if (applied.kind === 'error')
670
+ return;
671
+ const issues = (0, issues_1.parseHandoffIssues)(result.handoff.extras, fresh, step);
672
+ if (issues.ok !== true)
673
+ return;
674
+ try {
675
+ // The sink survives a throw: a shell rebuilt before the failure is
676
+ // durable and reads healthy on the retry, so only this pass can
677
+ // warn that its original detail is gone.
678
+ (0, issues_1.archiveIssues)(dir, (0, issues_1.applyReportedIssues)(applied.state, step, issues.issues, issues.updates), reconstructedIssueIds);
679
+ }
680
+ catch (e) {
681
+ archiveError = e;
682
+ return;
683
+ }
684
+ ready = true;
685
+ });
686
+ warnReconstructedArchives(reconstructedIssueIds);
687
+ if (archiveError !== null) {
688
+ (0, agent_output_1.warnToAgent)({
689
+ title: `The issue details reported by ${step.migrationId} could not be archived (${(0, util_1.summarizeError)(archiveError)}).`,
690
+ bodyLines: [
691
+ `The step's outcome was not folded; fix the underlying problem, then run the reconcile again.`,
692
+ ],
693
+ });
694
+ }
695
+ if (!ready)
696
+ continue;
697
+ // Phase 2: the commit and the install are side effects, so they happen
698
+ // outside the lock; the transition, the issue application, and the
699
+ // ledger entry then land in one fresh-state write. A crash cannot leave
700
+ // the step settled with its commit forgotten.
459
701
  const { entry, installFailed } = await foldLedgerEntry(root, dir, current, step, promptOutcome);
460
- // The fold re-validates against fresh disk state, on the attempt this
702
+ // The write re-validates against fresh disk state, on the attempt this
461
703
  // handoff was read for. That window is wide (a git commit plus a package
462
704
  // install), and 'awaiting-prompt-outcome' recurs, so without the attempt
463
705
  // check a concurrent reconcile's retry could take this outcome as its own.
464
706
  // A dropped fold is equivalent to the crash-refold window: the commit
465
707
  // landed but the ledger misses it.
708
+ // Written through the lock directly so the issue application is re-archived
709
+ // on the state the write actually lands on: a claim assigned between the
710
+ // phases can add an update record phase 1 never saw.
466
711
  let folded = false;
467
- current = (0, state_lock_1.updateRunState)(dir, (fresh) => {
712
+ let updateArchiveError = null;
713
+ let detailArchiveError = null;
714
+ let archivesDegraded = false;
715
+ const refoldReconstructedIds = [];
716
+ current = (0, state_lock_1.withRunStateLock)(dir, () => {
717
+ const fresh = (0, run_state_1.readRunState)(dir);
468
718
  const applied = (0, state_machine_1.applyStepEvent)(fresh, {
469
719
  type: 'foldPromptOutcome',
470
720
  stepId: step.id,
@@ -472,18 +722,78 @@ async function foldHandoffs(root, dir, state) {
472
722
  promptOutcome,
473
723
  });
474
724
  if (applied.kind === 'error')
475
- return null;
725
+ return fresh;
726
+ const issues = (0, issues_1.parseHandoffIssues)(result.handoff.extras, fresh, step);
727
+ if (issues.ok !== true)
728
+ return fresh;
729
+ const application = (0, issues_1.applyReportedIssues)(applied.state, step, issues.issues, issues.updates);
730
+ try {
731
+ // Phase 1 wrote these files, so a reconstruction here means one
732
+ // vanished between the phases; the ids surface that loss. The sink
733
+ // survives a throw, so a shell rebuilt before a later batch failed
734
+ // still gets warned.
735
+ (0, issues_1.archiveIssues)(dir, application, refoldReconstructedIds);
736
+ }
737
+ catch (e) {
738
+ // A landed commit outranks the drop: refolding would re-attempt
739
+ // its commit against a clean tree as no-changes and lose the entry
740
+ // for good. Phase 1 already archived this handoff's records durably
741
+ // once; the fold proceeds and the loss is warned.
742
+ const intact = (0, issues_1.applicationArchivesIntact)(dir, application);
743
+ if (entry?.kind !== 'landed' && intact !== true) {
744
+ detailArchiveError = e;
745
+ return fresh;
746
+ }
747
+ updateArchiveError = e;
748
+ archivesDegraded = intact !== true;
749
+ }
476
750
  folded = true;
477
751
  const next = installFailed
478
- ? (0, state_machine_1.markInstallFailed)(applied.state, step.id)
479
- : applied.state;
480
- return entry ? appendCommit(next, entry) : next;
752
+ ? (0, state_machine_1.markInstallFailed)(application.state, step.id)
753
+ : application.state;
754
+ // A landed commit carries the fixes of every issue resolved by a step it
755
+ // names: the folding step's own resolutions, and those of absorbed steps
756
+ // whose failed commit attempts left them unattached.
757
+ const written = entry
758
+ ? appendCommit(next, (0, issues_1.attachIssueIdsToCommitEntry)(next, entry))
759
+ : next;
760
+ (0, run_state_1.writeRunState)(dir, written);
761
+ return written;
481
762
  });
482
- // Only the handoff this fold consumed is removed. A rejected fold leaves
483
- // it in place: it belongs to whichever attempt is on disk now, and that
484
- // attempt's own reconcile still has to read it.
485
- if (folded)
486
- removeHandoff(dir, step.migrationId);
763
+ warnReconstructedArchives(refoldReconstructedIds);
764
+ if (detailArchiveError !== null) {
765
+ (0, agent_output_1.warnToAgent)({
766
+ title: `The issue details reported by ${step.migrationId} could not be archived (${(0, util_1.summarizeError)(detailArchiveError)}).`,
767
+ bodyLines: [
768
+ `The step's outcome was not folded; fix the underlying problem, then run the reconcile again.`,
769
+ ],
770
+ });
771
+ }
772
+ if (updateArchiveError !== null) {
773
+ (0, agent_output_1.warnToAgent)(archivesDegraded
774
+ ? {
775
+ title: `Some issue transition records for ${step.migrationId} could not be archived (${(0, util_1.summarizeError)(updateArchiveError)}).`,
776
+ bodyLines: [
777
+ `run.json stays authoritative for the dispositions; the archived files under the run's issues directory are missing or incomplete for this fold's issues, and its landed commit takes precedence over retrying the archive.`,
778
+ ],
779
+ }
780
+ : {
781
+ title: `Re-archiving the issue records for ${step.migrationId} failed (${(0, util_1.summarizeError)(updateArchiveError)}).`,
782
+ bodyLines: [
783
+ `Nothing was lost: the fold's records were verified on disk and recorded in run.json. The failed write may point at a disk problem worth checking.`,
784
+ ],
785
+ });
786
+ }
787
+ // A rejected fold leaves the handoff in place: it belongs to whichever
788
+ // attempt is on disk now, and that attempt's own reconcile still has to
789
+ // read it. The stored agent-work payloads go with a terminal outcome; a
790
+ // failed fold keeps them, since a retry re-hands the newest surviving copy.
791
+ if (folded) {
792
+ removeHandoff(dir, step.id);
793
+ if (promptOutcome.status !== 'failed') {
794
+ (0, agent_work_payload_1.removeAgentWorkPayloads)(dir, step.id, step.attempt);
795
+ }
796
+ }
487
797
  }
488
798
  return current;
489
799
  }
@@ -692,35 +1002,51 @@ async function commitForStep(root, dir, state, step) {
692
1002
  };
693
1003
  }
694
1004
  // --- dispense ---------------------------------------------------------------
695
- function advanceAndDispense(root, dir, runId, state) {
1005
+ function advanceAndDispense(root, dir, runId) {
1006
+ // Demote recorded issues no remaining step can claim before anything is
1007
+ // rendered. Done here, at the single choke point every dispense, retry
1008
+ // prompt, and completion goes through, on a fresh locked read: a step
1009
+ // turned terminal by a concurrent reconcile or worker since the caller's
1010
+ // read must not leave an unclaimable issue labeled recorded in a digest
1011
+ // or the completion report.
1012
+ const state = (0, state_lock_1.updateRunState)(dir, (fresh) => {
1013
+ const settled = (0, issues_1.settleUnclaimableIssues)(fresh);
1014
+ return settled === fresh ? null : settled;
1015
+ });
696
1016
  const step = firstActionableStep(state);
697
1017
  if (!step) {
698
1018
  completeRun(root, dir, runId, state);
699
1019
  return;
700
1020
  }
1021
+ // Track the response streak before emitting: a crash in between costs at
1022
+ // most a count one ahead of what was emitted, and the escalation is
1023
+ // advisory. Only the step responses below count: the completion response
1024
+ // above repeats by design, and a rejected --step-action ends the reconcile
1025
+ // before reaching here, already naming its own fix.
1026
+ const noProgress = trackNoProgress(dir, step);
701
1027
  switch (step.status) {
702
1028
  case 'pending':
703
- dispenseNextStep(root, dir, runId, state, step);
1029
+ dispenseNextStep(root, dir, runId, state, step, noProgress);
704
1030
  break;
705
1031
  case 'dispensed':
706
1032
  // Re-entry before the worker advanced the step; re-emit its command.
707
- emitNextStep(root, runId, step);
1033
+ emitNextStep(root, runId, state, step, noProgress);
708
1034
  break;
709
1035
  case 'failed':
710
- emitRetryFailed(root, runId, state, step);
1036
+ emitRetryFailed(root, runId, state, step, noProgress);
711
1037
  break;
712
1038
  case 'died':
713
- emitDied(root, runId, state, step);
1039
+ emitDied(root, runId, state, step, noProgress);
714
1040
  break;
715
1041
  case 'running':
716
- emitStillRunning(root, runId, step);
1042
+ emitStillRunning(root, runId, step, noProgress);
717
1043
  break;
718
1044
  case 'awaiting-prompt-outcome':
719
- emitAwaitPrompt(root, dir, runId, step);
1045
+ emitAwaitPrompt(root, dir, runId, step, noProgress);
720
1046
  break;
721
1047
  case 'succeeded':
722
1048
  case 'skipped':
723
- // firstActionableStep already excludes these via TERMINAL_STATUSES;
1049
+ // firstActionableStep already excludes these via TERMINAL_STEP_STATUSES;
724
1050
  // landing here means an already-terminal step slipped through
725
1051
  // unclassified rather than being left to stall the run silently.
726
1052
  throw new Error(`Orchestrator could not dispense step '${step.id}': step is already ${step.status}.`);
@@ -733,9 +1059,45 @@ function advanceAndDispense(root, dir, runId, state) {
733
1059
  }
734
1060
  }
735
1061
  function firstActionableStep(state) {
736
- return state.steps.find((s) => !TERMINAL_STATUSES.has(s.status));
1062
+ return state.steps.find((s) => !run_state_1.TERMINAL_STEP_STATUSES.has(s.status));
737
1063
  }
738
- function dispenseNextStep(root, dir, runId, state, step) {
1064
+ // The fingerprint digests the whole persisted state (minus the streak itself),
1065
+ // so any durable change resets the count with no per-field enumeration to fall
1066
+ // out of date. A response whose own production writes state after the streak
1067
+ // is tracked fingerprints the pre-write state, so its first repeat resets
1068
+ // rather than increments.
1069
+ function responseFingerprint(state) {
1070
+ return (0, crypto_1.createHash)('sha256')
1071
+ .update(JSON.stringify({ ...state, noProgress: undefined }))
1072
+ .digest('hex');
1073
+ }
1074
+ // A young, parseably-started worker neither increments nor resets the
1075
+ // actionable-response streak. Unproven age counts normally.
1076
+ function trackNoProgress(dir, step) {
1077
+ if (step.status === 'running') {
1078
+ const startedAt = step.startedAt ? Date.parse(step.startedAt) : NaN;
1079
+ if (Number.isFinite(startedAt) &&
1080
+ Date.now() - startedAt < HANG_THRESHOLD_MS) {
1081
+ return null;
1082
+ }
1083
+ }
1084
+ let streak;
1085
+ (0, state_lock_1.updateRunState)(dir, (fresh) => {
1086
+ const fingerprint = responseFingerprint(fresh);
1087
+ const prev = fresh.noProgress;
1088
+ streak =
1089
+ prev && prev.fingerprint === fingerprint
1090
+ ? { ...prev, consecutiveCount: prev.consecutiveCount + 1 }
1091
+ : {
1092
+ fingerprint,
1093
+ consecutiveCount: 1,
1094
+ firstSeenAt: new Date().toISOString(),
1095
+ };
1096
+ return { ...fresh, noProgress: streak };
1097
+ });
1098
+ return streak.consecutiveCount >= NO_PROGRESS_THRESHOLD ? streak : null;
1099
+ }
1100
+ function dispenseNextStep(root, dir, runId, state, step, noProgress) {
739
1101
  // Read the pre-migration baselines (git and package.json reads) before the
740
1102
  // lock; the dispense transition and the baselines then apply to the fresh
741
1103
  // state in one write.
@@ -762,22 +1124,23 @@ function dispenseNextStep(root, dir, runId, state, step) {
762
1124
  if (advancedElsewhere) {
763
1125
  // Terminates: step statuses only advance, so each re-entry observes
764
1126
  // strictly later state and lands in a non-pending branch of the dispatch.
765
- advanceAndDispense(root, dir, runId, current);
1127
+ advanceAndDispense(root, dir, runId);
766
1128
  return;
767
1129
  }
768
- emitNextStep(root, runId, current.steps.find((s) => s.id === step.id));
1130
+ emitNextStep(root, runId, current, current.steps.find((s) => s.id === step.id), noProgress);
769
1131
  }
770
- function emitNextStep(root, runId, step) {
1132
+ function emitNextStep(root, runId, state, step, noProgress) {
771
1133
  const migrationId = step.migrationId;
772
- emit(runId, step, 'next-step', {
1134
+ emit(root, runId, step, 'next-step', {
773
1135
  command: workerCommand(root, migrationId, runId),
774
1136
  next: reconcileCommand(root, runId),
775
1137
  instructionLines: [
776
1138
  `Apply migration ${migrationId} by running the command below, then run the "next" command to record the outcome and get the next step.`,
1139
+ ...(0, issues_1.renderIssueDigestLines)(state, step.id, runId),
777
1140
  ],
778
- });
1141
+ }, noProgress);
779
1142
  }
780
- function emitRetryFailed(root, runId, state, step) {
1143
+ function emitRetryFailed(root, runId, state, step, noProgress) {
781
1144
  const migrationId = step.migrationId;
782
1145
  // A worker failure records its summary on the outcome; a prompt the agent
783
1146
  // reported as failed carries the agent's own reason on the prompt outcome.
@@ -787,18 +1150,21 @@ function emitRetryFailed(root, runId, state, step) {
787
1150
  const cleanRetry = canOfferCleanRetry(root, state, step, head);
788
1151
  // A failure recorded before the generator marker can still have written to
789
1152
  // the tree (a direct fs or exec side effect, or a crash mid-flush); a
790
- // marker means only the install and commit are left, so plain retry is
791
- // safe outright. So is retrying a step with no generator half to rerun.
1153
+ // marker means only the handed-back half (a prompt or a validation pass)
1154
+ // or the install and commit are left, so plain retry is safe outright. So
1155
+ // is retrying a step with no generator half to rerun.
792
1156
  const pending = generatorPending(step);
793
1157
  const retrySafety = pending
794
1158
  ? assessPreMarkerRetry(root, step)
795
1159
  : { kind: 'safe' };
1160
+ const capReached = rearmCapReached(step);
796
1161
  const lines = [
797
1162
  `Migration ${migrationId} failed${summary ? `: ${summary}` : ''}.`,
798
1163
  ` started from: ${step.gitRefBefore ?? '(unknown)'}`,
799
1164
  ` current HEAD: ${head ?? '(unknown)'}`,
800
1165
  ` working tree: ${tree === null ? '(unknown)' : tree ? `\n${tree}` : '(clean)'}`,
801
1166
  ``,
1167
+ ...(capReached ? [rearmCapLine(step), ``] : []),
802
1168
  `Decide how to proceed and re-run reconcile with one of:`,
803
1169
  retryOptionLine(retrySafety, reconcileCommand(root, runId, 'retry')),
804
1170
  ];
@@ -812,11 +1178,26 @@ function emitRetryFailed(root, runId, state, step) {
812
1178
  // A step whose generator may still run gets no `next`, whichever retry the
813
1179
  // checks above would accept: git can vouch for the tracked tree only, and
814
1180
  // an agent that follows `next` blindly must not rerun a generator over
815
- // writes nothing here could see. Choosing a retry has to be explicit.
816
- emit(runId, step, 'retry-failed', {
817
- ...(pending ? {} : { next: reconcileCommand(root, runId, 'retry') }),
1181
+ // writes nothing here could see. Choosing a retry has to be explicit. The
1182
+ // rearm cap withholds it too: each rearm resets the response streak, so a
1183
+ // blindly-followed retry `next` would loop past every escalation.
1184
+ emit(root, runId, step, 'retry-failed', {
1185
+ ...(pending || capReached
1186
+ ? {}
1187
+ : { next: reconcileCommand(root, runId, 'retry') }),
818
1188
  instructionLines: lines,
819
- });
1189
+ }, noProgress);
1190
+ }
1191
+ // Whether the step has used up its rearms: attempts beyond the first are all
1192
+ // rearms, whichever retry action produced them.
1193
+ function rearmCapReached(step) {
1194
+ return step.attempt - 1 >= REARM_ESCALATION_CAP;
1195
+ }
1196
+ // No availability promise: which retry forms remain is the option list's to
1197
+ // say (a pre-marker death may offer none), and the cap itself withholds only
1198
+ // the preselected continuation.
1199
+ function rearmCapLine(step) {
1200
+ 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.`;
820
1201
  }
821
1202
  // Whether the step's generator half may still have to run: it exists and no
822
1203
  // attempt has recorded running it. Only then can a retry apply a generator
@@ -827,6 +1208,11 @@ function emitRetryFailed(root, runId, state, step) {
827
1208
  function generatorPending(step) {
828
1209
  return step.generatorCompleted !== true && step.hasGenerator !== false;
829
1210
  }
1211
+ // Absent generatorMadeChanges (a marker an older nx wrote) counts as applied,
1212
+ // keeping that version's fold behavior.
1213
+ function generatorChangesApplied(step) {
1214
+ return (step.generatorCompleted === true && step.generatorMadeChanges !== false);
1215
+ }
830
1216
  // Appended to the failed and died dispenses of a step whose generator may rerun.
831
1217
  const UNVERIFIABLE_WRITES_LINE = `None of these can be verified against writes git does not see (ignored paths, files outside the repository); if this migration writes there, inspect that state before choosing.`;
832
1218
  function retryOptionLine(safety, command) {
@@ -859,6 +1245,8 @@ function retryOptionLine(safety, command) {
859
1245
  // records dirty, a run created before that field existed carries nothing to
860
1246
  // check, and an unreadable HEAD is no ref at all, so none of the three can be
861
1247
  // read as a restore point that exists.
1248
+ // The debt check is run-wide: a clean retry resets and cleans the whole tree,
1249
+ // which would discard every other failed step's uncommitted work as well.
862
1250
  function canOfferCleanRetry(root, state, step, head) {
863
1251
  return (state.createCommits &&
864
1252
  !state.checkpointFailed &&
@@ -926,22 +1314,21 @@ function assessPreMarkerRetry(root, step) {
926
1314
  }
927
1315
  return { kind: 'safe' };
928
1316
  }
929
- function emitDied(root, runId, state, step) {
1317
+ function emitDied(root, runId, state, step, noProgress) {
930
1318
  const migrationId = step.migrationId;
931
1319
  const ref = step.gitRefBefore;
932
1320
  const head = (0, git_utils_1.getLatestCommitSha)(root);
933
1321
  const tree = dirtyTreeSummary(root);
934
1322
  const cleanRetry = canOfferCleanRetry(root, state, step, head);
935
- // The generator half is recorded (or the step never had one), so a retry
936
- // that keeps the tree as it stands has the rest of the step left to run: a
937
- // prompt, or the install and commit its worker never reached.
938
1323
  const resume = !generatorPending(step);
1324
+ const capReached = rearmCapReached(step);
939
1325
  const lines = [
940
1326
  `The worker for ${migrationId} died; its process is gone.`,
941
1327
  ` started from: ${ref ?? '(unknown)'}`,
942
1328
  ` current HEAD: ${head ?? '(unknown)'}`,
943
1329
  ` working tree: ${tree === null ? '(unknown)' : tree ? `\n${tree}` : '(clean)'}`,
944
1330
  ``,
1331
+ ...(capReached ? [rearmCapLine(step), ``] : []),
945
1332
  ];
946
1333
  const options = [];
947
1334
  if (resume) {
@@ -962,18 +1349,21 @@ function emitDied(root, runId, state, step) {
962
1349
  if (!resume) {
963
1350
  lines.push(UNVERIFIABLE_WRITES_LINE);
964
1351
  }
965
- // `retry` is preselected wherever it is legal: it is the only resolution
966
- // that neither discards work nor records a result the run never produced.
967
- // While the generator may still run there is no `next` at all: a reset
968
- // cannot be verified against writes git does not see, and adopting records
969
- // a result nothing checked, so an agent that follows `next` blindly must
970
- // land on neither.
971
- emit(runId, step, 'died', {
972
- ...(resume ? { next: reconcileCommand(root, runId, 'retry') } : {}),
1352
+ // `retry` is preselected wherever it is legal: it is the only resolution that
1353
+ // neither discards work nor records a result the run never produced. While
1354
+ // the generator may still run there is no `next` at all: a reset cannot be
1355
+ // verified against writes git does not see, and adopting records a result
1356
+ // nothing checked, so an agent that follows `next` blindly must land on
1357
+ // neither. The rearm cap withholds it for the same reason as in
1358
+ // emitRetryFailed.
1359
+ emit(root, runId, step, 'died', {
1360
+ ...(resume && !capReached
1361
+ ? { next: reconcileCommand(root, runId, 'retry') }
1362
+ : {}),
973
1363
  instructionLines: lines,
974
- });
1364
+ }, noProgress);
975
1365
  }
976
- function emitStillRunning(root, runId, step) {
1366
+ function emitStillRunning(root, runId, step, noProgress) {
977
1367
  const migrationId = step.migrationId;
978
1368
  const ageMs = step.startedAt ? Date.now() - Date.parse(step.startedAt) : 0;
979
1369
  const lines = [
@@ -982,47 +1372,157 @@ function emitStillRunning(root, runId, step) {
982
1372
  if (ageMs >= HANG_THRESHOLD_MS) {
983
1373
  lines.push(`It has been running for ${Math.floor(ageMs / 60000)} minutes and may be hung. Verify pid ${step.pid}; either keep waiting, or kill it so the next reconcile can classify it as died.`);
984
1374
  }
985
- emit(runId, step, 'still-running', {
1375
+ emit(root, runId, step, 'still-running', {
986
1376
  next: reconcileCommand(root, runId),
987
1377
  instructionLines: lines,
988
- });
1378
+ }, noProgress);
989
1379
  }
990
- function emitAwaitPrompt(root, dir, runId, step) {
1380
+ function emitAwaitPrompt(root, dir, runId, step, noProgress) {
991
1381
  const migrationId = step.migrationId;
992
- const { package: pkg, name } = (0, state_machine_1.splitMigrationId)(migrationId);
993
- const filePath = handoffPath(dir, { package: pkg, name });
994
- // The package id becomes real path segments, so handing over the path
995
- // without its directory is what would force the agent to `mkdir -p`. Same
996
- // reason the classic runner pre-creates it in run-step.ts.
997
- (0, fs_1.mkdirSync)((0, path_1.dirname)(filePath), { recursive: true });
998
- const lines = [
999
- `Migration ${migrationId} is a prompt-based migration awaiting your outcome.`,
1000
- `Apply the prompt (see the worker's earlier <nx_migrate_prompt> block), then write the handoff file and run the "next" command.`,
1001
- `Handoff file: ${filePath}`,
1002
- `Handoff JSON: { "status": "success" | "failed", "summary": "<what you did>" }. To mark the prompt not applicable, use "status": "success" with "outcome": "skipped".`,
1003
- ];
1382
+ const filePath = (0, handoff_1.runStepHandoffPath)(dir, step.id);
1383
+ // Recreated if the agent removed it, so the handed-over path always has its
1384
+ // parent (an agent that has to `mkdir -p` pays a permission prompt).
1385
+ const handoffsDir = (0, run_state_1.runHandoffsDir)(dir);
1386
+ const handoffsDirIs = (0, handoff_1.handoffsDirState)(handoffsDir);
1387
+ if (handoffsDirIs === 'missing') {
1388
+ (0, fs_1.mkdirSync)(handoffsDir, { recursive: true });
1389
+ }
1390
+ // Claim only while fresh state still awaits this handoff. Steps that never
1391
+ // park cannot report updates; serial dispensing fixes order, and stale
1392
+ // attempts redispatch.
1393
+ let advancedElsewhere = false;
1394
+ const claimed = (0, state_lock_1.updateRunState)(dir, (fresh) => {
1395
+ const freshStep = fresh.steps.find((s) => s.id === step.id);
1396
+ if (freshStep?.status !== 'awaiting-prompt-outcome' ||
1397
+ freshStep.attempt !== step.attempt) {
1398
+ advancedElsewhere = true;
1399
+ return null;
1400
+ }
1401
+ const next = (0, issues_1.claimIssuesForStep)(fresh, step.id, runId);
1402
+ return next === fresh ? null : next;
1403
+ });
1404
+ if (advancedElsewhere) {
1405
+ // Terminates like dispenseNextStep's redispatch: step state only
1406
+ // advances, so each re-entry observes strictly later state.
1407
+ advanceAndDispense(root, dir, runId);
1408
+ return;
1409
+ }
1410
+ const validating = step.awaitingKind === 'generator-validation';
1411
+ // The plan's prompt path is the ground truth for a prompt park: a stored copy
1412
+ // naming different instructions is rejected against it.
1413
+ const planPrompt = validating
1414
+ ? null
1415
+ : planPromptPath(dir, claimed, migrationId);
1416
+ // Re-emit the payload the worker stored when it parked the step, so a
1417
+ // session that lost the original block gets the work restated. An awaiting
1418
+ // step offers no retry action, so a dispense that only pointed backward
1419
+ // could stall a valid run forever; only a plan that cannot name the prompt
1420
+ // falls back to that pointer.
1421
+ const payload = (0, agent_work_payload_1.readAgentWorkPayload)((0, agent_work_payload_1.agentWorkPayloadPath)(dir, step.id, step.attempt), {
1422
+ migrationId,
1423
+ kind: validating ? 'generator-validation' : 'migration-prompt',
1424
+ ...(planPrompt === null ? {} : { promptPath: planPrompt }),
1425
+ }) ??
1426
+ (validating
1427
+ ? { migrationId, kind: 'generator-validation' }
1428
+ : planPrompt === null
1429
+ ? null
1430
+ : { migrationId, prompt: planPrompt });
1431
+ if (payload) {
1432
+ (0, agent_output_1.emitPromptBlock)(migrationId, payload);
1433
+ }
1434
+ const blockRef = payload
1435
+ ? 'the <nx_migrate_prompt> block above'
1436
+ : "the worker's earlier <nx_migrate_prompt> block";
1437
+ const lines = validating
1438
+ ? [
1439
+ `Migration ${migrationId} ran its generator; its changes are awaiting your validation.`,
1440
+ `Validate them (see ${blockRef} and the runbook's validation scope rules), then write the handoff file and run the "next" command.`,
1441
+ ]
1442
+ : [
1443
+ `Migration ${migrationId} is a prompt-based migration awaiting your outcome.`,
1444
+ `Apply the prompt (see ${blockRef}), then write the handoff file and run the "next" command.`,
1445
+ // Resolved per dispense: an earlier step may have changed the formatter
1446
+ // since the runbook was written.
1447
+ `Format command: ${(0, format_command_1.resolveFormatCommand)(root, (0, util_1.pmExecPrefix)(root)) ??
1448
+ 'none (no configured formatter is installed)'}`,
1449
+ ];
1450
+ lines.push(`Handoff file: ${filePath}`,
1451
+ // The skipped outcome is offered only while nothing of the migration is in
1452
+ // the tree: once a generator's changes are applied, the fold treats a
1453
+ // skipped handoff as completed anyway.
1454
+ validating
1455
+ ? `Handoff JSON: ${(0, fragments_1.renderHandoffShapeInline)('what you verified')}. If validation does not apply here, use "status": "success" and say so in the summary.`
1456
+ : generatorChangesApplied(step)
1457
+ ? `Handoff JSON: ${(0, fragments_1.renderHandoffShapeInline)('what you did')}. If the prompt does not apply here, use "status": "success" and say so in the summary; the migration's generator changes are already applied.`
1458
+ : `Handoff JSON: ${(0, fragments_1.renderHandoffShapeInline)('what you did')}. To mark the prompt not applicable, use "status": "success" with "outcome": "skipped".`);
1459
+ lines.push(...(0, issues_1.renderIssueDigestLines)(claimed, step.id, runId));
1004
1460
  // A handoff that exists but can't be read/parsed/validated is a rejection,
1005
1461
  // not a still-awaited outcome. Naming why stops the run from re-emitting the
1006
1462
  // same await forever while the agent leaves the bad file in place.
1007
- const rejection = describeRejectedHandoff(filePath);
1463
+ // A non-directory in the handoffs dir's place is never read through, so a
1464
+ // rewritten handoff cannot cure it: name the replacement instead.
1465
+ const rejection = handoffsDirIs === 'other'
1466
+ ? [
1467
+ `${handoffsDir} is not a directory, so no handoff can be read from it. Replace it with a directory, then write the handoff file and run the "next" command.`,
1468
+ ]
1469
+ : describeRejectedHandoff(dir, claimed, step);
1008
1470
  if (rejection.length > 0) {
1009
1471
  lines.push('', ...rejection);
1010
1472
  }
1011
- emit(runId, step, 'await-prompt', {
1473
+ emit(root, runId, step, 'await-prompt', {
1012
1474
  next: reconcileCommand(root, runId),
1013
1475
  instructionLines: lines,
1014
- });
1476
+ }, noProgress);
1477
+ }
1478
+ // The prompt path the run's latest plan snapshot records for the migration.
1479
+ // Every failure lands on null, damaged-but-parseable snapshots included: this
1480
+ // runs while re-handing an awaiting step's work, and throwing here would keep
1481
+ // the run from re-dispensing that work at all. The snapshot name is validated
1482
+ // as a bare `plan-<round>.json` at state read, so the join cannot leave the run
1483
+ // directory.
1484
+ function planPromptPath(dir, state, migrationId) {
1485
+ const round = (0, state_machine_1.latestRound)(state);
1486
+ if (!round)
1487
+ return null;
1488
+ let migrations;
1489
+ try {
1490
+ migrations = (0, fileutils_1.readJsonFile)((0, path_1.join)(dir, round.planSnapshot)).migrations;
1491
+ }
1492
+ catch {
1493
+ return null;
1494
+ }
1495
+ if (!Array.isArray(migrations))
1496
+ return null;
1497
+ for (const entry of migrations) {
1498
+ if (typeof entry !== 'object' || entry === null)
1499
+ continue;
1500
+ const migration = entry;
1501
+ if (`${migration.package}:${migration.name}` !== migrationId)
1502
+ continue;
1503
+ return typeof migration.prompt === 'string' ? migration.prompt : null;
1504
+ }
1505
+ return null;
1015
1506
  }
1016
- // Empty unless a handoff file is present but unusable; wording mirrors the
1017
- // classic runner's ambiguous-outcome cause lines.
1018
- function describeRejectedHandoff(handoffPath) {
1019
- const result = (0, handoff_1.readHandoffWithReason)(handoffPath);
1020
- if (result.ok)
1507
+ // Wording mirrors the classic runner's ambiguous-outcome cause lines. A
1508
+ // readable handoff whose issue report is invalid is a rejection too: the fold
1509
+ // refused to consume it, and only this response can tell the agent why.
1510
+ function describeRejectedHandoff(dir, state, step) {
1511
+ const result = readStepHandoff(dir, step.id);
1512
+ const followUp = 'Rewrite the handoff file, then run the "next" command.';
1513
+ if (result.ok === true) {
1514
+ const issues = (0, issues_1.parseHandoffIssues)(result.handoff.extras, state, step);
1515
+ if (issues.ok !== true) {
1516
+ return [
1517
+ `The handoff file was rejected: ${issues.reason}.`,
1518
+ followUp,
1519
+ ];
1520
+ }
1021
1521
  return [];
1522
+ }
1022
1523
  const { reason, detail } = result;
1023
1524
  if (reason === 'missing')
1024
1525
  return [];
1025
- const followUp = 'Rewrite the handoff file, then run the "next" command.';
1026
1526
  switch (reason) {
1027
1527
  case 'read-error':
1028
1528
  return [
@@ -1050,13 +1550,17 @@ function describeRejectedHandoff(handoffPath) {
1050
1550
  // exit would tell the driving agent that reconcile itself crashed, and it
1051
1551
  // would stop reading for the correction it is being handed.
1052
1552
  function emitError(root, runId, reason) {
1553
+ // A rejected action is an active response like any dispense, so it carries
1554
+ // the runbook footer: the master receiving it may have just lost its
1555
+ // context. One sanitized array feeds both outputs, as in emit().
1556
+ const lines = (0, agent_output_1.safeLines)([reason, ...runbookFooterLines(root, runId)]);
1053
1557
  (0, agent_output_1.warnToAgent)({
1054
1558
  title: 'The requested --step-action could not be applied.',
1055
- bodyLines: [reason],
1559
+ bodyLines: lines,
1056
1560
  });
1057
1561
  (0, agent_output_1.emitStepBlock)(runId, '-', 'error', {
1058
1562
  next: reconcileCommand(root, runId),
1059
- instructions: reason,
1563
+ instructions: lines.join('\n'),
1060
1564
  });
1061
1565
  (0, migrate_analytics_1.reportMigrateOrchestratorDispense)({ action: 'error', attempt: 0 });
1062
1566
  }
@@ -1108,29 +1612,69 @@ function completeRun(root, dir, runId, state) {
1108
1612
  if (installLine) {
1109
1613
  (0, agent_output_1.warnToAgent)({ title: installLine });
1110
1614
  }
1615
+ const issueLines = (0, issues_1.renderUnresolvedIssueLines)(current, runId);
1616
+ if (issueLines.length > 0) {
1617
+ (0, agent_output_1.warnToAgent)({ title: issueLines[0], bodyLines: issueLines.slice(1) });
1618
+ }
1111
1619
  const instructionLines = [
1112
1620
  `Migrate run ${runId} is complete.`,
1113
1621
  ` applied: ${completed}`,
1114
1622
  ` skipped: ${skipped}`,
1115
1623
  ...(commitDebt ? [debtLine] : []),
1116
1624
  ...(installLine ? [installLine] : []),
1625
+ ...issueLines,
1117
1626
  ];
1118
1627
  (0, agent_output_1.logToAgent)({ title: 'nx migrate: complete', bodyLines: instructionLines });
1119
1628
  (0, agent_output_1.emitStepBlock)(runId, '-', 'complete', {
1120
1629
  instructions: instructionLines.join('\n'),
1121
1630
  });
1122
1631
  }
1123
- function emit(runId, step, action, payload) {
1632
+ function emit(root, runId, step, action, payload, noProgress) {
1633
+ // At the escalation threshold the whole response becomes the 'no-progress'
1634
+ // action, with the step's own instructions kept below so acting on them stays
1635
+ // possible. Never a step status: nothing durable changed, and a status would
1636
+ // have to be walked back once the agent acts.
1637
+ const escalated = noProgress !== null;
1638
+ const effectiveAction = escalated ? 'no-progress' : action;
1124
1639
  const { instructionLines, ...rest } = payload;
1640
+ const allLines = escalated
1641
+ ? [...noProgressLines(step, noProgress), ...(instructionLines ?? [])]
1642
+ : instructionLines;
1643
+ const withFooter = allLines
1644
+ ? [...allLines, ...runbookFooterLines(root, runId)]
1645
+ : undefined;
1125
1646
  // One sanitized array feeds both, so the block payload says exactly what the
1126
1647
  // human echo said.
1127
- const lines = instructionLines ? (0, agent_output_1.safeLines)(instructionLines) : undefined;
1128
- (0, agent_output_1.logToAgent)({ title: `nx migrate: ${action}`, bodyLines: lines });
1129
- (0, agent_output_1.emitStepBlock)(runId, step.id, action, {
1648
+ const lines = withFooter ? (0, agent_output_1.safeLines)(withFooter) : undefined;
1649
+ (0, agent_output_1.logToAgent)({ title: `nx migrate: ${effectiveAction}`, bodyLines: lines });
1650
+ (0, agent_output_1.emitStepBlock)(runId, step.id, effectiveAction, {
1130
1651
  ...rest,
1131
1652
  ...(lines ? { instructions: lines.join('\n') } : {}),
1132
1653
  });
1133
- (0, migrate_analytics_1.reportMigrateOrchestratorDispense)({ action, attempt: step.attempt });
1654
+ (0, migrate_analytics_1.reportMigrateOrchestratorDispense)({
1655
+ action: effectiveAction,
1656
+ attempt: step.attempt,
1657
+ });
1658
+ }
1659
+ function noProgressLines(step, streak) {
1660
+ return [
1661
+ `No progress: this is response ${streak.consecutiveCount} in a row for migration ${step.migrationId} with no change in the run's recorded state since ${streak.firstSeenAt}.`,
1662
+ `Re-running the reconcile command changes nothing on its own; act on the instructions below. If something is blocking you from acting on them, stop looping and report the blocker to the user.`,
1663
+ ``,
1664
+ ];
1665
+ }
1666
+ // Append the runbook path to init, active and rejected responses so a
1667
+ // compacted, truncated or restarted session can recover the contract. Missing
1668
+ // or non-regular runbooks stand alone.
1669
+ function runbookFooterLines(root, runId) {
1670
+ if (lstatRunbook((0, path_1.join)((0, run_state_1.runDir)(root, runId), runbook_1.RUNBOOK_FILE_NAME))?.isFile() !==
1671
+ true) {
1672
+ return [];
1673
+ }
1674
+ return [
1675
+ ``,
1676
+ `Runbook: ${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${runId}/${runbook_1.RUNBOOK_FILE_NAME}. After a compaction or restart, re-read it and run \`${reconcileCommand(root, runId)}\`; never infer the run's progress from memory.`,
1677
+ ];
1134
1678
  }
1135
1679
  // Raw argv is forwarded verbatim across the wrapper hops, so every flag is a
1136
1680
  // single `--flag=value` token. Interpolated values are validated shell-safe at
@@ -1176,11 +1720,24 @@ function applyEventOrThrow(state, event) {
1176
1720
  }
1177
1721
  return result.state;
1178
1722
  }
1179
- function handoffPath(dir, migration) {
1180
- return (0, handoff_1.stepHandoffPath)(dir, migration);
1723
+ function readStepHandoff(dir, stepId) {
1724
+ return (0, handoff_1.readHandoffWithReason)((0, handoff_1.runStepHandoffPath)(dir, stepId), (0, run_state_1.runHandoffsDir)(dir));
1181
1725
  }
1182
- function removeHandoff(dir, migrationId) {
1183
- (0, fs_1.rmSync)(handoffPath(dir, (0, state_machine_1.splitMigrationId)(migrationId)), { force: true });
1726
+ // Probe-time guard: a handoffs dir the agent swapped for a symlink into a
1727
+ // directory holding a file of the step's fixed name would otherwise have the
1728
+ // orchestrator delete that file. A swap between the probe and the rm is not
1729
+ // caught. A probe failure skips the rm too; the next read reports it.
1730
+ function removeHandoff(dir, stepId) {
1731
+ let state;
1732
+ try {
1733
+ state = (0, handoff_1.handoffsDirState)((0, run_state_1.runHandoffsDir)(dir));
1734
+ }
1735
+ catch {
1736
+ return;
1737
+ }
1738
+ if (state === 'directory') {
1739
+ (0, fs_1.rmSync)((0, handoff_1.runStepHandoffPath)(dir, stepId), { force: true });
1740
+ }
1184
1741
  }
1185
1742
  // null means the probe itself failed; the death dispense renders that as
1186
1743
  // '(unknown)', because '(clean)' would invite a retry-clean reset over