From dd2416f4eb1aa6099d841c9206dea4f85cec281d Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 15:20:55 -0800 Subject: [PATCH 01/11] v1.92.46.0 fix(brain-sync): never commit a synced JSON file that does not parse (#3763) (#3796) * test: synced JSON that does not parse is never committed and is reported (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(brain-sync): hold a synced JSON file that does not parse; --profile and doctor report it (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(brain-sync): read JSON index blobs with one cat-file batch, no python subprocess (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(brain-sync): block instead of committing a one-sided merge when a held file is mid-merge (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(brain-sync): merge-block status names the real recovery and waiting files (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(brain-sync): JSON holds in the --status, held-files and two-machine sections (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(brain-sync): #3704 fixtures write valid JSON to developer-profile.json (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(brain-sync): keep the reviewed JSON paragraph wording after the main merge (#3763) 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> --- CHANGELOG.md | 16 +++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-brain-sync | 79 ++++++++++-- bin/gstack-developer-profile | 12 ++ bin/gstack-doctor | 18 ++- docs/gbrain-sync-errors.md | 9 +- docs/gbrain-sync.md | 12 +- package.json | 2 +- test/brain-sync.test.ts | 176 ++++++++++++++++++++++---- test/gstack-developer-profile.test.ts | 18 +++ test/gstack-doctor.test.ts | 19 +++ test/gstack-skill-start.test.ts | 2 +- 13 files changed, 319 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7a2763c2..2619a01d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [1.92.46.0] - 2026-11-01 + +**brain-sync no longer publishes a synced JSON file that doesn't parse, and the profile and doctor now say when one is broken.** When two machines edit `developer-profile.json` before syncing, git's merge can leave conflict markers in it. brain-sync committed and pushed that file, so every machine got a profile that no longer parsed, while `gstack-developer-profile --profile` and `gstack-doctor` both reported everything ok (#3763). + +### Fixed +- **brain-sync holds back a synced `.json` file that doesn't parse** (`bin/gstack-brain-sync`). Before each commit it parses every staged `*.json` file (today `developer-profile.json` and `projects/*/decisions.active.json`). A file that doesn't parse is held with rule `invalid-json`, along with any file coupled to it (`decisions.jsonl` with its snapshot). It stays queued and syncs on the next run after you repair it; everything else syncs as usual. The fix line says to repair the file. It does not offer `--skip-file`, which would stop the profile syncing for good. +- **A held file in an unfinished git merge stops the sync instead of committing half the merge.** If a held file is part of a merge that git could not finish, committing the merge without it would quietly keep only this machine's version and drop the other machine's edit. brain-sync now commits nothing and reports `blocked` until you repair the file; the merge and the queue are kept. +- **Held-status messages name the right cause.** The skill-start attention line, `--status` and `gstack-doctor` no longer blame the secret scan for every held file; doctor reports how many held files are not valid JSON. +- **`gstack-doctor` warns when a synced JSON file holds conflict markers**, naming the file and how to restore it from the sync repo's history. + +### Changed +- **`gstack-developer-profile --profile` now exits 1 when the profile is not valid JSON.** It prints ` is not valid JSON: ` on stderr (noting conflict markers when it finds them) and nothing on stdout. It used to print the broken file and exit 0, so scripts that read `--profile` should check its exit status. + +### Tests +- `test/brain-sync.test.ts`: a queued profile with conflict markers, a marker-free syntax error, an invalid `decisions.active.json` holding its coupled `decisions.jsonl` until repaired, two machines editing the profile (the other machine's edit stays on the remote), and an unfinished merge with a held profile. `test/gstack-developer-profile.test.ts`: `--profile` on an unparseable profile. `test/gstack-doctor.test.ts`: a conflict-marker warning, and a JSON hold not attributed to the secret scan. All fail on main. + ## [1.92.45.0] - 2026-11-01 ### /ship review scales to risk, not size diff --git a/VERSION b/VERSION index 603bff1c3..cbd266e1d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.45.0 +1.92.46.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index d00c52753..7d20d84f4 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.45.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.46.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-brain-sync b/bin/gstack-brain-sync index 25625c2bb..c95c6985e 100755 --- a/bin/gstack-brain-sync +++ b/bin/gstack-brain-sync @@ -38,11 +38,11 @@ # applies; `ok` and `idle` print none. is this script's absolute path # and the held count. Rows are read by test/gstack-skill-start.test.ts. # -# held trigger: status=held: the secret scan held back flagged files and synced the rest -# line: ARTIFACTS_SYNC: attention: status=held held=. The secret scan is holding back file(s); everything else still syncs. See which files and how to fix them: --status -# fix: edit the file so the scan no longer matches, or --skip-file (permanent; undo with --unskip-file ) +# held trigger: status=held: the secret scan or the JSON check held back flagged files and synced the rest +# line: ARTIFACTS_SYNC: attention: status=held held=. Sync is holding back file(s) that the secret scan flagged or that are not valid JSON; everything else still syncs. See which files and how to fix them: --status +# fix: edit the file so the scan no longer matches (or repair a file that is not valid JSON), or --skip-file (permanent; undo with --unskip-file ) # blocked trigger: status=blocked: a re-scan still flagged after the hold, so nothing was committed -# line: ARTIFACTS_SYNC: attention: status=blocked. The secret scan flagged a file it could not hold back on its own, so nothing is syncing. See the fix: --status +# line: ARTIFACTS_SYNC: attention: status=blocked. Sync committed nothing: a flagged file could not be held back on its own, or held files are part of an unfinished git merge. See the fix: --status # fix: edit or skip the files named by --status, or clear the batch with --drop-queue --yes # push_failed trigger: status=push_failed: a commit is saved locally but the push failed # line: ARTIFACTS_SYNC: attention: status=push_failed. Synced files are committed locally but the push failed; it retries at skill start. See the cause: --status @@ -144,7 +144,7 @@ remote_auth_hint() { # Status file schema (one JSON object; readers: --status, --attention, doctor): # status ok | idle | held | collision | blocked | push_failed | error # (collision: files were held only for case-colliding paths) -# held_count files the secret scan held back this drain (hits + dependents) +# held_count files the secret scan or the JSON check held back this drain (hits + dependents) # case_held queued files held because their path differs only by letter # case from another path in the repo or the batch # case_names the colliding path component names, comma-separated and @@ -953,6 +953,8 @@ for rule, path in hits: "rule": rule, "dependents": deps, "fixes": [ + f"{path} is not valid JSON (often git conflict markers from a merge); repair it and the next sync re-checks it" + ] if rule == "invalid-json" else [ f"edit {path} so the secret scan no longer matches ({rule}); the next sync re-scans it", f"{self_bin} --skip-file {path} (permanently excludes it{also} from future syncs; undo with {self_bin} --unskip-file {path})", ], @@ -982,6 +984,52 @@ for path, rows in ((unstage_out, unstage), (wait_out, waiting)): PYEOF } +# Append "invalid-json" to the hits file $2 for every staged *.json +# path in the NUL-separated names file $1 whose index blob does not parse +# (#3763): a git merge can leave conflict markers in a synced JSON file, and +# committing it spreads an unreadable file to every machine. A path the +# secret scan already flagged is held for that reason alone. One +# `git cat-file --batch` reads the index blobs; a staged deletion has none. +append_invalid_json_hits() { + python3 - "$1" "$2" "$1.json" <<'PYEOF' || return 1 +import sys +names_path, hits_path, out_path = sys.argv[1:4] +with open(names_path, "rb") as f: + names = [p for p in f.read().decode("utf-8", "surrogateescape").split("\0") if p.endswith(".json") and "\n" not in p] +with open(hits_path, encoding="utf-8", errors="surrogateescape") as f: + flagged = {l.rstrip("\r\n").split("\t", 1)[-1] for l in f if "\t" in l} +with open(out_path, "w", encoding="utf-8", errors="surrogateescape", newline="\n") as out: + for p in names: + if p not in flagged: + out.write(":" + p + "\n") +PYEOF + [ -s "$1.json" ] || return 0 + git -C "$GSTACK_HOME" cat-file --batch < "$1.json" > "$1.json.blobs" 2>/dev/null || return 1 + python3 - "$1.json" "$1.json.blobs" "$2" <<'PYEOF' +import json, sys +batch_path, blobs_path, hits_path = sys.argv[1:4] +with open(batch_path, encoding="utf-8", errors="surrogateescape") as f: + names = [l[1:].rstrip("\n") for l in f if l.startswith(":")] +with open(blobs_path, "rb") as f: + data = f.read() +pos = 0 +with open(hits_path, "a", encoding="utf-8", errors="surrogateescape", newline="\n") as out: + for p in names: + nl = data.index(b"\n", pos) + header = data[pos:nl].split(b" ") + pos = nl + 1 + if header[-1] == b"missing": + continue + size = int(header[2]) + blob = data[pos:pos + size] + pos += size + 1 + try: + json.loads(blob.decode("utf-8")) + except ValueError: + out.write("invalid-json\t" + p + "\n") +PYEOF +} + # Fail closed: unstage everything, keep the queue, status blocked. block_drain() { git -C "$GSTACK_HOME" reset -q HEAD -- . >/dev/null 2>&1 || true @@ -994,7 +1042,9 @@ block_drain() { # one scanner pass attributes findings to staged paths, plan_holds decides # what is held or waits, those paths are unstaged and leave $1 (their records # stay queued), then the whole remaining staged diff must re-scan clean. -# Sets ST_HELD and WAITING. Returns 1 after falling back to block_drain. +# Sets ST_HELD and WAITING. Returns 1 after falling back to block_drain, or +# when a path would be held during an unfinished merge (the merge commit +# would silently keep only this side of it). hold_flagged_paths() { local paths_file="$1" groups_file="$2" scan="$1.scan" rc=0 gate_rc=0 gate_out WAITING=0 @@ -1003,6 +1053,7 @@ hold_flagged_paths() { git -C "$GSTACK_HOME" diff --cached --no-renames > "$scan.diff" 2>/dev/null || rc=1 git -C "$GSTACK_HOME" diff --cached --no-renames -z --name-only > "$scan.names" 2>/dev/null || rc=1 [ "$rc" -eq 0 ] && { secret_scan attribute "$scan.diff" "$scan.names" "$scan.hits" || rc=1; } + [ "$rc" -eq 0 ] && { append_invalid_json_hits "$scan.names" "$scan.hits" || rc=1; } # A successful add need not stage the requested spelling or current bytes # (case-insensitive indexes and assume-unchanged can both report success). # Reuse the scanner's name snapshot; refusal preserves the index and queue. @@ -1023,6 +1074,12 @@ hold_flagged_paths() { ST_HELD=$(cat "$scan.held") WAITING=$(grep -c . "$scan.wait" 2>/dev/null || true) WAITING=${WAITING:-0} + if [ -s "$scan.unstage" ] && git -C "$GSTACK_HOME" rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then + ST_DRAINABLE=0 + write_status "blocked" "a git merge in $GSTACK_HOME is unfinished and $(grep -c . "$scan.unstage") file(s) are held back or wait for their coupled group; committing the merge without them would drop the other side's changes. Repair the files held[] names (they stay queued and sync on the next run), or finish the merge yourself; --skip-file and --drop-queue do not clear this. Nothing was committed; the merge and the queue are kept" + echo "BRAIN_SYNC: blocked: unfinished merge with held files" >&2 + return 1 + fi if [ -s "$scan.unstage" ]; then if ! tr -d '\r' < "$scan.unstage" | git --literal-pathspecs -C "$GSTACK_HOME" reset -q HEAD --pathspec-from-file=- >/dev/null 2>&1; then block_drain "git could not unstage the held files" @@ -1347,11 +1404,13 @@ subcmd_once() { hold_flagged_paths "$paths_file" "$groups_file" || exit 0 ST_DRAINABLE=$(( ADD_FAILED + WAITING )) [ "$WAITING" -gt 0 ] && ST_NOTE="$ST_NOTE; $WAITING file(s) of a coupled group wait for a consistent generation (still queued)" - local held_n ok_code="ok" + local held_n json_n ok_code="ok" held_n=$(printf '%s' "$ST_HELD" | grep -o '"rule"' | wc -l | tr -d ' ') + json_n=$(printf '%s' "$ST_HELD" | grep -o '"rule": "invalid-json"' | wc -l | tr -d ' ') if [ "$held_n" -gt 0 ]; then ok_code="held" - ST_NOTE="$ST_NOTE; the secret scan held back $held_n flagged file(s) (and any coupled files), NOT synced; see held[] for each file's fixes" + [ "$held_n" -gt "$json_n" ] && ST_NOTE="$ST_NOTE; the secret scan held back $(( held_n - json_n )) flagged file(s) (and any coupled files), NOT synced; see held[] for each file's fixes" + [ "$json_n" -gt 0 ] && ST_NOTE="$ST_NOTE; $json_n synced file(s) are not valid JSON (often git conflict markers) and were held back (and any coupled files), NOT synced and still queued; see held[] for each file's fixes" echo "BRAIN_SYNC: held $held_n flagged file(s); see $SELF_BIN --status" >&2 elif [ "$ST_CASE_HELD" -gt 0 ]; then ok_code="collision" @@ -1552,8 +1611,8 @@ subcmd_attention() { case "$last_push" in $iso) ;; *) last_push="" ;; esac case "$code" in - held) echo "ARTIFACTS_SYNC: attention: status=held held=$held. The secret scan is holding back $held file(s); everything else still syncs. See which files and how to fix them: $bin --status" ;; - blocked) echo "ARTIFACTS_SYNC: attention: status=blocked. The secret scan flagged a file it could not hold back on its own, so nothing is syncing. See the fix: $bin --status" ;; + held) echo "ARTIFACTS_SYNC: attention: status=held held=$held. Sync is holding back $held file(s) that the secret scan flagged or that are not valid JSON; everything else still syncs. See which files and how to fix them: $bin --status" ;; + blocked) echo "ARTIFACTS_SYNC: attention: status=blocked. Sync committed nothing: a flagged file could not be held back on its own, or held files are part of an unfinished git merge. See the fix: $bin --status" ;; push_failed) echo "ARTIFACTS_SYNC: attention: status=push_failed. Synced files are committed locally but the push failed; it retries at skill start. See the cause: $bin --status" ;; error) echo "ARTIFACTS_SYNC: attention: status=error. The last sync stopped before committing; the queue is kept and retried. See the cause: $bin --status" ;; unknown) echo "ARTIFACTS_SYNC: attention: status=unknown. The sync status file is unreadable. See it: $bin --status" ;; diff --git a/bin/gstack-developer-profile b/bin/gstack-developer-profile index b5db05262..f64178e2b 100755 --- a/bin/gstack-developer-profile +++ b/bin/gstack-developer-profile @@ -394,6 +394,18 @@ do_read() { # ----------------------------------------------------------------------- do_profile() { ensure_profile + local PARSE_ERR + if ! PARSE_ERR=$(PROFILE_FILE_PATH="$PROFILE_FILE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " + const text = require('fs').readFileSync(process.env.PROFILE_FILE_PATH, 'utf-8'); + try { JSON.parse(text); } catch (e) { + const markers = /^(<{7}|={7}|>{7})( |\$)/m.test(text) ? ' (it holds git conflict markers from a merge)' : ''; + console.log(e.message + markers); + process.exit(1); + } + " 2>&1); then + echo "gstack-developer-profile: $PROFILE_FILE is not valid JSON: $PARSE_ERR; repair the file or restore it from git -C $GSTACK_HOME log -- developer-profile.json" >&2 + return 1 + fi cat "$PROFILE_FILE" } diff --git a/bin/gstack-doctor b/bin/gstack-doctor index 0690fb3a9..18adb49fc 100755 --- a/bin/gstack-doctor +++ b/bin/gstack-doctor @@ -275,7 +275,7 @@ check_codex_probe() { } check_sync() { - local mode file json status held drainable drain push queue=0 now f detail case_names fix="$BIN/gstack-brain-sync --status" + local mode file json json_held status held drainable drain push queue=0 now f detail case_names fix="$BIN/gstack-brain-sync --status" cfg artifacts_sync_mode off; mode=$_cfg if [ ! -d "$STATE_ROOT/.git" ]; then row "not configured" "artifacts sync" "off; cross-machine artifacts sync is unavailable" "$BIN/gstack-artifacts-init" @@ -285,6 +285,11 @@ check_sync() { row "not configured" "artifacts sync" "artifacts_sync_mode off; cross-machine artifacts sync is unavailable" "$BIN/gstack-config set artifacts_sync_mode artifacts-only" return fi + for f in "$STATE_ROOT/developer-profile.json" "$STATE_ROOT"/projects/*/decisions.active.json; do + [ -f "$f" ] && grep -qE '^(<{7}|>{7})( |$)' "$f" 2>/dev/null || continue + row warn "artifacts sync" "${f#"$STATE_ROOT"/} holds git conflict markers and does not parse" "repair the file, or restore it from git -C $STATE_ROOT log -- ${f#"$STATE_ROOT"/}" + return + done for f in "$STATE_ROOT/.brain-queue.d"/*.json; do [ -f "$f" ] && queue=$((queue + 1)); done for f in "$STATE_ROOT/.brain-queue.jsonl" "$STATE_ROOT/.brain-queue.jsonl.migrating"; do [ -f "$f" ] && queue=$((queue + $(wc -l < "$f" | tr -d ' '))) @@ -306,8 +311,15 @@ check_sync() { [ -n "$push" ] || push=$(epoch_of "$(head -1 "$STATE_ROOT/.brain-last-push" 2>/dev/null)") now=$(date +%s) case "$status" in - held) row warn "artifacts sync" "status held${held:+ ($held file(s) held by the secret scanner)}" "$fix"; return ;; - blocked) row warn "artifacts sync" "status blocked (a re-scan still flags staged content)" "$fix"; return ;; + held) + json_held=$(printf '%s' "$json" | grep -o '"rule": *"invalid-json"' | wc -l | tr -d ' ') + if [ "${json_held:-0}" -gt 0 ]; then + row warn "artifacts sync" "status held ($json_held synced file(s) are not valid JSON${held:+; $held held in all})" "$fix" + else + row warn "artifacts sync" "status held${held:+ ($held file(s) held by the secret scanner)}" "$fix" + fi + return ;; + blocked) row warn "artifacts sync" "status blocked (nothing was committed; --status names the cause)" "$fix"; return ;; push_failed|error|unknown) row warn "artifacts sync" "status $status" "$fix"; return ;; esac if [ -n "$case_names" ]; then diff --git a/docs/gbrain-sync-errors.md b/docs/gbrain-sync-errors.md index cd81aa9dd..2d14d7f07 100644 --- a/docs/gbrain-sync-errors.md +++ b/docs/gbrain-sync-errors.md @@ -38,8 +38,8 @@ cause, the held files and the fix. | State | Trigger | Attention line (after `ARTIFACTS_SYNC: attention: `) | Fix | |---|---|---|---| -| `held` | The secret scan held back flagged files and synced the rest | `status=held held=. The secret scan is holding back file(s); everything else still syncs. See which files and how to fix them: --status` | Edit the file so the scan no longer matches, or ` --skip-file ` (permanent; undo with `--unskip-file `) | -| `blocked` | A re-scan still flagged after the hold, so nothing was committed | `status=blocked. The secret scan flagged a file it could not hold back on its own, so nothing is syncing. See the fix: --status` | Edit or skip the files `--status` names, or clear the batch with ` --drop-queue --yes` | +| `held` | The secret scan or the JSON check held back flagged files and synced the rest | `status=held held=. Sync is holding back file(s) that the secret scan flagged or that are not valid JSON; everything else still syncs. See which files and how to fix them: --status` | Edit the file so the scan no longer matches, or ` --skip-file ` (permanent; undo with `--unskip-file `), or repair a file that is not valid JSON | +| `blocked` | A re-scan still flagged after the hold, or a file would be held during an unfinished git merge, so nothing was committed | `status=blocked. Sync committed nothing: a flagged file could not be held back on its own, or held files are part of an unfinished git merge. See the fix: --status` | Edit or skip the files `--status` names, or clear the batch with ` --drop-queue --yes`. For an unfinished merge, repair the held files or finish the merge; skipping files or dropping the queue does not clear it | | `push_failed` | A commit is saved locally but the push failed | `status=push_failed. Synced files are committed locally but the push failed; it retries at skill start. See the cause: --status` | The fix `--status` names (usually `gh auth status`) | | `error` | The drain stopped before committing and kept the queue | `status=error. The last sync stopped before committing; the queue is kept and retried. See the cause: --status` | The cause `--status` names: a git index lock, a commit hook, a `git add` failure | | `unknown` | The status file holds a status code outside this table | `status=unknown. The sync status file is unreadable. See it: --status` | Run ` --once` to rewrite it | @@ -76,6 +76,11 @@ both fixes: ~/.claude/skills/gstack/bin/gstack-brain-sync --drop-queue --yes ``` +A synced `.json` file that does not parse (often git conflict markers from a +merge) is held the same way under rule `invalid-json`. Its only fix is to +repair the file; `--skip-file` is not offered, because it would stop that +file syncing for good. The next sync re-checks it. + **Git index lock.** A git process killed mid-write leaves `~/.gstack/.git/index.lock`. The sync removes it once it is older than 10 minutes, because every gstack writer to `~/.gstack` takes the same sync lock diff --git a/docs/gbrain-sync.md b/docs/gbrain-sync.md index 43d7f2e00..756e8186f 100644 --- a/docs/gbrain-sync.md +++ b/docs/gbrain-sync.md @@ -91,8 +91,8 @@ gstack-brain-sync --status ``` Shows: the status (`ok`, `idle`, `held`, `collision`, `blocked`, -`push_failed`, `error`), files held back by the secret scan with their -fixes, files held because their path differs only by letter case from +`push_failed`, `error`), files held back by the secret scan or because +they are not valid JSON, with their fixes, files held because their path differs only by letter case from another path (`case_held`, `case_names`; see [troubleshooting](troubleshooting.md#artifact-case-collision)), the `drainable` count, the last sync and push times, pending queue depth, and @@ -156,7 +156,7 @@ to it); everything else syncs. The held file stays queued and is re-scanned at every sync, and skill start prints: ``` -ARTIFACTS_SYNC: attention: status=held held=1. The secret scan is holding back 1 file(s); everything else still syncs. See which files and how to fix them: --status +ARTIFACTS_SYNC: attention: status=held held=1. Sync is holding back 1 file(s) that the secret scan flagged or that are not valid JSON; everything else still syncs. See which files and how to fix them: --status ``` To remediate: @@ -200,6 +200,12 @@ is kept in a git stash; `gstack-brain-sync --status` names the stash and the `git -C ~/.gstack --literal-pathspecs restore --source= -- ` command that brings it back. +JSON files such as `developer-profile.json` have no merge driver, so a +merge can leave conflict markers in them. The sync parses every staged +`.json` file and holds back one that does not parse (status `held`, rule +`invalid-json`) until you repair it, and `gstack-doctor` warns when a synced profile or +decision snapshot holds conflict markers. + ## Cross-machine pull cadence The preamble runs `git fetch` + `git merge --ff-only` once per 24 hours diff --git a/package.json b/package.json index 9b0d4049c..95e77f142 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.45", + "version": "1.92.46", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/brain-sync.test.ts b/test/brain-sync.test.ts index ff7ee1432..0e5d60fde 100644 --- a/test/brain-sync.test.ts +++ b/test/brain-sync.test.ts @@ -843,7 +843,7 @@ describe('gstack-brain-sync per-file hold', () => { } function setup() { init(); - write(SHARED, '# shared v1\n'); + write(SHARED, '{"note":"shared v1"}\n'); write(OTHER, '# other v1\n'); expect(drain().status).toBe(0); expect(statusJson().status).toBe('ok'); @@ -856,15 +856,15 @@ describe('gstack-brain-sync per-file hold', () => { test('a dirty tracked file the remote did not touch rides through the merge', () => { setup(); - peerCommit(SHARED, '# shared from the peer\n'); + peerCommit(SHARED, '{"note":"shared from the peer"}\n'); write(OTHER, '# other, local edit not queued\n', false); write(CLEAN, '# a plan\n'); expect(drain().status).toBe(0); expect(statusJson().status).toBe('ok'); expect(remoteFiles()).toContain(CLEAN); - expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n'); + expect(remoteShow(SHARED).stdout).toBe('{"note":"shared from the peer"}\n'); expect(fs.readFileSync(path.join(tmpHome, OTHER), 'utf-8')).toBe('# other, local edit not queued\n'); - expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared from the peer\n'); + expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared from the peer"}\n'); }); /** The restore command the status names, run as printed. */ @@ -878,17 +878,17 @@ describe('gstack-brain-sync per-file hold', () => { test('a dirty tracked file the remote also changed no longer leaves the push failed', () => { setup(); - peerCommit(SHARED, '# shared from the peer\n'); - write(SHARED, '# shared, local edit not queued\n', false); + peerCommit(SHARED, '{"note":"shared from the peer"}\n'); + write(SHARED, '{"note":"shared, local edit not queued"}\n', false); write(CLEAN, '# a plan\n'); expect(drain().status).toBe(0); const s = statusJson(); expect(s.status).toBe('ok'); expect(remoteFiles()).toContain(CLEAN); - expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n'); + expect(remoteShow(SHARED).stdout).toBe('{"note":"shared from the peer"}\n'); // The merged version stays in the working tree: no conflict markers, // nothing staged, and nothing stale for a later sync to publish. - expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared from the peer\n'); + expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared from the peer"}\n'); expect(unmerged()).toBe(''); expect(staged()).toBe(''); expect(s.message).toContain(`local edits to ${SHARED} conflicted`); @@ -900,23 +900,23 @@ describe('gstack-brain-sync per-file hold', () => { write(OTHER, '# other v2\n'); expect(drain().status).toBe(0); expect(statusJson().status).toBe('ok'); - expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n'); - expect(git(['show', `${sha}:${SHARED}`]).stdout).toBe('# shared, local edit not queued\n'); + expect(remoteShow(SHARED).stdout).toBe('{"note":"shared from the peer"}\n'); + expect(git(['show', `${sha}:${SHARED}`]).stdout).toBe('{"note":"shared, local edit not queued"}\n'); // The printed command brings the local edit back. expect(recover(s.message)).toBe(sha!); - expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n'); + expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared, local edit not queued"}\n'); }); test('a local deletion of a file the remote changed keeps the remote file and stashes the deletion', () => { setup(); - peerCommit(SHARED, '# shared from the peer\n'); + peerCommit(SHARED, '{"note":"shared from the peer"}\n'); fs.rmSync(path.join(tmpHome, SHARED)); write(CLEAN, '# a plan\n'); expect(drain().status).toBe(0); const s = statusJson(); expect(s.status).toBe('ok'); - expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n'); - expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared from the peer\n'); + expect(remoteShow(SHARED).stdout).toBe('{"note":"shared from the peer"}\n'); + expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared from the peer"}\n'); expect(unmerged()).toBe(''); expect(s.message).toContain(`local edits to ${SHARED} conflicted`); recover(s.message); @@ -934,7 +934,7 @@ describe('gstack-brain-sync per-file hold', () => { } finally { fs.rmSync(peer, { recursive: true, force: true }); } - write(SHARED, '# shared, local edit not queued\n', false); + write(SHARED, '{"note":"shared, local edit not queued"}\n', false); write(CLEAN, '# a plan\n'); expect(drain().status).toBe(0); const s = statusJson(); @@ -943,7 +943,7 @@ describe('gstack-brain-sync per-file hold', () => { expect(fs.existsSync(path.join(tmpHome, SHARED))).toBe(false); expect(unmerged()).toBe(''); recover(s.message); - expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n'); + expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared, local edit not queued"}\n'); }); test('a conflicting file whose name looks like a pattern resets only itself', () => { @@ -954,16 +954,16 @@ describe('gstack-brain-sync per-file hold', () => { expect(git(['merge', '-q', '--ff-only', 'origin/main']).status).toBe(0); peerCommit(GLOB, '# glob from the peer\n'); write(GLOB, '# glob, local edit not queued\n', false); - write(SHARED, '# shared, local edit not queued\n', false); + write(SHARED, '{"note":"shared, local edit not queued"}\n', false); write(CLEAN, '# a plan\n'); expect(drain().status).toBe(0); const s = statusJson(); expect(s.status).toBe('ok'); expect(fs.readFileSync(path.join(tmpHome, GLOB), 'utf-8')).toBe('# glob from the peer\n'); - expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n'); + expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared, local edit not queued"}\n'); recover(s.message); expect(fs.readFileSync(path.join(tmpHome, GLOB), 'utf-8')).toBe('# glob, local edit not queued\n'); - expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n'); + expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared, local edit not queued"}\n'); }); test('a file that never checks out clean does not strand the other local edits', () => { @@ -973,7 +973,7 @@ describe('gstack-brain-sync per-file hold', () => { expect(drain().status).toBe(0); fs.mkdirSync(path.join(tmpHome, '.git', 'info'), { recursive: true }); fs.writeFileSync(path.join(tmpHome, '.git', 'info', 'attributes'), '*.md text eol=lf\n'); - peerCommit(SHARED, '# shared from the peer\n'); + peerCommit(SHARED, '{"note":"shared from the peer"}\n'); write(OTHER, '# other, local edit not queued\n', false); write(CLEAN, '# a plan\n'); expect(drain().status).toBe(0); @@ -987,28 +987,28 @@ describe('gstack-brain-sync per-file hold', () => { setup(); write(OTHER, '# other, older stashed work\n', false); expect(git(['stash', 'push', '-q', '-m', 'older work']).status).toBe(0); - peerCommit(SHARED, '# shared from the peer\n'); - write(SHARED, '# shared, local edit not queued\n', false); + peerCommit(SHARED, '{"note":"shared from the peer"}\n'); + write(SHARED, '{"note":"shared, local edit not queued"}\n', false); write(CLEAN, '# a plan\n'); const shim = gitShim('case " $* " in *" stash push "*) exit 0;; esac'); expect(drain(shim.env).status).toBe(0); expect(statusJson().status).toBe('push_failed'); expect(git(['stash', 'list']).stdout).toContain('older work'); expect(fs.readFileSync(path.join(tmpHome, OTHER), 'utf-8')).toBe('# other v1\n'); - expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n'); + expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared, local edit not queued"}\n'); }); test('a conflicting merge is aborted and git\'s reason reaches the status', () => { setup(); - peerCommit(SHARED, '# shared from the peer\n'); - write(SHARED, '# shared, local commit\n'); + peerCommit(SHARED, '{"note":"shared from the peer"}\n'); + write(SHARED, '{"note":"shared, local commit"}\n'); expect(drain().status).toBe(0); const s = statusJson(); expect(s.status).toBe('push_failed'); expect(s.message).toContain(`Merge conflict in ${SHARED}`); expect(mergeHead()).toBe(false); expect(unmerged()).toBe(''); - expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local commit\n'); + expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('{"note":"shared, local commit"}\n'); // The next drain is not blocked by a half-finished merge. write(OTHER, '# other v2\n'); expect(drain().status).toBe(0); @@ -1270,6 +1270,130 @@ describe('gstack-brain-sync per-file hold', () => { expect(remoteShow(SNAP).stdout).toContain('"ccc"'); }); }); + + describe('a synced JSON file that does not parse is never committed (#3763)', () => { + const PROFILE = 'developer-profile.json'; + const MARKED = '{\n "sessions": [],\n<<<<<<< HEAD\n "v": "local"\n=======\n "v": "peer"\n>>>>>>> origin/main\n}\n'; + const parses = (text: string) => { try { JSON.parse(text); return true; } catch { return false; } }; + + test('a queued profile holding conflict markers is held back; the rest syncs; a repaired profile syncs next', () => { + init(); + write(PROFILE, MARKED); + write(CLEAN, '# a plan\n'); + expect(drain().status).toBe(0); + expect(remoteFiles()).toContain(CLEAN); + expect(remoteFiles()).not.toContain(PROFILE); + expect(staged()).toBe(''); + expect(spoolText()).toContain(PROFILE); + const s = statusJson(); + expect(s.status).toBe('held'); + expect(s.held.map((h: { path: string; rule: string }) => [h.path, h.rule])).toEqual([[PROFILE, 'invalid-json']]); + expect(s.held[0].fixes[0]).toContain(`${PROFILE} is not valid JSON`); + expect(s.held[0].fixes).toHaveLength(1); + expect(s.message).toContain('not valid JSON'); + expect(s.message).not.toContain('secret scan'); + + write(PROFILE, '{"sessions":[],"v":"repaired"}\n', false); + expect(drain().status).toBe(0); + expect(statusJson().status).toBe('ok'); + expect(remoteShow(PROFILE).stdout).toBe('{"sessions":[],"v":"repaired"}\n'); + }); + + test('a marker-free syntax error is held too', () => { + init(); + write(PROFILE, '{"sessions": [],}\n'); + expect(drain().status).toBe(0); + expect(remoteFiles()).not.toContain(PROFILE); + expect(statusJson().held.map((h: { rule: string }) => h.rule)).toEqual(['invalid-json']); + expect(spoolText()).toContain(PROFILE); + }); + + test('an unparseable decisions.active.json holds its coupled decisions.jsonl until repaired', () => { + init(); + writeDecisions(['a'], ['a']); + expect(drain().status).toBe(0); + write(LOG, [decide('a'), decide('bb')].join('\n') + '\n', false); + write(SNAP, '[{"id":"a"},\n<<<<<<< HEAD\n{"id":"bb"}\n=======\n{"id":"c"}\n>>>>>>> origin/main\n]\n', false); + run(['gstack-brain-enqueue', LOG]); + expect(drain().status).toBe(0); + const s = statusJson(); + expect(s.status).toBe('held'); + expect(s.held.map((h: { path: string; rule: string; dependents: string[] }) => [h.path, h.rule, h.dependents])).toEqual([[SNAP, 'invalid-json', [LOG]]]); + expect(remoteShow(LOG).stdout).toBe(decide('a') + '\n'); + expect(spoolText()).toContain(LOG); + + writeDecisions(['a', 'bb'], ['a', 'bb']); + expect(drain().status).toBe(0); + expect(statusJson().status).toBe('ok'); + expect(remoteShow(LOG).stdout).toBe([decide('a'), decide('bb')].join('\n') + '\n'); + }); + + test('two machines editing the profile never put conflict markers on the remote', () => { + init(); + write(PROFILE, '{"sessions":[],"v":"base"}\n'); + expect(drain().status).toBe(0); + // The retry merge commits with the ambient identity; a CI runner has none. + expect(git(['config', 'user.name', 'fixture']).status).toBe(0); + expect(git(['config', 'user.email', 'fixture@example.com']).status).toBe(0); + const peer = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-peer-')); + try { + expect(git(['clone', '-q', bareRemote, peer]).status).toBe(0); + fs.writeFileSync(path.join(peer, PROFILE), '{"sessions":[],"v":"peer"}\n'); + expect(git(['add', '-f', '--', PROFILE], peer).status).toBe(0); + expect(git(['-c', 'user.name=peer', '-c', 'user.email=peer@example.com', 'commit', '-qm', 'peer profile'], peer).status).toBe(0); + expect(git(['push', '-q', 'origin', 'HEAD'], peer).status).toBe(0); + } finally { + fs.rmSync(peer, { recursive: true, force: true }); + } + write(PROFILE, '{"sessions":[],"v":"local"}\n'); + expect(drain().status).toBe(0); + // Every later profile write queues the file again. + run(['gstack-brain-enqueue', PROFILE]); + write(CLEAN, '# a plan\n'); + expect(drain().status).toBe(0); + expect(parses(remoteShow(PROFILE).stdout)).toBe(true); + expect(remoteShow(PROFILE).stdout).not.toContain('<<<<<<<'); + expect(remoteShow(PROFILE).stdout).toBe('{"sessions":[],"v":"peer"}\n'); + }); + + test('an unfinished merge with a held profile commits nothing until the profile is repaired', () => { + init(); + write(PROFILE, '{"sessions":[],"v":"base"}\n'); + expect(drain().status).toBe(0); + expect(git(['config', 'user.name', 'fixture']).status).toBe(0); + expect(git(['config', 'user.email', 'fixture@example.com']).status).toBe(0); + const peer = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-peer-')); + try { + expect(git(['clone', '-q', bareRemote, peer]).status).toBe(0); + fs.writeFileSync(path.join(peer, PROFILE), '{"sessions":[],"v":"peer"}\n'); + expect(git(['add', '-f', '--', PROFILE], peer).status).toBe(0); + expect(git(['-c', 'user.name=peer', '-c', 'user.email=peer@example.com', 'commit', '-qm', 'peer profile'], peer).status).toBe(0); + expect(git(['push', '-q', 'origin', 'HEAD'], peer).status).toBe(0); + } finally { + fs.rmSync(peer, { recursive: true, force: true }); + } + fs.writeFileSync(path.join(tmpHome, PROFILE), '{"sessions":[],"v":"local"}\n'); + expect(git(['commit', '-qam', 'local profile']).status).toBe(0); + expect(git(['fetch', '-q', 'origin']).status).toBe(0); + const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim(); + expect(git(['merge', '--no-edit', `origin/${branch}`]).status).not.toBe(0); + run(['gstack-brain-enqueue', PROFILE]); + write(CLEAN, '# a plan\n'); + expect(drain().status).toBe(0); + expect(statusJson().status).toBe('blocked'); + expect(statusJson().message).toContain('unfinished'); + expect(statusJson().message).not.toContain('--skip-file '); + expect(remoteShow(PROFILE).stdout).toBe('{"sessions":[],"v":"peer"}\n'); + expect(remoteFiles()).not.toContain(CLEAN); + expect(git(['rev-parse', '-q', '--verify', 'MERGE_HEAD']).status).toBe(0); + + write(PROFILE, '{"sessions":[],"v":"merged"}\n', false); + expect(drain().status).toBe(0); + expect(statusJson().status).toBe('ok'); + expect(remoteShow(PROFILE).stdout).toBe('{"sessions":[],"v":"merged"}\n'); + expect(remoteFiles()).toContain(CLEAN); + }); + }); }); // --------------------------------------------------------------- diff --git a/test/gstack-developer-profile.test.ts b/test/gstack-developer-profile.test.ts index 11685bb64..46a945508 100644 --- a/test/gstack-developer-profile.test.ts +++ b/test/gstack-developer-profile.test.ts @@ -628,6 +628,24 @@ describe('gstack-developer-profile --check-mismatch', () => { // Error handling // ----------------------------------------------------------------------- +describe('gstack-developer-profile --profile on an unparseable profile (#3763)', () => { + test('exits non-zero, prints nothing to stdout, and names the file and the conflict markers', () => { + fs.writeFileSync(path.join(tmpHome, 'developer-profile.json'), '{\n<<<<<<< HEAD\n "sessions": []\n=======\n "sessions": [1]\n>>>>>>> origin/main\n}\n'); + const r = runDev('--profile'); + expect(r.status).not.toBe(0); + expect(r.stdout).toBe(''); + expect(r.stderr).toContain('developer-profile.json is not valid JSON'); + expect(r.stderr).toContain('git conflict markers'); + }); + + test('a valid profile still prints as before', () => { + fs.writeFileSync(path.join(tmpHome, 'developer-profile.json'), '{"sessions":[]}\n'); + const r = runDev('--profile'); + expect(r.status).toBe(0); + expect(r.stdout).toBe('{"sessions":[]}\n'); + }); +}); + describe('gstack-developer-profile errors', () => { test('unknown subcommand exits non-zero', () => { const r = runDev('--not-a-real-subcommand'); diff --git a/test/gstack-doctor.test.ts b/test/gstack-doctor.test.ts index 61a680b40..367034c42 100644 --- a/test/gstack-doctor.test.ts +++ b/test/gstack-doctor.test.ts @@ -136,6 +136,25 @@ describe('gstack-doctor', () => { expect(r.out).not.toContain('.brain-sync-status.json'); }); + test('artifacts sync: a synced JSON file with conflict markers warns by name (#3763)', () => { + const f = makeFixture(); + syncOn(f, { status: 'ok', message: 'pushed 3 file(s)', drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) }); + write(path.join(f.state, 'developer-profile.json'), '{\n<<<<<<< HEAD\n "v": 1\n=======\n "v": 2\n>>>>>>> origin/main\n}\n'); + const r = doctor(f); + expect(r.row('artifacts sync').state).toBe('warn'); + expect(r.row('artifacts sync').detail).toBe('developer-profile.json holds git conflict markers and does not parse'); + write(path.join(f.state, 'developer-profile.json'), '{"v": 2}\n'); + expect(doctor(f).row('artifacts sync').state).toBe('ok'); + }); + + test('artifacts sync: a JSON hold is not attributed to the secret scanner (#3763)', () => { + const f = makeFixture(); + syncOn(f, { status: 'held', message: 'held', held_count: 1, held: [{ path: 'developer-profile.json', rule: 'invalid-json', dependents: [], fixes: ['repair', 'skip'] }], drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) }); + const r = doctor(f); + expect(r.row('artifacts sync').state).toBe('warn'); + expect(r.row('artifacts sync').detail).toBe('status held (1 synced file(s) are not valid JSON; 1 held in all)'); + }); + test('artifacts sync: held, then ok', () => { const f = makeFixture(); syncOn(f, { status: 'held', message: 'held 2 file(s): sk-ant-secret', held_count: 2, held: [{ path: 'projects/p/notes.md', rule: 'anthropic_key', dependents: [], fixes: ['edit', 'skip'] }, { path: 'projects/p/b.md', rule: 'anthropic_key', dependents: [], fixes: ['edit', 'skip'] }], drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) }); diff --git a/test/gstack-skill-start.test.ts b/test/gstack-skill-start.test.ts index 5dca2124f..1fd895132 100644 --- a/test/gstack-skill-start.test.ts +++ b/test/gstack-skill-start.test.ts @@ -816,7 +816,7 @@ describe('ARTIFACTS_SYNC attention lines (sync status table)', () => { const held = runStart([], env); expect(held).toMatch(/^ARTIFACTS_SYNC: mode=full \| last_push=\S+ \| queue=\d+$/m); expect(attention(held)).toEqual([ - `ARTIFACTS_SYNC: attention: status=held held=1. The secret scan is holding back 1 file(s); everything else still syncs. See which files and how to fix them: ${path.join(ROOT, 'bin', 'gstack-brain-sync')} --status`, + `ARTIFACTS_SYNC: attention: status=held held=1. Sync is holding back 1 file(s) that the secret scan flagged or that are not valid JSON; everything else still syncs. See which files and how to fix them: ${path.join(ROOT, 'bin', 'gstack-brain-sync')} --status`, ]); expect(held).not.toContain('ghp_'); expect(remoteFiles()).toContain('projects/p/ceo-plans/clean.md'); From 35a676ff68b88619308591727ace7ac733650841 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 15:36:31 -0800 Subject: [PATCH 02/11] v1.92.47.0 fix(ship): /ship Step 5 runs test lanes one at a time on 4 cores or fewer, with --lanes serial|parallel (GST-851) (#3821) * fix(ship-preflight): run Step 5 lanes one at a time on 4 cores or fewer, with --lanes serial|parallel (GST-851) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: GST-851 fragment Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship): Step 5 says lanes run one at a time on 4 or fewer cores and names --lanes (GST-851 docs audit) 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> --- CHANGELOG.md | 7 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- lib/ship-bin-common.ts | 34 +++++++++++---- lib/ship-preflight.ts | 16 ++++--- package.json | 2 +- ship/sections/tests.md | 5 ++- ship/sections/tests.md.tmpl | 5 ++- test/ship-preflight.test.ts | 42 +++++++++++++++++++ .../ship/gst-851-serial-lanes.json | 4 ++ 10 files changed, 100 insertions(+), 19 deletions(-) create mode 100644 test/size-ledger/ship/gst-851-serial-lanes.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 2619a01d1..dab48b0c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.92.47.0] - 2026-11-01 + +- **`/ship` Step 5 runs its test lanes one at a time on machines with 4 or fewer cores, and `--lanes serial|parallel` picks the mode by hand** (`gstack-ship-preflight`, GST-851). Preflight used to start every `--lane` at once. On garrytan/gstack-evals#115 six lanes (a 1,451-test bun suite, tsc, a Docker tier and an arm tarball trial) started within 60 ms of each other on a 4-core, 16 GB machine, and running lanes together has taken machines down before (gstack-evals #101, #106, #107). Now, with no flag, preflight runs one lane at a time when `os.availableParallelism()` is 4 or less and all of them at once above that. `--lanes serial` or `--lanes parallel` overrides the default, and any other value, or `--lanes` with no value, is a usage error (exit 2). A new `LANES: serial|parallel (--lanes| cores)` line before the `LANE` lines says which mode ran and why. Each lane still prints its own `LANE` line with its own `tests_ran` count, in the order the lanes were given. Serial runs take the sum of the lane times instead of the longest one. There is no ship-policy key for this: a new key needs key versioning and a `min_gstack_version` bump, and the core-count default already covers the 4-core case. + +### Tests +- `test/ship-preflight.test.ts`: `--lanes serial` runs lanes one after another and keeps each lane's count; `--lanes parallel` keeps lane order when a later lane finishes first; an unflagged run names the core count; a bad or missing `--lanes` value is a usage error; and the core-count default is serial at 4 cores or fewer. + ## [1.92.46.0] - 2026-11-01 **brain-sync no longer publishes a synced JSON file that doesn't parse, and the profile and doctor now say when one is broken.** When two machines edit `developer-profile.json` before syncing, git's merge can leave conflict markers in it. brain-sync committed and pushed that file, so every machine got a profile that no longer parsed, while `gstack-developer-profile --profile` and `gstack-doctor` both reported everything ok (#3763). diff --git a/VERSION b/VERSION index cbd266e1d..f0f525626 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.46.0 +1.92.47.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 7d20d84f4..f4e17a48a 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.46.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.47.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/lib/ship-bin-common.ts b/lib/ship-bin-common.ts index 2702f251f..0bb6e6b05 100644 --- a/lib/ship-bin-common.ts +++ b/lib/ship-bin-common.ts @@ -7,6 +7,7 @@ */ import { spawn, spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { fragmentSlug } from './changelog-fragments'; import { parseUnifiedDiff, stampOnlyLine, versionAtRef } from './restamp'; @@ -150,13 +151,32 @@ export class Report { } } -/** Run each lane through gstack-evidence run in parallel and read its recorded line. */ -export async function runLanes(cwd: string, list: { label: string; command: string }[]): Promise<{ label: string; exit: number | null; testsRan: string; log: string }[]> { - return Promise.all(list.map(async l => { - const r = await binAsync(cwd, 'gstack-evidence', ['run', '--label', l.label, '--', l.command]); - const m = /gstack-evidence: recorded label=\S+ exit=(\S+) tests_ran=(\S+) log=(\S+)/.exec(r.stderr); - return { label: l.label, exit: r.status, testsRan: m?.[2] ?? 'unknown', log: m?.[3] ?? '-' }; - })); +/** + * How many lanes run at once (GST-851): `--lanes serial` one, `--lanes parallel` all; unset, one on a machine with 4 or fewer + * cores (six lanes at once took 4-core machines down on gstack-evals) and all above that. Any other value is a usage error. + */ +export function laneConcurrency(mode: string | undefined, cpus = os.availableParallelism()): number | string { + if (mode === 'serial') return 1; + if (mode === 'parallel') return Infinity; + if (mode !== undefined) return `--lanes ${mode} is not serial or parallel`; + return cpus <= 4 ? 1 : Infinity; +} + +/** Run each lane through gstack-evidence run, at most `concurrency` at a time, and read its recorded line; results keep the lanes' order. */ +export async function runLanes(cwd: string, list: { label: string; command: string }[], concurrency = Infinity): Promise<{ label: string; exit: number | null; testsRan: string; log: string }[]> { + const out: { label: string; exit: number | null; testsRan: string; log: string }[] = []; + let next = 0; + const worker = async () => { + while (next < list.length) { + const i = next++; + const l = list[i]!; + const r = await binAsync(cwd, 'gstack-evidence', ['run', '--label', l.label, '--', l.command]); + const m = /gstack-evidence: recorded label=\S+ exit=(\S+) tests_ran=(\S+) log=(\S+)/.exec(r.stderr); + out[i] = { label: l.label, exit: r.status, testsRan: m?.[2] ?? 'unknown', log: m?.[3] ?? '-' }; + } + }; + await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, list.length)) }, worker)); + return out; } /** Step 5's lane verdict: a lane passes on its runner count, never exit 0 alone. */ diff --git a/lib/ship-preflight.ts b/lib/ship-preflight.ts index ed1f48f50..519ee5475 100644 --- a/lib/ship-preflight.ts +++ b/lib/ship-preflight.ts @@ -6,22 +6,24 @@ * agent (a merge conflict, a failing or zero-run lane, the carry route, the Apple target). */ import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { classifyDiff } from './review-depth'; import { audit } from './ship-audit'; -import { bin, firstLine, flagReader, gh, git, hostedOrigin, laneLine, lanes, Report, runLanes, shipPolicy, short } from './ship-bin-common'; +import { bin, firstLine, flagReader, gh, git, hostedOrigin, laneConcurrency, laneLine, lanes, Report, runLanes, shipPolicy, 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 [--lane