From 002685d7027648aad9647106b79463d664b57dcf Mon Sep 17 00:00:00 2001 From: ElCaaarnal Date: Fri, 16 Oct 2026 19:46:09 -0700 Subject: [PATCH 1/4] fix(main-channel): install a moved main Shell when the Gentle AI step fails When the Gentle AI step of gentle-shell upgrade --channel main fails, the main Shell that moved is still packed and installed, no channel is recorded, and the command exits with the original error plus the retry advice, so the retry runs the new Shell's code. An error without a main-channel code becomes main-upgrade-incomplete, so neither the advice nor either error is lost. A current Shell keeps the previous behavior. Closes #2356 --- README.md | 2 +- docs/install-wizard.md | 18 ++++ scripts/main-channel.mjs | 37 +++++++- tests/main-channel-upgrade.test.ts | 140 ++++++++++++++++++++++++++++- 4 files changed, 191 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index abba02eb8..9145e20af 100644 --- a/README.md +++ b/README.md @@ -579,7 +579,7 @@ gentle-shell upgrade --rollback
-- **Main channel:** On the main channel it rebuilds Gentle AI and Gentle Shell from the latest `main` commits (only what changed) and needs pnpm and Go (a Go older than the minimum is replaced, for the build only, by the pinned Go the installer left in its own folder); switching back to release restores the pinned Gentle AI binary. +- **Main channel:** On the main channel it rebuilds Gentle AI and Gentle Shell from the latest `main` commits (only what changed) and needs pnpm and Go (a Go older than the minimum is replaced, for the build only, by the pinned Go the installer left in its own folder); switching back to release restores the pinned Gentle AI binary. When the Gentle AI build fails but Gentle Shell moved, the new Gentle Shell is still installed and the command exits with an error asking you to run `gentle-shell upgrade --channel main` again, which finishes Gentle AI with the new Shell's code. - **Your Pi:** With pnpm 11, the updated Gentle Shell may run a newer Pi that pnpm puts next to it (always at least the minimum); your own Pi is never changed. - **Bundled install:** A bundled install (the web installer's own Node, pnpm and Pi) follows the release channel only: it installs the new version beside the current one, verifies it, then switches, keeping the previous version for `--rollback`. On release, once the latest release publishes the bundled distribution, `upgrade` moves a global pnpm or npm installation to the bundled one and asks once before removing the old global `gentle-pi`. - **`update` vs `upgrade`:** `gentle-shell update` is a different command: it is Pi's own package update. In a bundled install Pi ships pinned with Gentle Shell, so updating Pi itself runs `gentle-shell upgrade` instead. diff --git a/docs/install-wizard.md b/docs/install-wizard.md index 8d7bff20d..3827fd8b4 100644 --- a/docs/install-wizard.md +++ b/docs/install-wizard.md @@ -818,6 +818,24 @@ publishes main builds, so main is built on this computer `acquireGo` as the wizard when no earlier run published it, and only when Gentle AI is rebuilt. A failed download stops with `main-requires-tools` before anything is built; the user's Go is never run for the build. +- When the Gentle AI step of `gentle-shell upgrade --channel main` fails (the + build, its verification, or getting the pinned Go) and the main Shell moved, + the upgrade still packs and installs that Shell, records no channel, prints + `Updated Gentle Shell to the latest main (); Gentle AI was not updated.` + and exits 1 with the original error (code and message) followed by + `; Gentle Shell was updated to main but Gentle AI was not: run + gentle-shell upgrade --channel main to finish`. The retry then rebuilds Gentle + AI with the new Shell's code, so a fix in the main-channel upgrade path reaches + an install whose own copy of it is broken. Until then Gentle AI stays the + previously registered main build or the pinned binary: a rejected build is + never registered. When the Shell install fails as well, its error is reported + with `; Gentle AI was not updated either ()`. An unexpected + error that has no main-channel code (a filesystem `EACCES`, for example) is + reported as `main-upgrade-incomplete` with its own message and the same + suffix, so neither the retry advice nor either error is lost. When the Shell + is already current, a failed Gentle AI step fails as before, with nothing + installed or recorded. The wizard's runner is the one bundled with the + downloaded installer and does not get this behavior from a newer main. - Trust: Gentle AI source is verified by Go's checksum database; the Gentle Shell source is fetched over TLS from GitHub by exact commit SHA, with no pinned digest. Main builds are development builds. diff --git a/scripts/main-channel.mjs b/scripts/main-channel.mjs index 8836b288f..0ff5c343d 100644 --- a/scripts/main-channel.mjs +++ b/scripts/main-channel.mjs @@ -424,6 +424,16 @@ async function mainGo({ userGo, pinnedGo, acquire, run }) { } } +/** `error` with `detail` appended to its message, keeping its code and cause; any + * other error becomes `main-upgrade-incomplete` with its message and itself as cause. */ +function withDetail(error, detail) { + if (!(error instanceof MainChannelError)) { + return new MainChannelError("main-upgrade-incomplete", `${error?.message ?? error}; ${detail}`, error); + } + const message = error.message.slice(`${error.code}: `.length); + return new MainChannelError(error.code, `${message}; ${detail}`, error.cause); +} + /** * Updates Gentle Shell along its recorded channel (or the one `--channel` selects): * release goes to the latest npm release, main to the latest `main` commits of @@ -478,11 +488,30 @@ export async function runUpgrade({ args, ctx, platform, arch = process.arch, pac out(`gentle-shell is already at the latest main: ${summary}.`); return 0; } - if (!aiCurrent) await buildMainGentleAi({ commit: gentleAiCommit, ctx, platform, goPath: await mainGo({ userGo, pinnedGo, acquire, run }), run, fs }); + // Fail forward (#2356): a Gentle AI step that fails still installs a main Shell that + // moved, so the retry runs the new Shell's code; the channel is recorded only when + // both steps succeed, so that retry rebuilds Gentle AI. Verification stays fail-closed. + let aiError = null; + if (!aiCurrent) { + try { + await buildMainGentleAi({ commit: gentleAiCommit, ctx, platform, goPath: await mainGo({ userGo, pinnedGo, acquire, run }), run, fs }); + } catch (error) { + if (shellCurrent) throw error; + aiError = error; + } + } if (!shellCurrent) { - const manager = await ownerManager({ ctx, platform, packageRoot, adapters }); - const tgz = await packMainShell({ commit: shellCommit, ctx, fetch, run, pnpm, fs, platform }); - await installGlobal(manager, tgz, run); + try { + const manager = await ownerManager({ ctx, platform, packageRoot, adapters }); + const tgz = await packMainShell({ commit: shellCommit, ctx, fetch, run, pnpm, fs, platform }); + await installGlobal(manager, tgz, run); + } catch (error) { + throw aiError ? withDetail(error, `Gentle AI was not updated either (${aiError.message ?? aiError})`) : error; + } + } + if (aiError) { + out(`Updated Gentle Shell to the latest main (${shellCommit.slice(0, 12)}); Gentle AI was not updated.`); + throw withDetail(aiError, `Gentle Shell was updated to main ${shellCommit.slice(0, 12)} but Gentle AI was not: run gentle-shell upgrade --channel main to finish`); } await writeChannel(ctx, { channel: "main", shellCommit, gentleAiCommit }, fs); out(`Updated to the latest main: ${summary}.`); diff --git a/tests/main-channel-upgrade.test.ts b/tests/main-channel-upgrade.test.ts index 7a1aa55f1..3f55e8d71 100644 --- a/tests/main-channel-upgrade.test.ts +++ b/tests/main-channel-upgrade.test.ts @@ -19,7 +19,8 @@ type Invocation = { command: string; prefix: string[] } | null; * `which` finds `/`; `invocation`, when given, is the host's adapter. */ function world({ owner = "pnpm", latest = "4.1.0", commits = { ai: AI_SHA, shell: SHELL_SHA }, tools = ["go", "pnpm", "npm"], platform = "darwin", bin = "/usr/bin", suffix = "", invocation = undefined as ((name: string) => Promise) | undefined, fs = fsPromises as typeof fsPromises, - migrate = undefined as ((input: { latest: string; owner: () => Promise }) => Promise) | undefined } = {}) { + migrate = undefined as ((input: { latest: string; owner: () => Promise }) => Promise) | undefined, + failGoInstall = false, failShellInstall = false } = {}) { // owner "linked": `npm link` of a source checkout, so npm's global entry points outside npm's root. const root = realpathSync(mkdtempSync(join(tmpdir(), "upgrade-"))); const home = join(root, "home"); @@ -50,6 +51,7 @@ function world({ owner = "pnpm", latest = "4.1.0", commits = { ai: AI_SHA, shell } calls.push({ command, argv }); if (argv[0] === "install" && argv[1]?.includes("gentle-ai/v4/cmd/gentle-ai@")) { + if (failGoInstall) return { code: 1, stdout: "" }; mkdirSync(options.env!.GOBIN, { recursive: true }); for (const name of ["gentle-ai", "gentle-ai.exe"]) writeFileSync(join(options.env!.GOBIN, name), "#!/bin/sh\n", { mode: 0o755 }); return { code: 0, stdout: "" }; @@ -73,6 +75,7 @@ function world({ owner = "pnpm", latest = "4.1.0", commits = { ai: AI_SHA, shell writeFileSync(join(argv[argv.indexOf("-C") + 1], "package.json"), JSON.stringify({ name: "gentle-pi", version: "4.1.0", scripts: { prepack: "x" } })); return { code: 0, stdout: "" }; } + if (failShellInstall && argv.includes("-g") && argv.some((arg) => arg.endsWith(".tgz"))) return { code: 1, stdout: "" }; if (argv.includes("pack")) { const version = JSON.parse(readFileSync(join(options.cwd!, "package.json"), "utf8")).version; writeFileSync(join(argv[argv.indexOf("--pack-destination") + 1], `gentle-pi-${version}.tgz`), "tgz"); @@ -194,6 +197,141 @@ test("a main install rebuilds only what moved on main and records the new commit } finally { w.cleanup(); } }); +// #2356: a Gentle AI step that fails still installs a main Shell that moved, so the +// retry runs the new Shell's code; the channel is recorded only when both succeed. +const GO_INSTALL_FAILED = `main-gentle-ai-build-failed: go install github.com/gentleman-programming/gentle-ai/v4/cmd/gentle-ai@${NEW_AI} failed`; +const PRIOR_REGISTRATION = JSON.stringify({ schema: "gentle-pi.dev-binary/v1", path: "/prior/main/gentle-ai" }); +const NEW_SHELL_TGZ = (home: string) => join(home, ".pi", "gentle-ai", "main", "packages", "gentle-pi-4.1.0-main.333333333333.tgz"); +async function onPriorMain(w: ReturnType) { + await writeChannel(w.ctx, { channel: "main", shellCommit: SHELL_SHA, gentleAiCommit: AI_SHA }, fsPromises); + writeFileSync(join(w.home, ".pi", "gentle-ai", "dev-binary.json"), PRIOR_REGISTRATION); + return () => ({ channel: readFileSync(join(w.home, ".pi", "gentle-ai", "channel.json"), "utf8"), registration: readFileSync(join(w.home, ".pi", "gentle-ai", "dev-binary.json"), "utf8") }); +} + +test("a failed Gentle AI build still installs the main Shell that moved, records no channel and names the retry", posixOnly, async () => { + const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, failGoInstall: true }); + try { + const files = await onPriorMain(w); + const before = files(); + await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => { + assert.equal(error.code, "main-gentle-ai-build-failed"); + assert.equal(error.message, `${GO_INSTALL_FAILED}; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish`); + return true; + }); + assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]); + assert.deepEqual(w.lines, ["Updated Gentle Shell to the latest main (333333333333); Gentle AI was not updated."]); + assert.deepEqual(files(), before); + assert.deepEqual(JSON.parse(before.registration), JSON.parse(PRIOR_REGISTRATION)); + } finally { w.cleanup(); } +}); + +test("a Go that cannot be obtained for the Gentle AI build still installs the main Shell that moved", posixOnly, async () => { + const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, tools: ["pnpm", "npm"] }); + try { + const files = await onPriorMain(w); + const before = files(); + await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => { + assert.equal(error.code, "main-requires-tools"); + assert.match(error.message, /^main-requires-tools: the main channel builds Gentle AI with Go .*Unexpected Go acquisition in sandboxed upgrade fixture.*; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish$/); + return true; + }); + assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]); + assert.deepEqual(w.lines, ["Updated Gentle Shell to the latest main (333333333333); Gentle AI was not updated."]); + assert.deepEqual(files(), before); + } finally { w.cleanup(); } +}); + +test("a failed Gentle AI build with the main Shell already current fails as before: nothing installed or recorded", posixOnly, async () => { + const w = world({ commits: { ai: NEW_AI, shell: SHELL_SHA }, failGoInstall: true }); + try { + const files = await onPriorMain(w); + const before = files(); + await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => { + assert.equal(error.code, "main-gentle-ai-build-failed"); + assert.equal(error.message, GO_INSTALL_FAILED); + return true; + }); + assert.deepEqual(w.installs(), []); + assert.deepEqual(w.lines, []); + assert.deepEqual(files(), before); + } finally { w.cleanup(); } +}); + +test("when the Gentle AI build and the Shell install both fail, the Shell failure is reported with the Gentle AI one", posixOnly, async () => { + const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, failGoInstall: true, failShellInstall: true }); + try { + const files = await onPriorMain(w); + const before = files(); + await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => { + assert.equal(error.code, "upgrade-install-failed"); + assert.equal(error.message, `upgrade-install-failed: pnpm could not install ${NEW_SHELL_TGZ(w.home)}; Gentle AI was not updated either (${GO_INSTALL_FAILED})`); + return true; + }); + assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]); + assert.deepEqual(w.lines, []); + assert.deepEqual(files(), before); + } finally { w.cleanup(); } +}); + +// An ordinary failure that is not a MainChannelError (EACCES) keeps the retry advice and both errors. +const eacces = (what: string) => Object.assign(new Error(`EACCES: permission denied, ${what}`), { code: "EACCES" }); +const COPY_DENIED = eacces("copyfile"); +const MANIFEST_DENIED = eacces("open package.json"); +const deniedFs = ({ copy = false, manifest = false }) => ({ ...fsPromises, + copyFile: async (...args: Parameters) => { if (copy) throw COPY_DENIED; return fsPromises.copyFile(...args); }, + readFile: (async (path: string, options?: unknown) => { + if (manifest && String(path).endsWith(join(".source", "src", "package.json"))) throw MANIFEST_DENIED; + return fsPromises.readFile(path, options as never); + }) as typeof fsPromises.readFile }) as typeof fsPromises; + +test("an ordinary error in the Gentle AI step still installs the main Shell that moved and names the retry", posixOnly, async () => { + const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: deniedFs({ copy: true }) }); + try { + const files = await onPriorMain(w); + const before = files(); + await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => { + assert.ok(error instanceof MainChannelError); + assert.equal(error.code, "main-upgrade-incomplete"); + assert.equal(error.message, "main-upgrade-incomplete: EACCES: permission denied, copyfile; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish"); + assert.equal(error.cause, COPY_DENIED); + return true; + }); + assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]); + assert.deepEqual(w.lines, ["Updated Gentle Shell to the latest main (333333333333); Gentle AI was not updated."]); + assert.deepEqual(files(), before); + } finally { w.cleanup(); } +}); + +test("ordinary errors in both steps report the Shell error with the Gentle AI one", posixOnly, async () => { + const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: deniedFs({ copy: true, manifest: true }) }); + try { + const files = await onPriorMain(w); + const before = files(); + await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => { + assert.ok(error instanceof MainChannelError); + assert.equal(error.code, "main-upgrade-incomplete"); + assert.equal(error.message, "main-upgrade-incomplete: EACCES: permission denied, open package.json; Gentle AI was not updated either (EACCES: permission denied, copyfile)"); + assert.equal(error.cause, MANIFEST_DENIED); + return true; + }); + assert.deepEqual(w.installs(), []); + assert.deepEqual(w.lines, []); + assert.deepEqual(files(), before); + } finally { w.cleanup(); } +}); + +test("an ordinary error in the Gentle AI step with the main Shell already current is rethrown unchanged", posixOnly, async () => { + const w = world({ commits: { ai: NEW_AI, shell: SHELL_SHA }, fs: deniedFs({ copy: true }) }); + try { + const files = await onPriorMain(w); + const before = files(); + await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: Error) => error === COPY_DENIED); + assert.deepEqual(w.installs(), []); + assert.deepEqual(w.lines, []); + assert.deepEqual(files(), before); + } finally { w.cleanup(); } +}); + test("a main install whose Gentle AI did not move rebuilds only Gentle Shell", posixOnly, async () => { const w = world({ commits: { ai: AI_SHA, shell: NEW_SHELL } }); try { From 344a2e0e940e1828723d5da6d63d750bbbfa74c2 Mon Sep 17 00:00:00 2001 From: ElCaaarnal Date: Fri, 16 Oct 2026 20:07:58 -0700 Subject: [PATCH 2/4] fix(main-channel): treat any Gentle AI rejection as a failed step A falsy rejection (undefined, null, 0, false) from the Gentle AI step was read as success: the upgrade recorded both commits without building Gentle AI. A separate failure flag decides the fail-forward path, and a thrown value is described by its message when it has one. The docs now make finishing Gentle AI on retry depend on resolving the original cause. Refs #2356 --- README.md | 2 +- docs/install-wizard.md | 8 +++-- scripts/main-channel.mjs | 16 ++++++--- tests/main-channel-upgrade.test.ts | 54 ++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9145e20af..f39281707 100644 --- a/README.md +++ b/README.md @@ -579,7 +579,7 @@ gentle-shell upgrade --rollback
-- **Main channel:** On the main channel it rebuilds Gentle AI and Gentle Shell from the latest `main` commits (only what changed) and needs pnpm and Go (a Go older than the minimum is replaced, for the build only, by the pinned Go the installer left in its own folder); switching back to release restores the pinned Gentle AI binary. When the Gentle AI build fails but Gentle Shell moved, the new Gentle Shell is still installed and the command exits with an error asking you to run `gentle-shell upgrade --channel main` again, which finishes Gentle AI with the new Shell's code. +- **Main channel:** On the main channel it rebuilds Gentle AI and Gentle Shell from the latest `main` commits (only what changed) and needs pnpm and Go (a Go older than the minimum is replaced, for the build only, by the pinned Go the installer left in its own folder); switching back to release restores the pinned Gentle AI binary. When the Gentle AI build fails but Gentle Shell moved, the new Gentle Shell is still installed and the command exits with an error asking you to run `gentle-shell upgrade --channel main` again. The retry rebuilds Gentle AI with the new Shell's code, and finishes once the cause of the original error is resolved. - **Your Pi:** With pnpm 11, the updated Gentle Shell may run a newer Pi that pnpm puts next to it (always at least the minimum); your own Pi is never changed. - **Bundled install:** A bundled install (the web installer's own Node, pnpm and Pi) follows the release channel only: it installs the new version beside the current one, verifies it, then switches, keeping the previous version for `--rollback`. On release, once the latest release publishes the bundled distribution, `upgrade` moves a global pnpm or npm installation to the bundled one and asks once before removing the old global `gentle-pi`. - **`update` vs `upgrade`:** `gentle-shell update` is a different command: it is Pi's own package update. In a bundled install Pi ships pinned with Gentle Shell, so updating Pi itself runs `gentle-shell upgrade` instead. diff --git a/docs/install-wizard.md b/docs/install-wizard.md index 3827fd8b4..c2e0b889d 100644 --- a/docs/install-wizard.md +++ b/docs/install-wizard.md @@ -816,8 +816,9 @@ publishes main builds, so main is built on this computer a version it cannot check) it builds with the pinned Go under `/tools/go`, downloading and verifying it there with the same `acquireGo` as the wizard when no earlier run published it, and only when - Gentle AI is rebuilt. A failed download stops with `main-requires-tools` - before anything is built; the user's Go is never run for the build. + Gentle AI is rebuilt. A failed download stops the Gentle AI build with + `main-requires-tools` before it starts (a main Shell that moved is still + installed, as below); the user's Go is never run for the build. - When the Gentle AI step of `gentle-shell upgrade --channel main` fails (the build, its verification, or getting the pinned Go) and the main Shell moved, the upgrade still packs and installs that Shell, records no channel, prints @@ -826,7 +827,8 @@ publishes main builds, so main is built on this computer `; Gentle Shell was updated to main but Gentle AI was not: run gentle-shell upgrade --channel main to finish`. The retry then rebuilds Gentle AI with the new Shell's code, so a fix in the main-channel upgrade path reaches - an install whose own copy of it is broken. Until then Gentle AI stays the + an install whose own copy of it is broken; a cause the new Shell does not fix + (a Go download that keeps failing, for example) still has to be resolved first. Until then Gentle AI stays the previously registered main build or the pinned binary: a rejected build is never registered. When the Shell install fails as well, its error is reported with `; Gentle AI was not updated either ()`. An unexpected diff --git a/scripts/main-channel.mjs b/scripts/main-channel.mjs index 0ff5c343d..c64a1e434 100644 --- a/scripts/main-channel.mjs +++ b/scripts/main-channel.mjs @@ -424,11 +424,16 @@ async function mainGo({ userGo, pinnedGo, acquire, run }) { } } +/** A thrown value's message when it has one (an Error or not), otherwise the value as text. */ +function describe(error) { + return typeof error?.message === "string" ? error.message : String(error); +} + /** `error` with `detail` appended to its message, keeping its code and cause; any * other error becomes `main-upgrade-incomplete` with its message and itself as cause. */ function withDetail(error, detail) { if (!(error instanceof MainChannelError)) { - return new MainChannelError("main-upgrade-incomplete", `${error?.message ?? error}; ${detail}`, error); + return new MainChannelError("main-upgrade-incomplete", `${describe(error)}; ${detail}`, error); } const message = error.message.slice(`${error.code}: `.length); return new MainChannelError(error.code, `${message}; ${detail}`, error.cause); @@ -491,12 +496,15 @@ export async function runUpgrade({ args, ctx, platform, arch = process.arch, pac // Fail forward (#2356): a Gentle AI step that fails still installs a main Shell that // moved, so the retry runs the new Shell's code; the channel is recorded only when // both steps succeed, so that retry rebuilds Gentle AI. Verification stays fail-closed. - let aiError = null; + // A flag, not the caught value: a rejection may be any value, even a falsy one. + let aiFailed = false; + let aiError; if (!aiCurrent) { try { await buildMainGentleAi({ commit: gentleAiCommit, ctx, platform, goPath: await mainGo({ userGo, pinnedGo, acquire, run }), run, fs }); } catch (error) { if (shellCurrent) throw error; + aiFailed = true; aiError = error; } } @@ -506,10 +514,10 @@ export async function runUpgrade({ args, ctx, platform, arch = process.arch, pac const tgz = await packMainShell({ commit: shellCommit, ctx, fetch, run, pnpm, fs, platform }); await installGlobal(manager, tgz, run); } catch (error) { - throw aiError ? withDetail(error, `Gentle AI was not updated either (${aiError.message ?? aiError})`) : error; + throw aiFailed ? withDetail(error, `Gentle AI was not updated either (${describe(aiError)})`) : error; } } - if (aiError) { + if (aiFailed) { out(`Updated Gentle Shell to the latest main (${shellCommit.slice(0, 12)}); Gentle AI was not updated.`); throw withDetail(aiError, `Gentle Shell was updated to main ${shellCommit.slice(0, 12)} but Gentle AI was not: run gentle-shell upgrade --channel main to finish`); } diff --git a/tests/main-channel-upgrade.test.ts b/tests/main-channel-upgrade.test.ts index 3f55e8d71..45c0db63d 100644 --- a/tests/main-channel-upgrade.test.ts +++ b/tests/main-channel-upgrade.test.ts @@ -332,6 +332,60 @@ test("an ordinary error in the Gentle AI step with the main Shell already curren } finally { w.cleanup(); } }); +// A rejection that is not an Error, even a falsy one, is still a failed Gentle AI step. +const rejectingCopy = (value: unknown) => ({ ...fsPromises, copyFile: async () => { throw value; } }) as typeof fsPromises; + +test("a falsy rejection in the Gentle AI step still records no channel and names the retry", posixOnly, async () => { + for (const value of [undefined, null, 0, false]) { + const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: rejectingCopy(value) }); + try { + const files = await onPriorMain(w); + const before = files(); + await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => { + assert.ok(error instanceof MainChannelError); + assert.equal(error.code, "main-upgrade-incomplete"); + assert.equal(error.message, `main-upgrade-incomplete: ${String(value)}; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish`); + return true; + }); + assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]); + assert.deepEqual(w.lines, ["Updated Gentle Shell to the latest main (333333333333); Gentle AI was not updated."]); + assert.deepEqual(files(), before); + } finally { w.cleanup(); } + } +}); + +test("a rejection that is not an Error but has a message keeps that message", posixOnly, async () => { + for (const failShellInstall of [false, true]) { + const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: rejectingCopy({ message: "EACCES: copy denied", code: "EACCES" }), failShellInstall }); + try { + const files = await onPriorMain(w); + const before = files(); + await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => { + assert.equal(error.message, failShellInstall + ? `upgrade-install-failed: pnpm could not install ${NEW_SHELL_TGZ(w.home)}; Gentle AI was not updated either (EACCES: copy denied)` + : "main-upgrade-incomplete: EACCES: copy denied; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish"); + return true; + }); + assert.deepEqual(files(), before); + } finally { w.cleanup(); } + } +}); + +test("a null rejection in the Gentle AI step is kept when the Shell install fails too", posixOnly, async () => { + const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: rejectingCopy(null), failShellInstall: true }); + try { + const files = await onPriorMain(w); + const before = files(); + await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => { + assert.equal(error.code, "upgrade-install-failed"); + assert.equal(error.message, `upgrade-install-failed: pnpm could not install ${NEW_SHELL_TGZ(w.home)}; Gentle AI was not updated either (null)`); + return true; + }); + assert.deepEqual(w.lines, []); + assert.deepEqual(files(), before); + } finally { w.cleanup(); } +}); + test("a main install whose Gentle AI did not move rebuilds only Gentle Shell", posixOnly, async () => { const w = world({ commits: { ai: AI_SHA, shell: NEW_SHELL } }); try { From 841074932b4a574b6715017af4f9f426e4e8f4a3 Mon Sep 17 00:00:00 2001 From: ElCaaarnal Date: Fri, 16 Oct 2026 20:28:22 -0700 Subject: [PATCH 3/4] test(main-channel): cover the retry that completes a fail-forward upgrade After a failed Gentle AI step installed the moved Shell, the retry from that Shell rebuilds Gentle AI, registers it, records both commits, and a further run is a no-op. Refs #2356 --- tests/main-channel-upgrade.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/main-channel-upgrade.test.ts b/tests/main-channel-upgrade.test.ts index 45c0db63d..a740351dd 100644 --- a/tests/main-channel-upgrade.test.ts +++ b/tests/main-channel-upgrade.test.ts @@ -241,6 +241,35 @@ test("a Go that cannot be obtained for the Gentle AI build still installs the ma } finally { w.cleanup(); } }); +test("after a fail-forward, the retry from the new Shell builds Gentle AI and records both commits", posixOnly, async () => { + // The first copy of the built binary fails (as a rejected build would); the retry's succeeds. + let copies = 0; + const fs = { ...fsPromises, copyFile: async (...args: Parameters) => { + if (copies++ === 0) throw new MainChannelError("main-gentle-ai-unverified", "first attempt rejected"); + return fsPromises.copyFile(...args); + } } as typeof fsPromises; + const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs }); + try { + const files = await onPriorMain(w); + const before = files(); + await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => error.code === "main-gentle-ai-unverified"); + assert.deepEqual(files(), before); + // The retry runs from the Shell the first attempt installed. No channel was recorded, + // so it rebuilds Gentle AI and repacks the Shell before recording both commits. + w.lines.length = 0; + assert.equal(await w.upgrade([], "4.1.0-main.333333333333"), 0); + assert.deepEqual(w.lines, ["Updated to the latest main: Gentle Shell 333333333333, Gentle AI 222222222222."]); + assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`, `/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]); + assert.deepEqual(await readChannel(w.ctx, fsPromises), { channel: "main", shellCommit: NEW_SHELL, gentleAiCommit: NEW_AI }); + assert.equal(JSON.parse(readFileSync(join(w.home, ".pi", "gentle-ai", "dev-binary.json"), "utf8")).path, + join(w.home, ".pi", "gentle-ai", "main", "gentle-ai", NEW_AI, "gentle-ai")); + // A third run is a no-op: both commits are current. + w.lines.length = 0; + assert.equal(await w.upgrade([], "4.1.0-main.333333333333"), 0); + assert.deepEqual(w.lines, ["gentle-shell is already at the latest main: Gentle Shell 333333333333, Gentle AI 222222222222."]); + } finally { w.cleanup(); } +}); + test("a failed Gentle AI build with the main Shell already current fails as before: nothing installed or recorded", posixOnly, async () => { const w = world({ commits: { ai: NEW_AI, shell: SHELL_SHA }, failGoInstall: true }); try { From af7ba736a4fa92ef76158b9e97cd697af29160d7 Mon Sep 17 00:00:00 2001 From: ElCaaarnal Date: Fri, 16 Oct 2026 20:51:13 -0700 Subject: [PATCH 4/4] test(main-channel): state that the retry test simulates the new Shell's run Refs #2356 --- tests/main-channel-upgrade.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/main-channel-upgrade.test.ts b/tests/main-channel-upgrade.test.ts index a740351dd..7c1bdffd5 100644 --- a/tests/main-channel-upgrade.test.ts +++ b/tests/main-channel-upgrade.test.ts @@ -254,7 +254,8 @@ test("after a fail-forward, the retry from the new Shell builds Gentle AI and re const before = files(); await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => error.code === "main-gentle-ai-unverified"); assert.deepEqual(files(), before); - // The retry runs from the Shell the first attempt installed. No channel was recorded, + // The retry is simulated as the new Shell's run: the same runUpgrade with the new + // version (the installed package itself is not executed). No channel was recorded, // so it rebuilds Gentle AI and repacks the Shell before recording both commits. w.lines.length = 0; assert.equal(await w.upgrade([], "4.1.0-main.333333333333"), 0);