nx 23.3.0-beta.2 → 23.3.0-beta.4

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 (58) hide show
  1. package/dist/bin/init-local.js +7 -1
  2. package/dist/src/command-line/add/add.js +5 -1
  3. package/dist/src/command-line/init/implementation/utils.js +6 -1
  4. package/dist/src/command-line/migrate/agentic/capture-generator-output.d.ts +25 -0
  5. package/dist/src/command-line/migrate/agentic/capture-generator-output.js +121 -5
  6. package/dist/src/command-line/migrate/agentic/close-agent-session.d.ts +21 -0
  7. package/dist/src/command-line/migrate/agentic/close-agent-session.js +126 -0
  8. package/dist/src/command-line/migrate/agentic/definitions.d.ts +24 -0
  9. package/dist/src/command-line/migrate/agentic/definitions.js +5 -2
  10. package/dist/src/command-line/migrate/agentic/handoff.d.ts +23 -0
  11. package/dist/src/command-line/migrate/agentic/handoff.js +62 -3
  12. package/dist/src/command-line/migrate/agentic/master/invocations.d.ts +12 -0
  13. package/dist/src/command-line/migrate/agentic/master/invocations.js +54 -0
  14. package/dist/src/command-line/migrate/agentic/master/run-master-session.d.ts +12 -0
  15. package/dist/src/command-line/migrate/agentic/master/run-master-session.js +99 -0
  16. package/dist/src/command-line/migrate/agentic/master/spawn-master.d.ts +31 -0
  17. package/dist/src/command-line/migrate/agentic/master/spawn-master.js +217 -0
  18. package/dist/src/command-line/migrate/agentic/prompts/fragments.js +3 -0
  19. package/dist/src/command-line/migrate/agentic/runner.d.ts +0 -21
  20. package/dist/src/command-line/migrate/agentic/runner.js +12 -249
  21. package/dist/src/command-line/migrate/agentic/terminal-repair.d.ts +1 -0
  22. package/dist/src/command-line/migrate/agentic/terminal-repair.js +27 -0
  23. package/dist/src/command-line/migrate/agentic/windows-cmd.d.ts +33 -0
  24. package/dist/src/command-line/migrate/agentic/windows-cmd.js +88 -0
  25. package/dist/src/command-line/migrate/deferred-output.d.ts +44 -0
  26. package/dist/src/command-line/migrate/deferred-output.js +92 -0
  27. package/dist/src/command-line/migrate/execute-migration.d.ts +9 -3
  28. package/dist/src/command-line/migrate/execute-migration.js +53 -16
  29. package/dist/src/command-line/migrate/migrate-commits.d.ts +4 -4
  30. package/dist/src/command-line/migrate/migrate-commits.js +7 -6
  31. package/dist/src/command-line/migrate/migrate.js +28 -13
  32. package/dist/src/command-line/migrate/run/broker.d.ts +138 -0
  33. package/dist/src/command-line/migrate/run/broker.js +507 -0
  34. package/dist/src/command-line/migrate/run/clean-retry.d.ts +12 -0
  35. package/dist/src/command-line/migrate/run/clean-retry.js +98 -0
  36. package/dist/src/command-line/migrate/run/index.d.ts +4 -3
  37. package/dist/src/command-line/migrate/run/index.js +7 -1
  38. package/dist/src/command-line/migrate/run/issues.d.ts +8 -1
  39. package/dist/src/command-line/migrate/run/issues.js +40 -6
  40. package/dist/src/command-line/migrate/run/orchestrator.d.ts +18 -1
  41. package/dist/src/command-line/migrate/run/orchestrator.js +409 -377
  42. package/dist/src/command-line/migrate/run/run-state.d.ts +16 -0
  43. package/dist/src/command-line/migrate/run/run-state.js +33 -2
  44. package/dist/src/command-line/migrate/run/runbook.js +8 -4
  45. package/dist/src/command-line/migrate/run/state-machine.d.ts +24 -1
  46. package/dist/src/command-line/migrate/run/state-machine.js +112 -21
  47. package/dist/src/command-line/migrate/run/util.d.ts +2 -1
  48. package/dist/src/command-line/migrate/run/util.js +3 -3
  49. package/dist/src/command-line/migrate/run/worker.js +170 -164
  50. package/dist/src/core/graph/main.js +1 -1
  51. package/dist/src/daemon/server/server.js +11 -8
  52. package/dist/src/native/nx.wasm32-wasi.debug.wasm +0 -0
  53. package/dist/src/native/nx.wasm32-wasi.wasm +0 -0
  54. package/dist/src/tasks-runner/run-command.js +3 -0
  55. package/dist/src/utils/git-utils.d.ts +12 -0
  56. package/dist/src/utils/git-utils.js +76 -30
  57. package/dist/src/utils/package-json.js +8 -0
  58. package/package.json +11 -11
@@ -2,12 +2,15 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runOrchestratorInit = runOrchestratorInit;
4
4
  exports.runOrchestratorReconcile = runOrchestratorReconcile;
5
+ exports.completionWarnings = completionWarnings;
5
6
  const child_process_1 = require("child_process");
6
7
  const crypto_1 = require("crypto");
7
8
  const fs_1 = require("fs");
8
9
  const path_1 = require("path");
9
10
  const fileutils_1 = require("../../../utils/fileutils");
10
11
  const atomic_write_1 = require("./atomic-write");
12
+ const clean_retry_1 = require("./clean-retry");
13
+ const broker_1 = require("./broker");
11
14
  const git_utils_1 = require("../../../utils/git-utils");
12
15
  const versions_1 = require("../../../utils/versions");
13
16
  const handoff_1 = require("../agentic/handoff");
@@ -81,7 +84,7 @@ function refuseUnsafeScratchExposure(exposure, thenWhat) {
81
84
  }
82
85
  }
83
86
  async function runOrchestratorInit(input) {
84
- const { root, migrationsJson, createCommits, commitPrefix, skipInstall, installedNxVersion, validate, } = input;
87
+ const { root, migrationsJson, createCommits, commitPrefix, skipInstall, installedNxVersion, validate, emitAgentInstructions = true, } = input;
85
88
  const planHash = (0, run_id_1.computePlanHash)(migrationsJson);
86
89
  // An active run means a prior init already happened (e.g. it crashed before
87
90
  // the agent's first reconcile); starting a second run would compete with it.
@@ -102,9 +105,9 @@ async function runOrchestratorInit(input) {
102
105
  throw new Error(`The migration id '${id}' contains characters that are not shell-safe. Orchestrated runs require shell-safe migration ids.`);
103
106
  }
104
107
  }
108
+ const policy = { createCommits, skipInstall };
105
109
  if (active) {
106
- resumeRun(root, active.runId, active.state);
107
- return;
110
+ return resumeRun(root, active.runId, active.state, policy, emitAgentInstructions);
108
111
  }
109
112
  const runId = (0, run_id_1.createRunId)();
110
113
  const dir = (0, run_state_1.runDir)(root, runId);
@@ -200,10 +203,9 @@ async function runOrchestratorInit(input) {
200
203
  return null;
201
204
  });
202
205
  if (winner) {
203
- resumeRun(root, winner.runId, winner.state);
204
- return;
206
+ return resumeRun(root, winner.runId, winner.state, policy, emitAgentInstructions);
205
207
  }
206
- finishInit(root, dir, runId, state, 'created');
208
+ return finishInit(root, dir, runId, state, 'created', emitAgentInstructions);
207
209
  }
208
210
  // Reads the newest active run, refusing one whose plan differs from the
209
211
  // incoming plan; null when no run is active. Uninterpretable run dirs refuse
@@ -240,7 +242,14 @@ function findActiveRunForPlan(root, planHash) {
240
242
  }
