diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b195eea2..8a7b3a0a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,104 @@ # Changelog +## [1.92.62.0] - 2026-11-02 + +- **`gstack-pregate clear --run-url` checks the run's workflow.** On a lane with no run profile, `clear` used to record any URL as the receipt, so a `windows-smoke.yml` run could clear `windows-free-tests.yml/windows-free-shard` on a branch cut before #3806. When the base config and the GitHub origin can be read, `clear` now reads the run and refuses `PREGATE_RECEIPT_INVALID` (`workflow_mismatch`) unless its workflow file is the lane's own; a run gh cannot read (`api_error`) or a URL that is not an Actions run on this repository (`run_ref_invalid`) refuses too, and each refusal names its own fix. A run of the lane's own workflow clears as before, including a job link copied with `?pr=` or `#step`. `test_backend/*` lanes, `--bundle-lane` and non-GitHub origins are unchanged. On main the Windows obligation is `windows-smoke.yml/windows-smoke`, and a test now checks that against the repository's own workflows. The `ready` conflict hint now says to clear each lane with a run of that lane's own workflow. `test/pregate-clear-runs.test.ts` covers the mismatch, the matching control, the unreadable run, the non-run URL and the copied job links; each failed before its fix. + +## [1.92.61.0] - 2026-11-02 + +- **The security class reaches outbound URLs, archives and every `child_process` spelling** (GST-850). `lib/review-risk.ts` now flags any call to an outbound HTTP client (`fetch`, `http(s).get` or `.request`, axios, got, undici, `requests`, `httpx`, urllib), `new URL` from input, redirect toggles, proxy settings and host allowlists; tar, zip and unzip extraction, `path.join` or `resolve` on request or entry input, and `'..'` checks; and any changed line that names the `child_process` module. The new rules parse no import clause or call arguments, and a test holds each under 100 ms on 200,000-character hostile lines. +- **The money class reads metering and charge code.** Paths and lines naming meter, metering, metered, charge, billable, billed or credits, plus token usage, a billing proxy and cost or charge clients, now route to the money reader. gbrain-evals #289, which changed a metering proxy and billing clients and read `required=none`, is now a regression test that reads money. "costume", a CLI's usage text and a plain proxy do not count. +- **When a diff publishes numbers, the batched specialist reads the write-up's text files** instead of leaving docs out of its diff. The collector records the files a diff creates. +- **UI text and SVGs stay code (light).** An allowlist docs path for chart SVGs and locale values was built and dropped for review cost: its first review round found a new bypass class in each version. Every bypass found so far is a test row that classes as code. +- Replayed on the 26 GSTA-167 no-loss units, no unit loses a reviewer role. The replay checked which reviewers get dispatched, not R51's re-scored findings. Over the last 100 main commits, 3 move from light to full and no commit with a gate or risk class drops depth. + +## [1.92.60.0] - 2026-11-02 + +Free test shards can now run their files as several sub-shards side by side inside one CI job. `bun run scripts/test-free-shards.ts --ci-run --shard --result --jobs ` splits the shard into k parts that each run in their own sandboxed `bun test` process. The default stays at 1, so CI runs exactly as before until a workflow opts in. Bad `--jobs` values stop with `FREE_JOBS_INVALID`, a sub-shard that runs out of time prints `FREE_SUBSHARD_TIMEOUT` with a serial reproduce command, and every CI shard result now records per-file test and `expect()` counts. See docs/TESTING_INTERNALS.md#sub-shards-inside-one-ci-job. + +## [1.92.59.0] - 2026-11-02 + +**`test:health` and the factory scorecard leave reused main pushes out of their free-suite flake counts (#3725).** Since #3709, a main push whose merged pull request already passed on the same tree reuses that run. It finishes green, runs no shards and uploads nothing, so both readers counted it as a run with zero flakes. As reuse grows, that pulls real flake rates under the 5% bar, and `bun run test:health --enforce` stops flagging files without any error. + +### Fixed +- **One rule for both readers** (`reuseChecker` in `scripts/lib/ci-history.ts`). A run with no artifacts and a green `reuse` job is a reused main push. `readCiFlakeLedgers` leaves it out of the window and its denominator and fills the window with older runs, so it still holds 20 runs that ran their shards. `readFreeRetries` in `lib/factory-scorecard.ts` leaves it out of the free-retry count. Both reports name how many runs they left out. +- **Bounded and visible.** A run with any artifact costs no extra call. A run with none costs one job listing, at most 40 per read. Past that cap, or when a listing fails, the run counts as it did before and the report says so. + +### Tests +- `test/test-health-report.test.ts`: reused runs among real flaky runs (2 flaky of 20 real runs, with 30 reused) failed on the old code with 2/45 = 4.4%, which passed the 5% bar; now 2/20 = 10% fails it. The lookup cap and a failed lookup both count the run as before, and the report says so. +- `test/factory-scorecard.test.ts`: two reused runs in the window failed on the old code ("in 1 of 5 runs"); now the free-retry line matches the real runs only ("in 1 of 3 runs") and names the 2 runs it left out. + +## [1.92.58.0] - 2026-11-02 + +**A parent agent can now run the CEO-only review through the driver: `gstack-autoplan next --ceo-only` (GST-537).** `/autoplan --ceo-only` only worked inside a Claude Code session, so a crew or eval harness driving `gstack-autoplan` from another host had to run the whole review and stop at the approval gate. Now the same review-only run works from the driver on any host. + +### Added +- **`gstack-autoplan next --ceo-only`** (first `next` only): opens the CEO phase alone. When it closes, the run prints `REVIEWED:`, writes `/ceo-review.md` with the same sections as the skill path (hashed in `run.json`), and ends `GSTACK_RESULT: skill=autoplan status=reviewed run=`. It writes no gate, never edits the plan and never approves anything. +- **Decision rows with status `auto_kept`**: each item a gate would have asked is recorded as kind `auto`, the plan's direction kept, with no answer time. An item only the owner can accept (a Critical finding left open when CEO closed deferred) is left undecided, with no chosen option, and the report lists it as left open for the owner. Tasks in a CEO-only run wait on no decision. `run.json` now accepts `reviewed`, and `gstack-artifact validate` passes a finished CEO-only run. +- **Coded refusals**: `--ceo-only` beside `--ui`, `--developer-tool` or `--agent-primary` (on the command line or in the plan's scope marker), a scope marker that names `--ceo-only`, or a plan that is the report file `/ceo-review.md` (directly, through a symlink or a hard link) refuses with `CEO_ONLY_CONFLICT`, so the report never overwrites the plan; on a directory that holds a full run, `CEO_ONLY_AFTER_INIT`. `answer`, `recommend`, `revise` and `gstack-approvals import` refuse a CEO-only run with `CEO_ONLY_NO_GATE`. A CEO-only run stopped by `--spend-cap` recovers the usual way: `budget` then `resume`, or `close --deferred`, which ends it `reviewed`. + +### Fixed +- `gstack-approvals import --autoplan` no longer imports from a run whose `run.json` is present but not valid JSON; it refuses with `ARTIFACT_INVALID_JSON` instead of guessing the run's identity. + +## [1.92.57.0] - 2026-11-02 + +**Hermes can start gstack skills again (GST-882).** On stock Hermes, the Tirith scanner flagged every gstack skill's first command as HIGH ("Nested executable body could not be resolved"), so `gstack-skill-start` was denied in 8 of 8 trials. Tirith-only findings can't go on Hermes's permanent allowlist, so there was no workaround. The start line Tirith couldn't follow was a grouped fallback that picked the script through a variable. + +### Fixed +- **The skill-start line on Codex, Hermes and the other env-var hosts is plain shell.** A skill installed for one folder (Hermes, and the Codex install `./setup` writes) now checks for that folder's `bin/gstack-skill-start` and then runs it by its literal path. Tirith 0.4.2 allows that line where it blocked the old one. A checked-in render keeps the same lookup order (an exported `GSTACK_ROOT`, then the repo's own install, then the global one) as plain lines, without the `{ …; exit 1;}` group. When no install exists, both print the same "no install found" line with the setup command, and the start command then fails. +- **An install-root render's first block no longer honors an exported `GSTACK_ROOT`.** Tirith blocks any start command picked through a variable. Later blocks still honor it. `docs/troubleshooting.md` names this exception. + +### Tests +- `test/skill-start-plain.test.ts`: on every host and both render kinds, the start fence has no grouped fallback, and a missing install fails loudly under `bash -u`. The install-root start command is a literal path that runs, and a checked-in render finds the global install. On each env-var host, the start lookup picks the same root as later blocks for global, repo-local and exported installs. + +## [1.92.56.0] - 2026-11-02 + +**`gstack-ship-receipt write` records the session kind on Windows.** Without `--session-kind` or `GSTACK_SESSION_KIND`, it asks the `gstack-session-kind` bash helper. It used to spawn that helper by path, which never starts on Windows, so every /ship receipt there carried no `session_kind`. It now starts the helper through `lib/script-invocation.ts`, which uses Git Bash on Windows. This is the /ship half of #3541. + +### Fixed +- **`bin/gstack-ship-receipt`** starts `gstack-session-kind` with `runScriptSync`. + +### Tests +- `test/ship-receipt-session-kind.test.ts` (windows-free-tests too): a receipt written without a session kind records what `gstack-session-kind` prints. + +## [1.92.55.0] - 2026-11-02 + +- **`/ship` finish no longer stops on a branch whose restamp moved its own stale stamp after a lost slot** (`gstack-ship-release finish`, GST-895). When a branch loses its merge slot, merges main and runs `gstack-restamp --next`, the restamp moves the branch's own `since:` rows from the lost version to the new one. Finish only accepted a moved version at or above the reviewed tree's VERSION, which now came from main, so it read those rows as a behavior change and stopped with "insert 5–11.5 → 12–14 → 16" (gbrain #8134 on 1.92.46.0). Now a line the branch added itself, by the same rule restamp uses (added since the merge base with main, and not added by main), may move any version newer than the branch's fork point, and finish routes it as release metadata. A row main carries, a row main edited after the merge, and a branch row at or before the fork point still stop. Only `stamp_files` paths count, and nothing else on the line may change. + +### Tests +- `test/ship-rereview-triggers.test.ts`: after a lost slot, a merge of main and a restamp, the branch's moved row routes as metadata; moving main's own row, a merged main row that main later edited, a branch row at the fork version, or one older than the fork point each stop. The first case fails on main with `behavior=1`. + +## [1.92.54.0] - 2026-11-02 + +**`train land` and a single-PR `land` can no longer publish onto main at the same time, and a slot that forgot its version bump can no longer merge (#3599, #3531; GSTA-157).** Before, `train land` took no lock, so it and `land` could both read the same main and publish a combined tree neither tested. And train 33's `verify --train` said "may merge" for #3527's head after a bare commit dropped the stamp that restamp had written but not staged, so #3527 shipped under #3526's version. + +### Fixed +- **`train land` takes the shared land lock** (`refs/notes/gstack-land`, now in lib/ship-queue-land-lock.ts) as `train slot (#)`. Whichever of `land` and `train land` starts second refuses with `QUEUE_LAND_LOCKED`, naming the holder. Once a slot has merged, the train keeps the lock in phase `between` until its last slot lands, through a refusal on the next slot too, so no single land slips in between slots and sets off a freeze. A stop that freezes the queue leaves the lock in phase `freeze`, which only the admitted repair train takes over while the freeze lasts; once the freeze is cleared, the next `land` or `train land` takes over a freeze lock whose release push failed. A lock ref deleted on origin now reads as no lock, not as this clone's cached copy. +- **`--break-lock` prints the holder** (`LOCK_HOLDER:`), keeps land's staleness, mid-push and drift rules, never breaks a `freeze` lock, waits 10 minutes on a `between` lock, and refuses a live train run's lock unless `--break-train-lock` is also given. `train land` accepts both flags. +- **`verify --train` checks the slot stamp itself**: the head's VERSION must be above main's and its CHANGELOG headed with that version directly above main's top release, or it refuses with the new `QUEUE_TRAIN_UNSTAMPED`, naming the rule and both versions. It prints `STAMP_VERSION:` from the head. +- **`verify --train` calls a PR merged only when GitHub reports it MERGED.** An open release-only PR (#3530's shape) now gets the pre-merge check, and any other open or unreadable PR refuses with `QUEUE_PROOF_MISSING` without freezing the queue. So does a verify that could not read the PR's current head (`--no-fetch` or a failed fetch), instead of judging the unstamped pin. +- **`gstack-restamp` stages the files it writes** and prints `STAGED:`. It refuses before writing when a stamp file holds unstaged edits of your own (`RESTAMP_UNSTAGED_EDITS`); when staging fails, it says `RESTAMP_NOT_STAGED` and the next run stages the files. +- Two comments in lib/ship-queue-train-gate.ts now say "one spend cap per command gate across every prefix of the train", which is what the code does. + +### Tests +- `test/ship-queue-land-lock.test.ts`: two writers racing one lock sha, the four lock read states, owner kinds, the `--break-lock` rules, and both lock directions, the between phase, refusal and resume, and the freeze lock's repair takeover through `bin/gstack-ship-queue`. +- `test/ship-queue-train.test.ts`: the #3527 and #3530 shapes, the CHANGELOG rule, a claim-at-open pin, and no false freeze. `test/gstack-restamp-queue.test.ts`: staging after a no-commit merge, the unstaged-edits refusal, and a staging failure and its rerun. + +## [1.92.53.0] - 2026-11-02 + +**`reap` and `up` now destroy only your own Ubicloud VMs (GST-573).** Before, every `up` destroyed any VM in the project whose TTL had ended, whoever created it, which broke the rule that cloud deletes stay with the owner who made the resource. Now reap destroys only the caller's own expired VMs and its own old TTL-less ones, and only counts other owners' expired VMs in its log (`reap: left N expired VMs of other owners; only their owners reap them`). + +### Changed +- **Owner-scoped reap.** Another owner's VM, even an expired one, and an untagged one are never reaped. A lane's leftover VM is destroyed by that owner's own next `up` or `reap`, so a lane that never runs again leaves its VM until someone with that owner runs `reap` or `down`. + +## [1.92.52.0] - 2026-11-02 + +**/ship on Capy runs Exploratory QA again.** Since v1.91.345.0 (#3648) the driver sent Step 9.2.1 out as a QA reader subagent, but the section says only the parent runs report-only discovery, so the reader refused on every pass ("parent-only") and the parent ran the probes afterward anyway, an extra reader call each pass (the C4-36 and C5-36 replays). + +### Fixed +- **The parent runs Step 9.2.1's probes itself, as the section says.** Phase 3 does the section's method Reads and no probe; Phase 4's one program runs the smoke and plan checks after Step 8.1 collects the plan checks and before Scope Check, dedup and Fix-First, and the record's Checks heading carries the `Exploratory QA:` line. The call count is unchanged, and the section text is untouched. +- **`gstack-review-dispatch pass` refuses a QA extra.** An extra named `qa`, or one whose prompt holds Step 9.2.1's "Only the parent runs report-only discovery.", stops the pass before any reader is created. A prompt file that cannot be read stops as an unreadable prompt, not a raw read error. The Capy skill lists Steps 7, 8 and 14.5 as extras, not Step 9.2.1. + ## [1.92.51.0] - 2026-11-01 **PRs no longer wait on the six-shard Windows suite. A short `windows-smoke` check is the required Windows check, and the full suite runs nightly on main.** Windows has minimal support (docs/support-tiers.md). Since 2026-11-01 06:00 UTC, Windows Free Tests finished last on 71 of 73 PR heads, with a wall time of 892 s at p50 and 1,886 s at p90 over 71 green PR runs, and each run used about 59 Windows runner-minutes. The new smoke check finished in 198 s on this PR (run 41325664750). With smoke in its place, the slowest required check on a PR drops from 892 s to 562 s at p50 and from 1,877 s to 1,193 s at p90. diff --git a/VERSION b/VERSION index fdd84fc4e..84b00ae41 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.51.0 +1.92.62.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 306c1f587..3b68e506c 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.51.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.62.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index ff551298e..89b7f2782 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -57,8 +57,8 @@ line yourself; gstack never writes the plan. ### Commands -- `gstack-autoplan next --out --plan [scope flags] [--light] [--spend-cap ] [--max-rounds ] [--estimate-usd ] [--scaffold]` - first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default 2 unattended); a run with --spend-cap, in any session kind (or any unattended Capy run), reserves 5.00 per voice for each attempt, so the cap binds; --estimate-usd sets that reservation and the run keeps it for later phases. +- `gstack-autoplan next --out --plan [scope flags] [--light] [--spend-cap ] [--max-rounds ] [--estimate-usd ] [--scaffold] [--ceo-only]` + first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default 2 unattended); a run with --spend-cap, in any session kind (or any unattended Capy run), reserves 5.00 per voice for each attempt, so the cap binds; --estimate-usd sets that reservation and the run keeps it for later phases; --ceo-only (command line only, never a scope marker) reviews CEO alone and ends `status=reviewed` with `/ceo-review.md` (`REVIEWED:`): no gate, and the gate verbs refuse it (`CEO_ONLY_NO_GATE`). - `gstack-autoplan run --out --plan --native-model --outside-model --native-runner api --runner api [--max-tokens ] [--spend-cap ]` hosts without subagents: one command calls both models directly (ANTHROPIC_API_KEY, OPENAI_API_KEY) and loops next/submit to the gate; a response cut off at the output limit is reported truncated, so raise --max-tokens (default 16384 for both providers; OpenAI counts reasoning tokens toward it); each api call reserves its priced worst case (a model with no price reserves the per-voice default under a cap), a native call that went out but bound no result settles at its usage, or its full reservation when the usage is unknown, and a failed outside call stays counted at its reservation. - `gstack-autoplan next --out [--redispatch ]` diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan index faccea3fd..070aa3844 100755 --- a/bin/gstack-autoplan +++ b/bin/gstack-autoplan @@ -2,7 +2,7 @@ /** * gstack-autoplan — the unattended /autoplan runner as an explicit, durable state machine (plan B2). * - * gstack-autoplan next --out [--plan ] [--plan-heading ] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--docs-only] + * gstack-autoplan next --out [--plan ] [--plan-heading ] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--docs-only] [--ceo-only] * [--no-outside] [--light] [--spend-cap ] [--estimate-usd ] [--deadline ] [--redispatch ] [--json] * gstack-autoplan submit --out --phase

