From e48baf5ed6b587d47ee59828f2584880093163cb Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 23 Oct 2026 23:22:40 -0400 Subject: [PATCH] fix(doctor): name the recovery each pending adoption state accepts for a pinned literal override An interrupted preparation stop (pending operation, no publication) and an interrupted retained command of an active adoption both reported the pending switch step 'hack config adopt --recover --rollback', which each of those states refuses. Doctor and the --fix note now name 'hack config adopt --recover --stop' and 'hack down --recover' there. A busy refusal over a pending switch now names the step that state accepts instead of only the generic interrupted-lock recovery, which completes the switch and refuses a literal override. Refs HACK-1414 Co-Authored-By: Claude Opus 5.5 --- docs/reference/native-compose-adoption.md | 24 +++++- src/commands/config-adopt.ts | 40 +++++++++- src/lib/compose-env-at-rest.ts | 80 +++++++++++++------ src/lib/native-compose-adoption-marker.ts | 60 ++++++++++++-- tests/compose-env-at-rest.test.ts | 50 +++++++++++- tests/doctor-fix-adopted-project.test.ts | 51 ++++++++++++ ...native-compose-adoption-generation.test.ts | 50 ++++++++++-- 7 files changed, 308 insertions(+), 47 deletions(-) diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index 88b35345..98edfc53 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -535,10 +535,26 @@ A repeated `--recover --stop` after that refusal does not reach the publication boundary again: the switch is already journaled, so the retried preparation stop refuses it as a pending publication with the generic `E_LEGACY_ADOPTION_BUSY` code ("busy or requires explicit interrupted-lock recovery") and no publication -diagnostic; the literal-env message appears only on the first refusal. -`hack doctor` reports the pinned literal override and names -`hack config adopt --recover --rollback` as the next step while the switch is -pending, since `hack down` and plain `--rollback` refuse in that state (HACK-1391). +diagnostic; the literal-env message appears only on the first refusal. Plain +`hack config adopt --recover` would complete the switch, which refuses the literal +override, so whenever `hack config adopt` refuses as busy over a pending switch +its message adds the step that state accepts: `--recover --rollback` when the +bound override holds literal values, otherwise `--recover` to complete or +`--recover --rollback` to restore (HACK-1414). + +`hack doctor` reports the pinned literal override and names the first command the +pending state accepts, since `hack down` and plain `--rollback` refuse in every +pending state (HACK-1391, HACK-1414): + +- A pending switch: `hack config adopt --recover --rollback`. +- An interrupted preparation stop (before any switch, so `--recover --rollback` + refuses too): `hack config adopt --recover --stop`, which refuses to publish + over the literal override and journals the switch; then + `hack config adopt --recover --rollback`. +- An interrupted retained-container command of an active adoption: + `hack down --recover`, then `hack config adopt --rollback`. + +`hack doctor --fix` keeps the pinned file and its note names the same step. Unmodified v4 clients cannot be universally fenced: they ignore future legacy versions and may select ancestor Compose inputs. Adoption requires the upgraded diff --git a/src/commands/config-adopt.ts b/src/commands/config-adopt.ts index 9343ecdd..ac739043 100644 --- a/src/commands/config-adopt.ts +++ b/src/commands/config-adopt.ts @@ -14,6 +14,7 @@ import { type LegacyComposeAdoptedGenerationStore, openLegacyComposeAdoptedGenerationStore, } from "../lib/native-compose-adoption-generation.ts"; +import { inspectLegacyComposeAdoptionSelectionDetail } from "../lib/native-compose-adoption-marker.ts"; import { LEGACY_ADOPTION_LITERAL_ENV_OVERRIDE_CODE, legacyAdoptionEnvOverrideHoldsValues, @@ -88,6 +89,43 @@ class LiteralEnvOverrideRefusal extends HackCliError { } } +/** + * Next steps appended to `E_LEGACY_ADOPTION_BUSY` when a publication switch + * is journaled. That code's own text names only "explicit interrupted-lock + * recovery", and plain `hack config adopt --recover` completes the switch, + * which refuses a bound literal override, so the step must name the command + * this state accepts (HACK-1414). Fixed text; values omitted. + */ +export const PENDING_SWITCH_BUSY_NEXT_STEP = + "A publication switch is pending. If no other adoption command is running, run 'hack config adopt --recover' to complete it or 'hack config adopt --recover --rollback' to restore the originals."; +export const PENDING_LITERAL_SWITCH_BUSY_NEXT_STEP = + "A publication switch is pending and the bound .hack/.internal/compose.env.override.yml holds literal env values, so completing it refuses. If no other adoption command is running, run 'hack config adopt --recover --rollback' to release it."; + +/** The store refusal's fixed message, plus the pending-switch step for BUSY. */ +async function adoptionRefusalMessage(opts: { + readonly error: LegacyComposeAdoptedGenerationError; + readonly projectRoot: string; +}): Promise { + if (opts.error.code !== "E_LEGACY_ADOPTION_BUSY") { + return opts.error.message; + } + try { + const detail = await inspectLegacyComposeAdoptionSelectionDetail({ + projectRoot: opts.projectRoot, + }); + if (detail?.pending !== "switch") { + return opts.error.message; + } + const literal = await legacyAdoptionEnvOverrideHoldsValues({ + projectRoot: opts.projectRoot, + }); + return `${opts.error.message} ${literal ? PENDING_LITERAL_SWITCH_BUSY_NEXT_STEP : PENDING_SWITCH_BUSY_NEXT_STEP}`; + } catch { + // Unreadable state keeps the store's fixed message alone. + return opts.error.message; + } +} + async function dryRun( projectRoot: string, signal: AbortSignal, @@ -299,7 +337,7 @@ export const configAdoptCommand = withHandler(spec, async ({ ctx, args }) => { code: "E_CONFIG_INVALID", message: error instanceof LegacyComposeAdoptedGenerationError - ? error.message + ? await adoptionRefusalMessage({ error, projectRoot }) : "Legacy adoption refused; original data and recovery evidence retained. Values omitted.", ...(diagnostic ? { detail: { legacy_adoption_publication_refusal: diagnostic } } diff --git a/src/lib/compose-env-at-rest.ts b/src/lib/compose-env-at-rest.ts index 78ab53ae..a1ec8a1a 100644 --- a/src/lib/compose-env-at-rest.ts +++ b/src/lib/compose-env-at-rest.ts @@ -7,7 +7,10 @@ import { classifyComposeEnvOverrideText, } from "./compose-managed-env.ts"; import { isRecord } from "./guards.ts"; -import { inspectLegacyComposeAdoptionSelection } from "./native-compose-adoption-marker.ts"; +import { + inspectLegacyComposeAdoptionSelectionDetail, + type LegacyComposeAdoptionPending, +} from "./native-compose-adoption-marker.ts"; import { type ReplaceablePrivateFileOptions, readReplaceablePrivateFile, @@ -39,9 +42,9 @@ import { * such a generation exists (prepared, active or pending), nothing here removes * the file, because removal would make the adoption unloadable; the supported * way to drop its values is rollback (`hack config adopt --rollback`, or - * `--recover --rollback` while an adoption operation or switch is pending), - * `hack up`, then adopting again. New adoptions refuse a literal override - * (`hack config adopt`). + * `--recover --rollback` while a switch is pending, after first settling an + * interrupted preparation stop or retained command), `hack up`, then adopting + * again. New adoptions refuse a literal override (`hack config adopt`). */ export const COMPOSE_ENV_OVERRIDE_FILENAME = "compose.env.override.yml"; @@ -338,26 +341,29 @@ export async function composeEnvOverrideAdoptionBound(opts: { /** * How a saved adoption binds the override: `bound` as in - * {@link composeEnvOverrideAdoptionBound}, and `pending` when the binding - * adoption has a pending or interrupted operation or switch. A pending - * adoption fences `hack down` and plain `hack config adopt --rollback`, so - * only explicit recovery (`hack config adopt --recover --rollback`) releases - * the file (HACK-1318). Unreadable adoption state is bound and not pending. + * {@link composeEnvOverrideAdoptionBound}, and `pending` naming what a + * pending binding adoption waits on (false when it is not pending). A pending + * adoption fences `hack down` and plain `hack config adopt --rollback`, and + * each pending kind accepts one explicit recovery command first (HACK-1318, + * HACK-1414). Unreadable adoption state is bound and not pending. */ export async function composeEnvOverrideAdoptionBinding(opts: { readonly projectDir: string; -}): Promise<{ readonly bound: boolean; readonly pending: boolean }> { +}): Promise<{ + readonly bound: boolean; + readonly pending: false | LegacyComposeAdoptionPending; +}> { const projectDir = resolve(opts.projectDir); if (basename(projectDir) !== HACK_PROJECT_DIR_PRIMARY) { return { bound: false, pending: false }; } try { - const status = await inspectLegacyComposeAdoptionSelection({ + const detail = await inspectLegacyComposeAdoptionSelectionDetail({ projectRoot: dirname(projectDir), }); return { - bound: status !== null && status !== "rolled-back", - pending: status === "pending", + bound: detail !== null && detail.status !== "rolled-back", + pending: detail?.pending ?? false, }; } catch { return { bound: true, pending: false }; @@ -470,10 +476,11 @@ export type ComposeEnvAtRestReport = { readonly override: ComposeEnvOverrideAtRest; /** A saved adoption may pin the override; see {@link composeEnvOverrideAdoptionBound}. */ readonly adoptionBound: boolean; - /** The pinning adoption has a pending or interrupted operation or switch, - * so only `hack config adopt --recover --rollback` releases the file; see - * {@link composeEnvOverrideAdoptionBinding}. False when not bound. */ - readonly adoptionPending: boolean; + /** What the pinning adoption's pending or interrupted operation or switch + * waits on, which decides the recovery command that releases the file; see + * {@link composeEnvOverrideAdoptionBinding}. False when not pending or not + * bound. */ + readonly adoptionPending: false | LegacyComposeAdoptionPending; /** Asides left by interrupted removals; each may hold literal values. */ readonly asides: { readonly stale: number; readonly fresh: number }; }; @@ -497,11 +504,11 @@ export async function inspectComposeEnvAtRest(opts: { : { stale: [], fresh: [] }; const binding = composeEnvOverrideMayHoldValues(override) ? await composeEnvOverrideAdoptionBinding(opts) - : { bound: false, pending: false }; + : ({ bound: false, pending: false } as const); return { override, adoptionBound: binding.bound, - adoptionPending: binding.bound && binding.pending, + adoptionPending: binding.bound ? binding.pending : false, asides: { stale: asides.stale.length, fresh: asides.fresh.length }, }; } @@ -532,20 +539,43 @@ export function composeEnvAtRestRepairs(report: ComposeEnvAtRestReport): { export const ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP = "To drop them, stop the adopted containers ('hack down'), run 'hack config adopt --rollback', run 'hack up' so Hack rewrites the file with references only, then adopt again with 'hack config adopt --stop'."; /** - * The same, while the pinning adoption has a pending or interrupted operation - * or switch: `hack down` and plain `--rollback` refuse in that state, and only - * explicit recovery releases the switch (HACK-1318, HACK-1391). + * The same, while the pinning adoption has a pending switch: `hack down` and + * plain `--rollback` refuse in that state, and only explicit recovery releases + * the switch (HACK-1318, HACK-1391). */ export const PENDING_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP = "To drop them, release the pending adoption switch with 'hack config adopt --recover --rollback' (plain rollback and 'hack down' refuse while it is pending), run 'hack up' so Hack rewrites the file with references only, then adopt again with 'hack config adopt --stop'."; +/** + * The same, after an interrupted `hack config adopt --stop` before + * publication: there is no switch yet, so `--recover --rollback` refuses as + * well as plain rollback and `hack down`. Only `--recover --stop` retries the + * stop; publication then refuses the bound literal override and journals the + * switch that `--recover --rollback` releases (HACK-1318, HACK-1414). + */ +export const INTERRUPTED_PREPARATION_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP = + "To drop them, first retry the interrupted adoption stop with 'hack config adopt --recover --stop' (rollback and 'hack down' refuse until it finishes); it then refuses to publish over this file and leaves the adoption switch pending. Release that switch with 'hack config adopt --recover --rollback', run 'hack up' so Hack rewrites the file with references only, then adopt again with 'hack config adopt --stop'."; +/** + * The same, after an interrupted retained-container command of an active + * adoption: rollback (plain or `--recover`) and plain `hack down` refuse until + * `hack down --recover` stops the originals and settles it (HACK-1414). + */ +export const INTERRUPTED_OPERATION_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP = + "To drop them, first settle the interrupted adopted command with 'hack down --recover', which stops the adopted containers (rollback and plain 'hack down' refuse until it finishes). Then run 'hack config adopt --rollback', run 'hack up' so Hack rewrites the file with references only, then adopt again with 'hack config adopt --stop'."; /** The next step for a report whose override a saved adoption pins. */ export function adoptedComposeEnvOverrideNextStep( report: Pick ): string { - return report.adoptionPending - ? PENDING_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP - : ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP; + switch (report.adoptionPending) { + case "switch": + return PENDING_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP; + case "preparation-stop": + return INTERRUPTED_PREPARATION_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP; + case "retained-operation": + return INTERRUPTED_OPERATION_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP; + default: + return ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP; + } } /** diff --git a/src/lib/native-compose-adoption-marker.ts b/src/lib/native-compose-adoption-marker.ts index b10faddc..6e46ef05 100644 --- a/src/lib/native-compose-adoption-marker.ts +++ b/src/lib/native-compose-adoption-marker.ts @@ -51,25 +51,56 @@ export class LegacyComposeAdoptionSelectionError extends HackCliError { function refuse(): never { throw new LegacyComposeAdoptionSelectionError(); } +/** + * What a `pending` selection waits on, which decides the only command that + * moves it on: + * - `switch`: a journaled publication switch or rollback. `hack config adopt + * --recover` completes it and `--recover --rollback` restores the originals. + * - `preparation-stop`: an interrupted `hack config adopt --stop` before + * publication. Only `hack config adopt --recover --stop` retries it. + * - `retained-operation`: an interrupted retained-container command of an + * active adoption. Only `hack down --recover` settles it. + * A receipt never holds a pending operation and a pending switch together. + */ +export type LegacyComposeAdoptionPending = + | "switch" + | "preparation-stop" + | "retained-operation"; + +/** A selection status with what a pending one waits on. */ +export type LegacyComposeAdoptionSelectionDetail = { + readonly status: LegacyComposeAdoptionSelection; + /** Null unless `status` is `pending`. */ + readonly pending: LegacyComposeAdoptionPending | null; +}; + function classify( value: unknown, checkout: Parameters[1] -): LegacyComposeAdoptionSelection { +): LegacyComposeAdoptionSelectionDetail { const state = parseLegacyComposeAdoptionReceipt(value, checkout); if (state.pendingOperation !== null) { - return "pending"; + // The receipt parser admits a pending operation only over an active + // publication (a retained command) or before one (a preparation stop). + return { + status: "pending", + pending: + state.publication?.phase === "active" + ? "retained-operation" + : "preparation-stop", + }; } if (state.publication === null) { - return "prepared"; + return { status: "prepared", pending: null }; } switch (state.publication.phase) { case "switching": case "rolling-back": - return "pending"; + return { status: "pending", pending: "switch" }; case "active": - return "active"; + return { status: "active", pending: null }; case "rolled-back": - return "rolled-back"; + return { status: "rolled-back", pending: null }; default: refuse(); } @@ -99,6 +130,19 @@ function classifyStateRootFailure(error: unknown): StateRootFailure | null { export async function inspectLegacyComposeAdoptionSelection(opts: { readonly projectRoot: string; }): Promise { + return ( + (await inspectLegacyComposeAdoptionSelectionDetail(opts))?.status ?? null + ); +} + +/** + * {@link inspectLegacyComposeAdoptionSelection} plus what a pending selection + * waits on, for callers that name a recovery command. Same reads, refusals + * and value-free result. + */ +export async function inspectLegacyComposeAdoptionSelectionDetail(opts: { + readonly projectRoot: string; +}): Promise { const directories: HeldDirectory[] = []; let stateRootFailure: StateRootFailure | null = null; try { @@ -140,7 +184,7 @@ export async function inspectLegacyComposeAdoptionSelection(opts: { } const saved = await readPrivate(receiptPath, 64 * 1024), parsed = parseImportDocument({ text: saved.text, document: "config" }); - const status = classify(parsed.value, { + const detail = classify(parsed.value, { root: { dev: rootDirectory.info.dev, ino: rootDirectory.info.ino }, project: { dev: projectDirectory.info.dev, @@ -154,7 +198,7 @@ export async function inspectLegacyComposeAdoptionSelection(opts: { if (!sameFile(saved.info, current.info) || saved.text !== current.text) { refuse(); } - return status; + return detail; } catch { if (stateRootFailure !== null) { throw new LegacyComposeAdoptionSelectionError(stateRootFailure); diff --git a/tests/compose-env-at-rest.test.ts b/tests/compose-env-at-rest.test.ts index 65c60c56..7b0cacca 100644 --- a/tests/compose-env-at-rest.test.ts +++ b/tests/compose-env-at-rest.test.ts @@ -21,6 +21,8 @@ import { composeEnvAtRestRepairs, composeEnvOverridePath, describeComposeEnvAtRest, + INTERRUPTED_OPERATION_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP, + INTERRUPTED_PREPARATION_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP, inspectComposeEnvAtRest, inspectComposeEnvOverrideAtRest, PENDING_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP, @@ -517,7 +519,7 @@ test("doctor names explicit recovery rollback for an override that a pending ado report: { override, adoptionBound: true, - adoptionPending: true, + adoptionPending: "switch", asides: { stale: 0, fresh: 0 }, }, projectDirName: ".hack", @@ -532,9 +534,9 @@ test("doctor names explicit recovery rollback for an override that a pending ado expect(pending.message).toContain( PENDING_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP ); - expect(adoptedComposeEnvOverrideNextStep({ adoptionPending: true })).toBe( - PENDING_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP - ); + expect( + adoptedComposeEnvOverrideNextStep({ adoptionPending: "switch" }) + ).toBe(PENDING_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP); expect(adoptedComposeEnvOverrideNextStep({ adoptionPending: false })).toBe( ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP ); @@ -552,6 +554,46 @@ test("doctor names explicit recovery rollback for an override that a pending ado } }); +test("doctor names the recovery each interrupted adoption kind accepts first (HACK-1414)", () => { + const path = "/unused"; + const describe = ( + adoptionPending: "preparation-stop" | "retained-operation" + ) => + describeComposeEnvAtRest({ + report: { + override: { kind: "plaintext", path, values: 2, services: 1 }, + adoptionBound: true, + adoptionPending, + asides: { stale: 0, fresh: 0 }, + }, + projectDirName: ".hack", + }).message; + // An interrupted preparation stop has no switch yet: `--recover --rollback` + // refuses there, and only `--recover --stop` moves it on. + const preparation = describe("preparation-stop"); + expect(preparation).toContain( + INTERRUPTED_PREPARATION_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP + ); + expect(preparation).toMatch( + /first retry the interrupted adoption stop with 'hack config adopt --recover --stop'.*Release that switch with 'hack config adopt --recover --rollback'/ + ); + expect(preparation).not.toContain( + PENDING_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP + ); + expect(preparation).not.toContain("'hack config adopt --rollback'"); + // An interrupted retained command of an active adoption: rollback of either + // kind refuses until `hack down --recover` settles it. + const operation = describe("retained-operation"); + expect(operation).toContain( + INTERRUPTED_OPERATION_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP + ); + expect(operation).toContain( + "first settle the interrupted adopted command with 'hack down --recover'" + ); + expect(operation).not.toContain("--recover --rollback"); + expect(operation).not.toContain("--recover --stop"); +}); + /** Names and contents of every entry in a directory outside the project. */ async function snapshotDirectory( directory: string diff --git a/tests/doctor-fix-adopted-project.test.ts b/tests/doctor-fix-adopted-project.test.ts index 1360422a..69c0cdd1 100644 --- a/tests/doctor-fix-adopted-project.test.ts +++ b/tests/doctor-fix-adopted-project.test.ts @@ -3,6 +3,11 @@ import { randomBytes } from "node:crypto"; import { lstat, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { + ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP, + INTERRUPTED_OPERATION_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP, +} from "../src/lib/compose-env-at-rest.ts"; +import { renderManagedComposeEnvOverride } from "../src/lib/compose-managed-env.ts"; import { resetNoInteractiveFlagForTests } from "../src/lib/interactivity.ts"; import { inspectLegacyComposeAdoptionSelection } from "../src/lib/native-compose-adoption-marker.ts"; import { restoreEnv } from "./helpers/env.ts"; @@ -25,6 +30,7 @@ import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; * only against the fixture checkout with no global or system config. */ +const LITERAL_CANARY = "hack-1414-literal-override-canary"; const runCalls: string[][] = []; const noteCalls: string[] = []; @@ -265,6 +271,51 @@ test("doctor --fix with a pending adoption leaves the pending operation for expl } }, 120_000); +test("doctor --fix keeps a literal env override that a pending adoption pins and names the recovery that state accepts (HACK-1414)", async () => { + const store = await h.store(); + try { + const generation = await store.prepare({ binary: h.compiler }); + await store.publish({ generation, binary: h.compiler }); + } finally { + await store.close(); + } + // An interrupted retained command of the active adoption: rollback (plain + // or `--recover`) refuses until `hack down --recover` settles it. + await leaveRetainedSourceBindOperationPending(h); + expect( + await inspectLegacyComposeAdoptionSelection({ projectRoot: h.root }) + ).toBe("pending"); + // An earlier version's literal override. Doctor reads only the adoption + // status, so it reports the file as pinned without loading the adoption. + const overridePath = join(h.root, ".hack/.internal/compose.env.override.yml"); + const literal = + renderManagedComposeEnvOverride({ + targetServices: ["web"], + globalEnv: {}, + serviceEnv: { web: { LITERAL_SECRET: LITERAL_CANARY } }, + }) ?? ""; + await writeFile(overridePath, literal, { mode: 0o600 }); + const receipt = await readFile(receiptPath(), "utf8"); + + const code = await runDoctorFix(); + + const notes = noteCalls.join("\n"); + expect(code).toBe(0); + expect(notes).toContain( + `Kept .hack/.internal/compose.env.override.yml: a saved 'hack config adopt' generation pins it, and removing it would break the adoption (values omitted). ${INTERRUPTED_OPERATION_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP}` + ); + expect(notes).toContain("'hack down --recover'"); + // The settled and pending-switch steps both name commands this state + // refuses. + expect(notes).not.toContain(ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP); + expect(notes).not.toContain("--recover --rollback"); + expect(notes).not.toContain(LITERAL_CANARY); + expect(notes).not.toContain(SOURCE_BIND_CANARY); + // Kept as is, and the adoption is left for explicit recovery. + expect(await readFile(overridePath, "utf8")).toBe(literal); + expect(await readFile(receiptPath(), "utf8")).toBe(receipt); +}, 120_000); + /** `runCli doctor --fix` in the fixture with a private HOME and the mocks on. */ async function runDoctorFix(): Promise { process.env.HOME = home; diff --git a/tests/native-compose-adoption-generation.test.ts b/tests/native-compose-adoption-generation.test.ts index 4504b1db..bdad6963 100644 --- a/tests/native-compose-adoption-generation.test.ts +++ b/tests/native-compose-adoption-generation.test.ts @@ -21,6 +21,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { PENDING_LITERAL_SWITCH_BUSY_NEXT_STEP } from "../src/commands/config-adopt.ts"; import { checkComposeEnvOverrideAtRest } from "../src/commands/doctor.ts"; import { resolveModernComposeEnvOverrides, @@ -2301,6 +2302,39 @@ boundedTest( "pending" ); const pinned = await lstat(override); + // Interrupted preparation stop, before any switch (HACK-1414): rollback, + // explicit or plain, refuses and changes nothing, so doctor must name + // `--recover --stop` first, not `--recover --rollback`. + const interrupted = await readReceipt(); + expect(interrupted.pendingOperation).not.toBeNull(); + expect(interrupted.publication).toBeNull(); + for (const flags of [ + ["--recover", "--rollback", "--json"], + ["--rollback", "--json"], + ]) { + const refused = await adoptCli(flags, binary); + expect(refused.code).not.toBe(0); + expect(refused.output).not.toContain(CANARY); + expect(await readReceipt()).toEqual(interrupted); + } + expect( + await inspectComposeEnvAtRest({ projectDir: join(projectRoot, ".hack") }) + ).toMatchObject({ + override: { kind: "plaintext" }, + adoptionBound: true, + adoptionPending: "preparation-stop", + }); + const beforeStop = await checkComposeEnvOverrideAtRest({ + startDir: projectRoot, + }); + expect(beforeStop.status).toBe("warn"); + expect(beforeStop.message).toContain( + "first retry the interrupted adoption stop with 'hack config adopt --recover --stop'" + ); + expect(beforeStop.message).not.toContain( + "release the pending adoption switch with" + ); + expect(beforeStop.message).not.toContain(CANARY); // `--recover --stop` retries the stop and then publishes; plain // `--recover` completes the journaled switch. Both refuse before any // original moves and name the release step. @@ -2339,18 +2373,24 @@ boundedTest( // A second `--recover --stop` no longer reaches the publication boundary: // the switch is already journaled, so the retried preparation stop refuses // it as a pending publication (the generic busy code) without the - // literal-env message or a publication diagnostic (HACK-1391). + // literal-env message or a publication diagnostic (HACK-1391). Plain + // `--recover` (the busy text's "interrupted-lock recovery") completes the + // switch, which refuses the literal override, so the message names the + // release step instead (HACK-1414). const repeated = await adoptCli(["--recover", "--stop", "--json"], binary); expect(repeated.code).not.toBe(0); expect(JSON.parse(repeated.stdout)).toEqual({ ok: false, error: { code: "E_CONFIG_INVALID", - message: new LegacyComposeAdoptedGenerationError( - "E_LEGACY_ADOPTION_BUSY" - ).message, + message: `${ + new LegacyComposeAdoptedGenerationError("E_LEGACY_ADOPTION_BUSY") + .message + } ${PENDING_LITERAL_SWITCH_BUSY_NEXT_STEP}`, }, }); + expect(repeated.output).toContain("hack config adopt --recover --rollback"); + expect(repeated.output).not.toContain("'hack config adopt --recover' to"); expect(repeated.output).not.toContain(CANARY); expect((await readReceipt()).publication).toMatchObject({ phase: "switching", @@ -2365,7 +2405,7 @@ boundedTest( expect(report).toMatchObject({ override: { kind: "plaintext" }, adoptionBound: true, - adoptionPending: true, + adoptionPending: "switch", }); const checked = await checkComposeEnvOverrideAtRest({ startDir: projectRoot,