241
243
  // Shared resume tail for an active run found before or under the creation
242
244
  // lock, so the two discovery points cannot drift apart.
243
- function resumeRun(root, runId, state) {
245
+ function resumeRun(root, runId, state, policy, emitAgentInstructions) {
246
+ // Refused before the checkpoint retry below, which commits.
247
+ if (state.createCommits !== policy.createCommits ||
248
+ (state.skipInstall === true) !== policy.skipInstall) {
249
+ 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.`);
252
+ }
244
253
  const dir = (0, run_state_1.runDir)(root, runId);
245
254
  // Ignore/index state can change while a durable run is paused (a checkout,
246
255
  // a .gitignore edit, a forced add). Probe before the checkpoint retry:
@@ -254,13 +263,13 @@ function resumeRun(root, runId, state) {
254
263
  // contract must not first change git history or durable run state.
255
264
  const runbook = ensureRunbook(root, dir, runId, state);
256
265
  if (runbook === null) {
257
- return;
266
+ return { kind: 'refused' };
258
267
  }
259
268
  // A run flagged checkpointFailed gets one more chance to capture the
260
269
  // pre-existing tree state before its first migration commit absorbs it.
261
270
  const resumed = ensureCheckpoint(root, dir, state);
262
271
  announceResume(runId, resumed);
263
- finishInit(root, dir, runId, resumed, 'resumed', runbook);
272
+ return finishInit(root, dir, runId, resumed, 'resumed', emitAgentInstructions, runbook);
264
273
  }
265
274
  function announceResume(runId, state) {
266
275
  const applied = state.steps.filter((s) => s.status === 'succeeded').length;
@@ -289,26 +298,43 @@ function ensureCheckpoint(root, dir, state) {
289
298
  return state;
290
299
  if (state.steps.some((s) => s.status !== 'pending'))
291
300
  return state;
292
- // The checkpoint commit is a git side effect, so it runs before the lock; the
293
- // ledger append and flag clear then apply to the fresh on-disk state.
294
- const checkpoint = checkpointEntry(root, state.commitPrefix);
295
- // The retried checkpoint captured everything, so clean retries are safe
296
- // again. Only a verified-clean tree clears the flag: a failed probe proves
297
- // nothing was captured.
298
- const cleared = (0, git_utils_1.getWorkingTreeStatus)(root) === 'clean';
299
- if (!checkpoint && !cleared)
300
- return state;
301
- return (0, state_lock_1.updateRunState)(dir, (fresh) => {
302
- // Re-check both guards on the fresh state: a concurrent reconcile may have
303
- // cleared the flag or advanced a step while the commit ran. Skipping here
304
- // can leave that commit unledgered, the documented crash-window shape.
305
- if (!fresh.checkpointFailed ||
306
- fresh.steps.some((s) => s.status !== 'pending')) {
307
- return null;
308
- }
309
- const next = checkpoint ? appendCommit(fresh, checkpoint) : fresh;
310
- return cleared ? { ...next, checkpointFailed: false } : next;
311
- });
301
+ // Reserved so no step is dispensed into the tree the checkpoint captures;
302
+ // a run that moved on since the snapshot has nothing left to capture.
303
+ let lease;
304
+ try {
305
+ lease = (0, broker_1.acquireTreeOperation)(dir, { kind: 'checkpoint' });
306
+ }
307
+ catch (e) {
308
+ if (e instanceof broker_1.BrokerStaleRequestError)
309
+ return state;
310
+ throw e;
311
+ }
312
+ try {
313
+ // The checkpoint commit is a git side effect, so it runs before the lock;
314
+ // the ledger append and flag clear then apply to the fresh on-disk state.
315
+ const checkpoint = checkpointEntry(root, state.commitPrefix);
316
+ // The retried checkpoint captured everything, so clean retries are safe
317
+ // again. Only a verified-clean tree clears the flag: a failed probe proves
318
+ // nothing was captured.
319
+ const cleared = (0, git_utils_1.getWorkingTreeStatus)(root) === 'clean';
320
+ if (!checkpoint && !cleared)
321
+ return state;
322
+ return (0, state_lock_1.updateRunState)(dir, (fresh) => {
323
+ // Re-check both guards on the fresh state: a concurrent reconcile may
324
+ // have cleared the flag or advanced a step while the commit ran.
325
+ // Skipping here can leave that commit unledgered, the documented
326
+ // crash-window shape.
327
+ if (!fresh.checkpointFailed ||
328
+ fresh.steps.some((s) => s.status !== 'pending')) {
329
+ return null;
330
+ }
331
+ const next = checkpoint ? (0, state_machine_1.appendCommit)(fresh, checkpoint) : fresh;
332
+ return cleared ? { ...next, checkpointFailed: false } : next;
333
+ });
334
+ }
335
+ finally {
336
+ lease.release();
337
+ }
312
338
  }
313
339
  // Commits pre-existing working-tree state so the first migration's commit can't
314
340
  // absorb it, returning the ledger entry only when a commit verifiably landed.
@@ -331,7 +357,7 @@ function checkpointEntry(root, commitPrefix) {
331
357
  // No migration step is dispensed here: the agent reads the runbook first and
332
358
  // asks for the run's current step by reconciling, so the contract always lands
333
359
  // before the first command does.
334
- function finishInit(root, dir, runId, state, origin,
360
+ function finishInit(root, dir, runId, state, origin, emitAgentInstructions,
335
361
  // The runbook bytes when the caller already ensured them (the resume path,
336
362
  // which must fail before its git and state side effects).
337
363
  runbook) {
@@ -358,7 +384,17 @@ runbook) {
358
384
  }
359
385
  const content = runbook ?? ensureRunbook(root, dir, runId, current);
360
386
  if (content === null) {
361
- return;
387
+ return { kind: 'refused' };
388
+ }
389
+ const ready = {
390
+ kind: 'ready',
391
+ runId,
392
+ runRoot: root,
393
+ runbookPath: (0, path_1.join)(dir, current.runbookPath ?? runbook_1.RUNBOOK_FILE_NAME),
394
+ reconcileCommand: reconcileCommand(root, runId),
395
+ };
396
+ if (!emitAgentInstructions) {
397
+ return ready;
362
398
  }
363
399
  (0, agent_output_1.emitRunbookBlock)(runId, content);
364
400
  const instructionLines = [
@@ -369,9 +405,10 @@ runbook) {
369
405
  const lines = (0, agent_output_1.safeLines)(instructionLines);
370
406
  (0, agent_output_1.logToAgent)({ title: `nx migrate: run ${origin}`, bodyLines: lines });
371
407
  (0, agent_output_1.emitStepBlock)(runId, '-', 'initialized', {
372
- next: reconcileCommand(root, runId),
408
+ next: ready.reconcileCommand,
373
409
  instructions: lines.join('\n'),
374
410
  });
411
+ return ready;
375
412
  }
376
413
  // A missing runbook is re-rendered only by the nx version that created the
377
414
  // run: its content is version-locked, and a different nx re-rendering it would
@@ -484,128 +521,100 @@ async function runOrchestratorReconcile(input) {
484
521
  state = detectDeaths(dir, state);
485
522
  // (c) apply the decision relay to the single failed/died step.
486
523
  if (stepAction) {
487
- const result = applyReconcileStepAction(root, state, stepAction);
488
- if (result.kind === 'error') {
489
- emitError(root, runId, result.reason);
490
- return; // state untouched
491
- }
492
- const target = result.targetStep;
493
- // An adopted death commits its working tree; that git side effect runs
494
- // before the lock (locked sections must stay synchronous), then the
495
- // transition and its ledger entry land in one fresh-state write so a
496
- // crash can't leave the step succeeded unrecorded. As with a fold, that
497
- // window is wide, and a rejected reapply after the commit landed is
498
- // equivalent to commitForStep's crash-refold window: the commit stays in
499
- // history, the ledger misses it, and the rejection names it below so the
500
- // agent re-decides against the moved HEAD.
501
- // Without commits the adopted tree is still this migration's result, and
502
- // it can carry package.json edits the dead worker never installed; the
503
- // install has to run here or the next dispense captures the modified
504
- // dependencies as its own baseline and nothing is left to detect them.
505
- // A skip leaves the tree as it stands too, so it owes the same install
506
- // and, with commits on, the same debt record as a prompt that did not
507
- // complete. Retries owe nothing: the rearmed attempt reconciles itself.
508
- const { entry, installFailed } = stepAction === 'adopt'
509
- ? state.createCommits
510
- ? await commitForStep(root, dir, state, target)
511
- : {
512
- entry: null,
513
- installFailed: await installFailedForStep(root, dir, state, target),
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.
527
+ const scope = {};
528
+ try {
529
+ const result = await applyReconcileStepAction(root, dir, state, stepAction, scope);
530
+ if (result.kind === 'error') {
531
+ emitError(root, runId, result.reason);
532
+ return; // no transition was written
533
+ }
534
+ const target = result.targetStep;
535
+ // The commit is a git side effect, so it runs before the lock (locked
536
+ // sections stay synchronous); the transition and any unrecorded entry then
537
+ // land in one write. Adopt and skip keep the tree, so the install they owe
538
+ // 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 };
550
+ // A rearm starts a fresh attempt; drop the stale handoff before the rearm
551
+ // is persisted so a crash in between can't refold the old outcome into the
552
+ // new attempt. Losing the handoff without the rearm is safe: the step is
553
+ // still failed/died and the agent re-issues the action.
554
+ if (stepAction === 'retry' || stepAction === 'retry-clean') {
555
+ removeHandoff(dir, target.id);
556
+ // A reset-backed retry reruns the generator, so payloads from earlier
557
+ // attempts describe a tree that was reset away. Hygiene only: the persisted
558
+ // generatorCompletedAtAttempt bound is the correctness gate.
559
+ if (stepAction === 'retry-clean' &&
560
+ result.state.steps.find((s) => s.id === target.id)
561
+ .generatorCompleted !== true) {
562
+ (0, agent_work_payload_1.removeAgentWorkPayloads)(dir, target.id, target.attempt);
514
563
  }
515
- : stepAction === 'skip'
516
- ? await retainedTreeSideEffects(root, dir, state, target)
517
- : { entry: null, installFailed: false };
518
- // A rearm starts a fresh attempt; drop the stale handoff before the rearm
519
- // is persisted so a crash in between can't refold the old outcome into the
520
- // new attempt. Losing the handoff without the rearm is safe: the step is
521
- // still failed/died and the agent re-issues the action.
522
- if (stepAction === 'retry' || stepAction === 'retry-clean') {
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
564
  }
539
- }
540
- // Re-validate the transition against the fresh disk state: if a concurrent
541
- // reconcile already resolved this step, surface the state machine's own
542
- // rejection through the same emitError path rather than writing over it.
543
- // The bound attempt keeps the acceptance checks above honest: they ran
544
- // against `state`, and a step that was re-armed and failed again in
545
- // between is a different attempt those checks never saw.
546
- let freshRejection;
547
- let reopenedIssueUpdates = [];
548
- const written = (0, state_lock_1.updateRunState)(dir, (fresh) => {
549
- const reapplied = (0, state_machine_1.applyStepEvent)(fresh, {
550
- type: 'stepAction',
551
- stepId: target.id,
552
- action: stepAction,
553
- attempt: target.attempt,
565
+ // Re-validate against fresh disk state so a concurrent reconcile's own
566
+ // rejection surfaces through emitError instead of being written over. The
567
+ // bound attempt keeps the snapshot checks above honest.
568
+ let freshRejection;
569
+ const written = (0, state_lock_1.updateRunState)(dir, (fresh) => {
570
+ // A plain retry takes no reservation of its own, so this is where it
571
+ // learns that a live process still commits or installs for the step.
572
+ const held = (0, broker_1.liveTreeOperation)(fresh, scope.lease?.owner);
573
+ if (held) {
574
+ freshRejection = (0, broker_1.treeBusyMessage)(held);
575
+ return null;
576
+ }
577
+ // A plain retry's acceptance read the generator marker on the
578
+ // snapshot; a clean retry that started meanwhile forgets the marker
579
+ // without moving the attempt, so the attempt check cannot see it.
580
+ const freshStep = fresh.steps.find((s) => s.id === target.id);
581
+ if (stepAction === 'retry' &&
582
+ freshStep !== undefined &&
583
+ generatorPending(freshStep) !== generatorPending(target)) {
584
+ freshRejection = `Cannot apply action 'retry' to step '${target.id}': whether its generator ran changed since this reconcile read it. Run the reconcile again.`;
585
+ return null;
586
+ }
587
+ const reapplied = (0, state_machine_1.applyStepEvent)(fresh, {
588
+ type: 'stepAction',
589
+ stepId: target.id,
590
+ action: stepAction,
591
+ attempt: target.attempt,
592
+ });
593
+ if (reapplied.kind === 'error') {
594
+ freshRejection = reapplied.reason;
595
+ return null;
596
+ }
597
+ const next = installFailed
598
+ ? (0, state_machine_1.markInstallFailed)(reapplied.state, target.id)
599
+ : reapplied.state;
600
+ // An adopted commit absorbs uncovered failed steps the same way a fold
601
+ // commit does, so it carries their resolved issues too. A session's
602
+ // parent records its own commits as it answers.
603
+ return entry && !recorded
604
+ ? (0, state_machine_1.appendCommit)(next, (0, issues_1.attachIssueIdsToCommitEntry)(next, entry))
605
+ : next;
554
606
  });
555
- if (reapplied.kind === 'error') {
556
- freshRejection = reapplied.reason;
557
- return null;
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;
607
+ if (freshRejection) {
608
+ emitError(root, runId, entry?.kind === 'landed' && entry.sha
609
+ ? `${freshRejection} Note: this action's commit ${entry.sha} had already landed and stays in history; resolve the step against the tree as it stands now.`
610
+ : freshRejection);
611
+ return;
567
612
  }
568
- const next = installFailed
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;
576
- });
577
- if (freshRejection) {
578
- emitError(root, runId, entry?.kind === 'landed' && entry.sha
579
- ? `${freshRejection} Note: this action's commit ${entry.sha} had already landed and stays in history; resolve the step against the tree as it stands now.`
580
- : freshRejection);
581
- return;
613
+ state = written;
582
614
  }
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);
615
+ finally {
616
+ scope.lease?.release();
607
617
  }
