mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* v0.41.11.0 feat: conversation retrieval upgrade — production-bar replacement for PR #1406 Long chat threads stop swallowing your search results. The recall miss class on long iMessage/Slack imports (60K+ msg history; a chunk that reads only "Locker 93 code 9494" has no topical anchor because "cabin" was established 50K messages earlier) gets fixed by walking conversation/meeting/slack/email pages, splitting into time-windowed segments (30-min gap or 30-msg cap), prepending a topical/temporal header, and running through the existing extractFactsFromTurn() so the resulting anchor-rich facts surface in gbrain search. This is the production-bar replacement for PR #1406 (which closes LAST per Codex T6d, AFTER this PR is green). The bug fix survives 1:1; the wrapping closes 14 load-bearing issues the original PR deferred or shipped silent bugs around. The wave went through CEO scope review, 3 rounds of spec review, 2 rounds of Codex outside voice grounding the plan against actual code, and 2 passes of eng review. Version-slot note: originally planned as v0.41.2.0; master shipped its own v0.41.2.0 (lens packs) plus v0.41.3-6.0 between plan-time and ship-time. Re-bumped to v0.41.11.0 (next free slot; v0.41.7-10 claimed by other open PRs). Key files (new): - src/commands/extract-conversation-facts.ts — CLI command with --types, --max-cost-usd, --background, --override-disabled, --slug, --dry-run, --limit, --since, --force, --sleep, --segment-limit, --source-id. Strict per-source core; two-phase page enumeration (paginated listPages with 10×25MB cap = 250MB worst case); 25MB body cap; page-global row_num accumulator (Codex C1 unique-index collision fix); page-level TERMINAL audit row after all segments commit (Codex C7 durable extraction marker); optional opts.budgetTracker (Codex C5 — nested withBudgetTracker REPLACES, so caller-managed scope passes tracker through); reads compiled_truth + timeline (F1 — PR silently dropped timeline half); honors facts.extraction_enabled kill-switch with --override-disabled escape (F2); --types reads cycle config as single source of truth (Eng-v2 A2); fingerprint on sourceId only (Eng-v2 A3 — widening types doesn't invalidate completion); string-encoded op-checkpoint entries "sourceId|slug|endIso" for resume; segment caps tuned 6500/30 (Eng-v2 T5) to stay under extract.ts MAX_TURN_TEXT_CHARS=8000. - src/core/cycle/conversation-facts-backfill.ts — cycle phase wrapper (default OFF). Iterates listSources() directly; creates ONE brain-wide BudgetTracker per tick + wraps the loop in withBudgetTracker + passes tracker through opts.budgetTracker so core doesn't nest-replace. Two-layer cost AND walltime protection: per-source caps ($1, 20min) AND brain-wide caps ($5, 30min). - test/extract-conversation-facts.test.ts — 27 unit cases (parse, segment, render, checkpoint encoding, fingerprint, terminal audit row, row_num accumulator, F2 kill-switch, --override-disabled). - skills/migrations/v0.41.11.0.md — agent-facing migration guide. Key files (modified): - src/commands/jobs.ts — register extract-conversation-facts Minion handler. NOT in PROTECTED_JOB_NAMES; BudgetExhausted catch + persist + mark completed with result.budget_exhausted (NOT a failure). - src/commands/doctor.ts — computeConversationFactsBacklogCheck (3-state: SKIPPED when feature disabled per Eng-v2 C9, OK at backlog=0, WARN at >10 with paste-ready remediation step via makeRemediationStep). Doctor query is source-scoped (Codex C2 cross-source safety) and matches the TERMINAL audit row (Codex C7), not any-fact-for-slug. - src/commands/sources.ts — runAudit extended with facts_backfill_estimate field for cost preview. - src/cli.ts — CLI_ONLY + CLI_ONLY_SELF_HELP + THIN_CLIENT_REFUSED_COMMANDS + dispatch case for extract-conversation-facts. - src/core/cycle.ts — new CyclePhase 'conversation_facts_backfill'; PHASE_SCOPE='source' (taxonomy only per cycle.ts:131 — wrapper does own multi-source iteration); wired into ALL_PHASES + NEEDS_LOCK_PHASES; dispatch block runs between consolidate and embed. - src/core/migrate.ts — migration v94 adds partial index idx_facts_extract_conversation_session ON facts(source_id, source_session) WHERE source LIKE 'cli:extract-conversation-facts%' so doctor query stays fast on million-fact brains. v14 precedent: transaction:false + invalid-index pre-drop on Postgres, plain CREATE INDEX on PGLite. - src/core/schema-pack/base/gbrain-base.yaml — promote conversation (temporal, extractable) and atom (annotation, NOT extractable — atoms ARE the extracted form) into base. Flip concept.extractable: true semantically (cosmetic on backstop path per Codex T3; the original grandfather migration was solving a phantom, dropped). Filing rules added for both new types. - src/core/schema-pack/base/gbrain-recommended.yaml — remove duplicate conversation (now inherits via extends: gbrain-base). - src/core/types.ts — ALL_PAGE_TYPES extended with conversation, atom. - test/extractable-pack.test.ts — updated parity gate (24 page types vs PR's 22; concept + conversation now extractable, atom not). - test/schema-cli.test.ts — page-count expectation 22→24. - VERSION + package.json bumped to 0.41.11.0. - CHANGELOG.md release-summary in the required ELI10-first voice + itemized changes section. - CLAUDE.md Key Files entry for the new modules + architecture notes. - llms.txt + llms-full.txt regenerated. Plan + decisions persisted at: ~/.claude/plans/system-instruction-you-are-working-linear-unicorn.md CEO plan at: ~/.gstack/projects/garrytan-gbrain/ceo-plans/2026-05-25-conversation-retrieval-upgrade.md Co-Authored-By: garrytan-agents <garrytan-agents@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: align v0.41.11.0 phase ordering + bump hardcoded counts after master merge Three CI failures from the master merge in this branch: 1. test/phase-scope-coverage.test.ts pinned `ALL_PHASES.length === 19` and `Object.keys(PHASE_SCOPE).length === 19`. After merging master's v0.41 lens-packs (extract_atoms + synthesize_concepts) + my new conversation_facts_backfill phase, the total is 20. 2. test/core/cycle.serial.test.ts had two hardcoded `19` assertions (`hookCalls` and `report.phases.length`) tracking the same count. Both bumped to 20. 3. cycle.serial's `'default: all 6 phases run in order'` test asserts `report.phases.map(p => p.phase) === ALL_PHASES`. My initial commit put `conversation_facts_backfill` in ALL_PHASES between consolidate and propose_takes, but the runCycle dispatch block runs it AFTER the calibration trio (propose_takes / grade_takes / calibration_profile) and BEFORE embed. List and dispatch order didn't match, so the equality assertion failed. Resolution: moved 'conversation_facts_backfill' in ALL_PHASES to AFTER 'calibration_profile' so list-order matches dispatch-order. The dispatch block placement was correct (and remains correct); the list-position comment originally said "AFTER consolidate" but the dispatch runs it after the WHOLE consolidate→calibration_profile block, not just after consolidate. Comment now reflects reality. Verified: 61/61 pass across the 3 affected test files (2.9s wallclock). The CI logs also showed a "(unnamed) [3058.07ms]" failure in shard 1; unable to reproduce locally (test/scripts/run-unit-parallel.test.ts passes 6/6 in 1s). Suspected CI-load flakiness under bun's parallel scheduler. If it persists on the next CI run, will dig in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): orphan sleep cleanup in run-unit-parallel.sh heartbeat Two CI runs in a row reported `(fail) (unnamed) [~2400ms]` in shard 1 on this PR. Investigation: - CI's end-of-job cleanup logged: "Terminate orphan process: pid (3344) (sleep)" × 6 sleep processes. - The 6 matches exactly the 6 `runWrapper()` calls in test/scripts/run-unit-parallel.test.ts (1 orphan sleep per invocation). - Each `runWrapper()` spawns scripts/run-unit-parallel.sh, which spawns a heartbeat function that runs `while true; do sleep 10; ...; done` in the background. - The wrapper's EXIT trap was `kill "$HB_PID" 2>/dev/null` — kills the heartbeat shell, but its currently-running `sleep 10` child gets reparented to init/launchd because SIGTERM to a bash shell sleeping inside `sleep` doesn't propagate to the sleep child before wait returns. Known bash quirk on Linux. - bun's test runner treats the orphan sleeps as a `(unnamed)` failure attributed to the test file that spawned the wrapper. Fix: pkill children FIRST, then kill heartbeat. If we kill heartbeat first, its child sleep orphans and pkill -P can no longer find it (ppid changes to 1). Reorder applied to both the trap AND the normal shutdown path. Verified locally: before fix, 6 orphan sleeps after the test ran; after fix, 0 orphan sleeps. Test still passes 6/6 in ~1s. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
370 lines
18 KiB
Bash
Executable File
370 lines
18 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# scripts/run-unit-parallel.sh — fast unit-test loop, parallel fan-out.
|
||
#
|
||
# Spawns N parallel `bun test` processes, each running a hash-disjoint shard
|
||
# of the unit-test set (files only — no e2e, no .slow, no .serial). After
|
||
# all shards complete, runs serial-only files (*.serial.test.ts) with
|
||
# --max-concurrency=1. Failure-first logging: extracts failure blocks from
|
||
# each shard's log, writes to .context/test-failures.log with --- shard $i:
|
||
# prefixes, prints loud stderr banner if any failures, exit non-zero.
|
||
#
|
||
# Usage:
|
||
# bash scripts/run-unit-parallel.sh [--shards N] [--max-concurrency N] [--dry-run]
|
||
#
|
||
# Env overrides:
|
||
# SHARDS=N same as --shards
|
||
# GBRAIN_TEST_SHARD_TIMEOUT per-shard wallclock cap, seconds (default 600)
|
||
# GBRAIN_TEST_MAX_CONCURRENCY passed through to bun test (default 4)
|
||
#
|
||
# Output files (workspace-local; falls back to /tmp if .context/ unwritable):
|
||
# .context/test-failures.log failure blocks (cleared at start)
|
||
# .context/test-summary.txt per-shard pass/fail/skip/duration (cleared at start)
|
||
# .context/test-shards/ per-shard logs + exit codes (cleared at start)
|
||
|
||
set -uo pipefail
|
||
|
||
cd "$(dirname "$0")/.."
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# CPU detection: Apple Silicon perf cores → Mac total physical → nproc → 4.
|
||
# Returns a single positive integer.
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
detect_cpus() {
|
||
local n=""
|
||
n=$(sysctl -n hw.perflevel0.physicalcpu 2>/dev/null) && [ -n "$n" ] && [ "$n" -gt 0 ] && echo "$n" && return
|
||
n=$(sysctl -n hw.physicalcpu 2>/dev/null) && [ -n "$n" ] && [ "$n" -gt 0 ] && echo "$n" && return
|
||
n=$(nproc 2>/dev/null) && [ -n "$n" ] && [ "$n" -gt 0 ] && echo "$n" && return
|
||
echo 4
|
||
}
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# Argument parsing. --shards N override wins over $SHARDS; both are clamped.
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
SHARDS_OVERRIDE=""
|
||
MAX_CONCURRENCY_OVERRIDE=""
|
||
DRY_RUN=0
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--shards) SHARDS_OVERRIDE="$2"; shift 2 ;;
|
||
--shards=*) SHARDS_OVERRIDE="${1#*=}"; shift ;;
|
||
--max-concurrency) MAX_CONCURRENCY_OVERRIDE="$2"; shift 2 ;;
|
||
--max-concurrency=*) MAX_CONCURRENCY_OVERRIDE="${1#*=}"; shift ;;
|
||
--dry-run) DRY_RUN=1; shift ;;
|
||
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
|
||
esac
|
||
done
|
||
|
||
N="${SHARDS_OVERRIDE:-${SHARDS:-$(detect_cpus)}}"
|
||
if ! printf '%s' "$N" | grep -qE '^[0-9]+$' || [ "$N" -lt 1 ]; then
|
||
echo "ERROR: invalid shard count: $N" >&2; exit 2
|
||
fi
|
||
# v0.40.10 flake-hardening: clamp default to 4 (was 8) to match CI's
|
||
# test-shard.sh fan-out. At 8-shard parallel on Apple Silicon we observed
|
||
# shard 5 SIGKILL during source-health.test.ts's PGLite migration replay —
|
||
# 8 parallel PGLite WASM inits contend severely on the lockfile, and the
|
||
# 92-migration replay × 8 simultaneous can wedge past even 900s. CI uses
|
||
# 4 and is stable. Trade ~2x wallclock for reliability + parity with CI's
|
||
# fan-out. Override via --shards N or SHARDS=N (still capped at 8).
|
||
[ "$N" -gt 8 ] && N=8
|
||
if [ -z "${SHARDS_OVERRIDE:-}" ] && [ -z "${SHARDS:-}" ] && [ "$N" -gt 4 ]; then
|
||
N=4
|
||
fi
|
||
|
||
INTRA_CONC="${MAX_CONCURRENCY_OVERRIDE:-${GBRAIN_TEST_MAX_CONCURRENCY:-4}}"
|
||
# v0.40.10 flake-hardening: bump per-shard cap 600 → 1500 (was 900). At
|
||
# 4-shard default each shard runs 159 files / ~2420 tests with internal
|
||
# wallclock 960-1020s. The 900s value (sized for 8-shard's ~80 files /
|
||
# 1100 tests at 620-770s) false-killed shard 1 at 900s even though it
|
||
# had completed in 968s. 1500s cap gives ~55% headroom over observed
|
||
# 4-shard wallclock; real hangs still hit it. Override via
|
||
# GBRAIN_TEST_SHARD_TIMEOUT=N.
|
||
SHARD_TIMEOUT="${GBRAIN_TEST_SHARD_TIMEOUT:-1500}"
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# Output directories. Prefer workspace-local .context/, fall back to /tmp.
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
LOG_DIR=""
|
||
if mkdir -p .context/test-shards 2>/dev/null; then
|
||
LOG_DIR=".context/test-shards"
|
||
FAILURES_LOG=".context/test-failures.log"
|
||
SUMMARY_FILE=".context/test-summary.txt"
|
||
else
|
||
LOG_DIR="/tmp/gbrain-test-shards-$$"
|
||
FAILURES_LOG="/tmp/gbrain-test-failures.log"
|
||
SUMMARY_FILE="/tmp/gbrain-test-summary.txt"
|
||
mkdir -p "$LOG_DIR" || { echo "ERROR: cannot create log dir" >&2; exit 2; }
|
||
fi
|
||
# Clear from prior run.
|
||
rm -f "$LOG_DIR"/shard-*.log "$LOG_DIR"/shard-*.exit "$LOG_DIR"/shard-*.wedged 2>/dev/null
|
||
: > "$FAILURES_LOG"
|
||
: > "$SUMMARY_FILE"
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# Resolve `timeout` command. macOS without coreutils has neither; we degrade
|
||
# to bg-pid + sleep cap. For now, prefer gtimeout (brew coreutils) → timeout.
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
TIMEOUT_BIN=""
|
||
if command -v gtimeout >/dev/null 2>&1; then TIMEOUT_BIN="gtimeout"
|
||
elif command -v timeout >/dev/null 2>&1; then TIMEOUT_BIN="timeout"
|
||
fi
|
||
|
||
START_TS=$(date +%s)
|
||
echo "[unit-parallel] N=$N shards | --max-concurrency=$INTRA_CONC | timeout=${SHARD_TIMEOUT}s | logs=$LOG_DIR" >&2
|
||
|
||
if [ "$DRY_RUN" = "1" ]; then
|
||
echo "[unit-parallel] dry-run: would spawn $N shards with the above settings."
|
||
for i in $(seq 1 "$N"); do
|
||
SHARD="$i/$N" bash scripts/run-unit-shard.sh --dry-run-list 2>/dev/null \
|
||
| sed "s|^| [s$i] |"
|
||
done
|
||
exit 0
|
||
fi
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# Spawn shards. Each child captures its own exit code into a sentinel file
|
||
# so $? is recoverable per-shard (we never trust `wait`'s aggregate value).
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
SHARD_PIDS=()
|
||
for i in $(seq 1 "$N"); do
|
||
(
|
||
SHARD_LOG="$LOG_DIR/shard-$i.log"
|
||
if [ -n "$TIMEOUT_BIN" ]; then
|
||
"$TIMEOUT_BIN" "${SHARD_TIMEOUT}s" \
|
||
env SHARD="$i/$N" \
|
||
bash scripts/run-unit-shard.sh --max-concurrency="$INTRA_CONC" \
|
||
> "$SHARD_LOG" 2>&1
|
||
else
|
||
env SHARD="$i/$N" \
|
||
bash scripts/run-unit-shard.sh --max-concurrency="$INTRA_CONC" \
|
||
> "$SHARD_LOG" 2>&1 &
|
||
pid=$!
|
||
( sleep "$SHARD_TIMEOUT" && kill -TERM "$pid" 2>/dev/null && \
|
||
sleep 5 && kill -KILL "$pid" 2>/dev/null ) &
|
||
cap_pid=$!
|
||
wait "$pid" 2>/dev/null
|
||
kill "$cap_pid" 2>/dev/null
|
||
wait "$cap_pid" 2>/dev/null
|
||
fi
|
||
rc=$?
|
||
echo "$rc" > "$LOG_DIR/shard-$i.exit"
|
||
[ "$rc" = "124" ] && echo "WEDGED" > "$LOG_DIR/shard-$i.wedged"
|
||
) &
|
||
SHARD_PIDS+=($!)
|
||
done
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# Heartbeat: every 10s, print per-shard progress to stderr by tailing logs
|
||
# and counting Bun's `(pass)` / `(fail)` / `(skip)` markers. Read-only.
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# grep_count: returns 0 (single integer) if file is missing or zero matches,
|
||
# otherwise the match count. Avoids the `grep -c | echo 0` double-output bug
|
||
# where 0 matches produces a 2-line "0\n0" string that breaks arithmetic.
|
||
grep_count() {
|
||
local pattern="$1"; local file="$2"
|
||
if [ ! -f "$file" ]; then echo 0; return; fi
|
||
local n
|
||
n=$(grep -cE "$pattern" "$file" 2>/dev/null) || n=0
|
||
echo "${n:-0}"
|
||
}
|
||
|
||
# bun_summary_count: parses Bun's summary lines (one per `bun test` invocation
|
||
# inside a shard — there's only one when we pass an explicit file list).
|
||
# Looks for ` N pass` / ` N fail` / ` N skip` patterns and sums them across
|
||
# all summary blocks the shard emitted. `bun test` prints these near the end
|
||
# of its output. Format: leading whitespace + integer + space + label.
|
||
bun_summary_count() {
|
||
local label="$1"; local file="$2"
|
||
if [ ! -f "$file" ]; then echo 0; return; fi
|
||
awk -v label="$label" '
|
||
$1 ~ /^[0-9]+$/ && $2 == label { total += $1 }
|
||
END { print total + 0 }
|
||
' "$file"
|
||
}
|
||
|
||
heartbeat() {
|
||
while true; do
|
||
sleep 10
|
||
local line=""
|
||
for i in $(seq 1 "$N"); do
|
||
if [ -f "$LOG_DIR/shard-$i.exit" ]; then
|
||
local rc; rc=$(cat "$LOG_DIR/shard-$i.exit" 2>/dev/null || echo "?")
|
||
local status="✓"
|
||
[ "$rc" != "0" ] && status="✗"
|
||
line="$line [s$i: done $status]"
|
||
else
|
||
local lf="$LOG_DIR/shard-$i.log"
|
||
if [ -f "$lf" ]; then
|
||
# Heartbeat: prefer Bun's per-test "✓" (passed) and "(fail)" markers
|
||
# so we see live progress; the "N pass" summary line only appears at
|
||
# the very end of the shard and would always show 0 mid-run.
|
||
local p f
|
||
p=$(grep_count '^[[:space:]]+✓' "$lf")
|
||
f=$(grep_count '^\(fail\)' "$lf")
|
||
line="$line [s$i: ${p}p ${f}f ...]"
|
||
else
|
||
line="$line [s$i: starting]"
|
||
fi
|
||
fi
|
||
done
|
||
printf '[heartbeat] %s\n' "$line" >&2
|
||
done
|
||
}
|
||
heartbeat &
|
||
HB_PID=$!
|
||
# v0.41.11.0 cleanup: pkill children FIRST, then kill heartbeat. If we
|
||
# kill the heartbeat shell first, its current `sleep 10` is reparented
|
||
# to init/launchd and pkill -P can no longer find it (orphan). Order:
|
||
# children first while the parent PID is still findable, then parent.
|
||
# Known bash quirk: SIGTERM to a shell sleeping inside `sleep` doesn't
|
||
# propagate to the sleep child before the wait returns. Without this,
|
||
# each invocation of this script leaks ONE orphan sleep; CI's "orphan
|
||
# process cleanup" at end-of-job reports them as (unnamed) test failures.
|
||
# Seen on the garrytan/port-pr-1406 PR, 2 CI runs in a row, 6 orphans
|
||
# matching the 6 invocations in test/scripts/run-unit-parallel.test.ts.
|
||
trap 'pkill -P "$HB_PID" 2>/dev/null; kill "$HB_PID" 2>/dev/null; wait "$HB_PID" 2>/dev/null' EXIT
|
||
|
||
# Wait for every shard. Don't care about wait's exit code.
|
||
for pid in "${SHARD_PIDS[@]}"; do wait "$pid" 2>/dev/null || true; done
|
||
|
||
pkill -P "$HB_PID" 2>/dev/null
|
||
kill "$HB_PID" 2>/dev/null
|
||
wait "$HB_PID" 2>/dev/null
|
||
trap - EXIT
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# Aggregate failures (single writer; serial; never concurrent).
|
||
# Bun failure block format: from `(fail) ...` line through next `(pass)`,
|
||
# `(skip)`, blank line, or `__bun_test_summary__` marker.
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
TOTAL_FAILURES=0
|
||
TOTAL_PASS=0
|
||
TOTAL_SKIP=0
|
||
TOTAL_RC=0
|
||
for i in $(seq 1 "$N"); do
|
||
SHARD_LOG="$LOG_DIR/shard-$i.log"
|
||
EXIT_FILE="$LOG_DIR/shard-$i.exit"
|
||
WEDGED_FILE="$LOG_DIR/shard-$i.wedged"
|
||
rc=1
|
||
[ -f "$EXIT_FILE" ] && rc=$(cat "$EXIT_FILE" 2>/dev/null || echo 1)
|
||
|
||
pass_count=$(bun_summary_count "pass" "$SHARD_LOG")
|
||
fail_count=$(bun_summary_count "fail" "$SHARD_LOG")
|
||
skip_count=$(bun_summary_count "skip" "$SHARD_LOG")
|
||
TOTAL_PASS=$((TOTAL_PASS + pass_count))
|
||
TOTAL_FAILURES=$((TOTAL_FAILURES + fail_count))
|
||
TOTAL_SKIP=$((TOTAL_SKIP + skip_count))
|
||
|
||
if [ -f "$WEDGED_FILE" ]; then
|
||
TOTAL_RC=1
|
||
{
|
||
echo "--- shard $i: WEDGED after ${SHARD_TIMEOUT}s ---"
|
||
[ -f "$SHARD_LOG" ] && tail -50 "$SHARD_LOG"
|
||
echo ""
|
||
} >> "$FAILURES_LOG"
|
||
echo "shard $i/$N: WEDGED after ${SHARD_TIMEOUT}s (rc=$rc)" >> "$SUMMARY_FILE"
|
||
continue
|
||
fi
|
||
|
||
echo "shard $i/$N: pass=$pass_count fail=$fail_count skip=$skip_count rc=$rc" >> "$SUMMARY_FILE"
|
||
|
||
if [ "$rc" != "0" ]; then
|
||
TOTAL_RC=1
|
||
if [ "$fail_count" -gt 0 ] && [ -f "$SHARD_LOG" ]; then
|
||
# Extract each (fail) block: from `(fail)` line through next `(pass)`,
|
||
# `(skip)`, blank line, or `__bun_test_summary__`. Single awk pass.
|
||
awk -v shard="$i" '
|
||
/^\(fail\) / { in_block=1; print "--- shard " shard ": " $0; next }
|
||
in_block {
|
||
if (/^\(pass\)/ || /^\(skip\)/ || /^[[:space:]]*$/ || /__bun_test_summary__/) { in_block=0; print ""; next }
|
||
print $0
|
||
}
|
||
' "$SHARD_LOG" >> "$FAILURES_LOG"
|
||
elif [ -f "$SHARD_LOG" ]; then
|
||
# Non-zero rc but no (fail) line found — extraction couldn't pinpoint.
|
||
# Dump the full shard log so we never silently lose the failure cause.
|
||
{
|
||
echo "--- shard $i: rc=$rc, no (fail) markers — full log follows ---"
|
||
cat "$SHARD_LOG"
|
||
echo ""
|
||
} >> "$FAILURES_LOG"
|
||
fi
|
||
fi
|
||
done
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# Print each shard's full output to stdout (developer expects to scroll
|
||
# through it). Print summary file last for one-glance overview.
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
for i in $(seq 1 "$N"); do
|
||
SHARD_LOG="$LOG_DIR/shard-$i.log"
|
||
echo ""
|
||
echo "════════════ shard $i/$N ════════════"
|
||
[ -f "$SHARD_LOG" ] && cat "$SHARD_LOG"
|
||
done
|
||
echo ""
|
||
echo "════════════ summary ════════════"
|
||
cat "$SUMMARY_FILE"
|
||
echo ""
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# Serial pass: any *.serial.test.ts files run after parallel pass.
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
SERIAL_RC=0
|
||
SERIAL_FILES_COUNT=0
|
||
SERIAL_FILES_COUNT=$(find test -name '*.serial.test.ts' -not -path 'test/e2e/*' 2>/dev/null | wc -l | tr -d ' ')
|
||
if [ "$SERIAL_FILES_COUNT" -gt 0 ]; then
|
||
echo "════════════ serial pass ($SERIAL_FILES_COUNT files) ════════════"
|
||
bash scripts/run-serial-tests.sh > "$LOG_DIR/serial.log" 2>&1
|
||
SERIAL_RC=$?
|
||
cat "$LOG_DIR/serial.log"
|
||
if [ "$SERIAL_RC" != "0" ]; then
|
||
TOTAL_RC=1
|
||
s_fail=$(bun_summary_count "fail" "$LOG_DIR/serial.log")
|
||
TOTAL_FAILURES=$((TOTAL_FAILURES + s_fail))
|
||
if [ "$s_fail" -gt 0 ]; then
|
||
awk '
|
||
/^\(fail\) / { in_block=1; print "--- shard serial: " $0; next }
|
||
in_block {
|
||
if (/^\(pass\)/ || /^\(skip\)/ || /^[[:space:]]*$/ || /__bun_test_summary__/) { in_block=0; print ""; next }
|
||
print $0
|
||
}
|
||
' "$LOG_DIR/serial.log" >> "$FAILURES_LOG"
|
||
else
|
||
{
|
||
echo "--- shard serial: rc=$SERIAL_RC, no (fail) markers — full log follows ---"
|
||
cat "$LOG_DIR/serial.log"
|
||
echo ""
|
||
} >> "$FAILURES_LOG"
|
||
fi
|
||
echo "serial: rc=$SERIAL_RC fail=$s_fail" >> "$SUMMARY_FILE"
|
||
else
|
||
s_pass=$(bun_summary_count "pass" "$LOG_DIR/serial.log")
|
||
TOTAL_PASS=$((TOTAL_PASS + s_pass))
|
||
echo "serial: pass=$s_pass rc=0" >> "$SUMMARY_FILE"
|
||
fi
|
||
fi
|
||
|
||
END_TS=$(date +%s)
|
||
ELAPSED=$((END_TS - START_TS))
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# Loud banner if anything failed. To stderr so it survives `| head`/`| tail`.
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
if [ "$TOTAL_RC" != "0" ]; then
|
||
ABS_FAIL=$(cd "$(dirname "$FAILURES_LOG")" && pwd)/$(basename "$FAILURES_LOG")
|
||
{
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "❌ $TOTAL_FAILURES TEST FAILURES — full details:"
|
||
echo " $ABS_FAIL"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
tail -30 "$FAILURES_LOG"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "[unit-parallel] elapsed=${ELAPSED}s | pass=$TOTAL_PASS fail=$TOTAL_FAILURES skip=$TOTAL_SKIP"
|
||
} >&2
|
||
exit 1
|
||
fi
|
||
|
||
echo "[unit-parallel] elapsed=${ELAPSED}s | pass=$TOTAL_PASS fail=$TOTAL_FAILURES skip=$TOTAL_SKIP" >&2
|
||
exit 0
|