nx 23.3.0-beta.2 → 23.3.0-beta.3

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
@@ -22,6 +22,7 @@ const run_id_1 = require("./run-id");
22
22
  const state_machine_1 = require("./state-machine");
23
23
  const state_lock_1 = require("./state-lock");
24
24
  const util_1 = require("./util");
25
+ const broker_1 = require("./broker");
25
26
  const text_1 = require("../text");
26
27
  const agent_output_1 = require("./agent-output");
27
28
  const agent_work_payload_1 = require("./agent-work-payload");
@@ -98,10 +99,9 @@ async function runSingleMigrationWorker(input) {
98
99
  function readMigrationsSource(root, runId) {
99
100
  if (runId) {
100
101
  const dir = (0, run_state_1.runDir)(root, runId);
101
- // A missing run.json would surface a raw ENOENT from readRunState's
102
- // readFileSync; report it the way the orchestrator does instead, down to
103
- // carrying no remediation: starting a run is a separate, gated entry point,
104
- // and `--run-migrations` would run the whole plan in process instead.
102
+ // A missing run.json would surface a raw ENOENT from readRunState; report
103
+ // it the way the orchestrator does instead, with no remediation: starting
104
+ // a run is a gated entry point of its own.
105
105
  if (!(0, run_state_1.hasRunState)(dir)) {
106
106
  throw new Error(`No migrate run '${runId}' was found under ${types_1.MIGRATE_RUNS_RELATIVE_DIR}.`);
107
107
  }
@@ -358,177 +358,180 @@ async function runRecorded(root, runId, migration, skipInstall, isVerbose) {
358
358
  // invocation may have claimed a later attempt whose flag `step` predates.
359
359
  const startedStep = state.steps.find((s) => s.id === step.id);
360
360
  const generatorAlreadyCompleted = startedStep.generatorCompleted === true;
361
- // The run records its own install policy because dispensed worker commands
362
- // are re-invoked by the loop and never carry the user's flags; an explicit
363
- // --skip-install on this invocation still applies on top of it.
361
+ // Dispensed worker commands never carry the user's flags, so the run records
362
+ // its install policy. An explicit --skip-install here applies only to the
363
+ // installs this process runs itself; a parent session answers from its own.
364
364
  const effectiveSkipInstall = state.skipInstall === true || skipInstall;
365
365
  // The run records the resolved validation policy at init; absent (a state
366
366
  // predating the field) falls back to the same default init applies.
367
367
  const shouldValidate = state.validate !== false;
368
+ // The tree reservation an in-process install or commit takes is held until
369
+ // the transition that records this attempt's outcome is written.
370
+ const scope = {};
368
371
  // Called before a retry hands the step's work back: the prompt or validation
369
372
  // may need the dependencies the earlier attempt's generator added.
370
- const reinstallFromBaseline = () => recordingInstallFailure(dir, step.id, () => (0, util_1.installDepsChangedSinceDispense)(root, dir, startedStep, effectiveSkipInstall, `${(0, execute_migration_1.formatSingleMigrationRerunCommand)(migrationId)} --run-id=${runId}`));
373
+ const reinstallFromBaseline = () => (0, broker_1.installStepTree)(dir, startedStep, 'install', () => recordingInstallFailure(dir, step.id, () => (0, util_1.installDepsChangedSinceDispense)(root, dir, startedStep, effectiveSkipInstall, `${(0, execute_migration_1.formatSingleMigrationRerunCommand)(migrationId)} --run-id=${runId}`)), scope);
371
374
  let outcome;
372
375
  let awaitingKind;
373
376
  const payloadPath = (0, agent_work_payload_1.agentWorkPayloadPath)(dir, step.id, startedStep.attempt);
374
377
  try {
375
- if ((0, migration_shape_1.isPromptOnlyMigration)(migration)) {
376
- emitOrPrintPrompt(root, migration, agenticKind, {
377
- persistPath: payloadPath,
378
- });
379
- awaitingKind = 'migration-prompt';
380
- }
381
- else if (generatorAlreadyCompleted &&
382
- startedStep.agenticWaived !== true &&
383
- (0, migration_shape_1.isHybridMigration)(migration)) {
384
- await reinstallFromBaseline();
385
- const carried = (0, agent_work_payload_1.latestStoredAgentWorkPayload)(dir, step.id, startedStep.attempt, startedStep.generatorCompletedAtAttempt, {
386
- migrationId,
387
- kind: 'migration-prompt',
388
- promptPath: migration.prompt,
389
- });
390
- if (carried) {
391
- reemitCarriedAgentWork(migrationId, 'migration-prompt', payloadPath, carried);
392
- }
393
- else {
378
+ try {
379
+ if ((0, migration_shape_1.isPromptOnlyMigration)(migration)) {
394
380
  emitOrPrintPrompt(root, migration, agenticKind, {
395
381
  persistPath: payloadPath,
396
382
  });
383
+ awaitingKind = 'migration-prompt';
397
384
  }
398
- awaitingKind = 'migration-prompt';
399
- }
400
- else if (generatorAlreadyCompleted &&
401
- startedStep.validationOwed === true) {
402
- // The persisted flag is the only record that these changes still owe a
403
- // validation pass: the decision needed the generator result, which is
404
- // gone with the attempt that made it, and a true waiver never writes the
405
- // flag. The commit stays with the fold, as on a first attempt.
406
- await reinstallFromBaseline();
407
- const carried = (0, agent_work_payload_1.latestStoredAgentWorkPayload)(dir, step.id, startedStep.attempt, startedStep.generatorCompletedAtAttempt, { migrationId, kind: 'generator-validation' });
408
- if (carried) {
409
- reemitCarriedAgentWork(migrationId, 'generator-validation', payloadPath, carried);
385
+ else if (generatorAlreadyCompleted &&
386
+ startedStep.agenticWaived !== true &&
387
+ (0, migration_shape_1.isHybridMigration)(migration)) {
388
+ await reinstallFromBaseline();
389
+ const carried = (0, agent_work_payload_1.latestStoredAgentWorkPayload)(dir, step.id, startedStep.attempt, startedStep.generatorCompletedAtAttempt, {
390
+ migrationId,
391
+ kind: 'migration-prompt',
392
+ promptPath: migration.prompt,
393
+ });
394
+ if (carried) {
395
+ reemitCarriedAgentWork(migrationId, 'migration-prompt', payloadPath, carried);
396
+ }
397
+ else {
398
+ emitOrPrintPrompt(root, migration, agenticKind, {
399
+ persistPath: payloadPath,
400
+ });
401
+ }
402
+ awaitingKind = 'migration-prompt';
403
+ }
404
+ else if (generatorAlreadyCompleted &&
405
+ startedStep.validationOwed === true) {
406
+ // The flag is the only record that these changes still owe a validation
407
+ // pass; the decision needed the generator result, which is gone with the
408
+ // attempt that made it. The commit stays with the fold.
409
+ await reinstallFromBaseline();
410
+ const carried = (0, agent_work_payload_1.latestStoredAgentWorkPayload)(dir, step.id, startedStep.attempt, startedStep.generatorCompletedAtAttempt, { migrationId, kind: 'generator-validation' });
411
+ if (carried) {
412
+ reemitCarriedAgentWork(migrationId, 'generator-validation', payloadPath, carried);
413
+ }
414
+ else {
415
+ emitValidationBlock(root, migration, undefined, {
416
+ persistPath: payloadPath,
417
+ });
418
+ }
419
+ awaitingKind = 'generator-validation';
420
+ }
421
+ else if (generatorAlreadyCompleted) {
422
+ state = await finishCompletedGenerator(dir, root, state, startedStep, migration, effectiveSkipInstall, runId, scope);
423
+ outcome = buildOutcome([], [], 'The generator ran in an earlier attempt; this attempt completed its install and commit.', root);
410
424
  }
411
425
  else {
412
- emitValidationBlock(root, migration, undefined, {
413
- persistPath: payloadPath,
426
+ const installer = new execute_migration_1.ChangedDepInstaller(root, effectiveSkipInstall, `${(0, execute_migration_1.formatSingleMigrationRerunCommand)(migrationId)} --run-id=${runId}`);
427
+ // Read once; the run and the hybrid documentation resolution share it.
428
+ const resolvedCollection = (0, execute_migration_1.readMigrationCollection)(migration.package, root);
429
+ const { changes, nextSteps, agentContext, skipAgentic, logs, madeChanges, } = await (0, execute_migration_1.runNxOrAngularMigration)(root, migration, isVerbose,
430
+ // Captured whenever an agent step may consume it: a hybrid's prompt
431
+ // payload, or the validation pass over the generator's changes.
432
+ (0, migration_shape_1.isHybridMigration)(migration) || shouldValidate, resolvedCollection);
433
+ // Mirrors the classic loop's waiver semantics (migrate.ts): the two must
434
+ // agree on when an AI step was owed for skipAgentic to waive.
435
+ const validationApplies = shouldValidate && !(0, migration_shape_1.isHybridMigration)(migration) && changes.length > 0;
436
+ const waivedAgenticStep = skipAgentic && ((0, migration_shape_1.isHybridMigration)(migration) || validationApplies);
437
+ const validationOwed = validationApplies && !waivedAgenticStep;
438
+ const promptOwed = (0, migration_shape_1.isHybridMigration)(migration) && !waivedAgenticStep;
439
+ // Recorded before the commit is attempted: from here on the changes are
440
+ // in the tree, so a failed install or commit must leave a retry with
441
+ // only those left to do rather than running the generator again.
442
+ state = transition(dir, {
443
+ type: 'markGeneratorCompleted',
444
+ stepId: step.id,
445
+ agenticWaived: waivedAgenticStep,
446
+ validationOwed,
447
+ madeChanges,
414
448
  });
449
+ if (waivedAgenticStep) {
450
+ (0, migrate_output_1.logWaivedAgenticStep)(migration, agentContext);
451
+ }
452
+ else if (!(0, migration_shape_1.isHybridMigration)(migration) && !validationApplies) {
453
+ forwardDroppedAgentContext(migration, agentContext, agenticKind);
454
+ }
455
+ // Stored before the install: a failed install fails the attempt, and
456
+ // the retry re-hands this payload instead of a context-free one.
457
+ const impl = { logs, changes, agentContext };
458
+ const owed = validationOwed
459
+ ? validationPayload(root, migration, impl, resolvedCollection)
460
+ : promptOwed
461
+ ? promptPayload(root, migration, impl, resolvedCollection)
462
+ : undefined;
463
+ if (owed) {
464
+ (0, agent_work_payload_1.persistAgentWorkPayload)(payloadPath, owed);
465
+ }
466
+ const install = () => recordingInstallFailure(dir, step.id, () => installer.installDepsIfChanged());
467
+ // A no-op step's commit would absorb prior pending diffs under its name;
468
+ // a step handing work back defers its commit to the fold, so the
469
+ // migration still lands as one commit. The install runs either way: the
470
+ // agent may run tasks that need what the generator added.
471
+ if (state.createCommits &&
472
+ madeChanges &&
473
+ !validationOwed &&
474
+ !promptOwed) {
475
+ state = await commitStepChanges(dir, root, state, startedStep, migration, install, scope);
476
+ }
477
+ else {
478
+ await (0, broker_1.installStepTree)(dir, startedStep, 'install', install, scope);
479
+ }
480
+ if (installer.skippedInstall) {
481
+ (0, execute_migration_1.logSkippedPostMigrationInstall)(root);
482
+ }
483
+ else if (installer.installed) {
484
+ (0, util_1.recordInstallLanded)(root, dir, step.id);
485
+ }
486
+ printNextSteps(migration, nextSteps);
487
+ if (validationOwed) {
488
+ emitValidationPayload(migrationId, owed);
489
+ awaitingKind = 'generator-validation';
490
+ }
491
+ else if (promptOwed) {
492
+ emitPromptForOuterAgent(migrationId, owed, true);
493
+ awaitingKind = 'migration-prompt';
494
+ }
495
+ else {
496
+ outcome = buildOutcome(changes, nextSteps, migration.description, root);
497
+ }
415
498
  }
416
- awaitingKind = 'generator-validation';
417
499
  }
418
- else if (generatorAlreadyCompleted) {
419
- // Reached when the earlier attempt waived the AI step, or none was owed
420
- // (no changes, validation off, or an older-nx state).
421
- state = await finishCompletedGenerator(dir, root, state, startedStep, migration, effectiveSkipInstall, runId);
422
- outcome = buildOutcome([], [], 'The generator ran in an earlier attempt; this attempt completed its install and commit.', root);
500
+ catch (e) {
501
+ // Another attempt owns the step, and `fail` is not attempt-bound.
502
+ if (e instanceof broker_1.BrokerStaleRequestError)
503
+ throw e;
504
+ // The failed-step dispense surfaces this so the agent can decide
505
+ // retry-vs-skip; carry the error's first line, not a full stack.
506
+ transition(dir, {
507
+ type: 'fail',
508
+ stepId: step.id,
509
+ finishedAt: (0, util_1.nowIso)(),
510
+ outcome: { summary: (0, util_1.summarizeError)(e) },
511
+ });
512
+ throw e;
423
513
  }
424
- else {
425
- const installer = new execute_migration_1.ChangedDepInstaller(root, effectiveSkipInstall, `${(0, execute_migration_1.formatSingleMigrationRerunCommand)(migrationId)} --run-id=${runId}`);
426
- // Read once; the run and the hybrid documentation resolution share it.
427
- const resolvedCollection = (0, execute_migration_1.readMigrationCollection)(migration.package, root);
428
- const { changes, nextSteps, agentContext, skipAgentic, logs, madeChanges, } = await (0, execute_migration_1.runNxOrAngularMigration)(root, migration, isVerbose,
429
- // Captured whenever an agent step may consume it: a hybrid's prompt
430
- // payload, or the validation pass over the generator's changes.
431
- (0, migration_shape_1.isHybridMigration)(migration) || shouldValidate, resolvedCollection);
432
- // Mirrors the classic loop's waiver semantics (migrate.ts): the two must
433
- // agree on when an AI step was owed for skipAgentic to waive.
434
- const validationApplies = shouldValidate && !(0, migration_shape_1.isHybridMigration)(migration) && changes.length > 0;
435
- const waivedAgenticStep = skipAgentic && ((0, migration_shape_1.isHybridMigration)(migration) || validationApplies);
436
- const validationOwed = validationApplies && !waivedAgenticStep;
437
- const promptOwed = (0, migration_shape_1.isHybridMigration)(migration) && !waivedAgenticStep;
438
- // Recorded before the commit is attempted: from here on the changes are
439
- // in the tree, so a failed install or commit must leave a retry with
440
- // only those left to do rather than running the generator again. The
441
- // waiver and the owed validation ride along so that retry re-emits
442
- // exactly the agent work this attempt decided was owed.
443
- state = transition(dir, {
444
- type: 'markGeneratorCompleted',
514
+ // Handed-back work is applied by a separate actor, so parking exits
515
+ // successfully rather than failing the step.
516
+ if (awaitingKind !== undefined) {
517
+ transition(dir, {
518
+ type: 'awaitPromptOutcome',
445
519
  stepId: step.id,
446
- agenticWaived: waivedAgenticStep,
447
- validationOwed,
448
- madeChanges,
520
+ finishedAt: (0, util_1.nowIso)(),
521
+ awaitingKind,
449
522
  });
450
- if (waivedAgenticStep) {
451
- (0, migrate_output_1.logWaivedAgenticStep)(migration, agentContext);
452
- }
453
- else if (!(0, migration_shape_1.isHybridMigration)(migration) && !validationApplies) {
454
- forwardDroppedAgentContext(migration, agentContext, agenticKind);
455
- }
456
- // Stored before the install: a failed install fails the attempt, and
457
- // the retry re-hands this payload instead of a context-free one.
458
- const impl = { logs, changes, agentContext };
459
- const owed = validationOwed
460
- ? validationPayload(root, migration, impl, resolvedCollection)
461
- : promptOwed
462
- ? promptPayload(root, migration, impl, resolvedCollection)
463
- : undefined;
464
- if (owed) {
465
- (0, agent_work_payload_1.persistAgentWorkPayload)(payloadPath, owed);
466
- }
467
- const install = () => recordingInstallFailure(dir, step.id, () => installer.installDepsIfChanged());
468
- // Commits follow the run config, not CLI flags, and only when the
469
- // generator changed something: a no-op step's commit would absorb prior
470
- // pending diffs under its name. A step handing work back defers its
471
- // commit to the fold, so the migration lands as one commit and a failed
472
- // hand-back leaves the changes uncommitted for review, as in the classic
473
- // loop. The install still runs first: the agent may run tasks that need
474
- // what the generator added.
475
- if (state.createCommits &&
476
- madeChanges &&
477
- !validationOwed &&
478
- !promptOwed) {
479
- state = await commitStepChanges(dir, root, state, step, migration, install);
480
- }
481
- else {
482
- await install();
483
- }
484
- if (installer.skippedInstall) {
485
- (0, execute_migration_1.logSkippedPostMigrationInstall)(root);
486
- }
487
- else if (installer.installed) {
488
- (0, util_1.recordInstallLanded)(root, dir, step.id);
489
- }
490
- printNextSteps(migration, nextSteps);
491
- if (validationOwed) {
492
- emitValidationPayload(migrationId, owed);
493
- awaitingKind = 'generator-validation';
494
- }
495
- else if (promptOwed) {
496
- emitPromptForOuterAgent(migrationId, owed, true);
497
- awaitingKind = 'migration-prompt';
498
- }
499
- else {
500
- outcome = buildOutcome(changes, nextSteps, migration.description, root);
501
- }
523
+ return;
502
524
  }
503
- }
504
- catch (e) {
505
- // The failed-step dispense surfaces this so the agent can decide
506
- // retry-vs-skip; carry the error's first line, not a full stack.
507
525
  transition(dir, {
508
- type: 'fail',
526
+ type: 'succeed',
509
527
  stepId: step.id,
510
528
  finishedAt: (0, util_1.nowIso)(),
511
- outcome: { summary: (0, util_1.summarizeError)(e) },
529
+ ...(outcome ? { outcome } : {}),
512
530
  });
513
- throw e;
514
531
  }
515
- // Handed-back work is applied by a separate actor, so parking exits
516
- // successfully rather than failing the step.
517
- if (awaitingKind !== undefined) {
518
- transition(dir, {
519
- type: 'awaitPromptOutcome',
520
- stepId: step.id,
521
- finishedAt: (0, util_1.nowIso)(),
522
- awaitingKind,
523
- });
524
- return;
532
+ finally {
533
+ scope.lease?.release();
525
534
  }
526
- transition(dir, {
527
- type: 'succeed',
528
- stepId: step.id,
529
- finishedAt: (0, util_1.nowIso)(),
530
- ...(outcome ? { outcome } : {}),
531
- });
532
535
  }
533
536
  // Applies a step event to the freshest on-disk state under the lock, writes it,
534
537
  // and returns it. Reading fresh is what makes a racing worker's 'start' see the
@@ -561,49 +564,52 @@ async function recordingInstallFailure(dir, stepId, install) {
561
564
  // on the install or the commit. The generator's changes are already in the
562
565
  // tree, so only those two are left, and the install compares against the
563
566
  // step's persisted baseline rather than against what that generator wrote.
564
- async function finishCompletedGenerator(dir, root, state, step, migration, skipInstall, runId) {
567
+ async function finishCompletedGenerator(dir, root, state, step, migration, skipInstall, runId, scope) {
565
568
  const migrationId = `${migration.package}:${migration.name}`;
566
569
  const installDeps = () => recordingInstallFailure(dir, step.id, () => (0, util_1.installDepsChangedSinceDispense)(root, dir, step, skipInstall, `${(0, execute_migration_1.formatSingleMigrationRerunCommand)(migrationId)} --run-id=${runId}`));
567
570
  // Same no-op guard as the first attempt. Only an explicit false skips the
568
571
  // commit: absent means an older nx wrote the marker without recording the
569
572
  // answer, and the commit is kept as that version's retries did.
570
573
  if (!state.createCommits || step.generatorMadeChanges === false) {
571
- await installDeps();
574
+ await (0, broker_1.installStepTree)(dir, step, 'install', installDeps, scope);
572
575
  return state;
573
576
  }
574
- return commitStepChanges(dir, root, state, step, migration, installDeps);
577
+ return commitStepChanges(dir, root, state, step, migration, installDeps, scope);
575
578
  }
576
579
  // Installs what the step changed, commits it, and records the result in the
577
580
  // ledger, shared by a step's first attempt and by one that only has the commit
578
581
  // left to do. The absorbed step ids are computed before the commit so the
579
582
  // ledger entry and the commit body name the same ones.
580
- async function commitStepChanges(dir, root, state, step, migration, installDeps) {
583
+ async function commitStepChanges(dir, root, state, step, migration, installDeps, scope) {
581
584
  const absorbedStepIds = (0, state_machine_1.uncoveredFailedStepIds)(state).filter((id) => id !== step.id);
582
- let result;
585
+ let commit;
583
586
  try {
584
- result = await (0, migrate_commits_1.commitMigrationIfRequested)(root, migration, true, state.commitPrefix, installDeps, (0, state_machine_1.stepsToPendingMigrations)(state, absorbedStepIds));
587
+ commit = await (0, broker_1.commitStepTree)(dir, step, absorbedStepIds, () => (0, migrate_commits_1.commitMigrationIfRequested)(root, migration, true, state.commitPrefix, installDeps, (0, state_machine_1.stepsToPendingMigrations)(state, absorbedStepIds)), scope);
585
588
  }
586
589
  catch (commitError) {
587
- // A post-migration install failure leaves the diff uncommitted; record the
588
- // debt so only a landed entry can cover it.
589
- appendCommit(dir, { kind: 'failed', stepIds: [step.id] });
590
+ // Nothing ran for a stale or refused request, so there is no debt.
591
+ if (commitError instanceof broker_1.BrokerStaleRequestError ||
592
+ commitError instanceof broker_1.TreeBusyError) {
593
+ throw commitError;
594
+ }
595
+ // The install failed, or the session ended unanswered and the commit may
596
+ // or may not have landed; record the debt until a landed entry covers it.
597
+ recordCommitEntry(dir, { kind: 'failed', stepIds: [step.id] });
590
598
  throw commitError;
591
599
  }
592
- if (result.status === 'failed') {
600
+ if (commit.result.status === 'failed') {
593
601
  (0, util_1.warnCommitFailed)(migration.name);
594
602
  }
595
- const entry = (0, state_machine_1.commitResultToLedgerEntry)(result, step.id, absorbedStepIds);
596
- return entry ? appendCommit(dir, entry) : state;
603
+ const entry = (0, state_machine_1.commitResultToLedgerEntry)(commit.result, step.id, commit.absorbedStepIds);
604
+ // A session's parent records what it commits itself, receipt included.
605
+ return entry && !commit.recorded ? recordCommitEntry(dir, entry) : state;
597
606
  }
598
607
  // The git commit itself already ran outside the lock; only this pure append is
599
608
  // locked. A landed entry carries the resolved issues of every step it names,
600
609
  // same as the orchestrator's fold and adopt appends: an absorbed step's
601
610
  // resolutions would otherwise land unattached.
602
- function appendCommit(dir, entry) {
603
- return (0, state_lock_1.updateRunState)(dir, (fresh) => ({
604
- ...fresh,
605
- commits: [...fresh.commits, (0, issues_1.attachIssueIdsToCommitEntry)(fresh, entry)],
606
- }));
611
+ function recordCommitEntry(dir, entry) {
612
+ return (0, state_lock_1.updateRunState)(dir, (fresh) => (0, state_machine_1.appendCommit)(fresh, (0, issues_1.attachIssueIdsToCommitEntry)(fresh, entry)));
607
613
  }
608
614
  function buildOutcome(changes, nextSteps, description, root) {
609
615
  const outcome = {};
@@ -668,7 +674,7 @@ async function runAgenticStep(agenticRun, input) {
668
674
  // commit's diff, so it passes its own guidance instead of the default.
669
675
  // `commitMigrationIfRequested` logs a failed commit itself with that guidance.
670
676
  function attemptStandaloneCommit(root, migration, createCommits, commitPrefix, installDepsIfChanged) {
671
- return (0, migrate_commits_1.commitMigrationIfRequested)(root, migration, createCommits, commitPrefix, installDepsIfChanged, [], 'Commit or revert the changes manually.');
677
+ return (0, migrate_commits_1.commitMigrationIfRequested)(root, migration, createCommits, commitPrefix, installDepsIfChanged, [], 'Commit or revert any remaining uncommitted changes manually.');
672
678
  }
673
679
  async function commitAndLogAgenticOutcome(args) {
674
680
  const commit = await attemptStandaloneCommit(args.root, args.migration, args.createCommits, args.commitPrefix, args.installDepsIfChanged);