diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index b42cf1ce..b691dcae 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -69,8 +69,11 @@ jobs: if: github.event_name != 'pull_request' || github.event.pull_request.draft == false strategy: fail-fast: false + # Hosted macOS runners can queue past the timeout, which then reports a job + # that never ran as failed. Pull requests rely on Release's native macOS + # lifecycle checks instead; main still checks the flake's macOS build. matrix: - runner: [ubuntu-latest, macos-14] + runner: ${{ github.event_name == 'pull_request' && fromJSON('["ubuntu-latest"]') || fromJSON('["ubuntu-latest", "macos-14"]') }} runs-on: ${{ matrix.runner }} timeout-minutes: 15 steps: diff --git a/quest/m0/README.md b/quest/m0/README.md index 98a86a04..7e26660a 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -35,7 +35,6 @@ ownership-manifest design: ## Required -- [Lifecycle flakes](/quest/m0/lifecycle-flakes.md) - lifecycle CI jobs stop failing on Windows `EBUSY` and unstarted macOS jobs - [Worker tests](/quest/m0/worker-tests.md) - every Worker test passes alone and in any order - [Gate bench](/quest/m0/gate-bench.md) - measure headroom admission on an idle host - [Agent lifecycle integration](/quest/m0/worktree-agents.md) - reclaim completed and suspended checkouts through their harness diff --git a/quest/m0/lifecycle-flakes.md b/quest/m0/lifecycle-flakes.md deleted file mode 100644 index 11ea183e..00000000 --- a/quest/m0/lifecycle-flakes.md +++ /dev/null @@ -1,30 +0,0 @@ -# [S] Stop the lifecycle CI jobs failing at random - -## Goal - -The lifecycle CI jobs fail only when the code under test is broken: the -Windows check never fails with `EBUSY` while removing its temporary -directory, and a macOS job that never gets a runner does not show as a -failed check. - -## Plan - -Decided 2026-10-22 (user) as follow-ups of -[kixelated/quest#192](https://github.com/kixelated/quest/pull/192) and -[kixelated/quest#191](https://github.com/kixelated/quest/pull/191): - -- **Windows `EBUSY`.** The Windows arm64 Release job failed after its own - checks passed, in the final `rmSync(work, { recursive: true, force: true })` - of `scripts/lifecycle-check.mjs`, on #192 and on the `quest/m0/worktree` - branch (run 39679528766). A rerun passed both times. Find which process or - handle still holds the directory (for example a child process the check - started, or a file it left open) and fix that cause. No retries, sleeps, or - ignored errors. Reproduce it on a native Windows runner before fixing. -- **macOS queue timeout.** On #191 and - [kixelated/quest#185](https://github.com/kixelated/quest/pull/185), the - optional `lifecycle (macos-14)` job waited 15 minutes for a hosted runner - during a GitHub macOS capacity notice, was cancelled without running, and - showed as failed. Decide from the workflow (`.github/workflows/check.yml`) - whether to shorten its queue limit, report it as skipped, or drop it from - pull request runs, keeping macOS covered somewhere. -- Keep both fixes covered by the existing lifecycle jobs. diff --git a/scripts/install-check.ps1 b/scripts/install-check.ps1 index 236c39f5..29bb8b77 100644 --- a/scripts/install-check.ps1 +++ b/scripts/install-check.ps1 @@ -10,7 +10,10 @@ if ($Tag -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?( $architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() $expectedArchitecture = if ($Target -eq 'aarch64-pc-windows-msvc') { 'Arm64' } else { 'X64' } if ($architecture -ne $expectedArchitecture) { throw "Expected native $expectedArchitecture runner; got $architecture" } -$work = Join-Path ([System.IO.Path]::GetTempPath()) ('quest install ' + [guid]::NewGuid()) +# The runner empties RUNNER_TEMP after the job, so nothing here deletes the installed binary: on +# windows-11-arm, the runner's agent briefly opens running executables without delete sharing. +if (-not $env:RUNNER_TEMP) { throw 'Run this on a GitHub Actions runner, which empties RUNNER_TEMP' } +$work = Join-Path $env:RUNNER_TEMP ('quest install ' + [guid]::NewGuid()) $originalDirectory = Get-Location $originalMise = @{} foreach ($name in @('MISE_CONFIG_DIR', 'MISE_DATA_DIR', 'MISE_CACHE_DIR')) { $originalMise[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') } @@ -53,5 +56,4 @@ try { } finally { Set-Location $originalDirectory foreach ($name in $originalMise.Keys) { [Environment]::SetEnvironmentVariable($name, $originalMise[$name], 'Process') } - if (Test-Path -LiteralPath $work) { Remove-Item -LiteralPath $work -Recurse -Force } } diff --git a/scripts/lifecycle-check.mjs b/scripts/lifecycle-check.mjs index e8e39aa6..c303b1eb 100644 --- a/scripts/lifecycle-check.mjs +++ b/scripts/lifecycle-check.mjs @@ -2,8 +2,6 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; import { - chmodSync, - copyFileSync, existsSync, lstatSync, mkdirSync, @@ -29,17 +27,12 @@ const assertFile = (root, path, expected) => assert.equal(read(root, path), expe const skills = ["audit", "complete", "delete", "export", "import", "iterate", "merge", "plan", "spawn", "start"]; const destinations = [".claude/skills", ".agents/skills"]; try { + // Run the caller's binary in place, so the cleanup below never deletes an executable it just ran: + // on windows-11-arm, the hosted runner's agent briefly opens running executables without delete + // sharing, and removing one then fails with EBUSY. Callers test a binary path with spaces. const javascript = extname(input) === ".js"; - const binary = join( - work, - "tool bin", - javascript ? "quest.js" : process.platform === "win32" ? "quest.exe" : "quest", - ); - mkdirSync(dirname(binary)); - copyFileSync(input, binary); - if (process.platform !== "win32") chmodSync(binary, 0o755); - const command = javascript ? process.execPath : binary; - const prefix = javascript ? [binary] : []; + const command = javascript ? process.execPath : input; + const prefix = javascript ? [input] : []; const run = (root, ...args) => execFileSync(command, [...prefix, "--root", root, ...args], { cwd: root, diff --git a/scripts/lifecycle-check.sh b/scripts/lifecycle-check.sh index a984ea8e..5f58c0fc 100644 --- a/scripts/lifecycle-check.sh +++ b/scripts/lifecycle-check.sh @@ -14,8 +14,11 @@ esac work=$(mktemp -d "${TMPDIR:-/tmp}/quest-lifecycle.XXXXXX") trap 'rm -rf "$work"' EXIT -# The shared lifecycle is also run directly by native Windows CI. -bun "$(dirname -- "${BASH_SOURCE[0]}")/lifecycle-check.mjs" "$binary" +# The shared lifecycle is also run directly by native Windows CI. It runs the +# binary in place, so give it a path with spaces. +mkdir "$work/tool bin" +cp "$binary" "$work/tool bin/" +bun "$(dirname -- "${BASH_SOURCE[0]}")/lifecycle-check.mjs" "$work/tool bin/$(basename -- "$binary")" # A reader that closes stdout early, like `quest unblocked | head -1`, ends the # command quietly. The writer waits on a FIFO until the reader has closed, so