ab-new-vs-baseline.sh 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. #!/usr/bin/env bash
  2. # A/B a codegraph retrieval/steering change: the NEW build (current HEAD) vs a
  3. # BASELINE build (a git ref) — BOTH with codegraph attached — on the same
  4. # implementation task, measuring how many Read vs codegraph calls the agent
  5. # makes. ISOLATES the change (unlike run-all.sh's with-vs-without). The agent
  6. # works on a throwaway copy of the target, so your repos are never touched.
  7. #
  8. # Reliable attach (works even when this is itself run nested inside a Claude
  9. # session): each arm PRE-WARMS a persistent codegraph daemon for its target so
  10. # claude connects to an already-bound, index-loaded daemon instantly — before
  11. # the agent's first turn — and SKIPS codegraph's startup re-exec via
  12. # CODEGRAPH_WASM_RELAUNCHED=1. Without this, on a multi-step task the agent
  13. # dives into Read/grep before codegraph finishes its ~2-3s startup (worse under
  14. # the CPU contention of a nested run) and runs with NO codegraph.
  15. #
  16. # Gotcha: claude's `system/init` snapshot can read status:"pending" / 0 tools
  17. # even when the server then connects fine — judge by ACTUAL codegraph usage in
  18. # parse-run.mjs's "by type", not the init line.
  19. #
  20. # Usage: ab-new-vs-baseline.sh <indexed-repo> "<task>" [baseline-ref]
  21. # <indexed-repo> a repo with a .codegraph index (copied per arm)
  22. # "<task>" an implementation task, e.g. "Add X to Y and wire it through"
  23. # [baseline-ref] git ref for the BEFORE build (default: HEAD~1)
  24. # Env:
  25. # AGENT_EVAL_OUT output dir (default: /tmp/ab-new-vs-baseline)
  26. # RUNS runs per arm (default 1). Run-to-run variance is large —
  27. # use >=2 and report the range, never a single run. Both arms
  28. # build/index ONCE and then run RUNS times, so raising this is
  29. # far cheaper than re-invoking the script.
  30. # MODEL / EFFORT default sonnet / high. Never raise without a reason: sonnet
  31. # is the deliberate floor model (see CLAUDE.md).
  32. #
  33. # Both arms run with CODEGRAPH_NO_PROMPT_HOOK=1: the machine's ambient
  34. # UserPromptSubmit front-load hook resolves to whichever build is currently in
  35. # dist/, so leaving it on injects context through a second, uncontrolled channel
  36. # and confounds the tool-call counts this script exists to compare.
  37. set -uo pipefail
  38. TARGET="${1:?usage: ab-new-vs-baseline.sh <indexed-repo> \"<task>\" [baseline-ref]}"
  39. TASK="${2:?task required}"
  40. BASE_REF="${3:-HEAD~1}"
  41. ENGINE="$(cd "$(dirname "$0")/../.." && pwd)"
  42. BIN="$ENGINE/dist/bin/codegraph.js"
  43. OUT="${AGENT_EVAL_OUT:-/tmp/ab-new-vs-baseline}"
  44. PARSE="$ENGINE/scripts/agent-eval/parse-run.mjs"
  45. command -v claude >/dev/null || { echo "claude CLI not on PATH"; exit 1; }
  46. [ -d "$TARGET/.codegraph" ] || { echo "target not indexed: run 'codegraph init $TARGET' first"; exit 1; }
  47. if ! git -C "$ENGINE" diff --quiet || ! git -C "$ENGINE" diff --cached --quiet; then
  48. echo "engine repo has uncommitted changes — commit or stash first (this script checks files out)"; exit 1
  49. fi
  50. CHANGED=$(git -C "$ENGINE" diff --name-only "$BASE_REF" HEAD -- src 2>/dev/null)
  51. [ -n "$CHANGED" ] || { echo "no src/ changes between $BASE_REF and HEAD — nothing to A/B"; exit 1; }
  52. # On exit: kill any eval daemons + restore the engine to HEAD.
  53. cleanup() {
  54. pkill -9 -f "serve --mcp --path $OUT/" 2>/dev/null
  55. git -C "$ENGINE" checkout HEAD -- $CHANGED 2>/dev/null
  56. ( cd "$ENGINE" && npm run build >/dev/null 2>&1 )
  57. }
  58. # INT/TERM too: killing the script mid-baseline-arm otherwise leaves the engine
  59. # checked out at the baseline ref, which silently poisons every later build.
  60. trap cleanup EXIT INT TERM
  61. mkdir -p "$OUT"
  62. echo "###### engine=$ENGINE baseline=$BASE_REF"
  63. echo "###### changed: $(echo "$CHANGED" | tr '\n' ' ')"
  64. echo "###### target=$TARGET"
  65. echo "###### task=$TASK"
  66. echo
  67. # Two pristine copies so each arm starts clean (the agent edits its own copy).
  68. rm -rf "$OUT/t-new" "$OUT/t-base"
  69. rsync -a --exclude node_modules --exclude .git --exclude dist --exclude .codegraph "$TARGET/" "$OUT/t-new/"
  70. cp -R "$OUT/t-new" "$OUT/t-base"
  71. prewarm() { # target — spawn a persistent daemon (current $BIN) and wait for its socket
  72. pkill -9 -f "serve --mcp --path $1" 2>/dev/null
  73. CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS=1800000 node "$BIN" serve --mcp --path "$1" </dev/null >/dev/null 2>&1 &
  74. node -e 'const fs=require("fs");let n=0;const t=setInterval(()=>{if(fs.existsSync(process.argv[1]+"/.codegraph/daemon.sock")){clearInterval(t);process.exit(0)}if(n++>150){clearInterval(t);process.exit(1)}},100)' "$1" \
  75. && echo " daemon warm: $1" || echo " WARN: daemon never bound for $1 (arm may run without codegraph)"
  76. }
  77. run_arm() { # label, target-copy — runs the task $RUNS times against one build
  78. local label="$1" tgt="$2" c="$OUT/mcp-$1.json"
  79. # Connect to the pre-warmed daemon; skip the startup re-exec for a fast attach.
  80. # CODEGRAPH_EXPLORE_DEBUG points explore's per-file allocation diagnostic at a
  81. # sidecar (no-op on builds predating it; never perturbs the response).
  82. printf '{"mcpServers":{"codegraph":{"command":"env","args":["CODEGRAPH_WASM_RELAUNCHED=1","CODEGRAPH_EXPLORE_DEBUG=%s","node","%s","serve","--mcp","--path","%s"]}}}' \
  83. "$OUT/explore-$label.jsonl" "$BIN" "$tgt" > "$c"
  84. rm -f "$OUT/explore-$label.jsonl"
  85. echo "############## ARM [$label] ##############"
  86. for i in $(seq 1 "${RUNS:-1}"); do
  87. # Re-warm per run: the previous run's daemon is killed below, and a cold
  88. # attach is exactly the failure this pre-warm exists to prevent.
  89. prewarm "$tgt"
  90. ( cd "$tgt" && CODEGRAPH_NO_PROMPT_HOOK=1 claude -p "$TASK" \
  91. --output-format stream-json --verbose --permission-mode bypassPermissions \
  92. --model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" --max-budget-usd 4 --strict-mcp-config --mcp-config "$c" \
  93. </dev/null > "$OUT/run-$label-$i.jsonl" 2>"$OUT/run-$label-$i.err" )
  94. echo "-- run $i --"
  95. node "$PARSE" "$OUT/run-$label-$i.jsonl" 2>&1 | grep -E "by type|Result" || echo " (parse failed — see $OUT/run-$label-$i.jsonl)"
  96. pkill -9 -f "serve --mcp --path $tgt" 2>/dev/null
  97. done
  98. echo
  99. }
  100. echo "== NEW build (HEAD) =="
  101. ( cd "$ENGINE" && npm run build >/dev/null 2>&1 ) && echo " built"
  102. node "$BIN" init "$OUT/t-new" >/dev/null 2>&1 && echo " indexed t-new"
  103. run_arm new "$OUT/t-new"
  104. echo "== BASELINE build ($BASE_REF) =="
  105. # Per-file: a file ADDED since baseline has no pathspec on the ref — and a
  106. # single multi-file checkout with one bad pathspec checks out NOTHING, which
  107. # silently ran the NEW build in the baseline arm. Absent-on-baseline → remove.
  108. for f in $CHANGED; do
  109. git -C "$ENGINE" checkout "$BASE_REF" -- "$f" 2>/dev/null || rm -f "$ENGINE/$f"
  110. done
  111. ( cd "$ENGINE" && npm run build >/dev/null 2>&1 ) && echo " built"
  112. node "$BIN" init "$OUT/t-base" >/dev/null 2>&1 && echo " indexed t-base"
  113. run_arm baseline "$OUT/t-base"
  114. echo "###### DONE. Compare the [new] vs [baseline] 'by type' counts above"
  115. echo "###### (especially Read vs mcp__codegraph__*). Full logs in: $OUT"