--voice native|outside --result | --record [--attest ] * --model [--attempt ] [--runner ] [--usage-usd ] [--reconcile ] [--json] @@ -31,10 +31,11 @@ */ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { amendmentSteps, answerGate, closeDeferred, finishGate, recommendGate } from '../lib/autoplan-gate'; +import { amendmentSteps, answerGate, closeDeferred, recommendGate } from '../lib/autoplan-gate'; +import { checkCeoOnlyFlags, finishRun, refuseCeoOnly } from '../lib/autoplan-ceo-only'; import { gateCost, gateCostPayload, renderGateCost, type GateCost } from '../lib/autoplan-gate-cost'; import { attemptEstimate, changeBudget, escalateLine, liftStop } from '../lib/autoplan-budget'; -import { DEFAULT_CARRY_CHECK_CAP_USD, carryLines, submitCarryCheck } from '../lib/autoplan-carry'; +import { DEFAULT_CARRY_CHECK_CAP_USD, submitCarryCheck } from '../lib/autoplan-carry'; import { ANSWERED_BY_RE } from '../lib/gate-list'; import { checkpointLines, checkpointRun, restoreCheckpoint, type CheckpointMoment } from '../lib/autoplan-checkpoint'; import { admit, touchInstallLease } from '../lib/autoplan-admission'; @@ -57,10 +58,11 @@ import { COORDINATOR_CONTRACT } from '../lib/coordinator-contract'; const HELP = `gstack-autoplan — unattended /autoplan: next/submit loop, answer, resume (docs/unattended.md) DRIVER: ${driverFile()} - next --out

[--plan ] [--plan-heading ] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx] [--docs-only] + next --out [--plan ] [--plan-heading ] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx] [--docs-only] [--ceo-only] [--no-outside] [--light] [--spend-cap ] [--max-rounds ] [--estimate-usd ] [--deadline ] [--redispatch ] [--json] --max-rounds (first next): review rounds per phase (default 2 unattended); at the cap a phase closes accepted-with-findings --docs-only (first next; also detected when every Implementation-plan path is docs-class): CEO (native voice only) + Eng + --ceo-only (first next, command line only): the CEO phase alone, no gate; ends status=reviewed with /ceo-review.md, never approved --estimate-usd: reserved per new attempt, kept for the rest of the run; with --spend-cap any run (or an unattended run on Capy) reserves a default so the cap binds --scaffold (first next): a plan missing one of its two sections is reviewed from /plan-source.md with the heading added --light loads each phase's checklist plus the sections its scope needs (the unattended default); interactive kinds load everything @@ -187,16 +189,18 @@ function doNext(out: string, rest: string[], json: boolean, price?: NextOptions[ const cwd = process.cwd(); const initLines: string[] = []; const fresh = !fs.existsSync(path.join(out, FILES.run)); + if (!fresh) checkCeoOnlyFlags(rest, undefined, readState(out)); if (fresh) { const plan = flag(rest, '--plan'); if (!plan) throw new RunError('RUN_NOT_INITIALIZED', `${out} has no run.json; pass --plan on the first next`, 3); + const ceoOnly = checkCeoOnlyFlags(rest, path.resolve(plan), undefined, out); const ui = rest.includes('--ui') ? true : rest.includes('--no-ui') ? false : undefined; const maxRounds = num(rest, '--max-rounds'); if (maxRounds !== undefined && (!Number.isInteger(maxRounds) || maxRounds < 1)) throw new RunError('ARTIFACT_SCHEMA', '--max-rounds needs an integer >= 1', 1); const carryCheckCapUsd = num(rest, '--carry-check-cap'); if (carryCheckCapUsd !== undefined && !(carryCheckCapUsd >= 0)) throw new RunError('ARTIFACT_SCHEMA', '--carry-check-cap needs a number >= 0', 1); initRun({ plan, out, cwd, planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null, maxRounds, - carryForward: !rest.includes('--no-carry-forward'), carryCheckCapUsd, + carryForward: !rest.includes('--no-carry-forward'), carryCheckCapUsd, ceoOnly, deadlineMin: num(rest, '--deadline'), light: rest.includes('--light'), scaffold: rest.includes('--scaffold'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary'), dx: rest.includes('--no-dx') ? false : undefined, ...(rest.includes('--docs-only') ? { docsOnly: true } : {}) } }, initLines); } const redispatch = rest.flatMap((a, i) => (a === '--redispatch' && rest[i + 1] ? [rest[i + 1]!] : [])); @@ -253,6 +257,7 @@ function doRevise(out: string, rest: string[], json: boolean): number { const plan = flag(rest, '--plan'); const incorporates = listFlag(rest, '--incorporates'); const withdraw = listFlag(rest, '--withdraw'); const settle = listFlag(rest, '--settle'); if (!plan && !withdraw.length && !settle.length) return usage('revise needs --plan (and optionally --incorporates ), --withdraw or --settle '); if (!plan && incorporates.length) return usage('--incorporates binds accepted changes to a revised plan: pass --plan '); + refuseCeoOnly(readState(out), 'revise'); const r = reviseRun({ out, plan: plan ? path.resolve(plan) : undefined, incorporates, withdraw, settle, cwd: process.cwd() }); const lines = [...r.lines]; if (r.state.status === 'running' || r.state.status === 'amend_pending') lines.push(nextLine(r.state, out)); @@ -273,7 +278,7 @@ function doClose(out: string, rest: string[], json: boolean): number { const r = closeDeferred(state, phase as Phase, { reason: reason.trim(), by, cause: 'owner' }); moment = 'transition'; const lines = [...r.lines, ...(stopped ? liftStop(state, `${phase} closed deferred by ${by}`) : [])]; - if (!currentPhase(state)) { finishGate(state); lines.push(...carryLines(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : [])); } + if (!currentPhase(state)) lines.push(...finishRun(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : [])); else { state.status = 'running'; lines.push(`NEXT: gstack-autoplan next --out ${out}`); } writeState(state); lines.push(resultLineFor(state.status, out)); @@ -415,7 +420,7 @@ async function main(argv: string[]): Promise { if (cmd === 'answer') { const rev = flag(rest, '--gate-rev'); const reply = flag(rest, '--reply'); const by = flag(rest, '--by'); if (reply === undefined) return usage('answer needs --reply ""'); - if (rev === undefined) { const s = readState(out); process.stderr.write(resultLine('gstack-autoplan answer', 'GATE_REV_STALE', `no --gate-rev given; current gate_rev=${s.gate_rev ?? 0}`) + '\n'); return EXIT.refused; } + if (rev === undefined) { const s = readState(out); refuseCeoOnly(s, 'answer'); process.stderr.write(resultLine('gstack-autoplan answer', 'GATE_REV_STALE', `no --gate-rev given; current gate_rev=${s.gate_rev ?? 0}`) + '\n'); return EXIT.refused; } const replyFile = flag(rest, '--owner-reply-file'); const ownerFlag = flag(rest, '--owner-reply'); if (replyFile !== undefined && ownerFlag !== undefined) return usage('--owner-reply and --owner-reply-file are exclusive'); const wordsBy = flag(rest, '--words-by') ?? 'owner'; @@ -423,6 +428,7 @@ async function main(argv: string[]): Promise { const ownerReply = replyFile !== undefined ? fs.readFileSync(replyFile, 'utf8') : ownerFlag === '-' ? fs.readFileSync(0, 'utf8') : ownerFlag; return mutating(out, json, () => { const state = readState(out); + refuseCeoOnly(state, 'answer'); const r = answerGate(state, Number(rev), reply, by, { recHash: flag(rest, '--rec-hash'), ownerReply, ...(wordsBy === 'owner' ? {} : { wordsBy }) }); writeState(state); const lines = [...r.lines]; @@ -437,6 +443,7 @@ async function main(argv: string[]): Promise { if (rev === undefined || !item || !option || !basis?.trim()) return usage('recommend needs --gate-rev --item --option --basis ""'); return mutating(out, json, () => { const state = readState(out); + refuseCeoOnly(state, 'recommend'); const lines = recommendGate(state, Number(rev), item.toLowerCase(), option.toLowerCase(), basis.trim(), flag(rest, '--by') ?? 'parent'); writeState(state); emit(json, [...lines, resultLineFor(state.status, out)], { run: state.run, status: state.status, gate_rev: state.gate_rev, out }); diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 33f77842f..960129724 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -338,6 +338,9 @@ gstack_result_anchor() { RECEIPT_GIT_TOO_OLD) printf '%s' 'receipt-git-too-old' ;; CHANGELOG_FRAGMENT_INVALID) printf '%s' 'changelog-fragment-invalid' ;; QUEUE_LAND_LOCKED) printf '%s' 'queue-land-locked' ;; + QUEUE_TRAIN_UNSTAMPED) printf '%s' 'queue-train-unstamped' ;; + RESTAMP_NOT_STAGED) printf '%s' 'restamp-not-staged' ;; + RESTAMP_UNSTAGED_EDITS) printf '%s' 'restamp-unstaged-edits' ;; QUEUE_LAND_CONFLICT) printf '%s' 'queue-land-conflict' ;; QUEUE_LAND_DRIFT) printf '%s' 'queue-land-drift' ;; QUEUE_LAND_BASE_BUSY) printf '%s' 'queue-land-base-busy' ;; @@ -375,6 +378,9 @@ gstack_result_anchor() { OFFICE_HOURS_PROBE_REFUSED) printf '%s' 'office-hours-probe-refused' ;; TASKS_SUPERSEDE_REFUSED) printf '%s' 'tasks-supersede-refused' ;; REALITY_RECEIPT_UNRESOLVED) printf '%s' 'reality-receipt-unresolved' ;; + CEO_ONLY_CONFLICT) printf '%s' 'ceo-only-conflict' ;; + CEO_ONLY_AFTER_INIT) printf '%s' 'ceo-only-after-init' ;; + CEO_ONLY_NO_GATE) printf '%s' 'ceo-only-no-gate' ;; *) return 1 ;; esac } diff --git a/bin/gstack-restamp b/bin/gstack-restamp index b4922171b..ef6e44c7b 100755 --- a/bin/gstack-restamp +++ b/bin/gstack-restamp @@ -10,11 +10,18 @@ * Runs gstack-version-bump write, the policy's release_tool, the CHANGELOG * re-heading and the stamp_paths rewrites in an isolated staging worktree, * shows the diff limited to stamp files, and writes the branch only after - * every stage validated (journal: .gstack/tmp/restamp-journal.json). - * Idempotent; --dry-run writes nothing. Workflow command: ends with the + * every stage validated (journal: .gstack/tmp/restamp-journal.json), then + * stages what it wrote (`git add -A -- `, STAGED:), so a bare commit after + * a --no-commit base merge cannot drop the stamp (#3531). A stamp file holding + * unstaged edits of the caller's own refuses before anything is written + * (RESTAMP_UNSTAGED_EDITS); a staging failure leaves the files written and + * .gstack/tmp/restamp-staging.json, which the noop rerun stages, or drops when a + * file changed since (RESTAMP_NOT_STAGED). Idempotent; --dry-run writes nothing. Workflow command: ends with the * GSTACK_RESULT line. Logic: lib/restamp.ts; policy: lib/ship-policy.ts. */ import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; import { EXIT, exitTableHelp, formatResult } from '../lib/headless-artifacts'; import { GATE_AHEAD_REL, JOURNAL_REL, restamp, type RestampResult } from '../lib/restamp'; import { RESULT_CODES, type ResultCodeName } from '../lib/result-codes'; @@ -39,7 +46,7 @@ const HELP = `gstack-restamp — stamp the version, release tool, CHANGELOG and --dry-run print the full diff, write nothing --json JSON only on stdout -Prints STAMP_VERSION: , the stage table, the stamp diff and +Prints STAMP_VERSION: , the stage table, the stamp diff, STAGED: and GSTACK_RESULT: skill=restamp status=complete|refused|incomplete run=${JOURNAL_REL}. ${exitTableHelp()}`; @@ -48,6 +55,49 @@ function flag(args: string[], name: string): string | undefined { return i >= 0 ? args[i + 1] : undefined; } +const STAGING_REL = '.gstack/tmp/restamp-staging.json'; +interface Pending { files: string[]; blobs: Record } +interface Staging { staged?: string[]; dropped?: string[]; failure?: string } + +const gitIn = (root: string, args: string[]) => spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 60_000 }); +/** Each file's blob sha in the worktree (one git call), null for a file that does not exist. */ +function blobsOf(root: string, files: string[]): Record { + const present = files.filter(f => fs.existsSync(path.join(root, f))); + const shas = present.length ? gitIn(root, ['hash-object', '--', ...present]).stdout.trim().split('\n') : []; + return Object.fromEntries(files.map(f => [f, present.includes(f) ? shas[present.indexOf(f)] ?? '' : null])); +} +function readPending(root: string): Pending | null { + try { return JSON.parse(fs.readFileSync(path.join(root, STAGING_REL), 'utf8')) as Pending; } catch { return null; } +} + +/** Files with unstaged edits that are the caller's own (not bytes a pending restamp wrote and could not stage). */ +function ownUnstaged(root: string): string[] { + const pending = readPending(root); + const unstaged = gitIn(root, ['diff', '--name-only', '-z']).stdout.split('\0').filter(Boolean); + const now = pending ? blobsOf(root, unstaged.filter(f => f in pending.blobs)) : {}; + return unstaged.filter(f => !(pending && f in now && pending.blobs[f] === now[f])); +} + +/** + * Stage what restamp wrote (`written`), or what a pending marker says is still unstaged; a marker file whose bytes + * changed since (a hand commit, another branch, a base merge) is stale for that file and dropped, never a failure. + * Null when there is nothing to stage. + */ +function stage(root: string, written: string[] | null): Staging | null { + const marker = path.join(root, STAGING_REL); + const pending = written ? { files: written, blobs: blobsOf(root, written) } : readPending(root); + if (!pending?.files.length) return null; + if (written) { fs.mkdirSync(path.dirname(marker), { recursive: true }); fs.writeFileSync(marker, JSON.stringify(pending, null, 2) + '\n'); } + const now = written ? pending.blobs : blobsOf(root, pending.files); + const dropped = pending.files.filter(f => now[f] !== pending.blobs[f]); + const files = pending.files.filter(f => !dropped.includes(f)); + if (!files.length) { fs.rmSync(marker, { force: true }); return { staged: [], dropped }; } + const add = gitIn(root, ['add', '-A', '--', ...files]); + if (add.status !== 0) return { failure: `git add of ${files.join(' ')} failed (${(add.stderr || add.stdout).trim().split('\n')[0]}); the stamp stays written and ${STAGING_REL} names it` }; + fs.rmSync(marker, { force: true }); + return { staged: gitIn(root, ['diff', '--cached', '--name-only', '-z', '--', ...files]).stdout.split('\0').filter(Boolean), dropped }; +} + function main(argv: string[]): number { if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(HELP + '\n'); return EXIT.ok; } const json = argv.includes('--json'); @@ -65,23 +115,32 @@ function main(argv: string[]): number { const after = flag(argv, '--after'); const base = resolveBaseBranch(repoRoot, flag(argv, '--base')); - const result = restamp({ + const options = { repoRoot, base, version: flag(argv, '--version'), next: argv.includes('--next'), bump, queueMode, after: after ? { pr: after, ref: flag(argv, '--predecessor-ref') } : undefined, pr: flag(argv, '--pr'), expectBase: flag(argv, '--expect-base'), was: flag(argv, '--was'), expectOrder: flag(argv, '--expect-order')?.split(',').map(s => s.trim()).filter(Boolean), policyFrom, allowRepoCommands: argv.includes('--allow-repo-commands'), dryRun: argv.includes('--dry-run'), failAfter: process.env.GSTACK_RESTAMP_FAIL_AFTER, - }); - return report(result, json); + }; + const writes = !after && !options.dryRun; + const own = writes ? ownUnstaged(repoRoot) : []; + const dry = own.length ? restamp({ ...options, dryRun: true }) : null; + if (dry && (dry.status === 'refused' || dry.status === 'failed')) return report(dry, json); + const clash = (dry?.files ?? []).filter(f => own.includes(f)); + if (clash.length) return report({ status: 'refused', code: 'RESTAMP_UNSTAGED_EDITS', message: `${clash.join(', ')} ${clash.length === 1 ? 'holds' : 'hold'} unstaged edits of your own, which staging the stamp would sweep into the stamp commit; nothing written`, stages: [] }, json); + const result = restamp(options); + const staging = writes && (result.status === 'stamped' || result.status === 'noop') ? stage(repoRoot, result.status === 'stamped' ? result.files ?? [] : null) : null; + return report(result, json, staging); } -function report(r: RestampResult, json: boolean): number { - const status = r.status === 'refused' ? 'refused' : r.status === 'failed' ? 'incomplete' : 'complete'; - const code = r.status === 'refused' ? EXIT.refused : r.status === 'failed' ? EXIT.fail : EXIT.ok; +function report(r: RestampResult, json: boolean, staging: Staging | null = null): number { + const status = r.status === 'refused' ? 'refused' : r.status === 'failed' || staging?.failure ? 'incomplete' : 'complete'; + const code = r.status === 'refused' ? EXIT.refused : r.status === 'failed' || staging?.failure ? EXIT.fail : EXIT.ok; if (json) { - process.stdout.write(JSON.stringify({ ...r, result: status }) + '\n'); + process.stdout.write(JSON.stringify({ ...r, ...(staging ?? {}), ...(staging?.failure && !r.code ? { code: 'RESTAMP_NOT_STAGED' } : {}), result: status }) + '\n'); return code; } + if (staging?.failure) process.stderr.write(`gstack-restamp: ${staging.failure}; fix: ${RESULT_CODES.RESTAMP_NOT_STAGED.fix} (RESTAMP_NOT_STAGED)\n`); if (r.code) { const row = RESULT_CODES[r.code as ResultCodeName]; process.stderr.write(`gstack-restamp: ${r.message}; fix: ${r.fix ?? row.fix} (${r.code})\n`); @@ -95,6 +154,8 @@ function report(r: RestampResult, json: boolean): number { if (r.gateAhead) process.stdout.write(`GATE_AHEAD: head=${r.gateAhead.gated_head} tree=${r.gateAhead.gated_tree} predecessor=#${r.gateAhead.predecessor_pr}@${r.gateAhead.predecessor_head.slice(0, 12)} worktree=${r.gateAhead.worktree} evidence_branch=${r.gateAhead.evidence_branch_key}\n`); if (r.diff) process.stdout.write(r.diff + (r.diff.endsWith('\n') ? '' : '\n')); process.stdout.write(`FILES: ${(r.files ?? []).join(' ') || '(none)'}\n`); + if (staging?.staged) process.stdout.write(`STAGED: ${staging.staged.join(' ') || '(none)'}\n`); + if (staging?.dropped?.length) process.stdout.write(`NOTE: ${staging.dropped.join(' ')} changed since an earlier restamp wrote ${staging.dropped.length === 1 ? 'it' : 'them'}; not staged, and that run's staging marker is dropped\n`); if (r.mergeCondition) process.stdout.write(`MERGE_CONDITION: sha=${r.mergeCondition}\n`); if (r.status === 'gate-ahead' && r.gateAhead) process.stdout.write(`next: run the verification gate in ${r.gateAhead.worktree}, then \`gstack-evidence bundle --out \` there; the branch is untouched. At this PR's turn rerun \`gstack-restamp --next\` here and read \`gstack-tree-receipt --from ${GATE_AHEAD_REL}\` before deciding what to rerun.\n`); else if ((r.status === 'stamped' || r.status === 'noop') && r.queueMode === 'stamp-at-merge') process.stdout.write(`next: NEW_VERSION=${r.newVersion}; commit and push only the stamp diff above (FILES), with no edit of your own: the train's verify accepts the CHANGELOG only as the base's plus this PR's entry re-headed; merge with the expected head ${r.mergeCondition ?? 'sha'} and recompute on a stale queue.\n`); diff --git a/bin/gstack-ship-queue b/bin/gstack-ship-queue index f72bd0e8f..738acc9c2 100755 --- a/bin/gstack-ship-queue +++ b/bin/gstack-ship-queue @@ -14,7 +14,7 @@ * gstack-ship-queue gate-ahead --list --pr * gstack-ship-queue verify --pr --screen (train policy: the head's screen verdict from its run) * gstack-ship-queue verify --post-merge [--timeout ] (after a merge: main's push workflows at that commit until they settle) - * gstack-ship-queue land --pr [--dry-run] [--no-wait] [--break-lock] (the merge step; lib/ship-queue-land.ts) + * gstack-ship-queue land --pr [--dry-run] [--no-wait] [--break-lock [--break-train-lock]] (the merge step; lib/ship-queue-land.ts) * gstack-ship-queue land --pr --repair (after QUEUE_LAND_DRIFT: open the outside merge's release PR) * gstack-ship-queue land --census * gstack-ship-queue train build|status|verdict|bisect|rebuild|watch|prune ... (merge trains; train --help) @@ -57,7 +57,7 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc verify --pr --screen (train policy) the PR head's screen verdict read from its run (SCREEN_VERDICT) verify --post-merge [--timeout ] wait for every check run at a merged commit to settle: POST_MERGE: green|red|pending and one CHECK: line per workflow; resumable, default timeout 2700 s - land --pr [--dry-run] [--no-wait] [--timeout ] [--break-lock] [--allow-repo-commands] + land --pr [--dry-run] [--no-wait] [--timeout ] [--break-lock [--break-train-lock]] [--allow-repo-commands] the merge step: lock, merge the base, stamp (fragments folded), push the stamp commit, wait for its checks, squash with --match-head-commit, confirm the base tree; resumable land --pr --repair after QUEUE_LAND_DRIFT: open the PR that gives the merge outside land its own @@ -172,7 +172,7 @@ async function main(argv: string[]): Promise { if (args.includes('--census')) return finish('land', l.census(ctx)); if (!pr || !/^\d+$/.test(pr)) return usage('land needs --pr (or --census)'); const timeout = flag(args, '--timeout'); - return finish('land', l.land(ctx, { pr, repair: args.includes('--repair'), dryRun: args.includes('--dry-run'), noWait: args.includes('--no-wait'), breakLock: args.includes('--break-lock'), allowRepoCommands: args.includes('--allow-repo-commands'), waitTimeoutMin: timeout && /^\d+$/.test(timeout) ? Number(timeout) : undefined })); + return finish('land', l.land(ctx, { pr, repair: args.includes('--repair'), dryRun: args.includes('--dry-run'), noWait: args.includes('--no-wait'), breakLock: args.includes('--break-lock'), breakTrainLock: args.includes('--break-train-lock'), allowRepoCommands: args.includes('--allow-repo-commands'), waitTimeoutMin: timeout && /^\d+$/.test(timeout) ? Number(timeout) : undefined })); } if (!pr || !/^\d+$/.test(pr)) return usage(`${cmd} needs --pr `); const gateExit = flag(args, '--gate-exit'); diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index b2d902b65..3de38d032 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -37,6 +37,7 @@ import * as path from 'node:path'; import { EXIT, exitTableHelp, type ShipReceipt } from '../lib/headless-artifacts'; import { resultLine, resultLineWithDocs } from '../lib/result-codes'; import { versionAtRef } from '../lib/restamp'; +import { runScriptSync } from '../lib/script-invocation'; import { readPregateFile, verifyPregate } from '../lib/pregate/index'; import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, runningGstackVersion, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy'; import { approvalForReceipt } from '../lib/approval-citations'; @@ -179,7 +180,7 @@ function cmdWrite(rest: string[], json: boolean): number { const tree = receiptFile ? JSON.parse(fs.readFileSync(receiptFile, 'utf8')) : null; let sessionKind = flag(rest, '--session-kind') ?? process.env.GSTACK_SESSION_KIND; if (!sessionKind) { - const r = spawnSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-session-kind'), [], { encoding: 'utf8', timeout: 15_000 }); + const r = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-session-kind'), [], { encoding: 'utf8', timeout: 15_000 }); sessionKind = r.status === 0 ? r.stdout.trim() || undefined : undefined; } let pregate: PregateField | undefined; diff --git a/captain/templates/slot-call.md b/captain/templates/slot-call.md index f1908884a..36d70f1db 100644 --- a/captain/templates/slot-call.md +++ b/captain/templates/slot-call.md @@ -12,10 +12,12 @@ Run: gstack-restamp --next --base --pr --expect-base --expect-o Slot 2 and later (and slot 1 when BASE moved): merge BASE into your pin first (expect a CHANGELOG-only conflict; resolve it with mergeChangelogs from lib/changelog-check.ts, the one-liner in docs/ship-policy.md's merge-train section, never by hand; grep for conflict -markers before you stamp). Commit only the BASE merge and the stamp files this call names: - (plus the file carrying a `*_KEY_VERSION` your PR introduced, on your own -line only, when this call names it), never a fifth: a file the restamp lists that this call -does not name is not a stamp, leave it out and say so. +markers before you stamp). The restamp stages what it writes (STAGED:); commit what it +staged, which must be only the BASE merge and the stamp files this call names: +(plus the file carrying a `*_KEY_VERSION` your PR introduced, on your own line only, when this +call names it), never a fifth: a file the call does not name is not a stamp, `git restore +--staged --worktree` it and say so. verify reads the head's own STAMP_VERSION and refuses an unstamped +head (QUEUE_TRAIN_UNSTAMPED). A pin stacked on a PR that merged earlier conflicts beyond the CHANGELOG where the parent's squash touched your lines: take your pin's bytes there (verify checks the tree against T); mergeChangelogs carries your edits to the parent's released entry as Gate integrity diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 7c87a2786..c6d85c010 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -46,7 +46,7 @@ Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "" })` (its own jam; a subagent reading root `"this"` starts `--subagent --reading-from this --meter-scope tree`) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading --credits `, never alone. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`). -/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT:` (`poll`: rerun) or `STOP:`. `models` maps roles to models (`outside-voice`: another family); Step 11 adds `stage: "voices"`; a full repeat pass, `diffBase: ""`. Steps 7, 8, 9.2.1 and 14.5 (`step: "14.5"`) use `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your jam's transcript since /ship began. A small waiting child saves nothing. +/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT:` (`poll`: rerun) or `STOP:`. `models` maps roles to models (`outside-voice`: another family); Step 11 adds `stage: "voices"`; a full repeat pass, `diffBase: ""`. Steps 7, 8 and 14.5 (`step: "14.5"`) use `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your jam's transcript since /ship began. A small waiting child saves nothing. ## Plan, approve, ship diff --git a/changelog.d/gsta203-pr6-clear-workflow-check.md b/changelog.d/gsta203-pr6-clear-workflow-check.md deleted file mode 100644 index 8465779ba..000000000 --- a/changelog.d/gsta203-pr6-clear-workflow-check.md +++ /dev/null @@ -1 +0,0 @@ -- **`gstack-pregate clear --run-url` checks the run's workflow.** On a lane with no run profile, `clear` used to record any URL as the receipt, so a `windows-smoke.yml` run could clear `windows-free-tests.yml/windows-free-shard` on a branch cut before #3806. When the base config and the GitHub origin can be read, `clear` now reads the run and refuses `PREGATE_RECEIPT_INVALID` (`workflow_mismatch`) unless its workflow file is the lane's own; a run gh cannot read (`api_error`) or a URL that is not an Actions run on this repository (`run_ref_invalid`) refuses too, and each refusal names its own fix. A run of the lane's own workflow clears as before, including a job link copied with `?pr=` or `#step`. `test_backend/*` lanes, `--bundle-lane` and non-GitHub origins are unchanged. On main the Windows obligation is `windows-smoke.yml/windows-smoke`, and a test now checks that against the repository's own workflows. The `ready` conflict hint now says to clear each lane with a run of that lane's own workflow. `test/pregate-clear-runs.test.ts` covers the mismatch, the matching control, the unreadable run, the non-run URL and the copied job links; each failed before its fix. diff --git a/docs/TESTING_INTERNALS.md b/docs/TESTING_INTERNALS.md index ea9a9f4ca..91c400897 100644 --- a/docs/TESTING_INTERNALS.md +++ b/docs/TESTING_INTERNALS.md @@ -1002,7 +1002,7 @@ with `UBICLOUD: left=<0|1|unknown> owner= vm= others=`, checked against the project's VM list after teardown; anything but `left=0` turns a passing command into exit 1. pregate-remote.sh reads that line from the file named by `UBI_LEFT_FILE`, never from the command's output. No owner-age sweep runs by default (the TTL reaper -below covers every owner); set +below covers this owner's expired VMs); set `UBI_GC_HOURS` to a positive number (or run `gc HOURS`; `gc 0` is a no-op) to sweep only this owner's VMs older than that. For other commands, use the runner directly: `scripts/ubicloud/ubi-runner.sh run --setup ')], + ['an uppercase SCRIPT', swap('<title>', '<SCRIPT>alert(1)</SCRIPT><title>')], + ['a multi-line onload attribute', ['<svg xmlns="http://www.w3.org/2000/svg"', ' onload', ' ="alert(1)">', '</svg>']], + ['an onclick handler', swap('<rect ', '<rect onclick="alert(1)" ')], + ['a Vue :fill binding', swap('<rect ', '<rect :fill="leak(document.cookie)" ')], + ['a v-bind: binding', swap('<rect ', '<rect v-bind:fill="x" ')], + ['an Alpine x-bind: binding', swap('<rect ', '<rect x-bind:fill="x" ')], + ['an @click binding', swap('<rect ', '<rect @click="x()" ')], + ['mustache text', swap('33/41 & up', '{{ constructor.constructor("alert(1)")() }}')], + ['a foreignObject', swap('<title>', '<foreignObject><div>x</div></foreignObject><title>')], + ['an animate on href', swap('<use href="#g"/>', '<use href="#g"><animate attributeName="href" to="javascript:alert(1)"/></use>')], + ['a set element', swap('<use href="#g"/>', '<set attributeName="fill" to="red"/>')], + ['a doctype with an entity', ['<!DOCTYPE svg [<!ENTITY x "y">]>', ...CHART.slice(1)]], + ['an xlink:href', swap('<use href="#g"/>', '<use xlink:href="#g"/>')], + ['an outside use href', swap('<use href="#g"/>', '<use href="https://evil.example/x.svg#a"/>')], + ['an href off use', swap('<text x="30"', '<text href="#g" x="30"')], + ['a style url(', swap('style="opacity: 0.9"', 'style="fill: url(#g)"')], + ['a style @import', swap('style="opacity: 0.9"', 'style="@import url(x.css)"')], + ['an outside fill url', swap('fill="url(#g)"', 'fill="url(https://evil.example/x)"')], + ['a CSS-escaped url', swap('fill="url(#g)"', 'fill="\\75rl(https://evil.example/x)"')], + ['an xml-stylesheet instruction', ['<?xml-stylesheet href="https://evil.example/x.css"?>', ...CHART.slice(1)]], + ['a CDATA section', swap('33/41 & up', '<![CDATA[<script>x</script>]]>')], + ['an image element', swap('<title>', '<image href="https://evil.example/x.png"/><title>')], + ['a style element', swap('<title>', '<style>@import url(x.css);</style><title>')], + ['another namespace', swap('xmlns="http://www.w3.org/2000/svg"', 'xmlns="http://www.w3.org/1999/xhtml"')], + ['a numeric entity in an attribute', swap('fill="url(#g)"', 'fill="url(https://evil.example/x)"')], + ['an unclosed root', CHART.slice(0, -2)], + ['two roots', [...CHART, '<svg xmlns="http://www.w3.org/2000/svg"></svg>']], + ['text after the root', [...CHART, 'trailing']], + ['an HTML-closed empty comment <!-->', swap('<title>', '<!--><image width="1" height="1"/>--><title>')], + ['an HTML-closed empty comment <!--->', swap('<title>', '<!---><foreignObject/>--><title>')], + ['numeric references spelling an interpolation', swap('33/41 & up', '{{run()}}')], + ['an attribute interpolation', swap('<text x="30"', '<text class="{{run()}}" x="30"')], + ])('a new SVG with %s is code', (_n, lines) => { + expect(svgClass(lines as string[])).toBe('code'); + }); + test('an edited SVG is code even when both sides are clean', () => { + expect(classOf(SVG_PATH, CHART, swap('33/41', '30/41'))).toBe('code'); + expect(classOf(SVG_PATH, CHART)).toBe('code'); + }); + test.each([ + ['i18n .ts', 'src/i18n/en.ts', " save: 'Save',", " save: 'Save now',"], + ['string-concat HTML', 'src/App.tsx', "const html = '<h1>' + title + '</h1>';", "const html = '<h1>' + leak() + '</h1>';"], + ['an edit inside an existing <script>', 'src/App.vue', ' const label = "Save";', ' const label = "Save now";'], + ['a Vue :title binding', 'src/App.vue', '<div :title="user.name">Profile</div>', '<div :title="leak(document.cookie)">Profile</div>'], + ['a markup value', 'locales/en.json', ' "save": "Save",', ' "save": "<img src=x onerror=alert(1)>",'], + ['a changed placeholder', 'locales/en.json', ' "hi": "Hi {name}",', ' "hi": "Hi {user.password}",'], + ['a new mustache expression', 'locales/en.json', ' "hi": "Hi",', ' "hi": "Hi {{constructor.constructor(1)()}}",'], + ['a vue-i18n linked message', 'locales/en.json', ' "hi": "Hi",', ' "hi": "Hi @:secret.key",'], + ['an i18next nesting', 'locales/en.json', ' "hi": "Hi",', ' "hi": "Hi $t(secret)",'], + ['a renamed key', 'locales/en.json', ' "save": "Save",', ' "store": "Save",'], + ['an escaped character', 'locales/en.json', ' "save": "Save",', ' "save": "\\u003cscript\\u003e",'], + ['a script URL', 'locales/en.yml', ' link: "https://example.com"', ' link: "javascript:alert(1)"'], + ['a locale file outside a locale directory', 'src/en.json', ' "save": "Save",', ' "save": "Save now",'], + ['a locale file under config/', 'config/locales/en.yml', ' save: "Save"', ' save: "Save now"'], + ['an escaped script scheme', 'locales/en.json', ' "go": "Continue",', ' "go": "java\\nscript:alert(1)",'], + ['a protocol-relative link', 'locales/en.json', ' "help": "[docs](/help)",', ' "help": "[docs](//evil.example/help)",'], + ['a YAML null', 'locales/en.yml', ' label: Save', ' label: null'], + ['a YAML boolean', 'locales/en.yml', ' label: Save', ' label: false'], + ['a renamed gettext placeholder', 'locale/fr/LC_MESSAGES/app.po', 'msgstr "Bonjour %(name)s"', 'msgstr "Bonjour %(password)s"'], + ['an Ansible task under a locale role', 'roles/locale/tasks/main.yml', ' command: a-tool one', ' command: b-tool two'], + ['a non-message .properties under lang/', 'src/main/resources/lang/app.properties', 'db.url=host-a', 'db.url=host-b'], + ['a non-locale JSON under lang/', 'tools/lang/rules.json', ' "pattern": "^[a-z]+$",', ' "pattern": ".*",'], + ['a locale value carrying a risk line', 'src/i18n/en.yml', ' command: echo ok', ' command: rm -rf /tmp/x'], + ])('%s is code', (_n, p, before, after) => { + expect(classOf(p, [after], [before])).toBe('code'); + }); + test('an added key and the backtracking input are code, and the input finishes fast', () => { + expect(classOf('locales/en.json', [' "extra": "Extra",'])).toBe('code'); + expect(classOf('src/i18n/en.yml', [' title: Hello', ' sub: World'], [' title: Hello', ' sub: World'])).toBe('code'); + const t = performance.now(); + expect(classOf('locales/en.yml', [' save: "' + '\\'.repeat(10_000) + 'x'], [' save: "Save"'])).toBe('code'); + expect(classOf('locales/en.yml', [' save: "' + '\\'.repeat(9_999) + '"'], [' save: "Save"'])).toBe('code'); + expect(performance.now() - t).toBeLessThan(100); + }); +}); + +describe('GST-850: security reaches outbound URLs, archives, path traversal and every child_process spelling', () => { + // Value: protects=an SSRF, archive-extraction, traversal or shell-exec diff reaches the full panel; fails_when=a spelling of any of + // them reads light, or a literal URL, a spawnSync import or a tarball create raises a class; seam=matchRisk + test.each([ + "import { exec } from 'node:child_process';", 'import { execSync as run } from "child_process";', "import { spawn, spawnSync } from 'node:child_process';", + "import cp from 'node:child_process';", "import * as cp from 'child_process';", "import cp, { spawnSync } from 'child_process';", + "const { exec } = require('child_process');", "const cp = require('node:child_process');", "const cp = await import('node:child_process');", + 'exec(cmd, cb);', 'cp.exec(cmd);', "import { spawnSync } from 'node:child_process';", "import type { ExecOptions } from 'node:child_process';", + "const r = await fetch('https://api.github.com/repos');", 'r = requests.request("GET", user_url)', "http.get('http://x.example/', { hostname: req.query.t }, cb);", 'const res = await fetch(url);', "await fetch('https://' + host + '/v1');", 'https.get(target, cb);', 'await axios.get(req.query.u);', 'const body = await got(url).text();', + 'r = requests.get(url, timeout=5)', 'urllib.request.urlopen(target)', 'const next = new URL(req.query.next, base);', "import { request } from 'undici';", + "const res = await fetch('https://x.example', { redirect: 'follow' });", 'maxRedirects: 10,', 'r = httpx.get("https://x.example", follow_redirects=True)', 'const p = process.env.HTTPS_PROXY;', 'const agent = new HttpsProxyAgent(p);', + "const ALLOWED_HOSTS = ['api.example.com'];", 'hostAllowlist.push(h);', + 'await tar.x({ file, cwd: dest });', 'new AdmZip(buf).extractAllTo(dir, true);', "import yauzl from 'yauzl';", "import extract from 'extract-zip';", 'zipfile.ZipFile(p).extractall(d)', + 'tarfile.open(p).extractall(d)', 'tar -xzf "$TGZ" -C "$DIR"', 'unzip -o "$ZIP" -d "$DIR"', 'fs.writeFileSync(path.join(root, entry.path), data);', 'const dest = path.resolve(out, req.params.name);', + "if (rel.startsWith('..')) throw new Error('escape');", "if (parts.includes('..')) return;", + ])('%s is security', line => { + expect(risk(['src/net.ts'], { 'src/net.ts': [line] })).toEqual(['security']); + }); + test('a named import split over lines is security, from either side of the diff', () => { + const split = ['import {', ' spawnSync,', ' exec,', "} from 'node:child_process';"]; + expect(risk(['src/run.ts'], { 'src/run.ts': split })).toEqual(['security']); + expect([...matchRisk({ codePaths: ['src/run.ts'], added: {}, removed: { 'src/run.ts': split }, scopeAuth: false }).keys()]).toEqual(['security']); + expect(risk(['src/run.ts'], { 'src/run.ts': ['import {', ' type ExecOptions,', ' exec as run,', "} from 'node:child_process';"] })).toEqual(['security']); + }); + test('a literal new URL, tarball creation, parent-dir joins and look-alike names are not security', () => { + for (const line of ["const u = new URL('https://example.com');", 'const here = new URL(import.meta.url);', 'tar -czf out.tgz dir', "const root = path.join(import.meta.dir, '..');", + 'const m = re.exec(line);', "ENV.fetch('HOME')", 'git fetch origin main']) + expect(risk(['src/run.ts'], { 'src/run.ts': [line] })).toEqual([]); + }); +}); + +describe('GST-850: money reaches metering, charge and credit code', () => { + // Value: protects=a billing-client or metering diff gets the money reader; fails_when=gbrain-evals #289 reads required=none again, + // or costume, a CLI usage line or a proxy with no billing names money; seam=matchRisk and targetedClasses + test('gbrain-evals #289 (metering proxy and particle billing clients) reads money, each code path on its own', () => { + const x = JSON.parse(fs.readFileSync(path.join(ROOT, 'test/fixtures/review-risk/gbrain-evals-289.json'), 'utf8')); + expect(targetedClasses({ paths: x.paths, added: x.added, removed: x.removed, scope: { SCOPE_AUTH: 'false' }, gateTagged: 0 }).classes).toEqual(['money']); + expect(classifyDiff({ paths: x.paths, added: x.added, removed: x.removed, gateTagged: 0 }).risk).toContain('money'); + const code = Object.keys({ ...x.added, ...x.removed }); + expect(code.length).toBe(7); + for (const p of code) expect(matchRisk({ codePaths: [p], added: { [p]: x.added[p] ?? [] }, removed: { [p]: x.removed[p] ?? [] }, scopeAuth: false }).has('money')).toBe(true); + }); + test('metering, charge and credit words and pairs name money; costume, CLI usage and a plain proxy do not', () => { + expect(risk(['eval/runner/harness-metering-proxy.ts'])).toEqual(['money']); + expect(risk(['lib/usage-tokens.ts'])).toEqual(['money']); + for (const line of ['if (compatMayHaveBilled(e)) break;', 'const left = credits - spent;', 'await meter.record(n);', 'const usd = res.usage.output_tokens * rate;', + 'const billingProxy = startProxy();', 'export class CostClient {', 'const billable = true;']) + expect(risk(['src/run.ts'], { 'src/run.ts': [line] })).toEqual(['money']); + expect(risk(['src/costume.ts'])).toEqual([]); + expect(risk(['src/cli/usage.ts'], { 'src/cli/usage.ts': ['export function printUsage() { console.log(USAGE); }'] })).toEqual([]); + expect(risk(['src/proxy.ts'], { 'src/proxy.ts': ['const target = proxyTarget(host);'] })).toEqual([]); + expect(risk(['src/geometry.ts'], { 'src/geometry.ts': ['const diameter = 2 * r; // a parameter'] })).toEqual([]); + expect(classifyDiff({ paths: ['docs/usage.md'], gateTagged: 0, added: { 'docs/usage.md': ['Usage: gstack run'] }, removed: {} }).risk).toEqual([]); + }); +}); diff --git a/test/script-invocation-tripwire.test.ts b/test/script-invocation-tripwire.test.ts index f7e0a2b2d..bc16fb04c 100644 --- a/test/script-invocation-tripwire.test.ts +++ b/test/script-invocation-tripwire.test.ts @@ -34,8 +34,6 @@ const KNOWN_SITES: Record<string, string> = { 'bin/gstack-issue-links': 'outside this wave: the gstack-issue-guard spawn moves to runScriptSync', // Found when the bin-constant rule was added (fix wave PR 5, item 10b). 'bin/gstack-brain-cache': 'outside this wave: the gstack-config salience read moves to runScriptSync', - // Found when the scanner learned the import.meta.url bin path (#3541). - 'bin/gstack-ship-receipt': 'the gstack-session-kind spawn moves to runScriptSync in the #3541 ship-receipt follow-up', 'lib/autoplan-admission.ts': 'returns before the spawn on win32; the bin/gstack-lease touch moves to runScriptSync with that bin\'s owner', }; diff --git a/test/shard-jobs.test.ts b/test/shard-jobs.test.ts new file mode 100644 index 000000000..83714013d --- /dev/null +++ b/test/shard-jobs.test.ts @@ -0,0 +1,465 @@ +/** + * Sub-shards inside one CI job (`--ci-run … --jobs k`, scripts/lib/shard-jobs.ts): + * the k = 1 golden path, identity-bound assignment, rejection of missing, + * duplicate and incomplete sub-shard results, one retry pass per CI shard, + * serial TREE_MUTATING and SLOW_UNDER_LOAD files, the timeout text, and per-file + * test and expect() counts from real bun children at k = 1 and k > 1. + */ +import { describe, expect, spyOn, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + collectFreeTestFiles, createFreeCiPlan, eligibleFreeRetryFiles, parseCliOptions, runFreeShard, TREE_MUTATING, verifyFreeCiResults, + type FreeCiResult, type FreeShardOutcome, +} from '../scripts/test-free-shards'; +import { + assertSoloSeedSample, assignSubShards, ciRunContext, FREE_JOBS_INVALID, mergeSubShardOutcomes, parseJobsFlag, parseJunitCounts, + readJunitReport, runCiShard, SLOW_UNDER_LOAD, subShardRecordProblems, subShardTimeoutText, validateJobsForShard, type CiShardDeps, type RunShardOptions, +} from '../scripts/lib/shard-jobs'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const EXCLUSIVE = Object.keys(TREE_MUTATING)[0]; +const files = Array.from({ length: 7 }, (_, i) => `test/sample-${i}.test.ts`); +const durations = Object.fromEntries(files.map((file, i) => [file, (i + 1) * 1_000])); +const context = { planPath: 'plan.json', runId: '123/attempt 2', runner: 'runner-7 (linux, 8 CPUs)' }; + +const passed = (shardFiles: string[], shard = 1): FreeShardOutcome => ({ + shard, files: shardFiles, status: 'passed', exitCode: 0, elapsedMs: 5, groupPid: null, failingFiles: [], unattributedFailures: 0, + summary: { testsRan: shardFiles.length * 2, filesRan: shardFiles.length, sawTerminalSummary: true }, +}); +const failed = (shardFiles: string[], failing: string[], shard = 1): FreeShardOutcome => ({ + ...passed(shardFiles, shard), status: 'failed', exitCode: 1, failingFiles: failing, +}); + +function harness(shardFiles: string[], respond: (files: string[]) => FreeShardOutcome | Promise<FreeShardOutcome> = passed) { + const calls: Array<{ files: string[]; shardNumber: number; totalShards: number; options: RunShardOptions; startedAt: number; endedAt?: number }> = []; + const retries: FreeShardOutcome[][] = []; + const guards: Array<{ files: string[]; shards: number }> = []; + let homeChange: string | null = null; + let terminate = false; + const deps: CiShardDeps = { + async runShard(runFiles, shardNumber, totalShards, options) { + const call: (typeof calls)[number] = { files: runFiles, shardNumber, totalShards, options, startedAt: performance.now() }; + calls.push(call); + await new Promise(resolve => setTimeout(resolve, 20)); + try { return await respond(runFiles); } finally { call.endedAt = performance.now(); } + }, + async retry(outcomes) { + retries.push(outcomes); + return { exitCode: outcomes.every(outcome => outcome.status === 'passed') ? 0 : 1, retry: null }; + }, + pack: (packFiles, count) => Array.from({ length: count }, (_, i) => packFiles.filter((_, j) => j % count === i)), + isSerial: file => file in TREE_MUTATING || file in SLOW_UNDER_LOAD, + terminationRequested: () => terminate, + phaseGuard: (guarded, shards) => { guards.push({ files: guarded, shards }); return { verify: () => homeChange }; }, + log: () => {}, + }; + const plan = { version: 1 as const, revision: 'revision-a', id: 'plan-id-for-tests', shards: [{ shard: 1, files: shardFiles, predictedMs: 0 }] }; + const shard = plan.shards[0]; + const run = (jobs: number) => runCiShard({ plan, shard, revision: 'revision-a', jobs, wallTimeoutMs: 360_000, verbose: false, context }, deps); + return { calls, retries, guards, deps, plan, shard, run, setHomeChange: (value: string) => { homeChange = value; }, cancel: () => { terminate = true; } }; +} + +describe('--jobs validation (FREE_JOBS_INVALID)', () => { + test('accepts positive whole numbers and rejects everything else with the code and the precedence note', () => { + expect(parseJobsFlag('1')).toBe(1); + expect(parseJobsFlag('4')).toBe(4); + for (const raw of ['0', '-1', '2.5', 'abc', '', ' 2', undefined]) { + expect(() => parseJobsFlag(raw)).toThrow(FREE_JOBS_INVALID); + expect(() => parseJobsFlag(raw)).toThrow(/GSTACK_FREE_JOBS sets the local full-suite process count and never changes --ci-run/); + } + }); + + test('--jobs belongs to --ci-run only, and an absent --jobs leaves the parsed options unchanged', () => { + const base = ['--ci-run', 'plan.json', '--shard', '1', '--result', 'r.json']; + expect(parseCliOptions([...base, '--jobs', '4']).jobs).toBe(4); + expect('jobs' in parseCliOptions(base)).toBe(false); + expect(() => parseCliOptions([...base, '--jobs', '0'])).toThrow(FREE_JOBS_INVALID); + expect(() => parseCliOptions([...base, '--jobs'])).toThrow(FREE_JOBS_INVALID); + expect(() => parseCliOptions(['--jobs', '2'])).toThrow(`${FREE_JOBS_INVALID}: --jobs needs --ci-run`); + expect(() => parseCliOptions(['--ci-plan', 'p.json', '--jobs', '2'])).toThrow(FREE_JOBS_INVALID); + }); + + test('k may not exceed the shard file count; an empty shard still takes 1', () => { + expect(() => validateJobsForShard(4, 4, 3)).not.toThrow(); + expect(() => validateJobsForShard(1, 0, 21)).not.toThrow(); + expect(() => validateJobsForShard(5, 4, 3)).toThrow(`${FREE_JOBS_INVALID}: --jobs 5 is more than CI shard 3's 4 file(s); use 1 to 4.`); + expect(() => validateJobsForShard(2, 0, 21)).toThrow(FREE_JOBS_INVALID); + }); +}); + +describe('k = 1 golden: PR 2 changes nothing in CI', () => { + test('one child over the whole planned shard with the same wall, one retry pass, and the same result keys', async () => { + const h = harness(files); + const { result, exitCode } = await h.run(1); + expect(h.calls.map(({ files: f, shardNumber, totalShards, options }) => ({ f, shardNumber, totalShards, options }))).toEqual([ + { f: h.shard.files, shardNumber: 1, totalShards: 1, options: { wallTimeoutMs: 360_000, verbose: false, testCounts: true } }, + ]); + expect(h.guards).toEqual([]); + expect(h.retries).toEqual([[passed(h.shard.files)]]); + expect(h.calls[0].options.wallTimeoutMs).toBe(360_000); + expect(Object.keys(result)).toEqual(['planId', 'revision', 'outcome', 'retry']); + expect(result).toEqual({ planId: h.plan.id, revision: 'revision-a', outcome: passed(h.shard.files), retry: null }); + expect(exitCode).toBe(0); + }); + + test('a k = 1 failure reaches the retry pass as the child reported it', async () => { + const h = harness(files, runFiles => failed(runFiles, [runFiles[0]])); + const { result, exitCode } = await h.run(1); + expect(h.retries).toEqual([[failed(h.shard.files, [h.shard.files[0]])]]); + expect(result.outcome).toBe(h.retries[0][0]); + expect(exitCode).toBe(1); + }); +}); + +describe('sub-shard assignment', () => { + const pack = (packFiles: string[], count: number) => Array.from({ length: count }, (_, i) => packFiles.filter((_, j) => j % count === i)); + + test('disjoint, identity-bound, covering the shard once, with exclusive files in one trailing serial sub-shard', () => { + const shardFiles = [...files, EXCLUSIVE]; + const assignments = assignSubShards('plan-id-for-tests', { shard: 4, files: shardFiles }, 3, file => file in TREE_MUTATING, pack); + expect(assignments.map(entry => [entry.id, entry.phase, entry.files.length])).toEqual([ + ['plan-id-for-:4.1', 'parallel', 3], ['plan-id-for-:4.2', 'parallel', 2], ['plan-id-for-:4.3', 'parallel', 2], ['plan-id-for-:4.4', 'serial', 1], + ]); + expect(assignments.at(-1)!.files).toEqual([EXCLUSIVE]); + expect(assignments.flatMap(entry => entry.files).sort()).toEqual([...shardFiles].sort()); + }); + + test('k is clamped to the parallel file count and a shard of only exclusive files stays one serial child', () => { + expect(assignSubShards('p', { shard: 1, files: files.slice(0, 2) }, 4, () => false, pack).length).toBe(2); + expect(assignSubShards('p', { shard: 1, files: [EXCLUSIVE] }, 1, file => file in TREE_MUTATING, pack) + .map(entry => entry.phase)).toEqual(['serial']); + }); + + test('a packer that drops or repeats a file is rejected before any child starts', () => { + expect(() => assignSubShards('p', { shard: 2, files }, 2, () => false, packFiles => [packFiles, [packFiles[0]]])).toThrow(/FREE_SUBSHARD_REJECTED/); + expect(() => assignSubShards('p', { shard: 2, files }, 2, () => false, packFiles => [packFiles.slice(1)])).toThrow(/exactly once/); + }); +}); + +describe('k > 1: merge, rejection and one retry pass per CI shard', () => { + test('parallel sub-shards share the concurrent home guard, then exclusive files run alone after they settle', async () => { + const h = harness([...files, EXCLUSIVE]); + const { result, exitCode } = await h.run(3); + expect(exitCode).toBe(0); + const parallel = h.calls.filter(call => !call.files.includes(EXCLUSIVE)); + const serial = h.calls.find(call => call.files.includes(EXCLUSIVE))!; + expect(parallel).toHaveLength(3); + expect(parallel.every(call => typeof call.options.homeGuard === 'function')).toBe(true); + expect(serial.options.homeGuard).toBeUndefined(); + expect(serial.files).toEqual([EXCLUSIVE]); + expect(serial.startedAt).toBeGreaterThanOrEqual(Math.max(...parallel.map(call => call.endedAt!))); + expect(h.guards).toEqual([{ files: parallel.flatMap(call => call.files), shards: 3 }]); + expect(h.calls.every(call => call.totalShards === 4)).toBe(true); + expect(parallel.every(call => call.options.wallTimeoutMs === 360_000)).toBe(true); + expect(serial.options.wallTimeoutMs).toBeLessThan(360_000); + expect(serial.options.wallTimeoutMs).toBeGreaterThan(350_000); + expect(result.jobs).toBe(3); + expect(result.outcome).toMatchObject({ shard: 1, files: h.shard.files, status: 'passed', exitCode: 0, failingFiles: [], unattributedFailures: 0, + summary: { testsRan: 16, filesRan: 8, sawTerminalSummary: true } }); + expect(result.subShards!.map(record => record.status)).toEqual(['passed', 'passed', 'passed', 'passed']); + expect(h.retries).toHaveLength(1); + expect(() => verifyFreeCiResults(h.plan, [result])).not.toThrow(); + }); + + test('failures from every sub-shard are collected first, so k never multiplies the five-file retry cap', async () => { + const h = harness(files, runFiles => failed(runFiles, runFiles.slice(0, 2))); + const { result, exitCode } = await h.run(3); + expect(h.retries).toHaveLength(1); + expect(h.retries[0]).toHaveLength(1); + expect(result.outcome.failingFiles).toHaveLength(6); + expect(eligibleFreeRetryFiles(h.retries[0])).toBeNull(); + expect(exitCode).toBe(1); + const spread = harness(files, runFiles => failed(runFiles, runFiles.slice(0, 1))); + await spread.run(3); + expect(eligibleFreeRetryFiles(spread.retries[0])!.sort()).toEqual(spread.calls.map(call => call.files[0]).sort()); + }); + + test('a missing, duplicate or incomplete sub-shard result fails the CI shard and vetoes the retry', async () => { + const errors = spyOn(console, 'error').mockImplementation(() => {}); + try { + const cases: Array<(runFiles: string[]) => FreeShardOutcome | Promise<FreeShardOutcome>> = [ + runFiles => runFiles.includes(files[0]) ? Promise.reject(new Error('spawn failed')) : passed(runFiles), + runFiles => passed(runFiles.includes(files[0]) ? [...runFiles, files[1]] : runFiles), + runFiles => runFiles.includes(files[0]) ? { ...passed(runFiles), summary: { testsRan: 1, filesRan: runFiles.length - 1, sawTerminalSummary: true } } : passed(runFiles), + runFiles => runFiles.includes(files[0]) ? { ...passed(runFiles), summary: undefined } : passed(runFiles), + ]; + for (const respond of cases) { + errors.mockClear(); + const h = harness(files, respond); + const { result, exitCode } = await h.run(3); + expect(h.calls).toHaveLength(3); + expect(result.outcome.status).toBe('failed'); + expect(result.outcome.unattributedFailures).toBeGreaterThan(0); + expect(eligibleFreeRetryFiles([result.outcome])).toBeNull(); + expect(exitCode).toBe(1); + expect(errors.mock.calls.map(call => String(call[0])).some(line => line.startsWith('[test:free] FREE_SUBSHARD_REJECTED: CI shard 1: plan-id-for-:1.'))).toBe(true); + } + } finally { errors.mockRestore(); } + }); + + test('a failed sub-shard that ran too few or too many files is rejected, so counts cannot cancel and no retry repairs it', async () => { + const errors = spyOn(console, 'error').mockImplementation(() => {}); + try { + const h = harness(files.slice(0, 4), runFiles => ({ ...failed(runFiles, [runFiles[0]]), + summary: { testsRan: 2, filesRan: runFiles.includes(files[0]) ? 1 : 3, sawTerminalSummary: true } })); + const { result } = await h.run(2); + expect(result.outcome.summary!.filesRan).toBe(4); + expect(result.outcome.unattributedFailures).toBeGreaterThanOrEqual(2); + expect(eligibleFreeRetryFiles([result.outcome])).toBeNull(); + const forged: FreeCiResult = { ...result, outcome: { ...result.outcome, unattributedFailures: 0 }, + retry: { ...passed([files[0], files[1]]), shard: 2 } }; + expect(() => verifyFreeCiResults({ ...h.plan, revision: 'revision-a' } as never, [forged])).toThrow(/did not run every assigned file|identity/); + expect(subShardRecordProblems(forged)).toContain('a sub-shard did not run every assigned file'); + } finally { errors.mockRestore(); } + }); + + test('the serial sub-shard gets only what is left of the wall, and none left means it is not launched', async () => { + const errors = spyOn(console, 'error').mockImplementation(() => {}); + try { + const h = harness([...files, EXCLUSIVE], async runFiles => { + if (!runFiles.includes(EXCLUSIVE)) await new Promise(resolve => setTimeout(resolve, 60)); + return passed(runFiles); + }); + const { result } = await runCiShard({ plan: h.plan, shard: h.shard, revision: 'revision-a', jobs: 2, wallTimeoutMs: 50, verbose: false, context }, h.deps); + expect(h.calls.some(call => call.files.includes(EXCLUSIVE))).toBe(false); + expect(result.subShards!.at(-1)).toMatchObject({ status: 'missing', phase: 'serial' }); + expect(result.outcome.status).toBe('failed'); + } finally { errors.mockRestore(); } + }); + + test('a real-home change in the concurrent phase and a cancelled serial phase both fail the shard', async () => { + const errors = spyOn(console, 'error').mockImplementation(() => {}); + try { + const home = harness(files); + home.setHomeChange('real home changed while 3 shards ran concurrently'); + expect((await home.run(3)).result.outcome.status).toBe('failed'); + const cancelled = harness([...files, EXCLUSIVE]); + cancelled.cancel(); + const { result } = await cancelled.run(2); + expect(cancelled.calls.some(call => call.files.includes(EXCLUSIVE))).toBe(false); + expect(result.subShards!.at(-1)).toMatchObject({ status: 'missing', files: [EXCLUSIVE] }); + expect(result.outcome.status).toBe('failed'); + } finally { errors.mockRestore(); } + }); + + test('a sub-shard timeout prints FREE_SUBSHARD_TIMEOUT with the file, k, runner, run id and a serial reproduce', async () => { + const errors = spyOn(console, 'error').mockImplementation(() => {}); + try { + const h = harness(files, runFiles => runFiles.includes(files[3]) + ? { ...failed(runFiles, []), status: 'timed-out', exitCode: null, inFlight: [files[3]] } : passed(runFiles)); + const { result, exitCode } = await h.run(3); + expect(result.outcome.status).toBe('timed-out'); + expect(exitCode).toBe(1); + const text = errors.mock.calls.map(call => String(call[0])).join('\n'); + expect(text).toContain('FREE_SUBSHARD_TIMEOUT: CI shard 1 sub-shard plan-id-for-:1.1 (3 files)'); + expect(text).toContain('hit its 360s wall at --jobs 3 on runner-7 (linux, 8 CPUs), run 123/attempt 2.'); + expect(text).toContain(`in flight: ${files[3]}`); + expect(text).toContain(`printf '%s\\n' '${files[3]}' > files.txt && GSTACK_FREE_JOBS=1 bun run scripts/test-free-shards.ts --files-from files.txt`); + expect(text).toContain("--ci-run 'plan.json' --shard 1 --result shard-1.json --jobs 1"); + expect(text).toContain('add it to SLOW_UNDER_LOAD in scripts/lib/shard-jobs.ts with run id 123/attempt 2; never raise the timeout.'); + } finally { errors.mockRestore(); } + }); + + test('--ci-verify rejects tampered sub-shard records', () => { + const plan = createFreeCiPlan(files, 1, durations, 'revision-a'); + const shard = plan.shards[0]; + const assignments = assignSubShards(plan.id, shard, 2, () => false, packFiles => [packFiles.slice(0, 3), packFiles.slice(3)]); + const merged = mergeSubShardOutcomes(shard, assignments.map(assignment => ({ assignment, outcome: passed(assignment.files) })), 10); + const result: FreeCiResult = { planId: plan.id, revision: 'revision-a', outcome: merged.outcome, retry: null, jobs: 2, subShards: merged.records }; + expect(() => verifyFreeCiResults(plan, [result])).not.toThrow(); + const renamed = structuredClone(result); + renamed.subShards![0].id = 'other:1.1'; + expect(() => verifyFreeCiResults(plan, [renamed])).toThrow(/ids or phases/); + const tooMany = structuredClone(result); + tooMany.jobs = 1; + expect(subShardRecordProblems({ ...tooMany, jobs: 2, subShards: [...tooMany.subShards!, { ...tooMany.subShards![0], id: `${plan.id.slice(0, 12)}:1.3`, files: [] }] })) + .toContain('sub-shard ids or phases do not match the plan and --jobs'); + const overlap = structuredClone(result); + overlap.subShards![1].files = [...overlap.subShards![1].files, overlap.subShards![0].files[0]]; + expect(() => verifyFreeCiResults(plan, [overlap])).toThrow(/sub-shards rejected/); + const hidden = structuredClone(result); + hidden.subShards![0].status = 'failed'; + expect(() => verifyFreeCiResults(plan, [hidden])).toThrow(/a passing shard holds a failed sub-shard/); + expect(() => verifyFreeCiResults(plan, [{ ...result, jobs: 1 }])).toThrow(/valid --jobs/); + }); +}); + +describe('per-file test and expect() counts from bun JUnit', () => { + const xml = (file: string) => `<?xml version="1.0" encoding="UTF-8"?> +<testsuites name="bun test" tests="6" assertions="9" failures="1" skipped="2"> + <testsuite name="${file}" file="${file}" tests="3" assertions="9" failures="1" skipped="2"> + <testsuite name="grp" file="${file}" tests="1" assertions="5"> + <testcase name="x > y "q"" classname="grp" file="${file}" line="2" assertions="5" /> + </testsuite> + <testcase name="todo" classname="" file="${file}" line="3" assertions="0"><skipped message="TODO" /></testcase> + <testcase name="skip" classname="" file="${file}" line="4" assertions="0"><skipped /></testcase> + <testcase name="fails" classname="" file="${file}" line="5" assertions="4"><failure type="AssertionError" message="a <b>">x</failure></testcase> + </testsuite> +</testsuites>`; + + test('classifies every testcase and sums assertions per file; matches cwd-relative paths back to planned files', () => { + const counts = parseJunitCounts(xml('../../test/a&b.test.ts'), ['test/a&b.test.ts'], 4); + expect(counts).toEqual({ source: 'bun-junit', complete: true, files: { 'test/a&b.test.ts': { tests: 4, pass: 1, fail: 1, skip: 1, todo: 1, expects: 9 } }, + totals: { tests: 4, pass: 1, fail: 1, skip: 1, todo: 1, expects: 9 } }); + }); + + test('an ambiguous short path is never credited to one of two planned files that share it', () => { + const counts = parseJunitCounts(xml('a.test.ts'), ['test/a.test.ts', 'browse/test/a.test.ts'], 4); + expect(counts.complete).toBe(false); + expect(counts.problem).toContain('unplanned'); + }); + + test('an absolute path is credited to the planned file under the root; an unplanned file never borrows a shorter planned path', () => { + const planned = ['test/telemetry.test.ts', 'browse/test/telemetry.test.ts']; + expect(Object.keys(parseJunitCounts(xml('/repo/browse/test/telemetry.test.ts'), planned, 4, '/repo').files)).toEqual(['browse/test/telemetry.test.ts']); + expect(Object.keys(parseJunitCounts(xml('../../repo/test/telemetry.test.ts'), planned, 4, '/repo').files)).toEqual(['test/telemetry.test.ts']); + const borrowed = parseJunitCounts(xml('browse/test/x.test.ts'), ['test/x.test.ts'], 4, '/repo'); + expect(borrowed).toMatchObject({ complete: false, problem: expect.stringContaining('browse/test/x.test.ts') }); + expect(parseJunitCounts(xml('/elsewhere/test/x.test.ts'), ['test/x.test.ts'], 4, '/repo').complete).toBe(false); + }); + + test('the report is read only as a bounded regular file, never through a link or a FIFO', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-junit-')); + try { + fs.writeFileSync(path.join(dir, 'real.xml'), '<testsuites/>'); + expect(readJunitReport(path.join(dir, 'real.xml'))).toBe('<testsuites/>'); + expect(readJunitReport(path.join(dir, 'absent.xml'))).toBeNull(); + if (process.platform !== 'win32') { + fs.symlinkSync(path.join(dir, 'real.xml'), path.join(dir, 'link.xml')); + expect(readJunitReport(path.join(dir, 'link.xml'))).toBeNull(); + const fifo = Bun.spawnSync(['mkfifo', path.join(dir, 'fifo.xml')], { timeout: 5_000 }); + if (fifo.exitCode === 0) expect(readJunitReport(path.join(dir, 'fifo.xml'))).toBeNull(); + } + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + test('a missing report, an unplanned file or a count that disagrees with bun summary is incomplete, never silently complete', () => { + expect(parseJunitCounts(null, ['test/a.test.ts'], 4)).toMatchObject({ complete: false, problem: 'bun wrote no JUnit report' }); + expect(parseJunitCounts(xml('test/z.test.ts'), ['test/a.test.ts'], 4)).toMatchObject({ complete: false, problem: expect.stringContaining('unplanned') }); + expect(parseJunitCounts(xml('test/a.test.ts'), ['test/a.test.ts'], 5)).toMatchObject({ complete: false, problem: "JUnit counted 4 tests, bun's summary 5" }); + expect(parseJunitCounts(xml('test/a.test.ts'), ['test/a.test.ts'], null).complete).toBe(false); + }); + + test('real bun children: counts are identical per file at k = 1 and k = 2, add up to bun summary, and leave the stream unchanged', async () => { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-shard-jobs-'))); + try { + const fixtures: Record<string, string> = { + 't/a.test.ts': "import { test, expect } from 'bun:test';\ntest('a1', () => { expect(1).toBe(1); expect(2).toBe(2); });\ntest('a2', () => { expect(3).toBe(3); });\ntest.skip('a3', () => {});\n", + 't/b.test.ts': "import { test, expect, describe } from 'bun:test';\ndescribe('g', () => { test('b1', () => { for (let i = 0; i < 5; i++) expect(i).toBe(i); }); });\ntest.todo('b2');\n", + 't/c.test.ts': "import { test, expect } from 'bun:test';\ntest('c1', () => { expect([1]).toEqual([1]); });\n", + 't/d.test.ts': "import { test, expect } from 'bun:test';\ntest.each([1, 2, 3])('d%p', n => { expect(n).toBeGreaterThan(0); expect(n).toBeLessThan(4); });\n", + }; + for (const [file, body] of Object.entries(fixtures)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), body); + } + const planned = Object.keys(fixtures); + const plan = createFreeCiPlan(planned, 1, {}, 'rev'); + const pids: number[] = []; + const deps = (logs: string[]): CiShardDeps => ({ + runShard: async (runFiles, n, total, options) => { + const logFilePath = path.join(root, `stream-${logs.length}-${runFiles.length}-${n}-${total}.log`); + logs.push(logFilePath); + const outcome = await runFreeShard(runFiles, n, total, { ...options, rootDir: root, quiet: true, log: () => {}, logFilePath }); + if (outcome.groupPid) pids.push(outcome.groupPid); + return outcome; + }, + retry: async outcomes => ({ exitCode: outcomes[0].status === 'passed' ? 0 : 1, retry: null }), + pack: (packFiles, count) => Array.from({ length: count }, (_, i) => packFiles.filter((_, j) => j % count === i)), + isSerial: () => false, + terminationRequested: () => false, + log: () => {}, + }); + const input = { plan, shard: plan.shards[0], revision: 'rev', wallTimeoutMs: 60_000, verbose: false, context }; + const serialLogs: string[] = []; + const one = await runCiShard({ ...input, jobs: 1 }, deps(serialLogs)); + const two = await runCiShard({ ...input, jobs: 2 }, deps([])); + expect([one.exitCode, two.exitCode]).toEqual([0, 0]); + const expected = { + 't/a.test.ts': { tests: 3, pass: 2, fail: 0, skip: 1, todo: 0, expects: 3 }, + 't/b.test.ts': { tests: 2, pass: 1, fail: 0, skip: 0, todo: 1, expects: 5 }, + 't/c.test.ts': { tests: 1, pass: 1, fail: 0, skip: 0, todo: 0, expects: 1 }, + 't/d.test.ts': { tests: 3, pass: 3, fail: 0, skip: 0, todo: 0, expects: 6 }, + }; + expect(one.result.outcome.counts).toMatchObject({ complete: true, files: expected, totals: { tests: 9, expects: 15 } }); + expect(two.result.outcome.counts).toEqual(one.result.outcome.counts); + expect(two.result.subShards).toHaveLength(2); + expect(one.result.outcome.summary).toEqual(two.result.outcome.summary); + const bare = await runFreeShard(planned, 1, 1, { rootDir: root, quiet: true, log: () => {}, logFilePath: path.join(root, 'bare.log') }); + expect(bare.counts).toBeUndefined(); + const stream = (file: string) => fs.readFileSync(file, 'utf8').replace(/ \[[\d.]+m?s\]/g, '').split('\n').filter(line => !line.startsWith('[test:free]')); + expect(stream(serialLogs[0])).toEqual(stream(path.join(root, 'bare.log'))); + for (const pid of pids) expect(() => process.kill(pid, 0)).toThrow(); + } finally { fs.rmSync(root, { recursive: true, force: true }); } + }, 60_000); + + test('a hung sub-shard is killed at its wall while its sibling finishes, and no child outlives the parent', async () => { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-shard-jobs-'))); + const errors = spyOn(console, 'error').mockImplementation(() => {}); + try { + const planned = ['t/ok.test.ts', 't/hang.test.ts']; + for (const file of planned) { + fs.mkdirSync(path.join(root, 't'), { recursive: true }); + fs.writeFileSync(path.join(root, file), "import { test, expect } from 'bun:test';\ntest('ok', () => { expect(1).toBe(1); });\n"); + } + const plan = createFreeCiPlan(planned, 1, {}, 'rev'); + const outcomes: FreeShardOutcome[] = []; + const startedAt = performance.now(); + const { result } = await runCiShard({ plan, shard: plan.shards[0], revision: 'rev', jobs: 2, wallTimeoutMs: 2_000, verbose: false, context }, { + runShard: async (runFiles, n, total, options) => { + const outcome = await runFreeShard(runFiles, n, total, { ...options, rootDir: root, quiet: true, log: () => {}, + ...(runFiles.includes('t/hang.test.ts') ? { commandFor: () => ({ command: process.execPath, args: ['-e', 'setInterval(() => {}, 1000)'] }) } : {}) }); + outcomes.push(outcome); + return outcome; + }, + retry: async () => ({ exitCode: 1, retry: null }), + pack: packFiles => packFiles.map(file => [file]), + isSerial: () => false, + terminationRequested: () => false, + log: () => {}, + }); + expect(performance.now() - startedAt).toBeLessThan(20_000); + expect(result.outcome.status).toBe('timed-out'); + expect(result.subShards!.map(record => [record.files[0], record.status]).sort()).toEqual([['t/hang.test.ts', 'timed-out'], ['t/ok.test.ts', 'passed']]); + for (const outcome of outcomes) expect(() => process.kill(outcome.groupPid!, 0)).toThrow(); + expect(errors.mock.calls.some(call => String(call[0]).includes('FREE_SUBSHARD_TIMEOUT'))).toBe(true); + } finally { errors.mockRestore(); fs.rmSync(root, { recursive: true, force: true }); } + }, 60_000); +}); + +describe('serial lists and the duration seed', () => { + test('SLOW_UNDER_LOAD entries name live free files, a reason and a CI run id, and never overlap TREE_MUTATING', () => { + const census = new Set(collectFreeTestFiles()); + for (const [file, entry] of Object.entries(SLOW_UNDER_LOAD)) { + expect(census.has(file), `SLOW_UNDER_LOAD names ${file}, which is not a free test file`).toBe(true); + expect(file in TREE_MUTATING).toBe(false); + expect(entry.reason.trim()).not.toBe(''); + expect(entry.runId).toMatch(/^\d+(\/attempt \d+)?$/); + } + }); + + test('seeds take only one-file children; sub-shard runs have no path to the seed writer', () => { + expect(() => assertSoloSeedSample('test/a.test.ts', { files: ['test/a.test.ts'] })).not.toThrow(); + expect(() => assertSoloSeedSample('test/a.test.ts', { files: ['test/a.test.ts', 'test/b.test.ts'] })).toThrow(/one-file serial children/); + expect(() => assertSoloSeedSample('test/a.test.ts', { files: ['test/a.test.ts'], summary: { testsRan: 9, filesRan: 2, sawTerminalSummary: true } })).toThrow(/one-file serial children/); + expect(fs.readFileSync(path.join(ROOT, 'scripts/lib/shard-jobs.ts'), 'utf8')).not.toContain('writeDurationSeed'); + const runner = fs.readFileSync(path.join(ROOT, 'scripts/test-free-shards.ts'), 'utf8'); + const recorder = runner.slice(runner.indexOf('async function recordFreeTestDurations('), runner.indexOf('async function retryFailedFreeFiles(')); + expect(runner.split('writeDurationSeed(').length - 1).toBe(recorder.split('writeDurationSeed(').length - 1); + expect(recorder).toContain('assertSoloSeedSample(file, outcome)'); + }); + + test('the run context names the CI run and attempt, else local', () => { + expect(ciRunContext('p.json', { GITHUB_RUN_ID: '42', GITHUB_RUN_ATTEMPT: '3', RUNNER_NAME: 'ubi-1' })).toMatchObject({ runId: '42/attempt 3', runner: expect.stringMatching(/^ubi-1 \(/) }); + expect(ciRunContext('p.json', {}).runId).toBe('local'); + expect(subShardTimeoutText(2, 4, { id: 'x:2.1', phase: 'parallel', files: ['test/a.test.ts'], status: 'timed-out', exitCode: null, elapsedMs: 1, failingFiles: [], unattributedFailures: 0 }, + [], 10_000, context)[1]).toContain('in flight: unknown'); + const serial = { id: 'x:2.5', phase: 'serial' as const, files: ['test/s.test.ts'], status: 'timed-out' as const, exitCode: null, elapsedMs: 1, failingFiles: [], unattributedFailures: 0 }; + const squeezed = subShardTimeoutText(2, 4, serial, [], 4_000, context, 360_000); + expect(squeezed[0]).toContain('hit its 4s wall'); + expect(squeezed[4]).toContain('left this serial sub-shard 4s of the 360s shard wall'); + expect(subShardTimeoutText(2, 4, serial, [], 360_000, context, 360_000)[4]).toContain('already runs alone'); + }); +}); diff --git a/test/ship-driver.test.ts b/test/ship-driver.test.ts index 70cf50cc4..58f9dc471 100644 --- a/test/ship-driver.test.ts +++ b/test/ship-driver.test.ts @@ -219,16 +219,37 @@ describe('variant G: the call plan, observe budget, waits and audit readers (GST expect(flat(read('ship/sections/budget-handoff.md.tmpl'))).toContain('the wait until the last settles (`done`, `failed` or a final summary; a fresh `idle` has not run)'); }); - // Value: protects=audits that cannot block (Steps 7, 8, 9.2.1, 14.5) run on a small model at low effort with their sections' own briefs, and skip their reader when --audit decides them; fails_when=an audit reader inherits the lane's model and effort (#36: the coverage audit alone was 2.45M at Opus), or an audit brief is paraphrased; + // Value: protects=audits that cannot block (Steps 7, 8, 14.5) run on a small model at low effort with their sections' own briefs, and skip their reader when --audit decides them; fails_when=an audit reader inherits the lane's model and effort (#36: the coverage audit alone was 2.45M at Opus), or an audit brief is paraphrased; // why_new=audit model routing is new; seam=none; no_claim=the audits' recall test('audit readers run on a small model at low effort, named for Capy, and Phase 3 dispatches only what READER names', () => { - expect(flat(doc)).toContain('Audit readers (Steps 7, 8, 9.2.1, 14.5) cannot block, so each runs on a small model at low effort; their findings go to the PR body'); + expect(flat(doc)).toContain('Audit readers (Steps 7, 8, 14.5) cannot block, so each runs on a small model at low effort; their findings go to the PR body'); expect(flat(doc)).toContain('`Coverage:` and Step 8\'s line come from `--audit` when no reader ran'); const capy = read('capy/skill/gstack/SKILL.md'); expect(capy).toContain('model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }'); expect(capy).toContain('`anthropic/claude-haiku-5-5` also fits'); }); + // Value: protects=Step 9.2.1's report-only probes run in the parent's own Phase 4 program, as the section says, with its Reads in Phase 3, and + // their result line reaches the record, at no extra call; fails_when=the driver or the Capy skill sends Step 9.2.1 out as a QA reader + // (C4-36, C5-36: it refused both passes, "parent-only", and QA coverage was empty), the probes leave the record, or they take their own call; + // why_new=#3648 made Step 9.2.1 a reader (GST-450); seam=none; no_claim=the probes' recall + test('Step 9.2.1 is the parent\'s: Reads in Phase 3, probes in Phase 4\'s one program, Exploratory QA in the record, never an extra', () => { + const f = flat(doc); + const phase = (n: number) => f.slice(f.indexOf(`## ${n}. `), f.indexOf(`## ${n + 1}. `)); + expect(phase(3)).not.toMatch(/QA reader/); + expect(phase(3)).toContain("9.2.1's Reads only"); + expect(phase(4)).toContain("Step 8.1, 9.2.1's probes, 8.2 (`Scope Check:`)"); + const at = ['Step 8.1', "9.2.1's probes", '8.2 (`Scope Check:`)', 'Steps 9.3, 9.4 and the repeat decision'].map(s => phase(4).indexOf(s)); + expect(at[0]).toBeGreaterThanOrEqual(0); + expect(at).toEqual([...at].sort((a, b) => a - b)); + expect(phase(4)).toContain('one program'); + expect(f).toContain('the plan checks, `Exploratory QA:`, `Scope Check:`), **Decisions**'); + const capy = flat(read('capy/skill/gstack/SKILL.md')); + expect(capy).toContain('Steps 7, 8 and 14.5 (`step: "14.5"`) use `extras`'); + expect(capy).not.toMatch(/9\.2\.1[^.]*`extras`/); + expect(flat(read('ship/sections/review-army.md'))).toContain('### Step 9.2.1: Exploratory QA (before Fix-First) Only the parent runs report-only discovery.'); + }); + // Value: protects=a driven /ship dispatches every blocking reader on its rendered brief file unchanged, checks it with `sent`, merges findings only through `collect`, gates judgment with `judge` and verifies the run before the bind; fails_when=the driver lets the model write or trim a brief again or merge findings in prose (round 4b: arm D lost 8 blockers with 75 of 112 briefs paraphrased); // why_new=the driver named no renderer or hash check; seam=none; no_claim=the parent's adherence, which verify checks after the fact test('briefs go out as rendered and findings come back unchanged: render, sent, collect, judge, verify', () => { diff --git a/test/ship-queue-land-lock.test.ts b/test/ship-queue-land-lock.test.ts new file mode 100644 index 000000000..6afefbfce --- /dev/null +++ b/test/ship-queue-land-lock.test.ts @@ -0,0 +1,368 @@ +/** + * The shared land lock (lib/ship-queue-land-lock.ts, #3599): `land` and `train land` take the same + * refs/notes/gstack-land lock, so a standalone land and a train slot can never both publish onto one main. + * Module cases run against a bare origin and two clones (the race, the read states, owner kinds, the + * --break-lock rules); command cases drive bin/gstack-ship-queue against the train fixture and its gh + * shims (both directions, the train-scoped between phase, refusal and resume, the freeze-owned lock and the + * repair train's takeover). + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { acquireLandLock, LAND_LOCK_REF, readLock, writeLock, type LandLock, type LockOwner } from '../lib/ship-queue-land-lock'; +import { BASE_POLICY, cleanup, makeFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture'; +import { coordinatorClone } from './helpers/ship-queue-fixture'; +import { + GREEN_GATE, TRAIN_POLICY, fragmentPr, nonceOf, queue, repoKey, trainFixture, trainJson, wrapLand, type TrainFixture, +} from './helpers/ship-queue-train-fixture'; + +const repos: FixtureRepo[] = []; +const fixtures: TrainFixture[] = []; +afterEach(() => { + while (repos.length) cleanup(repos.pop()!); + while (fixtures.length) cleanup(fixtures.pop()!.repo); +}); + +const T0 = Date.parse('2026-10-31T12:00:00Z'); +const at = (min: number) => () => new Date(T0 + min * 60_000); + +/** Run fn with the fixture's git identity in process.env (the lock writes commits in-process). */ +function withRepoEnv<T>(repo: FixtureRepo, fn: () => T): T { + const saved = Object.fromEntries(Object.keys(repo.env).map(k => [k, process.env[k]])); + Object.assign(process.env, repo.env); + try { return fn(); } finally { + for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } + } +} +function bareRepo(): FixtureRepo { const r = makeFixtureRepo({ policy: BASE_POLICY, prefix: 'gstack-land-lock-' }); repos.push(r); return r; } +const standalone = (pr: string): LockOwner => ({ pr }); +const slotOf = (train: string, slot: number, pr: string): LockOwner => ({ train, slot, pr }); +function take(root: string, owner: LockOwner, o: { journal?: string; now?: () => Date; breakLock?: boolean; breakTrainLock?: boolean; liveTrain?: (nonce: string) => boolean; phase?: LandLock['phase'] } = {}) { + return acquireLandLock({ + root, holder: 'test:1', owner, journal: o.journal ?? ('train' in owner && owner.train ? `train/${owner.train}/slot-${owner.slot}` : `land/${owner.pr}`), + phase: () => o.phase ?? 'stamping', now: o.now ?? at(0), breakLock: o.breakLock, breakTrainLock: o.breakTrainLock, liveTrain: o.liveTrain, + }); +} +const failureOf = (r: ReturnType<typeof take>) => ('failure' in r ? r.failure : null); + +describe('the lock module', () => { + test('two writers racing the same lock sha leave one winner', () => { + const repo = bareRepo(); + const other = coordinatorClone(repo); + withRepoEnv(repo, () => { + const lock = (pr: string): LandLock => ({ pr, holder: `h${pr}`, started_at: new Date(T0).toISOString(), phase: 'stamping', journal: `land/${pr}` }); + expect(readLock(repo.work).state).toBe('absent'); + const first = writeLock(repo.work, '', lock('1')); + const second = writeLock(other, '', lock('2')); + expect([first.ok, second.ok]).toEqual([true, false]); + const seen = readLock(other); + expect(seen.state === 'held' && seen.lock.pr).toBe('1'); + const a = writeLock(repo.work, seen.sha, lock('3')); + const b = writeLock(other, seen.sha, lock('4')); + expect([a.ok, b.ok]).toEqual([true, false]); + const final = readLock(other); + expect(final.state === 'held' && final.lock.pr).toBe('3'); + }); + }, 60_000); + + test('absent, released, held and unreadable are distinct; an unreadable lock refuses, and --break-lock replaces it', () => { + const repo = bareRepo(); + withRepoEnv(repo, () => { + const root = repo.work; + expect(readLock(root)).toMatchObject({ state: 'absent' }); + const got = take(root, standalone('5')); + expect(failureOf(got)).toBeNull(); + expect(readLock(root)).toMatchObject({ state: 'held', lock: { pr: '5', phase: 'stamping' } }); + if ('held' in got) expect(got.held.release()).toBe(true); + expect(readLock(root)).toMatchObject({ state: 'released' }); + const junk = repo.git('commit-tree', '4b825dc642cb6eb9a060e54bf8d69288fbee4904', '-m', 'not a lock'); + repo.git('push', '-q', '-f', 'origin', `${junk}:${LAND_LOCK_REF}`); + expect(readLock(root)).toMatchObject({ state: 'unreadable' }); + const refused = take(root, standalone('6')); + expect(failureOf(refused)).toMatchObject({ code: 'QUEUE_LAND_LOCKED' }); + expect(failureOf(refused)!.message).toContain('cannot be read'); + const broke = take(root, standalone('6'), { breakLock: true }); + expect(failureOf(broke)).toBeNull(); + expect(broke.lines.join('\n')).toContain(`LOCK: broke an unreadable lock at ${junk.slice(0, 12)}`); + expect(readLock(root)).toMatchObject({ state: 'held', lock: { pr: '6' } }); + }); + }, 60_000); + + test('ownership compares the owner kind and journal: the same PR across commands and across trains refuses; a resume of the same journal and a sequential acquisition succeed', () => { + const repo = bareRepo(); + withRepoEnv(repo, () => { + const root = repo.work; + const mine = take(root, standalone('7'), { phase: 'waiting' }); + expect(failureOf(mine)).toBeNull(); + const asSlot = take(root, slotOf('trainA', 1, '7')); + expect(failureOf(asSlot)).toMatchObject({ code: 'QUEUE_LAND_LOCKED' }); + expect(failureOf(asSlot)!.message).toMatch(/^#7 holds the land lock \(test:1, phase waiting, since /); + const resumed = take(root, standalone('7'), { now: at(3) }); + expect(failureOf(resumed)).toBeNull(); + expect(readLock(root)).toMatchObject({ state: 'held', lock: { pr: '7', started_at: new Date(T0).toISOString() } }); + if ('held' in resumed) resumed.held.release(); + + const trainA = take(root, slotOf('trainA', 1, '7'), { phase: 'pushing' }); + expect(failureOf(trainA)).toBeNull(); + expect(readLock(root)).toMatchObject({ state: 'held', lock: { train: 'trainA', slot: 1, pr: '7' } }); + const trainB = take(root, slotOf('trainB', 1, '7')); + expect(failureOf(trainB)!.message).toMatch(/^train trainA slot 1 \(#7\) holds the land lock/); + expect(failureOf(take(root, standalone('7')))!.message).toMatch(/^train trainA slot 1 \(#7\) holds the land lock/); + expect(failureOf(take(root, slotOf('trainA', 2, '8')))!.message).toMatch(/^train trainA slot 1 \(#7\) holds the land lock/); + const same = take(root, slotOf('trainA', 1, '7')); + expect(failureOf(same)).toBeNull(); + if ('held' in same) same.held.release(); + expect(failureOf(take(root, standalone('8')))).toBeNull(); + }); + }, 60_000); + + test('--break-lock prints the holder, refuses a live train run\'s lock without --break-train-lock, and keeps the staleness, mid-push, between and freeze rules', () => { + const repo = bareRepo(); + withRepoEnv(repo, () => { + const root = repo.work; + const hold = (owner: LockOwner, phase: LandLock['phase']) => { + const r = readLock(root); + if (r.state === 'held') writeLock(root, r.sha, { released: true, by: 'test', at: new Date(T0).toISOString() }); + expect(failureOf(take(root, owner, { phase }))).toBeNull(); + }; + hold(slotOf('trainA', 2, '9'), 'waiting'); + const live = take(root, standalone('3'), { breakLock: true, now: at(1) }); + expect(failureOf(live)).toMatchObject({ code: 'QUEUE_LAND_LOCKED' }); + expect(failureOf(live)!.message).toContain('--break-train-lock'); + expect(live.lines).toContain(`LOCK_HOLDER: train trainA slot 2 (#9) holder=test:1 phase=waiting since=${new Date(T0).toISOString()}`); + const ended = take(root, standalone('3'), { breakLock: true, now: at(1), liveTrain: () => false }); + expect(failureOf(ended)).toBeNull(); + expect(ended.lines).toContain("LOCK: broke train trainA slot 2 (#9)'s lock (test:1, phase waiting)"); + + hold(slotOf('trainA', 2, '9'), 'waiting'); + const forced = take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(1) }); + expect(failureOf(forced)).toBeNull(); + + hold(slotOf('trainA', 1, '9'), 'between'); + const young = take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(1) }); + expect(failureOf(young)!.message).toMatch(/train trainA slot 1 \(#9\) is between slots \(60 s ago\); --break-lock refused/); + expect(failureOf(take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(11) }))).toBeNull(); + + hold(slotOf('trainA', 1, '9'), 'pushing'); + expect(failureOf(take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(1) }))!.message).toMatch(/train trainA slot 1 \(#9\)'s holder is mid-push \(60 s ago\); --break-lock refused/); + + hold(slotOf('trainA', 1, '9'), 'freeze'); + const frozen = take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(60) }); + expect(failureOf(frozen)).toMatchObject({ code: 'QUEUE_TRAIN_FROZEN' }); + expect(failureOf(take(root, slotOf('trainB', 1, '4'), { breakLock: true, breakTrainLock: true, now: at(60) }))).toMatchObject({ code: 'QUEUE_TRAIN_FROZEN' }); + + hold(standalone('5'), 'waiting'); + const plain = take(root, standalone('3'), { breakLock: true, now: at(1) }); + expect(plain.lines).toContain(`LOCK_HOLDER: #5 holder=test:1 phase=waiting since=${new Date(T0).toISOString()}`); + expect(plain.lines).toContain("LOCK: broke #5's lock (test:1, phase waiting)"); + }); + }, 60_000); +}); + +const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' }; +function stampedTrain(prs = ['1', '2']) { + const fx = trainFixture(POLICY, 'gstack-land-lock-'); fixtures.push(fx); wrapLand(fx); + const heads = prs.map(pr => fragmentPr(fx, pr)); + fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); + const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', prs.map((pr, i) => `${pr}@${heads[i]}`).join(',')]).out); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + return { fx, heads, nonce }; +} +const trainLand = (fx: TrainFixture, nonce: string, pr: string, extra: string[] = []) => queue(fx, ['train', 'land', '--train', nonce, '--pr', pr, '--no-wait', ...extra]); +const land = (fx: TrainFixture, pr: string, extra: string[] = []) => queue(fx, ['land', '--pr', pr, '--no-wait', '--allow-repo-commands', ...extra]); +const originRev = (fx: TrainFixture, ref: string) => fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', '--verify', '-q', ref); +/** The lock as origin holds it: the parsed lock.json, or null when origin has no lock ref. */ +function originLock(fx: TrainFixture): any { + const sha = (() => { try { return originRev(fx, LAND_LOCK_REF); } catch { return ''; } })(); + return sha ? JSON.parse(fx.repo.git('--git-dir', fx.repo.origin, 'show', `${sha}:lock.json`)) : null; +} +const freezeFile = (fx: TrainFixture) => path.join(fx.state, 'ship-queue', 'freeze'); + +describe('land and train land share the lock', () => { + test('a train slot holding the lock refuses a standalone land (no takeover, no freeze; --break-lock names the train and wants --break-train-lock); the train keeps it between slots and releases it after the last', () => { + const { fx, heads, nonce } = stampedTrain(); + const pin3 = fragmentPr(fx, '3'); + const pushed = trainLand(fx, nonce, '1'); + expect(pushed.out, pushed.err).toMatch(/^PUSHED: [0-9a-f]{40}/m); + expect(originLock(fx)).toMatchObject({ train: nonce, slot: 1, pr: '1', phase: 'waiting' }); + const lockSha = originRev(fx, LAND_LOCK_REF); + + const blocked = land(fx, '3'); + expect(blocked.status).toBe(EXIT.refused); + expect(blocked.err).toMatch(new RegExp(`train ${nonce} slot 1 \\(#1\\) holds the land lock .*phase waiting.*\\(QUEUE_LAND_LOCKED\\)`)); + const broke = land(fx, '3', ['--break-lock']); + expect(broke.status).toBe(EXIT.refused); + expect(broke.err).toContain('--break-train-lock'); + expect(broke.out).toMatch(new RegExp(`^LOCK_HOLDER: train ${nonce} slot 1 \\(#1\\) holder=\\S+ phase=waiting since=`, 'm')); + expect(originRev(fx, LAND_LOCK_REF)).toBe(lockSha); + expect(originRev(fx, 'refs/heads/pr-3')).toBe(pin3); + expect(fs.existsSync(freezeFile(fx))).toBe(false); + + const merged = trainLand(fx, nonce, '1'); + expect(merged.status, merged.err).toBe(EXIT.ok); + expect(originLock(fx)).toMatchObject({ train: nonce, slot: 1, pr: '1', phase: 'between' }); + expect(land(fx, '3').err).toMatch(new RegExp(`train ${nonce} slot 1 \\(#1\\) holds the land lock .*phase between.*\\(QUEUE_LAND_LOCKED\\)`)); + expect(originRev(fx, 'refs/heads/pr-3')).toBe(pin3); + + const second = trainLand(fx, nonce, '2'); + expect(second.out, second.err).toMatch(/^LOCK: train \S+ slot 2 takes over its train's between-slots lock/m); + expect(originLock(fx)).toMatchObject({ train: nonce, slot: 2, pr: '2', phase: 'waiting' }); + expect(trainLand(fx, nonce, '2').status).toBe(EXIT.ok); + expect(originLock(fx)).toMatchObject({ released: true }); + expect(trainJson(fx, nonce).state).toBe('merged'); + expect(land(fx, '3').out).toMatch(/^PUSHED: [0-9a-f]{40}/m); + void heads; + }, 300_000); + + // fails_when=settleLock releases the lock on a refusal partway through a train, so one transient gh or fetch failure on slot 2 lets a standalone land publish onto S1 and the rerun freezes the queue (red team, PR #3737 pass 1); why_new=the test above refuses slot 1 only, before anything merged; seam=the fixture's gh pr view; no_claim=the freeze itself + test('a refusal partway through a train (slot 1 merged, slot 2 cannot read its PR) keeps the lock between slots, so a standalone land still refuses', () => { + const { fx, nonce } = stampedTrain(); + fragmentPr(fx, '3'); + trainLand(fx, nonce, '1'); + expect(trainLand(fx, nonce, '1').status).toBe(EXIT.ok); + const row = fx.read().prs['2']; + fx.write(d => { delete d.prs['2']; }); + const refused = trainLand(fx, nonce, '2'); + expect(refused.err).toContain('(QUEUE_PROOF_MISSING)'); + expect(refused.out).toMatch(/^LOCK: between /m); + expect(originLock(fx)).toMatchObject({ train: nonce, phase: 'between' }); + expect(land(fx, '3').err).toMatch(/holds the land lock .*phase between.*\(QUEUE_LAND_LOCKED\)/); + fx.write(d => { d.prs['2'] = row; }); + expect(trainLand(fx, nonce, '2').out).toMatch(/^PUSHED: [0-9a-f]{40}/m); + }, 300_000); + + /** Another train's lock, written to origin as that train's run would have left it. */ + function seedLock(fx: TrainFixture, lock: Partial<LandLock> & { train: string; phase: LandLock['phase'] }): void { + withRepoEnv(fx.repo, () => { + const cur = readLock(fx.repo.work); + expect(writeLock(fx.repo.work, cur.sha, { pr: '9', slot: 1, holder: 'old:1', started_at: '2026-10-31T12:00:00.000Z', phase_at: '2026-10-31T12:00:00.000Z', journal: `train/${lock.train}/slot-1`, ...lock } as LandLock).ok).toBe(true); + }); + } + /** A freeze record for this repository, cleared at `cleared`, written to the state root as the clearing verify leaves it. */ + function clearedFreeze(fx: TrainFixture, train: string, cleared: string): void { + const file = path.join(freezeFile(fx), `${repoKey(fx)}.json`); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ schema: 1, kind: 'freeze', repo_key: repoKey(fx), train, merged: ['9'], unmerged: [], main: { sha: '0'.repeat(40), tree: '0'.repeat(40) }, reason: 'test', backstop: null, repair: null, created_at: '2026-10-31T11:00:00.000Z', cleared_at: cleared, cleared_by: 'test', updated_at: cleared })); + } + + // fails_when=trainTakeover reads "no active freeze record here" as "the freeze is cleared", so a train whose notes fetch failed, or whose freezer never pushed the record, takes the freeze lock and lands onto a frozen main (red team, PR #3737 pass 2); why_new=the freeze test above only exercises the admitted repair train's takeover; seam=a lock and a freeze record written straight to origin and the state root; no_claim=how the record went missing + test('another train\'s freeze lock is taken over only on a freeze record cleared after the lock: no record, or one cleared before it, refuses', () => { + const { fx, nonce } = stampedTrain(['1']); + const old = 'gate.train-old'; + seedLock(fx, { train: old, phase: 'freeze' }); + const noRecord = trainLand(fx, nonce, '1'); + expect(noRecord.status).toBe(EXIT.refused); + expect(noRecord.err).toMatch(/train gate\.train-old slot 1 \(#9\) holds the land lock .*phase freeze.*\(QUEUE_LAND_LOCKED\)/); + expect(noRecord.out).not.toMatch(/^PUSHED:/m); + expect(originLock(fx)).toMatchObject({ train: old, phase: 'freeze' }); + + clearedFreeze(fx, old, '2026-10-31T11:30:00.000Z'); + expect(trainLand(fx, nonce, '1').err).toContain('(QUEUE_LAND_LOCKED)'); + clearedFreeze(fx, old, '2026-10-31T12:30:00.000Z'); + const taken = trainLand(fx, nonce, '1'); + expect(taken.out, taken.err).toContain(`LOCK: train ${nonce} takes over train ${old} slot 1 (#9)'s freeze lock; that freeze was cleared at 2026-10-31T12:30:00.000Z`); + expect(taken.out).toMatch(/^PUSHED: [0-9a-f]{40}/m); + }, 300_000); + + // fails_when=a freeze lock whose release failed after its freeze cleared refuses every standalone land for good, or a lock ref deleted on origin still reads held from this clone's cached copy (adversarial review, PR #3737 Step 11); why_new=the tests above take the freeze lock over only from train land; seam=a lock and a freeze record written straight to origin and the state root; no_claim=why the release failed + test('a standalone land takes over a freeze lock once a fetched freeze record shows it cleared after the lock, and a lock deleted on origin reads as absent', () => { + const { fx } = stampedTrain(['1']); + fragmentPr(fx, '3'); + seedLock(fx, { train: 'gate.train-old', phase: 'freeze' }); + const frozen = land(fx, '3'); + expect(frozen.status).toBe(EXIT.refused); + expect(frozen.err).toMatch(/train gate\.train-old slot 1 \(#9\) holds the land lock .*phase freeze.*\(QUEUE_LAND_LOCKED\)/); + clearedFreeze(fx, 'gate.train-old', '2026-10-31T12:30:00.000Z'); + const taken = land(fx, '3'); + expect(taken.out, taken.err).toContain("LOCK: taking over train gate.train-old slot 1 (#9)'s freeze lock; that freeze was cleared at 2026-10-31T12:30:00.000Z"); + expect(taken.out).toMatch(/^PUSHED: [0-9a-f]{40}/m); + + const { fx: other } = stampedTrain(['1']); + fragmentPr(other, '3'); + seedLock(other, { train: 'gate.train-old', phase: 'waiting' }); + expect(land(other, '3').err).toContain('(QUEUE_LAND_LOCKED)'); + other.repo.git('--git-dir', other.repo.origin, 'update-ref', '-d', LAND_LOCK_REF); + expect(land(other, '3').out).toMatch(/^PUSHED: [0-9a-f]{40}/m); + }, 300_000); + + // fails_when=trainTakeover treats a live train as terminal (two landers publish onto one main) or never takes over a finished train's lock (a stale lock blocks every later train); why_new=no other test seats a foreign train's lock; seam=a second train record written into the state root; no_claim=how that train ended + test('another train\'s lock is taken over once that train\'s record is terminal, and refused while it is still merging', () => { + const { fx, nonce } = stampedTrain(['1']); + const other = `${nonce}-other`; + const put = (state: string) => { + const dir = path.join(fx.state, 'ship-queue', 'trains', other); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'train.json'), JSON.stringify({ ...trainJson(fx, nonce), nonce: other, state })); + }; + put('merging'); + seedLock(fx, { train: other, phase: 'waiting' }); + expect(trainLand(fx, nonce, '1').err).toMatch(/holds the land lock .*phase waiting.*\(QUEUE_LAND_LOCKED\)/); + put('merged'); + const taken = trainLand(fx, nonce, '1'); + expect(taken.out, taken.err).toContain(`takes over train ${other} slot 1 (#9)'s lock; that train is merged`); + expect(taken.out).toMatch(/^PUSHED: [0-9a-f]{40}/m); + }, 300_000); + + test('a standalone land holding the lock refuses train land; a refusal before any push releases the train\'s lock', () => { + const { fx, heads, nonce } = stampedTrain(); + fragmentPr(fx, '3'); + expect(land(fx, '3').out).toMatch(/^PUSHED: /m); + const blocked = trainLand(fx, nonce, '1'); + expect(blocked.status).toBe(EXIT.refused); + expect(blocked.err).toMatch(/#3 holds the land lock .*phase waiting.*\(QUEUE_LAND_LOCKED\)/); + expect(blocked.out).not.toMatch(/^PUSHED:/m); + expect(originRev(fx, 'refs/heads/pr-1')).toBe(heads[0]); + + expect(land(fx, '3').out).toContain('LOCK: released'); + const stale = trainLand(fx, nonce, '1'); + expect(stale.err).toMatch(/nothing of train \S+ merged, so the queue is not frozen.*\(QUEUE_TRAIN_TREE_MISMATCH\)/); + expect(originLock(fx)).toMatchObject({ released: true }); + }, 300_000); + + test('after a push the train keeps its lock through a failed wait and a resume takes it back', () => { + const { fx, nonce } = stampedTrain(['1']); + const stamp = /^PUSHED: ([0-9a-f]{40})/m.exec(trainLand(fx, nonce, '1').out)![1]!; + fx.write(d => { d.ci[stamp] = [{ name: 'free-tests', status: 'completed', conclusion: 'failure' }] as any; }); + const red = trainLand(fx, nonce, '1'); + expect(red.status).not.toBe(EXIT.ok); + expect(originLock(fx)).toMatchObject({ train: nonce, slot: 1, pr: '1', phase: 'waiting' }); + fx.write(d => { delete d.ci[stamp]; }); + const resumed = trainLand(fx, nonce, '1'); + expect(resumed.status, resumed.err).toBe(EXIT.ok); + expect(originLock(fx)).toMatchObject({ released: true }); + }, 300_000); + + test('a stop that freezes leaves the lock in a freeze phase that --break-lock never breaks; only the admitted repair train takes it over, and it is released once the freeze clears', () => { + const { fx, nonce } = stampedTrain(); + trainLand(fx, nonce, '1'); + expect(trainLand(fx, nonce, '1').status).toBe(EXIT.ok); + trainLand(fx, nonce, '2'); + fx.write(d => { (d as any).outsideOnMerge = true; }); + const drift = trainLand(fx, nonce, '2'); + expect(drift.err).toContain('(QUEUE_TRAIN_TREE_MISMATCH)'); + expect(drift.out).toMatch(/^QUEUE_TRAIN_FROZEN: /m); + expect(originLock(fx)).toMatchObject({ train: nonce, phase: 'freeze' }); + const lockSha = originRev(fx, LAND_LOCK_REF); + + fragmentPr(fx, '3'); + const refused = land(fx, '3', ['--break-lock', '--break-train-lock']); + expect(refused.status).toBe(EXIT.refused); + expect(refused.err).toContain('(QUEUE_TRAIN_FROZEN)'); + expect(originRev(fx, LAND_LOCK_REF)).toBe(lockSha); + + const h4 = fragmentPr(fx, '4'); + const repair = queue(fx, ['train', 'build', '--repair', '--prs', `4@${h4}`]); + expect(repair.status, repair.err).toBe(EXIT.ok); + const rn = nonceOf(repair.out); + expect(queue(fx, ['train', 'verdict', '--train', rn]).status).toBe(EXIT.ok); + const taken = trainLand(fx, rn, '4'); + expect(taken.out, taken.err).toMatch(new RegExp(`^LOCK: repair train ${rn} takes over the freeze lock of train ${nonce}`, 'm')); + expect(originLock(fx)).toMatchObject({ train: rn, slot: 1, pr: '4', phase: 'freeze' }); + expect(land(fx, '3', ['--break-lock', '--break-train-lock']).err).toContain('(QUEUE_TRAIN_FROZEN)'); + const cleared = trainLand(fx, rn, '4'); + expect(cleared.status, cleared.err).toBe(EXIT.ok); + expect(cleared.out).toMatch(/^FREEZE: cleared /m); + expect(originLock(fx)).toMatchObject({ released: true }); + }, 600_000); +}); diff --git a/test/ship-queue-train-freeze.test.ts b/test/ship-queue-train-freeze.test.ts index 13c09b2d4..a5602c003 100644 --- a/test/ship-queue-train-freeze.test.ts +++ b/test/ship-queue-train-freeze.test.ts @@ -5,7 +5,7 @@ import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; import { cleanup } from './helpers/restamp-fixture'; import { coordinatorClone } from './helpers/ship-queue-fixture'; -import { publishMain, twoPrTrain } from './helpers/ship-queue-train-cases'; +import { laneStamp, markMerged, publishMain, twoPrTrain } from './helpers/ship-queue-train-cases'; import { TRAIN_POLICY, dispatches, line, nonceOf, openPr, queue, readyReceipt, repoKey, trainFixture, trainJson, type TrainFixture, @@ -28,8 +28,10 @@ describe('verify, freeze, repair and clear', () => { const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); const released = (prefix: string, version: string) => fx.repo.git('show', `${prefix}:CHANGELOG.md`).replace('## [Unreleased]', `## [${version}] - 2026-10-18`) + '\n'; const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: released(t.prefixes[1].commit, '1.2.4.0') }); + markMerged(fx, '1'); expect(v('1').status).toBe(EXIT.ok); - expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h2}`); + const s2 = laneStamp(fx, '2', h2, { mergeMain: true }); + expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s2}`); // Main's CHANGELOG plus #2's stamped entry, with one line the pin never wrote. const smuggled = fx.repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n- #2 change\n- added after the gate\n') + '\n'; const broken = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'CHANGELOG.md', text: smuggled }); @@ -111,6 +113,7 @@ describe('verify, freeze, repair and clear', () => { const rn = nonceOf(queue(fx, ['train', 'build', '--repair', '--prs', `3@${h3}`]).out); expect(queue(fx, ['train', 'verdict', '--train', rn]).status).toBe(EXIT.ok); publishMain(fx, trainJson(fx, rn).prefixes[1].commit, '1.2.6.0'); + markMerged(fx, '3'); const cleared = queue(fx, ['verify', '--pr', '3', '--train', rn], {}, coord); expect(cleared.out).toMatch(/^FREEZE: cleared /m); expect(next(cleared.out)).toBe(`NEXT: gh pr view 2 --json state,headRefOid (unreadable at the freeze: #2@${h2.slice(0, 12)}); seat each that is OPEN in a train`); @@ -125,6 +128,7 @@ describe('verify, freeze, repair and clear', () => { const coord = coordinatorClone(fx.repo); const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); publishMain(fx, t.prefixes[1].commit, '1.2.4.0'); + markMerged(fx, '1'); expect(v('1').status).toBe(EXIT.ok); const later = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'a later train\n' }); fx.write(d => { (d.prs['2'] as any).state = 'MERGED'; }); @@ -157,6 +161,7 @@ describe('verify, freeze, repair and clear', () => { const coord = coordinatorClone(fx.repo); const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); publishMain(fx, t.prefixes[1].commit, '1.2.4.0'); + markMerged(fx, '1'); expect(v('1').status).toBe(EXIT.ok); const later = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'a later train\n' }); const tree = fx.repo.git('rev-parse', `${later}^{tree}`); @@ -181,6 +186,7 @@ describe('verify, freeze, repair and clear', () => { const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); const released = fx.repo.git('show', `${t.prefixes[1].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n'; const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: released }); + markMerged(fx, '1'); expect(v('1').status).toBe(EXIT.ok); const foreign = publishMain(fx, t.prefixes[1].commit, '1.2.4.1', { file: 'stray.txt', text: 'landed between the two slots\n' }); const text = fx.repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n- #2 change\n- added after the gate\n') + '\n'; diff --git a/test/ship-queue-train-land.test.ts b/test/ship-queue-train-land.test.ts index 64a046ee7..9ac7ac65a 100644 --- a/test/ship-queue-train-land.test.ts +++ b/test/ship-queue-train-land.test.ts @@ -10,47 +10,13 @@ import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; import { cleanup } from './helpers/restamp-fixture'; import { - GREEN_GATE, RED_GATE, TRAIN_POLICY, line, nonceOf, openPr, queue, readyReceipt, shippedBody, trainFixture, trainJson, type TrainFixture, + GREEN_GATE, RED_GATE, TRAIN_POLICY, fragmentPr, line, nonceOf, openPr, queue, readyReceipt, shippedBody, trainFixture, trainJson, wrapLand as wrap, type TrainFixture, } from './helpers/ship-queue-train-fixture'; const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' }; const fixtures: TrainFixture[] = []; afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); }); -/** pr view reads the head from origin's branch; pr edit and pr merge act on origin; any commit without recorded CI reads green. */ -function wrap(fx: TrainFixture): void { - const dir = path.join(fx.repo.root, 'gh-land-wrap'); - fs.mkdirSync(dir, { recursive: true }); - const shim = path.join(fx.repo.root, 'gh-train-shim', 'shim.js'); - fs.writeFileSync(path.join(dir, 'gh'), [ - '#!/usr/bin/env node', - "const fs=require('fs');const cp=require('child_process');const D=process.env.LAND_WRAP_DATA;const a=process.argv.slice(2);", - "const d=JSON.parse(fs.readFileSync(D,'utf8'));const save=()=>fs.writeFileSync(D,JSON.stringify(d));", - "const g=(...x)=>cp.execFileSync('git',['--git-dir',d.origin,...x],{encoding:'utf8',timeout:30000}).trim();", - "const rev=r=>{try{return g('rev-parse','--verify','--quiet',r)}catch{return ''}};", - "if(a[0]==='pr'&&a[1]==='view'&&d.prs[a[2]]){const p=d.prs[a[2]];const h=rev('refs/heads/'+p.headRefName);if(h)p.headRefOid=h;save();console.log(JSON.stringify(p));process.exit(0)}", - "if(a[0]==='pr'&&a[1]==='edit'){(d.labels=d.labels||{})[a[2]]=a[a.indexOf('--add-label')+1];save();process.exit(0)}", - "if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];if(rev('refs/heads/'+p.headRefName)!==want){console.error('Head branch was modified');process.exit(1)}", - " const before=rev('main');let tree=rev(want+'^{tree}');", - " if(d.outsideOnMerge){const blob=cp.execFileSync('git',['--git-dir',d.origin,'hash-object','-w','--stdin'],{input:'outside\\n',encoding:'utf8',timeout:30000}).trim();", - " const ls=g('ls-tree',before+'^{tree}')+'\\n100644 blob '+blob+'\\toutside.txt';const ot=cp.execFileSync('git',['--git-dir',d.origin,'mktree'],{input:ls+'\\n',encoding:'utf8',timeout:30000}).trim();", - " g('update-ref','refs/heads/main',g('commit-tree',ot,'-p',before,'-m','outside'));tree=g('merge-tree','--write-tree','--merge-base='+before,rev('main'),want).split('\\n')[0];d.outsideOnMerge=false}", - " g('update-ref','refs/heads/main',g('commit-tree',tree,'-p',rev('main'),'-m','squash #'+a[2]));p.state='MERGED';save();process.exit(0)}", - "const paged=/commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1]||'');", - "if(a[0]==='api'&&paged&&!(d.ci||{})[paged[1]]&&!((d.statuses||{})[paged[1]])){const list=paged[2]==='check-runs'?[{name:'free-tests',status:'completed',conclusion:'success',id:1,completed_at:'2026-10-26T00:00:00Z'}]:[];const key=paged[2]==='check-runs'?'check_runs':'statuses';console.log(JSON.stringify(paged[3]==='1'?{total_count:list.length,state:'success',[key]:list}:{total_count:list.length,[key]:[]}));process.exit(0)}", - `const r=cp.spawnSync('node',[${JSON.stringify(shim)},D,...a],{stdio:'inherit',timeout:120000});process.exit(r.status??1);`, - '', - ].join('\n'), { mode: 0o755 }); - fx.env.PATH = `${dir}:${fx.env.PATH}`; - (fx.env as Record<string, string>).LAND_WRAP_DATA = fx.dataFile; -} - -function fragmentPr(fx: TrainFixture, pr: string, fragment = `- #${pr} change\n`): string { - const head = openPr(fx, pr, { file: `changelog.d/pr-${pr}.md`, text: fragment, changelog: false }); - fx.repo.git('push', '-q', 'origin', `${head}:refs/heads/pr-${pr}`); - readyReceipt(fx, pr, head); - return head; -} function stampedTrain(gate = GREEN_GATE, policy: Record<string, unknown> = POLICY) { const fx = trainFixture(policy, 'gstack-train-land-'); fixtures.push(fx); wrap(fx); const h1 = fragmentPr(fx, '1'); @@ -190,10 +156,15 @@ describe('stamped trains: build and land', () => { }, 300_000); test('a failed stamp push with the branch still at the pin fails retryably instead of reporting a move', () => { - const { fx, nonce } = stampedTrain(); + const { fx, h1, nonce } = stampedTrain(); expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); const hook = path.join(fx.repo.origin, 'hooks', 'pre-receive'); fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + const refused = land(fx, nonce, '1'); + expect(refused.err).toMatch(/the land lock push to origin failed and the lock there is unchanged; nothing was pushed.*\(QUEUE_NOT_READY\)/); + expect(refused.err).not.toContain('another lander'); + expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(h1); + fs.writeFileSync(hook, '#!/bin/sh\nwhile read old new ref; do [ "$ref" = refs/heads/pr-1 ] && exit 1; done\nexit 0\n', { mode: 0o755 }); const failed = land(fx, nonce, '1'); expect(failed.err).toMatch(/the stamp push failed and pr-1 still reads.*\(QUEUE_NOT_READY\)/); expect(failed.err).not.toContain('QUEUE_TRAIN_MOVED'); diff --git a/test/ship-queue-train-verify.test.ts b/test/ship-queue-train-verify.test.ts index 5c8551d0b..67ebcf224 100644 --- a/test/ship-queue-train-verify.test.ts +++ b/test/ship-queue-train-verify.test.ts @@ -6,7 +6,7 @@ import { mergeChangelogs, parseChangelog } from '../lib/changelog-check'; import { EXIT } from '../lib/headless-artifacts'; import { cleanup } from './helpers/restamp-fixture'; import { coordinatorClone } from './helpers/ship-queue-fixture'; -import { publishMain, twoPrTrain } from './helpers/ship-queue-train-cases'; +import { laneStamp, markMerged, publishMain, twoPrTrain } from './helpers/ship-queue-train-cases'; import { GREEN_GATE, TRAIN_POLICY, dispatches, line, nonceOf, openPr, queue, readyReceipt, repoKey, setPrBody, shippedBody, trainFixture, trainJson, type TrainFixture, @@ -27,21 +27,24 @@ describe('verify, freeze, repair and clear', () => { const coord = coordinatorClone(fx.repo); const v = (pr: string, train = nonce) => queue(fx, ['verify', '--pr', pr, '--train', train], {}, coord); + const s1 = laneStamp(fx, '1', h1); const pre1 = v('1'); expect(pre1.status).toBe(EXIT.ok); expect(pre1.out).toContain(`VERIFY_TRAIN: #1 may merge as slot 1 of train ${nonce}`); - expect(line(pre1.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`); + expect(line(pre1.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s1}`); const early = v('2'); expect(early.status).toBe(EXIT.fail); expect(early.err).toMatch(/is neither T1 nor T2 modulo stamps.*nothing of train \S+ merged, so the queue is not frozen.*\(QUEUE_TRAIN_TREE_MISMATCH\)/); - publishMain(fx, t.prefixes[1].commit, '1.2.4.0'); + publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: fx.repo.git('show', `${s1}:CHANGELOG.md`) + '\n' }); + markMerged(fx, '1'); const post1 = v('1'); expect(post1.status).toBe(EXIT.ok); - expect(post1.out).toMatch(/^main: [0-9a-f]{40} = T1 \(same modulo stamps \(1 stamp file\(s\)\)\)$/m); + expect(post1.out).toMatch(/^main: [0-9a-f]{40} = T1 \(same modulo stamps \(2 stamp file\(s\)\)\)$/m); expect(post1.out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`); expect(next(post1.out)).toBe(`NEXT: gstack-ship-queue verify --pr 2 --train ${nonce}`); - expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h2}`); + const s2 = laneStamp(fx, '2', h2, { mergeMain: true }); + expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s2}`); const broken = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'not in the train\n' }); const mismatch = v('2'); @@ -69,8 +72,16 @@ describe('verify, freeze, repair and clear', () => { expect(queue(fx, ['train', 'verdict', '--train', rn]).status).toBe(EXIT.ok); expect(v('2').err).toContain('(QUEUE_TRAIN_FROZEN)'); expect(dispatches(fx, 'evals.yml').filter(d => d.ref === 'main').length).toBe(1); - expect(line(v('3', rn).out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h3}`); + // The broken main still carries T2's [Unreleased] section, so #3's lane stamps its own entry by hand. + fx.repo.git('checkout', '-q', '-B', 'lane3', h3); + fs.writeFileSync(path.join(fx.repo.work, 'VERSION'), '1.2.6.0\n'); + fs.writeFileSync(path.join(fx.repo.work, 'CHANGELOG.md'), fx.repo.git('show', `${h3}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.6.0] - 2026-10-18') + '\n'); + fx.repo.git('commit', '-qam', 'slot stamp #3'); + const s3 = fx.repo.git('rev-parse', 'HEAD'); + fx.repo.git('push', '-q', '-f', 'origin', `${s3}:refs/pull/3/head`); + expect(line(v('3', rn).out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s3}`); publishMain(fx, trainJson(fx, rn).prefixes[1].commit, '1.2.6.0'); + markMerged(fx, '3'); const cleared = v('3', rn); expect(cleared.status).toBe(EXIT.ok); expect(cleared.out).toMatch(/^FREEZE: cleared \(repair #3 via train \S+; main [0-9a-f]{12} carries a green record\)$/m); @@ -85,14 +96,10 @@ describe('verify, freeze, repair and clear', () => { const coord = coordinatorClone(fx.repo); const v = () => queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coord); const { repo } = fx; - repo.git('checkout', '-q', '-B', 'lane1', h1); - fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n'); - repo.git('commit', '-qam', 'v1.2.4.0 stamp'); - const stamped = repo.git('rev-parse', 'HEAD'); - repo.git('push', '-q', '-f', 'origin', `${stamped}:refs/pull/1/head`); + const stamped = laneStamp(fx, '1', h1); const ok = v(); expect(ok.status).toBe(EXIT.ok); - expect(ok.out).toContain(`head: ${stamped} (same modulo stamps (1 stamp file(s)))`); + expect(ok.out).toContain(`head: ${stamped} (same modulo stamps (5 stamp file(s)))`); expect(ok.out).toContain(`SHIP_RECEIPT: pr=1 head=${h1.slice(0, 12)} version=- base=main (binds the pinned head)`); expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${stamped}`); setPrBody(fx, '1', shippedBody('1', 'b'.repeat(40))); @@ -133,6 +140,7 @@ describe('verify, freeze, repair and clear', () => { const coord = coordinatorClone(fx.repo); const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0'); + markMerged(fx, '1'); expect(v('1').status).toBe(EXIT.ok); const { repo } = fx; @@ -142,6 +150,7 @@ describe('verify, freeze, repair and clear', () => { repo.git('rm', '-rq', '.'); repo.git('checkout', t.prefixes[2].commit, '--', '.'); fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n'); + fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), repo.git('show', `${t.prefixes[2].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.5.0] - 2026-10-18') + '\n'); if (extra) fs.writeFileSync(path.join(repo.work, extra.file), extra.text); repo.git('add', '-A'); const tree = repo.git('write-tree'); @@ -224,8 +233,10 @@ describe('verify, freeze, repair and clear', () => { const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); // Slot 1: #1 squash-merges and is released as 1.2.4.0 (its [Unreleased] re-headed). - expect(line(v('1').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`); + const s1 = laneStamp(fx, '1', h1); + expect(line(v('1').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s1}`); const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: repo.git('show', `${t.prefixes[1].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n' }); + markMerged(fx, '1'); expect(v('1').out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`); // Slot 2: the lane merges main into its pin; mergeChangelogs carries the child past its released parent. @@ -249,6 +260,7 @@ describe('verify, freeze, repair and clear', () => { // The squash merge publishes exactly that tree; the post-merge verify confirms it instead of freezing the queue. const main2 = publishMain(fx, laneHead, '1.2.5.0'); expect(repo.git('rev-parse', `${main2}^{tree}`)).toBe(repo.git('rev-parse', `${laneHead}^{tree}`)); + markMerged(fx, '2'); const post = v('2'); expect([post.status, post.err]).toEqual([EXIT.ok, '']); expect(line(post.out, 'main:')).toBe(`main: ${main2} = T2 (same modulo stamps (1 stamp file(s)) except CHANGELOG.md, main ${main1.slice(0, 12)}'s CHANGELOG.md plus #2's own entry)`); @@ -256,3 +268,133 @@ describe('verify, freeze, repair and clear', () => { expect(queue(fx, ['train', 'status']).out).not.toContain('QUEUE_TRAIN_FROZEN'); }, 300_000); }); + +describe('verify --train proves the slot stamp and a GitHub merge (#3531)', () => { + /** A main commit that carries an unstamped [Unreleased] entry, the way a bare commit after a no-commit merge shipped #3527. */ + function unstampedMain(fx: TrainFixture, entry: string): string { + const { repo } = fx; + repo.git('fetch', '-q', 'origin'); + repo.git('checkout', '-q', '-B', 'mainsim', 'origin/main'); + fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), fs.readFileSync(path.join(repo.work, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n', `# Changelog\n\n## [Unreleased]\n\n${entry}\n`)); + repo.git('commit', '-qam', 'merged without its stamp'); + repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main'); + repo.git('fetch', '-q', 'origin'); + return repo.git('rev-parse', 'HEAD'); + } + const freezeFile = (fx: TrainFixture) => path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`); + + test('#3527: an unstamped slot head (the bare pin, or a no-commit base merge committed without the restamp it wrote) refuses QUEUE_TRAIN_UNSTAMPED naming both versions; the stamped head passes with STAMP_VERSION from the head', () => { + const fx = make(); + const { h1, h2, nonce } = twoPrTrain(fx); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const t = trainJson(fx, nonce); + const coord = coordinatorClone(fx.repo); + const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); + + const bare = v('1'); + expect(bare.status).toBe(EXIT.refused); + expect(bare.err).toMatch(/#1's head [0-9a-f]{12} is not stamped above main: version: the head's VERSION 1\.2\.3\.0 is not above main's 1\.2\.3\.0.*\(QUEUE_TRAIN_UNSTAMPED\)/); + expect(bare.out).not.toContain('MERGE_CONDITION:'); + expect(next(bare.out)).toMatch(/^NEXT: #1's lane runs gstack-restamp --next on its head, commits what restamp staged/); + + const s1 = laneStamp(fx, '1', h1); + const ok = v('1'); + expect(ok.status, ok.err).toBe(EXIT.ok); + expect(ok.out).toContain('STAMP_VERSION: 1.2.4.0'); + expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s1}`); + const unread = queue(fx, ['verify', '--pr', '1', '--train', nonce, '--no-fetch'], {}, coord); + expect(unread.status).toBe(EXIT.refused); + expect(unread.err).toMatch(/#1's current head was not read \(--no-fetch\).*\(QUEUE_PROOF_MISSING\)/); + expect(unread.out).not.toContain('MERGE_CONDITION:'); + publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: fx.repo.git('show', `${s1}:CHANGELOG.md`) + '\n' }); + markMerged(fx, '1'); + expect(v('1').out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`); + + laneStamp(fx, '2', h2, { mergeMain: true, dropStamp: true }); + const dropped = v('2'); + expect(dropped.status, dropped.err).toBe(EXIT.refused); + expect(dropped.err).toMatch(/#2's head [0-9a-f]{12} is not stamped above main: version: the head's VERSION 1\.2\.4\.0 is not above main's 1\.2\.4\.0.*\(QUEUE_TRAIN_UNSTAMPED\)/); + expect(dropped.out).not.toContain('MERGE_CONDITION:'); + expect(fs.existsSync(freezeFile(fx))).toBe(false); + const s2 = laneStamp(fx, '2', h2, { mergeMain: true }); + const stamped = v('2'); + expect(stamped.status, stamped.err).toBe(EXIT.ok); + expect(stamped.out).toContain('STAMP_VERSION: 1.2.5.0'); + expect(line(stamped.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s2}`); + }, 300_000); + + test('the CHANGELOG rule is named: a head whose VERSION moved but whose entry is still [Unreleased] refuses', () => { + const fx = make(); + const { h1, nonce } = twoPrTrain(fx); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const { repo } = fx; + repo.git('checkout', '-q', '-B', 'lane1', h1); + fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n'); + repo.git('commit', '-qam', 'VERSION only'); + repo.git('push', '-q', '-f', 'origin', `${repo.git('rev-parse', 'HEAD')}:refs/pull/1/head`); + const r = queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coordinatorClone(fx.repo)); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toMatch(/is not stamped above main: changelog: CHANGELOG\.md's top section at the head is \[Unreleased\], not \[1\.2\.4\.0\].*\(QUEUE_TRAIN_UNSTAMPED\)/); + }, 300_000); + + test('claim-at-open: a pin that carries its own stamp verifies at head == pin', () => { + const fx = make({ ...TRAIN_POLICY, queue_mode: 'claim-at-open' }); + const h1 = openPr(fx, '1', { file: 'VERSION', text: '1.2.4.0\n', changelog: cl => cl.replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-18\n\n- #1 change\n') }); + readyReceipt(fx, '1', h1); + fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); + const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1}`, '--urgent']).out); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const r = queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coordinatorClone(fx.repo)); + expect(r.status, r.err).toBe(EXIT.ok); + expect(r.out).toContain('STAMP_VERSION: 1.2.4.0'); + expect(line(r.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`); + }, 300_000); + + test('#3530: an open release-only PR whose stamp-only diff makes main equal its prefix modulo stamps is not reported merged; it takes the pre-merge path', () => { + const fx = make(); + unstampedMain(fx, '- #3527 change'); + const h5 = openPr(fx, '5', { file: 'VERSION', text: '1.2.4.0\n', changelog: cl => cl.replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') }); + readyReceipt(fx, '5', h5); + fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); + const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `5@${h5}`, '--urgent']).out); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const r = queue(fx, ['verify', '--pr', '5', '--train', nonce], {}, coordinatorClone(fx.repo)); + expect(r.out).not.toMatch(/^VERIFY_TRAIN: #5 merged/m); + expect(r.status, r.err).toBe(EXIT.ok); + expect(r.out).toContain(`VERIFY_TRAIN: #5 may merge as slot 1 of train ${nonce}`); + expect(line(r.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h5}`); + expect(trainJson(fx, nonce).merged).toEqual([]); + }, 300_000); + + test('no false freeze: main at the next prefix while GitHub reports the PR OPEN, or cannot be read, refuses QUEUE_PROOF_MISSING and never freezes; MERGED confirms', () => { + const fx = make(); + const { h1, nonce } = twoPrTrain(fx); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const t = trainJson(fx, nonce); + const coord = coordinatorClone(fx.repo); + const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); + laneStamp(fx, '1', h1); + expect(v('1').status).toBe(EXIT.ok); + publishMain(fx, t.prefixes[1].commit, '1.2.4.0'); + markMerged(fx, '1'); + expect(v('1').status).toBe(EXIT.ok); + publishMain(fx, t.prefixes[2].commit, '1.2.5.0'); + + const open = v('2'); + expect(open.status).toBe(EXIT.refused); + expect(open.err).toMatch(/GitHub reports #2 as OPEN, not MERGED.*\(QUEUE_PROOF_MISSING\)/); + expect(open.out).not.toMatch(/^VERIFY_TRAIN: #2 merged/m); + expect(open.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m); + expect(next(open.out)).toContain('rerun once GitHub reports #2 MERGED'); + const saved = fx.read().prs['2']; + fx.write(d => { delete d.prs['2']; }); + const unread = v('2'); + expect(unread.err).toContain('(QUEUE_PROOF_MISSING)'); + expect(unread.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m); + expect(fs.existsSync(freezeFile(fx))).toBe(false); + fx.write(d => { d.prs['2'] = { ...(saved as object), state: 'MERGED' }; }); + const done = v('2'); + expect(done.status, done.err).toBe(EXIT.ok); + expect(done.out).toContain(`VERIFY_TRAIN: #2 merged; main's tree is T2 of train ${nonce} (2/2 merged)`); + }, 300_000); +}); diff --git a/test/ship-receipt-session-kind.test.ts b/test/ship-receipt-session-kind.test.ts new file mode 100644 index 000000000..48defdd94 --- /dev/null +++ b/test/ship-receipt-session-kind.test.ts @@ -0,0 +1,56 @@ +/** + * #3541 (the /ship half): `gstack-ship-receipt write` with no --session-kind + * and no GSTACK_SESSION_KIND asks bin/gstack-session-kind, a bash script. + * Spawned by path it never starts on Windows (no shebangs), so every receipt + * written there carried no session_kind. + * + * Windows-safe by construction: both bins launch through + * lib/script-invocation.ts (Git Bash on win32), state lives under os.tmpdir(). + */ +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { runScriptSync } from '../lib/script-invocation'; + +const ROOT = path.resolve(import.meta.dir, '..'); + +let tmp = '', repo = '', env: NodeJS.ProcessEnv = {}; + +beforeEach(() => { + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-receipt-kind-'))); + repo = path.join(tmp, 'repo'); + fs.mkdirSync(repo); + const gitconfig = path.join(tmp, 'gitconfig'); + fs.writeFileSync(gitconfig, ''); + env = { + ...process.env, HOME: tmp, USERPROFILE: tmp, GSTACK_STATE_ROOT: path.join(tmp, 'state'), GSTACK_HOME: path.join(tmp, 'state'), GSTACK_SESSION_KIND: '', + GIT_CONFIG_GLOBAL: gitconfig, GIT_CONFIG_NOSYSTEM: '1', GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined, + }; + const git = (...args: string[]) => { + const r = spawnSync('git', args, { cwd: repo, env, encoding: 'utf8', timeout: 30_000 }); + expect(r.status, r.stderr).toBe(0); + }; + git('init', '-q', '-b', 'main'); + fs.writeFileSync(path.join(repo, 'a.txt'), 'a\n'); + git('add', 'a.txt'); + git('-c', 'user.email=fixture@example.test', '-c', 'user.name=Fixture', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture'); + git('update-ref', 'refs/remotes/origin/main', 'HEAD'); + git('switch', '-q', '-c', 'feat'); +}); + +afterEach(() => fs.rmSync(tmp, { recursive: true, force: true })); + +test('write without a session kind records what gstack-session-kind prints (#3541)', () => { + const kind = runScriptSync(path.join(ROOT, 'bin', 'gstack-session-kind'), [], { cwd: repo, env, encoding: 'utf8', timeout: 30_000 }); + expect(kind.status, kind.stderr).toBe(0); + const body = path.join(tmp, 'body.md'); + fs.writeFileSync(body, '## Summary\n'); + const w = runScriptSync(path.join(ROOT, 'bin', 'gstack-ship-receipt'), ['write', '--pr', '1', '--base', 'main', '--body', body, '--json'], { + cwd: repo, env, encoding: 'utf8', timeout: 120_000, + }); + expect(w.error).toBeUndefined(); + expect(w.status, w.stderr).toBe(0); + expect(JSON.parse(w.stdout).session_kind).toBe(kind.stdout.trim()); +}, 150_000); diff --git a/test/ship-rereview-triggers.test.ts b/test/ship-rereview-triggers.test.ts index f06b65074..8795718cb 100644 --- a/test/ship-rereview-triggers.test.ts +++ b/test/ship-rereview-triggers.test.ts @@ -202,6 +202,53 @@ describe('a version stamp after review (GBRA-251, gbrain #8095)', () => { }); }); +describe('a restamp after a lost slot (GST-895, GBRA-218, gbrain #8134)', () => { + // Value: protects=after a lost slot and a merge of the base, the restamp moves the branch's own stale stamp (older + // than the reviewed tree's VERSION, newer than its fork point) and finish routes that line as a stamp; a row the + // base carries (another PR's release at the same version), a merged base row the base later edited, or a branch row + // at or older than the fork point, moved the same way still stops; + // fails_when=stampedFiles floors every moved version at the reviewed tree's VERSION (behavior=1 on gbrain #8134) or + // drops the floor for base lines; seam=lib/ship-bin-common.ts stampedFiles; no_claim=the restamp writer + const policy = JSON.stringify({ stamp_files: ['VERSION', 'src/notice.ts'], stamp_patterns: ["since: '{version}'"] }); + const notice = (...rows: [string, string][]) => `export const NOTICES = [\n${rows.map(([v, text]) => ` { since: '${v}', text: '${text}' },`).join('\n')}\n];\n`; + const lostSlot = (ownRow: string) => { + const f = fx({ '.gstack/ship-policy.json': policy, VERSION: '1.0.0.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old']) }, + { VERSION: '1.0.1.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], [ownRow, 'mine']) }); + f.advanceBase({ VERSION: '1.0.2.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs']) }); + mergeMain(f, { VERSION: '1.0.2.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], [ownRow, 'mine']) }); + return { f, reviewed: wtree(f), reviewedBase: mergeBase(f), post: snapshot(f) }; + }; + const behaviorStop = 'STOP: behavior, tests or build inputs changed since review (src/notice.ts): insert 5–11.5 → 12–14 → 16 before Step 17 (stage 2.1)'; + + test('the moved stale stamp is metadata; a moved base row or pre-fork row stops', () => { + const own = lostSlot('1.0.1.0'); + own.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], ['1.0.3.0', 'mine']) }); own.f.commit('restamp 1.0.3.0'); + const ok = finish(own.f, own.reviewed, own.post, ['--reviewed-base', own.reviewedBase]); + expect([lineOf(ok.stdout, 'ROUTE'), lineOf(ok.stdout, 'DOCS')]).toEqual([expect.stringMatching(/ behavior=0 docs=0 metadata=2 tests=0 /), expect.not.stringContaining('src/notice.ts')]); + + const base = lostSlot('1.0.1.0'); + base.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.3.0', 'theirs'], ['1.0.3.0', 'mine']) }); base.f.commit('restamp plus the base row'); + const b = finish(base.f, base.reviewed, base.post, ['--reviewed-base', base.reviewedBase]); + expect([b.status, lineOf(b.stdout, 'STOP')]).toEqual([3, behaviorStop]); + + const old = lostSlot('0.9.5.0'); + old.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], ['1.0.3.0', 'mine']) }); old.f.commit('moves a pre-fork row'); + const o = finish(old.f, old.reviewed, old.post, ['--reviewed-base', old.reviewedBase]); + expect([o.status, lineOf(o.stdout, 'STOP')]).toEqual([3, behaviorStop]); + + const atFork = lostSlot('1.0.0.0'); + atFork.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], ['1.0.3.0', 'mine']) }); atFork.f.commit('moves a row at the fork version'); + const a = finish(atFork.f, atFork.reviewed, atFork.post, ['--reviewed-base', atFork.reviewedBase]); + expect([a.status, lineOf(a.stdout, 'STOP')]).toEqual([3, behaviorStop]); + + const edited = lostSlot('1.0.1.0'); + edited.f.advanceBase({ VERSION: '1.0.2.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs, edited']) }); edited.f.git('fetch', '-q', 'origin'); + edited.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.3.0', 'theirs'], ['1.0.3.0', 'mine']) }); edited.f.commit('moves a merged base row the base later edited'); + const e = finish(edited.f, edited.reviewed, edited.post, ['--reviewed-base', edited.reviewedBase]); + expect([e.status, lineOf(e.stdout, 'STOP')]).toEqual([3, behaviorStop]); + }); +}); + describe('adjacent base edits (GST-384, #3631, founderstar#441)', () => { // Value: protects=a base edit next to the branch's unchanged hunk asks for a scoped review of only those hunks (finish // context=N, carry RECEIPT_CARRY_SCOPED_REVIEW), and a clean scoped review lets it through; fails_when=it forces the diff --git a/test/skill-start-plain.test.ts b/test/skill-start-plain.test.ts new file mode 100644 index 000000000..a81ba628a --- /dev/null +++ b/test/skill-start-plain.test.ts @@ -0,0 +1,89 @@ +/** + * GST-882: the skill-start fence must be plain shell a static resolver can + * follow. Hermes's Tirith 0.4.2 blocks a grouped fallback + * (`[ -d … ]||{ …; exit 1;}`) and any variable-selected command with HIGH + * "Nested executable body could not be resolved", which no allowlist entry can + * clear, so every gstack skill start was denied. An install-root render names + * the literal start path; every render keeps a loud failure when gstack is + * missing. + */ +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ALL_HOST_CONFIGS } from '../hosts'; +import { generatePreambleBash } from '../scripts/resolvers/preamble/generate-preamble-bash'; +import { insertRuntimePreludes, runtimeRootPrelude } from '../scripts/resolvers/runtime-root'; +import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types'; + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gst882-')); +const ctx = (host: string, installRoot: string | null) => + ({ host, paths: HOST_PATHS[host], skillName: 'ship', tmplPath: '', model: 'claude', installRoot }) as TemplateContext; +const startFence = (c: TemplateContext) => insertRuntimePreludes(generatePreambleBash(c), c).match(/```bash\n([\s\S]*?)\n```/)![1]; +const GROUPED = /(?:^|[;&|]\s*)\{\s|exit 1;\s*\}/m; +const sh = (body: string, env: Record<string, string>, cwd = tmp) => + spawnSync('bash', ['-uc', body], { cwd, encoding: 'utf8', timeout: 10_000, env: { PATH: '/usr/bin:/bin', ...env } }); +function stubStart(root: string) { + fs.mkdirSync(path.join(root, 'bin'), { recursive: true }); + fs.mkdirSync(path.join(root, 'lib'), { recursive: true }); + fs.writeFileSync(path.join(root, 'bin', 'gstack-skill-start'), '#!/bin/sh\necho "STARTED $0 $*"\n', { mode: 0o755 }); +} + +describe('skill-start fence is plain shell on every host (GST-882)', () => { + for (const host of ALL_HOST_CONFIGS) { + for (const installRoot of [null, path.join(tmp, `${host.name}-install`)]) { + const label = `${host.name} ${installRoot ? 'install-root' : 'checked-in'} render`; + test(`${label}: no grouped fallback`, () => { + expect(startFence(ctx(host.name, installRoot))).not.toMatch(GROUPED); + }); + if (!host.usesEnvVars) continue; + test(`${label}: a missing install fails loudly`, () => { + const r = sh(startFence(ctx(host.name, installRoot)), { HOME: path.join(tmp, 'empty-home') }); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain('gstack: no install found'); + expect(r.stderr).toContain(`./setup --host ${host.name}`); + }); + } + if (!host.usesEnvVars) continue; + test(`${host.name} install-root render: the start command is a literal path that runs`, () => { + const root = path.join(tmp, `${host.name}-literal`); + const fence = startFence(ctx(host.name, root)); + const command = fence.split('\n').at(-1)!; + expect(command).toStartWith(`"${root}/bin/gstack-skill-start" --skill "ship"`); + expect(fence).not.toContain('$'); + stubStart(root); + const r = sh(fence, { HOME: path.join(tmp, 'empty-home') }); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout).toContain(`STARTED ${root}/bin/gstack-skill-start --skill ship`); + }); + test(`${host.name} checked-in render: finds the global install`, () => { + const home = path.join(tmp, `${host.name}-home`); + const global = path.join(home, host.name === 'codex' ? '.codex/skills/gstack' : host.globalRoot); + stubStart(global); + const r = sh(startFence(ctx(host.name, null)), { HOME: home }); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout).toContain('STARTED '); + expect(r.stdout).toContain('--skill ship'); + }); + test(`${host.name} checked-in render: the start lookup picks the root later fences pick`, () => { + const base = path.join(tmp, `${host.name}-parity`); + const home = path.join(base, 'home'); + const repo = path.join(base, 'repo'); + const exported = path.join(base, 'exported'); + stubStart(path.join(home, host.name === 'codex' ? '.codex/skills/gstack' : host.globalRoot)); + fs.mkdirSync(repo, { recursive: true }); + spawnSync('git', ['init', '-q', repo], { timeout: 10_000 }); + const c = ctx(host.name, null); + const lookup = startFence(c).split('\n').slice(0, -1).join('\n'); + const root = (body: string, env: Record<string, string>) => sh(`${body}\necho "ROOT=$GSTACK_ROOT"`, env, repo).stdout.trim().split('\n').at(-1); + for (const layout of ['global', 'repo-local', 'exported']) { + if (layout === 'repo-local') stubStart(path.join(repo, host.localSkillRoot)); + if (layout === 'exported') stubStart(exported); + const env: Record<string, string> = layout === 'exported' ? { HOME: home, GSTACK_ROOT: exported } : { HOME: home }; + expect(root(lookup, env)).toBe(root(runtimeRootPrelude(c), env)); + if (layout === 'exported') expect(root(lookup, env)).toBe(`ROOT=${exported}`); + } + }); + } +}); diff --git a/test/test-free-shards.test.ts b/test/test-free-shards.test.ts index 620fbaf51..3d84740c3 100644 --- a/test/test-free-shards.test.ts +++ b/test/test-free-shards.test.ts @@ -196,7 +196,7 @@ describe('test-free-shards: exclusive host-state phase', () => { let child: ReturnType<typeof Bun.spawn> | undefined; let watchdog: ReturnType<typeof setTimeout> | undefined; try { - for (const file of ['scripts/test-free-shards.ts', 'scripts/test-strict-output.ts', 'scripts/lib/shard-engine.ts', 'scripts/lib/free-home-guard.ts', 'scripts/lib/free-ci-health.ts', 'scripts/lib/windows-curation.ts', 'lib/state-root.ts', + for (const file of ['scripts/test-free-shards.ts', 'scripts/test-strict-output.ts', 'scripts/lib/shard-engine.ts', 'scripts/lib/free-home-guard.ts', 'scripts/lib/free-ci-health.ts', 'scripts/lib/windows-curation.ts', 'scripts/lib/shard-jobs.ts', 'lib/state-root.ts', 'test/helpers/paid-test-set.ts', 'test/helpers/touchfiles.ts', 'test/helpers/touchfiles-data.ts', 'test/helpers/test-selection.ts']) { const target = path.join(directory, file); fs.mkdirSync(path.dirname(target), { recursive: true }); diff --git a/test/test-health-report.test.ts b/test/test-health-report.test.ts index 41f7d2736..3aca3162f 100644 --- a/test/test-health-report.test.ts +++ b/test/test-health-report.test.ts @@ -112,14 +112,60 @@ describe('W7a flake ledger reader', () => { const client = fakeClient([ [/workflows\/free-tests\.yml\/runs/, () => ({ workflow_runs: runs })], [/runs\/100\/artifacts/, () => ({ artifacts: [{ id: 1, name: 'flake-ledger-3', size_in_bytes: 10 }, { id: 2, name: 'free-result-3', size_in_bytes: 10 }] })], - [/runs\/101\/artifacts/, () => ({ artifacts: [{ id: 3, name: 'flake-ledger-7', size_in_bytes: 10, expired: true }] })], - [/runs\/\d+\/artifacts/, () => ({ artifacts: [] })], + [/runs\/101\/artifacts/, () => ({ artifacts: [{ id: 3, name: 'flake-ledger-7', size_in_bytes: 10, expired: true }, { id: 4, name: 'free-result-7', size_in_bytes: 10 }] })], + [/runs\/\d+\/artifacts/, () => ({ artifacts: [{ id: 5, name: 'free-result-1', size_in_bytes: 10 }] })], ], { 1: { 'flake-ledger.jsonl': ledger('test/a.test.ts') + ledger('test/a.test.ts') } }); const window = readCiFlakeLedgers({ client, repo: 'o/r', sinceDays: 7, now: NOW, cacheDir: tmp('health-cache-') }); expect(window).toMatchObject({ runs: 20, flakyRuns: 1, enforceable: true, files: [{ file: 'test/a.test.ts', flakyRuns: 1, entries: 2, rate: 0.05, failing: false }] }); expect(client.calls[0]).toContain('branch=main'); }); + // A reused main push (#3709) uploads no artifact and its reuse job succeeds; a real run uploads free-plan and free-result-*. + const reuseClient = (runs: ReturnType<typeof run>[], kind: (id: number) => 'reused' | 'real' | 'flaky', jobs?: () => unknown) => fakeClient([ + [/workflows\/free-tests\.yml\/runs/, () => ({ workflow_runs: runs })], + [/runs\/\d+\/artifacts/, apiPath => { + const k = kind(Number(/runs\/(\d+)\/artifacts/.exec(apiPath)![1])); + return { artifacts: k === 'reused' ? [] : [{ id: 8, name: 'free-plan', size_in_bytes: 10 }, + ...(k === 'flaky' ? [{ id: 9, name: 'flake-ledger-3', size_in_bytes: 10 }] : [])] }; + }], + [/runs\/\d+\/attempts\/1\/jobs/, jobs ?? (() => ({ jobs: [{ id: 1, name: 'reuse', conclusion: 'success' }, { id: 2, name: 'free-plan', conclusion: 'skipped' }, + { id: 3, name: 'free-tests', conclusion: 'success' }] }))], + ], { 9: { 'flake-ledger.jsonl': ledger('test/a.test.ts') } }); + + test('reused main pushes that ran no shards stay out of the window and its denominator; the window refills with older runs that ran shards (#3725)', () => { + // Value: protects=test:health --enforce still flags a file flaky in >5% of runs that executed shards when main pushes reuse their PR's run; fails_when=a reused run (no artifacts, reuse job green) counts as a zero-flake run again; why_new=#3725, after #3709 reused runs diluted 2/20 (10%) to 2/45 (4.4%); seam=GhClient fake; no_claim=the live artifact retention + const recent = Array.from({ length: 45 }, (_, i) => run(200 + i, { created_at: day(1 + i * 0.1) })); + const older = Array.from({ length: 10 }, (_, i) => run(300 + i, { created_at: day(10 + i) })); + const client = reuseClient([...recent, ...older], id => (id === 200 || id === 203 ? 'flaky' : id < 300 && (id - 200) % 3 !== 0 ? 'reused' : 'real')); + const window = readCiFlakeLedgers({ client, repo: 'o/r', sinceDays: 7, now: NOW, cacheDir: tmp('health-cache-') }); + expect(window).toMatchObject({ runs: 20, flakyRuns: 2, skipped: 30, enforceable: true, since: day(14), + files: [{ file: 'test/a.test.ts', flakyRuns: 2, rate: 0.1, failing: true }] }); + expect(window.note).toBeUndefined(); + expect(client.calls.filter(call => call.includes('/artifacts')).length).toBe(50); + expect(client.calls.filter(call => call.includes('/jobs')).length).toBe(30); + expect(flakeCheck(window)).toMatchObject({ status: 'fail', detail: 'test/a.test.ts: flaky in 2/20 main runs (10.0% > 5%); 30 reused main push(es) left out' }); + }); + + test('the reuse check spends at most 40 job listings; past the cap or on a failed listing a run counts as before, and the report says so', () => { + // Value: protects=the reuse lookups stay bounded and fail toward the pre-#3725 count visibly; fails_when=lookups are uncapped, or a capped or failed lookup drops the run or stays silent; why_new=#3725 adds a per-run job listing; seam=GhClient fake; no_claim=none beyond protects + const runs = [...Array.from({ length: 20 }, (_, i) => run(400 + i, { created_at: day(1 + i * 0.1) })), + ...Array.from({ length: 60 }, (_, i) => run(500 + i, { created_at: day(8 + i * 0.5) }))]; + const kind = (id: number) => (id === 400 ? 'flaky' : id === 401 ? 'real' : 'reused'); + const client = reuseClient(runs, kind); + const window = readCiFlakeLedgers({ client, repo: 'o/r', sinceDays: 7, now: NOW, cacheDir: tmp('health-cache-') }); + expect(window).toMatchObject({ runs: 20, flakyRuns: 1, skipped: 40, enforceable: true, since: day(8 + 39 * 0.5), + files: [{ file: 'test/a.test.ts', rate: 0.05, failing: false }] }); + expect(window.note).toBe('18 run(s) with no artifacts not checked for main-push reuse (cap of 40 job listings reached); counted as before #3725'); + expect(client.calls.filter(call => call.includes('/jobs')).length).toBe(40); + expect(client.calls.filter(call => call.includes('/artifacts')).length).toBe(60); + expect(flakeCheck(window).detail).toBe('no file above 5% over 20 main runs; 40 reused main push(es) left out; ' + window.note); + + const failing = reuseClient(runs, kind, () => { throw new Error('gh api failed: HTTP 502: Bad Gateway'); }); + const unchecked = readCiFlakeLedgers({ client: failing, repo: 'o/r', sinceDays: 7, now: NOW, cacheDir: tmp('health-cache-') }); + expect(unchecked).toMatchObject({ runs: 20, skipped: 0, since: day(1 + 19 * 0.1), files: [{ file: 'test/a.test.ts', rate: 0.05 }] }); + expect(unchecked.note).toStartWith('18 run(s) with no artifacts not checked for main-push reuse (GitHub API server error'); + }); + test('the job-summary table names files, counts and shards', () => { expect(formatFlakeSummary([])).toContain('None: every file passed on its first run.'); const table = formatFlakeSummary(parseFlakeLedger(ledger('test/a.test.ts') + ledger('test/a.test.ts') + ledger('test/b.test.ts'))); @@ -188,7 +234,7 @@ describe('collectHealth over recorded fixture data', () => { { id: 4, name: 'redispatch', conclusion: 'skipped', started_at: null, completed_at: null }] })], [/workflows\/free-tests\.yml\/runs/, () => ({ workflow_runs: [run(60), run(61, { conclusion: 'failure' })] })], [/runs\/60\/artifacts/, () => ({ artifacts: [{ id: 61, name: 'flake-ledger-2', size_in_bytes: 10 }] })], - [/runs\/61\/artifacts/, () => ({ artifacts: [] })], + [/runs\/61\/artifacts/, () => ({ artifacts: [{ id: 62, name: 'free-result-1', size_in_bytes: 10 }] })], [/workflows\/windows-smoke\.yml\/runs/, () => ({ workflow_runs: windowsRuns })], ], { 11: { 'manifest.json': JSON.stringify({ prCoverage: { mode: 'full-fallback', e2e: ['gate-a', 'gate-b'], judges: ['judge-a'], diff --git a/test/timeline-stop-hook-slug.test.ts b/test/timeline-stop-hook-slug.test.ts index 7a85dc6dc..379e4d16c 100644 --- a/test/timeline-stop-hook-slug.test.ts +++ b/test/timeline-stop-hook-slug.test.ts @@ -35,6 +35,7 @@ const HOOK_FILES = [ 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', + 'lib/result-codes-autoplan.ts', ]; const SLUG = 'stop-hook-slug-test'; const BUDGET_MS = 2000; diff --git a/test/ubicloud-pregate-remote.test.ts b/test/ubicloud-pregate-remote.test.ts index 2263b617a..79afd1296 100644 --- a/test/ubicloud-pregate-remote.test.ts +++ b/test/ubicloud-pregate-remote.test.ts @@ -20,7 +20,7 @@ afterAll(() => fs.rmSync(SCRATCH, { recursive: true, force: true })); const COPIED = ['scripts/ubicloud/pregate-remote.sh', 'bin/gstack-wtree', 'bin/gstack-path-spelling.sh', 'scripts/test-free-shards.ts', 'scripts/test-strict-output.ts', 'scripts/lib/shard-engine.ts', 'scripts/lib/free-home-guard.ts', - 'scripts/lib/free-ci-health.ts', 'scripts/lib/windows-curation.ts', 'lib/state-root.ts', 'test/helpers/paid-test-set.ts', + 'scripts/lib/free-ci-health.ts', 'scripts/lib/windows-curation.ts', 'scripts/lib/shard-jobs.ts', 'lib/state-root.ts', 'test/helpers/paid-test-set.ts', 'test/helpers/touchfiles.ts', 'test/helpers/touchfiles-data.ts', 'test/helpers/test-selection.ts']; const TESTS: Record<string, string> = { 'test/alpha.test.ts': "import { expect, test } from 'bun:test';\ntest('alpha', () => { expect(1).toBe(1); });\n", diff --git a/test/ubicloud-runner-run.test.ts b/test/ubicloud-runner-run.test.ts index 81890df77..2e4cabfad 100644 --- a/test/ubicloud-runner-run.test.ts +++ b/test/ubicloud-runner-run.test.ts @@ -50,6 +50,7 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', ( const err = r.stderr.toString(); expect(r.exitCode, err).toBe(0); expect(err).toMatch(/UBICLOUD: left=0 owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 others=1\n/); + expect(err).toMatch(/carries a 3h TTL \(this owner's next up or reap destroys it once it expires\); if this process dies first: /); expect(f.listed()).toBe('eu-central-h1 ubirun-thrd1-9999999999-sib1-t3\n'); // Value: protects=UBI_LEFT_FILE carries the same leftover line pregate-remote.sh parses, apart from the command's output; fails_when=the file is not written or its format drifts from pregate-remote.sh's parser; why_new=the pregate stub hand-copies the format; seam=none; no_claim=real VM teardown (fake API and ssh) expect(readFileSync(leftFile, 'utf8')).toMatch(/^UBICLOUD: left=0 owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 others=1\n$/); diff --git a/test/ubicloud-runner-verify-quota.test.ts b/test/ubicloud-runner-verify-quota.test.ts index e099b9638..a7cbeea6b 100644 --- a/test/ubicloud-runner-verify-quota.test.ts +++ b/test/ubicloud-runner-verify-quota.test.ts @@ -210,12 +210,12 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', ( }, 90_000); test('reap reports a failed list and a failed destroy as failures; UBI_TTL_HOURS rejects leading zeros and more than three digits', () => { - const f = fakeVm({ listed: 'eu-central-h1 ubirun-other-1000000000-bbbb-t1\n' }); + const f = fakeVm({ listed: 'eu-central-h1 ubirun-thrd1-1000000000-bbbb-t1\n' }); try { - writeFileSync(join(f.root, 'bin/curl'), '#!/usr/bin/env bash\nout=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\nbody=$(cat)\ncase "$body" in\n *\'"list"\'*) echo "eu-central-h1 ubirun-other-1000000000-bbbb-t1" > "$out"; printf 200 ;;\n *\'"show"\'*) printf \'state: running\\nsize: standard-16\\nip4: 192.0.2.1\\n\' > "$out"; printf 200 ;;\n *) echo "boom" > "$out"; printf 500 ;;\nesac\n'); + writeFileSync(join(f.root, 'bin/curl'), '#!/usr/bin/env bash\nout=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\nbody=$(cat)\ncase "$body" in\n *\'"list"\'*) echo "eu-central-h1 ubirun-thrd1-1000000000-bbbb-t1" > "$out"; printf 200 ;;\n *\'"show"\'*) printf \'state: running\\nsize: standard-16\\nip4: 192.0.2.1\\n\' > "$out"; printf 200 ;;\n *) echo "boom" > "$out"; printf 500 ;;\nesac\n'); const destroyFails = run(f, ['reap']); expect(destroyFails.exitCode).toBe(1); - expect(destroyFails.stderr.toString()).toContain('failed to reap eu-central-h1/ubirun-other-1000000000-bbbb-t1'); + expect(destroyFails.stderr.toString()).toContain('failed to reap eu-central-h1/ubirun-thrd1-1000000000-bbbb-t1'); expect(destroyFails.stderr.toString()).toContain('reap: 1 of 1 expired VMs could not be destroyed'); writeFileSync(join(f.root, 'bin/curl'), '#!/usr/bin/env bash\nout=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\ncat >/dev/null; echo "down" > "$out"; printf 503\n'); const listFails = run(f, ['reap']); diff --git a/test/ubicloud-runner.test.ts b/test/ubicloud-runner.test.ts index 71d1de15e..66f6605bf 100644 --- a/test/ubicloud-runner.test.ts +++ b/test/ubicloud-runner.test.ts @@ -204,25 +204,25 @@ esac } finally { rmSync(f.root, { recursive: true, force: true }); } }); - test('up reaps expired TTL VMs of any owner first, names its own VM with -t<hours>, and the quota preflight refuses before any create request', () => { - const listed = 'eu-central-h1 ubirun-other-1000000000-bbbb-t1\neu-central-h1 ubirun-peer-9999999999-cccc\n'; + test('up reaps this owner\'s expired TTL VMs first and leaves another owner\'s, names its own VM with -t<hours>, and the quota preflight refuses before any create request', () => { + const listed = 'eu-central-h1 ubirun-thrd1-1000000000-hhhh-t1\neu-central-h1 ubirun-peer-9999999999-cccc\n'; const f = fakeApi({ destroy: 200, listed }); try { const r = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'up'], { env: { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_VM_RECORD: f.record, UBI_OWNER: 'thrd1', UBI_QUOTA_VCPUS: '31' }, timeout: 90_000 }); expect(r.exitCode).toBe(1); const err = r.stderr.toString(); - expect(err).toMatch(/reaping expired eu-central-h1\/ubirun-other-1000000000-bbbb-t1 \(owner other; its TTL ended \d+ min ago\)/); + expect(err).toMatch(/reaping expired eu-central-h1\/ubirun-thrd1-1000000000-hhhh-t1 \(owner thrd1; its TTL ended \d+ min ago\)/); expect(err).toMatch(/quota preflight refused eu-central-h1\/ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 \(standard-16\): it needs 16 vCPUs and the project's VMs already use 16 of 31/); expect(err).toContain('create refused before any request: vCPU quota exhausted'); const destroyed = f.calls().split('\n').filter(l => l.includes('destroy')); expect(destroyed).toHaveLength(1); - expect(destroyed[0]).toContain('ubirun-other-1000000000-bbbb-t1'); + expect(destroyed[0]).toContain('ubirun-thrd1-1000000000-hhhh-t1'); expect(f.calls()).not.toContain('"create"'); expect(existsSync(f.record)).toBe(false); } finally { rmSync(f.root, { recursive: true, force: true }); } }); - test('UBI_REAP=0 skips the reap and UBI_TTL_HOURS sets the name\'s TTL; reap destroys expired TTLs and this owner\'s old TTL-less names, never unexpired, recent, another owner\'s TTL-less, untagged or foreign ones', () => { + test('UBI_REAP=0 skips the reap and UBI_TTL_HOURS sets the name\'s TTL; reap destroys this owner\'s expired TTLs and old TTL-less names, never unexpired, recent, another owner\'s expired or TTL-less, untagged or foreign ones', () => { const now = Math.floor(Date.now() / 1000); const listed = `eu-central-h1 ubirun-other-1000000000-bbbb-t1\neu-central-h1 ubirun-thrd1-9999999999-dddd-t1\neu-central-h1 ubirun-thrd1-1000000000-aaaa\neu-central-h1 ubirun-1000000000-cccc\neu-central-h1 ubirun-thrd1-${now - 3 * 3600}-eeee\neu-central-h1 ubirun-gbra240-1000000000-gggg\neu-central-h1 team-db-1000000000-ffff\n`; const f = fakeApi({ destroy: 200, listed }); @@ -235,7 +235,8 @@ esac expect(reap.exitCode).toBe(0); expect(reap.stderr.toString()).toMatch(/reaping TTL-less eu-central-h1\/ubirun-thrd1-1000000000-aaaa \(owner thrd1; \d+h old, legacy sweep at 4h\)/); const destroyed = f.calls().split('\n').filter(l => l.includes('destroy')).map(l => l.match(/ubirun-[a-z0-9-]+|team-db-[a-z0-9-]+/)?.[0]).sort(); - expect(destroyed).toEqual(['ubirun-other-1000000000-bbbb-t1', 'ubirun-thrd1-1000000000-aaaa']); + expect(destroyed).toEqual(['ubirun-thrd1-1000000000-aaaa']); + expect(reap.stderr.toString()).toContain('reap: left 1 expired VMs of other owners; only their owners reap them'); expect(f.run('reap').stderr.toString()).toContain('reap: no expired VMs'); // Value: protects=UBI_LEGACY_HOURS sets reap's bound for TTL-less ubirun names (a 3h-old name is spared at 4h, reaped at 2h); fails_when=reap ignores UBI_LEGACY_HOURS and keeps a fixed bound; why_new=only the default bound and the validation are tested; seam=none; no_claim=real Ubicloud list and destroy (fake curl) const lowered = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'reap'], { env: { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_VM_RECORD: f.record, UBI_OWNER: 'thrd1', UBI_LEGACY_HOURS: '2' }, timeout: 90_000 }); @@ -243,13 +244,34 @@ esac expect(lowered.stderr.toString()).toMatch(/reaping TTL-less eu-central-h1\/ubirun-thrd1-\d{10}-eeee \(owner thrd1; 3h old, legacy sweep at 2h\)/); expect(f.calls()).toMatch(/ubirun-thrd1-\d{10}-eeee[^\n]*destroy/); // Value: protects=the legacy sweep never destroys another lane's or an untagged TTL-less VM (GST-324: 22 gbra240 eval VMs lost); fails_when=the sweep matches any owner again; why_new=the cross-owner sweep destroyed a paid eval; seam=none; no_claim=real Ubicloud list and destroy (fake curl) - expect(f.calls()).not.toMatch(/(team-db|dddd|gggg|cccc)[^\n]*destroy/); + expect(f.calls()).not.toMatch(/(team-db|dddd|gggg|cccc|bbbb)[^\n]*destroy/); const ownerless = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'reap'], { env: { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_VM_RECORD: f.record, UBI_OWNER: '', UBI_LEGACY_HOURS: '1' }, timeout: 90_000 }); expect(ownerless.exitCode).toBe(0); expect(ownerless.stderr.toString()).not.toContain('reaping TTL-less'); } finally { rmSync(f.root, { recursive: true, force: true }); } }, 30_000); + // Value: protects=reap and every up destroy only the caller's own VMs, never another owner's, even one whose TTL ran out (the 2026-10-30 owner-scoped-deletes rule; GST-324 lost 22 of gbrain's eval VMs to a cross-owner sweep); fails_when=reap judges another owner's VM by its TTL again; why_new=the old cases asserted the cross-owner TTL reap this rule forbids; seam=none; no_claim=real Ubicloud list and destroy (fake curl) + test('reap and up destroy only the caller\'s own expired VMs: another owner\'s and an untagged expired VM survive (GST-573)', () => { + const listed = 'eu-central-h1 ubirun-other-1000000000-bbbb-t1\neu-central-h1 ubirun-1000000000-cccc-t1\neu-central-h1 ubirun-thrd1-1000000000-hhhh-t1\n'; + const f = fakeApi({ destroy: 200, listed }); + try { + const reap = f.run('reap'); + expect(reap.exitCode).toBe(0); + expect(reap.stderr.toString()).toMatch(/reaping expired eu-central-h1\/ubirun-thrd1-1000000000-hhhh-t1 \(owner thrd1; its TTL ended \d+ min ago\)/); + expect(reap.stderr.toString()).toContain('reap: left 2 expired VMs of other owners; only their owners reap them'); + const destroyed = f.calls().split('\n').filter(l => l.includes('destroy')); + expect(destroyed).toHaveLength(1); + expect(destroyed[0]).toContain('ubirun-thrd1-1000000000-hhhh-t1'); + const up = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'up'], { env: { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_VM_RECORD: f.record, UBI_OWNER: 'thrd1', UBI_QUOTA_VCPUS: '1' }, timeout: 90_000 }); + expect(up.stderr.toString()).toContain('create refused before any request'); + expect(up.stderr.toString()).toContain('reap: left 2 expired VMs of other owners; only their owners reap them'); + expect(up.stderr.toString()).not.toMatch(/reaping[^\n]*(ubirun-other|ubirun-1000000000-cccc)/); + expect(f.calls().split('\n').filter(l => l.includes('destroy'))).toHaveLength(1); + for (const name of ['ubirun-other-1000000000-bbbb-t1', 'ubirun-1000000000-cccc-t1']) expect(readFileSync(join(f.root, 'listed'), 'utf8')).toContain(name); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 90_000); + // Value: protects=a project with no VMs (Ubicloud answers vm list with a bare newline) reads as empty everywhere vm_rows feeds; fails_when=a blank list row becomes a VM named "-" again and reap dies on it or usage asks to show it; why_new=every fake list in this file had at least one VM, so #3678 shipped green; seam=none; no_claim=the real API's empty-body shape (taken from #3678's reading of ubi_cli.rb) test('an empty project (vm list answers a bare or blank line) has no VMs: reap destroys nothing and exits 0, usage prints total 0 0, list --mine prints nothing (#3678)', () => { for (const listed of ['\n', ' \n']) { diff --git a/test/writing-style-resolver.test.ts b/test/writing-style-resolver.test.ts index 092c2c200..b2adc3621 100644 --- a/test/writing-style-resolver.test.ts +++ b/test/writing-style-resolver.test.ts @@ -94,7 +94,7 @@ describe('Writing Style preamble section', () => { const fenceLine = out.split('\n').find(l => l.includes('gstack-skill-start" --skill')); expect(fenceLine).toBeDefined(); expect(fenceLine).not.toMatch(/~\/\.claude\//); - expect(fenceLine).toContain('$GSTACK_BIN'); + expect(fenceLine).toContain('$GSTACK_ROOT/bin/'); }); test('tier 1 preamble does NOT include Writing Style section', () => { diff --git a/ubicloud/SKILL.md b/ubicloud/SKILL.md index 4aaa63463..3b90355ae 100644 --- a/ubicloud/SKILL.md +++ b/ubicloud/SKILL.md @@ -102,7 +102,7 @@ telemetry — it never blocks the workflow. A full suite or verify that takes an hour on a 4-core sandbox takes minutes on a `standard-16` VM, and a VM that outlives its run costs the whole project its shared vCPU quota. This skill is one runner with the discipline built in: every -VM carries its owner and a TTL in its name, any later run reaps expired VMs, the +VM carries its owner and a TTL in its name, its owner's next run reaps it once expired, the create is refused before the quota fills, a silent remote command is killed with its last output instead of hanging, and a repository's verify is one committed definition rather than a wrapper per lane. @@ -143,12 +143,12 @@ $U usage # VMs and vCPUs per owner: who holds the shared quota ``` A VM in `records` but absent from `list` is already gone: `$U down <name>` -forgets it. A VM in the project under another owner is theirs; leave it (its -TTL will reap it). Every `up` runs `$U reap` first, destroying any VM whose -`-t<hours>` TTL ended more than ten minutes ago, whichever owner made it, and -this owner's own `ubirun-<UBI_OWNER>-*` names with no TTL once they are -`UBI_LEGACY_HOURS` old (default 4). Another owner's TTL-less VM, an untagged -one and names outside the `ubirun-` prefix are never reaped. +forgets it. A VM in the project under another owner is theirs; leave it. +Every `up` runs `$U reap` first, destroying only this owner's +`ubirun-<UBI_OWNER>-*` VMs whose `-t<hours>` TTL ended more than ten minutes +ago, and those with no TTL once they are `UBI_LEGACY_HOURS` old (default 4). +Another owner's VM, expired or not, an untagged one and names outside the +`ubirun-` prefix are never reaped. Quota: with `UBI_QUOTA_VCPUS=<project ceiling>` exported (or after one refusal, whose maximum the runner remembers), `up` adds the size's vCPUs to the project's @@ -171,7 +171,7 @@ background operation leaves its VM until the reaper. So: `### gstack-detach EXIT=<code> ###` sentinel. - Set `UBI_TTL_HOURS` above the longest plausible run (default 3; 1-999) and name the expected duration in the thread before launching anything over an - hour. A run that outlives its TTL is reaped by the next `up` anywhere. + hour. A run that outlives its TTL is reaped by its owner's next `up` or `reap`. The runner prints `<name> carries a <n>h TTL ...; if this process dies first: <self> down <name>` right after the VM is ready; quote that line in your report diff --git a/ubicloud/SKILL.md.tmpl b/ubicloud/SKILL.md.tmpl index 970627d94..1c339af9f 100644 --- a/ubicloud/SKILL.md.tmpl +++ b/ubicloud/SKILL.md.tmpl @@ -28,7 +28,7 @@ sensitive: false A full suite or verify that takes an hour on a 4-core sandbox takes minutes on a `standard-16` VM, and a VM that outlives its run costs the whole project its shared vCPU quota. This skill is one runner with the discipline built in: every -VM carries its owner and a TTL in its name, any later run reaps expired VMs, the +VM carries its owner and a TTL in its name, its owner's next run reaps it once expired, the create is refused before the quota fills, a silent remote command is killed with its last output instead of hanging, and a repository's verify is one committed definition rather than a wrapper per lane. @@ -69,12 +69,12 @@ $U usage # VMs and vCPUs per owner: who holds the shared quota ``` A VM in `records` but absent from `list` is already gone: `$U down <name>` -forgets it. A VM in the project under another owner is theirs; leave it (its -TTL will reap it). Every `up` runs `$U reap` first, destroying any VM whose -`-t<hours>` TTL ended more than ten minutes ago, whichever owner made it, and -this owner's own `ubirun-<UBI_OWNER>-*` names with no TTL once they are -`UBI_LEGACY_HOURS` old (default 4). Another owner's TTL-less VM, an untagged -one and names outside the `ubirun-` prefix are never reaped. +forgets it. A VM in the project under another owner is theirs; leave it. +Every `up` runs `$U reap` first, destroying only this owner's +`ubirun-<UBI_OWNER>-*` VMs whose `-t<hours>` TTL ended more than ten minutes +ago, and those with no TTL once they are `UBI_LEGACY_HOURS` old (default 4). +Another owner's VM, expired or not, an untagged one and names outside the +`ubirun-` prefix are never reaped. Quota: with `UBI_QUOTA_VCPUS=<project ceiling>` exported (or after one refusal, whose maximum the runner remembers), `up` adds the size's vCPUs to the project's @@ -97,7 +97,7 @@ background operation leaves its VM until the reaper. So: `### gstack-detach EXIT=<code> ###` sentinel. - Set `UBI_TTL_HOURS` above the longest plausible run (default 3; 1-999) and name the expected duration in the thread before launching anything over an - hour. A run that outlives its TTL is reaped by the next `up` anywhere. + hour. A run that outlives its TTL is reaped by its owner's next `up` or `reap`. The runner prints `<name> carries a <n>h TTL ...; if this process dies first: <self> down <name>` right after the VM is ready; quote that line in your report