mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
Merge origin/main (v1.91.105.0) into capy/next-wave; restamp 1.91.106.0
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
commit
e9182b1289
20 files changed
+1779
-1116
No files matched your search
+46
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.102.0] - 2026-10-14
|
||||
## [1.91.106.0] - 2026-10-16
|
||||
|
||||
**The GBRA-40 field list is fixed: install and check agree on one Bun, `rec` approves an autoplan gate in one word, and pregate never sits silent.** Eighteen Capy threads upgraded to v1.91.101.0 and reported 21 problems (`GBRA-40`, October 14). Each gstack item was reproduced with a test that failed first; the plan went through `/autoplan` (two gate revisions, Claude and GPT-6.1 Sol voices per phase) and two independent code reviews. Two items are Capy's (skills not mounted on some crew threads, Drive copies not re-synced) and are handed off, not worked around.
|
||||
|
||||
@@ -27,6 +27,51 @@
|
||||
- New tests: `test/bun-resolve.test.ts`, `test/autoplan-checkpoint.test.ts`, `test/autoplan-pairing.test.ts` (with this run's real pairs as fixtures), `test/pregate-supervise.test.ts`, `test/pregate-remote.test.ts`; many existing suites gained regressions.
|
||||
- The ship parity union cap is 1.6488 (measured), and the Capy skill's eager budget is 2601 tokens for its new autoplan checklist.
|
||||
|
||||
## [1.91.105.0] - 2026-10-16
|
||||
|
||||
**The free suite stops needing its retry pass.
|
||||
The health report tells the truth.**
|
||||
|
||||
Six of 46 main runs last week needed the free suite's serial retry pass to go green. Five test files caused it, and three shared one cause: since git 2.29, every `git commit` starts a background `git maintenance run` that creates and deletes `.git/objects/maintenance.lock` after the commit has returned, so a test that lists, watches or deletes `.git` right afterwards sees a file that is there one moment and gone the next. Those fixtures now turn auto-maintenance off, and the free runner does it for every test child as a backstop. The other two flakes were a fixed-count poll and a smoke test that called live GitHub. `gstack-timeline-read` now refuses filters it cannot apply instead of returning everything.
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
Measured with the repo's own reproducer (`scripts/ship-measure.ts free`, flaky retry off), before on 47d639a and after on this branch. Commands and per-file counts are in the PR body.
|
||||
|
||||
| Measurement | Before | After |
|
||||
|---|---|---|
|
||||
| The five ledger files, 16-vCPU Ubicloud VM (`bun run test:ubicloud --diagnostic`) | 197/200 reruns clean | 600/600 |
|
||||
| The three git fixtures under commit load, local runner env | 37/200 clean | 200/200 |
|
||||
| Next-version smoke test, live GitHub calls | one `gh api` per open PR, one after another (27 PRs at 1.2 s: 32.6 s, past its 30 s limit) | none (runs in about 60 ms) |
|
||||
| Weekly health: unseeded free test files | 164 (enforced check failing) | 0 |
|
||||
| Weekly health: Windows wall population | dispatch recordings on main ("n/a (0 runs)" on Oct 12) | 78 successful PR runs, 9.2 min p50 (7 days to 2026-10-15) |
|
||||
|
||||
The headline number is the first row: three of 200 clean-tree reruns failed before, none of 600 after.
|
||||
|
||||
### What this means for you
|
||||
|
||||
When the free suite goes red now, it is far more likely to be your change. Read the failure, not the retry ledger.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
#### Fixed
|
||||
|
||||
- **If you run `gstack-timeline-read --since` or `--branch` without a value,** you get exit 2 and `--since must be a relative time like "7 days ago" (got no value); accepted: ...` (or `--branch must be a branch name (got flag '--limit')`) instead of a crash.
|
||||
- **If you pass `--since yesterday` or `--since "2 weeks"`,** you get exit 2 naming what it got, instead of your whole unfiltered history. Accepted forms: `7 days ago` (minute, hour, day, week, month), `7d` / `24h` / `2w`, and ISO dates `2026-10-01` or `2026-10-01T10:00:00Z`.
|
||||
- **If you pass an argument `gstack-timeline-read` does not know,** it exits 2 with the usage line instead of ignoring it. `--help` prints the usage line.
|
||||
|
||||
#### Changed
|
||||
|
||||
- **If you read the weekly test-health report,** "Free-suite and Windows wall" now measures successful Windows runs on pull requests, says so (`windows-free-tests (successful PR runs) 9.2 min (78 runs)`), and prints `no successful PR runs in window` when there are none. It used to average the dispatch-only duration recordings on main.
|
||||
- **If the health report says the duration seed is stale,** its fix line now names what the refresh needs (`UBICLOUD_API_KEY`, then `bun run test:ubicloud --record-durations`, which copies the seed back into your checkout) and tells contributors without the key to leave it to a maintainer. It no longer offers a CI recording, which does not exist.
|
||||
|
||||
#### For contributors
|
||||
|
||||
- The free runner appends `maintenance.auto=false` and `gc.auto=0` to every shard child's `GIT_CONFIG_*` pairs (`test/test-free-shards-git-maintenance.test.ts`). Fixtures that build their own git environment set the keys themselves; `docs/TESTING_INTERNALS.md#flake-ledger` has the rule and a recipe for reproducing a ledger entry from a CI run's `free-plan` artifact.
|
||||
- The zombie publication-lock test synchronizes on a PID line its Python parent prints after `os.waitid(..., WNOWAIT)`, and the next-version smoke test runs the real CLI against a fake `gh` that logs and checks every call.
|
||||
- `scripts/free-test-durations.json` is refreshed from a Ubicloud recording of this tree.
|
||||
- No community contributions in this release.
|
||||
|
||||
## [1.91.101.0] - 2026-10-13
|
||||
|
||||
**The field wave's residual gaps are closed.** v1.91.96.0 through v1.91.100.0 shipped the install-honesty and unattended-`/autoplan` wave (`docs/designs/GSTACK_FIELD_WAVE_2026_10_12.md`); running that release on a Capy machine left six gaps, each reproduced here with a test that failed first.
|
||||
|
||||
@@ -88,9 +88,6 @@ Left open by the office-hours tier fix (#2801, #879, #1049, #1958, #1723, #1651
|
||||
follows the carried `SESSION_TIER` through Phase 6 (first run gets the
|
||||
introduction, second run asks about the earlier assignment). No office-hours
|
||||
quality judge exists either. **Effort:** M. **Priority:** P2.
|
||||
- **`gstack-timeline-read --since`/`--branch` without a value** — both still
|
||||
read an unbound `$2` under `set -u` and crash with exit 1, the class #1723
|
||||
fixed for `--limit`. **Effort:** S. **Priority:** P3.
|
||||
|
||||
### P3: Oct 8 fix wave, safety hooks follow-ups (filed 2026-10-08)
|
||||
|
||||
@@ -5394,3 +5391,65 @@ skills were told is fenced.
|
||||
Guard both writes. Effort S.
|
||||
|
||||
**Priority:** P3. **Depends on:** none.
|
||||
|
||||
### P1–P2: fix wave 2 follow-ups (filed 2026-10-15)
|
||||
|
||||
From the free-suite health wave (flaky-pass fixes, #3154 seed refresh). Owner
|
||||
Garry for each; revisit 2026-10-29 unless a date says otherwise.
|
||||
|
||||
- **`gstack-next-version` fetches claimed slots one PR at a time** —
|
||||
`GH_API_CONCURRENCY = 10` (bin/gstack-next-version:90), but its async workers
|
||||
call `spawnSync`, so the per-PR `gh api` calls run serially: 8 stubbed PRs at
|
||||
0.5 s each took 4160 ms instead of about 500 ms (4108 ms on a rerun), and 27
|
||||
open PRs at about 1.2 s each pass the 30 s that callers allow, which is how the
|
||||
old live smoke test failed. Every `/ship` slot allocation pays this. Fix:
|
||||
async spawn with bounded overlap, verified by a delayed fake `gh`. Changing the
|
||||
CLI selects 108 paid cases and 24 judges, so it ships as its own priced PR on
|
||||
Garry's call. **Effort:** S. **Priority:** P1.
|
||||
- **Standing Linux duration-seed recorder** — the seed refresh in this wave
|
||||
keeps CEO-10 green for days, not weeks: main gains about 15 free test files a
|
||||
day against a limit of 5 unseeded, so the weekly health run goes red again
|
||||
until a recorder lands. Design: a scheduled `free-durations-record.yml` that
|
||||
records on the standard runner and updates one seed PR before the Monday
|
||||
health run, or the `free-plan` job consuming the latest recording artifact once
|
||||
#3190 (owner of `free-tests.yml`) lands. Agree the design with the owner of
|
||||
`free-tests.yml` before 2026-10-29. **Effort:** M. **Priority:** P1.
|
||||
- **PR full-fallback and recorded spend (audit next step)** — 2026-10-15:
|
||||
full-fallback 45.9% of PR runs, $73.52 recorded PR-lane spend per merged PR,
|
||||
push-to-verdict p50 11.4 min (target 3–5 min). The pre-change spend baseline
|
||||
was never recorded, so the 50% reduction verdict is unavailable
|
||||
(docs/test-audit-2026-10.md, "Post-merge follow-up"). Next step, from the
|
||||
audit's selection replay: touchfile mappings for `bin/`, `lib/`, `hosts/` and
|
||||
`make-pdf/`, so a branch touching them stops selecting every case. Candidate
|
||||
headline for the next wave. **Effort:** M. **Priority:** P1.
|
||||
- **Free flaky-pass target follow-ups (2026-10-22 and 2026-10-29)** — read
|
||||
main's `flake-ledger-*` artifacts (`bun run test:health --since-days 7`) and
|
||||
check the free-suite flaky-pass rate is under 5% of main runs; a file still in
|
||||
the ledger gets a named cause or a new entry here. **Effort:** S. **Priority:** P1.
|
||||
- **`createQAFunctionalFixture` sets git auto-maintenance off itself** — its
|
||||
repos inherit the free runner's backstop keys in CI, but a direct `bun test`
|
||||
run still races `.git/objects/maintenance.lock` during observer setup.
|
||||
The helper selects 22 paid cases, so this is a priced follow-up.
|
||||
**Effort:** S. **Priority:** P2.
|
||||
- **`test:ubicloud --diagnostic` cannot give clean counts for two ledger
|
||||
shards** — `ship-measure free` reruns set a private `HOME` under `/tmp` and
|
||||
`GSTACK_STATE_ROOT`, so `browse/test/path-validation.test.ts` (its upload
|
||||
fixture builds an "outside" directory under `os.userInfo().homedir`, which
|
||||
Bun takes from `HOME`) and `browse/test/pair-agent-tunnel-eval.test.ts` (its
|
||||
denial log follows the inherited state root, not its fake `HOME`) fail 30/30
|
||||
in diagnostic mode while passing in the normal suite. Ledger shards that hold
|
||||
them can never report `passed = n`. **Effort:** S. **Priority:** P2.
|
||||
- **`test/plan-seed-submission.test.ts` reads its fake CLI's events after a
|
||||
fixed 50 ms sleep** — "actual PTY launcher carries placeholder styling into
|
||||
owned seed submission: xterm-256color" sends `/plan-eng-review\r`, sleeps
|
||||
50 ms and expects the `slash` event already in `events.jsonl`; under the
|
||||
16-worker duration recording on 2026-10-15 the event had not landed
|
||||
(`['paste', 'enter', 'end_turn']`). Wait for the CLI's own write instead of a
|
||||
timer (docs/TESTING_INTERNALS.md#flake-ledger). **Effort:** S. **Priority:** P2.
|
||||
- **Split the free files the 2026-10-15 seed put over 60 s** —
|
||||
`test/setup-install-registry.test.ts` (82 s), `test/host-renders-prune.test.ts`
|
||||
(64 s) and `test/setup-codex-scope.test.ts` (60.4 s) are allowlisted with this
|
||||
todo in `scripts/free-test-seed-allowlist.json`; `test/brain-sync.test.ts`
|
||||
(57 s) and `test/setup-codex-scope-namespaces.test.ts` (57 s) are next. Split
|
||||
each by describe block, rerun `bun run test:ubicloud --record-durations` and
|
||||
delete the entries. **Effort:** S each. **Priority:** P2.
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.102.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.106.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,
|
||||
|
||||
+69
-20
@@ -1,26 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-timeline-read — read and format project timeline
|
||||
# Usage: gstack-timeline-read [--since "7 days ago"] [--limit N] [--branch NAME]
|
||||
# Usage: gstack-timeline-read [--since "7 days ago"|7d|YYYY-MM-DD] [--limit N] [--branch NAME]
|
||||
#
|
||||
# Session timeline: local-only, never sent anywhere.
|
||||
# Reads ~/.gstack/projects/$SLUG/timeline.jsonl, filters, formats.
|
||||
# Exit 0 silently if no timeline file exists or nothing matches. Missing bun
|
||||
# exits 127 and a failed read exits non-zero with its stderr (#2790). A missing
|
||||
# or non-positive-integer --limit exits 2 before any read (#1723).
|
||||
# or non-positive-integer --limit, a missing or unparseable --since, a missing
|
||||
# --branch, or an unknown argument exits 2 before any read (#1723); --help
|
||||
# prints the usage line and exits 0.
|
||||
set -euo pipefail
|
||||
USAGE='Usage: gstack-timeline-read [--since "7 days ago"|7d|YYYY-MM-DD] [--limit N] [--branch NAME]'
|
||||
SINCE_FORMS='accepted: "<N> minute|hour|day|week|month[s] ago", "<N>h|d|w", or an ISO date YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ'
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
|
||||
gstack_state_root_select
|
||||
GSTACK_HOME="$_gstack_sr_root"
|
||||
|
||||
SINCE=""
|
||||
SINCE_MS=0
|
||||
LIMIT=20
|
||||
BRANCH=""
|
||||
|
||||
missing_value() { # remaining-arg-count next-arg -> why the flag has no usable value, or nothing
|
||||
if [[ $1 -lt 2 ]]; then echo "got no value"
|
||||
elif [[ -z "$2" ]]; then echo "got an empty value"
|
||||
elif [[ "$2" == --* ]]; then echo "got flag '$2'"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
days_from_civil() { # year month day (UTC) -> days since 1970-01-01
|
||||
local y=$((10#$1)) m=$((10#$2)) d=$((10#$3))
|
||||
[[ $m -le 2 ]] && y=$((y - 1))
|
||||
local era=$((y / 400)) yoe doy mp
|
||||
yoe=$((y - era * 400)); mp=$(((m + 9) % 12)); doy=$(((153 * mp + 2) / 5 + d - 1))
|
||||
echo $((era * 146097 + yoe * 365 + yoe / 4 - yoe / 100 + doy - 719468))
|
||||
}
|
||||
|
||||
since_cutoff_ms() { # one --since value -> cutoff epoch ms, or nothing when unparseable
|
||||
local now n unit seconds lower
|
||||
now=$(date -u +%s)
|
||||
lower=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$lower" =~ ^([1-9][0-9]{0,5})[[:space:]]*(minute|hour|day|week|month)s?[[:space:]]+ago$ ]]; then
|
||||
n=${BASH_REMATCH[1]}; unit=${BASH_REMATCH[2]}
|
||||
elif [[ "$lower" =~ ^([1-9][0-9]{0,5})([hdw])$ ]]; then
|
||||
n=${BASH_REMATCH[1]}
|
||||
case ${BASH_REMATCH[2]} in h) unit=hour ;; d) unit=day ;; w) unit=week ;; esac
|
||||
elif [[ "$1" =~ ^([0-9]{4})-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])(T([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])Z)?$ ]]; then
|
||||
seconds=$(( $(days_from_civil "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}") * 86400 ))
|
||||
[[ -n "${BASH_REMATCH[4]}" ]] && seconds=$((seconds + 10#${BASH_REMATCH[5]} * 3600 + 10#${BASH_REMATCH[6]} * 60 + 10#${BASH_REMATCH[7]}))
|
||||
echo $((seconds * 1000)); return 0
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
case $unit in minute) seconds=60 ;; hour) seconds=3600 ;; day) seconds=86400 ;; week) seconds=604800 ;; month) seconds=2592000 ;; esac
|
||||
echo $(((now - n * seconds) * 1000))
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--since) SINCE="$2"; shift 2 ;;
|
||||
--help|-h) echo "$USAGE"; exit 0 ;;
|
||||
--since)
|
||||
problem=$(missing_value $# "${2-}")
|
||||
if [[ -n "$problem" ]]; then
|
||||
echo "gstack-timeline-read: --since must be a relative time like \"7 days ago\" ($problem); $SINCE_FORMS" >&2
|
||||
exit 2
|
||||
fi
|
||||
SINCE_MS=$(since_cutoff_ms "$2")
|
||||
if [[ -z "$SINCE_MS" ]]; then
|
||||
echo "gstack-timeline-read: --since must be a relative time like \"7 days ago\" (got '$2'); $SINCE_FORMS" >&2
|
||||
exit 2
|
||||
fi
|
||||
shift 2 ;;
|
||||
--limit)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "gstack-timeline-read: --limit must be a positive integer (got no value); example: --limit 20" >&2
|
||||
@@ -31,8 +83,17 @@ while [[ $# -gt 0 ]]; do
|
||||
exit 2
|
||||
fi
|
||||
LIMIT="$2"; shift 2 ;;
|
||||
--branch) BRANCH="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
--branch)
|
||||
problem=$(missing_value $# "${2-}")
|
||||
if [[ -n "$problem" ]]; then
|
||||
echo "gstack-timeline-read: --branch must be a branch name ($problem)" >&2
|
||||
exit 2
|
||||
fi
|
||||
BRANCH="$2"; shift 2 ;;
|
||||
*)
|
||||
echo "gstack-timeline-read: unknown argument '$1'" >&2
|
||||
echo "$USAGE" >&2
|
||||
exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -47,23 +108,11 @@ command -v bun >/dev/null 2>&1 || {
|
||||
exit 127
|
||||
}
|
||||
|
||||
cat "$TIMELINE_FILE" | GSTACK_TIMELINE_SINCE="$SINCE" GSTACK_TIMELINE_BRANCH="$BRANCH" GSTACK_TIMELINE_LIMIT="$LIMIT" bun -e "
|
||||
cat "$TIMELINE_FILE" | GSTACK_TIMELINE_SINCE_MS="$SINCE_MS" GSTACK_TIMELINE_BRANCH="$BRANCH" GSTACK_TIMELINE_LIMIT="$LIMIT" bun -e "
|
||||
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
|
||||
const since = process.env.GSTACK_TIMELINE_SINCE || '';
|
||||
const branch = process.env.GSTACK_TIMELINE_BRANCH || '';
|
||||
const limit = Number(process.env.GSTACK_TIMELINE_LIMIT);
|
||||
|
||||
let sinceMs = 0;
|
||||
if (since) {
|
||||
// Parse relative time like '7 days ago'
|
||||
const match = since.match(/(\d+)\s*(day|hour|minute|week|month)s?\s*ago/i);
|
||||
if (match) {
|
||||
const n = parseInt(match[1]);
|
||||
const unit = match[2].toLowerCase();
|
||||
const ms = { minute: 60000, hour: 3600000, day: 86400000, week: 604800000, month: 2592000000 };
|
||||
sinceMs = Date.now() - n * (ms[unit] || 86400000);
|
||||
}
|
||||
}
|
||||
const sinceMs = Number(process.env.GSTACK_TIMELINE_SINCE_MS);
|
||||
|
||||
const entries = [];
|
||||
for (const line of lines) {
|
||||
|
||||
@@ -6,7 +6,8 @@ import { spawnSync } from 'node:child_process';
|
||||
import { acquireAgentStateLock, agentRecordPath, readAgentStartTime, writeAgentRecord, type AgentRecord } from '../src/terminal-agent-control';
|
||||
|
||||
const roots: string[] = [];
|
||||
const children: ReturnType<typeof Bun.spawn>[] = [];
|
||||
const children: Bun.Subprocess[] = [];
|
||||
let unfinished: (() => Promise<Error>) | undefined;
|
||||
const directory = () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'publication-lock-'));
|
||||
roots.push(root);
|
||||
@@ -31,6 +32,8 @@ async function fixture(dead = true) {
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (unfinished) console.error((await unfinished()).message);
|
||||
unfinished = undefined;
|
||||
for (const child of children.splice(0)) {
|
||||
if (child.exitCode === null) try { child.kill('SIGKILL'); } catch {}
|
||||
await child.exited;
|
||||
@@ -51,25 +54,36 @@ describe('owned terminal-agent publication lock recovery', () => {
|
||||
|
||||
test.skipIf(process.platform !== 'linux')('a dead zombie with the exact recorded birth cannot retain its publication lock', async () => {
|
||||
const root = directory();
|
||||
const ready = path.join(root, 'zombie-pid');
|
||||
const python = [
|
||||
'import os,time',
|
||||
'pid=os.fork()',
|
||||
'if pid==0: os._exit(0)',
|
||||
`with open(${JSON.stringify(ready)},'w') as f: f.write(str(pid))`,
|
||||
'os.waitid(os.P_PID,pid,os.WEXITED|os.WNOWAIT)',
|
||||
'print(pid,flush=True)',
|
||||
'time.sleep(30)',
|
||||
].join('\n');
|
||||
const parent = Bun.spawn(['python3', '-c', python], { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||
const parent = Bun.spawn(['python3', '-c', python], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
children.push(parent);
|
||||
for (let n = 0; n < 300 && !fs.existsSync(ready); n++) await Bun.sleep(10);
|
||||
expect(fs.existsSync(ready)).toBe(true);
|
||||
const pid = Number(fs.readFileSync(ready, 'utf8'));
|
||||
let state = '';
|
||||
for (let n = 0; n < 300; n++) {
|
||||
state = fs.readFileSync(`/proc/${pid}/stat`, 'utf8').match(/^\d+ \(.*\) ([A-Z])/u)?.[1] || '';
|
||||
if (state === 'Z') break;
|
||||
await Bun.sleep(10);
|
||||
const failure = async (reason: string) => {
|
||||
if (parent.exitCode === null) parent.kill('SIGKILL');
|
||||
const status = await parent.exited;
|
||||
const stderr = await new Response(parent.stderr).text();
|
||||
return new Error(`${reason}; zombie parent exit status ${status}; stderr tail: ${stderr.slice(-500) || '(empty)'}`);
|
||||
};
|
||||
unfinished = () => failure('zombie parent never reported its exited child');
|
||||
const reader = parent.stdout.getReader();
|
||||
let line = '';
|
||||
while (!line.includes('\n')) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) throw await failure(`zombie parent closed stdout before a full PID line (got ${JSON.stringify(line)})`);
|
||||
line += new TextDecoder().decode(chunk.value);
|
||||
}
|
||||
reader.releaseLock();
|
||||
unfinished = undefined;
|
||||
const reported = line.slice(0, line.indexOf('\n'));
|
||||
if (!/^[1-9]\d*$/.test(reported)) throw await failure(`zombie parent reported a malformed PID ${JSON.stringify(reported)}`);
|
||||
const pid = Number(reported);
|
||||
const state = fs.readFileSync(`/proc/${pid}/stat`, 'utf8').match(/^\d+ \(.*\) ([A-Z])/u)?.[1] || '';
|
||||
expect(state).toBe('Z');
|
||||
expect(() => process.kill(pid, 0)).not.toThrow();
|
||||
const record: AgentRecord = { pid, gen: 'zombie-generation', startedAt: Date.now(),
|
||||
|
||||
@@ -1133,15 +1133,44 @@ Free-suite flake rules:
|
||||
daemon without `CI=true` launches Chromium sandboxed.
|
||||
- Handshakes with a runner wait for the runner's own observable effect (its
|
||||
progress line, a coalesced read count), not for a fixture side file or a timer.
|
||||
- Fixtures that commit and then list, watch or remove a repository's `.git`
|
||||
turn git auto-maintenance off themselves (`maintenance.auto=false` and
|
||||
`gc.auto=0` in the repo config, or in the explicit git env they build). Since
|
||||
git 2.29, `git commit` starts a detached `git maintenance run --auto` that
|
||||
creates and deletes `.git/objects/maintenance.lock` after the commit has
|
||||
returned; a listing counts it, a watcher's `lstat` misses it, and Bun's
|
||||
`rmSync(..., { recursive: true, force: true })` returns without an error while
|
||||
leaving the directory behind. `runFreeShard` appends the same two keys to
|
||||
every shard child's `GIT_CONFIG_*` pairs as a backstop, so it covers fixtures
|
||||
that inherit the environment, and `bun run scripts/ship-measure.ts free
|
||||
--backend local` measures with that environment; a fixture with its own
|
||||
replacement env never sees the backstop.
|
||||
|
||||
To reproduce a ledger entry, take the shard's exact file list from the CI run's
|
||||
`free-plan` artifact, then rerun it with the flaky retry off on a CI-mirrored VM:
|
||||
|
||||
```bash
|
||||
gh run download <run-id> -R garrytan/gstack -n free-plan -D /tmp/free-plan
|
||||
files=$(jq -r --argjson s <shard> '.shards[] | select(.shard == $s) | .files | join(",")' /tmp/free-plan/free-plan.json)
|
||||
bun run test:ubicloud --diagnostic --files "$files" --reruns 30
|
||||
```
|
||||
|
||||
The ledger entry names the shard (`flake-ledger-<shard>`). Read the counts from
|
||||
the run's `free-measurement.json` (`completed`, `passed`, `capHit`): with a
|
||||
before-fix failure rate p, a fix needs at least 3/p clean completed reruns of
|
||||
the same command, so zero failures would have under a 5% chance if nothing had
|
||||
changed. Cut or unstarted reruns are incomplete, not passes.
|
||||
|
||||
### Free suite duration seed
|
||||
|
||||
`scripts/free-test-durations.json` drives shard packing; files missing from it
|
||||
pack at the seed's 99th percentile. Acceptable seeds come from
|
||||
pack at the seed's 99th percentile. Acceptable seeds come only from
|
||||
`bun run test:ubicloud --record-durations` (each file timed in its own child on a
|
||||
VM with the CI lane's environment) or a CI recording. A 4-core laptop or
|
||||
small-sandbox recording is not acceptable: browser and display tests skip or slow
|
||||
down there, so the seed misstates them.
|
||||
VM with the CI lane's environment; it needs `UBICLOUD_API_KEY` and copies the
|
||||
refreshed seed back into the checkout). A 4-core laptop or small-sandbox
|
||||
recording is not acceptable: browser and display tests skip or slow down there,
|
||||
so the seed misstates them. Contributors without `UBICLOUD_API_KEY` leave the
|
||||
refresh to a maintainer.
|
||||
|
||||
- `--ci-plan` warns in the job summary, with the refresh command, when more than 5
|
||||
files are unseeded; it never fails a PR. `--ci-verify` lists shards that ran
|
||||
|
||||
@@ -274,3 +274,34 @@ The 4 remaining shouting pins are enum or marker tokens (`PLAN MODE EXCEPTION
|
||||
ALWAYS RUN`, `[CRITICAL]`, `ALWAYS-ON` CLI output), not emphasis. Most remaining
|
||||
sentence pins sit in QA caller, detector-helper and workflow files that other
|
||||
work in flight owns.
|
||||
|
||||
## Post-merge follow-up (2026-10-15)
|
||||
|
||||
The "post-merge audit metrics" TODO, from a no-spend run of
|
||||
`bun run test:health --since-days 7` (window 2026-10-08T22:14Z to
|
||||
2026-10-15T22:14Z). The 2026-10-12 figures are the weekly health run in issue
|
||||
#3154 (run 38234556073), kept as dated history.
|
||||
|
||||
| Metric | 2026-10-12 | 2026-10-15 | Target | Verdict |
|
||||
|---|---:|---:|---|---|
|
||||
| PR full-fallback rate | 45.0% (58/129 runs) | 45.9% (39/85 runs) | none numeric; the replay above names the cause | open: branches touching `bin/`, `lib/`, `hosts/` or `make-pdf/` still select every case |
|
||||
| Recorded PR-lane spend per merged PR | $75.09 (46 merged) | $73.52 (33 merged) | down at least 50% from the pre-change baseline | unavailable: baseline not recorded (below) |
|
||||
| Cancelled slice-minutes per week | 1,939 (44 runs) | 1,545 (33 runs) | none numeric | reported |
|
||||
| PR push-to-verdict | p50 12.0 min, p95 1,160.7 min (122 runs) | p50 11.4 min, p95 107.9 min (80 runs) | 3–5 minutes for typical PR checks (CONTRIBUTING.md) | missed |
|
||||
| Free-suite flaky-pass rate | 13.0% (6/46 main runs) | 6.1% (2/33 main runs; `terminal-agent-publication-lock` 2/33, over the 5% per-file check) | under 5% of main runs | missed; fix wave 2 repairs the five ledger files |
|
||||
|
||||
**Spend baseline.** The pre-change window was recomputed with the exported
|
||||
`collectHealth` at `now = 2026-10-04T00:00Z`, seven days, through a client that
|
||||
drops runs created and PRs merged after that instant (`listWorkflowRuns` has no
|
||||
upper bound of its own). The window holds 12 merged PRs but no
|
||||
`trial-outcomes-pr-*` artifacts and no paid-plan manifest with `prCoverage`:
|
||||
the recorder that feeds this metric shipped with the audit, so the PR lanes
|
||||
before it wrote nothing it can read (their other artifacts are retained until
|
||||
2026-11-02 and later). Baseline not recorded; verdict unavailable. The P1 in
|
||||
TODOS.md is opened on the absolute figures.
|
||||
|
||||
**Census reliability (not yet due).** Due after the fourth scheduled census
|
||||
after the audit merge (2026-10-26). Interim: periodic 2.00 expected failed
|
||||
trials per run (6/538 over 3 runs; target at most 1.0), gate census 0.33 (1/308
|
||||
over 3 runs; target at most 0.5). The TODO keeps its due date; the census
|
||||
repair stays with #3153 and #3200.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.102",
|
||||
"version": "1.91.106",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+1193
-1029
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"entries": [
|
||||
{"file": "test/host-conformance.test.ts", "reason": "real ./setup per host and layout; the time is setup's own skill-linking loop, not fixture copies (shared fixture landed in W6c)"},
|
||||
{"file": "test/shared-libs-stage-actor.test.ts", "reason": "re-runs the paid shared-libs runner body against git fixture repos per test; owned by the paid-harness value-bar review"}
|
||||
{"file": "test/shared-libs-stage-actor.test.ts", "reason": "re-runs the paid shared-libs runner body against git fixture repos per test; owned by the paid-harness value-bar review"},
|
||||
{"file": "test/setup-install-registry.test.ts", "reason": "82s in the 2026-10-15 recording (58s on 2026-10-04): 11 cases each run the real ./setup, gstack-relink, gstack-config and gstack-uninstall against throwaway checkouts", "todo": "split by describe block so the packer can balance it, then rerun bun run test:ubicloud --record-durations (TODOS.md, fix wave 2 follow-ups)"},
|
||||
{"file": "test/host-renders-prune.test.ts", "reason": "64s in the 2026-10-15 recording (unseeded before): every case runs a real ./setup or the gstack-upgrade migration over host render dirs", "todo": "split by describe block so the packer can balance it, then rerun bun run test:ubicloud --record-durations (TODOS.md, fix wave 2 follow-ups)"},
|
||||
{"file": "test/setup-codex-scope.test.ts", "reason": "60.4s in the 2026-10-15 recording (48s on 2026-10-04): Codex install scope through the real ./setup", "todo": "split by describe block so the packer can balance it, then rerun bun run test:ubicloud --record-durations (TODOS.md, fix wave 2 follow-ups)"}
|
||||
]
|
||||
}
|
||||
@@ -1490,6 +1490,24 @@ function logFreeRecovery(log: (line: string) => void, outcome: FreeShardOutcome,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backstop for fixtures that commit and then list, watch or remove `.git`:
|
||||
* git's detached auto-maintenance child creates and deletes
|
||||
* `.git/objects/maintenance.lock` after `git commit` has returned. Appends
|
||||
* `maintenance.auto=false` and `gc.auto=0` after any inherited GIT_CONFIG_*
|
||||
* pairs; a malformed inherited count is left untouched so git still rejects it.
|
||||
*/
|
||||
export function disableGitAutoMaintenance(env: NodeJS.ProcessEnv): void {
|
||||
const inherited = (env.GIT_CONFIG_COUNT ?? '').trim();
|
||||
if (inherited !== '' && !/^\d+$/.test(inherited)) return;
|
||||
const count = inherited === '' ? 0 : Number(inherited);
|
||||
env[`GIT_CONFIG_KEY_${count}`] = 'maintenance.auto';
|
||||
env[`GIT_CONFIG_VALUE_${count}`] = 'false';
|
||||
env[`GIT_CONFIG_KEY_${count + 1}`] = 'gc.auto';
|
||||
env[`GIT_CONFIG_VALUE_${count + 1}`] = '0';
|
||||
env.GIT_CONFIG_COUNT = String(count + 2);
|
||||
}
|
||||
|
||||
/** One line per shard, printed after the run: `[test:free] shard i/N: M files, XXs, pass|fail|timed-out`. */
|
||||
function shardEpilogue(outcome: FreeShardOutcome, totalShards: number): string {
|
||||
return `[test:free] shard ${outcome.shard}/${totalShards}: ${outcome.files.length} files, `
|
||||
@@ -1559,6 +1577,7 @@ export async function runFreeShard(
|
||||
// shards and prior daemons replace each other's state; override inherited state.
|
||||
env.BROWSE_STATE_FILE = path.join(stateDir, '.gstack', 'browse.json');
|
||||
env.GSTACK_FREE_SHARD_ID = randomUUID();
|
||||
disableGitAutoMaintenance(env);
|
||||
const home = options.homeGuard ? options.homeGuard(files, env, stateDir) : guardFreeHome(files, env);
|
||||
|
||||
const startedAt = Date.now();
|
||||
|
||||
@@ -138,7 +138,7 @@ export function flakeCheck(window: FlakeWindow | Error): Check {
|
||||
|
||||
export function unseededCheck(root: string): Check {
|
||||
const base = { id: 'unseeded-free-files', title: 'Free test files missing from the duration seed (CEO-10)',
|
||||
fix: 'Refresh the seed: `bun run test:ubicloud --record-durations` (or a CI recording; a 4-core laptop recording is not acceptable), then commit scripts/free-test-durations.json (docs/TESTING_INTERNALS.md#free-suite-duration-seed).' };
|
||||
fix: 'Refresh the seed with UBICLOUD_API_KEY set: `bun run test:ubicloud --record-durations` times every free file on a CI-mirrored VM and copies scripts/free-test-durations.json back into this checkout; commit it (docs/TESTING_INTERNALS.md#free-suite-duration-seed). Without UBICLOUD_API_KEY, leave the refresh to a maintainer: a 4-core laptop or sandbox recording skips or slows browser and display tests, so it is not accepted.' };
|
||||
const durations = loadFreeTestDurations(root);
|
||||
if (!durations) return { ...base, status: 'unavailable', detail: 'unavailable: scripts/free-test-durations.json is missing or unreadable; next: refresh the seed' };
|
||||
const unseeded = unseededFreeFiles(collectFreeTestFiles(root), durations);
|
||||
@@ -243,12 +243,13 @@ export function collectHealth(inputs: HealthInputs): HealthReport {
|
||||
detail: window.files.slice(0, 15).map(file => `${file.file}: ${file.flakyRuns}/${window.runs} runs (${pct(file.rate)})${file.failing ? ' FAIL > 5%' : ''}`) };
|
||||
}));
|
||||
|
||||
metrics.push(metric('free-wall', 'Free-suite and Windows wall', 'p50 of run_started_at to updated_at for successful main runs of free-tests.yml and windows-free-tests.yml', () => {
|
||||
const wall = (workflow: string) => {
|
||||
const walls = listWorkflowRuns(client, repo, workflow, { since, branch: 'main' }).filter(run => run.conclusion === 'success').map(run => span(run.startedAt, run.updatedAt)!);
|
||||
return `${minutes(percentile(walls, 0.5))} (${walls.length} runs)`;
|
||||
};
|
||||
return { value: `free-tests ${wall('free-tests.yml')}; windows-free-tests ${wall('windows-free-tests.yml')}` };
|
||||
metrics.push(metric('free-wall', 'Free-suite and Windows wall', 'p50 of run_started_at to updated_at for successful main runs of free-tests.yml and successful pull_request runs of windows-free-tests.yml (its workflow_dispatch duration recordings excluded)', () => {
|
||||
const walls = (workflow: string, filter: { branch?: string; event?: string }) =>
|
||||
listWorkflowRuns(client, repo, workflow, { since, ...filter }).filter(run => run.conclusion === 'success').map(run => span(run.startedAt, run.updatedAt)!);
|
||||
const free = walls('free-tests.yml', { branch: 'main' });
|
||||
const windows = walls('windows-free-tests.yml', { event: 'pull_request' });
|
||||
const windowsValue = windows.length ? `${minutes(percentile(windows, 0.5))} (${windows.length} runs)` : 'no successful PR runs in window';
|
||||
return { value: `free-tests ${minutes(percentile(free, 0.5))} (${free.length} runs); windows-free-tests (successful PR runs) ${windowsValue}` };
|
||||
}));
|
||||
|
||||
metrics.push(metric('free-serial', 'Free-suite serial time', 'sum of scripts/free-test-durations.json (the committed seed)', () => {
|
||||
|
||||
@@ -95,6 +95,7 @@ describe('seed ceiling and shrink-only allowlist', () => {
|
||||
});
|
||||
|
||||
test('the committed seed holds the ceiling and the allowlist only shrinks', () => {
|
||||
expect(SEED_ALLOWLIST_FILE).toBe('scripts/free-test-seed-allowlist.json');
|
||||
const seed = JSON.parse(fs.readFileSync(path.join(ROOT, FREE_TEST_DURATIONS_FILE), 'utf8')) as { durations: Record<string, number> };
|
||||
const allowlist = parseSeedAllowlist(fs.readFileSync(path.join(ROOT, SEED_ALLOWLIST_FILE), 'utf8'));
|
||||
const explicitBase = process.env.GSTACK_FREE_SEED_BASE;
|
||||
|
||||
@@ -21,6 +21,7 @@ function repo() {
|
||||
fs.mkdirSync(path.join(dir, 'handbook'), { recursive: true });
|
||||
git(root, 'init', '-q', '-b', 'main', dir);
|
||||
git(dir, 'config', 'user.email', 't@t'); git(dir, 'config', 'user.name', 't'); git(dir, 'config', 'commit.gpgsign', 'false');
|
||||
git(dir, 'config', 'maintenance.auto', 'false'); git(dir, 'config', 'gc.auto', '0');
|
||||
fs.writeFileSync(path.join(dir, 'app.ts'), 'export const a = 1;\n');
|
||||
fs.writeFileSync(path.join(dir, 'handbook', 'guide.md'), '# Guide\n');
|
||||
git(dir, 'add', '.'); git(dir, 'commit', '-qm', 'base');
|
||||
@@ -38,7 +39,7 @@ describe('gstack-docs-candidate', () => {
|
||||
test('snapshot records base, HEAD, index, NUL-safe path lists and content hashes without touching the repository', () => {
|
||||
const { dir, record } = repo();
|
||||
const gitDir = path.join(dir, '.git');
|
||||
const before = fs.readdirSync(gitDir, { recursive: true }).length;
|
||||
const before = fs.readdirSync(gitDir, { recursive: true }) as string[];
|
||||
const indexBefore = fs.readFileSync(path.join(gitDir, 'index'));
|
||||
const result = run(dir, 'snapshot', '--out', record, '--audit-id', 'a1', '--mode', 'edit', '--base', 'main', '--docs', 'handbook');
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
@@ -51,7 +52,9 @@ describe('gstack-docs-candidate', () => {
|
||||
expect(saved.hashes['app.ts']).toBe(git(dir, 'hash-object', 'app.ts'));
|
||||
expect(saved.index).toHaveLength(2);
|
||||
expect(fs.statSync(record).mode & 0o777).toBe(0o600);
|
||||
expect(fs.readdirSync(gitDir, { recursive: true }).length).toBe(before);
|
||||
const after = fs.readdirSync(gitDir, { recursive: true }) as string[];
|
||||
const changed = [...before.filter(entry => !after.includes(entry)).map(entry => `-${entry}`), ...after.filter(entry => !before.includes(entry)).map(entry => `+${entry}`)];
|
||||
expect(after.length, `.git entries changed during snapshot: ${changed.join(', ') || '(same names)'}`).toBe(before.length);
|
||||
expect(fs.readFileSync(path.join(gitDir, 'index')).equals(indexBefore)).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -932,35 +932,90 @@ describe("width pinned on failed base read (3-digit repos)", () => {
|
||||
|
||||
describe("integration (smoke)", () => {
|
||||
const SMOKE_SCRIPT = join(import.meta.dir, "..", "bin", "gstack-next-version");
|
||||
// Bumps timeout to 30s — the test spawns a real `bun run` subprocess that
|
||||
// does a `gh pr list` against the live GitHub API to inspect claimed slots.
|
||||
// Network latency makes 5s tight on developer machines.
|
||||
const FAKE_OWNER = "fixture-owner";
|
||||
const CLAIM = { pr: 41, branch: "fixture/claim", version: "1.6.4.0", url: "https://github.com/fixture-owner/repo/pull/41" };
|
||||
// A named fake `gh` first on PATH answers every call the CLI makes in this
|
||||
// checkout and logs each argv; anything else exits 2 so a fixture gap cannot
|
||||
// silently change the path. The live GitHub API is never called.
|
||||
function fakeGh(dir: string): { calls: () => string[][]; glabCalls: () => string } {
|
||||
const log = join(dir, "gh-calls.log");
|
||||
const glabLog = join(dir, "glab-calls.log");
|
||||
const prList = JSON.stringify([{ number: CLAIM.pr, headRefName: CLAIM.branch, headRepositoryOwner: { login: FAKE_OWNER }, url: CLAIM.url, isDraft: false }]);
|
||||
const content = Buffer.from(`${CLAIM.version}\n`).toString("base64");
|
||||
writeFileSync(join(dir, "gh"), [
|
||||
"#!/bin/sh",
|
||||
`printf '%s\\037' "$@" >> '${log}'; printf '\\n' >> '${log}'`,
|
||||
'case "$*" in',
|
||||
' "auth status") exit 0 ;;',
|
||||
' "pr view --json number -q .number") exit 1 ;;',
|
||||
` "pr list --state open --base main --limit 200 --json number,headRefName,headRepositoryOwner,url,isDraft") printf '%s\\n' '${prList}'; exit 0 ;;`,
|
||||
` "repo view --json owner -q .owner.login") echo '${FAKE_OWNER}'; exit 0 ;;`,
|
||||
` "api repos/{owner}/{repo}/contents/VERSION?ref=${encodeURIComponent(CLAIM.branch)} -q .content") echo '${content}'; exit 0 ;;`,
|
||||
"esac",
|
||||
`echo "FAKE_GH_UNHANDLED $*" >> '${log}'`,
|
||||
'echo "FAKE_GH_UNHANDLED $*" >&2',
|
||||
"exit 2",
|
||||
"",
|
||||
].join("\n"), { mode: 0o755 });
|
||||
writeFileSync(join(dir, "glab"), `#!/bin/sh\necho "$*" >> '${glabLog}'\nexit 1\n`, { mode: 0o755 });
|
||||
return {
|
||||
calls: () => readFileSync(log, "utf8").trimEnd().split("\n").map((line) => line.split("\x1f").filter(Boolean)),
|
||||
glabCalls: () => { try { return readFileSync(glabLog, "utf8"); } catch { return ""; } },
|
||||
};
|
||||
}
|
||||
|
||||
test("CLI runs against real repo and emits parseable JSON", async () => {
|
||||
const proc = Bun.spawnSync([
|
||||
"bun",
|
||||
"run",
|
||||
"./bin/gstack-next-version",
|
||||
"--base",
|
||||
"main",
|
||||
"--bump",
|
||||
"patch",
|
||||
"--current-version",
|
||||
"1.6.3.0",
|
||||
"--workspace-root",
|
||||
"null", // skip sibling scan in CI
|
||||
], { timeout: 30_000 });
|
||||
const out = new TextDecoder().decode(proc.stdout);
|
||||
const parsed = JSON.parse(out);
|
||||
expect(parsed).toHaveProperty("version");
|
||||
expect(parseVersion(parsed.version)).not.toBeNull();
|
||||
expect(parsed).toHaveProperty("bump", "patch");
|
||||
expect(parsed).toHaveProperty("host");
|
||||
expect(["github", "gitlab", "unknown"]).toContain(parsed.host);
|
||||
expect(parsed).toHaveProperty("claimed");
|
||||
expect(Array.isArray(parsed.claimed)).toBe(true);
|
||||
expect(parsed).toHaveProperty("siblings");
|
||||
expect(parsed.siblings).toEqual([]); // --workspace-root null disabled scanning
|
||||
expect(parsed).toHaveProperty("version_path", "VERSION"); // default when no config + no flag
|
||||
const stubDir = mkdtempSync(join(tmpdir(), "nextver-smoke-gh-"));
|
||||
try {
|
||||
const gh = fakeGh(stubDir);
|
||||
const proc = Bun.spawnSync([
|
||||
"bun",
|
||||
"run",
|
||||
"./bin/gstack-next-version",
|
||||
"--base",
|
||||
"main",
|
||||
"--bump",
|
||||
"patch",
|
||||
"--current-version",
|
||||
"1.6.3.0",
|
||||
"--workspace-root",
|
||||
"null", // skip sibling scan in CI
|
||||
], { timeout: 30_000, env: { ...process.env, PATH: `${stubDir}:${process.env.PATH ?? ""}` } });
|
||||
const stderr = new TextDecoder().decode(proc.stderr);
|
||||
expect(proc.exitCode, stderr).toBe(0);
|
||||
const out = new TextDecoder().decode(proc.stdout);
|
||||
const parsed = JSON.parse(out);
|
||||
expect(parsed).toHaveProperty("version");
|
||||
expect(parseVersion(parsed.version)).not.toBeNull();
|
||||
expect(parsed).toHaveProperty("bump", "patch");
|
||||
expect(parsed).toHaveProperty("host");
|
||||
expect(["github", "gitlab", "unknown"]).toContain(parsed.host);
|
||||
expect(parsed).toHaveProperty("claimed");
|
||||
expect(Array.isArray(parsed.claimed)).toBe(true);
|
||||
expect(parsed).toHaveProperty("siblings");
|
||||
expect(parsed.siblings).toEqual([]); // --workspace-root null disabled scanning
|
||||
expect(parsed).toHaveProperty("version_path", "VERSION"); // default when no config + no flag
|
||||
|
||||
const remote = execFileSync("git", ["remote", "get-url", "origin"], { cwd: join(import.meta.dir, ".."), encoding: "utf8", timeout: 10_000 }).trim();
|
||||
expect(gh.calls()).toEqual([
|
||||
...(remote.includes("github.com") ? [] : [["auth", "status"]]),
|
||||
["pr", "view", "--json", "number", "-q", ".number"],
|
||||
["pr", "list", "--state", "open", "--base", "main", "--limit", "200", "--json", "number,headRefName,headRepositoryOwner,url,isDraft"],
|
||||
["repo", "view", "--json", "owner", "-q", ".owner.login"],
|
||||
["api", `repos/{owner}/{repo}/contents/VERSION?ref=${encodeURIComponent(CLAIM.branch)}`, "-q", ".content"],
|
||||
]);
|
||||
expect(gh.calls().flat().join(" ")).not.toContain("FAKE_GH_UNHANDLED");
|
||||
expect(stderr).not.toContain("FAKE_GH_UNHANDLED");
|
||||
expect(gh.glabCalls()).toBe("");
|
||||
expect(parsed.host).toBe("github");
|
||||
expect(parsed.offline).toBe(false);
|
||||
expect(parsed.fallback).toBeNull();
|
||||
expect(parsed.warnings).toEqual([]);
|
||||
expect(parsed.claimed).toEqual([CLAIM]); // the canned PR's owner matches the canned repo owner
|
||||
expect(parsed.version).toBe("1.6.5.0"); // the canned claim on 1.6.4.0 is skipped
|
||||
} finally {
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000); // Headroom over the 4-5s wall time of the spawned process under load
|
||||
|
||||
test("CLI runs with --version-path and surfaces it in JSON output", async () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ async function exercise(mode: 'success' | 'max-turns' | 'first-timeout' | 'secon
|
||||
const scratch = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'legacy-design-free-')));
|
||||
const home = path.join(scratch, 'home'); fs.mkdirSync(home);
|
||||
const env = { PATH: process.env.PATH ?? '', HOME: home, GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_CONFIG_COUNT: '2', GIT_CONFIG_KEY_0: 'maintenance.auto', GIT_CONFIG_VALUE_0: 'false', GIT_CONFIG_KEY_1: 'gc.auto', GIT_CONFIG_VALUE_1: '0',
|
||||
GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
|
||||
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) };
|
||||
const callbacks: Array<{ name: string; fn: () => Promise<void>; timeout: number }> = [];
|
||||
@@ -25,6 +26,8 @@ async function exercise(mode: 'success' | 'max-turns' | 'first-timeout' | 'secon
|
||||
const recordBudgets: number[] = [];
|
||||
let launched = false, sdkSignal: AbortSignal | undefined;
|
||||
let inputVerified = false, saved = '', resultError: unknown;
|
||||
const removals: string[] = [];
|
||||
const survivors = () => `surviving fixture dirs: ${dirs.filter(dir => fs.existsSync(dir)).join(', ') || '(none)'}; removals: ${removals.join('; ') || '(none)'}`;
|
||||
const owned = (p: string) => {
|
||||
const full = path.resolve(p);
|
||||
expect(full.startsWith(scratch + path.sep)).toBe(true);
|
||||
@@ -43,13 +46,17 @@ async function exercise(mode: 'success' | 'max-turns' | 'first-timeout' | 'secon
|
||||
},
|
||||
copyFileSync(a: string, b: string) { owned(b); fs.copyFileSync(a, b); },
|
||||
cpSync(a: string, b: string, opts: any) { owned(b); fs.cpSync(a, b, opts); },
|
||||
rmSync(p: string, opts: any) { owned(p); fs.rmSync(p, opts); },
|
||||
rmSync(p: string, opts: any) {
|
||||
owned(p);
|
||||
try { fs.rmSync(p, opts); } catch (error) { removals.push(`${p}: ${error}`); throw error; }
|
||||
if (fs.existsSync(p)) removals.push(`${p}: rmSync returned but the path survived`);
|
||||
},
|
||||
};
|
||||
const args = {
|
||||
ROOT, fs: localFs, path, os: { tmpdir: () => scratch }, expect,
|
||||
CAPTURE_MS, CAPTURE_LONG_MS, OFFICE_HOURS_BUN_GRACE_MS, resolveEvalModel, runId: 'free-legacy-design',
|
||||
evalCollector: { addTest(entry: any) {
|
||||
expect(dirs.every(dir => !fs.existsSync(dir))).toBe(true);
|
||||
expect(dirs.every(dir => !fs.existsSync(dir)), survivors()).toBe(true);
|
||||
rows.push(entry);
|
||||
} },
|
||||
runRecordedOfficeHoursAttempt: (options: Parameters<typeof runRecordedOfficeHoursAttempt>[0]) => {
|
||||
@@ -134,7 +141,7 @@ async function exercise(mode: 'success' | 'max-turns' | 'first-timeout' | 'secon
|
||||
expect(callbacks).toHaveLength(1); expect(callbacks[0]!.timeout).toBe(CAPTURE_LONG_MS);
|
||||
try { await callbacks[0]!.fn(); } catch (error) { resultError = error; }
|
||||
expect(inputVerified).toBe(true); expect(dirs).toHaveLength(1);
|
||||
expect(dirs.every(dir => !fs.existsSync(dir))).toBe(true);
|
||||
expect(dirs.every(dir => !fs.existsSync(dir)), survivors()).toBe(true);
|
||||
expect(recordBudgets).toEqual([CAPTURE_LONG_MS - OFFICE_HOURS_BUN_GRACE_MS]);
|
||||
expect(sdkSignal?.aborted).toBe(true);
|
||||
return { resultError, rows, saved };
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* The free runner's git auto-maintenance backstop: every shard child gets
|
||||
* maintenance.auto=false and gc.auto=0 appended to its inherited GIT_CONFIG_*
|
||||
* pairs, so a fixture commit cannot leave a detached `git maintenance run`
|
||||
* creating and deleting .git/objects/maintenance.lock under a later listing,
|
||||
* watch or removal. Fixtures that build their own git env set the keys
|
||||
* themselves (docs/TESTING_INTERNALS.md#flake-ledger).
|
||||
*/
|
||||
import { afterEach, describe, 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 { disableGitAutoMaintenance, runFreeShard } from '../scripts/test-free-shards';
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); });
|
||||
const scratch = () => {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'free-git-maintenance-')));
|
||||
roots.push(root);
|
||||
return root;
|
||||
};
|
||||
const gitConfigEnv = (env: NodeJS.ProcessEnv) => Object.fromEntries(Object.entries(env).filter(([key]) => key.startsWith('GIT_CONFIG_')).sort());
|
||||
const cleanGitEnv = (home: string): NodeJS.ProcessEnv => ({
|
||||
PATH: process.env.PATH ?? '', HOME: home, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: path.join(home, 'no-global-gitconfig'),
|
||||
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
|
||||
});
|
||||
|
||||
/** The GIT_CONFIG_* entries the real runFreeShard child received. */
|
||||
async function childGitConfig(env: NodeJS.ProcessEnv | undefined): Promise<NodeJS.ProcessEnv> {
|
||||
const out = path.join(scratch(), 'child-env.json');
|
||||
const probe = `require('fs').writeFileSync(${JSON.stringify(out)}, JSON.stringify(Object.fromEntries(Object.entries(process.env).filter(([k]) => k.startsWith('GIT_CONFIG_')))));`
|
||||
+ `console.log('Ran 1 tests across 1 files. [1.00ms]');`;
|
||||
const outcome = await runFreeShard(['probe'], 1, 1, { env, quiet: true, log: () => {},
|
||||
commandFor: () => ({ command: process.execPath, args: ['-e', probe] }) });
|
||||
expect(outcome.status).toBe('passed');
|
||||
return gitConfigEnv(JSON.parse(fs.readFileSync(out, 'utf8')));
|
||||
}
|
||||
|
||||
const OFF = { maintenance: ['maintenance.auto', 'false'], gc: ['gc.auto', '0'] } as const;
|
||||
const appended = (start: number) => ({
|
||||
[`GIT_CONFIG_KEY_${start}`]: OFF.maintenance[0], [`GIT_CONFIG_VALUE_${start}`]: OFF.maintenance[1],
|
||||
[`GIT_CONFIG_KEY_${start + 1}`]: OFF.gc[0], [`GIT_CONFIG_VALUE_${start + 1}`]: OFF.gc[1],
|
||||
});
|
||||
|
||||
describe('free runner: git auto-maintenance off for every shard child', () => {
|
||||
test('absent and zero inherited GIT_CONFIG_COUNT both start the pairs at index 0', async () => {
|
||||
const home = scratch();
|
||||
expect(await childGitConfig(cleanGitEnv(home))).toEqual(gitConfigEnv({ ...cleanGitEnv(home), GIT_CONFIG_COUNT: '2', ...appended(0) }));
|
||||
expect(await childGitConfig({ ...cleanGitEnv(home), GIT_CONFIG_COUNT: '0' }))
|
||||
.toEqual(gitConfigEnv({ ...cleanGitEnv(home), GIT_CONFIG_COUNT: '2', ...appended(0) }));
|
||||
});
|
||||
|
||||
test('a nonzero inherited count keeps its pairs and appends after them', async () => {
|
||||
const env = { ...cleanGitEnv(scratch()), GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'user.name', GIT_CONFIG_VALUE_0: 'Fixture' };
|
||||
expect(await childGitConfig(env)).toEqual(gitConfigEnv({ ...env, GIT_CONFIG_COUNT: '3', ...appended(1) }));
|
||||
});
|
||||
|
||||
test('an explicit options.env and the default process.env are both covered, and neither caller object changes', async () => {
|
||||
const env = { ...cleanGitEnv(scratch()), GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'core.autocrlf', GIT_CONFIG_VALUE_0: 'false' };
|
||||
const envBefore = structuredClone(env);
|
||||
const processBefore = gitConfigEnv(process.env);
|
||||
await childGitConfig(env);
|
||||
expect(env).toEqual(envBefore);
|
||||
const inherited = Number(process.env.GIT_CONFIG_COUNT || '0');
|
||||
expect(await childGitConfig(undefined)).toEqual({ ...processBefore, GIT_CONFIG_COUNT: String(inherited + 2), ...appended(inherited) });
|
||||
expect(gitConfigEnv(process.env)).toEqual(processBefore);
|
||||
});
|
||||
|
||||
test('a malformed inherited count is passed through untouched and git still rejects it', async () => {
|
||||
const env = { ...cleanGitEnv(scratch()), GIT_CONFIG_COUNT: 'abc', GIT_CONFIG_KEY_0: 'user.name', GIT_CONFIG_VALUE_0: 'Fixture' };
|
||||
const child = await childGitConfig(env);
|
||||
expect(child).toEqual(gitConfigEnv(env));
|
||||
const result = spawnSync('git', ['config', '--get', 'user.name'], { env: { ...env, ...child }, encoding: 'utf8', timeout: 10_000 });
|
||||
expect(result.status, result.stderr).not.toBe(0);
|
||||
expect(result.stderr).toContain('GIT_CONFIG_COUNT');
|
||||
});
|
||||
|
||||
test('a traced commit in the runner child env starts no git maintenance run; the default env does', async () => {
|
||||
const root = scratch();
|
||||
const base = cleanGitEnv(root);
|
||||
const traced = (env: NodeJS.ProcessEnv, name: string) => {
|
||||
const repo = path.join(root, name);
|
||||
const git = (...args: string[]) => {
|
||||
const result = spawnSync('git', args, { cwd: repo, env: { ...env, GIT_TRACE: '1' }, encoding: 'utf8', timeout: 10_000 });
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
return result.stderr;
|
||||
};
|
||||
fs.mkdirSync(repo);
|
||||
git('init', '-q', '-b', 'main');
|
||||
git('config', 'user.email', 't@t'); git('config', 'user.name', 't'); git('config', 'commit.gpgsign', 'false');
|
||||
fs.writeFileSync(path.join(repo, 'a.txt'), 'a\n');
|
||||
git('add', 'a.txt');
|
||||
return git('commit', '-qm', 'traced').split('\n').filter(line => /maintenance run|gc --auto/.test(line));
|
||||
};
|
||||
expect(traced(base, 'defaults').length, 'control: the default env must show the auto-maintenance child').toBeGreaterThan(0);
|
||||
const runnerEnv = { ...base, ...(await childGitConfig(base)) };
|
||||
expect(traced(runnerEnv, 'runner')).toEqual([]);
|
||||
});
|
||||
|
||||
test('disableGitAutoMaintenance mutates only the env it is given', () => {
|
||||
const env: NodeJS.ProcessEnv = { GIT_CONFIG_COUNT: ' 2 ', GIT_CONFIG_KEY_0: 'a.b', GIT_CONFIG_VALUE_0: '1', GIT_CONFIG_KEY_1: 'c.d', GIT_CONFIG_VALUE_1: '2' };
|
||||
disableGitAutoMaintenance(env);
|
||||
expect(env).toEqual({ GIT_CONFIG_KEY_0: 'a.b', GIT_CONFIG_VALUE_0: '1', GIT_CONFIG_KEY_1: 'c.d', GIT_CONFIG_VALUE_1: '2', GIT_CONFIG_COUNT: '4', ...appended(2) });
|
||||
});
|
||||
});
|
||||
@@ -149,6 +149,10 @@ describe('enforced checks and the tracking issue (DX-9)', () => {
|
||||
const failing = unseededCheck(freeRoot(2, 6));
|
||||
expect(failing).toMatchObject({ status: 'fail', detail: expect.stringContaining('stale seed: 6 unseeded file(s) > 5') });
|
||||
expect(failing.fix).toContain('bun run test:ubicloud --record-durations');
|
||||
expect(failing.fix).toContain('UBICLOUD_API_KEY');
|
||||
expect(failing.fix).toContain('copies scripts/free-test-durations.json back into this checkout');
|
||||
expect(failing.fix).toContain('leave the refresh to a maintainer');
|
||||
expect(failing.fix).not.toContain('CI recording');
|
||||
expect(unseededCheck(freeRoot(2, 5))).toMatchObject({ status: 'pass', detail: expect.stringContaining('5 unseeded file(s) (limit 5)') });
|
||||
});
|
||||
|
||||
@@ -162,7 +166,8 @@ describe('enforced checks and the tracking issue (DX-9)', () => {
|
||||
});
|
||||
|
||||
describe('collectHealth over recorded fixture data', () => {
|
||||
function fixture() {
|
||||
function fixture(windowsRuns: unknown[] = [run(70, { event: 'pull_request', head_branch: 'feature' }),
|
||||
run(71, { event: 'workflow_dispatch', run_started_at: day(2), updated_at: day(1) })]) {
|
||||
const prTrials = formatTrialOutcomes([trialRecord('gate-a', 'passed', { tier: 'gate', lane: 'gate/pr', cost_usd: 2.5 }),
|
||||
trialRecord('gate-b', 'passed', { tier: 'gate', lane: 'gate/pr', execution: 'reused', cost_usd: 0 })]);
|
||||
const census = formatTrialOutcomes([
|
||||
@@ -184,7 +189,7 @@ describe('collectHealth over recorded fixture data', () => {
|
||||
[/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: [] })],
|
||||
[/workflows\/windows-free-tests\.yml\/runs/, () => ({ workflow_runs: [run(70)] })],
|
||||
[/workflows\/windows-free-tests\.yml\/runs/, () => ({ workflow_runs: windowsRuns })],
|
||||
], {
|
||||
11: { 'manifest.json': JSON.stringify({ prCoverage: { mode: 'full-fallback' } }) },
|
||||
12: { 'trial-outcomes.jsonl': prTrials },
|
||||
@@ -207,7 +212,7 @@ describe('collectHealth over recorded fixture data', () => {
|
||||
expect(value('census-redispatch').value).toBe('0 of 1 census(es) re-dispatched; longest paid slice job 20.0 min');
|
||||
expect(value('quarantine-evidence').value).toContain('insufficient evidence (not the same as healthy)');
|
||||
expect(value('free-flaky-pass-rate').value).toStartWith('50.0% (1/2 main runs');
|
||||
expect(value('free-wall').value).toBe('free-tests 0.0 min (1 runs); windows-free-tests 0.0 min (1 runs)');
|
||||
expect(value('free-wall').value).toBe('free-tests 0.0 min (1 runs); windows-free-tests (successful PR runs) 0.0 min (1 runs)');
|
||||
expect(value('free-serial').value).toBe('3 s over 3 files');
|
||||
for (const entry of report.metrics.filter(entry => entry.id !== 'net-complexity')) {
|
||||
expect(entry.rule.length).toBeGreaterThan(10);
|
||||
@@ -218,6 +223,18 @@ describe('collectHealth over recorded fixture data', () => {
|
||||
expect(formatHealth(report)).toContain('rule: share of evals.yml pull_request runs');
|
||||
});
|
||||
|
||||
test('the Windows wall counts only successful pull_request runs and says so when there are none', () => {
|
||||
const wall = (windowsRuns: unknown[]) => collectHealth({ client: fixture(windowsRuns), repo: 'o/r', now: NOW, sinceDays: 7, cacheDir: tmp('health-cache-'), root: freeRoot(3, 0) })
|
||||
.metrics.find(entry => entry.id === 'free-wall')!;
|
||||
const pr = (id: number, minutes: number, extra: Record<string, unknown> = {}) => run(id, { event: 'pull_request', head_branch: `pr-${id}`,
|
||||
run_started_at: day(1), updated_at: new Date(Date.parse(day(1)) + minutes * 60_000).toISOString(), ...extra });
|
||||
expect(wall([pr(70, 6), pr(71, 7), pr(74, 8), pr(72, 30, { conclusion: 'failure' }), run(73, { event: 'workflow_dispatch', run_started_at: day(3), updated_at: day(1) })]).value)
|
||||
.toBe('free-tests 0.0 min (1 runs); windows-free-tests (successful PR runs) 7.0 min (3 runs)');
|
||||
expect(wall([run(73, { event: 'workflow_dispatch', run_started_at: day(3), updated_at: day(1) })]).value)
|
||||
.toBe('free-tests 0.0 min (1 runs); windows-free-tests (successful PR runs) no successful PR runs in window');
|
||||
expect(wall([]).rule).toContain('workflow_dispatch duration recordings excluded');
|
||||
});
|
||||
|
||||
test('a rate-limited API degrades every remote metric to a named unavailable reason, never 0, and keeps the issue open', () => {
|
||||
const client = fakeClient([[/./, () => { throw new Error('gh api failed: HTTP 403: API rate limit exceeded for installation ID 1'); }]]);
|
||||
const report = collectHealth({ client, repo: 'o/r', now: NOW, sinceDays: 7, cacheDir: tmp('health-cache-'), root: freeRoot(3, 0) });
|
||||
|
||||
@@ -222,4 +222,64 @@ describe('gstack-timeline-read', () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --since/--branch used to read an unbound $2 (exit 1), and an unparseable
|
||||
// --since silently returned unfiltered history.
|
||||
const badFilters: Array<[string, string[], string]> = [
|
||||
['--since missing value', ['--since'], '--since must be a relative time like "7 days ago" (got no value)'],
|
||||
['--since empty value', ['--since', ''], '--since must be a relative time like "7 days ago" (got an empty value)'],
|
||||
['--since followed by a flag', ['--since', '--limit', '5'], "(got flag '--limit')"],
|
||||
['--since yesterday', ['--since', 'yesterday'], "--since must be a relative time like \"7 days ago\" (got 'yesterday')"],
|
||||
['--since 2 weeks', ['--since', '2 weeks'], "(got '2 weeks')"],
|
||||
['--since 7days', ['--since', '7days'], "(got '7days')"],
|
||||
['--since impossible month', ['--since', '2026-13-01'], "(got '2026-13-01')"],
|
||||
['--since unanchored', ['--since', 'about 7 days ago, roughly'], "(got 'about 7 days ago, roughly')"],
|
||||
['--branch missing value', ['--branch'], '--branch must be a branch name (got no value)'],
|
||||
['--branch empty value', ['--branch', ''], '--branch must be a branch name (got an empty value)'],
|
||||
['--branch followed by a flag', ['--branch', '--limit', '5'], "--branch must be a branch name (got flag '--limit')"],
|
||||
['unknown flag', ['--bogus'], "unknown argument '--bogus'"],
|
||||
['stray argument', ['stray'], "unknown argument 'stray'"],
|
||||
];
|
||||
for (const history of ['empty', 'populated'] as const) {
|
||||
for (const [label, args, message] of badFilters) {
|
||||
test(`${label} exits 2 before any read on ${history} history`, () => {
|
||||
if (history === 'populated') {
|
||||
runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: 'main', ts: '2026-03-28T10:00:00Z' }));
|
||||
}
|
||||
const result = runReadStatus(args);
|
||||
expect(result.status, result.stderr).toBe(2);
|
||||
expect(result.stdout).toBe('');
|
||||
expect(result.stderr).toContain(message);
|
||||
if (label.startsWith('--since')) expect(result.stderr).toContain('accepted: "<N> minute|hour|day|week|month[s] ago", "<N>h|d|w", or an ISO date');
|
||||
if (label === 'unknown flag' || label === 'stray argument') expect(result.stderr).toContain('Usage: gstack-timeline-read');
|
||||
});
|
||||
}
|
||||
|
||||
test(`--help prints the usage line and exits 0 on ${history} history`, () => {
|
||||
if (history === 'populated') {
|
||||
runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: 'main', ts: '2026-03-28T10:00:00Z' }));
|
||||
}
|
||||
const result = runReadStatus(['--help']);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe('Usage: gstack-timeline-read [--since "7 days ago"|7d|YYYY-MM-DD] [--limit N] [--branch NAME]');
|
||||
});
|
||||
|
||||
const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
|
||||
for (const since of ['7 days ago', '3 Hours ago', '7d', '24h', '1w', yesterday, `${yesterday}T00:00:00Z`]) {
|
||||
test(`--since '${since}' keeps recent events and drops old ones on ${history} history`, () => {
|
||||
if (history === 'populated') {
|
||||
runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: 'main', ts: '2020-01-01T10:00:00Z' }));
|
||||
runLog(JSON.stringify({ skill: 'ship', event: 'completed', branch: 'main', ts: new Date(Date.now() - 3_600_000).toISOString() }));
|
||||
}
|
||||
const result = runReadStatus(['--since', since]);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
if (history === 'empty') expect(result.stdout).toBe('');
|
||||
else {
|
||||
expect(result.stdout).toContain('/ship completed');
|
||||
expect(result.stdout).not.toContain('/review completed');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user