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
@@ -51,6 +51,8 @@ export interface MigrateStep {
51
51
  outcome?: MigrateStepOutcome;
52
52
  promptOutcome?: MigrateStepPromptOutcome;
53
53
  awaitingKind?: MigrateStepAwaitingKind;
54
+ commitLedgerIndex?: number;
55
+ commitStarted?: boolean;
54
56
  generatorCompleted?: boolean;
55
57
  generatorCompletedAtAttempt?: number;
56
58
  agenticWaived?: boolean;
@@ -95,6 +97,10 @@ export interface MigrateRunNoProgress {
95
97
  consecutiveCount: number;
96
98
  firstSeenAt: string;
97
99
  }
100
+ export interface MigrateRunPolicy {
101
+ createCommits: boolean;
102
+ skipInstall: boolean;
103
+ }
98
104
  export interface MigrateRunState {
99
105
  formatVersion: number;
100
106
  runId: string;
@@ -112,8 +118,18 @@ export interface MigrateRunState {
112
118
  issues?: MigrateRunIssue[];
113
119
  noProgress?: MigrateRunNoProgress;
114
120
  checkpointFailed?: boolean;
121
+ treeOperation?: MigrateTreeOperation;
115
122
  analytics: MigrateRunAnalytics;
116
123
  }
124
+ export declare const TREE_OPERATION_KINDS: readonly ['commit', 'install', 'fold-install', 'action-install', 'reset', 'checkpoint'];
125
+ export type MigrateTreeOperationKind = (typeof TREE_OPERATION_KINDS)[number];
126
+ export interface MigrateTreeOperation {
127
+ kind: MigrateTreeOperationKind;
128
+ stepId?: string;
129
+ attempt?: number;
130
+ owner: string;
131
+ pid: number;
132
+ }
117
133
  export declare function migrateRunsDir(root: string): string;
118
134
  export declare function runDir(root: string, runId: string): string;
119
135
  /** See `HANDOFFS_DIR_NAME` for why the subtree exists. */
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.NewerRunStateFormatError = exports.MIGRATE_ISSUE_DISPOSITIONS = exports.TERMINAL_STEP_STATUSES = exports.ISSUE_ID = exports.SHELL_SAFE_VALUE = exports.RUN_STATE_FILE_NAME = exports.CURRENT_RUN_STATE_FORMAT_VERSION = void 0;
3
+ exports.NewerRunStateFormatError = exports.TREE_OPERATION_KINDS = exports.MIGRATE_ISSUE_DISPOSITIONS = exports.TERMINAL_STEP_STATUSES = exports.ISSUE_ID = exports.SHELL_SAFE_VALUE = exports.RUN_STATE_FILE_NAME = exports.CURRENT_RUN_STATE_FORMAT_VERSION = void 0;
4
4
  exports.issueFingerprint = issueFingerprint;
5
5
  exports.migrateRunsDir = migrateRunsDir;
6
6
  exports.runDir = runDir;
@@ -18,6 +18,7 @@ const atomic_write_1 = require("./atomic-write");
18
18
  const git_utils_1 = require("../../../utils/git-utils");
19
19
  const versions_1 = require("../../../utils/versions");
20
20
  const types_1 = require("../agentic/types");
21
+ const handoff_1 = require("../agentic/handoff");
21
22
  const run_id_1 = require("./run-id");
22
23
  const text_1 = require("../text");
23
24
  exports.CURRENT_RUN_STATE_FORMAT_VERSION = 1;
@@ -111,6 +112,14 @@ exports.MIGRATE_ISSUE_DISPOSITIONS = [
111
112
  'resolved',
112
113
  'deferred-final',
113
114
  ];
115
+ exports.TREE_OPERATION_KINDS = [
116
+ 'commit',
117
+ 'install',
118
+ 'fold-install',
119
+ 'action-install',
120
+ 'reset',
121
+ 'checkpoint',
122
+ ];
114
123
  const REQUIRED_TOP_LEVEL_FIELDS = [
115
124
  'formatVersion',
116
125
  'runId',
@@ -226,6 +235,7 @@ function isStepShape(value) {
226
235
  isPromptOutcomeShape(value.promptOutcome) &&
227
236
  (value.awaitingKind === undefined ||
228
237
  isOneOf(MIGRATE_STEP_AWAITING_KINDS, value.awaitingKind)) &&
238
+ isOptionalBoolean(value.commitStarted) &&
229
239
  isOptionalBoolean(value.generatorCompleted) &&
230
240
  // Bounded by the step's attempt: a higher value could not name one that
231
241
  // exists.
@@ -234,6 +244,9 @@ function isStepShape(value) {
234
244
  value.generatorCompletedAtAttempt >= 1 &&
235
245
  value.generatorCompletedAtAttempt <=
236
246
  value.attempt)) &&
247
+ (value.commitLedgerIndex === undefined ||
248
+ (Number.isSafeInteger(value.commitLedgerIndex) &&
249
+ value.commitLedgerIndex >= 0)) &&
237
250
  isOptionalBoolean(value.agenticWaived) &&
238
251
  isOptionalBoolean(value.validationOwed) &&
239
252
  isOptionalBoolean(value.generatorMadeChanges) &&
@@ -338,6 +351,19 @@ function isNoProgressShape(value) {
338
351
  typeof value.firstSeenAt === 'string' &&
339
352
  ISO_TIMESTAMP.test(value.firstSeenAt)));
340
353
  }
354
+ function isTreeOperationShape(value) {
355
+ return (value === undefined ||
356
+ (isPlainObject(value) &&
357
+ isOneOf(exports.TREE_OPERATION_KINDS, value.kind) &&
358
+ (value.stepId === undefined || typeof value.stepId === 'string') &&
359
+ (value.attempt === undefined ||
360
+ (Number.isSafeInteger(value.attempt) &&
361
+ value.attempt >= 1)) &&
362
+ typeof value.owner === 'string' &&
363
+ value.owner.length > 0 &&
364
+ Number.isSafeInteger(value.pid) &&
365
+ value.pid > 0));
366
+ }
341
367
  function isAnalyticsShape(value) {
342
368
  return (isPlainObject(value) &&
343
369
  typeof value.startEmitted === 'boolean' &&
@@ -374,6 +400,7 @@ function hasValidRunStateShape(parsed) {
374
400
  parsed.commits.every((c) => c.issueIds === undefined ||
375
401
  c.issueIds.every((id) => (parsed.issues ?? []).some((i) => i.id === id))) &&
376
402
  isNoProgressShape(parsed.noProgress) &&
403
+ isTreeOperationShape(parsed.treeOperation) &&
377
404
  isAnalyticsShape(parsed.analytics));
378
405
  }
379
406
  function corruptRunStateError(filePath, reason) {
@@ -404,7 +431,11 @@ exports.NewerRunStateFormatError = NewerRunStateFormatError;
404
431
  */
405
432
  function readRunState(runDirPath) {
406
433
  const filePath = (0, path_1.join)(runDirPath, exports.RUN_STATE_FILE_NAME);
407
- const content = (0, fs_1.readFileSync)(filePath, 'utf-8');
434
+ // run.json lives in the agent-writable run directory, so it could be a
435
+ // planted symlink or FIFO; read it without reading a symlink's target or
436
+ // blocking on a FIFO, while still tolerating a concurrent tmp + rename
437
+ // publish. A non-regular file reads as corruption; ENOENT stays "no run".
438
+ const content = (0, handoff_1.readAtomicallyPublishedFile)(filePath, corruptRunStateError(filePath, 'is not a regular file.').message);
408
439
  let parsed;
409
440
  try {
410
441
  parsed = JSON.parse(content);
@@ -8,6 +8,9 @@ exports.renderRunbook = renderRunbook;
8
8
  const fragments_1 = require("../agentic/prompts/fragments");
9
9
  const types_1 = require("../agentic/types");
10
10
  const text_1 = require("../text");
11
+ // A resume without a commit flag takes its policy from nx.json and refuses
12
+ // the run when it differs from the recorded one.
13
+ const NX_JSON_MIGRATE_RULE = ` - Do not edit the \`migrate\` section of nx.json.`;
11
14
  exports.RUNBOOK_FILE_NAME = 'RUNBOOK.md';
12
15
  /**
13
16
  * Every interpolated value is collapsed to a single line: the rendered content
@@ -78,12 +81,13 @@ function renderRunbook(ctx) {
78
81
  ...(0, fragments_1.renderAuthorScopeRuleLines)((0, text_1.singleLine)(ctx.pmExec), {
79
82
  source: 'dispensed-step',
80
83
  }).map((line) => ` ${line}`),
84
+ NX_JSON_MIGRATE_RULE,
81
85
  ` The \`nx migrate\` commands this runbook and the step blocks hand you`,
82
- ` (the reconcile, worker and \`next\` commands) are the exception to that`,
83
- ` rule: run them as given.`,
86
+ ` (the reconcile, worker and \`next\` commands) are the exception to the`,
87
+ ` rule against mutating \`nx\` commands: run them as given.`,
84
88
  ];
85
89
  if (ctx.validate) {
86
- lines.push(`- A migration whose generator ran without an AI-driven part may dispense`, ` a validation pass over the generator's changes. Scope rules for that`, ` work:`, ...(0, fragments_1.renderValidationScopeRuleLines)().map((line) => ` ${line}`));
90
+ lines.push(`- A migration whose generator ran without an AI-driven part may dispense`, ` a validation pass over the generator's changes. Scope rules for that`, ` work:`, ...(0, fragments_1.renderValidationScopeRuleLines)().map((line) => ` ${line}`), NX_JSON_MIGRATE_RULE);
87
91
  }
88
92
  lines.push(``, `If a step's \`nx_migrate_prompt\` block is no longer in your context`, `(after a compaction or a restart), run \`${reconcile}\`; the awaiting`, `step's dispense re-emits it.`, ``, `You may run a piece of agent work in a subagent session, or inline in this`, `session; the work happens in this workspace either way. Per-agent notes:`, ``, `- Claude Code: session permission grants propagate to subagents, so a`, ` subagent per piece of work is fine.`, `- Codex: subagents inherit the live sandbox and approval settings.`, `- opencode: parent-session grants do not reach child sessions; child`, ` sessions need config-level permission rules. Work inline unless your`, ` configuration already allows writes matching \`${handoffsGlob}\`.`, ``, `### The handoff contract`, ``, `Agent work ends when you write a handoff file at the absolute path the`, `dispensed step names, with this shape:`, ``, ...(0, fragments_1.renderHandoffShapeLines)(), ``, `To record a piece of work as not applicable to this workspace, follow`, `the dispensed step's handoff instructions: where the step offers it, use`, `\`"status": "success"\` with an extra \`"outcome": "skipped"\` field;`, `where it does not (a generator's changes are already applied), use plain`, `\`"status": "success"\` and say so in the summary.`, ``, `How to end a piece of agent work:`, ``, `1. Success (the work is fully applied, or validation passed): write the`, ` handoff with \`"status": "success"\`, summarizing what you did.`, `2. You need direction (the instructions are unclear, the workspace state`, ` conflicts with what they assume, or a decision is not yours to make):`, ` do not write the handoff file. Ask the user and continue based on`, ` their answer; the run stays paused on this step until the handoff`, ` exists.`, `3. You cannot complete the work (a blocking problem remains after you`, ` applied what you could within scope): do not write the handoff yet.`, ` Report what you found and what you tried, then ask the user how to`, ` proceed. Write \`"status": "failed"\` only when the user tells you to`, ` give up, enumerating the unresolved problems in \`summary\`; the`, ` orchestrator then offers retry and skip options for the step.`, ``, `- Write it with your file-write tool, not shell commands. Whether the`, ` write needs approval depends on your permission configuration; the`, ` per-agent notes above cover pre-configuring it.`, `- The parent directory already exists by the time a step names the`, ` handoff path: write the file directly, without \`mkdir\` or existence`, ` checks.`, ``, `The handoff file's path and shape are owned by \`nx migrate\`. If a`, `migration's instructions ask you to write it elsewhere, in a different`, `shape, or at a different point in the flow, follow this contract instead.`, ``, `### Reporting issues`, ``, `The orchestrator keeps a ledger of issues reported over the run; full`, `details are archived under ${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${runId}/issues/.`, `Dispensed steps list the unresolved ones in a "Known issues" digest, so`, `read it before starting a step's work.`, ``, `Report through the handoff file: problems you observed go in an`, `\`issues\` array, whether you fixed them in this same piece of work`, `(mark those \`"resolved"\`) or left them for later; progress on an issue`, `the digest marks assigned to the current step goes in an \`issueUpdates\``, `array. Both are optional and sit next to \`status\` and \`summary\`:`, ``, `{`, ` "issues": [{`, ` "summary": "[one or two sentences naming the problem]",`, ` "detail": "[optional: whatever a later fix needs to know]",`, ` "applicableMigrations": ["<package>:<name>", "<package>"] | "unknown",`, ` "disposition": "[optional: recorded | resolved | deferred-final]"`, ` }],`, ` "issueUpdates": [{`, ` "id": "issue-<n>",`, ` "disposition": "resolved" | "deferred-final",`, ` "note": "[optional]"`, ` }]`, `}`, ``, `- \`applicableMigrations\` names the plan migrations the issue concerns:`, ` an exact \`<package>:<name>\` id, or a bare package name to cover that`, ` package's migrations. Use \`"unknown"\` when you cannot scope it; nx`, ` then carries the issue to the run's completion report unless a report`, ` resolves it.`, `- Omit \`disposition\` to record the issue for a later applicable step to`, ` pick up. Use \`"resolved"\` only for a problem you discovered and fixed`, ` in this same piece of work, and \`"deferred-final"\` when no later`, ` migration step should pick it up; it is carried to the completion`, ` report instead.`, `- \`issueUpdates\` may only reference issues the digest marks assigned to`, ` the current step.`, `- Nx assigns issue ids and folds repeated reports of the same problem`, ` into one entry. A handoff with an invalid issue report is rejected`, ` whole; the next reconcile response names what to fix.`, ``, `## Commits`, ``);
89
93
  if (ctx.createCommits) {
@@ -92,6 +96,6 @@ function renderRunbook(ctx) {
92
96
  else {
93
97
  lines.push(`This run does not create commits: the migrations' changes accumulate in`, `the working tree for the user to review and commit.`);
94
98
  }
95
- lines.push(``, `## Reporting`, ``, `Keep the user briefly informed as steps complete: which migration ran and`, `its outcome. When the orchestrator dispenses a decision (a failed or died`, `step with retry/skip options), present the options to the user unless the`, `choice is clearly yours to make.`, ``);
99
+ lines.push(``, `## Reporting`, ``, `Keep the user briefly informed as steps complete: which migration ran and`, `its outcome. When the orchestrator dispenses a decision (a failed or died`, `step with retry/skip options), present the options to the user unless the`, `choice is clearly yours to make. When the run is reported complete,`, `summarize the outcome for the user.`, ``);
96
100
  return lines.join('\n');
97
101
  }
@@ -1,5 +1,5 @@
1
1
  import type { CommitResult } from '../migrate-commits';
2
- import type { MigrateCommitLedgerEntry, MigrateRunState, MigrateStepAwaitingKind, MigrateStepOutcome, MigrateStepPromptOutcome } from './run-state';
2
+ import type { MigrateCommitLedgerEntry, MigrateRunState, MigrateStep, MigrateStepAwaitingKind, MigrateStepOutcome, MigrateStepPromptOutcome } from './run-state';
3
3
  import type { StepAction } from '../step-actions';
4
4
  export type { StepAction };
5
5
  export type StepEvent = {
@@ -54,6 +54,11 @@ export type ApplyStepEventResult = {
54
54
  reason: string;
55
55
  };
56
56
  export declare function applyStepEvent(state: MigrateRunState, event: StepEvent): ApplyStepEventResult;
57
+ /**
58
+ * Forgets the generator run of `stepId`'s current attempt, unless a landed
59
+ * commit already carries its changes.
60
+ */
61
+ export declare function discardGeneratorRun(state: MigrateRunState, stepId: string): MigrateRunState;
57
62
  /**
58
63
  * Records that the run could not install the dependency changes a step left
59
64
  * behind. Not a {@link StepEvent}: it annotates a step instead of moving it,
@@ -70,6 +75,24 @@ export declare function uncoveredFailedStepIds(state: MigrateRunState): string[]
70
75
  */
71
76
  export declare function hasPendingCommitDebt(state: MigrateRunState): boolean;
72
77
  export declare function coveringLandedEntries(state: MigrateRunState, stepId: string): MigrateCommitLedgerEntry[];
78
+ export declare function markCommitStarted(state: MigrateRunState, stepId: string): MigrateRunState;
79
+ export declare function clearCommitStarted(state: MigrateRunState, stepId: string): MigrateRunState;
80
+ /**
81
+ * True when a landed entry names the step, or a commit was started for it
82
+ * that no entry accounts for. Skipping the step would then report as not
83
+ * applied a migration whose commit is, or may be, in history.
84
+ */
85
+ export declare function commitMayBeInHistory(state: MigrateRunState, step: MigrateStep): boolean;
86
+ export declare function appendCommit(state: MigrateRunState, entry: MigrateCommitLedgerEntry): MigrateRunState;
87
+ /**
88
+ * The entry a parent session recorded for this attempt's commit, or undefined
89
+ * without a receipt. A receipt past the ledger or naming another step is
90
+ * corrupt run state, so it throws.
91
+ */
92
+ export declare function commitReceipt(state: MigrateRunState, step: MigrateStep): {
93
+ index: number;
94
+ entry: MigrateCommitLedgerEntry;
95
+ } | undefined;
73
96
  export declare function latestRound(state: MigrateRunState): MigrateRunState['rounds'][number] | undefined;
74
97
  export declare function splitMigrationId(id: string): {
75
98
  package: string;
@@ -1,10 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.applyStepEvent = applyStepEvent;
4
+ exports.discardGeneratorRun = discardGeneratorRun;
4
5
  exports.markInstallFailed = markInstallFailed;
5
6
  exports.uncoveredFailedStepIds = uncoveredFailedStepIds;
6
7
  exports.hasPendingCommitDebt = hasPendingCommitDebt;
7
8
  exports.coveringLandedEntries = coveringLandedEntries;
9
+ exports.markCommitStarted = markCommitStarted;
10
+ exports.clearCommitStarted = clearCommitStarted;
11
+ exports.commitMayBeInHistory = commitMayBeInHistory;
12
+ exports.appendCommit = appendCommit;
13
+ exports.commitReceipt = commitReceipt;
8
14
  exports.latestRound = latestRound;
9
15
  exports.splitMigrationId = splitMigrationId;
10
16
  exports.stepsToPendingMigrations = stepsToPendingMigrations;
@@ -125,6 +131,12 @@ function applyStepAction(state, index, step, action) {
125
131
  // offered under the same guard as for a death.
126
132
  return commit(state, index, cleanRearm(state, step));
127
133
  case 'skip':
134
+ if (commitMayBeInHistory(state, step)) {
135
+ return {
136
+ kind: 'error',
137
+ reason: `Cannot apply action 'skip' to step '${step.id}': a commit of its changes landed or was started and never recorded, so the migration may be committed. Use 'retry' to finish it.`,
138
+ };
139
+ }
128
140
  return commit(state, index, { ...step, status: 'skipped' });
129
141
  }
130
142
  }
@@ -141,7 +153,9 @@ function applyStepAction(state, index, step, action) {
141
153
  }
142
154
  return {
143
155
  kind: 'error',
144
- reason: `Cannot apply action 'retry' to step '${step.id}': the worker died before recording that its generator ran, so keeping the current tree could apply the migration twice. Use 'retry-clean', 'adopt' or 'skip' instead.`,
156
+ reason: `Cannot apply action 'retry' to step '${step.id}': the worker died before recording that its generator ran, so keeping the current tree could apply the migration twice. Use ${commitMayBeInHistory(state, step)
157
+ ? `'retry-clean' where offered, or 'adopt'`
158
+ : `'retry-clean', 'adopt' or 'skip'`} instead.`,
145
159
  };
146
160
  case 'retry-clean':
147
161
  return commit(state, index, cleanRearm(state, step));
@@ -151,9 +165,22 @@ function applyStepAction(state, index, step, action) {
151
165
  status: 'succeeded',
152
166
  outcome: { ...step.outcome, summary: adoptedSummary(step) },
153
167
  });
154
- case 'skip':
168
+ case 'skip': {
169
+ if (coveringLandedEntries(state, step.id).length > 0) {
170
+ return {
171
+ kind: 'error',
172
+ reason: `Cannot apply action 'skip' to step '${step.id}': a commit of its changes already landed, so the migration is applied. Use 'adopt' to record that.`,
173
+ };
174
+ }
175
+ if (step.commitStarted === true) {
176
+ return {
177
+ kind: 'error',
178
+ reason: `Cannot apply action 'skip' to step '${step.id}': a commit of its changes was started and never recorded, so it may be in history. Use 'adopt' to record the migration as applied.`,
179
+ };
180
+ }
155
181
  // Same as skipping a failure: the tree stays as the worker left it.
156
182
  return commit(state, index, { ...step, status: 'skipped' });
183
+ }
157
184
  }
158
185
  }
159
186
  return {
@@ -175,10 +202,8 @@ function adoptedSummary(step) {
175
202
  ? "Adopted after the worker died: its generator had run, and the working tree it left was taken as this migration's result."
176
203
  : "Adopted after the worker died before recording that its generator had run; the working tree it left was taken as this migration's result.";
177
204
  }
178
- // Re-arms a step for a fresh attempt. Drops every field the previous attempt
179
- // wrote (pid, timestamps, git ref, tree state, outcomes) so a later success
180
- // can't carry a stale failure outcome; dispenseCount stays cumulative across
181
- // attempts.
205
+ // Rebuilds the step for a fresh attempt so a stale outcome cannot survive;
206
+ // dispenseCount stays cumulative across attempts.
182
207
  // `keepGeneratorCompleted` says whether the generator's changes reach the new
183
208
  // attempt. They do when nothing resets the tree, and when the reset target
184
209
  // already contains the commit that landed them; re-running the generator there
@@ -202,27 +227,43 @@ function rearm(step, keepGeneratorCompleted) {
202
227
  ...(step.depsHashAtDispense !== undefined
203
228
  ? { depsHashAtDispense: step.depsHashAtDispense }
204
229
  : {}),
205
- ...(keepGeneratorCompleted && step.generatorCompleted
206
- ? { generatorCompleted: true }
207
- : {}),
208
- ...(keepGeneratorCompleted &&
209
- step.generatorCompleted &&
210
- step.generatorCompletedAtAttempt !== undefined
230
+ // A reset keeps a commit that landed before this attempt's ref as well.
231
+ ...(step.commitStarted ? { commitStarted: true } : {}),
232
+ ...(keepGeneratorCompleted ? generatorRunFields(step) : {}),
233
+ };
234
+ }
235
+ function generatorRunFields(step) {
236
+ if (!step.generatorCompleted)
237
+ return {};
238
+ return {
239
+ generatorCompleted: true,
240
+ ...(step.generatorCompletedAtAttempt !== undefined
211
241
  ? { generatorCompletedAtAttempt: step.generatorCompletedAtAttempt }
212
242
  : {}),
213
- ...(keepGeneratorCompleted && step.generatorCompleted && step.agenticWaived
214
- ? { agenticWaived: true }
215
- : {}),
216
- ...(keepGeneratorCompleted && step.generatorCompleted && step.validationOwed
217
- ? { validationOwed: true }
218
- : {}),
219
- ...(keepGeneratorCompleted &&
220
- step.generatorCompleted &&
221
- step.generatorMadeChanges !== undefined
243
+ ...(step.agenticWaived ? { agenticWaived: true } : {}),
244
+ ...(step.validationOwed ? { validationOwed: true } : {}),
245
+ ...(step.generatorMadeChanges !== undefined
222
246
  ? { generatorMadeChanges: step.generatorMadeChanges }
223
247
  : {}),
224
248
  };
225
249
  }
250
+ /**
251
+ * Forgets the generator run of `stepId`'s current attempt, unless a landed
252
+ * commit already carries its changes.
253
+ */
254
+ function discardGeneratorRun(state, stepId) {
255
+ if (coveringLandedEntries(state, stepId).length > 0)
256
+ return state;
257
+ return {
258
+ ...state,
259
+ steps: state.steps.map((step) => {
260
+ if (step.id !== stepId || !step.generatorCompleted)
261
+ return step;
262
+ const { generatorCompleted: _completed, generatorCompletedAtAttempt: _atAttempt, agenticWaived: _waived, validationOwed: _owed, generatorMadeChanges: _madeChanges, ...rest } = step;
263
+ return rest;
264
+ }),
265
+ };
266
+ }
226
267
  // A guarded transition whose observation was made against an earlier attempt
227
268
  // of the same step: the status recurred, so the observation says nothing about
228
269
  // the attempt on disk now.
@@ -286,6 +327,56 @@ function hasPendingCommitDebt(state) {
286
327
  function coveringLandedEntries(state, stepId) {
287
328
  return state.commits.filter((commit) => commit.kind === 'landed' && commit.stepIds.includes(stepId));
288
329
  }
330
+ function markCommitStarted(state, stepId) {
331
+ return {
332
+ ...state,
333
+ steps: state.steps.map((step) => step.id === stepId ? { ...step, commitStarted: true } : step),
334
+ };
335
+ }
336
+ function clearCommitStarted(state, stepId) {
337
+ return {
338
+ ...state,
339
+ steps: state.steps.map((step) => {
340
+ if (step.id !== stepId || !step.commitStarted)
341
+ return step;
342
+ const { commitStarted: _started, ...rest } = step;
343
+ return rest;
344
+ }),
345
+ };
346
+ }
347
+ /**
348
+ * True when a landed entry names the step, or a commit was started for it
349
+ * that no entry accounts for. Skipping the step would then report as not
350
+ * applied a migration whose commit is, or may be, in history.
351
+ */
352
+ function commitMayBeInHistory(state, step) {
353
+ return (coveringLandedEntries(state, step.id).length > 0 ||
354
+ step.commitStarted === true);
355
+ }
356
+ // Every ledger append. Entries are never removed or reordered: step receipts
357
+ // and resolution stamps index into the ledger. A failed entry says nothing
358
+ // about an earlier commit, so only a landed one clears the mark.
359
+ function appendCommit(state, entry) {
360
+ const accounted = entry.kind === 'landed'
361
+ ? entry.stepIds.reduce(clearCommitStarted, state)
362
+ : state;
363
+ return { ...accounted, commits: [...state.commits, entry] };
364
+ }
365
+ /**
366
+ * The entry a parent session recorded for this attempt's commit, or undefined
367
+ * without a receipt. A receipt past the ledger or naming another step is
368
+ * corrupt run state, so it throws.
369
+ */
370
+ function commitReceipt(state, step) {
371
+ const index = step.commitLedgerIndex;
372
+ if (index === undefined)
373
+ return undefined;
374
+ const entry = state.commits[index];
375
+ if (!entry || !entry.stepIds.includes(step.id)) {
376
+ throw new Error(`Step ${step.id} records its commit at ledger index ${index}, which ${entry ? 'does not name it' : 'does not exist'}.`);
377
+ }
378
+ return { index, entry };
379
+ }
289
380
  // The round with the highest index.
290
381
  function latestRound(state) {
291
382
  return state.rounds.reduce((newest, round) => (!newest || round.index > newest.index ? round : newest), undefined);
@@ -1,3 +1,4 @@
1
+ import type { MigrateOutputSink } from '../deferred-output';
1
2
  import type { MigrateStep } from './run-state';
2
3
  export declare function nowIso(): string;
3
4
  /**
@@ -28,7 +29,7 @@ export declare function depsHash(root: string): string | null;
28
29
  * started from are unknown rather than unchanged, and skipping there would
29
30
  * strand the change with nothing left to detect it.
30
31
  */
31
- export declare function installDepsChangedSinceDispense(root: string, dir: string, step: MigrateStep, skipInstall: boolean, rerunCommand?: string): Promise<void>;
32
+ export declare function installDepsChangedSinceDispense(root: string, dir: string, step: MigrateStep, skipInstall: boolean, rerunCommand?: string, sink?: MigrateOutputSink): Promise<void>;
32
33
  /**
33
34
  * Records what an install that just landed means for the run: the installing
34
35
  * step's dependency baseline moves to what is now on disk, and every step's
@@ -49,15 +49,15 @@ function depsHash(root) {
49
49
  * started from are unknown rather than unchanged, and skipping there would
50
50
  * strand the change with nothing left to detect it.
51
51
  */
52
- async function installDepsChangedSinceDispense(root, dir, step, skipInstall, rerunCommand) {
52
+ async function installDepsChangedSinceDispense(root, dir, step, skipInstall, rerunCommand, sink) {
53
53
  const current = depsHash(root);
54
54
  if (current !== null && current === step.depsHashAtDispense)
55
55
  return;
56
56
  if (skipInstall) {
57
- (0, execute_migration_1.logSkippedPostMigrationInstall)(root);
57
+ (0, execute_migration_1.logSkippedPostMigrationInstall)(root, sink);
58
58
  return;
59
59
  }
60
- await (0, execute_migration_1.runInstall)(root, 'post-migration', rerunCommand);
60
+ await (0, execute_migration_1.runInstall)(root, 'post-migration', rerunCommand, sink);
61
61
  recordInstallLanded(root, dir, step.id);
62
62
  }
63
63
  /**