mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-09 18:28:15 +01:00
fix(ship): a second full preflight stops while this checkout's /ship run is open (GST-1204) (#3950)
* fix(ship-preflight): forward --timeout to Step 5's pregate (GST-1196) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): a second full preflight stops while this checkout's /ship run is open (GST-1204) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): a new /ship from the same harness replaces a stopped run's marker; a run the lease can't record stops (GST-1204 review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
4ed1711d7e
commit
e09ea409f0
18 files changed
+321
-23
No files matched your search
+16
-2
@@ -30,6 +30,10 @@
|
||||
# gstack-lease release --pid <p> --run <id> [--root <checkout>]
|
||||
# close run <id> on <pid>'s lease (gstack-skill-end); once every run it
|
||||
# recorded is closed the lease is idle and live skips it (GST-1205)
|
||||
# gstack-lease open --run <id> [--pid <p> | --from <pid>] [--root <checkout>]
|
||||
# exit 0 while run <id> on a lease (<pid>'s, the harness's behind
|
||||
# --from, or any) is open (not released, under 120 minutes) and its
|
||||
# holder alive; 1 otherwise
|
||||
# gstack-lease list [--root <checkout>]
|
||||
# print `<pid> <age>m alive|idle|dead|stale` for every lease
|
||||
# A pid alone is not a holder: pids are reused after a reboot or a machine
|
||||
@@ -67,8 +71,8 @@ while [ $# -gt 0 ]; do
|
||||
shift
|
||||
done
|
||||
case "$VERB" in
|
||||
touch|live|list|release) ;;
|
||||
-h|--help|'') sed -n '2,34p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
touch|live|list|release|open) ;;
|
||||
-h|--help|'') sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "gstack-lease: unknown verb '$VERB'" >&2; exit 2 ;;
|
||||
esac
|
||||
[ -n "$ROOT" ] || ROOT=$(gstack_source_root "$BIN")
|
||||
@@ -318,6 +322,16 @@ case "$VERB" in
|
||||
fi
|
||||
unlock
|
||||
exit 0 ;;
|
||||
open)
|
||||
[ -z "$FROM" ] || PID=$(gstack_harness_pid "$FROM")
|
||||
case "$PID" in *[!0-9]*) echo "gstack-lease: open needs --run <id> and an optional --pid <number>" >&2; exit 2 ;; esac
|
||||
[ -n "$RUN" ] || { echo "gstack-lease: open needs --run <id> and an optional --pid <number>" >&2; exit 2; }
|
||||
for f in "$DIR"/${PID:-*}; do
|
||||
[ -f "$f" ] || continue
|
||||
[ "$(state "${f##*/}" "$(head -1 "$f" 2>/dev/null)" "$(age_min "$f")")" = alive ] || continue
|
||||
fresh "$f" | awk -v id="$RUN" '$1 == "run" && $2 == id { found = 1 } END { exit !found }' && exit 0
|
||||
done
|
||||
exit 1 ;;
|
||||
list)
|
||||
for f in "$DIR"/*; do
|
||||
[ -f "$f" ] || continue
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
**A full `gstack-ship-preflight` no longer merges the base under an open /ship run's reviews (GST-1204).** Running the full preflight again in the middle of /ship merged the base into the working tree (Step 3) while review readers were diffing that tree, which voided a review pass (gbrain-evals #317, a Capy lane).
|
||||
|
||||
### Fixed
|
||||
- **`gstack-ship-preflight`:** `--run <id>` records the /ship run once Steps 1-3 pass: a marker in the checkout's git dir, and an open run on the checkout's own lease, so the Capy driver needs no workflow lease. A later full preflight on that checkout stops with exit 3, before any fetch or merge, until `gstack-ship-release finish` clears the marker or the run is 120 minutes old (GST-1205's rule) or its holder is gone. The STOP names the run and the marker and says what to do: rerun Step 5 with `--tests-only`, let the /ship finish, or remove the marker of a run that stopped. `--tests-only` and `--audit` are never stopped. /ship's own first preflight, and a rerun after a Step 3 STOP, still work. A new `--run` id from the open run's own holder (the Claude Code session, or Capy's `capy-server`) replaces a run that stopped before finish; the same id, no `--run`, or another live session's run still stops. A `--run` the lease can't record (its lock held by a live process) stops before the fetch instead of going on unprotected. A `--run` value that isn't a plain id is a usage error (2).
|
||||
- **`gstack-ship-release finish`:** clears the run's marker once Steps 15-16 pass.
|
||||
- **`gstack-lease open --run <id> [--pid <p> | --from <pid>]`:** exits 0 while that run is open on a live holder and 1 otherwise, using the same released, 120-minute and dead or reused-pid rules as `live`.
|
||||
- **/ship Step 1** passes `--run <SESSION_ID>`, and **`ship/DRIVER.md` Phase 2** passes `--run <run dir name>`.
|
||||
- **Docs:** `docs/troubleshooting.md#ship-preflight-run-open` explains the new STOP; `docs/capy-install.md` lists `gstack-lease open`.
|
||||
|
||||
### Tests
|
||||
- `test/ship-preflight.test.ts`: a full rerun with the run open stops before the fetch and leaves HEAD, `origin/main` and the tree untouched, with or without `--run`; the driver's Phase 2 sequence refuses a mid-run full preflight while `--audit` and `--tests-only` go on; a finished or aged-out run doesn't block; after a stopped run and `gstack-skill-end`'s real release, a new id from the same holder goes on while another live holder's run is still refused; an unrecordable `--run` stops before the fetch; /ship's first preflight and its rerun after a conflict STOP work; a bad `--run` is usage.
|
||||
- `test/gstack-lease.test.ts`: `open` for an open, released, aged-out, reused-pid, dead and unrecorded run, with `--pid`, `--from` or neither, plus usage errors.
|
||||
@@ -232,7 +232,11 @@ rename can be broken and then publish into its successor's lock. While a live pr
|
||||
holds the lock, or it cannot be taken at all, `touch` exits 3 without writing
|
||||
(`gstack-skill-start` reports that the session holds no lease) and a reclaim
|
||||
keeps the lease. `bin/gstack-lease list` shows `alive|idle|dead|stale`, and
|
||||
`GSTACK_UPGRADE_FORCE=1` overrides.
|
||||
`GSTACK_UPGRADE_FORCE=1` overrides. `bin/gstack-lease open --run <id> [--pid <p> | --from <pid>]`
|
||||
answers for one run (exit 0 while it is open on a live holder);
|
||||
`gstack-ship-preflight` opens /ship runs on the project checkout's own lease and
|
||||
uses it to refuse a base merge under an open run
|
||||
([troubleshooting](troubleshooting.md#ship-preflight-run-open)).
|
||||
|
||||
## What the installer does
|
||||
|
||||
|
||||
@@ -631,6 +631,49 @@ fix-check record as Step 9's. Rerun Step 11's native pass on the same frozen tre
|
||||
**Expected result.** `gstack-review-read` shows both records at one tree with
|
||||
`review_freshness` `CURRENT`, and Step 11.5 continues to Step 12.
|
||||
|
||||
<a id="ship-preflight-run-open"></a>
|
||||
### `STOP: /ship run <id> is open on this checkout and past its preflight (<marker>; open until its finish or for 120 minutes) ...`
|
||||
|
||||
**Meaning.** A full `gstack-ship-preflight` (Steps 1-3) ran on a checkout where a
|
||||
/ship run already passed them and has not finished. A full preflight merges the
|
||||
base into the working tree, and that run's review readers diff this tree, so the
|
||||
merge would void their pass (gbrain-evals#317). /ship passes `--run <id>` (Step 1:
|
||||
the preamble's `SESSION_ID`; `ship/DRIVER.md` Phase 2: the run dir's name). Once
|
||||
Steps 1-3 succeed, preflight records the run in the checkout's git dir
|
||||
(`gstack-ship-run`) and opens it on the checkout's own lease
|
||||
(`<git dir>/gstack-leases`, so the driver needs no workflow lease). The run
|
||||
counts as open while the marker exists, which `gstack-ship-release finish` removes
|
||||
when Steps 15-16 pass, and while `bin/gstack-lease open` says so: under 120
|
||||
minutes old and its holder (the harness that ran preflight) alive. A finished,
|
||||
aged-out or dead run never blocks, and /ship's own first preflight (or a rerun
|
||||
after a Step 3 STOP) is never refused. A different `--run` from the open run's
|
||||
holder replaces it: that is a new /ship after one that stopped before finish.
|
||||
The holder is the first non-shell process above preflight: the Claude Code
|
||||
session, or on Capy the machine's one `capy-server`, so there only the same id or
|
||||
a missing `--run` is refused. The same id, no `--run`, or an open run held by
|
||||
another live process (another session) stops.
|
||||
|
||||
**What is kept.** Nothing ran: no fetch, no merge, the tree is untouched.
|
||||
|
||||
**Fix.** Rerun Step 5 with `gstack-ship-preflight --tests-only`, which never
|
||||
merges, or let that /ship finish. If that run is no longer running (it stopped
|
||||
before finish), remove the marker the STOP names (`rm <marker>`), then rerun.
|
||||
|
||||
**Expected result.** The preflight continues to `STATUS:` and `MERGE:`.
|
||||
|
||||
<a id="ship-preflight-run-unrecorded"></a>
|
||||
### `STOP: cannot record /ship run <id> on this checkout's lease (...)`
|
||||
|
||||
**Meaning.** `--run` was given, but `gstack-lease touch` could not open the run
|
||||
on the checkout's lease, for example while a live process holds its lock.
|
||||
Unrecorded, a later full preflight could not be refused, so preflight stops
|
||||
before the fetch instead of going on unprotected.
|
||||
|
||||
**What is kept.** Nothing ran: no fetch, no merge.
|
||||
|
||||
**Fix.** Do what the parenthesized `gstack-lease` line names (wait for the
|
||||
process holding `<git dir>/gstack-leases/.lock`), then rerun.
|
||||
|
||||
<a id="review-fingerprint-tmpdir"></a>
|
||||
### `gstack-wtree: cannot create a temp index under <dir>; set TMPDIR to a writable directory` / `gstack-review-log: cannot create a private object directory under <dir>; no working-tree fingerprint. Set TMPDIR to a writable directory.`
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@ const done = (r: { status: number | null; stdout?: string | Buffer | null; stder
|
||||
export function git(cwd: string, args: string[], timeout = 120_000): Run {
|
||||
return done(spawnSync('git', args, { cwd, encoding: 'utf8', timeout, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }));
|
||||
}
|
||||
/** GST-1204: the /ship run that passed Steps 1-3 on this checkout, recorded in its own git dir (per worktree) until finish clears it; empty outside a checkout. */
|
||||
export function shipRunMarker(cwd: string): string {
|
||||
const p = git(cwd, ['rev-parse', '--git-path', 'gstack-ship-run']);
|
||||
return p.status === 0 && p.stdout.trim() ? path.resolve(cwd, p.stdout.trim()) : '';
|
||||
}
|
||||
export function bin(cwd: string, name: string, args: string[], timeout = 600_000): Run {
|
||||
return done(runScriptSync(path.join(BIN_DIR, name), args, { cwd, encoding: 'utf8', timeout, maxBuffer: 64 * 1024 * 1024 }));
|
||||
}
|
||||
|
||||
+36
-4
@@ -15,11 +15,14 @@ import { buildRunCheck } from './pregate/run-check';
|
||||
import { classifyDiff } from './review-depth';
|
||||
import { wtreeFingerprint } from './script-invocation';
|
||||
import { audit } from './ship-audit';
|
||||
import { bin, carryNoneLine, firstLine, flagReader, gh, git, hostedOrigin, laneConcurrency, laneLine, lanes, Report, runLanes, shipPolicy, short } from './ship-bin-common';
|
||||
import { bin, carryNoneLine, firstLine, flagReader, gh, git, hostedOrigin, laneConcurrency, laneLine, lanes, Report, runLanes, shipPolicy, shipRunMarker, short } from './ship-bin-common';
|
||||
|
||||
export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Step 5's pregate and lanes in one call (ship/SKILL.md)
|
||||
|
||||
gstack-ship-preflight --base <base> [--lane <label>=<command>]... Steps 1-3; with lanes also Step 5
|
||||
--run <id> the /ship run (SESSION_ID, or the driver's run dir name): once Steps 1-3 pass it is recorded in
|
||||
this checkout's git dir and opened on its lease, and a later full preflight stops until finish
|
||||
clears it or 120 minutes pass, because its merge would move the tree the run's readers diff (GST-1204)
|
||||
gstack-ship-preflight --tests-only --base <base> --lane <label>=<command>... Step 5 only (after a repair)
|
||||
--lanes serial|parallel how Step 5 runs its lanes; unset, one at a time on 4 or fewer cores, all at once above
|
||||
--policy-from head Step 5's pregate reads the branch's .gstack/pregate.json, which may only add test lanes
|
||||
@@ -42,11 +45,35 @@ passed at this tree, command and runtime is reused: reused=<original time>), the
|
||||
decide or repair; nothing after it ran. Exit codes: 0 ok · 2 usage · 3 STOP`;
|
||||
|
||||
const DIST = /(^|\/)(cmd\/[^/]+\/main\.go|bin\/[^/]+|Cargo\.toml|setup\.py|package\.json)$/;
|
||||
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
|
||||
function integrate(cwd: string, base: string, r: Report): boolean {
|
||||
/**
|
||||
* GST-1204: a full preflight merges the base, so it stops while the /ship run that already passed Steps 1-3 on this checkout
|
||||
* is open: its marker is in the git dir (finish removes it) and its run is open on the checkout's own lease (gstack-lease
|
||||
* open: GST-1205's released, 120-minute and dead-holder rules). The run's readers diff this tree. A different --run whose
|
||||
* caller is the open run's holder (the harness behind preflight: the Claude Code session, or Capy's one capy-server) is a
|
||||
* new /ship after one that stopped before finish, so it goes on; the same id, no --run, or another live holder's run stops.
|
||||
* A --run is opened on the lease here, before the fetch, and one that cannot be is a STOP, never an unprotected run.
|
||||
*/
|
||||
function shipRunClear(cwd: string, base: string, run: string | undefined, r: Report): boolean {
|
||||
const marker = shipRunMarker(cwd);
|
||||
const open = marker && fs.existsSync(marker) ? fs.readFileSync(marker, 'utf8').trim() : '';
|
||||
const lease = (verb: string, id: string, from = false) => bin(cwd, 'gstack-lease', [verb, '--root', cwd, '--run', id, ...(from ? ['--from', String(process.ppid)] : [])], 60_000);
|
||||
if (RUN_ID.test(open) && lease('open', open).status === 0 && !(run && run !== open && lease('open', open, true).status === 0)) {
|
||||
r.stop(`/ship run ${open} is open on this checkout and past its preflight (${marker}; open until its finish or for 120 minutes): a full preflight would merge origin/${base} into the tree its review readers diff; rerun Step 5 with --tests-only, or let that /ship finish; if it is no longer running, rm ${marker}, then rerun`);
|
||||
return false;
|
||||
}
|
||||
const touch = run ? lease('touch', run, true) : undefined;
|
||||
if (!touch || touch.status === 0) return true;
|
||||
r.stop(`cannot record /ship run ${run} on this checkout's lease (${firstLine(touch.stderr) || `gstack-lease touch exit ${touch.status}`}): unrecorded, a later full preflight could merge origin/${base} under this run's reviews; fix that, then rerun`);
|
||||
return false;
|
||||
}
|
||||
|
||||
function integrate(cwd: string, base: string, run: string | undefined, r: Report): boolean {
|
||||
const branch = git(cwd, ['branch', '--show-current']).stdout.trim();
|
||||
r.add(`BRANCH: ${branch || '(detached)'} base=${base}`);
|
||||
if (!branch || branch === base) { r.stop(`on the base branch (${branch || 'detached HEAD'}): ship from a feature branch`); return false; }
|
||||
if (!shipRunClear(cwd, base, run, r)) return false;
|
||||
const status = git(cwd, ['status', '--porcelain']).stdout.split('\n').filter(Boolean);
|
||||
r.add(`STATUS: modified=${status.filter(l => !l.startsWith('??')).length} untracked=${status.filter(l => l.startsWith('??')).length} (uncommitted changes ship with this run)`);
|
||||
const fetch = git(cwd, ['fetch', 'origin', `+refs/heads/${base}:refs/remotes/origin/${base}`], 300_000);
|
||||
@@ -87,7 +114,10 @@ function integrate(cwd: string, base: string, r: Report): boolean {
|
||||
const scopeError = /^SCOPE_ERROR=(?!unmatched)(\S+)/m.exec(scope)?.[1];
|
||||
if (scopeError) r.add(`SCOPE: unknown (gstack-diff-scope origin/${base}: SCOPE_ERROR=${scopeError})`);
|
||||
else r.add(`SCOPE: frontend=${/SCOPE_FRONTEND=true/.test(scope)}${/SCOPE_FRONTEND=true/.test(scope) ? ' (Step 9 runs the design lite check; mention /design-review when no design review exists)' : ''}`);
|
||||
return merge(cwd, base, r);
|
||||
if (!merge(cwd, base, r)) return false;
|
||||
const marker = run && shipRunMarker(cwd);
|
||||
try { if (marker) fs.writeFileSync(marker, `${run}\n`); } catch (e) { r.stop(`cannot record /ship run ${run} in ${marker} (${firstLine((e as Error).message)}): the base is merged, but a later full preflight could merge again under this run's reviews; fix that, then rerun`); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The diff against origin/<base> must be about the PR's size: far more files (a stale or wrong base, HARN-15's 17,000-file review of main's history) or fewer (a fork's origin ahead of the PR's base, which would leave commits out of the gate scan) stops before any reader runs. Checked only when HEAD is the PR's pushed head. */
|
||||
@@ -228,13 +258,15 @@ export async function preflight(argv: readonly string[], cwd: string): Promise<{
|
||||
const timeout = flag('--timeout');
|
||||
if (has('--timeout') && !(timeout && /^[1-9]\d*$/.test(timeout) && Number(timeout) <= 3600)) return { text: 'gstack-ship-preflight: --timeout needs whole seconds from 1 to 3600\n', exit: 2 };
|
||||
if (!base || typeof list === 'string') return { text: `gstack-ship-preflight: ${typeof list === 'string' ? list : '--base <base> is required'}\n`, exit: 2 };
|
||||
const run = flag('--run');
|
||||
if (has('--run') && !(run && RUN_ID.test(run))) return { text: 'gstack-ship-preflight: --run needs a run id: letters, digits, dot, underscore or dash, at most 128\n', exit: 2 };
|
||||
if (has('--tests-only') && !list.length) return { text: 'gstack-ship-preflight: --tests-only needs at least one --lane <label>=<command>\n', exit: 2 };
|
||||
if (has('--simulate')) return has('--tests-only') && policyFrom === 'head' && list.length ? simulate(cwd, base, list, has('--allow-repo-commands')) : { text: 'gstack-ship-preflight: --simulate needs --tests-only --policy-from head and at least one --lane <label>=<command>\n', exit: 2 };
|
||||
const mode = flag('--lanes');
|
||||
const concurrency = has('--lanes') && mode === undefined ? '--lanes needs serial or parallel' : laneConcurrency(mode);
|
||||
if (typeof concurrency === 'string') return { text: `gstack-ship-preflight: ${concurrency}\n`, exit: 2 };
|
||||
const r = new Report();
|
||||
if (has('--tests-only') || integrate(cwd, base, r)) {
|
||||
if (has('--tests-only') || integrate(cwd, base, run, r)) {
|
||||
if (list.length) await tests(cwd, base, list, policyFrom, timeout, { concurrency, line: `LANES: ${concurrency === 1 ? 'serial' : 'parallel'} (${mode ? '--lanes' : `${os.availableParallelism()} cores`})` }, r);
|
||||
else if (r.exit === 0) r.next('Step 4: confirm the test framework, then rerun with --tests-only --lane <label>=<command> per suite (Step 5)');
|
||||
}
|
||||
|
||||
+9
-1
@@ -24,7 +24,7 @@ import { bumpVersion, cmpVersion, fmtVersion, parseVersion, versionWidth, type B
|
||||
import { waitForRun } from './ci-carry';
|
||||
import { PREPUSH_CHECK_IDS } from './pregate/index';
|
||||
import { buildRunCheck } from './pregate/run-check';
|
||||
import { bin, carryLane, carryNoneLine, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, mappedLanes, releasePaths, Report, runLanes, sh, shipPolicy, short, stampedFiles } from './ship-bin-common';
|
||||
import { bin, carryLane, carryNoneLine, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, mappedLanes, releasePaths, Report, runLanes, sh, shipPolicy, shipRunMarker, short, stampedFiles } from './ship-bin-common';
|
||||
|
||||
export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (ship/SKILL.md)
|
||||
|
||||
@@ -382,7 +382,15 @@ function docsFresh(cwd: string, base: string, postFile: string, moved: readonly
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Steps 15-16; once they pass, the /ship run's preflight marker is cleared, so a full preflight may merge the base again (GST-1204). */
|
||||
export async function finish(argv: readonly string[], cwd: string): Promise<Report> {
|
||||
const r = await finishStages(argv, cwd);
|
||||
const marker = r.exit === 0 && shipRunMarker(cwd);
|
||||
if (marker) fs.rmSync(marker, { force: true });
|
||||
return r;
|
||||
}
|
||||
|
||||
async function finishStages(argv: readonly string[], cwd: string): Promise<Report> {
|
||||
const { flag, all, has } = flagReader(argv);
|
||||
const base = flag('--base')!;
|
||||
const r = new Report();
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ Install check, this file.
|
||||
## 2. Preflight, tests, evals and reads: Steps 0.9, 1, 2, 3, 4, 5, 6, 6.5, 10
|
||||
|
||||
`gstack-ship-budget start --record <rec> --mode observe [--token-cap <n>] --reading R --credits C`, then
|
||||
`gstack-ship-preflight --base <base> --lane <label>=<command>...` (repo test commands). `CARRY: carried` ends the run on its `GSTACK_RESULT:`
|
||||
`gstack-ship-preflight --base <base> --run <run dir name> --lane <label>=<command>...` (repo test commands). `CARRY: carried` ends the run on its `GSTACK_RESULT:`
|
||||
line. `ship_lanes_from`: push early (`sections/tests.md#ci-carry`). A lane passes on `tests_ran: N` with exit 0; `0` is ZERO-RUN (failed); `unknown`
|
||||
is `count unavailable`. In-branch failures stop; after a repair rerun `--tests-only`.
|
||||
`requires-remote` needs `gstack-pregate clear` before Step 18. When prompts, templates, judges or eval
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ Install check, this file.
|
||||
## 2. Preflight, tests, evals and reads: Steps 0.9, 1, 2, 3, 4, 5, 6, 6.5, 10
|
||||
|
||||
`gstack-ship-budget start --record <rec> --mode observe [--token-cap <n>] --reading R --credits C`, then
|
||||
`gstack-ship-preflight --base <base> --lane <label>=<command>...` (repo test commands). `CARRY: carried` ends the run on its `GSTACK_RESULT:`
|
||||
`gstack-ship-preflight --base <base> --run <run dir name> --lane <label>=<command>...` (repo test commands). `CARRY: carried` ends the run on its `GSTACK_RESULT:`
|
||||
line. `ship_lanes_from`: push early (`sections/tests.md#ci-carry`). A lane passes on `tests_ran: N` with exit 0; `0` is ZERO-RUN (failed); `unknown`
|
||||
is `count unavailable`. In-branch failures stop; after a repair rerun `--tests-only`.
|
||||
`requires-remote` needs `gstack-pregate clear` before Step 18. When prompts, templates, judges or eval
|
||||
|
||||
+2
-2
@@ -324,8 +324,8 @@ repository-landing asks, including on Apple repos.
|
||||
|
||||
## Step 1: Pre-flight
|
||||
|
||||
Run `~/.claude/skills/gstack/bin/gstack-ship-preflight --base <base>`: Steps 1–3's mechanics in one call;
|
||||
act on every `STOP:` and `NEXT:` line.
|
||||
Run `~/.claude/skills/gstack/bin/gstack-ship-preflight --base <base> --run <SESSION_ID>`: Steps 1–3 at once;
|
||||
act on each `STOP:` and `NEXT:` line.
|
||||
|
||||
1. It saves the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
|
||||
2. Uncommitted changes are always included; do not ask (`STATUS:`).
|
||||
|
||||
+2
-2
@@ -130,8 +130,8 @@ repository-landing asks, including on Apple repos.
|
||||
|
||||
## Step 1: Pre-flight
|
||||
|
||||
Run `~/.claude/skills/gstack/bin/gstack-ship-preflight --base <base>`: Steps 1–3's mechanics in one call;
|
||||
act on every `STOP:` and `NEXT:` line.
|
||||
Run `~/.claude/skills/gstack/bin/gstack-ship-preflight --base <base> --run <SESSION_ID>`: Steps 1–3 at once;
|
||||
act on each `STOP:` and `NEXT:` line.
|
||||
|
||||
1. It saves the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
|
||||
2. Uncommitted changes are always included; do not ask (`STATUS:`).
|
||||
|
||||
+2
-2
@@ -325,8 +325,8 @@ repository-landing asks, including on Apple repos.
|
||||
|
||||
## Step 1: Pre-flight
|
||||
|
||||
Run `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-preflight --base <base>`: Steps 1–3's mechanics in one call;
|
||||
act on every `STOP:` and `NEXT:` line.
|
||||
Run `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-preflight --base <base> --run <SESSION_ID>`: Steps 1–3 at once;
|
||||
act on each `STOP:` and `NEXT:` line.
|
||||
|
||||
1. It saves the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
|
||||
2. Uncommitted changes are always included; do not ask (`STATUS:`).
|
||||
|
||||
+2
-2
@@ -324,8 +324,8 @@ repository-landing asks, including on Apple repos.
|
||||
|
||||
## Step 1: Pre-flight
|
||||
|
||||
Run `~/.claude/skills/gstack/bin/gstack-ship-preflight --base <base>`: Steps 1–3's mechanics in one call;
|
||||
act on every `STOP:` and `NEXT:` line.
|
||||
Run `~/.claude/skills/gstack/bin/gstack-ship-preflight --base <base> --run <SESSION_ID>`: Steps 1–3 at once;
|
||||
act on each `STOP:` and `NEXT:` line.
|
||||
|
||||
1. It saves the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
|
||||
2. Uncommitted changes are always included; do not ask (`STATUS:`).
|
||||
|
||||
+2
-2
@@ -323,8 +323,8 @@ repository-landing asks, including on Apple repos.
|
||||
|
||||
## Step 1: Pre-flight
|
||||
|
||||
Run `$GSTACK_ROOT/bin/gstack-ship-preflight --base <base>`: Steps 1–3's mechanics in one call;
|
||||
act on every `STOP:` and `NEXT:` line.
|
||||
Run `$GSTACK_ROOT/bin/gstack-ship-preflight --base <base> --run <SESSION_ID>`: Steps 1–3 at once;
|
||||
act on each `STOP:` and `NEXT:` line.
|
||||
|
||||
1. It saves the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
|
||||
2. Uncommitted changes are always included; do not ask (`STATUS:`).
|
||||
|
||||
+2
-2
@@ -325,8 +325,8 @@ repository-landing asks, including on Apple repos.
|
||||
|
||||
## Step 1: Pre-flight
|
||||
|
||||
Run `$GSTACK_ROOT/bin/gstack-ship-preflight --base <base>`: Steps 1–3's mechanics in one call;
|
||||
act on every `STOP:` and `NEXT:` line.
|
||||
Run `$GSTACK_ROOT/bin/gstack-ship-preflight --base <base> --run <SESSION_ID>`: Steps 1–3 at once;
|
||||
act on each `STOP:` and `NEXT:` line.
|
||||
|
||||
1. It saves the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
|
||||
2. Uncommitted changes are always included; do not ask (`STATUS:`).
|
||||
|
||||
@@ -768,6 +768,38 @@ describe('gstack-lease', () => {
|
||||
expect(fs.readFileSync(file, 'utf8')).not.toContain('-1-old');
|
||||
});
|
||||
|
||||
// GST-1204: gstack-ship-preflight asks whether one /ship run is still open; open answers with the same released, aged-out, dead and stale rules live uses.
|
||||
test('open --run [--pid | --from]: 0 while that run is open on a live holder; 1 once released, aged out, dead, reused or never recorded; usage is 2', () => {
|
||||
const { dir, leases } = checkout();
|
||||
const open = (pid: number | string, id: string) => run(['open', '--root', dir, '--pid', String(pid), '--run', id]).status;
|
||||
const p = sleeper();
|
||||
expect(run(['touch', '--root', dir, '--pid', String(p), '--run', `${p}-1-a`]).status).toBe(0);
|
||||
expect(run(['touch', '--root', dir, '--pid', String(p), '--run', `${p}-2-b`]).status).toBe(0);
|
||||
expect(open(p, `${p}-1-a`)).toBe(0);
|
||||
expect(run(['open', '--root', dir, '--run', `${p}-1-a`]).status).toBe(0);
|
||||
expect(run(['open', '--root', dir, '--run', `${p}-1-a`, '--from', String(process.pid)]).status).toBe(1);
|
||||
expect(run(['touch', '--root', dir, '--pid', String(process.pid), '--run', 'own-1']).status).toBe(0);
|
||||
expect(run(['open', '--root', dir, '--run', 'own-1', '--from', String(process.pid)]).status).toBe(0);
|
||||
expect(open(p, `${p}-9-z`)).toBe(1);
|
||||
expect(run(['release', '--root', dir, '--pid', String(p), '--run', `${p}-1-a`]).status).toBe(0);
|
||||
expect(open(p, `${p}-1-a`)).toBe(1);
|
||||
expect(open(p, `${p}-2-b`)).toBe(0);
|
||||
const file = path.join(leases, String(p));
|
||||
const body = fs.readFileSync(file, 'utf8');
|
||||
fs.writeFileSync(file, body.replace(/^run (\S+) \d+$/m, `run $1 ${Math.floor(Date.now() / 1000) - 121 * 60}`));
|
||||
expect(open(p, `${p}-2-b`)).toBe(1);
|
||||
fs.writeFileSync(file, body.replace(/^.*$/m, otherStart(body.split('\n')[0]!)));
|
||||
expect(open(p, `${p}-2-b`)).toBe(1);
|
||||
const dead = deadPid();
|
||||
fs.writeFileSync(path.join(leases, String(dead)), `linux 00000000-0000-0000-0000-000000000000 1\nrun ${dead}-1-a ${Math.floor(Date.now() / 1000)}\n`);
|
||||
expect(open(dead, `${dead}-1-a`)).toBe(1);
|
||||
expect(run(['open', '--root', dir, '--run', `${dead}-1-a`]).status).toBe(1);
|
||||
expect(open(424243, '424243-1-a')).toBe(1);
|
||||
expect(open('x', 'y')).toBe(2);
|
||||
expect(run(['open', '--root', dir, '--pid', String(p)]).status).toBe(2);
|
||||
expect(run(['open', '--root', dir]).status).toBe(2);
|
||||
});
|
||||
|
||||
test('gstack-skill-start reports a lease the held lock kept from being written, and still starts', () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-home-'));
|
||||
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-state-'));
|
||||
|
||||
+145
-1
@@ -4,10 +4,11 @@
|
||||
* moved into the bin; each STOP is a place the prose leaves the decision to the agent.
|
||||
*/
|
||||
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { laneConcurrency } from '../lib/ship-bin-common';
|
||||
import { lineOf, PREFLIGHT, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
|
||||
import { lineOf, PREFLIGHT, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
|
||||
|
||||
setDefaultTimeout(60_000);
|
||||
const fixtures: ShipFx[] = [];
|
||||
@@ -397,3 +398,146 @@ describe('gstack-ship-preflight Step 5 pregate: head policy and timeout_s (GST-4
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-ship-preflight during an open /ship run (GST-1204)', () => {
|
||||
const CHECKOUT = { GSTACK_LEASE_DIR: '' };
|
||||
const leaseFile = (f: ShipFx) => path.join(f.work, '.git', 'gstack-leases', String(process.pid));
|
||||
const wtree = (f: ShipFx) => f.run(path.join(path.dirname(RELEASE), 'gstack-wtree'), [], CHECKOUT).stdout.trim();
|
||||
/** Steps 12-16 in the fixture: the version bump, the docs snapshot and finish (the step that ends the run's hold on the base merge). */
|
||||
const finish = (f: ShipFx) => {
|
||||
const reviewed = wtree(f);
|
||||
expect(f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro'], CHECKOUT).status).toBe(0);
|
||||
const post = path.join(f.root, 'post.json');
|
||||
expect(f.run(path.join(path.dirname(RELEASE), 'gstack-docs-candidate'), ['snapshot', '--out', post, '--audit-id', 'a1', '--mode', 'edit', '--base', 'main', '--docs', 'README.md'], CHECKOUT).status).toBe(0);
|
||||
const done = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE], CHECKOUT);
|
||||
expect(done.status, done.stdout).toBe(0);
|
||||
};
|
||||
const refusedUntouched = (f: ShipFx, args: string[], run: string, added = 'docs/new.md') => {
|
||||
const head = f.git('rev-parse', 'HEAD').stdout.trim();
|
||||
const tracked = f.git('rev-parse', 'origin/main').stdout.trim();
|
||||
const r = f.run(PREFLIGHT, args, CHECKOUT);
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stdout).toContain(`STOP: /ship run ${run} is open on this checkout and past its preflight (`);
|
||||
expect(r.stdout).toContain('rerun Step 5 with --tests-only');
|
||||
expect(r.stdout).toMatch(/if it is no longer running, rm \S+gstack-ship-run, then rerun/);
|
||||
expect(r.stdout).not.toContain('MERGE:');
|
||||
expect(f.git('rev-parse', 'HEAD').stdout.trim()).toBe(head);
|
||||
expect(f.git('rev-parse', 'origin/main').stdout.trim()).toBe(tracked);
|
||||
expect(f.git('status', '--porcelain').stdout).toBe('');
|
||||
expect(fs.existsSync(path.join(f.work, added))).toBe(false);
|
||||
};
|
||||
|
||||
// Value: protects=a full preflight run again while this checkout's /ship run is open and past its own preflight stops before the fetch, so review readers never diff a tree the base merge moved;
|
||||
// fails_when=the rerun fetches or merges origin/<base> into the tree readers are diffing (gbrain-evals#317 voided a review pass), with or without --run; why_new=GST-1204;
|
||||
// seam=the run marker and gstack-lease open on the checkout's own lease; no_claim=a preflight that never passed --run
|
||||
test('a full preflight while this checkout\'s /ship run is open and past its preflight refuses before the fetch and leaves the tree untouched', () => {
|
||||
const f = fx();
|
||||
const run = `${process.pid}-${Math.floor(Date.now() / 1000)}-ab12cd34`;
|
||||
expect(f.run(PREFLIGHT, ['--base', 'main', '--run', run], CHECKOUT).status).toBe(0);
|
||||
f.advanceBase({ 'docs/new.md': '# new\n' });
|
||||
refusedUntouched(f, ['--base', 'main', '--run', run], run);
|
||||
refusedUntouched(f, ['--base', 'main'], run);
|
||||
});
|
||||
|
||||
// Value: protects=the Capy driver's sequence (ship/DRIVER.md Phase 2: preflight with --run and lanes, then --audit; repairs rerun --tests-only) is covered, and a mid-run full preflight is refused;
|
||||
// fails_when=the guard needs a workflow lease the driver never takes (GBEV-3 was a Capy lane), or it blocks --audit or --tests-only; why_new=GST-1204 driver coverage;
|
||||
// seam=the checkout lease gstack-ship-preflight opens itself; no_claim=reader dispatch
|
||||
test('the driver\'s sequence: Phase 2 preflight with --run, --audit and --tests-only go on; a mid-run full preflight is refused', () => {
|
||||
const f = fx();
|
||||
const phase2 = f.run(PREFLIGHT, ['--base', 'main', '--run', 'ship-run-7', '--lane', LANE], CHECKOUT);
|
||||
expect(phase2.status).toBe(0);
|
||||
expect(lineOf(phase2.stdout, 'LANE')).toMatch(/^LANE: tests pass/);
|
||||
expect(f.run(PREFLIGHT, ['--audit', '--base', 'main', '--out', path.join(f.root, 'run')], CHECKOUT).status).toBe(0);
|
||||
f.advanceBase({ 'docs/new.md': '# new\n' });
|
||||
refusedUntouched(f, ['--base', 'main', '--lane', LANE], 'ship-run-7');
|
||||
refusedUntouched(f, ['--base', 'main', '--run', 'ship-run-7', '--lane', LANE], 'ship-run-7');
|
||||
const repair = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--lane', LANE], CHECKOUT);
|
||||
expect(repair.status).toBe(0);
|
||||
expect(lineOf(repair.stdout, 'LANE')).toMatch(/^LANE: tests pass exit=0 tests_ran=3/);
|
||||
});
|
||||
|
||||
// Value: protects=finish ends the run's hold, and a run past the 120-minute window (GST-1205's fresh rule) no longer blocks the next preflight;
|
||||
// fails_when=a marker left by an earlier /ship refuses every later preflight on the checkout; why_new=GST-1204 negative control; seam=finish clears the marker, gstack-lease fresh; no_claim=dead or reused holders, covered in test/gstack-lease.test.ts
|
||||
test('a finished or aged-out /ship run does not block a full preflight', () => {
|
||||
const ends: Record<string, (f: ShipFx) => void> = {
|
||||
finished: finish,
|
||||
'aged out': f => fs.writeFileSync(leaseFile(f), fs.readFileSync(leaseFile(f), 'utf8').replace(/^(run \S+ )(\d+)$/m, (_, p, t) => `${p}${Number(t) - 7300}`)),
|
||||
};
|
||||
for (const [why, end] of Object.entries(ends)) {
|
||||
const f = fx();
|
||||
expect(f.run(PREFLIGHT, ['--base', 'main', '--run', 'ship-run-7'], CHECKOUT).status).toBe(0);
|
||||
end(f);
|
||||
f.advanceBase({ 'docs/new.md': '# new\n' });
|
||||
const r = f.run(PREFLIGHT, ['--base', 'main'], CHECKOUT);
|
||||
expect({ why, status: r.status }).toEqual({ why, status: 0 });
|
||||
expect(lineOf(r.stdout, 'MERGE')).toMatch(/^MERGE: merged origin\/main at /);
|
||||
}
|
||||
});
|
||||
|
||||
// Value: protects=a /ship that stopped before finish does not block the next /ship from the same harness: gstack-skill-end's real release closes only the install lease, so the
|
||||
// new run id replaces the open one; another live holder's open run (another session) is still refused; fails_when=a stopped run blocks its own harness for 120 minutes
|
||||
// (#3950 review), or any new id passes another session's open run; why_new=GST-1204 review fix 1; seam=gstack-lease open --from (the caller's harness); no_claim=Capy, where every session shares capy-server
|
||||
test('a stopped /ship (skill-end released it) does not block a new run id from the same holder; another live holder\'s run is still refused', () => {
|
||||
const f = fx();
|
||||
const stopped = `${process.pid}-${Math.floor(Date.now() / 1000)}-ab12cd34`;
|
||||
expect(f.run(PREFLIGHT, ['--base', 'main', '--run', stopped], CHECKOUT).status).toBe(0);
|
||||
const end = f.run(path.join(path.dirname(PREFLIGHT), 'gstack-skill-end'), ['--skill', 'ship', '--outcome', 'error', '--session-id', stopped, '--tel-start', '0'], { GSTACK_LEASE_DIR: path.join(f.root, 'install-leases') });
|
||||
expect(end.status, end.stdout + end.stderr).toBe(0);
|
||||
f.advanceBase({ 'docs/new.md': '# new\n' });
|
||||
refusedUntouched(f, ['--base', 'main', '--run', stopped], stopped);
|
||||
const next = f.run(PREFLIGHT, ['--base', 'main', '--run', 'ship-run-8'], CHECKOUT);
|
||||
expect(next.status, next.stdout).toBe(0);
|
||||
expect(lineOf(next.stdout, 'MERGE')).toMatch(/^MERGE: merged origin\/main at /);
|
||||
const other = spawn('sleep', ['300'], { stdio: 'ignore' });
|
||||
try {
|
||||
const touch = spawnSync('bash', [path.join(path.dirname(PREFLIGHT), 'gstack-lease'), 'touch', '--root', f.work, '--pid', String(other.pid), '--run', 'other-session'], { encoding: 'utf8', env: { ...process.env, ...CHECKOUT }, timeout: 30_000 });
|
||||
expect(touch.status).toBe(0);
|
||||
fs.writeFileSync(path.join(f.work, '.git', 'gstack-ship-run'), 'other-session\n');
|
||||
f.advanceBase({ 'docs/other.md': '# other\n' });
|
||||
refusedUntouched(f, ['--base', 'main', '--run', 'ship-run-9'], 'other-session', 'docs/other.md');
|
||||
} finally { other.kill('SIGKILL'); }
|
||||
});
|
||||
|
||||
// Value: protects=a --run that cannot be recorded on the lease (a live process holds its lock) stops before the fetch instead of going on unprotected; fails_when=preflight exits 0
|
||||
// with no record, so a later full preflight merges the base under the run's reviews (#3950 outside voice); why_new=GST-1204 review fix 2; seam=gstack-lease touch exit 3; no_claim=which process holds the lock
|
||||
test('a --run the lease cannot record stops before the fetch', () => {
|
||||
const f = fx();
|
||||
const lock = path.join(f.work, '.git', 'gstack-leases', '.lock');
|
||||
fs.mkdirSync(lock, { recursive: true });
|
||||
fs.writeFileSync(path.join(lock, 'owner'), `${process.pid} \n`);
|
||||
f.advanceBase({ 'docs/new.md': '# new\n' });
|
||||
const tracked = f.git('rev-parse', 'origin/main').stdout.trim();
|
||||
const r = f.run(PREFLIGHT, ['--base', 'main', '--run', 'ship-run-7'], { ...CHECKOUT, GSTACK_LEASE_LOCK_TRIES: '2' });
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stdout).toContain('STOP: cannot record /ship run ship-run-7 on this checkout\'s lease (gstack-lease: the lease directory is locked by live process');
|
||||
expect(r.stdout).not.toContain('MERGE:');
|
||||
expect(f.git('rev-parse', 'origin/main').stdout.trim()).toBe(tracked);
|
||||
expect(fs.existsSync(path.join(f.work, '.git', 'gstack-ship-run'))).toBe(false);
|
||||
});
|
||||
|
||||
// Value: protects=/ship's own first preflight runs, and a first preflight that stopped (Step 3's conflict: commit and rerun) reruns in the same /ship run;
|
||||
// fails_when=the run is recorded before Steps 1-3 pass, so /ship blocks itself; why_new=GST-1204 negative control; seam=the marker is written only once Steps 1-3 pass; no_claim=conflict resolution
|
||||
test('/ship\'s own first preflight runs, and a stopped first preflight reruns in the same run', () => {
|
||||
const f = fx({}, { 'src/a.ts': 'export const a = 2;\n' });
|
||||
f.advanceBase({ 'src/a.ts': 'export const a = 3;\n' });
|
||||
const first = f.run(PREFLIGHT, ['--base', 'main', '--run', 'ship-run-7'], CHECKOUT);
|
||||
expect(first.status).toBe(3);
|
||||
expect(first.stdout).toContain('STOP: merge conflicts in src/a.ts');
|
||||
f.write({ 'src/a.ts': 'export const a = 4;\n' });
|
||||
f.commit('merge main');
|
||||
const again = f.run(PREFLIGHT, ['--base', 'main', '--run', 'ship-run-7'], CHECKOUT);
|
||||
expect(again.status).toBe(0);
|
||||
expect(lineOf(again.stdout, 'MERGE')).toBe('MERGE: up to date');
|
||||
});
|
||||
|
||||
// Value: protects=--run takes only a plain token, so a crafted value cannot become a path or split a lease entry; fails_when=any string is recorded and looked up;
|
||||
// why_new=GST-1204; seam=flag parsing; no_claim=none
|
||||
test('--run that is not a plain run id is usage', () => {
|
||||
const f = fx();
|
||||
for (const bad of [['--run', '../x'], ['--run', 'a b'], ['--run', '-x'], ['--run']]) {
|
||||
const r = f.run(PREFLIGHT, ['--base', 'main', ...bad]);
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('--run needs a run id');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": 2,
|
||||
"why": "GST-1204: Step 1 passes --run <SESSION_ID> so a full preflight rerun cannot merge the base under an open run's reviews"
|
||||
}
|
||||
Reference in new issue
Block a user