UNPKG

nx

Version:

The core Nx plugin contains the core functionality of Nx like the project graph, nx commands and task orchestration.

1,201 lines 62.2 kB
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.runOrchestratorInit = runOrchestratorInit;
exports.runOrchestratorReconcile = runOrchestratorReconcile;
const child_process_1 = require("child_process");
const fs_1 = require("fs");
const path_1 = require("path");
const fileutils_1 = require("../../../utils/fileutils");
const git_utils_1 = require("../../../utils/git-utils");
const versions_1 = require("../../../utils/versions");
const handoff_1 = require("../agentic/handoff");
const handoff_gitignore_1 = require("../agentic/handoff-gitignore");
const types_1 = require("../agentic/types");
const migrate_commits_1 = require("../migrate-commits");
const migrate_analytics_1 = require("../migrate-analytics");
const sort_migrations_1 = require("../sort-migrations");
const run_id_1 = require("./run-id");
const run_state_1 = require("./run-state");
const state_lock_1 = require("./state-lock");
const state_machine_1 = require("./state-machine");
const migration_shape_1 = require("../migration-shape");
const util_1 = require("./util");
const text_1 = require("../text");
const agent_output_1 = require("./agent-output");
// The dark migrate orchestrator: drives a durable run one dispense at a time.
// An outer AI agent runs each dispensed command and re-invokes `nx migrate
// --run-id=<id>` to reconcile; there is no long-lived process.
const PLAN_SNAPSHOT_0 = 'plan-0.json';
// A running worker older than this may be hung; the still-running dispense
// escalates so the agent can verify or kill it.
const HANG_THRESHOLD_MS = 15 * 60 * 1000;
// Steps in these statuses are done; every other status needs a dispense.
const TERMINAL_STATUSES = new Set(['succeeded', 'skipped']);
const INIT_CONTINUE_HINT = 're-run the command, or unset NX_MIGRATE_ORCHESTRATOR to use the standard migrate flow.';
function continueRunHint(runId) {
    return `re-run the command to continue run '${runId}'.`;
}
// Refuses to proceed when the run's `git add -A` commits could sweep in the
// scratch under .nx/migrate-runs. Only commit-creating runs probe: without
// commits the worst case is git-status noise. Fails closed on an unusable
// probe: createCommits reaching the orchestrator means git was a repository
// at resolution time (resolveCreateCommits), so an unusable git here is an
// anomaly, and proceeding would risk absorbing run state into commits, where
// a later retry-clean `git reset --hard` could roll the tracked state back
// to a stale snapshot.
function assertScratchDirSafeForCommits(root, thenWhat) {
    refuseUnsafeScratchExposure((0, git_utils_1.getPathCommitExposure)(types_1.MIGRATE_RUNS_RELATIVE_DIR, root), thenWhat);
}
function refuseUnsafeScratchExposure(exposure, thenWhat) {
    switch (exposure) {
        case 'ignored':
            return;
        case 'tracked':
            throw new Error(`Files under ${types_1.MIGRATE_RUNS_RELATIVE_DIR} are committed to git, and ignore rules do not apply to tracked files, so migrate's commits would keep capturing this run's scratch state. ` +
                `Untrack them with \`git rm -r --cached ${types_1.MIGRATE_RUNS_RELATIVE_DIR}\`, commit that change, make sure .gitignore lists ${types_1.MIGRATE_RUNS_RELATIVE_DIR}, then ${thenWhat}`);
        case 'unignored':
            throw new Error(`${types_1.MIGRATE_RUNS_RELATIVE_DIR} is not ignored by git, so migrate's commits would capture this run's scratch state. ` +
                `Add a \`${types_1.MIGRATE_RUNS_RELATIVE_DIR}\` entry to .gitignore, then ${thenWhat}`);
        case 'unknown':
            throw new Error(`Could not verify with git that ${types_1.MIGRATE_RUNS_RELATIVE_DIR} is ignored, so migrate's commits could capture this run's scratch state. ` +
                `Make sure git is usable in this workspace, then ${thenWhat}`);
        default: {
            const exhaustive = exposure;
            throw new Error(`Unrecognized scratch exposure '${exhaustive}'.`);
        }
    }
}
async function runOrchestratorInit(input) {
    const { root, migrationsJson, createCommits, commitPrefix, skipInstall, installedNxVersion, } = input;
    const planHash = (0, run_id_1.computePlanHash)(migrationsJson);
    // An active run means a prior init already happened (e.g. it crashed before
    // the agent's first reconcile); starting a second run would compete with it.
    // Same plan: resume it. Different plan: refuse rather than guess which plan
    // the agent means. NewerRunStateFormatError propagates.
    const active = findActiveRunForPlan(root, planHash);
    // Dispensed commands interpolate migration ids verbatim, so every init
    // (fresh or resumed) validates the incoming plan's ids. After the mismatch
    // check: a plan that will be refused anyway should get the more actionable
    // mismatch error, not this one.
    const migrations = (migrationsJson.migrations ?? []);
    const sorted = (0, sort_migrations_1.sortMigrations)(migrations.slice(), {
        hoistHandoffGitignore: true,
    });
    for (const m of sorted) {
        const id = `${m.package}:${m.name}`;
        if (!run_state_1.SHELL_SAFE_VALUE.test(id)) {
            throw new Error(`The migration id '${id}' contains characters that are not shell-safe. Orchestrated runs require shell-safe migration ids.`);
        }
    }
    if (active) {
        resumeRun(root, active.runId, active.state);
        return;
    }
    const runId = (0, run_id_1.createRunId)();
    const dir = (0, run_state_1.runDir)(root, runId);
    // Probe before any git side effect: the checkpoint below is a `git add -A`
    // commit, so on a workspace where scratch is committable it would sweep in
    // prior runs' directories and manufacture the very tracked state the probe
    // refuses. Missing ignore coverage alone is not refused yet, because the
    // fallback below may still add the entry. 'ignored' also stands in for
    // "no hazard" when commits are off.
    const scratchExposure = createCommits
        ? (0, git_utils_1.getPathCommitExposure)(types_1.MIGRATE_RUNS_RELATIVE_DIR, root)
        : 'ignored';
    if (scratchExposure !== 'unignored') {
        refuseUnsafeScratchExposure(scratchExposure, INIT_CONTINUE_HINT);
    }
    // Applied before the checkpoint so the entry (when it can be added) already
    // covers older scratch by the time the checkpoint's `git add -A` runs; the
    // fallback's standalone commit is suppressed because that checkpoint
    // carries the edit. Unlike the classic loop, a planned ignore migration
    // can't be deferred to: the run dir is created below, before that
    // migration runs.
    await (0, handoff_gitignore_1.applyAgenticHandoffGitignoreFallback)({
        migrations: sorted,
        installedNxVersion,
        effectiveCreateCommits: createCommits,
        commitPrefix,
        root,
        applyWhenPlanned: true,
        commitStandalone: false,
    });
    if (scratchExposure === 'unignored') {
        // The fallback was the workspace's last chance at ignore coverage;
        // refuse when it could not add the entry (v23+ conscious removal, no
        // .gitignore, Lerna without nx.json).
        refuseUnsafeScratchExposure((0, git_utils_1.getPathCommitExposure)(types_1.MIGRATE_RUNS_RELATIVE_DIR, root), INIT_CONTINUE_HINT);
    }
    // Checkpoint pre-existing working-tree state BEFORE the run dir exists, so
    // the checkpoint's `git add -A` can't track this run's scratch and a clean
    // tree stays uncommitted (writing run.json would otherwise dirty it and fire
    // a spurious checkpoint). A crash between here and createRun leaves the
    // committed changes orphaned but never lost; the next init re-checkpoints a
    // now-clean tree as a no-op.
    const checkpoint = createCommits ? checkpointEntry(root, commitPrefix) : null;
    // The preflight checkpoint swallows its own failures, so the tree itself is
    // the only reliable signal: anything still uncommitted here predates every
    // step's gitRefBefore and rules out clean retries for the whole run. A
    // failed probe counts as dirty: mistaking it for clean would let a later
    // retry-clean reset destroy the very work this flag exists to protect.
    const checkpointFailed = createCommits && (0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean';
    const state = {
        formatVersion: run_state_1.CURRENT_RUN_STATE_FORMAT_VERSION,
        runId,
        createdAt: (0, util_1.nowIso)(),
        nxVersion: versions_1.nxVersion,
        status: 'active',
        createCommits,
        commitPrefix,
        ...(skipInstall ? { skipInstall: true } : {}),
        rounds: [
            {
                index: 0,
                planHash,
                planSnapshot: PLAN_SNAPSHOT_0,
            },
        ],
        steps: buildSteps(sorted),
        commits: checkpoint ? [checkpoint] : [],
        ...(checkpointFailed ? { checkpointFailed: true } : {}),
        analytics: { startEmitted: false, completeEmitted: false },
    };
    // The check/create boundary runs under the creation lock: without it, two
    // concurrent inits could both observe no active run above and create
    // competing runs against the same workspace. The git side effects above
    // stay outside the lock (locked sections must remain synchronous); a losing
    // init's checkpoint commit is the same orphan shape as the crash window
    // above, and the fallback's .gitignore edit is idempotent.
    const winner = (0, state_lock_1.withRunCreationLock)(root, () => {
        const nowActive = findActiveRunForPlan(root, planHash);
        if (nowActive) {
            return nowActive;
        }
        // The snapshot must exist before run.json makes the run discoverable: a
        // crash in between must not leave an active run without its plan.
        (0, fs_1.mkdirSync)(dir, { recursive: true });
        (0, fileutils_1.writeJsonFile)((0, path_1.join)(dir, PLAN_SNAPSHOT_0), migrationsJson);
        (0, run_state_1.createRun)(root, state);
        return null;
    });
    if (winner) {
        resumeRun(root, winner.runId, winner.state);
        return;
    }
    finishInit(root, dir, runId, state);
}
// Reads the newest active run, refusing one whose plan differs from the
// incoming plan; null when no run is active. Uninterpretable run dirs refuse
// a fresh start (one of them could be an active run this init would compete
// with) but only warn when a healthy active run is being resumed.
// NewerRunStateFormatError propagates from the read.
function findActiveRunForPlan(root, planHash) {
    const { active, uninterpretable } = (0, run_state_1.findActiveRun)(root);
    if (uninterpretable.length > 0) {
        const noun = uninterpretable.length === 1 ? 'directory' : 'directories';
        // A directory name is whatever is on disk and a reason quotes what it
        // found, so neither can be trusted to stay on the line it is put on.
        // Sanitized here rather than left to the gateway: these same lines are
        // joined into the throw below, which leaves through handleErrors.
        const details = uninterpretable.map((u) => `${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${(0, text_1.singleLine)(u.dirName)}: ${(0, text_1.singleLine)(u.reason)}`);
        if (!active) {
            throw new Error([
                `Whether a migrate run is still active could not be determined; starting a new run could re-apply migrations an unfinished run already applied.`,
                ...details,
                `Fix or remove the listed ${noun} (removing a run directory abandons that run; migrations it already applied remain applied), then re-run the command.`,
            ].join('\n'));
        }
        (0, agent_output_1.warnToAgent)({
            title: `Ignoring ${uninterpretable.length} migrate run ${noun} that could not be read.`,
            bodyLines: details,
        });
    }
    if (active && (0, state_machine_1.latestRound)(active.state)?.planHash !== planHash) {
        throw new Error(`A migrate run '${active.runId}' is already active with a different plan. ` +
            `Finish it first by running \`${reconcileCommand(root, active.runId)}\`, ` +
            `or remove ${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${active.runId} to abandon it.`);
    }
    return active;
}
// Shared resume tail for an active run found before or under the creation
// lock, so the two discovery points cannot drift apart.
function resumeRun(root, runId, state) {
    const dir = (0, run_state_1.runDir)(root, runId);
    // Ignore/index state can change while a durable run is paused (a checkout,
    // a .gitignore edit, a forced add). Probe before the checkpoint retry:
    // ensureCheckpoint is a `git add -A` commit, so on a workspace that became
    // unsafe it would absorb the run's own scratch.
    if (state.createCommits) {
        assertScratchDirSafeForCommits(root, continueRunHint(runId));
    }
    // A run flagged checkpointFailed gets one more chance to capture the
    // pre-existing tree state before its first migration commit absorbs it.
    const resumed = ensureCheckpoint(root, dir, state);
    announceResume(runId, resumed);
    finishInit(root, dir, runId, resumed);
}
// The dispense that follows says nothing about the steps already behind it, so
// a resumed run is otherwise indistinguishable from a fresh one that happens
// to start partway down the plan.
function announceResume(runId, state) {
    const applied = state.steps.filter((s) => s.status === 'succeeded').length;
    const skipped = state.steps.filter((s) => s.status === 'skipped').length;
    const remaining = state.steps.length - applied - skipped;
    // A subset of `remaining`, called out separately: a run is resumed most often
    // because one of these is waiting on a decision, and the count alone would
    // read as work that has not been reached yet.
    const stalled = state.steps.filter((s) => s.status === 'failed' || s.status === 'died').length;
    (0, agent_output_1.logToAgent)({
        title: `nx migrate: resuming run ${runId}`,
        bodyLines: [
            `  started: ${state.createdAt}`,
            `  progress: ${applied} applied, ${skipped} skipped, ${remaining} remaining${stalled > 0 ? ` (${stalled} awaiting a decision)` : ''}`,
        ],
    });
}
// Resume-only checkpoint retry, gated on checkpointFailed: a fresh init always
// evaluates the checkpoint before the run dir exists, so an unflagged run
// without a checkpoint entry started from a clean tree and there is nothing to
// capture (retrying there would commit the run's own scratch instead). Skipped
// once any migration step has advanced (a late checkpoint would absorb an
// already-run migration's changes).
function ensureCheckpoint(root, dir, state) {
    if (!state.createCommits || !state.checkpointFailed)
        return state;
    if (state.steps.some((s) => s.status !== 'pending'))
        return state;
    // The checkpoint commit is a git side effect, so it runs before the lock; the
    // ledger append and flag clear then apply to the fresh on-disk state.
    const checkpoint = checkpointEntry(root, state.commitPrefix);
    // The retried checkpoint captured everything, so clean retries are safe
    // again. Only a verified-clean tree clears the flag: a failed probe proves
    // nothing was captured.
    const cleared = (0, git_utils_1.getWorkingTreeStatus)(root) === 'clean';
    if (!checkpoint && !cleared)
        return state;
    return (0, state_lock_1.updateRunState)(dir, (fresh) => {
        // Re-check both guards on the fresh state: a concurrent reconcile may have
        // cleared the flag or advanced a step while the commit ran. Skipping here
        // can leave that commit unledgered, the documented crash-window shape.
        if (!fresh.checkpointFailed ||
            fresh.steps.some((s) => s.status !== 'pending')) {
            return null;
        }
        const next = checkpoint ? appendCommit(fresh, checkpoint) : fresh;
        return cleared ? { ...next, checkpointFailed: false } : next;
    });
}
// Commits pre-existing working-tree state so the first migration's commit can't
// absorb it, returning the ledger entry only when a commit verifiably landed.
// A clean tree is a no-op. Failure detection is the caller's job: the commit
// helper swallows failures, so callers re-check the tree afterwards.
function checkpointEntry(root, commitPrefix) {
    // Skip only on a verified-clean tree; on a failed probe the commit attempt
    // below re-probes and may succeed once the transient failure passes.
    if ((0, git_utils_1.getWorkingTreeStatus)(root) === 'clean') {
        return null;
    }
    const before = (0, git_utils_1.getLatestCommitSha)(root);
    (0, migrate_commits_1.commitCheckpointBeforeMigrations)(root, commitPrefix);
    const after = (0, git_utils_1.getLatestCommitSha)(root);
    if (after && after !== before) {
        return { kind: 'checkpoint', sha: after, stepIds: [] };
    }
    return null;
}
// Shared tail of a fresh and a resumed init: emit the init analytics once per
// run (watermark-guarded) and emit the current dispense.
function finishInit(root, dir, runId, state) {
    let current = state;
    if (!current.analytics.startEmitted) {
        // Claim the watermark on the fresh state first: of two concurrent inits
        // exactly one flips it, and only that one reports.
        let claimed = false;
        current = (0, state_lock_1.updateRunState)(dir, (fresh) => {
            if (fresh.analytics.startEmitted)
                return null;
            claimed = true;
            return {
                ...fresh,
                analytics: { ...fresh.analytics, startEmitted: true },
            };
        });
        if (claimed) {
            (0, migrate_analytics_1.reportMigrateOrchestratorInit)({
                migrationCount: current.steps.length,
                createCommits: current.createCommits,
            });
        }
    }
    advanceAndDispense(root, dir, runId, current);
}
async function runOrchestratorReconcile(input) {
    const { root, runId, stepAction } = input;
    if (!run_id_1.RUN_ID_SAFE.test(runId)) {
        throw new Error(`Invalid run id '${runId}'.`);
    }
    const dir = (0, run_state_1.runDir)(root, runId);
    if (!(0, run_state_1.hasRunState)(dir)) {
        // No remediation beyond the id: starting a run is a separate, gated entry
        // point, so pointing at it here would hand most callers a command that
        // does something else entirely.
        throw new Error(`No migrate run '${runId}' was found under ${types_1.MIGRATE_RUNS_RELATIVE_DIR}.`);
    }
    // Version refusal (NewerRunStateFormatError) propagates.
    let state = (0, run_state_1.readRunState)(dir);
    // Ignore/index state can change while a durable run is paused (a checkout,
    // a .gitignore edit, a forced add); re-verify before foldHandoffs, which
    // can itself commit a settled prompt step.
    if (state.createCommits) {
        assertScratchDirSafeForCommits(root, continueRunHint(runId));
    }
    // (a) fold handoffs into prompt outcomes (committing completed ones).
    state = await foldHandoffs(root, dir, state);
    // (b) reclassify running steps whose worker process is gone.
    state = detectDeaths(dir, state);
    // (c) apply the decision relay to the single failed/died step.
    if (stepAction) {
        const result = applyReconcileStepAction(root, state, stepAction);
        if (result.kind === 'error') {
            emitError(root, runId, result.reason);
            return; // state untouched
        }
        const target = result.targetStep;
        // An adopted death commits its working tree; that git side effect runs
        // before the lock (locked sections must stay synchronous), then the
        // transition and its ledger entry land in one fresh-state write so a
        // crash can't leave the step succeeded unrecorded. As with a fold, that
        // window is wide, and a rejected reapply after the commit landed is
        // equivalent to commitForStep's crash-refold window: the commit stays in
        // history, the ledger misses it, and the rejection names it below so the
        // agent re-decides against the moved HEAD.
        // Without commits the adopted tree is still this migration's result, and
        // it can carry package.json edits the dead worker never installed; the
        // install has to run here or the next dispense captures the modified
        // dependencies as its own baseline and nothing is left to detect them.
        // A skip leaves the tree as it stands too, so it owes the same install
        // and, with commits on, the same debt record as a prompt that did not
        // complete. Retries owe nothing: the rearmed attempt reconciles itself.
        const { entry, installFailed } = stepAction === 'adopt'
            ? state.createCommits
                ? await commitForStep(root, dir, state, target)
                : {
                    entry: null,
                    installFailed: await installFailedForStep(root, dir, state, target),
                }
            : stepAction === 'skip'
                ? await retainedTreeSideEffects(root, dir, state, target)
                : { entry: null, installFailed: false };
        // A rearm starts a fresh attempt; drop the stale handoff before the rearm
        // is persisted so a crash in between can't refold the old outcome into the
        // new attempt. Losing the handoff without the rearm is safe: the step is
        // still failed/died and the agent re-issues the action.
        if (stepAction === 'retry' || stepAction === 'retry-clean') {
            removeHandoff(dir, target.migrationId);
        }
        // Re-validate the transition against the fresh disk state: if a concurrent
        // reconcile already resolved this step, surface the state machine's own
        // rejection through the same emitError path rather than writing over it.
        // The bound attempt keeps the acceptance checks above honest: they ran
        // against `state`, and a step that was re-armed and failed again in
        // between is a different attempt those checks never saw.
        let freshRejection;
        const written = (0, state_lock_1.updateRunState)(dir, (fresh) => {
            const reapplied = (0, state_machine_1.applyStepEvent)(fresh, {
                type: 'stepAction',
                stepId: target.id,
                action: stepAction,
                attempt: target.attempt,
            });
            if (reapplied.kind === 'error') {
                freshRejection = reapplied.reason;
                return null;
            }
            const next = installFailed
                ? (0, state_machine_1.markInstallFailed)(reapplied.state, target.id)
                : reapplied.state;
            return entry ? appendCommit(next, entry) : next;
        });
        if (freshRejection) {
            emitError(root, runId, entry?.kind === 'landed' && entry.sha
                ? `${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.`
                : freshRejection);
            return;
        }
        state = written;
    }
    // (d) choose and emit the next dispense.
    advanceAndDispense(root, dir, runId, state);
}
function buildSteps(sortedMigrations) {
    return sortedMigrations.map((m, index) => ({
        id: `step-${index + 1}`,
        roundIndex: 0,
        migrationId: `${m.package}:${m.name}`,
        status: 'pending',
        attempt: 1,
        dispenseCount: 0,
        hasGenerator: !(0, migration_shape_1.isPromptOnlyMigration)(m),
    }));
}
// --- reconcile phases -------------------------------------------------------
async function foldHandoffs(root, dir, state) {
    let current = state;
    // Step ids are fixed for the life of a run, so the ids come from the caller's
    // snapshot while every status read comes from `current`: each iteration can
    // have advanced the run.
    for (const { id } of state.steps) {
        const step = current.steps.find((s) => s.id === id);
        if (step.status !== 'awaiting-prompt-outcome')
            continue;
        const result = (0, handoff_1.readHandoffWithReason)(handoffPath(dir, (0, state_machine_1.splitMigrationId)(step.migrationId)));
        if (!result.ok)
            continue; // still awaiting; the dispense asks to settle it
        const promptOutcome = handoffToPromptOutcome(result.handoff);
        // The commit and the install are side effects, so they happen before the
        // fold, outside the lock; the transition and its ledger entry then land in
        // one fresh-state write. A crash cannot leave the step settled with its
        // commit forgotten.
        const { entry, installFailed } = await foldLedgerEntry(root, dir, current, step, promptOutcome);
        // The fold re-validates against fresh disk state, on the attempt this
        // handoff was read for. That window is wide (a git commit plus a package
        // install), and 'awaiting-prompt-outcome' recurs, so without the attempt
        // check a concurrent reconcile's retry could take this outcome as its own.
        // A dropped fold is equivalent to the crash-refold window: the commit
        // landed but the ledger misses it.
        let folded = false;
        current = (0, state_lock_1.updateRunState)(dir, (fresh) => {
            const applied = (0, state_machine_1.applyStepEvent)(fresh, {
                type: 'foldPromptOutcome',
                stepId: step.id,
                attempt: step.attempt,
                promptOutcome,
            });
            if (applied.kind === 'error')
                return null;
            folded = true;
            const next = installFailed
                ? (0, state_machine_1.markInstallFailed)(applied.state, step.id)
                : applied.state;
            return entry ? appendCommit(next, entry) : next;
        });
        // Only the handoff this fold consumed is removed. A rejected fold leaves
        // it in place: it belongs to whichever attempt is on disk now, and that
        // attempt's own reconcile still has to read it.
        if (folded)
            removeHandoff(dir, step.migrationId);
    }
    return current;
}
// What a folded prompt outcome owes the run state. A completed prompt with
// commits on is committed and its result classified as usual, the install
// riding in on the commit path. Every other outcome still reconciles the
// dependencies itself: the prompt (or the generator half before it) can have
// edited package.json whether or not it completed, and skipping the install
// there strands that change with nothing left to detect it, since the next
// step's dispense captures the already-modified state as its own baseline.
//
// A failed or skipped prompt is not committed, but it can still have left
// edits behind, so a tree that is not verifiably clean records debt: the
// changes then read as pending for a later commit to absorb, and the
// completion warning knows about them. A failed probe counts as dirty,
// matching every other retry-safety decision in this file; debt a later landed
// entry covers costs nothing.
async function foldLedgerEntry(root, dir, state, step, promptOutcome) {
    if (promptOutcome.status === 'completed') {
        if (state.createCommits) {
            return commitForStep(root, dir, state, step);
        }
        return {
            entry: null,
            installFailed: await installFailedForStep(root, dir, state, step),
        };
    }
    return retainedTreeSideEffects(root, dir, state, step);
}
// Shared by prompts that did not complete and by skipped failed or died steps:
// the tree is kept as it stands, so the step still owes the install of any
// dependency edits it left and, with commits on, a debt record when the tree
// is not verifiably clean (see foldLedgerEntry for why).
async function retainedTreeSideEffects(root, dir, state, step) {
    const installFailed = await installFailedForStep(root, dir, state, step);
    const entry = state.createCommits && (0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean'
        ? { kind: 'failed', stepIds: [step.id] }
        : null;
    return { entry, installFailed };
}
// Installs the dependency changes a step's tree may carry when no commit path
// will do it (the fold of a prompt outcome that lands no commit, or a
// non-commit adopt), returning whether the install failed. A failure is
// recorded rather than thrown: reconcile still owes the agent a dispense, and
// a warning alone dies with this process.
async function installFailedForStep(root, dir, state, step) {
    try {
        await (0, util_1.installDepsChangedSinceDispense)(root, dir, step, state.skipInstall === true, reconcileCommand(root, state.runId));
        return false;
    }
    catch (e) {
        (0, agent_output_1.warnToAgent)({
            title: `The dependencies changed by ${step.migrationId} could not be installed (${(0, util_1.summarizeError)(e)}).`,
            bodyLines: [`Run \`${(0, util_1.pmInstallCommand)(root)}\` before continuing.`],
        });
        return true;
    }
}
// A failed handoff fails the prompt; a success handoff completes it, unless it
// marks the prompt not applicable via `extras.outcome === 'skipped'`.
function handoffToPromptOutcome(handoff) {
    if (handoff.status === 'failed') {
        return { status: 'failed', summary: handoff.summary };
    }
    if (handoff.extras && handoff.extras['outcome'] === 'skipped') {
        return { status: 'skipped', summary: handoff.summary };
    }
    return { status: 'completed', summary: handoff.summary };
}
function detectDeaths(dir, state) {
    let current = state;
    // As in foldHandoffs: ids from the caller's snapshot, statuses from
    // `current`, so an earlier iteration's write is visible to the next.
    for (const { id } of state.steps) {
        const step = current.steps.find((s) => s.id === id);
        if (step.status !== 'running')
            continue;
        if (step.pid === undefined || (0, util_1.isPidAlive)(step.pid))
            continue;
        // markDied re-validates against fresh disk state, on the attempt and pid
        // this observation was made for: if the worker finished between the
        // snapshot and the write, or a retry already put a live worker on the
        // step, the transition is rejected and the step is left as recorded.
        current = (0, state_lock_1.updateRunState)(dir, (fresh) => {
            const applied = (0, state_machine_1.applyStepEvent)(fresh, {
                type: 'markDied',
                stepId: step.id,
                attempt: step.attempt,
            });
            return applied.kind === 'ok' ? applied.state : null;
        });
    }
    return current;
}
function applyReconcileStepAction(root, state, action) {
    const candidates = state.steps.filter((s) => s.status === 'failed' || s.status === 'died');
    if (candidates.length === 0) {
        return {
            kind: 'error',
            reason: `No step is failed or died, so there is nothing for --step-action=${action} to target.`,
        };
    }
    if (candidates.length > 1) {
        return {
            kind: 'error',
            reason: `More than one step is failed or died; --step-action targets exactly one. Resolve them one at a time.`,
        };
    }
    const step = candidates[0];
    // A retry-clean the dispense would not have offered must be refused here
    // too, or a hand-crafted reconcile could reset a tree with no restore point
    // and destroy prior steps' work.
    if (action === 'retry-clean') {
        const head = (0, git_utils_1.getLatestCommitSha)(root);
        const fallback = step.status === 'died'
            ? `Use 'adopt' or 'skip' instead.`
            : `Use 'retry' or 'skip' instead.`;
        if (!canOfferCleanRetry(root, state, step, head)) {
            return {
                kind: 'error',
                reason: `Cannot apply action 'retry-clean' to step '${step.id}': ${cleanRetryUnavailableReason(root, state, step, head)} ${fallback}`,
            };
        }
        // The reset itself is delegated to the caller, and every check above
        // passes identically whether or not it ran, so only the tree can say
        // whether the reset actually happened. Anything but a verified-clean tree
        // is refused: accepting would drop the generator marker and rerun the
        // generator over the previous attempt's output.
        if ((0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean') {
            return {
                kind: 'error',
                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}`,
            };
        }
    }
    // A failed generator can have written to the tree before throwing, and a
    // plain retry reruns it, so a pre-marker retry is accepted only when git
    // can see nothing of the failed attempt in the tree. The state machine is
    // pure and cannot read the tree, which is why the gate lives here.
    if (action === 'retry' &&
        step.status === 'failed' &&
        generatorPending(step)) {
        const safety = assessPreMarkerRetry(root, step);
        if (safety.kind === 'unsafe') {
            return {
                kind: 'error',
                reason: `Cannot apply action 'retry' to step '${step.id}': ${safety.reason} Use 'retry-clean' where offered, or 'skip'.`,
            };
        }
        if (safety.kind === 'warned') {
            (0, agent_output_1.warnToAgent)({
                title: `Retrying ${step.migrationId} without verification`,
                bodyLines: [safety.warning],
            });
        }
    }
    const applied = (0, state_machine_1.applyStepEvent)(state, {
        type: 'stepAction',
        stepId: step.id,
        action,
        attempt: step.attempt,
    });
    if (applied.kind === 'error') {
        return applied;
    }
    return { kind: 'ok', state: applied.state, targetStep: step };
}
// Commits the working tree left by a folded prompt outcome or an adopted
// death, returning the ledger entry the caller persists together with the
// step transition (null when there was nothing to commit). The worker's
// recorded-commit path classifies through the same commitResultToLedgerEntry.
//
// Remaining narrow window: a crash after the git commit but before the state
// write refolds on the next reconcile, where the commit attempt sees a clean
// tree ('no-changes') and the ledger simply misses that landed entry; the
// changes themselves are never lost. A lost landed entry can also strand the
// failed entries it had absorbed, which is why completion double-checks the
// tree before warning about debt.
async function commitForStep(root, dir, state, step) {
    const { name } = (0, state_machine_1.splitMigrationId)(step.migrationId);
    const absorbedStepIds = (0, state_machine_1.uncoveredFailedStepIds)(state).filter((id) => id !== step.id);
    let result;
    try {
        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));
    }
    catch (e) {
        // The dependency install is the only thing that throws here: the commit
        // attempt itself reports through result.status, and the install's own
        // bookkeeping never throws. Both consequences are recorded, and neither
        // aborts reconcile so the next dispense still fires. The debt cannot stand
        // in for the install failure: a later step's commit absorbs this diff and
        // lands an entry naming this step, which clears the debt while the
        // dependencies are still missing.
        (0, util_1.warnCommitFailed)(name, e);
        return {
            entry: { kind: 'failed', stepIds: [step.id] },
            installFailed: true,
        };
    }
    if (result.status === 'failed') {
        (0, util_1.warnCommitFailed)(name);
    }
    return {
        entry: (0, state_machine_1.commitResultToLedgerEntry)(result, step.id, absorbedStepIds),
        installFailed: false,
    };
}
// --- dispense ---------------------------------------------------------------
function advanceAndDispense(root, dir, runId, state) {
    const step = firstActionableStep(state);
    if (!step) {
        completeRun(root, dir, runId, state);
        return;
    }
    switch (step.status) {
        case 'pending':
            dispenseNextStep(root, dir, runId, state, step);
            break;
        case 'dispensed':
            // Re-entry before the worker advanced the step; re-emit its command.
            emitNextStep(root, runId, step);
            break;
        case 'failed':
            emitRetryFailed(root, runId, state, step);
            break;
        case 'died':
            emitDied(root, runId, state, step);
            break;
        case 'running':
            emitStillRunning(root, runId, step);
            break;
        case 'awaiting-prompt-outcome':
            emitAwaitPrompt(root, dir, runId, step);
            break;
        case 'succeeded':
        case 'skipped':
            // firstActionableStep already excludes these via TERMINAL_STATUSES;
            // landing here means an already-terminal step slipped through
            // unclassified rather than being left to stall the run silently.
            throw new Error(`Orchestrator could not dispense step '${step.id}': step is already ${step.status}.`);
        default: {
            // A new MigrateStepStatus member with no case above fails typecheck
            // here until it is classified.
            const exhaustive = step.status;
            throw new Error(`Orchestrator could not dispense step '${step.id}': unrecognized status '${exhaustive}'.`);
        }
    }
}
function firstActionableStep(state) {
    return state.steps.find((s) => !TERMINAL_STATUSES.has(s.status));
}
function dispenseNextStep(root, dir, runId, state, step) {
    // Read the pre-migration baselines (git and package.json reads) before the
    // lock; the dispense transition and the baselines then apply to the fresh
    // state in one write.
    const baselines = {
        gitRefBefore: (0, git_utils_1.getLatestCommitSha)(root) ?? undefined,
        treeCleanAtDispense: (0, git_utils_1.getWorkingTreeStatus)(root) === 'clean',
        depsHashAtDispense: (0, util_1.depsHash)(root),
    };
    let advancedElsewhere = false;
    const current = (0, state_lock_1.updateRunState)(dir, (fresh) => {
        // A concurrent init or reconcile may have dispensed (or further advanced)
        // this step since the caller's read; reclassify against the fresh state
        // below instead of failing the duplicate transition.
        if (fresh.steps.find((s) => s.id === step.id)?.status !== 'pending') {
            advancedElsewhere = true;
            return null;
        }
        const dispensed = applyEventOrThrow(fresh, {
            type: 'dispense',
            stepId: step.id,
        });
        return setDispenseBaselines(dispensed, step.id, baselines);
    });
    if (advancedElsewhere) {
        // Terminates: step statuses only advance, so each re-entry observes
        // strictly later state and lands in a non-pending branch of the dispatch.
        advanceAndDispense(root, dir, runId, current);
        return;
    }
    emitNextStep(root, runId, current.steps.find((s) => s.id === step.id));
}
function emitNextStep(root, runId, step) {
    const migrationId = step.migrationId;
    emit(runId, step, 'next-step', {
        command: workerCommand(root, migrationId, runId),
        next: reconcileCommand(root, runId),
        instructionLines: [
            `Apply migration ${migrationId} by running the command below, then run the "next" command to record the outcome and get the next step.`,
        ],
    });
}
function emitRetryFailed(root, runId, state, step) {
    const migrationId = step.migrationId;
    // A worker failure records its summary on the outcome; a prompt the agent
    // reported as failed carries the agent's own reason on the prompt outcome.
    const summary = step.outcome?.summary ?? step.promptOutcome?.summary;
    const head = (0, git_utils_1.getLatestCommitSha)(root);
    const tree = dirtyTreeSummary(root);
    const cleanRetry = canOfferCleanRetry(root, state, step, head);
    // A failure recorded before the generator marker can still have written to
    // the tree (a direct fs or exec side effect, or a crash mid-flush); a
    // marker means only the install and commit are left, so plain retry is
    // safe outright. So is retrying a step with no generator half to rerun.
    const pending = generatorPending(step);
    const retrySafety = pending
        ? assessPreMarkerRetry(root, step)
        : { kind: 'safe' };
    const lines = [
        `Migration ${migrationId} failed${summary ? `: ${summary}` : ''}.`,
        `  started from: ${step.gitRefBefore ?? '(unknown)'}`,
        `  current HEAD: ${head ?? '(unknown)'}`,
        `  working tree: ${tree === null ? '(unknown)' : tree ? `\n${tree}` : '(clean)'}`,
        ``,
        `Decide how to proceed and re-run reconcile with one of:`,
        retryOptionLine(retrySafety, reconcileCommand(root, runId, 'retry')),
    ];
    if (cleanRetry) {
        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')}`);
    }
    lines.push(`  skip:  ${reconcileCommand(root, runId, 'skip')}`);
    if (pending) {
        lines.push(UNVERIFIABLE_WRITES_LINE);
    }
    // A step whose generator may still run gets no `next`, whichever retry the
    // checks above would accept: git can vouch for the tracked tree only, and
    // an agent that follows `next` blindly must not rerun a generator over
    // writes nothing here could see. Choosing a retry has to be explicit.
    emit(runId, step, 'retry-failed', {
        ...(pending ? {} : { next: reconcileCommand(root, runId, 'retry') }),
        instructionLines: lines,
    });
}
// Whether the step's generator half may still have to run: it exists and no
// attempt has recorded running it. Only then can a retry apply a generator
// twice, so only then is a continuation withheld from `next`. A step with no
// generator (prompt-only) is retried by re-prompting the agent over the tree
// it already knows, which is the designed recovery; a step recorded before the
// kind was persisted counts as having one.
function generatorPending(step) {
    return step.generatorCompleted !== true && step.hasGenerator !== false;
}
// Appended to the failed and died dispenses of a step whose generator may rerun.
const UNVERIFIABLE_WRITES_LINE = `None of these can be verified against writes git does not see (ignored paths, files outside the repository); if this migration writes there, inspect that state before choosing.`;
function retryOptionLine(safety, command) {
    switch (safety.kind) {
        case 'safe':
            return `  retry: re-run over the current tree: ${command}`;
        case 'warned':
            return `  retry: re-run over the current tree; without git nothing can verify what the failed attempt left, so inspect the tree first: ${command}`;
        case 'unsafe':
            return `  retry: re-run over the current tree; refused until the working tree is clean and HEAD is at the started-from ref: ${command}`;
        default: {
            const exhaustive = safety;
            return exhaustive;
        }
    }
}
// A clean retry resets the tree to the step's captured pre-migration ref.
// That is only safe when every prior diff is already committed: without
// per-migration commits the ref is the run's starting commit (the reset would
// wipe all prior steps' uncommitted work); a failed init checkpoint or a
// pending step commit means the ref predates diffs the reset would also
// destroy; without a captured ref there is nothing to reset to; edits already
// in the tree when this step was dispensed (the user's own, or an earlier
// step's the checkpoint never saw) are not represented by the ref either; and
// HEAD anywhere other than the ref means something was committed since the
// step was dispensed that the reset would discard, whether that is this step's
// own commit (recorded, or made in the window before the worker died writing
// its ledger entry) or one the user made alongside the run.
// Cleanliness and position both have to say so explicitly: a failed tree probe
// records dirty, a run created before that field existed carries nothing to
// check, and an unreadable HEAD is no ref at all, so none of the three can be
// read as a restore point that exists.
function canOfferCleanRetry(root, state, step, head) {
    return (state.createCommits &&
        !state.checkpointFailed &&
        !(0, state_machine_1.hasPendingCommitDebt)(state) &&
        !!step.gitRefBefore &&
        head === step.gitRefBefore &&
        step.treeCleanAtDispense === true &&
        !endangeredLandedEntry(root, state, step));
}
// The last landed ledger entry covering the step whose commit a reset to the
// step's gitRefBefore would discard. Entries from earlier attempts predate the
// ref re-captured at re-dispense and survive the reset; only a commit that is
// not an ancestor of the ref (or cannot be verified as one) is endangered.
function endangeredLandedEntry(root, state, step) {
    let endangered = null;
    for (const entry of (0, state_machine_1.coveringLandedEntries)(state, step.id)) {
        if (!entry.sha ||
            !step.gitRefBefore ||
            !(0, git_utils_1.isAncestorCommit)(entry.sha, step.gitRefBefore, root)) {
            endangered = entry;
        }
    }
    return endangered;
}
// Explains why retry-clean is withheld for a failed or died step; feeds the
// death dispense and a rejected --step-action=retry-clean.
function cleanRetryUnavailableReason(root, state, step, head) {
    const endangered = endangeredLandedEntry(root, state, step);
    if (endangered) {
        return endangered.sha
            ? `this migration's changes already landed in commit ${endangered.sha}, which a reset would discard.`
            : `this migration's changes already landed in a commit, which a reset would discard.`;
    }
    if (step.gitRefBefore && head !== step.gitRefBefore) {
        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.`;
    }
    return `resetting the tree could discard uncommitted work that no restore point accounts for.`;
}
function assessPreMarkerRetry(root, step) {
    const repo = (0, git_utils_1.getGitRepositoryStatus)(root);
    if (repo === 'not-git') {
        return {
            kind: 'warned',
            warning: `The workspace is not a git repository, so nothing can verify whether the failed attempt left partial changes in the tree. The retry reruns the generator over whatever is there; confirm the tree yourself first.`,
        };
    }
    if (repo === 'unknown') {
        return {
            kind: 'unsafe',
            reason: `the git repository state could not be determined, so nothing can verify whether the failed attempt left changes in the tree.`,
        };
    }
    const head = (0, git_utils_1.getLatestCommitSha)(root);
    if (!step.gitRefBefore || head !== step.gitRefBefore) {
        return {
            kind: 'unsafe',
            reason: `HEAD is at ${head ?? '(unreadable)'} rather than the ${step.gitRefBefore ?? '(unrecorded)'} this migration started from, so the failed attempt's changes may already be committed and rerunning the generator could apply them twice.`,
        };
    }
    if ((0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean') {
        return {
            kind: 'unsafe',
            reason: `the working tree is not verifiably clean, and the failed attempt may have written to it before failing; rerunning the generator over those changes could apply them twice.`,
        };
    }
    return { kind: 'safe' };
}
function emitDied(root, runId, state, step) {
    const migrationId = step.migrationId;
    const ref = step.gitRefBefore;
    const head = (0, git_utils_1.getLatestCommitSha)(root);
    const tree = dirtyTreeSummary(root);
    const cleanRetry = canOfferCleanRetry(root, state, step, head);
    // The generator half is recorded (or the step never had one), so a retry
    // that keeps the tree as it stands has the rest of the step left to run: a
    // prompt, or the install and commit its worker never reached.
    const resume = !generatorPending(step);
    const lines = [
        `The worker for ${migrationId} died; its process is gone.`,
        `  started from: ${ref ?? '(unknown)'}`,
        `  current HEAD: ${head ?? '(unknown)'}`,
        `  working tree: ${tree === null ? '(unknown)' : tree ? `\n${tree}` : '(clean)'}`,
        ``,
    ];
    const options = [];
    if (resume) {
        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')}`);
    }
    if (cleanRetry) {
        options.push(
        // Two commands rather than one `&&` chain: the agent runs these in its
        // own shell, and not every shell joins statements that way.
        `  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')}`);
    }
    else {
        lines.push(`A clean retry is unavailable: ${cleanRetryUnavailableReason(root, state, step, head)}`);
    }
    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')}`);
    lines.push(`Choose exactly one:`);
    lines.push(...options);
    if (!resume) {
        lines.push(UNVERIFIABLE_WRITES_LINE);
    }
    // `retry` is preselected wherever it is legal: it is the only resolution
    // that neither discards work nor records a result the run never produced.
    // While the generator may still run there is no `next` at all: a reset
    // cannot be verified against writes git does not see, and adopting records
    // a result nothing checked, so an agent that follows `next` blindly must
    // land on neither.
    emit(runId, step, 'died', {
        ...(resume ? { next: reconcileCommand(root, runId, 'retry') } : {}),
        instructionLines: lines,
    });
}
function emitStillRunning(root, runId, step) {
    const migrationId = step.migrationId;
    const ageMs = step.startedAt ? Date.now() - Date.parse(step.startedAt) : 0;
    const lines = [
        `The worker for ${migrationId} (pid ${step.pid}) is still running. Wait for it to finish, then run the "next" command.`,
    ];
    if (ageMs >= HANG_THRESHOLD_MS) {
        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.`);
    }
    emit(runId, step, 'still-running', {
        next: reconcileCommand(root, runId),
        instructionLines: lines,
    });
}
function emitAwaitPrompt(root, dir, runId, step) {
    const migrationId = step.migrationId;
    const { package: pkg, name } = (0, state_machine_1.splitMigrationId)(migrationId);
    const filePath = handoffPath(dir, { package: pkg, name });
    // The package id becomes real path segments, so handing over the path
    // without its directory is what would force the agent to `mkdir -p`. Same
    // reason the classic runner pre-creates it in run-step.ts.
    (0, fs_1.mkdirSync)((0, path_1.dirname)(filePath), { recursive: true });
    const lines = [
        `Migration ${migrationId} is a prompt-based migration awaiting your outcome.`,
        `Apply the prompt (see the worker's earlier <nx_migrate_prompt> block), then write the handoff file and run the "next" command.`,
        `Handoff file: ${filePath}`,
        `Handoff JSON: { "status": "success" | "failed", "summary": "<what you did>" }. To mark the prompt not applicable, use "status": "success" with "outcome": "skipped".`,
    ];
    // A handoff that exists but can't be read/parsed/validated is a rejection,
    // not a still-awaited outcome. Naming why stops the run from re-emitting the
    // same await forever while the agent leaves the bad file in place.
    const rejection = describeRejectedHandoff(filePath);
    if (rejection.length > 0) {
        lines.push('', ...rejection);
    }
    emit(runId, step, 'await-prompt', {
        next: reconcileCommand(root, runId),
        instructionLines: lines,
    });
}
// Empty unless a handoff file is present but unusable; wording mirrors the
// classic runner's ambiguous-outcome cause lines.
function describeRejectedHandoff(handoffPath) {
    const result = (0, handoff_1.readHandoffWithReason)(handoffPath);
    if (result.ok)
        return [];
    const { reason, detail } = result;
    if (reason === 'missing')
        return [];
    const followUp = 'Rewrite the handoff file, then run the "next" command.';
    switch (reason) {
        case 'read-error':
            return [
                `The handoff file was rejected: it could not be read${detail ? ` (${detail})` : ''}.`,
                followUp,
            ];
        case 'parse-error':
            return [
                `The handoff file was rejected: it contained invalid JSON${detail ? ` (${detail})` : ''}.`,
                followUp,
            ];
        case 'shape-mismatch':
            return [
                'The handoff file was rejected: it was missing required fields or had an unexpected shape.',
                followUp,
            ];
        default: {
            const exhaustive = reason;
            throw new Error(`Unrecognized handoff rejection reason '${exhaustive}'.`);
        }
    }
}
// A refused --step-action still exits 0: the tagged error block is the answer
// to the request, and it carries the reconcile command to run next. A non-zero
// exit would tell the driving agent that reconcile itself crashed, and it
// would stop reading for the correction it is being handed.
function emitError(root, runId, reason) {
    (0, agent_output_1.warnToAgent)({
        title: 'The requested --step-action could not be applied.',
        bodyLines: [reason],
    });
    (0, agent_output_1.emitStepBlock)(runId, '-', 'error', {
        next: reconcileCommand(root, runId),
        instructions: reason,
    });
    (0, migrate_analytics_1.reportMigrateOrchestratorDispense)({ action: 'error', attempt: 0 });
}
function completeRun(root, dir, runId, state) {
    let current = state;
    const completed = current.steps.filter((s) => s.status === 'succeeded').length;
    const skipped = current.steps.filter((s) => s.status === 'skipped').length;
    const dispenseCount = current.steps.reduce((n, s) => n + s.dispenseCount, 0);
    // The crash-refold window can strand a failed ledger entry whose diff was in
    // fact absorbed; suppress the warning only on a verified-clean tree. A dirty
    // tree can still be unrelated edits, so the warning only claims the changes
    // "may remain".
    const commitDebt = (0, state_machine_1.hasPendingCommitDebt)(current) && (0, git_utils_1.getWorkingTreeStatus)(root) !== 'clean';
    // Persist the terminal status and claim the watermark in one fresh-state
    // write before emitting: a crash between the write and the output can't
    // double-count the completion, and of two concurrent reconciles exactly one
    // claims the report.
    let shouldEmit = false;
    if (current.status !== 'completed' || !current.analytics.completeEmitted) {
        current = (0, state_lock_1.updateRunState)(dir, (fresh) => {
            if (fresh.status === 'completed' && fresh.analytics.completeEmitted) {
                return null;
            }
            shouldEmit = !fresh.analytics.completeEmitted;
            return {
                ...fresh,
                status: 'completed',
                analytics: { ...fresh.analytics, completeEmitted: true },
            };
        });
    }
    if (shouldEmit) {
        (0, migrate_analytics_1.reportMigrateOrchestratorComplete)({
            completed,
            skipped,
            dispenseCount,
        });
    }
    const debtLine = 'Some migration changes could not be committed and may remain in the working tree; review and commit them manually.';
    if (commitDebt) {
        (0, agent_output_1.warnToAgent)({ title: debtLine });
    }
    const uninstalled = current.steps.filter((s) => s.installFailed);
    const installLine = uninstalled.length > 0
        ? `The dependency changes made by ${uninstalled
            .map((s) => s.migrationId)
            .join(', ')} were not installed; run \`${(0, util_1.pmInstallCommand)(root)}\` before using the workspace.`
        : null;
    if (installLine) {
        (0, agent_output_1.warnToAgent)({ title: installLine });
    }
    const instructionLines = [
        `Migrate run ${runId} is complete.`,
        `  applied: ${completed}`,
        `  skipped: ${skipped}`,
        ...(commitDebt ? [debtLine] : []),
        ...(installLine ? [installLine] : []),
    ];
    (0, agent_output_1.logToAgent)({ title: 'nx migrate: complete', bodyLines: instructionLines });
    (0, agent_output_1.emitStepBlock)(runId, '-', 'complete', {
        instructions: instructionLines.join('\n'),
    });
}
function emit(runId, step, action, payload) {
    const { instructionLines, ...rest } = payload;
    // One sanitized array feeds both, so the block payload says exactly what the
    // human echo said.
    const lines = instructionLines ? (0, agent_output_1.safeLines)(instructionLines) : undefined;
    (0, agent_output_1.logToAgent)({ title: `nx migrate: ${action}`, bodyLines: lines });
    (0, agent_output_1.emitStepBlock)(runId, step.id, action, {
        ...rest,
        ...(lines ? { instructions: lines.join('\n') } : {}),
    });
    (0, migrate_analytics_1.reportMigrateOrchestratorDispense)({ action, attempt: step.attempt });
}
// Raw argv is forwarded verbatim across the wrapper hops, so every flag is a
// single `--flag=value` token. Interpolated values are validated shell-safe at
// init (migration ids; resumed run ids are gated by the run-dir scan) and
// reconcile entry (run id).
function workerCommand(root, migrationId, runId) {
    return `${(0, util_1.pmExecPrefix)(root)} nx migrate --run-migration=${migrationId} --run-id=${runId}`;
}
function reconcileCommand(root, runId, action) {
    const base = `${(0, util_1.pmExecPrefix)(root)} nx migrate --run-id=${runId}`;
    return action ? `${base} --step-action=${action}` : base;
}
// --- helpers ----------------------------------------------------------------
function appendCommit(state, entry) {
    return { ...state, commits: [...state.commits, entry] };
}
// Records what the workspace looked like as this attempt starts. The git ref
// and the tree state are re-captured per dispense, since a retry restarts from
// wherever the tree is now. The dependency baseline is not: it tracks the last
// dependencies that were actually installed, moving only when an install
// lands, so a retry that only has the commit left to do still sees the
// previous attempt's package.json edits as needing one.
function setDispenseBaselines(state, stepId, baselines) {
    const depsBaseline = state.steps.find((s) => s.id === stepId)?.depsHashAtDispense;
    return {
        ...state,
        steps: state.steps.map((s) => s.id === stepId
            ? {
                ...s,
                gitRefBefore: baselines.gitRefBefore,
                treeCleanAtDispense: baselines.treeCleanAtDispense,
                depsHashAtDispense: depsBaseline ?? baselines.depsHashAtDispense ?? undefined,
            }
            : s),
    };
}
// Applies a step event to fresh state or throws the orchestrator's advance
// error. Pure; callers persist the result via updateRunState.
function applyEventOrThrow(state, event) {
    const result = (0, state_machine_1.applyStepEvent)(state, event);
    if (result.kind === 'error') {
        throw new Error(`Orchestrator could not advance the run: ${result.reason}`);
    }
    return result.state;
}
function handoffPath(dir, migration) {
    return (0, handoff_1.stepHandoffPath)(dir, migration);
}
function removeHandoff(dir, migrationId) {
    (0, fs_1.rmSync)(handoffPath(dir, (0, state_machine_1.splitMigrationId)(migrationId)), { force: true });
}
// null means the probe itself failed; the death dispense renders that as
// '(unknown)', because '(clean)' would invite a retry-clean reset over
// evidence that was never gathered (getWorkingTreeStatus's contract).
function dirtyTreeSummary(root) {
    try {
        return (0, child_process_1.execSync)('git status --porcelain', {
            encoding: 'utf8',
            cwd: root,
            stdio: ['ignore', 'pipe', 'pipe'],
            windowsHide: true,
        }).trim();
    }
    catch {
        return null;
    }
}