From 8e18441bf164c1498d0b2d1d02349ae030f3ace5 Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 15:54:43 +0000 Subject: [PATCH 1/3] fix(install): a child's install for another workflow no longer drops /ship's install verdict (GST-1233) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-capy-install | 6 +- bin/gstack-install-verdict.sh | 111 +++++++++++++++----------- changelog.d/gst-1233-verdict-union.md | 3 + docs/capy-install.md | 9 ++- lib/install-verdict.ts | 18 ++++- lib/pregate/runtime.ts | 10 +-- test/gstack-capy-install.test.ts | 30 +++++++ test/install-verdict.test.ts | 18 +++++ 8 files changed, 152 insertions(+), 53 deletions(-) create mode 100644 changelog.d/gst-1233-verdict-union.md diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index 4ad259627..d5343f2e8 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -429,7 +429,9 @@ verdict_kept() { # An ok run keeps what an earlier ok install certified for this checkout, project, HEAD and Bun, # so one machine serving /ship and its docs step stays admitted for both (#3561): required is # the union, pins count as planning-only only when both runs skipped them, and for= lists every -# workflow (comma-separated). A failing run, another HEAD or another Bun replaces the verdict. +# workflow (comma-separated). A failing run, another HEAD or another Bun replaces the verdict, so +# each run also records its own workflow's verdict at .for/.json, which admission +# falls back to: a later install for another workflow never drops this one (GST-1233). record_verdict() { # TRAILER OK — --check keeps the recorded resolution (drift is reported, never re-recorded) local v_for="${FOR:-}" v_req v_plan e_for id if verdict_kept; then @@ -440,6 +442,8 @@ record_verdict() { # TRAILER OK — --check keeps the recorded resolution (drift else set -- "$1" "$2" "$RES_BIN" "$RES_VER" "$RES_RULE" "${BUN_INSTALL:-}"; fi v_req=$(printf '%s\n' "${OUT:-}" | sed -n 's/^required: //p' | head -1) v_plan=$(printf '%s\n' "${OUT:-}" | grep -q '^SKIP pins (planning-only' && echo 1 || echo 0) + case "$FOR" in ''|*[!A-Za-z0-9._-]*) ;; *) [ "$PROFILE" -eq 1 ] || gstack_install_verdict_write "${VERDICT%.json}.for/$FOR.json" "$ROOT_REAL" "$HEAD_SHA" "$FOR" "$v_req" "$v_plan" "$2" "$1" "${MIN_REVISION:-}" "$LAUNCH_DIR" \ + "$PROJECT_REAL" "$PIN_EXACT" "$3" "$4" "$5" "$6" "$NODE_BIN" "$NODE_VER" "$NODE_RULE" || say "could not write the install verdict ${VERDICT%.json}.for/$FOR.json" ;; esac if [ "$2" = 1 ] && [ "$PROFILE" -eq 0 ] && [ -f "$VERDICT" ] && [ "$(gstack_install_verdict_field "$VERDICT" ok)" = true ] \ && [ "$(gstack_install_verdict_field "$VERDICT" head)" = "$HEAD_SHA" ] && [ "$(gstack_install_verdict_field "$VERDICT" checkout)" = "$ROOT_REAL" ] \ && [ "$(gstack_install_verdict_field "$VERDICT" bun_path)" = "$3" ] && e_for=$(gstack_install_verdict_field "$VERDICT" for) \ diff --git a/bin/gstack-install-verdict.sh b/bin/gstack-install-verdict.sh index 7477dd56d..8cfa6854d 100644 --- a/bin/gstack-install-verdict.sh +++ b/bin/gstack-install-verdict.sh @@ -21,6 +21,9 @@ # /install-verdicts/.json. An install # profile's verdict (for=review-voice) at the keyed path yields to the launched # GSTACK_INSTALL_VERDICT, so a reviewer's install never shadows it (GST-1161). +# Each workflow install also writes its own record, .for/.json, +# which admission tries when the shared verdict does not admit that workflow, so +# another workflow's later install never drops it (GST-1233). # # Admission (gstack-skill-start, after SESSION_KIND): an unattended session, # or an explicit GSTACK_SESSION_KIND=spawned session with GSTACK_CAPY=1, @@ -109,12 +112,62 @@ gstack_install_verdict_field() { esac } +# _gstack_ia_check — the verdict checks gstack_install_admission runs on $file +# for $skill (its locals: src, project, where, found): sets detail (empty when +# the verdict admits), fix, code, trailer and, for a Bun drift, _ia_status. +_gstack_ia_check() { + detail="" + code=INSTALL_UNVERIFIED + fix="run the Capy skill installer (scripts/install.sh --for $skill) on this machine, then launch with the GSTACK_LAUNCH line it prints" + if [ ! -f "$file" ]; then + detail="no install verdict at $file for $src" + fix="run the Capy skill installer (scripts/install.sh --for $skill) from ${project:-this directory}: the verdict is keyed by the project directory the installer runs in, so a run from the skill's own folder does not cover ${project:-it}; then launch with the GSTACK_LAUNCH line it prints" + else + trailer=$(gstack_install_verdict_field "$file" trailer) + v_head=$(gstack_install_verdict_field "$file" head) + head=$(git -C "$src" rev-parse HEAD 2>/dev/null) + v_schema=$(gstack_install_verdict_field "$file" schema_version) + if { [ -z "$(gstack_install_verdict_field "$file" ok)" ] && [ -z "$v_schema" ]; } || { [ "$v_schema" != 1 ] && [ "$v_schema" != 2 ]; }; then + detail="the install verdict at $file is unreadable or has an unsupported schema_version; rerun the installer" + elif [ "$(gstack_install_verdict_field "$file" ok)" != true ]; then + detail="the install verdict says: $trailer" + elif [ "$(gstack_install_verdict_field "$file" checkout)" != "$src" ]; then + detail="the install verdict is for checkout $(gstack_install_verdict_field "$file" checkout), not $src" + elif [ -z "$head" ] || [ "$v_head" != "$head" ]; then + detail="the install verdict is for another HEAD (${v_head:0:12}; $src is at ${head:0:12})" + elif [ "$(gstack_install_verdict_field "$file" pins_planning_only)" = true ] && ! gstack_doctor_skill_pins_advisory "$skill"; then + detail="the install verdict is planning-only (pins skipped for $(gstack_install_verdict_field "$file" for)) and $skill runs the repo's tests" + elif [ "$(gstack_install_verdict_field "$file" schema_version)" != 1 ] && ! gstack_doctor_skill_pins_advisory "$skill" \ + && v_project=$(gstack_install_verdict_field "$file" project) && [ "$v_project" != "$project" ]; then + detail="the install verdict checked ${v_project:-no project}, not ${project:-this directory (no project)}, and $skill runs the repo's tests" + fix="run the Capy skill installer (scripts/install.sh --for $skill) from ${project:-this directory}, then launch with the GSTACK_LAUNCH line it prints" + else + for id in $(gstack_doctor_skill_requires "$skill" 2>/dev/null); do + case " $(gstack_install_verdict_field "$file" required) " in *" $id "*) ;; *) detail="the install verdict certified for=$(gstack_install_verdict_field "$file" for), which does not cover $id that $skill needs"; break ;; esac + done + if [ -n "$detail" ] && gstack_doctor_install_profile "$(gstack_install_verdict_field "$file" for)"; then + detail="$detail ($(gstack_install_verdict_field "$file" for) certifies only the read-only helpers)" + fix="run GSTACK_CAPY_ROOT=$src bash $src/capy/skill/gstack/scripts/install.sh --for $skill (upgrades this checkout in place: ./setup and host profiles, no second clone), then launch with the GSTACK_LAUNCH line it prints" + fi + fi + v_bun=$(gstack_install_verdict_field "$file" bun_path) + if [ -z "$detail" ] && [ -n "$v_bun" ]; then + running=$(gstack_realpath "$where" 2>/dev/null) + _gstack_bun_is_shim "$where" && running=$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -p process.execPath /dev/null | head -1) + if [ -n "$running" ] && [ "$running" != "$v_bun" ]; then + _ia_status=runtime code=BUN_RESOLUTION_DRIFT + detail="bun $found at $running is running, but the install verdict recorded bun $(gstack_install_verdict_field "$file" bun_version) at $v_bun" + fix="launch with the GSTACK_LAUNCH line (PATH=$(dirname "$v_bun"):\$PATH), or re-run the Capy skill installer with --check (scripts/install.sh --check --for $skill) to see what changed" + fi + fi + fi +} # gstack_install_admission BIN_DIR SKILL KIND — sets _ia_applies (0|1), # _ia_refuse (0|1), _ia_warn (0|1), _ia_status (ok|runtime|verdict), # _ia_line (the INSTALL: or INSTALL_VERDICT: line to print, may be empty). # Needs gstack-bun-version.sh and gstack-doctor-components.sh sourced. gstack_install_admission() { - local bin="$1" skill="$2" kind="$3" hard=0 capy=0 src where found detail="" fix="" code="" file trailer v_head head id project v_project running v_bun + local bin="$1" skill="$2" kind="$3" hard=0 capy=0 src where found detail="" fix="" code="" file trailer v_head head id project v_project running v_bun v_schema _ia_own _ia_m_file _ia_m_detail _ia_m_fix _ia_m_code _ia_m_status _ia_m_trailer _ia_applies=0 _ia_refuse=0 _ia_warn=0 _ia_status=ok _ia_line="" case "$kind" in unattended|spawned) _ia_applies=1 ;; *) return 0 ;; esac [ "${GSTACK_CAPY:-}" = 1 ] && capy=1 @@ -136,51 +189,19 @@ gstack_install_admission() { esac [ -f "$file" ] || [ ! -f "${GSTACK_INSTALL_VERDICT:-}" ] || file="$GSTACK_INSTALL_VERDICT" [ -f "$file" ] || file=$(gstack_install_verdict_legacy_path "${_GH:-$HOME/.gstack}" "$src") - code=INSTALL_UNVERIFIED - fix="run the Capy skill installer (scripts/install.sh --for $skill) on this machine, then launch with the GSTACK_LAUNCH line it prints" - if [ ! -f "$file" ]; then - detail="no install verdict at $file for $src" - fix="run the Capy skill installer (scripts/install.sh --for $skill) from ${project:-this directory}: the verdict is keyed by the project directory the installer runs in, so a run from the skill's own folder does not cover ${project:-it}; then launch with the GSTACK_LAUNCH line it prints" - else - trailer=$(gstack_install_verdict_field "$file" trailer) - v_head=$(gstack_install_verdict_field "$file" head) - head=$(git -C "$src" rev-parse HEAD 2>/dev/null) - v_schema=$(gstack_install_verdict_field "$file" schema_version) - if { [ -z "$(gstack_install_verdict_field "$file" ok)" ] && [ -z "$v_schema" ]; } || { [ "$v_schema" != 1 ] && [ "$v_schema" != 2 ]; }; then - detail="the install verdict at $file is unreadable or has an unsupported schema_version; rerun the installer" - elif [ "$(gstack_install_verdict_field "$file" ok)" != true ]; then - detail="the install verdict says: $trailer" - elif [ "$(gstack_install_verdict_field "$file" checkout)" != "$src" ]; then - detail="the install verdict is for checkout $(gstack_install_verdict_field "$file" checkout), not $src" - elif [ -z "$head" ] || [ "$v_head" != "$head" ]; then - detail="the install verdict is for another HEAD (${v_head:0:12}; $src is at ${head:0:12})" - elif [ "$(gstack_install_verdict_field "$file" pins_planning_only)" = true ] && ! gstack_doctor_skill_pins_advisory "$skill"; then - detail="the install verdict is planning-only (pins skipped for $(gstack_install_verdict_field "$file" for)) and $skill runs the repo's tests" - elif [ "$(gstack_install_verdict_field "$file" schema_version)" != 1 ] && ! gstack_doctor_skill_pins_advisory "$skill" \ - && v_project=$(gstack_install_verdict_field "$file" project) && [ "$v_project" != "$project" ]; then - detail="the install verdict checked ${v_project:-no project}, not ${project:-this directory (no project)}, and $skill runs the repo's tests" - fix="run the Capy skill installer (scripts/install.sh --for $skill) from ${project:-this directory}, then launch with the GSTACK_LAUNCH line it prints" - else - for id in $(gstack_doctor_skill_requires "$skill" 2>/dev/null); do - case " $(gstack_install_verdict_field "$file" required) " in *" $id "*) ;; *) detail="the install verdict certified for=$(gstack_install_verdict_field "$file" for), which does not cover $id that $skill needs"; break ;; esac - done - if [ -n "$detail" ] && gstack_doctor_install_profile "$(gstack_install_verdict_field "$file" for)"; then - detail="$detail ($(gstack_install_verdict_field "$file" for) certifies only the read-only helpers)" - fix="run GSTACK_CAPY_ROOT=$src bash $src/capy/skill/gstack/scripts/install.sh --for $skill (upgrades this checkout in place: ./setup and host profiles, no second clone), then launch with the GSTACK_LAUNCH line it prints" - fi - fi - v_bun=$(gstack_install_verdict_field "$file" bun_path) - if [ -z "$detail" ] && [ -n "$v_bun" ]; then - running=$(gstack_realpath "$where" 2>/dev/null) - _gstack_bun_is_shim "$where" && running=$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -p process.execPath /dev/null | head -1) - if [ -n "$running" ] && [ "$running" != "$v_bun" ]; then - _ia_status=runtime code=BUN_RESOLUTION_DRIFT - detail="bun $found at $running is running, but the install verdict recorded bun $(gstack_install_verdict_field "$file" bun_version) at $v_bun" - fix="launch with the GSTACK_LAUNCH line (PATH=$(dirname "$v_bun"):\$PATH), or re-run the Capy skill installer with --check (scripts/install.sh --check --for $skill) to see what changed" - fi - fi - [ -n "$detail" ] || { _ia_line="INSTALL_VERDICT: ok $trailer"; return 0; } + _gstack_ia_check + # GST-1233: each install also keeps its own verdict per workflow, so when a + # later install for another workflow replaced the shared one, this + # workflow's own record still admits it. + _ia_own="${file%.json}.for/$skill.json" + case "$skill" in ''|*[!A-Za-z0-9._-]*) _ia_own="" ;; esac + if [ -n "$detail" ] && [ -n "$_ia_own" ] && [ -f "$_ia_own" ]; then + _ia_m_file=$file _ia_m_detail=$detail _ia_m_fix=$fix _ia_m_code=$code _ia_m_status=$_ia_status _ia_m_trailer=$trailer + file=$_ia_own _ia_status=ok + _gstack_ia_check + [ -z "$detail" ] || { file=$_ia_m_file detail=$_ia_m_detail fix=$_ia_m_fix code=$_ia_m_code _ia_status=$_ia_m_status trailer=$_ia_m_trailer; } fi + [ -n "$detail" ] || { _ia_line="INSTALL_VERDICT: ok $trailer"; return 0; } [ "$_ia_status" = runtime ] || _ia_status=verdict else return 0 diff --git a/changelog.d/gst-1233-verdict-union.md b/changelog.d/gst-1233-verdict-union.md new file mode 100644 index 000000000..fd40a6b03 --- /dev/null +++ b/changelog.d/gst-1233-verdict-union.md @@ -0,0 +1,3 @@ +### Fixed + +- **A child's install for another workflow no longer drops /ship's install verdict on a shared machine (GST-1233).** When a /ship docs child ran `install.sh --for document-release` with a different Bun on PATH, or its install failed, it replaced the shared install verdict, and the parent's finish pregate refused with `INSTALL_UNVERIFIED` until someone reinstalled `--for ship`. Each workflow install now also writes that workflow's own verdict beside the shared one (`.for/.json`), and workflow admission and the pregate fall back to it with the same checks. A new HEAD still invalidates both. diff --git a/docs/capy-install.md b/docs/capy-install.md index 6f0feef46..d499a78c7 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -330,7 +330,14 @@ keeps the lease. `bin/gstack-lease list` shows `alive|idle|dead|stale`, and the installer prints `the install verdict at keeps for= and adds (for=)`, and the trailer still names this run's workflow). A failing run, a new HEAD or another Bun replaces the verdict, and so does any - workflow install over a review-voice verdict. Admission refuses a workflow whose + workflow install over a review-voice verdict. Each workflow install (and + `--check`) also writes that workflow's own verdict beside it, at + `.for/.json`, with only this run's result. When the shared + verdict does not admit a workflow, admission and the pregate try the + workflow's own record with the same checks, so a later install for another + workflow on a shared machine (a /ship docs child resolving another Bun, or a + failed child install) never drops /ship's admission (GST-1233). A new HEAD + still invalidates both. Admission refuses a workflow whose running Bun is not the one the verdict recorded ([BUN_RESOLUTION_DRIFT](troubleshooting.md#bun-resolution-drift)), and so do the pregate's lanes and guards, which also refuse an invalid verdict diff --git a/lib/install-verdict.ts b/lib/install-verdict.ts index f9599a477..1e05e3df3 100644 --- a/lib/install-verdict.ts +++ b/lib/install-verdict.ts @@ -142,7 +142,23 @@ export interface VerdictProblem { detail: string; code: ResultCodeName; fix?: st export function verdictProblem(skill: string, gstackRoot: string, env: Env, bunPath: string, bunVersion: string, cwd?: string): VerdictProblem | null { const src = sourceRoot(gstackRoot); const project = installProject(src, cwd); - const file = findInstallVerdict(src, project, env); + return admittedVerdict(skill, src, findInstallVerdict(src, project, env), project, bunPath, bunVersion).problem; +} + +/** + * The verdict file that admits `skill`: `file` itself, else the workflow's own record + * .for/.json that every install also writes, so a later install for another + * workflow never drops this one (GST-1233). When neither admits, `file` with its problem. + */ +export function admittedVerdict(skill: string, src: string, file: string, project: string | null, bunPath: string, bunVersion: string): { file: string; problem: VerdictProblem | null } { + const problem = verdictFileProblem(file, skill, src, project, bunPath, bunVersion); + const own = file.replace(/\.json$/, `.for/${skill}.json`); + if (problem && /^[A-Za-z0-9._-]+$/.test(skill) && fs.existsSync(own) && !verdictFileProblem(own, skill, src, project, bunPath, bunVersion)) return { file: own, problem: null }; + return { file, problem }; +} + +/** Why the verdict in `file` does not admit `skill`; null when it does. */ +function verdictFileProblem(file: string, skill: string, src: string, project: string | null, bunPath: string, bunVersion: string): VerdictProblem | null { const v = readInstallVerdict(file); const problem = (detail: string): VerdictProblem => ({ detail, code: 'INSTALL_UNVERIFIED' }); if (!v && fs.existsSync(file)) return problem(`the install verdict at ${file} is unreadable or has an unsupported schema_version; rerun the installer`); diff --git a/lib/pregate/runtime.ts b/lib/pregate/runtime.ts index e0a8d6af8..82386438a 100644 --- a/lib/pregate/runtime.ts +++ b/lib/pregate/runtime.ts @@ -22,7 +22,7 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { findInstallVerdict, installProject, readInstallVerdict, sourceRoot, verdictProblem } from '../install-verdict'; +import { admittedVerdict, findInstallVerdict, installProject, readInstallVerdict, sourceRoot } from '../install-verdict'; import { resultCodeSuffix } from '../result-codes'; import { collectPins, evaluatePins, exactPlan, parseVersion, satisfies } from '../runtime-pins'; import { resolveStateRoot } from '../state-root'; @@ -152,15 +152,15 @@ export interface LaunchRuntime { * The Bun the lanes and guards run under, its dir first on PATH; the env is * unchanged when nothing qualifies. Under the admission condition the install * verdict for (checkout, project) is validated as workflow admission does - * (verdictProblem: ok, HEAD, pins, project, coverage, for /ship) and its + * (admittedVerdict: ok, HEAD, pins, project, coverage, for /ship; else /ship's own record) and its * recorded Bun is the one launched; the gstack checkout itself binds the * 'none' verdict (installProject). */ export function launchRuntime(repoRoot: string, env: NodeJS.ProcessEnv): LaunchRuntime { const checkout = sourceRoot(GSTACK_ROOT); const project = installProject(checkout, repoRoot); - const file = findInstallVerdict(checkout, project, env); - const recorded = readInstallVerdict(file); + const admitted = admittedVerdict('ship', checkout, findInstallVerdict(checkout, project, env), project, '', ''); + const recorded = readInstallVerdict(admitted.file); let pinsRoot: string | null = null; try { pinsRoot = fs.realpathSync(repoRoot); } catch { /* unresolvable: no pins */ } const { pinExact, accept } = projectBunPins(pinsRoot, env); @@ -174,7 +174,7 @@ export function launchRuntime(repoRoot: string, env: NodeJS.ProcessEnv): LaunchR if (env.GSTACK_CAPY !== '1' && !recorded) { verdictLine = 'install verdict: not checked (no GSTACK_CAPY=1 and no verdict for this checkout and project)'; } else { - const problem = verdictProblem('ship', GSTACK_ROOT, env, '', '', repoRoot); + const { problem } = admitted; if (problem) return refuse('INSTALL_UNVERIFIED', problem.detail, problem.fix ?? installer); verdict = 'ok'; if (recorded!.bun_path) { diff --git a/test/gstack-capy-install.test.ts b/test/gstack-capy-install.test.ts index 405458549..de314b8b0 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -406,6 +406,36 @@ describe('gstack-capy-install', () => { expect(admits('ship')?.detail).toBe('the install verdict certified for=document-release, which does not cover codex that ship needs'); }); + // GST-1233 (TRUT-26): the union above needs the same Bun and an ok run, so a child whose shell + // resolves another Bun, or whose install fails, still replaced the shared verdict and /ship's + // finish pregate reported INSTALL_UNVERIFIED. Each workflow keeps its own verdict as well. + test('--for ship then --for document-release with another Bun or a failed run: ship stays admitted; another HEAD invalidates both', () => { + const f = fixture(); + const env = { HOME: f.home, GSTACK_HOME: path.join(f.home, '.gstack-state') }; + const admits = (skill: string) => verdictProblem(skill, f.root, env, '', '1.4.2', f.home); + const ship = run(f, ['--for', 'ship'], { STUB_REQUIRED: 'claude codex patch runtime pins' }); + expect(ship.status, ship.stdout + ship.stderr).toBe(0); + const file = ship.stdout.match(/GSTACK_INSTALL_VERDICT=(\S+)/)![1]!; + const childBin = path.join(f.base, 'child-bin'); + script(path.join(childBin, 'bun'), ['[ "$1" = --version ] && { echo 1.4.2; exit 0; }', 'exit 0']); + const docs = run(f, ['--for', 'document-release'], { STUB_REQUIRED: 'claude runtime', PATH: `${childBin}${path.delimiter}${f.env.PATH}` }); + expect(docs.status, docs.stdout + docs.stderr).toBe(0); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toMatchObject({ for: 'document-release', bun_path: path.join(childBin, 'bun') }); + expect(admits('ship')).toBeNull(); + expect(admits('document-release')).toBeNull(); + const failed = run(f, ['--for', 'document-release'], { STUB_REQUIRED: 'claude runtime', STUB_DOCTOR_FAIL: '1' }); + expect(failed.status).not.toBe(0); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toMatchObject({ for: 'document-release', ok: false }); + expect(admits('ship')).toBeNull(); + expect(admits('document-release')?.detail).toContain('the install verdict says: gstack: fail'); + sh('git -c user.email=t@t -c user.name=t commit -q --allow-empty -m next', f.root); + expect(admits('ship')?.detail).toMatch(/^the install verdict says: gstack: fail/); + const moved = run(f, ['--for', 'document-release'], { STUB_REQUIRED: 'claude runtime' }); + expect(moved.status, moved.stdout + moved.stderr).toBe(0); + expect(admits('document-release')).toBeNull(); + expect(admits('ship')?.detail).toBe('the install verdict certified for=document-release, which does not cover codex that ship needs'); + }); + test('--root clones when the directory is absent and refuses a non-gstack directory', () => { const f = fixture(); const clone = path.join(f.base, 'clone'); diff --git a/test/install-verdict.test.ts b/test/install-verdict.test.ts index 98aa8ed14..85b55c39a 100644 --- a/test/install-verdict.test.ts +++ b/test/install-verdict.test.ts @@ -152,6 +152,24 @@ describe('install verdict keyed by (checkout, project): both twins', () => { expect(both('autoplan', proj, env).detail).toContain('unsupported schema_version'); }); + test("GST-1233: a later install for another workflow replaced the shared verdict; both twins admit from the workflow's own record, never from a stale one", () => { + const sr = tmp(); + const proj = project('truther'); + const shared = writeVerdict(sr, { project: proj, bun_path: '/opt/child/bun', bun_version: '1.4.2', schema_version: 2, for: 'document-release', + required: ['claude', 'runtime'], trailer: 'gstack: ok 9.9.9.9 for=document-release' }); + const own = shared.replace(/\.json$/, '.for/ship.json'); + const env = { GSTACK_STATE_ROOT: sr }; + const without = both('ship', proj, env); + expect(without).toMatchObject({ refuse: true, status: 'verdict', code: 'INSTALL_UNVERIFIED', detail: 'the install verdict certified for=document-release, which does not cover codex that ship needs' }); + writeVerdict(sr, { project: proj, bun_path: BUN, bun_version: '1.4.2', schema_version: 2, for: 'ship', trailer: 'gstack: ok 9.9.9.9 for=ship' }, own); + expect(both('ship', proj, env)).toMatchObject({ refuse: false, status: 'ok' }); + expect(bashAdmit('ship', proj, { _GH: sr, ...env }).line).toBe('INSTALL_VERDICT: ok gstack: ok 9.9.9.9 for=ship'); + writeVerdict(sr, { project: proj, bun_path: BUN, bun_version: '1.4.2', schema_version: 2, for: 'ship', head: 'f'.repeat(40) }, own); + const stale = both('ship', proj, env); + expect(stale).toMatchObject({ refuse: true, status: 'verdict', code: 'INSTALL_UNVERIFIED', detail: without.detail }); + expect(bashAdmit('ship', proj, { _GH: sr, ...env }).line).toBe(without.line); + }); + test('a running Bun other than the recorded one refuses with BUN_RESOLUTION_DRIFT and names both', () => { const sr = tmp(); const a = project('a'); From e6850ceec3fc1db1ac4f99c20db2f98305290ba1 Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 16:04:55 +0000 Subject: [PATCH 2/3] fix(install): keep each workflow's Bun on --check, refuse after a failed setup, launch only the admitting record's Bun (GST-1233 review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-capy-install | 6 ++++-- bin/gstack-install-verdict.sh | 4 +++- changelog.d/gst-1233-verdict-union.md | 2 +- docs/capy-install.md | 7 ++++++- lib/install-verdict.ts | 5 +++-- lib/pregate/runtime.ts | 4 +++- test/bun-resolve.test.ts | 29 +++++++++++++++++++++++++++ test/gstack-capy-install.test.ts | 8 ++++++++ test/install-verdict.test.ts | 6 ++++++ 9 files changed, 63 insertions(+), 8 deletions(-) diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index d5343f2e8..26b9e6668 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -163,8 +163,10 @@ PROJECT_REAL=""; [ -n "$PROJECT" ] && PROJECT_REAL=$(cd "$PROJECT" && pwd -P) && VERDICT=$(gstack_install_verdict_path "$GSTACK_STATE_ROOT" "$ROOT_REAL" "$PROJECT_REAL") REC_BUN="" REC_VER="" REC_RULE="" REC_BUN_INSTALL="" REC_FOR="" if [ -f "$VERDICT" ]; then - REC_BUN=$(gstack_install_verdict_field "$VERDICT" bun_path) REC_VER=$(gstack_install_verdict_field "$VERDICT" bun_version) - REC_RULE=$(gstack_install_verdict_field "$VERDICT" bun_rule) REC_BUN_INSTALL=$(gstack_install_verdict_field "$VERDICT" bun_install) + _rec=$VERDICT + case "$FOR" in ''|*[!A-Za-z0-9._-]*) ;; *) [ "$PROFILE" -eq 0 ] && [ "$(gstack_install_verdict_field "${VERDICT%.json}.for/$FOR.json" ok 2>/dev/null)" = true ] && _rec="${VERDICT%.json}.for/$FOR.json" ;; esac + REC_BUN=$(gstack_install_verdict_field "$_rec" bun_path) REC_VER=$(gstack_install_verdict_field "$_rec" bun_version) + REC_RULE=$(gstack_install_verdict_field "$_rec" bun_rule) REC_BUN_INSTALL=$(gstack_install_verdict_field "$_rec" bun_install) REC_FOR=$(gstack_install_verdict_field "$VERDICT" for) fi # A reviewer's machine (--for review-voice) asked to run a workflow upgrades diff --git a/bin/gstack-install-verdict.sh b/bin/gstack-install-verdict.sh index 8cfa6854d..5714bf60a 100644 --- a/bin/gstack-install-verdict.sh +++ b/bin/gstack-install-verdict.sh @@ -192,9 +192,11 @@ gstack_install_admission() { _gstack_ia_check # GST-1233: each install also keeps its own verdict per workflow, so when a # later install for another workflow replaced the shared one, this - # workflow's own record still admits it. + # workflow's own record still admits it; never after a failed ./setup, which + # changed the checkout every record certified. _ia_own="${file%.json}.for/$skill.json" case "$skill" in ''|*[!A-Za-z0-9._-]*) _ia_own="" ;; esac + case "$trailer" in "gstack: fail setup"*) _ia_own="" ;; esac if [ -n "$detail" ] && [ -n "$_ia_own" ] && [ -f "$_ia_own" ]; then _ia_m_file=$file _ia_m_detail=$detail _ia_m_fix=$fix _ia_m_code=$code _ia_m_status=$_ia_status _ia_m_trailer=$trailer file=$_ia_own _ia_status=ok diff --git a/changelog.d/gst-1233-verdict-union.md b/changelog.d/gst-1233-verdict-union.md index fd40a6b03..d40746af5 100644 --- a/changelog.d/gst-1233-verdict-union.md +++ b/changelog.d/gst-1233-verdict-union.md @@ -1,3 +1,3 @@ ### Fixed -- **A child's install for another workflow no longer drops /ship's install verdict on a shared machine (GST-1233).** When a /ship docs child ran `install.sh --for document-release` with a different Bun on PATH, or its install failed, it replaced the shared install verdict, and the parent's finish pregate refused with `INSTALL_UNVERIFIED` until someone reinstalled `--for ship`. Each workflow install now also writes that workflow's own verdict beside the shared one (`.for/.json`), and workflow admission and the pregate fall back to it with the same checks. A new HEAD still invalidates both. +- **A child's install for another workflow no longer drops /ship's install verdict on a shared machine (GST-1233).** When a /ship docs child ran `install.sh --for document-release` with a different Bun on PATH, or its install check failed, it replaced the shared install verdict, and the parent's finish pregate refused with `INSTALL_UNVERIFIED` until someone reinstalled `--for ship`. Each workflow install now also writes that workflow's own verdict beside the shared one (`.for/.json`), and workflow admission and the pregate fall back to it with the same checks. A new HEAD or a failed `./setup` still invalidates every record, `--check --for ship` keeps ship's own recorded Bun, and the pregate never launches a Bun its admitting record didn't certify. The shared launch file is a separate follow-up (GST-1239). diff --git a/docs/capy-install.md b/docs/capy-install.md index d499a78c7..a1da5fd13 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -337,7 +337,12 @@ keeps the lease. `bin/gstack-lease list` shows `alive|idle|dead|stale`, and workflow's own record with the same checks, so a later install for another workflow on a shared machine (a /ship docs child resolving another Bun, or a failed child install) never drops /ship's admission (GST-1233). A new HEAD - still invalidates both. Admission refuses a workflow whose + still invalidates both, and a failed `./setup` (`gstack: fail setup`) + invalidates every workflow's record. `--check --for ` keeps the + Bun that workflow's own record holds. The launch file and launch directory + stay shared per checkout and project (GST-1239), so the pregate launches + from the recorded Bun's own directory when the launch directory's `bun` + resolves to another one. Admission refuses a workflow whose running Bun is not the one the verdict recorded ([BUN_RESOLUTION_DRIFT](troubleshooting.md#bun-resolution-drift)), and so do the pregate's lanes and guards, which also refuse an invalid verdict diff --git a/lib/install-verdict.ts b/lib/install-verdict.ts index 1e05e3df3..60f787cd2 100644 --- a/lib/install-verdict.ts +++ b/lib/install-verdict.ts @@ -148,12 +148,13 @@ export function verdictProblem(skill: string, gstackRoot: string, env: Env, bunP /** * The verdict file that admits `skill`: `file` itself, else the workflow's own record * .for/.json that every install also writes, so a later install for another - * workflow never drops this one (GST-1233). When neither admits, `file` with its problem. + * workflow never drops this one (GST-1233), except after a failed ./setup, which changed the + * checkout every record certified. When neither admits, `file` with its problem. */ export function admittedVerdict(skill: string, src: string, file: string, project: string | null, bunPath: string, bunVersion: string): { file: string; problem: VerdictProblem | null } { const problem = verdictFileProblem(file, skill, src, project, bunPath, bunVersion); const own = file.replace(/\.json$/, `.for/${skill}.json`); - if (problem && /^[A-Za-z0-9._-]+$/.test(skill) && fs.existsSync(own) && !verdictFileProblem(own, skill, src, project, bunPath, bunVersion)) return { file: own, problem: null }; + if (problem && /^[A-Za-z0-9._-]+$/.test(skill) && !readInstallVerdict(file)?.trailer?.startsWith('gstack: fail setup') && fs.existsSync(own) && !verdictFileProblem(own, skill, src, project, bunPath, bunVersion)) return { file: own, problem: null }; return { file, problem }; } diff --git a/lib/pregate/runtime.ts b/lib/pregate/runtime.ts index 82386438a..9985bafc8 100644 --- a/lib/pregate/runtime.ts +++ b/lib/pregate/runtime.ts @@ -181,7 +181,9 @@ export function launchRuntime(repoRoot: string, env: NodeJS.ProcessEnv): LaunchR const now = fs.existsSync(recorded!.bun_path) ? bunVersionOf(recorded!.bun_path, env) : ''; const why = !now ? 'is gone' : now !== recorded!.bun_version ? `now reports ${now}` : !accept(now) ? 'no longer satisfies the project pins' : ''; if (why) return refuse('BUN_RESOLUTION_DRIFT', `recorded bun ${recorded!.bun_version} at ${recorded!.bun_path} ${why}`, 're-run the Capy skill installer with --check (scripts/install.sh --check --for ship) and follow its fix'); - const launch = recorded!.launch_path && fs.existsSync(recorded!.launch_path) ? recorded!.launch_path : path.dirname(recorded!.bun_path); + let launchBun = ''; + try { launchBun = fs.realpathSync(path.join(recorded!.launch_path, 'bun')); } catch { /* no Bun in the launch dir */ } + const launch = recorded!.launch_path && fs.existsSync(recorded!.launch_path) && (!launchBun || launchBun === recorded!.bun_path) ? recorded!.launch_path : path.dirname(recorded!.bun_path); return result(launch, now, `bun ${now} at ${recorded!.bun_path} (recorded by the install verdict)`, 'ok', ''); } } diff --git a/test/bun-resolve.test.ts b/test/bun-resolve.test.ts index 9af139cd9..ade1fe074 100644 --- a/test/bun-resolve.test.ts +++ b/test/bun-resolve.test.ts @@ -138,4 +138,33 @@ describe('one Bun resolution contract, two twins', () => { expect(viaLaunch.dir).toBe(launch); expect(viaLaunch.env.PATH?.split(path.delimiter)[0]).toBe(launch); }); + + // GST-1233: a docs child's install replaced the shared verdict and republished the shared launch + // directory with its own Bun; the finish pregate admits from ship's own record and launches ship's Bun. + test("launchRuntime admits /ship from its own record when another workflow's install replaced the shared verdict, and never launches that install's Bun", () => { + const l = layout({ storeVersions: ['1.4.4'] }); + const { installVerdictPath } = require('../lib/install-verdict') as typeof import('../lib/install-verdict'); + const checkout = fs.realpathSync(ROOT); + const file = installVerdictPath(checkout, l.state, null); + const head = spawnSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 10_000 }).stdout.trim(); + const childBun = path.join(bunStore(l.state), '1.4.4/bun'); + const launch = file.replace(/\.json$/, '.launch'); + fs.mkdirSync(launch, { recursive: true }); + fs.symlinkSync(childBun, path.join(launch, 'bun')); + const record = { schema_version: 2, checkout, project: '', head, pins_planning_only: false, ok: true, min_revision: '', launch_path: launch, time: '' }; + fs.writeFileSync(file, JSON.stringify({ ...record, for: 'document-release', required: ['claude'], trailer: 'gstack: ok for=document-release', bun_path: fs.realpathSync(childBun), bun_version: '1.4.4' })); + const refused = launchRuntime(ROOT, l.env); + expect(refused.refusal).toMatchObject({ code: 'INSTALL_UNVERIFIED' }); + expect(refused.refusal?.detail).toContain('certified for=document-release'); + const own = file.replace(/\.json$/, '.for/ship.json'); + fs.mkdirSync(path.dirname(own), { recursive: true }); + fs.writeFileSync(own, JSON.stringify({ ...record, for: 'ship', required: ['claude', 'codex', 'patch'], trailer: 'gstack: ok for=ship', bun_path: REAL_BUN, bun_version: Bun.version })); + const r = launchRuntime(ROOT, l.env); + expect(r.refusal).toBeNull(); + expect(r.verdict).toBe('ok'); + expect(r.label).toBe(`bun ${Bun.version} at ${REAL_BUN} (recorded by the install verdict)`); + expect(r.dir).toBe(path.dirname(REAL_BUN)); + fs.writeFileSync(file, JSON.stringify({ ...JSON.parse(fs.readFileSync(file, 'utf8')), ok: false, trailer: 'gstack: fail setup — ./setup failed' })); + expect(launchRuntime(ROOT, l.env).refusal?.detail).toBe('install verdict: the install verdict says: gstack: fail setup — ./setup failed'); + }); }); diff --git a/test/gstack-capy-install.test.ts b/test/gstack-capy-install.test.ts index de314b8b0..9809cae64 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -423,6 +423,14 @@ describe('gstack-capy-install', () => { expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toMatchObject({ for: 'document-release', bun_path: path.join(childBin, 'bun') }); expect(admits('ship')).toBeNull(); expect(admits('document-release')).toBeNull(); + const shipBun = JSON.parse(fs.readFileSync(file.replace(/\.json$/, '.for/ship.json'), 'utf8')).bun_path; + expect(shipBun).not.toBe(path.join(childBin, 'bun')); + const check = run(f, ['--check', '--for', 'ship'], { STUB_REQUIRED: 'claude codex patch runtime pins' }); + expect(check.status, check.stdout + check.stderr).toBe(0); + expect(JSON.parse(fs.readFileSync(file.replace(/\.json$/, '.for/ship.json'), 'utf8')).bun_path).toBe(shipBun); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toMatchObject({ for: 'ship', bun_path: shipBun }); + expect(admits('ship')).toBeNull(); + expect(admits('document-release')).toBeNull(); const failed = run(f, ['--for', 'document-release'], { STUB_REQUIRED: 'claude runtime', STUB_DOCTOR_FAIL: '1' }); expect(failed.status).not.toBe(0); expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toMatchObject({ for: 'document-release', ok: false }); diff --git a/test/install-verdict.test.ts b/test/install-verdict.test.ts index 85b55c39a..3c4bb0050 100644 --- a/test/install-verdict.test.ts +++ b/test/install-verdict.test.ts @@ -164,6 +164,12 @@ describe('install verdict keyed by (checkout, project): both twins', () => { writeVerdict(sr, { project: proj, bun_path: BUN, bun_version: '1.4.2', schema_version: 2, for: 'ship', trailer: 'gstack: ok 9.9.9.9 for=ship' }, own); expect(both('ship', proj, env)).toMatchObject({ refuse: false, status: 'ok' }); expect(bashAdmit('ship', proj, { _GH: sr, ...env }).line).toBe('INSTALL_VERDICT: ok gstack: ok 9.9.9.9 for=ship'); + const setupFailed = { project: proj, bun_path: '/opt/child/bun', bun_version: '1.4.2', schema_version: 2, for: 'document-release', ok: false, trailer: 'gstack: fail setup' }; + writeVerdict(sr, setupFailed); + expect(both('ship', proj, env)).toMatchObject({ refuse: true, status: 'verdict', detail: 'the install verdict says: gstack: fail setup' }); + writeVerdict(sr, { ...setupFailed, trailer: 'gstack: fail codex' }); + expect(both('ship', proj, env)).toMatchObject({ refuse: false, status: 'ok' }); + writeVerdict(sr, { project: proj, bun_path: '/opt/child/bun', bun_version: '1.4.2', schema_version: 2, for: 'document-release', required: ['claude', 'runtime'], trailer: 'gstack: ok 9.9.9.9 for=document-release' }); writeVerdict(sr, { project: proj, bun_path: BUN, bun_version: '1.4.2', schema_version: 2, for: 'ship', head: 'f'.repeat(40) }, own); const stale = both('ship', proj, env); expect(stale).toMatchObject({ refuse: true, status: 'verdict', code: 'INSTALL_UNVERIFIED', detail: without.detail }); From a0d44c31caef735cc32ef7c66177446612129b13 Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 16:09:38 +0000 Subject: [PATCH 3/3] docs(changelog): GST-1233 fragment names the pregate launch-dir check precisely Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- changelog.d/gst-1233-verdict-union.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/gst-1233-verdict-union.md b/changelog.d/gst-1233-verdict-union.md index d40746af5..3c221974b 100644 --- a/changelog.d/gst-1233-verdict-union.md +++ b/changelog.d/gst-1233-verdict-union.md @@ -1,3 +1,3 @@ ### Fixed -- **A child's install for another workflow no longer drops /ship's install verdict on a shared machine (GST-1233).** When a /ship docs child ran `install.sh --for document-release` with a different Bun on PATH, or its install check failed, it replaced the shared install verdict, and the parent's finish pregate refused with `INSTALL_UNVERIFIED` until someone reinstalled `--for ship`. Each workflow install now also writes that workflow's own verdict beside the shared one (`.for/.json`), and workflow admission and the pregate fall back to it with the same checks. A new HEAD or a failed `./setup` still invalidates every record, `--check --for ship` keeps ship's own recorded Bun, and the pregate never launches a Bun its admitting record didn't certify. The shared launch file is a separate follow-up (GST-1239). +- **A child's install for another workflow no longer drops /ship's install verdict on a shared machine (GST-1233).** When a /ship docs child ran `install.sh --for document-release` with a different Bun on PATH, or its install check failed, it replaced the shared install verdict, and the parent's finish pregate refused with `INSTALL_UNVERIFIED` until someone reinstalled `--for ship`. Each workflow install now also writes that workflow's own verdict beside the shared one (`.for/.json`), and workflow admission and the pregate fall back to it with the same checks. A new HEAD or a failed `./setup` still invalidates every record, `--check --for ship` keeps ship's own recorded Bun, and the pregate skips a launch directory whose `bun` resolves to a Bun other than the one its admitting record certified. The shared launch file is a separate follow-up (GST-1239).