608
- state = written;
609
618
  }
610
619
  // (d) choose and emit the next dispense.
611
620
  advanceAndDispense(root, dir, runId);
@@ -621,17 +630,6 @@ function buildSteps(sortedMigrations) {
621
630
  hasGenerator: !(0, migration_shape_1.isPromptOnlyMigration)(m),
622
631
  }));
623
632
  }
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
- }
635
633
  async function foldHandoffs(root, dir, state) {
636
634
  let current = state;
637
635
  // Step ids are fixed for the life of a run, so the ids come from the caller's
@@ -668,6 +666,9 @@ async function foldHandoffs(root, dir, state) {
668
666
  });
669
667
  if (applied.kind === 'error')
670
668
  return;
669
+ // A corrupt receipt refuses the fold here, before the commit below
670
+ // could land unrecorded.
671
+ (0, state_machine_1.commitReceipt)(fresh, fresh.steps.find((s) => s.id === step.id));
671
672
  const issues = (0, issues_1.parseHandoffIssues)(result.handoff.extras, fresh, step);
672
673
  if (issues.ok !== true)
673
674
  return;
@@ -683,7 +684,7 @@ async function foldHandoffs(root, dir, state) {
683
684
  }
684
685
  ready = true;
685
686
  });
686
- warnReconstructedArchives(reconstructedIssueIds);
687
+ (0, issues_1.warnReconstructedArchives)(reconstructedIssueIds);
687
688
  if (archiveError !== null) {
688
689
  (0, agent_output_1.warnToAgent)({
689
690
  title: `The issue details reported by ${step.migrationId} could not be archived (${(0, util_1.summarizeError)(archiveError)}).`,
@@ -694,73 +695,85 @@ async function foldHandoffs(root, dir, state) {
694
695
  }
695
696
  if (!ready)
696
697
  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.
701
- const { entry, installFailed } = await foldLedgerEntry(root, dir, current, step, promptOutcome);
702
- // The write re-validates against fresh disk state, on the attempt this
703
- // handoff was read for. That window is wide (a git commit plus a package
704
- // install), and 'awaiting-prompt-outcome' recurs, so without the attempt
705
- // check a concurrent reconcile's retry could take this outcome as its own.
706
- // A dropped fold is equivalent to the crash-refold window: the commit
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.
698
+ // Phase 2: the commit and the install are side effects, so they run outside
699
+ // the lock; the transition, the issue application and any unrecorded ledger
700
+ // entry then land in one fresh-state write.
711
701
  let folded = false;
712
702
  let updateArchiveError = null;
713
703
  let detailArchiveError = null;
714
704
  let archivesDegraded = false;
715
705
  const refoldReconstructedIds = [];
716
- current = (0, state_lock_1.withRunStateLock)(dir, () => {
717
- const fresh = (0, run_state_1.readRunState)(dir);
718
- const applied = (0, state_machine_1.applyStepEvent)(fresh, {
719
- type: 'foldPromptOutcome',
720
- stepId: step.id,
721
- attempt: step.attempt,
722
- promptOutcome,
723
- });
724
- if (applied.kind === 'error')
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;
706
+ const scope = {};
707
+ try {
708
+ const { entry, installFailed, recorded } = await foldLedgerEntry(root, dir, current, step, promptOutcome, scope);
709
+ // Bound to the attempt this handoff was read for: the window is wide (a
710
+ // commit plus an install) and 'awaiting-prompt-outcome' recurs, so a
711
+ // concurrent retry could otherwise take this outcome as its own. Under the
712
+ // lock so the issue application re-archives on the state it lands on.
713
+ current = (0, state_lock_1.withRunStateLock)(dir, () => {
714
+ const fresh = (0, run_state_1.readRunState)(dir);
715
+ if ((0, broker_1.liveTreeOperation)(fresh, scope.lease?.owner))
745
716
  return fresh;
717
+ const applied = (0, state_machine_1.applyStepEvent)(fresh, {
718
+ type: 'foldPromptOutcome',
719
+ stepId: step.id,
720
+ attempt: step.attempt,
721
+ promptOutcome,
722
+ });
723
+ if (applied.kind === 'error')
724
+ return fresh;
725
+ const issues = (0, issues_1.parseHandoffIssues)(result.handoff.extras, fresh, step);
726
+ if (issues.ok !== true)
727
+ return fresh;
728
+ // A commit this process ran takes the handoff's resolutions when it is
729
+ // appended below; otherwise the entry already recorded for this attempt
730
+ // takes them, stamped at its index.
731
+ const receipt = (0, state_machine_1.commitReceipt)(applied.state, applied.state.steps.find((s) => s.id === step.id));
732
+ const carrier = entry && !recorded ? undefined : receipt;
733
+ const application = (0, issues_1.applyReportedIssues)(applied.state, step, issues.issues, issues.updates, carrier?.index);
734
+ try {
735
+ // Phase 1 wrote these files, so a reconstruction here means one
736
+ // vanished between the phases; the ids surface that loss. The sink
737
+ // survives a throw, so a shell rebuilt before a later batch failed
738
+ // still gets warned.
739
+ (0, issues_1.archiveIssues)(dir, application, refoldReconstructedIds);
746
740
  }
747
- updateArchiveError = e;
748
- archivesDegraded = intact !== true;
749
- }
750
- folded = true;
751
- const next = installFailed
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;
762
- });
763
- warnReconstructedArchives(refoldReconstructedIds);
741
+ catch (e) {
742
+ // A landed commit outranks the drop: refolding would re-attempt
743
+ // its commit against a clean tree as no-changes and lose the entry
744
+ // for good. Phase 1 already archived this handoff's records durably
745
+ // once; the fold proceeds and the loss is warned.
746
+ const intact = (0, issues_1.applicationArchivesIntact)(dir, application);
747
+ if (entry?.kind !== 'landed' && intact !== true) {
748
+ detailArchiveError = e;
749
+ return fresh;
750
+ }
751
+ updateArchiveError = e;
752
+ archivesDegraded = intact !== true;
753
+ }
754
+ folded = true;
755
+ let next = installFailed
756
+ ? (0, state_machine_1.markInstallFailed)(application.state, step.id)
757
+ : application.state;
758
+ // A landed commit carries the fixes of every issue resolved by a step it
759
+ // names, absorbed steps included.
760
+ if (carrier)
761
+ next = (0, issues_1.enrichCommitEntryIssueIds)(next, carrier.index);
762
+ const written = entry && !recorded
763
+ ? (0, state_machine_1.appendCommit)(next, (0, issues_1.attachIssueIdsToCommitEntry)(next, entry))
764
+ : next;
765
+ (0, run_state_1.writeRunState)(dir, written);
766
+ return written;
767
+ });
768
+ }
769
+ finally {
770
+ scope.lease?.release();
771
+ }
772
+ // The written state still names the lease just released; the caller's
773
+ // own reservation checks must not read it as another process's hold.
774
+ if (scope.lease)
775
+ current = (0, run_state_1.readRunState)(dir);
776
+ (0, issues_1.warnReconstructedArchives)(refoldReconstructedIds);
764
777
  if (detailArchiveError !== null) {
765
778
  (0, agent_output_1.warnToAgent)({
766
779
  title: `The issue details reported by ${step.migrationId} could not be archived (${(0, util_1.summarizeError)(detailArchiveError)}).`,
@@ -797,54 +810,50 @@ async function foldHandoffs(root, dir, state) {
797
810
  }
798
811
  return current;
799
812
  }
800
- // What a folded prompt outcome owes the run state. A completed prompt with
801
- // commits on is committed and its result classified as usual, the install
802
- // riding in on the commit path. Every other outcome still reconciles the
803
- // dependencies itself: the prompt (or the generator half before it) can have
804
- // edited package.json whether or not it completed, and skipping the install
805
- // there strands that change with nothing left to detect it, since the next
806
- // step's dispense captures the already-modified state as its own baseline.
807
- //
808
- // A failed or skipped prompt is not committed, but it can still have left
809
- // edits behind, so a tree that is not verifiably clean records debt: the
810
- // changes then read as pending for a later commit to absorb, and the
811
- // completion warning knows about them. A failed probe counts as dirty,
812
- // matching every other retry-safety decision in this file; debt a later landed
813
- // entry covers costs nothing.
814
- async function foldLedgerEntry(root, dir, state, step, promptOutcome) {
813
+ // What a folded prompt outcome owes the run state. Only a completed prompt
814
+ // with commits on rides its install in on the commit path; every other
815
+ // outcome installs here, or the next dispense takes the unreconciled
816
+ // package.json edit as its own baseline. A tree that is not verifiably clean
817
+ // records debt; a failed probe counts as dirty.
818
+ async function foldLedgerEntry(root, dir, state, step, promptOutcome, scope) {
815
819
  if (promptOutcome.status === 'completed') {
816
820
  if (state.createCommits) {
817
- return commitForStep(root, dir, state, step);
821
+ return commitForStep(root, dir, state, step, scope);
818
822
  }
819
823
  return {
820
824
  entry: null,
821
- installFailed: await installFailedForStep(root, dir, state, step),
825
+ installFailed: await installFailedForStep(root, dir, state, step, 'fold-install', scope),
822
826
  };
823
827
  }
824
- return retainedTreeSideEffects(root, dir, state, step);
828
+ return retainedTreeSideEffects(root, dir, state, step, 'fold-install', scope);
825
829
  }
826
830
  // Shared by prompts that did not complete and by skipped failed or died steps:
827
831
  // the tree is kept as it stands, so the step still owes the install of any
828
832
  // dependency edits it left and, with commits on, a debt record when the tree
829
833
  // is not verifiably clean (see foldLedgerEntry for why).
830
- async function retainedTreeSideEffects(root, dir, state, step) {
831
- const installFailed = await installFailedForStep(root, dir, state, step);
834
+ async function retainedTreeSideEffects(root, dir, state, step, seam, scope) {
835
+ const installFailed = await installFailedForStep(root, dir, state, step, seam, scope);
832
836
  const entry = state.createCommits && (0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean'
833
837
  ? { kind: 'failed', stepIds: [step.id] }
834
838
  : null;
835
839
  return { entry, installFailed };
836
840
  }
837
841
  // Installs the dependency changes a step's tree may carry when no commit path
838
- // will do it (the fold of a prompt outcome that lands no commit, or a
839
- // non-commit adopt), returning whether the install failed. A failure is
840
- // recorded rather than thrown: reconcile still owes the agent a dispense, and
841
- // a warning alone dies with this process.
842
- async function installFailedForStep(root, dir, state, step) {
842
+ // will, returning whether the install failed. A failure is recorded rather
843
+ // than thrown: reconcile still owes the agent a dispense. Broker errors do
844
+ // throw, as in commitForStep: the next reconcile redoes the action.
845
+ async function installFailedForStep(root, dir, state, step, seam, scope) {
846
+ const skipInstall = state.skipInstall === true;
843
847
  try {
844
- await (0, util_1.installDepsChangedSinceDispense)(root, dir, step, state.skipInstall === true, reconcileCommand(root, state.runId));
848
+ await (0, broker_1.installStepTree)(dir, step, seam, () => (0, util_1.installDepsChangedSinceDispense)(root, dir, step, skipInstall, reconcileCommand(root, state.runId)), scope);
845
849
  return false;
846
850
  }
847
851
  catch (e) {
852
+ if (e instanceof broker_1.BrokerStaleRequestError ||
853
+ e instanceof broker_1.BrokerUnavailableError ||
854
+ e instanceof broker_1.TreeBusyError) {
855
+ throw e;
856
+ }
848
857
  (0, agent_output_1.warnToAgent)({
849
858
  title: `The dependencies changed by ${step.migrationId} could not be installed (${(0, util_1.summarizeError)(e)}).`,
850
859
  bodyLines: [`Run \`${(0, util_1.pmInstallCommand)(root)}\` before continuing.`],
@@ -878,6 +887,10 @@ function detectDeaths(dir, state) {
878
887
  // snapshot and the write, or a retry already put a live worker on the
879
888
  // step, the transition is rejected and the step is left as recorded.
880
889
  current = (0, state_lock_1.updateRunState)(dir, (fresh) => {
890
+ // A dead worker's commit or install may still be running in the
891
+ // session's parent; the step stays running until that lands.
892
+ if ((0, broker_1.liveTreeOperation)(fresh))
893
+ return null;
881
894
  const applied = (0, state_machine_1.applyStepEvent)(fresh, {
882
895
  type: 'markDied',
883
896
  stepId: step.id,
@@ -888,7 +901,7 @@ function detectDeaths(dir, state) {
888
901
  }
889
902
  return current;
890
903
  }
891
- function applyReconcileStepAction(root, state, action) {
904
+ async function applyReconcileStepAction(root, dir, state, action, scope) {
892
905
  const candidates = state.steps.filter((s) => s.status === 'failed' || s.status === 'died');
893
906
  if (candidates.length === 0) {
894
907
  return {
@@ -903,29 +916,51 @@ function applyReconcileStepAction(root, state, action) {
903
916
  };
904
917
  }
905
918
  const step = candidates[0];
919
+ const held = (0, broker_1.liveTreeOperation)(state);
920
+ if (held) {
921
+ return { kind: 'error', reason: (0, broker_1.treeBusyMessage)(held) };
922
+ }
906
923
  // A retry-clean the dispense would not have offered must be refused here
907
924
  // too, or a hand-crafted reconcile could reset a tree with no restore point
908
925
  // and destroy prior steps' work.
909
926
  if (action === 'retry-clean') {
910
927
  const head = (0, git_utils_1.getLatestCommitSha)(root);
911
928
  const fallback = step.status === 'died'
912
- ? `Use 'adopt' or 'skip' instead.`
913
- : `Use 'retry' or 'skip' instead.`;
914
- if (!canOfferCleanRetry(root, state, step, head)) {
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.`;
935
+ if (!(0, clean_retry_1.canOfferCleanRetry)(root, state, step, head)) {
936
+ return {
937
+ kind: 'error',
938
+ reason: `Cannot apply action 'retry-clean' to step '${step.id}': ${(0, clean_retry_1.cleanRetryUnavailableReason)(root, state, step, head)} ${fallback}`,
939
+ };
940
+ }
941
+ // Reset under the reservation: the checks above ran on a snapshot, and
942
+ // resetForCleanRetry re-checks against the state read once it is held.
943
+ try {
944
+ await (0, broker_1.resetStepTree)(dir, step, () => (0, clean_retry_1.resetForCleanRetry)(root, dir, step.id), scope);
945
+ }
946
+ catch (e) {
947
+ if (e instanceof broker_1.BrokerStaleRequestError ||
948
+ e instanceof broker_1.BrokerUnavailableError ||
949
+ e instanceof broker_1.TreeBusyError) {
950
+ throw e;
951
+ }
915
952
  return {
916
953
  kind: 'error',
917
- reason: `Cannot apply action 'retry-clean' to step '${step.id}': ${cleanRetryUnavailableReason(root, state, step, head)} ${fallback}`,
954
+ reason: `Cannot apply action 'retry-clean' to step '${step.id}': the reset to ${step.gitRefBefore} failed: ${e instanceof Error ? e.message : String(e)} ${fallback}`,
918
955
  };
919
956
  }
920
- // The reset itself is delegated to the caller, and every check above
921
- // passes identically whether or not it ran, so only the tree can say
922
- // whether the reset actually happened. Anything but a verified-clean tree
923
- // is refused: accepting would drop the generator marker and rerun the
957
+ // Only the tree can say whether the reset left it clean. Anything else is
958
+ // refused: accepting would rearm a step whose next attempt reruns the
924
959
  // generator over the previous attempt's output.
925
960
  if ((0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean') {
926
961
  return {
927
962
  kind: 'error',
928
- reason: `Cannot apply action 'retry-clean' to step '${step.id}': the working tree is not verifiably clean, so the reset this action requires has not happened. Run \`git reset --hard ${step.gitRefBefore}\` then \`git clean -fd -e ${types_1.MIGRATE_RUNS_RELATIVE_DIR}\` first, then re-run it. ${fallback}`,
963
+ reason: `Cannot apply action 'retry-clean' 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. ${fallback}`,
929
964
  };
930
965
  }
931
966
  }
@@ -940,7 +975,7 @@ function applyReconcileStepAction(root, state, action) {
940
975
  if (safety.kind === 'unsafe') {
941
976
  return {
942
977
  kind: 'error',
943
- reason: `Cannot apply action 'retry' to step '${step.id}': ${safety.reason} Use 'retry-clean' where offered, or 'skip'.`,
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'`}.`,
944
979
  };
945
980
  }
946
981
  if (safety.kind === 'warned') {
@@ -962,46 +997,45 @@ function applyReconcileStepAction(root, state, action) {
962
997
  return { kind: 'ok', state: applied.state, targetStep: step };
963
998
  }
964
999
  // Commits the working tree left by a folded prompt outcome or an adopted
965
- // death, returning the ledger entry the caller persists together with the
966
- // step transition (null when there was nothing to commit). The worker's
967
- // recorded-commit path classifies through the same commitResultToLedgerEntry.
968
- //
969
- // Remaining narrow window: a crash after the git commit but before the state
970
- // write refolds on the next reconcile, where the commit attempt sees a clean
971
- // tree ('no-changes') and the ledger simply misses that landed entry; the
972
- // changes themselves are never lost. A lost landed entry can also strand the
973
- // failed entries it had absorbed, which is why completion double-checks the
974
- // tree before warning about debt.
975
- async function commitForStep(root, dir, state, step) {
1000
+ // death. The caller persists `entry` with the step transition unless
1001
+ // `recorded` says a session's parent already did; null when nothing to
1002
+ // 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) {
976
1005
  const { name } = (0, state_machine_1.splitMigrationId)(step.migrationId);
977
1006
  const absorbedStepIds = (0, state_machine_1.uncoveredFailedStepIds)(state).filter((id) => id !== step.id);
978
- let result;
1007
+ const skipInstall = state.skipInstall === true;
1008
+ let commit;
979
1009
  try {
980
- result = await (0, migrate_commits_1.commitMigrationIfRequested)(root, { name }, true, state.commitPrefix, () => (0, util_1.installDepsChangedSinceDispense)(root, dir, step, state.skipInstall === true, reconcileCommand(root, state.runId)), (0, state_machine_1.stepsToPendingMigrations)(state, absorbedStepIds));
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);
981
1011
  }
982
1012
  catch (e) {
983
- // The dependency install is the only thing that throws here: the commit
984
- // attempt itself reports through result.status, and the install's own
985
- // bookkeeping never throws. Both consequences are recorded, and neither
986
- // aborts reconcile so the next dispense still fires. The debt cannot stand
987
- // in for the install failure: a later step's commit absorbs this diff and
988
- // lands an entry naming this step, which clears the debt while the
989
- // dependencies are still missing.
1013
+ // Nothing to record: the step moved on, another process holds the tree, or
1014
+ // the parent never answered. The next reconcile redoes this fold or adopt.
1015
+ if (e instanceof broker_1.BrokerStaleRequestError ||
1016
+ e instanceof broker_1.BrokerUnavailableError ||
1017
+ e instanceof broker_1.TreeBusyError) {
1018
+ throw e;
1019
+ }
1020
+ // The dependency install is the only other thrower: the commit attempt
1021
+ // reports through result.status. The debt cannot stand in for the install
1022
+ // failure, since a later commit absorbing this diff clears the debt while
1023
+ // the dependencies are still missing.
990
1024
  (0, util_1.warnCommitFailed)(name, e);
991
1025
  return {
992
1026
  entry: { kind: 'failed', stepIds: [step.id] },
993
1027
  installFailed: true,
994
1028
  };
995
1029
  }
996
- if (result.status === 'failed') {
1030
+ if (commit.result.status === 'failed') {
997
1031
  (0, util_1.warnCommitFailed)(name);
998
1032
  }
999
1033
  return {
1000
- entry: (0, state_machine_1.commitResultToLedgerEntry)(result, step.id, absorbedStepIds),
1034
+ entry: (0, state_machine_1.commitResultToLedgerEntry)(commit.result, step.id, commit.absorbedStepIds),
1001
1035
  installFailed: false,
1036
+ recorded: commit.recorded,
1002
1037
  };
1003
1038
  }
1004
- // --- dispense ---------------------------------------------------------------
1005
1039
  function advanceAndDispense(root, dir, runId) {
1006
1040
  // Demote recorded issues no remaining step can claim before anything is
1007
1041
  // rendered. Done here, at the single choke point every dispense, retry
@@ -1024,6 +1058,14 @@ function advanceAndDispense(root, dir, runId) {
1024
1058
  // above repeats by design, and a rejected --step-action ends the reconcile
1025
1059
  // before reaching here, already naming its own fix.
1026
1060
  const noProgress = trackNoProgress(dir, step);
1061
+ // Another live process holds the tree: the responses below would offer
1062
+ // actions it refuses or name a pid already gone. A running step's own
1063
+ // operation with a live worker keeps still-running, which offers none.
1064
+ const held = (0, broker_1.liveTreeOperation)(state);
1065
+ if (held && !isOwnOperation(step, held)) {
1066
+ emitHeld(root, runId, step, held, noProgress);
1067
+ return;
1068
+ }
1027
1069
  switch (step.status) {
1028
1070
  case 'pending':
1029
1071
  dispenseNextStep(root, dir, runId, state, step, noProgress);
@@ -1039,7 +1081,7 @@ function advanceAndDispense(root, dir, runId) {
1039
1081
  emitDied(root, runId, state, step, noProgress);
1040
1082
  break;
1041
1083
  case 'running':
1042
- emitStillRunning(root, runId, step, noProgress);
1084
+ emitStillRunning(root, runId, step, held, noProgress);
1043
1085
  break;
1044
1086
  case 'awaiting-prompt-outcome':
1045
1087
  emitAwaitPrompt(root, dir, runId, step, noProgress);
@@ -1058,6 +1100,12 @@ function advanceAndDispense(root, dir, runId) {
1058
1100
  }
1059
1101
  }
1060
1102
  }
1103
+ function isOwnOperation(step, held) {
1104
+ return (step.status === 'running' &&
1105
+ held.stepId === step.id &&
1106
+ step.pid !== undefined &&
1107
+ (0, util_1.isPidAlive)(step.pid));
1108
+ }
1061
1109
  function firstActionableStep(state) {
1062
1110
  return state.steps.find((s) => !run_state_1.TERMINAL_STEP_STATUSES.has(s.status));
1063
1111
  }
@@ -1107,6 +1155,7 @@ function dispenseNextStep(root, dir, runId, state, step, noProgress) {
1107
1155
  depsHashAtDispense: (0, util_1.depsHash)(root),
1108
1156
  };
1109
1157
  let advancedElsewhere = false;
1158
+ let held;
1110
1159
  const current = (0, state_lock_1.updateRunState)(dir, (fresh) => {
1111
1160
  // A concurrent init or reconcile may have dispensed (or further advanced)
1112
1161
  // this step since the caller's read; reclassify against the fresh state
@@ -1115,6 +1164,12 @@ function dispenseNextStep(root, dir, runId, state, step, noProgress) {
1115
1164
  advancedElsewhere = true;
1116
1165
  return null;
1117
1166
  }
1167
+ // A checkpoint in flight would capture the dispensed step's changes;
1168
+ // checked in the write that dispenses, since it can start after any
1169
+ // earlier read.
1170
+ held = (0, broker_1.liveTreeOperation)(fresh);
1171
+ if (held)
1172
+ return null;
1118
1173
  const dispensed = applyEventOrThrow(fresh, {
1119
1174
  type: 'dispense',
1120
1175
  stepId: step.id,
@@ -1127,6 +1182,10 @@ function dispenseNextStep(root, dir, runId, state, step, noProgress) {
1127
1182
  advanceAndDispense(root, dir, runId);
1128
1183
  return;
1129
1184
  }
1185
+ if (held) {
1186
+ emitHeld(root, runId, step, held, noProgress);
1187
+ return;
1188
+ }
1130
1189
  emitNextStep(root, runId, current, current.steps.find((s) => s.id === step.id), noProgress);
1131
1190
  }
1132
1191
  function emitNextStep(root, runId, state, step, noProgress) {
@@ -1147,7 +1206,7 @@ function emitRetryFailed(root, runId, state, step, noProgress) {
1147
1206
  const summary = step.outcome?.summary ?? step.promptOutcome?.summary;
1148
1207
  const head = (0, git_utils_1.getLatestCommitSha)(root);
1149
1208
  const tree = dirtyTreeSummary(root);
1150
- const cleanRetry = canOfferCleanRetry(root, state, step, head);
1209
+ const cleanRetry = (0, clean_retry_1.canOfferCleanRetry)(root, state, step, head);
1151
1210
  // A failure recorded before the generator marker can still have written to
1152
1211
  // the tree (a direct fs or exec side effect, or a crash mid-flush); a
1153
1212
  // marker means only the handed-back half (a prompt or a validation pass)
@@ -1169,9 +1228,12 @@ function emitRetryFailed(root, runId, state, step, noProgress) {
1169
1228
  retryOptionLine(retrySafety, reconcileCommand(root, runId, 'retry')),
1170
1229
  ];
1171
1230
  if (cleanRetry) {
1172
- lines.push(` retry-clean: restore the tree to ${step.gitRefBefore ?? 'the pre-migration ref'} first (e.g. \`git reset --hard ${step.gitRefBefore ?? '<ref>'}\` then \`git clean -fd -e ${types_1.MIGRATE_RUNS_RELATIVE_DIR}\`, keeping the run state out of the clean), then retry from that clean state by running: ${reconcileCommand(root, runId, 'retry-clean')}`);
1231
+ 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
+ }
1233
+ // 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')}`);
1173
1236
  }
1174
- lines.push(` skip: ${reconcileCommand(root, runId, 'skip')}`);
1175
1237
  if (pending) {
1176
1238
  lines.push(UNVERIFIABLE_WRITES_LINE);
1177
1239
  }
@@ -1229,62 +1291,6 @@ function retryOptionLine(safety, command) {
1229
1291
  }
1230
1292
  }
1231
1293
  }
1232
- // A clean retry resets the tree to the step's captured pre-migration ref.
1233
- // That is only safe when every prior diff is already committed: without
1234
- // per-migration commits the ref is the run's starting commit (the reset would
1235
- // wipe all prior steps' uncommitted work); a failed init checkpoint or a
1236
- // pending step commit means the ref predates diffs the reset would also
1237
- // destroy; without a captured ref there is nothing to reset to; edits already
1238
- // in the tree when this step was dispensed (the user's own, or an earlier
1239
- // step's the checkpoint never saw) are not represented by the ref either; and
1240
- // HEAD anywhere other than the ref means something was committed since the
1241
- // step was dispensed that the reset would discard, whether that is this step's
1242
- // own commit (recorded, or made in the window before the worker died writing
1243
- // its ledger entry) or one the user made alongside the run.
1244
- // Cleanliness and position both have to say so explicitly: a failed tree probe
1245
- // records dirty, a run created before that field existed carries nothing to
1246
- // check, and an unreadable HEAD is no ref at all, so none of the three can be
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.
1250
- function canOfferCleanRetry(root, state, step, head) {
1251
- return (state.createCommits &&
1252
- !state.checkpointFailed &&
1253
- !(0, state_machine_1.hasPendingCommitDebt)(state) &&
1254
- !!step.gitRefBefore &&
1255
- head === step.gitRefBefore &&
1256
- step.treeCleanAtDispense === true &&
1257
- !endangeredLandedEntry(root, state, step));
1258
- }
1259
- // The last landed ledger entry covering the step whose commit a reset to the
1260
- // step's gitRefBefore would discard. Entries from earlier attempts predate the
1261
- // ref re-captured at re-dispense and survive the reset; only a commit that is
1262
- // not an ancestor of the ref (or cannot be verified as one) is endangered.
1263
- function endangeredLandedEntry(root, state, step) {
1264
- let endangered = null;
1265
- for (const entry of (0, state_machine_1.coveringLandedEntries)(state, step.id)) {
1266
- if (!entry.sha ||
1267
- !step.gitRefBefore ||
1268
- !(0, git_utils_1.isAncestorCommit)(entry.sha, step.gitRefBefore, root)) {
1269
- endangered = entry;
1270
- }
1271
- }
1272
- return endangered;
1273
- }
1274
- // Explains why retry-clean is withheld for a failed or died step; feeds the
1275
- // death dispense and a rejected --step-action=retry-clean.
1276
- function cleanRetryUnavailableReason(root, state, step, head) {
1277
- const endangered = endangeredLandedEntry(root, state, step);
1278
- if (endangered) {
1279
- return endangered.sha
1280
- ? `this migration's changes already landed in commit ${endangered.sha}, which a reset would discard.`
1281
- : `this migration's changes already landed in a commit, which a reset would discard.`;
1282
- }
1283
- if (step.gitRefBefore && head !== step.gitRefBefore) {
1284
- return `HEAD is at ${head ?? '(unreadable)'} rather than the ${step.gitRefBefore} this migration started from, so a reset would discard what was committed in between.`;
1285
- }
1286
- return `resetting the tree could discard uncommitted work that no restore point accounts for.`;
1287
- }
1288
1294
  function assessPreMarkerRetry(root, step) {
1289
1295
  const repo = (0, git_utils_1.getGitRepositoryStatus)(root);
1290
1296
  if (repo === 'not-git') {
@@ -1319,7 +1325,7 @@ function emitDied(root, runId, state, step, noProgress) {
1319
1325
  const ref = step.gitRefBefore;
1320
1326
  const head = (0, git_utils_1.getLatestCommitSha)(root);
1321
1327
  const tree = dirtyTreeSummary(root);
1322
- const cleanRetry = canOfferCleanRetry(root, state, step, head);
1328
+ const cleanRetry = (0, clean_retry_1.canOfferCleanRetry)(root, state, step, head);
1323
1329
  const resume = !generatorPending(step);
1324
1330
  const capReached = rearmCapReached(step);
1325
1331
  const lines = [
@@ -1335,15 +1341,16 @@ function emitDied(root, runId, state, step, noProgress) {
1335
1341
  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')}`);
1336
1342
  }
1337
1343
  if (cleanRetry) {
1338
- options.push(
1339
- // Two commands rather than one `&&` chain: the agent runs these in its
1340
- // own shell, and not every shell joins statements that way.
1341
- ` retry-clean: restore the tree to ${ref ?? 'the pre-migration ref'} first (e.g. \`git reset --hard ${ref ?? '<ref>'}\` then \`git clean -fd -e ${types_1.MIGRATE_RUNS_RELATIVE_DIR}\`, keeping the run state out of the clean), then retry from that clean state by running: ${reconcileCommand(root, runId, 'retry-clean')}`);
1344
+ 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')}`);
1342
1345
  }
1343
1346
  else {
1344
- lines.push(`A clean retry is unavailable: ${cleanRetryUnavailableReason(root, state, step, head)}`);
1347
+ lines.push(`A clean retry is unavailable: ${(0, clean_retry_1.cleanRetryUnavailableReason)(root, state, step, head)}`);
1348
+ }
1349
+ options.push(` adopt: keep the current working-tree state as this migration's result, then run: ${reconcileCommand(root, runId, 'adopt')}`);
1350
+ // 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')}`);
1345
1353
  }
1346
- options.push(` adopt: keep the current working-tree state as this migration's result, then run: ${reconcileCommand(root, runId, 'adopt')}`, ` skip: leave the tree as it stands and move on without this migration, then run: ${reconcileCommand(root, runId, 'skip')}`);
1347
1354
  lines.push(`Choose exactly one:`);
1348
1355
  lines.push(...options);
1349
1356
  if (!resume) {
@@ -1363,14 +1370,30 @@ function emitDied(root, runId, state, step, noProgress) {
1363
1370
  instructionLines: lines,
1364
1371
  }, noProgress);
1365
1372
  }
1366
- function emitStillRunning(root, runId, step, noProgress) {
1373
+ function emitHeld(root, runId, step, held, noProgress) {
1374
+ emit(root, runId, step, 'held', {
1375
+ next: reconcileCommand(root, runId),
1376
+ instructionLines: [(0, broker_1.treeBusyMessage)(held)],
1377
+ }, noProgress);
1378
+ }
1379
+ function emitStillRunning(root, runId, step, held, noProgress) {
1367
1380
  const migrationId = step.migrationId;
1368
1381
  const ageMs = step.startedAt ? Date.now() - Date.parse(step.startedAt) : 0;
1369
1382
  const lines = [
1370
1383
  `The worker for ${migrationId} (pid ${step.pid}) is still running. Wait for it to finish, then run the "next" command.`,
1371
1384
  ];
1385
+ // A holder pid other than the worker's is the session's parent process.
1386
+ const parentOperation = held !== undefined && held.pid !== step.pid
1387
+ ? (0, broker_1.treeOperationLabel)(held)
1388
+ : undefined;
1389
+ if (parentOperation) {
1390
+ lines.push(`The nx process ${held.pid} is running ${parentOperation} for it; killing the worker does not stop that, and the step can be classified as died only once it finishes.`);
1391
+ }
1372
1392
  if (ageMs >= HANG_THRESHOLD_MS) {
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.`);
1393
+ const hung = `It has been running for ${Math.floor(ageMs / 60000)} minutes and may be hung.`;
1394
+ lines.push(parentOperation
1395
+ ? `${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
+ : `${hung} Verify pid ${step.pid}; either keep waiting, or kill it so the next reconcile can classify it as died.`);
1374
1397
  }
1375
1398
  emit(root, runId, step, 'still-running', {
1376
1399
  next: reconcileCommand(root, runId),
@@ -1564,16 +1587,44 @@ function emitError(root, runId, reason) {
1564
1587
  });
1565
1588
  (0, migrate_analytics_1.reportMigrateOrchestratorDispense)({ action: 'error', attempt: 0 });
1566
1589
  }
1590
+ /**
1591
+ * What a completed run leaves behind for whoever reads its summary, one line
1592
+ * group per warning: commit debt, uninstalled dependency changes, unresolved
1593
+ * issues. Empty when nothing is left.
1594
+ */
1595
+ function completionWarnings(root, runId, state) {
1596
+ // The crash-refold window can strand a failed ledger entry whose diff was in
1597
+ // fact absorbed; suppress the warning only on a verified-clean tree. A dirty
1598
+ // tree can still be unrelated edits, so the warning only claims the changes
1599
+ // "may remain".
1600
+ const commitDebt = (0, state_machine_1.hasPendingCommitDebt)(state) && (0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean';
1601
+ const uninstalled = state.steps.filter((s) => s.installFailed);
1602
+ const issueLines = (0, issues_1.renderUnresolvedIssueLines)(state, runId);
1603
+ return [
1604
+ ...(commitDebt
1605
+ ? [
1606
+ [
1607
+ 'Some migration changes could not be committed and may remain in the working tree; review and commit them manually.',
1608
+ ],
1609
+ ]
1610
+ : []),
1611
+ ...(uninstalled.length > 0
1612
+ ? [
1613
+ [
1614
+ `The dependency changes made by ${uninstalled
1615
+ .map((s) => s.migrationId)
1616
+ .join(', ')} were not installed; run \`${(0, util_1.pmInstallCommand)(root)}\` before using the workspace.`,
1617
+ ],
1618
+ ]
1619
+ : []),
1620
+ ...(issueLines.length > 0 ? [issueLines] : []),
1621
+ ];
1622
+ }
1567
1623
  function completeRun(root, dir, runId, state) {
1568
1624
  let current = state;
1569
1625
  const completed = current.steps.filter((s) => s.status === 'succeeded').length;
1570
1626
  const skipped = current.steps.filter((s) => s.status === 'skipped').length;
1571
1627
  const dispenseCount = current.steps.reduce((n, s) => n + s.dispenseCount, 0);
1572
- // The crash-refold window can strand a failed ledger entry whose diff was in
1573
- // fact absorbed; suppress the warning only on a verified-clean tree. A dirty
1574
- // tree can still be unrelated edits, so the warning only claims the changes
1575
- // "may remain".
1576
- const commitDebt = (0, state_machine_1.hasPendingCommitDebt)(current) && (0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean';
1577
1628
  // Persist the terminal status and claim the watermark in one fresh-state
1578
1629
  // write before emitting: a crash between the write and the output can't
1579
1630
  // double-count the completion, and of two concurrent reconciles exactly one
@@ -1599,30 +1650,15 @@ function completeRun(root, dir, runId, state) {
1599
1650
  dispenseCount,
1600
1651
  });
1601
1652
  }
1602
- const debtLine = 'Some migration changes could not be committed and may remain in the working tree; review and commit them manually.';
1603
- if (commitDebt) {
1604
- (0, agent_output_1.warnToAgent)({ title: debtLine });
1605
- }
1606
- const uninstalled = current.steps.filter((s) => s.installFailed);
1607
- const installLine = uninstalled.length > 0
1608
- ? `The dependency changes made by ${uninstalled
1609
- .map((s) => s.migrationId)
1610
- .join(', ')} were not installed; run \`${(0, util_1.pmInstallCommand)(root)}\` before using the workspace.`
1611
- : null;
1612
- if (installLine) {
1613
- (0, agent_output_1.warnToAgent)({ title: installLine });
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) });
1653
+ const warnings = completionWarnings(root, runId, current);
1654
+ for (const lines of warnings) {
1655
+ (0, agent_output_1.warnToAgent)({ title: lines[0], bodyLines: lines.slice(1) });
1618
1656
  }
1619
1657
  const instructionLines = [
1620
1658
  `Migrate run ${runId} is complete.`,
1621
1659
  ` applied: ${completed}`,
1622
1660
  ` skipped: ${skipped}`,
1623
- ...(commitDebt ? [debtLine] : []),
1624
- ...(installLine ? [installLine] : []),
1625
- ...issueLines,
1661
+ ...warnings.flat(),
1626
1662
  ];
1627
1663
  (0, agent_output_1.logToAgent)({ title: 'nx migrate: complete', bodyLines: instructionLines });
1628
1664
  (0, agent_output_1.emitStepBlock)(runId, '-', 'complete', {
@@ -1687,10 +1723,6 @@ function reconcileCommand(root, runId, action) {
1687
1723
  const base = `${(0, util_1.pmExecPrefix)(root)} nx migrate --run-id=${runId}`;
1688
1724
  return action ? `${base} --step-action=${action}` : base;
1689
1725
  }
1690
- // --- helpers ----------------------------------------------------------------
1691
- function appendCommit(state, entry) {
1692
- return { ...state, commits: [...state.commits, entry] };
1693
- }
1694
1726
  // Records what the workspace looked like as this attempt starts. The git ref
1695
1727
  // and the tree state are re-captured per dispense, since a retry restarts from
1696
1728
  // wherever the tree is now. The dependency baseline is not: it tracks the last