From 4b9ed3b4270c49f693ea03abea0f9bf3ba5e7cda Mon Sep 17 00:00:00 2001 From: zhishu Date: Mon, 27 Jul 2026 09:34:59 +0000 Subject: [PATCH 01/29] docs(spec): Phase 2 optimization loop design (Wave 1: R1-R5) --- ...6-07-27-phase2-optimization-loop-design.md | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-27-phase2-optimization-loop-design.md diff --git a/docs/superpowers/specs/2026-07-27-phase2-optimization-loop-design.md b/docs/superpowers/specs/2026-07-27-phase2-optimization-loop-design.md new file mode 100644 index 00000000..f9f84db0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-phase2-optimization-loop-design.md @@ -0,0 +1,150 @@ +# Phase 2 Optimization Loop — Design + +**Date**: 2026-07-27 +**Status**: Approved (user-reviewed section by section) +**Program spec**: `docs/superpowers/specs/2026-07-24-memory-eval-optimization-design.md` +**Baseline**: engram `2adc24c` scorecards (`eval/results/`, v0.3.0) +**Inputs**: research priority matrix (`docs/research/2026-07-24-memory-sota-survey.md` §5), baseline report (`eval/results/baseline-report.md`) + +## Goal (program acceptance anchors) + +| Anchor | Baseline | Target | +|---|---|---| +| L3 memory uplift (mean e2e score, memory − no-memory) | 2.267 | **≥ 4.53** | +| L2 injected tokens per session | 7824 | **≤ 3912** | + +Baseline decomposition that drives this design: all uplift comes from gotcha +tasks (+8.50); memory is a net drag on architecture-qa (−0.90) and small-fix +(−0.80); the memory arm emits +48% output tokens; the static protocol is 71% +of injected cost (hook 3202 + SKILL 1587 + MCP instructions 787). + +## Mode & scope + +- **Fully autonomous loop** (user decision): including L3 milestones; escalate + only on unresolvable bottlenecks. +- **Change surface** (user decision): protocol/plugin text, engram Go client, + MemoryLake server-side *proposals* (documents only, non-blocking). +- Composition: **Wave 1 = R1–R5** (two tracks: cost R1/R3, effect R2/R4, + guardrails R5), each independently implementable, measurable, revertable. + +## Loop protocol + +``` +for Rn in R1..R5: + implement (TDD, SDD subagent flow, opus/fable models) + → go test ./... green (+ internal/paritytest) + → run L1 + L2 → scorecard vs previous round + → behavioral changes (R2/R4): targeted L3 subset (6 tasks × 2 arms × N=1) + → effective: keep + record (ledger + engram memory) + → ineffective/regressed: fix once; if still failing, git revert, record why +after all: full L3 (N=2) → acceptance vs both anchors → final report +``` + +Quantified keep/revert gates: +- R1/R3 (cost track): L2 injected tokens drop AND L1 recall@5 regression ≤ 0.03. +- R2/R4 (effect track): targeted L3 subset mean does not drop (R2 must raise + the fix/arch subset; R4 must not drop gotcha). +- The targeted subset always includes fix-003 and arch-005 (known victims). + +## R1 — Static protocol slimming & layering (cost lead, ~−4000 tok) + +- Rewrite the hook-injected protocol to a **compact core** (~800 tok): when to + save/search/context, MemoryLake-project simplifications. Details (conflict + loop, topic_key format rules, examples) live only in + `plugin/*/skills/memory/SKILL.md` — the on-demand skill the hook text + currently duplicates. +- MCP server instructions trimmed to ~300 tok (one-line tool roles + pointer + to the skill). +- Reuse the existing `engram protocol-mode` mechanism: `compact` (default) / + `full` (rollback path). +- Version header + stable section ordering for prompt-cache friendliness + (bonus; not counted toward the token anchor). + +## R2 — Answer-first behavior correction (uplift lead) + +Replace the current protocol's save-eager behavioral bias with three rules: +1. **The final reply must contain the complete answer** — memory serves future + sessions, never substitutes for answering now. +2. **Silent saves**: mem_save is never narrated and never replaces the answer; + batch saves at task end. +3. **Bounded retrieval**: one mem_search attempt at task start; on miss, + proceed normally — no repeated searching. + +Validation: targeted L3 subset must include fix-003 (agent answered "saved to +memory" instead of the analysis → scored 0) and arch-005 (6,4 vs 10,10). +Monitor mem_save call counts from the L3 sidecar to confirm "silent ≠ absent". + +## R3 — Progressive retrieval (retrieval-side cost) + +- **mem_search index mode**: hits return `{id, title, 1–2 sentence summary, + token_count, score}`; full body via `mem_get_observation`. Tool description + steers "scan index first, expand deliberately". +- **mem_context layering**: pinned block (R4) + one-line recent-session index + + summaries of the 3 most recent observations; total budget ≤ 400 tok + (currently 697). +- Optional `budget` parameter on both tools; over-budget results truncated by + relevance with an explicit "N more omitted" marker. +- Implementation: `internal/mcp` response assembly + `internal/memorylake` + (FormatContext/Search); SQLite backend changed in lockstep (paritytest + gates). +- Summaries must preserve key entities (deterministic rule, not free + paraphrase), so gotcha-critical identifiers survive indexing. + +## R4 — `mem_pin` core-memory block (gotcha reinforcement + cache stability) + +- Pinned facts render as a "core memory" section at the top of mem_context, + **hard cap 1KB** (~10 one-line facts); when over cap, oldest pin demotes + with a notice. +- Stable ordering (by pin time) → stable prompt prefix. +- MemoryLake backend already has PinObservation/isPinned; work is + FormatContext rendering + cap logic (+ SQLite parity). +- Protocol text gains a pin criterion: "pin only facts whose loss causes + irreversible damage or repeated pitfalls". +- Eval corpus: pin 2–3 real phoenix gotcha facts through the normal usage + path — disclosed in the round report (legitimate usage, not test-teaching; + the L1 dataset stays frozen and untouched). + +## R5 — Eval guardrails (anti-gaming, anti-drift) + +- **distractor-ratio** in L1: share of non-hits ranked above the first hit, + per query and aggregated — guards R3's truncation from promoting noise. +- **Mandatory no-memory comparison row** in the report generator (Zep audit + lesson: "memory looks helpful but isn't" is the worst silent failure). +- **`evalrun -suite compare -base -cur `** wiring for + `scorecard.CompareMarkdown` (also resolves the final review's dead-code + note). + +## MemoryLake server proposals (parallel, non-blocking) + +One proposal document per topic, produced alongside the loop: temporal +validity fields (`valid_from`/`valid_until` de-ranking), relevance-threshold +control on search, batch endpoints. Delivered to the server team; no client +work depends on them in Wave 1. + +## Risks & mitigations + +| Risk | Mitigation | +|---|---| +| R1 slimming degrades protocol adherence (save rate drops) | count mem_save calls in targeted L3 sidecar; L1 corpus frozen so retrieval metrics unaffected; if degraded, re-add text incrementally | +| R2 overcorrects (agent stops saving) | same save-count monitor; rules say silent, not absent | +| R3 index mode starves the agent (too lazy to expand) | targeted L3 gotcha subset must not drop; summaries keep key entities by rule | +| SQLite/MemoryLake behavior divergence | every round passes `go test ./...` + `internal/paritytest` | +| Protocol changes affect all users | behavioral changes ship via PR + release cadence with scorecards as PR evidence | +| Optimization pollutes frozen datasets | datasets remain read-only; R4 pinning goes through the real usage path and is disclosed in round reports | + +## Testing strategy + +- TDD per item; protocol-text changes get golden-file tests against accidental + drift; R3/R4 assembly logic unit-tested; paritytest for backend parity. +- Round-level: R5 guardrail metrics + scorecard comparison every round. +- Behavior-level: targeted L3 subsets; final full L3 (N=2) for acceptance. + +## Out of scope (Wave 1) + +- Remaining matrix items (consolidation pass, temporal schema client work, + relations graph, path-glob injection, LongMemEval/LoCoMo/BEAM fixtures, + scale sweeps) — candidates for Wave 2 based on Wave 1 residual gap. +- Write-time compression of stored facts (explicit non-goal per matrix + guardrail row). +- Accurate tokenizer (count-tokens API) — unless a round's decision hinges on + approx-vs-real divergence, in which case it becomes part of that round. From cdfbb9102d85c5c8d703d9ecf103d3261e8a3ccd Mon Sep 17 00:00:00 2001 From: zhishu Date: Mon, 27 Jul 2026 09:42:59 +0000 Subject: [PATCH 02/29] docs(plan): Phase 2 Wave 1 implementation plan (R1-R5 optimization loop) --- .../plans/2026-07-27-phase2-wave1.md | 967 ++++++++++++++++++ 1 file changed, 967 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-27-phase2-wave1.md diff --git a/docs/superpowers/plans/2026-07-27-phase2-wave1.md b/docs/superpowers/plans/2026-07-27-phase2-wave1.md new file mode 100644 index 00000000..23546ee9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-phase2-wave1.md @@ -0,0 +1,967 @@ +# Phase 2 Wave 1 (R1–R5 Optimization Loop) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Execute Wave 1 of the Phase 2 optimization loop — eval guardrails, protocol slimming, answer-first behavior, progressive retrieval, pinned core memory — and verify against the two acceptance anchors (uplift 2.267 → ≥ 4.53; injected tokens 7824 → ≤ 3912). + +**Architecture:** Guardrails and eval tooling land first (Task 1) so every optimization round is measured with them. Each Rn is its own task cluster ending in an eval round with quantified keep/revert gates. Protocol text changes ride the EXISTING slim/full `protocol-mode` mechanism (slim is currently dead: version floor 1.4.0 vs fork 0.3.0 — Task 2 revives it), so `full` remains the rollback path at runtime, and `git revert` remains the rollback path in code. + +**Tech Stack:** Go 1.25 (CGO_ENABLED=0), bash hook scripts, live MemoryLake, `claude -p` headless for L3. + +## Global Constraints + +- Branch: `feat/phase2-wave1` (create in Task 1 Step 1 from up-to-date `main`). +- Every commit: `go test ./...` green in this sandbox (known allowed failures: none — cmd/engram is green here since v0.3.0 fixes; tests intentionally hit live MemoryLake, user-accepted) + `go test -tags parity ./internal/paritytest/` for backend-touching tasks. +- Go env: `source /workspace/work/goenv.sh` before ANY go command. +- Conventional commits; NO `Co-Authored-By` trailers. gofmt/tabs. +- Subagent dispatches use opus/fable models (user rule). +- Frozen datasets are READ-ONLY (`eval/datasets/*`); scorecards append-only under `eval/results/`. +- Keep/revert gates (spec, verbatim): R1/R3 — L2 injected tokens drop AND L1 recall@5 regression ≤ 0.03; R2/R4 — targeted L3 subset mean does not drop (R2 must raise the fix/arch subset; R4 must not drop gotcha). Fix once on failure; second failure → `git revert` the item, record why, move on. +- Every eval round: run L1 with `-out eval/results/--l1.json`, L1-verify, L2 with `-l1-scorecard`, then `evalrun -suite compare`; commit scorecards + one ledger line + one engram `mem_save` (topic_key `eval/wave1-round-`). +- L3 invocations always: `env -u ENGRAM_BACKEND /workspace/work/evalrun-w1 ... -engram-bin `; baseline binary stays `/workspace/work/engram-baseline`; build the optimized binary fresh per round: `go build -o /workspace/work/engram-w1 ./cmd/engram`. +- Autonomy: fully autonomous including L3 milestones (user decision); escalate only on unresolvable bottlenecks. + +## File Structure + +``` +eval/metrics/metrics.go # +DistractorRatio (Task 1) +eval/runner/l1.go # distractor metric + payload formatter hook (Tasks 1, 8) +eval/cmd/evalrun/main.go # +compare suite, +-tasks filter (Task 1); R3 formatter wiring (Task 8) +eval/e2e/task.go # FilterTasks (Task 1) +internal/mcp/audit.go # ENGRAM_MCP_AUDIT_LOG opt-in tool-call audit (Task 1) +internal/setup/protocol.go # version-floor fix consumed by cmd (Task 2) +cmd/engram/main.go # protocol-mode --set + floor change (Task 2) +plugin/claude-code/scripts/session-start.sh # slim branch = compact protocol (Task 3), answer-first (Task 6) +plugin/codex/scripts/session-start.sh # same edits, codex copy (Tasks 3, 6) +plugin/*/skills/memory/SKILL.md # absorbs detail removed from hook/instructions (Tasks 4, 6) +internal/mcp/mcp.go # serverInstructions rewrite (Task 4); search index mode (Task 8) +internal/mcp/search_index.go # FormatSearchIndex + budget (Task 8) +internal/store/store.go # FormatContext layering + pinned cap (Tasks 9, 11) +internal/memorylake/backend.go # FormatContext layering + pinned cap (Tasks 9, 11) +docs/proposals/2026-07-memorylake-server-asks.md # Task 13 +eval/results/… # round scorecards + wave1-report.md (Task 12) +``` + +--- + +### Task 1: Eval guardrails & round tooling (R5 + loop plumbing) + +**Files:** +- Modify: `eval/metrics/metrics.go`, `eval/runner/l1.go`, `eval/cmd/evalrun/main.go`, `eval/e2e/task.go` +- Create: `internal/mcp/audit.go` +- Test: `eval/metrics/metrics_test.go`, `eval/runner/l1_test.go`, `eval/e2e/task_test.go`, `internal/mcp/audit_test.go` + +**Interfaces:** +- Consumes: existing `metrics.RecallAtK/MRR/ApproxTokens`, `scorecard.Load/CompareMarkdown`, `e2e.LoadTasks`, `runner.RunL1`. +- Produces: + - `func metrics.DistractorRatio(firstHitRank, resultCount int) float64` — share of results ranked above the first hit; 0 when rank ≤ 1; misses (rank 0) return 1 when resultCount > 0, 0 otherwise. + - RunL1 per-item value `distractor_ratio` + aggregate metric `avg_distractor_ratio`. + - CLI `evalrun -suite compare -base -cur ` — prints `scorecard.CompareMarkdown(base, cur)` to stdout. + - CLI l3 flag `-tasks id1,id2` → `func e2e.FilterTasks(tasks []Task, csv string) ([]Task, error)` (empty csv = all; unknown id = error listing valid ids). + - Env `ENGRAM_MCP_AUDIT_LOG=`: every mem_* tool call appends one line ` ` (O_APPEND, best-effort — never fails the call). Used by targeted L3 rounds to count mem_save calls per arm. + +- [ ] **Step 1: Branch + failing tests for DistractorRatio and FilterTasks** + +```bash +cd /workspace/phoenix/engram && git checkout main && git pull && git checkout -b feat/phase2-wave1 +``` + +Append to `eval/metrics/metrics_test.go`: + +```go +func TestDistractorRatio(t *testing.T) { + cases := []struct { + rank, count int + want float64 + }{ + {1, 10, 0}, // hit on top: no distractors + {3, 10, 0.2}, // 2 distractors above hit / 10 results + {0, 10, 1}, // miss with results: all distractors + {0, 0, 0}, // no results at all + {2, 2, 0.5}, + } + for _, c := range cases { + if got := DistractorRatio(c.rank, c.count); got != c.want { + t.Errorf("DistractorRatio(%d,%d) = %v, want %v", c.rank, c.count, got, c.want) + } + } +} +``` + +Append to `eval/e2e/task_test.go`: + +```go +func TestFilterTasks(t *testing.T) { + tasks := []Task{{ID: "a"}, {ID: "b"}, {ID: "c"}} + got, err := FilterTasks(tasks, "c,a") + if err != nil || len(got) != 2 || got[0].ID != "a" || got[1].ID != "c" { + t.Fatalf("FilterTasks = %+v, %v", got, err) + } + if all, err := FilterTasks(tasks, ""); err != nil || len(all) != 3 { + t.Fatalf("empty csv should return all, got %d, %v", len(all), err) + } + if _, err := FilterTasks(tasks, "a,zzz"); err == nil { + t.Fatal("unknown id must error") + } +} +``` + +- [ ] **Step 2: Run to verify failure** — `source /workspace/work/goenv.sh && go test ./eval/metrics/ ./eval/e2e/` → FAIL `undefined: DistractorRatio` / `undefined: FilterTasks`. + +- [ ] **Step 3: Implement** + +`eval/metrics/metrics.go` (append): + +```go +// DistractorRatio: share of returned results ranked above the first hit. +// rank is 1-based (0 = miss). A miss with results counts as all-distractor +// (1.0); no results at all counts 0 (nothing was injected). +func DistractorRatio(firstHitRank, resultCount int) float64 { + if resultCount == 0 { + return 0 + } + if firstHitRank == 0 { + return 1 + } + return float64(firstHitRank-1) / float64(resultCount) +} +``` + +`eval/e2e/task.go` (append): + +```go +// FilterTasks returns the subset of tasks whose IDs appear in csv (comma +// separated), preserving tasks' original order. Empty csv returns all. +func FilterTasks(tasks []Task, csv string) ([]Task, error) { + csv = strings.TrimSpace(csv) + if csv == "" { + return tasks, nil + } + want := map[string]bool{} + for _, id := range strings.Split(csv, ",") { + want[strings.TrimSpace(id)] = true + } + var out []Task + for _, t := range tasks { + if want[t.ID] { + out = append(out, t) + delete(want, t.ID) + } + } + if len(want) > 0 { + var valid []string + for _, t := range tasks { + valid = append(valid, t.ID) + } + var missing []string + for id := range want { + missing = append(missing, id) + } + sort.Strings(missing) + return nil, fmt.Errorf("unknown task ids %v (valid: %v)", missing, valid) + } + return out, nil +} +``` + +(add `sort`/`strings` imports as needed) + +In `eval/runner/l1.go` `RunL1` loop, after computing `rank`, add: + +```go + dr := metrics.DistractorRatio(rank, len(results)) +``` + +add `"distractor_ratio": dr` to the per-item `Values` map, accumulate `totalDR += dr` (declare `var totalDR float64` beside `totalTokens`), and alongside `avg_tokens_per_query` emit `m["avg_distractor_ratio"] = totalDR / float64(len(cases))`. Extend `TestRunL1` in `eval/runner/l1_test.go`: with the existing fixture (ranks `[2,0]`, both queries returning 2 and 1 results respectively) assert `sc.Metrics["avg_distractor_ratio"] == 0.75` ((1/2 + 1)/2) and `sc.PerItem[0].Values["distractor_ratio"] == 0.5`. + +In `eval/cmd/evalrun/main.go`: add flags `baseCard := flag.String("base", "", ...)`, `tasksCSV := flag.String("tasks", "", "l3: comma-separated task id filter")`; add `case "compare":` calling: + +```go +func runCompare(basePath, curPath string) { + base, err := scorecard.Load(basePath) + if err != nil { + fatal("base: %v", err) + } + cur, err := scorecard.Load(curPath) + if err != nil { + fatal("cur: %v", err) + } + fmt.Print(scorecard.CompareMarkdown(base, cur)) +} +``` + +(`-cur` reuses the existing `-l1-scorecard`? No — add `curCard := flag.String("cur", "", ...)`.) In `runL3`, after `LoadTasks`, insert `tasks, err = e2e.FilterTasks(tasks, tasksCSV)` (thread the flag through) with `fatal` on error. + +- [ ] **Step 4: Audit log — failing test** + +`internal/mcp/audit_test.go`: + +```go +package mcp + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAuditToolCall(t *testing.T) { + p := filepath.Join(t.TempDir(), "audit.log") + t.Setenv("ENGRAM_MCP_AUDIT_LOG", p) + auditToolCall("mem_save") + auditToolCall("mem_search") + b, err := os.ReadFile(p) + if err != nil { + t.Fatalf("audit file: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(b)), "\n") + if len(lines) != 2 || !strings.HasSuffix(lines[0], " mem_save") || !strings.HasSuffix(lines[1], " mem_search") { + t.Fatalf("unexpected audit content: %q", string(b)) + } +} + +func TestAuditToolCallDisabled(t *testing.T) { + t.Setenv("ENGRAM_MCP_AUDIT_LOG", "") + auditToolCall("mem_save") // must not panic or create files +} +``` + +- [ ] **Step 5: Implement `internal/mcp/audit.go`** + +```go +package mcp + +import ( + "fmt" + "os" + "time" +) + +// auditToolCall appends one " " line to the file named by +// ENGRAM_MCP_AUDIT_LOG. Opt-in, best-effort: any failure is ignored so the +// audit can never break a tool call. Used by the eval harness to count +// mem_* usage per session (Phase 2 spec: R2 save-count monitoring). +func auditToolCall(tool string) { + path := os.Getenv("ENGRAM_MCP_AUDIT_LOG") + if path == "" { + return + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer f.Close() + fmt.Fprintf(f, "%s %s\n", time.Now().UTC().Format(time.RFC3339), tool) +} +``` + +Wire it: in `internal/mcp/mcp.go`, every registered mem_* handler passes through a common wrapper or switch — find the single registration point (the `server.AddTool(...)` calls around mcp.go:300±) and wrap each `server.ToolHandlerFunc` with: + +```go +func withAudit(name string, h server.ToolHandlerFunc) server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + auditToolCall(name) + return h(ctx, req) + } +} +``` + +applied at each AddTool site (mechanical: `withAudit("mem_save", handleSave(...))` etc.). + +- [ ] **Step 6: Full verify + commit** + +Run: `go test ./eval/... ./internal/mcp/` → PASS; `go build ./...` clean. + +```bash +git add eval/ internal/mcp/audit.go internal/mcp/audit_test.go internal/mcp/mcp.go +git commit -m "feat(eval): wave1 guardrails — distractor ratio, compare suite, task filter, mem_* audit log" +``` + +--- + +### Task 2: R1a — revive slim protocol-mode (floor fix + `--set`) + +**Files:** +- Modify: `cmd/engram/main.go` (cmdProtocolMode, protocolVersionFloor, meetsProtocolVersionFloor) +- Test: `cmd/engram/main_extra_test.go` (append) + +**Interfaces:** +- Consumes: `setup.ReadProtocolMode(dataDir, slug)`, `setup.WriteProtocolMode(dataDir, slug, mode)`, `setup.ProtocolModeSlim/Full` (existing). +- Produces: `engram protocol-mode ` honors slim at fork versions (floor `0.4.0`; `dev` builds meet the floor); `engram protocol-mode --set slim|full` persists the mode and prints the effective value. + +- [ ] **Step 1: Failing tests** (append to `cmd/engram/main_extra_test.go`): + +```go +func TestProtocolModeSetAndFloor(t *testing.T) { + stubExitWithPanic(t) + cfg := testConfig(t) + + // --set slim persists and (dev build meets floor) reads back slim. + withArgs(t, "engram", "protocol-mode", "claude-code", "--set", "slim") + stdout, stderr, recovered := captureOutputAndRecover(t, func() { cmdProtocolMode(cfg) }) + if recovered != nil || stderr != "" || !strings.Contains(stdout, "slim") { + t.Fatalf("set slim: panic=%v stderr=%q stdout=%q", recovered, stderr, stdout) + } + withArgs(t, "engram", "protocol-mode", "claude-code") + stdout, _, _ = captureOutputAndRecover(t, func() { cmdProtocolMode(cfg) }) + if !strings.Contains(stdout, "slim") { + t.Fatalf("read after set: want slim, got %q", stdout) + } + + // --set full flips back. + withArgs(t, "engram", "protocol-mode", "claude-code", "--set", "full") + stdout, _, _ = captureOutputAndRecover(t, func() { cmdProtocolMode(cfg) }) + if !strings.Contains(stdout, "full") { + t.Fatalf("set full: got %q", stdout) + } + + // invalid value is fatal + withArgs(t, "engram", "protocol-mode", "claude-code", "--set", "banana") + _, _, recovered = captureOutputAndRecover(t, func() { cmdProtocolMode(cfg) }) + if _, ok := recovered.(exitCode); !ok { + t.Fatalf("invalid --set must exit fatally, got %v", recovered) + } +} + +func TestMeetsProtocolVersionFloorForkVersions(t *testing.T) { + cases := map[string]bool{ + "0.3.0": false, "0.4.0": true, "v0.5.1": true, "1.4.0": true, + "dev": true, "": false, "garbage": false, + } + for v, want := range cases { + if got := meetsProtocolVersionFloor(v); got != want { + t.Errorf("meetsProtocolVersionFloor(%q) = %v, want %v", v, got, want) + } + } +} +``` + +- [ ] **Step 2: Verify failure** — `go test ./cmd/engram/ -run 'TestProtocolModeSetAndFloor|TestMeetsProtocolVersionFloorForkVersions'` → FAIL. + +- [ ] **Step 3: Implement in `cmd/engram/main.go`** + +Change `protocolVersionFloor` to `[3]int{0, 4, 0}` and update its comment (fork versioning; slim relies on the Wave-1 compact protocol shipped ≥ v0.4.0). In `meetsProtocolVersionFloor`, before parsing, add: + +```go + if v == "dev" { + return true // source builds are by definition current + } +``` + +Rewrite `cmdProtocolMode`: + +```go +func cmdProtocolMode(cfg store.Config) { + if len(os.Args) < 3 { + fmt.Fprintln(os.Stderr, "usage: engram protocol-mode [--set slim|full]") + exitFunc(1) + return + } + slug := os.Args[2] + + if len(os.Args) >= 5 && os.Args[3] == "--set" { + want := os.Args[4] + if want != setup.ProtocolModeSlim && want != setup.ProtocolModeFull { + fmt.Fprintf(os.Stderr, "invalid mode %q: must be slim or full\n", want) + exitFunc(1) + return + } + if err := setup.WriteProtocolMode(cfg.DataDir, slug, want); err != nil { + fmt.Fprintf(os.Stderr, "write protocol mode: %v\n", err) + exitFunc(1) + return + } + } + + mode := setup.ReadProtocolMode(cfg.DataDir, slug) + if mode == setup.ProtocolModeSlim && meetsProtocolVersionFloor(version) { + fmt.Println(setup.ProtocolModeSlim) + return + } + fmt.Println(setup.ProtocolModeFull) +} +``` + +(Confirm the exported name is `setup.ProtocolModeFull` — if the constant is named differently in internal/setup/protocol.go:16±, use the actual name; do not add new constants.) + +- [ ] **Step 4: Verify + commit** — targeted tests PASS, `go test ./cmd/engram/` no new failures. + +```bash +git add cmd/engram/main.go cmd/engram/main_extra_test.go +git commit -m "feat(protocol): revive slim protocol-mode for fork versions and add --set" +``` + +--- + +### Task 3: R1b — compact protocol text (hook slim branch) + +**Files:** +- Modify: `plugin/claude-code/scripts/session-start.sh` (the `[ "$mode" != "slim" ]` protocol branch — replace the slim alternative's text), `plugin/codex/scripts/session-start.sh` (same change; adapt tool-name differences only if the codex file's full text differs — mirror whatever structure it has) +- Test: `plugin/assets_test.go` (append golden budget test) + +**Interfaces:** +- Produces: slim-mode hook stdout whose PROTOCOL section is ≤ 3400 bytes (~850 tok). Full mode byte-for-byte unchanged (rollback path). + +- [ ] **Step 1: Failing golden test** (append to `plugin/assets_test.go`; follow that file's existing pattern for locating plugin files): + +```go +func TestSlimProtocolBudgetAndCoreRules(t *testing.T) { + b, err := os.ReadFile("claude-code/scripts/session-start.sh") + if err != nil { + t.Fatal(err) + } + s := string(b) + start := strings.Index(s, "SLIM_PROTOCOL") + if start < 0 { + t.Fatal("slim branch heredoc marker SLIM_PROTOCOL not found") + } + end := strings.Index(s[start+1:], "SLIM_PROTOCOL") + if end < 0 { + t.Fatal("unterminated SLIM_PROTOCOL heredoc") + } + slim := s[start : start+1+end] + if len(slim) > 3400 { + t.Fatalf("slim protocol is %d bytes, budget 3400", len(slim)) + } + for _, must := range []string{ + "mem_save", "mem_search", "mem_context", "mem_session_summary", + "topic_key", "memorylake", "SKILL", // pointer to the on-demand skill + } { + if !strings.Contains(strings.ToLower(slim), strings.ToLower(must)) { + t.Fatalf("slim protocol missing required token %q", must) + } + } +} +``` + +- [ ] **Step 2: Verify failure** — `go test ./plugin/ -run TestSlimProtocolBudget` → FAIL (no SLIM_PROTOCOL marker yet). + +- [ ] **Step 3: Rewrite the slim branch** + +In `plugin/claude-code/scripts/session-start.sh`, locate the `if [ "$mode" != "slim" ]` protocol block (line ~151). Keep the full branch's heredoc EXACTLY as-is; give the `else` branch this heredoc (replacing whatever slim currently emits): + +```bash +else +cat <<'SLIM_PROTOCOL' +## Engram Memory — Active Protocol (compact) + +Persistent memory across sessions. Tools: mem_save, mem_search, mem_context, +mem_session_summary, mem_get_observation, mem_save_prompt (others via ToolSearch). + +RULES +1. SAVE decisions, bug root causes, conventions, gotchas, and user + preferences when they happen — silently: never narrate saves in your + reply, never let a save substitute for answering. Batch saves at task end. +2. Your final reply must contain the complete answer itself; memory serves + FUTURE sessions, not this reply. +3. SEARCH once at task start for relevant prior work ("have we seen this + before?"). On miss, proceed normally — do not search repeatedly. +4. mem_context at session start / after compaction for recent history. +5. Durable facts need topic_key (lowercase-kebab, max 2 levels, e.g. + architecture/auth-model) — same key updates in place. +6. End of session: mem_session_summary before saying done. +7. MemoryLake-backed projects (check: engram memorylake status): dedup and + conflict-merge are automatic — mem_save/mem_search/mem_context suffice. + SQLite projects: on judgment_required, follow the conflict loop in the + memory SKILL. + +Details, examples, and edge cases: load the `memory` SKILL on demand. +SLIM_PROTOCOL +fi +``` + +NOTE: this text already phrases saving as silent/answer-first because the +compact rewrite and R2's rules land in the same sentences; Task 6 (R2) will +strengthen the FULL branch and SKILL to match, and the R1 eval round (Task 5) +measures cost only — attribution of uplift changes belongs to Task 7's +targeted L3 round. If the existing slim branch structure differs (e.g. it +shares the status-block code), preserve the non-protocol parts (session +creation, context injection) — only the PROTOCOL text block changes. + +Apply the same slim-branch rewrite to `plugin/codex/scripts/session-start.sh` +(same heredoc; codex file may lack a slim branch — if so, add it mirroring the +claude-code structure). + +- [ ] **Step 4: Verify + inspect real output** + +`go test ./plugin/` → PASS. Then real smoke: `echo '{"session_id":"t1","cwd":"/workspace/phoenix/engram"}' | bash plugin/claude-code/scripts/session-start.sh | head -50` after `engram protocol-mode claude-code --set slim` with a locally built binary on PATH — confirm the slim text renders and the context block still appends. Reset with `--set full` afterward (the user's real machine default until Wave 1 ships). + +- [ ] **Step 5: Commit** + +```bash +git add plugin/ +git commit -m "feat(protocol): compact slim protocol text (~850 tok) behind protocol-mode" +``` + +--- + +### Task 4: R1c — MCP instructions trim + SKILL absorbs detail + +**Files:** +- Modify: `internal/mcp/mcp.go` (`serverInstructions` const, line ~194), `plugin/claude-code/skills/memory/SKILL.md`, `plugin/codex/skills/memory/SKILL.md` +- Test: `internal/mcp/mcp_test.go` (append budget test) + +**Interfaces:** +- Produces: `ServerInstructions()` ≤ 1300 bytes (~325 tok). SKILL.md gains the conflict-loop and topic-key detail that the old instructions carried (SKILL is loaded on demand — its size is NOT in the per-session budget). + +- [ ] **Step 1: Failing test** (append to `internal/mcp/mcp_test.go`): + +```go +func TestServerInstructionsBudget(t *testing.T) { + s := ServerInstructions() + if len(s) > 1300 { + t.Fatalf("serverInstructions is %d bytes, budget 1300", len(s)) + } + for _, must := range []string{"mem_save", "mem_search", "mem_context", "SKILL", "memorylake"} { + if !strings.Contains(strings.ToLower(s), strings.ToLower(must)) { + t.Fatalf("instructions missing %q", must) + } + } +} +``` + +- [ ] **Step 2: Verify failure** (current const is ~3KB) → FAIL on budget. + +- [ ] **Step 3: Rewrite `serverInstructions`** + +```go +const serverInstructions = `Engram: persistent memory across sessions and compactions. + +Core tools: mem_save (save decisions/bugs/conventions/gotchas — silently, +batched at task end, never replacing your answer), mem_search (once at task +start), mem_context (session start / post-compaction), mem_session_summary +(before finishing), mem_get_observation (expand a search hit), mem_save_prompt. +Durable facts use topic_key (lowercase-kebab, max 2 levels) to update in place. + +MemoryLake-backed projects (engram memorylake status): dedup/conflict-merge +are automatic; the three core tools suffice. SQLite projects: if mem_save +returns judgment_required, follow the conflict loop documented in the memory +SKILL. Details and examples: load the memory SKILL on demand.` +``` + +Move the removed detail (conflict-loop walkthrough with per-candidate +judgment_id rule, when-to-ask-user heuristic, deferred tool list) INTO +`plugin/claude-code/skills/memory/SKILL.md` under a new `## Conflict loop +(SQLite projects)` section — copy the prose verbatim from the old const before +deleting it; same for the codex SKILL. + +- [ ] **Step 4: Verify + commit** — `go test ./internal/mcp/ ./plugin/` PASS. + +```bash +git add internal/mcp/mcp.go internal/mcp/mcp_test.go plugin/ +git commit -m "feat(protocol): trim MCP server instructions to ~325 tok; SKILL absorbs conflict-loop detail" +``` + +--- + +### Task 5: R1 eval round (gates: tokens ↓, recall@5 regression ≤ 0.03) + +**Files:** +- Create: `eval/results/--l1.json`, `…-l2.json`, ledger + report lines + +**Interfaces:** consumes Task 1's compare suite; produces round-1 scorecards + keep/revert decision. + +- [ ] **Step 1: Build optimized binary + enable slim for the eval arm** + +```bash +source /workspace/work/goenv.sh && go build -o /workspace/work/engram-w1 ./cmd/engram +/workspace/work/engram-w1 protocol-mode claude-code --set slim +``` + +- [ ] **Step 2: Run L1 + L1-verify + L2** + +```bash +go run ./eval/cmd/evalrun -suite l1 +go run ./eval/cmd/evalrun -suite l1-verify -l1-scorecard eval/results/--l1.json +PATH=/workspace/work:$PATH go run ./eval/cmd/evalrun -suite l2 -l1-scorecard eval/results/--l1.json +``` + +CRITICAL measurement note: L2's hook slice must measure the SLIM path — run the l2 suite with the w1 binary first on PATH (`PATH=/workspace/work:$PATH` finds `engram-w1`? No: the hook calls `engram`; symlink it: `ln -sf /workspace/work/engram-w1 /workspace/work/engram` before the l2 run, remove after). Expected: `static_hook_tokens` drops from 3202 to ≤ 1400 (slim protocol + status block) and `static_mcp_instr_tokens` from 787 to ≤ 340. + +- [ ] **Step 3: Gate check + record** + +```bash +go run ./eval/cmd/evalrun -suite compare -base eval/results/2adc24c-2026-07-24-l1.json -cur eval/results/ +go run ./eval/cmd/evalrun -suite compare -base eval/results/2adc24c-2026-07-24-l2.json -cur eval/results/ +``` + +Gate: new `injected_tokens_per_session` < 7824 AND `recall@5` ≥ 0.766 (0.796 − 0.03). L1 exercises backend.Search directly, so R1 should not move recall at all — any movement flags live-service drift; note it. On pass: commit scorecards, append ledger line `Wave1 R1: kept (tokens X, recall@5 Y)`, `mem_save` topic_key `eval/wave1-round-1`. On fail: fix once (likely: slim text missing a required behavior), re-run; second fail → `git revert` Tasks 2–4 commits, record, continue to R2 (which then edits the FULL protocol text instead). + +```bash +git add eval/results/ && git commit -m "feat(eval): wave1 round 1 (R1 protocol slimming) scorecards" +``` + +--- + +### Task 6: R2 — answer-first behavior in FULL protocol + SKILL + +**Files:** +- Modify: `plugin/claude-code/scripts/session-start.sh` (FULL branch: replace the `### PROACTIVE SAVE` section), `plugin/codex/scripts/session-start.sh` (same), `plugin/claude-code/skills/memory/SKILL.md` + codex copy (mirror the rules) +- Test: `plugin/assets_test.go` (append) + +**Interfaces:** produces the three R2 rules present in BOTH protocol modes (slim already carries them from Task 3; this task aligns the full branch and SKILL so rollback of R1 does not lose R2). + +- [ ] **Step 1: Failing test** (append to `plugin/assets_test.go`): + +```go +func TestAnswerFirstRulesPresent(t *testing.T) { + for _, p := range []string{ + "claude-code/scripts/session-start.sh", + "claude-code/skills/memory/SKILL.md", + } { + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + s := strings.ToLower(string(b)) + for _, must := range []string{ + "final reply must contain the complete answer", + "never narrate saves", + "batch saves at task end", + } { + if !strings.Contains(s, must) { + t.Fatalf("%s missing answer-first rule %q", p, must) + } + } + if strings.Contains(s, "immediately after any of these") { + t.Fatalf("%s still contains save-eager phrasing", p) + } + } +} +``` + +- [ ] **Step 2: Verify failure** → FAIL (full branch still has PROACTIVE SAVE list). + +- [ ] **Step 3: Rewrite the FULL branch's `### PROACTIVE SAVE` section** + +Replace the whole `### PROACTIVE SAVE — do NOT wait for user to ask` section (the trigger list and the `Self-check after EVERY task` paragraph) with: + +``` +### SAVING — silent, batched, never instead of answering +Save decisions, bug root causes, conventions, gotchas, and user preferences. +But: your final reply must contain the complete answer itself — memory serves +future sessions, never this reply. Never narrate saves ("I've saved this to +memory") and never let a save replace the answer. Batch saves at task end. + +### SEARCHING — once, up front +One mem_search at task start ("have we seen this before?"). On miss, proceed +normally; do not search repeatedly for the same information. +``` + +Apply the same replacement in the codex hook, and add the identical rules to +both SKILL.md files (replacing their PROACTIVE-SAVE-style sections if +present). Keep everything else in the full branch untouched. + +- [ ] **Step 4: Verify + commit** — `go test ./plugin/` PASS (both new tests). + +```bash +git add plugin/ +git commit -m "feat(protocol): answer-first save rules — silent, batched, bounded retrieval" +``` + +--- + +### Task 7: R2 eval round (targeted L3 subset + save-count monitor) + +**Interfaces:** consumes `-tasks` filter + `ENGRAM_MCP_AUDIT_LOG` (Task 1). Gate: subset mean(memory) for fix/arch tasks must RISE vs the same tuples in the baseline sidecar; save count per session must stay ≥ 1 on tasks where the baseline arm saved. + +- [ ] **Step 1: Run the targeted subset** (12 sessions ≈ cheap): + +```bash +source /workspace/work/goenv.sh && go build -o /workspace/work/evalrun-w1 ./eval/cmd/evalrun && go build -o /workspace/work/engram-w1 ./cmd/engram +ENGRAM_MCP_AUDIT_LOG=/workspace/work/w1-r2-audit.log \ +env -u ENGRAM_BACKEND /workspace/work/evalrun-w1 -suite l3 -n 1 \ + -tasks fix-002,fix-003,fix-005,arch-001,arch-005,gotcha-001 \ + -engram-bin /workspace/work/engram-w1 \ + -out eval/results/w1-r2-l3subset.json +``` + +(ENGRAM_MCP_AUDIT_LOG is inherited by the arm's engram mcp subprocess via BuildClaudeCmd's environ passthrough — verify one audit line appears; if the env var is stripped anywhere, thread it explicitly and note the deviation.) + +- [ ] **Step 2: Extract per-task comparison vs baseline** + +Baseline per-tuple scores live in `eval/results/2adc24c-2026-07-24-l3.json.runs.jsonl` — compare memory-arm means for the six tasks (python3 one-off over both sidecars). Gate: mean over {fix-002,fix-003,fix-005,arch-001,arch-005} memory-arm strictly higher than baseline's same-task memory-arm mean (baseline: fix-002 0.5, fix-003 0, fix-005 0, arch-001 8, arch-005 5 → mean 2.7); gotcha-001 must stay ≥ 8. Save-count: `grep -c mem_save /workspace/work/w1-r2-audit.log` ≥ 1. + +- [ ] **Step 3: Also run L1 + L2** (R2 is text-only; L2 confirms token budget didn't regress) + compare + record + commit scorecards (`feat(eval): wave1 round 2 (R2 answer-first) scorecards`). On gate fail: strengthen rules once (likely: rule phrasing not surfacing in final answers); second fail → revert Task 6, record. + +--- + +### Task 8: R3a — mem_search index mode + budget + +**Files:** +- Create: `internal/mcp/search_index.go` +- Modify: `internal/mcp/mcp.go` (handleSearch result rendering + tool description + `budget` arg), `eval/runner/l1.go` (measure agent-visible payload via the new formatter) +- Test: `internal/mcp/search_index_test.go`, `eval/runner/l1_test.go` + +**Interfaces:** +- Produces: `func FormatSearchIndex(results []store.SearchResult, budgetTokens int) string` (exported, in internal/mcp) — one line per hit: `N. [type] title — (id: X, ~T tok, score S.SS)`; summary = first sentence of Content, hard-capped 160 chars at a rune boundary; budgetTokens ≤ 0 means default 600; output truncated at the last whole entry that fits the budget with a trailing line `(+K more omitted — raise budget or refine query)`; token estimate = (len(bytes)+3)/4 as a local unexported helper (do NOT import eval/metrics from internal — copy the 3-line formula with a comment naming eval/metrics.ApproxTokens as the twin). +- handleSearch: renders hits through FormatSearchIndex (replacing full-body rendering), honors new optional `budget` integer arg, and instructs in the tool description: "returns an index (title + summary + id); call mem_get_observation with an id for the full body — scan first, expand deliberately." +- RunL1 measures per-query payload tokens as `ApproxTokens(mcp.FormatSearchIndex(results, 0))` instead of JSON-marshal (this is the agent-visible payload). + +- [ ] **Step 1: Failing tests** (`internal/mcp/search_index_test.go`): + +```go +package mcp + +import ( + "strings" + "testing" + + "github.com/Gentleman-Programming/engram/internal/store" +) + +func sr(title, content string) store.SearchResult { + var r store.SearchResult + r.Type = "decision" + r.Title = title + r.Content = content + r.SyncID = "obs-" + title + r.Rank = 0.9 + return r +} + +func TestFormatSearchIndexShape(t *testing.T) { + out := FormatSearchIndex([]store.SearchResult{ + sr("auth-model", "We use JWT with 15m expiry. Refresh tokens rotate."), + }, 0) + if !strings.Contains(out, "auth-model") || !strings.Contains(out, "We use JWT with 15m expiry.") { + t.Fatalf("index missing title/first-sentence summary: %q", out) + } + if strings.Contains(out, "Refresh tokens rotate") { + t.Fatalf("index leaked full body: %q", out) + } + if !strings.Contains(out, "obs-auth-model") { + t.Fatalf("index missing id: %q", out) + } +} + +func TestFormatSearchIndexBudget(t *testing.T) { + var many []store.SearchResult + for i := 0; i < 50; i++ { + many = append(many, sr(strings.Repeat("t", 20), strings.Repeat("word ", 60))) + } + out := FormatSearchIndex(many, 100) // ~400 bytes budget + if len(out) > 700 { // budget + one entry slack + omission line + t.Fatalf("budget not enforced: %d bytes", len(out)) + } + if !strings.Contains(out, "more omitted") { + t.Fatalf("missing omission marker: %q", out) + } +} +``` + +(If `store.SearchResult`'s embedded Observation lacks a `SyncID`/`Type` field under those names, check `internal/store/store.go`'s Observation struct and use the actual field names — the test helper is the single place to adjust.) + +- [ ] **Step 2: Verify failure** → FAIL `undefined: FormatSearchIndex`. + +- [ ] **Step 3: Implement `internal/mcp/search_index.go`** + +```go +package mcp + +import ( + "fmt" + "strings" + "unicode/utf8" + + "github.com/Gentleman-Programming/engram/internal/store" +) + +const defaultSearchBudgetTokens = 600 + +// approxTokens mirrors eval/metrics.ApproxTokens (ceil(bytes/4)); duplicated +// because internal packages must not depend on eval/. +func approxTokens(s string) int { return (len(s) + 3) / 4 } + +// firstSentence returns content up to the first sentence terminator, capped +// at 160 chars on a rune boundary. Deterministic — key entities in the +// opening sentence always survive (Phase 2 spec R3 rule). +func firstSentence(content string) string { + c := strings.TrimSpace(content) + for i, r := range c { + if r == '.' || r == '!' || r == '?' || r == '\n' || r == '。' || r == '!' || r == '?' { + c = c[:i+utf8.RuneLen(r)] + break + } + } + if utf8.RuneCountInString(c) > 160 { + runes := []rune(c) + c = string(runes[:160]) + "…" + } + return c +} + +// FormatSearchIndex renders search hits as a reference-first index: the agent +// scans titles/summaries and expands chosen ids via mem_get_observation. +// budgetTokens <= 0 uses the default; entries stop at the last whole line +// under budget, with an explicit omission marker. +func FormatSearchIndex(results []store.SearchResult, budgetTokens int) string { + if budgetTokens <= 0 { + budgetTokens = defaultSearchBudgetTokens + } + var b strings.Builder + used := 0 + shown := 0 + for i, r := range results { + line := fmt.Sprintf("%d. [%s] %s — %s (id: %s, ~%d tok, score %.2f)\n", + i+1, r.Type, r.Title, firstSentence(r.Content), r.SyncID, approxTokens(r.Content), r.Rank) + if used+approxTokens(line) > budgetTokens && shown > 0 { + break + } + b.WriteString(line) + used += approxTokens(line) + shown++ + } + if shown < len(results) { + fmt.Fprintf(&b, "(+%d more omitted — raise budget or refine query)\n", len(results)-shown) + } + return b.String() +} +``` + +(Adjust `r.SyncID`/`r.Type` to the Observation struct's real exported field names found in Step 1.) + +- [ ] **Step 4: Wire into handleSearch + tool schema** + +In `handleSearch` (internal/mcp/mcp.go:998+): read `budget := intArg(req, "budget", 0)`; where the current implementation renders results into the tool result text, render `FormatSearchIndex(results, budget)` instead (keep the surrounding envelope — project metadata, counts — unchanged). Update the mem_search tool registration's Description to the index-mode wording above and add the optional integer `budget` parameter. Existing mcp tests asserting full-body search output will fail — update those assertions to the index shape (they are the golden record of this intentional behavior change; list every changed assertion in the report). + +- [ ] **Step 5: Re-point L1 measurement** + +In `eval/runner/l1.go`, replace the payload measurement: + +```go + payload := mcp.FormatSearchIndex(results, 0) + tokens := float64(metrics.ApproxTokens(payload)) +``` + +(import `internal/mcp`; delete the `encoding/json` marshal). Update `TestRunL1`'s token assertion if it pinned a JSON-based value (it only asserts > 0 — verify). + +- [ ] **Step 6: Verify + commit** — `go test ./internal/mcp/ ./eval/... && go build ./...` PASS. + +```bash +git add internal/mcp/ eval/runner/ +git commit -m "feat(retrieval): mem_search returns budgeted reference-first index; L1 measures agent-visible payload" +``` + +--- + +### Task 9: R3b — mem_context layering (≤ 400 tok, both backends) + +**Files:** +- Modify: `internal/store/store.go` (`FormatContext`, line ~3298), `internal/memorylake/backend.go` (`FormatContext`, line ~443) +- Test: `internal/store/store_test.go` (append), `internal/memorylake/backend_test.go` (append), `internal/paritytest/testdata/corpus.jsonl` row if the corpus schema supports context cases (check `paritytest.LoadCorpus`; if context is not a corpus dimension, add a semantic parity assertion test in `internal/paritytest/parity_test.go` instead) + +**Interfaces:** +- Produces (both backends, identical layout): context = `## Memory Context` + `### Pinned` (Task 11 caps it) + `### Recent Sessions` (one line each, max 5: `- `) + `### Recent Observations` (max 3, title + first sentence via the same 160-char deterministic rule) + footer `Full bodies: mem_search / mem_get_observation.`; total output hard-capped at 1600 bytes (~400 tok) by dropping oldest observations first, then oldest sessions (never the pinned block). + +- [ ] **Step 1: Failing tests.** `internal/store/store_test.go` (append; follow the file's existing store-fixture helpers for creating sessions/observations): + +```go +func TestFormatContextLayeredBudget(t *testing.T) { + s := newTestStore(t) // use the file's existing constructor helper name + seedSessionWithSummary(t, s, "proj-x", "long summary. second sentence that must not appear.") + for i := 0; i < 10; i++ { + seedObservation(t, s, "proj-x", fmt.Sprintf("title-%d", i), strings.Repeat("body sentence. ", 40)) + } + out, err := s.FormatContext("proj-x", "") + if err != nil { + t.Fatal(err) + } + if len(out) > 1600 { + t.Fatalf("context %d bytes, cap 1600", len(out)) + } + if strings.Contains(out, "second sentence that must not appear") { + t.Fatal("session summary not truncated to first sentence") + } + if !strings.Contains(out, "mem_get_observation") { + t.Fatal("missing expand-on-demand footer") + } + if strings.Count(out, "title-") > 3 { + t.Fatal("more than 3 recent observations rendered") + } +} +``` + +(`newTestStore`/`seed*` — use the actual helper names present in store_test.go; if none exist for sessions-with-summary, create observations via `s.AddObservation` and a session via `s.CreateSession`+`s.EndSession` directly.) Mirror an equivalent test in `internal/memorylake/backend_test.go` against an httptest fixture (mirror `TestBackend_Search_PassesThrough`'s server-building pattern; facts with created_at ordering, one pinned). + +- [ ] **Step 2: Verify failures.** + +- [ ] **Step 3: Implement both FormatContext bodies** to the layout above. Shared constants per backend (no cross-package sharing available): + +```go +const ( + contextByteCap = 1600 + contextRecentObs = 3 + contextRecentSessions = 5 + contextSummaryTruncLen = 160 +) +``` + +Reuse each backend's existing data access (store: `RecentSessions`, `PinnedObservations`, `recentUnpinnedObservations`; memorylake: current fact partition) — only the RENDERING changes: one-line sessions, 3 observation summaries (first-sentence rule identical to Task 8's `firstSentence` — memorylake already has `truncate`, replace its use for this block with a local firstSentence copy; store side likewise), the footer, then enforce `contextByteCap` by dropping oldest-observation → oldest-session lines until under cap. + +- [ ] **Step 4: Parity + full verify** — `go test ./internal/store/ ./internal/memorylake/ && go test -tags parity ./internal/paritytest/` (live-gated; skips without key — run it WITH the configured key since this sandbox has it). Add a parity assertion if feasible per the corpus design; otherwise document in the commit body that layout parity is enforced by the two mirrored unit tests. + +- [ ] **Step 5: Commit** — `git add internal/ && git commit -m "feat(retrieval): layered mem_context with 400-token budget (both backends)"` + +--- + +### Task 10: R3 eval round (gates: tokens ↓, recall@5 regression ≤ 0.03, distractor ratio not worse) + +Same procedure as Task 5: build `engram-w1` + symlink for hook, L1 + L1-verify + L2 + compare vs round-1 cards. R3-specific expectations: `avg_tokens_per_query` drops sharply (index vs full bodies — expect ≤ 200 from 517), `context_tokens` ≤ 400 (from 697), `injected_tokens_per_session` should now be near/below the 3912 target; `avg_distractor_ratio` must not increase vs round 1 (Task 1 metric — this is R5 guarding R3). recall@5 gate unchanged (search itself untouched; only rendering). Also run a 4-task targeted L3 gotcha+fix sanity (`-tasks gotcha-001,gotcha-003,fix-001,fix-003 -n 1`) — gotcha tasks must stay ≥ baseline (index mode must not starve retrieval-dependent tasks; this is the spec's R3 risk row). Record, commit (`feat(eval): wave1 round 3 (R3 progressive retrieval) scorecards`), keep/revert per gates. + +--- + +### Task 11: R4 — pinned core-memory block (cap, order, criterion, real pins) + +**Files:** +- Modify: `internal/memorylake/backend.go` + `internal/store/store.go` (pinned section: 1KB cap, pin-time ascending order), `plugin/*/scripts/session-start.sh` slim text + `plugin/*/skills/memory/SKILL.md` (pin criterion line), tests in both backend test files + +**Interfaces:** +- Produces: `### Pinned (core memory)` section capped at 1024 bytes; entries ordered by pin time ascending (stable prefix); overflow drops the OLDEST pin from rendering with a trailing `(pin cap reached: N pinned facts not shown — mem_unpin to prune)`. Protocol text gains: `Pin (mem_pin) only facts whose loss causes irreversible damage or repeated pitfalls; pinned facts are always in context.` + +- [ ] **Step 1: Failing tests** — in both backend test files: seed 15 pinned facts of ~120 bytes each; assert rendered pinned section ≤ 1024 bytes, overflow marker present, the NEWEST pinned fact shown, and the marker's count equals the number dropped. Overflow policy (spec: "oldest pin demotes"): render pins in pin-time ascending order, and when over the 1024-byte cap drop OLDEST pins first until the newest ones fit. Store-side pin time = the pin operation's update timestamp (check what `PinObservation` records; memorylake: `isPinned` metadata — if no pin timestamp exists, order by fact `UpdatedAt` and document that as the pin-time proxy). + +- [ ] **Step 2: Implement** cap+order in both `FormatContext` pinned blocks (inside the Task 9 structure); add the criterion line to slim heredoc (stays within the 3400-byte golden budget — the line is ~110 bytes; Task 3's budget left ≥ 400 bytes slack) and SKILL files. + +- [ ] **Step 3: Pin real phoenix gotcha facts** (legitimate usage, disclosed): via MCP or CLI against the live phoenix project, pin the delta-kernel-FFI-rebase fact and the isolation2 invalid-relation convention fact (ids from `evalrun -suite dump-facts -project phoenix`). Record the two fact ids in the round report. + +- [ ] **Step 4: Eval round** — L1+L2 (context_tokens will rise slightly — pinned block; must stay ≤ 400 total) + targeted L3 gotcha subset (`-tasks gotcha-001,gotcha-002,gotcha-004,gotcha-005 -n 1`): gate = gotcha memory-arm mean ≥ baseline's 8.5. Commit code + scorecards (`feat(memory): pinned core-memory block with 1KB cap` / `feat(eval): wave1 round 4 (R4 pinned core) scorecards`), keep/revert per gates. + +--- + +### Task 12: Full L3 acceptance + Wave 1 report + +- [ ] **Step 1: Full L3 (N=2), optimized binary** + +```bash +source /workspace/work/goenv.sh && go build -o /workspace/work/engram-w1 ./cmd/engram && go build -o /workspace/work/evalrun-w1 ./eval/cmd/evalrun +nohup env -u ENGRAM_BACKEND /workspace/work/evalrun-w1 -suite l3 -n 2 -engram-bin /workspace/work/engram-w1 -out eval/results/w1-final-l3.json > /workspace/work/w1-l3.log 2>&1 & +``` + +Monitor via the sidecar verdict count (same pattern as baseline). ~60 sessions. + +- [ ] **Step 2: Acceptance arithmetic + report** + +Write `eval/results/wave1-report.md`: anchors table (baseline → wave1 → target → verdict), per-category L3 breakdown, MANDATORY no-memory row (R5), per-round kept/reverted ledger with per-round L2/L1 deltas, honest bottleneck analysis for any missed anchor (spec: no dressing up), assumptions unchanged (search-calls=3.0, tokenizer approx). Commit scorecards + report (`feat(eval): wave1 acceptance run and report`). `mem_save` topic_key `eval/wave1-acceptance` with both final numbers. + +- [ ] **Step 3: Ship** — final whole-branch review (superpowers:requesting-code-review flow), then PR per repo rules (issue with status:approved + type:feature + Closes #N; use `~/bin/gh`); release decision stays with the user. + +--- + +### Task 13: MemoryLake server-side proposals (parallel, non-blocking) + +**Files:** Create `docs/proposals/2026-07-memorylake-server-asks.md` + +- [ ] **Step 1:** Write three proposal sections, each: current client-side pain (cite Wave 1 scorecard numbers), proposed API change with a concrete request/response sketch, expected effect on the two anchors. Topics: (1) temporal validity (`valid_from`/`valid_until` on facts + de-ranking superseded facts in search); (2) relevance-threshold / min-score parameter on `POST /memories/search` (client currently filters after paying for the payload); (3) batch fact-add + batch forget endpoints (migration and cleanup do N round-trips today). +- [ ] **Step 2:** No TBD placeholders; commit (`docs(proposals): MemoryLake server asks from wave1 evidence`). May be executed any time after Task 5 (needs round-1 numbers for citations). + +--- + +## Verification checklist (end of plan) + +- Every round's scorecards + compare output committed under `eval/results/`; ledger has one line per round with kept/reverted status. +- `go test ./...` green at every commit; paritytest run for Tasks 9/11. +- Frozen datasets untouched (`git diff --stat main -- eval/datasets/` shows nothing). +- wave1-report.md states both anchor verdicts with no-memory comparison row. +- All five golden/budget tests present: slim ≤ 3400B, instructions ≤ 1300B, answer-first rules, context ≤ 1600B, pinned ≤ 1024B. From 086c1cb7e407cdb4e19f96bdf4adfae823d3f4be Mon Sep 17 00:00:00 2001 From: zhishu Date: Mon, 27 Jul 2026 11:06:44 +0000 Subject: [PATCH 03/29] =?UTF-8?q?feat(eval):=20wave1=20guardrails=20?= =?UTF-8?q?=E2=80=94=20distractor=20ratio,=20compare=20suite,=20task=20fil?= =?UTF-8?q?ter,=20mem=5F*=20audit=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- eval/cmd/evalrun/main.go | 30 +++++++++++++++++++++--- eval/e2e/task.go | 34 ++++++++++++++++++++++++++++ eval/e2e/task_test.go | 14 ++++++++++++ eval/metrics/metrics.go | 13 +++++++++++ eval/metrics/metrics_test.go | 18 +++++++++++++++ eval/runner/l1.go | 11 ++++++--- eval/runner/l1_test.go | 7 ++++++ internal/mcp/audit.go | 38 +++++++++++++++++++++++++++++++ internal/mcp/audit_test.go | 28 +++++++++++++++++++++++ internal/mcp/mcp.go | 44 ++++++++++++++++++------------------ 10 files changed, 209 insertions(+), 28 deletions(-) create mode 100644 internal/mcp/audit.go create mode 100644 internal/mcp/audit_test.go diff --git a/eval/cmd/evalrun/main.go b/eval/cmd/evalrun/main.go index 7a0926e4..cfb5c457 100644 --- a/eval/cmd/evalrun/main.go +++ b/eval/cmd/evalrun/main.go @@ -26,7 +26,7 @@ import ( ) func main() { - suite := flag.String("suite", "", "l1 | l1-verify | l2 | l3 | judge-calibrate | dump-facts") + suite := flag.String("suite", "", "l1 | l1-verify | l2 | l3 | compare | judge-calibrate | dump-facts") dsPath := flag.String("dataset", "", "dataset path (l1/l3)") project := flag.String("project", "phoenix", "engram project name") out := flag.String("out", "", "output scorecard path (default eval/results/--.json)") @@ -39,6 +39,9 @@ func main() { phoenixDir := flag.String("phoenix-dir", "/workspace/phoenix", "repo dir claude runs in (l3)") probe := flag.Bool("probe-only", false, "run only the isolation probe (l3)") taskID := flag.String("task", "", "task id for judge-calibrate") + tasksCSV := flag.String("tasks", "", "l3: comma-separated task id filter (default: all tasks)") + baseCard := flag.String("base", "", "compare: baseline scorecard path") + curCard := flag.String("cur", "", "compare: current scorecard path") flag.Parse() switch *suite { @@ -53,7 +56,9 @@ func main() { if dsDir == "" { dsDir = "eval/datasets/phoenix-e2e-v1" } - runL3(dsDir, strings.Split(*arms, ","), *engramBin, *phoenixDir, *model, *out, *n, *probe) + runL3(dsDir, strings.Split(*arms, ","), *engramBin, *phoenixDir, *model, *out, *n, *probe, *tasksCSV) + case "compare": + runCompare(*baseCard, *curCard) case "judge-calibrate": judgeCalibrate(*dsPath, *taskID, *model) case "dump-facts": @@ -207,11 +212,30 @@ func fatal(format string, args ...any) { os.Exit(1) } -func runL3(dsDir string, armNames []string, engramBin, phoenixDir, model, out string, n int, probeOnly bool) { +// runCompare prints the base-vs-current scorecard diff table to stdout. It is +// the round-over-round guardrail check: every optimization round compares its +// fresh scorecard against the frozen baseline card. +func runCompare(basePath, curPath string) { + base, err := scorecard.Load(basePath) + if err != nil { + fatal("base: %v", err) + } + cur, err := scorecard.Load(curPath) + if err != nil { + fatal("cur: %v", err) + } + fmt.Print(scorecard.CompareMarkdown(base, cur)) +} + +func runL3(dsDir string, armNames []string, engramBin, phoenixDir, model, out string, n int, probeOnly bool, tasksCSV string) { tasks, err := e2e.LoadTasks(filepath.Join(dsDir, "tasks")) if err != nil { fatal("tasks: %v", err) } + tasks, err = e2e.FilterTasks(tasks, tasksCSV) + if err != nil { + fatal("tasks: %v", err) + } tpl, err := os.ReadFile(filepath.Join(dsDir, "judge_prompt.md")) if err != nil { fatal("judge prompt: %v", err) diff --git a/eval/e2e/task.go b/eval/e2e/task.go index 08f2aff3..4eff9b65 100644 --- a/eval/e2e/task.go +++ b/eval/e2e/task.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "sort" + "strings" ) type Rubric struct { @@ -61,3 +62,36 @@ func LoadTasks(dir string) ([]Task, error) { } return tasks, nil } + +// FilterTasks returns the subset of tasks whose IDs appear in csv (comma +// separated), preserving tasks' original order. Empty csv returns all. +func FilterTasks(tasks []Task, csv string) ([]Task, error) { + csv = strings.TrimSpace(csv) + if csv == "" { + return tasks, nil + } + want := map[string]bool{} + for _, id := range strings.Split(csv, ",") { + want[strings.TrimSpace(id)] = true + } + var out []Task + for _, t := range tasks { + if want[t.ID] { + out = append(out, t) + delete(want, t.ID) + } + } + if len(want) > 0 { + var valid []string + for _, t := range tasks { + valid = append(valid, t.ID) + } + var missing []string + for id := range want { + missing = append(missing, id) + } + sort.Strings(missing) + return nil, fmt.Errorf("unknown task ids %v (valid: %v)", missing, valid) + } + return out, nil +} diff --git a/eval/e2e/task_test.go b/eval/e2e/task_test.go index d271d7b4..5e152711 100644 --- a/eval/e2e/task_test.go +++ b/eval/e2e/task_test.go @@ -56,3 +56,17 @@ func TestRealTaskSetParses(t *testing.T) { t.Errorf("task count %d outside 12-16", len(tasks)) } } + +func TestFilterTasks(t *testing.T) { + tasks := []Task{{ID: "a"}, {ID: "b"}, {ID: "c"}} + got, err := FilterTasks(tasks, "c,a") + if err != nil || len(got) != 2 || got[0].ID != "a" || got[1].ID != "c" { + t.Fatalf("FilterTasks = %+v, %v", got, err) + } + if all, err := FilterTasks(tasks, ""); err != nil || len(all) != 3 { + t.Fatalf("empty csv should return all, got %d, %v", len(all), err) + } + if _, err := FilterTasks(tasks, "a,zzz"); err == nil { + t.Fatal("unknown id must error") + } +} diff --git a/eval/metrics/metrics.go b/eval/metrics/metrics.go index 8fcb3100..8165077d 100644 --- a/eval/metrics/metrics.go +++ b/eval/metrics/metrics.go @@ -62,3 +62,16 @@ func HitsKeywordGroups(text string, groups [][]string) bool { } return true } + +// DistractorRatio: share of returned results ranked above the first hit. +// rank is 1-based (0 = miss). A miss with results counts as all-distractor +// (1.0); no results at all counts 0 (nothing was injected). +func DistractorRatio(firstHitRank, resultCount int) float64 { + if resultCount == 0 { + return 0 + } + if firstHitRank == 0 { + return 1 + } + return float64(firstHitRank-1) / float64(resultCount) +} diff --git a/eval/metrics/metrics_test.go b/eval/metrics/metrics_test.go index d9ce67ca..932682cc 100644 --- a/eval/metrics/metrics_test.go +++ b/eval/metrics/metrics_test.go @@ -52,3 +52,21 @@ func TestHitsKeywordGroups(t *testing.T) { t.Error("expected miss on empty groups (vacuous hit would poison recall)") } } + +func TestDistractorRatio(t *testing.T) { + cases := []struct { + rank, count int + want float64 + }{ + {1, 10, 0}, // hit on top: no distractors + {3, 10, 0.2}, // 2 distractors above hit / 10 results + {0, 10, 1}, // miss with results: all distractors + {0, 0, 0}, // no results at all + {2, 2, 0.5}, + } + for _, c := range cases { + if got := DistractorRatio(c.rank, c.count); got != c.want { + t.Errorf("DistractorRatio(%d,%d) = %v, want %v", c.rank, c.count, got, c.want) + } + } +} diff --git a/eval/runner/l1.go b/eval/runner/l1.go index 6557f2d0..bbec039d 100644 --- a/eval/runner/l1.go +++ b/eval/runner/l1.go @@ -34,6 +34,7 @@ func RunL1(b SearchBackend, cases []dataset.RetrievalCase, cfg L1Config) (scorec ranks := make([]int, 0, len(cases)) items := make([]scorecard.ItemResult, 0, len(cases)) var totalTokens float64 + var totalDR float64 latencies := make([]float64, 0, len(cases)) for _, c := range cases { @@ -51,18 +52,21 @@ func RunL1(b SearchBackend, cases []dataset.RetrievalCase, cfg L1Config) (scorec break } } + dr := metrics.DistractorRatio(rank, len(results)) payload, _ := json.Marshal(results) tokens := float64(metrics.ApproxTokens(string(payload))) ranks = append(ranks, rank) totalTokens += tokens + totalDR += dr latencies = append(latencies, latMS) items = append(items, scorecard.ItemResult{ ID: c.ID, Values: map[string]float64{ - "first_hit_rank": float64(rank), - "tokens": tokens, - "latency_ms": latMS, + "first_hit_rank": float64(rank), + "tokens": tokens, + "latency_ms": latMS, + "distractor_ratio": dr, }, }) } @@ -75,6 +79,7 @@ func RunL1(b SearchBackend, cases []dataset.RetrievalCase, cfg L1Config) (scorec } if len(cases) > 0 { m["avg_tokens_per_query"] = totalTokens / float64(len(cases)) + m["avg_distractor_ratio"] = totalDR / float64(len(cases)) m["latency_p50_ms"] = percentile(latencies, 0.50) m["latency_p95_ms"] = percentile(latencies, 0.95) } diff --git a/eval/runner/l1_test.go b/eval/runner/l1_test.go index 566c5e66..1ad39d83 100644 --- a/eval/runner/l1_test.go +++ b/eval/runner/l1_test.go @@ -50,6 +50,13 @@ func TestRunL1(t *testing.T) { if len(sc.PerItem) != 2 || sc.PerItem[0].Values["first_hit_rank"] != 2 { t.Errorf("per-item results wrong: %+v", sc.PerItem) } + // q1 hits at rank 2 of 2 results → (2-1)/2 = 0.5; q2 misses with 1 result → 1. + if got := sc.PerItem[0].Values["distractor_ratio"]; got != 0.5 { + t.Errorf("per-item distractor_ratio = %v, want 0.5", got) + } + if got := sc.Metrics["avg_distractor_ratio"]; got != 0.75 { + t.Errorf("avg_distractor_ratio = %v, want 0.75", got) + } if sc.Suite != "l1" { t.Errorf("suite = %q, want l1", sc.Suite) } diff --git a/internal/mcp/audit.go b/internal/mcp/audit.go new file mode 100644 index 00000000..b8e1747e --- /dev/null +++ b/internal/mcp/audit.go @@ -0,0 +1,38 @@ +package mcp + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// auditToolCall appends one " " line to the file named by +// ENGRAM_MCP_AUDIT_LOG. Opt-in, best-effort: any failure is ignored so the +// audit can never break a tool call. Used by the eval harness to count +// mem_* usage per session (Phase 2 spec: R2 save-count monitoring). +func auditToolCall(tool string) { + path := os.Getenv("ENGRAM_MCP_AUDIT_LOG") + if path == "" { + return + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer f.Close() + fmt.Fprintf(f, "%s %s\n", time.Now().UTC().Format(time.RFC3339), tool) +} + +// withAudit wraps a tool handler so every invocation is recorded by +// auditToolCall before the real handler runs. It is applied at every +// mem_* AddTool site in registerTools. +func withAudit(name string, h server.ToolHandlerFunc) server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + auditToolCall(name) + return h(ctx, req) + } +} diff --git a/internal/mcp/audit_test.go b/internal/mcp/audit_test.go new file mode 100644 index 00000000..4566e178 --- /dev/null +++ b/internal/mcp/audit_test.go @@ -0,0 +1,28 @@ +package mcp + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAuditToolCall(t *testing.T) { + p := filepath.Join(t.TempDir(), "audit.log") + t.Setenv("ENGRAM_MCP_AUDIT_LOG", p) + auditToolCall("mem_save") + auditToolCall("mem_search") + b, err := os.ReadFile(p) + if err != nil { + t.Fatalf("audit file: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(b)), "\n") + if len(lines) != 2 || !strings.HasSuffix(lines[0], " mem_save") || !strings.HasSuffix(lines[1], " mem_search") { + t.Fatalf("unexpected audit content: %q", string(b)) + } +} + +func TestAuditToolCallDisabled(t *testing.T) { + t.Setenv("ENGRAM_MCP_AUDIT_LOG", "") + auditToolCall("mem_save") // must not panic or create files +} diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 89924e04..7827ca22 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -321,7 +321,7 @@ func registerTools(srv *server.MCPServer, sel BackendSelector, cfg MCPConfig, al mcp.Description("Max results (default: 10, max: 20)"), ), ), - handleSearch(sel, cfg, activity), + withAudit("mem_search", handleSearch(sel, cfg, activity)), ) } @@ -395,7 +395,7 @@ Examples: mcp.Description("Automatically capture the current user prompt when available (default: true). Set false for SDD artifacts or automated saves."), ), ), - queuedWriteHandler(writeQueue, handleSave(sel, cfg, activity)), + withAudit("mem_save", queuedWriteHandler(writeQueue, handleSave(sel, cfg, activity))), ) } @@ -430,7 +430,7 @@ Examples: mcp.Description("New topic key (normalized internally)"), ), ), - queuedWriteHandler(writeQueue, handleUpdate(sel, cfg)), + withAudit("mem_update", queuedWriteHandler(writeQueue, handleUpdate(sel, cfg))), ) } @@ -451,7 +451,7 @@ Examples: mcp.WithString("observation_id", mcp.Description("Observation sync_id for action=mark_reviewed (from mem_search/mem_save/mem_get_observation results).")), mcp.WithString("id", mcp.Description("Backward-compatible alias for observation_id.")), ), - queuedWriteHandler(writeQueue, handleReview(sel, cfg)), + withAudit("mem_review", queuedWriteHandler(writeQueue, handleReview(sel, cfg))), ) } @@ -476,7 +476,7 @@ Examples: mcp.Description("Observation content used as fallback if title is empty"), ), ), - handleSuggestTopicKey(), + withAudit("mem_suggest_topic_key", handleSuggestTopicKey()), ) } @@ -499,7 +499,7 @@ Examples: mcp.Description("If true, permanently deletes the observation"), ), ), - queuedWriteHandler(writeQueue, handleDelete(sel, cfg)), + withAudit("mem_delete", queuedWriteHandler(writeQueue, handleDelete(sel, cfg))), ) } @@ -530,7 +530,7 @@ Examples: mcp.Description("Short-lived token returned by an ambiguous_project error. Required with project_choice_reason=user_selected_after_ambiguous_project."), ), ), - queuedWriteHandler(writeQueue, handleSavePrompt(sel, cfg, activity)), + withAudit("mem_save_prompt", queuedWriteHandler(writeQueue, handleSavePrompt(sel, cfg, activity))), ) } @@ -547,7 +547,7 @@ Examples: mcp.WithOpenWorldHintAnnotation(false), mcp.WithString("id", mcp.Required(), mcp.Description("Observation sync_id to pin (from mem_search/mem_save/mem_get_observation results)")), ), - handlePin(sel, cfg, true), + withAudit("mem_pin", handlePin(sel, cfg, true)), ) } if shouldRegister("mem_unpin", allowlist) { @@ -562,7 +562,7 @@ Examples: mcp.WithOpenWorldHintAnnotation(false), mcp.WithString("id", mcp.Required(), mcp.Description("Observation sync_id to unpin (from mem_search/mem_save/mem_get_observation results)")), ), - handlePin(sel, cfg, false), + withAudit("mem_unpin", handlePin(sel, cfg, false)), ) } @@ -584,7 +584,7 @@ Examples: ), // JW7: limit param removed — schema advertised it but handleContext never read it. ), - handleContext(sel, cfg, activity), + withAudit("mem_context", handleContext(sel, cfg, activity)), ) } @@ -603,7 +603,7 @@ Examples: mcp.Description("Project to echo in envelope context (omit for auto-detect; stats themselves are global aggregates)"), ), ), - handleStats(sel, cfg), + withAudit("mem_stats", handleStats(sel, cfg)), ) } @@ -632,7 +632,7 @@ Examples: mcp.Description("Filter by project name (omit for auto-detect)"), ), ), - handleTimeline(sel, cfg), + withAudit("mem_timeline", handleTimeline(sel, cfg)), ) } @@ -651,7 +651,7 @@ Examples: mcp.Description("The observation sync_id to retrieve (from mem_search/mem_save results)"), ), ), - handleGetObservation(sel, cfg), + withAudit("mem_get_observation", handleGetObservation(sel, cfg)), ) } @@ -714,7 +714,7 @@ GUIDELINES: mcp.Description("Short-lived token returned by an ambiguous_project error. Required with project_choice_reason=user_selected_after_ambiguous_project."), ), ), - queuedWriteHandler(writeQueue, handleSessionSummary(sel, cfg, activity)), + withAudit("mem_session_summary", queuedWriteHandler(writeQueue, handleSessionSummary(sel, cfg, activity))), ) } @@ -737,7 +737,7 @@ GUIDELINES: mcp.Description("Working directory"), ), ), - queuedWriteHandler(writeQueue, handleSessionStart(sel, cfg, activity)), + withAudit("mem_session_start", queuedWriteHandler(writeQueue, handleSessionStart(sel, cfg, activity))), ) } @@ -760,7 +760,7 @@ GUIDELINES: mcp.Description("Summary of what was accomplished"), ), ), - queuedWriteHandler(writeQueue, handleSessionEnd(sel, cfg, activity)), + withAudit("mem_session_end", queuedWriteHandler(writeQueue, handleSessionEnd(sel, cfg, activity))), ) } @@ -790,7 +790,7 @@ Duplicates are automatically detected and skipped — safe to call multiple time mcp.Description("Source identifier (e.g. 'subagent-stop', 'session-end')"), ), ), - queuedWriteHandler(writeQueue, handleCapturePassive(sel, cfg, activity)), + withAudit("mem_capture_passive", queuedWriteHandler(writeQueue, handleCapturePassive(sel, cfg, activity))), ) } @@ -814,7 +814,7 @@ Duplicates are automatically detected and skipped — safe to call multiple time mcp.Description("The canonical project name to merge INTO (e.g. 'engram')"), ), ), - queuedWriteHandler(writeQueue, handleMergeProjects(sel)), + withAudit("mem_merge_projects", queuedWriteHandler(writeQueue, handleMergeProjects(sel))), ) } @@ -829,7 +829,7 @@ Duplicates are automatically detected and skipped — safe to call multiple time mcp.WithIdempotentHintAnnotation(true), mcp.WithOpenWorldHintAnnotation(false), ), - handleCurrentProject(sel, cfg), + withAudit("mem_current_project", handleCurrentProject(sel, cfg)), ) } @@ -847,7 +847,7 @@ Duplicates are automatically detected and skipped — safe to call multiple time mcp.WithString("project", mcp.Description("Project to diagnose (omit for auto-detect)")), mcp.WithString("check", mcp.Description("Optional diagnostic check code to run")), ), - handleDoctor(sel, cfg), + withAudit("mem_doctor", handleDoctor(sel, cfg)), ) } @@ -901,7 +901,7 @@ Re-judging an already-judged ID overwrites the verdict (deliberate revision).`), mcp.Description("Session ID for provenance (default: auto)"), ), ), - queuedWriteHandler(writeQueue, handleJudge(sel, activity)), + withAudit("mem_judge", queuedWriteHandler(writeQueue, handleJudge(sel, activity))), ) } @@ -958,7 +958,7 @@ ERROR: Returns IsError=true if IDs are unknown, relation is invalid, or cross-pr mcp.Description("Your model identifier for provenance (e.g. \"claude-haiku-4-5\")"), ), ), - handleCompare(sel, cfg, activity), + withAudit("mem_compare", handleCompare(sel, cfg, activity)), ) } } From cf92b3333032875eae8e7ac52c5a0f6de42527fa Mon Sep 17 00:00:00 2001 From: zhishu Date: Mon, 27 Jul 2026 11:17:57 +0000 Subject: [PATCH 04/29] fix(eval): guard partial L3 scorecards, tolerate trailing task csv, test audit passthrough, document audit env --- DOCS.md | 1 + eval/cmd/evalrun/main.go | 25 ++++++++++++++++++++- eval/cmd/evalrun/main_test.go | 26 ++++++++++++++++++++++ eval/e2e/task.go | 9 +++++++- eval/e2e/task_test.go | 12 ++++++++++ internal/mcp/audit_test.go | 41 +++++++++++++++++++++++++++++++++++ 6 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 eval/cmd/evalrun/main_test.go diff --git a/DOCS.md b/DOCS.md index 23891eaf..5985d978 100644 --- a/DOCS.md +++ b/DOCS.md @@ -474,6 +474,7 @@ Response: | `ENGRAM_HTTP_TOKEN` | Optional Bearer auth for the local HTTP server. When set, the following routes require `Authorization: Bearer `: `DELETE /sessions/{id}`, `DELETE /observations/{id}`, `DELETE /prompts/{id}`, `GET /export`, `POST /import`, `POST /projects/migrate`. Comparison is constant-time. Token is read at request time (no restart needed). When unset, all routes are open (zero-config default). | (unset — open) | | `ENGRAM_TIMEZONE` | Timezone for timestamp display in the TUI and cloud dashboard. Accepts any IANA zone name (e.g. `America/New_York`, `Europe/Berlin`). Falls back to system local time when unset or invalid. | system local | | `ENGRAM_AGENT_CLI` | LLM runner name used by `engram conflicts scan --semantic` and the HTTP `/conflicts/scan` endpoint. Accepted values: `claude`, `opencode`. | (unset) | +| `ENGRAM_MCP_AUDIT_LOG` | Opt-in audit trail for `engram mcp`. When set to a file path, one ` ` line is appended per `mem_*` tool **call arrival** — it counts attempts, not successful writes, so a call that later errors or is rejected still produces a line. Best-effort: any file/permission failure is silently ignored and never fails the tool call. Primarily intended for the eval harness to measure per-session `mem_*` usage. | (unset — disabled) | | `ENGRAM_BACKEND` | Global safety valve for the [MemoryLake backend](#memorylake-backend). Set to `sqlite` to force every project onto local SQLite regardless of per-project enablement. Any other value (or unset) defers to the per-project enablement list. | (unset — per-project) | | `ENGRAM_MEMORYLAKE_BASE_URL` | MemoryLake V3 API base URL. Required to enable the MemoryLake backend for any project. | (unset) | | `ENGRAM_MEMORYLAKE_API_KEY` | MemoryLake API key (`Authorization: Bearer `). Required to enable the MemoryLake backend for any project. | (unset) | diff --git a/eval/cmd/evalrun/main.go b/eval/cmd/evalrun/main.go index cfb5c457..635074d5 100644 --- a/eval/cmd/evalrun/main.go +++ b/eval/cmd/evalrun/main.go @@ -227,7 +227,24 @@ func runCompare(basePath, curPath string) { fmt.Print(scorecard.CompareMarkdown(base, cur)) } +// validateL3Flags rejects flag combinations that would produce a misleading +// scorecard. A -tasks filter yields a PARTIAL grid, so it must never be written +// to the default full-grid path (eval/results/--l3.json) where it +// would silently masquerade as — or overwrite — a complete run. +func validateL3Flags(out, tasksCSV string) error { + if strings.TrimSpace(tasksCSV) != "" && out == "" { + return fmt.Errorf("-tasks requires an explicit -out " + + "(partial runs must not overwrite the default full-grid scorecard path)") + } + return nil +} + func runL3(dsDir string, armNames []string, engramBin, phoenixDir, model, out string, n int, probeOnly bool, tasksCSV string) { + // Validate flags first: this must fatal before any arm materialization or + // claude invocation so a bad invocation costs nothing. + if err := validateL3Flags(out, tasksCSV); err != nil { + fatal("%v", err) + } tasks, err := e2e.LoadTasks(filepath.Join(dsDir, "tasks")) if err != nil { fatal("tasks: %v", err) @@ -382,10 +399,16 @@ func runL3(dsDir string, armNames []string, engramBin, phoenixDir, model, out st if a, b := m["mean_score_memory"], m["mean_score_no-memory"]; len(armNames) > 1 { m["uplift"] = a - b } + env := map[string]string{"model": model, "n": strconv.Itoa(n), "arms": strings.Join(armNames, ",")} + if f := strings.TrimSpace(tasksCSV); f != "" { + // Mark the scorecard as a partial grid so downstream comparison never + // treats a filtered run as a full-suite result. + env["tasks"] = f + } sc := scorecard.Scorecard{ Suite: "l3", Date: time.Now().UTC().Format("2006-01-02"), GitSHA: gitSHA(), Metrics: m, PerItem: items, - Env: map[string]string{"model": model, "n": strconv.Itoa(n), "arms": strings.Join(armNames, ",")}, + Env: env, } writeCard(sc, out) } diff --git a/eval/cmd/evalrun/main_test.go b/eval/cmd/evalrun/main_test.go new file mode 100644 index 00000000..177f1b6e --- /dev/null +++ b/eval/cmd/evalrun/main_test.go @@ -0,0 +1,26 @@ +package main + +import ( + "strings" + "testing" +) + +// A -tasks filter produces a PARTIAL L3 grid, so it must never land on the +// default full-grid scorecard path. Guard fires at flag-validation time, before +// any arm materialization or claude spend. +func TestValidateL3Flags(t *testing.T) { + if err := validateL3Flags("", "fix-001"); err == nil { + t.Fatal("-tasks without -out must be rejected") + } else if !strings.Contains(err.Error(), "-tasks requires an explicit -out") { + t.Errorf("unexpected message: %v", err) + } + if err := validateL3Flags("", " "); err != nil { + t.Errorf("whitespace-only -tasks is not a filter: %v", err) + } + if err := validateL3Flags("", ""); err != nil { + t.Errorf("no filter + default out must be allowed: %v", err) + } + if err := validateL3Flags("eval/results/partial.json", "fix-001"); err != nil { + t.Errorf("explicit -out must be allowed: %v", err) + } +} diff --git a/eval/e2e/task.go b/eval/e2e/task.go index 4eff9b65..b6385023 100644 --- a/eval/e2e/task.go +++ b/eval/e2e/task.go @@ -72,7 +72,14 @@ func FilterTasks(tasks []Task, csv string) ([]Task, error) { } want := map[string]bool{} for _, id := range strings.Split(csv, ",") { - want[strings.TrimSpace(id)] = true + id = strings.TrimSpace(id) + if id == "" { // tolerate trailing/duplicate commas: "fix-001," or "a,,b" + continue + } + want[id] = true + } + if len(want) == 0 { // csv held only separators/whitespace — same as no filter + return tasks, nil } var out []Task for _, t := range tasks { diff --git a/eval/e2e/task_test.go b/eval/e2e/task_test.go index 5e152711..a1f37ecb 100644 --- a/eval/e2e/task_test.go +++ b/eval/e2e/task_test.go @@ -69,4 +69,16 @@ func TestFilterTasks(t *testing.T) { if _, err := FilterTasks(tasks, "a,zzz"); err == nil { t.Fatal("unknown id must error") } + // Trailing (and interior) empty entries are tolerated, not reported as an + // unknown empty id. + trailing, err := FilterTasks(tasks, "a,") + if err != nil || len(trailing) != 1 || trailing[0].ID != "a" { + t.Fatalf("trailing comma: got %+v, %v", trailing, err) + } + if got, err := FilterTasks(tasks, "a,,b"); err != nil || len(got) != 2 { + t.Fatalf("interior empty entry: got %+v, %v", got, err) + } + if got, err := FilterTasks(tasks, " , "); err != nil || len(got) != 3 { + t.Fatalf("all-empty csv should return all, got %+v, %v", got, err) + } } diff --git a/internal/mcp/audit_test.go b/internal/mcp/audit_test.go index 4566e178..5683e5f3 100644 --- a/internal/mcp/audit_test.go +++ b/internal/mcp/audit_test.go @@ -1,10 +1,14 @@ package mcp import ( + "context" + "errors" "os" "path/filepath" "strings" "testing" + + mcppkg "github.com/mark3labs/mcp-go/mcp" ) func TestAuditToolCall(t *testing.T) { @@ -26,3 +30,40 @@ func TestAuditToolCallDisabled(t *testing.T) { t.Setenv("ENGRAM_MCP_AUDIT_LOG", "") auditToolCall("mem_save") // must not panic or create files } + +// withAudit must be a transparent wrapper: it records the call arrival and then +// returns the inner handler's (result, error) pair completely unchanged. +func TestWithAuditPassesResultAndErrorThrough(t *testing.T) { + p := filepath.Join(t.TempDir(), "audit.log") + t.Setenv("ENGRAM_MCP_AUDIT_LOG", p) + + sentinelRes := mcppkg.NewToolResultText("sentinel-payload") + sentinelErr := errors.New("sentinel-error") + calls := 0 + stub := func(ctx context.Context, req mcppkg.CallToolRequest) (*mcppkg.CallToolResult, error) { + calls++ + return sentinelRes, sentinelErr + } + + req := mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{"k": "v"}}} + res, err := withAudit("x", stub)(context.Background(), req) + + if calls != 1 { + t.Fatalf("inner handler called %d times, want 1", calls) + } + if res != sentinelRes { + t.Errorf("result not passed through: got %+v, want %+v", res, sentinelRes) + } + if !errors.Is(err, sentinelErr) { + t.Errorf("error not passed through: got %v, want %v", err, sentinelErr) + } + + b, readErr := os.ReadFile(p) + if readErr != nil { + t.Fatalf("audit file: %v", readErr) + } + lines := strings.Split(strings.TrimSpace(string(b)), "\n") + if len(lines) != 1 || !strings.HasSuffix(lines[0], " x") { + t.Fatalf("expected one audit line for %q, got %q", "x", string(b)) + } +} From f5466b9e71b56870b89f603e3937db40212bf04a Mon Sep 17 00:00:00 2001 From: zhishu Date: Mon, 27 Jul 2026 11:45:29 +0000 Subject: [PATCH 05/29] feat(protocol): revive slim protocol-mode for fork versions and add --set The slim protocol path was unreachable: protocolVersionFloor was 1.4.0 while this fork versions in the 0.x line, so `engram protocol-mode ` always printed "full" and the session-start hook never rendered the compact protocol text. - Lower protocolVersionFloor to 0.4.0 (fork versioning). - Treat the "dev" build placeholder as meeting the floor (source builds are by definition current), and strip semver pre-release/build metadata before parsing so Go >= 1.24 VCS pseudo-versions (0.4.1-0.2026...+dirty) parse instead of silently degrading to "full". - Add `engram protocol-mode --set slim|full`, which persists via the existing setup.WriteProtocolMode and then prints the effective mode. An invalid value is fatal (unlike `setup --protocol=`, where it only warns). - Retune the pre-existing floor tests to the new 0.4.0 floor and dev-meets- floor semantics. --- cmd/engram/main.go | 70 ++++++++++++++++++++++++------- cmd/engram/main_extra_test.go | 43 +++++++++++++++++++ cmd/engram/setup_protocol_test.go | 37 +++++++++------- 3 files changed, 120 insertions(+), 30 deletions(-) diff --git a/cmd/engram/main.go b/cmd/engram/main.go index 78c4bd47..41fb0127 100644 --- a/cmd/engram/main.go +++ b/cmd/engram/main.go @@ -2933,21 +2933,42 @@ func applyProtocolMode(cfg store.Config, slug, mode string) { } } -// cmdProtocolMode implements `engram protocol-mode `: prints "slim" to -// stdout ONLY when the persisted mode for slug is "slim" AND the running -// binary's version meets the slim floor (>= 1.4.0); any other case -// (unrecognized slug, missing/corrupted mode file, version below floor, -// unparseable version) prints "full". All branching lives here in Go so it -// runs under `go test` — the Claude Code hook scripts only read this single -// line of stdout. +// cmdProtocolMode implements `engram protocol-mode [--set slim|full]`. +// +// Read path: prints "slim" to stdout ONLY when the persisted mode for slug is +// "slim" AND the running binary's version meets the slim floor +// (>= protocolVersionFloor); any other case (unrecognized slug, +// missing/corrupted mode file, version below floor, unparseable version) +// prints "full". All branching lives here in Go so it runs under `go test` — +// the Claude Code hook scripts only read this single line of stdout. +// +// Write path: `--set slim|full` persists the mode for slug first (an invalid +// value is fatal — unlike `engram setup --protocol=`, which only warns, +// because here setting the mode IS the whole point of the invocation), then +// falls through to the read path so the printed line is always the effective +// mode, not merely the requested one. func cmdProtocolMode(cfg store.Config) { if len(os.Args) < 3 { - fmt.Fprintln(os.Stderr, "usage: engram protocol-mode ") + fmt.Fprintln(os.Stderr, "usage: engram protocol-mode [--set slim|full]") exitFunc(1) return } slug := os.Args[2] + if len(os.Args) >= 5 && os.Args[3] == "--set" { + want := os.Args[4] + if want != setup.ProtocolModeSlim && want != setup.ProtocolModeFull { + fmt.Fprintf(os.Stderr, "invalid mode %q: must be slim or full\n", want) + exitFunc(1) + return + } + if err := setup.WriteProtocolMode(cfg.DataDir, slug, want); err != nil { + fmt.Fprintf(os.Stderr, "write protocol mode: %v\n", err) + exitFunc(1) + return + } + } + mode := setup.ReadProtocolMode(cfg.DataDir, slug) if mode == setup.ProtocolModeSlim && meetsProtocolVersionFloor(version) { fmt.Println(setup.ProtocolModeSlim) @@ -2957,19 +2978,36 @@ func cmdProtocolMode(cfg store.Config) { } // protocolVersionFloor is the minimum engram version required to honor a -// persisted "slim" protocol-mode: the slim status block relies on the -// MCP serverInstructions duplication fix shipped in this release. -var protocolVersionFloor = [3]int{1, 4, 0} - -// meetsProtocolVersionFloor reports whether v (e.g. "1.4.0", "v1.5.2", or the -// build-time "dev" placeholder) is >= protocolVersionFloor. Any unparseable -// or empty value returns false — the caller then falls back to "full". +// persisted "slim" protocol-mode. This fork versions in the 0.x line, so the +// floor tracks fork versioning: slim relies on the Wave-1 compact protocol +// text shipped in v0.4.0 and later. +var protocolVersionFloor = [3]int{0, 4, 0} + +// meetsProtocolVersionFloor reports whether v (e.g. "0.4.0", "v0.5.2", or the +// build-time "dev" placeholder) is >= protocolVersionFloor. "dev" (a +// build-from-source binary) is by definition current and meets the floor. Any +// other unparseable or empty value returns false — the caller then falls back +// to "full". func meetsProtocolVersionFloor(v string) bool { v = strings.TrimPrefix(strings.TrimSpace(v), "v") - if v == "" || v == "dev" { + if v == "dev" { + return true // source builds are by definition current + } + if v == "" { return false } + // Drop semver pre-release / build metadata before parsing. Since Go 1.24 + // a plain `go build` stamps debug.BuildInfo.Main.Version from VCS, so + // locally built binaries report a pseudo-version like + // "0.4.1-0.20260727111757-cf92b3333032+dirty" rather than "dev"; without + // this trim the third segment fails Atoi and every such build silently + // falls back to "full". A pre-release of a floor-meeting version counts + // as meeting the floor (it already carries the slim protocol text). + if i := strings.IndexAny(v, "-+"); i >= 0 { + v = v[:i] + } + segments := strings.SplitN(v, ".", 3) var parts [3]int for i, s := range segments { diff --git a/cmd/engram/main_extra_test.go b/cmd/engram/main_extra_test.go index 9bb2530a..13ee0cc9 100644 --- a/cmd/engram/main_extra_test.go +++ b/cmd/engram/main_extra_test.go @@ -4342,3 +4342,46 @@ func TestCmdMCPAutosyncPollTickerPullsDuringServe(t *testing.T) { t.Fatalf("expected MCP autosync poll ticker proof to complete cleanly, panic=%v stderr=%q", recovered, stderr) } } + +func TestProtocolModeSetAndFloor(t *testing.T) { + stubExitWithPanic(t) + cfg := testConfig(t) + + // --set slim persists and (dev build meets floor) reads back slim. + withArgs(t, "engram", "protocol-mode", "claude-code", "--set", "slim") + stdout, stderr, recovered := captureOutputAndRecover(t, func() { cmdProtocolMode(cfg) }) + if recovered != nil || stderr != "" || !strings.Contains(stdout, "slim") { + t.Fatalf("set slim: panic=%v stderr=%q stdout=%q", recovered, stderr, stdout) + } + withArgs(t, "engram", "protocol-mode", "claude-code") + stdout, _, _ = captureOutputAndRecover(t, func() { cmdProtocolMode(cfg) }) + if !strings.Contains(stdout, "slim") { + t.Fatalf("read after set: want slim, got %q", stdout) + } + + // --set full flips back. + withArgs(t, "engram", "protocol-mode", "claude-code", "--set", "full") + stdout, _, _ = captureOutputAndRecover(t, func() { cmdProtocolMode(cfg) }) + if !strings.Contains(stdout, "full") { + t.Fatalf("set full: got %q", stdout) + } + + // invalid value is fatal + withArgs(t, "engram", "protocol-mode", "claude-code", "--set", "banana") + _, _, recovered = captureOutputAndRecover(t, func() { cmdProtocolMode(cfg) }) + if _, ok := recovered.(exitCode); !ok { + t.Fatalf("invalid --set must exit fatally, got %v", recovered) + } +} + +func TestMeetsProtocolVersionFloorForkVersions(t *testing.T) { + cases := map[string]bool{ + "0.3.0": false, "0.4.0": true, "v0.5.1": true, "1.4.0": true, + "dev": true, "": false, "garbage": false, + } + for v, want := range cases { + if got := meetsProtocolVersionFloor(v); got != want { + t.Errorf("meetsProtocolVersionFloor(%q) = %v, want %v", v, got, want) + } + } +} diff --git a/cmd/engram/setup_protocol_test.go b/cmd/engram/setup_protocol_test.go index 900d6243..602f9704 100644 --- a/cmd/engram/setup_protocol_test.go +++ b/cmd/engram/setup_protocol_test.go @@ -386,10 +386,11 @@ func TestCmdSetupWriteReadPathParityUnderEnvDataDir(t *testing.T) { if recovered != nil || stderr != "" { t.Fatalf("panic=%v stderr=%q", recovered, stderr) } - // version == "dev" in the test binary, so the version floor is never - // met — this assertion targets read-path parity, not the floor check. - if strings.TrimSpace(stdout) != "full" { - t.Fatalf("stdout = %q, want %q (version=dev fails the floor check)", stdout, "full") + // version == "dev" in the test binary, and dev builds meet the version + // floor, so the persisted slim mode surfaces as-is — this assertion + // targets read-path parity, not the floor check. + if strings.TrimSpace(stdout) != "slim" { + t.Fatalf("stdout = %q, want %q (dev build meets the floor)", stdout, "slim") } } @@ -422,7 +423,7 @@ func TestCmdProtocolModeSlimButVersionBelowFloor(t *testing.T) { } oldVersion := version - version = "1.3.9" + version = "0.3.9" // below the 0.4.0 fork floor t.Cleanup(func() { version = oldVersion }) withArgs(t, "engram", "protocol-mode", "claude-code") @@ -521,18 +522,26 @@ func TestMeetsProtocolVersionFloor(t *testing.T) { in string want bool }{ - {"1.4.0", true}, - {"1.4.1", true}, - {"1.5.0", true}, + // Floor is the 0.4.0 fork version. + {"0.4.0", true}, + {"0.4.1", true}, + {"0.5.0", true}, + {"1.0.0", true}, {"2.0.0", true}, - {"1.3.9", false}, - {"1.0.0", false}, - {"0.9.9", false}, - {"dev", false}, + {"0.3.9", false}, + {"0.0.1", false}, + {"0.3", false}, + // A build-from-source binary is by definition current. + {"dev", true}, + // Go >= 1.24 stamps VCS pseudo-versions into local `go build` + // binaries; pre-release/build metadata must not defeat the parse. + {"0.4.1-0.20260727111757-cf92b3333032+dirty", true}, + {"0.3.1-0.20260727111757-cf92b3333032+dirty", false}, + {"0.4.0+dirty", true}, {"", false}, {"not-a-version", false}, - {"v1.4.0", true}, - {"1.4", true}, + {"v0.4.0", true}, + {"0.4", true}, } for _, tt := range tests { if got := meetsProtocolVersionFloor(tt.in); got != tt.want { From 568cc37e72c164c85c1ee687d91a3c062f5b8587 Mon Sep 17 00:00:00 2001 From: zhishu Date: Mon, 27 Jul 2026 11:51:40 +0000 Subject: [PATCH 06/29] fix(protocol): correct slim floor version in setup help text --- cmd/engram/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/engram/main.go b/cmd/engram/main.go index 41fb0127..6223c4f5 100644 --- a/cmd/engram/main.go +++ b/cmd/engram/main.go @@ -2903,7 +2903,7 @@ func printSetupUsage() { fmt.Println(" installed agent slug (default: full). Unknown or") fmt.Println(" missing values fall back to full with a warning.") fmt.Println(" slim currently only takes effect for claude-code,") - fmt.Println(" and only when the installed engram is >= 1.4.0.") + fmt.Println(" and only when the installed engram is >= 0.4.0.") fmt.Println(" --help, -h Show this help and exit.") } From 85f3aa41ce9bb901922460578c8e4b58e1d06b72 Mon Sep 17 00:00:00 2001 From: zhishu Date: Mon, 27 Jul 2026 11:57:05 +0000 Subject: [PATCH 07/29] feat(protocol): compact slim protocol text (~850 tok) behind protocol-mode --- plugin/assets_test.go | 52 +++++++++++++++++++++ plugin/claude-code/scripts/session-start.sh | 26 +++++++++++ plugin/codex/scripts/session-start.sh | 38 +++++++++++++++ 3 files changed, 116 insertions(+) diff --git a/plugin/assets_test.go b/plugin/assets_test.go index f219af06..a9c4ffa6 100644 --- a/plugin/assets_test.go +++ b/plugin/assets_test.go @@ -79,6 +79,58 @@ func TestPluginAssetsDoNotLeakSpanishTriggers(t *testing.T) { } } +// TestSlimProtocolBudgetAndCoreRules pins the compact (slim) protocol text +// emitted by the session-start hooks to a token budget and asserts the rules +// that must survive compaction. Slim mode exists to cut per-session prompt +// cost, so an unbounded slim heredoc defeats the whole point; the required +// tokens are the load-bearing parts of the protocol that cannot be deferred to +// the on-demand `memory` SKILL. +func TestSlimProtocolBudgetAndCoreRules(t *testing.T) { + root := repoRoot(t) + + // Budget in bytes for the slim heredoc (marker line to closing marker). + // ~3400 bytes ≈ ~850 tokens. + const slimBudgetBytes = 3400 + + scripts := []string{ + filepath.Join(root, "plugin", "claude-code", "scripts", "session-start.sh"), + filepath.Join(root, "plugin", "codex", "scripts", "session-start.sh"), + } + + for _, path := range scripts { + rel, _ := filepath.Rel(root, path) + + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + s := string(b) + + start := strings.Index(s, "SLIM_PROTOCOL") + if start < 0 { + t.Fatalf("%s: slim branch heredoc marker SLIM_PROTOCOL not found", rel) + } + end := strings.Index(s[start+1:], "SLIM_PROTOCOL") + if end < 0 { + t.Fatalf("%s: unterminated SLIM_PROTOCOL heredoc", rel) + } + slim := s[start : start+1+end] + + if len(slim) > slimBudgetBytes { + t.Fatalf("%s: slim protocol is %d bytes, budget %d", rel, len(slim), slimBudgetBytes) + } + + for _, must := range []string{ + "mem_save", "mem_search", "mem_context", "mem_session_summary", + "topic_key", "memorylake", "SKILL", // pointer to the on-demand skill + } { + if !strings.Contains(strings.ToLower(slim), strings.ToLower(must)) { + t.Fatalf("%s: slim protocol missing required token %q", rel, must) + } + } + } +} + // marketplaceJSON is the minimal structure of .claude-plugin/marketplace.json // needed to extract the version declared for the engram plugin entry. type marketplaceJSON struct { diff --git a/plugin/claude-code/scripts/session-start.sh b/plugin/claude-code/scripts/session-start.sh index 8a1807f4..66392568 100755 --- a/plugin/claude-code/scripts/session-start.sh +++ b/plugin/claude-code/scripts/session-start.sh @@ -187,6 +187,32 @@ If the current project uses the MemoryLake backend (check with `engram memorylak ### SESSION CLOSE — before saying "done": Call `mem_session_summary` with: Goal, Discoveries, Accomplished, Next Steps, Relevant Files. PROTOCOL +else +cat <<'SLIM_PROTOCOL' +## Engram Memory — Active Protocol (compact) + +Persistent memory across sessions. Tools: mem_save, mem_search, mem_context, +mem_session_summary, mem_get_observation, mem_save_prompt (others via ToolSearch). + +RULES +1. SAVE decisions, bug root causes, conventions, gotchas, and user + preferences when they happen — silently: never narrate saves in your + reply, never let a save substitute for answering. Batch saves at task end. +2. Your final reply must contain the complete answer itself; memory serves + FUTURE sessions, not this reply. +3. SEARCH once at task start for relevant prior work ("have we seen this + before?"). On miss, proceed normally — do not search repeatedly. +4. mem_context at session start / after compaction for recent history. +5. Durable facts need topic_key (lowercase-kebab, max 2 levels, e.g. + architecture/auth-model) — same key updates in place. +6. End of session: mem_session_summary before saying done. +7. MemoryLake-backed projects (check: engram memorylake status): dedup and + conflict-merge are automatic — mem_save/mem_search/mem_context suffice. + SQLite projects: on judgment_required, follow the conflict loop in the + memory SKILL. + +Details, examples, and edge cases: load the `memory` SKILL on demand. +SLIM_PROTOCOL fi # Inject memory context if available diff --git a/plugin/codex/scripts/session-start.sh b/plugin/codex/scripts/session-start.sh index 13550b1e..b8f2ab5e 100755 --- a/plugin/codex/scripts/session-start.sh +++ b/plugin/codex/scripts/session-start.sh @@ -137,7 +137,18 @@ fi ENCODED_PROJECT=$(printf '%s' "$PROJECT" | jq -sRr @uri) CONTEXT=$(curl -sf "${ENGRAM_URL}/context?project=${ENCODED_PROJECT}" --max-time 3 2>/dev/null | jq -r '.context // empty') +# Resolve protocol verbosity mode for this slug. All slim/full branching +# (including the engram-version floor check) lives in Go — see `engram +# protocol-mode`. A missing/old engram binary or an unrecognized subcommand +# never yields "slim" here, so this always defaults safely to full. $mode is +# NEVER echoed/logged to this hook's own stdout. +mode=$(engram protocol-mode codex 2>/dev/null) +if [ "$mode" != "slim" ]; then + mode="full" +fi + # Inject Memory Protocol + context — stdout is returned to Codex as additionalContext +if [ "$mode" != "slim" ]; then cat <<'PROTOCOL' ## Engram Persistent Memory — ACTIVE PROTOCOL @@ -176,6 +187,33 @@ If the current project uses the MemoryLake backend (check with `engram memorylak ### SESSION CLOSE — before saying "done": Call `mem_session_summary` with: Goal, Discoveries, Accomplished, Next Steps, Relevant Files. PROTOCOL +else +cat <<'SLIM_PROTOCOL' +## Engram Memory — Active Protocol (compact) + +Persistent memory across sessions. Tools: mem_save, mem_search, mem_context, +mem_session_summary, mem_get_observation, mem_save_prompt (others via ToolSearch). + +RULES +1. SAVE decisions, bug root causes, conventions, gotchas, and user + preferences when they happen — silently: never narrate saves in your + reply, never let a save substitute for answering. Batch saves at task end. +2. Your final reply must contain the complete answer itself; memory serves + FUTURE sessions, not this reply. +3. SEARCH once at task start for relevant prior work ("have we seen this + before?"). On miss, proceed normally — do not search repeatedly. +4. mem_context at session start / after compaction for recent history. +5. Durable facts need topic_key (lowercase-kebab, max 2 levels, e.g. + architecture/auth-model) — same key updates in place. +6. End of session: mem_session_summary before saying done. +7. MemoryLake-backed projects (check: engram memorylake status): dedup and + conflict-merge are automatic — mem_save/mem_search/mem_context suffice. + SQLite projects: on judgment_required, follow the conflict loop in the + memory SKILL. + +Details, examples, and edge cases: load the `memory` SKILL on demand. +SLIM_PROTOCOL +fi # Inject memory context if available if [ -n "$CONTEXT" ]; then From 045b9c79e7c9f6b53a73baefcc2d12026d2c05ee Mon Sep 17 00:00:00 2001 From: zhishu Date: Mon, 27 Jul 2026 12:07:15 +0000 Subject: [PATCH 08/29] fix(protocol): setup help text reflects codex slim support --- cmd/engram/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/engram/main.go b/cmd/engram/main.go index 6223c4f5..6aa7063d 100644 --- a/cmd/engram/main.go +++ b/cmd/engram/main.go @@ -2902,7 +2902,7 @@ func printSetupUsage() { fmt.Println(" --protocol= Set the session-start protocol verbosity for the") fmt.Println(" installed agent slug (default: full). Unknown or") fmt.Println(" missing values fall back to full with a warning.") - fmt.Println(" slim currently only takes effect for claude-code,") + fmt.Println(" slim currently takes effect for claude-code and codex,") fmt.Println(" and only when the installed engram is >= 0.4.0.") fmt.Println(" --help, -h Show this help and exit.") } From 52b39f4cf4f5c2c6bf947c24ad49d4090c8ea51c Mon Sep 17 00:00:00 2001 From: zhishu Date: Mon, 27 Jul 2026 12:12:26 +0000 Subject: [PATCH 09/29] feat(protocol): trim MCP server instructions to ~325 tok; SKILL absorbs conflict-loop detail --- internal/mcp/mcp.go | 80 +++++++---------------- internal/mcp/mcp_test.go | 64 +++++++++++------- plugin/assets_test.go | 64 ++++++++++++++++++ plugin/claude-code/skills/memory/SKILL.md | 43 +++++++++++- plugin/codex/skills/memory/SKILL.md | 43 +++++++++++- 5 files changed, 210 insertions(+), 84 deletions(-) diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 7827ca22..6dae4de4 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -189,64 +189,28 @@ func NewServerWithSelector(sel BackendSelector, cfg MCPConfig, allowlist map[str } // serverInstructions tells MCP clients when to use Engram's tools. -// 7 core tools are eager (always in context). The rest are deferred -// and require ToolSearch to load. -const serverInstructions = `Engram provides persistent memory that survives across sessions and compactions. - -CORE TOOLS (always available — use without ToolSearch): - mem_save — save decisions, bugs, discoveries, conventions PROACTIVELY (do not wait to be asked) - mem_search — find past work, decisions, or context from previous sessions - mem_context — get recent session history (call at session start or after compaction) - mem_session_summary — save end-of-session summary (MANDATORY before saying "done") - mem_get_observation — get full untruncated content of a search result by ID - mem_save_prompt — save user prompt for context - mem_current_project — detect current project from cwd (recommended first call) - -DEFERRED TOOLS (use ToolSearch when needed): - mem_update, mem_review, mem_pin, mem_unpin, mem_suggest_topic_key, mem_session_start, mem_session_end, - mem_stats, mem_delete, mem_timeline, mem_capture_passive, mem_merge_projects - -PROACTIVE SAVE RULE: Call mem_save immediately after ANY decision, bug fix, discovery, or convention — not just when asked. - -## MemoryLake-backed projects - -If the current project uses the MemoryLake backend (check with "engram memorylake status"), -dedup, updating existing memories, and merging contradictions are handled AUTOMATICALLY by the -backend — you only need mem_save / mem_search / mem_context; you do NOT need to call mem_update, -mem_judge, or mem_compare yourself (mem_save never returns judgment_required there, so the conflict -loop below simply won't trigger). Projects on the default SQLite backend are unaffected — keep -following the topic-key/upsert and conflict-surfacing rules below. - -## CONFLICT SURFACING — when mem_save returns candidates - -After every mem_save call, check the response envelope for judgment_required. - -IF judgment_required IS TRUE: - Iterate candidates[] and call mem_judge once per candidate using the - judgment_id from that candidate entry. Do NOT use the top-level judgment_id - for multiple candidates — each candidate has its own. - - HEURISTIC — when to ask the user vs. resolve autonomously: - - ASK the user (conversationally, as part of your next reply) when: - - confidence is below 0.7, OR - - the relation you would choose is supersedes or conflicts_with AND the - observation type is architecture, policy, or decision - - RESOLVE silently (call mem_judge without asking) when: - - confidence >= 0.7 AND the relation is not supersedes/conflicts_with, OR - - the relation is related, compatible, scoped, or not_conflict - - HOW TO ASK (conversationally — never via blocking CLI or dashboard prompt): - Raise it naturally in your next reply to the user. Example phrasing: - "I noticed memory #abc123 might conflict with what we just saved. - Want me to mark the new one as superseding it, or are they about - different scopes? I can also mark them as compatible if both still apply." - - AFTER RESOLUTION (both paths): - Call mem_judge with the chosen relation, a reason, and if the user gave - explicit direction, include their words as the evidence field. This persists - the verdict and closes the pending conflict row.` +// +// This text is injected into EVERY session by every MCP client, so it is pure +// per-session overhead and is held to a hard byte budget (see +// TestServerInstructionsBudget). Keep it to the rules an agent must know +// unconditionally: the core tools, the save rule (post-compaction recovery in +// slim mode depends on this line), topic_key, and a pointer to the on-demand +// `memory` SKILL. Occasional detail — the conflict-resolution loop, the +// when-to-ask-the-user heuristic, the deferred tool list — lives in +// plugin/{claude-code,codex}/skills/memory/SKILL.md, which is loaded on demand +// and therefore costs nothing per session. +const serverInstructions = `Engram: persistent memory across sessions and compactions. + +Core tools: mem_save (save decisions/bugs/conventions/gotchas — silently, +batched at task end, never replacing your answer), mem_search (once at task +start), mem_context (session start / post-compaction), mem_session_summary +(before finishing), mem_get_observation (expand a search hit), mem_save_prompt. +Durable facts use topic_key (lowercase-kebab, max 2 levels) to update in place. + +MemoryLake-backed projects (engram memorylake status): dedup/conflict-merge +are automatic; the three core tools suffice. SQLite projects: if mem_save +returns judgment_required, follow the conflict loop documented in the memory +SKILL. Details and examples: load the memory SKILL on demand.` // NewServerWithTools creates an MCP server registering only the tools in // the allowlist. If allowlist is nil, all tools are registered. diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 3f3a35b9..3467164d 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -6231,39 +6231,32 @@ func TestAllTools_ReadResponseEnvelope_WithAssertions(t *testing.T) { // ─── Phase E — Conflict Surfacing Instructions ──────────────────────────────── -// TestServerInstructions_ConflictSurfacingBlock verifies that serverInstructions -// contains the CONFLICT SURFACING section with all required guidance phrases. -// This is the RED→GREEN test for Phase E (E.1). -func TestServerInstructions_ConflictSurfacingBlock(t *testing.T) { +// TestServerInstructions_ConflictSurfacingPointer verifies that +// serverInstructions still tells the agent what to do when mem_save returns +// judgment_required, and where the full walkthrough lives. +// +// The walkthrough itself (per-candidate judgment_id rule, ask-vs-resolve +// heuristic, phrasing example, evidence field) moved out of the per-session +// instructions into plugin/{claude-code,codex}/skills/memory/SKILL.md under +// "## Conflict loop (SQLite projects)" — see TestMemorySkillsCarryConflictLoop +// in plugin/assets_test.go, which pins the phrases there. Keeping the detail +// here would put an on-demand walkthrough in every session's prompt. +func TestServerInstructions_ConflictSurfacingPointer(t *testing.T) { required := []string{ - // Section header — agents must be able to grep for it - "## CONFLICT SURFACING", - // Core trigger condition "judgment_required", - // The action: iterate candidates and call mem_judge - "candidates[]", - "mem_judge", - - // Heuristic: low confidence threshold - "0.7", + // Backend distinction: the loop is SQLite-only + "SQLite", - // Heuristic: ask for high-stakes relation+type combos - "supersedes", - "conflicts_with", - "architecture", - - // Conversational (not blocking) resolution pattern - "conversationally", - - // Post-resolution: persist via mem_judge with evidence - "evidence", + // The delegation target the agent must load for the walkthrough + "conflict loop", + "SKILL", } for _, phrase := range required { if !strings.Contains(serverInstructions, phrase) { - t.Errorf("serverInstructions is missing required phrase %q in CONFLICT SURFACING block", phrase) + t.Errorf("serverInstructions is missing required phrase %q for conflict-loop delegation", phrase) } } } @@ -7468,3 +7461,26 @@ func TestHandleSearch_MatchModeInvalidError(t *testing.T) { t.Fatalf("parameter-validation error must not contain query-advice suffix \"Try simpler keywords\", got: %s", text) } } + +// ─── Wave1 R1c — Server instructions token budget ───────────────────────────── + +// TestServerInstructionsBudget pins the per-session cost of the MCP server +// instructions. This text is injected into EVERY session by every MCP client, +// so it is pure per-session overhead — detail that an agent needs only +// occasionally (the conflict loop, the deferred tool list) belongs in the +// on-demand `memory` SKILL, not here. The budget is 1300 bytes ≈ 325 tokens. +// +// The required substrings are the load-bearing parts that cannot be deferred: +// the three core tools, the pointer to the SKILL, and the MemoryLake escape +// hatch that tells the agent when the conflict loop does not apply. +func TestServerInstructionsBudget(t *testing.T) { + s := ServerInstructions() + if len(s) > 1300 { + t.Fatalf("serverInstructions is %d bytes, budget 1300", len(s)) + } + for _, must := range []string{"mem_save", "mem_search", "mem_context", "SKILL", "memorylake"} { + if !strings.Contains(strings.ToLower(s), strings.ToLower(must)) { + t.Fatalf("instructions missing %q", must) + } + } +} diff --git a/plugin/assets_test.go b/plugin/assets_test.go index a9c4ffa6..5394ce1e 100644 --- a/plugin/assets_test.go +++ b/plugin/assets_test.go @@ -131,6 +131,70 @@ func TestSlimProtocolBudgetAndCoreRules(t *testing.T) { } } +// TestMemorySkillsCarryConflictLoop pins the conflict-resolution walkthrough to +// the on-demand `memory` SKILL files. This detail used to live in the MCP +// server instructions, which every client injects into every session; it was +// moved here because an agent needs it only when mem_save actually returns +// judgment_required, and SKILL.md is loaded on demand (its size is not in the +// per-session budget — see TestServerInstructionsBudget in internal/mcp). +// +// If these phrases disappear, the trimmed server instructions point at a SKILL +// that no longer documents the loop, and the guidance is lost entirely. +func TestMemorySkillsCarryConflictLoop(t *testing.T) { + root := repoRoot(t) + + skills := []string{ + filepath.Join(root, "plugin", "claude-code", "skills", "memory", "SKILL.md"), + filepath.Join(root, "plugin", "codex", "skills", "memory", "SKILL.md"), + } + + required := []string{ + // Section header — agents must be able to grep for it + "## Conflict loop (SQLite projects)", + + // Core trigger condition + "judgment_required", + + // The action: iterate candidates and call mem_judge + "candidates[]", + "mem_judge", + + // The per-candidate judgment_id rule (not the top-level one) + "Do NOT use the top-level judgment_id", + + // Heuristic: low confidence threshold + "0.7", + + // Heuristic: ask for high-stakes relation+type combos + "supersedes", + "conflicts_with", + "architecture", + + // Conversational (not blocking) resolution pattern + "conversationally", + + // Post-resolution: persist via mem_judge with evidence + "evidence", + + // The deferred tool list also moved out of the server instructions + "mem_merge_projects", + } + + for _, path := range skills { + rel, _ := filepath.Rel(root, path) + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + text := string(b) + for _, phrase := range required { + if !strings.Contains(text, phrase) { + t.Errorf("%s is missing required phrase %q from the conflict loop section", rel, phrase) + } + } + } +} + // marketplaceJSON is the minimal structure of .claude-plugin/marketplace.json // needed to extract the version declared for the engram plugin entry. type marketplaceJSON struct { diff --git a/plugin/claude-code/skills/memory/SKILL.md b/plugin/claude-code/skills/memory/SKILL.md index 7db77eee..5b16fb80 100644 --- a/plugin/claude-code/skills/memory/SKILL.md +++ b/plugin/claude-code/skills/memory/SKILL.md @@ -77,7 +77,48 @@ Format for `mem_save`: ### MemoryLake-backed projects -If the current project uses the **MemoryLake backend** (check with `engram memorylake status`), dedup, updating existing memories, and merging contradictions are handled **automatically** by the backend — you only need `mem_save` / `mem_search` / `mem_context`; you do **not** need to call `mem_update`, `mem_judge`, or `mem_compare` yourself. Projects on the default **SQLite backend** are unaffected — keep following the topic-key/upsert rules above. +If the current project uses the **MemoryLake backend** (check with `engram memorylake status`), dedup, updating existing memories, and merging contradictions are handled **automatically** by the backend — you only need `mem_save` / `mem_search` / `mem_context`; you do **not** need to call `mem_update`, `mem_judge`, or `mem_compare` yourself (`mem_save` never returns `judgment_required` there, so the conflict loop below simply won't trigger). Projects on the default **SQLite backend** are unaffected — keep following the topic-key/upsert and conflict-surfacing rules. + +## Conflict loop (SQLite projects) + +The MCP server instructions point here instead of carrying this walkthrough in +every session. It applies to projects on the default **SQLite backend** only. + +### CONFLICT SURFACING — when mem_save returns candidates + +After every mem_save call, check the response envelope for judgment_required. + +IF judgment_required IS TRUE: + Iterate candidates[] and call mem_judge once per candidate using the + judgment_id from that candidate entry. Do NOT use the top-level judgment_id + for multiple candidates — each candidate has its own. + + HEURISTIC — when to ask the user vs. resolve autonomously: + + ASK the user (conversationally, as part of your next reply) when: + - confidence is below 0.7, OR + - the relation you would choose is supersedes or conflicts_with AND the + observation type is architecture, policy, or decision + + RESOLVE silently (call mem_judge without asking) when: + - confidence >= 0.7 AND the relation is not supersedes/conflicts_with, OR + - the relation is related, compatible, scoped, or not_conflict + + HOW TO ASK (conversationally — never via blocking CLI or dashboard prompt): + Raise it naturally in your next reply to the user. Example phrasing: + "I noticed memory #abc123 might conflict with what we just saved. + Want me to mark the new one as superseding it, or are they about + different scopes? I can also mark them as compatible if both still apply." + + AFTER RESOLUTION (both paths): + Call mem_judge with the chosen relation, a reason, and if the user gave + explicit direction, include their words as the evidence field. This persists + the verdict and closes the pending conflict row. + +### Deferred tools (use ToolSearch when needed) + + mem_update, mem_review, mem_pin, mem_unpin, mem_suggest_topic_key, mem_session_start, mem_session_end, + mem_stats, mem_delete, mem_timeline, mem_capture_passive, mem_merge_projects ## WHEN TO SEARCH MEMORY diff --git a/plugin/codex/skills/memory/SKILL.md b/plugin/codex/skills/memory/SKILL.md index 8a2c4ba0..d25dfcc3 100644 --- a/plugin/codex/skills/memory/SKILL.md +++ b/plugin/codex/skills/memory/SKILL.md @@ -76,7 +76,48 @@ Format for `mem_save`: ### MemoryLake-backed projects -If the current project uses the **MemoryLake backend** (check with `engram memorylake status`), dedup, updating existing memories, and merging contradictions are handled **automatically** by the backend — you only need `mem_save` / `mem_search` / `mem_context`; you do **not** need to call `mem_update`, `mem_judge`, or `mem_compare` yourself. Projects on the default **SQLite backend** are unaffected — keep following the topic-key/upsert rules above. +If the current project uses the **MemoryLake backend** (check with `engram memorylake status`), dedup, updating existing memories, and merging contradictions are handled **automatically** by the backend — you only need `mem_save` / `mem_search` / `mem_context`; you do **not** need to call `mem_update`, `mem_judge`, or `mem_compare` yourself (`mem_save` never returns `judgment_required` there, so the conflict loop below simply won't trigger). Projects on the default **SQLite backend** are unaffected — keep following the topic-key/upsert and conflict-surfacing rules. + +## Conflict loop (SQLite projects) + +The MCP server instructions point here instead of carrying this walkthrough in +every session. It applies to projects on the default **SQLite backend** only. + +### CONFLICT SURFACING — when mem_save returns candidates + +After every mem_save call, check the response envelope for judgment_required. + +IF judgment_required IS TRUE: + Iterate candidates[] and call mem_judge once per candidate using the + judgment_id from that candidate entry. Do NOT use the top-level judgment_id + for multiple candidates — each candidate has its own. + + HEURISTIC — when to ask the user vs. resolve autonomously: + + ASK the user (conversationally, as part of your next reply) when: + - confidence is below 0.7, OR + - the relation you would choose is supersedes or conflicts_with AND the + observation type is architecture, policy, or decision + + RESOLVE silently (call mem_judge without asking) when: + - confidence >= 0.7 AND the relation is not supersedes/conflicts_with, OR + - the relation is related, compatible, scoped, or not_conflict + + HOW TO ASK (conversationally — never via blocking CLI or dashboard prompt): + Raise it naturally in your next reply to the user. Example phrasing: + "I noticed memory #abc123 might conflict with what we just saved. + Want me to mark the new one as superseding it, or are they about + different scopes? I can also mark them as compatible if both still apply." + + AFTER RESOLUTION (both paths): + Call mem_judge with the chosen relation, a reason, and if the user gave + explicit direction, include their words as the evidence field. This persists + the verdict and closes the pending conflict row. + +### Deferred tools (use ToolSearch when needed) + + mem_update, mem_review, mem_pin, mem_unpin, mem_suggest_topic_key, mem_session_start, mem_session_end, + mem_stats, mem_delete, mem_timeline, mem_capture_passive, mem_merge_projects ## WHEN TO SEARCH MEMORY From 0e9020d45674f0dfa29d9279ca2dd770791ed70f Mon Sep 17 00:00:00 2001 From: zhishu Date: Mon, 27 Jul 2026 12:23:36 +0000 Subject: [PATCH 10/29] fix(protocol): qualify deferred-tool list preloading and pin the save rule in the budget test --- internal/mcp/mcp_test.go | 2 +- plugin/claude-code/skills/memory/SKILL.md | 2 +- plugin/codex/skills/memory/SKILL.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 3467164d..a4e7db67 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -7478,7 +7478,7 @@ func TestServerInstructionsBudget(t *testing.T) { if len(s) > 1300 { t.Fatalf("serverInstructions is %d bytes, budget 1300", len(s)) } - for _, must := range []string{"mem_save", "mem_search", "mem_context", "SKILL", "memorylake"} { + for _, must := range []string{"mem_save", "mem_search", "mem_context", "SKILL", "memorylake", "batched at task end"} { if !strings.Contains(strings.ToLower(s), strings.ToLower(must)) { t.Fatalf("instructions missing %q", must) } diff --git a/plugin/claude-code/skills/memory/SKILL.md b/plugin/claude-code/skills/memory/SKILL.md index 5b16fb80..5214e5a2 100644 --- a/plugin/claude-code/skills/memory/SKILL.md +++ b/plugin/claude-code/skills/memory/SKILL.md @@ -115,7 +115,7 @@ IF judgment_required IS TRUE: explicit direction, include their words as the evidence field. This persists the verdict and closes the pending conflict row. -### Deferred tools (use ToolSearch when needed) +### Deferred tools (use ToolSearch when needed; the session-start hook preloads several of these — ToolSearch only if a call fails) mem_update, mem_review, mem_pin, mem_unpin, mem_suggest_topic_key, mem_session_start, mem_session_end, mem_stats, mem_delete, mem_timeline, mem_capture_passive, mem_merge_projects diff --git a/plugin/codex/skills/memory/SKILL.md b/plugin/codex/skills/memory/SKILL.md index d25dfcc3..82c89518 100644 --- a/plugin/codex/skills/memory/SKILL.md +++ b/plugin/codex/skills/memory/SKILL.md @@ -114,7 +114,7 @@ IF judgment_required IS TRUE: explicit direction, include their words as the evidence field. This persists the verdict and closes the pending conflict row. -### Deferred tools (use ToolSearch when needed) +### Deferred tools (use ToolSearch when needed; the session-start hook preloads several of these — ToolSearch only if a call fails) mem_update, mem_review, mem_pin, mem_unpin, mem_suggest_topic_key, mem_session_start, mem_session_end, mem_stats, mem_delete, mem_timeline, mem_capture_passive, mem_merge_projects From 66845a3e85ec74d3db1338ba5d292d3285bb2659 Mon Sep 17 00:00:00 2001 From: zhishu Date: Mon, 27 Jul 2026 12:31:19 +0000 Subject: [PATCH 11/29] feat(eval): wave1 round 1 (R1 protocol slimming) scorecards --- eval/results/0e9020d-2026-07-27-l1-llm.json | 515 ++++++++++++++++++++ eval/results/0e9020d-2026-07-27-l1.json | 508 +++++++++++++++++++ eval/results/0e9020d-2026-07-27-l2.json | 18 + 3 files changed, 1041 insertions(+) create mode 100644 eval/results/0e9020d-2026-07-27-l1-llm.json create mode 100644 eval/results/0e9020d-2026-07-27-l1.json create mode 100644 eval/results/0e9020d-2026-07-27-l2.json diff --git a/eval/results/0e9020d-2026-07-27-l1-llm.json b/eval/results/0e9020d-2026-07-27-l1-llm.json new file mode 100644 index 00000000..e2c8cc9f --- /dev/null +++ b/eval/results/0e9020d-2026-07-27-l1-llm.json @@ -0,0 +1,515 @@ +{ + "git_sha": "0e9020d", + "date": "2026-07-27", + "suite": "l1v", + "metrics": { + "avg_distractor_ratio": 0.13888888888888887, + "avg_tokens_per_query": 890.7777777777778, + "latency_p50_ms": 589, + "latency_p95_ms": 1120, + "llm_verified_hits": 2, + "mrr": 0.7376543209876544, + "mrr_llm": 0.7561728395061729, + "recall@1": 0.6851851851851852, + "recall@10": 0.7962962962962963, + "recall@10_llm": 0.8333333333333334, + "recall@1_llm": 0.6851851851851852, + "recall@5": 0.7962962962962963, + "recall@5_llm": 0.8333333333333334 + }, + "per_item": [ + { + "id": "r-001", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 636, + "tokens": 390 + } + }, + { + "id": "r-002", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1060, + "tokens": 1567 + } + }, + { + "id": "r-003", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1148, + "tokens": 1272 + } + }, + { + "id": "r-004", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 560, + "tokens": 472 + } + }, + { + "id": "r-005", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 557, + "tokens": 665 + } + }, + { + "id": "r-006", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1015, + "tokens": 656 + } + }, + { + "id": "r-007", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 620, + "tokens": 1060 + } + }, + { + "id": "r-008", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 542, + "tokens": 1 + } + }, + { + "id": "r-009", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 595, + "tokens": 140 + } + }, + { + "id": "r-010", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 547, + "tokens": 1 + } + }, + { + "id": "r-011", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 555, + "tokens": 1361 + } + }, + { + "id": "r-012", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 745, + "tokens": 338 + } + }, + { + "id": "r-013", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1255, + "tokens": 1552 + } + }, + { + "id": "r-014", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 552, + "tokens": 142 + } + }, + { + "id": "r-015", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 599, + "tokens": 369 + } + }, + { + "id": "r-016", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 546, + "tokens": 1431 + } + }, + { + "id": "r-017", + "values": { + "distractor_ratio": 0.3333333333333333, + "first_hit_rank": 2, + "latency_ms": 554, + "tokens": 1437 + } + }, + { + "id": "r-018", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 546, + "tokens": 1109 + } + }, + { + "id": "r-019", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 539, + "tokens": 1552 + } + }, + { + "id": "r-020", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1066, + "tokens": 1346 + } + }, + { + "id": "r-021", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 549, + "tokens": 3845 + } + }, + { + "id": "r-022", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1000, + "tokens": 1110 + } + }, + { + "id": "r-023", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 588, + "tokens": 1437 + } + }, + { + "id": "r-024", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1021, + "tokens": 3191 + } + }, + { + "id": "r-025", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 602, + "tokens": 532 + } + }, + { + "id": "r-026", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 536, + "llm_verified_rank": 2, + "tokens": 840 + } + }, + { + "id": "r-027", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 792, + "tokens": 817 + } + }, + { + "id": "r-028", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 543, + "tokens": 535 + } + }, + { + "id": "r-029", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 618, + "llm_verified_rank": 2, + "tokens": 1067 + } + }, + { + "id": "r-030", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1120, + "tokens": 3383 + } + }, + { + "id": "r-031", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1226, + "tokens": 532 + } + }, + { + "id": "r-032", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 1086, + "tokens": 1 + } + }, + { + "id": "r-033", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 580, + "tokens": 901 + } + }, + { + "id": "r-034", + "values": { + "distractor_ratio": 0.3333333333333333, + "first_hit_rank": 2, + "latency_ms": 589, + "tokens": 1437 + } + }, + { + "id": "r-035", + "values": { + "distractor_ratio": 0.5, + "first_hit_rank": 2, + "latency_ms": 1075, + "tokens": 902 + } + }, + { + "id": "r-036", + "values": { + "distractor_ratio": 0.5, + "first_hit_rank": 2, + "latency_ms": 619, + "tokens": 1067 + } + }, + { + "id": "r-037", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 588, + "tokens": 1067 + } + }, + { + "id": "r-038", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 574, + "tokens": 1069 + } + }, + { + "id": "r-039", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 560, + "tokens": 1 + } + }, + { + "id": "r-040", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 568, + "tokens": 1 + } + }, + { + "id": "r-041", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 582, + "tokens": 537 + } + }, + { + "id": "r-042", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 589, + "tokens": 1 + } + }, + { + "id": "r-043", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 602, + "tokens": 532 + } + }, + { + "id": "r-044", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 579, + "tokens": 532 + } + }, + { + "id": "r-045", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 650, + "tokens": 532 + } + }, + { + "id": "r-046", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1017, + "tokens": 532 + } + }, + { + "id": "r-047", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 545, + "tokens": 579 + } + }, + { + "id": "r-048", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 589, + "tokens": 208 + } + }, + { + "id": "r-049", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 616, + "tokens": 1050 + } + }, + { + "id": "r-050", + "values": { + "distractor_ratio": 0.3333333333333333, + "first_hit_rank": 2, + "latency_ms": 1045, + "tokens": 956 + } + }, + { + "id": "r-051", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 556, + "tokens": 208 + } + }, + { + "id": "r-052", + "values": { + "distractor_ratio": 0.5, + "first_hit_rank": 3, + "latency_ms": 543, + "tokens": 1421 + } + }, + { + "id": "r-053", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 794, + "tokens": 208 + } + }, + { + "id": "r-054", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1063, + "tokens": 210 + } + } + ], + "env": { + "backend": "memorylake", + "dataset": "eval/datasets/phoenix-retrieval-v1.jsonl", + "project": "phoenix" + } +} diff --git a/eval/results/0e9020d-2026-07-27-l1.json b/eval/results/0e9020d-2026-07-27-l1.json new file mode 100644 index 00000000..1f37589d --- /dev/null +++ b/eval/results/0e9020d-2026-07-27-l1.json @@ -0,0 +1,508 @@ +{ + "git_sha": "0e9020d", + "date": "2026-07-27", + "suite": "l1", + "metrics": { + "avg_distractor_ratio": 0.13888888888888887, + "avg_tokens_per_query": 890.7777777777778, + "latency_p50_ms": 589, + "latency_p95_ms": 1120, + "mrr": 0.7376543209876544, + "recall@1": 0.6851851851851852, + "recall@10": 0.7962962962962963, + "recall@5": 0.7962962962962963 + }, + "per_item": [ + { + "id": "r-001", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 636, + "tokens": 390 + } + }, + { + "id": "r-002", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1060, + "tokens": 1567 + } + }, + { + "id": "r-003", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1148, + "tokens": 1272 + } + }, + { + "id": "r-004", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 560, + "tokens": 472 + } + }, + { + "id": "r-005", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 557, + "tokens": 665 + } + }, + { + "id": "r-006", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1015, + "tokens": 656 + } + }, + { + "id": "r-007", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 620, + "tokens": 1060 + } + }, + { + "id": "r-008", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 542, + "tokens": 1 + } + }, + { + "id": "r-009", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 595, + "tokens": 140 + } + }, + { + "id": "r-010", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 547, + "tokens": 1 + } + }, + { + "id": "r-011", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 555, + "tokens": 1361 + } + }, + { + "id": "r-012", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 745, + "tokens": 338 + } + }, + { + "id": "r-013", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1255, + "tokens": 1552 + } + }, + { + "id": "r-014", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 552, + "tokens": 142 + } + }, + { + "id": "r-015", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 599, + "tokens": 369 + } + }, + { + "id": "r-016", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 546, + "tokens": 1431 + } + }, + { + "id": "r-017", + "values": { + "distractor_ratio": 0.3333333333333333, + "first_hit_rank": 2, + "latency_ms": 554, + "tokens": 1437 + } + }, + { + "id": "r-018", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 546, + "tokens": 1109 + } + }, + { + "id": "r-019", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 539, + "tokens": 1552 + } + }, + { + "id": "r-020", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1066, + "tokens": 1346 + } + }, + { + "id": "r-021", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 549, + "tokens": 3845 + } + }, + { + "id": "r-022", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1000, + "tokens": 1110 + } + }, + { + "id": "r-023", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 588, + "tokens": 1437 + } + }, + { + "id": "r-024", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1021, + "tokens": 3191 + } + }, + { + "id": "r-025", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 602, + "tokens": 532 + } + }, + { + "id": "r-026", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 536, + "tokens": 840 + } + }, + { + "id": "r-027", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 792, + "tokens": 817 + } + }, + { + "id": "r-028", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 543, + "tokens": 535 + } + }, + { + "id": "r-029", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 618, + "tokens": 1067 + } + }, + { + "id": "r-030", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1120, + "tokens": 3383 + } + }, + { + "id": "r-031", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1226, + "tokens": 532 + } + }, + { + "id": "r-032", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 1086, + "tokens": 1 + } + }, + { + "id": "r-033", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 580, + "tokens": 901 + } + }, + { + "id": "r-034", + "values": { + "distractor_ratio": 0.3333333333333333, + "first_hit_rank": 2, + "latency_ms": 589, + "tokens": 1437 + } + }, + { + "id": "r-035", + "values": { + "distractor_ratio": 0.5, + "first_hit_rank": 2, + "latency_ms": 1075, + "tokens": 902 + } + }, + { + "id": "r-036", + "values": { + "distractor_ratio": 0.5, + "first_hit_rank": 2, + "latency_ms": 619, + "tokens": 1067 + } + }, + { + "id": "r-037", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 588, + "tokens": 1067 + } + }, + { + "id": "r-038", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 574, + "tokens": 1069 + } + }, + { + "id": "r-039", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 560, + "tokens": 1 + } + }, + { + "id": "r-040", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 568, + "tokens": 1 + } + }, + { + "id": "r-041", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 582, + "tokens": 537 + } + }, + { + "id": "r-042", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 589, + "tokens": 1 + } + }, + { + "id": "r-043", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 602, + "tokens": 532 + } + }, + { + "id": "r-044", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 579, + "tokens": 532 + } + }, + { + "id": "r-045", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 650, + "tokens": 532 + } + }, + { + "id": "r-046", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1017, + "tokens": 532 + } + }, + { + "id": "r-047", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 545, + "tokens": 579 + } + }, + { + "id": "r-048", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 589, + "tokens": 208 + } + }, + { + "id": "r-049", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 616, + "tokens": 1050 + } + }, + { + "id": "r-050", + "values": { + "distractor_ratio": 0.3333333333333333, + "first_hit_rank": 2, + "latency_ms": 1045, + "tokens": 956 + } + }, + { + "id": "r-051", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 556, + "tokens": 208 + } + }, + { + "id": "r-052", + "values": { + "distractor_ratio": 0.5, + "first_hit_rank": 3, + "latency_ms": 543, + "tokens": 1421 + } + }, + { + "id": "r-053", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 794, + "tokens": 208 + } + }, + { + "id": "r-054", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1063, + "tokens": 210 + } + } + ], + "env": { + "backend": "memorylake", + "dataset": "eval/datasets/phoenix-retrieval-v1.jsonl", + "project": "phoenix" + } +} diff --git a/eval/results/0e9020d-2026-07-27-l2.json b/eval/results/0e9020d-2026-07-27-l2.json new file mode 100644 index 00000000..61f18a4b --- /dev/null +++ b/eval/results/0e9020d-2026-07-27-l2.json @@ -0,0 +1,18 @@ +{ + "git_sha": "0e9020d", + "date": "2026-07-27", + "suite": "l2", + "metrics": { + "avg_search_tokens": 890.7777777777778, + "context_tokens": 1636, + "injected_tokens_per_session": 9548.333333333334, + "static_hook_tokens": 2936, + "static_mcp_instr_tokens": 184, + "static_skill_tokens": 2120 + }, + "env": { + "project": "phoenix", + "search_calls_assumed": "3.0", + "tokenizer": "approx-bytes/4" + } +} From 9a7b540b18976b0b9796c3978e68931db0143f76 Mon Sep 17 00:00:00 2001 From: zhishu Date: Mon, 27 Jul 2026 12:38:31 +0000 Subject: [PATCH 12/29] feat(protocol): answer-first save rules everywhere + slim survives compaction --- plugin/assets_test.go | 90 +++++++++++++++++++ plugin/claude-code/scripts/post-compaction.sh | 52 ++++++++--- plugin/claude-code/scripts/session-start.sh | 24 ++--- plugin/claude-code/skills/memory/SKILL.md | 49 ++++------ plugin/codex/scripts/post-compaction.sh | 73 ++++++++++++--- plugin/codex/scripts/session-start.sh | 24 ++--- plugin/codex/skills/memory/SKILL.md | 49 ++++------ 7 files changed, 244 insertions(+), 117 deletions(-) diff --git a/plugin/assets_test.go b/plugin/assets_test.go index 5394ce1e..7e4fdb8f 100644 --- a/plugin/assets_test.go +++ b/plugin/assets_test.go @@ -195,6 +195,96 @@ func TestMemorySkillsCarryConflictLoop(t *testing.T) { } } +// TestAnswerFirstRulesPresent pins the answer-first behavior rules (R2) to +// BOTH protocol modes and both SKILL files. The slim protocol carries them +// already; asserting them on the whole file means a rollback of the slim +// protocol (R1) cannot silently drop R2 with it. +// +// The banned phrase is the save-eager trigger-list lead-in ("call mem_save +// IMMEDIATELY after ANY of these"), which is what made agents interrupt their +// answer to narrate a save. +func TestAnswerFirstRulesPresent(t *testing.T) { + root := repoRoot(t) + + files := []string{ + filepath.Join(root, "plugin", "claude-code", "scripts", "session-start.sh"), + filepath.Join(root, "plugin", "claude-code", "scripts", "post-compaction.sh"), + filepath.Join(root, "plugin", "codex", "scripts", "session-start.sh"), + filepath.Join(root, "plugin", "codex", "scripts", "post-compaction.sh"), + filepath.Join(root, "plugin", "claude-code", "skills", "memory", "SKILL.md"), + filepath.Join(root, "plugin", "codex", "skills", "memory", "SKILL.md"), + } + + for _, path := range files { + rel, _ := filepath.Rel(root, path) + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + s := strings.ToLower(string(b)) + for _, must := range []string{ + "final reply must contain the complete answer", + "never narrate saves", + "batch saves at task end", + } { + if !strings.Contains(s, must) { + t.Errorf("%s missing answer-first rule %q", rel, must) + } + } + if strings.Contains(s, "immediately after any of these") { + t.Errorf("%s still contains save-eager phrasing", rel) + } + } +} + +// slimProtocolHeredoc returns the SLIM_PROTOCOL heredoc of a hook script, +// from the opening marker through (but excluding) the closing marker. +func slimProtocolHeredoc(t *testing.T, path, rel string) string { + t.Helper() + + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + s := string(b) + + start := strings.Index(s, "SLIM_PROTOCOL") + if start < 0 { + t.Fatalf("%s: slim branch heredoc marker SLIM_PROTOCOL not found", rel) + } + end := strings.Index(s[start+1:], "SLIM_PROTOCOL") + if end < 0 { + t.Fatalf("%s: unterminated SLIM_PROTOCOL heredoc", rel) + } + return s[start : start+1+end] +} + +// TestSlimProtocolSurvivesCompaction asserts that the post-compaction hooks +// carry a slim branch whose protocol text is byte-identical to the +// session-start hook's. Compaction is exactly when the protocol has to be +// re-injected, so a post-compaction hook that only knows the full protocol +// would silently undo slim mode mid-session (and blow the token budget slim +// mode exists to protect). Byte identity is the cheapest defense against the +// two copies drifting apart. +func TestSlimProtocolSurvivesCompaction(t *testing.T) { + root := repoRoot(t) + + for _, plugin := range []string{"claude-code", "codex"} { + sessionStart := filepath.Join(root, "plugin", plugin, "scripts", "session-start.sh") + postCompaction := filepath.Join(root, "plugin", plugin, "scripts", "post-compaction.sh") + relStart, _ := filepath.Rel(root, sessionStart) + relCompact, _ := filepath.Rel(root, postCompaction) + + want := slimProtocolHeredoc(t, sessionStart, relStart) + got := slimProtocolHeredoc(t, postCompaction, relCompact) + + if got != want { + t.Errorf("%s slim protocol drifted from %s\n--- session-start ---\n%s\n--- post-compaction ---\n%s", + relCompact, relStart, want, got) + } + } +} + // marketplaceJSON is the minimal structure of .claude-plugin/marketplace.json // needed to extract the version declared for the engram plugin entry. type marketplaceJSON struct { diff --git a/plugin/claude-code/scripts/post-compaction.sh b/plugin/claude-code/scripts/post-compaction.sh index 1651cc35..cea1246c 100755 --- a/plugin/claude-code/scripts/post-compaction.sh +++ b/plugin/claude-code/scripts/post-compaction.sh @@ -56,18 +56,15 @@ mem_save, mem_search, mem_context, mem_session_summary, mem_get_observation, mem Use ToolSearch for other tools: mem_update, mem_suggest_topic_key, mem_session_start, mem_session_end, mem_stats, mem_delete, mem_timeline, mem_capture_passive -### PROACTIVE SAVE — do NOT wait for user to ask -Call `mem_save` IMMEDIATELY after ANY of these: -- Decision made (architecture, convention, workflow, tool choice) -- Bug fixed (include root cause) -- Convention or workflow documented/updated -- Notion/Jira/GitHub artifact created or updated with significant content -- Non-obvious discovery, gotcha, or edge case found -- Pattern established (naming, structure, approach) -- User preference or constraint learned -- Feature implemented with non-obvious approach - -**Self-check after EVERY task**: "Did I just make a decision, fix a bug, learn something, or establish a convention? If yes → mem_save NOW." +### SAVING — silent, batched, never instead of answering +Save decisions, bug root causes, conventions, gotchas, and user preferences. +But: your final reply must contain the complete answer itself — memory serves +future sessions, never this reply. Never narrate saves ("I've saved this to +memory") and never let a save replace the answer. Batch saves at task end. + +### SEARCHING — once, up front +One mem_search at task start ("have we seen this before?"). On miss, proceed +normally; do not search repeatedly for the same information. ### MemoryLake-backed projects If the current project uses the MemoryLake backend (check with `engram memorylake status`), dedup, updating existing memories, and merging contradictions are handled automatically by the backend — you only need mem_save / mem_search / mem_context; you do not need to call mem_update, mem_judge, or mem_compare yourself. Default SQLite projects are unaffected — keep following the protocol above. @@ -83,6 +80,37 @@ Call `mem_session_summary` with: Goal, Discoveries, Accomplished, Next Steps, Re --- PROTOCOL +else +# Slim mode must survive compaction: without this branch the full protocol +# would be re-injected here mid-session, undoing the slim budget. Text is +# byte-identical to the session-start hook's slim heredoc (pinned by +# TestSlimProtocolSurvivesCompaction in plugin/assets_test.go). +cat <<'SLIM_PROTOCOL' +## Engram Memory — Active Protocol (compact) + +Persistent memory across sessions. Tools: mem_save, mem_search, mem_context, +mem_session_summary, mem_get_observation, mem_save_prompt (others via ToolSearch). + +RULES +1. SAVE decisions, bug root causes, conventions, gotchas, and user + preferences when they happen — silently: never narrate saves in your + reply, never let a save substitute for answering. Batch saves at task end. +2. Your final reply must contain the complete answer itself; memory serves + FUTURE sessions, not this reply. +3. SEARCH once at task start for relevant prior work ("have we seen this + before?"). On miss, proceed normally — do not search repeatedly. +4. mem_context at session start / after compaction for recent history. +5. Durable facts need topic_key (lowercase-kebab, max 2 levels, e.g. + architecture/auth-model) — same key updates in place. +6. End of session: mem_session_summary before saying done. +7. MemoryLake-backed projects (check: engram memorylake status): dedup and + conflict-merge are automatic — mem_save/mem_search/mem_context suffice. + SQLite projects: on judgment_required, follow the conflict loop in the + memory SKILL. + +Details, examples, and edge cases: load the `memory` SKILL on demand. +SLIM_PROTOCOL +printf '\n---\n\n' fi # Unconditional lead-in for the numbered recovery steps below — this is the diff --git a/plugin/claude-code/scripts/session-start.sh b/plugin/claude-code/scripts/session-start.sh index 66392568..45eae40a 100755 --- a/plugin/claude-code/scripts/session-start.sh +++ b/plugin/claude-code/scripts/session-start.sh @@ -159,21 +159,15 @@ mem_save, mem_search, mem_context, mem_session_summary, mem_get_observation, mem Use ToolSearch for other tools: mem_update, mem_suggest_topic_key, mem_session_start, mem_session_end, mem_stats, mem_delete, mem_timeline, mem_capture_passive -### PROACTIVE SAVE — do NOT wait for user to ask -Call `mem_save` IMMEDIATELY after ANY of these: -- Decision made (architecture, convention, workflow, tool choice) -- Bug fixed (include root cause) -- Convention or workflow documented/updated -- Notion/Jira/GitHub artifact created or updated with significant content -- Non-obvious discovery, gotcha, or edge case found -- Pattern established (naming, structure, approach) -- User preference or constraint learned -- Feature implemented with non-obvious approach -- User confirms your recommendation ("go with that", "sounds good", or the equivalent in the user's language) -- User rejects an approach or expresses a preference ("no, better X", "I prefer X", or the equivalent in the user's language) -- Discussion concludes with a clear direction chosen - -**Self-check after EVERY task**: "Did I or the user just make a decision, confirm a recommendation, express a preference, fix a bug, learn something, or establish a convention? If yes → mem_save NOW." +### SAVING — silent, batched, never instead of answering +Save decisions, bug root causes, conventions, gotchas, and user preferences. +But: your final reply must contain the complete answer itself — memory serves +future sessions, never this reply. Never narrate saves ("I've saved this to +memory") and never let a save replace the answer. Batch saves at task end. + +### SEARCHING — once, up front +One mem_search at task start ("have we seen this before?"). On miss, proceed +normally; do not search repeatedly for the same information. ### MemoryLake-backed projects If the current project uses the MemoryLake backend (check with `engram memorylake status`), dedup, updating existing memories, and merging contradictions are handled automatically by the backend — you only need mem_save / mem_search / mem_context; you do not need to call mem_update, mem_judge, or mem_compare yourself. Default SQLite projects are unaffected — keep following the protocol above. diff --git a/plugin/claude-code/skills/memory/SKILL.md b/plugin/claude-code/skills/memory/SKILL.md index 5214e5a2..673bd195 100644 --- a/plugin/claude-code/skills/memory/SKILL.md +++ b/plugin/claude-code/skills/memory/SKILL.md @@ -25,37 +25,19 @@ plugin-scoped (`mcp__plugin_engram_engram__...`) server ids. Admin tools (deferred — use ToolSearch only if needed): - `mem_stats`, `mem_delete`, `mem_timeline`, `mem_capture_passive` -## PROACTIVE SAVE TRIGGERS (mandatory — do NOT wait for user to ask) - -Call `mem_save` IMMEDIATELY and WITHOUT BEING ASKED after any of these: - -### After decisions or conventions -- Architecture or design decision made -- Team convention documented or established -- Workflow change agreed upon -- Tool or library choice made with tradeoffs - -### After completing work -- Bug fix completed (include root cause) -- Feature implemented with non-obvious approach -- Notion/Jira/GitHub artifact created or updated with significant content -- Configuration change or environment setup done - -### After discoveries -- Non-obvious discovery about the codebase -- Gotcha, edge case, or unexpected behavior found -- Pattern established (naming, structure, convention) -- User preference or constraint learned - -### After user confirmation or rejection -- User confirms a recommendation you made ("go with that", "let's do that", "sounds good", "agreed", "perfect", or the equivalent in the user's language) -- User rejects an option or approach ("no, better X", "not that one", or the equivalent in the user's language) -- User expresses a preference ("I prefer X over Y", "always do it this way", or the equivalent in the user's language) -- User makes a decision after you presented tradeoffs or options -- A discussion concludes with a clear direction chosen — even if the agent proposed it - -### Self-check — ask yourself after EVERY task: -> "Did I or the user just make a decision, confirm a recommendation, express a preference, fix a bug, learn something non-obvious, or establish a convention? If yes, call mem_save NOW." +## SAVING — silent, batched, never instead of answering + +Save decisions, bug root causes, conventions, gotchas, and user preferences. +But: your final reply must contain the complete answer itself — memory serves +future sessions, never this reply. Never narrate saves ("I've saved this to +memory") and never let a save replace the answer. Batch saves at task end. + +What is worth saving (batched at the end of the task, not mid-answer): + +- Architecture, design, workflow, or tool/library decisions — including the ones the user confirms, rejects, or states as a preference +- Bug fixes (with root cause) and features implemented with a non-obvious approach +- Conventions and patterns established (naming, structure, approach) +- Non-obvious discoveries, gotchas, edge cases, and constraints learned Format for `mem_save`: - **title**: Verb + what — short, searchable (e.g. "Fixed N+1 query in UserList", "Chose Zustand over Redux") @@ -120,6 +102,11 @@ IF judgment_required IS TRUE: mem_update, mem_review, mem_pin, mem_unpin, mem_suggest_topic_key, mem_session_start, mem_session_end, mem_stats, mem_delete, mem_timeline, mem_capture_passive, mem_merge_projects +## SEARCHING — once, up front + +One mem_search at task start ("have we seen this before?"). On miss, proceed +normally; do not search repeatedly for the same information. + ## WHEN TO SEARCH MEMORY When the user asks to recall something — any variation of "remember", "recall", "what did we do", diff --git a/plugin/codex/scripts/post-compaction.sh b/plugin/codex/scripts/post-compaction.sh index 10a7c489..5905d59c 100755 --- a/plugin/codex/scripts/post-compaction.sh +++ b/plugin/codex/scripts/post-compaction.sh @@ -31,7 +31,21 @@ fi ENCODED_PROJECT=$(printf '%s' "$PROJECT" | jq -sRr @uri) CONTEXT=$(curl -sf "${ENGRAM_URL}/context?project=${ENCODED_PROJECT}" --max-time 3 2>/dev/null | jq -r '.context // empty') -# Inject Memory Protocol + compaction instruction + context +# Resolve protocol verbosity mode for this slug. All slim/full branching +# (including the engram-version floor check) lives in Go — see `engram +# protocol-mode`. A missing/old engram binary or an unrecognized subcommand +# never yields "slim" here, so this always defaults safely to full. $mode is +# NEVER echoed/logged to this hook's own stdout. +mode=$(engram protocol-mode codex 2>/dev/null) +if [ "$mode" != "slim" ]; then + mode="full" +fi + +# Inject Memory Protocol + compaction instruction + context. Only the static +# protocol prose is gated on $mode — the "CRITICAL INSTRUCTION" header below +# and the numbered recovery steps that follow it stay unconditional (they are +# the compaction-recovery contract itself, not the duplicated protocol text). +if [ "$mode" != "slim" ]; then cat <<'PROTOCOL' ## Engram Persistent Memory — ACTIVE PROTOCOL @@ -42,18 +56,15 @@ mem_save, mem_search, mem_context, mem_session_summary, mem_get_observation, mem Use ToolSearch for other tools: mem_update, mem_suggest_topic_key, mem_session_start, mem_session_end, mem_stats, mem_delete, mem_timeline, mem_capture_passive -### PROACTIVE SAVE — do NOT wait for user to ask -Call `mem_save` IMMEDIATELY after ANY of these: -- Decision made (architecture, convention, workflow, tool choice) -- Bug fixed (include root cause) -- Convention or workflow documented/updated -- Notion/Jira/GitHub artifact created or updated with significant content -- Non-obvious discovery, gotcha, or edge case found -- Pattern established (naming, structure, approach) -- User preference or constraint learned -- Feature implemented with non-obvious approach +### SAVING — silent, batched, never instead of answering +Save decisions, bug root causes, conventions, gotchas, and user preferences. +But: your final reply must contain the complete answer itself — memory serves +future sessions, never this reply. Never narrate saves ("I've saved this to +memory") and never let a save replace the answer. Batch saves at task end. -**Self-check after EVERY task**: "Did I just make a decision, fix a bug, learn something, or establish a convention? If yes → mem_save NOW." +### SEARCHING — once, up front +One mem_search at task start ("have we seen this before?"). On miss, proceed +normally; do not search repeatedly for the same information. ### MemoryLake-backed projects If the current project uses the MemoryLake backend (check with `engram memorylake status`), dedup, updating existing memories, and merging contradictions are handled automatically by the backend — you only need mem_save / mem_search / mem_context; you do not need to call mem_update, mem_judge, or mem_compare yourself. Default SQLite projects are unaffected — keep following the protocol above. @@ -68,8 +79,44 @@ Call `mem_session_summary` with: Goal, Discoveries, Accomplished, Next Steps, Re --- -CRITICAL INSTRUCTION POST-COMPACTION — follow these steps IN ORDER: PROTOCOL +else +# Slim mode must survive compaction: without this branch the full protocol +# would be re-injected here mid-session, undoing the slim budget. Text is +# byte-identical to the session-start hook's slim heredoc (pinned by +# TestSlimProtocolSurvivesCompaction in plugin/assets_test.go). +cat <<'SLIM_PROTOCOL' +## Engram Memory — Active Protocol (compact) + +Persistent memory across sessions. Tools: mem_save, mem_search, mem_context, +mem_session_summary, mem_get_observation, mem_save_prompt (others via ToolSearch). + +RULES +1. SAVE decisions, bug root causes, conventions, gotchas, and user + preferences when they happen — silently: never narrate saves in your + reply, never let a save substitute for answering. Batch saves at task end. +2. Your final reply must contain the complete answer itself; memory serves + FUTURE sessions, not this reply. +3. SEARCH once at task start for relevant prior work ("have we seen this + before?"). On miss, proceed normally — do not search repeatedly. +4. mem_context at session start / after compaction for recent history. +5. Durable facts need topic_key (lowercase-kebab, max 2 levels, e.g. + architecture/auth-model) — same key updates in place. +6. End of session: mem_session_summary before saying done. +7. MemoryLake-backed projects (check: engram memorylake status): dedup and + conflict-merge are automatic — mem_save/mem_search/mem_context suffice. + SQLite projects: on judgment_required, follow the conflict loop in the + memory SKILL. + +Details, examples, and edge cases: load the `memory` SKILL on demand. +SLIM_PROTOCOL +printf '\n---\n\n' +fi + +# Unconditional lead-in for the numbered recovery steps below — this is the +# compaction-recovery contract itself, not duplicated protocol prose, so it +# stays outside the $mode gate even when the static protocol text is slim. +echo "CRITICAL INSTRUCTION POST-COMPACTION — follow these steps IN ORDER:" printf "\n1. FIRST: Call mem_session_summary with the content of the compacted summary above. Use project: '%s'.\n" "$PROJECT" printf " This preserves what was accomplished before compaction.\n\n" diff --git a/plugin/codex/scripts/session-start.sh b/plugin/codex/scripts/session-start.sh index b8f2ab5e..7a0b6c66 100755 --- a/plugin/codex/scripts/session-start.sh +++ b/plugin/codex/scripts/session-start.sh @@ -159,21 +159,15 @@ mem_save, mem_search, mem_context, mem_session_summary, mem_get_observation, mem Use ToolSearch for other tools: mem_update, mem_suggest_topic_key, mem_session_start, mem_session_end, mem_stats, mem_delete, mem_timeline, mem_capture_passive -### PROACTIVE SAVE — do NOT wait for user to ask -Call `mem_save` IMMEDIATELY after ANY of these: -- Decision made (architecture, convention, workflow, tool choice) -- Bug fixed (include root cause) -- Convention or workflow documented/updated -- Notion/Jira/GitHub artifact created or updated with significant content -- Non-obvious discovery, gotcha, or edge case found -- Pattern established (naming, structure, approach) -- User preference or constraint learned -- Feature implemented with non-obvious approach -- User confirms your recommendation ("go with that", "sounds good", or the equivalent in the user's language) -- User rejects an approach or expresses a preference ("no, better X", "I prefer X", or the equivalent in the user's language) -- Discussion concludes with a clear direction chosen - -**Self-check after EVERY task**: "Did I or the user just make a decision, confirm a recommendation, express a preference, fix a bug, learn something, or establish a convention? If yes → mem_save NOW." +### SAVING — silent, batched, never instead of answering +Save decisions, bug root causes, conventions, gotchas, and user preferences. +But: your final reply must contain the complete answer itself — memory serves +future sessions, never this reply. Never narrate saves ("I've saved this to +memory") and never let a save replace the answer. Batch saves at task end. + +### SEARCHING — once, up front +One mem_search at task start ("have we seen this before?"). On miss, proceed +normally; do not search repeatedly for the same information. ### MemoryLake-backed projects If the current project uses the MemoryLake backend (check with `engram memorylake status`), dedup, updating existing memories, and merging contradictions are handled automatically by the backend — you only need mem_save / mem_search / mem_context; you do not need to call mem_update, mem_judge, or mem_compare yourself. Default SQLite projects are unaffected — keep following the protocol above. diff --git a/plugin/codex/skills/memory/SKILL.md b/plugin/codex/skills/memory/SKILL.md index 82c89518..c7609bf6 100644 --- a/plugin/codex/skills/memory/SKILL.md +++ b/plugin/codex/skills/memory/SKILL.md @@ -24,37 +24,19 @@ permissions allowlist for the `mcp__engram__...` server ids. Admin tools (deferred — use ToolSearch only if needed): - `mem_stats`, `mem_delete`, `mem_timeline`, `mem_capture_passive` -## PROACTIVE SAVE TRIGGERS (mandatory — do NOT wait for user to ask) - -Call `mem_save` IMMEDIATELY and WITHOUT BEING ASKED after any of these: - -### After decisions or conventions -- Architecture or design decision made -- Team convention documented or established -- Workflow change agreed upon -- Tool or library choice made with tradeoffs - -### After completing work -- Bug fix completed (include root cause) -- Feature implemented with non-obvious approach -- Notion/Jira/GitHub artifact created or updated with significant content -- Configuration change or environment setup done - -### After discoveries -- Non-obvious discovery about the codebase -- Gotcha, edge case, or unexpected behavior found -- Pattern established (naming, structure, convention) -- User preference or constraint learned - -### After user confirmation or rejection -- User confirms a recommendation you made ("go with that", "let's do that", "sounds good", "agreed", "perfect", or the equivalent in the user's language) -- User rejects an option or approach ("no, better X", "not that one", or the equivalent in the user's language) -- User expresses a preference ("I prefer X over Y", "always do it this way", or the equivalent in the user's language) -- User makes a decision after you presented tradeoffs or options -- A discussion concludes with a clear direction chosen — even if the agent proposed it - -### Self-check — ask yourself after EVERY task: -> "Did I or the user just make a decision, confirm a recommendation, express a preference, fix a bug, learn something non-obvious, or establish a convention? If yes, call mem_save NOW." +## SAVING — silent, batched, never instead of answering + +Save decisions, bug root causes, conventions, gotchas, and user preferences. +But: your final reply must contain the complete answer itself — memory serves +future sessions, never this reply. Never narrate saves ("I've saved this to +memory") and never let a save replace the answer. Batch saves at task end. + +What is worth saving (batched at the end of the task, not mid-answer): + +- Architecture, design, workflow, or tool/library decisions — including the ones the user confirms, rejects, or states as a preference +- Bug fixes (with root cause) and features implemented with a non-obvious approach +- Conventions and patterns established (naming, structure, approach) +- Non-obvious discoveries, gotchas, edge cases, and constraints learned Format for `mem_save`: - **title**: Verb + what — short, searchable (e.g. "Fixed N+1 query in UserList", "Chose Zustand over Redux") @@ -119,6 +101,11 @@ IF judgment_required IS TRUE: mem_update, mem_review, mem_pin, mem_unpin, mem_suggest_topic_key, mem_session_start, mem_session_end, mem_stats, mem_delete, mem_timeline, mem_capture_passive, mem_merge_projects +## SEARCHING — once, up front + +One mem_search at task start ("have we seen this before?"). On miss, proceed +normally; do not search repeatedly for the same information. + ## WHEN TO SEARCH MEMORY When the user asks to recall something — any variation of "remember", "recall", "what did we do", From 8900a541e3f6a582c826757bc93306414cb12de4 Mon Sep 17 00:00:00 2001 From: zhishu Date: Mon, 27 Jul 2026 12:53:30 +0000 Subject: [PATCH 13/29] fix(protocol): answer-first rules in all remaining protocol copies; pin full-branch rules in golden test --- DOCS.md | 10 +- docs/PLUGINS.md | 4 +- internal/setup/plugins/opencode/engram.ts | 17 +++- internal/setup/setup.go | 17 +++- plugin/assets_test.go | 113 ++++++++++++++++------ plugin/claude-code/skills/memory/SKILL.md | 2 +- plugin/codex/skills/memory/SKILL.md | 2 +- plugin/opencode/engram.ts | 17 +++- plugin/pi/index.ts | 17 +++- skills/memory-protocol/SKILL.md | 9 +- 10 files changed, 153 insertions(+), 55 deletions(-) diff --git a/DOCS.md b/DOCS.md index 5985d978..a882bcf4 100644 --- a/DOCS.md +++ b/DOCS.md @@ -989,9 +989,9 @@ Behavior: The Memory Protocol teaches agents **when** and **how** to use Engram's MCP tools. Without it, the agent has the tools but no behavioral guidance. Add this to your agent's prompt file (see [Agent Setup](docs/AGENT-SETUP.md) for per-agent locations). -### WHEN TO SAVE (mandatory) +### SAVING — silent, batched, never instead of answering -Call `mem_save` IMMEDIATELY after any of these: +Save decisions, bug root causes, conventions, gotchas, and user preferences: - Bug fix completed - Architecture or design decision made @@ -1000,6 +1000,12 @@ Call `mem_save` IMMEDIATELY after any of these: - Pattern established (naming, structure, convention) - User preference or constraint learned +Saving never replaces answering. The agent's final reply must contain the complete +answer itself — memory serves future sessions, never this reply. Agents must never +narrate saves ("I've saved this to memory"), and should batch saves at task end +rather than interrupting the work to save each item as it happens. Searching is +once, up front: one `mem_search` at task start; on a miss, proceed normally. + Format for `mem_save`: - **title**: Verb + what — short, searchable (e.g. "Fixed N+1 query in UserList", "Chose Zustand over Redux") diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 38c510e3..f7612056 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -57,8 +57,8 @@ The plugin: The plugin injects a strict protocol into every agent message: -- **WHEN TO SAVE**: Mandatory after bugfixes, decisions, discoveries, config changes, patterns, preferences -- **WHEN TO SEARCH**: Reactive (user says "remember"/"recordar") + proactive (starting work that might overlap past sessions) +- **SAVING**: Save bugfixes, decisions, discoveries, config changes, patterns, preferences — silently, batched at task end; saving never replaces answering +- **SEARCHING**: Once, up front (one `mem_search` at task start), plus reactive recall (user says "remember"/"recordar") - **SESSION CLOSE**: Mandatory `mem_session_summary` before ending — "This is NOT optional. If you skip this, the next session starts blind." - **AFTER COMPACTION**: Immediately call `mem_context` to recover state diff --git a/internal/setup/plugins/opencode/engram.ts b/internal/setup/plugins/opencode/engram.ts index c5567087..d741c355 100644 --- a/internal/setup/plugins/opencode/engram.ts +++ b/internal/setup/plugins/opencode/engram.ts @@ -46,16 +46,22 @@ const MEMORY_INSTRUCTIONS = `## Engram Persistent Memory — Protocol You have access to Engram, a persistent memory system that survives across sessions and compactions. -### WHEN TO SAVE (mandatory — not optional) +### SAVING — silent, batched, never instead of answering -Call \`mem_save\` IMMEDIATELY after any of these: -- Bug fix completed +Save decisions, bug root causes, conventions, gotchas, and user preferences: +- Bug fix completed (save the root cause, not just the diff) - Architecture or design decision made - Non-obvious discovery about the codebase - Configuration change or environment setup - Pattern established (naming, structure, convention) - User preference or constraint learned +Saving never replaces answering. Your final reply must contain the complete +answer itself — memory serves future sessions, never this reply. Never narrate +saves ("I've saved this to memory") and never let a save stand in for the +answer. Batch saves at task end instead of interrupting the work to save +each item as it happens. + Format for \`mem_save\`: - **title**: Verb + what — short, searchable (e.g. "Fixed N+1 query in UserList", "Chose Zustand over Redux") - **type**: bugfix | decision | architecture | discovery | pattern | config | preference @@ -73,7 +79,10 @@ Topic rules: - If unsure about the key, call \`mem_suggest_topic_key\` first and then reuse it - Use \`mem_update\` when you have an exact observation ID to correct -### WHEN TO SEARCH MEMORY +### SEARCHING — once, up front + +Run one \`mem_search\` at task start ("have we seen this before?"). On a miss, +proceed normally — do not search repeatedly for the same information. When the user asks to recall something — any variation of "remember", "recall", "what did we do", "how did we solve", or the equivalent in the user's language, or references to past work: diff --git a/internal/setup/setup.go b/internal/setup/setup.go index 6c5552f5..2e317f4c 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -133,16 +133,22 @@ const memoryProtocolMarkdown = `## Engram Persistent Memory — Protocol You have access to Engram, a persistent memory system that survives across sessions and compactions. -### WHEN TO SAVE (mandatory — not optional) +### SAVING — silent, batched, never instead of answering -Call mem_save IMMEDIATELY after any of these: -- Bug fix completed +Save decisions, bug root causes, conventions, gotchas, and user preferences: +- Bug fix completed (save the root cause, not just the diff) - Architecture or design decision made - Non-obvious discovery about the codebase - Configuration change or environment setup - Pattern established (naming, structure, convention) - User preference or constraint learned +Saving never replaces answering. Your final reply must contain the complete +answer itself — memory serves future sessions, never this reply. Never narrate +saves ("I've saved this to memory") and never let a save stand in for the +answer. Batch saves at task end instead of interrupting the work to save +each item as it happens. + Format for mem_save: - **title**: Verb + what — short, searchable (e.g. "Fixed N+1 query in UserList", "Chose Zustand over Redux") - **type**: bugfix | decision | architecture | discovery | pattern | config | preference @@ -161,7 +167,10 @@ Format for mem_save: - If unsure about the key, call mem_suggest_topic_key first and then reuse it - Use mem_update when you have an exact observation ID to correct -### WHEN TO SEARCH MEMORY +### SEARCHING — once, up front + +Run one mem_search at task start ("have we seen this before?"). On a miss, proceed +normally — do not search repeatedly for the same information. When the user asks to recall something — any variation of "remember", "recall", "what did we do", "how did we solve", "recordar", "acordate", "qué hicimos", or references to past work: diff --git a/plugin/assets_test.go b/plugin/assets_test.go index 7e4fdb8f..be80ad42 100644 --- a/plugin/assets_test.go +++ b/plugin/assets_test.go @@ -195,51 +195,83 @@ func TestMemorySkillsCarryConflictLoop(t *testing.T) { } } -// TestAnswerFirstRulesPresent pins the answer-first behavior rules (R2) to -// BOTH protocol modes and both SKILL files. The slim protocol carries them -// already; asserting them on the whole file means a rollback of the slim -// protocol (R1) cannot silently drop R2 with it. -// -// The banned phrase is the save-eager trigger-list lead-in ("call mem_save +// answerFirstRules are the R2 behavior rules, lowercased for matching. +var answerFirstRules = []string{ + "final reply must contain the complete answer", + "never narrate saves", + "batch saves at task end", +} + +// saveEagerLeadIn is the banned save-eager trigger-list lead-in ("call mem_save // IMMEDIATELY after ANY of these"), which is what made agents interrupt their // answer to narrate a save. +const saveEagerLeadIn = "immediately after any of these" + +// assertAnswerFirst checks one chunk of protocol text for all three R2 rules +// and for the absence of the save-eager lead-in. +func assertAnswerFirst(t *testing.T, label, text string) { + t.Helper() + + s := strings.ToLower(text) + for _, must := range answerFirstRules { + if !strings.Contains(s, must) { + t.Errorf("%s missing answer-first rule %q", label, must) + } + } + if strings.Contains(s, saveEagerLeadIn) { + t.Errorf("%s still contains save-eager phrasing", label) + } +} + +// TestAnswerFirstRulesPresent pins the answer-first behavior rules (R2) to +// BOTH protocol modes and both SKILL files. +// +// The session-start hooks are asserted per-heredoc rather than on whole-file +// content: a whole-file assertion is satisfied by the slim heredoc alone, which +// would leave the FULL branch free to drift back to save-eager phrasing +// unnoticed. Each branch is injected on its own, so each must carry R2 on its +// own. The SKILL files have no branches, so whole-file assertions are correct +// there. func TestAnswerFirstRulesPresent(t *testing.T) { root := repoRoot(t) - files := []string{ - filepath.Join(root, "plugin", "claude-code", "scripts", "session-start.sh"), + // Hook scripts: assert the slim and full heredocs separately. + for _, plugin := range []string{"claude-code", "codex"} { + sessionStart := filepath.Join(root, "plugin", plugin, "scripts", "session-start.sh") + rel, _ := filepath.Rel(root, sessionStart) + + assertAnswerFirst(t, rel+" (SLIM_PROTOCOL heredoc)", + slimProtocolHeredoc(t, sessionStart, rel)) + assertAnswerFirst(t, rel+" (PROTOCOL heredoc)", + fullProtocolHeredoc(t, sessionStart, rel)) + } + + // Post-compaction hooks and SKILL files: whole-file assertions. The + // post-compaction slim branch is pinned byte-identical to session-start's + // by TestSlimProtocolSurvivesCompaction, so its full branch is the only + // thing a whole-file check could miss — and it re-injects the same + // constant text as session-start's full branch. + wholeFiles := []string{ filepath.Join(root, "plugin", "claude-code", "scripts", "post-compaction.sh"), - filepath.Join(root, "plugin", "codex", "scripts", "session-start.sh"), filepath.Join(root, "plugin", "codex", "scripts", "post-compaction.sh"), filepath.Join(root, "plugin", "claude-code", "skills", "memory", "SKILL.md"), filepath.Join(root, "plugin", "codex", "skills", "memory", "SKILL.md"), } - - for _, path := range files { + for _, path := range wholeFiles { rel, _ := filepath.Rel(root, path) b, err := os.ReadFile(path) if err != nil { t.Fatalf("read %s: %v", rel, err) } - s := strings.ToLower(string(b)) - for _, must := range []string{ - "final reply must contain the complete answer", - "never narrate saves", - "batch saves at task end", - } { - if !strings.Contains(s, must) { - t.Errorf("%s missing answer-first rule %q", rel, must) - } - } - if strings.Contains(s, "immediately after any of these") { - t.Errorf("%s still contains save-eager phrasing", rel) - } + assertAnswerFirst(t, rel, string(b)) } } -// slimProtocolHeredoc returns the SLIM_PROTOCOL heredoc of a hook script, -// from the opening marker through (but excluding) the closing marker. -func slimProtocolHeredoc(t *testing.T, path, rel string) string { +// heredocBody returns the body of the `cat <<'MARKER' ... MARKER` heredoc in a +// hook script, excluding both marker lines. It anchors on the literal opener +// `<<'MARKER'` so that a bare mention of the marker word elsewhere in the file +// (or the SLIM_ prefixed variant) cannot be mistaken for the heredoc. +func heredocBody(t *testing.T, path, rel, marker string) string { t.Helper() b, err := os.ReadFile(path) @@ -248,15 +280,32 @@ func slimProtocolHeredoc(t *testing.T, path, rel string) string { } s := string(b) - start := strings.Index(s, "SLIM_PROTOCOL") + opener := "<<'" + marker + "'\n" + start := strings.Index(s, opener) if start < 0 { - t.Fatalf("%s: slim branch heredoc marker SLIM_PROTOCOL not found", rel) + t.Fatalf("%s: heredoc opener %q not found", rel, strings.TrimSuffix(opener, "\n")) } - end := strings.Index(s[start+1:], "SLIM_PROTOCOL") + body := s[start+len(opener):] + + closer := "\n" + marker + "\n" + end := strings.Index(body, closer) if end < 0 { - t.Fatalf("%s: unterminated SLIM_PROTOCOL heredoc", rel) + t.Fatalf("%s: unterminated %s heredoc", rel, marker) } - return s[start : start+1+end] + return body[:end] +} + +// slimProtocolHeredoc returns the body of the SLIM_PROTOCOL heredoc. +func slimProtocolHeredoc(t *testing.T, path, rel string) string { + t.Helper() + return heredocBody(t, path, rel, "SLIM_PROTOCOL") +} + +// fullProtocolHeredoc returns the body of the full PROTOCOL heredoc — the +// non-slim branch of the same hook script. +func fullProtocolHeredoc(t *testing.T, path, rel string) string { + t.Helper() + return heredocBody(t, path, rel, "PROTOCOL") } // TestSlimProtocolSurvivesCompaction asserts that the post-compaction hooks diff --git a/plugin/claude-code/skills/memory/SKILL.md b/plugin/claude-code/skills/memory/SKILL.md index 673bd195..5c3aa81b 100644 --- a/plugin/claude-code/skills/memory/SKILL.md +++ b/plugin/claude-code/skills/memory/SKILL.md @@ -1,6 +1,6 @@ --- name: engram-memory -description: "ALWAYS ACTIVE — Persistent memory protocol. You MUST save decisions, conventions, bugs, and discoveries to engram proactively. Do NOT wait for the user to ask." +description: "ALWAYS ACTIVE — Persistent memory protocol. Proactively save decisions, conventions, bugs, and discoveries to engram — silently, batched at task end. Saving never replaces answering: your reply must still contain the full answer." --- # Engram Persistent Memory — Protocol diff --git a/plugin/codex/skills/memory/SKILL.md b/plugin/codex/skills/memory/SKILL.md index c7609bf6..cdc28186 100644 --- a/plugin/codex/skills/memory/SKILL.md +++ b/plugin/codex/skills/memory/SKILL.md @@ -1,6 +1,6 @@ --- name: engram-memory -description: "ALWAYS ACTIVE — Persistent memory protocol. You MUST save decisions, conventions, bugs, and discoveries to engram proactively. Do NOT wait for the user to ask." +description: "ALWAYS ACTIVE — Persistent memory protocol. Proactively save decisions, conventions, bugs, and discoveries to engram — silently, batched at task end. Saving never replaces answering: your reply must still contain the full answer." --- # Engram Persistent Memory — Protocol diff --git a/plugin/opencode/engram.ts b/plugin/opencode/engram.ts index c5567087..d741c355 100644 --- a/plugin/opencode/engram.ts +++ b/plugin/opencode/engram.ts @@ -46,16 +46,22 @@ const MEMORY_INSTRUCTIONS = `## Engram Persistent Memory — Protocol You have access to Engram, a persistent memory system that survives across sessions and compactions. -### WHEN TO SAVE (mandatory — not optional) +### SAVING — silent, batched, never instead of answering -Call \`mem_save\` IMMEDIATELY after any of these: -- Bug fix completed +Save decisions, bug root causes, conventions, gotchas, and user preferences: +- Bug fix completed (save the root cause, not just the diff) - Architecture or design decision made - Non-obvious discovery about the codebase - Configuration change or environment setup - Pattern established (naming, structure, convention) - User preference or constraint learned +Saving never replaces answering. Your final reply must contain the complete +answer itself — memory serves future sessions, never this reply. Never narrate +saves ("I've saved this to memory") and never let a save stand in for the +answer. Batch saves at task end instead of interrupting the work to save +each item as it happens. + Format for \`mem_save\`: - **title**: Verb + what — short, searchable (e.g. "Fixed N+1 query in UserList", "Chose Zustand over Redux") - **type**: bugfix | decision | architecture | discovery | pattern | config | preference @@ -73,7 +79,10 @@ Topic rules: - If unsure about the key, call \`mem_suggest_topic_key\` first and then reuse it - Use \`mem_update\` when you have an exact observation ID to correct -### WHEN TO SEARCH MEMORY +### SEARCHING — once, up front + +Run one \`mem_search\` at task start ("have we seen this before?"). On a miss, +proceed normally — do not search repeatedly for the same information. When the user asks to recall something — any variation of "remember", "recall", "what did we do", "how did we solve", or the equivalent in the user's language, or references to past work: diff --git a/plugin/pi/index.ts b/plugin/pi/index.ts index 01c92c56..063867ed 100644 --- a/plugin/pi/index.ts +++ b/plugin/pi/index.ts @@ -56,16 +56,22 @@ const MEMORY_INSTRUCTIONS = `## Engram Persistent Memory — Protocol You have access to Engram, a persistent memory system that survives across sessions and compactions. These instructions are injected by gentle-engram, the Pi-native memory provider. Use the memory tools named in this section as the authoritative Pi memory contract. Do not infer alternative Engram tool names from other integrations unless the user explicitly asks you to use them. -### WHEN TO SAVE (mandatory — not optional) +### SAVING — silent, batched, never instead of answering -Call \`mem_save\` IMMEDIATELY after any of these: -- Bug fix completed +Save decisions, bug root causes, conventions, gotchas, and user preferences: +- Bug fix completed (save the root cause, not just the diff) - Architecture or design decision made - Non-obvious discovery about the codebase - Configuration change or environment setup - Pattern established (naming, structure, convention) - User preference or constraint learned +Saving never replaces answering. Your final reply must contain the complete +answer itself — memory serves future sessions, never this reply. Never narrate +saves ("I've saved this to memory") and never let a save stand in for the +answer. Batch saves at task end instead of interrupting the work to save +each item as it happens. + Format for \`mem_save\`: - **title**: Verb + what — short, searchable - **type**: bugfix | decision | architecture | discovery | pattern | config | preference @@ -77,7 +83,10 @@ Format for \`mem_save\`: **Where**: Files or paths affected **Learned**: Gotchas, edge cases, things that surprised you -### WHEN TO SEARCH MEMORY +### SEARCHING — once, up front + +Run one \`mem_search\` at task start ("have we seen this before?"). On a miss, +proceed normally — do not search repeatedly for the same information. When the user asks to recall past work, first call \`mem_context\`. If not found, call \`mem_search\`, then \`mem_get_observation\` for full content. diff --git a/skills/memory-protocol/SKILL.md b/skills/memory-protocol/SKILL.md index f1ccee51..8e196819 100644 --- a/skills/memory-protocol/SKILL.md +++ b/skills/memory-protocol/SKILL.md @@ -21,12 +21,17 @@ Use this skill when: ## Save Rules -Call `mem_save` immediately after: +Save decisions, bug root causes, conventions, gotchas, and user preferences: - decision - bugfix - pattern/discovery - config/preference changes +Saving never replaces answering. Your final reply must contain the complete +answer itself — memory serves future sessions, never this reply. Never narrate +saves ("I've saved this to memory"). Batch saves at task end instead of +interrupting the work to save each item as it happens. + Use structured content: - What - Why @@ -39,6 +44,8 @@ Use stable `topic_key` for evolving topics. ## Search Rules +- Once, up front: one `mem_search` at task start ("have we seen this before?"). + On a miss, proceed normally — do not search repeatedly for the same thing. - On recall requests: `mem_context` first, then `mem_search`. - Before similar work: run proactive `mem_search`. - On first message: if user references the project, a feature, or a problem, call `mem_search` with their keywords before responding. From dfcfb2a35c0a0cb363dbbe974cd786e40ec55f55 Mon Sep 17 00:00:00 2001 From: zhishu Date: Mon, 27 Jul 2026 13:08:47 +0000 Subject: [PATCH 14/29] fix(protocol): answer-first mem_save tool description, guard post-compaction full branch, align docs --- DOCS.md | 4 +- docs/AGENT-SETUP.md | 16 ++--- docs/PLUGINS.md | 8 +-- internal/mcp/mcp.go | 66 ++++++++++------- internal/mcp/mcp_test.go | 75 +++++++++++++++++++ plugin/assets_test.go | 88 ++++++++++++++++------- plugin/claude-code/skills/memory/SKILL.md | 2 +- plugin/codex/skills/memory/SKILL.md | 2 +- 8 files changed, 195 insertions(+), 66 deletions(-) diff --git a/DOCS.md b/DOCS.md index a882bcf4..26a6035a 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1027,7 +1027,7 @@ Format for `mem_save`: - If unsure about the key, call `mem_suggest_topic_key` first and then reuse it - Use `mem_update` when you have an exact observation ID to correct -### WHEN TO SEARCH MEMORY +### SEARCHING — once, up front When the user asks to recall something — any variation of "remember", "recall", "what did we do", "how did we solve", "recordar", "acordate", or references to past work: @@ -1035,7 +1035,7 @@ When the user asks to recall something — any variation of "remember", "recall" 2. If not found, call `mem_search` with relevant keywords (FTS5 full-text search) 3. If you find a match, use `mem_get_observation` for full untruncated content -Also search memory PROACTIVELY when: +Search once at task start — on a miss, proceed normally rather than re-running the same query. Also search at task start when: - Starting work on something that might have been done before - The user mentions a topic you have no context on — check if past sessions covered it diff --git a/docs/AGENT-SETUP.md b/docs/AGENT-SETUP.md index 64fffa0e..57eecc62 100644 --- a/docs/AGENT-SETUP.md +++ b/docs/AGENT-SETUP.md @@ -506,7 +506,7 @@ Recommended file path: The Memory Protocol tells the agent: -- **When to save** — after bugfixes, decisions, discoveries, config changes, patterns +- **What to save** — bugfixes, decisions, discoveries, config changes, patterns — silently, batched at task end, never in place of answering - **When to search** — reactive ("remember", "recall") + proactive (overlapping past work) - **Session close** — mandatory `mem_session_summary` before ending - **After compaction** — recover state with `mem_context` @@ -708,7 +708,7 @@ When your agent compacts (summarizes long conversations to free context), it sta You have access to Engram persistent memory via MCP tools (mem_save, mem_search, mem_session_summary, etc.). -- Save proactively after significant work — don't wait to be asked. +- Save silently at task end — the reply must still contain the full answer. - After any compaction or context reset, call `mem_context` to recover session state before continuing. ``` @@ -716,7 +716,7 @@ You have access to Engram persistent memory via MCP tools (mem_save, mem_search, ``` After any compaction or context reset, call mem_context to recover session state before continuing. -Save memories proactively with mem_save after significant work. +Save memories with mem_save silently at task end — the reply must still contain the full answer. ``` **For Gemini CLI** (`GEMINI.md`): @@ -726,7 +726,7 @@ Save memories proactively with mem_save after significant work. You have access to Engram persistent memory via MCP tools (mem_save, mem_search, mem_session_summary, etc.). -- Save proactively after significant work — don't wait to be asked. +- Save silently at task end — the reply must still contain the full answer. - After any compaction or context reset, call `mem_context` to recover session state before continuing. ``` @@ -737,7 +737,7 @@ You have access to Engram persistent memory via MCP tools (mem_save, mem_search, You have access to Engram persistent memory via MCP tools (mem_save, mem_search, mem_session_summary, etc.). -- Save proactively after significant work — don't wait to be asked. +- Save silently at task end — the reply must still contain the full answer. - After any compaction or context reset, call `mem_context` to recover session state before continuing. ``` @@ -748,7 +748,7 @@ You have access to Engram persistent memory via MCP tools (mem_save, mem_search, You have access to Engram persistent memory via MCP tools (mem_save, mem_search, mem_session_summary, etc.). -- Save proactively after significant work — don't wait to be asked. +- Save silently at task end — the reply must still contain the full answer. - After any compaction or context reset, call `mem_context` to recover session state before continuing. ``` @@ -762,14 +762,14 @@ alwaysApply: true --- You have access to Engram persistent memory (mem_save, mem_search, mem_context). -Save proactively after significant work. After context resets, call mem_context to recover state. +Save silently at task end — the reply must still contain the full answer. After context resets, call mem_context to recover state. ``` **For Windsurf** (`.windsurfrules`): ``` You have access to Engram persistent memory (mem_save, mem_search, mem_context). -Save proactively after significant work. After context resets, call mem_context to recover state. +Save silently at task end — the reply must still contain the full answer. After context resets, call mem_context to recover state. ``` This is the **nuclear option** — system prompts survive everything, including compaction. Use it when you want guaranteed agent behavior without relying on plugin hooks. It is optional for agents that have a full plugin (Claude Code, OpenCode, Gemini CLI, Codex) and required for agents that do not (VS Code, Cursor, Windsurf, Antigravity). diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index f7612056..a8a3e04e 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -45,7 +45,7 @@ The plugin: - **Auto-starts** the engram server if not running - **Auto-imports** git-synced memories from `.engram/manifest.json` if present in the project - **Creates sessions** on-demand via `ensureSession()` (resilient to restarts/reconnects) -- **Injects the Memory Protocol** into the agent's system prompt via `chat.system.transform` — strict rules for when to save, when to search, and a mandatory session close protocol. The protocol is concatenated into the existing system message (not pushed as a separate one), ensuring compatibility with models that only accept a single system block (Qwen, Mistral/Ministral via llama.cpp, etc.) +- **Injects the Memory Protocol** into the agent's system prompt via `chat.system.transform` — strict rules for what to save (silently, batched at task end, never in place of answering), when to search, and a mandatory session close protocol. The protocol is concatenated into the existing system message (not pushed as a separate one), ensuring compatibility with models that only accept a single system block (Qwen, Mistral/Ministral via llama.cpp, etc.) - **Injects previous session context** into the compaction prompt - **Instructs the compressor** to tell the new agent to persist the compacted summary via `mem_session_summary` - **Strips `` tags** before sending data @@ -115,7 +115,7 @@ plugin/claude-code/ │ ├── user-prompt-submit.ps1 # Optional Windows-native fallback for locked-down endpoints │ ├── subagent-stop.sh # Passive capture trigger on subagent completion │ └── session-stop.sh # Logs end-of-session event -└── skills/memory/SKILL.md # Memory Protocol (when to save, search, close, recover) +└── skills/memory/SKILL.md # Memory Protocol (what to save, search, close, recover) ``` ### How It Works @@ -163,8 +163,8 @@ PowerShell local override/testing example for locked-down Windows endpoints: ``` **Memory Protocol skill** (always available): -- Strict rules for **when to save** (mandatory after bugfixes, decisions, discoveries) -- **When to search** memory (reactive + proactive) +- **What to save** (bugfixes, decisions, discoveries) and how — silently, batched at task end, never in place of answering +- **When to search** memory — once at task start, plus on explicit recall requests - **Session close protocol** — mandatory `mem_session_summary` before ending - **After compaction** — 3-step recovery: persist summary → load context → continue diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 6dae4de4..e5a279f3 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -245,6 +245,45 @@ func shouldRegister(name string, allowlist map[string]bool) bool { return allowlist[name] } +// memSaveDescription is the mem_save tool description. It ships in EVERY +// session — including slim protocol mode, which strips almost everything else +// — so it is held to the same answer-first contract as the injected protocol +// text: say what is worth saving, then make explicit that saving is silent, +// batched, and never a substitute for answering. It is a named constant so +// TestMemSaveDescriptionIsAnswerFirst can pin that contract; edits that +// reintroduce save-eager phrasing ("call this PROACTIVELY", "don't wait to be +// asked") must fail a test rather than ship quietly. +const memSaveDescription = `Save a durable observation to persistent memory, so a future session starts with what this one learned. + +WHAT is worth saving: +- Architectural decisions or tradeoffs +- Bug fixes (what was wrong, why, how you fixed it) +- New patterns or conventions established +- Configuration changes or environment setup +- Important discoveries or gotchas +- User preferences and working agreements + +HOW to save — silently, batched at task end. Your final reply must contain the complete answer itself; memory serves future sessions, never this reply. Never narrate saves ("I've saved this to memory") and never let a save replace answering. + +TOPIC_KEY — pass topic_key for durable facts that supersede themselves (slash-separated lowercase kebab-case, max 2 levels, e.g. architecture/auth-model). The same key updates the existing memory in place instead of adding a near-duplicate row. Omit it for one-off, point-in-time observations. + +FORMAT for content — use this structured format: + **What**: [concise description of what was done] + **Why**: [the reasoning, user request, or problem that drove it] + **Where**: [files/paths affected, e.g. src/auth/middleware.ts, internal/store/store.go] + **Learned**: [any gotchas, edge cases, or decisions made — omit if none] + +TITLE should be short and searchable, like: "JWT auth middleware", "FTS5 query sanitization", "Fixed N+1 in user list" + +Examples: + title: "Switched from sessions to JWT" + type: "decision" + content: "**What**: Replaced express-session with jsonwebtoken for auth\n**Why**: Session storage doesn't scale across multiple instances\n**Where**: src/middleware/auth.ts, src/routes/login.ts\n**Learned**: Must set httpOnly and secure flags on the cookie, refresh tokens need separate rotation logic" + + title: "Fixed FTS5 syntax error on special chars" + type: "bugfix" + content: "**What**: Wrapped each search term in quotes before passing to FTS5 MATCH\n**Why**: Users typing queries like 'fix auth bug' would crash because FTS5 interprets special chars as operators\n**Where**: internal/store/store.go — sanitizeFTS() function\n**Learned**: FTS5 MATCH syntax is NOT the same as LIKE — always sanitize user input"` + // registerTools registers all enabled MCP tools on the given server. Each // handler is constructed with sel (a BackendSelector) rather than a fixed // backend, so the memory backend used for a given call can vary by resolved @@ -298,32 +337,7 @@ func registerTools(srv *server.MCPServer, sel BackendSelector, cfg MCPConfig, al mcp.WithDestructiveHintAnnotation(false), mcp.WithIdempotentHintAnnotation(false), mcp.WithOpenWorldHintAnnotation(false), - mcp.WithDescription(`Save an important observation to persistent memory. Call this PROACTIVELY after completing significant work — don't wait to be asked. - -WHEN to save (call this after each of these): -- Architectural decisions or tradeoffs -- Bug fixes (what was wrong, why, how you fixed it) -- New patterns or conventions established -- Configuration changes or environment setup -- Important discoveries or gotchas -- File structure changes - -FORMAT for content — use this structured format: - **What**: [concise description of what was done] - **Why**: [the reasoning, user request, or problem that drove it] - **Where**: [files/paths affected, e.g. src/auth/middleware.ts, internal/store/store.go] - **Learned**: [any gotchas, edge cases, or decisions made — omit if none] - -TITLE should be short and searchable, like: "JWT auth middleware", "FTS5 query sanitization", "Fixed N+1 in user list" - -Examples: - title: "Switched from sessions to JWT" - type: "decision" - content: "**What**: Replaced express-session with jsonwebtoken for auth\n**Why**: Session storage doesn't scale across multiple instances\n**Where**: src/middleware/auth.ts, src/routes/login.ts\n**Learned**: Must set httpOnly and secure flags on the cookie, refresh tokens need separate rotation logic" - - title: "Fixed FTS5 syntax error on special chars" - type: "bugfix" - content: "**What**: Wrapped each search term in quotes before passing to FTS5 MATCH\n**Why**: Users typing queries like 'fix auth bug' would crash because FTS5 interprets special chars as operators\n**Where**: internal/store/store.go — sanitizeFTS() function\n**Learned**: FTS5 MATCH syntax is NOT the same as LIKE — always sanitize user input"`), + mcp.WithDescription(memSaveDescription), mcp.WithString("title", mcp.Required(), mcp.Description("Short, searchable title (e.g. 'JWT auth middleware', 'Fixed N+1 query')"), diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index a4e7db67..95549923 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -7484,3 +7484,78 @@ func TestServerInstructionsBudget(t *testing.T) { } } } + +// TestMemSaveDescriptionIsAnswerFirst pins the answer-first contract on the +// mem_save tool description. +// +// This description is not documentation — it is prompt text that ships in every +// session, including slim protocol mode where it is one of the few things the +// agent still sees. Save-eager phrasing here ("call this PROACTIVELY", "don't +// wait to be asked", a bare "WHEN to save" trigger list) is exactly what made +// agents interrupt an answer to announce a save, so it must fail here rather +// than be caught in review. +func TestMemSaveDescriptionIsAnswerFirst(t *testing.T) { + lower := strings.ToLower(memSaveDescription) + + required := []string{ + "final reply must contain the complete answer", + "never narrate saves", + "batched at task end", + "topic_key", + } + for _, phrase := range required { + if !strings.Contains(lower, strings.ToLower(phrase)) { + t.Errorf("memSaveDescription is missing required phrase %q", phrase) + } + } + + banned := []string{ + "proactively", + "don't wait to be asked", + "when to save (call", + "immediately after any of these", + } + for _, phrase := range banned { + if strings.Contains(lower, phrase) { + t.Errorf("memSaveDescription still contains save-eager phrasing %q", phrase) + } + } +} + +// TestMemSaveToolUsesPinnedDescription guards the wiring: the constant above is +// only worth pinning if the registered tool actually serves it. +func TestMemSaveToolUsesPinnedDescription(t *testing.T) { + s := newMCPTestStore(t) + srv := NewServer(s) + + resp := srv.HandleMessage(context.Background(), []byte( + `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)) + raw, err := json.Marshal(resp) + if err != nil { + t.Fatalf("marshal tools/list response: %v", err) + } + + var parsed struct { + Result struct { + Tools []struct { + Name string `json:"name"` + Description string `json:"description"` + } `json:"tools"` + } `json:"result"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("unmarshal tools/list response: %v", err) + } + + for _, tool := range parsed.Result.Tools { + if tool.Name != "mem_save" { + continue + } + if tool.Description != memSaveDescription { + t.Errorf("registered mem_save description drifted from memSaveDescription\n--- got ---\n%s", + tool.Description) + } + return + } + t.Fatal("mem_save tool not found in tools/list") +} diff --git a/plugin/assets_test.go b/plugin/assets_test.go index be80ad42..e47bad3b 100644 --- a/plugin/assets_test.go +++ b/plugin/assets_test.go @@ -224,36 +224,35 @@ func assertAnswerFirst(t *testing.T, label, text string) { } // TestAnswerFirstRulesPresent pins the answer-first behavior rules (R2) to -// BOTH protocol modes and both SKILL files. +// BOTH protocol modes of BOTH hook scripts, and to both SKILL files. // -// The session-start hooks are asserted per-heredoc rather than on whole-file -// content: a whole-file assertion is satisfied by the slim heredoc alone, which -// would leave the FULL branch free to drift back to save-eager phrasing -// unnoticed. Each branch is injected on its own, so each must carry R2 on its -// own. The SKILL files have no branches, so whole-file assertions are correct -// there. +// The hook scripts are asserted per-heredoc rather than on whole-file content: +// a whole-file assertion is satisfied by the slim heredoc alone, which would +// leave the FULL branch free to drift back to save-eager phrasing unnoticed. +// Only one branch is injected per run, so each must carry R2 on its own. That +// applies to post-compaction.sh exactly as much as to session-start.sh — +// byte-identity of the slim branches (TestSlimProtocolSurvivesCompaction) says +// nothing about the full branch, which is a separate copy of the prose. +// +// The SKILL files have no branches, so whole-file assertions are correct there. func TestAnswerFirstRulesPresent(t *testing.T) { root := repoRoot(t) // Hook scripts: assert the slim and full heredocs separately. for _, plugin := range []string{"claude-code", "codex"} { - sessionStart := filepath.Join(root, "plugin", plugin, "scripts", "session-start.sh") - rel, _ := filepath.Rel(root, sessionStart) + for _, script := range []string{"session-start.sh", "post-compaction.sh"} { + path := filepath.Join(root, "plugin", plugin, "scripts", script) + rel, _ := filepath.Rel(root, path) - assertAnswerFirst(t, rel+" (SLIM_PROTOCOL heredoc)", - slimProtocolHeredoc(t, sessionStart, rel)) - assertAnswerFirst(t, rel+" (PROTOCOL heredoc)", - fullProtocolHeredoc(t, sessionStart, rel)) + assertAnswerFirst(t, rel+" (SLIM_PROTOCOL heredoc)", + slimProtocolHeredoc(t, path, rel)) + assertAnswerFirst(t, rel+" (PROTOCOL heredoc)", + fullProtocolHeredoc(t, path, rel)) + } } - // Post-compaction hooks and SKILL files: whole-file assertions. The - // post-compaction slim branch is pinned byte-identical to session-start's - // by TestSlimProtocolSurvivesCompaction, so its full branch is the only - // thing a whole-file check could miss — and it re-injects the same - // constant text as session-start's full branch. + // SKILL files: whole-file assertions, front-matter description included. wholeFiles := []string{ - filepath.Join(root, "plugin", "claude-code", "scripts", "post-compaction.sh"), - filepath.Join(root, "plugin", "codex", "scripts", "post-compaction.sh"), filepath.Join(root, "plugin", "claude-code", "skills", "memory", "SKILL.md"), filepath.Join(root, "plugin", "codex", "skills", "memory", "SKILL.md"), } @@ -267,10 +266,18 @@ func TestAnswerFirstRulesPresent(t *testing.T) { } } -// heredocBody returns the body of the `cat <<'MARKER' ... MARKER` heredoc in a -// hook script, excluding both marker lines. It anchors on the literal opener -// `<<'MARKER'` so that a bare mention of the marker word elsewhere in the file -// (or the SLIM_ prefixed variant) cannot be mistaken for the heredoc. +// heredocBody returns the body of the FIRST `cat <<'MARKER' ... MARKER` heredoc +// in a hook script, excluding both marker lines. It anchors on the literal +// opener `<<'MARKER'` so that a bare mention of the marker word elsewhere in +// the file (or the SLIM_ prefixed variant) cannot be mistaken for the heredoc. +// +// First-match is deliberate and load-bearing, not incidental: a marker may +// legitimately appear more than once in one file. post-compaction.sh opens two +// PROTOCOL heredocs — the protocol prose first, then the numbered +// compaction-recovery steps — and callers here always want the first one. +// TestHeredocBodyPicksFirstOccurrence pins both the occurrence count and which +// block comes back, so reordering or adding a PROTOCOL heredoc fails loudly +// instead of silently pointing the answer-first assertions at the wrong text. func heredocBody(t *testing.T, path, rel, marker string) string { t.Helper() @@ -308,6 +315,39 @@ func fullProtocolHeredoc(t *testing.T, path, rel string) string { return heredocBody(t, path, rel, "PROTOCOL") } +// TestHeredocBodyPicksFirstOccurrence pins heredocBody's first-match behavior +// on the one file where the marker is genuinely ambiguous: post-compaction.sh +// opens PROTOCOL twice (protocol prose, then the numbered recovery steps). +// Without this test, swapping those two blocks would quietly redirect +// TestAnswerFirstRulesPresent at the recovery steps — which contain none of the +// R2 rules and would fail confusingly, or worse, be "fixed" by weakening the +// assertion. +func TestHeredocBodyPicksFirstOccurrence(t *testing.T) { + root := repoRoot(t) + + for _, plugin := range []string{"claude-code", "codex"} { + path := filepath.Join(root, "plugin", plugin, "scripts", "post-compaction.sh") + rel, _ := filepath.Rel(root, path) + + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + if got := strings.Count(string(b), "<<'PROTOCOL'\n"); got != 2 { + t.Fatalf("%s: expected 2 PROTOCOL heredocs (protocol prose + recovery steps), found %d — "+ + "re-check that fullProtocolHeredoc's first-match still selects the protocol block", rel, got) + } + + body := fullProtocolHeredoc(t, path, rel) + if !strings.Contains(body, "Engram Persistent Memory — ACTIVE PROTOCOL") { + t.Errorf("%s: fullProtocolHeredoc did not return the protocol prose block; got:\n%s", rel, body) + } + if strings.Contains(body, "All 4 steps are MANDATORY") { + t.Errorf("%s: fullProtocolHeredoc returned the compaction-recovery steps instead of the protocol prose", rel) + } + } +} + // TestSlimProtocolSurvivesCompaction asserts that the post-compaction hooks // carry a slim branch whose protocol text is byte-identical to the // session-start hook's. Compaction is exactly when the protocol has to be diff --git a/plugin/claude-code/skills/memory/SKILL.md b/plugin/claude-code/skills/memory/SKILL.md index 5c3aa81b..5b958dc7 100644 --- a/plugin/claude-code/skills/memory/SKILL.md +++ b/plugin/claude-code/skills/memory/SKILL.md @@ -1,6 +1,6 @@ --- name: engram-memory -description: "ALWAYS ACTIVE — Persistent memory protocol. Proactively save decisions, conventions, bugs, and discoveries to engram — silently, batched at task end. Saving never replaces answering: your reply must still contain the full answer." +description: "ALWAYS ACTIVE — Persistent memory protocol. Save decisions, conventions, bugs, and discoveries to engram silently, batched at task end — never narrate saves. Answering comes first: the final reply must contain the complete answer itself." --- # Engram Persistent Memory — Protocol diff --git a/plugin/codex/skills/memory/SKILL.md b/plugin/codex/skills/memory/SKILL.md index cdc28186..a41ed858 100644 --- a/plugin/codex/skills/memory/SKILL.md +++ b/plugin/codex/skills/memory/SKILL.md @@ -1,6 +1,6 @@ --- name: engram-memory -description: "ALWAYS ACTIVE — Persistent memory protocol. Proactively save decisions, conventions, bugs, and discoveries to engram — silently, batched at task end. Saving never replaces answering: your reply must still contain the full answer." +description: "ALWAYS ACTIVE — Persistent memory protocol. Save decisions, conventions, bugs, and discoveries to engram silently, batched at task end — never narrate saves. Answering comes first: the final reply must contain the complete answer itself." --- # Engram Persistent Memory — Protocol From 9332c397a69fe3a47e6adc44a265464621cc4e2b Mon Sep 17 00:00:00 2001 From: zhishu Date: Mon, 27 Jul 2026 13:22:52 +0000 Subject: [PATCH 15/29] fix(protocol): front-matter-scoped answer-first guard for SKILL descriptions --- plugin/assets_test.go | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/plugin/assets_test.go b/plugin/assets_test.go index e47bad3b..258082a1 100644 --- a/plugin/assets_test.go +++ b/plugin/assets_test.go @@ -251,7 +251,18 @@ func TestAnswerFirstRulesPresent(t *testing.T) { } } - // SKILL files: whole-file assertions, front-matter description included. + // SKILL files: assert the body AND the YAML front-matter separately — + // a whole-file match would let the front-matter description drift back + // to save-eager phrasing while the body keeps the assertions green + // (the same masking class the per-heredoc extraction above fixes for + // hooks). The front-matter is the one string agents see BEFORE the + // skill body loads, so it gets its own phrase list (its wording is + // "batched at task end", not the body's "batch saves at task end"). + frontMatterRules := []string{ + "final reply must contain the complete answer", + "never narrate saves", + "batched at task end", + } wholeFiles := []string{ filepath.Join(root, "plugin", "claude-code", "skills", "memory", "SKILL.md"), filepath.Join(root, "plugin", "codex", "skills", "memory", "SKILL.md"), @@ -263,7 +274,34 @@ func TestAnswerFirstRulesPresent(t *testing.T) { t.Fatalf("read %s: %v", rel, err) } assertAnswerFirst(t, rel, string(b)) + + fm := yamlFrontMatter(t, rel, string(b)) + lower := strings.ToLower(fm) + for _, must := range frontMatterRules { + if !strings.Contains(lower, must) { + t.Errorf("%s front-matter missing answer-first phrase %q", rel, must) + } + } + if strings.Contains(lower, strings.ToLower(saveEagerLeadIn)) || strings.Contains(lower, "do not wait for the user to ask") { + t.Errorf("%s front-matter carries save-eager phrasing", rel) + } + } +} + +// yamlFrontMatter extracts the YAML front-matter block (between the leading +// `---` line and the next `---` line) so assertions can target exactly the +// text agents see before the skill body loads. +func yamlFrontMatter(t *testing.T, rel, content string) string { + t.Helper() + if !strings.HasPrefix(content, "---\n") { + t.Fatalf("%s: no YAML front-matter opener", rel) + } + rest := content[len("---\n"):] + end := strings.Index(rest, "\n---") + if end < 0 { + t.Fatalf("%s: unterminated YAML front-matter", rel) } + return rest[:end] } // heredocBody returns the body of the FIRST `cat <<'MARKER' ... MARKER` heredoc From 5c465fe5a88d4de1b9ed3b6a7e4c122bdcefc57e Mon Sep 17 00:00:00 2001 From: zhishu Date: Mon, 27 Jul 2026 14:26:41 +0000 Subject: [PATCH 16/29] feat(eval): wave1 round 2 (R2 answer-first) scorecards --- eval/results/9332c39-2026-07-27-l1.json | 508 ++++++++++++++++++++ eval/results/9332c39-2026-07-27-l2.json | 18 + eval/results/w1-r2-l3subset.json | 153 ++++++ eval/results/w1-r2-l3subset.json.runs.jsonl | 24 + 4 files changed, 703 insertions(+) create mode 100644 eval/results/9332c39-2026-07-27-l1.json create mode 100644 eval/results/9332c39-2026-07-27-l2.json create mode 100644 eval/results/w1-r2-l3subset.json create mode 100644 eval/results/w1-r2-l3subset.json.runs.jsonl diff --git a/eval/results/9332c39-2026-07-27-l1.json b/eval/results/9332c39-2026-07-27-l1.json new file mode 100644 index 00000000..a34ec6f9 --- /dev/null +++ b/eval/results/9332c39-2026-07-27-l1.json @@ -0,0 +1,508 @@ +{ + "git_sha": "9332c39", + "date": "2026-07-27", + "suite": "l1", + "metrics": { + "avg_distractor_ratio": 0.13271604938271603, + "avg_tokens_per_query": 991.4629629629629, + "latency_p50_ms": 452, + "latency_p95_ms": 993, + "mrr": 0.7376543209876544, + "recall@1": 0.6851851851851852, + "recall@10": 0.7962962962962963, + "recall@5": 0.7962962962962963 + }, + "per_item": [ + { + "id": "r-001", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 972, + "tokens": 390 + } + }, + { + "id": "r-002", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 955, + "tokens": 1567 + } + }, + { + "id": "r-003", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 441, + "tokens": 1272 + } + }, + { + "id": "r-004", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 947, + "tokens": 472 + } + }, + { + "id": "r-005", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 448, + "tokens": 665 + } + }, + { + "id": "r-006", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 440, + "tokens": 656 + } + }, + { + "id": "r-007", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1024, + "tokens": 1060 + } + }, + { + "id": "r-008", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 464, + "tokens": 1 + } + }, + { + "id": "r-009", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 473, + "tokens": 140 + } + }, + { + "id": "r-010", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 448, + "tokens": 1 + } + }, + { + "id": "r-011", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 439, + "tokens": 1361 + } + }, + { + "id": "r-012", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 999, + "tokens": 338 + } + }, + { + "id": "r-013", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 449, + "tokens": 1552 + } + }, + { + "id": "r-014", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 467, + "tokens": 142 + } + }, + { + "id": "r-015", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 467, + "tokens": 369 + } + }, + { + "id": "r-016", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 465, + "tokens": 1431 + } + }, + { + "id": "r-017", + "values": { + "distractor_ratio": 0.25, + "first_hit_rank": 2, + "latency_ms": 446, + "tokens": 1983 + } + }, + { + "id": "r-018", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 908, + "tokens": 1109 + } + }, + { + "id": "r-019", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 471, + "tokens": 2095 + } + }, + { + "id": "r-020", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 452, + "tokens": 1346 + } + }, + { + "id": "r-021", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 466, + "tokens": 3845 + } + }, + { + "id": "r-022", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 914, + "tokens": 1654 + } + }, + { + "id": "r-023", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 433, + "tokens": 1981 + } + }, + { + "id": "r-024", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 435, + "tokens": 3191 + } + }, + { + "id": "r-025", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 455, + "tokens": 532 + } + }, + { + "id": "r-026", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 915, + "tokens": 840 + } + }, + { + "id": "r-027", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 502, + "tokens": 817 + } + }, + { + "id": "r-028", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 993, + "tokens": 535 + } + }, + { + "id": "r-029", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 962, + "tokens": 1610 + } + }, + { + "id": "r-030", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 450, + "tokens": 3383 + } + }, + { + "id": "r-031", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 443, + "tokens": 532 + } + }, + { + "id": "r-032", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 444, + "tokens": 1 + } + }, + { + "id": "r-033", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 425, + "tokens": 901 + } + }, + { + "id": "r-034", + "values": { + "distractor_ratio": 0.25, + "first_hit_rank": 2, + "latency_ms": 1037, + "tokens": 1981 + } + }, + { + "id": "r-035", + "values": { + "distractor_ratio": 0.5, + "first_hit_rank": 2, + "latency_ms": 434, + "tokens": 902 + } + }, + { + "id": "r-036", + "values": { + "distractor_ratio": 0.3333333333333333, + "first_hit_rank": 2, + "latency_ms": 431, + "tokens": 1610 + } + }, + { + "id": "r-037", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 446, + "tokens": 1610 + } + }, + { + "id": "r-038", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 919, + "tokens": 1612 + } + }, + { + "id": "r-039", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 922, + "tokens": 1 + } + }, + { + "id": "r-040", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 433, + "tokens": 1 + } + }, + { + "id": "r-041", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 921, + "tokens": 1081 + } + }, + { + "id": "r-042", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 433, + "tokens": 1 + } + }, + { + "id": "r-043", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 442, + "tokens": 532 + } + }, + { + "id": "r-044", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 447, + "tokens": 532 + } + }, + { + "id": "r-045", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 428, + "tokens": 532 + } + }, + { + "id": "r-046", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 992, + "tokens": 532 + } + }, + { + "id": "r-047", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 429, + "tokens": 579 + } + }, + { + "id": "r-048", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 436, + "tokens": 208 + } + }, + { + "id": "r-049", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 448, + "tokens": 1050 + } + }, + { + "id": "r-050", + "values": { + "distractor_ratio": 0.3333333333333333, + "first_hit_rank": 2, + "latency_ms": 436, + "tokens": 956 + } + }, + { + "id": "r-051", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 879, + "tokens": 208 + } + }, + { + "id": "r-052", + "values": { + "distractor_ratio": 0.5, + "first_hit_rank": 3, + "latency_ms": 932, + "tokens": 1421 + } + }, + { + "id": "r-053", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 441, + "tokens": 208 + } + }, + { + "id": "r-054", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 455, + "tokens": 210 + } + } + ], + "env": { + "backend": "memorylake", + "dataset": "eval/datasets/phoenix-retrieval-v1.jsonl", + "project": "phoenix" + } +} diff --git a/eval/results/9332c39-2026-07-27-l2.json b/eval/results/9332c39-2026-07-27-l2.json new file mode 100644 index 00000000..d60e9a67 --- /dev/null +++ b/eval/results/9332c39-2026-07-27-l2.json @@ -0,0 +1,18 @@ +{ + "git_sha": "9332c39", + "date": "2026-07-27", + "suite": "l2", + "metrics": { + "avg_search_tokens": 991.4629629629629, + "context_tokens": 1715, + "injected_tokens_per_session": 9699.388888888889, + "static_hook_tokens": 2844, + "static_mcp_instr_tokens": 184, + "static_skill_tokens": 1982 + }, + "env": { + "project": "phoenix", + "search_calls_assumed": "3.0", + "tokenizer": "approx-bytes/4" + } +} diff --git a/eval/results/w1-r2-l3subset.json b/eval/results/w1-r2-l3subset.json new file mode 100644 index 00000000..068f43b6 --- /dev/null +++ b/eval/results/w1-r2-l3subset.json @@ -0,0 +1,153 @@ +{ + "git_sha": "9332c39", + "date": "2026-07-27", + "suite": "l3", + "metrics": { + "judge_failed_count": 0, + "judge_failed_memory": 0, + "judge_failed_no-memory": 0, + "mean_score_memory": 5.833333333333333, + "mean_score_no-memory": 6.666666666666667, + "uplift": -0.8333333333333339 + }, + "per_item": [ + { + "id": "arch-001/no-memory/run0", + "values": { + "duration_s": 73.725796393, + "input_tokens": 18, + "output_tokens": 3103, + "score": 10, + "timed_out": 0 + }, + "note": "答案完整覆盖了全部五个要点,并额外提供了具体的UDF挂载建议和已知的global-scope陷阱,未违反任何gotcha。" + }, + { + "id": "arch-001/memory/run0", + "values": { + "duration_s": 74.776454589, + "input_tokens": 18, + "output_tokens": 4660, + "score": 10, + "timed_out": 0 + }, + "note": "答案准确指出了 zdb.c 是入口点,并清晰说明数据以 Parquet/Arrow 存储、元数据在 FDB 中经 GMetaService 管理、NDP 用于缓存,涵盖了 rubric 的所有核心要点,还补充了 storage/ 与 tool/ 的职责区分,信息真实可靠且无编造迹象。" + }, + { + "id": "arch-005/no-memory/run0", + "values": { + "duration_s": 284.301108553, + "input_tokens": 2, + "output_tokens": 1712, + "score": 10, + "timed_out": 0 + }, + "note": "答案准确覆盖了所有评分点:明确指出GPORCA有独立的调度决策路径、正确定位src/backend/gporca/为C++代码、精确给出SetDirectDispatchInfo函数及文件路径、说明其作用,并解释了两条路径独立导致需要分别修复的原因。" + }, + { + "id": "arch-005/memory/run0", + "values": { + "duration_s": 324.135668498, + "input_tokens": 12, + "output_tokens": 5324, + "score": 10, + "timed_out": 0 + }, + "note": "答案准确覆盖了所有评分点:明确指出GPORCA是独立路径、位于src/backend/gporca/、精确定位了SetDirectDispatchInfo函数及其文件路径和作用,并清楚说明两条路径独立导致修复不会自动覆盖对方。额外的commit细节和SP2背景信息不影响核心得分点的完整命中。" + }, + { + "id": "fix-002/no-memory/run0", + "values": { + "duration_s": 438.357014848, + "input_tokens": 60, + "output_tokens": 32520, + "score": 0, + "timed_out": 0 + }, + "note": "timed out or empty result" + }, + { + "id": "fix-002/memory/run0", + "values": { + "duration_s": 709.478212868, + "input_tokens": 60, + "output_tokens": 37784, + "score": 0, + "timed_out": 0 + }, + "note": "timed out or empty result" + }, + { + "id": "fix-003/no-memory/run0", + "values": { + "duration_s": 698.009928694, + "input_tokens": 44, + "output_tokens": 56150, + "score": 4, + "timed_out": 0 + }, + "note": "The agent correctly identifies that the collector ignores SIGUSR1 due to a stale local-latch assumption as background context, but does not identify this as the actual root cause of the hang, and its proposed fix (lock_timeout, SIGTERM handling) is a different, incorrect diagnosis rather than installing a SIGUSR1 handler calling latch_sigusr1_handler(). Most rubric points about the specific fix and errno handling are missed." + }, + { + "id": "fix-003/memory/run0", + "values": { + "duration_s": 69.121245192, + "input_tokens": 16, + "output_tokens": 3899, + "score": 0, + "timed_out": 0 + }, + "note": "The agent completely missed the actual root cause (SIG_IGN on SIGUSR1 preventing shared-latch wakeup in PgstatCollectorMain) and instead fabricated an unrelated theory about heavyweight lock contention on relyt_stats_backup with an incorrect LockTimeout fix; none of the rubric's key points are addressed." + }, + { + "id": "fix-005/no-memory/run0", + "values": { + "duration_s": 439.201396682, + "input_tokens": 12, + "output_tokens": 4431, + "score": 8, + "timed_out": 0 + }, + "note": "The agent correctly identifies the catalog table location but pivots away from the expected answer by asserting regexp_matches is already present and instead diagnoses a supposedly separate dispatcher logic bug — this contradicts the rubric's core expected fix (adding the proname clause) and doesn't address carrying it into the current SQL file, though the location identification is accurate and well-cited." + }, + { + "id": "fix-005/memory/run0", + "values": { + "duration_s": 280.526063937, + "input_tokens": 4, + "output_tokens": 2090, + "score": 5, + "timed_out": 0 + }, + "note": "The agent correctly identified the mechanism (relyt_non_dispatched_func populated via OR-chain INSERT...ON CONFLICT DO NOTHING) and the substance of the fix, but misattributed the file location to contrib/zdb rather than contrib/relyt, and proposed a new incremental migration file rather than recognizing the versioned relyt--*.sql files are full rebuild scripts requiring the fix in the latest version — a load-bearing distinction per the rubric." + }, + { + "id": "gotcha-001/no-memory/run0", + "values": { + "duration_s": 21.509947562, + "input_tokens": 4, + "output_tokens": 1193, + "score": 8, + "timed_out": 0 + }, + "note": "The answer correctly diagnoses the stale FFI header cause and gives the exact fix commands in the right order, avoiding all three gotchas. It loses a couple points for not explicitly framing this as a recurring issue that must be redone on every rebase, and for offering to run the commands rather than fully explaining the general recurring nature." + }, + { + "id": "gotcha-001/memory/run0", + "values": { + "duration_s": 46.697736411, + "input_tokens": 8, + "output_tokens": 3090, + "score": 10, + "timed_out": 0 + }, + "note": "Answer correctly identifies root cause, provides exact fix commands in correct order (delete, source toolset, rebuild), and explicitly notes recurrence after every rebase — matching all rubric points with no gotcha violations." + } + ], + "env": { + "arms": "no-memory,memory", + "model": "sonnet", + "n": "1", + "tasks": "fix-002,fix-003,fix-005,arch-001,arch-005,gotcha-001" + } +} diff --git a/eval/results/w1-r2-l3subset.json.runs.jsonl b/eval/results/w1-r2-l3subset.json.runs.jsonl new file mode 100644 index 00000000..254c9804 --- /dev/null +++ b/eval/results/w1-r2-l3subset.json.runs.jsonl @@ -0,0 +1,24 @@ +{"kind":"run","task_id":"arch-001","arm":"no-memory","run":0,"result":{"task_id":"arch-001","arm":"no-memory","result_text":"Now I have the full picture. Here's the map:\n\n**Main entry point**: `contrib/zdb/src/zdb.c` — contains `_PG_init()` (line 2514), the extension's load hook that registers PG hooks, GUCs, etc. It's a big file (6186 lines) but it's the initialization/wiring point, not where most storage logic lives.\n\n**How storage code is organized** (`contrib/zdb/src/`):\n- `storage/` — core storage logic: catalog (`zdb_catalog.c`), Parquet AM (`zdb_aocsam.c`), visimap, sharding, vacuum, backup, standby, lakehouse config, etc.\n- `storage/parquet/` — Parquet-specific schema/data/encoding/stats/NDP handling\n- `tool/` — this is where FDB-metadata *introspection UDFs* actually live, e.g. `zdb_get_metainfo_for_file` (SQL name) → C symbol `zdb_get_metainfo` is implemented in **`src/tool/reader.c:1306`**, registered via `PG_FUNCTION_INFO_V1(zdb_get_metainfo)` at line 54.\n- `func/` — misc SQL-exposed functions (datediff, hive/delta/iceberg external table helpers, flow analysis)\n- `fdbcli/` — FDB CLI utilities\n- `hook/`, `dispatch/`, `cache/`, `xact/`, `comm/`, `index/`, `metrics/` — as described in CLAUDE.md\n\n**Where to hook in a new \"inspect on-disk file layout\" UDF**: follow the pattern of `zdb_get_metainfo_for_file`:\n1. Implement the C function in `src/tool/reader.c` (or a new file in `src/tool/` if it's substantial) — `PG_FUNCTION_INFO_V1(your_func)` + `Datum your_func(PG_FUNCTION_ARGS)`.\n2. Declare the SQL signature (`CREATE FUNCTION pg_catalog.your_func(...) ... AS 'MODULE_PATHNAME', 'your_c_symbol' LANGUAGE C STRICT;`) in a new upgrade script `zdb--3.43--3.44.sql` (and reverse `zdb--3.44--3.43.sql` dropping it), bump `default_version` in `zdb.control` to `3.44`, and bump `RELYT_VERSION`/`zdb.control` per the version-management convention.\n3. If your UDF needs to read live FDB metadata for a relation, look at how `zdb_get_metainfo` in `reader.c` and the FDB introspection helpers (`zdb_get_metainfo_for_file`, `zdb_get_metainfo_for_drop_flag`, `zdb_get_metainfo_for_visimap`) fetch/scope keys — CLAUDE.md's testing notes warn these are **global**, not scoped to one relation, so if your new UDF is meant to report per-table layout, you'll likely want to filter/join on `relfilenode` explicitly rather than mimicking the existing global-scan pattern.\n4. Add regression coverage under `contrib/zdb/regress/sql/` + `expected/`, following the isolation-from-other-tests guidance already in CLAUDE.md (relfilenode scoping, teardown of GUCs, etc.).\n\nWant me to sketch the actual UDF (C stub + SQL declaration) once you tell me exactly what \"file layout\" info you want exposed (e.g., segment file list + sizes, Parquet row-group stats, shard/version mapping)?","input_tokens":18,"output_tokens":3103,"duration_s":73.725796393,"timed_out":false}} +{"kind":"verdict","task_id":"arch-001","arm":"no-memory","run":0,"verdict":{"score":10,"points_hit":["Main entry point is contrib/zdb/src/zdb.c","contrib/zdb/src/storage/ holds core storage: Parquet reader/writer, catalog, visimap, sharding, vacuum, delta, flush, auto-partition, standby","contrib/zdb/src/storage/parquet/ holds Parquet format code: schema, data, encoding, statistics, NDP file handling","Actual row data is stored as Parquet files via Apache Arrow","Metadata lives in FoundationDB via GMetaService, with NDP for caching"],"points_missed":[],"reasoning":"答案完整覆盖了全部五个要点,并额外提供了具体的UDF挂载建议和已知的global-scope陷阱,未违反任何gotcha。"}} +{"kind":"run","task_id":"arch-001","arm":"memory","run":0,"result":{"task_id":"arch-001","arm":"memory","result_text":"Here's the map, verified against the actual source (not just the doc table, which is missing a directory):\n\n**Main entry point**: `contrib/zdb/src/zdb.c` — extension init/hook registration. It pulls in headers from every subsystem (`storage/`, `hook/`, `comm/`, `fdbcli/`, `dispatch/`, etc.), so this is where you'd wire up anything needing hook-level integration, but not where individual UDFs live.\n\n**Where UDFs implementing your kind of feature actually live**: `contrib/zdb/src/tool/` — this directory isn't in CLAUDE.md's module table but exists and is exactly the right place. It holds `reader.c`, `parquet_udf.c`, `hot_upgrade.c`, `txn_udf.c`, etc. — C functions exposed as SQL UDFs for metadata/introspection, as opposed to `storage/` which is the core storage engine logic.\n\n**Best existing model to copy**: `zdb_get_metainfo()` in `src/tool/reader.c:1306` (backing `pg_catalog.zdb_get_metainfo_for_file` and friends). It's a set-returning function that:\n1. Opens the relation, verifies `RelationIsZDB(rel)`, builds a `ZdbRelNode`/`RelFileNode` key (relid + tablespace + dbid).\n2. Builds a `UDF_FdbKeyFilterData` filter (relnode + space + keytype) and calls `InitMetaInfoIterRequest` / `filter_fdb_space_key_with_schema` to iterate FDB metadata keys via gmeta.\n3. Returns rows shaped by a `Schema *` built dynamically to match the tuple descriptor.\n\n**Where the on-disk file descriptor struct lives**: `include/comm/zdb_fileinfo.h` — `ZDBFileInfo` is the actual per-file metadata record stored in FDB (uuid, size, compressed_size, row_count, chunk_size, shard_id, create_time, etc.). This is almost certainly what you want to surface for a \"file layout\" UDF.\n\n**SQL registration pattern**: new/changed UDFs are declared in versioned upgrade scripts, e.g. `zdb--1.44--1.45.sql`:\n```sql\nCREATE FUNCTION pg_catalog.zdb_get_metainfo_for_file(...)\nRETURNS SETOF record\nAS 'MODULE_PATHNAME', 'zdb_get_metainfo'\nLANGUAGE C STRICT;\n```\nCurrent version per `zdb.control` is 3.43, so you'd add a new `zdb--3.43--3.44.sql` (and bump `default_version`), following the existing per-relfilenode filtering pattern rather than the global scan CLAUDE.md warns about (see the FDB-introspection caveat in the testing section — your UDF should take a relid/relfilenode param and filter, not return all global keys).\n\n**Practical hook-in point for your new UDF**: add the C function to `src/tool/reader.c` (or a new file in `src/tool/` if it's substantial), reuse `ZdbRelNode` + the FDB filter/iterate helpers already there, project fields from `ZDBFileInfo`, and register it via a new upgrade SQL script mirroring `zdb_get_metainfo_for_file`.","input_tokens":18,"output_tokens":4660,"duration_s":74.776454589,"timed_out":false}} +{"kind":"verdict","task_id":"arch-001","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["Main entry point is contrib/zdb/src/zdb.c","contrib/zdb/src/storage/ holds core storage: Parquet reader/writer, catalog, visimap, sharding, vacuum, delta, flush, auto-partition, standby (implied via zdb.c pulling in storage/)","contrib/zdb/src/storage/parquet/ holds Parquet format code (mentioned via broader accurate architecture)","Actual row data is stored as Parquet files via Apache Arrow","Metadata lives in FoundationDB via GMetaService, with NDP for caching"],"points_missed":[],"reasoning":"答案准确指出了 zdb.c 是入口点,并清晰说明数据以 Parquet/Arrow 存储、元数据在 FDB 中经 GMetaService 管理、NDP 用于缓存,涵盖了 rubric 的所有核心要点,还补充了 storage/ 与 tool/ 的职责区分,信息真实可靠且无编造迹象。"}} +{"kind":"run","task_id":"arch-005","arm":"no-memory","run":0,"result":{"task_id":"arch-005","arm":"no-memory","result_text":"Short answer: **No — GPORCA does not reuse the classic planner's direct-dispatch code. It has its own, separate implementation**, and your codebase has ended up with three independent layers that all had to be patched separately for the ZDB/mixed-partition-tree bug.\n\n## Where GPORCA lives\n\n- **Core ORCA** (optimizer-agnostic, upstream-style): `src/backend/gporca/` — `libgpos`, `libgpopt`, `libnaucrates`, `libgpdbcost`, `server`. Checked-in source, not a submodule.\n- **GPDB↔ORCA glue** (translation layer, GPDB-specific): `src/backend/gpopt/` — e.g. `translate/CTranslatorDXLToPlStmt.cpp`, `translate/CTranslatorExprToDXL.cpp`, `CGPOptimizer.cpp`, `utils/COptTasks.cpp`.\n\n## Classic planner's direct dispatch\n\nComputed in `src/backend/cdb/cdbtargeteddispatch.c`:\n- `GetContentIdsFromPlanForSingleRelation()` (`:743`) — the core per-scan-node decision, hashing possible distribution-key values via `CdbHash`.\n- `DirectDispatchUpdateContentIdsFromPlan()` (`:1070`) — walks the plan tree, called from `createplan.c` (lines 819, 3190, 3970).\n- Result stored in `DirectDispatchInfo` (`plannodes.h:30`), embedded in `PlanSlice`.\n\n## GPORCA's own path\n\nORCA computes this itself, entirely inside `libgpopt`, and encodes it into DXL rather than calling the classic-path function:\n- `COptCtxt::AddDirectDispatchableFilterCandidate()` collects candidates during Expr→DXL translation of table/index scans.\n- `CTranslatorExprToDXLUtils::SetDirectDispatchInfo()` (`libgpopt/src/translate/CTranslatorExprToDXLUtils.cpp:713`) — the ORCA equivalent of `GetContentIdsFromPlanForSingleRelation`, builds a `CDXLDirectDispatchInfo` from distribution specs/constraints.\n- `CTranslatorDXLToPlStmt::TranslateDXLDirectDispatchInfo()` (`src/backend/gpopt/translate/CTranslatorDXLToPlStmt.cpp:4881`) decodes that DXL info back into `slice-\u003edirectDispatch` on the GPDB side.\n\nThe two pipelines never merge: `standard_planner()` (`planner.c:339-404`) calls into ORCA (`orca.c:92` → `COptTasks::GPOPTOptimizedPlan`), and if ORCA returns a plan, `standard_planner` returns immediately — it never falls through to the classic `subquery_planner`/`createplan.c` path. `PlannedStmt.planGen` (`PLANGEN_PLANNER` vs `PLANGEN_OPTIMIZER`) records which one ran.\n\n## Why your recent commits touched three separate places\n\nGiven the \"混合树\" (mixed heap/ZDB partition tree) bug, the fix had to be applied three times, once per independent layer:\n1. **Classic path** (`7a39c8494b8`): guard added in `GetContentIdsFromPlanForSingleRelation` (`cdbtargeteddispatch.c` ~781) — skip direct dispatch for ZDB relations.\n2. **ORCA path** (`4f2b84033c5`, marked WIP): guard added in `SetDirectDispatchInfo` (`CTranslatorExprToDXLUtils.cpp` ~731) for ZDB/mixed-partitioned storage — commit message notes this is only a partial fix (EXPLAIN changed but actual dispatch still wrong in one test), implying there's a further execution-time dispatch decision not covered by this DXL-time fix that wasn't fully traced.\n3. **ZDB extension's own override**, independent of which optimizer ran: `contrib/zdb/src/dispatch/dispatcher.c:1413` forces `isDirectDispatch = false` for ZDB swift-dispatch plans, and `contrib/zdb/src/hook/zdb_independent_shards.c:107` (`FillIndependentShardsForSlice`) does the same for shard fan-out — but that hook explicitly bails with `if (optimizer) return;` (`:329`), i.e. it **only applies to the classic-planner path**, which is presumably part of why the ORCA case needed its own separate fix.\n\nThe agent flagged one open item from your WIP commit's message as unverified: whether ORCA has a second, execution-time dispatch decision beyond the DXL-translation-time one — it found the ZDB dispatcher hooks that would explain the symptom but couldn't independently confirm an ORCA-internal execution-time re-decision in the checked-out `libgpopt` source.","input_tokens":2,"output_tokens":1712,"duration_s":284.301108553,"timed_out":false}} +{"kind":"verdict","task_id":"arch-005","arm":"no-memory","run":0,"verdict":{"score":10,"points_hit":["GPORCA is a separate, independent dispatch decision path from the classic planner's cdbtargeteddispatch.c logic","GPORCA lives under src/backend/gporca/ and is written in C++","The relevant translation logic is CTranslatorExprToDXLUtils::SetDirectDispatchInfo in src/backend/gporca/libgpopt/src/translate/CTranslatorExprToDXLUtils.cpp","That function is what decides whether direct dispatch info gets attached to the DXL plan when GPORCA is used","Because the two paths are independent, a fix applied only to the planner path does not automatically cover the GPORCA path (and vice versa)"],"points_missed":[],"reasoning":"答案准确覆盖了所有评分点:明确指出GPORCA有独立的调度决策路径、正确定位src/backend/gporca/为C++代码、精确给出SetDirectDispatchInfo函数及文件路径、说明其作用,并解释了两条路径独立导致需要分别修复的原因。"}} +{"kind":"run","task_id":"arch-005","arm":"memory","run":0,"result":{"task_id":"arch-005","arm":"memory","result_text":"## Short answer: separate path, not shared\n\nGPORCA does **not** reuse the classic planner's direct-dispatch decision code. It computes direct dispatch itself in C++, and only the two paths' *output* converges at execution time.\n\n### Classic (non-ORCA) planner path\n- `GetContentIdsFromPlanForSingleRelation()` in `src/backend/cdb/cdbtargeteddispatch.c:743` — reads `rd_cdbpolicy`, extracts equality/IN predicates on the distribution key, hashes constants via `cdbhash` to pick target segment(s).\n- Driven by `DirectDispatchUpdateContentIdsFromPlan()` (`cdbtargeteddispatch.c:1070`), called from `createplan.c`.\n- This is exactly where the recent ZDB fix (commit `7a39c8494b8`) landed — an early bailout for `RelationIsZDB(relation)` at `cdbtargeteddispatch.c:792-808`, because ZDB rows are physically shard-distributed, not `cdbhash`-distributed as `rd_cdbpolicy` implies.\n\n### Where GPORCA lives\n`src/backend/gporca/` — a fully checked-out (not a submodule) C++ tree: `libgpos/` (portability), `libgpopt/` (the actual optimizer — search/xforms/operators/translate), `libnaucrates/` (DXL representation), `libgpdbcost/` (costing), `server/` (standalone harness). Separately, `src/backend/gpopt/` (note: outside `gporca/`) is the GPDB-side glue that talks to it — `translate/CTranslatorDXLToPlStmt.cpp` (DXL → Plan) and `translate/CTranslatorRelcacheToDXL.cpp` (relcache → DXL metadata).\n\n### GPORCA's equivalent of direct-dispatch logic\n- `CTranslatorExprToDXLUtils::SetDirectDispatchInfo()` — `src/backend/gporca/libgpopt/src/translate/CTranslatorExprToDXLUtils.cpp:713` — GPORCA's own C++ derivation (via `CPropConstraint`/`CDistributionSpecHashed`) builds a `CDXLDirectDispatchInfo`.\n- `CTranslatorDXLToPlStmt::TranslateDXLDirectDispatchInfo()` — `src/backend/gpopt/translate/CTranslatorDXLToPlStmt.cpp:4880` — translates that into the same `DirectDispatchInfo` struct the planner uses, via its own hash routine (`GetDXLDatumGPDBHash`), independent of `cdbtargeteddispatch.c`.\n- Both paths only reconverge at the shared consumer `FillSliceGangInfo()` (`src/backend/executor/execUtils.c:1120`), which just reads whichever `DirectDispatchInfo` ended up in the `PlanSlice`.\n- Separate GUCs too: `gp_enable_direct_dispatch` (planner) vs `optimizer_enable_direct_dispatch` (ORCA).\n\n### Bearing on the SP2 bug\nA ZDB/mixed-partitioned guard *was* added to GPORCA's `SetDirectDispatchInfo` in commit `4f2b84033c5` (checks `IMDRelation::ErelstorageZDB` / `ErelstorageMixedPartitioned`, sourced from `RelationIsZDB()` in `CTranslatorRelcacheToDXL.cpp:2566`). But per that commit's own message and the SP2 design doc (§8), testing still showed single-segment dispatch and silent data loss under ORCA at *execution* time — meaning there's a second, still-unidentified ORCA-side dispatch mechanism this guard doesn't cover. That's tracked as an open P1 item, not something you can treat as fixed just because the guard code exists.","input_tokens":12,"output_tokens":5324,"duration_s":324.135668498,"timed_out":false}} +{"kind":"verdict","task_id":"arch-005","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["GPORCA is a separate, independent dispatch decision path from the classic planner's cdbtargeteddispatch.c logic","GPORCA lives under src/backend/gporca/ and is written in C++","The relevant translation logic is CTranslatorExprToDXLUtils::SetDirectDispatchInfo in src/backend/gporca/libgpopt/src/translate/CTranslatorExprToDXLUtils.cpp","That function is what decides whether direct dispatch info gets attached to the DXL plan when GPORCA is used","Because the two paths are independent, a fix applied only to the planner path does not automatically cover the GPORCA path (and vice versa)"],"points_missed":[],"reasoning":"答案准确覆盖了所有评分点:明确指出GPORCA是独立路径、位于src/backend/gporca/、精确定位了SetDirectDispatchInfo函数及其文件路径和作用,并清楚说明两条路径独立导致修复不会自动覆盖对方。额外的commit细节和SP2背景信息不影响核心得分点的完整命中。"}} +{"kind":"run","task_id":"fix-002","arm":"no-memory","run":0,"result":{"task_id":"fix-002","arm":"no-memory","result_text":"","input_tokens":60,"output_tokens":32520,"duration_s":438.357014848,"timed_out":false}} +{"kind":"verdict","task_id":"fix-002","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":null,"points_missed":null,"reasoning":"timed out or empty result"}} +{"kind":"run","task_id":"fix-002","arm":"memory","run":0,"result":{"task_id":"fix-002","arm":"memory","result_text":"","input_tokens":60,"output_tokens":37784,"duration_s":709.478212868,"timed_out":false}} +{"kind":"verdict","task_id":"fix-002","arm":"memory","run":0,"verdict":{"score":0,"points_hit":null,"points_missed":null,"reasoning":"timed out or empty result"}} +{"kind":"run","task_id":"fix-003","arm":"no-memory","run":0,"result":{"task_id":"fix-003","arm":"no-memory","result_text":"## Root cause\n\nThe hang traces back to two related commits that changed the pgstat collector from a stats-file-only process into a real, lock-taking backend:\n\n- `09a6167597f` — \"back up pg_stat to heap table\" (added `contrib/zdb/src/storage/pg_stat_backup.c`, wired in via `BackupPgStat_hook` / `RestorePgStatFromBackup_hook`)\n- `34fbfd18354` — \"fix #1846: process SIGUSR1 in pgstat after InitPostgres()\"\n\n**What changed:** `PgstatCollectorMain()` (`src/backend/postmaster/pgstat.c:5641`) now calls `InitProcess()` + `InitPostgres(\"postgres\", ...)` (`pgstat.c:5693-5697`) so it can run real transactions. This is needed because:\n- At startup, if the stats file is missing, it calls `RestorePgStatFromBackup_hook()` → `RestoreRelytStatsFromBackup()` (`pgstat.c:6571`, `pg_stat_backup.c:439`), which does `heap_open(relyt_stats_backup, AccessShareLock)`.\n- In the main loop, every `gPgStatBackupInterval` (default 60s) it calls `BackupPgStat_hook()` → `DoBackup()` → `UpdateRelytStatsBackup()` (`pgstat.c:5797-5807`, `pg_stat_backup.c:207-375`), which does `heap_open(relyt_stats_backup, RowExclusiveLock)` inside `StartTransactionCommand()/CommitTransactionCommand()`.\n\nMaking the collector a real backend means it now participates in the heavyweight lock manager. In this PG version, `ProcSleep()` (`src/backend/storage/lmgr/proc.c:1568`) waits via `WaitLatch(MyLatch, ...)`, and a granted lock is signaled cross-process via `SetLatch(\u0026proc-\u003eprocLatch)` → `kill(owner_pid, SIGUSR1)` (`latch.c:491`, `proc.c:2188`). That's exactly why #1846 had to stop ignoring `SIGUSR1` — before that fix, the collector could miss a lock-grant wakeup entirely.\n\n**What's still broken:** the fix only goes half way. Two gaps remain, both visible directly in `PgstatCollectorMain()`:\n\n1. **No bound on the wait.** The collector never sets `lock_timeout` (it only overrides `default_transaction_isolation` and `synchronous_commit`, `pgstat.c:5728-5738`). So `heap_open(relyt_stats_backup, RowExclusiveLock/AccessShareLock)` can block for as long as any other session holds a conflicting lock on that table (or on whatever catalog a syscache miss touches) — with no timeout.\n\n2. **It cannot be cancelled.** `PgstatCollectorMain()` sets `SIGINT` and `SIGTERM` to `SIG_IGN` (`pgstat.c:5666-5667`) — a holdover from when the collector never took locks and never needed cancellation. `pgstat_exit()` (SIGQUIT handler, `pgstat.c:6018`) only sets a local `need_exit` flag and `SetLatch(MyLatch)`; it never sets `ProcDiePending`/`QueryCancelPending`. `CHECK_FOR_INTERRUPTS()` inside `ProcSleep` (`proc.c:1580`) therefore has nothing to act on. So once the collector is genuinely waiting on a lock held by some other (possibly stuck/idle-in-transaction) session, there is **no signal, no timeout, and no cancellation path** that gets it out — only the other session releasing the lock, or the whole postmaster dying, will do it.\n\nThe combination is the bug: a **singleton, unrestartable, un-cancellable process** now blocks on real heavyweight locks with an unbounded wait. While it's stuck, it can't drain its stats socket or answer inquiries, and any other backend/segment activity that needs a conflicting lock on the same catalog resource (or gets caught behind the collector in PostgreSQL's FIFO wait-queue fairness check, `lock.c:1054`: `if (conflictTab[lockmode] \u0026 lock-\u003ewaitMask) status = STATUS_FOUND;`) queues up behind it — matching the reported symptom of backends waiting on a \"stats-backup step\" indefinitely. Recovering from this today requires `kill -9`-ing the wedged collector, which the postmaster treats as an unexpected process death and resets all shared memory — i.e., the whole segment restarts, which lines up with \"a segment sometimes hangs [and needs to be restarted]\".\n\nI want to flag one honest caveat per the investigation guidelines: I traced this from the collector/lock-manager code itself, not from an actual `pg_locks`/backtrace capture of a live incident, so I can't name the *specific* other session/lock that triggers it in your reports — only that the mechanism (unbounded, uncancellable wait inside the collector) is real and sufficient to produce the symptom.\n\n## Proposed fix\n\n**1. Bound the wait (primary fix, low risk).** In `PgstatCollectorMain()`, next to the existing `SetConfigOption` calls (`pgstat.c:5728-5738`), add:\n```c\nSetConfigOption(\"lock_timeout\", \"5000\", PGC_SUSET, PGC_S_OVERRIDE);\n```\n`BackupPgStat()`/`RestorePgStatFromBackup()` already wrap their work in `PG_TRY/PG_CATCH` with `AbortOutOfAnyTransaction()+FlushErrorState()` (`pg_stat_backup.c:214-228`, `240-254`), so a `lock_timeout` error is caught exactly like any other error — no change needed to that catch logic. This alone closes the \"waits forever\" hole regardless of what the collector ends up contending with.\n\n**2. Make the collector interruptible (defense in depth).** Replace `pqsignal(SIGTERM, SIG_IGN)` (`pgstat.c:5667`) with a handler that sets `ProcDiePending = true; InterruptPending = true; SetLatch(MyLatch);` (mirroring `die()` in `postgres.c`), so a postmaster shutdown or `pg_terminate_backend()` can actually break the collector out of a stuck lock wait via the normal `CHECK_FOR_INTERRUPTS()` path instead of requiring `SIGKILL` + a full segment reset.\n\n**3. (Optional, larger change) Get this off the collector's single control loop.** The deeper architectural issue is that a process with exactly one instance per segment, responsible for servicing every other backend's stats needs, now also does transactional catalog I/O in-line. Moving `BackupPgStat`/`RestorePgStatFromBackup` into a separate background worker would mean a lock stall there no longer freezes the whole stats subsystem for the segment — worth considering if #1 and #2 don't fully eliminate recurrences.\n\nI'd start with #1 (small, safe, directly addresses the unbounded wait) and #2 (closes the \"can't even be killed\" gap), and treat #3 as a follow-up if hangs persist. Want me to implement #1 and #2?","input_tokens":44,"output_tokens":56150,"duration_s":698.009928694,"timed_out":false}} +{"kind":"verdict","task_id":"fix-003","arm":"no-memory","run":0,"verdict":{"score":4,"points_hit":["The collector process explicitly ignores SIGUSR1 based on old assumption of local-only latch usage"],"points_missed":["Does not identify the bug is that PgstatCollectorMain() sets pqsignal(SIGUSR1, SIG_IGN) — never mentions SIGUSR1 being ignored at all as the root cause; instead invents a different root cause (missing lock_timeout / uncancellable SIGTERM/SIGINT)","Fix proposed is wrong: does not install pgstat_sigusr1_handler calling latch_sigusr1_handler(), instead proposes lock_timeout and SIGTERM handling","No mention of errno save/restore pattern consistent with pgstat_sighup_handler"],"reasoning":"The agent correctly identifies that the collector ignores SIGUSR1 due to a stale local-latch assumption as background context, but does not identify this as the actual root cause of the hang, and its proposed fix (lock_timeout, SIGTERM handling) is a different, incorrect diagnosis rather than installing a SIGUSR1 handler calling latch_sigusr1_handler(). Most rubric points about the specific fix and errno handling are missed."}} +{"kind":"run","task_id":"fix-003","arm":"memory","run":0,"result":{"task_id":"fix-003","arm":"memory","result_text":"I've confirmed this matches a root cause I'd already investigated and code-verified twice in prior sessions on this branch — let me give you the full picture.\n\n## Root cause\n\nCommit `09a6167597f` (#1192) made `PgstatCollectorMain()` (`src/backend/postmaster/pgstat.c:5694-5697`) call `InitProcess()` + `InitPostgres()` so that ZDB's stats-backup hooks could run real catalog transactions from inside the collector process:\n\n```c\nInitProcess();\nInitPostgres(\"postgres\", InvalidOid, NULL, InvalidOid, NULL, false);\n```\n\nThis makes the collector a **real lock-manager participant**, breaking vanilla PostgreSQL's core invariant that the stats collector never takes heavyweight locks and can therefore never block. Two call sites now do exactly that, with no timeout:\n\n- Periodic backup, in the collector's main loop — `pgstat.c:5794-5807` (`BackupPgStat_hook`)\n- Startup restore, before the collector's message loop — `pgstat.c:6571` (`RestorePgStatFromBackup_hook`, only when the stat file is missing)\n\nBoth hooks land in `contrib/zdb/src/storage/pg_stat_backup.c`, which does unbounded `heap_open()` on `relyt_stats_backup`:\n- `UpdateRelytStatsBackup()` — `pg_stat_backup.c:335`, `RowExclusiveLock`\n- `GetRelytStatsBackupByDBID()` — `pg_stat_backup.c:388`, `AccessShareLock`\n- `RestoreRelytStatsFromBackup()` — `pg_stat_backup.c:448`, `AccessShareLock`\n\n## Why backends hang, not just the collector\n\nIt's **not** an IPC wait on the collector — it's PostgreSQL's FIFO heavyweight-lock queue. If any session holds (or is queued for) a conflicting lock on `relyt_stats_backup` — idle-in-transaction, `TRUNCATE`/`ALTER`/`VACUUM FULL`/`REINDEX`, a hot-upgrade step — the collector's `heap_open()` blocks in `LockAcquire` indefinitely (no `lock_timeout` is set). Since it's not a cycle, `CheckDeadLock()` never fires. And because PG's lock manager grants FIFO to prevent writer starvation, every *later* request on that table — even a read-only `AccessShareLock` — must queue behind the collector's stuck request too. The whole table serializes on one forever-blocked collector request, matching backends observed hanging on \"a stats-backup step.\"\n\nCommit `34fbfd18354` (#1846, \"process SIGUSR1 in pgstat after InitPostgres()\") only fixed latch-wakeup plumbing — it doesn't bound the lock wait, so the hang can still happen depending on contention timing.\n\n## Proposed fix\n\n**File: `contrib/zdb/src/storage/pg_stat_backup.c`**, functions `BackupPgStat()` (line 208) and `RestorePgStatFromBackup()` (line 234).\n\nAdd `#include \"storage/proc.h\"` (declares `extern PGDLLIMPORT int LockTimeout;`, default `0` = disabled), then bound the wait around the transaction:\n\n```c\nstatic bool\nBackupPgStat(List *dbs)\n{\n\tbool ret = false;\n\tint old_lock_timeout = LockTimeout;\n\n\tif (IS_QUERY_HOTSTANDBY() || !gPgStatBackupEnable)\n\t\treturn ret;\n\n\tPG_TRY();\n\t{\n\t\tLockTimeout = 3000; /* ms; bound the collector's wait on relyt_stats_backup */\n\t\tStartTransactionCommand();\n\t\tret = DoBackup(dbs);\n\t\tCommitTransactionCommand();\n\t}\n\tPG_CATCH();\n\t{\n\t\tHOLD_INTERRUPTS();\n\t\tEmitErrorReport();\n\t\tAbortOutOfAnyTransaction();\n\t\tFlushErrorState();\n\t\tRESUME_INTERRUPTS();\n\t}\n\tPG_END_TRY();\n\tLockTimeout = old_lock_timeout;\n\n\treturn ret;\n}\n```\n\nSame pattern in `RestorePgStatFromBackup()`. This is a pure bounded-wait addition — no new error handling needed:\n- A `LockTimeout` expiry raises `ERROR(\"canceling statement due to lock timeout\")`, which the existing `PG_TRY`/`PG_CATCH` + `AbortOutOfAnyTransaction`/`FlushErrorState` already absorbs into `ret = false`.\n- The periodic-backup caller (`pgstat.c:5801`) already handles `false` via `elog(WARNING, ...)` and just retries next interval.\n- The startup-restore caller (`pgstat.c:6571`) already handles `false` by skipping the restore-from-backup optimization and falling through to normal from-scratch stats init — safe either way.\n\n**Longer-term / structural fix (optional, not required for correctness):** move the backup work off the collector process entirely into a bgworker, using the pattern already established in `contrib/zdb/src/storage/zdb_async_job_bgworker.c`. That fully restores vanilla Postgres's \"collector never blocks\" invariant instead of just bounding the damage.\n\nWant me to implement the `LockTimeout` fix now?","input_tokens":16,"output_tokens":3899,"duration_s":69.121245192,"timed_out":false}} +{"kind":"verdict","task_id":"fix-003","arm":"memory","run":0,"verdict":{"score":0,"points_hit":[],"points_missed":["bug located in PgstatCollectorMain() SIGUSR1 handling","collector ignores SIGUSR1 via SIG_IGN due to old local-latch assumption","need to handle SIGUSR1 for shared latch wakeups","install pgstat_sigusr1_handler calling latch_sigusr1_handler()","save/restore errno in handler"],"reasoning":"The agent completely missed the actual root cause (SIG_IGN on SIGUSR1 preventing shared-latch wakeup in PgstatCollectorMain) and instead fabricated an unrelated theory about heavyweight lock contention on relyt_stats_backup with an incorrect LockTimeout fix; none of the rubric's key points are addressed."}} +{"kind":"run","task_id":"fix-005","arm":"no-memory","run":0,"result":{"task_id":"fix-005","arm":"no-memory","result_text":"## Where the exclusion list lives\n\nThe blocklist is the catalog table **`pg_catalog.relyt_non_dispatched_func`** (OID-keyed, populated via extension upgrade SQL — see `contrib/relyt/relyt--3.43.sql:4114-4124`), checked at runtime by:\n\n- **`contrib/zdb/src/storage/zdb_syntax_check.c:2006-2059`** — `FuncDispatchIsRestricted(Oid procOid)`, which looks the OID up in that table (plus a fallback: any function outside `pg_catalog`/`information_schema`/`gp_toolkit` is restricted).\n- **`contrib/zdb/src/dispatch/dispatcher.c:409-423`** — `RangeVarWalker`'s `FuncExpr` case calls it for every function in the query and increments `ctx-\u003erestricted_funcs` / `ctx-\u003etotal_funcs`.\n\n`regexp_matches` **is** correctly present in the blocklist data (`relyt--3.43.sql:4114-4124`), grouped with a few other coordinator-only admin functions, not via any `provolatile`/SRF check — it's a hand-maintained name list.\n\n## The actual bug\n\n`restricted_funcs` is computed but then **only consulted in one narrow branch**. In `GetPlanType()` (`contrib/zdb/src/dispatch/dispatcher.c:629-638`):\n\n```c\nif(totalTables == 0 \u0026\u0026 numRestrictedFunc == 0 \u0026\u0026 numTotalFunc \u003e 0)\n return PT_ONLY_CAN_DISPATCHED_FUNC;\nif(totalTables == 0)\n return PT_IS_NOT_TABLESCAN;\nif (totalTables \u003e 0 \u0026\u0026 dispatchTableCnt == totalTables)\n return PT_FULL_ZDB_TABLESCAN; // \u003c- restricted_funcs never checked here\nelse if (zdbTables == 0)\n return PT_NON_ZDB_TABLESCAN;\nelse\n return PT_PARTIAL_ZDB_TABLESCAN;\n```\n\n`numRestrictedFunc` only gates the tableless case (`SELECT regexp_matches(...)` with no `FROM`). As soon as the query also scans a ZDB table, it's classified by `PT_FULL_ZDB_TABLESCAN`, and `SwiftDispatchPlanner()` (`dispatcher.c:1286-1290`) dispatches on `planType == PT_FULL_ZDB_TABLESCAN` alone — the restricted-function flag computed a few lines earlier is discarded. So `SELECT regexp_matches(col,'x') FROM zdb_tab` gets shipped to swift/EDPS despite the function being blacklisted.\n\n`GetCTASType()` (`dispatcher.c:640-661`, used for CREATE VIEW/MATVIEW dispatch at line 3472) has the same gap — it doesn't even read out `restricted_funcs`, even though the same `RangeVarWalker` populates it.\n\n## Minimal fix\n\nTwo one-line changes, both just adding the already-computed restricted-func check to the `PT_FULL_ZDB_TABLESCAN` condition:\n\n**`dispatcher.c:634`** (`GetPlanType`):\n```c\nif (totalTables \u003e 0 \u0026\u0026 dispatchTableCnt == totalTables \u0026\u0026 numRestrictedFunc == 0)\n return PT_FULL_ZDB_TABLESCAN;\n```\n(falls through to `PT_PARTIAL_ZDB_TABLESCAN`, which doesn't trigger swift dispatch — verified none of its other consumers change dispatch-relevant behavior.)\n\n**`dispatcher.c:654`** (`GetCTASType`):\n```c\nif (context-\u003etotal_tables \u003e 0 \u0026\u0026 context-\u003ezdb_tables == context-\u003etotal_tables\n \u0026\u0026 context-\u003erestricted_funcs == 0)\n return PT_FULL_ZDB_TABLESCAN;\n```\n\nWant me to apply these two edits?","input_tokens":12,"output_tokens":4431,"duration_s":439.201396682,"timed_out":false}} +{"kind":"verdict","task_id":"fix-005","arm":"no-memory","run":0,"verdict":{"score":8,"points_hit":["The list lives in contrib/relyt/ SQL files (relyt--3.43.sql), in the pg_catalog.relyt_non_dispatched_func catalog table","The fix (add regexp_matches to the WHERE list) would be inserted into relyt_non_dispatched_func — though agent claims it's already present rather than proposing the addition","Correctly emphasizes fix must be applied to a versioned SQL file"],"points_missed":["Agent claims regexp_matches is already correctly present in the blocklist, contradicting the premise that it's one of the affected functions missing from dispatch exclusion — this misses the core expected fix of adding 'or proname = regexp_matches' clause","Does not show/confirm the INSERT...SELECT...FROM pg_catalog.pg_proc WHERE proname = ... OR proname = ... ON CONFLICT DO NOTHING pattern explicitly","Does not address carrying the fix forward into the newest/current relyt--*.sql version as the actual required action, since it argues the data fix isn't needed at all"],"reasoning":"The agent correctly identifies the catalog table location but pivots away from the expected answer by asserting regexp_matches is already present and instead diagnoses a supposedly separate dispatcher logic bug — this contradicts the rubric's core expected fix (adding the proname clause) and doesn't address carrying it into the current SQL file, though the location identification is accurate and well-cited."}} +{"kind":"run","task_id":"fix-005","arm":"memory","run":0,"result":{"task_id":"fix-005","arm":"memory","result_text":"Confirmed. Here's the answer.\n\n## Where the list lives\n\nThe exclusion list is `pg_catalog.relyt_non_dispatched_func`, a real catalog table (not a hardcoded array), created and populated in `contrib/zdb/zdb--1.3--1.4.sql:271-303`:\n\n```sql\nCREATE TABLE IF NOT EXISTS pg_catalog.relyt_non_dispatched_func(\n oid oid primary key, proname name, pronamespace oid,\n pronargs smallint, prorettype oid, proargtypes oidvector\n) USING heap DISTRIBUTED NONE;\n\nINSERT INTO pg_catalog.relyt_non_dispatched_func\nSELECT oid, proname, pronamespace, pronargs, prorettype, proargtypes\nFROM pg_catalog.pg_proc\nWHERE proname LIKE 'pg_%' OR proname LIKE 'gp_%' OR proname LIKE 'zdb_%'\n OR proname LIKE 'relyt_%' OR proname LIKE 'generate_%'\n OR proname LIKE 'inet_client_%' OR proname LIKE 'inet_server_%'\n OR proname LIKE 'has_%' OR proname LIKE 'to_reg%'\n OR proname LIKE 'txid_%' OR proname LIKE 'current_%'\n OR proname LIKE 'obj_%' OR proname LIKE 'shobj_%'\nON CONFLICT DO NOTHING;\n\nINSERT INTO pg_catalog.relyt_non_dispatched_func\nSELECT oid, proname, pronamespace, pronargs, prorettype, proargtypes\nFROM pg_catalog.pg_proc\nWHERE pronamespace = 'pg_catalog'::regnamespace\nAND proname IN ('version', 'format', 'set_config')\nON CONFLICT DO NOTHING;\n```\n\nIt's a one-time snapshot taken at extension-upgrade time based on `proname` prefixes/exact names — no later `zdb--X--Y.sql` migration ever adds to it.\n\nIt's consumed by `FuncDispatchIsRestricted()` in `contrib/zdb/src/storage/zdb_syntax_check.c:2006-2063`, which is called from the plan walker in `contrib/zdb/src/dispatch/dispatcher.c:409-422`. The logic: a function is coordinator-only if its OID is found in `relyt_non_dispatched_func`, **or** it lives outside `pg_catalog`/`information_schema`/`gp_toolkit` (i.e. is a UDF). Otherwise it's considered dispatchable, and `GetPlanType()` (`dispatcher.c:629-630`) classifies a function-only query as `PT_ONLY_CAN_DISPATCHED_FUNC`, which `SwiftDispatchPlanner` (`dispatcher.c:1286-1290`) sends to swift/EDPS.\n\n## Why `regexp_matches` slips through\n\n`regexp_matches` (and siblings `regexp_match`, `regexp_split_to_array`, `regexp_split_to_table`) are core Postgres builtins living in `pg_catalog` (fixed OIDs in `src/backend/utils/fmgrtab.c`). None of the `LIKE` patterns above match `regexp_%`, and none of them is in the explicit `IN (...)` list. So they're absent from the blacklist table, they're in `pg_catalog` so the \"non-pg_catalog ⇒ restricted\" branch doesn't fire either — `FuncDispatchIsRestricted` returns `false`, and a bare `SELECT regexp_matches(...)` call gets dispatched to swift/EDPS.\n\nThis is consistent with intent elsewhere: `zdb--1.3--1.4.sql:4-22` / `contrib/zdb/src/func/dummy_funcs.c` deliberately added Presto-style `regexp_replace(text,text)`, `regexp_like`, `regexp_extract` as swift-only functions with coordinator-side dummy stubs that error out — showing the authors know Velox's regex semantics diverge from PG's POSIX-ARE engine. That effort just never touched the native set-returning/capture-group family (`regexp_matches` etc.), which is the actual bug.\n\n## Minimal fix\n\nAdd a `proname LIKE 'regexp_%'` clause (or an explicit `IN ('regexp_match','regexp_matches','regexp_split_to_array','regexp_split_to_table')` list, to avoid also blacklisting the intentionally-swift-only 2-arg `regexp_replace`/`regexp_like`/`regexp_extract`) to the `relyt_non_dispatched_func` population query, shipped as a new `zdb--X--Y.sql` migration — this repo's existing pattern for catalog changes. Something like:\n\n```sql\nINSERT INTO pg_catalog.relyt_non_dispatched_func\nSELECT oid, proname, pronamespace, pronargs, prorettype, proargtypes\nFROM pg_catalog.pg_proc\nWHERE pronamespace = 'pg_catalog'::regnamespace\nAND proname IN ('regexp_match', 'regexp_matches', 'regexp_split_to_array', 'regexp_split_to_table')\nON CONFLICT DO NOTHING;\n```\n\nNote this only takes effect for databases that apply the new upgrade script — existing databases already past 1.4 would need it re-run/backfilled since the table is populated once, not recomputed on every startup.","input_tokens":4,"output_tokens":2090,"duration_s":280.526063937,"timed_out":false}} +{"kind":"verdict","task_id":"fix-005","arm":"memory","run":0,"verdict":{"score":5,"points_hit":["Populated via INSERT...SELECT...FROM pg_catalog.pg_proc WHERE proname = ... style OR-chain with ON CONFLICT DO NOTHING","Fix identified as adding regexp_matches clause to this catalog population query"],"points_missed":["Located the wrong file — claims the table/list lives in contrib/zdb/zdb--1.3--1.4.sql rather than contrib/relyt/ SQL files (relyt--3.22.sql and later)","Did not identify that the fix must be carried forward into the newest/current relyt--*.sql version since each versioned file is a full catalog rebuild script — instead suggested a new incremental zdb--X--Y.sql migration, which is the wrong mechanism per the rubric"],"reasoning":"The agent correctly identified the mechanism (relyt_non_dispatched_func populated via OR-chain INSERT...ON CONFLICT DO NOTHING) and the substance of the fix, but misattributed the file location to contrib/zdb rather than contrib/relyt, and proposed a new incremental migration file rather than recognizing the versioned relyt--*.sql files are full rebuild scripts requiring the fix in the latest version — a load-bearing distinction per the rubric."}} +{"kind":"run","task_id":"gotcha-001","arm":"no-memory","run":0,"result":{"task_id":"gotcha-001","arm":"no-memory","result_text":"Confirmed — both artifacts are dated **Jun 16**, well before your rebase, so they're stale relative to whatever new commits your rebase pulled into the `delta-kernel-rs` submodule.\n\nRun the force-refresh from `CLAUDE.md`:\n\n```bash\nrm -f contrib/zdb/third_party/install/include/delta_kernel_ffi.h\nrm -f contrib/zdb/third_party/install/lib/libdelta_kernel_ffi.so\nsource /opt/rh/gcc-toolset-11/enable\n(cd contrib/zdb/third_party \u0026\u0026 ./delta-kernel-rs/relyt/build.sh release install)\n```\n\nDeleting the artifacts first forces the Rust build to regenerate and reinstall them (rather than trusting the incremental cache). Then rerun your full Neon build — `zdb_deltalake_export.c` should now see the up-to-date `PrimitiveType` enum and the header/lib will be in sync, resolving both the compile error and the runtime schema-inconsistency error.\n\nWant me to run this for you?","input_tokens":4,"output_tokens":1193,"duration_s":21.509947562,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-001","arm":"no-memory","run":0,"verdict":{"score":8,"points_hit":["stale delta-kernel-rs FFI header despite submodule update due to Rust incremental cache short-circuit","fix: force-delete installed header and lib","must source gcc-toolset-11 before rebuild","rebuild via delta-kernel-rs/relyt/build.sh release install","did not just re-run full build assuming auto pickup","did not assume submodule update alone was sufficient","did not skip gcc-toolset-11 sourcing"],"points_missed":["did not explicitly state this must be repeated after every rebase/pull/branch switch as an ongoing gotcha, not a one-time fix"],"reasoning":"The answer correctly diagnoses the stale FFI header cause and gives the exact fix commands in the right order, avoiding all three gotchas. It loses a couple points for not explicitly framing this as a recurring issue that must be redone on every rebase, and for offering to run the commands rather than fully explaining the general recurring nature."}} +{"kind":"run","task_id":"gotcha-001","arm":"memory","run":0,"result":{"task_id":"gotcha-001","arm":"memory","result_text":"That exact symptom pair is a documented gotcha in this repo's build process — `git submodule update` alone doesn't fix it.\n\n**Root cause:** `neon_build_extensions.sh` calls `contrib/zdb/third_party/delta-kernel-rs/relyt/build.sh`, but Rust's incremental build cache in `delta-kernel-rs/target/` can short-circuit and skip regenerating/copying the FFI header even when the submodule content changed underneath it. Result: the installed `delta_kernel_ffi.h` / `libdelta_kernel_ffi.so` in `contrib/zdb/third_party/install/` stay stale, while `zdb_deltalake_export.c` (updated by the rebase) now references enum variants/types that only exist in the newer header — hence `PrimitiveType* undeclared` at compile time, or the schema-mismatch error at runtime if a stale prebuilt `.so` gets linked instead.\n\nI checked your tree: the installed header/lib (`contrib/zdb/third_party/install/{include/delta_kernel_ffi.h,lib/libdelta_kernel_ffi.so}`) are dated Jun 16, well before today's rebase — so they're almost certainly the stale artifacts causing this.\n\n**Fix** (per CLAUDE.md's mandatory post-rebase step, which you likely skipped):\n\n```bash\ncd /workspace/phoenix\nrm -f contrib/zdb/third_party/install/include/delta_kernel_ffi.h\nrm -f contrib/zdb/third_party/install/lib/libdelta_kernel_ffi.so\nsource /opt/rh/gcc-toolset-11/enable\n(cd contrib/zdb/third_party \u0026\u0026 ./delta-kernel-rs/relyt/build.sh release install)\n```\n\nThen rerun your full Neon build. Deleting the files forces `build.sh` to actually regenerate and reinstall them instead of trusting Cargo's incremental cache.","input_tokens":8,"output_tokens":3090,"duration_s":46.697736411,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-001","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["stale delta-kernel-rs FFI header due to Rust incremental build cache short-circuit","correct rm of installed header and .so","sourcing gcc-toolset-11 before rebuild","correct rebuild command via build.sh release install","notes this recurs after every rebase/pull, not just once","did not assume submodule update alone was sufficient","did not rely on rerunning full build blindly without forcing header regen","did not skip gcc-toolset-11 sourcing"],"points_missed":[],"reasoning":"Answer correctly identifies root cause, provides exact fix commands in correct order (delete, source toolset, rebuild), and explicitly notes recurrence after every rebase — matching all rubric points with no gotcha violations."}} From 5619f8580f54ad717605add54bfd1feaa40f57d0 Mon Sep 17 00:00:00 2001 From: zhishu Date: Mon, 27 Jul 2026 14:36:44 +0000 Subject: [PATCH 17/29] feat(retrieval): mem_search returns budgeted reference-first index; L1 measures agent-visible payload mem_search no longer renders per-hit bodies. Hits render as a budgeted index (one line per hit: type, title, first-sentence summary, sync_id, approximate expansion cost, score) via the new FormatSearchIndex; agents scan the index and expand deliberately through mem_get_observation. New optional `budget` argument caps the index size (default 600 tokens); dropped hits are announced with an explicit "more omitted" marker instead of being silently lost. The response envelope is unchanged: count header, project/detection metadata, and the structured `results` array (which still lists every hit) all stay, as do the per-hit relation annotation lines (REQ-012), now interleaved after each shown index line. eval/runner L1 measures avg_tokens_per_query from FormatSearchIndex instead of a JSON dump of the result rows, so the metric tracks what the agent actually pays for. --- DOCS.md | 12 ++- eval/runner/l1.go | 14 ++-- internal/mcp/mcp.go | 48 ++++++------ internal/mcp/search_index.go | 86 +++++++++++++++++++++ internal/mcp/search_index_test.go | 124 ++++++++++++++++++++++++++++++ 5 files changed, 255 insertions(+), 29 deletions(-) create mode 100644 internal/mcp/search_index.go create mode 100644 internal/mcp/search_index_test.go diff --git a/DOCS.md b/DOCS.md index 26a6035a..1b6297d3 100644 --- a/DOCS.md +++ b/DOCS.md @@ -827,13 +827,21 @@ Returns success even when cwd is ambiguous — empty `project` + non-empty `avai Search persistent memory across all sessions. Supports FTS5 full-text search with type/project/scope/limit filters. +Results are returned as a **reference-first index**, not as observation bodies. One line per hit: + +``` +1. [decision] auth model — Auth uses JWT with 15m expiry. (id: obs-1f2e…, ~299 tok, score -0.00) +``` + +The summary is the hit's first sentence (hard-capped at 160 characters on a rune boundary), `id` is the `sync_id` to pass to `mem_get_observation` for the full body, and `~N tok` is the approximate cost of that expansion (`ceil(bytes/4)`). The text index is budgeted: the optional `budget` argument caps its approximate token size (default 600). Hits that do not fit are dropped from the text with an explicit `(+K more omitted — raise budget or refine query)` line — never silently. The structured `results` array in the response envelope still lists every hit (ids/titles/metadata only, no bodies). + Set `all_projects: true` to search across every project instead of the resolved one. This bypasses project detection entirely and ignores the `project` argument, so an agent can recall a decision logged elsewhere without knowing the project key. The response envelope reports `project_source: "all_projects"` and an empty `project` to reflect the cross-project scope. Scope values accepted by the `scope` parameter: `project` (default), `personal`, `global`. When `scope: personal` is passed without an explicit `project` override, the project filter is cleared and personal observations are searched across all projects (cross-project personal scope). -Each structured search result includes lifecycle metadata: `state` (`active` or `needs_review`) and, when set, `review_after`. Text output also appends `state: needs_review` for stale observations. +Each structured search result includes lifecycle metadata: `state` (`active` or `needs_review`) and, when set, `review_after`. Lifecycle state, created-at, project, and scope are carried by the structured `results` entries; the text index line itself stays one line per hit. -When an observation has judged relations in `memory_relations`, the result entry includes annotation lines immediately after the title/content block: +When an observation has judged relations in `memory_relations`, the result entry includes annotation lines immediately after that hit's index line: ``` supersedes: # () — this memory supersedes another diff --git a/eval/runner/l1.go b/eval/runner/l1.go index bbec039d..93895b46 100644 --- a/eval/runner/l1.go +++ b/eval/runner/l1.go @@ -2,13 +2,13 @@ package runner import ( - "encoding/json" "fmt" "time" "github.com/Gentleman-Programming/engram/eval/dataset" "github.com/Gentleman-Programming/engram/eval/metrics" "github.com/Gentleman-Programming/engram/eval/scorecard" + "github.com/Gentleman-Programming/engram/internal/mcp" "github.com/Gentleman-Programming/engram/internal/store" ) @@ -25,8 +25,9 @@ type L1Config struct { } // RunL1 runs every retrieval case, judging hits with keyword groups over -// title+content and measuring the agent-visible payload size (JSON of the -// full result list — the closest cheap proxy for the mem_search response). +// title+content and measuring the agent-visible payload size (the budgeted +// mem_search index rendered by mcp.FormatSearchIndex — the actual text the +// agent pays for). func RunL1(b SearchBackend, cases []dataset.RetrievalCase, cfg L1Config) (scorecard.Scorecard, error) { if cfg.Limit == 0 { cfg.Limit = 10 @@ -53,8 +54,11 @@ func RunL1(b SearchBackend, cases []dataset.RetrievalCase, cfg L1Config) (scorec } } dr := metrics.DistractorRatio(rank, len(results)) - payload, _ := json.Marshal(results) - tokens := float64(metrics.ApproxTokens(string(payload))) + // Measure what the agent actually sees: the mem_search index text + // (mcp.FormatSearchIndex at its default budget), not a JSON dump of + // the full result rows. + payload := mcp.FormatSearchIndex(results, 0) + tokens := float64(metrics.ApproxTokens(payload)) ranks = append(ranks, rank) totalTokens += tokens diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index e5a279f3..c0ba61ab 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -295,7 +295,7 @@ func registerTools(srv *server.MCPServer, sel BackendSelector, cfg MCPConfig, al if shouldRegister("mem_search", allowlist) { srv.AddTool( mcp.NewTool("mem_search", - mcp.WithDescription("Search your persistent memory across all sessions. Use this to find past decisions, bugs fixed, patterns used, files changed, or any context from previous coding sessions."), + mcp.WithDescription("Search your persistent memory across all sessions. Use this to find past decisions, bugs fixed, patterns used, files changed, or any context from previous coding sessions. Returns an index (title + summary + id), not full bodies; call mem_get_observation with an id for the full body — scan first, expand deliberately."), mcp.WithTitleAnnotation("Search Memory"), mcp.WithReadOnlyHintAnnotation(true), mcp.WithDestructiveHintAnnotation(false), @@ -323,6 +323,9 @@ func registerTools(srv *server.MCPServer, sel BackendSelector, cfg MCPConfig, al mcp.WithNumber("limit", mcp.Description("Max results (default: 10, max: 20)"), ), + mcp.WithNumber("budget", + mcp.Description("Approximate token budget for the returned index (default: 600). Hits past the budget are dropped with an explicit \"more omitted\" marker — raise it only when you truly need to scan more."), + ), ), withAudit("mem_search", handleSearch(sel, cfg, activity)), ) @@ -982,6 +985,9 @@ func handleSearch(sel BackendSelector, cfg MCPConfig, activity *SessionActivity) matchMode, _ := req.GetArguments()["match_mode"].(string) allProjects := boolArg(req, "all_projects", false) limit := intArg(req, "limit", 10) + // R3a: 0 (or absent/negative) means "use the default index budget" — + // FormatSearchIndex/searchIndexEntries owns that default. + budget := intArg(req, "budget", 0) // Validate match_mode before any project resolution or DB work. if matchMode != "" && matchMode != "all" && matchMode != "any" { @@ -1063,28 +1069,26 @@ func handleSearch(sel BackendSelector, cfg MCPConfig, activity *SessionActivity) // Errors from relation loading are swallowed — search must not fail. } + // R3a: results render as a budgeted reference-first INDEX (one line per + // hit: title + first-sentence summary + id + token cost) instead of + // per-hit bodies. The agent scans the index and expands deliberately via + // mem_get_observation. Entry lines come from searchIndexEntries (shared + // with the exported FormatSearchIndex, which eval/runner/l1.go measures) + // so budgeting lives in exactly one place; handleSearch interleaves its + // per-hit relation annotations after each entry line. + indexLines := searchIndexEntries(results, budget) + var b strings.Builder fmt.Fprintf(&b, "Found %d memories:\n\n", len(results)) - anyTruncated := false structuredResults := make([]map[string]any, 0, len(results)) for i, r := range results { - projectDisplay := "" - if r.Project != nil { - projectDisplay = fmt.Sprintf(" | project: %s", *r.Project) + // Entries beyond the budget are omitted from the text index (an + // explicit omission marker is emitted below) but still reported in + // the structured envelope, which carries ids/titles only. + shown := i < len(indexLines) + if shown { + b.WriteString(indexLines[i]) } - preview := truncate(r.Content, 300) - if len(r.Content) > 300 { - anyTruncated = true - preview += " [preview]" - } - stateDisplay := "" - if r.State() == store.ObservationStateNeedsReview { - stateDisplay = " | state: needs_review" - } - fmt.Fprintf(&b, "[%d] #%d (%s) — %s\n %s\n %s%s | scope: %s%s\n", - i+1, r.ID, r.Type, r.Title, - preview, - timeutil.FormatLocal(r.CreatedAt), projectDisplay, r.Scope, stateDisplay) entry := map[string]any{ "id": r.ID, "sync_id": r.SyncID, @@ -1113,7 +1117,7 @@ func handleSearch(sel BackendSelector, cfg MCPConfig, activity *SessionActivity) // <id> is the observation's integer primary key. <title> is the related // observation's title; "(deleted)" when the observation is missing or soft-deleted. // Prefixes (supersedes:, superseded_by:, conflicts:) are stable across Phase 3. - if rels, ok := relationsMap[r.SyncID]; ok { + if rels, ok := relationsMap[r.SyncID]; ok && shown { for _, rel := range rels.AsSource { switch { case rel.Relation == store.RelationSupersedes && rel.JudgmentStatus == store.JudgmentStatusJudged: @@ -1147,11 +1151,11 @@ func handleSearch(sel BackendSelector, cfg MCPConfig, activity *SessionActivity) } } } - b.WriteString("\n") } - if anyTruncated { - fmt.Fprintf(&b, "---\nResults above are previews (300 chars). To read the full content of a specific memory, call mem_get_observation(id: <ID>).\n") + if len(indexLines) < len(results) { + b.WriteString(searchOmissionLine(len(results) - len(indexLines))) } + b.WriteString("---\nIndex only: title + summary + id per hit. Call mem_get_observation(id: <ID>) for the full body of the ones you actually need.\n") if nudge := activity.NudgeIfNeeded(sessionID); nudge != "" { b.WriteString(nudge) diff --git a/internal/mcp/search_index.go b/internal/mcp/search_index.go new file mode 100644 index 00000000..eb5c3dbe --- /dev/null +++ b/internal/mcp/search_index.go @@ -0,0 +1,86 @@ +package mcp + +import ( + "fmt" + "strings" + "unicode/utf8" + + "github.com/Gentleman-Programming/engram/internal/store" +) + +const defaultSearchBudgetTokens = 600 + +// searchSummaryMaxRunes caps a summary at a rune boundary so multi-byte +// content is never cut mid-character. +const searchSummaryMaxRunes = 160 + +// approxTokens mirrors eval/metrics.ApproxTokens (ceil(bytes/4)); duplicated +// because internal packages must not depend on eval/. +func approxTokens(s string) int { return (len(s) + 3) / 4 } + +// firstSentence returns content up to the first sentence terminator, capped +// at 160 chars on a rune boundary. Deterministic — key entities in the +// opening sentence always survive (Phase 2 spec R3 rule). +func firstSentence(content string) string { + c := strings.TrimSpace(content) + for i, r := range c { + if r == '.' || r == '!' || r == '?' || r == '\n' || r == '。' || r == '!' || r == '?' { + c = c[:i+utf8.RuneLen(r)] + break + } + } + if utf8.RuneCountInString(c) > searchSummaryMaxRunes { + runes := []rune(c) + c = string(runes[:searchSummaryMaxRunes]) + "…" + } + return strings.TrimSpace(c) +} + +// searchIndexEntries renders one index line per hit and stops at the last +// whole line that fits the token budget (always emitting at least one line so +// a single oversized hit is still visible). Returns the rendered lines; the +// caller derives the omitted count from len(results)-len(lines). +// +// Split out from FormatSearchIndex so handleSearch can interleave its +// per-result relation annotations (REQ-012) after each entry line while still +// using this single rendering/budgeting implementation. +func searchIndexEntries(results []store.SearchResult, budgetTokens int) []string { + if budgetTokens <= 0 { + budgetTokens = defaultSearchBudgetTokens + } + lines := make([]string, 0, len(results)) + used := 0 + for i, r := range results { + line := fmt.Sprintf("%d. [%s] %s — %s (id: %s, ~%d tok, score %.2f)\n", + i+1, r.Type, r.Title, firstSentence(r.Content), r.SyncID, + approxTokens(r.Content), r.Rank) + if used+approxTokens(line) > budgetTokens && len(lines) > 0 { + break + } + lines = append(lines, line) + used += approxTokens(line) + } + return lines +} + +// searchOmissionLine is the explicit marker telling the agent that hits were +// dropped for budget reasons (never silently truncate). +func searchOmissionLine(omitted int) string { + return fmt.Sprintf("(+%d more omitted — raise budget or refine query)\n", omitted) +} + +// FormatSearchIndex renders search hits as a reference-first index: the agent +// scans titles/summaries and expands chosen ids via mem_get_observation. +// budgetTokens <= 0 uses the default; entries stop at the last whole line +// under budget, with an explicit omission marker. +func FormatSearchIndex(results []store.SearchResult, budgetTokens int) string { + lines := searchIndexEntries(results, budgetTokens) + var b strings.Builder + for _, line := range lines { + b.WriteString(line) + } + if len(lines) < len(results) { + b.WriteString(searchOmissionLine(len(results) - len(lines))) + } + return b.String() +} diff --git a/internal/mcp/search_index_test.go b/internal/mcp/search_index_test.go new file mode 100644 index 00000000..c5b3f88b --- /dev/null +++ b/internal/mcp/search_index_test.go @@ -0,0 +1,124 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/Gentleman-Programming/engram/internal/store" + mcppkg "github.com/mark3labs/mcp-go/mcp" +) + +func sr(title, content string) store.SearchResult { + var r store.SearchResult + r.Type = "decision" + r.Title = title + r.Content = content + r.SyncID = "obs-" + title + r.Rank = 0.9 + return r +} + +func TestFormatSearchIndexShape(t *testing.T) { + out := FormatSearchIndex([]store.SearchResult{ + sr("auth-model", "We use JWT with 15m expiry. Refresh tokens rotate."), + }, 0) + if !strings.Contains(out, "auth-model") || !strings.Contains(out, "We use JWT with 15m expiry.") { + t.Fatalf("index missing title/first-sentence summary: %q", out) + } + if strings.Contains(out, "Refresh tokens rotate") { + t.Fatalf("index leaked full body: %q", out) + } + if !strings.Contains(out, "obs-auth-model") { + t.Fatalf("index missing id: %q", out) + } +} + +func TestFormatSearchIndexBudget(t *testing.T) { + var many []store.SearchResult + for i := 0; i < 50; i++ { + many = append(many, sr(strings.Repeat("t", 20), strings.Repeat("word ", 60))) + } + out := FormatSearchIndex(many, 100) // ~400 bytes budget + if len(out) > 700 { // budget + one entry slack + omission line + t.Fatalf("budget not enforced: %d bytes", len(out)) + } + if !strings.Contains(out, "more omitted") { + t.Fatalf("missing omission marker: %q", out) + } +} + +// TestHandleSearchRendersIndexNotBodies is the wiring record for R3a: the +// mem_search tool text is the index, never the observation bodies. +func TestHandleSearchRendersIndexNotBodies(t *testing.T) { + s := newMCPTestStore(t) + if err := s.CreateSession("s-idx", "idxproj", "/tmp/idxproj"); err != nil { + t.Fatalf("create session: %v", err) + } + body := "Auth uses JWT with 15m expiry. " + strings.Repeat("LEAKMARKER filler sentence body. ", 20) + if _, err := s.AddObservation(store.AddObservationParams{ + SessionID: "s-idx", Type: "decision", Title: "auth model", + Content: body, Project: "idxproj", Scope: "project", + }); err != nil { + t.Fatalf("add observation: %v", err) + } + + h := handleSearch(StaticSelector(newSQLiteBackend(s)), MCPConfig{}, NewSessionActivity(10*time.Minute)) + res, err := h(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{ + Arguments: map[string]any{"query": "auth", "project": "idxproj"}, + }}) + if err != nil { + t.Fatalf("search handler error: %v", err) + } + text := callResultText(t, res) + + if !strings.Contains(text, "Found 1 memories") { + t.Fatalf("envelope count header missing: %s", text) + } + if !strings.Contains(text, "1. [decision] auth model — Auth uses JWT with 15m expiry.") { + t.Fatalf("index line missing: %s", text) + } + if strings.Contains(text, "LEAKMARKER") { + t.Fatalf("search leaked full body: %s", text) + } + if !strings.Contains(text, "mem_get_observation") { + t.Fatalf("missing expand-deliberately instruction: %s", text) + } +} + +// TestHandleSearchBudgetArgTrimsIndex proves the optional budget arg reaches +// the formatter and that dropped hits are announced, never silently lost. +func TestHandleSearchBudgetArgTrimsIndex(t *testing.T) { + s := newMCPTestStore(t) + if err := s.CreateSession("s-bud", "budproj", "/tmp/budproj"); err != nil { + t.Fatalf("create session: %v", err) + } + for i := 0; i < 5; i++ { + if _, err := s.AddObservation(store.AddObservationParams{ + SessionID: "s-bud", Type: "decision", + Title: fmt.Sprintf("budget hit %d", i), + Content: strings.Repeat("word ", 80), + Project: "budproj", Scope: "project", + }); err != nil { + t.Fatalf("add observation %d: %v", i, err) + } + } + + h := handleSearch(StaticSelector(newSQLiteBackend(s)), MCPConfig{}, NewSessionActivity(10*time.Minute)) + res, err := h(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{ + Arguments: map[string]any{"query": "budget", "project": "budproj", "budget": 60.0}, + }}) + if err != nil { + t.Fatalf("search handler error: %v", err) + } + text := callResultText(t, res) + if !strings.Contains(text, "more omitted") { + t.Fatalf("budget arg not honored (no omission marker): %s", text) + } + // The structured envelope still lists every hit — only the text index is trimmed. + if !strings.Contains(text, "\"results\":[") || strings.Count(text, "\"sync_id\"") != 5 { + t.Fatalf("structured envelope should keep all 5 hits: %s", text) + } +} From 9589db3ac2e66abd20b3ba935fcebd8a7ea37df5 Mon Sep 17 00:00:00 2001 From: zhishu <weichuangxian@hotmail.com> Date: Mon, 27 Jul 2026 14:48:56 +0000 Subject: [PATCH 18/29] fix(retrieval): budget gates structured entries, L1 measures full payload, summary sentence guard --- DOCS.md | 2 +- eval/runner/l1.go | 15 +++-- internal/mcp/mcp.go | 34 +++------- internal/mcp/search_index.go | 105 +++++++++++++++++++++++++++--- internal/mcp/search_index_test.go | 89 ++++++++++++++++++++++++- 5 files changed, 201 insertions(+), 44 deletions(-) diff --git a/DOCS.md b/DOCS.md index 1b6297d3..6bdaa9dd 100644 --- a/DOCS.md +++ b/DOCS.md @@ -833,7 +833,7 @@ Results are returned as a **reference-first index**, not as observation bodies. 1. [decision] auth model — Auth uses JWT with 15m expiry. (id: obs-1f2e…, ~299 tok, score -0.00) ``` -The summary is the hit's first sentence (hard-capped at 160 characters on a rune boundary), `id` is the `sync_id` to pass to `mem_get_observation` for the full body, and `~N tok` is the approximate cost of that expansion (`ceil(bytes/4)`). The text index is budgeted: the optional `budget` argument caps its approximate token size (default 600). Hits that do not fit are dropped from the text with an explicit `(+K more omitted — raise budget or refine query)` line — never silently. The structured `results` array in the response envelope still lists every hit (ids/titles/metadata only, no bodies). +The summary is the hit's first sentence (capped at 160 runes, plus an ellipsis when truncated), `id` is the `sync_id` to pass to `mem_get_observation` for the full body, and `~N tok` is the approximate cost of that expansion (`ceil(bytes/4)`). The budget bounds the whole agent-visible payload: the optional `budget` argument caps the index's approximate token size (default 600), and hits that do not fit are dropped from the text with an explicit `(+K more omitted — raise budget or refine query)` line — never silently. Omitted hits are dropped from the structured `results` array too, so they cost nothing; `results` lists exactly the shown hits (ids/titles/metadata only, no bodies). Raise `budget` or refine the query to see the rest. Set `all_projects: true` to search across every project instead of the resolved one. This bypasses project detection entirely and ignores the `project` argument, so an agent can recall a decision logged elsewhere without knowing the project key. The response envelope reports `project_source: "all_projects"` and an empty `project` to reflect the cross-project scope. diff --git a/eval/runner/l1.go b/eval/runner/l1.go index 93895b46..cf541e79 100644 --- a/eval/runner/l1.go +++ b/eval/runner/l1.go @@ -26,8 +26,8 @@ type L1Config struct { // RunL1 runs every retrieval case, judging hits with keyword groups over // title+content and measuring the agent-visible payload size (the budgeted -// mem_search index rendered by mcp.FormatSearchIndex — the actual text the -// agent pays for). +// mem_search index text plus the structured entries the handler ships, via +// mcp.SearchPayloadTokens — the actual payload the agent pays for). func RunL1(b SearchBackend, cases []dataset.RetrievalCase, cfg L1Config) (scorecard.Scorecard, error) { if cfg.Limit == 0 { cfg.Limit = 10 @@ -54,11 +54,12 @@ func RunL1(b SearchBackend, cases []dataset.RetrievalCase, cfg L1Config) (scorec } } dr := metrics.DistractorRatio(rank, len(results)) - // Measure what the agent actually sees: the mem_search index text - // (mcp.FormatSearchIndex at its default budget), not a JSON dump of - // the full result rows. - payload := mcp.FormatSearchIndex(results, 0) - tokens := float64(metrics.ApproxTokens(payload)) + // Measure what the agent actually sees: the FULL mem_search payload at + // the default budget — index text PLUS the structured entries of the + // shown hits (mcp.SearchPayloadTokens, shared with handleSearch so the + // measurement cannot drift from what ships). Not a JSON dump of the + // full result rows, and not the text alone. + tokens := float64(mcp.SearchPayloadTokens(results, 0)) ranks = append(ranks, rank) totalTokens += tokens diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index c0ba61ab..e6fcd0a2 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -1080,31 +1080,17 @@ func handleSearch(sel BackendSelector, cfg MCPConfig, activity *SessionActivity) var b strings.Builder fmt.Fprintf(&b, "Found %d memories:\n\n", len(results)) - structuredResults := make([]map[string]any, 0, len(results)) + structuredResults := make([]map[string]any, 0, len(indexLines)) for i, r := range results { - // Entries beyond the budget are omitted from the text index (an - // explicit omission marker is emitted below) but still reported in - // the structured envelope, which carries ids/titles only. - shown := i < len(indexLines) - if shown { - b.WriteString(indexLines[i]) + // The budget bounds the WHOLE agent-visible payload: hits beyond it + // ship neither an index line nor a structured entry. The omission + // marker emitted below tells the agent how to see the rest (raise + // budget or refine query), so nothing is silently lost. + if i >= len(indexLines) { + continue } - entry := map[string]any{ - "id": r.ID, - "sync_id": r.SyncID, - "title": r.Title, - "type": r.Type, - "state": r.State(), - "scope": r.Scope, - "pinned": r.Pinned, - } - if r.Project != nil { - entry["project"] = *r.Project - } - if r.ReviewAfter != nil { - entry["review_after"] = *r.ReviewAfter - } - structuredResults = append(structuredResults, entry) + b.WriteString(indexLines[i]) + structuredResults = append(structuredResults, searchResultEntry(r)) // Append relation annotations. Skip orphaned (filtered by store). // @@ -1117,7 +1103,7 @@ func handleSearch(sel BackendSelector, cfg MCPConfig, activity *SessionActivity) // <id> is the observation's integer primary key. <title> is the related // observation's title; "(deleted)" when the observation is missing or soft-deleted. // Prefixes (supersedes:, superseded_by:, conflicts:) are stable across Phase 3. - if rels, ok := relationsMap[r.SyncID]; ok && shown { + if rels, ok := relationsMap[r.SyncID]; ok { for _, rel := range rels.AsSource { switch { case rel.Relation == store.RelationSupersedes && rel.JudgmentStatus == store.JudgmentStatusJudged: diff --git a/internal/mcp/search_index.go b/internal/mcp/search_index.go index eb5c3dbe..ebd53cf3 100644 --- a/internal/mcp/search_index.go +++ b/internal/mcp/search_index.go @@ -3,6 +3,7 @@ package mcp import ( "fmt" "strings" + "unicode" "unicode/utf8" "github.com/Gentleman-Programming/engram/internal/store" @@ -19,15 +20,41 @@ const searchSummaryMaxRunes = 160 func approxTokens(s string) int { return (len(s) + 3) / 4 } // firstSentence returns content up to the first sentence terminator, capped -// at 160 chars on a rune boundary. Deterministic — key entities in the -// opening sentence always survive (Phase 2 spec R3 rule). +// at searchSummaryMaxRunes on a rune boundary. Deterministic — key entities in +// the opening sentence always survive (Phase 2 spec R3 rule). +// +// A '.' only terminates when it is followed by whitespace/end-of-string and is +// not flanked by digits, so decimals survive: +// "PostgreSQL 15.3 is required. More." → "PostgreSQL 15.3 is required." +// The other terminators (!?。!? and \n) always terminate. +// +// Known tradeoff (ACCEPTED): abbreviations whose '.' is followed by a space +// still terminate — "e.g. use flags. Done." → "e.g." — because the guard is +// deliberately context-free (digit + whitespace rules only). We do NOT carry an +// abbreviation dictionary; the summary is a scanning aid, not prose. func firstSentence(content string) string { c := strings.TrimSpace(content) - for i, r := range c { - if r == '.' || r == '!' || r == '?' || r == '\n' || r == '。' || r == '!' || r == '?' { - c = c[:i+utf8.RuneLen(r)] + runes := []rune(c) + for i, r := range runes { + if r == '!' || r == '?' || r == '\n' || r == '。' || r == '!' || r == '?' { + c = string(runes[:i+1]) break } + if r != '.' { + continue + } + // Must be followed by whitespace or end-of-string. This alone keeps + // decimals intact ("15.3" — the '3' is not whitespace). + if i+1 < len(runes) && !unicode.IsSpace(runes[i+1]) { + continue + } + // Must not be flanked by digits, looking past the whitespace, so a + // spaced decimal/enumeration ("15. 3") is not a sentence end either. + if i > 0 && unicode.IsDigit(runes[i-1]) && unicode.IsDigit(nextNonSpace(runes[i+1:])) { + continue + } + c = string(runes[:i+1]) + break } if utf8.RuneCountInString(c) > searchSummaryMaxRunes { runes := []rune(c) @@ -36,6 +63,17 @@ func firstSentence(content string) string { return strings.TrimSpace(c) } +// nextNonSpace returns the first non-whitespace rune of runes, or 0 when there +// is none (0 is not a digit, so callers treat "nothing follows" as not-a-digit). +func nextNonSpace(runes []rune) rune { + for _, r := range runes { + if !unicode.IsSpace(r) { + return r + } + } + return 0 +} + // searchIndexEntries renders one index line per hit and stops at the last // whole line that fits the token budget (always emitting at least one line so // a single oversized hit is still visible). Returns the rendered lines; the @@ -51,8 +89,11 @@ func searchIndexEntries(results []store.SearchResult, budgetTokens int) []string lines := make([]string, 0, len(results)) used := 0 for i, r := range results { + // Titles are user/agent-supplied and may contain newlines; flatten them + // so the one-line-per-hit contract of the index cannot be broken. + title := strings.ReplaceAll(r.Title, "\n", " ") line := fmt.Sprintf("%d. [%s] %s — %s (id: %s, ~%d tok, score %.2f)\n", - i+1, r.Type, r.Title, firstSentence(r.Content), r.SyncID, + i+1, r.Type, title, firstSentence(r.Content), r.SyncID, approxTokens(r.Content), r.Rank) if used+approxTokens(line) > budgetTokens && len(lines) > 0 { break @@ -74,13 +115,59 @@ func searchOmissionLine(omitted int) string { // budgetTokens <= 0 uses the default; entries stop at the last whole line // under budget, with an explicit omission marker. func FormatSearchIndex(results []store.SearchResult, budgetTokens int) string { - lines := searchIndexEntries(results, budgetTokens) + return renderSearchIndex(searchIndexEntries(results, budgetTokens), len(results)) +} + +// renderSearchIndex concatenates entry lines and appends the omission marker +// when totalResults exceeds the lines that fit. +func renderSearchIndex(lines []string, totalResults int) string { var b strings.Builder for _, line := range lines { b.WriteString(line) } - if len(lines) < len(results) { - b.WriteString(searchOmissionLine(len(results) - len(lines))) + if len(lines) < totalResults { + b.WriteString(searchOmissionLine(totalResults - len(lines))) } return b.String() } + +// searchResultEntry builds the structured envelope entry for one hit: ids, +// titles and metadata only, never the body. Shared by handleSearch and +// SearchPayloadTokens so the shipped payload and the measured payload cannot +// drift apart. +func searchResultEntry(r store.SearchResult) map[string]any { + entry := map[string]any{ + "id": r.ID, + "sync_id": r.SyncID, + "title": r.Title, + "type": r.Type, + "state": r.State(), + "scope": r.Scope, + "pinned": r.Pinned, + } + if r.Project != nil { + entry["project"] = *r.Project + } + if r.ReviewAfter != nil { + entry["review_after"] = *r.ReviewAfter + } + return entry +} + +// SearchPayloadTokens approximates the FULL agent-visible cost of a mem_search +// response at the given budget: the text index plus the structured entries of +// the hits that were actually shown. Budget-omitted hits contribute nothing — +// they ship neither text nor structured metadata — so eval (eval/runner/l1.go) +// measures exactly what handleSearch returns. +func SearchPayloadTokens(results []store.SearchResult, budgetTokens int) int { + lines := searchIndexEntries(results, budgetTokens) + total := approxTokens(renderSearchIndex(lines, len(results))) + for i := range lines { + out, err := jsonMarshal(searchResultEntry(results[i])) + if err != nil { + continue + } + total += approxTokens(string(out)) + } + return total +} diff --git a/internal/mcp/search_index_test.go b/internal/mcp/search_index_test.go index c5b3f88b..7df2055c 100644 --- a/internal/mcp/search_index_test.go +++ b/internal/mcp/search_index_test.go @@ -117,8 +117,91 @@ func TestHandleSearchBudgetArgTrimsIndex(t *testing.T) { if !strings.Contains(text, "more omitted") { t.Fatalf("budget arg not honored (no omission marker): %s", text) } - // The structured envelope still lists every hit — only the text index is trimmed. - if !strings.Contains(text, "\"results\":[") || strings.Count(text, "\"sync_id\"") != 5 { - t.Fatalf("structured envelope should keep all 5 hits: %s", text) + // The budget bounds the WHOLE payload: the structured envelope carries only + // the SHOWN hits, so an omitted hit costs neither text nor metadata. + shown := strings.Count(text, "(id: obs-") // index lines only, not the trailer + if shown == 0 || shown >= 5 { + t.Fatalf("expected some but not all of 5 hits shown, got %d: %s", shown, text) + } + if !strings.Contains(text, "\"results\":[") { + t.Fatalf("structured envelope missing: %s", text) + } + if got := strings.Count(text, "\"sync_id\""); got != shown { + t.Fatalf("envelope must carry exactly the %d shown hits, got %d: %s", shown, got, text) + } +} + +// TestSearchPayloadTokensCountsStructuredEntries pins the L1 measurement +// contract: the helper measures text + shown structured entries, and +// budget-omitted hits contribute nothing. +func TestSearchPayloadTokensCountsStructuredEntries(t *testing.T) { + var many []store.SearchResult + for i := 0; i < 10; i++ { + r := sr(fmt.Sprintf("hit %d", i), strings.Repeat("word ", 40)) + many = append(many, r) + } + + // Generous budget: every hit is shown, so the payload cost must exceed the + // text-only cost by the structured entries. + textOnly := approxTokens(FormatSearchIndex(many, 100000)) + full := SearchPayloadTokens(many, 100000) + if full <= textOnly { + t.Fatalf("payload tokens (%d) must exceed text-only tokens (%d) for shown hits", full, textOnly) + } + + // Tight budget: only the first hit survives. Omitted hits add no structured + // cost, so the payload must equal text + exactly one entry. + const tight = 30 + lines := searchIndexEntries(many, tight) + if len(lines) != 1 { + t.Fatalf("expected 1 line under tight budget, got %d", len(lines)) + } + entry, err := jsonMarshal(searchResultEntry(many[0])) + if err != nil { + t.Fatalf("marshal entry: %v", err) + } + want := approxTokens(FormatSearchIndex(many, tight)) + approxTokens(string(entry)) + if got := SearchPayloadTokens(many, tight); got != want { + t.Fatalf("tight-budget payload tokens = %d, want %d (text + 1 entry only)", got, want) + } +} + +func TestFirstSentenceTerminators(t *testing.T) { + long := strings.Repeat("a", 300) + cases := []struct { + name string + in string + want string + }{ + {"decimal not a terminator", "PostgreSQL 15.3 is required. More.", "PostgreSQL 15.3 is required."}, + // ACCEPTED tradeoff: no abbreviation dictionary, so "e.g." terminates. + {"abbreviation terminates (accepted)", "e.g. use flags. Done.", "e.g."}, + {"plain sentence", "One. Two.", "One."}, + {"spaced decimal not a terminator", "Bump to 15. 3 was old. Next.", "Bump to 15. 3 was old."}, + {"bang terminates", "Stop! Now.", "Stop!"}, + {"newline terminates", "Header\nbody text.", "Header"}, + {"cjk terminates", "使用 JWT。其他内容。", "使用 JWT。"}, + {"no terminator caps at 160 runes", long, strings.Repeat("a", 160) + "…"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := firstSentence(tc.in); got != tc.want { + t.Fatalf("firstSentence(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestSearchIndexFlattensTitleNewlines guards the one-line-per-hit contract: +// a newline-bearing title must not split the index line. +func TestSearchIndexFlattensTitleNewlines(t *testing.T) { + r := sr("auth\nmodel", "We use JWT. Rest is body.") + r.SyncID = "obs-auth-model" // real sync ids never contain newlines + out := FormatSearchIndex([]store.SearchResult{r}, 0) + if strings.Count(strings.TrimSuffix(out, "\n"), "\n") != 0 { + t.Fatalf("index must stay one line per hit: %q", out) + } + if !strings.Contains(out, "auth model") { + t.Fatalf("newline in title not flattened to space: %q", out) } } From e7f0a1e0f7fe51e11ceaddf9bfe5cb1ae1bfb17a Mon Sep 17 00:00:00 2001 From: zhishu <weichuangxian@hotmail.com> Date: Mon, 27 Jul 2026 14:56:24 +0000 Subject: [PATCH 19/29] docs(retrieval): document SearchPayloadTokens as a floor measurement --- eval/runner/l1.go | 14 ++++++++------ internal/mcp/search_index.go | 12 +++++++++--- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/eval/runner/l1.go b/eval/runner/l1.go index cf541e79..3d3b6c08 100644 --- a/eval/runner/l1.go +++ b/eval/runner/l1.go @@ -27,7 +27,10 @@ type L1Config struct { // RunL1 runs every retrieval case, judging hits with keyword groups over // title+content and measuring the agent-visible payload size (the budgeted // mem_search index text plus the structured entries the handler ships, via -// mcp.SearchPayloadTokens — the actual payload the agent pays for). +// mcp.SearchPayloadTokens). NOTE: this is a floor, not an exact count — +// envelope framing (header, trailer, JSON wrapper, relation annotations) is +// unmeasured; see the SearchPayloadTokens doc comment before reading gates +// off avg_tokens_per_query. func RunL1(b SearchBackend, cases []dataset.RetrievalCase, cfg L1Config) (scorecard.Scorecard, error) { if cfg.Limit == 0 { cfg.Limit = 10 @@ -54,11 +57,10 @@ func RunL1(b SearchBackend, cases []dataset.RetrievalCase, cfg L1Config) (scorec } } dr := metrics.DistractorRatio(rank, len(results)) - // Measure what the agent actually sees: the FULL mem_search payload at - // the default budget — index text PLUS the structured entries of the - // shown hits (mcp.SearchPayloadTokens, shared with handleSearch so the - // measurement cannot drift from what ships). Not a JSON dump of the - // full result rows, and not the text alone. + // Measure what the agent sees at the default budget: index text PLUS + // the structured entries of the shown hits (mcp.SearchPayloadTokens; + // per-hit content is single-sourced with handleSearch). Envelope + // framing is NOT counted — treat this as a floor. tokens := float64(mcp.SearchPayloadTokens(results, 0)) ranks = append(ranks, rank) diff --git a/internal/mcp/search_index.go b/internal/mcp/search_index.go index ebd53cf3..3663d908 100644 --- a/internal/mcp/search_index.go +++ b/internal/mcp/search_index.go @@ -154,11 +154,17 @@ func searchResultEntry(r store.SearchResult) map[string]any { return entry } -// SearchPayloadTokens approximates the FULL agent-visible cost of a mem_search +// SearchPayloadTokens approximates the agent-visible cost of a mem_search // response at the given budget: the text index plus the structured entries of // the hits that were actually shown. Budget-omitted hits contribute nothing — -// they ship neither text nor structured metadata — so eval (eval/runner/l1.go) -// measures exactly what handleSearch returns. +// they ship neither text nor structured metadata. +// +// This is a FLOOR, not an exact count. Unmeasured (typically ~60+ tokens per +// query, more on relation-heavy corpora): relation-annotation lines, the +// "Found N memories" header, the "Index only: …" trailer/nudge strings, and +// respondWithProject's JSON wrapper keys/escaping. Per-hit content (index +// line + structured entry) IS single-sourced with handleSearch and cannot +// drift; the envelope framing above is not. func SearchPayloadTokens(results []store.SearchResult, budgetTokens int) int { lines := searchIndexEntries(results, budgetTokens) total := approxTokens(renderSearchIndex(lines, len(results))) From 0e3f867a2c1f819c679583928bb69e3f4f15075c Mon Sep 17 00:00:00 2001 From: zhishu <weichuangxian@hotmail.com> Date: Mon, 27 Jul 2026 15:07:29 +0000 Subject: [PATCH 20/29] feat(retrieval): layered mem_context with 400-token budget (both backends) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FormatContext now renders one layered block on both backends: ## Memory Context ### Pinned (as before: content cut to 300 runes) ### Recent Sessions (max 5, one line: <started_at> <first sentence>) ### Recent Observations (max 3, title + first sentence, 160-rune cap) Full bodies: mem_search / mem_get_observation. The whole block is hard capped at 1600 bytes (~400 tokens): overflow drops the oldest observation lines first, then the oldest session lines. Pinned content is never dropped (capping the pinned block itself is a separate change). Behaviour changes beyond rendering: - The "### Recent User Prompts" section is gone — prompts cost more bytes than they buy inside a 400-token budget, and they have no MemoryLake analogue, so keeping them would break cross-backend layout parity. They remain available via RecentPrompts / SearchPrompts (GET /prompts/recent, GET /prompts/search). FormatContext no longer reads user_prompts at all. - Store heading changed from "## Memory from Previous Sessions" and the MemoryLake heading from "## Memory Context (MemoryLake)" to the shared "## Memory Context" so both backends emit byte-identical structure. firstSentence is copied into internal/store and internal/memorylake with a comment naming internal/mcp.firstSentence as the canonical twin: internal/mcp imports internal/store, so store cannot import it back, and memorylake must not import mcp (import cycle). Same duplication pattern already used for truncate and approxTokens. Layout parity is enforced by the two mirrored unit tests (store.TestFormatContextLayeredBudget and memorylake.TestBackend_FormatContext_LayeredBudget): paritytest's corpus schema (CorpusEntry) has no context dimension — it describes observations to save plus gold queries — so a corpus row cannot express a context case, and `go test -tags parity ./internal/paritytest/` skips here (no live ENGRAM_MEMORYLAKE_API_KEY configured in this sandbox). Updated assertions that encoded the old layout: cmd/engram (heading), internal/server e2e (heading), store's prompts-section assertion (now asserts absence), store's "recent prompts error" subtest (now asserts FormatContext survives a dropped user_prompts table), and memorylake's 300-rune truncation test (retargeted at a pinned fact + a 160-rune summary assertion for the unpinned one). --- DOCS.md | 21 ++- cmd/engram/main_test.go | 2 +- internal/memorylake/backend.go | 175 +++++++++++++++++++---- internal/memorylake/backend_test.go | 98 ++++++++++++- internal/server/server_e2e_test.go | 2 +- internal/store/store.go | 214 ++++++++++++++++++++++------ internal/store/store_test.go | 148 ++++++++++++++++++- 7 files changed, 571 insertions(+), 89 deletions(-) diff --git a/DOCS.md b/DOCS.md index 6bdaa9dd..7a8d8934 100644 --- a/DOCS.md +++ b/DOCS.md @@ -900,7 +900,26 @@ When called in the same MCP process, this also feeds process-local current promp ### mem_context -Get recent memory context from previous sessions — shows sessions, prompts, and observations, with optional scope filtering for observations. +Get recent memory context from previous sessions, with optional scope filtering for observations. + +The block is layered and budgeted (~400 tokens / 1600 bytes hard cap) so it is cheap to call at every session start and after every compaction: + +``` +## Memory Context + +### Pinned +- [decision] **title**: <content, cut to 300 runes> + +### Recent Sessions +- <started_at> <summary first sentence | (active) | (no summary)> (max 5) + +### Recent Observations +- [type] **title**: <content first sentence, cut to 160 runes> (max 3) + +Full bodies: mem_search / mem_get_observation. +``` + +Overflow past the cap drops the oldest observation lines first, then the oldest session lines; pinned content is never dropped. Sections with no rows are omitted entirely, and an empty project returns an empty string. Recent user prompts are **not** part of this block (they cost more than they buy inside the budget) — read them with `GET /prompts/recent` / `GET /prompts/search` instead. The MemoryLake backend renders the identical layout minus `### Recent Sessions` (it has no session tracking). Scope values accepted by the `scope` parameter: `project` (default), `personal`, `global`. When `scope: personal` is passed without an explicit `project` override, the project filter is cleared and personal observations are returned across all projects (cross-project personal scope). diff --git a/cmd/engram/main_test.go b/cmd/engram/main_test.go index fac306fa..57a9009a 100644 --- a/cmd/engram/main_test.go +++ b/cmd/engram/main_test.go @@ -532,7 +532,7 @@ func TestCmdContextAndStats(t *testing.T) { if ctxErr != "" { t.Fatalf("expected no stderr for populated context, got: %q", ctxErr) } - if !strings.Contains(ctxOut, "## Memory from Previous Sessions") || !strings.Contains(ctxOut, "Recent Observations") { + if !strings.Contains(ctxOut, "## Memory Context") || !strings.Contains(ctxOut, "Recent Observations") { t.Fatalf("unexpected populated context output: %q", ctxOut) } diff --git a/internal/memorylake/backend.go b/internal/memorylake/backend.go index fc1bf9e0..656d439a 100644 --- a/internal/memorylake/backend.go +++ b/internal/memorylake/backend.go @@ -8,6 +8,8 @@ import ( "strings" "sync" "time" + "unicode" + "unicode/utf8" "github.com/Gentleman-Programming/engram/internal/store" ) @@ -400,18 +402,19 @@ func (b *MemoryLakeBackend) Timeline(syncID string, before, after int) (*store.T } // maxFormatContextRecent bounds how many non-pinned facts FormatContext -// includes, mirroring the local store's cfg.MaxContextResults ceiling (a +// considers, mirroring the local store's cfg.MaxContextResults ceiling (a // fixed reasonable default here since this backend has no equivalent config -// field). +// field). The layered layout renders at most contextRecentObs of them. const maxFormatContextRecent = 20 // formatContextContentTruncateLen mirrors internal/store's FormatContext, -// which truncates every observation's content to 300 runes (via its -// unexported truncate helper) before rendering pinned/recent lines — see -// internal/store/store.go's FormatContext. internal/store does not export -// that helper, so truncate below replicates it so MemoryLake-backed projects -// produce the same shape of context block as SQLite-backed ones (task-12 -// hardening brief I3). +// which truncates a *pinned* observation's content to 300 runes (via its +// unexported truncate helper, as contextPinnedTruncLen) before rendering the +// pinned lines — see internal/store/store.go's FormatContext. internal/store +// does not export that helper, so truncate below replicates it so +// MemoryLake-backed projects produce the same shape of context block as +// SQLite-backed ones (task-12 hardening brief I3). Recent-observation lines use +// firstSentence on both backends instead. const formatContextContentTruncateLen = 300 // truncate mirrors internal/store's unexported truncate(s, max): a rune-safe @@ -426,11 +429,125 @@ func truncate(s string, max int) string { return string(runes[:max]) + "..." } -// FormatContext renders a human-readable text block of the project's facts, -// optionally filtered by scope, with pinned facts (metadata["pinned"] == true) -// listed ahead of the most recent unpinned ones — the same priority order as -// the local store's FormatContext (pinned section before recent-observations -// section). +// Layered mem_context contract (Phase 2 spec R3b) — a byte-for-byte mirror of +// internal/store's constants of the same names (see internal/store/store.go). +// internal/memorylake does import internal/store, but these are deliberately +// unexported there (presentation detail of the local backend, not part of its +// API), so they are duplicated here the same way truncate is. Keep in sync. +const ( + contextByteCap = 1600 + contextRecentObs = 3 + contextSummaryTruncLen = 160 +) + +// contextFooter closes every context block with the expand-on-demand pointer +// (identical string to internal/store's contextFooter). +const contextFooter = "Full bodies: mem_search / mem_get_observation.\n" + +// firstSentence returns content up to the first sentence terminator, capped +// at contextSummaryTruncLen on a rune boundary. Deterministic — key entities in +// the opening sentence always survive (Phase 2 spec R3 rule). +// +// A '.' only terminates when it is followed by whitespace/end-of-string and is +// not flanked by digits, so decimals survive: +// "PostgreSQL 15.3 is required. More." → "PostgreSQL 15.3 is required." +// The other terminators (!?。!? and \n) always terminate. +// +// Known tradeoff (ACCEPTED): abbreviations whose '.' is followed by a space +// still terminate — "e.g. use flags. Done." → "e.g." — because the guard is +// deliberately context-free (digit + whitespace rules only). We do NOT carry an +// abbreviation dictionary; the summary is a scanning aid, not prose. +// +// Canonical twin: internal/mcp.firstSentence (internal/mcp/search_index.go), +// whose rune cap is spelled searchSummaryMaxRunes (same value, 160); +// internal/store carries the third copy. internal/memorylake must not import +// internal/mcp (import cycle), so this duplication mirrors truncate above. Keep +// the three copies behaviourally identical. +func firstSentence(content string) string { + c := strings.TrimSpace(content) + runes := []rune(c) + for i, r := range runes { + if r == '!' || r == '?' || r == '\n' || r == '。' || r == '!' || r == '?' { + c = string(runes[:i+1]) + break + } + if r != '.' { + continue + } + // Must be followed by whitespace or end-of-string. This alone keeps + // decimals intact ("15.3" — the '3' is not whitespace). + if i+1 < len(runes) && !unicode.IsSpace(runes[i+1]) { + continue + } + // Must not be flanked by digits, looking past the whitespace, so a + // spaced decimal/enumeration ("15. 3") is not a sentence end either. + if i > 0 && unicode.IsDigit(runes[i-1]) && unicode.IsDigit(nextNonSpace(runes[i+1:])) { + continue + } + c = string(runes[:i+1]) + break + } + if utf8.RuneCountInString(c) > contextSummaryTruncLen { + runes := []rune(c) + c = string(runes[:contextSummaryTruncLen]) + "…" + } + return strings.TrimSpace(c) +} + +// nextNonSpace returns the first non-whitespace rune of runes, or 0 when there +// is none (0 is not a digit, so callers treat "nothing follows" as not-a-digit). +func nextNonSpace(runes []rune) rune { + for _, r := range runes { + if !unicode.IsSpace(r) { + return r + } + } + return 0 +} + +// renderLayeredContext assembles the layered block and enforces +// contextByteCap by dropping the oldest observation line (this backend has no +// session lines — see FormatContext). Pinned lines are never dropped. Mirrors +// internal/store's renderLayeredContext. +func renderLayeredContext(pinnedLines, obsLines []string) string { + for { + out := joinLayeredContext(pinnedLines, obsLines) + if len(out) <= contextByteCap { + return out + } + if n := len(obsLines); n > 0 { + obsLines = obsLines[:n-1] + continue + } + return out + } +} + +func joinLayeredContext(pinnedLines, obsLines []string) string { + var b strings.Builder + b.WriteString("## Memory Context\n\n") + writeContextSection(&b, "### Pinned\n", pinnedLines) + writeContextSection(&b, "### Recent Observations\n", obsLines) + b.WriteString(contextFooter) + return b.String() +} + +func writeContextSection(b *strings.Builder, heading string, lines []string) { + if len(lines) == 0 { + return + } + b.WriteString(heading) + for _, line := range lines { + b.WriteString(line) + } + b.WriteString("\n") +} + +// FormatContext renders the layered context block of the project's facts, +// optionally filtered by scope: pinned facts (metadata["pinned"] == true) +// first, then up to contextRecentObs first-sentence summaries of the most +// recent unpinned facts, then the expand-on-demand footer — the same layout +// (and the same contextByteCap budget) as the local store's FormatContext. // // project is accepted for signature compatibility but ignored: a backend // instance is already bound to a single MemoryLake project (see @@ -477,28 +594,24 @@ func (b *MemoryLakeBackend) FormatContext(project, scope string) (string, error) return "", nil } - var out strings.Builder - out.WriteString("## Memory Context (MemoryLake)\n\n") - - if len(pinned) > 0 { - out.WriteString("### Pinned\n") - for _, f := range pinned { - o := ObservationFromFact(f) - fmt.Fprintf(&out, "- [%s] **%s**: %s\n", o.Type, o.Title, truncate(o.Content, formatContextContentTruncateLen)) - } - out.WriteString("\n") + pinnedLines := make([]string, 0, len(pinned)) + for _, f := range pinned { + o := ObservationFromFact(f) + pinnedLines = append(pinnedLines, fmt.Sprintf("- [%s] **%s**: %s\n", + o.Type, o.Title, truncate(o.Content, formatContextContentTruncateLen))) } - if len(recent) > 0 { - out.WriteString("### Recent Observations\n") - for _, f := range recent { - o := ObservationFromFact(f) - fmt.Fprintf(&out, "- [%s] **%s**: %s\n", o.Type, o.Title, truncate(o.Content, formatContextContentTruncateLen)) - } - out.WriteString("\n") + if len(recent) > contextRecentObs { + recent = recent[:contextRecentObs] + } + obsLines := make([]string, 0, len(recent)) + for _, f := range recent { + o := ObservationFromFact(f) + obsLines = append(obsLines, fmt.Sprintf("- [%s] **%s**: %s\n", + o.Type, o.Title, firstSentence(o.Content))) } - return out.String(), nil + return renderLayeredContext(pinnedLines, obsLines), nil } // isPinned reports whether a fact's metadata carries the pinned flag set by diff --git a/internal/memorylake/backend_test.go b/internal/memorylake/backend_test.go index da4206c9..d88ced30 100644 --- a/internal/memorylake/backend_test.go +++ b/internal/memorylake/backend_test.go @@ -2,6 +2,7 @@ package memorylake import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -720,16 +721,25 @@ func TestBackend_FormatContext_ScopeFilter(t *testing.T) { } } -// TestBackend_FormatContext_TruncatesContentTo300 is the I3 regression: -// internal/store's FormatContext truncates every observation's content to -// 300 runes before rendering it (store.go's truncate(obs.Content, 300)); the -// MemoryLake backend must match that so a project's rendered context block -// has the same shape regardless of which backend produced it. -func TestBackend_FormatContext_TruncatesContentTo300(t *testing.T) { +// TestBackend_FormatContext_TruncatesPinnedContentTo300 is the I3 regression: +// internal/store's FormatContext truncates a pinned observation's content to +// 300 runes before rendering it (store.go's truncate(obs.Content, +// contextPinnedTruncLen)); the MemoryLake backend must match that so a +// project's rendered context block has the same shape regardless of which +// backend produced it. +// +// Since the layered rewrite (Phase 2 R3b) this 300-rune cut applies to the +// pinned block only — recent-observation lines are cut to their first sentence +// (contextSummaryTruncLen) on both backends, which +// TestBackend_FormatContext_LayeredBudget covers. The fact below is therefore +// pinned; the unpinned tail of this test asserts the 160-rune summary cap. +func TestBackend_FormatContext_TruncatesPinnedContentTo300(t *testing.T) { long := strings.Repeat("a", 400) items := []map[string]any{ {"id": "fact-1", "fact": long, "created_at": "2026-07-20T00:00:00Z", - "metadata": map[string]any{metaTitle: "T", metaType: "note", metaScope: "global"}}, + "metadata": map[string]any{metaTitle: "T", metaType: "note", metaScope: "global", "pinned": true}}, + {"id": "fact-2", "fact": strings.Repeat("b", 400), "created_at": "2026-07-20T01:00:00Z", + "metadata": map[string]any{metaTitle: "U", metaType: "note", metaScope: "global"}}, } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/api/v3/workspaces/ws-1/projects/proj-1/memories/facts") { @@ -752,6 +762,11 @@ func TestBackend_FormatContext_TruncatesContentTo300(t *testing.T) { if !strings.Contains(out, want) { t.Fatalf("FormatContext missing expected 300-rune truncation with ellipsis:\n%s", out) } + wantSummary := strings.Repeat("b", contextSummaryTruncLen) + "…" + if !strings.Contains(out, wantSummary) { + t.Fatalf("FormatContext missing expected %d-rune summary cap on the recent line:\n%s", + contextSummaryTruncLen, out) + } } // ─── listAllFacts pagination is bounded (I2) ──────────────────────────────── @@ -805,3 +820,72 @@ func TestListAllFacts_TerminatesAgainstInfiniteContinuationToken(t *testing.T) { t.Fatalf("TotalObservations=%d, want %d (one fact per page, capped)", stats.TotalObservations, maxListAllFactsPages) } } + +// ─── Layered mem_context budget (Phase 2 R3b) ──────────────────────────────── + +// TestBackend_FormatContext_LayeredBudget mirrors internal/store's +// TestFormatContextLayeredBudget: the MemoryLake rendering must produce the +// same layered block (single "## Memory Context" heading, at most +// contextRecentObs observation summaries cut to their first sentence, the +// expand-on-demand footer, whole block under contextByteCap) so a project's +// context looks the same on either backend. This backend has no sessions, so +// the "### Recent Sessions" section is simply absent. +func TestBackend_FormatContext_LayeredBudget(t *testing.T) { + items := []map[string]any{ + {"id": "fact-pin", "fact": "PINNED-MARKER pinned body. second pinned sentence.", + "created_at": "2026-07-20T00:00:00Z", + "metadata": map[string]any{metaTitle: "pinned-title", metaType: "decision", metaScope: "global", "pinned": true}}, + } + for i := 0; i < 10; i++ { + items = append(items, map[string]any{ + "id": fmt.Sprintf("fact-%d", i), + "fact": fmt.Sprintf("title-%d first sentence. tail sentence that must not appear.", i), + "created_at": fmt.Sprintf("2026-07-21T%02d:00:00Z", i), + "metadata": map[string]any{metaTitle: fmt.Sprintf("title-%d", i), metaType: "note", metaScope: "global"}, + }) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/api/v3/workspaces/ws-1/projects/proj-1/memories/facts") { + json.NewEncoder(w).Encode(map[string]any{"success": true, "data": map[string]any{"items": items}}) + return + } + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + })) + defer srv.Close() + b := newTestBackend(t, srv.URL) + + out, err := b.FormatContext("proj", "global") + if err != nil { + t.Fatalf("FormatContext: %v", err) + } + if len(out) > contextByteCap { + t.Fatalf("context %d bytes, cap %d:\n%s", len(out), contextByteCap, out) + } + if !strings.HasPrefix(out, "## Memory Context\n") { + t.Fatalf("missing layered heading:\n%s", out) + } + if !strings.Contains(out, "PINNED-MARKER") { + t.Fatalf("pinned content must never be dropped:\n%s", out) + } + if !strings.Contains(out, "Full bodies: mem_search / mem_get_observation.") { + t.Fatalf("missing expand-on-demand footer:\n%s", out) + } + if got := strings.Count(out, "title-"); got != contextRecentObs*2 { + // Each rendered observation prints its title once and its first + // sentence (which starts with the same "title-N" token) once. + t.Fatalf("rendered %d title- tokens, want %d (%d observations):\n%s", + got, contextRecentObs*2, contextRecentObs, out) + } + if strings.Contains(out, "tail sentence that must not appear") { + t.Fatalf("observation body not cut to first sentence:\n%s", out) + } + if strings.Contains(out, "title-0 first sentence") { + t.Fatalf("oldest observation must be dropped:\n%s", out) + } + if !strings.Contains(out, "title-9") { + t.Fatalf("newest observation must be rendered:\n%s", out) + } + if strings.Contains(out, "### Recent Sessions") { + t.Fatalf("MemoryLake backend has no sessions to render:\n%s", out) + } +} diff --git a/internal/server/server_e2e_test.go b/internal/server/server_e2e_test.go index 72b348b1..f5326083 100644 --- a/internal/server/server_e2e_test.go +++ b/internal/server/server_e2e_test.go @@ -451,7 +451,7 @@ func TestCoreReadHandlersAndHelpersE2E(t *testing.T) { t.Fatalf("expected 200 context, got %d", contextResp.StatusCode) } contextData := decodeJSON[map[string]string](t, contextResp) - if !strings.Contains(contextData["context"], "Memory from Previous Sessions") { + if !strings.Contains(contextData["context"], "## Memory Context") { t.Fatalf("expected formatted context output") } diff --git a/internal/store/store.go b/internal/store/store.go index 9c6537b9..e830d81e 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -20,6 +20,8 @@ import ( "strconv" "strings" "time" + "unicode" + "unicode/utf8" "github.com/Gentleman-Programming/engram/internal/timeutil" sqlite "modernc.org/sqlite" @@ -3295,8 +3297,147 @@ SELECT 1 FROM ( // ─── Context Formatting ───────────────────────────────────────────────────── +// Layered mem_context contract (Phase 2 spec R3b). mem_context is called at +// every session start and after every compaction, so the whole block is hard +// capped at contextByteCap bytes (~400 tokens at the 4-bytes-per-token +// approximation). Overflow is resolved by dropping the oldest observation +// lines first, then the oldest session lines; pinned content is never dropped +// (capping the pinned block itself is a separate concern). +// +// The identical contract is implemented by internal/memorylake's FormatContext +// so a project's context block looks the same on either backend — keep the two +// in sync. +const ( + contextByteCap = 1600 + contextRecentObs = 3 + contextRecentSessions = 5 + contextSummaryTruncLen = 160 +) + +// contextPinnedTruncLen is the pre-existing pinned/observation content cut used +// by FormatContext before the layered rewrite. The layered layout keeps it for +// the pinned block only (recent observations now use firstSentence). +const contextPinnedTruncLen = 300 + +// contextFooter closes every context block with the expand-on-demand pointer: +// the block carries first sentences only, full bodies are one call away. +const contextFooter = "Full bodies: mem_search / mem_get_observation.\n" + +// firstSentence returns content up to the first sentence terminator, capped +// at contextSummaryTruncLen on a rune boundary. Deterministic — key entities in +// the opening sentence always survive (Phase 2 spec R3 rule). +// +// A '.' only terminates when it is followed by whitespace/end-of-string and is +// not flanked by digits, so decimals survive: +// "PostgreSQL 15.3 is required. More." → "PostgreSQL 15.3 is required." +// The other terminators (!?。!? and \n) always terminate. +// +// Known tradeoff (ACCEPTED): abbreviations whose '.' is followed by a space +// still terminate — "e.g. use flags. Done." → "e.g." — because the guard is +// deliberately context-free (digit + whitespace rules only). We do NOT carry an +// abbreviation dictionary; the summary is a scanning aid, not prose. +// +// Canonical twin: internal/mcp.firstSentence (internal/mcp/search_index.go), +// whose rune cap is spelled searchSummaryMaxRunes (same value, 160). +// internal/mcp imports internal/store, so internal/store cannot import it back; +// this duplication mirrors how mcp duplicates eval/metrics.ApproxTokens. Keep +// the three copies (mcp, store, memorylake) behaviourally identical. +func firstSentence(content string) string { + c := strings.TrimSpace(content) + runes := []rune(c) + for i, r := range runes { + if r == '!' || r == '?' || r == '\n' || r == '。' || r == '!' || r == '?' { + c = string(runes[:i+1]) + break + } + if r != '.' { + continue + } + // Must be followed by whitespace or end-of-string. This alone keeps + // decimals intact ("15.3" — the '3' is not whitespace). + if i+1 < len(runes) && !unicode.IsSpace(runes[i+1]) { + continue + } + // Must not be flanked by digits, looking past the whitespace, so a + // spaced decimal/enumeration ("15. 3") is not a sentence end either. + if i > 0 && unicode.IsDigit(runes[i-1]) && unicode.IsDigit(nextNonSpace(runes[i+1:])) { + continue + } + c = string(runes[:i+1]) + break + } + if utf8.RuneCountInString(c) > contextSummaryTruncLen { + runes := []rune(c) + c = string(runes[:contextSummaryTruncLen]) + "…" + } + return strings.TrimSpace(c) +} + +// nextNonSpace returns the first non-whitespace rune of runes, or 0 when there +// is none (0 is not a digit, so callers treat "nothing follows" as not-a-digit). +func nextNonSpace(runes []rune) rune { + for _, r := range runes { + if !unicode.IsSpace(r) { + return r + } + } + return 0 +} + +// renderLayeredContext assembles the layered block and enforces +// contextByteCap by dropping the oldest observation line, then the oldest +// session line, until the rendered block fits. Every slice is newest-first, so +// "oldest" is the last element. Pinned lines are never dropped: if a block is +// still over cap once observations and sessions are gone, it is returned as-is. +func renderLayeredContext(pinnedLines, sessionLines, obsLines []string) string { + for { + out := joinLayeredContext(pinnedLines, sessionLines, obsLines) + if len(out) <= contextByteCap { + return out + } + if n := len(obsLines); n > 0 { + obsLines = obsLines[:n-1] + continue + } + if n := len(sessionLines); n > 0 { + sessionLines = sessionLines[:n-1] + continue + } + return out + } +} + +func joinLayeredContext(pinnedLines, sessionLines, obsLines []string) string { + var b strings.Builder + b.WriteString("## Memory Context\n\n") + writeContextSection(&b, "### Pinned\n", pinnedLines) + writeContextSection(&b, "### Recent Sessions\n", sessionLines) + writeContextSection(&b, "### Recent Observations\n", obsLines) + b.WriteString(contextFooter) + return b.String() +} + +func writeContextSection(b *strings.Builder, heading string, lines []string) { + if len(lines) == 0 { + return + } + b.WriteString(heading) + for _, line := range lines { + b.WriteString(line) + } + b.WriteString("\n") +} + +// FormatContext renders the layered context block for a project: pinned +// observations, then up to contextRecentSessions one-line session entries, then +// up to contextRecentObs observation summaries, then the expand-on-demand +// footer — all within contextByteCap bytes. +// +// Recent user prompts are deliberately NOT part of the layered block: they cost +// more bytes than they buy inside a 400-token budget. They remain available via +// RecentPrompts / SearchPrompts. func (s *Store) FormatContext(project, scope string) (string, error) { - sessions, err := s.RecentSessions(project, 5) + sessions, err := s.RecentSessions(project, contextRecentSessions) if err != nil { return "", err } @@ -3311,58 +3452,45 @@ func (s *Store) FormatContext(project, scope string) (string, error) { return "", err } - prompts, err := s.RecentPrompts(project, 10) - if err != nil { - return "", err - } - - if len(sessions) == 0 && len(pinned) == 0 && len(observations) == 0 && len(prompts) == 0 { + if len(sessions) == 0 && len(pinned) == 0 && len(observations) == 0 { return "", nil } - var b strings.Builder - b.WriteString("## Memory from Previous Sessions\n\n") - - if len(sessions) > 0 { - b.WriteString("### Recent Sessions\n") - for _, sess := range sessions { - summary := "" - if sess.Summary != nil { - summary = fmt.Sprintf(": %s", truncate(*sess.Summary, 200)) - } - fmt.Fprintf(&b, "- **%s** (%s)%s [%d observations]\n", - sess.Project, timeutil.FormatLocal(sess.StartedAt), summary, sess.ObservationCount) - } - b.WriteString("\n") + pinnedLines := make([]string, 0, len(pinned)) + for _, obs := range pinned { + pinnedLines = append(pinnedLines, fmt.Sprintf("- [%s] **%s**: %s\n", + obs.Type, obs.Title, truncate(obs.Content, contextPinnedTruncLen))) } - if len(prompts) > 0 { - b.WriteString("### Recent User Prompts\n") - for _, p := range prompts { - fmt.Fprintf(&b, "- %s: %s\n", timeutil.FormatLocal(p.CreatedAt), truncate(p.Content, 200)) - } - b.WriteString("\n") + sessionLines := make([]string, 0, len(sessions)) + for _, sess := range sessions { + sessionLines = append(sessionLines, fmt.Sprintf("- %s %s\n", + timeutil.FormatLocal(sess.StartedAt), sessionSummaryLine(sess))) } - if len(pinned) > 0 { - b.WriteString("### Pinned\n") - for _, obs := range pinned { - fmt.Fprintf(&b, "- [%s] **%s**: %s\n", - obs.Type, obs.Title, truncate(obs.Content, 300)) - } - b.WriteString("\n") + if len(observations) > contextRecentObs { + observations = observations[:contextRecentObs] } - - if len(observations) > 0 { - b.WriteString("### Recent Observations\n") - for _, obs := range observations { - fmt.Fprintf(&b, "- [%s] **%s**: %s\n", - obs.Type, obs.Title, truncate(obs.Content, 300)) - } - b.WriteString("\n") + obsLines := make([]string, 0, len(observations)) + for _, obs := range observations { + obsLines = append(obsLines, fmt.Sprintf("- [%s] **%s**: %s\n", + obs.Type, obs.Title, firstSentence(obs.Content))) } - return b.String(), nil + return renderLayeredContext(pinnedLines, sessionLines, obsLines), nil +} + +// sessionSummaryLine renders the one-line session tail: the summary's first +// sentence when there is one, "(active)" for a still-open session, and +// "(no summary)" for a session that ended without one. +func sessionSummaryLine(sess SessionSummary) string { + if sess.Summary != nil && strings.TrimSpace(*sess.Summary) != "" { + return firstSentence(*sess.Summary) + } + if sess.EndedAt == nil { + return "(active)" + } + return "(no summary)" } // ─── Export / Import ───────────────────────────────────────────────────────── diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 5ed55ca6..eb76e1c2 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -3274,8 +3274,13 @@ func TestStoreAdditionalQueryAndMutationBranches(t *testing.T) { if err != nil { t.Fatalf("format context: %v", err) } - if !strings.Contains(ctx, "Recent User Prompts") { - t.Fatalf("expected prompts section in context output") + // Prompts are no longer part of the layered context block (Phase 2 R3b) — + // they are retrieved via RecentPrompts/SearchPrompts instead. + if strings.Contains(ctx, "Recent User Prompts") { + t.Fatalf("prompts section must not appear in the layered context output:\n%s", ctx) + } + if !strings.Contains(ctx, "## Memory Context") { + t.Fatalf("expected layered context heading:\n%s", ctx) } } @@ -4049,7 +4054,9 @@ func TestHookFallbacksAndAdditionalBranches(t *testing.T) { } }) - t.Run("recent prompts error", func(t *testing.T) { + // The layered layout (Phase 2 R3b) no longer reads user prompts, so a + // broken user_prompts table must NOT fail FormatContext any more. + t.Run("prompt table is not a context dependency", func(t *testing.T) { s := newTestStore(t) if err := s.CreateSession("s-ctx2", "engram", "/tmp/engram"); err != nil { t.Fatalf("create session: %v", err) @@ -4057,8 +4064,8 @@ func TestHookFallbacksAndAdditionalBranches(t *testing.T) { if _, err := s.db.Exec("DROP TABLE user_prompts"); err != nil { t.Fatalf("drop prompts: %v", err) } - if _, err := s.FormatContext("", ""); err == nil { - t.Fatalf("expected format context to fail from recent prompts") + if _, err := s.FormatContext("", ""); err != nil { + t.Fatalf("format context must not depend on user_prompts: %v", err) } }) }) @@ -8830,3 +8837,134 @@ func TestSanitizeFTS(t *testing.T) { }) } } + +// ─── Layered mem_context budget (Phase 2 R3b) ──────────────────────────────── + +// TestFormatContextLayeredBudget pins the layered context contract: a single +// "## Memory Context" heading, one-line sessions whose summary is cut to its +// first sentence, at most contextRecentObs observation summaries, an +// expand-on-demand footer, and a hard contextByteCap on the whole block. +func TestFormatContextLayeredBudget(t *testing.T) { + s := newTestStore(t) + + if err := s.CreateSession("s-layered", "proj-x", "/tmp/proj-x"); err != nil { + t.Fatalf("create session: %v", err) + } + if err := s.EndSession("s-layered", "long summary. second sentence that must not appear."); err != nil { + t.Fatalf("end session: %v", err) + } + + for i := 0; i < 10; i++ { + if _, err := s.AddObservation(AddObservationParams{ + SessionID: "s-layered", + Type: "decision", + Title: fmt.Sprintf("title-%d", i), + Content: strings.Repeat("body sentence ", 40), + Project: "proj-x", + Scope: "project", + }); err != nil { + t.Fatalf("add observation %d: %v", i, err) + } + } + + out, err := s.FormatContext("proj-x", "") + if err != nil { + t.Fatalf("format context: %v", err) + } + if len(out) > contextByteCap { + t.Fatalf("context %d bytes, cap %d:\n%s", len(out), contextByteCap, out) + } + if !strings.HasPrefix(out, "## Memory Context\n") { + t.Fatalf("missing layered heading:\n%s", out) + } + if strings.Contains(out, "second sentence that must not appear") { + t.Fatalf("session summary not truncated to first sentence:\n%s", out) + } + if !strings.Contains(out, "long summary.") { + t.Fatalf("session summary first sentence missing:\n%s", out) + } + if !strings.Contains(out, "mem_get_observation") { + t.Fatalf("missing expand-on-demand footer:\n%s", out) + } + if got := strings.Count(out, "title-"); got != contextRecentObs { + t.Fatalf("rendered %d observations, want %d:\n%s", got, contextRecentObs, out) + } + // Newest-first: the last three observations survive, the oldest do not. + for _, want := range []string{"title-9", "title-8", "title-7"} { + if !strings.Contains(out, want) { + t.Fatalf("expected newest observation %s in context:\n%s", want, out) + } + } + if strings.Contains(out, "title-0") { + t.Fatalf("oldest observation must be dropped:\n%s", out) + } + if strings.Contains(out, "Recent User Prompts") { + t.Fatalf("prompts section is not part of the layered layout:\n%s", out) + } +} + +// TestFormatContextByteCapDropsObservationsThenSessionsNeverPinned proves the +// drop order when the block exceeds contextByteCap: oldest observations go +// first, then oldest sessions, and the pinned block always survives. +func TestFormatContextByteCapDropsObservationsThenSessionsNeverPinned(t *testing.T) { + s := newTestStore(t) + + pinnedBody := "PINNED-MARKER decision body that must never be dropped." + for i := 0; i < 6; i++ { + id := fmt.Sprintf("s-cap-%d", i) + if err := s.CreateSession(id, "proj-cap", "/tmp/proj-cap"); err != nil { + t.Fatalf("create session %s: %v", id, err) + } + if err := s.EndSession(id, fmt.Sprintf("session %d summary %s.", i, strings.Repeat("x", 150))); err != nil { + t.Fatalf("end session %s: %v", id, err) + } + } + + pinnedID, err := s.AddObservation(AddObservationParams{ + SessionID: "s-cap-0", + Type: "decision", + Title: "pinned-title", + Content: pinnedBody, + Project: "proj-cap", + Scope: "project", + }) + if err != nil { + t.Fatalf("add pinned observation: %v", err) + } + if err := s.PinObservation(pinnedID); err != nil { + t.Fatalf("pin observation: %v", err) + } + + for i := 0; i < 5; i++ { + if _, err := s.AddObservation(AddObservationParams{ + SessionID: "s-cap-0", + Type: "note", + Title: fmt.Sprintf("obs-%d", i), + Content: strings.Repeat("filler sentence words ", 20) + ".", + Project: "proj-cap", + Scope: "project", + }); err != nil { + t.Fatalf("add observation %d: %v", i, err) + } + } + + out, err := s.FormatContext("proj-cap", "") + if err != nil { + t.Fatalf("format context: %v", err) + } + if len(out) > contextByteCap { + t.Fatalf("context %d bytes, cap %d:\n%s", len(out), contextByteCap, out) + } + if !strings.Contains(out, "PINNED-MARKER") { + t.Fatalf("pinned content must never be dropped by the byte cap:\n%s", out) + } + if strings.Count(out, "obs-") > contextRecentObs { + t.Fatalf("more than %d observations rendered:\n%s", contextRecentObs, out) + } + if n := strings.Count(out, "session "); n > contextRecentSessions { + t.Fatalf("rendered %d sessions, cap %d:\n%s", n, contextRecentSessions, out) + } + if !strings.Contains(out, "Full bodies: mem_search / mem_get_observation.") { + t.Fatalf("footer must survive the byte cap:\n%s", out) + } +} From b4db321f97334d6e2d2101dd5b6652dcc618e52e Mon Sep 17 00:00:00 2001 From: zhishu <weichuangxian@hotmail.com> Date: Mon, 27 Jul 2026 15:30:01 +0000 Subject: [PATCH 21/29] fix(retrieval): pin context drop-order/cap contracts, document tail and prompts posture Wave1 Task 9 review fixes. Tests: the store byte-cap test rendered 1473 bytes (under the 1600 cap), so the cap loop never ran and its drop-order assertions were vacuous. Both backends now use fixtures that overflow before dropping (store 1693, memorylake 1654), guard that fact explicitly via a reconstructed pre-cap render, and assert survivors by name: sessions all survive, newest observations survive, the oldest rendered one is dropped, pinned and footer never dropped. Section order (Pinned < Recent Sessions < Recent Observations) asserted on both sides. Validated against three mutations (sessions-before-observations, newest-first drop, cap loop deleted) -- each fails the store test. Code: sessionSummaryLine names the project in cross-project renders only (blank project filter, i.e. scope=personal), memorylake's formatContextContentTruncateLen renamed to contextPinnedTruncLen for grep parity with internal/store, and handleContext notes that contextByteCap bounds the rendered block only -- the stats/nudge tail is uncapped and outside the L2 measurement. Docs: DOCS.md no longer implies stdio agents can read prompts back; states the Wave-1 posture (write-only over MCP, readable via engram serve HTTP and the cloud dashboard) and documents the cross-project session line format. --- DOCS.md | 6 +- internal/mcp/mcp.go | 4 + internal/memorylake/backend.go | 23 +++-- internal/memorylake/backend_test.go | 115 +++++++++++++++++++++ internal/store/store.go | 36 +++++-- internal/store/store_test.go | 151 ++++++++++++++++++++++++++-- 6 files changed, 306 insertions(+), 29 deletions(-) diff --git a/DOCS.md b/DOCS.md index 7a8d8934..307021eb 100644 --- a/DOCS.md +++ b/DOCS.md @@ -898,6 +898,8 @@ Delete an observation by ID. Uses soft-delete by default (`deleted_at`); optiona Save user prompts — records what the user asked so future sessions have context about user goals. When called in the same MCP process, this also feeds process-local current prompt context used by later `mem_save` calls with `capture_prompt=true`. The same MCP process lifecycle must receive the prompt context before the later save; prompt capture is best-effort and `mem_save` still succeeds when no context is available. +Prompts are **write-only over MCP**: `mem_save_prompt` is the only prompt tool the MCP server registers, so stdio agents (Claude Code, Codex, Gemini, Cursor, Windsurf) can save prompts but cannot read them back. Reading is via `engram serve` HTTP (`GET /prompts/recent`, `GET /prompts/search`) — the OpenCode plugin and Pi extension already run it — or the cloud dashboard's prompt views. This is the intended Wave-1 posture, not a gap. + ### mem_context Get recent memory context from previous sessions, with optional scope filtering for observations. @@ -919,7 +921,9 @@ The block is layered and budgeted (~400 tokens / 1600 bytes hard cap) so it is c Full bodies: mem_search / mem_get_observation. ``` -Overflow past the cap drops the oldest observation lines first, then the oldest session lines; pinned content is never dropped. Sections with no rows are omitted entirely, and an empty project returns an empty string. Recent user prompts are **not** part of this block (they cost more than they buy inside the budget) — read them with `GET /prompts/recent` / `GET /prompts/search` instead. The MemoryLake backend renders the identical layout minus `### Recent Sessions` (it has no session tracking). +Overflow past the cap drops the oldest observation lines first, then the oldest session lines; pinned content is never dropped. Sections with no rows are omitted entirely, and an empty project returns an empty string. In a cross-project render (blank project filter — e.g. `scope: personal` with no `project` override) each session line is prefixed with its project: `- <started_at> [<project>] <summary>`. The MemoryLake backend renders the identical layout minus `### Recent Sessions` (it has no session tracking). + +Recent user prompts are **not** part of this block (they cost more than they buy inside the budget). The intended Wave-1 posture is that **prompts are write-only for stdio agents**: Claude Code, Codex, Gemini, Cursor, and Windsurf reach Engram over stdio MCP, which exposes `mem_save_prompt` but no prompt-read tool. Saved prompts are readable only through the `engram serve` HTTP API (`GET /prompts/recent`, `GET /prompts/search`) — used by the OpenCode plugin and the Pi extension, which already run `engram serve` — and through the cloud dashboard. Prompts still feed `mem_save --capture_prompt` within the same MCP process, so stdio agents get their value indirectly rather than by reading them back. Scope values accepted by the `scope` parameter: `project` (default), `personal`, `global`. When `scope: personal` is passed without an explicit `project` override, the project filter is cleared and personal observations are returned across all projects (cross-project personal scope). diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index e6fcd0a2..cfa9d598 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -1736,6 +1736,10 @@ func handleContext(sel BackendSelector, cfg MCPConfig, activity *SessionActivity projects = "none" } + // contextByteCap (internal/store) bounds contextResult — the rendered + // block — only; this stats/nudge tail is uncapped and is NOT part of the + // L2 context measurement (eval/tokenmeter.ContextTokens measures the + // block, not this envelope). result := fmt.Sprintf("%s\n---\nMemory stats: %d sessions, %d observations across projects: %s", contextResult, stats.TotalSessions, stats.TotalObservations, projects) diff --git a/internal/memorylake/backend.go b/internal/memorylake/backend.go index 656d439a..81913b98 100644 --- a/internal/memorylake/backend.go +++ b/internal/memorylake/backend.go @@ -407,15 +407,18 @@ func (b *MemoryLakeBackend) Timeline(syncID string, before, after int) (*store.T // field). The layered layout renders at most contextRecentObs of them. const maxFormatContextRecent = 20 -// formatContextContentTruncateLen mirrors internal/store's FormatContext, -// which truncates a *pinned* observation's content to 300 runes (via its -// unexported truncate helper, as contextPinnedTruncLen) before rendering the -// pinned lines — see internal/store/store.go's FormatContext. internal/store -// does not export that helper, so truncate below replicates it so -// MemoryLake-backed projects produce the same shape of context block as -// SQLite-backed ones (task-12 hardening brief I3). Recent-observation lines use -// firstSentence on both backends instead. -const formatContextContentTruncateLen = 300 +// contextPinnedTruncLen mirrors internal/store's FormatContext, which +// truncates a *pinned* observation's content to 300 runes (via its unexported +// truncate helper, spelled with this same name) before rendering the pinned +// lines — see internal/store/store.go's FormatContext. internal/store does not +// export that helper, so truncate below replicates it so MemoryLake-backed +// projects produce the same shape of context block as SQLite-backed ones +// (task-12 hardening brief I3). Recent-observation lines use firstSentence on +// both backends instead. +// +// The name is deliberately identical to internal/store's constant so a grep for +// contextPinnedTruncLen surfaces both copies of the rule at once. +const contextPinnedTruncLen = 300 // truncate mirrors internal/store's unexported truncate(s, max): a rune-safe // cut to at most max runes, with a literal "..." appended when s was longer. @@ -598,7 +601,7 @@ func (b *MemoryLakeBackend) FormatContext(project, scope string) (string, error) for _, f := range pinned { o := ObservationFromFact(f) pinnedLines = append(pinnedLines, fmt.Sprintf("- [%s] **%s**: %s\n", - o.Type, o.Title, truncate(o.Content, formatContextContentTruncateLen))) + o.Type, o.Title, truncate(o.Content, contextPinnedTruncLen))) } if len(recent) > contextRecentObs { diff --git a/internal/memorylake/backend_test.go b/internal/memorylake/backend_test.go index d88ced30..46ed0b88 100644 --- a/internal/memorylake/backend_test.go +++ b/internal/memorylake/backend_test.go @@ -885,6 +885,121 @@ func TestBackend_FormatContext_LayeredBudget(t *testing.T) { if !strings.Contains(out, "title-9") { t.Fatalf("newest observation must be rendered:\n%s", out) } + assertBackendContextSectionOrder(t, out) +} + +// TestBackend_FormatContext_ByteCapDropsOldestObservationNeverPinned is the +// MemoryLake twin of internal/store's +// TestFormatContextByteCapDropsObservationsThenSessionsNeverPinned. The fixture +// below (three 300-rune pinned facts plus long recent facts) renders past +// contextByteCap BEFORE any dropping — see the pre-cap guard — so the cap loop +// in renderLayeredContext genuinely runs. Survivors are asserted by name: the +// oldest of the contextRecentObs rendered facts is dropped first, the newest +// survive, and every pinned line survives. This backend has no session lines, +// so the store's "observations before sessions" stage has no analogue here. +func TestBackend_FormatContext_ByteCapDropsOldestObservationNeverPinned(t *testing.T) { + pinnedBody := "PINNED-MARKER " + strings.Repeat("p", 400) + var items []map[string]any + for i := 0; i < 3; i++ { + items = append(items, map[string]any{ + "id": fmt.Sprintf("fact-pin-%d", i), + "fact": pinnedBody, + "created_at": fmt.Sprintf("2026-07-20T%02d:00:00Z", i), + "metadata": map[string]any{ + metaTitle: fmt.Sprintf("pinned-%d", i), metaType: "decision", + metaScope: "global", "pinned": true, + }, + }) + } + recentBody := func(i int) string { + return fmt.Sprintf("title-%d %s.", i, strings.Repeat("filler words ", 30)) + } + for i := 0; i < 10; i++ { + items = append(items, map[string]any{ + "id": fmt.Sprintf("fact-%d", i), + "fact": recentBody(i), + "created_at": fmt.Sprintf("2026-07-21T%02d:00:00Z", i), + "metadata": map[string]any{metaTitle: fmt.Sprintf("title-%d", i), metaType: "note", metaScope: "global"}, + }) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/api/v3/workspaces/ws-1/projects/proj-1/memories/facts") { + json.NewEncoder(w).Encode(map[string]any{"success": true, "data": map[string]any{"items": items}}) + return + } + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + })) + defer srv.Close() + b := newTestBackend(t, srv.URL) + + out, err := b.FormatContext("proj", "global") + if err != nil { + t.Fatalf("FormatContext: %v", err) + } + + // The fixture must overflow the cap BEFORE dropping, otherwise the drop + // assertions below would be vacuously satisfied by a loop that never runs. + var pinnedLines []string + for i := 0; i < 3; i++ { + pinnedLines = append(pinnedLines, fmt.Sprintf("- [decision] **pinned-%d**: %s\n", + i, truncate(pinnedBody, contextPinnedTruncLen))) + } + var obsLines []string + for i := 9; i > 9-contextRecentObs; i-- { + obsLines = append(obsLines, fmt.Sprintf("- [note] **title-%d**: %s\n", i, firstSentence(recentBody(i)))) + } + if precap := len(joinLayeredContext(pinnedLines, obsLines)); precap <= contextByteCap { + t.Fatalf("fixture renders %d bytes before dropping, already under the %d-byte cap: "+ + "the cap loop is not exercised, enlarge the fixture", precap, contextByteCap) + } + + if len(out) > contextByteCap { + t.Fatalf("context %d bytes, cap %d:\n%s", len(out), contextByteCap, out) + } + // Pinned lines are never dropped: all three survive, each truncated to + // contextPinnedTruncLen runes. + if got := strings.Count(out, "PINNED-MARKER"); got != 3 { + t.Fatalf("all 3 pinned lines must survive the byte cap, found %d:\n%s", got, out) + } + for i := 0; i < 3; i++ { + if !strings.Contains(out, fmt.Sprintf("**pinned-%d**", i)) { + t.Fatalf("pinned line %d must survive the byte cap:\n%s", i, out) + } + } + if !strings.Contains(out, "Full bodies: mem_search / mem_get_observation.") { + t.Fatalf("footer must survive the byte cap:\n%s", out) + } + // Drop is oldest-first: the two newest recent facts survive... + for _, want := range []string{"**title-9**", "**title-8**"} { + if !strings.Contains(out, want) { + t.Fatalf("newest observation %s must survive the byte cap (drop is oldest-first):\n%s", want, out) + } + } + // ...and the oldest of the contextRecentObs rendered facts is dropped. + if strings.Contains(out, "**title-7**") { + t.Fatalf("oldest rendered observation title-7 must be dropped by the byte cap:\n%s", out) + } + if got := strings.Count(out, "**title-"); got != contextRecentObs-1 { + t.Fatalf("rendered %d observation lines, want %d after the cap drop:\n%s", + got, contextRecentObs-1, out) + } + assertBackendContextSectionOrder(t, out) +} + +// assertBackendContextSectionOrder pins the layered section order of a +// MemoryLake context block: Pinned before Recent Observations, and no +// "### Recent Sessions" section at all (this backend has no session tracking). +// Mirrors internal/store's assertContextSectionOrder, minus sessions. +func assertBackendContextSectionOrder(t *testing.T, out string) { + t.Helper() + pinned := strings.Index(out, "### Pinned") + observations := strings.Index(out, "### Recent Observations") + if pinned < 0 || observations < 0 { + t.Fatalf("expected both section headings (pinned=%d observations=%d):\n%s", pinned, observations, out) + } + if pinned >= observations { + t.Fatalf("section order must be Pinned < Recent Observations, got %d/%d:\n%s", pinned, observations, out) + } if strings.Contains(out, "### Recent Sessions") { t.Fatalf("MemoryLake backend has no sessions to render:\n%s", out) } diff --git a/internal/store/store.go b/internal/store/store.go index e830d81e..b9a2e2f2 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -3462,10 +3462,16 @@ func (s *Store) FormatContext(project, scope string) (string, error) { obs.Type, obs.Title, truncate(obs.Content, contextPinnedTruncLen))) } + // A blank project filter means RecentSessions returned rows from every + // project (the cross-project render used by scope=personal), so each + // session line has to say which project it came from. Per-project renders + // stay byte-identical to before — the prefix would be pure noise there. + crossProject := strings.TrimSpace(project) == "" + sessionLines := make([]string, 0, len(sessions)) for _, sess := range sessions { sessionLines = append(sessionLines, fmt.Sprintf("- %s %s\n", - timeutil.FormatLocal(sess.StartedAt), sessionSummaryLine(sess))) + timeutil.FormatLocal(sess.StartedAt), sessionSummaryLine(sess, crossProject))) } if len(observations) > contextRecentObs { @@ -3483,14 +3489,26 @@ func (s *Store) FormatContext(project, scope string) (string, error) { // sessionSummaryLine renders the one-line session tail: the summary's first // sentence when there is one, "(active)" for a still-open session, and // "(no summary)" for a session that ended without one. -func sessionSummaryLine(sess SessionSummary) string { - if sess.Summary != nil && strings.TrimSpace(*sess.Summary) != "" { - return firstSentence(*sess.Summary) - } - if sess.EndedAt == nil { - return "(active)" - } - return "(no summary)" +// +// crossProject prefixes the tail with "[<project>] " so a cross-project render +// (blank project filter, e.g. scope=personal) attributes each session to its +// own project. Per-project renders pass false and emit no prefix. +// +// internal/memorylake has no session lines at all (MemoryLake has no session +// tracking — see its FormatContext), so there is no twin of this helper to keep +// in byte parity: its block simply omits "### Recent Sessions". +func sessionSummaryLine(sess SessionSummary, crossProject bool) string { + tail := "(no summary)" + switch { + case sess.Summary != nil && strings.TrimSpace(*sess.Summary) != "": + tail = firstSentence(*sess.Summary) + case sess.EndedAt == nil: + tail = "(active)" + } + if crossProject && strings.TrimSpace(sess.Project) != "" { + return "[" + sess.Project + "] " + tail + } + return tail } // ─── Export / Import ───────────────────────────────────────────────────────── diff --git a/internal/store/store_test.go b/internal/store/store_test.go index eb76e1c2..6a2c558d 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -8904,13 +8904,27 @@ func TestFormatContextLayeredBudget(t *testing.T) { } // TestFormatContextByteCapDropsObservationsThenSessionsNeverPinned proves the -// drop order when the block exceeds contextByteCap: oldest observations go -// first, then oldest sessions, and the pinned block always survives. +// drop order when the block exceeds contextByteCap. The fixture below renders +// well past the cap before any dropping (see the pre-cap guard), so the cap +// loop in renderLayeredContext genuinely runs, and the surviving set is +// asserted by name rather than by count: +// +// - all contextRecentSessions session lines survive (observations are dropped +// BEFORE sessions), +// - the two NEWEST observations (obs-4, obs-3) survive and the oldest of the +// three rendered ones (obs-2) is dropped (drop is oldest-first), +// - the pinned line and the footer always survive. +// +// These assertions are the regression net for three mutations, each of which +// must fail this test: (a) dropping sessions before observations, (b) dropping +// newest-first (obsLines[1:]), (c) deleting the cap loop entirely. func TestFormatContextByteCapDropsObservationsThenSessionsNeverPinned(t *testing.T) { s := newTestStore(t) pinnedBody := "PINNED-MARKER decision body that must never be dropped." - for i := 0; i < 6; i++ { + // Exactly contextRecentSessions sessions, so every one of them is expected + // in the rendered block and "a session line went missing" is unambiguous. + for i := 0; i < contextRecentSessions; i++ { id := fmt.Sprintf("s-cap-%d", i) if err := s.CreateSession(id, "proj-cap", "/tmp/proj-cap"); err != nil { t.Fatalf("create session %s: %v", id, err) @@ -8952,19 +8966,138 @@ func TestFormatContextByteCapDropsObservationsThenSessionsNeverPinned(t *testing if err != nil { t.Fatalf("format context: %v", err) } + + // The fixture must overflow the cap BEFORE dropping, otherwise the drop + // order below would be vacuously satisfied by a loop that never runs. + pinnedLines := []string{fmt.Sprintf("- [decision] **pinned-title**: %s\n", pinnedBody)} + sessionLines := make([]string, 0, contextRecentSessions) + // timeutil.FormatLocal renders exactly 19 bytes ("2006-01-02 15:04:05"), + // so a 19-byte stand-in keeps this reconstruction byte-exact. + tsWidth := strings.Repeat("t", len("2006-01-02 15:04:05")) + for i := 0; i < contextRecentSessions; i++ { + sessionLines = append(sessionLines, fmt.Sprintf("- %s %s\n", tsWidth, + firstSentence(fmt.Sprintf("session %d summary %s.", i, strings.Repeat("x", 150))))) + } + obsLines := make([]string, 0, contextRecentObs) + for i := 0; i < contextRecentObs; i++ { + obsLines = append(obsLines, fmt.Sprintf("- [note] **obs-%d**: %s\n", i, + firstSentence(strings.Repeat("filler sentence words ", 20)+"."))) + } + if precap := len(joinLayeredContext(pinnedLines, sessionLines, obsLines)); precap <= contextByteCap { + t.Fatalf("fixture renders %d bytes before dropping, which is already under the %d-byte cap: "+ + "the cap loop is not exercised, enlarge the fixture", precap, contextByteCap) + } + if len(out) > contextByteCap { t.Fatalf("context %d bytes, cap %d:\n%s", len(out), contextByteCap, out) } if !strings.Contains(out, "PINNED-MARKER") { t.Fatalf("pinned content must never be dropped by the byte cap:\n%s", out) } - if strings.Count(out, "obs-") > contextRecentObs { - t.Fatalf("more than %d observations rendered:\n%s", contextRecentObs, out) - } - if n := strings.Count(out, "session "); n > contextRecentSessions { - t.Fatalf("rendered %d sessions, cap %d:\n%s", n, contextRecentSessions, out) - } if !strings.Contains(out, "Full bodies: mem_search / mem_get_observation.") { t.Fatalf("footer must survive the byte cap:\n%s", out) } + + // (a) Observations are dropped before sessions: every session line survives. + for i := 0; i < contextRecentSessions; i++ { + want := fmt.Sprintf("session %d summary", i) + if !strings.Contains(out, want) { + t.Fatalf("session line %q must survive: observations are dropped before sessions:\n%s", want, out) + } + } + if n := strings.Count(out, "session "); n != contextRecentSessions { + t.Fatalf("rendered %d session lines, want exactly %d:\n%s", n, contextRecentSessions, out) + } + + // (b) Drop is oldest-first: the two newest observations survive... + for _, want := range []string{"obs-4", "obs-3"} { + if !strings.Contains(out, want) { + t.Fatalf("newest observation %s must survive the byte cap (drop is oldest-first):\n%s", want, out) + } + } + // (c) ...and the oldest of the three rendered observations is dropped. This + // only holds if the cap loop actually ran. + if strings.Contains(out, "obs-2") { + t.Fatalf("oldest rendered observation obs-2 must be dropped by the byte cap:\n%s", out) + } + // obs-0/obs-1 never even reach the renderer (contextRecentObs = 3). + for _, unwanted := range []string{"obs-0", "obs-1"} { + if strings.Contains(out, unwanted) { + t.Fatalf("observation %s is outside contextRecentObs and must not render:\n%s", unwanted, out) + } + } + if n := strings.Count(out, "**obs-"); n != 2 { + t.Fatalf("rendered %d observation lines, want exactly 2 after the cap drop:\n%s", n, out) + } + + assertContextSectionOrder(t, out) +} + +// assertContextSectionOrder pins the layered section order of a context block: +// Pinned, then Recent Sessions, then Recent Observations — most durable first, +// most volatile last (Phase 2 spec R3b). Headings that are absent (a section +// with no rows is omitted) are skipped. +func assertContextSectionOrder(t *testing.T, out string) { + t.Helper() + pinned := strings.Index(out, "### Pinned") + sessions := strings.Index(out, "### Recent Sessions") + observations := strings.Index(out, "### Recent Observations") + if pinned < 0 || sessions < 0 || observations < 0 { + t.Fatalf("expected all three section headings (pinned=%d sessions=%d observations=%d):\n%s", + pinned, sessions, observations, out) + } + if !(pinned < sessions && sessions < observations) { + t.Fatalf("section order must be Pinned < Recent Sessions < Recent Observations, got %d/%d/%d:\n%s", + pinned, sessions, observations, out) + } +} + +// TestFormatContextCrossProjectSessionAttribution pins the cross-project session +// line format. A blank project filter (the scope=personal render) makes +// RecentSessions return rows from every project, so each session line must name +// its project: "- <ts> [<project>] <summary>". A per-project render must stay +// byte-identical to the unprefixed form. +func TestFormatContextCrossProjectSessionAttribution(t *testing.T) { + s := newTestStore(t) + + for _, p := range []string{"proj-a", "proj-b"} { + id := "s-" + p + if err := s.CreateSession(id, p, "/tmp/"+p); err != nil { + t.Fatalf("create session %s: %v", id, err) + } + if err := s.EndSession(id, "did work in "+p+"."); err != nil { + t.Fatalf("end session %s: %v", id, err) + } + if _, err := s.AddObservation(AddObservationParams{ + SessionID: id, Type: "note", Title: "t-" + p, Content: "body.", + Project: p, Scope: "personal", + }); err != nil { + t.Fatalf("add observation %s: %v", id, err) + } + } + + cross, err := s.FormatContext("", "personal") + if err != nil { + t.Fatalf("cross-project format context: %v", err) + } + for _, want := range []string{"[proj-a] did work in proj-a.", "[proj-b] did work in proj-b."} { + if !strings.Contains(cross, want) { + t.Fatalf("cross-project render must attribute sessions, missing %q:\n%s", want, cross) + } + } + + scoped, err := s.FormatContext("proj-a", "personal") + if err != nil { + t.Fatalf("scoped format context: %v", err) + } + if strings.Contains(scoped, "[proj-a]") { + t.Fatalf("per-project render must not prefix session lines with the project:\n%s", scoped) + } + if !strings.Contains(scoped, "- did work in proj-a.") && + !strings.Contains(scoped, " did work in proj-a.") { + t.Fatalf("per-project render lost the session summary:\n%s", scoped) + } + if strings.Contains(scoped, "proj-b") { + t.Fatalf("per-project render leaked another project:\n%s", scoped) + } } From f88c9ed58ca9fe563f252e2372f3ae16426e2fe3 Mon Sep 17 00:00:00 2001 From: zhishu <weichuangxian@hotmail.com> Date: Mon, 27 Jul 2026 16:27:34 +0000 Subject: [PATCH 22/29] feat(eval): wave1 round 3 (R3 progressive retrieval) scorecards --- eval/results/b4db321-2026-07-27-l1.json | 508 ++++++++++++++++++++ eval/results/b4db321-2026-07-27-l2.json | 18 + eval/results/w1-r3-l3subset.json | 109 +++++ eval/results/w1-r3-l3subset.json.runs.jsonl | 16 + 4 files changed, 651 insertions(+) create mode 100644 eval/results/b4db321-2026-07-27-l1.json create mode 100644 eval/results/b4db321-2026-07-27-l2.json create mode 100644 eval/results/w1-r3-l3subset.json create mode 100644 eval/results/w1-r3-l3subset.json.runs.jsonl diff --git a/eval/results/b4db321-2026-07-27-l1.json b/eval/results/b4db321-2026-07-27-l1.json new file mode 100644 index 00000000..9f627874 --- /dev/null +++ b/eval/results/b4db321-2026-07-27-l1.json @@ -0,0 +1,508 @@ +{ + "git_sha": "b4db321", + "date": "2026-07-27", + "suite": "l1", + "metrics": { + "avg_distractor_ratio": 0.13271604938271603, + "avg_tokens_per_query": 205.59259259259258, + "latency_p50_ms": 599, + "latency_p95_ms": 1114, + "mrr": 0.7376543209876544, + "recall@1": 0.6851851851851852, + "recall@10": 0.7962962962962963, + "recall@5": 0.7962962962962963 + }, + "per_item": [ + { + "id": "r-001", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 610, + "tokens": 185 + } + }, + { + "id": "r-002", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1011, + "tokens": 488 + } + }, + { + "id": "r-003", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1090, + "tokens": 359 + } + }, + { + "id": "r-004", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 562, + "tokens": 268 + } + }, + { + "id": "r-005", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 532, + "tokens": 325 + } + }, + { + "id": "r-006", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 561, + "tokens": 205 + } + }, + { + "id": "r-007", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1125, + "tokens": 288 + } + }, + { + "id": "r-008", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 545, + "tokens": 0 + } + }, + { + "id": "r-009", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1110, + "tokens": 67 + } + }, + { + "id": "r-010", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 580, + "tokens": 0 + } + }, + { + "id": "r-011", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 566, + "tokens": 310 + } + }, + { + "id": "r-012", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 538, + "tokens": 125 + } + }, + { + "id": "r-013", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 583, + "tokens": 387 + } + }, + { + "id": "r-014", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1101, + "tokens": 67 + } + }, + { + "id": "r-015", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 578, + "tokens": 77 + } + }, + { + "id": "r-016", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 582, + "tokens": 336 + } + }, + { + "id": "r-017", + "values": { + "distractor_ratio": 0.25, + "first_hit_rank": 2, + "latency_ms": 532, + "tokens": 308 + } + }, + { + "id": "r-018", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 578, + "tokens": 256 + } + }, + { + "id": "r-019", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 570, + "tokens": 418 + } + }, + { + "id": "r-020", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 568, + "tokens": 254 + } + }, + { + "id": "r-021", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 590, + "tokens": 668 + } + }, + { + "id": "r-022", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 565, + "tokens": 327 + } + }, + { + "id": "r-023", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 996, + "tokens": 308 + } + }, + { + "id": "r-024", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 547, + "tokens": 578 + } + }, + { + "id": "r-025", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 572, + "tokens": 86 + } + }, + { + "id": "r-026", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 1206, + "tokens": 140 + } + }, + { + "id": "r-027", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 608, + "tokens": 169 + } + }, + { + "id": "r-028", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 596, + "tokens": 74 + } + }, + { + "id": "r-029", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 1023, + "tokens": 231 + } + }, + { + "id": "r-030", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1086, + "tokens": 585 + } + }, + { + "id": "r-031", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 3947, + "tokens": 86 + } + }, + { + "id": "r-032", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 1040, + "tokens": 0 + } + }, + { + "id": "r-033", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1038, + "tokens": 163 + } + }, + { + "id": "r-034", + "values": { + "distractor_ratio": 0.25, + "first_hit_rank": 2, + "latency_ms": 1073, + "tokens": 308 + } + }, + { + "id": "r-035", + "values": { + "distractor_ratio": 0.5, + "first_hit_rank": 2, + "latency_ms": 588, + "tokens": 163 + } + }, + { + "id": "r-036", + "values": { + "distractor_ratio": 0.3333333333333333, + "first_hit_rank": 2, + "latency_ms": 713, + "tokens": 231 + } + }, + { + "id": "r-037", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1114, + "tokens": 231 + } + }, + { + "id": "r-038", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 635, + "tokens": 231 + } + }, + { + "id": "r-039", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 623, + "tokens": 0 + } + }, + { + "id": "r-040", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 599, + "tokens": 0 + } + }, + { + "id": "r-041", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 1056, + "tokens": 145 + } + }, + { + "id": "r-042", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 598, + "tokens": 0 + } + }, + { + "id": "r-043", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 712, + "tokens": 86 + } + }, + { + "id": "r-044", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 624, + "tokens": 86 + } + }, + { + "id": "r-045", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 648, + "tokens": 86 + } + }, + { + "id": "r-046", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 570, + "tokens": 86 + } + }, + { + "id": "r-047", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 589, + "tokens": 170 + } + }, + { + "id": "r-048", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 587, + "tokens": 93 + } + }, + { + "id": "r-049", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 650, + "tokens": 233 + } + }, + { + "id": "r-050", + "values": { + "distractor_ratio": 0.3333333333333333, + "first_hit_rank": 2, + "latency_ms": 588, + "tokens": 226 + } + }, + { + "id": "r-051", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 823, + "tokens": 93 + } + }, + { + "id": "r-052", + "values": { + "distractor_ratio": 0.5, + "first_hit_rank": 3, + "latency_ms": 757, + "tokens": 310 + } + }, + { + "id": "r-053", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 605, + "tokens": 93 + } + }, + { + "id": "r-054", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 584, + "tokens": 93 + } + } + ], + "env": { + "backend": "memorylake", + "dataset": "eval/datasets/phoenix-retrieval-v1.jsonl", + "project": "phoenix" + } +} diff --git a/eval/results/b4db321-2026-07-27-l2.json b/eval/results/b4db321-2026-07-27-l2.json new file mode 100644 index 00000000..447a3b69 --- /dev/null +++ b/eval/results/b4db321-2026-07-27-l2.json @@ -0,0 +1,18 @@ +{ + "git_sha": "b4db321", + "date": "2026-07-27", + "suite": "l2", + "metrics": { + "avg_search_tokens": 205.59259259259258, + "context_tokens": 88, + "injected_tokens_per_session": 5714.777777777777, + "static_hook_tokens": 2844, + "static_mcp_instr_tokens": 184, + "static_skill_tokens": 1982 + }, + "env": { + "project": "phoenix", + "search_calls_assumed": "3.0", + "tokenizer": "approx-bytes/4" + } +} diff --git a/eval/results/w1-r3-l3subset.json b/eval/results/w1-r3-l3subset.json new file mode 100644 index 00000000..4efbf24b --- /dev/null +++ b/eval/results/w1-r3-l3subset.json @@ -0,0 +1,109 @@ +{ + "git_sha": "b4db321", + "date": "2026-07-27", + "suite": "l3", + "metrics": { + "judge_failed_count": 0, + "judge_failed_memory": 0, + "judge_failed_no-memory": 0, + "mean_score_memory": 7, + "mean_score_no-memory": 6.75, + "uplift": 0.25 + }, + "per_item": [ + { + "id": "fix-001/no-memory/run0", + "values": { + "duration_s": 1005.572311988, + "input_tokens": 50, + "output_tokens": 79477, + "score": 3, + "timed_out": 0 + }, + "note": "The answer correctly locates the general area (nodeShareInputScan.c) and correctly identifies LIMIT-induced early termination as the trigger, but invents an entirely different mechanism (ndone/nsharers notification race, missing CHECK_FOR_INTERRUPTS) rather than the actual documented root cause involving the shareinput_Xslice_hash entry being prematurely removed by a squelched reader before the writer initializes. The proposed fix therefore does not address the real bug at all." + }, + { + "id": "fix-001/memory/run0", + "values": { + "duration_s": 434.368211253, + "input_tokens": 16150, + "output_tokens": 24235, + "score": 3, + "timed_out": 0 + }, + "note": "The agent invented an entirely different (and cross-slice-irrelevant) mechanism involving local shares, Append/Sequence squelch propagation, and Motion deadlock, rather than identifying the actual documented root cause: the cross-slice xslice-state hash table entry being prematurely removed by a squelched reader before the writer initializes. None of the rubric's specific technical points (shareinput_Xslice_hash, release_shareinput_reference bool param, debug_shareinput_xslice GUC) are present, and the proposed fix targets the wrong code path entirely, so despite thorough and well-reasoned analysis, it does not match the actual bug." + }, + { + "id": "fix-003/no-memory/run0", + "values": { + "duration_s": 614.260972599, + "input_tokens": 46, + "output_tokens": 48032, + "score": 4, + "timed_out": 0 + }, + "note": "The answer correctly identifies the exact file, function, root cause (SIG_IGN on SIGUSR1 despite shared latch use post-InitProcess/InitPostgres), and the fix (pgstat_sigusr1_handler calling latch_sigusr1_handler), matching most core rubric points, but presents it as already-applied historical fact rather than the proposed fix requested, and never mentions the errno save/restore requirement, and spends most of the answer on speculative residual-risk proposals not asked for." + }, + { + "id": "fix-003/memory/run0", + "values": { + "duration_s": 577.503000585, + "input_tokens": 99, + "output_tokens": 46118, + "score": 5, + "timed_out": 0 + }, + "note": "The agent correctly locates PgstatCollectorMain, identifies SIGUSR1/SIG_IGN and the correct fix (pgstat_sigusr1_handler → latch_sigusr1_handler), but frames this as already-fixed by a prior commit and treats it as secondary to a self-invented blocking-lock root cause not supported by the rubric, and omits the errno save/restore detail." + }, + { + "id": "gotcha-001/no-memory/run0", + "values": { + "duration_s": 20.255161652, + "input_tokens": 4, + "output_tokens": 1310, + "score": 10, + "timed_out": 0 + }, + "note": "Answer correctly diagnoses the stale FFI header/incremental-cache issue, gives the exact deletion and rebuild commands including sourcing gcc-toolset-11, and does not fall into any of the listed gotchas (no assumption submodule update alone fixes it, no skipping toolset sourcing, no blind re-run of full build without the manual fix)." + }, + { + "id": "gotcha-001/memory/run0", + "values": { + "duration_s": 17.817611112, + "input_tokens": 4, + "output_tokens": 1163, + "score": 10, + "timed_out": 0 + }, + "note": "Answer correctly identifies the stale FFI header/lib caused by Cargo's incremental cache, gives the exact rm + gcc-toolset-11 + rebuild sequence matching the rubric, and explicitly notes recurrence after rebases; no gotchas violated." + }, + { + "id": "gotcha-003/no-memory/run0", + "values": { + "duration_s": 22.711551673, + "input_tokens": 6, + "output_tokens": 1543, + "score": 10, + "timed_out": 0 + }, + "note": "Answer correctly identifies root cause (global FDB scan), explains why it flakes in schedule, and gives the correct relfilenode-join fix with the exact reference file, hitting all core rubric points and both gotcha avoidances; it only omits the catalog-table (zdb_backup_gc_records/zdb_shard_stats) generalization." + }, + { + "id": "gotcha-003/memory/run0", + "values": { + "duration_s": 53.102502636, + "input_tokens": 14, + "output_tokens": 3290, + "score": 10, + "timed_out": 0 + }, + "note": "Answer correctly identifies the global-scope root cause, explains flakiness mechanism, and provides the exact relfilenode-join fix with correct reference files, matching almost all rubric points; it only omits mention of the pg_catalog.zdb_* catalog tables needing the same treatment." + } + ], + "env": { + "arms": "no-memory,memory", + "model": "sonnet", + "n": "1", + "tasks": "gotcha-001,gotcha-003,fix-001,fix-003" + } +} diff --git a/eval/results/w1-r3-l3subset.json.runs.jsonl b/eval/results/w1-r3-l3subset.json.runs.jsonl new file mode 100644 index 00000000..2c0ac969 --- /dev/null +++ b/eval/results/w1-r3-l3subset.json.runs.jsonl @@ -0,0 +1,16 @@ +{"kind":"run","task_id":"fix-001","arm":"no-memory","run":0,"result":{"task_id":"fix-001","arm":"no-memory","result_text":"Good, that's confirmed as a real, queryable wait event. Here's the writeup of the investigation.\n\n## Root cause\n\nThe hang lives in the cross-slice **ShareInputScan (Shared/CTE Scan)** synchronization protocol in `src/backend/executor/nodeShareInputScan.c`, and `LIMIT` is the trigger because it's the executor's main source of *asymmetric early termination* — different segments/slices/readers stop consuming a shared scan at different, data-dependent points in time, which is exactly what this protocol is fragile against.\n\n**Mechanism.** A cross-slice `ShareInputScan` producer materializes its child into a shared tuplestore file and then, in `ExecEndShareInputScan()` (nodeShareInputScan.c:565-570), calls `shareinput_writer_waitdone(node-\u003eref, sisc-\u003enconsumers)`, blocking until every consumer *slice* has reported \"done\" via `shareinput_reader_notifydone()`. Consumers report \"done\" either by:\n- reaching real EOF and going through `ExecutorEnd()`'s normal teardown (nodeShareInputScan.c:565-582), or\n- being cut off early via `ExecLimit()` → `ExecSquelchNode()` → `ExecSquelchShareInputScan()` (execAmi.c:713-870, nodeLimit.c:246-264, nodeShareInputScan.c:620-675).\n\n**Bug #1 — inconsistent bookkeeping between the two paths.** When a slice has more than one local `ShareInputScanState` reading the *same* share (`local_state-\u003ensharers \u003e 1`, e.g. a CTE referenced twice in one slice), the squelch path gates the producer notification on a real counter:\n```c\nlocal_state-\u003endone++;\nif (local_state-\u003endone == local_state-\u003ensharers) // nodeShareInputScan.c:666\n shareinput_reader_notifydone(...);\n```\nbut the normal-completion path in `ExecEndShareInputScan()` ignores `nsharers`/`ndone` entirely and fires on the *first* instance to reach `ExecutorEnd`:\n```c\nif (!local_state-\u003eclosed) { // nodeShareInputScan.c:573\n shareinput_reader_notifydone(...);\n local_state-\u003eclosed = true;\n}\n```\nThese two protocols only stay consistent because of an *unenforced* ordering assumption — that every `Squelch()` call happens strictly before the final `ExecutorEnd()` walk in the same process. That's true for the ordinary `LIMIT` path (nodeLimit.c's `expect_rescan` guard protects the direct case), but it's not documented, not asserted, and not obviously true for every squelch trigger (e.g. `execMotionSender`'s stop-detection at nodeMotion.c:284-289) or for abort/error teardown. If it's ever violated, `Assert(!local_state-\u003eclosed)` (nodeShareInputScan.c:662, and again at line 381 in `ExecShareInputScan` itself) fires in cassert builds, and in production builds the check is silently skipped, leaving `ndone`/`nsharers`/`closed` mutually inconsistent.\n\n**Bug #2 — the real \"hang forever, uncancellable\" mechanism.** Neither `shareinput_reader_waitready()` (nodeShareInputScan.c:937-966) nor `shareinput_writer_waitdone()` (nodeShareInputScan.c:1023-1060) ever call `CHECK_FOR_INTERRUPTS()` in their `ConditionVariableSleep` retry loops — unlike `ExecLimit_guts()`, which explicitly does (nodeLimit.c:51). So whenever the producer/consumer participant count is off by even one (for *any* reason — a squelch/end race like above, or the general \"CTE producer/consumer count mismatch\" class this codebase has already had to fix repeatedly at the ORCA planner level for replicated tables, see commits `3e37d72e657` / `24a54a79fec`, whose own commit message admits the fix \"doesn't eliminate the possibility of a number mismatch... significantly minimizes the likelihood\"), the stuck backend cannot be woken by query cancel, `statement_timeout`, or even a hard terminate — it just sleeps on the condition variable indefinitely. That's precisely the \"query appears to stall indefinitely and never completes\" symptom, rather than a recoverable slowdown.\n\nWhy this is LIMIT-specific and intermittent: `LIMIT` is what makes different consumers/producers finish \"early\" and at different wall-clock times depending on which segment/slice happens to satisfy the row quota first — exactly the condition needed to expose a participant-count race that a fully-drained (no `LIMIT`) query would never hit, since without early termination every reader always goes through the same natural-EOF path.\n\n## Recommended fix\n\n1. **`src/backend/executor/nodeShareInputScan.c`** — add `CHECK_FOR_INTERRUPTS()` inside the `for(;;)` loops of `shareinput_reader_waitready()` and `shareinput_writer_waitdone()`. This is the highest-value, lowest-risk change: it doesn't remove the underlying race, but it guarantees a stuck query can always be cancelled/timed-out instead of requiring a backend/segment restart.\n2. **Same file** — unify the \"consumer finished\" notification into one helper (e.g. `shareinput_consumer_finish(ShareInputScanState *node)`) that always does the `ndone++`/`ndone==nsharers`/`notifydone`/`release_shareinput_reference` sequence, and call it from both `ExecEndShareInputScan()`'s consumer branch and `ExecSquelchShareInputScan()`'s consumer branch. This removes the two-different-protocols hazard and the reliance on an undocumented ordering invariant.\n3. Longer-term: revisit the \"alien node\" gap explicitly flagged in the code's own comment at nodeShareInputScan.c:463-467 (\"We could also exit here immediately if this is an alien node... but we can't easily detect that here\") — a share participant that's structurally present in the plan but never actually exercised still registers into `nsharers`/`nconsumers`, which is the same fragile-count problem already fixed once (for replicated CTEs) at the planner level.\n\n## Caveat\n\nThis is a multi-process synchronization bug; static reading can show the mechanism and the concrete missing-interrupt-check defect, but can't 100% prove which exact interleaving fires in your users' reports. Before landing a fix, I'd recommend capturing `pg_stat_activity.wait_event` (look for `ShareInputScan`) plus backend backtraces across segments during a live hang — that will confirm whether the producer is parked in `shareinput_writer_waitdone()` as this analysis predicts.","input_tokens":50,"output_tokens":79477,"duration_s":1005.572311988,"timed_out":false}} +{"kind":"verdict","task_id":"fix-001","arm":"no-memory","run":0,"verdict":{"score":3,"points_hit":["Root cause is in nodeShareInputScan.c cross-slice sync mechanism","LIMIT triggers early/asymmetric termination as the intermittency factor"],"points_missed":["Does not identify shareinput_Xslice_hash shared-memory hash table as the coordination mechanism","Does not identify that a reader (squelched before writer initializes) can remove the xslice-state entry first","Does not identify that the writer then creates a new entry and hangs waiting on already-gone readers","Fix proposed (CHECK_FOR_INTERRUPTS, unifying notify paths) does not match the required fix of preventing removal of the hash entry during squelch (e.g. bool param on release_shareinput_reference)","No mention of a debug GUC like debug_shareinput_xslice"],"reasoning":"The answer correctly locates the general area (nodeShareInputScan.c) and correctly identifies LIMIT-induced early termination as the trigger, but invents an entirely different mechanism (ndone/nsharers notification race, missing CHECK_FOR_INTERRUPTS) rather than the actual documented root cause involving the shareinput_Xslice_hash entry being prematurely removed by a squelched reader before the writer initializes. The proposed fix therefore does not address the real bug at all."}} +{"kind":"run","task_id":"fix-001","arm":"memory","run":0,"result":{"task_id":"fix-001","arm":"memory","result_text":"## Root cause\n\nThe hang is a Motion/interconnect deadlock caused by an incomplete squelch handler for **local (same-slice) Share Input Scan** nodes, in `src/backend/executor/nodeShareInputScan.c`.\n\n**The chain:**\n\n1. `ExecLimit` (`src/backend/executor/nodeLimit.c:245-264`), once the limit is satisfied, calls `ExecSquelchNode(node)` on itself (line 260) — unconditionally except when `expect_rescan` is set. This recurses down through the entire subtree feeding the Limit.\n2. If that subtree contains an `Append`/`Sequence`/`MergeAppend`, their squelch handlers unconditionally squelch **every** branch, including branches that were **never executed** because an earlier branch alone already satisfied the LIMIT:\n - `ExecSquelchAppend` — `src/backend/executor/nodeAppend.c:761-768`: `for (i = 0; i \u003c node-\u003eas_nplans; i++) ExecSquelchNode(node-\u003eappendplans[i]);`\n - `ExecSquelchSequence` — `src/backend/executor/nodeSequence.c:162-167`: same pattern over `node-\u003esubplans[i]`.\n3. If one of those never-executed branches contains a **local** `ShareInputScan` (`sisc-\u003ecross_slice == false` — e.g. a CTE/shared subquery referenced only in that slice), `ExecSquelchNode` dispatches to `ExecSquelchShareInputScan` (`nodeShareInputScan.c:620-675`):\n\n```c\nif (sisc-\u003ecross_slice \u0026\u0026 node-\u003eref)\n{\n if (currentSliceId == sisc-\u003eproducer_slice_id || estate-\u003ees_plannedstmt-\u003enumSlices == 1)\n {\n /* producer: force materialization if not ready, so cross-slice readers don't hang */\n if (!local_state-\u003eready)\n init_tuplestore_state(node);\n }\n else\n {\n /* consumer: notify producer */\n ...\n }\n}\n```\n\nFor a **local** share, `sisc-\u003ecross_slice` is false and `node-\u003eref` is always `NULL` (set in `ExecInitShareInputScan`, `nodeShareInputScan.c:499-513`: `sisstate-\u003eref = node-\u003ecross_slice ? get_shareinput_reference(...) : NULL;`). So the entire body above is skipped — `ExecSquelchShareInputScan` does **nothing** but `ExecClearTuple`. Crucially, it also **deliberately does not recurse into the child** (comment at `nodeShareInputScan.c:630-641`: \"its subtree may still need to be executed... don't recurse in this case\").\n\nThat reasoning assumes some *other* sharer of the same local share will still execute the child later. But if this squelched instance is the one holding the real child (`local_state-\u003echildState`, set by whichever `ShareInputScanState` had a non-NULL `outerPlan` — see `ExecInitShareInputScan`, `nodeShareInputScan.c:438-440,494-495`), and no other sharer of that share_id ever gets a chance to run (because it, too, lives only in branches that the LIMIT/Append bypassed), then **nobody ever calls `init_tuplestore_state`** for that share. Its child subplan is never executed and never squelched.\n\nIf that child subplan starts with a receiving Motion (perfectly legal — a \"local\" SISC just means all *readers* are in one slice; the shared subplan it drains can still pull from a different sending slice across a Motion), the sending-side Motion on another QE blocks forever waiting for this receiver to either consume or explicitly squelch — exactly the class of deadlock `ExecSquelchNode` exists to prevent (see the doc comment at `execAmi.c:686-711`), except SISC's own squelch handler reintroduces it for the local-share case.\n\nContrast this with the **cross-slice producer** case, which already has the correct defensive logic: \"if we haven't materialized the tuplestore yet, we need to do it now, even though we won't need the data... otherwise readers will hang waiting for us forever\" (`nodeShareInputScan.c:646-657`). That fix was never extended to local shares — apparently an oversight, not a deliberate design choice, since eager materialization is just as safe for local shares (any later sharer that legitimately still wants to read this share simply finds `local_state-\u003eready == true` and reuses the tuplestore via the \"another local reader\" path in `init_tuplestore_state`, `nodeShareInputScan.c:324-332`).\n\n**Why it's intermittent:** it only manifests when (a) the plan has a `LIMIT` above an `Append`/`Sequence`/`MergeAppend`, (b) one branch contains a local Share Input Scan whose real child pulls across a Motion, and (c) the LIMIT happens to be satisfied entirely from *other* branches before that branch is ever touched. Whether (c) holds depends on branch order, row counts, and data skew across segments — the same query can sail through when the shared branch happens to get pulled at least once, and hang when it doesn't.\n\n## Proposed fix\n\nIn `ExecSquelchShareInputScan` (`src/backend/executor/nodeShareInputScan.c:620-675`), add a branch for local shares mirroring the existing cross-slice-producer logic:\n\n```c\nvoid\nExecSquelchShareInputScan(ShareInputScanState *node)\n{\n\tEState\t *estate = node-\u003ess.ps.state;\n\tShareInputScan *sisc = (ShareInputScan *) node-\u003ess.ps.plan;\n\tshareinput_local_state *local_state = node-\u003elocal_state;\n\n\tExecClearTuple(node-\u003ess.ps.ps_ResultTupleSlot);\n\n\tif (sisc-\u003ecross_slice \u0026\u0026 node-\u003eref)\n\t{\n\t\t/* ... existing cross-slice logic, unchanged ... */\n\t}\n\telse if (!sisc-\u003ecross_slice \u0026\u0026 !local_state-\u003eready)\n\t{\n\t\t/*\n\t\t * Local share whose tuplestore was never materialized. If no other\n\t\t * sharer of this share ever executes (e.g. this was the only\n\t\t * occurrence, living in an Append/Sequence branch that got skipped\n\t\t * once an enclosing LIMIT was satisfied elsewhere), nobody will ever\n\t\t * drain local_state-\u003echildState. If that child contains a receiving\n\t\t * Motion, its sender blocks forever. Materialize eagerly instead --\n\t\t * safe even if another sharer does end up reading it later, since\n\t\t * init_tuplestore_state() is idempotent on local_state-\u003eready.\n\t\t */\n\t\tinit_tuplestore_state(node);\n\t}\n}\n```\n\nThis brings local shares to parity with the cross-slice producer path, which already handles the identical hazard.\n\n**Trade-off:** this forces full execution of a shared subplan the first time *any* of its sharers is squelched without having run, even in cases where the branch really was abandoned and nobody would ever have needed the data — costing possibly-wasted work. That's the same trade-off already accepted for cross-slice producers, and correctness (no deadlock) should dominate. If the extra cost matters, a tighter version can be built by extending the existing `nsharers`/`ndone` bookkeeping (currently only counted for cross-slice consumers, `nodeShareInputScan.c:490-492`) to local shares too, and only force-materializing when the *last* live local sharer is being squelched — but that adds complexity for a performance-only benefit.\n\n**Suggested regression test:** a plan with `LIMIT` over an `Append` (or `UNION ALL`) where one branch is a local CTE/shared-subquery reference behind a redistribute/broadcast Motion, and the other branch alone can satisfy the limit — run repeatedly (or pin via the existing `SIMPLE_FAULT_INJECTOR`/`Debug_shareinput_xslice` machinery already in the file) to force the \"shared branch never touched\" ordering deterministically, rather than relying on it happening probabilistically.\n\nWant me to implement this change and add a regression test under `contrib/zdb/regress` (or `src/test/isolation2`, since this is core executor behavior, not ZDB-specific)?","input_tokens":16150,"output_tokens":24235,"duration_s":434.368211253,"timed_out":false}} +{"kind":"verdict","task_id":"fix-001","arm":"memory","run":0,"verdict":{"score":3,"points_hit":[],"points_missed":["Root cause is in shareinput_Xslice_hash shared-memory hash table entry lifecycle, not local-share squelch logic","Reader squelched before writer initializes removes the entry first, causing writer to hang","Fix direction: release_shareinput_reference needs a bool param to avoid removing entry during squelch","debug_shareinput_xslice GUC point"],"reasoning":"The agent invented an entirely different (and cross-slice-irrelevant) mechanism involving local shares, Append/Sequence squelch propagation, and Motion deadlock, rather than identifying the actual documented root cause: the cross-slice xslice-state hash table entry being prematurely removed by a squelched reader before the writer initializes. None of the rubric's specific technical points (shareinput_Xslice_hash, release_shareinput_reference bool param, debug_shareinput_xslice GUC) are present, and the proposed fix targets the wrong code path entirely, so despite thorough and well-reasoned analysis, it does not match the actual bug."}} +{"kind":"run","task_id":"fix-003","arm":"no-memory","run":0,"result":{"task_id":"fix-003","arm":"no-memory","result_text":"## Root cause\n\nThe hang traces back to one architectural change: **`contrib/zdb` turned the stats collector from a detached, lock-free utility process into a full transactional backend**, but the collector's signal wiring wasn't updated to match, leaving a window where it can wait on a heavyweight lock and never wake up.\n\n### 1. What changed (commit `09a6167597f`, \"back up pg_stat to heap table\")\n\n`PgstatCollectorMain()` in `src/backend/postmaster/pgstat.c:5642` used to be a standalone process: no `PGPROC`, no catalog access, `SIGUSR1` explicitly ignored (\"we only use a local latch\"). This commit made it call `InitProcess()` + `InitPostgres(\"postgres\", ...)` (`pgstat.c:5694-5700`), giving it a real `PGPROC`/`PGXACT` slot (tagged `PROC_IS_PGSTAT`, `src/include/storage/proc.h:68`) so it can run real transactions.\n\nThat capability is used by `contrib/zdb/src/storage/pg_stat_backup.c`:\n- `UpdateRelytStatsBackup()` (`pg_stat_backup.c:314-375`) does `heap_open(relyt_stats_backup, RowExclusiveLock)` + `CatalogTupleInsert/Update`, wrapped in a real `StartTransactionCommand()/CommitTransactionCommand()` (`BackupPgStat()`, `pg_stat_backup.c:207-231`), invoked from the collector's main loop every `gPgStatBackupInterval` seconds when `pending_write_requests_backup` is non-empty.\n- `RestoreRelytStatsFromBackup()` (`pg_stat_backup.c:438-516`) does `heap_open(relyt_stats_backup, AccessShareLock)`, invoked from `pgstat_read_statsfiles()` (`pgstat.c:6259-6264`) **at collector startup on every node**, whenever the local stat file doesn't exist yet.\n\nCrucially, `InitRelytStatsBackupHook()` is registered unconditionally for every backend (`contrib/zdb/src/zdb.c:5580`, before the master/segment branch), so both hooks are wired up on segments too — this is why a *segment's* collector, not just the coordinator's, can end up taking a heavyweight lock on `relyt_stats_backup` at startup.\n\n### 2. Why a lock wait there could hang forever\n\nOnce the collector is a real lock-manager participant, a conflicting lock on `relyt_stats_backup` (concurrent extension upgrade/DDL, a second in-flight backup cycle, etc.) sends it into `ProcSleep()` (`src/backend/storage/lmgr/proc.c:1571`), which blocks on `WaitLatch(MyLatch, WL_LATCH_SET | WL_EXIT_ON_PM_DEATH, 0, ...)` — no `WL_TIMEOUT`, so it's an unbounded wait. The only two ways to break out are:\n- A **one-shot** `DEADLOCK_TIMEOUT` (`enable_timeout_after(DEADLOCK_TIMEOUT, DeadlockTimeout)`, `proc.c:1545`) firing once via `SIGALRM`/`CheckDeadLockAlert()` — self-signaled, so unaffected by the bug.\n- The **actual lock-grant wakeup**, delivered by the lock releaser via `SetLatch()` → `kill(collector_pid, SIGUSR1)`, which only does anything if the target's `SIGUSR1` handler runs `latch_sigusr1_handler()` to write the self-pipe that unblocks its `epoll_wait`/`select`.\n\nBefore the fix, `PgstatCollectorMain()` still had `pqsignal(SIGUSR1, SIG_IGN)`. So: `SetLatch()` correctly flipped `is_set` in shared memory, but the ignored signal never interrupted the collector's blocking syscall. Once the single deadlock-timeout tick (~1s) passed without the lock happening to be free at that exact instant, the collector had **no further scheduled wakeup at all** and stayed parked in `WaitLatch()` indefinitely — holding its `PGPROC` slot and its open transaction, and never returning to the top of its dispatch loop to drain the pgstat UDP socket or service any other node's requests. Any backend needing something from that collector (a fresh stat file, a conflicting lock on the same table, DROP-DATABASE-style backend counting) stalls behind it.\n\n### 3. The fix already applied\n\nCommit `34fbfd18354` (\"fix #1846: process SIGUSR1 in pgstat after InitPostgres()\") closes exactly this gap: `pgstat.c:5673` now does `pqsignal(SIGUSR1, pgstat_sigusr1_handler)`, where the handler (`pgstat.c:6041-6049`) calls `latch_sigusr1_handler()`, matching every ordinary backend. This is present in current HEAD, so the collector now wakes correctly on real lock grants.\n\n## Residual risk / what I'd still change\n\nEven with that fix, the design is fragile because the collector is a **singleton per node** whose single dispatch loop now does blocking, lock-taking heap DML inline:\n\n1. **`RestoreRelytStatsFromBackup()` runs unconditionally at every segment's collector startup** (`pgstat.c:6259`, gated only by `stat()` returning `ENOENT`) and can block the *entire* collector from ever reaching its message loop if `relyt_stats_backup` is momentarily lock-contended (e.g. a rolling extension upgrade). That freezes pgstat for the whole segment until the lock clears — no bound at all beyond the (now-working) latch wakeup.\n2. **`BackupPgStat_hook()` is called synchronously inside the main dispatch loop** (`pgstat.c:5502-5514`), so any lock wait there stalls `pgstat_write_statsfiles()` and socket draining for as long as the wait lasts — by design this can now be arbitrarily long, just not infinite.\n\nRecommended follow-up (files/functions):\n- In `contrib/zdb/src/storage/pg_stat_backup.c`, replace the blocking `heap_open(..., RowExclusiveLock/AccessShareLock)` in `UpdateRelytStatsBackup()` and `RestoreRelytStatsFromBackup()` with a `ConditionalLockRelation`/bounded-retry variant (or set a short `lock_timeout` around the transaction): on failure, just skip this backup/restore cycle and retry next interval rather than blocking the collector's only thread of execution.\n- Longer-term, move the backup/restore work off the collector's hot loop entirely (a small dedicated bgworker signaled by the collector) so a lock wait there can never stall ordinary stats delivery — this also removes the need for the collector itself to be a full transactional backend at all, which is what created this class of bug in the first place.","input_tokens":46,"output_tokens":48032,"duration_s":614.260972599,"timed_out":false}} +{"kind":"verdict","task_id":"fix-003","arm":"no-memory","run":0,"verdict":{"score":4,"points_hit":["The bug is in src/backend/postmaster/pgstat.c, in PgstatCollectorMain()","Fix: install a real SIGUSR1 handler (pgstat_sigusr1_handler) that calls latch_sigusr1_handler(), replacing the SIG_IGN registration","Once the collector starts using shared latches it must handle SIGUSR1 or waiters will hang"],"points_missed":["Does not explicitly frame the root cause as the collector ignoring SIGUSR1 based on the old local-latch assumption as the primary bug being asked about — instead frames it as already-fixed history and pivots to a different 'residual risk' proposal","Does not mention the handler must save/restore errno around the call consistent with pgstat_sighup_handler"],"reasoning":"The answer correctly identifies the exact file, function, root cause (SIG_IGN on SIGUSR1 despite shared latch use post-InitProcess/InitPostgres), and the fix (pgstat_sigusr1_handler calling latch_sigusr1_handler), matching most core rubric points, but presents it as already-applied historical fact rather than the proposed fix requested, and never mentions the errno save/restore requirement, and spends most of the answer on speculative residual-risk proposals not asked for."}} +{"kind":"run","task_id":"fix-003","arm":"memory","run":0,"result":{"task_id":"fix-003","arm":"memory","result_text":"Now I have a complete picture. Here's the analysis.\n\n## Root cause\n\nThe stats collector hang traces back to a feature that was bolted onto `PgstatCollectorMain()` (`src/backend/postmaster/pgstat.c:5638`): a periodic \"stats backup\" step that persists the pgstat snapshot into a real catalog table, `pg_catalog.relyt_stats_backup` (`contrib/zdb/src/storage/pg_stat_backup.c`).\n\n**Call chain, once per `gPgStatBackupInterval`, inline in the collector's message loop:**\n```\nPgstatCollectorMain() pgstat.c:5797-5806\n → BackupPgStat_hook() = BackupPgStat() pg_stat_backup.c:208\n → StartTransactionCommand()\n → DoBackup(dbs) pg_stat_backup.c:225\n → UpdateRelytStatsBackup(...) pg_stat_backup.c:315\n → heap_open(relyt_stats_backup, RowExclusiveLock) :335\n → CommitTransactionCommand()\n```\n\nTo be able to run a real transaction (`heap_open`, `CatalogTupleInsert/Update`, MVCC snapshots), the collector now calls `InitProcess()` + `InitPostgres(\"postgres\", ...)` at startup (pgstat.c:5688-5697), which it never did before — that's the \"recent change.\" This turns the collector into a full lock-manager participant with a real `PGPROC`/shared latch, for the first time.\n\n**Why that causes a hang:** `heap_open(..., RowExclusiveLock)` takes a normal, *blocking* heavyweight lock (`LockRelationOid` → `ProcSleep` if there's a conflict). If any other session holds a stronger lock on `relyt_stats_backup` (autovacuum, a long `SELECT ... FOR UPDATE`, DDL, a `pg_dump`/backup tool, an idle-in-transaction session, etc.), the collector itself blocks in `ProcSleep`. Since there is exactly **one** stats collector process per segment, and it's now stuck deep inside `DoBackup()`, it stops draining `pgStatSock` and stops calling `pgstat_write_statsfiles()` entirely — every other backend on that segment calling `backend_read_statsfile()` (autovacuum, ANALYZE, `pg_stat_*` views, planner stats, etc.) degrades, and anything relying on a completed backup round has no bound on how long it waits. That's \"the collector fails to release the backends waiting on it.\"\n\nA companion bug made this worse: waking a process out of `ProcSleep` requires the lock releaser to `SetLatch()` the waiter's *shared* PGPROC latch, which the kernel delivers as `SIGUSR1`. `PgstatCollectorMain()` used to do `pqsignal(SIGUSR1, SIG_IGN)` (dating from when the collector only ever used a local, single-process latch). Once `InitProcess()` was added, an ignored `SIGUSR1` meant the collector could **never** wake up again after blocking — a true permanent hang. Commit `34fbfd18354` (\"fix #1846: process SIGUSR1 in pgstat after InitPostgres()\") already patched this by installing `pgstat_sigusr1_handler()` (pgstat.c:6041) → `latch_sigusr1_handler()`. I verified that fix is structurally correct (`InitProcess()` does `OwnLatch(\u0026MyProc-\u003eprocLatch); SwitchToSharedLatch();`, so `MyLatch` legitimately becomes the shared latch, and signals are safely queued rather than lost during the blocked-signal startup window).\n\n**That existing fix is necessary but not sufficient** — it only guarantees the collector *eventually* wakes once the conflicting lock is released; it does nothing to bound *how long* that can take, and it doesn't remove the underlying design flaw: a singleton process every backend depends on for fresh statistics must never be allowed to block on a lock a backend can hold indefinitely. That's why the hang is intermittent (\"sometimes\") — it only manifests when something else happens to be holding a conflicting lock on `relyt_stats_backup` at the moment the backup interval fires.\n\n## Proposed fix\n\n1. **Make the backup's lock acquisition non-blocking** (`contrib/zdb/src/storage/pg_stat_backup.c:335`, `UpdateRelytStatsBackup`): replace the blocking `heap_open(oid, RowExclusiveLock)` with `ConditionalLockRelationOid(oid, RowExclusiveLock)` (declared in `src/include/storage/lmgr.h:43`) followed by `heap_open(oid, NoLock)` on success. If the conditional lock fails, have `DoBackup`/`BackupPgStat` return `false` immediately (skip this round — `CheckPgStatBackupInterval_hook()` will retry next interval). This guarantees `PgstatCollectorMain()`'s loop can never be parked in `ProcSleep`, independent of whatever else the SIGUSR1 fix does.\n\n2. **Better: move the backup off the collector process entirely.** The collector's only job should stay \"drain the UDP socket, write flat stat files\" — the historical invariant that's why it never had a `PGPROC`/took locks in the first place. `DoBackup()` only reads the already-written stat files off disk (`ReadStatFile`, `pgstat_stat_filename` / `get_dbstat_filename`) and writes them into a catalog table — that work doesn't need to run inside the collector at all. Run it from a small dedicated background worker (or a scheduled job) triggered by `gPgStatBackupInterval`, doing its own `StartTransactionCommand`/`heap_open`/commit in its own process. This removes the need for `InitProcess()`/`InitPostgres()`/the SIGUSR1 plumbing in `PgstatCollectorMain()` altogether, eliminating the whole class of \"collector blocks on a backend's lock\" risk rather than just mitigating it.\n\n3. **Keep** the SIGUSR1 handler from `34fbfd18354` regardless of which option is chosen — it's a correct fix for the general \"collector participates in the lock manager\" hazard and should stay as defense-in-depth even after (1) or (2) removes the common trigger.\n\nI'd recommend (2) as the real fix and (1) as a minimal, low-risk stopgap if a fast patch is needed first.","input_tokens":99,"output_tokens":46118,"duration_s":577.503000585,"timed_out":false}} +{"kind":"verdict","task_id":"fix-003","arm":"memory","run":0,"verdict":{"score":5,"points_hit":["The bug is in src/backend/postmaster/pgstat.c, in PgstatCollectorMain()","Once the collector calls InitProcess()/InitPostgres() and starts using shared latches, it must handle SIGUSR1 to receive latch wakeups, or waiters relying on it will hang","Fix: install a real SIGUSR1 handler (pgstat_sigusr1_handler) that calls latch_sigusr1_handler(), replacing SIG_IGN"],"points_missed":["Does not identify that the collector explicitly does pqsignal(SIGUSR1, SIG_IGN) as the primary/sole root cause — instead frames it as a secondary 'companion bug' and attributes the main hang to a separate ConditionalLock design flaw not in the rubric","Does not mention the handler must save/restore errno around the call"],"reasoning":"The agent correctly locates PgstatCollectorMain, identifies SIGUSR1/SIG_IGN and the correct fix (pgstat_sigusr1_handler → latch_sigusr1_handler), but frames this as already-fixed by a prior commit and treats it as secondary to a self-invented blocking-lock root cause not supported by the rubric, and omits the errno save/restore detail."}} +{"kind":"run","task_id":"gotcha-001","arm":"no-memory","run":0,"result":{"task_id":"gotcha-001","arm":"no-memory","result_text":"Confirmed — that's exactly the stale-header issue. The installed header/lib (`contrib/zdb/third_party/install/{include/delta_kernel_ffi.h,lib/libdelta_kernel_ffi.so}`) are dated **Jun 16**, while your rebase onto latest main pulled the `delta-kernel-rs` submodule forward to `995f844` (well past that). The C consumer `zdb_deltalake_export.c` was updated for the new submodule commit's enum variants (hence `PrimitiveType*` undeclared / schema-inconsistent at runtime), but Cargo's incremental build in `target/` thinks nothing changed and skips regenerating+copying the header into `install/include`.\n\n**Root cause:** `git submodule update --init --recursive` only moves the submodule's checked-out commit — it does not rebuild delta-kernel-rs or refresh the installed artifacts. The stale installed header/lib are what your C code and linker actually see.\n\n**Fix** (from CLAUDE.md's mandatory pre-build steps):\n\n```bash\ncd /workspace/phoenix\nrm -f contrib/zdb/third_party/install/include/delta_kernel_ffi.h\nrm -f contrib/zdb/third_party/install/lib/libdelta_kernel_ffi.so\nsource /opt/rh/gcc-toolset-11/enable\n(cd contrib/zdb/third_party \u0026\u0026 ./delta-kernel-rs/relyt/build.sh release install)\n```\n\nDeleting the artifacts first forces the build script to actually regenerate/copy them instead of relying on Cargo's incremental cache. After that, rerun your full Neon build — the `PrimitiveType* undeclared` compile error and the `Schema inconsistent` runtime error should both go away since they're the same root cause (stale FFI header/lib), just surfacing at compile-time vs. run-time depending on whether you'd already partially built.","input_tokens":4,"output_tokens":1310,"duration_s":20.255161652,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-001","arm":"no-memory","run":0,"verdict":{"score":10,"points_hit":["identified stale delta-kernel-rs FFI header as root cause with correct incremental-build short-circuit explanation","gave exact fix commands removing header and .so","specified sourcing gcc-toolset-11 before rebuild","gave correct rebuild command via delta-kernel-rs/relyt/build.sh release install","advised rerunning full build after fix rather than assuming full build alone fixes it, implying this is a recurring pre-build step"],"points_missed":[],"reasoning":"Answer correctly diagnoses the stale FFI header/incremental-cache issue, gives the exact deletion and rebuild commands including sourcing gcc-toolset-11, and does not fall into any of the listed gotchas (no assumption submodule update alone fixes it, no skipping toolset sourcing, no blind re-run of full build without the manual fix)."}} +{"kind":"run","task_id":"gotcha-001","arm":"memory","run":0,"result":{"task_id":"gotcha-001","arm":"memory","result_text":"Confirmed — this matches a known gotcha, already documented in this repo's CLAUDE.md pre-build section, that you've hit before (the header file exists but is stale, timestamped Jun 16).\n\n**Cause:** `git submodule update --init --recursive` only checks out the `delta-kernel-rs` source at the right commit — it doesn't rebuild it. The full build's `neon_build_extensions.sh` does call `delta-kernel-rs/relyt/build.sh`, but Cargo's incremental cache can decide \"nothing changed\" and skip regenerating/copying `delta_kernel_ffi.h` and `libdelta_kernel_ffi.so` into `contrib/zdb/third_party/install/`. Meanwhile `zdb_deltalake_export.c` (which references `PrimitiveType*` enum variants) was already updated by the rebase to expect the new header. Two symptoms depending on which side is stale:\n- **Compile time**: stale header missing new enum variants → `PrimitiveType* undeclared`.\n- **Runtime**: header is new/matches but the `.so` (or vice versa) is stale → schema mismatch → `Create write context failed [errno: -1, msg: Schema inconsistent]`.\n\n**Fix** — force Cargo to regenerate rather than trust its incremental cache:\n\n```bash\nrm -f contrib/zdb/third_party/install/include/delta_kernel_ffi.h\nrm -f contrib/zdb/third_party/install/lib/libdelta_kernel_ffi.so\nsource /opt/rh/gcc-toolset-11/enable\n(cd contrib/zdb/third_party \u0026\u0026 ./delta-kernel-rs/relyt/build.sh release install)\n```\n\nThen rerun your full Neon build (`sh neon/cicd/build.sh`). This should be step 2 of the mandatory pre-build routine after every rebase/pull/branch-switch, per `CLAUDE.md` — worth doing preemptively next time before kicking off a full build.","input_tokens":4,"output_tokens":1163,"duration_s":17.817611112,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-001","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["identified stale delta-kernel-rs FFI header as root cause despite submodule update","explained Rust/Cargo incremental build can short-circuit header/lib copy","correct fix: rm the installed header and lib","correctly requires sourcing gcc-toolset-11 before rebuild","correct rebuild command via delta-kernel-rs/relyt/build.sh release install","notes this must be repeated after every rebase/pull/branch switch","did not assume submodule update alone was sufficient","did not skip gcc-toolset-11 sourcing","did not just re-run full build blindly, gave targeted fix first"],"points_missed":[],"reasoning":"Answer correctly identifies the stale FFI header/lib caused by Cargo's incremental cache, gives the exact rm + gcc-toolset-11 + rebuild sequence matching the rubric, and explicitly notes recurrence after rebases; no gotchas violated."}} +{"kind":"run","task_id":"gotcha-003","arm":"no-memory","run":0,"result":{"task_id":"gotcha-003","arm":"no-memory","result_text":"**Root cause:** `zdb_get_metainfo_for_file` / `_for_drop_flag` / `_for_visimap` are global introspection UDFs — they scan *all* FDB keys of that kind cluster-wide, not just the ones for your table. A bare `select count(*) from zdb_get_metainfo_for_file(...)` will also pick up tombstone/segfile/visimap/drop-flag keys left behind by whatever other tests in the schedule ran before yours (or are running concurrently, since the schedule shares one cluster + FDB instance). That's exactly why it passes standalone but flakes in the full run — no other test's leftover keys are around to pollute the global scan.\n\n**Fix:** capture your table's `relfilenode` into a helper table *before* the `DROP`, then join every introspection query against it instead of taking a raw `count(*)`. This repo already has the pattern in `contrib/zdb/regress/sql/backup_and_gc/setup_backup.sql`:\n\n```sql\n-- before DROP TABLE, record the relfilenode\nCREATE TABLE IF NOT EXISTS relfilenode_record_for_drop_tbl(relname text, relfilenode oid);\nINSERT INTO relfilenode_record_for_drop_tbl\n SELECT 'your_tbl', relfilenode FROM pg_class WHERE relname = 'your_tbl';\n\nDROP TABLE your_tbl;\n\n-- then scope every introspection query to that relfilenode\nSELECT count(*) FROM\n(\n SELECT tablespace, dbid, relationid, xmin FROM zdb_get_metainfo_for_file(2, \u003cspace\u003e, 'S')\n) a\nJOIN\n(\n SELECT relfilenode FROM relfilenode_record_for_drop_tbl WHERE relname = 'your_tbl'\n) b ON a.relationid = b.relfilenode;\n```\n\nSame join pattern applies to `zdb_get_metainfo_for_drop_flag` (with `'D'`) and `zdb_get_metainfo_for_visimap` (with `'V'`). You can either inline this join in your test's `.sql` file, or reuse/adapt the `get_meta_file_count` / `get_meta_visimap_count` / `get_meta_drop_flag_count` plpgsql helper functions already defined in `setup_backup.sql` if your schedule loads that setup file.\n\nAlso worth checking in teardown: drop your `relfilenode_record_for_drop_tbl` rows (or use a distinctive table name) so you don't collide with other tests reusing the same helper table name, per the \"Table-name collisions\" guidance in CLAUDE.md.","input_tokens":6,"output_tokens":1543,"duration_s":22.711551673,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-003","arm":"no-memory","run":0,"verdict":{"score":10,"points_hit":["global FDB keys not scoped to test's table","bare count(*) mixes in leftover keys from other tests","fix: capture relfilenode before DROP, join introspection queries on it","references setup_backup.sql as example pattern","did not use bare count(*)","did not assume UDFs are pre-scoped"],"points_missed":["did not explicitly mention pg_catalog.zdb_* catalog tables (zdb_backup_gc_records, zdb_shard_stats) needing the same relfilenode filtering"],"reasoning":"Answer correctly identifies root cause (global FDB scan), explains why it flakes in schedule, and gives the correct relfilenode-join fix with the exact reference file, hitting all core rubric points and both gotcha avoidances; it only omits the catalog-table (zdb_backup_gc_records/zdb_shard_stats) generalization."}} +{"kind":"run","task_id":"gotcha-003","arm":"memory","run":0,"result":{"task_id":"gotcha-003","arm":"memory","result_text":"Now I have the full confirmed pattern. Here's the answer:\n\n## Why it goes flaky\n\n`zdb_get_metainfo_for_file` / `zdb_get_metainfo_for_drop_flag` / `zdb_get_metainfo_for_visimap` are **global FDB introspection UDFs** — they take a tablespace/type filter but not a per-relation filter. They return metadata for *every* table in the tablespace, not just yours. Looking at the actual signature usage (`contrib/zdb/regress/sql/backup_and_gc/setup_backup.sql`):\n\n```sql\nSELECT tablespace, dbid, relationid, xmin FROM zdb_get_metainfo_for_file(2, _space, 'S')\n```\n\nthere's no relation argument at all — `relationid` only shows up as a column in the *result*, which you're supposed to filter yourself.\n\nWhen your test runs alone, your table is the only thing that ever touched that tablespace, so a bare `count(*)` happens to be correct by coincidence. In the full schedule, other tests running earlier (or, per the isolation2 model, other tests/sessions concurrently) leave behind segfile/tombstone/drop-flag/visimap keys for *their* tables in the same tablespace. Your `count(*)` silently sums those in too, so the assertion becomes order- and neighbor-dependent — i.e., flaky.\n\n## The fix\n\nDon't call these UDFs bare. Capture your table's `relfilenode` **before** the `DROP TABLE` (once dropped, `pg_class` no longer has it), stash it in a helper table, then `JOIN` the introspection UDF's output on `relationid = relfilenode`. This is exactly the pattern used throughout `contrib/zdb/regress/sql/backup_and_gc/*` (e.g. `drop_table_left_meta.sql`, `truncate_table_left_meta.sql`, `setup_backup.sql`):\n\n```sql\ncreate table if not exists relfilenode_record_for_drop_tbl(relname text, relfilenode oid, relid oid) using heap;\n\ninsert into relfilenode_record_for_drop_tbl\n select relname, relfilenode, oid from pg_class where oid = 'your_tbl'::regclass;\n-- if you need the count from a segment/utility-mode connection, capture it there too:\n-1U: insert into relfilenode_record_for_drop_tbl\n select relname, relfilenode, oid from pg_class where oid = 'your_tbl'::regclass;\n\ndrop table your_tbl;\n\nselect count(*) from\n(\n select relationid from zdb_get_metainfo_for_file(2, \u003cspace\u003e, 'S')\n) a\njoin\n(\n select relfilenode from relfilenode_record_for_drop_tbl where relname = 'your_tbl'\n) b on a.relationid = b.relfilenode;\n```\n\nSame join shape for `zdb_get_metainfo_for_drop_flag` and `zdb_get_metainfo_for_visimap` — swap the UDF and the type-tag arg (`'D'`, `'V'` respectively), keep the join on `relationid = relfilenode`.\n\nAlso: drop `relfilenode_record_for_drop_tbl` (or use a name unique to your test) in your teardown block, since it's a shared plain table that other tests in the schedule may also create with `create table if not exists`.","input_tokens":14,"output_tokens":3290,"duration_s":53.102502636,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-003","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["FDB-introspection UDFs return global keys not scoped to test's table","bare count(*) mixes in leftover keys from other tests in schedule/cluster","fix: capture relfilenode into temp table before DROP, then JOIN on relationid=relfilenode","referenced setup_backup.sql and drop_table_left_meta.sql as pattern examples","did not use bare count(*)","did not assume UDFs are pre-scoped"],"points_missed":["did not explicitly mention pg_catalog.zdb_* catalog tables (zdb_backup_gc_records, zdb_shard_stats) as having the same global-scope issue"],"reasoning":"Answer correctly identifies the global-scope root cause, explains flakiness mechanism, and provides the exact relfilenode-join fix with correct reference files, matching almost all rubric points; it only omits mention of the pg_catalog.zdb_* catalog tables needing the same treatment."}} From 7ae4e9c8d5e3150a4f2d47c6b73a6fa261ae4c82 Mon Sep 17 00:00:00 2001 From: zhishu <weichuangxian@hotmail.com> Date: Mon, 27 Jul 2026 16:43:12 +0000 Subject: [PATCH 23/29] feat(memory): pinned core-memory block with 1KB cap and stable ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pinned section is the one part of the layered mem_context block the 1600-byte outer cap never drops, so an over-pinning project could starve sessions/observations out of the block from the inside. Give it its own ceiling. Both backends now render `### Pinned (core memory)` with: - a 1024-byte cap over the whole section (heading + entries + marker + separator), enforced before the outer contextByteCap runs, so the outer invariant ("never drops pinned") is unchanged; - entries in pin-time ASCENDING order, keeping the section a stable prefix that grows at the end as new pins arrive; - overflow that drops the OLDEST pins from *rendering* only (nothing is unpinned or deleted) behind a trailing `(pin cap reached: N pinned facts not shown — mem_unpin to prune)`. Pin time is a documented proxy, not a stored timestamp: neither backend records when a pin happened (pinning deliberately does not touch updated_at on SQLite), so ordering uses updated_at falling back to created_at, with id breaking ties. Documented on PinnedObservations, factPinTime and in DOCS.md. Protocol text gains the pin criterion — "only facts whose loss causes irreversible damage or repeated pitfalls" — in all four slim heredocs (kept byte-identical, slim is now 1399/3400 bytes) and both memory SKILL.md files, so an agent learns the cost of pinning in the same session it can pin. --- DOCS.md | 13 +- internal/memorylake/backend.go | 111 +++++++++++- internal/memorylake/backend_test.go | 148 +++++++++++++++- internal/store/store.go | 114 +++++++++++-- internal/store/store_test.go | 161 +++++++++++++++++- plugin/assets_test.go | 38 +++++ plugin/claude-code/scripts/post-compaction.sh | 3 +- plugin/claude-code/scripts/session-start.sh | 3 +- plugin/claude-code/skills/memory/SKILL.md | 9 + plugin/codex/scripts/post-compaction.sh | 3 +- plugin/codex/scripts/session-start.sh | 3 +- plugin/codex/skills/memory/SKILL.md | 9 + 12 files changed, 584 insertions(+), 31 deletions(-) diff --git a/DOCS.md b/DOCS.md index 307021eb..cd77fc7b 100644 --- a/DOCS.md +++ b/DOCS.md @@ -909,8 +909,9 @@ The block is layered and budgeted (~400 tokens / 1600 bytes hard cap) so it is c ``` ## Memory Context -### Pinned -- [decision] **title**: <content, cut to 300 runes> +### Pinned (core memory) +- [decision] **title**: <content, cut to 300 runes> (oldest pin first, section ≤ 1024 bytes) +(pin cap reached: N pinned facts not shown — mem_unpin to prune) (only when the 1024 bytes run out) ### Recent Sessions - <started_at> <summary first sentence | (active) | (no summary)> (max 5) @@ -921,7 +922,13 @@ The block is layered and budgeted (~400 tokens / 1600 bytes hard cap) so it is c Full bodies: mem_search / mem_get_observation. ``` -Overflow past the cap drops the oldest observation lines first, then the oldest session lines; pinned content is never dropped. Sections with no rows are omitted entirely, and an empty project returns an empty string. In a cross-project render (blank project filter — e.g. `scope: personal` with no `project` override) each session line is prefixed with its project: `- <started_at> [<project>] <summary>`. The MemoryLake backend renders the identical layout minus `### Recent Sessions` (it has no session tracking). +Overflow past the cap drops the oldest observation lines first, then the oldest session lines; pinned content is never dropped by that outer cap. + +**`### Pinned (core memory)` is capped separately at 1024 bytes** — heading, entry lines, overflow marker and blank separator included. Entries render in **pin-time ascending order** (oldest pin first), so the section is a stable prefix that grows at the end as new pins arrive. When the 1024 bytes are exhausted, the **oldest** pins stop being *rendered* (they stay pinned and searchable) and a trailing `(pin cap reached: N pinned facts not shown — mem_unpin to prune)` line names how many were withheld. Over-pinning therefore evicts your own earlier pins from the block; `mem_unpin` is the prune. + +Pin time is a documented **proxy**, not a stored timestamp: neither backend records when a pin happened (`mem_pin` only flips `observations.pinned` / the MemoryLake `pinned` metadata key and deliberately leaves `updated_at` untouched on SQLite), so the section orders by `updated_at`, falling back to `created_at`. Consequence: re-pinning an old memory does not move it to the end of the section, while editing a pinned memory's content does. Ordering is deterministic either way (id breaks ties). + +Sections with no rows are omitted entirely, and an empty project returns an empty string. In a cross-project render (blank project filter — e.g. `scope: personal` with no `project` override) each session line is prefixed with its project: `- <started_at> [<project>] <summary>`. The MemoryLake backend renders the identical layout minus `### Recent Sessions` (it has no session tracking). Recent user prompts are **not** part of this block (they cost more than they buy inside the budget). The intended Wave-1 posture is that **prompts are write-only for stdio agents**: Claude Code, Codex, Gemini, Cursor, and Windsurf reach Engram over stdio MCP, which exposes `mem_save_prompt` but no prompt-read tool. Saved prompts are readable only through the `engram serve` HTTP API (`GET /prompts/recent`, `GET /prompts/search`) — used by the OpenCode plugin and the Pi extension, which already run `engram serve` — and through the cloud dashboard. Prompts still feed `mem_save --capture_prompt` within the same MCP process, so stdio agents get their value indirectly rather than by reading them back. diff --git a/internal/memorylake/backend.go b/internal/memorylake/backend.go index 81913b98..38ff57ec 100644 --- a/internal/memorylake/backend.go +++ b/internal/memorylake/backend.go @@ -420,6 +420,19 @@ const maxFormatContextRecent = 20 // contextPinnedTruncLen surfaces both copies of the rule at once. const contextPinnedTruncLen = 300 +// Pinned core-memory contract (Phase 2 spec R4) — byte-identical copies of +// internal/store's constants of the same names, duplicated for the same reason +// as contextByteCap above (they are unexported presentation details there). See +// internal/store/store.go for the full rationale; the short version: the pinned +// section is the one block the outer contextByteCap never drops, so it carries +// its own 1024-byte ceiling, renders pin-time ASCENDING, and on overflow drops +// the OLDEST pins from rendering behind a marker. +const ( + pinnedSectionByteCap = 1024 + pinnedSectionHeading = "### Pinned (core memory)\n" + pinnedCapMarkerFmt = "(pin cap reached: %d pinned facts not shown — mem_unpin to prune)\n" +) + // truncate mirrors internal/store's unexported truncate(s, max): a rune-safe // cut to at most max runes, with a literal "..." appended when s was longer. // Copied here (not exported by internal/store) — see @@ -508,11 +521,59 @@ func nextNonSpace(runes []rune) rune { return 0 } -// renderLayeredContext assembles the layered block and enforces -// contextByteCap by dropping the oldest observation line (this backend has no -// session lines — see FormatContext). Pinned lines are never dropped. Mirrors -// internal/store's renderLayeredContext. +// capPinnedLines enforces pinnedSectionByteCap over the rendered pinned +// section. lines arrive in pin-time ASCENDING order (oldest pin first, see +// factPinTime), so overflow is resolved by dropping from the FRONT — oldest pins +// stop being rendered until the newest ones fit — and a marker line naming the +// number withheld is appended. Nothing is unpinned or deleted; the marker points +// at mem_unpin as the way to prune for good. +// +// Canonical twin: internal/store.capPinnedLines. Keep byte-identical. +func capPinnedLines(lines []string) []string { + if len(lines) == 0 { + return lines + } + for dropped := 0; dropped < len(lines); dropped++ { + marker := "" + if dropped > 0 { + marker = fmt.Sprintf(pinnedCapMarkerFmt, dropped) + } + shown := lines[dropped:] + if pinnedSectionBytes(shown, marker) > pinnedSectionByteCap { + continue + } + if marker == "" { + return shown + } + out := make([]string, 0, len(shown)+1) + out = append(out, shown...) + return append(out, marker) + } + // Only reached when even the newest entry alone exceeds the cap: core + // memory is never emptied, so it is rendered over cap rather than dropped. + return []string{lines[len(lines)-1], fmt.Sprintf(pinnedCapMarkerFmt, len(lines)-1)} +} + +// pinnedSectionBytes is the rendered size of the pinned section: heading + +// entry lines + overflow marker + the blank separator writeContextSection +// appends. Twin of internal/store.pinnedSectionBytes. +func pinnedSectionBytes(lines []string, marker string) int { + n := len(pinnedSectionHeading) + len(marker) + 1 + for _, line := range lines { + n += len(line) + } + return n +} + +// renderLayeredContext assembles the layered block: the pinned section is +// capped first and on its own (capPinnedLines), then contextByteCap is enforced +// over the whole block by dropping the oldest observation line (this backend has +// no session lines — see FormatContext). Pinned lines are never dropped by the +// outer cap. Mirrors internal/store's renderLayeredContext. func renderLayeredContext(pinnedLines, obsLines []string) string { + // Once, before the loop — capPinnedLines appends a marker line, so a second + // pass would count it as an entry and report the wrong number withheld. + pinnedLines = capPinnedLines(pinnedLines) for { out := joinLayeredContext(pinnedLines, obsLines) if len(out) <= contextByteCap { @@ -529,7 +590,7 @@ func renderLayeredContext(pinnedLines, obsLines []string) string { func joinLayeredContext(pinnedLines, obsLines []string) string { var b strings.Builder b.WriteString("## Memory Context\n\n") - writeContextSection(&b, "### Pinned\n", pinnedLines) + writeContextSection(&b, pinnedSectionHeading, pinnedLines) writeContextSection(&b, "### Recent Observations\n", obsLines) b.WriteString(contextFooter) return b.String() @@ -547,8 +608,9 @@ func writeContextSection(b *strings.Builder, heading string, lines []string) { } // FormatContext renders the layered context block of the project's facts, -// optionally filtered by scope: pinned facts (metadata["pinned"] == true) -// first, then up to contextRecentObs first-sentence summaries of the most +// optionally filtered by scope: pinned facts (metadata["pinned"] == true) first +// — pin-time ascending and self-capped at pinnedSectionByteCap — then up to +// contextRecentObs first-sentence summaries of the most // recent unpinned facts, then the expand-on-demand footer — the same layout // (and the same contextByteCap budget) as the local store's FormatContext. // @@ -586,8 +648,9 @@ func (b *MemoryLakeBackend) FormatContext(project, scope string) (string, error) recent = append(recent, f) } } - // Most-recent-first within each group. - reverseFacts(pinned) + // Pinned renders pin-time ascending (see factPinTime); recent renders + // most-recent-first. + sortFactsByPinTimeAsc(pinned) reverseFacts(recent) if len(recent) > maxFormatContextRecent { recent = recent[:maxFormatContextRecent] @@ -857,6 +920,36 @@ func (b *MemoryLakeBackend) forgetFact(factID string) error { return b.client.doJSON("POST", path, nil, nil) } +// factPinTime returns the ordering key for the pinned section. +// +// PIN-TIME PROXY (documented limitation, mirrors internal/store's +// PinnedObservations): MemoryLake records no pin timestamp — setPinned only +// PATCHes metadata["pinned"] = true — so the fact's UpdatedAt is used, falling +// back to CreatedAt when the API returns no updated_at. Consequence: a PATCH +// that touches the fact (including the pin PATCH itself, if the server bumps +// updated_at) moves it to the end of the section, while re-pinning a fact whose +// updated_at the server leaves alone does not. Ordering stays deterministic +// either way (ID breaks ties). +func factPinTime(f Fact) string { + if t := strings.TrimSpace(f.UpdatedAt); t != "" { + return t + } + return f.CreatedAt +} + +// sortFactsByPinTimeAsc orders facts oldest-pin-first by the factPinTime proxy. +// String comparison, like sortFactsByCreatedAsc — MemoryLake timestamps are +// fixed-width RFC3339 UTC, so lexical order is chronological order. +func sortFactsByPinTimeAsc(facts []Fact) { + sort.SliceStable(facts, func(i, j int) bool { + ti, tj := factPinTime(facts[i]), factPinTime(facts[j]) + if ti != tj { + return ti < tj + } + return facts[i].ID < facts[j].ID + }) +} + // sortFactsByCreatedAsc orders facts by created_at ascending, falling back to // id for stable ordering when timestamps tie or are empty. func sortFactsByCreatedAsc(facts []Fact) { diff --git a/internal/memorylake/backend_test.go b/internal/memorylake/backend_test.go index 46ed0b88..3753caef 100644 --- a/internal/memorylake/backend_test.go +++ b/internal/memorylake/backend_test.go @@ -956,8 +956,13 @@ func TestBackend_FormatContext_ByteCapDropsOldestObservationNeverPinned(t *testi if len(out) > contextByteCap { t.Fatalf("context %d bytes, cap %d:\n%s", len(out), contextByteCap, out) } - // Pinned lines are never dropped: all three survive, each truncated to - // contextPinnedTruncLen runes. + // Pinned lines are never dropped by the OUTER cap: all three survive, each + // truncated to contextPinnedTruncLen runes. Note the fixture also sits just + // under the separate pinned-section cap — heading (25) + 3 × 331-byte lines + // + blank separator = 1019 of pinnedSectionByteCap's 1024 bytes — so a + // fourth pinned fact (or a longer title here) would legitimately trip the + // pin cap and make this assertion fail; TestBackend_FormatContext_ + // PinnedSectionCapAndOrder is where that behavior belongs. if got := strings.Count(out, "PINNED-MARKER"); got != 3 { t.Fatalf("all 3 pinned lines must survive the byte cap, found %d:\n%s", got, out) } @@ -986,13 +991,150 @@ func TestBackend_FormatContext_ByteCapDropsOldestObservationNeverPinned(t *testi assertBackendContextSectionOrder(t, out) } +// backendPinnedSectionBody returns the rendered pinned section of a context +// block — heading, entry lines, overflow marker when present, and the blank +// separator writeContextSection appends. Twin of internal/store's +// pinnedSectionBody: sections are separated by exactly one blank line and no +// entry line is empty, so the first "\n\n" after the heading ends the section. +func backendPinnedSectionBody(t *testing.T, out string) string { + t.Helper() + start := strings.Index(out, pinnedSectionHeading) + if start < 0 { + t.Fatalf("pinned heading %q not found in context block:\n%s", pinnedSectionHeading, out) + } + rest := out[start:] + end := strings.Index(rest[len(pinnedSectionHeading):], "\n\n") + if end < 0 { + t.Fatalf("pinned section is not terminated by a blank line:\n%s", out) + } + return rest[:len(pinnedSectionHeading)+end+2] +} + +// TestBackend_FormatContext_PinnedSectionCapAndOrder is the MemoryLake twin of +// internal/store's TestFormatContextPinnedSectionCapAndOrder: the pinned section +// is capped at pinnedSectionByteCap on its own, entries render in pin-time +// ASCENDING order, and overflow drops the OLDEST pins behind a marker naming how +// many were withheld. +// +// The fixture pins 15 facts whose rendered lines are exactly 120 bytes each +// (1800 bytes, well past the cap) with updated_at ASCENDING by pin index and +// created_at DESCENDING — MemoryLake records no pin timestamp (setPinned only +// PATCHes metadata["pinned"]), so UpdatedAt is the documented pin-time proxy and +// the inversion makes an implementation that ordered by created_at fail here. +// +// Regression net for the same four mutations as the store twin: (a) deleting the +// cap loop, (b) reversing to newest-first, (c) ordering by created_at, (d) +// dropping more pins than the cap requires. +func TestBackend_FormatContext_PinnedSectionCapAndOrder(t *testing.T) { + const ( + totalPins = 15 + // "- [decision] **pin-NN**: " (25) + body (94) + "\n" = 120 bytes. + lineBytes = 120 + ) + body := func(i int) string { + return fmt.Sprintf("core fact %02d ", i) + strings.Repeat("y", 81) + } + var items []map[string]any + for i := 0; i < totalPins; i++ { + items = append(items, map[string]any{ + "id": fmt.Sprintf("fact-pin-%02d", i), + "fact": body(i), + // created_at descends with i, updated_at (the pin-time proxy) ascends. + "created_at": fmt.Sprintf("2026-02-%02dT00:00:00Z", 20-i), + "updated_at": fmt.Sprintf("2026-03-01T%02d:00:00Z", i), + "metadata": map[string]any{ + metaTitle: fmt.Sprintf("pin-%02d", i), metaType: "decision", + metaScope: "global", "pinned": true, + }, + }) + } + // One unpinned fact so the block still has a Recent Observations section. + items = append(items, map[string]any{ + "id": "fact-recent", "fact": "recent unpinned body.", + "created_at": "2026-03-02T00:00:00Z", + "metadata": map[string]any{metaTitle: "recent", metaType: "note", metaScope: "global"}, + }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/api/v3/workspaces/ws-1/projects/proj-1/memories/facts") { + json.NewEncoder(w).Encode(map[string]any{"success": true, "data": map[string]any{"items": items}}) + return + } + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + })) + defer srv.Close() + b := newTestBackend(t, srv.URL) + + // Sanity: the fixture must render past the pinned cap before any dropping. + if precap := len(pinnedSectionHeading) + totalPins*lineBytes + 1; precap <= pinnedSectionByteCap { + t.Fatalf("fixture renders %d pinned bytes, already under the %d-byte cap: enlarge it", + precap, pinnedSectionByteCap) + } + + out, err := b.FormatContext("proj", "global") + if err != nil { + t.Fatalf("FormatContext: %v", err) + } + + section := backendPinnedSectionBody(t, out) + if len(section) > pinnedSectionByteCap { + t.Fatalf("pinned section is %d bytes, cap %d:\n%s", len(section), pinnedSectionByteCap, section) + } + + shown := strings.Count(section, "**pin-") + if shown == 0 { + t.Fatalf("pinned section dropped every entry; core memory must keep the newest:\n%s", section) + } + if shown == totalPins { + t.Fatalf("all %d pinned entries rendered: the cap was not enforced:\n%s", totalPins, section) + } + + marker := fmt.Sprintf(pinnedCapMarkerFmt, totalPins-shown) + if !strings.Contains(section, marker) { + t.Fatalf("expected overflow marker %q in:\n%s", marker, section) + } + + for i := 0; i < totalPins-shown; i++ { + if strings.Contains(section, fmt.Sprintf("**pin-%02d**", i)) { + t.Fatalf("oldest pin pin-%02d must be dropped from rendering:\n%s", i, section) + } + } + for i := totalPins - shown; i < totalPins; i++ { + if !strings.Contains(section, fmt.Sprintf("**pin-%02d**", i)) { + t.Fatalf("newest pin pin-%02d must survive the pinned cap:\n%s", i, section) + } + } + + prev := -1 + for i := totalPins - shown; i < totalPins; i++ { + at := strings.Index(section, fmt.Sprintf("**pin-%02d**", i)) + if at <= prev { + t.Fatalf("pinned entries must render in pin-time ascending order; pin-%02d at %d follows %d:\n%s", + i, at, prev, section) + } + prev = at + } + + if len(section)+lineBytes <= pinnedSectionByteCap { + t.Fatalf("pinned section is %d bytes with %d entries: one more would still fit under %d, "+ + "the cap drops more than necessary:\n%s", len(section), shown, pinnedSectionByteCap, section) + } + + if len(out) > contextByteCap { + t.Fatalf("context %d bytes, cap %d:\n%s", len(out), contextByteCap, out) + } + if !strings.Contains(out, "Full bodies: mem_search / mem_get_observation.") { + t.Fatalf("footer must survive:\n%s", out) + } +} + // assertBackendContextSectionOrder pins the layered section order of a // MemoryLake context block: Pinned before Recent Observations, and no // "### Recent Sessions" section at all (this backend has no session tracking). // Mirrors internal/store's assertContextSectionOrder, minus sessions. func assertBackendContextSectionOrder(t *testing.T, out string) { t.Helper() - pinned := strings.Index(out, "### Pinned") + pinned := strings.Index(out, pinnedSectionHeading) observations := strings.Index(out, "### Recent Observations") if pinned < 0 || observations < 0 { t.Fatalf("expected both section headings (pinned=%d observations=%d):\n%s", pinned, observations, out) diff --git a/internal/store/store.go b/internal/store/store.go index b9a2e2f2..5f3db638 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -2422,6 +2422,21 @@ func (s *Store) RecentObservations(project, scope string, limit int) ([]Observat return s.queryObservations(query, args...) } +// PinnedObservations returns a project's pinned observations in PIN-TIME +// ASCENDING order (oldest pin first), which is the order FormatContext renders +// the "### Pinned (core memory)" section in — a stable prefix that only grows at +// the end as new pins arrive. +// +// PIN-TIME PROXY (documented limitation): there is no pin timestamp in the +// schema. setObservationPinned flips observations.pinned and deliberately leaves +// updated_at alone (TestPinnedObservationsAndFormatContextPriority pins that +// behavior — pinning is not a content edit and must not disturb sync/export), so the +// closest available ordering key is updated_at — the last time the row's content +// changed, falling back to created_at for rows written before updated_at existed. +// Consequence: re-pinning an old observation does NOT move it to the end of the +// section, and editing a pinned observation's content DOES. Ordering is stable +// and deterministic either way (id breaks ties); a true pinned_at column is the +// fix if the distinction ever matters. func (s *Store) PinnedObservations(project, scope string) ([]Observation, error) { project, _ = NormalizeProject(project) @@ -2441,7 +2456,7 @@ func (s *Store) PinnedObservations(project, scope string) ([]Observation, error) args = append(args, normalizeScope(scope)) } - query += " ORDER BY datetime(o.created_at) DESC, o.id DESC" + query += " ORDER BY datetime(COALESCE(NULLIF(o.updated_at, ''), o.created_at)) ASC, o.id ASC" return s.queryObservations(query, args...) } @@ -3319,6 +3334,28 @@ const ( // the pinned block only (recent observations now use firstSentence). const contextPinnedTruncLen = 300 +// Pinned core-memory contract (Phase 2 spec R4). The pinned block is the one +// section the outer contextByteCap never drops, so it needs its own ceiling: +// without one, a project that pins 50 facts starves sessions/observations out of +// the block entirely and blows the 400-token budget from the inside. +// +// - pinnedSectionByteCap bounds the WHOLE rendered section (heading + entry +// lines + overflow marker + the blank separator writeContextSection adds), +// so it is the number a caller can verify by measuring the output. +// - Entries render in pin-time ASCENDING order (oldest pin first), which keeps +// the section a stable prefix as new pins arrive at the end. +// - Overflow drops the OLDEST pins from *rendering* (never from storage) until +// the newest ones fit, and appends pinnedCapMarkerFmt naming how many were +// withheld plus the way to prune them for good. +// +// internal/memorylake carries byte-identical copies of all three (its +// FormatContext must render the same block) — keep them in sync. +const ( + pinnedSectionByteCap = 1024 + pinnedSectionHeading = "### Pinned (core memory)\n" + pinnedCapMarkerFmt = "(pin cap reached: %d pinned facts not shown — mem_unpin to prune)\n" +) + // contextFooter closes every context block with the expand-on-demand pointer: // the block carries first sentences only, full bodies are one call away. const contextFooter = "Full bodies: mem_search / mem_get_observation.\n" @@ -3384,12 +3421,68 @@ func nextNonSpace(runes []rune) rune { return 0 } -// renderLayeredContext assembles the layered block and enforces -// contextByteCap by dropping the oldest observation line, then the oldest -// session line, until the rendered block fits. Every slice is newest-first, so -// "oldest" is the last element. Pinned lines are never dropped: if a block is -// still over cap once observations and sessions are gone, it is returned as-is. +// capPinnedLines enforces pinnedSectionByteCap over the rendered pinned +// section. lines arrive in pin-time ASCENDING order (oldest pin first, see +// PinnedObservations), so overflow is resolved by dropping from the FRONT — +// oldest pins stop being rendered until the newest ones fit — and a marker line +// naming the number withheld is appended. +// +// Nothing is deleted: dropped pins stay pinned in storage and remain reachable +// via mem_search / mem_get_observation; the marker points at mem_unpin as the +// way to prune them for good. +// +// Canonical twin: internal/memorylake.capPinnedLines. Keep byte-identical. +func capPinnedLines(lines []string) []string { + if len(lines) == 0 { + return lines + } + for dropped := 0; dropped < len(lines); dropped++ { + marker := "" + if dropped > 0 { + marker = fmt.Sprintf(pinnedCapMarkerFmt, dropped) + } + shown := lines[dropped:] + if pinnedSectionBytes(shown, marker) > pinnedSectionByteCap { + continue + } + if marker == "" { + return shown + } + out := make([]string, 0, len(shown)+1) + out = append(out, shown...) + return append(out, marker) + } + // Unreachable in practice: the loop's last iteration already tries "newest + // entry alone". It is only taken when even that single entry exceeds the + // cap, and the answer is the same — core memory is never emptied, so the + // newest pin is rendered over cap rather than dropped. + return []string{lines[len(lines)-1], fmt.Sprintf(pinnedCapMarkerFmt, len(lines)-1)} +} + +// pinnedSectionBytes is the rendered size of the pinned section: heading + +// entry lines + the overflow marker (empty when there is none) + the blank +// separator writeContextSection appends. This is exactly what a caller measures +// in the output, which is what pinnedSectionByteCap is defined against. +func pinnedSectionBytes(lines []string, marker string) int { + n := len(pinnedSectionHeading) + len(marker) + 1 + for _, line := range lines { + n += len(line) + } + return n +} + +// renderLayeredContext assembles the layered block. The pinned section is +// capped first and on its own (capPinnedLines), then contextByteCap is enforced +// over the whole block by dropping the oldest observation line, then the oldest +// session line, until it fits. Sessions and observations are newest-first, so +// "oldest" is the last element. Pinned lines are never dropped by the outer cap: +// if a block is still over cap once observations and sessions are gone, it is +// returned as-is. func renderLayeredContext(pinnedLines, sessionLines, obsLines []string) string { + // Once, before the loop: capPinnedLines appends a marker line, so running + // it again on its own output would count that marker as an entry and + // report the wrong number withheld. + pinnedLines = capPinnedLines(pinnedLines) for { out := joinLayeredContext(pinnedLines, sessionLines, obsLines) if len(out) <= contextByteCap { @@ -3410,7 +3503,7 @@ func renderLayeredContext(pinnedLines, sessionLines, obsLines []string) string { func joinLayeredContext(pinnedLines, sessionLines, obsLines []string) string { var b strings.Builder b.WriteString("## Memory Context\n\n") - writeContextSection(&b, "### Pinned\n", pinnedLines) + writeContextSection(&b, pinnedSectionHeading, pinnedLines) writeContextSection(&b, "### Recent Sessions\n", sessionLines) writeContextSection(&b, "### Recent Observations\n", obsLines) b.WriteString(contextFooter) @@ -3429,9 +3522,10 @@ func writeContextSection(b *strings.Builder, heading string, lines []string) { } // FormatContext renders the layered context block for a project: pinned -// observations, then up to contextRecentSessions one-line session entries, then -// up to contextRecentObs observation summaries, then the expand-on-demand -// footer — all within contextByteCap bytes. +// observations (pin-time ascending, self-capped at pinnedSectionByteCap), then +// up to contextRecentSessions one-line session entries, then up to +// contextRecentObs observation summaries, then the expand-on-demand footer — all +// within contextByteCap bytes. // // Recent user prompts are deliberately NOT part of the layered block: they cost // more bytes than they buy inside a 400-token budget. They remain available via diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 6a2c558d..599c36f0 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -333,7 +333,7 @@ func TestPinnedObservationsAndFormatContextPriority(t *testing.T) { if err != nil { t.Fatalf("format context: %v", err) } - pinnedIdx := strings.Index(ctx, "### Pinned") + pinnedIdx := strings.Index(ctx, pinnedSectionHeading) recentIdx := strings.Index(ctx, "### Recent Observations") if pinnedIdx < 0 || recentIdx < 0 || pinnedIdx > recentIdx { t.Fatalf("expected pinned section before recent observations, got:\n%s", ctx) @@ -341,6 +341,13 @@ func TestPinnedObservationsAndFormatContextPriority(t *testing.T) { if !strings.Contains(ctx, "pinned architecture") { t.Fatalf("expected pinned observation in context, got:\n%s", ctx) } + // A pinned section that fits under pinnedSectionByteCap is rendered whole: + // no entry withheld, no overflow marker. Guards the other side of + // capPinnedLines from TestFormatContextPinnedSectionCapAndOrder — an + // off-by-one in its drop loop would withhold pins that comfortably fit. + if strings.Contains(ctx, "pin cap reached") { + t.Fatalf("a single pinned observation must not trip the pinned cap marker, got:\n%s", ctx) + } if !strings.Contains(ctx, "recent three") || !strings.Contains(ctx, "recent two") { t.Fatalf("expected max recent unpinned observations in context, got:\n%s", ctx) } @@ -9033,13 +9040,163 @@ func TestFormatContextByteCapDropsObservationsThenSessionsNeverPinned(t *testing assertContextSectionOrder(t, out) } +// pinnedSectionBody returns the rendered pinned section of a context block: +// the heading, every entry line, the overflow marker when there is one, and the +// blank separator line writeContextSection appends. Sections are separated by +// exactly one blank line and no entry line is empty, so the first "\n\n" after +// the heading is the section end. +// +// This is what pinnedSectionByteCap is measured against, so the helper +// deliberately re-derives the boundary from the output text rather than calling +// the renderer's own accounting. +func pinnedSectionBody(t *testing.T, out string) string { + t.Helper() + start := strings.Index(out, pinnedSectionHeading) + if start < 0 { + t.Fatalf("pinned heading %q not found in context block:\n%s", pinnedSectionHeading, out) + } + rest := out[start:] + end := strings.Index(rest[len(pinnedSectionHeading):], "\n\n") + if end < 0 { + t.Fatalf("pinned section is not terminated by a blank line:\n%s", out) + } + return rest[:len(pinnedSectionHeading)+end+2] +} + +// TestFormatContextPinnedSectionCapAndOrder pins the R4 core-memory contract on +// the local store: the pinned section is capped at pinnedSectionByteCap on its +// own (inside, not instead of, the outer contextByteCap), entries render in +// pin-time ASCENDING order, and overflow drops the OLDEST pins behind a marker +// that names exactly how many were withheld. +// +// The fixture pins 15 observations whose rendered lines are exactly 120 bytes +// each (1800 bytes of entries — well past the 1024-byte cap) and stamps them so +// updated_at (the pin-time proxy, see PinnedObservations) ASCENDS with the pin +// index while created_at DESCENDS. That inversion is deliberate: an +// implementation that ordered by created_at, or that reversed the section to +// newest-first, would keep the wrong pins and fail here. +// +// Regression net for four mutations, each of which must fail this test: +// (a) deleting the cap loop, (b) rendering newest-first / reversing the order, +// (c) ordering by created_at instead of the pin-time proxy, (d) dropping more +// pins than the cap requires. +func TestFormatContextPinnedSectionCapAndOrder(t *testing.T) { + s := newTestStore(t) + + if err := s.CreateSession("s-pin-cap", "proj-pin-cap", "/tmp/proj-pin-cap"); err != nil { + t.Fatalf("create session: %v", err) + } + + const ( + totalPins = 15 + // "- [decision] **pin-NN**: " (25) + body (94) + "\n" = 120 bytes. + lineBytes = 120 + ) + body := func(i int) string { + return fmt.Sprintf("core fact %02d ", i) + strings.Repeat("y", 81) + } + for i := 0; i < totalPins; i++ { + id, err := s.AddObservation(AddObservationParams{ + SessionID: "s-pin-cap", + Type: "decision", + Title: fmt.Sprintf("pin-%02d", i), + Content: body(i), + Project: "proj-pin-cap", + Scope: "project", + }) + if err != nil { + t.Fatalf("add observation %d: %v", i, err) + } + if err := s.PinObservation(id); err != nil { + t.Fatalf("pin observation %d: %v", i, err) + } + // updated_at ascends with i (pin-time proxy), created_at descends. + if _, err := s.db.Exec(`UPDATE observations SET created_at = ?, updated_at = ? WHERE id = ?`, + fmt.Sprintf("2026-02-%02d 00:00:00", 20-i), + fmt.Sprintf("2026-03-01 %02d:00:00", i), id); err != nil { + t.Fatalf("stamp observation %d: %v", i, err) + } + } + + // Sanity: the fixture must render past the pinned cap before any dropping, + // otherwise every assertion below is vacuous. + if precap := len(pinnedSectionHeading) + totalPins*lineBytes + 1; precap <= pinnedSectionByteCap { + t.Fatalf("fixture renders %d pinned bytes, already under the %d-byte cap: enlarge it", + precap, pinnedSectionByteCap) + } + + out, err := s.FormatContext("proj-pin-cap", "") + if err != nil { + t.Fatalf("format context: %v", err) + } + + section := pinnedSectionBody(t, out) + if len(section) > pinnedSectionByteCap { + t.Fatalf("pinned section is %d bytes, cap %d:\n%s", len(section), pinnedSectionByteCap, section) + } + + shown := strings.Count(section, "**pin-") + if shown == 0 { + t.Fatalf("pinned section dropped every entry; core memory must keep the newest:\n%s", section) + } + if shown == totalPins { + t.Fatalf("all %d pinned entries rendered: the cap was not enforced:\n%s", totalPins, section) + } + + // The marker names exactly the number withheld. + marker := fmt.Sprintf(pinnedCapMarkerFmt, totalPins-shown) + if !strings.Contains(section, marker) { + t.Fatalf("expected overflow marker %q in:\n%s", marker, section) + } + + // Survivors are the NEWEST contiguous suffix: the oldest pins are dropped. + for i := 0; i < totalPins-shown; i++ { + if strings.Contains(section, fmt.Sprintf("**pin-%02d**", i)) { + t.Fatalf("oldest pin pin-%02d must be dropped from rendering:\n%s", i, section) + } + } + for i := totalPins - shown; i < totalPins; i++ { + if !strings.Contains(section, fmt.Sprintf("**pin-%02d**", i)) { + t.Fatalf("newest pin pin-%02d must survive the pinned cap:\n%s", i, section) + } + } + + // Ascending pin-time order among the survivors (stable prefix rule). + prev := -1 + for i := totalPins - shown; i < totalPins; i++ { + at := strings.Index(section, fmt.Sprintf("**pin-%02d**", i)) + if at <= prev { + t.Fatalf("pinned entries must render in pin-time ascending order; pin-%02d at %d follows %d:\n%s", + i, at, prev, section) + } + prev = at + } + + // The cap drops the MINIMUM: one more (older) entry would not have fit. + // Dropping one fewer keeps the marker the same width here (8 → 7), so the + // only delta is the extra 120-byte line. + if len(section)+lineBytes <= pinnedSectionByteCap { + t.Fatalf("pinned section is %d bytes with %d entries: one more would still fit under %d, "+ + "the cap drops more than necessary:\n%s", len(section), shown, pinnedSectionByteCap, section) + } + + // The pinned cap operates INSIDE the outer budget, which still never drops + // pinned lines and still keeps the footer. + if len(out) > contextByteCap { + t.Fatalf("context %d bytes, cap %d:\n%s", len(out), contextByteCap, out) + } + if !strings.Contains(out, "Full bodies: mem_search / mem_get_observation.") { + t.Fatalf("footer must survive:\n%s", out) + } +} + // assertContextSectionOrder pins the layered section order of a context block: // Pinned, then Recent Sessions, then Recent Observations — most durable first, // most volatile last (Phase 2 spec R3b). Headings that are absent (a section // with no rows is omitted) are skipped. func assertContextSectionOrder(t *testing.T, out string) { t.Helper() - pinned := strings.Index(out, "### Pinned") + pinned := strings.Index(out, pinnedSectionHeading) sessions := strings.Index(out, "### Recent Sessions") observations := strings.Index(out, "### Recent Observations") if pinned < 0 || sessions < 0 || observations < 0 { diff --git a/plugin/assets_test.go b/plugin/assets_test.go index 258082a1..c502a0f9 100644 --- a/plugin/assets_test.go +++ b/plugin/assets_test.go @@ -131,6 +131,44 @@ func TestSlimProtocolBudgetAndCoreRules(t *testing.T) { } } +// pinCriterionLine is the R4 pin criterion. It must reach the agent in every +// session (so: the slim protocol both hooks inject, not only the on-demand +// SKILL), because the pinned block is the one part of mem_context the byte cap +// never drops — an agent that pins liberally starves its own context. +const pinCriterionLine = "Pin (mem_pin) only facts whose loss causes irreversible damage " + + "or repeated pitfalls; pinned facts are always in context." + +// TestPinCriterionInProtocolAndSkills asserts the pin criterion is present, as +// one unbroken line, in both session-start hooks, both post-compaction hooks +// (compaction is exactly when an agent re-reads the protocol and might start +// pinning), and both memory SKILL files. +// +// The sentence is asserted verbatim rather than by keyword so it stays greppable +// and cannot be silently softened into "pin important facts". +func TestPinCriterionInProtocolAndSkills(t *testing.T) { + root := repoRoot(t) + + for _, plugin := range []string{"claude-code", "codex"} { + for _, script := range []string{"session-start.sh", "post-compaction.sh"} { + path := filepath.Join(root, "plugin", plugin, "scripts", script) + rel, _ := filepath.Rel(root, path) + if slim := slimProtocolHeredoc(t, path, rel); !strings.Contains(slim, pinCriterionLine) { + t.Errorf("%s: slim protocol is missing the pin criterion %q:\n%s", rel, pinCriterionLine, slim) + } + } + + skill := filepath.Join(root, "plugin", plugin, "skills", "memory", "SKILL.md") + rel, _ := filepath.Rel(root, skill) + b, err := os.ReadFile(skill) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + if !strings.Contains(string(b), pinCriterionLine) { + t.Errorf("%s is missing the pin criterion %q", rel, pinCriterionLine) + } + } +} + // TestMemorySkillsCarryConflictLoop pins the conflict-resolution walkthrough to // the on-demand `memory` SKILL files. This detail used to live in the MCP // server instructions, which every client injects into every session; it was diff --git a/plugin/claude-code/scripts/post-compaction.sh b/plugin/claude-code/scripts/post-compaction.sh index cea1246c..4a8f887c 100755 --- a/plugin/claude-code/scripts/post-compaction.sh +++ b/plugin/claude-code/scripts/post-compaction.sh @@ -103,7 +103,8 @@ RULES 5. Durable facts need topic_key (lowercase-kebab, max 2 levels, e.g. architecture/auth-model) — same key updates in place. 6. End of session: mem_session_summary before saying done. -7. MemoryLake-backed projects (check: engram memorylake status): dedup and +7. Pin (mem_pin) only facts whose loss causes irreversible damage or repeated pitfalls; pinned facts are always in context. +8. MemoryLake-backed projects (check: engram memorylake status): dedup and conflict-merge are automatic — mem_save/mem_search/mem_context suffice. SQLite projects: on judgment_required, follow the conflict loop in the memory SKILL. diff --git a/plugin/claude-code/scripts/session-start.sh b/plugin/claude-code/scripts/session-start.sh index 45eae40a..d892ffcf 100755 --- a/plugin/claude-code/scripts/session-start.sh +++ b/plugin/claude-code/scripts/session-start.sh @@ -200,7 +200,8 @@ RULES 5. Durable facts need topic_key (lowercase-kebab, max 2 levels, e.g. architecture/auth-model) — same key updates in place. 6. End of session: mem_session_summary before saying done. -7. MemoryLake-backed projects (check: engram memorylake status): dedup and +7. Pin (mem_pin) only facts whose loss causes irreversible damage or repeated pitfalls; pinned facts are always in context. +8. MemoryLake-backed projects (check: engram memorylake status): dedup and conflict-merge are automatic — mem_save/mem_search/mem_context suffice. SQLite projects: on judgment_required, follow the conflict loop in the memory SKILL. diff --git a/plugin/claude-code/skills/memory/SKILL.md b/plugin/claude-code/skills/memory/SKILL.md index 5b958dc7..ded90aa3 100644 --- a/plugin/claude-code/skills/memory/SKILL.md +++ b/plugin/claude-code/skills/memory/SKILL.md @@ -50,6 +50,15 @@ Format for `mem_save`: **Where**: Files or paths affected **Learned**: Gotchas, edge cases, things that surprised you (omit if none) +### Pinning — the exception, not the habit + +Pin (mem_pin) only facts whose loss causes irreversible damage or repeated pitfalls; pinned facts are always in context. +The pinned block is the one part of `mem_context` the byte budget never drops, and it +carries its own 1024-byte ceiling: entries render oldest-pin-first, and once the +ceiling is reached the OLDEST pins stop being rendered behind a +`(pin cap reached: N pinned facts not shown — mem_unpin to prune)` marker. Over-pinning +therefore evicts your own earlier pins from context — use `mem_unpin` to prune. + ### Topic update rules (mandatory) - Different topics MUST NOT overwrite each other (example: architecture decision vs bugfix) diff --git a/plugin/codex/scripts/post-compaction.sh b/plugin/codex/scripts/post-compaction.sh index 5905d59c..fa4b8fb9 100755 --- a/plugin/codex/scripts/post-compaction.sh +++ b/plugin/codex/scripts/post-compaction.sh @@ -103,7 +103,8 @@ RULES 5. Durable facts need topic_key (lowercase-kebab, max 2 levels, e.g. architecture/auth-model) — same key updates in place. 6. End of session: mem_session_summary before saying done. -7. MemoryLake-backed projects (check: engram memorylake status): dedup and +7. Pin (mem_pin) only facts whose loss causes irreversible damage or repeated pitfalls; pinned facts are always in context. +8. MemoryLake-backed projects (check: engram memorylake status): dedup and conflict-merge are automatic — mem_save/mem_search/mem_context suffice. SQLite projects: on judgment_required, follow the conflict loop in the memory SKILL. diff --git a/plugin/codex/scripts/session-start.sh b/plugin/codex/scripts/session-start.sh index 7a0b6c66..0a4f57b3 100755 --- a/plugin/codex/scripts/session-start.sh +++ b/plugin/codex/scripts/session-start.sh @@ -200,7 +200,8 @@ RULES 5. Durable facts need topic_key (lowercase-kebab, max 2 levels, e.g. architecture/auth-model) — same key updates in place. 6. End of session: mem_session_summary before saying done. -7. MemoryLake-backed projects (check: engram memorylake status): dedup and +7. Pin (mem_pin) only facts whose loss causes irreversible damage or repeated pitfalls; pinned facts are always in context. +8. MemoryLake-backed projects (check: engram memorylake status): dedup and conflict-merge are automatic — mem_save/mem_search/mem_context suffice. SQLite projects: on judgment_required, follow the conflict loop in the memory SKILL. diff --git a/plugin/codex/skills/memory/SKILL.md b/plugin/codex/skills/memory/SKILL.md index a41ed858..0fd32dd5 100644 --- a/plugin/codex/skills/memory/SKILL.md +++ b/plugin/codex/skills/memory/SKILL.md @@ -49,6 +49,15 @@ Format for `mem_save`: **Where**: Files or paths affected **Learned**: Gotchas, edge cases, things that surprised you (omit if none) +### Pinning — the exception, not the habit + +Pin (mem_pin) only facts whose loss causes irreversible damage or repeated pitfalls; pinned facts are always in context. +The pinned block is the one part of `mem_context` the byte budget never drops, and it +carries its own 1024-byte ceiling: entries render oldest-pin-first, and once the +ceiling is reached the OLDEST pins stop being rendered behind a +`(pin cap reached: N pinned facts not shown — mem_unpin to prune)` marker. Over-pinning +therefore evicts your own earlier pins from context — use `mem_unpin` to prune. + ### Topic update rules (mandatory) - Different topics MUST NOT overwrite each other (example: architecture decision vs bugfix) From 2289c68e6663681e4abbc3e1da8f7ad8720e987f Mon Sep 17 00:00:00 2001 From: zhishu <weichuangxian@hotmail.com> Date: Mon, 27 Jul 2026 17:00:39 +0000 Subject: [PATCH 24/29] fix(memory): never emit a zero pin-cap marker; truncate titles in context lines capPinnedLines fell through its drop loop when the pinned section reduced to a single entry that alone exceeded pinnedSectionByteCap, returning that line plus "(pin cap reached: 0 pinned facts not shown - mem_unpin to prune)". Nothing was withheld, so the marker was false agent-facing text. A lone entry now returns early: core memory is never emptied and no marker is emitted, so the marker's count is always >= 1. Also cap rendered titles at 120 runes (contextTitleTruncLen) in the pinned and recent-observation line renderers of both backends. Content was already bounded, but a pinned line is the one line neither cap may drop, so an unbounded title was the remaining way to carry the whole block past contextByteCap with nothing left to trim. Tests: per-backend coverage for the lone-over-cap entry (no marker anywhere) and for a 300-byte title no longer pushing a pinned-intact block past the 1600-byte outer cap, each with a guard proving the path is exercised. Adds a pin-cap sanity guard to the ML byte-cap fixture, notes the blank-line caveat on the pinnedSectionBody test helpers, and documents the two edge cases in DOCS.md and both memory SKILLs. --- DOCS.md | 8 +- internal/memorylake/backend.go | 35 +++++- internal/memorylake/backend_test.go | 121 +++++++++++++++++++ internal/store/store.go | 44 ++++++- internal/store/store_test.go | 137 ++++++++++++++++++++++ plugin/claude-code/skills/memory/SKILL.md | 2 + plugin/codex/skills/memory/SKILL.md | 2 + 7 files changed, 336 insertions(+), 13 deletions(-) diff --git a/DOCS.md b/DOCS.md index cd77fc7b..7cbdb654 100644 --- a/DOCS.md +++ b/DOCS.md @@ -910,21 +910,23 @@ The block is layered and budgeted (~400 tokens / 1600 bytes hard cap) so it is c ## Memory Context ### Pinned (core memory) -- [decision] **title**: <content, cut to 300 runes> (oldest pin first, section ≤ 1024 bytes) +- [decision] **title**: <content, cut to 300 runes> (title cut to 120 runes; oldest pin first, section ≤ 1024 bytes) (pin cap reached: N pinned facts not shown — mem_unpin to prune) (only when the 1024 bytes run out) ### Recent Sessions - <started_at> <summary first sentence | (active) | (no summary)> (max 5) ### Recent Observations -- [type] **title**: <content first sentence, cut to 160 runes> (max 3) +- [type] **title**: <content first sentence, cut to 160 runes> (title cut to 120 runes; max 3) Full bodies: mem_search / mem_get_observation. ``` Overflow past the cap drops the oldest observation lines first, then the oldest session lines; pinned content is never dropped by that outer cap. -**`### Pinned (core memory)` is capped separately at 1024 bytes** — heading, entry lines, overflow marker and blank separator included. Entries render in **pin-time ascending order** (oldest pin first), so the section is a stable prefix that grows at the end as new pins arrive. When the 1024 bytes are exhausted, the **oldest** pins stop being *rendered* (they stay pinned and searchable) and a trailing `(pin cap reached: N pinned facts not shown — mem_unpin to prune)` line names how many were withheld. Over-pinning therefore evicts your own earlier pins from the block; `mem_unpin` is the prune. +**`### Pinned (core memory)` is capped separately at 1024 bytes** — heading, entry lines, overflow marker and blank separator included. Entries render in **pin-time ascending order** (oldest pin first), so the section is a stable prefix that grows at the end as new pins arrive. When the 1024 bytes are exhausted, the **oldest** pins stop being *rendered* (they stay pinned and searchable) and a trailing `(pin cap reached: N pinned facts not shown — mem_unpin to prune)` line names how many were withheld. Over-pinning therefore evicts your own earlier pins from the block; `mem_unpin` is the prune. In practice, with the 300-rune content cut on each line, only about 2-3 pinned facts render before the 1024 bytes run out. + +Two edge cases of that cap are fixed by contract. **A single pinned entry is never dropped and never carries a marker**: if the newest pin alone exceeds 1024 bytes it renders over cap (core memory is never emptied), and since nothing was withheld no `(pin cap reached: …)` line is emitted — the marker's count is always ≥ 1. And **titles are cut to 120 runes** (with `…`) in both the pinned and recent-observation lines: content was already bounded, so an unbounded title was the one remaining way for a pinned line — the one line neither cap may drop — to carry the whole block past the 1600-byte budget. Pin time is a documented **proxy**, not a stored timestamp: neither backend records when a pin happened (`mem_pin` only flips `observations.pinned` / the MemoryLake `pinned` metadata key and deliberately leaves `updated_at` untouched on SQLite), so the section orders by `updated_at`, falling back to `created_at`. Consequence: re-pinning an old memory does not move it to the end of the section, while editing a pinned memory's content does. Ordering is deterministic either way (id breaks ties). diff --git a/internal/memorylake/backend.go b/internal/memorylake/backend.go index 38ff57ec..38fe91ff 100644 --- a/internal/memorylake/backend.go +++ b/internal/memorylake/backend.go @@ -420,6 +420,24 @@ const maxFormatContextRecent = 20 // contextPinnedTruncLen surfaces both copies of the rule at once. const contextPinnedTruncLen = 300 +// contextTitleTruncLen is a byte-identical copy of internal/store's constant of +// the same name: rendered titles in pinned/observation context lines are cut to +// 120 runes. Titles were the last unbounded field in a context line, and a +// single pinned line is the one thing neither cap can drop (see capPinnedLines) +// — see internal/store/store.go for the full rationale. Keep in sync. +const contextTitleTruncLen = 120 + +// truncTitle mirrors internal/store.truncTitle: a rune-safe cut to at most +// contextTitleTruncLen runes with "…" appended when title was longer (note the +// single ellipsis glyph, unlike truncate's literal "..."). Keep in sync. +func truncTitle(title string) string { + runes := []rune(title) + if len(runes) <= contextTitleTruncLen { + return title + } + return string(runes[:contextTitleTruncLen]) + "…" +} + // Pinned core-memory contract (Phase 2 spec R4) — byte-identical copies of // internal/store's constants of the same names, duplicated for the same reason // as contextByteCap above (they are unexported presentation details there). See @@ -533,6 +551,13 @@ func capPinnedLines(lines []string) []string { if len(lines) == 0 { return lines } + // A lone entry is always rendered — core memory is never emptied — so + // nothing can be withheld and there is no marker to emit. Without this + // early return, a single line over the cap fell through the loop to the + // tail below and produced "0 pinned facts not shown", which is false. + if len(lines) == 1 { + return lines + } for dropped := 0; dropped < len(lines); dropped++ { marker := "" if dropped > 0 { @@ -549,8 +574,10 @@ func capPinnedLines(lines []string) []string { out = append(out, shown...) return append(out, marker) } - // Only reached when even the newest entry alone exceeds the cap: core - // memory is never emptied, so it is rendered over cap rather than dropped. + // Only reached when even the newest entry alone exceeds the cap, with at + // least one older entry behind it (per the len == 1 guard above): core + // memory is never emptied, so it renders over cap rather than dropped, and + // the marker counts the len-1 ≥ 1 pins withheld. return []string{lines[len(lines)-1], fmt.Sprintf(pinnedCapMarkerFmt, len(lines)-1)} } @@ -664,7 +691,7 @@ func (b *MemoryLakeBackend) FormatContext(project, scope string) (string, error) for _, f := range pinned { o := ObservationFromFact(f) pinnedLines = append(pinnedLines, fmt.Sprintf("- [%s] **%s**: %s\n", - o.Type, o.Title, truncate(o.Content, contextPinnedTruncLen))) + o.Type, truncTitle(o.Title), truncate(o.Content, contextPinnedTruncLen))) } if len(recent) > contextRecentObs { @@ -674,7 +701,7 @@ func (b *MemoryLakeBackend) FormatContext(project, scope string) (string, error) for _, f := range recent { o := ObservationFromFact(f) obsLines = append(obsLines, fmt.Sprintf("- [%s] **%s**: %s\n", - o.Type, o.Title, firstSentence(o.Content))) + o.Type, truncTitle(o.Title), firstSentence(o.Content))) } return renderLayeredContext(pinnedLines, obsLines), nil diff --git a/internal/memorylake/backend_test.go b/internal/memorylake/backend_test.go index 3753caef..01fb70d3 100644 --- a/internal/memorylake/backend_test.go +++ b/internal/memorylake/backend_test.go @@ -952,6 +952,16 @@ func TestBackend_FormatContext_ByteCapDropsOldestObservationNeverPinned(t *testi t.Fatalf("fixture renders %d bytes before dropping, already under the %d-byte cap: "+ "the cap loop is not exercised, enlarge the fixture", precap, contextByteCap) } + // Sanity in the other direction: the fixture must NOT trip the separate + // pinned-section cap. This test asserts all three pinned lines survive; a + // pin-cap trip would drop the oldest behind a marker and fail below with a + // misleading "found 2" instead of naming the real cause. See the arithmetic + // note under the survival assertion — the fixture sits 5 bytes clear. + if pinBytes := pinnedSectionBytes(pinnedLines, ""); pinBytes > pinnedSectionByteCap { + t.Fatalf("fixture pinned section is %d bytes, over the %d-byte pin cap: the pin cap now "+ + "drops entries this test expects to survive, shrink the pinned fixture", + pinBytes, pinnedSectionByteCap) + } if len(out) > contextByteCap { t.Fatalf("context %d bytes, cap %d:\n%s", len(out), contextByteCap, out) @@ -996,6 +1006,11 @@ func TestBackend_FormatContext_ByteCapDropsOldestObservationNeverPinned(t *testi // separator writeContextSection appends. Twin of internal/store's // pinnedSectionBody: sections are separated by exactly one blank line and no // entry line is empty, so the first "\n\n" after the heading ends the section. +// +// Same caveat as the twin: an entry whose content contains a BLANK line ends the +// section early here and would be under-measured. Production accounting sums the +// entry strings (pinnedSectionBytes) and is unaffected — keep fixture bodies free +// of blank lines. func backendPinnedSectionBody(t *testing.T, out string) string { t.Helper() start := strings.Index(out, pinnedSectionHeading) @@ -1128,6 +1143,112 @@ func TestBackend_FormatContext_PinnedSectionCapAndOrder(t *testing.T) { } } +// singlePinnedFactBackend serves exactly one pinned fact (title/body given) from +// the facts endpoint, so the pinned-section degenerate paths below can be driven +// without repeating the httptest wiring. +func singlePinnedFactBackend(t *testing.T, title, body string) *MemoryLakeBackend { + t.Helper() + items := []map[string]any{{ + "id": "fact-solo-pin", "fact": body, "created_at": "2026-07-20T00:00:00Z", + "metadata": map[string]any{ + metaTitle: title, metaType: "decision", metaScope: "global", "pinned": true, + }, + }} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/api/v3/workspaces/ws-1/projects/proj-1/memories/facts") { + json.NewEncoder(w).Encode(map[string]any{"success": true, "data": map[string]any{"items": items}}) + return + } + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + })) + t.Cleanup(srv.Close) + return newTestBackend(t, srv.URL) +} + +// TestBackend_FormatContext_PinnedLoneOverCapEntryRendersWithoutMarker is the +// MemoryLake twin of internal/store's +// TestFormatContextPinnedLoneOverCapEntryRendersWithoutMarker: a SINGLE pinned +// entry that alone exceeds pinnedSectionByteCap still renders (core memory is +// never emptied) and emits NO overflow marker — nothing was withheld, so +// "(pin cap reached: 0 pinned facts not shown …)" would be false. +func TestBackend_FormatContext_PinnedLoneOverCapEntryRendersWithoutMarker(t *testing.T) { + // contextPinnedTruncLen emoji runes: exactly at the rune cut (body renders + // whole, no "...") at 4 bytes each, so the line is ~1220 bytes — past the + // 998 bytes an entry may occupy once the heading (25) and blank separator + // (1) are charged against the 1024-byte cap. + body := strings.Repeat("🔥", contextPinnedTruncLen) + b := singlePinnedFactBackend(t, "solo-pin", body) + + out, err := b.FormatContext("proj", "global") + if err != nil { + t.Fatalf("FormatContext: %v", err) + } + + // Sanity: the lone entry must genuinely be over the cap, otherwise this + // test passes on the ordinary "everything fits" path and proves nothing. + section := backendPinnedSectionBody(t, out) + if len(section) <= pinnedSectionByteCap { + t.Fatalf("fixture pinned section is %d bytes, within the %d-byte cap: the lone-entry "+ + "over-cap path is not exercised, enlarge the fixture:\n%s", + len(section), pinnedSectionByteCap, section) + } + + if !strings.Contains(out, "**solo-pin**") { + t.Fatalf("the lone pinned entry must render even over cap:\n%s", out) + } + if !strings.Contains(out, body) { + t.Fatalf("the lone pinned entry's body must render whole:\n%s", out) + } + if got := fmt.Sprintf(pinnedCapMarkerFmt, 0); strings.Contains(out, got) { + t.Fatalf("emitted a zero-count pin-cap marker %q — nothing was withheld:\n%s", got, out) + } + if strings.Contains(out, "pin cap reached") { + t.Fatalf("no pin-cap marker may appear when nothing is withheld:\n%s", out) + } + if strings.Contains(out, "not shown") { + t.Fatalf("no pin-cap marker may appear when nothing is withheld:\n%s", out) + } +} + +// TestBackend_FormatContext_TruncatesTitleKeepingPinnedBlockUnderOuterCap is the +// MemoryLake twin of internal/store's +// TestFormatContextTruncatesTitleKeepingPinnedBlockUnderOuterCap: the title cut +// (contextTitleTruncLen) is cap enforcement, not cosmetics — a pinned line is the +// one thing NEITHER cap can drop, so an unbounded title was the remaining way to +// push the block past contextByteCap with nothing left to trim. +func TestBackend_FormatContext_TruncatesTitleKeepingPinnedBlockUnderOuterCap(t *testing.T) { + longTitle := strings.Repeat("T", 300) // 300 bytes of title + body := strings.Repeat("🔥", contextPinnedTruncLen) + b := singlePinnedFactBackend(t, longTitle, body) + + out, err := b.FormatContext("proj", "global") + if err != nil { + t.Fatalf("FormatContext: %v", err) + } + + // Pre-cap guard: with the title rendered whole, the pinned line ALONE (which + // the outer cap never drops) already carries the block over contextByteCap. + untruncated := fmt.Sprintf("- [decision] **%s**: %s\n", longTitle, body) + if precap := len(joinLayeredContext([]string{untruncated}, nil)); precap <= contextByteCap { + t.Fatalf("fixture renders %d bytes with the title untruncated, already under the %d-byte "+ + "cap: the bypass is not exercised, enlarge the title or body", precap, contextByteCap) + } + + if len(out) > contextByteCap { + t.Fatalf("context %d bytes, cap %d:\n%s", len(out), contextByteCap, out) + } + wantTitle := strings.Repeat("T", contextTitleTruncLen) + "…" + if !strings.Contains(out, "**"+wantTitle+"**") { + t.Fatalf("title must render cut to %d runes with an ellipsis:\n%s", contextTitleTruncLen, out) + } + if strings.Contains(out, longTitle) { + t.Fatalf("untruncated title must not render:\n%s", out) + } + if !strings.Contains(out, body) { + t.Fatalf("the pinned body must still render whole (only the title is cut):\n%s", out) + } +} + // assertBackendContextSectionOrder pins the layered section order of a // MemoryLake context block: Pinned before Recent Observations, and no // "### Recent Sessions" section at all (this backend has no session tracking). diff --git a/internal/store/store.go b/internal/store/store.go index 5f3db638..58209090 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -3334,6 +3334,31 @@ const ( // the pinned block only (recent observations now use firstSentence). const contextPinnedTruncLen = 300 +// contextTitleTruncLen caps a rendered title in a pinned/observation context +// line. Titles are user/agent supplied and were the last unbounded field in a +// context line: content is cut at contextPinnedTruncLen / contextSummaryTruncLen +// runes, but a multi-kilobyte title used to render whole. That matters for the +// pinned block specifically, because a single pinned line is the one thing +// neither cap can drop (see capPinnedLines), so an unbounded title could push +// the whole block past contextByteCap with nothing left to trim. +// +// internal/memorylake carries a byte-identical copy — keep the two in sync. +const contextTitleTruncLen = 120 + +// truncTitle cuts title to at most contextTitleTruncLen runes, appending "…" +// when it was longer. Distinct from truncate (which appends a literal "...") +// so a cut title reads as one glyph of elision inside the bolded run, matching +// firstSentence's ellipsis. +// +// Canonical twin: internal/memorylake.truncTitle. Keep byte-identical. +func truncTitle(title string) string { + runes := []rune(title) + if len(runes) <= contextTitleTruncLen { + return title + } + return string(runes[:contextTitleTruncLen]) + "…" +} + // Pinned core-memory contract (Phase 2 spec R4). The pinned block is the one // section the outer contextByteCap never drops, so it needs its own ceiling: // without one, a project that pins 50 facts starves sessions/observations out of @@ -3436,6 +3461,13 @@ func capPinnedLines(lines []string) []string { if len(lines) == 0 { return lines } + // A lone entry is always rendered — core memory is never emptied — so + // nothing can be withheld and there is no marker to emit. Without this + // early return, a single line over the cap fell through the loop to the + // tail below and produced "0 pinned facts not shown", which is false. + if len(lines) == 1 { + return lines + } for dropped := 0; dropped < len(lines); dropped++ { marker := "" if dropped > 0 { @@ -3452,10 +3484,10 @@ func capPinnedLines(lines []string) []string { out = append(out, shown...) return append(out, marker) } - // Unreachable in practice: the loop's last iteration already tries "newest - // entry alone". It is only taken when even that single entry exceeds the - // cap, and the answer is the same — core memory is never emptied, so the - // newest pin is rendered over cap rather than dropped. + // Reached only when even the newest entry alone exceeds the cap (with at + // least one older entry behind it, per the len == 1 guard above): core + // memory is never emptied, so the newest pin renders over cap rather than + // being dropped, and the marker counts the len-1 ≥ 1 pins withheld. return []string{lines[len(lines)-1], fmt.Sprintf(pinnedCapMarkerFmt, len(lines)-1)} } @@ -3553,7 +3585,7 @@ func (s *Store) FormatContext(project, scope string) (string, error) { pinnedLines := make([]string, 0, len(pinned)) for _, obs := range pinned { pinnedLines = append(pinnedLines, fmt.Sprintf("- [%s] **%s**: %s\n", - obs.Type, obs.Title, truncate(obs.Content, contextPinnedTruncLen))) + obs.Type, truncTitle(obs.Title), truncate(obs.Content, contextPinnedTruncLen))) } // A blank project filter means RecentSessions returned rows from every @@ -3574,7 +3606,7 @@ func (s *Store) FormatContext(project, scope string) (string, error) { obsLines := make([]string, 0, len(observations)) for _, obs := range observations { obsLines = append(obsLines, fmt.Sprintf("- [%s] **%s**: %s\n", - obs.Type, obs.Title, firstSentence(obs.Content))) + obs.Type, truncTitle(obs.Title), firstSentence(obs.Content))) } return renderLayeredContext(pinnedLines, sessionLines, obsLines), nil diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 599c36f0..8aa214dc 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -9049,6 +9049,12 @@ func TestFormatContextByteCapDropsObservationsThenSessionsNeverPinned(t *testing // This is what pinnedSectionByteCap is measured against, so the helper // deliberately re-derives the boundary from the output text rather than calling // the renderer's own accounting. +// +// Limitation of that re-derivation: an entry whose rendered content contains a +// BLANK line would end the section early here, so the helper would under-measure +// it. Production accounting never has that problem — pinnedSectionBytes sums the +// entry strings themselves — so this is a test-helper caveat only. Keep fixture +// bodies free of blank lines. func pinnedSectionBody(t *testing.T, out string) string { t.Helper() start := strings.Index(out, pinnedSectionHeading) @@ -9190,6 +9196,137 @@ func TestFormatContextPinnedSectionCapAndOrder(t *testing.T) { } } +// TestFormatContextPinnedLoneOverCapEntryRendersWithoutMarker pins the +// degenerate pinned-cap path: when the section is a SINGLE entry that alone +// exceeds pinnedSectionByteCap, the entry still renders (core memory is never +// emptied) and NO overflow marker is emitted. Nothing was withheld, so a +// "(pin cap reached: 0 pinned facts not shown — mem_unpin to prune)" line would +// be agent-facing text that is simply false. +// +// Twin: internal/memorylake's TestBackend_FormatContext_ +// PinnedLoneOverCapEntryRendersWithoutMarker. +func TestFormatContextPinnedLoneOverCapEntryRendersWithoutMarker(t *testing.T) { + s := newTestStore(t) + + if err := s.CreateSession("s-pin-solo", "proj-pin-solo", "/tmp/proj-pin-solo"); err != nil { + t.Fatalf("create session: %v", err) + } + + // contextPinnedTruncLen emoji runes: exactly at the rune cut (so the body + // renders whole, no "...") but 4 bytes each, so the rendered line is ~1220 + // bytes — past the 998 bytes an entry may occupy once the heading (25) and + // the blank separator (1) are charged against the 1024-byte cap. + body := strings.Repeat("🔥", contextPinnedTruncLen) + id, err := s.AddObservation(AddObservationParams{ + SessionID: "s-pin-solo", + Type: "decision", + Title: "solo-pin", + Content: body, + Project: "proj-pin-solo", + Scope: "project", + }) + if err != nil { + t.Fatalf("add observation: %v", err) + } + if err := s.PinObservation(id); err != nil { + t.Fatalf("pin observation: %v", err) + } + + out, err := s.FormatContext("proj-pin-solo", "") + if err != nil { + t.Fatalf("format context: %v", err) + } + + // Sanity: the lone entry must genuinely be over the cap, otherwise this + // test passes on the ordinary "everything fits" path and proves nothing. + section := pinnedSectionBody(t, out) + if len(section) <= pinnedSectionByteCap { + t.Fatalf("fixture pinned section is %d bytes, within the %d-byte cap: the lone-entry "+ + "over-cap path is not exercised, enlarge the fixture:\n%s", + len(section), pinnedSectionByteCap, section) + } + + if !strings.Contains(out, "**solo-pin**") { + t.Fatalf("the lone pinned entry must render even over cap:\n%s", out) + } + if !strings.Contains(out, body) { + t.Fatalf("the lone pinned entry's body must render whole:\n%s", out) + } + // No marker in any form: neither the zero-count rendering nor the shared + // prefix (which would catch a marker with any other count). + if got := fmt.Sprintf(pinnedCapMarkerFmt, 0); strings.Contains(out, got) { + t.Fatalf("emitted a zero-count pin-cap marker %q — nothing was withheld:\n%s", got, out) + } + if strings.Contains(out, "pin cap reached") { + t.Fatalf("no pin-cap marker may appear when nothing is withheld:\n%s", out) + } + if strings.Contains(out, "not shown") { + t.Fatalf("no pin-cap marker may appear when nothing is withheld:\n%s", out) + } +} + +// TestFormatContextTruncatesTitleKeepingPinnedBlockUnderOuterCap pins the title +// cut (contextTitleTruncLen) as a cap-enforcement mechanism, not cosmetics: a +// pinned line is the one thing NEITHER cap can drop, so an unbounded title used +// to be the remaining way to push the whole block past contextByteCap with +// nothing left to trim. The pre-cap guard below reconstructs the untruncated +// line and asserts exactly that. +// +// Twin: internal/memorylake's TestBackend_FormatContext_ +// TruncatesTitleKeepingPinnedBlockUnderOuterCap. +func TestFormatContextTruncatesTitleKeepingPinnedBlockUnderOuterCap(t *testing.T) { + s := newTestStore(t) + + if err := s.CreateSession("s-pin-title", "proj-pin-title", "/tmp/proj-pin-title"); err != nil { + t.Fatalf("create session: %v", err) + } + + longTitle := strings.Repeat("T", 300) // 300 bytes of title + body := strings.Repeat("🔥", contextPinnedTruncLen) + id, err := s.AddObservation(AddObservationParams{ + SessionID: "s-pin-title", + Type: "decision", + Title: longTitle, + Content: body, + Project: "proj-pin-title", + Scope: "project", + }) + if err != nil { + t.Fatalf("add observation: %v", err) + } + if err := s.PinObservation(id); err != nil { + t.Fatalf("pin observation: %v", err) + } + + out, err := s.FormatContext("proj-pin-title", "") + if err != nil { + t.Fatalf("format context: %v", err) + } + + // Pre-cap guard: with the title rendered whole, the pinned line ALONE (which + // the outer cap never drops) already carries the block over contextByteCap, + // so the truncation below is what keeps the cap enforceable. + untruncated := fmt.Sprintf("- [decision] **%s**: %s\n", longTitle, body) + if precap := len(joinLayeredContext([]string{untruncated}, nil, nil)); precap <= contextByteCap { + t.Fatalf("fixture renders %d bytes with the title untruncated, already under the %d-byte "+ + "cap: the bypass is not exercised, enlarge the title or body", precap, contextByteCap) + } + + if len(out) > contextByteCap { + t.Fatalf("context %d bytes, cap %d:\n%s", len(out), contextByteCap, out) + } + wantTitle := strings.Repeat("T", contextTitleTruncLen) + "…" + if !strings.Contains(out, "**"+wantTitle+"**") { + t.Fatalf("title must render cut to %d runes with an ellipsis:\n%s", contextTitleTruncLen, out) + } + if strings.Contains(out, longTitle) { + t.Fatalf("untruncated title must not render:\n%s", out) + } + if !strings.Contains(out, body) { + t.Fatalf("the pinned body must still render whole (only the title is cut):\n%s", out) + } +} + // assertContextSectionOrder pins the layered section order of a context block: // Pinned, then Recent Sessions, then Recent Observations — most durable first, // most volatile last (Phase 2 spec R3b). Headings that are absent (a section diff --git a/plugin/claude-code/skills/memory/SKILL.md b/plugin/claude-code/skills/memory/SKILL.md index ded90aa3..c23ba582 100644 --- a/plugin/claude-code/skills/memory/SKILL.md +++ b/plugin/claude-code/skills/memory/SKILL.md @@ -58,6 +58,8 @@ carries its own 1024-byte ceiling: entries render oldest-pin-first, and once the ceiling is reached the OLDEST pins stop being rendered behind a `(pin cap reached: N pinned facts not shown — mem_unpin to prune)` marker. Over-pinning therefore evicts your own earlier pins from context — use `mem_unpin` to prune. +Concretely: with the 300-rune content cap on each pinned line, only ~2-3 pinned facts +render before the 1KB cap is reached — over-pinning evicts your own earlier pins. ### Topic update rules (mandatory) diff --git a/plugin/codex/skills/memory/SKILL.md b/plugin/codex/skills/memory/SKILL.md index 0fd32dd5..ab2e00a0 100644 --- a/plugin/codex/skills/memory/SKILL.md +++ b/plugin/codex/skills/memory/SKILL.md @@ -57,6 +57,8 @@ carries its own 1024-byte ceiling: entries render oldest-pin-first, and once the ceiling is reached the OLDEST pins stop being rendered behind a `(pin cap reached: N pinned facts not shown — mem_unpin to prune)` marker. Over-pinning therefore evicts your own earlier pins from context — use `mem_unpin` to prune. +Concretely: with the 300-rune content cap on each pinned line, only ~2-3 pinned facts +render before the 1KB cap is reached — over-pinning evicts your own earlier pins. ### Topic update rules (mandatory) From 862bb4198dc4bc66f82e66d18f8dd8eb61bc8653 Mon Sep 17 00:00:00 2001 From: zhishu <weichuangxian@hotmail.com> Date: Mon, 27 Jul 2026 17:17:54 +0000 Subject: [PATCH 25/29] feat(eval): wave1 round 4 (R4 pinned core memory) scorecards --- eval/results/2289c68-2026-07-27-l1.json | 508 ++++++++++++++++++++ eval/results/2289c68-2026-07-27-l2.json | 18 + eval/results/w1-r4-l3subset.json | 109 +++++ eval/results/w1-r4-l3subset.json.runs.jsonl | 16 + 4 files changed, 651 insertions(+) create mode 100644 eval/results/2289c68-2026-07-27-l1.json create mode 100644 eval/results/2289c68-2026-07-27-l2.json create mode 100644 eval/results/w1-r4-l3subset.json create mode 100644 eval/results/w1-r4-l3subset.json.runs.jsonl diff --git a/eval/results/2289c68-2026-07-27-l1.json b/eval/results/2289c68-2026-07-27-l1.json new file mode 100644 index 00000000..bd34526a --- /dev/null +++ b/eval/results/2289c68-2026-07-27-l1.json @@ -0,0 +1,508 @@ +{ + "git_sha": "2289c68", + "date": "2026-07-27", + "suite": "l1", + "metrics": { + "avg_distractor_ratio": 0.13271604938271603, + "avg_tokens_per_query": 205.59259259259258, + "latency_p50_ms": 584, + "latency_p95_ms": 1165, + "mrr": 0.7376543209876544, + "recall@1": 0.6851851851851852, + "recall@10": 0.7962962962962963, + "recall@5": 0.7962962962962963 + }, + "per_item": [ + { + "id": "r-001", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 588, + "tokens": 185 + } + }, + { + "id": "r-002", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1003, + "tokens": 488 + } + }, + { + "id": "r-003", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 537, + "tokens": 359 + } + }, + { + "id": "r-004", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1002, + "tokens": 268 + } + }, + { + "id": "r-005", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 553, + "tokens": 325 + } + }, + { + "id": "r-006", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1140, + "tokens": 205 + } + }, + { + "id": "r-007", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 537, + "tokens": 288 + } + }, + { + "id": "r-008", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 1189, + "tokens": 0 + } + }, + { + "id": "r-009", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 538, + "tokens": 67 + } + }, + { + "id": "r-010", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 525, + "tokens": 0 + } + }, + { + "id": "r-011", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1752, + "tokens": 310 + } + }, + { + "id": "r-012", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 539, + "tokens": 125 + } + }, + { + "id": "r-013", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 601, + "tokens": 387 + } + }, + { + "id": "r-014", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 585, + "tokens": 67 + } + }, + { + "id": "r-015", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 577, + "tokens": 77 + } + }, + { + "id": "r-016", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 548, + "tokens": 336 + } + }, + { + "id": "r-017", + "values": { + "distractor_ratio": 0.25, + "first_hit_rank": 2, + "latency_ms": 589, + "tokens": 308 + } + }, + { + "id": "r-018", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 594, + "tokens": 256 + } + }, + { + "id": "r-019", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1051, + "tokens": 418 + } + }, + { + "id": "r-020", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 588, + "tokens": 254 + } + }, + { + "id": "r-021", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1165, + "tokens": 668 + } + }, + { + "id": "r-022", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 584, + "tokens": 327 + } + }, + { + "id": "r-023", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1071, + "tokens": 308 + } + }, + { + "id": "r-024", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 601, + "tokens": 578 + } + }, + { + "id": "r-025", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 599, + "tokens": 86 + } + }, + { + "id": "r-026", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 1186, + "tokens": 140 + } + }, + { + "id": "r-027", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 572, + "tokens": 169 + } + }, + { + "id": "r-028", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 1004, + "tokens": 74 + } + }, + { + "id": "r-029", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 587, + "tokens": 231 + } + }, + { + "id": "r-030", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 562, + "tokens": 585 + } + }, + { + "id": "r-031", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 590, + "tokens": 86 + } + }, + { + "id": "r-032", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 552, + "tokens": 0 + } + }, + { + "id": "r-033", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 519, + "tokens": 163 + } + }, + { + "id": "r-034", + "values": { + "distractor_ratio": 0.25, + "first_hit_rank": 2, + "latency_ms": 1036, + "tokens": 308 + } + }, + { + "id": "r-035", + "values": { + "distractor_ratio": 0.5, + "first_hit_rank": 2, + "latency_ms": 531, + "tokens": 163 + } + }, + { + "id": "r-036", + "values": { + "distractor_ratio": 0.3333333333333333, + "first_hit_rank": 2, + "latency_ms": 508, + "tokens": 231 + } + }, + { + "id": "r-037", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 489, + "tokens": 231 + } + }, + { + "id": "r-038", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 508, + "tokens": 231 + } + }, + { + "id": "r-039", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 484, + "tokens": 0 + } + }, + { + "id": "r-040", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 499, + "tokens": 0 + } + }, + { + "id": "r-041", + "values": { + "distractor_ratio": 1, + "first_hit_rank": 0, + "latency_ms": 522, + "tokens": 145 + } + }, + { + "id": "r-042", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 0, + "latency_ms": 497, + "tokens": 0 + } + }, + { + "id": "r-043", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 956, + "tokens": 86 + } + }, + { + "id": "r-044", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1100, + "tokens": 86 + } + }, + { + "id": "r-045", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 517, + "tokens": 86 + } + }, + { + "id": "r-046", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 514, + "tokens": 86 + } + }, + { + "id": "r-047", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1022, + "tokens": 170 + } + }, + { + "id": "r-048", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1053, + "tokens": 93 + } + }, + { + "id": "r-049", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 1091, + "tokens": 233 + } + }, + { + "id": "r-050", + "values": { + "distractor_ratio": 0.3333333333333333, + "first_hit_rank": 2, + "latency_ms": 516, + "tokens": 226 + } + }, + { + "id": "r-051", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 528, + "tokens": 93 + } + }, + { + "id": "r-052", + "values": { + "distractor_ratio": 0.5, + "first_hit_rank": 3, + "latency_ms": 1024, + "tokens": 310 + } + }, + { + "id": "r-053", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 513, + "tokens": 93 + } + }, + { + "id": "r-054", + "values": { + "distractor_ratio": 0, + "first_hit_rank": 1, + "latency_ms": 522, + "tokens": 93 + } + } + ], + "env": { + "backend": "memorylake", + "dataset": "eval/datasets/phoenix-retrieval-v1.jsonl", + "project": "phoenix" + } +} diff --git a/eval/results/2289c68-2026-07-27-l2.json b/eval/results/2289c68-2026-07-27-l2.json new file mode 100644 index 00000000..1097872c --- /dev/null +++ b/eval/results/2289c68-2026-07-27-l2.json @@ -0,0 +1,18 @@ +{ + "git_sha": "2289c68", + "date": "2026-07-27", + "suite": "l2", + "metrics": { + "avg_search_tokens": 205.59259259259258, + "context_tokens": 240, + "injected_tokens_per_session": 6083.777777777777, + "static_hook_tokens": 2875, + "static_mcp_instr_tokens": 184, + "static_skill_tokens": 2168 + }, + "env": { + "project": "phoenix", + "search_calls_assumed": "3.0", + "tokenizer": "approx-bytes/4" + } +} diff --git a/eval/results/w1-r4-l3subset.json b/eval/results/w1-r4-l3subset.json new file mode 100644 index 00000000..6babdc6f --- /dev/null +++ b/eval/results/w1-r4-l3subset.json @@ -0,0 +1,109 @@ +{ + "git_sha": "2289c68", + "date": "2026-07-27", + "suite": "l3", + "metrics": { + "judge_failed_count": 0, + "judge_failed_memory": 0, + "judge_failed_no-memory": 0, + "mean_score_memory": 8.75, + "mean_score_no-memory": 7.75, + "uplift": 1 + }, + "per_item": [ + { + "id": "gotcha-001/no-memory/run0", + "values": { + "duration_s": 71.822120992, + "input_tokens": 14, + "output_tokens": 4476, + "score": 10, + "timed_out": 0 + }, + "note": "Answer covers all rubric points accurately and matches the documented fix precisely, explicitly warning not to just rerun the full build or rely on submodule update alone, and correctly includes sourcing gcc-toolset-11." + }, + { + "id": "gotcha-001/memory/run0", + "values": { + "duration_s": 35.411048592, + "input_tokens": 6, + "output_tokens": 1851, + "score": 10, + "timed_out": 0 + }, + "note": "Answer covers every rubric point: stale FFI header cause, incremental-build short-circuit explanation, exact rm/source/rebuild commands, and explicitly frames it as a recurring pre-build step rather than a one-off fix; no gotchas violated." + }, + { + "id": "gotcha-002/no-memory/run0", + "values": { + "duration_s": 79.275226311, + "input_tokens": 8, + "output_tokens": 5215, + "score": 5, + "timed_out": 0 + }, + "note": "代理正确识别了应使用较大的OID以避免与真实relation冲突,并深入讨论了跨测试collision风险,这部分覆盖了要点;但没有给出仓库中实际采用的9999999这一具体值或提及其来源于一次真实的commit修复(从88888改为9999999),错失了关键的具体建议和背景依据。" + }, + { + "id": "gotcha-002/memory/run0", + "values": { + "duration_s": 51.926457303, + "input_tokens": 6, + "output_tokens": 3417, + "score": 5, + "timed_out": 0 + }, + "note": "The agent correctly identified the core collision risk and avoided suggesting a small OID like 88888, hitting the main gotcha-avoidance points, but it missed the specific real convention (9999999) that the rubric wants, and incorrectly asserted no such convention exists in the repo when one does per the rubric's stated commit history." + }, + { + "id": "gotcha-004/no-memory/run0", + "values": { + "duration_s": 77.462447162, + "input_tokens": 14, + "output_tokens": 4948, + "score": 6, + "timed_out": 0 + }, + "note": "The answer hits the teardown/GUC-reset point solidly and mentions the fault-injector/synchronous-UDF alternative, but its primary diagnosis is a confidently-stated, unverified claim about pg_reload_conf() only affecting the coordinator that isn't part of the expected reasoning and isn't verified in code as CLAUDE.md's investigation guidelines demand, burying the actual expected explanation (in-flight worker not killed by GUC change) as a secondary point." + }, + { + "id": "gotcha-004/memory/run0", + "values": { + "duration_s": 66.018837767, + "input_tokens": 12, + "output_tokens": 4693, + "score": 10, + "timed_out": 0 + }, + "note": "Answer correctly identifies in-flight workers surviving GUC reload, explains ALTER SYSTEM persistence across the schedule, provides a concrete teardown with alter system reset + pg_reload_conf(), and recommends fault-injector pinning with the exact injection points and synchronous UDFs from the rubric; neither gotcha is violated." + }, + { + "id": "gotcha-005/no-memory/run0", + "values": { + "duration_s": 14.611817804, + "input_tokens": 2, + "output_tokens": 831, + "score": 10, + "timed_out": 0 + }, + "note": "答案准确指出了两种执行器(psycopg2 vs psql)的差异,正确说明了应参考 results/ 目录下的实际输出而非用 pg_regress 验证,并给出了具体修复步骤,完全命中所有得分点且未触犯任何扣分项。" + }, + { + "id": "gotcha-005/memory/run0", + "values": { + "duration_s": 14.894055723, + "input_tokens": 2, + "output_tokens": 845, + "score": 10, + "timed_out": 0 + }, + "note": "The answer correctly identifies the psycopg2 vs psql executor distinction, explicitly avoids recommending pg_regress for validation, correctly notes the format differences, and recommends using results/ output as the source of truth for hand-writing or copying the expected file." + } + ], + "env": { + "arms": "no-memory,memory", + "model": "sonnet", + "n": "1", + "tasks": "gotcha-001,gotcha-002,gotcha-004,gotcha-005" + } +} diff --git a/eval/results/w1-r4-l3subset.json.runs.jsonl b/eval/results/w1-r4-l3subset.json.runs.jsonl new file mode 100644 index 00000000..8af3c47f --- /dev/null +++ b/eval/results/w1-r4-l3subset.json.runs.jsonl @@ -0,0 +1,16 @@ +{"kind":"run","task_id":"gotcha-001","arm":"no-memory","run":0,"result":{"task_id":"gotcha-001","arm":"no-memory","result_text":"**Root cause:** this is the exact known failure mode documented in `CLAUDE.md`'s pre-build section — after a rebase, `contrib/zdb/third_party/delta-kernel-rs`'s submodule pointer moves to a commit with new/changed `PrimitiveType` enum variants (or other FFI surface), but Cargo's incremental build in `delta-kernel-rs/relyt/build.sh` sees the `target/` cache and thinks nothing changed, so it **skips re-copying the generated header/lib** into `contrib/zdb/third_party/install/`. The C side (`zdb_deltalake_export.c`) was already updated (upstream) to reference the new variants, so:\n- at compile time you get `PrimitiveType* undeclared` (stale header missing the new variant), or\n- if it does compile against a half-stale header/lib pair, you get the runtime `Create write context failed [errno: -1, msg: Schema inconsistent]` (header/lib mismatch causes ABI-level schema disagreement between the Rust FFI and the C caller).\n\nI checked the current worktree: `delta-kernel-rs` is pinned at `995f844` (2026-06-05), the installed `delta_kernel_ffi.h`/`.so` are dated 2026-06-16 (i.e. already regenerated *after* that commit), and `zdb_deltalake_export.c` was last touched 2026-06-12 — all consistent, and the variants the C file uses (`PrimitiveTypeShort`, `PrimitiveTypeDecimal`, etc.) are present in the installed header. So right now this specific pairing isn't stale. That means when you hit the error, your build ran against an **older cached install** (leftover `.h`/`.so` from before your rebase picked up the new delta-kernel-rs commit, or a stale Cargo `target/` from a previous checkout).\n\n**Fix** — force a clean regen instead of relying on `neon_build_extensions.sh`'s incremental path:\n```bash\nrm -f contrib/zdb/third_party/install/include/delta_kernel_ffi.h\nrm -f contrib/zdb/third_party/install/lib/libdelta_kernel_ffi.so\nsource /opt/rh/gcc-toolset-11/enable\n(cd contrib/zdb/third_party \u0026\u0026 ./delta-kernel-rs/relyt/build.sh release install)\n```\nThen rerun the full build. If it still short-circuits (Cargo still thinks the target is current), also nuke the Rust cache to be safe:\n```bash\nrm -rf contrib/zdb/third_party/delta-kernel-rs/target\n```\nbefore re-running `build.sh`.\n\nSide note unrelated to this bug: your `git status` shows `neon` and `contrib/zdb/third_party/ndp` submodules as dirty (`m`, with `ndp` showing a `-dirty` suffix on its pinned commit) — worth checking those aren't carrying stray local changes before you build, since a dirty `ndp` checkout could introduce its own inconsistency.","input_tokens":14,"output_tokens":4476,"duration_s":71.822120992,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-001","arm":"no-memory","run":0,"verdict":{"score":10,"points_hit":["identified stale delta-kernel-rs FFI header as root cause despite submodule update, due to Cargo incremental build short-circuiting the header copy","gave correct fix commands: rm installed header and .so","sourced gcc-toolset-11 before rebuilding","correct rebuild command via delta-kernel-rs/relyt/build.sh release install","noted this must be redone after every rebase/pull, not just once"],"points_missed":[],"reasoning":"Answer covers all rubric points accurately and matches the documented fix precisely, explicitly warning not to just rerun the full build or rely on submodule update alone, and correctly includes sourcing gcc-toolset-11."}} +{"kind":"run","task_id":"gotcha-001","arm":"memory","run":0,"result":{"task_id":"gotcha-001","arm":"memory","result_text":"That's the known gotcha — confirmed by the timestamps. Here's what's going on:\n\n**Root cause**\n\nThe installed FFI header/lib (`contrib/zdb/third_party/install/{include/delta_kernel_ffi.h, lib/libdelta_kernel_ffi.so}`) are stamped **Jun 16**, but `contrib/zdb/third_party/delta-kernel-rs` is pinned to commit `995f844` from **Jun 5**. Since your rebase pulled in later commits to `zdb_deltalake_export.c` that expect newer enum variants from that Rust crate, the two are now out of sync.\n\n`git submodule update --init --recursive` only updates the submodule checkout — it does **not** regenerate the FFI header. That's a separate step (`delta-kernel-rs/relyt/build.sh`), and per the note in CLAUDE.md, Cargo's incremental build cache can see \"nothing changed\" in the crate source and skip re-emitting the header/lib even when the *consumer* C code has moved on. That mismatch shows up two ways:\n- **Compile time**: `PrimitiveType* undeclared` — the installed header is missing symbols the new C code references.\n- **Runtime**: `Create write context failed [errno: -1, msg: Schema inconsistent]` — an old header got picked up against a newer/older `.so`, so the Parquet/Delta schema struct layout mismatches.\n\n**Fix**\n\nForce-delete the installed artifacts so the build script can't short-circuit, then rebuild:\n\n```bash\nrm -f contrib/zdb/third_party/install/include/delta_kernel_ffi.h\nrm -f contrib/zdb/third_party/install/lib/libdelta_kernel_ffi.so\nsource /opt/rh/gcc-toolset-11/enable\n(cd contrib/zdb/third_party \u0026\u0026 ./delta-kernel-rs/relyt/build.sh release install)\n```\n\nThen rerun your full Neon build (`sh neon/cicd/build.sh`, or `RELYT_WITH_NEON=false sh neon/cicd/build.sh` if you're not testing Neon). This is now a standard step in the mandatory pre-build checklist after any rebase — worth doing every time before a full build, not just when you hit the error.","input_tokens":6,"output_tokens":1851,"duration_s":35.411048592,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-001","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["identified stale delta-kernel-rs FFI header as root cause despite submodule update","explained Rust incremental build cache short-circuit skipping header/lib regen","correct fix: rm the installed header and .so","sourced gcc-toolset-11 before rebuild","correct rebuild command via delta-kernel-rs/relyt/build.sh release install","noted this must be done every rebase/pull, not a one-time fix","did not assume submodule update alone was sufficient","did not skip gcc-toolset-11 sourcing","did not merely suggest rerunning the full build as the fix"],"points_missed":[],"reasoning":"Answer covers every rubric point: stale FFI header cause, incremental-build short-circuit explanation, exact rm/source/rebuild commands, and explicitly frames it as a recurring pre-build step rather than a one-off fix; no gotchas violated."}} +{"kind":"run","task_id":"gotcha-002","arm":"no-memory","run":0,"result":{"task_id":"gotcha-002","arm":"no-memory","result_text":"For a negative \"relation doesn't exist\" test, pick a large, clearly-out-of-range literal rather than a small \"plausible\" number. A few concrete things to watch for:\n\n**What to use**\n- `oid` is unsigned 32-bit (0 to 4294967295). A value like `2147483647` (or any 9-10 digit number safely below the max) reads clearly as a sentinel and won't be mistaken for a real object.\n- Avoid anything below `16384` (`FirstNormalObjectId`) — that range is reserved for pinned/bootstrapped catalog objects, so a \"nonexistent\" oid down there risks accidentally matching a real system object on some catalog layout.\n- Don't use `0` unless you're specifically testing the `InvalidOid` path — that's a distinct case (no oid supplied) from \"well-formed oid that just doesn't correspond to anything.\"\n\n**What to be careful about in this repo specifically**\n- This test suite already hardcodes explicit OIDs elsewhere — e.g. `contrib/zdb/regress/sql/create_with_oid.sql` creates real objects with `dboid=123456`, `tableoid=234567..234570`, `relfilenode=773240..773250`. If your schedule runs in the same shared `isolation2test` cluster (per the test-isolation guidance in CLAUDE.md), your \"nonexistent\" sentinel must not collide with an oid/relfilenode some *other* test in the schedule explicitly assigns — otherwise your negative test silently starts hitting a real row. Grep for your chosen number across `sql/` before locking it in.\n- Confirm which catalog the introspection function actually keys off — `pg_class.oid` vs `relfilenode` are different values in ZDB (see the `create_with_oid.sql` example above, tableoid ≠ relfilenode), so make sure you're passing the right kind of identifier for the function you're testing, not just \"an oid-looking number.\"\n- As a cheap self-check in the test itself, you can assert non-existence directly rather than trusting the constant blindly: `SELECT count(*) FROM pg_class WHERE oid = \u003cyour_value\u003e;` should return 0 before you rely on it in the negative-path query.\n- If you want a bulletproof-guaranteed-never-existed value instead of a guessed constant, an alternative pattern is: create a throwaway table, capture its oid into a psql variable, drop the table, then use `:saved_oid` in the negative-path query — this avoids collision entirely, at the cost of the oid not being visible as a literal in the `.sql`/`.out` files (which is fine since you don't need to print the oid itself, just the \"not found\" result).","input_tokens":8,"output_tokens":5215,"duration_s":79.275226311,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-002","arm":"no-memory","run":0,"verdict":{"score":5,"points_hit":["Small hardcoded OIDs can collide with a real relation's OID (agent discusses collision risk extensively)","The test should assert expected 'relation not found' behavior, not depend on coincidental unused-ness"],"points_missed":["Did not recommend 9999999 specifically or any similarly-styled large round number matching the repo convention","Missed that this convention comes from a real prior fix (async_load_job/simple_load tests changed from 88888 to 9999999)","Suggested 2147483647 (max int32) which is an unusual/edge-case value rather than the established repo convention of a large round number"],"reasoning":"代理正确识别了应使用较大的OID以避免与真实relation冲突,并深入讨论了跨测试collision风险,这部分覆盖了要点;但没有给出仓库中实际采用的9999999这一具体值或提及其来源于一次真实的commit修复(从88888改为9999999),错失了关键的具体建议和背景依据。"}} +{"kind":"run","task_id":"gotcha-002","arm":"memory","run":0,"result":{"task_id":"gotcha-002","arm":"memory","result_text":"No existing convention in this repo's ZDB tests to copy from, so I'll answer from first principles.\n\n**Don't hardcode a small \"obviously safe-looking\" literal like `99999` or `1234`.** Two independent risk zones matter here:\n\n1. **Low end (\u003c ~16384, `FirstNormalObjectId`)** — reserved for objects created by `initdb`/bootstrap and any object OIDs pinned via `unused_oids`/genbki. Never safe to assume \"empty.\"\n2. **Mid-range, just-above-bootstrap** — this is where the real danger is in *this* repo specifically. The ZDB regress schedule runs dozens of SQL files against a **shared** `isolation2test` database/cluster (per your own CLAUDE.md notes on cross-test pollution), plus the extension itself, third-party components (gmeta/arrow/ndp), and other contrib extensions (pgvector, postgis, pg_search, etc.) all create catalog/relation OIDs at install/init time. A literal that happens to be free today can silently become a real relation's OID after:\n - someone adds a new test earlier in the schedule that creates more tables,\n - a new extension/catalog table is added,\n - or the same test runs on a different PG version (v12 vs v14–v17 have different bootstrap catalog counts), which matters here since Phoenix supports multiple vendor PG versions.\n\nThat's exactly the kind of hidden cross-test-state flakiness CLAUDE.md's \"Isolate new regression tests\" section warns about for relfilenode/FDB keys — the same hazard applies to OIDs.\n\n**Recommendation: don't hardcode at all — compute a guaranteed-nonexistent OID at test time.**\n\n```sql\nselect (select max(oid) from pg_class) + 1000000 as bogus_oid \\gset\nselect * from zdb_get_metainfo_for_file(:bogus_oid);\n```\n\nThis is immune to catalog growth across versions/extensions and to schedule ordering. If your test framework needs a literal (e.g. because `\\gset` isn't usable in that harness, or you want deterministic `expected/*.out`), then:\n\n- Pick something clearly outside any plausible OID range for a test DB, e.g. **`2000000000`** (2 billion) — still well under the `oid` type's max (`4294967295`, unsigned 32-bit) so it won't overflow or get cast oddly, but far beyond anything initdb/extensions/thousands of test-created objects would ever reach.\n- Add a defensive comment noting *why* it's that large (so nobody \"cleans it up\" to a small number later).\n- Still verify at test-write time with `select 2000000000::oid in (select oid from pg_class);` to confirm it's actually free in your current build before committing the expected output.\n\nAvoid using the literal max (`4294967295`) itself or values right at the `oid` wraparound boundary — some code paths may treat \"invalid OID\" (`0`) or edge sentinels specially, so staying comfortably inside the range but far from real usage is safer than testing a boundary value you don't actually intend to test.","input_tokens":6,"output_tokens":3417,"duration_s":51.926457303,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-002","arm":"memory","run":0,"verdict":{"score":5,"points_hit":["Small hardcoded OIDs can collide with real relation OIDs and cause instability across cluster states — extensively argued","Should assert expected 'relation not found' behavior rather than depending on coincidental non-use (implicit via dynamic computation recommendation)"],"points_missed":["Did not recommend the large OID value 9999999 as the convention; instead recommended 2000000000 or a dynamically computed value","Missed that this convention comes from a real prior fix in the repo (commit changing async_load_job/simple_load tests from 88888 to 9999999) — agent explicitly claimed no existing convention exists, which is factually wrong"],"reasoning":"The agent correctly identified the core collision risk and avoided suggesting a small OID like 88888, hitting the main gotcha-avoidance points, but it missed the specific real convention (9999999) that the rubric wants, and incorrectly asserted no such convention exists in the repo when one does per the rubric's stated commit history."}} +{"kind":"run","task_id":"gotcha-004","arm":"no-memory","run":0,"result":{"task_id":"gotcha-004","arm":"no-memory","result_text":"Two likely causes, and I found direct evidence for the first one in an existing test (`contrib/zdb/regress/sql/backup_and_gc/drop_table_left_meta.sql`):\n\n**1. `pg_reload_conf()` on its own only reloads the coordinator.** In GPDB, `ALTER SYSTEM SET` gets dispatched cluster-wide and rewrites `postgresql.auto.conf` everywhere, but a bare `select pg_reload_conf();` only sends SIGHUP to the backend you're connected to (the coordinator). Each segment's postmaster — and thus its own autovacuum launcher/workers — never gets signaled, so segments keep running with the *old* `relyt.autovacuum` value until something reloads their config too. That matches your symptom exactly: GC still happens even though you \"turned it off,\" because you only turned it off on the coordinator. The existing test does this correctly:\n\n```sql\nselect * from pg_reload_conf() union all select pg_reload_conf() from gp_dist_random('gp_id');\n```\n\nIf your setup only calls `pg_reload_conf()` once (no `gp_dist_random`), that's almost certainly your bug.\n\n**2. Even with a full reload, there's a race window.** `relyt.autovacuum` is read live per-relation inside `ZDBNeedsVacAnalyze` (`contrib/zdb/src/storage/zdb_vacuum.c:2019`) each time an autovacuum worker considers a table — it's not a kill switch for workers already running. A worker/launcher cycle from a *previous* test that started before your reload lands can still complete a GC pass during your test window. This is exactly why `drop_table_left_meta.sql` doesn't rely on the GUC+timing at all for determinism — it pins execution with `gp_inject_fault('auto_vac_before_backup_dropped_table'/'auto_vac_after_backup_dropped_table'/'auto_vac_after_gc_dropped_table', 'suspend', ...)` and drives things forward with `wait_until_triggered`/`reset`, or (per CLAUDE.md) synchronous UDFs like `relyt_vacuum_dropped_relation()` / `relyt_vacuum_aborted_txn()`. If your test's correctness depends on \"no GC happened,\" prefer that deterministic pinning over toggling the GUC and hoping.\n\n**Teardown should mirror the same file's pattern** — reset every GUC you altered and reload on *all* segments, not just the coordinator, and reset any fault injectors you left suspended:\n\n```sql\n-- start_ignore\nalter system reset relyt.autovacuum;\n-- ...reset any other GUCs you touched...\nselect * from pg_reload_conf() union all select pg_reload_conf() from gp_dist_random('gp_id');\n-- end_ignore\n```\n\nLeaving `relyt.autovacuum=off` cluster-wide via `ALTER SYSTEM SET` (without a reset) is exactly the kind of GUC leakage CLAUDE.md warns about — it silently disables GC for every test that runs later in the schedule.","input_tokens":14,"output_tokens":4948,"duration_s":77.462447162,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-004","arm":"no-memory","run":0,"verdict":{"score":6,"points_hit":["Teardown must reset relyt.autovacuum via alter system reset + pg_reload_conf(), addressing GUC leakage into later tests","Correctly notes GUC changes don't stop an already-running/in-flight worker (framed as race window)"],"points_missed":["Did not clearly state the core rubric point that an autovacuum worker spawned by an earlier test may already be running even after the GUC flip — instead invented an unsupported claim that pg_reload_conf() only reloads the coordinator and segments need gp_dist_random, which is not part of the expected reasoning and is a speculative/unverified technical claim presented with high confidence","Recommendation to use fault injectors / synchronous UDFs (relyt_vacuum_dropped_relation()/relyt_vacuum_aborted_txn()) is present but secondary to the fabricated primary cause, diluting the correct answer"],"reasoning":"The answer hits the teardown/GUC-reset point solidly and mentions the fault-injector/synchronous-UDF alternative, but its primary diagnosis is a confidently-stated, unverified claim about pg_reload_conf() only affecting the coordinator that isn't part of the expected reasoning and isn't verified in code as CLAUDE.md's investigation guidelines demand, burying the actual expected explanation (in-flight worker not killed by GUC change) as a secondary point."}} +{"kind":"run","task_id":"gotcha-004","arm":"memory","run":0,"result":{"task_id":"gotcha-004","arm":"memory","result_text":"Good question — the CLAUDE.md guidance covers this, and the actual test (`contrib/zdb/regress/sql/backup_and_gc/drop_table_left_meta.sql`) shows the real pattern in use. Here's what's going on.\n\n## Why `relyt.autovacuum = off` + reload isn't actually a clean guarantee\n\n1. **In-flight workers aren't killed by a config reload.** `pg_reload_conf()` only tells the launcher to stop *scheduling new* workers — a worker already forked by a previous test (on the old config) keeps running to completion regardless of what you just set. If your setup races a still-running worker from the prior test, you'll see GC activity you didn't cause.\n\n2. **`relyt.autovacuum` only gates one class of background work.** Look at line 2 vs line 6 of `drop_table_left_meta.sql` — it sets the *core PG* `autovacuum=on` **and** `relyt.autovacuum=off` together. That's deliberate: `relyt.autovacuum` is a narrower switch. Other GC/vacuum paths run on their own independent GUCs — `relyt.vacuum_shard_backup_schedule_interval`, `relyt.vacuum_deleted_backup_interval`, `relyt.autovacuum_max_workers`, `autovacuum_naptime` — none of which are touched by flipping `relyt.autovacuum` alone. If a prior test left `autovacuum_naptime` low or backup/GC intervals aggressive, that keeps firing under your test even with `relyt.autovacuum = off`.\n\n3. **Synchronous GC calls bypass the GUC entirely.** Explicit calls like `relyt_vacuum_dropped_relation()` / `relyt_vacuum_aborted_txn()`, or a plain `vacuum` statement, run regardless of `relyt.autovacuum`. If anything in your own test (or a concurrently-running background piece from the schedule) invokes these, \"autovacuum off\" gives you no protection.\n\n4. **GUC leakage is bidirectional.** `ALTER SYSTEM` + `pg_reload_conf()` is cluster-global and outlives your session — so it's not just that later tests can inherit *your* leftover settings, it's that *you* can inherit unreset settings from whatever ran before you in the schedule. Your setup block doesn't \"start clean\" just because you set your own GUCs; anything the previous test forgot to reset is still active underneath yours.\n\n## What teardown should look like\n\nMirror every GUC you set in setup, explicitly reset (not just re-toggle), and reload on **master and all segments** — the real test does this with `gp_dist_random('gp_id')`:\n\n```sql\n-- start_ignore\nalter system reset relyt.autovacuum;\nalter system reset relyt.vacuum_shard_backup_schedule_interval;\nalter system reset relyt.vacuum_deleted_backup_interval;\nalter system reset relyt.vacuum_dropped_relation_num;\nalter system reset relyt.autovacuum_max_workers;\nalter system reset relyt.autovacuum_max_reserved_workers_for_gc_aborted;\nalter system reset autovacuum_naptime;\nalter system reset autovacuum;\nselect * from pg_reload_conf() union all select pg_reload_conf() from gp_dist_random('gp_id');\n-- end_ignore\n```\n\nWrap both setup and teardown in `-- start_ignore` / `-- end_ignore` so the reload noise doesn't pollute the expected output diff (see lines 1–10 and 586–597).\n\n## If you need actual determinism, not just \"quieter ambient noise\"\n\nTurning autovacuum off only reduces the *chance* of a race — it doesn't eliminate it, per points 1–3 above. If your assertions depend on \"GC has definitely not run yet\" or \"GC has definitely finished,\" don't rely on the GUC at all — pin the worker with `gp_inject_fault` at the actual GC entry points (`auto_vac_before_backup_dropped_table`, `auto_vac_after_backup_dropped_table`, `auto_vac_after_gc_dropped_table`), suspend it, do your assertions, then `wait_until_triggered`/`reset` to let it proceed — exactly the pattern in `drop_table_left_meta.sql`. That gives you a synchronous checkpoint instead of a probabilistic \"it's probably off\" window, and it's immune to leftover workers from earlier tests since you're gating on the fault point itself, not on whether a worker got scheduled at all.","input_tokens":12,"output_tokens":4693,"duration_s":66.018837767,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-004","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["An autovacuum worker spawned by an earlier test may already be running/in-flight even after the GUC is flipped off, since the GUC change doesn't kill an already-running worker","To reliably prevent background GC, pin the worker with a fault injector or drive the work synchronously instead","ALTER SYSTEM SET + pg_reload_conf() persists into every subsequent test in the schedule, so it must be undone","Teardown must include alter system reset relyt.autovacuum (and any other cluster GUCs set) followed by pg_reload_conf()"],"points_missed":[],"reasoning":"Answer correctly identifies in-flight workers surviving GUC reload, explains ALTER SYSTEM persistence across the schedule, provides a concrete teardown with alter system reset + pg_reload_conf(), and recommends fault-injector pinning with the exact injection points and synchronous UDFs from the rubric; neither gotcha is violated."}} +{"kind":"run","task_id":"gotcha-005","arm":"no-memory","run":0,"result":{"task_id":"gotcha-005","arm":"no-memory","result_text":"The mismatch is almost certainly a format difference between test executors, not a real result difference.\n\n`installcheck-default` runs via `pg_isolation2_regress`, which drives queries through psycopg2 — not `pg_regress`, which drives them through `psql`. These two produce different output formatting even for identical query results:\n\n- Command tags differ (e.g. `INSERT 0 N` vs isolation2's tag format)\n- Column alignment/padding differs (isolation2 output is left-justified with trailing spaces where psql right-pads/truncates differently)\n- SQL echo formatting differs (single-line echo vs psql's echo style)\n- Trailing whitespace behavior differs\n\nSince you captured your `expected/foo.out` by running the SQL through `pg_regress`, it has `pg_regress`-style formatting baked in, so `pg_isolation2_regress` (used by `installcheck-default`) will diff against it even when the actual data/rows are correct.\n\nFix: regenerate the expected file from the isolation2 executor's actual output instead of pg_regress's. Run the test once, let it fail, then look at `contrib/zdb/regress/results/foo.out` (produced by the `installcheck-default` run itself) and use that as your new `expected/foo.out` — after manually verifying the row data is correct. This is called out in `CLAUDE.md`:\n\n\u003e 格式必须匹配 `pg_isolation2_regress`(即 `make installcheck-default`)的输出,不能用 `pg_regress` 验证——两者执行器不同(前者走 psycopg2,后者走 psql),输出格式(command tag、列对齐、尾随空格等)完全不同。可以手写 expected 文件(用于验证预期行为),但格式需参考 `results/` 中的实际输出。\n\nSo: run `make install installcheck-default` once (it'll fail), copy the resulting `results/foo.out` → `expected/foo.out`, verify the data by eye, and rerun.","input_tokens":2,"output_tokens":831,"duration_s":14.611817804,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-005","arm":"no-memory","run":0,"verdict":{"score":10,"points_hit":["installcheck-default runs through pg_isolation2_regress (psycopg2-based executor), not pg_regress (psql-based executor)","cannot validate expected .out via pg_regress; command tags/column alignment/trailing whitespace differ","hand-writing is fine but must match pg_isolation2_regress conventions (single-line echo, command tags like CREATE/INSERT N/DROP, ljust alignment)","reference actual output under results/ rather than guessing","did not validate via pg_regress (avoided gotcha)","did not assume the two executors produce same formatting (avoided gotcha)"],"points_missed":[],"reasoning":"答案准确指出了两种执行器(psycopg2 vs psql)的差异,正确说明了应参考 results/ 目录下的实际输出而非用 pg_regress 验证,并给出了具体修复步骤,完全命中所有得分点且未触犯任何扣分项。"}} +{"kind":"run","task_id":"gotcha-005","arm":"memory","run":0,"result":{"task_id":"gotcha-005","arm":"memory","result_text":"That's a known gotcha documented in this repo's CLAUDE.md: `installcheck-default` runs through `pg_isolation2_regress`, not `pg_regress`, and the two use different executors — `pg_isolation2_regress` drives queries via psycopg2, while plain `pg_regress` drives them via `psql`. Even when the actual query *results* are identical, the output formatting differs (command tags like `INSERT N`/`DROP`, column alignment, trailing whitespace, how the SQL itself is echoed), so an expected file captured from a `pg_regress` run will show a diff against `installcheck-default` even though nothing is semantically wrong.\n\nFix: don't generate/validate the expected file with `pg_regress`. Instead:\n1. Run the test through the actual target: `make install installcheck-default` (or just run the isolation2 regress binary directly), let it fail/diff once, and look at the real output in `contrib/zdb/regress/results/foo.out`.\n2. Copy that `results/foo.out` into `expected/foo.out` (after eyeballing it's correct), rather than hand-writing from a `pg_regress` capture.\n3. If you do want to hand-write the expected file (e.g., to also document expected behavior), match the `pg_isolation2_regress` conventions: single-line SQL echo, command tags like `CREATE`/`INSERT 0 N`/`DROP`, left-justified (`ljust`) column formatting with trailing spaces preserved.\n\nSo: regenerate/copy your expected file from a real `installcheck-default` `results/` run instead of from `pg_regress`'s output.","input_tokens":2,"output_tokens":845,"duration_s":14.894055723,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-005","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["installcheck-default runs through pg_isolation2_regress (psycopg2-based executor), not pg_regress (psql-based executor) — the two executors produce different output formats","You cannot validate an expected .out file by running it through pg_regress; the command tags, column alignment, and trailing whitespace differ between the two executors","It is fine to hand-write the expected file, but its format must match pg_isolation2_regress conventions: single-line SQL echo, command tags like CREATE/INSERT N/DROP, left-justified (ljust) column alignment with trailing spaces","Reference the actual output already produced under results/ in the regress directory to get the exact format right, rather than guessing","did NOT validate/generate the expected .out file using pg_regress","did NOT assume pg_regress and pg_isolation2_regress produce the same output formatting"],"points_missed":[],"reasoning":"The answer correctly identifies the psycopg2 vs psql executor distinction, explicitly avoids recommending pg_regress for validation, correctly notes the format differences, and recommends using results/ output as the source of truth for hand-writing or copying the expected file."}} From b2fffd38502110d4728cc4aff3e099f230151765 Mon Sep 17 00:00:00 2001 From: zhishu <weichuangxian@hotmail.com> Date: Mon, 27 Jul 2026 17:23:29 +0000 Subject: [PATCH 26/29] docs(proposals): MemoryLake server asks from wave1 evidence --- .../2026-07-memorylake-server-asks.md | 509 ++++++++++++++++++ 1 file changed, 509 insertions(+) create mode 100644 docs/proposals/2026-07-memorylake-server-asks.md diff --git a/docs/proposals/2026-07-memorylake-server-asks.md b/docs/proposals/2026-07-memorylake-server-asks.md new file mode 100644 index 00000000..d6ae82bd --- /dev/null +++ b/docs/proposals/2026-07-memorylake-server-asks.md @@ -0,0 +1,509 @@ +# MemoryLake server-side asks — evidence from Engram Phase-2 Wave 1 + +**Author:** Engram (client team) +**Date:** 2026-07-27 +**Audience:** MemoryLake server team +**Status:** proposal — no server work committed, no client work blocked on it + +Engram is a MemoryLake client: for projects enabled via `engram memorylake +enable`, every memory read and write goes through the V3 API (see +`internal/memorylake/writequeue.go`, `search.go`, `backend.go`). Over +2026-07-24…2026-07-27 we built a three-level memory eval harness and ran a +baseline plus four optimization rounds against the **live** MemoryLake project +`phoenix`. This document turns what that measurement exposed into three +concrete API asks, plus one section of client-side findings the server team +should know about. + +Every number below is quoted from a committed scorecard in +`eval/results/`; the file is named at each citation so it can be re-checked. + +## Program anchors these asks are measured against + +| Anchor | Baseline (`2adc24c`, 2026-07-24) | Phase-2 target | +|---|---|---| +| **L3 memory uplift** (mean end-to-end score, memory arm − no-memory arm) | **2.267** (6.000 − 3.733) | **≥ 4.53** | +| **L2 injected tokens / session** | **7824** | **≤ 3912** | + +Source: `eval/results/baseline-report.md`, `2adc24c-2026-07-24-l2.json`, +`2adc24c-2026-07-24-l3.json`. + +Baseline L2 decomposition (tokenizer `approx-bytes/4`, `search-calls=3.0`): +SessionStart hook stdout 3202 (41%), memory SKILL.md 1587 (20%), MCP server +instructions 787 (10%), `mem_context` payload 697 (9%), retrieval 517 × 3 = +1551 (20%). + +Where Wave 1 has taken those numbers so far, entirely with client-side work: +L2 composite `7824 → 6083.78` (`2289c68-2026-07-27-l2.json`), retrieval +`517.06 → 205.59` tokens/query (`2adc24c-2026-07-24-l1.json` → +`b4db321-2026-07-27-l1.json`). The three asks below target the parts of the +remaining gap that the client structurally **cannot** close, because the cost +is paid before our code sees the bytes. + +--- + +## 1. Temporal validity on facts (`valid_from` / `valid_until`) + +### 1a. Current client-side pain + +The V3 fact model has no notion of *when a fact was true*. A fact is either +present (and fully ranked) or forgotten. There is no way to say "this was +correct until 2026-07-24" and have search reflect that. + +The consequence is visible in our own corpus. When constructing the frozen L1 +dataset we dumped the live `phoenix` project and found that of its 6 facts, +one was **a tombstone written as prose**: + +> `[此条记忆已按用户要求于 2026-07-24 删除/作废...]` + +— cited verbatim in `eval/datasets/README.md` (Sources §1), which records that +this fact "was excluded entirely as source material — it was never used to +write a case, and no case's keywords match it." + +That is the ask in one artifact. A user's invalidation of a memory had to be +expressed as **fact text**, because the API offers no validity field. The +invalidation therefore: + +- **still occupies a search slot** — it is embedded, scored, and eligible to + rank ahead of a live fact; +- **still costs injected tokens** every time it is returned; +- **is invisible to the client as a signal** — Engram cannot filter or de-rank + it, because "this fact is dead" is only legible to a human reading Chinese + prose, not to `SearchFacts`. + +The distractor metric quantifies the general cost of ranked-but-useless facts. +From `2289c68-2026-07-27-l1.json` / `b4db321-2026-07-27-l1.json` +(`avg_distractor_ratio` = 0.13271604938271603) over 54 cases: + +| First-hit rank | Cases | +|---|---| +| 1 (clean top hit) | 37 | +| 2 | 5 | +| 3 | 1 | +| miss | 11 | + +Of the 11 misses, **6 returned zero results** (the current `threshold` 0.1 in +`semanticSearchFacts` filtered everything) and **5 returned a payload that was +100% distractors** (`distractor_ratio` = 1.0). A further 6 cases shipped +partial distractors above the first hit (2 × 0.25, 2 × 0.333, 2 × 0.5). The +earlier round measured the same shape at `avg_distractor_ratio` +0.13888888888888887 (`0e9020d-2026-07-27-l1.json`). So roughly one query in +five is paying tokens for facts ranked above the answer — and today a +superseded fact is indistinguishable from a live one in that ranking. + +This also gets worse with time, not better: the `phoenix` corpus grew 6 → 15 +facts organically during Wave 1 (recorded in `.superpowers/sdd/progress.md`, +W1-T5), which by itself moved retrieval from 517.06 to 890.78 and then +991.46 tokens/query (`0e9020d-2026-07-27-l1.json`, +`9332c39-2026-07-27-l1.json`) before client-side index-mode rendering pulled it +back to 205.59. Superseded facts accumulate; without validity they accumulate +*in the ranking*. + +### 1b. Proposed API change + +**(i) `valid_from` / `valid_until` as first-class fact fields**, settable at +add time and by PATCH. + +```http +POST /api/v3/workspaces/engram/projects/proj-52bfd150…/memories/facts +Content-Type: application/json + +{ + "facts": [ + { + "fact": "ZDB regression tests must scope FDB introspection UDFs by relfilenode.", + "valid_from": "2026-07-24T03:38:15Z" + } + ] +} +``` + +Note this requires the batch element to accept an **object** as well as the +bare string it accepts today (`{"facts": ["text", …]}` — +`writequeue.go:AddFacts`). We ask that the string form keep working unchanged +(see §4 on why verbatim string add is load-bearing for us). + +```http +PATCH /api/v3/workspaces/engram/projects/proj-52bfd150…/memories/facts/fact-4c762f9b5c83… + +{ "valid_until": "2026-07-24T00:00:00Z" } +``` + +Response (the shape `patchFactMetadata` already consumes — the echoed fact, +now carrying validity): + +```json +{ + "id": "fact-4c762f9b5c83…", + "fact": "…", + "metadata": { "pinned": true }, + "valid_from": "2026-07-20T11:02:00Z", + "valid_until": "2026-07-24T00:00:00Z", + "created_at": "2026-07-20T11:02:00Z", + "updated_at": "2026-07-27T16:25:00Z" +} +``` + +**(ii) Search honors validity by default, with explicit opt-in for history.** + +```http +POST /api/v3/workspaces/engram/memories/search + +{ + "query": "how do I avoid flaky FDB introspection counts", + "project_ids": ["proj-52bfd150…"], + "memory_types": ["fact"], + "top_k": 10, + "threshold": 0.1, + "validity": "current" // "current" (default) | "all" | "as_of" + // "as_of": "2026-07-01T00:00:00Z" — with validity:"as_of" +} +``` + +Semantics we would rely on: + +- `validity: "current"` — omit facts whose `valid_until` is in the past. This + is the behavior we want as the default for every agent-facing read + (`mem_search`, `mem_context`). +- `validity: "all"` — return them, each annotated (`"superseded": true`), so a + human-facing surface (Engram's TUI/dashboard timeline) can still show + history. +- `validity: "as_of"` — point-in-time reads. We do not need this for Wave 2; + we list it so the field is not designed as a boolean that later has to grow. + +A **de-ranking** variant (superseded facts returned but score-penalized) is +acceptable to us as a first increment and is strictly better than today, but +omission is what actually buys the token win. + +### 1c. Expected effect on the anchors + +- **Injected tokens (≤ 3912).** Direct and bounded: superseded facts stop + consuming search slots and stop being rendered. On the current 54-case L1 + set the addressable slice is the distractor mass — `avg_distractor_ratio` + 0.133, with 5 cases shipping a 100%-distractor payload. At 205.59 + tokens/query and `search-calls=3.0` (≈617 tokens of the 6083.78 composite), + eliminating the fully-wasted payloads is a small absolute win *today* on a + 15-fact corpus. Its real value is derivative-of-time: it removes the term + that made the composite drift from 7824 to 9699.39 as the corpus grew + (`9332c39-2026-07-27-l2.json`). Without validity, every optimization we ship + is re-eaten by accumulation. +- **Uplift (≥ 4.53).** This is the more important axis. Baseline per-category + uplift is gotcha **+8.50**, architecture-qa **−0.90**, small-fix **−0.80** + (`baseline-report.md`): memory only wins where the retrieved fact is *right*, + and actively hurts where the agent spends turns on material the repo already + answers better. A stale-but-confident fact is the worst case for that + arithmetic — it is the mechanism by which memory produces a *negative* + category score. Validity filtering is the only way to make "the memory + system will not hand the agent a fact the user already retired" a property + of the system rather than a hope. We are not claiming a specific point + gain: our current corpus has exactly one tombstone, so the effect is not + yet separately measurable. We are claiming it removes an unbounded downside + risk on the axis where our uplift target lives. + +--- + +## 2. `min_score` (relevance threshold) on search + +### 2a. Current client-side pain + +`semanticSearchFacts` (`search.go:79`) sends `top_k` and `threshold` +(`defaultSearchTopK` 10, `defaultSearchThreshold` 0.1). What we observe is +that `top_k` governs the wire payload and the effective floor is low enough +that weak hits ship. The client then filters and re-renders — **after** paying +for the full payload on the wire and in our own process. + +Evidence: + +- Baseline: **517.0555555555555 tokens/query** average over 54 cases on a + 6-fact corpus (`2adc24c-2026-07-24-l1.json`) — a payload larger than the + usable corpus, for a single question. +- As the corpus grew to 15 facts: **890.7777777777778** then + **991.4629629629629** tokens/query (`0e9020d-2026-07-27-l1.json`, + `9332c39-2026-07-27-l1.json`). +- After we shipped client-side index-mode rendering (W1-T8): **205.59259259259258** + tokens/query (`b4db321-2026-07-27-l1.json`, `2289c68-2026-07-27-l1.json`). + Round-over-round against `9332c39` that is **991.46 → 205.59, a 79% + reduction, with retrieval quality bit-identical**: `recall@1/5/10` + 0.6851851851851852 / 0.7962962962962963 / 0.7962962962962963 and MRR + 0.7376543209876544 in both scorecards, `avg_distractor_ratio` + 0.13271604938271603 in both. (Measurement caveat, recorded in + `.superpowers/sdd/progress.md` W1-T8: the token *basis* changed with that + commit — the pre-index number was a JSON-marshal payload, the post-index + number is index text plus the structured entries of shown hits. The recall + and distractor figures are unaffected by the basis change.) + +That last line is the argument. We cut what the *agent* sees by 79% and lost +nothing measurable — which means the bulk of the pre-index payload was never +load-bearing. But we could only cut the rendering. The bytes still cross the +network, are still +embedded-and-scored server-side, and still land in our heap: L1's own comment +records that it now measures the shown payload as a **floor**, with envelope +framing uncounted. A server-side `min_score` would cut the same waste one +layer earlier, where it is actually free. + +The distractor distribution confirms the sub-threshold hits are real and +shippable: 5 of 54 cases (`2289c68-2026-07-27-l1.json`) return a payload in +which **every** result is a distractor (`distractor_ratio` 1.0). Those queries +have no answer in the corpus. The correct response is an empty result set — +which the server already produces for 6 other misses, so the mechanism exists; +it is the floor that is too low to be useful. + +### 2b. Proposed API change + +Add `min_score` to the search body: a hard post-ranking floor, applied +server-side, independent of `top_k`. + +```http +POST /api/v3/workspaces/engram/memories/search + +{ + "query": "why is the memory arm slower on small-fix tasks", + "project_ids": ["proj-52bfd150…"], + "memory_types": ["fact"], + "top_k": 10, + "min_score": 0.45, + "actor_ids": ["actor-engram-cli"] +} +``` + +Response — same shape as today, plus the two fields that let a client tune +without guessing: + +```json +{ + "facts": [ + { "id": "fact-ae290150a41d…", "fact": "…", "score": 0.71, "metadata": {} } + ], + "returned_count": 1, + "filtered_by_min_score": 7 +} +``` + +`filtered_by_min_score` matters: without it a client cannot distinguish "no +matching facts exist" from "your floor is too high", and will hedge by setting +the floor low — reproducing today's behavior. With it, Engram can calibrate +per project and report the calibration in its own scorecards. + +Semantics we would rely on: + +1. `min_score` is applied **after** ranking and **before** truncation to + `top_k`, so raising the floor never silently drops a strong hit. +2. Returning fewer than `top_k` (including zero) is a normal, non-error + response. +3. `min_score` and the existing `threshold` must have a documented + relationship. Our reading of the current contract is that `threshold` is a + *retrieval-stage* similarity cutoff; if `min_score` is simply its + post-ranking counterpart, say so, and we will drop `threshold` from our + request body. + +### 2c. Expected effect on the anchors + +- **Injected tokens (≤ 3912).** Retrieval is ≈617 of the current 6083.78 + composite (205.59 × 3 assumed search calls). A `min_score` that suppresses + the 5 all-distractor payloads and trims the 6 partial-distractor cases would + reduce that slice further, but the honest accounting is that **the client + already captured most of the agent-visible retrieval win** (517.06 → 205.59). + The remaining gap to 3912 is dominated by `static_hook_tokens` **2875** + (`2289c68-2026-07-27-l2.json`), which is entirely ours to fix. So we are + **not** asking for `min_score` primarily on token grounds; we are asking + because it moves the cut server-side, which (a) removes the wire and + server-embedding cost that our floor-metric does not even measure, and + (b) keeps the win stable as corpora grow, which client-side rendering caps + do not (see the 6 → 15 fact drift in §1a). +- **Uplift (≥ 4.53).** A hard floor converts "the agent got 10 weak facts" into + "the agent got nothing and knows it." That is the behavior the baseline says + we need: architecture-qa uplift is **−0.90** and small-fix **−0.80** + precisely because the agent burns turns on memory material when the repo is + the better source. An empty, fast, unambiguous "no relevant memory" is the + cheapest possible correct answer on those categories, and it is the one + result shape the client cannot manufacture without first paying for the + wrong one. + +--- + +## 3. Batch endpoints: batch forget, batch metadata PATCH + +### 3a. Current client-side pain + +Batch **add** exists — `POST .../memories/facts` takes `{"facts": [...]}` and +`AddFacts` uses it. Nothing else does. Every other mutation is +one-fact-per-round-trip: + +- **Forget.** `MemoryLakeBackend.forgetFact` (`backend.go:944`) issues + `POST .../memories/facts/{id}/forget` per fact. On 2026-07-24 we cleaned **28 + junk facts** that test runs had written into the live `engram` project + (incident recorded in `eval/results/baseline-report.md` "Incidents & + environment notes" and `.superpowers/sdd/progress.md`). That was **28 + sequential per-fact round-trips** — 28 chances to fail partway, no + atomicity, and no way to express "forget this set" as one intent. There is no + hard delete at all: `DeleteObservation` documents that `hard_delete=true` + degrades to forget, so cleanup is *always* this loop. +- **Pin / metadata.** `setPinned` (`backend.go:300`) is a read-modify-write: + `GET .../facts/{id}` then `PATCH .../facts/{id}` with the merged metadata — + **two** round-trips per fact, because PATCH replaces `metadata` wholesale and + the client must not clobber keys it does not own. W1-T11 pinned 2 phoenix + gotcha facts (`fact-4c762f9b5c83…`, `fact-ae290150a41d…`) and that cost 4 + round-trips for 2 pins. Pinning a curated set of 20 facts for a project would + cost 40. + +Related, and cheap to fix in the same change: MemoryLake records **no pin +timestamp**. `factPinTime` documents the workaround — Engram orders the pinned +block by `updated_at` as a proxy, so any unrelated PATCH reshuffles the pinned +section. See the metadata note in §3b. + +### 3b. Proposed API change + +**(i) Batch forget.** + +```http +POST /api/v3/workspaces/engram/projects/proj-2ae06986…/memories/facts/forget + +{ "fact_ids": ["fact-a1…", "fact-b2…", "fact-c3…"] } +``` + +```json +{ + "forgotten": ["fact-a1…", "fact-b2…"], + "failed": [ { "id": "fact-c3…", "code": "NOT_FOUND" } ] +} +``` + +Per-item outcomes rather than all-or-nothing: our cleanup loop is inherently +best-effort over ids that may have already been forgotten, and a single 404 +must not abort the batch. (If the server prefers `DELETE` with a body, or a +`?ids=` query form, either is fine — the shape of the *response* is what we +depend on.) + +**(ii) Batch metadata PATCH, with merge semantics.** + +```http +PATCH /api/v3/workspaces/engram/projects/proj-52bfd150…/memories/facts + +{ + "merge": true, + "updates": [ + { "id": "fact-4c762f9b5c83…", "metadata": { "pinned": true } }, + { "id": "fact-ae290150a41d…", "metadata": { "pinned": true } } + ] +} +``` + +```json +{ + "updated": [ + { "id": "fact-4c762f9b5c83…", "metadata": { "pinned": true, "pinned_at": "2026-07-27T16:25:00Z" } }, + { "id": "fact-ae290150a41d…", "metadata": { "pinned": true, "pinned_at": "2026-07-27T16:25:01Z" } } + ], + "failed": [] +} +``` + +`merge: true` is the load-bearing half. It removes the GET from every +read-modify-write, taking pin from 2 round-trips to (amortized) a fraction of +one, and it eliminates a real lost-update race: today two concurrent metadata +writers each read-then-replace, and the loser's key vanishes. We would like +`merge: false` (today's replace) to remain available and to stay the default +for the existing single-fact PATCH, so nothing we already ship changes meaning. + +**(iii) Optional, small:** stamp a server-side `pinned_at` (or expose a +generic `metadata_updated_at`) so pinned-order does not have to be inferred +from `updated_at`. This is a one-field change that retires a documented +client-side approximation. + +### 3c. Expected effect on the anchors + +We are explicit here: **batch endpoints move neither anchor directly.** They +are latency, correctness, and operability asks, and we would rather say so than +attach a fabricated token number to them. + +- **Injected tokens (≤ 3912):** no direct effect. Indirect and real: the 28 + junk facts were *in a live project's ranking* until they were cleaned one at + a time. Corpus hygiene is a token lever (a 6 → 15 fact growth cost us + 517.06 → 991.46 tokens/query), and hygiene that costs O(n) round-trips does + not get done promptly. Cheap bulk forget is what makes "keep the corpus + clean" an operation rather than a project. +- **Uplift (≥ 4.53):** no direct effect. Indirect: pinning is our main + mechanism for guaranteeing that decisive gotcha facts are present, and the + gotcha category is where the *entire* baseline uplift lives (**+8.50** vs + 0.00 for the no-memory arm; the R4 targeted gotcha round measured memory-arm + mean **8.75** vs no-memory **7.75**, `w1-r4-l3subset.json`, N=1). Making + curation cheap makes that mechanism usable at more than 2 facts at a time. +- **Latency, the honest headline:** L1 p50/p95 is 584/1165 ms + (`2289c68-2026-07-27-l1.json`). Multiply a round-trip in that range by 28 + (cleanup) or by 2-per-pin (curation) and the cost of operating a MemoryLake + project is dominated by round-trip count, not by work done. + +--- + +## 4. Client-side findings for the server team's awareness + +Not asks. Two properties of the current system that shaped our work and would +shape anyone else's. + +**(a) mem0 async extraction lag constrains eval-dataset construction.** +`AppendObservation` writes a conversation *message*; MemoryLake's extraction +pipeline turns it into a fact asynchronously, out of band. Engram deliberately +does not poll or backfill — `mem_save` returns immediately after the append, +and the resulting fact is only observable later through normal read paths (see +`writequeue.go:AppendObservation`'s doc comment, and `backend.go:153`: "A +caller that needs the materialized fact … must re-find it later via Search +once mem0 has processed the message"). + +The eval consequence: **there is no point at which a test can assert "the thing +I just saved is now retrievable."** We could not *seed* a retrieval corpus and +then measure against it. The L1 dataset was instead mined from a frozen +read-only dump of whatever the live project already held — 6 facts, 5 usable — +and `eval/datasets/README.md` records the resulting caveat verbatim: "all 54 +cases are mined from only 5 usable facts … Recall figures from this dataset +measure retrieval quality over a small, dense corpus, not a broad one." Every +recall number in this document (0.7592592592592593 / 0.7962962962962963 / +0.7962962962962963 at baseline) inherits that limitation, and the limitation is +downstream of extraction being unobservable rather than of anything we chose. + +If the server ever exposes an extraction-completion signal — a +`GET .../messages/{id}/extraction` status, or a synchronous +`?wait_for_extraction=true` on message append — it would let clients build +seeded, reproducible retrieval fixtures. We are not requesting it in this +document; we are recording that its absence is why our corpus is 15 facts and +not 500. + +**(b) The direct fact-add endpoint's verbatim semantics are load-bearing for +migration idempotency.** `POST .../memories/facts` (shipped 2026-07-23) is the +one write path that bypasses conversation-plus-extraction: each string becomes +a fact **as-is, synchronously**, and the response carries the real fact ids. +Engram depends on all four of those properties: + +- **Verbatim** — an Engram observation is a `title` + `content` pair rendered + to text (`factText`). If the server rewrote, summarized, split, or merged it, + the migrated memory would no longer be the memory the user saved, and + `mem_get_observation` would return something the user never wrote. +- **Synchronous, with real ids** — the returned fact id becomes the + observation's `sync_id`. Every subsequent operation (get, update, pin, + forget) addresses the fact by it. +- **Not server-deduplicated** — `AddFacts` documents that posting the same text + twice creates two facts, and that callers needing idempotency must dedupe + first, which `MigrateObservations` does. This is the right split: the client + knows which of its local observations it has already migrated. + +The ask embedded in this finding is a *stability* ask, not a change: if +extraction, normalization, or server-side dedupe were ever added to this +endpoint, migration would stop being idempotent and existing `sync_id`s could +be invalidated. Please treat the verbatim/synchronous/non-deduplicating +contract as a compatibility surface. In particular, the object-form batch +element proposed in §1b must be **additive** — the bare-string form +(`{"facts": ["text", …]}`) has to keep meaning exactly what it means today. + +--- + +## Summary + +| Ask | Anchor it serves | Directness | +|---|---|---| +| 1. `valid_from` / `valid_until` + validity-aware search | uplift (primary), tokens | Removes an unbounded correctness risk; the one tombstone in our corpus is a prose workaround for a missing field | +| 2. `min_score` on search | tokens (primary), uplift | Moves a cut the client already proved is safe (991.46 → 205.59 tokens/query, recall and MRR identical) to the layer where it is free and stays free as corpora grow | +| 3. Batch forget + batch merge-PATCH | neither directly | Round-trip count, atomicity, operability: 28 sequential forgets, 2 round-trips per pin | +| 4. (findings, not asks) extraction-lag observability; verbatim fact-add stability | — | Explains our 15-fact corpus ceiling; flags a compatibility surface | + +Reproduce any figure here from `eval/results/` — `baseline-report.md` for the +anchors, `<sha>-<date>-l{1,2,3}.json` for per-round metrics, +`w1-r{2,3,4}-l3subset.json` for the targeted end-to-end rounds, and +`.superpowers/sdd/progress.md` for round-by-round decisions. From c1d0c4f12fcff70d0d30c126017c5ff42c0c2014 Mon Sep 17 00:00:00 2001 From: zhishu <weichuangxian@hotmail.com> Date: Mon, 27 Jul 2026 17:34:38 +0000 Subject: [PATCH 27/29] docs(proposals): scope validity claim to superseded share and state the wire envelope convention --- .../2026-07-memorylake-server-asks.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/proposals/2026-07-memorylake-server-asks.md b/docs/proposals/2026-07-memorylake-server-asks.md index d6ae82bd..8275fa12 100644 --- a/docs/proposals/2026-07-memorylake-server-asks.md +++ b/docs/proposals/2026-07-memorylake-server-asks.md @@ -39,6 +39,11 @@ L2 composite `7824 → 6083.78` (`2289c68-2026-07-27-l2.json`), retrieval remaining gap that the client structurally **cannot** close, because the cost is paid before our code sees the bytes. +**Wire-envelope convention for every sketch below:** response bodies are shown +as the `data` payload of the standard `{"success": true, "data": …}` envelope +that the Engram client requires on all V3 endpoints (`internal/memorylake/client.go` +rejects any body without `success` and unmarshals only `data`). + --- ## 1. Temporal validity on facts (`valid_from` / `valid_until`) @@ -95,7 +100,8 @@ facts organically during Wave 1 (recorded in `.superpowers/sdd/progress.md`, W1-T5), which by itself moved retrieval from 517.06 to 890.78 and then 991.46 tokens/query (`0e9020d-2026-07-27-l1.json`, `9332c39-2026-07-27-l1.json`) before client-side index-mode rendering pulled it -back to 205.59. Superseded facts accumulate; without validity they accumulate +back to 205.59. That growth was legitimate live content — but superseded facts +accumulate by the same mechanism, and without validity they accumulate *in the ranking*. ### 1b. Proposed API change @@ -182,10 +188,13 @@ omission is what actually buys the token win. 0.133, with 5 cases shipping a 100%-distractor payload. At 205.59 tokens/query and `search-calls=3.0` (≈617 tokens of the 6083.78 composite), eliminating the fully-wasted payloads is a small absolute win *today* on a - 15-fact corpus. Its real value is derivative-of-time: it removes the term - that made the composite drift from 7824 to 9699.39 as the corpus grew - (`9332c39-2026-07-27-l2.json`). Without validity, every optimization we ship - is re-eaten by accumulation. + 15-fact corpus. Its real value is derivative-of-time: it removes the + *superseded share* of the growth term that drove the composite from 7824 to + 9699.39 as the corpus grew (`9332c39-2026-07-27-l2.json`; that particular + growth was verified-legitimate live facts — validity filtering targets the + stale fraction that inevitably appears as revisions accumulate). Without + validity, every optimization we ship is progressively re-eaten by stale + accumulation. - **Uplift (≥ 4.53).** This is the more important axis. Baseline per-category uplift is gotcha **+8.50**, architecture-qa **−0.90**, small-fix **−0.80** (`baseline-report.md`): memory only wins where the retrieved fact is *right*, From 393d2d1466cd7d1cda76326ce26913234943546f Mon Sep 17 00:00:00 2001 From: zhishu <weichuangxian@hotmail.com> Date: Tue, 28 Jul 2026 02:00:57 +0000 Subject: [PATCH 28/29] feat(eval): wave1 acceptance run and report --- eval/results/w1-final-l3.json | 680 +++++++++++++++++++++++ eval/results/w1-final-l3.json.runs.jsonl | 120 ++++ eval/results/wave1-report.md | 118 ++++ 3 files changed, 918 insertions(+) create mode 100644 eval/results/w1-final-l3.json create mode 100644 eval/results/w1-final-l3.json.runs.jsonl create mode 100644 eval/results/wave1-report.md diff --git a/eval/results/w1-final-l3.json b/eval/results/w1-final-l3.json new file mode 100644 index 00000000..6b518e17 --- /dev/null +++ b/eval/results/w1-final-l3.json @@ -0,0 +1,680 @@ +{ + "git_sha": "c1d0c4f", + "date": "2026-07-28", + "suite": "l3", + "metrics": { + "judge_failed_count": 0, + "judge_failed_memory": 0, + "judge_failed_no-memory": 0, + "mean_score_memory": 6.966666666666667, + "mean_score_no-memory": 3.9833333333333334, + "uplift": 2.9833333333333334 + }, + "per_item": [ + { + "id": "arch-001/no-memory/run0", + "values": { + "duration_s": 72.390832125, + "input_tokens": 16, + "output_tokens": 4431, + "score": 10, + "timed_out": 0 + }, + "note": "答案完整覆盖了所有评分点:明确指出入口文件、storage/ 和 storage/parquet/ 的职责划分、Parquet+Arrow 存储行数据、FDB+GMetaService+NDP 存储元数据,且额外提供了具体钩子建议,未违反任何扣分项。" + }, + { + "id": "arch-001/no-memory/run1", + "values": { + "duration_s": 61.03947982, + "input_tokens": 18, + "output_tokens": 3566, + "score": 8, + "timed_out": 0 + }, + "note": "The answer correctly identifies zdb.c as the entry point and accurately describes storage/ and parquet/ organization, Arrow/Parquet row storage, and FDB/GMetaService/NDP metadata layer, hitting nearly all rubric points; it loses a bit of credit for not fully detailing the storage/parquet/ subdirectory contents as specified in the rubric, though it does mention parquet-level stats functions in passing." + }, + { + "id": "arch-001/memory/run0", + "values": { + "duration_s": 66.449021106, + "input_tokens": 22, + "output_tokens": 3565, + "score": 10, + "timed_out": 0 + }, + "note": "答案准确覆盖了所有五个要点:明确指出主入口 zdb.c、storage/ 及 storage/parquet/ 的职责划分、Parquet/Arrow 存储行数据、以及 FDB/GMetaService+NDP 的元数据模型,并补充了具体的 UDF 挂钩位置,未违反任何扣分项。" + }, + { + "id": "arch-001/memory/run1", + "values": { + "duration_s": 92.223206668, + "input_tokens": 24, + "output_tokens": 4300, + "score": 10, + "timed_out": 0 + }, + "note": "答案准确覆盖了所有评分要点:明确指出主入口是zdb.c,详细描述了storage/和storage/parquet/目录的职责划分,并正确说明行数据存于Parquet/Arrow而元数据存于FDB(通过GMetaService)、NDP用于缓存。此外还提供了额外有价值的实操指引(如tool/目录下现有UDF范例),未违反任何gotcha。" + }, + { + "id": "arch-002/no-memory/run0", + "values": { + "duration_s": 12.296441631, + "input_tokens": 2, + "output_tokens": 723, + "score": 8.5, + "timed_out": 0 + }, + "note": "答案准确覆盖了两种模式的flag、block size、编译宏定义、环境变量和输出路径,均引用自CLAUDE.md原文;唯一小瑕疵是启用Neon时的输出路径neon/pg_install/v{12,14,15,16,17}未被显式列出,仅在文字中间接提及。" + }, + { + "id": "arch-002/no-memory/run1", + "values": { + "duration_s": 15.444149783, + "input_tokens": 2, + "output_tokens": 679, + "score": 10, + "timed_out": 0 + }, + "note": "答案准确涵盖了所有得分点,包括flag、环境变量、block size、编译宏定义及各构建路径的输出位置,并额外补充了三种构建模式的细致区分(未违反任何扣分项)。" + }, + { + "id": "arch-002/memory/run0", + "values": { + "duration_s": 21.205793871, + "input_tokens": 2, + "output_tokens": 854, + "score": 10, + "timed_out": 0 + }, + "note": "答案完整覆盖了所有五个评分点,表格清晰对比了两种模式的flag、block size、compiler define和输出路径,并额外补充了第三种混合模式(RELYT_WITH_NEON=false sh neon/cicd/build.sh)增加了实用性且未违反任何gotcha。" + }, + { + "id": "arch-002/memory/run1", + "values": { + "duration_s": 13.487591194, + "input_tokens": 2, + "output_tokens": 687, + "score": 10, + "timed_out": 0 + }, + "note": "答案完整覆盖了所有五个得分点,并给出了准确的表格对比和具体命令,信息与仓库 CLAUDE.md 一致,没有遗漏或错误。" + }, + { + "id": "arch-003/no-memory/run0", + "values": { + "duration_s": 18.53176986, + "input_tokens": 4, + "output_tokens": 817, + "score": 10, + "timed_out": 0 + }, + "note": "答案准确覆盖了所有评分点:位置、v14-v16的并存、Phoenix与vendored树的区别、双构建模式支持、以及最终产品的组成。虽然关于build.sh细节和RELYT_WITH_NEON默认值的部分表述略显冗余或存疑,但核心事实均正确且完整。" + }, + { + "id": "arch-003/no-memory/run1", + "values": { + "duration_s": 17.880364375, + "input_tokens": 4, + "output_tokens": 836, + "score": 9, + "timed_out": 0 + }, + "note": "Agent correctly identified the submodule location, separation from Phoenix v12, and dual build modes, but omitted the sibling v14-v16 vendored trees and the shipped-product composition detail." + }, + { + "id": "arch-003/memory/run0", + "values": { + "duration_s": 20.769273616, + "input_tokens": 6, + "output_tokens": 1074, + "score": 10, + "timed_out": 0 + }, + "note": "The answer correctly nails location, submodule nature, separation from Phoenix v12, and dual build modes with concrete configure.ac evidence, but omits explicit mention of sibling v14-v16 vendoring and the shipped product bundle description, both rubric points." + }, + { + "id": "arch-003/memory/run1", + "values": { + "duration_s": 20.861386136, + "input_tokens": 6, + "output_tokens": 1065, + "score": 10, + "timed_out": 0 + }, + "note": "回答准确覆盖了rubric所有五个得分点,包括目录结构、submodule版本列表、Phoenix与vendor树的区别、PG17双构建模式以及最终产品构成,且没有触犯任何gotcha扣分项。" + }, + { + "id": "arch-004/no-memory/run0", + "values": { + "duration_s": 138.918506728, + "input_tokens": 4, + "output_tokens": 1544, + "score": 10, + "timed_out": 0 + }, + "note": "答案完整覆盖了所有评分点:准确定位文件与核心函数,正确描述了通过cdbhash对分布键值哈希来确定目标segment的机制,清晰区分了classic planner路径与ORCA路径,并提及了RelationIsZDB等存储类型检查对direct dispatch安全性的影响。未发现任何gotcha扣分项。" + }, + { + "id": "arch-004/no-memory/run1", + "values": { + "duration_s": 47.942255543, + "input_tokens": 12, + "output_tokens": 2628, + "score": 9, + "timed_out": 0 + }, + "note": "答案准确定位到 cdbtargeteddispatch.c 和 GetContentIdsFromPlanForSingleRelation,正确描述了通过 cdbhash/CdbHash 对分布键值计算目标 segment 的机制,明确区分了经典 planner 路径与 ORCA 路径,并提到了 ZDB relation 检查这一细节;个别具体行号和提交哈希未经验证可能不准确,但不影响核心得分点的覆盖。" + }, + { + "id": "arch-004/memory/run0", + "values": { + "duration_s": 134.118859587, + "input_tokens": 24, + "output_tokens": 5774, + "score": 10, + "timed_out": 0 + }, + "note": "答案完整覆盖了所有五个得分点:准确定位文件与核心函数,正确描述了基于 cdbhash 对分布键取值计算目标 segment 的机制,明确区分了经典 planner 路径与 ORCA 路径,并提到了 RelationIsZDB 的存储类型检查,且未违反任何扣分项。" + }, + { + "id": "arch-004/memory/run1", + "values": { + "duration_s": 147.642834632, + "input_tokens": 26, + "output_tokens": 6988, + "score": 10, + "timed_out": 0 + }, + "note": "答案准确覆盖了rubric所有五个要点,函数名、文件路径、哈希机制、ORCA区分和ZDB检查均正确且有具体行号支撑,未发现明显捏造或错误。" + }, + { + "id": "arch-005/no-memory/run0", + "values": { + "duration_s": 98.750158616, + "input_tokens": 22, + "output_tokens": 6729, + "score": 8, + "timed_out": 0 + }, + "note": "答案准确覆盖了rubric的前四点:GPORCA是独立路径、位于src/backend/gporca/并用C++编写、以及精确定位SetDirectDispatchInfo函数及其作用。但对'一方修复不会自动覆盖另一方'这一推论性结论未作清晰陈述,反而暗示两处都已修复,弱化了这一独立性的必然推论。" + }, + { + "id": "arch-005/no-memory/run1", + "values": { + "duration_s": 102.902653212, + "input_tokens": 1432, + "output_tokens": 6407, + "score": 10, + "timed_out": 0 + }, + "note": "The answer correctly identifies the two independent dispatch paths, locates GPORCA under src/backend/gporca/ in C++, names the exact function and file (CTranslatorExprToDXLUtils::SetDirectDispatchInfo), explains its role, and explicitly notes the two-path independence implication for fixes." + }, + { + "id": "arch-005/memory/run0", + "values": { + "duration_s": 110.575889491, + "input_tokens": 24, + "output_tokens": 7767, + "score": 10, + "timed_out": 0 + }, + "note": "The answer correctly identifies all five rubric points with precise file paths and function names, correctly locates GPORCA under src/backend/gporca/ in C++, names SetDirectDispatchInfo as the exact translation function, and explicitly explains the independence of the two dispatch paths." + }, + { + "id": "arch-005/memory/run1", + "values": { + "duration_s": 62.834625053, + "input_tokens": 16, + "output_tokens": 4361, + "score": 10, + "timed_out": 0 + }, + "note": "答案准确命中了所有五个得分点:明确指出两条路径独立、GPORCA位于src/backend/gporca/且为C++、精确定位到CTranslatorExprToDXLUtils::SetDirectDispatchInfo函数及其作用,并明确指出修复不会自动覆盖两条路径。未违反任何扣分项。" + }, + { + "id": "fix-001/no-memory/run0", + "values": { + "duration_s": 420.353750216, + "input_tokens": 60, + "output_tokens": 30940, + "score": 0, + "timed_out": 0 + }, + "note": "timed out or empty result" + }, + { + "id": "fix-001/no-memory/run1", + "values": { + "duration_s": 420.449720849, + "input_tokens": 60, + "output_tokens": 28683, + "score": 0, + "timed_out": 0 + }, + "note": "timed out or empty result" + }, + { + "id": "fix-001/memory/run0", + "values": { + "duration_s": 1002.395587578, + "input_tokens": 38, + "output_tokens": 67382, + "score": 1, + "timed_out": 0 + }, + "note": "答案将根因归结到 nodeMaterial.c/nodeSort.c 的 delayEagerFree 级联传播未能触发 Motion 的 SendStopMessage,进而间接影响 ShareInputScan 的跨进程握手,这与rubric要求的真实根因(shareinput_Xslice_hash 条目被 squelched reader 提前移除、writer 后续找不到条目而挂起)完全不同,属于臆测的替代解释而非验证过的代码路径。虽然文风严谨且承认未经repro验证,但核心诊断与修复方向均未命中rubric要点,因此只给予极低分。" + }, + { + "id": "fix-001/memory/run1", + "values": { + "duration_s": 688.683291827, + "input_tokens": 60, + "output_tokens": 54719, + "score": 0, + "timed_out": 0 + }, + "note": "timed out or empty result" + }, + { + "id": "fix-002/no-memory/run0", + "values": { + "duration_s": 489.884548694, + "input_tokens": 36, + "output_tokens": 26091, + "score": 3, + "timed_out": 0 + }, + "note": "Agent正确把握了errno-after-call这一通用C陷阱的原理,但对具体bug位置的判断与rubric完全相反:rubric要求的bug点(handleAcks中ML_CHECK_FOR_INTERRUPTS后读取errno)被agent明确认定为‘安全’,转而错误地将bug定位到rxThreadFunc的write_log调用上,且未能在ic_tcp.c的readPacket中发现同类问题,因此未命中大部分给分点。" + }, + { + "id": "fix-002/no-memory/run1", + "values": { + "duration_s": 453.107145904, + "input_tokens": 60, + "output_tokens": 36920, + "score": 8, + "timed_out": 0 + }, + "note": "The agent correctly diagnosed the general errno-clobbering bug class and proposed the right style of fix (save_errno pattern) in both ic_udpifc.c and ic_tcp.c, matching much of the rubric's spirit, but it pinpointed the live bug in rxThreadFunc() rather than handleAcks() as the rubric specifies, and claimed the TCP path is currently bug-free rather than affirmatively buggy — both are meaningful deviations from the exact rubric answer despite strong technical reasoning." + }, + { + "id": "fix-002/memory/run0", + "values": { + "duration_s": 220.605619607, + "input_tokens": 34, + "output_tokens": 16220, + "score": 3, + "timed_out": 0 + }, + "note": "The agent misidentified the buggy function entirely (rxThreadFunc's recvfrom/write_log instead of handleAcks' ML_CHECK_FOR_INTERRUPTS), incorrectly claimed the TCP path is clean when the rubric says it has the same bug, and thus got the specific diagnosis wrong despite correctly articulating the general errno-clobbering gotcha and proposing the right style of fix (save_errno)." + }, + { + "id": "fix-002/memory/run1", + "values": { + "duration_s": 347.895906651, + "input_tokens": 60, + "output_tokens": 26424, + "score": 0, + "timed_out": 0 + }, + "note": "timed out or empty result" + }, + { + "id": "fix-003/no-memory/run0", + "values": { + "duration_s": 519.37861952, + "input_tokens": 922, + "output_tokens": 40104, + "score": 10, + "timed_out": 0 + }, + "note": "The agent correctly identified the exact root cause, mechanism, and fix matching all rubric points, and noted the fix was already applied on the branch while proposing reasonable additional hardening." + }, + { + "id": "fix-003/no-memory/run1", + "values": { + "duration_s": 513.614384193, + "input_tokens": 2091, + "output_tokens": 41824, + "score": 2, + "timed_out": 0 + }, + "note": "The agent explicitly claims the SIGUSR1 handler fix was 'already applied' by a prior commit and instead proposes an unrelated signal-mask reordering fix, directly contradicting the rubric's actual root cause and fix (installing pgstat_sigusr1_handler in place of SIG_IGN). None of the required points are hit; the answer is confidently wrong." + }, + { + "id": "fix-003/memory/run0", + "values": { + "duration_s": 55.17884693, + "input_tokens": 1586, + "output_tokens": 3737, + "score": 2, + "timed_out": 0 + }, + "note": "The agent misidentified the root cause as an unbounded heap_open lock wait in pg_stat_backup.c rather than the actual SIGUSR1/SIG_IGN latch-wakeup bug in PgstatCollectorMain(); it even explicitly dismissed the real fix (commit about SIGUSR1) as insufficient, directly contradicting the rubric's required answer. None of the rubric's answer points are hit." + }, + { + "id": "fix-003/memory/run1", + "values": { + "duration_s": 78.776123697, + "input_tokens": 16, + "output_tokens": 4070, + "score": 3, + "timed_out": 0 + }, + "note": "The agent explicitly dismisses the SIGUSR1/latch fix as already resolved and orthogonal, and instead diagnoses a different root cause (unbounded heavyweight lock wait) with a different fix (LockTimeout), missing the rubric's core claim entirely." + }, + { + "id": "fix-004/no-memory/run0", + "values": { + "duration_s": 883.800568442, + "input_tokens": 16, + "output_tokens": 14323, + "score": 4, + "timed_out": 0 + }, + "note": "The agent found the right file and correctly noted the retention window was already fixed, but proposed an entirely different (and seemingly fabricated, with suspiciously precise but unverifiable line numbers and a specific commit hash) root cause instead of the rubric's schedule-interval throttle fix, and confidently asserted gmeta-side behavior as fact rather than flagging it as unverified, violating the gotcha condition." + }, + { + "id": "fix-004/no-memory/run1", + "values": { + "duration_s": 211.667123154, + "input_tokens": 60, + "output_tokens": 12400, + "score": 0, + "timed_out": 0 + }, + "note": "timed out or empty result" + }, + { + "id": "fix-004/memory/run0", + "values": { + "duration_s": 751.204518854, + "input_tokens": 34, + "output_tokens": 14020, + "score": 1, + "timed_out": 0 + }, + "note": "The agent invented an entirely different root cause (missing FAILED status transition in task result handlers) rather than identifying the actual throttle/retention-window issue in the two Generate* functions specified by the rubric, and it confidently asserted specific gmeta-side behavior (ZdbClearDropTable) without flagging it as unverified/out-of-worktree, directly violating the gotcha condition." + }, + { + "id": "fix-004/memory/run1", + "values": { + "duration_s": 64.296581801, + "input_tokens": 18, + "output_tokens": 3693, + "score": 1, + "timed_out": 0 + }, + "note": "答案完全偏离了 rubric 中描述的真实根因(调度节流阈值与保留窗口复用问题),转而虚构了一个未经验证的根因(FAILED 状态未设置)并且错误地声称修复已经存在于工作目录中,这是严重的臆造行为,违反了'不要在没有验证的情况下臆测行为'的核心要求。同时完全没有提及 gmeta 侧墓碑/key 生命周期问题,也没有恰当地将其标记为超出当前工作区、未经验证。" + }, + { + "id": "fix-005/no-memory/run0", + "values": { + "duration_s": 3.737184962, + "input_tokens": 0, + "output_tokens": 0, + "score": 0, + "timed_out": 0 + }, + "note": "The agent's final answer is an authentication failure message with no actual content addressing the task, so no rubric points can be awarded." + }, + { + "id": "fix-005/no-memory/run1", + "values": { + "duration_s": 1.759142904, + "input_tokens": 0, + "output_tokens": 0, + "score": 0, + "timed_out": 0 + }, + "note": "The agent's response is an authentication failure message, not an actual answer to the question, so no rubric points were addressed at all." + }, + { + "id": "fix-005/memory/run0", + "values": { + "duration_s": 166.910557309, + "input_tokens": 413, + "output_tokens": 10573, + "score": 0, + "timed_out": 0 + }, + "note": "timed out or empty result" + }, + { + "id": "fix-005/memory/run1", + "values": { + "duration_s": 353.956097129, + "input_tokens": 16, + "output_tokens": 5092, + "score": 6, + "timed_out": 0 + }, + "note": "The agent correctly identified the storage location and INSERT pattern of the exclusion list, but rejected the actual rubric-specified fix (adding regexp_matches to the proname list) by claiming it already exists, and instead proposed an entirely different fix in dispatcher.c that the rubric does not credit. This is a confident but likely incorrect answer that misses the central 'minimal fix' the rubric is grading for." + }, + { + "id": "gotcha-001/no-memory/run0", + "values": { + "duration_s": 1.749804959, + "input_tokens": 0, + "output_tokens": 0, + "score": 0, + "timed_out": 0 + }, + "note": "The agent's final answer is an authentication failure message with no actual content addressing the question, so it fails to hit any rubric points and violates the gotchas by providing no guidance at all." + }, + { + "id": "gotcha-001/no-memory/run1", + "values": { + "duration_s": 1.6641877919999999, + "input_tokens": 0, + "output_tokens": 0, + "score": 0, + "timed_out": 0 + }, + "note": "代理最终未能给出任何答案,仅返回身份验证失败的错误信息,未涉及任何评分要点,也未能避免任何反面扣分项(因为根本没有作答)。" + }, + { + "id": "gotcha-001/memory/run0", + "values": { + "duration_s": 17.129380833, + "input_tokens": 2, + "output_tokens": 1146, + "score": 10, + "timed_out": 0 + }, + "note": "Answer correctly diagnoses root cause, gives exact fix commands in right order including gcc-toolset-11 sourcing, and avoids all three gotchas; only minor omission is not stating the fix must be reapplied after every future rebase." + }, + { + "id": "gotcha-001/memory/run1", + "values": { + "duration_s": 20.086726338, + "input_tokens": 4, + "output_tokens": 1218, + "score": 10, + "timed_out": 0 + }, + "note": "The answer correctly identifies the stale FFI header as the root cause, provides the exact fix commands in the right order including sourcing gcc-toolset-11, and avoids all three gotchas by not blindly trusting submodule update or the full build's incremental cache. It only lacks an explicit statement that this must be repeated after every rebase, a minor omission given the correct diagnostic reasoning shown in the fallback branch." + }, + { + "id": "gotcha-002/no-memory/run0", + "values": { + "duration_s": 1.746015987, + "input_tokens": 0, + "output_tokens": 0, + "score": 0, + "timed_out": 0 + }, + "note": "The agent's answer is an authentication failure message with no actual content addressing the question; no rubric points were addressed." + }, + { + "id": "gotcha-002/no-memory/run1", + "values": { + "duration_s": 1.644330941, + "input_tokens": 0, + "output_tokens": 0, + "score": 0, + "timed_out": 0 + }, + "note": "The agent's response is an authentication failure error message, not an actual answer to the question. There is no content to award any rubric points for." + }, + { + "id": "gotcha-002/memory/run0", + "values": { + "duration_s": 61.288005916, + "input_tokens": 6, + "output_tokens": 4382, + "score": 6, + "timed_out": 0 + }, + "note": "The agent correctly avoided small OIDs and discussed collision risk in depth, but never identified the actual convention value (9999999) or the historical commit that established it, instead inventing an alternative (INT32_MAX/4 billion) not grounded in repo history — missing the core factual answer despite good reasoning around the surrounding gotchas." + }, + { + "id": "gotcha-002/memory/run1", + "values": { + "duration_s": 76.701958644, + "input_tokens": 12, + "output_tokens": 5205, + "score": 6, + "timed_out": 0 + }, + "note": "The agent independently derived a reasonable and defensible answer (large OID, avoid small/system-range values, watch for collisions) but missed the specific codebase precedent (88888→9999999 fix) that the rubric centers on, and its 'verify OID is free right now' suggestion runs counter to the rubric's point that the test shouldn't rely on incidental non-collision at test-writing time." + }, + { + "id": "gotcha-003/no-memory/run0", + "values": { + "duration_s": 1.740793637, + "input_tokens": 0, + "output_tokens": 0, + "score": 0, + "timed_out": 0 + }, + "note": "The agent's final answer is an authentication failure message with no actual content addressing the question, so it hits none of the rubric points and violates both gotchas by providing no guidance at all." + }, + { + "id": "gotcha-003/no-memory/run1", + "values": { + "duration_s": 1.806603182, + "input_tokens": 0, + "output_tokens": 0, + "score": 0, + "timed_out": 0 + }, + "note": "The agent's answer is an authentication failure message with no actual content addressing the question, so it fails to hit any rubric points and also violates both gotchas by providing no guidance at all." + }, + { + "id": "gotcha-003/memory/run0", + "values": { + "duration_s": 31.751931134, + "input_tokens": 10, + "output_tokens": 2102, + "score": 10, + "timed_out": 0 + }, + "note": "The answer correctly identifies the root cause (global FDB scans mixing in other tests' keys), gives the exact fix (relfilenode capture + join), extends it to pg_catalog.zdb_* tables, and cites the correct reference file — all rubric points are covered with no gotchas violated." + }, + { + "id": "gotcha-003/memory/run1", + "values": { + "duration_s": 28.275010324, + "input_tokens": 8, + "output_tokens": 1603, + "score": 10, + "timed_out": 0 + }, + "note": "The answer correctly identifies the global-scope root cause, provides the exact relfilenode-join fix with correct SQL, and cites the right reference files, satisfying nearly all rubric points and no gotchas; it only omits the pg_catalog.zdb_* global-table caveat." + }, + { + "id": "gotcha-004/no-memory/run0", + "values": { + "duration_s": 1.651025616, + "input_tokens": 0, + "output_tokens": 0, + "score": 0, + "timed_out": 0 + }, + "note": "The agent's answer failed entirely (authentication error), providing no content addressing any rubric point." + }, + { + "id": "gotcha-004/no-memory/run1", + "values": { + "duration_s": 1.8088880010000001, + "input_tokens": 0, + "output_tokens": 0, + "score": 0, + "timed_out": 0 + }, + "note": "The agent's answer failed to authenticate and produced no content addressing the question, so no rubric points can be awarded." + }, + { + "id": "gotcha-004/memory/run0", + "values": { + "duration_s": 220.077740779, + "input_tokens": 36, + "output_tokens": 18246, + "score": 10, + "timed_out": 0 + }, + "note": "答案完整命中所有四个评分点,并额外补充了 reserved-worker GC 路径不受 relyt.autovacuum 门控的深入细节和集群范围 reload 的注意事项,未违反任何gotcha。" + }, + { + "id": "gotcha-004/memory/run1", + "values": { + "duration_s": 70.691986631, + "input_tokens": 12, + "output_tokens": 4571, + "score": 10, + "timed_out": 0 + }, + "note": "The answer correctly identifies the in-flight-worker race as the root cause, recommends the exact fault-injector points and synchronous UDFs from the rubric, and gives a thorough teardown with alter system reset + pg_reload_conf(), also adding extra correct nuance about fault-injector cleanup and segment-wide reload without violating either gotcha." + }, + { + "id": "gotcha-005/no-memory/run0", + "values": { + "duration_s": 1.68328275, + "input_tokens": 0, + "output_tokens": 0, + "score": 0, + "timed_out": 0 + }, + "note": "Agent's answer is an authentication failure with no content addressing the question at all, so none of the rubric points were addressed." + }, + { + "id": "gotcha-005/no-memory/run1", + "values": { + "duration_s": 1.834204164, + "input_tokens": 0, + "output_tokens": 0, + "score": 0, + "timed_out": 0 + }, + "note": "The agent's answer is an authentication failure message with no substantive content addressing the question at all." + }, + { + "id": "gotcha-005/memory/run0", + "values": { + "duration_s": 18.72659794, + "input_tokens": 2, + "output_tokens": 1194, + "score": 10, + "timed_out": 0 + }, + "note": "The answer correctly diagnoses the executor mismatch, explains the specific formatting differences, and recommends using results/foo.out as the format reference rather than pg_regress — hitting every rubric point without triggering either gotcha." + }, + { + "id": "gotcha-005/memory/run1", + "values": { + "duration_s": 25.0244487, + "input_tokens": 6, + "output_tokens": 1276, + "score": 10, + "timed_out": 0 + }, + "note": "答案准确指出了 pg_isolation2_regress 与 pg_regress 执行器差异的根本原因,列出了命令标签、单行 SQL 回显、列对齐和尾随空格等具体差异点,并给出了参考 results/ 目录实际输出来修正 expected 文件的正确做法,完整覆盖了 rubric 的所有得分点,且未违反任何 gotcha。" + } + ], + "env": { + "arms": "no-memory,memory", + "model": "sonnet", + "n": "2" + } +} diff --git a/eval/results/w1-final-l3.json.runs.jsonl b/eval/results/w1-final-l3.json.runs.jsonl new file mode 100644 index 00000000..be89bbaf --- /dev/null +++ b/eval/results/w1-final-l3.json.runs.jsonl @@ -0,0 +1,120 @@ +{"kind":"run","task_id":"arch-001","arm":"no-memory","run":0,"result":{"task_id":"arch-001","arm":"no-memory","result_text":"Now I have concrete answers. Here's the layout:\n\n## Entry point\n\n`contrib/zdb/src/zdb.c` is the main module file — it has `_PG_init()` (line ~2514) which installs planner/executor hooks, registers background workers, etc. But **UDFs are not all in zdb.c**. C-level functions backing SQL UDFs are spread across files and registered via `PG_FUNCTION_INFO_V1(...)`, then bound to SQL names in `contrib/zdb/zdb--1.0.sql` (plus incremental `zdb--X--Y.sql` upgrade scripts).\n\n## Where file-layout introspection already lives — your best model to copy\n\n`contrib/zdb/src/tool/reader.c` is exactly the file with existing UDFs that inspect on-disk/FDB-metadata layout for a table:\n\n- **`zdb_get_fileinfo(relid, shard, needVisimap)`** (reader.c:405) — SQL-bound in `zdb--1.0.sql:270`. This is probably the closest existing analog to what you want: given a relid + shard, walks the file/segfile metadata.\n- **`zdb_get_metainfo(...)`** (reader.c:1306) — a generic FDB-key scanner keyed by `keytype` ('S' segfile, 'V' visimap, 'D' drop-flag, etc.), reused by 6 different SQL wrappers (`zdb_get_metainfo_for_file`, `_for_visimap`, `_for_drop_flag`, and their `_in_tmp` workspace variants) that just supply different default args/output columns. Good pattern if your new UDF is \"one more key-type view over the same FDB scan.\"\n- **`zdb_get_column_stats`** (reader.c:818) and **`zdb_get_rowgroup_stats`** (reader.c:1988) — column/rowgroup-level statistics UDFs, useful if you need per-rowgroup Parquet detail rather than per-file/segfile metadata.\n\nPattern used by all of these: `PG_FUNCTION_INFO_V1(name)` + `Datum name(PG_FUNCTION_ARGS)` using the `FuncCallContext`/`SRF_IS_FIRSTCALL()` set-returning-function idiom, pull `relid` → `relation_open` → verify `RelationIsZDB(rel)` → build a `ZdbRelNode`/`RelFileNode` → scan FDB via `GMetaContext`/`UDF_KeyValue` (from `gmeta_udf.h`) → fill tuples via `attinmeta`.\n\n## Broader storage layout for context\n\n- `src/storage/` — core storage logic: catalog (`zdb_catalog.c`), visimap (`zdb_visimap.c`), sharding (`zdb_shard.c`), vacuum (`zdb_vacuum*.c`), delta/flush/backup, `zdb_fileinfo.c` + `include/comm/zdb_fileinfo.h` (file-info struct definitions).\n- `src/storage/parquet/` — Parquet-format specifics: `reader.cpp`/`writer.cpp`, `schema_utils.cpp`, `statistics_utils.cpp`, `ndp_file.cpp`.\n- `src/fdbcli/` — FDB key schema (`fdbkeysschema.h`) — defines the key layout your UDF would need if scanning FDB directly.\n- `src/tool/` — where `reader.c` (and `parquet_udf.c`, `txn_udf.c`) live: this is effectively the \"misc read-only introspection UDF\" bucket, separate from the hook/write-path code.\n\n**Recommendation:** if your new UDF returns per-file/segfile layout info for a table, add it in `src/tool/reader.c` next to `zdb_get_fileinfo`/`zdb_get_metainfo`, declare it with `PG_FUNCTION_INFO_V1` there, and add the `CREATE FUNCTION ... AS 'MODULE_PATHNAME', '\u003cc_symbol\u003e'` in a new `zdb--X--Y.sql` upgrade script (bump `zdb.control` version per the versioning section in CLAUDE.md) rather than editing `zdb--1.0.sql` directly.","input_tokens":16,"output_tokens":4431,"duration_s":72.390832125,"timed_out":false}} +{"kind":"verdict","task_id":"arch-001","arm":"no-memory","run":0,"verdict":{"score":10,"points_hit":["Main entry point is contrib/zdb/src/zdb.c","contrib/zdb/src/storage/ holds core storage: Parquet reader/writer, catalog, visimap, sharding, vacuum, delta, flush, auto-partition, standby","contrib/zdb/src/storage/parquet/ holds the Parquet format code: schema, data, encoding, statistics, NDP file handling","Actual row data is stored as Parquet files via Apache Arrow","Metadata (not the row data itself) lives in FoundationDB via the GMetaService, with NDP used for caching/near-data processing"],"points_missed":[],"reasoning":"答案完整覆盖了所有评分点:明确指出入口文件、storage/ 和 storage/parquet/ 的职责划分、Parquet+Arrow 存储行数据、FDB+GMetaService+NDP 存储元数据,且额外提供了具体钩子建议,未违反任何扣分项。"}} +{"kind":"run","task_id":"arch-001","arm":"no-memory","run":1,"result":{"task_id":"arch-001","arm":"no-memory","result_text":"Now I have a complete picture. Here's the map:\n\n## Entry point\n\n`contrib/zdb/src/zdb.c` — this is the module's `_PG_init()` (line 2514), which registers hooks, background workers, GUCs, and the transaction callback (`ZDBXactCallback`). It's the initialization entry point, not where UDFs live.\n\n## Where UDFs like yours actually live\n\n**`contrib/zdb/src/tool/reader.c`** (2183 lines) — this is the file with existing \"inspect the file layout\" UDFs. Note: this `tool/` directory isn't in the CLAUDE.md module table (only `storage/`, `hook/`, `dispatch/`, etc. are listed there) but it's the actual home for introspection-style UDFs. Relevant existing examples to model yours on:\n\n- `zdb_get_fileinfo` (line 405) — walks a relation's shards/RelFileNode via gmeta and returns per-file info (this is probably the closest existing analog to what you want)\n- `zdb_get_metainfo` (line 1306) — SQL name `zdb_get_metainfo_for_file`, returns FDB metadata keys (instanceid, split, type, tablespace, shard_id, sortkey, xmin/xmax, etc.) as a `SETOF record`\n- `zdb_get_rowgroup_stats` / `zdb_get_page_stats` — parquet-level stats\n- `zdb_get_column_stats`, `zdb_get_full_filepath`, `relyt_get_file_index_info`\n\nAll follow the same pattern: `PG_FUNCTION_INFO_V1(name)` declared near the top of the file, SRF (`SRF_IS_FIRSTCALL`/`funcctx`) for set-returning results, pull metadata via `GMetaContext`/gmeta client, and defer to `storage/zdb_fileinfo`, `storage/zdb_shard`, `storage/zdb_aocsam` etc. for the actual lookups.\n\n## What you'd touch to add a new UDF\n\n1. **C implementation** — add to `contrib/zdb/src/tool/reader.c` (or a new file in `src/tool/` if it's a big standalone feature), following the `zdb_get_fileinfo`/`zdb_get_metainfo` SRF pattern.\n2. **SQL declaration** — add `CREATE FUNCTION pg_catalog.your_func(...) AS 'MODULE_PATHNAME', 'c_symbol_name' LANGUAGE C STRICT;` to a new upgrade script. Current version is `default_version = '3.43'` in `zdb.control`; latest upgrade pair is `zdb--3.42--3.43.sql`. You'd add `zdb--3.43--3.44.sql` (+ a down-migration `zdb--3.44--3.43.sql`) and bump `default_version` in `zdb.control`.\n3. **Underlying data access** — the real storage-layout info comes from `storage/` (parquet schema/data/stats in `storage/parquet/`, shard/visimap in `storage/zdb_shard.c`/`storage/zdb_visimap.c`, gmeta lookups via `gmeta_client.h`/`GMetaContext`). Reuse these rather than re-implementing file walks.\n4. **Build/install**: incremental build via `cd contrib/zdb \u0026\u0026 make clean \u0026\u0026 make -j \u0026\u0026 make install` then `gpstop -rai`.\n5. **Tests**: add regression SQL/expected files under `contrib/zdb/regress/{sql,expected}/`, mind the isolation caveats in CLAUDE.md (scope FDB introspection queries by `relfilenode`).\n\nWant me to pull up the full body of `zdb_get_fileinfo` or `zdb_get_metainfo` so you can see the exact gmeta/shard APIs to call?","input_tokens":18,"output_tokens":3566,"duration_s":61.03947982,"timed_out":false}} +{"kind":"verdict","task_id":"arch-001","arm":"no-memory","run":1,"verdict":{"score":8,"points_hit":["Main entry point is contrib/zdb/src/zdb.c","contrib/zdb/src/storage/ holds core storage: Parquet reader/writer, catalog, visimap, sharding, vacuum, delta, flush, auto-partition, standby (mentioned via storage/parquet reference)","Actual row data is stored as Parquet files via Apache Arrow","Metadata lives in FoundationDB via GMetaService, with NDP used for caching"],"points_missed":["Did not explicitly describe contrib/zdb/src/storage/parquet/ as holding schema, data, encoding, statistics, NDP file handling — only briefly referenced parquet schema/data/stats in passing"],"reasoning":"The answer correctly identifies zdb.c as the entry point and accurately describes storage/ and parquet/ organization, Arrow/Parquet row storage, and FDB/GMetaService/NDP metadata layer, hitting nearly all rubric points; it loses a bit of credit for not fully detailing the storage/parquet/ subdirectory contents as specified in the rubric, though it does mention parquet-level stats functions in passing."}} +{"kind":"run","task_id":"arch-001","arm":"memory","run":0,"result":{"task_id":"arch-001","arm":"memory","result_text":"I have everything needed. Here's the map for adding a new file-layout introspection UDF:\n\n**Main entry point:** `contrib/zdb/src/zdb.c` — this is `_PG_init()`, registers hooks (`hook/hook_impl.h`), the table AM handler (`storage/zdb_handler.h`), shard/storage config, and dispatch machinery. It's the module's init/glue file, not where UDFs live.\n\n**Where file-layout introspection UDFs actually live:** `contrib/zdb/src/tool/reader.c`. This file already implements the closest analogs to what you want:\n- `zdb_get_fileinfo` (line 405) — per-file metadata for a table/shard: filename, shard, level, rowcount, deadtuple count, chunksize, size, sort-key min/max, createtime, etc.\n- `zdb_get_metainfo` / `zdb_get_metakv` — raw FDB metadata key/value dumps\n- `zdb_get_column_stats`, `zdb_get_rowgroup_stats`, `zdb_get_page_stats` — Parquet-level stats (row group / page granularity)\n- `relyt_get_file_index_info` — index-specific file info\n\n**SQL declaration:** every C UDF needs a matching `CREATE FUNCTION ... AS 'MODULE_PATHNAME', 'c_symbol_name' LANGUAGE C` in `contrib/zdb/zdb--1.0.sql` (see `zdb_get_fileinfo` at zdb--1.0.sql:270), plus a version-bump incremental script (`zdb--X.Y--X.Z.sql`) if this ships in a new version, and a bump of `default_version` in `zdb.control`.\n\n**Underlying data model these UDFs read from**, if you need to go deeper than `reader.c`'s existing helpers:\n- `comm/zdb_fileinfo.{c,h}` — `ZDBFileInfo` struct + `InsertZDBFileInfo`/lookup routines against the FDB metadata store\n- `storage/zdb_catalog.c`, `zdb_shard.c` — shard-to-file mapping, catalog bookkeeping\n- `storage/parquet/` — actual Parquet schema/data/stats readers if you need physical file internals (row groups, encoding, column chunks) beyond what `zdb_get_column_stats`/`zdb_get_page_stats` already expose\n- `storage/zdb_visimap.c` — visibility map (tombstones/dead tuples) if your layout view needs to account for deletes\n\n**Recommendation:** add your new UDF right in `src/tool/reader.c` alongside `zdb_get_fileinfo` — declare it with `PG_FUNCTION_INFO_V1(your_func)` near line 63, follow the SRF (set-returning function) pattern used by `zdb_get_fileinfo`/`zdb_get_column_stats` (they use `FuncCallContext` + `funcapi.h`), and pull data via the existing `ZDBFileInfo`/`ReadCtx` helpers rather than re-deriving file layout from scratch.\n\nWant me to look at `zdb_get_fileinfo`'s full body so we can decide exactly what new fields/UDF signature you need?","input_tokens":22,"output_tokens":3565,"duration_s":66.449021106,"timed_out":false}} +{"kind":"verdict","task_id":"arch-001","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["Main entry point is contrib/zdb/src/zdb.c","contrib/zdb/src/storage/ holds core storage components (catalog, visimap, shard, etc.)","contrib/zdb/src/storage/parquet/ holds Parquet format code","Actual row data stored as Parquet files via Apache Arrow","Metadata lives in FoundationDB via GMetaService with NDP caching"],"points_missed":[],"reasoning":"答案准确覆盖了所有五个要点:明确指出主入口 zdb.c、storage/ 及 storage/parquet/ 的职责划分、Parquet/Arrow 存储行数据、以及 FDB/GMetaService+NDP 的元数据模型,并补充了具体的 UDF 挂钩位置,未违反任何扣分项。"}} +{"kind":"run","task_id":"arch-001","arm":"memory","run":1,"result":{"task_id":"arch-001","arm":"memory","result_text":"Now I have a full picture. Here's how to find your way in:\n\n## Main entry point\n\n`contrib/zdb/src/zdb.c` (6186 lines) — has `_PG_init()` (`zdb.c:2514`), which registers hooks, GUCs, and background workers when the extension loads. It's the module init point, but it's *not* where individual UDFs typically live — those are spread across subdirectories and simply linked into the same `.so`.\n\n## Where introspection/metadata UDFs actually live\n\nFor a \"look at ZDB's on-disk/metadata layout\" UDF, the existing precedent is `zdb_get_metainfo_for_file` and friends:\n\n- **C implementation**: `contrib/zdb/src/tool/reader.c:1306` — function `zdb_get_metainfo(PG_FUNCTION_ARGS)`, registered via `PG_FUNCTION_INFO_V1(zdb_get_metainfo)` at `reader.c:54`. It's a set-returning function (`SRF`) that opens the relation, builds a `ZdbRelNode`/`GMetaContext`, and walks FDB-backed metadata keys (segfile/visimap/drop-flag/etc. depending on `keytype`).\n- **SQL declaration**: `contrib/zdb/zdb--1.0.sql:363` declares `pg_catalog.zdb_get_metainfo_for_file(...)` as `LANGUAGE C STRICT` pointing at C symbol `'zdb_get_metainfo'`. Note the SQL-visible name differs from the C symbol — multiple SQL wrappers (`_for_file`, `_for_visimap`, `_for_drop_flag`, each with `_in_tmp` variants) all point at the *same* C function, differentiated by the `keytype`/`snaptype` args.\n- **src/tool/** also hosts other UDF-style entry points: `txn_udf.c`, `parquet_udf.c`, `hot_standby_admin.c`, `hot_upgrade.c`, `zdb_roaring.c` — this is generally where \"diagnostic/admin UDF\" code lives, as opposed to `src/storage/` which is the actual storage engine.\n\n## Storage code organization (`contrib/zdb/src/`)\n\n| Dir | Purpose |\n|---|---|\n| `storage/` | Core engine: Parquet I/O, catalog (`zdb_catalog.c`), visimap, sharding (`zdb_shard.c`), vacuum, delta, flush, auto-partition, `zdb_fileinfo.c` (file metadata struct + `InsertZDBFileInfo`) |\n| `storage/parquet/` | Parquet format details: schema, data, encoding, statistics |\n| `tool/` | UDF-style entry points exposed to SQL (introspection, admin, upgrade) — **this is likely where your new UDF's C code belongs** |\n| `comm/` | HTTP/S3 utilities, and headers like `comm/zdb_fileinfo.h` shared between storage and tool code |\n| `dispatch/` | Query dispatch to segments |\n| `xact/` | Transaction management |\n\n## To add your UDF, you'll touch\n\n1. **C function** — new `Datum` function in `src/tool/reader.c` (or a new file in `src/tool/`, then add its `.o` to `Makefile`'s `OBJS`/`$(TOOL_PATH)` list at `Makefile:60-61`). If it needs to read the FDB-backed file metadata, follow `zdb_get_metainfo`'s pattern: open the relation, get `rd_node`/`relfilenode`, use `GMetaContext` to query.\n2. **SQL declaration + version bump** — add `CREATE FUNCTION pg_catalog.your_udf(...) ... LANGUAGE C STRICT` in a new `zdb--3.43--3.44.sql` (and a `--3.44--3.43.sql` downgrade), bump `default_version` in `zdb.control`, per `cicd/bump_relyt_version.py` convention.\n3. If output should be scoped to a single table's `relfilenode` (recommended, per the CLAUDE.md note on introspection UDFs being global by default), follow the `relid`/`relfilenode`-scoping pattern already used in `zdb_get_metainfo`.\n\nWant me to sketch the actual UDF skeleton (signature + SQL decl) once you tell me what layout info it should expose (segfile list, shard mapping, Parquet stats, etc.)?","input_tokens":24,"output_tokens":4300,"duration_s":92.223206668,"timed_out":false}} +{"kind":"verdict","task_id":"arch-001","arm":"memory","run":1,"verdict":{"score":10,"points_hit":["Main entry point is contrib/zdb/src/zdb.c","contrib/zdb/src/storage/ holds core storage: Parquet reader/writer, catalog, visimap, sharding, vacuum, delta, flush, auto-partition, standby","contrib/zdb/src/storage/parquet/ holds the Parquet format code: schema, data, encoding, statistics, NDP file handling","Actual row data is stored as Parquet files via Apache Arrow","Metadata (not the row data itself) lives in FoundationDB via the GMetaService, with NDP used for caching/near-data processing"],"points_missed":[],"reasoning":"答案准确覆盖了所有评分要点:明确指出主入口是zdb.c,详细描述了storage/和storage/parquet/目录的职责划分,并正确说明行数据存于Parquet/Arrow而元数据存于FDB(通过GMetaService)、NDP用于缓存。此外还提供了额外有价值的实操指引(如tool/目录下现有UDF范例),未违反任何gotcha。"}} +{"kind":"run","task_id":"arch-002","arm":"no-memory","run":0,"result":{"task_id":"arch-002","arm":"no-memory","result_text":"Two build modes for Phoenix, per CLAUDE.md:\n\n**1. Full Build without Neon** (production-like, but no separate storage stack):\n```bash\nsource /opt/rh/gcc-toolset-11/enable\ncd /workspace/phoenix\nRELYT_WITH_NEON=false sh neon/cicd/build.sh\n```\n- Builds Phoenix (v12) + **all third_party** components (gmeta, arrow, ndp, zindex, etc.)\n- Output: `neon/pg_install/v12/`\n- Internally invokes `neon_build_extensions.sh` to compile everything under `contrib/zdb/third_party`\n\n**2. Phoenix-only Build without Neon** (fast local debug — this is what you want):\n```bash\ncd /workspace/phoenix \u0026\u0026 sh cicd/build.sh\n```\n- Builds **only** Phoenix (Greenplum v12), no third_party (gmeta/arrow/etc. skipped)\n- Output: `/workspace/phoenix/work/app`\n\nConcrete differences (from the Build Modes table):\n\n| | With Neon | Without Neon |\n|---|---|---|\n| Configure flag | `--enable-neon` | (none, default) |\n| Block size | 8K | 32K |\n| Compiler define | `-DRELYT_WITH_NEON` | (none) |\n| Env var | `RELYT_WITH_NEON=true` | `RELYT_WITH_NEON=false` |\n| Use case | Production / full build | Local debug / fast build |\n\nFor your \"fast local debug, no full Neon storage stack\" case, use the **Phoenix-only build** (`sh cicd/build.sh`) — it's the quickest since it skips third_party entirely and still gives 32K blocks with no Neon defines. When you're ready to compare against production-like behavior, use `RELYT_WITH_NEON=false sh neon/cicd/build.sh` (still no Neon, but includes all third_party extensions) or the full `sh neon/cicd/build.sh` with Neon enabled if you want the true production configuration (8K blocks, `-DRELYT_WITH_NEON`, full Neon storage layer).","input_tokens":2,"output_tokens":723,"duration_s":12.296441631,"timed_out":false}} +{"kind":"verdict","task_id":"arch-002","arm":"no-memory","run":0,"verdict":{"score":8.5,"points_hit":["Without Neon is the default (no flag); with Neon requires --enable-neon","RELYT_WITH_NEON=true|false controls neon/cicd/build.sh mode","Block size 8K vs 32K stated in table","-DRELYT_WITH_NEON define presence/absence stated in table","Output locations for both without-Neon variants (work/app, neon/pg_install/v12) and with-Neon (implied v{12,14,15,16,17} via full build)"],"points_missed":["With-Neon output location neon/pg_install/v{12,14,15,16,17} not explicitly stated in the table, only implied"],"reasoning":"答案准确覆盖了两种模式的flag、block size、编译宏定义、环境变量和输出路径,均引用自CLAUDE.md原文;唯一小瑕疵是启用Neon时的输出路径neon/pg_install/v{12,14,15,16,17}未被显式列出,仅在文字中间接提及。"}} +{"kind":"run","task_id":"arch-002","arm":"no-memory","run":1,"result":{"task_id":"arch-002","arm":"no-memory","result_text":"These are already documented in the project's CLAUDE.md, so no lookup needed.\n\n**Full Build with Neon** (production-like):\n```bash\ncd /workspace/phoenix \u0026\u0026 sh neon/cicd/build.sh\n```\n- Flag: `--enable-neon`\n- Block size: 8K\n- Compiler define: `-DRELYT_WITH_NEON`\n- Builds: Neon Rust components + Phoenix (v12) + v14/v15/v16/v17\n- Output: `neon/pg_install/v{12,14,15,16,17}/`\n- Env: `RELYT_WITH_NEON=true`\n\n**Phoenix-only Build without Neon** (fast local debug — what you want):\n```bash\ncd /workspace/phoenix \u0026\u0026 sh cicd/build.sh\n```\n- Flag: none (default `./configure` with no `--enable-neon`)\n- Block size: 32K\n- Compiler define: none\n- Builds: Phoenix (Greenplum v12) only — no Neon, no `contrib/zdb/third_party` components (gmeta/arrow/etc. skipped)\n- Output: `/workspace/phoenix/work/app`\n\nNote there's also a middle option, `RELYT_WITH_NEON=false sh neon/cicd/build.sh`, which builds Phoenix without Neon but *does* build all the `third_party` components (gmeta, arrow, ndp, zindex) — output goes to `neon/pg_install/v12/`. Use that instead of the plain `cicd/build.sh` if you need ZDB's third-party deps (e.g. Delta Lake/FDB features) in your debug build but still want to skip Neon.\n\nFor a fast debug build with no third-party deps needed, `cicd/build.sh` is the right call; when you're ready to compare against production, use `neon/cicd/build.sh` (with Neon) and remember each mode's `pg_install` output lives under a different path, and cluster setup (`cicd/setup_cluster.sh`) auto-detects the mode via `pg_config --configure`.","input_tokens":2,"output_tokens":679,"duration_s":15.444149783,"timed_out":false}} +{"kind":"verdict","task_id":"arch-002","arm":"no-memory","run":1,"verdict":{"score":10,"points_hit":["Without Neon is default (no flag); with Neon requires --enable-neon","RELYT_WITH_NEON=true|false controls neon/cicd/build.sh mode","Block size 8K with Neon vs 32K without","Compiler define -DRELYT_WITH_NEON present with Neon, absent without","Output locations correctly given for all three build paths"],"points_missed":[],"reasoning":"答案准确涵盖了所有得分点,包括flag、环境变量、block size、编译宏定义及各构建路径的输出位置,并额外补充了三种构建模式的细致区分(未违反任何扣分项)。"}} +{"kind":"run","task_id":"arch-002","arm":"memory","run":0,"result":{"task_id":"arch-002","arm":"memory","result_text":"This is directly answered by CLAUDE.md, no need to explore the codebase.\n\nThere are two local build modes for Phoenix (v12), controlled by `RELYT_WITH_NEON`:\n\n| | Without Neon (fast local debug) | With Neon (production-like) |\n|---|---|---|\n| **Command** | `cd /workspace/phoenix \u0026\u0026 sh cicd/build.sh` | `cd /workspace/phoenix \u0026\u0026 sh neon/cicd/build.sh` (or `RELYT_WITH_NEON=true`) |\n| **Configure flag** | none (default) | `--enable-neon` |\n| **Block size** | 32K | 8K |\n| **Compiler define** | none | `-DRELYT_WITH_NEON` |\n| **Output location** | `/workspace/phoenix/work/app` | `neon/pg_install/v{12,14,15,16,17}/` |\n| **Scope built** | Phoenix (Greenplum v12) only — does **not** build `contrib/zdb/third_party` (gmeta/arrow/ndp/zindex etc.) | Neon Rust components + Phoenix v12 + v14/v15/v16/v17, plus all third_party extensions |\n\nNotes:\n- There's a third variant for local work that still wants third_party built but skips Neon: `RELYT_WITH_NEON=false sh neon/cicd/build.sh` (uses `neon/cicd/build.sh` orchestration, no `--enable-neon`, still 32K blocks, outputs to `neon/pg_install/v12/`). Requires sourcing `gcc-toolset-11` first (gmeta needs gcc 8's C++17 ABI compat).\n- After a rebase/pull/branch switch, both modes require `git submodule update --init --recursive` first, and if `contrib/zdb` changed, force-refresh the delta-kernel-rs FFI header (see CLAUDE.md's pre-build steps) — stale headers cause `PrimitiveType* undeclared` at compile time or `Schema inconsistent` errors at runtime.\n- To bring up a cluster matching whichever build you picked: `sh cicd/destroy_cluster.sh \u0026\u0026 sh cicd/setup_cluster.sh` (auto-detects Neon mode via `pg_config --configure`).\n\nFor your workflow (fast local debug now, compare against prod-like later), you'd typically do the plain `cicd/build.sh` first, then later run the full `neon/cicd/build.sh` in a separate output tree (`neon/pg_install/`) so the two builds don't collide.","input_tokens":2,"output_tokens":854,"duration_s":21.205793871,"timed_out":false}} +{"kind":"verdict","task_id":"arch-002","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["Without Neon default vs --enable-neon flag","RELYT_WITH_NEON controls neon/cicd/build.sh mode","Block size 8K vs 32K","-DRELYT_WITH_NEON define present/absent","Output locations correctly distinguished across all three build variants"],"points_missed":[],"reasoning":"答案完整覆盖了所有五个评分点,表格清晰对比了两种模式的flag、block size、compiler define和输出路径,并额外补充了第三种混合模式(RELYT_WITH_NEON=false sh neon/cicd/build.sh)增加了实用性且未违反任何gotcha。"}} +{"kind":"run","task_id":"arch-002","arm":"memory","run":1,"result":{"task_id":"arch-002","arm":"memory","result_text":"This is answered directly from the CLAUDE.md build docs in this repo — no need to dig further.\n\nPhoenix (Greenplum v12 fork) has two build modes, controlled by `RELYT_WITH_NEON`:\n\n| | **Without Neon** (fast local debug) | **With Neon** (production-like) |\n|---|---|---|\n| Configure flag | (default, no flag) | `--enable-neon` |\n| Block size | 32K | 8K |\n| Compiler define | (none) | `-DRELYT_WITH_NEON` |\n| Use case | Local debug / fast build | Production / full build |\n\nConcrete build commands:\n\n**Without Neon** (what you want for fast local debug):\n```bash\nsource /opt/rh/gcc-toolset-11/enable\ncd /workspace/phoenix\nRELYT_WITH_NEON=false sh neon/cicd/build.sh\n```\nOutput: `neon/pg_install/v12/` — this builds Phoenix (v12) + all `third_party` components (gmeta, arrow, ndp, zindex, etc.) but no Neon Rust components.\n\nThere's also an even lighter option if you don't need `third_party` (gmeta/arrow/etc.) at all:\n```bash\ncd /workspace/phoenix \u0026\u0026 sh cicd/build.sh\n```\nOutput: `/workspace/phoenix/work/app` — Phoenix only, no Neon, no third_party, 32K blocks.\n\n**With Neon** (production-like, for comparison):\n```bash\ncd /workspace/phoenix \u0026\u0026 sh neon/cicd/build.sh\n```\nOutput: `neon/pg_install/v{12,14,15,16,17}/` — builds Neon Rust components + Phoenix (v12) + v14/v15/v16/v17, 8K blocks, `-DRELYT_WITH_NEON` defined.\n\nNote the pre-build step (submodule sync + delta-kernel-rs header refresh) is mandatory before either build if you've just rebased/pulled/switched branches — otherwise you risk stale submodule/header mismatches.","input_tokens":2,"output_tokens":687,"duration_s":13.487591194,"timed_out":false}} +{"kind":"verdict","task_id":"arch-002","arm":"memory","run":1,"verdict":{"score":10,"points_hit":["无 Neon 为默认(无需flag);启用 Neon 需 --enable-neon","RELYT_WITH_NEON=true|false 控制 neon/cicd/build.sh 的构建模式","块大小差异: 有 Neon 为 8K,无 Neon 为 32K","有 Neon 定义编译宏 -DRELYT_WITH_NEON;无 Neon 时不存在","无 Neon 输出路径 /workspace/phoenix/work/app 或 neon/pg_install/v12;有 Neon 输出至 neon/pg_install/v{12,14,15,16,17}"],"points_missed":[],"reasoning":"答案完整覆盖了所有五个得分点,并给出了准确的表格对比和具体命令,信息与仓库 CLAUDE.md 一致,没有遗漏或错误。"}} +{"kind":"run","task_id":"arch-003","arm":"no-memory","run":0,"result":{"task_id":"arch-003","arm":"no-memory","result_text":"Confirmed — the PG17 source lives at `neon/vendor/postgres-v17/`, a git submodule as its own PostgreSQL 17 tree, and it's a standalone git repo (has its own `.git`, `configure`, `Makefile`, etc.) checked out under the Neon submodule.\n\nAnswering directly:\n\n- **Location**: `neon/vendor/postgres-v17/` — a submodule of the `neon/` submodule, i.e. `neon` pulls in vendored PG source trees for v14/v15/v16/v17, distinct from the top-level Phoenix tree (which is PG 12.12/Greenplum, the repo root itself).\n- **Build modes**: Yes — same `RELYT_WITH_NEON` toggle applies. `neon/cicd/build.sh` defaults `RELYT_WITH_NEON=false` (32K blocks) and switches to `RELYT_WITH_NEON=true` (8K blocks, `-DRELYT_WITH_NEON`) when building with Neon support, same mechanism as the v12 Phoenix tree. The full build (`neon/cicd/build.sh`) builds v12 + v14–v17 together; v17 isn't built standalone by the top-level `cicd/build.sh` (that one is Phoenix/v12-only, no third_party).\n\nSo for a backport: patch the fix in `neon/vendor/postgres-v17/src/...`, then rebuild via `neon/cicd/build.sh` (with `RELYT_WITH_NEON=true` or `false` depending on which mode you want to validate) — output lands in `neon/pg_install/v17/`.","input_tokens":4,"output_tokens":817,"duration_s":18.53176986,"timed_out":false}} +{"kind":"verdict","task_id":"arch-003","arm":"no-memory","run":0,"verdict":{"score":10,"points_hit":["PG17 source lives at neon/vendor/postgres-v17/ (a git submodule)","Neon (neon/) also vendors v14, v15, and v16 under neon/vendor/postgres-v{14,15,16}/","Phoenix itself is a fork of PostgreSQL 12.12 / Greenplum, separate from the vendored v14-v17 trees","PG17, like Phoenix, can be built either with Neon (disaggregated storage) or without Neon","The final shipped product bundles Neon + PostgreSQL v17 (with Neon) + Phoenix (Greenplum v12, with Neon) + extensions (ZDB, pgvector, PostGIS, etc.)"],"points_missed":[],"reasoning":"答案准确覆盖了所有评分点:位置、v14-v16的并存、Phoenix与vendored树的区别、双构建模式支持、以及最终产品的组成。虽然关于build.sh细节和RELYT_WITH_NEON默认值的部分表述略显冗余或存疑,但核心事实均正确且完整。"}} +{"kind":"run","task_id":"arch-003","arm":"no-memory","run":1,"result":{"task_id":"arch-003","arm":"no-memory","result_text":"PG17 lives at `neon/vendor/postgres-v17/` — it's a git submodule, not part of the main Phoenix (v12) tree, and it's a plain PostgreSQL 17.5 source tree (its own `configure`, `Makefile`, etc.), separate from the Greenplum-based `src/` at the repo root.\n\nYes, it builds both ways, same as Phoenix:\n- **With Neon**: `neon/cicd/build.sh` builds it alongside pageserver/safekeeper/etc., 8K blocks, `--enable-neon`/`-DRELYT_WITH_NEON`.\n- **Without Neon**: standard `./configure \u0026\u0026 make` in that directory (no `--enable-neon` flag), 32K blocks — for local debugging without the Neon storage layer.\n\nCluster lifecycle for it is managed via dedicated scripts in `neon/cicd/`: `setup_pg17.sh`, `restart_pg17.sh`, `destroy_pg17.sh`, `package_pg17.sh` — separate from the Phoenix `cicd/setup_cluster.sh` used for the v12 tree.\n\nPractical implication for backporting: since v17 is a vendored submodule with its own build/test scripts, a fix originating in Phoenix (v12) C code won't automatically apply — you'd need to locate the equivalent code path in `neon/vendor/postgres-v17/` (it may have diverged given the version gap and Neon's own patches there) and build/test it independently via the `pg17`-specific scripts.","input_tokens":4,"output_tokens":836,"duration_s":17.880364375,"timed_out":false}} +{"kind":"verdict","task_id":"arch-003","arm":"no-memory","run":1,"verdict":{"score":9,"points_hit":["PG17 source lives at neon/vendor/postgres-v17/ (a git submodule)","Phoenix itself is a fork of PostgreSQL 12.12 / Greenplum, separate from the vendored v14-v17 trees","PG17, like Phoenix, can be built either with Neon (disaggregated storage) or without Neon"],"points_missed":["Neon (neon/) also vendors v14, v15, and v16 under neon/vendor/postgres-v{14,15,16}/ (not mentioned)","The final shipped product bundles Neon + PostgreSQL v17 (with Neon) + Phoenix (Greenplum v12, with Neon) + extensions (not mentioned)"],"reasoning":"Agent correctly identified the submodule location, separation from Phoenix v12, and dual build modes, but omitted the sibling v14-v16 vendored trees and the shipped-product composition detail."}} +{"kind":"run","task_id":"arch-003","arm":"memory","run":0,"result":{"task_id":"arch-003","arm":"memory","result_text":"Confirmed directly from the source tree. Here's the answer:\n\n**Location:** The standalone PostgreSQL 17 lives at `neon/vendor/postgres-v17/` — it's a git submodule of the `neon` repo (currently pinned at `859ed661103 build: add pg_tiktoken_c to CI build pipeline`), not part of the Phoenix (v12) source tree itself.\n\n**Neon build modes:** Yes — same dual-mode pattern as Phoenix v12. Its `configure.ac` defines the identical `--enable-neon` flag (lines 257–259, 470–471), which `AC_DEFINE`s `RELYT_WITH_NEON` and adds `-DRELYT_WITH_NEON` to `CFLAGS` when enabled — same macro name and mechanism as v12.\n\n- **With Neon**: built via `neon/cicd/build.sh` (the same orchestrator that builds v12/v14-v17), cluster brought up with `neon/cicd/setup_pg17.sh`, which calls `neon_local init/start` and spins up a Neon endpoint on PG 17.\n- **Without Neon**: configure without `--enable-neon`, standard standalone PG 17 build/install.\n\nSo for a backport decision: since it shares the same `--enable-neon` / `RELYT_WITH_NEON` convention as v12, a fix that's conditional on Neon vs. non-Neon code paths in Phoenix should port cleanly in structure — but note v17 is a separate submodule pinned to its own commit, so the backport is a distinct patch/PR against `neon/vendor/postgres-v17`, not a change that flows automatically from the Phoenix v12 tree.","input_tokens":6,"output_tokens":1074,"duration_s":20.769273616,"timed_out":false}} +{"kind":"verdict","task_id":"arch-003","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["PG17 source lives at neon/vendor/postgres-v17/ (a git submodule)","Phoenix itself is a fork of PostgreSQL 12.12 / Greenplum, separate from the vendored v14-v17 trees","PG17, like Phoenix, can be built either with Neon (disaggregated storage) or without Neon"],"points_missed":["Did not explicitly state that neon/ also vendors v14, v15, v16 under neon/vendor/postgres-v{14,15,16}/","Did not explicitly describe the final shipped product bundling Neon + PG17(with Neon) + Phoenix(with Neon) + extensions"],"reasoning":"The answer correctly nails location, submodule nature, separation from Phoenix v12, and dual build modes with concrete configure.ac evidence, but omits explicit mention of sibling v14-v16 vendoring and the shipped product bundle description, both rubric points."}} +{"kind":"run","task_id":"arch-003","arm":"memory","run":1,"result":{"task_id":"arch-003","arm":"memory","result_text":"Confirmed. The standalone PostgreSQL 17 source lives at `neon/vendor/postgres-v17/` (a git submodule), sibling to `postgres-v14/v15/v16` and `greenplum` (the v12/Phoenix source) under `neon/vendor/`.\n\nBuild support: yes, it builds both with and without Neon, same as the v12 Phoenix tree — the `RELYT_WITH_NEON` flag in `neon/cicd/build.sh` (line 25-32) drives it for all versions, including v17: `false` → 32K blocks, `true` → 8K blocks + Neon support. There's also a v17-specific cluster path: `neon/cicd/setup_pg17.sh`, `destroy_pg17.sh`, `restart_pg17.sh`, `package_pg17.sh`.\n\nFor a backport evaluation, key differences from the v12 (Phoenix) tree to keep in mind:\n- v17 is stock upstream PostgreSQL 17.5 (not a Greenplum fork) — no MPP/segment architecture, no ZDB/GPORCA, so anything tied to those (dispatch, cdbhash, ZDB storage) doesn't apply.\n- Neon integration for v17 comes from `neon/pgxn/` extensions (`neon`, `neon_walredo`, etc.) rather than being built into the core tree the way `--enable-neon` is for Phoenix.\n\nIf the fix you're backporting touches ZDB/GPORCA/MPP-specific logic (like the recent SP2 direct-dispatch fix), it likely has no v17 equivalent since v17 lacks that machinery — worth confirming which subsystem the fix lives in before scoping the backport.","input_tokens":6,"output_tokens":1065,"duration_s":20.861386136,"timed_out":false}} +{"kind":"verdict","task_id":"arch-003","arm":"memory","run":1,"verdict":{"score":10,"points_hit":["PG17源码位于neon/vendor/postgres-v17/(git submodule)","指出neon/vendor/下同时存在v14/v15/v16的兄弟目录","明确Phoenix是独立于v14-v17的PG12.12/Greenplum fork","确认PG17可以像Phoenix一样支持有/无Neon两种构建模式","提及最终产品打包了Neon+PG17(with Neon)+Phoenix(Greenplum v12, with Neon)+扩展"],"points_missed":[],"reasoning":"回答准确覆盖了rubric所有五个得分点,包括目录结构、submodule版本列表、Phoenix与vendor树的区别、PG17双构建模式以及最终产品构成,且没有触犯任何gotcha扣分项。"}} +{"kind":"run","task_id":"arch-004","arm":"no-memory","run":0,"result":{"task_id":"arch-004","arm":"no-memory","result_text":"Confirmed — the exploration was accurate. Here's the summary:\n\n## Where direct dispatch is decided (classic planner)\n\n**Core struct** — `DirectDispatchInfo` in `src/include/nodes/plannodes.h:30-44`:\n```c\ntypedef struct DirectDispatchInfo\n{\n bool isDirectDispatch;\n List *contentIds; /* target segment ids */\n bool haveProcessedAnyCalculations;\n} DirectDispatchInfo;\n```\nEmbedded per query slice as `PlanSlice.directDispatch`.\n\n**Decision logic** — `src/backend/cdb/cdbtargeteddispatch.c`:\n- `DirectDispatchUpdateContentIdsFromPlan()` (~line 1070), called from `create_plan()` in `src/backend/optimizer/plan/createplan.c` for each scan node, merges results into `root-\u003ecurSlice-\u003edirectDispatch` via `MergeDirectDispatchCalculationInfo()` (~1035).\n- For scan nodes, `GetContentIdsFromPlanForSingleRelation()` (line 743) does the real work.\n\n**The algorithm** (lines ~900-1016):\n1. Fetch the relation's `GpPolicy` (distribution policy). If it's not hash-distributed, bail (full dispatch).\n2. For each distribution-key column, call `DeterminePossibleValueSet()` (`predtest_valueset.c`) against the scan's quals to extract a finite set of possible constant values implied by `WHERE` (handles `=`, `IN`, etc.). If the set is unbounded or too large (`totalCombinations \u003e= numsegments * 3`), give up → full dispatch.\n3. Otherwise, for every combination of possible key values, build a `CdbHash` (`makeCdbHashForRelation`) and run the **same hash used for tuple placement on INSERT**:\n ```c\n cdbhashinit(h);\n for (i = 0; i \u003c policy-\u003enattrs; i++)\n cdbhash(h, i + 1, val-\u003econstvalue, ...);\n hashCode = cdbhashreduce(h);\n result.contentIds = list_append_unique_int(result.contentIds, hashCode);\n ```\n4. `cdbhashreduce()` in `src/backend/cdb/cdbhash.c` reduces the hash to a segment id (mod/bitmask/jump-consistent-hash depending on `reducealg`).\n5. There's also a fast-path shortcut for `gp_segment_id = const` quals, and this fork adds `TryDirectDispatchOnSubstringPrefix()` (lines 665-737) for `DISTRIBUTED BY substring(col,1,N)`-style keys.\n\nBecause it reuses the exact same `cdbhash`/`cdbhashreduce` as row insertion, a lookup on the constant is guaranteed to land on the same segment the row was originally distributed to.\n\n**Where it's consumed** — `src/backend/executor/execUtils.c`'s `FillSliceGangInfo()` copies `PlanSlice.directDispatch.contentIds` into `ExecSlice.segments`; `AssignGangs()`/`InventorySliceTree()` then calls `AllocateGang(ds, slice-\u003egangType, slice-\u003esegments)`, so only those segments get a gang — the dispatcher (`cdbdisp_query.c`) simply never sends the query to the others.\n\n**ORCA note**: GPORCA computes its own version but funnels it into the same `DirectDispatchInfo`/`PlanSlice.directDispatch` via `CTranslatorDXLToPlStmt`, so both planner paths converge at `FillSliceGangInfo()`/`AssignGangs()`.","input_tokens":4,"output_tokens":1544,"duration_s":138.918506728,"timed_out":false}} +{"kind":"verdict","task_id":"arch-004","arm":"no-memory","run":0,"verdict":{"score":10,"points_hit":["位置正确 (cdbtargeteddispatch.c)","关键函数正确 (GetContentIdsFromPlanForSingleRelation)","hash算法解释正确 (cdbhash/cdbhashreduce结合分布策略)","明确区分Postgres planner路径与GPORCA路径","提到relation storage type检查影响direct dispatch安全性"],"points_missed":[],"reasoning":"答案完整覆盖了所有评分点:准确定位文件与核心函数,正确描述了通过cdbhash对分布键值哈希来确定目标segment的机制,清晰区分了classic planner路径与ORCA路径,并提及了RelationIsZDB等存储类型检查对direct dispatch安全性的影响。未发现任何gotcha扣分项。"}} +{"kind":"run","task_id":"arch-004","arm":"no-memory","run":1,"result":{"task_id":"arch-004","arm":"no-memory","result_text":"I have enough to answer confidently.\n\n## Where it happens\n\nFor the classic (non-ORCA) planner, the file is **`src/backend/cdb/cdbtargeteddispatch.c`**, driven from **`src/backend/optimizer/plan/createplan.c`** (called at lines ~819, ~3190, ~3970, right after each scan/motion/top-level plan node is built).\n\n- `createplan.c` calls `DirectDispatchUpdateContentIdsFromPlan(root, plan)` bottom-up as each plan node is created.\n- That function (`cdbtargeteddispatch.c:1070`) switches on `nodeTag(plan)`: scans (`SeqScan`, `IndexScan`, `IndexOnlyScan`, `BitmapIndexScan`, `CustomScan`) delegate to `GetContentIdsFromPlanForSingleRelation()`; join/agg/sort nodes pass dispatch info through unchanged (their children's constraints only narrow, never widen, the segment set); nodes like `Motion`, `TidScan`, `FunctionScan`, `ShareInputScan`, `ForeignScan` explicitly **disable** direct dispatch (`DisableTargetedDispatch`) because they can't be reasoned about safely.\n- Results are merged per-slice via `MergeDirectDispatchCalculationInfo()` into `root-\u003ecurSlice-\u003edirectDispatch` (a `DirectDispatchInfo`: `isDirectDispatch` + `contentIds` list). Merging multiple scans in a join intersects/unions candidate segment sets conservatively — if any child says \"can't tell,\" the merge gives up.\n- For `INSERT` with a constant target row, `DirectDispatchUpdateContentIdsForInsert()` (called from `cdbmutate.c:1888`) handles it directly rather than via the scan path.\n\n## How the target segment is decided (`GetContentIdsFromPlanForSingleRelation`, line 742)\n\n1. Open the relation, get its `GpPolicy`. Bail out (no direct dispatch) if `policy == NULL`, `rte-\u003eforceDistRandom` is set, or the relation is a **ZDB (columnar) relation** — ZDB rows are physically sharded, not hash-distributed by `rd_cdbpolicy`, so hashing the distkey would target the wrong segment (this is exactly the bug your recent commits — `7a39c8494b8`/`4f2b84033c5` — are about, for mixed heap/ZDB partition trees).\n2. First checks for a `gp_segment_id = \u003cconst\u003e` (or IN-list) qual via `DeterminePossibleValueSet()` — if that pins the query to fewer segments than `numsegments`, it short-circuits directly to those content IDs.\n3. Otherwise, if the policy is hash-partitioned, for each distribution-key column it calls `DeterminePossibleValueSet(qual, var, opfamily)` to extract the finite set of possible constant values that column could take (via equality/IN predicates in the qual — this uses `predtest_valueset.c`'s constant-folding/predicate analysis). If any column's value set is unbounded (`isAnyValuePossible`), direct dispatch is abandoned for that relation.\n4. If all columns yield bounded value sets, it computes the Cartesian product (`totalCombinations`); if that's small enough (`\u003c numsegments * 3`), it hashes each combination with `CdbHash`/`cdbhashreduce()` (same hash function used for storage placement) to get a target segment id, and unions the resulting segment ids into `contentIds`. If the resulting set covers fewer segments than `numsegments`, direct dispatch is chosen; otherwise it falls back to full dispatch.\n5. There's also a newer specialized path, `TryDirectDispatchOnSubstringPrefix()` (line 665), for `DISTRIBUTED BY substring(col,1,N)`-style bytea-prefix hash distribution — it extracts a bounded set of N-byte prefixes from equality/range quals and hashes those instead.\n\nSo: **classic planner path = `createplan.c` invoking `cdbtargeteddispatch.c`'s per-scan-node analysis, merged per-slice, deciding the segment(s) by evaluating the distribution-key predicate's possible constant values and running them through the same `CdbHash` used to place the data.**","input_tokens":12,"output_tokens":2628,"duration_s":47.942255543,"timed_out":false}} +{"kind":"verdict","task_id":"arch-004","arm":"no-memory","run":1,"verdict":{"score":9,"points_hit":["The logic lives in src/backend/cdb/cdbtargeteddispatch.c","The key function is GetContentIdsFromPlanForSingleRelation","It targets a segment by hashing the distribution key value with the table's distribution policy (cdbhash) to compute which content/segment id should receive the query","This is the Postgres-planner dispatch path, distinct from the GPORCA optimizer's own dispatch-info translation path","The function checks relation storage type (e.g. RelationIsZDB) when deciding whether direct dispatch is safe for a given relation"],"points_missed":[],"reasoning":"答案准确定位到 cdbtargeteddispatch.c 和 GetContentIdsFromPlanForSingleRelation,正确描述了通过 cdbhash/CdbHash 对分布键值计算目标 segment 的机制,明确区分了经典 planner 路径与 ORCA 路径,并提到了 ZDB relation 检查这一细节;个别具体行号和提交哈希未经验证可能不准确,但不影响核心得分点的覆盖。"}} +{"kind":"run","task_id":"arch-004","arm":"memory","run":0,"result":{"task_id":"arch-004","arm":"memory","result_text":"## Where the decision is made\n\n**`src/backend/cdb/cdbtargeteddispatch.c`** is the file. It's invoked from `src/backend/optimizer/plan/createplan.c` (lines ~819, ~3190, ~3970), which calls `DirectDispatchUpdateContentIdsFromPlan(root, plan)` bottom-up as each plan node is built. INSERT has a separate entry point, `DirectDispatchUpdateContentIdsForInsert`, called from `cdbmutate.c:1888`.\n\n## How the target segment is chosen\n\nFor scan nodes (SeqScan, IndexScan, IndexOnlyScan, BitmapIndexScan, CustomScan), `GetContentIdsFromPlanForSingleRelation` (cdbtargeteddispatch.c:743) does the work:\n\n1. Opens the relation and reads its distribution policy (`rd_cdbpolicy` / `GpPolicy`).\n2. Special-cases a literal `gp_segment_id = const` qual directly (line ~832).\n3. For a hash-distributed policy, it runs `DeterminePossibleValueSet` over the qual to extract the possible constant value(s) for each distribution-key column (this is generic predicate analysis, not just `=` — it also handles `IN`, ranges that pin a small value set, etc.). It enumerates the cartesian product of those value combinations (bailing out if the combination count is too large — cap is `numsegments * 3`), and for each combination runs the values through the **same `CdbHash`/`cdbhashreduce`** function used at INSERT time to place a row. If the resulting segment set is smaller than `numsegments`, it marks `isDirectDispatch = true` with that list of content IDs.\n4. There's also a substring-prefix-distribution-key variant (`TryDirectDispatchOnSubstringPrefix`, line 665) for `bytea_prefix_N_hash_ops` columns, and an explicit carve-out (line ~792, `RelationIsZDB` check) that disables direct dispatch entirely for ZDB columnar relations — that's the mixed heap/ZDB correctness fix from commit `7a39c8494b8` you have in recent history, since ZDB rows are physically placed by shard rather than by the distkey's cdbhash.\n\nPer-node results get merged (`MergeDirectDispatchCalculationInfo`) into the current slice's `directDispatch` field. Several plan node types (Motion, TidScan, FunctionScan, ForeignScan, etc.) unconditionally disable it for the whole slice.\n\n## How it turns into an actual dispatch target\n\nAt execution setup, `FillSliceGangInfo` in `src/backend/executor/execUtils.c:1118` reads that `directDispatch` info off the `PlanSlice` and sets `ExecSlice-\u003esegments` to the resolved content-id list — that's what the dispatcher (`cdbdisp_query.c`/`cdbgang.c`) uses to allocate a gang against just those segments instead of all of them.\n\nNote this is the classic-planner path only; ORCA computes its own direct-dispatch info at the DXL level (`CDXLDirectDispatchInfo.h`), which is what your recent commit `4f2b84033c5` was patching for mixed partition trees.","input_tokens":24,"output_tokens":5774,"duration_s":134.118859587,"timed_out":false}} +{"kind":"verdict","task_id":"arch-004","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["文件位置 src/backend/cdb/cdbtargeteddispatch.c","关键函数 GetContentIdsFromPlanForSingleRelation","通过 cdbhash/CdbHash 对分布键哈希计算目标 segment","明确这是经典 planner 路径,区别于 ORCA 的 DXL 层 direct dispatch","提到 RelationIsZDB 检查来判断该 relation 是否可安全走 direct dispatch"],"points_missed":[],"reasoning":"答案完整覆盖了所有五个得分点:准确定位文件与核心函数,正确描述了基于 cdbhash 对分布键取值计算目标 segment 的机制,明确区分了经典 planner 路径与 ORCA 路径,并提到了 RelationIsZDB 的存储类型检查,且未违反任何扣分项。"}} +{"kind":"run","task_id":"arch-004","arm":"memory","run":1,"result":{"task_id":"arch-004","arm":"memory","result_text":"Here's the mechanism, with the two things you asked for — where it lives, and how the segment target is picked.\n\n## Where the decision is made\n\n**Entry point** — during plan creation, right after each scan/index-scan node is built:\n- `src/backend/optimizer/plan/createplan.c:818-819` (and again at `:3190`, `:3970`) calls `DirectDispatchUpdateContentIdsFromPlan(root, plan)`, gated on `Gp_role == GP_ROLE_DISPATCH \u0026\u0026 root-\u003econfig-\u003egp_enable_direct_dispatch`.\n- For `INSERT`, there's a parallel path: `DirectDispatchUpdateContentIdsForInsert()` called from `src/backend/cdb/cdbmutate.c:1888` (it hashes the literal `VALUES` instead of reading quals).\n\n**Core logic** — `src/backend/cdb/cdbtargeteddispatch.c`:\n- `DirectDispatchUpdateContentIdsFromPlan` (line 1070) dispatches on the plan node's tag. Scan-type nodes (`SeqScan`, `IndexScan`, `IndexOnlyScan`, `BitmapIndexScan`, `CustomScan`) call `GetContentIdsFromPlanForSingleRelation()` with that node's quals; most other nodes (joins, sorts, aggs) pass through unchanged; a few — `Motion`, `TidScan`, `FunctionScan`, `ForeignScan`, `SampleScan`, `ShareInputScan` — explicitly call `DisableTargetedDispatch()` because their semantics make single-segment routing unsafe/unknowable.\n- The per-node result is merged via `MergeDirectDispatchCalculationInfo()` into `root-\u003ecurSlice-\u003edirectDispatch` — so a slice with multiple scans/joins only stays \"direct dispatchable\" if *every* contributing node agrees on a segment set (a join doesn't add new info, it just doesn't invalidate what the scans already determined).\n- The merged `PlanSlice-\u003edirectDispatch` gets carried into the slice table by `build_slice_table_walker()` in `src/backend/cdb/cdbllize.c` (around line 1314).\n- Finally, at executor slice-table setup, `FillSliceGangInfo()` in `src/backend/executor/execUtils.c:1121` reads `ps-\u003edirectDispatch`: if `isDirectDispatch` is true, `slice-\u003esegments` is set to exactly `dd-\u003econtentIds` instead of the full `0..numsegments-1` range. That's what makes the gang/dispatch machinery (`cdbgang.c`, `cdbdisp_query.c`) talk to only that one QE.\n\n## How it picks the segment\n\nIn `GetContentIdsFromPlanForSingleRelation()` (`cdbtargeteddispatch.c:743`):\n1. It opens the relation and reads `rd_cdbpolicy` (the distribution policy). If there's no policy, or `forceDistRandom`, it bails (no direct dispatch).\n2. It first checks for a literal `gp_segment_id = \u003cconst\u003e` qual (via `DeterminePossibleValueSet`) — if present, that directly gives the content id(s), skipping the hash entirely.\n3. Otherwise, for a hash-distributed table, for each distribution-key column it calls `DeterminePossibleValueSet()` (built on the predicate-analysis code in `optimizer/predtest_valueset`) to see whether the quals pin that column to a small, finite set of constant values (equality, `IN`, etc. — anything that leaves the value set \"any\" kills direct dispatch for that column).\n4. If every distkey column resolves to a small finite set, and the cartesian product of those sets is smaller than `numsegments * 3` (to avoid degenerating into \"touches every segment anyway\"), it enumerates every combination and, for each one, runs the **exact same hash used for real tuple routing** — `makeCdbHashForRelation()` + `cdbhash()` per column + `cdbhashreduce()` — to compute the target content id, then unions all the resulting ids into `result.contentIds`.\n5. If the combination count is 0 (contradictory quals), it flags direct-dispatch-to-nowhere; if it can't bound the set, direct dispatch is disabled.\n\n## Fork-specific additions (relevant to your SP2 work)\n\nThis codebase has two Phoenix-specific layers on top of stock GPDB direct dispatch, both in `cdbtargeteddispatch.c`:\n- **ZDB exclusion** (line ~792): `RelationIsZDB(relation)` short-circuits to \"never direct dispatch\" — added because ZDB relations are physically shard-distributed, not cdbhash-consistent, so hash-based single-segment targeting would silently miss rows. This is the same root cause as the mixed heap/ZDB partition bug you fixed in commit `7a39c8494b8`.\n- **Substring-prefix pruning** (`TryDirectDispatchOnSubstringPrefix`, line 666, and the qual-matching helpers above it) — a bytea-prefix-aware extension for `DISTRIBUTED BY substring(col, 1, N)` policies, enumerating candidate N-byte prefixes from range/equality quals rather than requiring exact equality.\n\nNote: all of this is the **planner-side (classic Postgres planner)** path. GPORCA computes its own direct-dispatch info independently in the C++ optimizer and is a separate code path (matches the \"ORCA direct dispatch\" work in your recent commit `4f2b84033c5`) — not covered above.","input_tokens":26,"output_tokens":6988,"duration_s":147.642834632,"timed_out":false}} +{"kind":"verdict","task_id":"arch-004","arm":"memory","run":1,"verdict":{"score":10,"points_hit":["logic lives in src/backend/cdb/cdbtargeteddispatch.c","key function is GetContentIdsFromPlanForSingleRelation","targets segment by hashing distribution key value via cdbhash/cdbhashreduce per distribution policy","identifies this as the Postgres-planner path distinct from GPORCA's own dispatch-info translation","checks RelationIsZDB when deciding whether direct dispatch is safe"],"points_missed":[],"reasoning":"答案准确覆盖了rubric所有五个要点,函数名、文件路径、哈希机制、ORCA区分和ZDB检查均正确且有具体行号支撑,未发现明显捏造或错误。"}} +{"kind":"run","task_id":"arch-005","arm":"no-memory","run":0,"result":{"task_id":"arch-005","arm":"no-memory","result_text":"Good, this confirms the guard added in the wip commit. I have enough to give a precise, verified answer.\n\n## Short answer\n\nNo — GPORCA does **not** reuse the classic planner's direct-dispatch decision code. It has a completely separate, independent decision path written in C++ inside the ORCA optimizer itself. The two paths only converge again at the very last step: filling in the same `PlanSlice.directDispatch` struct that the executor consumes.\n\n## The two decision paths\n\n**Classic planner** (`optimizer=off`):\n- `src/backend/cdb/cdbtargeteddispatch.c` — `GetContentIdsFromPlanForSingleRelation()` (called from `src/backend/optimizer/plan/createplan.c:2139,3111`), walks the qual list looking for equality on the distribution key, computes `cdbhash` → segment content id.\n- Merged across Append children etc. in `src/backend/cdb/cdbllize.c` via `MergeDirectDispatchCalculationInfo()`.\n- This is the code your branch already patched (`7a39c8494b8`) to unconditionally disable direct dispatch for ZDB relations, since ZDB physical placement is by shard, not by `cdbhash`.\n\n**GPORCA** (`optimizer=on`):\n- Lives entirely in `src/backend/gporca/` (the C++ optimizer library: `libgpos`, `libgpopt`, `libnaucrates`, `libgpdbcost`, `libgpopt/src/translate/`...). This is where GPORCA \"itself\" lives — it's a separate C++ tree built into `libgporca_*.a`, distinct from the PG backend.\n- The direct-dispatch equivalent is `CTranslatorExprToDXLUtils::SetDirectDispatchInfo()` in `src/backend/gporca/libgpopt/src/translate/CTranslatorExprToDXLUtils.cpp:712`. It runs during DXL generation from the optimized `CExpression` plan tree, deriving the table's distribution spec and single-column equality constraint, and stamping a `CDXLDirectDispatchInfo` node onto the DXL plan.\n- Your `4f2b84033c5` wip commit already added a guard there (line ~735-751) that bails out when the base table's storage type is `ErelstorageZDB` or `ErelstorageMixedPartitioned` — mirroring the planner-side fix.\n- That DXL `CDXLDirectDispatchInfo` is then translated back into a real `PlanSlice` by `CTranslatorDXLToPlStmt::TranslateDXLDirectDispatchInfo()` in `src/backend/gpopt/translate/CTranslatorDXLToPlStmt.cpp:4881` — this glue layer (`src/backend/gpopt/translate/`) is the boundary between GPORCA's C++ world and Postgres's `Plan`/`PlanSlice` nodes.\n\n## Where they reconverge\n\nBoth paths ultimately just populate `PlanSlice.directDispatch` (the `DirectDispatchInfo` struct, `src/include/nodes/plannodes.h:39`). At execution time, both are consumed identically by `FillSliceGangInfo()` in `src/backend/executor/execUtils.c:1121`, which turns `contentIds` into the actual segment/gang list used for dispatch.\n\n## Open item from your design doc\n\nPer `docs/superpowers/specs/2026-07-17-mixed-partition-heap-zdb-sp2-design.md` §8: even after the `SetDirectDispatchInfo` guard above, `EXPLAIN` correctly shows `3:1(segments:3)` but the runtime `Test_print_direct_dispatch_info` still reports dispatch to a single content — meaning there's a **second, still-unlocated** ORCA-side segment-selection mechanism beyond the DXL `directDispatch` info. That's flagged in the doc as unverified/un-root-caused, and per the CLAUDE.md investigation guidelines I'm not going to guess at what it is — worth continuing to trace from `CTranslatorDXLToPlStmt` / dispatcher consumption if you want to pick that back up.","input_tokens":22,"output_tokens":6729,"duration_s":98.750158616,"timed_out":false}} +{"kind":"verdict","task_id":"arch-005","arm":"no-memory","run":0,"verdict":{"score":8,"points_hit":["GPORCA是与经典planner的cdbtargeteddispatch.c逻辑完全独立的dispatch决策路径","GPORCA位于src/backend/gporca/下并使用C++编写","正确指出相关翻译逻辑是CTranslatorExprToDXLUtils::SetDirectDispatchInfo (位于src/backend/gporca/libgpopt/src/translate/CTranslatorExprToDXLUtils.cpp)","该函数决定了在使用GPORCA时是否将direct dispatch信息附加到DXL计划上"],"points_missed":["未明确说明两条路径独立意味着仅修复planner路径不会自动覆盖GPORCA路径(反之亦然)——虽有提及ZDB guard已在两处都打了补丁,但没有清晰阐述这一推论性结论"],"reasoning":"答案准确覆盖了rubric的前四点:GPORCA是独立路径、位于src/backend/gporca/并用C++编写、以及精确定位SetDirectDispatchInfo函数及其作用。但对'一方修复不会自动覆盖另一方'这一推论性结论未作清晰陈述,反而暗示两处都已修复,弱化了这一独立性的必然推论。"}} +{"kind":"run","task_id":"arch-005","arm":"no-memory","run":1,"result":{"task_id":"arch-005","arm":"no-memory","result_text":"Now I have a complete picture. Here's the answer:\n\n## Two entirely separate direct-dispatch implementations\n\nGPORCA does **not** reuse the classic planner's direct-dispatch code (`src/backend/cdb/cdbtargeteddispatch.c`). It has its own independent implementation. Confirmed by tracing the caller graph:\n\n- `DirectDispatchUpdateContentIdsFromPlan()` / `DirectDispatchUpdateContentIdsForInsert()` (the classic-planner logic, `cdbtargeteddispatch.c:1070,1237`) are only ever called from `src/backend/optimizer/plan/createplan.c` (lines 819, 3190, 3970) and `cdbmutate.c:1888` — all exclusively part of the Postgres-planner plan-building path. ORCA never calls into this file.\n- GPORCA instead computes direct dispatch inside its own C++ pipeline while translating its internal `CExpression` plan into DXL:\n - `CTranslatorExprToDXL::GetDXLDirectDispatchInfo()` — `src/backend/gporca/libgpopt/src/translate/CTranslatorExprToDXL.cpp:5274`\n - `CTranslatorExprToDXLUtils::SetDirectDispatchInfo()` / `GetDXLDirectDispatchInfo()` / `GetDXLDirectDispatchInfoRandDist()` — `CTranslatorExprToDXLUtils.cpp:713,858,912`\n - This produces a `CDXLDirectDispatchInfo` node attached to the DXL tree.\n - It's then consumed back on the GPDB side in `src/backend/gpopt/translate/CTranslatorDXLToPlStmt.cpp:269-298`, which sets `slice-\u003edirectDispatch.isDirectDispatch/contentIds` directly on the `PlannedStmt` slice table — bypassing `cdbtargeteddispatch.c` entirely.\n\nSo: same downstream consumer (`PlanSlice.directDispatch` → dispatcher), completely different producer code, in a completely different language/module.\n\n## Where GPORCA lives in the tree\n\n- `src/backend/gporca/` — the optimizer core itself (vendored-style, mostly self-contained C++): `libgpos` (utility/memory layer), `libnaucrates` (DXL representation + serialization), `libgpopt` (the actual optimizer: memo, transformations, physical/logical operators, translate/ subdir for `CExpression → DXL`), `libgpdbcost` (costing).\n- `src/backend/gpopt/` — the GPDB-side glue that bridges Postgres `Query`/`Plan`/relcache to ORCA's DXL world: `translate/` (`CTranslatorDXLToPlStmt.cpp`, `CTranslatorRelcacheToDXL.cpp`, etc.), `relcache/`, `config/`, `utils/`.\n- Entry point from the planner is `optimize_query()` (`src/include/optimizer/orca.h:24`), invoked from `standard_planner()` in `planner.c:378` when `optimizer` GUC is on.\n\n## Relevance to the SP2 direct-dispatch bug you're tracking\n\nThis is exactly what `docs/superpowers/specs/2026-07-17-mixed-partition-heap-zdb-sp2-design.md` §8 (and your last two commits) already documents: the planner-side fix landed in `cdbtargeteddispatch.c`'s `GetContentIdsFromPlanForSingleRelation`, and a parallel guard was added on the ORCA side in `CTranslatorExprToDXLUtils::SetDirectDispatchInfo` (checking `RetrieveRelStorageType() == ErelstorageZDB/ErelstorageMixedPartitioned`). But per your own notes, that ORCA guard only fixed the `EXPLAIN`-visible slice info (`segments:1` → `segments:3`) — execution still dispatches to a single content, meaning there's a **second** ORCA-path decision point not yet located.\n\nGiven what I traced, the most likely place for that second path is inside `CTranslatorDXLToPlStmt.cpp` itself (around lines 269-298, `TranslateDXLDirectDispatchInfo` at line 4881) or something downstream in the dispatcher (`cdbdisp_query.c`) that re-derives target segments independently of `slice-\u003edirectDispatch` — e.g. from `PartitionedRelPruneInfo`/dynamic partition pruning info, or from a cached/stale `RelOptInfo` locus computed earlier in ORCA's own metadata layer (`CTableDescriptor`/`CMDRelationGPDB`) rather than from the DXL node your guard patches. That's the next thing worth instrumenting/breakpointing if you want to chase it down — happy to dig into `TranslateDXLDirectDispatchInfo` and the dispatcher's consumption path next if you want.","input_tokens":1432,"output_tokens":6407,"duration_s":102.902653212,"timed_out":false}} +{"kind":"verdict","task_id":"arch-005","arm":"no-memory","run":1,"verdict":{"score":10,"points_hit":["GPORCA is a separate, independent dispatch decision path from the classic planner's cdbtargeteddispatch.c logic","GPORCA lives under src/backend/gporca/ and is written in C++","The relevant translation logic is CTranslatorExprToDXLUtils::SetDirectDispatchInfo in src/backend/gporca/libgpopt/src/translate/CTranslatorExprToDXLUtils.cpp","That function is what decides whether direct dispatch info gets attached to the DXL plan when GPORCA is used","Because the two paths are independent, a fix applied only to the planner path does not automatically cover the GPORCA path (and vice versa)"],"points_missed":[],"reasoning":"The answer correctly identifies the two independent dispatch paths, locates GPORCA under src/backend/gporca/ in C++, names the exact function and file (CTranslatorExprToDXLUtils::SetDirectDispatchInfo), explains its role, and explicitly notes the two-path independence implication for fixes."}} +{"kind":"run","task_id":"arch-005","arm":"memory","run":0,"result":{"task_id":"arch-005","arm":"memory","result_text":"Now I have a complete, verified picture. Here's the answer:\n\n## Short answer\n\nGPORCA does **not** reuse the classic planner's direct-dispatch code. It has a completely separate path, and that's exactly the gap the SP2 investigation (commit `4f2b84033c5`, doc `docs/superpowers/specs/2026-07-17-mixed-partition-heap-zdb-sp2-design.md`) ran into.\n\n## Where GPORCA lives\n\n- **`src/backend/gporca/`** — the optimizer core itself, vendored as a standalone C++ library (not GPDB-aware): `libgpos` (utility/memory layer), `libgpopt` (the actual cost-based optimizer — memo, transformations, physical operators), `libgpdbcost` (GPDB-specific costing), `libnaucrates` (DXL — the XML-like intermediate plan representation ORCA emits), plus `server/` (unit tests) and `data/dxl/minidump/` (plan test fixtures).\n- **`src/backend/gpopt/`** — the thin GPDB-side glue layer that bridges Postgres/GPDB structures to ORCA's DXL world: `CGPOptimizer.cpp` (entry point), `translate/` (DXL ↔ Plan/relcache translators), `relcache/` (metadata provider), `gpdbwrappers.cpp` (safe wrappers for calling back into PG code from C++).\n\n## Entry point and why it bypasses the planner's direct-dispatch code\n\nIn `standard_planner()` (`src/backend/optimizer/plan/planner.c:368-404`), when ORCA is enabled it calls `optimize_query()` → `COptTasks::GPOPTOptimizedPlan()` (`src/backend/gpopt/CGPOptimizer.cpp:58`), and **if ORCA produces a plan, `standard_planner()` returns immediately** (`planner.c:402-403`). The rest of `standard_planner()` — including the classic planner's slicing/motion machinery in `cdbllize.c` and the direct-dispatch computation in `cdbtargeteddispatch.c` (`GetContentIdsFromPlanForSingleRelation`, called only from `createplan.c`/`cdbmutate.c`) — is never reached for an ORCA-produced plan. That's the code the earlier fix (`src/backend/cdb/cdbtargeteddispatch.c`, commit `7a39c8494b8`) patched, and it's planner-only.\n\nORCA computes its own direct-dispatch decision entirely inside the C++ core, before translation back to a `PlannedStmt`:\n\n- **Decision logic**: `src/backend/gporca/libgpopt/src/translate/CTranslatorExprToDXLUtils.cpp`, function `SetDirectDispatchInfo` — this is the ORCA equivalent of `GetContentIdsFromPlanForSingleRelation`. This is where the partial fix in `4f2b84033c5` added the `ErelstorageZDB`/`ErelstorageMixedPartitioned` guard.\n- **DXL representation**: `libnaucrates/include/naucrates/dxl/operators/CDXLDirectDispatchInfo.h` / `CDXLDirectDispatchInfo.cpp` — the serialized form of the decision, carried on DXL physical nodes.\n- **Translation back to a `Plan`**: `src/backend/gpopt/translate/CTranslatorDXLToPlStmt.cpp` — reads `dxlnode-\u003eGetDXLDirectDispatchInfo()` and sets `slice-\u003edirectDispatch.isDirectDispatch` / `.contentIds` (lines ~269-298, ~2360). This populates the *same* `PlanSlice.directDispatch` struct the classic planner uses, but via an entirely independent code path.\n\n## Why this matters (per the design doc's finding)\n\nThe SP2 doc already confirmed this experimentally: patching `SetDirectDispatchInfo` alone changed `EXPLAIN`'s Gather Motion from `1:1` to `3:1`, but `Test_print_direct_dispatch_info` still showed a single-segment dispatch at execution time and the mixed heap/ZDB tree still silently missed rows. That means the fix in `CTranslatorExprToDXLUtils.cpp` doesn't fully cover ORCA's behavior — there's still an unresolved second decision point somewhere between DXL generation and actual dispatch (the doc's open P1 item: trace how `directDispatch` survives from `CTranslatorDXLToPlStmt` through to whatever the dispatcher actually reads at send time — worth checking `cdbdisp*.c` / slice-table setup for where `PlanSlice.directDispatch.contentIds` is finally consumed, since that consumption path *is* shared between both optimizers). The memory/doc explicitly flags **ORCA as unsafe for mixed heap/ZDB partition trees** until that second path is found — worth confirming this is still the state of the code before relying on it further.","input_tokens":24,"output_tokens":7767,"duration_s":110.575889491,"timed_out":false}} +{"kind":"verdict","task_id":"arch-005","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["GPORCA is a separate, independent dispatch decision path from the classic planner's cdbtargeteddispatch.c logic","GPORCA lives under src/backend/gporca/ and is written in C++","The relevant translation logic is CTranslatorExprToDXLUtils::SetDirectDispatchInfo in src/backend/gporca/libgpopt/src/translate/CTranslatorExprToDXLUtils.cpp","That function is what decides whether direct dispatch info gets attached to the DXL plan when GPORCA is used","Because the two paths are independent, a fix applied only to the planner path does not automatically cover the GPORCA path (and vice versa)"],"points_missed":[],"reasoning":"The answer correctly identifies all five rubric points with precise file paths and function names, correctly locates GPORCA under src/backend/gporca/ in C++, names SetDirectDispatchInfo as the exact translation function, and explicitly explains the independence of the two dispatch paths."}} +{"kind":"run","task_id":"arch-005","arm":"memory","run":1,"result":{"task_id":"arch-005","arm":"memory","result_text":"This confirms the full picture. Here's the answer:\n\n## GPORCA has its own, entirely separate direct-dispatch decision path\n\nThe Postgres-planner path (`cdbtargeteddispatch.c`) and the GPORCA path share **no decision-making code**. They only converge at the very last step, where both populate the same `DirectDispatchInfo` struct (`src/include/nodes/plannodes.h:30`) that the dispatcher (`cdbdisp_query.c` / gang code) consumes to pick target segments.\n\n**Planner path:**\n- `src/backend/cdb/cdbtargeteddispatch.c` computes `DirectDispatchInfo` by walking the classic `Plan` tree's quals (`DirectDispatchUpdateContentIdsFromPlan`, `GetContentIdsFromPlanForSingleRelation`, etc.), hashing distribution-key constants against `GpPolicy`/`cdbhash`.\n\n**GPORCA path** (all in `src/backend/gporca/`, the C++ optimizer library — see it in `libgpopt/`, `libnaucrates/`, `libgpdbcost/`, `libgpos/`):\n1. **Decision + DXL emission**: `libgpopt/src/translate/CTranslatorExprToDXLUtils.cpp` and `CTranslatorExprToDXL.cpp` — this is the ORCA-internal `CExpression` → DXL translator. It collects \"direct-dispatchable filter\" candidates during translation (`AddDirectDispatchableFilterCandidate`, `FDirectDispatchableFilter`) and, at the plan root/DML node, computes the actual dispatch info (`SetDirectDispatchInfo`, `GetDXLDirectDispatchInfo`, `GetDXLDirectDispatchInfoRandDist`, `FDirectDispatchable`), based on `COptCtxt` state (`COptCtxt::PoctxtFromTLS()-\u003eGetDirectDispatchableFilters()`).\n2. **DXL representation**: `libnaucrates/src/operators/CDXLDirectDispatchInfo.cpp` + parser handler `CParseHandlerDirectDispatchInfo.cpp` — this is the on-the-wire/DXL node type (`CDXLDirectDispatchInfo`) that carries the computed dispatch datums.\n3. **Back into Postgres plan nodes**: `src/backend/gpopt/translate/CTranslatorDXLToPlStmt.cpp` (`TranslateDXLDirectDispatchInfo`, around line 4881) reads the DXL node and fills in `slice-\u003edirectDispatch.isDirectDispatch` / segment ids on the `Plan`'s slice table — this is the point where ORCA's result lands in the same struct the planner path also writes.\n\nSo: the bug your recent commits (`7a39c8494b8`, `4f2b84033c5`) were chasing for the classic planner in `cdbtargeteddispatch.c` has a structurally analogous — but code-wise independent — counterpart to check in ORCA: `CTranslatorExprToDXLUtils::FDirectDispatchable` / `SetDirectDispatchInfo`. If the fix needs to cover ORCA-chosen plans too (which is what \"混合树仍未完全解决\" in your latest wip commit message suggests), that's the file to patch, not `cdbtargeteddispatch.c`.\n\n**Where GPORCA itself lives**: `src/backend/gporca/` is a self-contained C++ subtree (its own concourse/cmake build), with:\n- `libgpos/` — low-level OS/memory/utility layer\n- `libnaucrates/` — DXL (the XML-like IR) representation, parsing, statistics\n- `libgpopt/` — the actual optimizer: search space, transforms (`CXform*`), physical/logical operators (`CPhysical*`/`CLogical*`), and the `translate/` subfolder bridging ORCA's internal plan to DXL (and, on the Postgres side, `src/backend/gpopt/translate/` bridges DXL back to a `PlannedStmt`)\n- `libgpdbcost/` — GPDB-specific costing model\n- `server/` — standalone driver/harness for ORCA unit tests and minidumps (the `data/dxl/minidump/*.mdp` files you saw, including several literally named `DirectDispatch-*.mdp`, are ORCA's existing direct-dispatch regression fixtures you could use as a starting point for a repro).","input_tokens":16,"output_tokens":4361,"duration_s":62.834625053,"timed_out":false}} +{"kind":"verdict","task_id":"arch-005","arm":"memory","run":1,"verdict":{"score":10,"points_hit":["GPORCA是与经典规划器cdbtargeteddispatch.c完全独立的直接派发决策路径","GPORCA位于src/backend/gporca/下,使用C++编写","相关翻译逻辑是src/backend/gporca/libgpopt/src/translate/CTranslatorExprToDXLUtils.cpp中的CTranslatorExprToDXLUtils::SetDirectDispatchInfo","该函数决定了在使用GPORCA时是否将direct dispatch信息附加到DXL计划中","两条路径相互独立,因此仅修复planner路径不会自动覆盖GPORCA路径(反之亦然)"],"points_missed":[],"reasoning":"答案准确命中了所有五个得分点:明确指出两条路径独立、GPORCA位于src/backend/gporca/且为C++、精确定位到CTranslatorExprToDXLUtils::SetDirectDispatchInfo函数及其作用,并明确指出修复不会自动覆盖两条路径。未违反任何扣分项。"}} +{"kind":"run","task_id":"fix-001","arm":"no-memory","run":0,"result":{"task_id":"fix-001","arm":"no-memory","result_text":"","input_tokens":60,"output_tokens":30940,"duration_s":420.353750216,"timed_out":false}} +{"kind":"verdict","task_id":"fix-001","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":null,"points_missed":null,"reasoning":"timed out or empty result"}} +{"kind":"run","task_id":"fix-001","arm":"no-memory","run":1,"result":{"task_id":"fix-001","arm":"no-memory","result_text":"","input_tokens":60,"output_tokens":28683,"duration_s":420.449720849,"timed_out":false}} +{"kind":"verdict","task_id":"fix-001","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":null,"points_missed":null,"reasoning":"timed out or empty result"}} +{"kind":"run","task_id":"fix-001","arm":"memory","run":0,"result":{"task_id":"fix-001","arm":"memory","result_text":"## Root cause\n\n**Where:** `src/backend/executor/nodeMaterial.c` (`ExecInitMaterial`, `ExecSquelchMaterial`) and the identical pattern in `src/backend/executor/nodeSort.c` (`ExecInitSort`, `ExecSquelchSort`), interacting with `ExecLimit` (`nodeLimit.c`) and the cross-slice handshake in `nodeShareInputScan.c`.\n\n**The mechanism that's supposed to prevent this hang.** A cross-slice `ShareInputScan` (used to share a CTE/subplan result across slices) synchronizes producer and consumer processes purely through a shared-memory hash table and a `ConditionVariable`, with **no timeout** (`nodeShareInputScan.c:937-1060`): consumers spin in `shareinput_reader_waitready()` until the producer calls `shareinput_writer_notifyready()`, and the producer spins in `shareinput_writer_waitdone()` until every consumer calls `shareinput_reader_notifydone()`. Because `LIMIT` commonly causes the executor to stop pulling tuples *before* a subtree naturally reaches EOF, `ExecLimit()` (`nodeLimit.c`) calls `ExecSquelchNode()` on itself once satisfied, and that call is expected to cascade down and force any `ShareInputScan` to either finish materializing (if it's the producer, `ExecSquelchShareInputScan`'s first branch, `nodeShareInputScan.c:642-658`) or notify the producer it's done (if it's a consumer, the second branch, `:659-674`). This is exactly the same deadlock-avoidance contract `ExecSquelchNode()` documents for `Motion` nodes (`execAmi.c:686-711`): \"tell source QEs not to clog up the pipeline... otherwise it will never send them.\"\n\n**Where the cascade silently breaks.** `ExecSquelchMaterial()` (`nodeMaterial.c:534-550`) and `ExecSquelchSort()` (`nodeSort.c:502-509`) only recurse into their child when `!node-\u003edelayEagerFree`:\n\n```c\nvoid\nExecSquelchMaterial(MaterialState *node)\n{\n if (!node-\u003edelayEagerFree)\n {\n ExecEagerFreeMaterial(node);\n ExecSquelchNode(outerPlanState(node));\n }\n}\n```\n\n`delayEagerFree` is meant to mean \"an ancestor genuinely still might rescan me, so don't discard state.\" But `ExecInitMaterial` (`nodeMaterial.c:246-250, 287-288`) and `ExecInitSort` (`nodeSort.c:287-288, 317-320`) both set it to `true` unconditionally whenever the node's own child is a `Motion`:\n\n```c\nif (node-\u003ecdb_shield_child_from_rescans || IsA(outerPlan((Plan *) node), Motion))\n eflags |= EXEC_FLAG_REWIND;\n...\nmatstate-\u003edelayEagerFree = ((eflags \u0026 (EXEC_FLAG_REWIND|EXEC_FLAG_BACKWARD|EXEC_FLAG_MARK)) != 0);\n```\n(and Sort does the same override again explicitly at `nodeSort.c:317-320`, *after* already computing `delayEagerFree` from the real incoming eflags). This conflates two unrelated things: \"does *my own parent* actually require me to be rescan-capable\" vs. \"do *I* need to keep my Motion child's output around a bit longer.\" A `Material`/`Sort` sitting directly on top of a `Motion` gets `delayEagerFree = true` even when nothing above it will ever rescan it — the code comment at `nodeMaterial.c:243-244` (\"the planner doesn't always set the flag for Material nodes that are put directly on top of Motion nodes\") confirms this is a real, ORCA-generated plan shape, not a hypothetical.\n\n**The hang.** When `LIMIT` is satisfied and squelches the tree, the cascade dies the moment it hits such a Material/Sort: the Motion underneath never gets `ExecSquelchMotion()` → `SendStopMessage()`. The sending QEs, once their interconnect send buffer for that Motion fills up, block forever — they were never told the receiver gave up. If that same blocked backend process is also the producer (or a consumer) for a cross-slice `ShareInputScan` referenced elsewhere in the same query (e.g. the same CTE joined a second way), it can never reach the point where it materializes/notifies-ready or notifies-done, so the peer slice spins forever in `shareinput_reader_waitready()` / `shareinput_writer_waitdone()` — a true, unrecoverable hang (no timeout anywhere in that wait loop).\n\nThis matches the reported symptoms precisely:\n- **Only some plans / only with LIMIT**: requires a plan shape where a rescan-shielding Material/Sort sits directly over a Motion *and* a `LIMIT` (or other early-terminating ancestor) squelches the tree before that Motion drains.\n- **Only some of the time**: depends on data volume/timing — whether the interconnect send buffer actually fills before the abandoned side would have finished anyway.\n\nFor context: this is a *different*, still-open gap from the historical race fixed in `b45797d6136`/`d12caee7220` (\"Fix race condition in CTE reader-writer communication\"), which addressed a squelched consumer prematurely deleting the shared hash-table entry. That fix is present on current HEAD and handles a different failure mode (the reader/writer shared-state lifecycle itself, not squelch-cascade propagation through Material/Sort).\n\n## Proposed fix\n\nStop conflating \"I must shield my Motion child\" with \"my ancestor requires me to be rescan-capable.\" Track the two separately, and only refuse to squelch-propagate when there's a genuine possibility of a future rescan from *above*:\n\nIn `nodeMaterial.c` (`ExecInitMaterial`), capture whether the *incoming* `eflags` already implied rescan before Material ORs in its own Motion-shielding requirement:\n\n```c\nbool ancestor_may_rescan = (eflags \u0026 (EXEC_FLAG_REWIND | EXEC_FLAG_BACKWARD | EXEC_FLAG_MARK)) != 0;\n...\nif (node-\u003ecdb_shield_child_from_rescans || IsA(outerPlan((Plan *) node), Motion))\n eflags |= EXEC_FLAG_REWIND;\nmatstate-\u003edelayEagerFree = ((eflags \u0026 (...)) != 0); /* unchanged, still gates freeing our own cache */\nmatstate-\u003eancestor_may_rescan = ancestor_may_rescan; /* new field on MaterialState */\n```\n\nIn `ExecSquelchMaterial()`:\n\n```c\nvoid\nExecSquelchMaterial(MaterialState *node)\n{\n if (!node-\u003edelayEagerFree)\n ExecEagerFreeMaterial(node);\n\n /*\n * Even if we must keep our own tuplestore (delayEagerFree), if nothing\n * above us actually requires us to remain rescan-capable, this squelch\n * is final -- propagate it so a Motion (or ShareInputScan) below us can\n * unblock its cross-process counterpart instead of hanging forever.\n */\n if (!node-\u003edelayEagerFree || !node-\u003eancestor_may_rescan)\n ExecSquelchNode(outerPlanState(node));\n}\n```\n\nApply the mirror-image change in `nodeSort.c` (`ExecInitSort` / `ExecSquelchSort`), which has the identical `delayEagerFree`-forced-by-Motion-child pattern at `nodeSort.c:287-288, 317-320, 502-509`.\n\nThis is a narrow, low-risk change: it only alters behavior for the specific case where a Material/Sort's *own* Motion-shielding need was the sole reason for `delayEagerFree=true` — the case genuinely required by an ancestor's rescan (e.g., NestLoop-inner mark/restore) is untouched, so no risk of the silent-data-loss regression the current guard was written to avoid.\n\n**Before merging**, I'd want to confirm this with a live repro (build a plan with `EXPLAIN` showing `Materialize`/`Sort` directly over a `Motion`, feeding a query that also references a shared CTE via `ShareInputScan`, under `LIMIT`) and check the stuck backends' stacks with gdb — I traced this from the executor source per this repo's investigation guidelines, but haven't run it against a live cluster, and the exact \"same backend also hosts an SIS role\" coincidence is plan-dependent enough that a repro would nail down whether this is the whole story or one of possibly several contributing gaps (e.g. worth also auditing `ExecSquelchHashJoin`/other join squelch paths for the same class of issue in case they shield a Motion child similarly).","input_tokens":38,"output_tokens":67382,"duration_s":1002.395587578,"timed_out":false}} +{"kind":"verdict","task_id":"fix-001","arm":"memory","run":0,"verdict":{"score":1,"points_hit":[],"points_missed":["未识别根因位于 nodeShareInputScan.c 的 shareinput_Xslice_hash 跨 slice 协调机制","未提及 reader 被 squelch 时可能先移除条目导致 writer 后续找不到并挂起","未提出 release_shareinput_reference 需要区分 squelch 场景的修复方向","未提及新增 debug_shareinput_xslice GUC 的诊断建议"],"reasoning":"答案将根因归结到 nodeMaterial.c/nodeSort.c 的 delayEagerFree 级联传播未能触发 Motion 的 SendStopMessage,进而间接影响 ShareInputScan 的跨进程握手,这与rubric要求的真实根因(shareinput_Xslice_hash 条目被 squelched reader 提前移除、writer 后续找不到条目而挂起)完全不同,属于臆测的替代解释而非验证过的代码路径。虽然文风严谨且承认未经repro验证,但核心诊断与修复方向均未命中rubric要点,因此只给予极低分。"}} +{"kind":"run","task_id":"fix-001","arm":"memory","run":1,"result":{"task_id":"fix-001","arm":"memory","result_text":"","input_tokens":60,"output_tokens":54719,"duration_s":688.683291827,"timed_out":false}} +{"kind":"verdict","task_id":"fix-001","arm":"memory","run":1,"verdict":{"score":0,"points_hit":null,"points_missed":null,"reasoning":"timed out or empty result"}} +{"kind":"run","task_id":"fix-002","arm":"no-memory","run":0,"result":{"task_id":"fix-002","arm":"no-memory","result_text":"## Investigation summary\n\nI traced every receive-path syscall in the interconnect layer (`src/backend/cdb/motion/ic_tcp.c`, `ic_udpifc.c`, `ic_common.c`). The pattern this codebase relies on is: **`errno` must be tested (or saved to a local) in the statement immediately following a failed `recv`/`recvfrom`/`select`/`poll`**, because almost anything else — logging, `CHECK_FOR_INTERRUPTS()`, another syscall — can legally overwrite `errno` even when it \"succeeds\" outwardly (an internal retry after an `EINTR`, a contended mutex, a partial write to a pipe, etc. all leave stray `errno` values behind). Every site that matters follows this except one.\n\n## The bug\n\n`src/backend/cdb/motion/ic_udpifc.c`, function **`rxThreadFunc`**, lines **6243–6289** (the UDP interconnect's background receiver thread):\n\n```c\n6255 read_count = recvfrom(UDP_listenerFd, (char *) pkt, Gp_max_packet_size, 0,\n6256 (struct sockaddr *) \u0026peer, \u0026peerlen);\n6257\n6258 if (pg_atomic_read_u32(\u0026ic_control_info.shutdown) == 1) // atomic load, errno-safe\n6259 { ... break; }\n6260\n6267 if (DEBUG5 \u003e= log_min_messages)\n6268 write_log(\"received inbound len %d\", read_count); // \u003c-- can touch errno\n6269\n6270 if (read_count \u003c 0)\n6271 {\n6274 if (errno == EWOULDBLOCK || errno == EINTR) // \u003c-- tests STALE errno\n6275 continue;\n6277 write_log(\"Interconnect error: recvfrom (%d)\", errno);\n6282 setRxThreadError(errno); // \u003c-- reports STALE errno\n```\n\n`write_log()` (`src/backend/cdb/cdbthreadlog.c:104-222`) calls `gettimeofday()`, `pthread_mutex_lock/unlock(\u0026send_mutex)`, and `vfprintf`+`fflush` on `stderr`. None of these are guaranteed errno-neutral: if the mutex is contended and the underlying futex wait is hit by a signal, or if the `vfprintf`/`fflush` write to `stderr` (which under the logging collector is a pipe to the syslogger) gets interrupted and glibc retries internally, the retry's eventual success doesn't restore the original `errno` — it's left however the last internal syscall inside `write_log()` set it. That stray value is what line 6274 then tests, **not** what `recvfrom()` actually set.\n\nNet effect: when a signal legitimately interrupts the RX thread mid-`recvfrom()` (exactly \"an interrupt arrives while a segment is in the middle of receiving on a socket\") or the socket transiently has nothing to read, the code can take the wrong branch — logging a bogus `\"Interconnect error: recvfrom (%d)\"` with a garbage errno and calling `setRxThreadError()`, which the main thread turns into a hard `ERROR` and aborts the query/motion, even though the connection is fine. This matches \"reads sporadically error out... even though the connection is actually fine\" precisely, and explains the intermittency (it depends on lock contention/pipe backpressure coinciding with the debug log call).\n\nConfirmation this is the *only* violation: the two sibling UDP call sites in the same file already guard against exactly this by saving `errno` immediately:\n- `handleAcks`, `ic_udpifc.c:4297-4299` — `int save_errno = errno;` captured right after `recvfrom()`, *before* `ML_CHECK_FOR_INTERRUPTS()`.\n- `pollAcks`, `ic_udpifc.c:5288-5290` — same pattern after `poll()`.\n- The `poll()` branch inside `rxThreadFunc` itself (lines 6205-6241) is safe too — nothing but an atomic load sits between `poll()` and its errno check.\n\nI also walked the TCP hot path (`readPacket`, `ic_tcp.c:328-464`, plus `readRegisterMessage`, `waitOnOutbound`, `RecvTupleChunkFromAnyTCP`) — every one of those tests `errno` in the literal next statement after `recv()`/`select()`, with nothing intervening (not even under `#ifdef AMS_VERBOSE_LOGGING`, since those debug `elog()` calls sit outside the receive loop). I could not find an equivalent defect on the TCP side; if you have a concrete trace showing this on a TCP-only cluster, it's likely a second, different bug and worth sharing so I can dig further there specifically.\n\n## Proposed fix\n\nIn `rxThreadFunc` (`ic_udpifc.c`), capture `errno` into a local **immediately** after the `recvfrom()` call, before the shutdown check and before the `DEBUG5` logging, then use that saved value everywhere below — mirroring `handleAcks`/`pollAcks`:\n\n```c\nread_count = recvfrom(UDP_listenerFd, (char *) pkt, Gp_max_packet_size, 0,\n (struct sockaddr *) \u0026peer, \u0026peerlen);\nint save_errno = errno; /* capture before anything else can touch errno */\n\nif (pg_atomic_read_u32(\u0026ic_control_info.shutdown) == 1)\n{ ... break; }\n\nif (DEBUG5 \u003e= log_min_messages)\n write_log(\"received inbound len %d\", read_count);\n\nif (read_count \u003c 0)\n{\n skip_poll = false;\n\n if (save_errno == EWOULDBLOCK || save_errno == EINTR)\n continue;\n\n write_log(\"Interconnect error: recvfrom (%d)\", save_errno);\n setRxThreadError(save_errno);\n continue;\n}\n```\n\nOnly `ic_udpifc.c` needs to change — no changes needed to `ic_tcp.c`, `ic_common.c`, or `cdbthreadlog.c`. Want me to apply this edit?","input_tokens":36,"output_tokens":26091,"duration_s":489.884548694,"timed_out":false}} +{"kind":"verdict","task_id":"fix-002","arm":"no-memory","run":0,"verdict":{"score":3,"points_hit":["同类errno-after-side-effecting-call这一C语言陷阱的一般性描述与识别原则被正确阐述"],"points_missed":["未定位到真正的bug点:rubric指出bug在handleAcks()中errno在ML_CHECK_FOR_INTERRUPTS()之后被读取,但agent反而明确认定handleAcks已经是安全的(save_errno在ML_CHECK_FOR_INTERRUPTS()之前捕获),从而完全排除了正确的bug位置","未提出handleAcks()的修复方案(因为误判其已安全)","未在ic_tcp.c的readPacket()中发现/修复同类问题,反而明确断言TCP侧未发现该缺陷,与rubric要求的fix相反","整体结论指向rxThreadFunc而非handleAcks,这是rubric未提及的错误定位,属于对bug核心事实的误判"],"reasoning":"Agent正确把握了errno-after-call这一通用C陷阱的原理,但对具体bug位置的判断与rubric完全相反:rubric要求的bug点(handleAcks中ML_CHECK_FOR_INTERRUPTS后读取errno)被agent明确认定为‘安全’,转而错误地将bug定位到rxThreadFunc的write_log调用上,且未能在ic_tcp.c的readPacket中发现同类问题,因此未命中大部分给分点。"}} +{"kind":"run","task_id":"fix-002","arm":"no-memory","run":1,"result":{"task_id":"fix-002","arm":"no-memory","result_text":"## Findings\n\n**Root cause:** `errno` gets read *after* other function calls have run since the failing syscall returned, instead of being snapshotted immediately. Any of those intervening calls (atomic load, logging, etc.) is free to clobber `errno` even on a normal/successful path, so a legitimate `EINTR`/`EWOULDBLOCK` gets misread as a hard socket error — even though the connection is perfectly healthy.\n\nThis is exactly issue **#1841** (\"Improper use of errno\"), which already got a partial fix in `handleAcks()`, `xmit_retry`, and `pollAcks()` in `ic_udpifc.c` (commit `cf630f5eaf1`, still present in HEAD — those correctly do `int save_errno = errno;` right after the syscall). But the fix missed the actual **data-receive** path, and a follow-up attempt at fixing `ic_tcp.c`'s `readPacket()` (same commit) introduced a *second* bug that was later reverted (`7e714adde36`), wiping out the TCP-side change entirely.\n\n### The live bug: `rxThreadFunc()` in `src/backend/cdb/motion/ic_udpifc.c` (~line 6170-6289)\n\nThis is the UDP interconnect's background receive thread — the actual \"receiving data on a socket\" path for UDP.\n\n```c\nread_count = recvfrom(UDP_listenerFd, (char *) pkt, Gp_max_packet_size, 0,\n (struct sockaddr *) \u0026peer, \u0026peerlen);\n\nif (pg_atomic_read_u32(\u0026ic_control_info.shutdown) == 1) // intervening call #1\n{ ... break; }\n\nif (DEBUG5 \u003e= log_min_messages)\n write_log(\"received inbound len %d\", read_count); // intervening call #2 — unconditional on error\n\nif (read_count \u003c 0)\n{\n skip_poll = false;\n if (errno == EWOULDBLOCK || errno == EINTR) // \u003c-- errno may already be stale here\n continue;\n write_log(\"Interconnect error: recvfrom (%d)\", errno);\n setRxThreadError(errno);\n continue;\n}\n```\n\n`write_log()` isn't gated on `read_count \u003c 0` — it fires on every packet, success or failure, whenever the log level is verbose enough. If a signal interrupts `recvfrom()` (returns `-1`/`EINTR`) and `write_log()`'s internal work (formatting, timestamping, the `write()` itself) resets `errno`, the code falls into the \"real error\" branch, logs a bogus `Interconnect error: recvfrom` and calls `setRxThreadError()` — poisoning the connection even though nothing is actually wrong. This explains the \"sporadic\" nature: it's latent at all times but only reliably triggers once verbose/debug logging is enabled (a classic Heisenbug support engineers hit while investigating).\n\nThe `poll()` error check just above (~line 6211-6236) has the same shape (`pg_atomic_read_u32` runs between `poll()` and the `errno == EINTR` check), though it's lower risk there since `write_log` in that spot is gated behind the `shutdown==1` branch (which `break`s before ever reaching the `errno` test).\n\n### Why \"both TCP and UDP\"\n\nThe TCP path (`readPacket()` in `ic_tcp.c`) currently checks `errno` immediately after `recv()`/`select()` with nothing in between, so it isn't presently exhibiting this bug — but it's the same function that was touched by the original #1841 fix attempt and its revert, so it's the natural place to apply the same defensive fix and prevent regression, especially since the file already uses the `save_errno` idiom elsewhere in this same codebase for exactly this reason.\n\n## Proposed fix\n\n1. **`ic_udpifc.c` `rxThreadFunc()`** — snapshot `errno` right after each syscall, before any other statement:\n ```c\n read_count = recvfrom(...);\n int recv_errno = errno;\n ...\n if (read_count \u003c 0)\n {\n skip_poll = false;\n if (recv_errno == EWOULDBLOCK || recv_errno == EINTR)\n continue;\n write_log(\"Interconnect error: recvfrom (%d)\", recv_errno);\n setRxThreadError(recv_errno);\n continue;\n }\n ```\n Same treatment for the `poll()` call a few lines above (`int poll_errno = errno;` immediately after `n = poll(...)`, then use `poll_errno` in the `if (n \u003c 0)` block).\n\n2. **`ic_tcp.c` `readPacket()`** — reinstate `save_errno`, but avoid the regression from `cf630f5eaf1`: give the `select()` retry its **own** fresh snapshot rather than reusing the outer `recv()`'s `save_errno`:\n ```c\n if ((n = recv(...)) \u003c 0)\n {\n int save_errno = errno;\n if (save_errno == EINTR) continue;\n if (save_errno == EWOULDBLOCK)\n {\n ...\n n = select(...);\n int select_errno = errno; // \u003c-- fresh capture, not save_errno\n if (n == 0 || (n \u003c 0 \u0026\u0026 select_errno == EINTR)) continue;\n else if (n \u003c 0) { ereport(ERROR, ... errdetail(\"... : %m\")); }\n }\n else { ereport(ERROR, ...); }\n }\n ```\n\nBoth changes mirror the pattern already used correctly in `handleAcks()`/`xmit_retry`/`pollAcks()` in the same file. Want me to apply these edits?","input_tokens":60,"output_tokens":36920,"duration_s":453.107145904,"timed_out":false}} +{"kind":"verdict","task_id":"fix-002","arm":"no-memory","run":1,"verdict":{"score":8,"points_hit":["Bug identified in ic_udpifc.c involving errno-after-side-effecting-call pattern","Correctly explains the general C gotcha that errno must be captured immediately after the syscall before other calls can clobber it","Same pattern class flagged and fixed in ic_tcp.c readPacket()","Fix proposes saving errno to a local variable immediately after the syscall and comparing against that instead of global errno"],"points_missed":["Rubric specifies the bug is in handleAcks() specifically, but the agent instead located the live bug in rxThreadFunc() (claiming handleAcks() was already fixed by a prior commit) — this is a different function than the rubric's target, so the core localization doesn't match","Agent asserts ic_tcp.c readPacket() currently has no bug and applies the fix only defensively/for regression prevention, whereas rubric expects it to be identified as having the actual same-class bug"],"reasoning":"The agent correctly diagnosed the general errno-clobbering bug class and proposed the right style of fix (save_errno pattern) in both ic_udpifc.c and ic_tcp.c, matching much of the rubric's spirit, but it pinpointed the live bug in rxThreadFunc() rather than handleAcks() as the rubric specifies, and claimed the TCP path is currently bug-free rather than affirmatively buggy — both are meaningful deviations from the exact rubric answer despite strong technical reasoning."}} +{"kind":"run","task_id":"fix-002","arm":"memory","run":0,"result":{"task_id":"fix-002","arm":"memory","result_text":"## Root cause\n\nThe bug is in `rxThreadFunc()` in `src/backend/cdb/motion/ic_udpifc.c` (lines 6255–6289) — the UDP interconnect's background receive thread. The TCP path (`readPacket()` / `RecvTupleChunkFromAnyTCP()` in `ic_tcp.c`) is clean; only the UDP `recvfrom()` branch has the hazard.\n\n```c\n6255 read_count = recvfrom(UDP_listenerFd, (char *) pkt, Gp_max_packet_size, 0,\n6256 (struct sockaddr *) \u0026peer, \u0026peerlen);\n6257\n6258 if (pg_atomic_read_u32(\u0026ic_control_info.shutdown) == 1) // safe: plain volatile load\n...\n6267 if (DEBUG5 \u003e= log_min_messages) // \u003c-- runs BEFORE errno check\n6268 write_log(\"received inbound len %d\", read_count);\n6269\n6270 if (read_count \u003c 0)\n6271 {\n6272 skip_poll = false;\n6273\n6274 if (errno == EWOULDBLOCK || errno == EINTR) // reads possibly-clobbered errno\n6275 continue;\n6276\n6277 write_log(\"Interconnect error: recvfrom (%d)\", errno);\n6278 setRxThreadError(errno); // latches a permanent error\n```\n\n`recvfrom()` sets `errno` on failure, but before it's checked at line 6274, a conditional `write_log(...)` call at 6267–6268 can run. `write_log()` (`cdbthreadlog.c`) internally calls `gettimeofday`, `pthread_mutex_lock/unlock`, `vfprintf`, and `fflush(stderr)` — none of which are guaranteed to preserve `errno`. If a signal legitimately interrupts `recvfrom()` (real `EINTR`, connection fine) and that intervening `write_log()` clobbers `errno`, the check at line 6274 fails, and execution falls through to `setRxThreadError(errno)`. That function does a compare-exchange-from-zero into `ic_control_info.eno` (`ic_udpifc.c:1139`), so the wrong error code *sticks* — the main thread's `checkRxThreadError()` (`ic_udpifc.c:1115`) later reads it and does `ereport(ERROR, \"interconnect encountered an error\")`, aborting the whole query even though the peer connection was fine. This matches the reported symptom exactly.\n\nThe `write_log` is gated on `DEBUG5 \u003e= log_min_messages` (DEBUG5=10 in `elog.h`, so it only fires when interconnect debug logging is turned all the way up) — which is why it's *sporadic*: it only manifests when verbose debug logging is enabled, ironically often exactly when someone is investigating interconnect flakiness.\n\nBy contrast, every other receive-path errno check in the same file does it correctly — `handleAcks()` (~line 4299) and `pollAcks()` (~line 5290) both do `int save_errno = errno;` as the very first statement after the syscall, before any other call, and branch on `save_errno` thereafter. `rxThreadFunc()`'s `recvfrom` branch is the one outlier that re-reads the live `errno` global after intervening calls.\n\n## Proposed fix\n\nIn `rxThreadFunc()`, `contrib`-independent, in `src/backend/cdb/motion/ic_udpifc.c`:\n\n1. Immediately after `read_count = recvfrom(...)` (line 6255), add `int save_errno = errno;` before *any* other statement (including the shutdown check and the debug `write_log`).\n2. Replace the bare `errno` reads at lines 6274, 6277, and 6287 with `save_errno`.\n3. Apply the same defensive pattern to the `poll()` branch just above (lines 6211–6237) for consistency/future-proofing — it's not currently exploitable (`pg_atomic_read_u32` doesn't touch `errno`), but it's the same shape and one future edit (e.g. adding a debug log there too) would silently reintroduce the bug.\n\nThis mirrors the existing `save_errno` idiom already used elsewhere in `ic_udpifc.c`, so it's a minimal, consistent fix rather than a new pattern.\n\nI haven't applied this yet — let me know if you want me to make the edit.","input_tokens":34,"output_tokens":16220,"duration_s":220.605619607,"timed_out":false}} +{"kind":"verdict","task_id":"fix-002","arm":"memory","run":0,"verdict":{"score":3,"points_hit":["This is a general C gotcha: errno must be captured immediately after the call that sets it, before any other library/macro call that could reset it","Fix pattern proposed (save errno into local variable immediately after failing syscall) matches the correct fix mechanism, though applied to wrong location"],"points_missed":["Bug is in handleAcks(), not rxThreadFunc() — agent explicitly misidentified handleAcks() as already correct","Did not identify ML_CHECK_FOR_INTERRUPTS() as the errno-clobbering call","Did not correctly identify the TCP analog (readPacket) as needing the same fix — agent claimed TCP path is 'clean'","Core diagnosis (write_log under DEBUG5 clobbering errno) is a different, likely incorrect root cause than the rubric's ML_CHECK_FOR_INTERRUPTS() explanation"],"reasoning":"The agent misidentified the buggy function entirely (rxThreadFunc's recvfrom/write_log instead of handleAcks' ML_CHECK_FOR_INTERRUPTS), incorrectly claimed the TCP path is clean when the rubric says it has the same bug, and thus got the specific diagnosis wrong despite correctly articulating the general errno-clobbering gotcha and proposing the right style of fix (save_errno)."}} +{"kind":"run","task_id":"fix-002","arm":"memory","run":1,"result":{"task_id":"fix-002","arm":"memory","result_text":"","input_tokens":60,"output_tokens":26424,"duration_s":347.895906651,"timed_out":false}} +{"kind":"verdict","task_id":"fix-002","arm":"memory","run":1,"verdict":{"score":0,"points_hit":null,"points_missed":null,"reasoning":"timed out or empty result"}} +{"kind":"run","task_id":"fix-003","arm":"no-memory","run":0,"result":{"task_id":"fix-003","arm":"no-memory","result_text":"## Root cause\n\n**What changed:** commit `09a6167597f` (\"back up pg_stat to heap table\", the ancestor of `pg_stat_backup.c`) turned the stats collector from PostgreSQL's classic \"fire-and-forget UDP listener\" into a **fully participating backend**. `PgstatCollectorMain()` (`src/backend/postmaster/pgstat.c:5642`) now calls `InitProcess()` and `InitPostgres(\"postgres\", ...)`, so the collector gets a real `PGPROC`, joins the `ProcArray`, and — critically — `InitProcess()` calls `SwitchToSharedLatch()` (`src/backend/storage/lmgr/proc.c:627`), which repoints `MyLatch` from the process-**local** latch to the **shared** `MyProc-\u003eprocLatch`.\n\nThat repointing matters because the collector's main loop still waits like this (`pgstat.c:5964-5967`):\n\n```c\nwr = WaitLatchOrSocket(MyLatch,\n WL_LATCH_SET | WL_POSTMASTER_DEATH | WL_SOCKET_READABLE,\n pgStatSock, -1L, /* \u003c-- infinite timeout, non-Windows */\n WAIT_EVENT_PGSTAT_MAIN);\n```\n\nWith a **local** latch (the pre-existing design), `-1L` is safe: the only things that can ever set that latch are the collector's own signal handlers running in-process (`pgstat_sighup_handler`, `pgstat_exit`), plus new UDP traffic on `pgStatSock`. Every wakeup path is guaranteed to fire.\n\nOnce the latch became **shared**, arbitrary other backends can legitimately call `SetLatch(\u0026collectorProc-\u003eprocLatch)` as part of normal Postgres machinery the collector now participates in as a real backend (heavyweight-lock/group-XID wakeups, condition variables, ProcSignal-based interrupts, etc.). Cross-process `SetLatch()` (`src/backend/storage/ipc/latch.c:490-491`) does **not** touch the target's self-pipe directly — it only does `kill(owner_pid, SIGUSR1)` and relies on the target's own `SIGUSR1` handler to write to its self-pipe (`latch_sigusr1_handler()`, `latch.c:1594`), which is the only thing that can actually break the target out of its blocking `epoll_wait()/poll()`.\n\nThe bug (tracked as **#1846**): at the point `PgstatCollectorMain` was converted to use `InitProcess()`, the signal setup was left untouched:\n\n```c\npqsignal(SIGUSR1, SIG_IGN);\n```\n\n`SIG_IGN` means the kernel discards the signal before it can ever interrupt the blocking syscall — no `EINTR`, nothing written to the self-pipe. The shared `Latch.is_set` flag does get flipped by the setter, but the collector, already parked in `WaitLatchOrSocket(..., -1L, ...)`, never notices. It only ever wakes up again by pure chance, when some other backend happens to send a new stats UDP datagram on `pgStatSock`. If a backend is meanwhile blocked waiting on something that only resolves once the collector runs its next loop iteration (e.g. it's queued behind a heavyweight lock the collector holds during the `relyt_stats_backup` catalog DML in `BackupPgStat()` → `DoBackup()` → `UpdateRelytStatsBackup()`, `contrib/zdb/src/storage/pg_stat_backup.c:314-375`, which takes `RowExclusiveLock` via `heap_open()`), that backend can wait indefinitely — exactly the \"block indefinitely\" symptom.\n\n## Current state\n\nThis exact bug was already partially fixed on this branch by commit `34fbfd18354` (\"fix #1846: process SIGUSR1 in pgstat after InitPostgres()\", already an ancestor of HEAD): it adds `pgstat_sigusr1_handler()` calling `latch_sigusr1_handler()` and installs it in place of `SIG_IGN`. That closes the specific hole above.\n\n## Remaining gap / proposed hardening\n\nThe design is still fragile because it depends on *every* future code path that can set the collector's shared latch reliably delivering and handling `SIGUSR1` — a single regression (or a signal-blocked critical section at the wrong instant) reproduces the same indefinite hang, and there's no self-healing. Two concrete changes:\n\n1. **`src/backend/postmaster/pgstat.c`, `PgstatCollectorMain()` main loop (~line 5964-5967):** replace the infinite `-1L` wait with a bounded one, mirroring the Windows branch immediately below it that already uses `WL_TIMEOUT` with a 2000ms cap for the analogous \"missed wakeup\" problem:\n ```c\n wr = WaitLatchOrSocket(MyLatch,\n WL_LATCH_SET | WL_POSTMASTER_DEATH | WL_SOCKET_READABLE | WL_TIMEOUT,\n pgStatSock, 2 * 1000L,\n WAIT_EVENT_PGSTAT_MAIN);\n ```\n This turns \"hang forever\" into \"self-corrects within ~2s\" for any wakeup that gets lost for whatever reason, at negligible CPU cost — it's the standard defense-in-depth pattern PostgreSQL itself uses for latch+socket loops.\n\n2. **`contrib/zdb/src/storage/pg_stat_backup.c`, `UpdateRelytStatsBackup()`/`BackupPgStat()`:** the collector now takes a genuine heavyweight `RowExclusiveLock` on `relyt_stats_backup` as part of ordinary transactional DML, but never bounds how long it will wait for that lock (no `lock_timeout`/`statement_timeout` is set for the collector — only `default_transaction_isolation`/`synchronous_commit` are overridden in `PgstatCollectorMain`). If a backend holds a conflicting lock on that table (DDL/migration, `pg_dump`, etc.) the collector itself can queue behind it before it ever reaches the `WaitLatchOrSocket` step, stalling all stats processing for that node for as long as the other lock is held. Set a short `lock_timeout` (e.g. via `SetConfigOption(\"lock_timeout\", ...)` next to the existing overrides in `PgstatCollectorMain`) and treat lock-timeout errors the same way `BackupPgStat()`'s existing `PG_CATCH` already treats other errors (abort + skip this cycle, retry next interval) so the collector never sits blocked on a foreign lock instead of servicing the stats socket.\n\nTogether these two changes remove both remaining sources of an unbounded wait: the collector's own event loop, and the lock it takes during the backup step.","input_tokens":922,"output_tokens":40104,"duration_s":519.37861952,"timed_out":false}} +{"kind":"verdict","task_id":"fix-003","arm":"no-memory","run":0,"verdict":{"score":10,"points_hit":["The bug is in src/backend/postmaster/pgstat.c, in PgstatCollectorMain()","The collector process explicitly ignores SIGUSR1 (pqsignal(SIGUSR1, SIG_IGN)) based on an old assumption that it only ever uses a local (non-shared) latch","Once the collector calls InitProcess()/InitPostgres() and starts using shared latches, it must handle SIGUSR1 to receive latch wakeups","Fix: install a real SIGUSR1 handler (pgstat_sigusr1_handler) that calls latch_sigusr1_handler(), replacing the SIG_IGN registration","The handler must save/restore errno around the call, consistent with the collector's other signal handlers"],"points_missed":[],"reasoning":"The agent correctly identified the exact root cause, mechanism, and fix matching all rubric points, and noted the fix was already applied on the branch while proposing reasonable additional hardening."}} +{"kind":"run","task_id":"fix-003","arm":"no-memory","run":1,"result":{"task_id":"fix-003","arm":"no-memory","result_text":"## Root cause\n\nThe hang traces to `PgstatCollectorMain()` in `src/backend/postmaster/pgstat.c` (introduced by commit `09a6167597f \"back up pg_stat to heap table\"`, which turned the stats collector into a full backend via `InitProcess()`/`InitPostgres()` so it could run real transactions for `BackupPgStat`).\n\nCurrent order in `PgstatCollectorMain()` (`src/backend/postmaster/pgstat.c:5680-5741`):\n\n```c\nSetProcessingMode(InitProcessing);\nBaseInit();\nInitProcess();\nInitPostgres(\"postgres\", InvalidOid, NULL, InvalidOid, NULL, false); // \u003c-- runs BLOCKED\n\nif (sigsetjmp(local_sigjmp_buf, 1) != 0) { ... }\nPG_exception_stack = \u0026local_sigjmp_buf;\nPG_SETMASK(\u0026UnBlockSig); // \u003c-- unblocked too late\n```\n\nPostmaster children are forked while the postmaster holds `BlockSig` (all signals blocked except a few that must never be blocked — `SIGTRAP`/`SIGABRT`/`SIGILL`/`SIGFPE`/`SIGSEGV`; see `pqinitmask()` in `src/backend/libpq/pqsignal.c:41`). Crucially, **both `SIGUSR1` and `SIGALRM` are included in `BlockSig`**. That means, right now, `InitPostgres()` executes with those signals still blocked, before the `PG_SETMASK(\u0026UnBlockSig)` call.\n\n`InitPostgres()` (`src/backend/utils/init/postinit.c`) does real, lockable work against shared catalogs — `LockSharedObject(DatabaseRelationId, MyDatabaseId, ...)` and `RelationCacheInitializePhase3()` (which takes `AccessShareLock` on numerous catalog relations). If any other backend momentarily holds a conflicting lock on one of those (e.g. a catalog `ALTER`/`VACUUM FULL`/`REINDEX`, or contention on `pg_database`), the collector enters `ProcSleep()` → `WaitLatch(MyLatch, WL_LATCH_SET | WL_EXIT_ON_PM_DEATH, 0, ...)` (`src/backend/storage/lmgr/proc.c:1571`) — note the **`0` timeout**, i.e. no built-in time bound. The only ways out are:\n\n1. Another backend releasing the lock and calling `SetLatch()` on the collector's `procLatch` → delivered via `SIGUSR1`.\n2. The deadlock-check timer (`enable_timeout_after(DEADLOCK_TIMEOUT, ...)`) firing → delivered via `SIGALRM`.\n\nBoth signals are blocked at this point, so neither wakeup can be delivered. The collector is stuck forever — even after the conflicting lock is released — because the pending signal only gets delivered once the mask is lifted, and the code that lifts the mask (`PG_SETMASK(\u0026UnBlockSig)`) never runs until the very call that's stuck (`InitPostgres()`) returns. That's a real deadlock, not just a slow wakeup, and it explains \"sometimes\" — it only bites when something else happens to hold a conflicting catalog lock at the moment the collector (re)starts. Once the collector is wedged like this, its `relyt_stats_backup`/pgstat responsibilities never run again, so any backend that later needs a conflicting lock on state the collector would otherwise touch (or that calls `CountOtherDBBackends`, etc.) queues up behind it and never progresses either — matching the reported symptom.\n\nThe already-applied commit `34fbfd18354 \"fix #1846: process SIGUSR1 in pgstat after InitPostgres()\"` fixed the *handler disposition* (`SIG_IGN` → real handler) but didn't fix the *mask ordering*, so the hang can still occur during the `InitPostgres()` window.\n\n### Confirming this against the codebase's own convention\n\nEvery other place in this tree that calls a \"real\" (specific-database) `InitPostgres()` unblocks signals **first**:\n- `PostgresMain()` (`src/backend/tcop/postgres.c:5259`, comment: *\"We need to allow SIGINT, etc during the initial transaction\"*) — `PG_SETMASK(\u0026UnBlockSig)` immediately after `InitProcess()`, then `InitPostgres()`.\n- `AutoVacWorkerMain()` (`src/backend/postmaster/autovacuum.c:1767` then `:1880`) — same order: unblock, then `InitPostgres(NULL, dbid, ...)` for the real target database.\n\n(`AutoVacLauncherMain()` is the one exception, but its `InitPostgres(NULL, InvalidOid, ...)` call doesn't attach to a real database, so it carries negligible lock-contention risk — that's not a valid model to have followed here.)\n\n## Proposed fix\n\nIn `src/backend/postmaster/pgstat.c`, reorder `PgstatCollectorMain()` so the `sigsetjmp`/`PG_exception_stack`/`PG_SETMASK(\u0026UnBlockSig)` block runs **before** `InitPostgres()`, matching `PostgresMain()`/`AutoVacWorkerMain()`:\n\n```c\n#ifndef EXEC_BACKEND\n\tInitProcess();\n#endif\n\n\tif (sigsetjmp(local_sigjmp_buf, 1) != 0)\n\t{\n\t\terror_context_stack = NULL;\n\t\tHOLD_INTERRUPTS();\n\t\tEmitErrorReport();\n\t\tproc_exit(0);\n\t}\n\n\t/* We can now handle ereport(ERROR) */\n\tPG_exception_stack = \u0026local_sigjmp_buf;\n\n\t/* Must unblock signals (SIGUSR1/SIGALRM) before InitPostgres(), which can\n\t * wait on catalog locks; otherwise a lock-wait there can never be woken. */\n\tPG_SETMASK(\u0026UnBlockSig);\n\n\tInitPostgres(\"postgres\", InvalidOid, NULL, InvalidOid, NULL, false);\n\n\t/* Force default_transaction_isolation ... */\n\t...\n\tSetProcessingMode(NormalProcessing);\n```\n\nThis is a pure reordering (no new logic), it doesn't touch the already-correct `pgstat_sigusr1_handler` fix, and it brings the collector's startup sequence in line with the two other backend-startup paths in this same file tree that connect to a real database.\n\nWant me to apply this edit?","input_tokens":2091,"output_tokens":41824,"duration_s":513.614384193,"timed_out":false}} +{"kind":"verdict","task_id":"fix-003","arm":"no-memory","run":1,"verdict":{"score":2,"points_hit":[],"points_missed":["The bug is in PgstatCollectorMain() ignoring SIGUSR1 via pqsignal(SIGUSR1, SIG_IGN)","The old assumption about local-only latch usage","Fix: install pgstat_sigusr1_handler calling latch_sigusr1_handler(), replacing SIG_IGN","Handler must save/restore errno consistent with pgstat_sighup_handler"],"reasoning":"The agent explicitly claims the SIGUSR1 handler fix was 'already applied' by a prior commit and instead proposes an unrelated signal-mask reordering fix, directly contradicting the rubric's actual root cause and fix (installing pgstat_sigusr1_handler in place of SIG_IGN). None of the required points are hit; the answer is confidently wrong."}} +{"kind":"run","task_id":"fix-003","arm":"memory","run":0,"result":{"task_id":"fix-003","arm":"memory","result_text":"All confirmed against current code. Here's the investigation and fix.\n\n## Root cause\n\nVanilla PostgreSQL guarantees the stats collector never takes heavyweight locks and can never block — that invariant was broken here.\n\n**The chain:**\n\n1. `PgstatCollectorMain()` (`src/backend/postmaster/pgstat.c:5694-5697`) now calls `InitProcess()` + `InitPostgres()`, turning the collector into a real backend with a PGPROC entry and full lock-manager participation — needed so the ZDB stats-backup hooks can run real `heap_open()`/catalog transactions against `relyt_stats_backup`.\n2. Two call sites now run catalog transactions from inside the collector with **no `lock_timeout`**:\n - Periodic backup, once per `relyt.pg_stat_back_up_interval` (default 60s), in the collector's main loop: `pgstat.c:5794-5807` → `BackupPgStat_hook`.\n - Startup restore, `pgstat.c:6569-6574` → `RestorePgStatFromBackup_hook`, run *before* the collector enters its message-receive loop when the stats file is missing (`ENOENT`).\n3. Both hooks resolve to `BackupPgStat()` / `RestorePgStatFromBackup()` in `contrib/zdb/src/storage/pg_stat_backup.c:207-257`, which call `heap_open(get_relname_relid(RELYT_STATS_BACKUP_TABLE, ...), RowExclusiveLock/AccessShareLock)` at lines 335/388/448 — an ordinary blocking `LockAcquire`, no timeout.\n\n**Why backends hang, not just the collector:** if any session holds (or is *waiting* for) a conflicting lock on `relyt_stats_backup` — idle-in-transaction, `TRUNCATE`/`ALTER`/`VACUUM FULL`/`REINDEX`, a hot-upgrade step — the collector's `heap_open()` blocks indefinitely in `LockAcquire`. This isn't a cycle, so `CheckDeadLock()` never fires. PostgreSQL's heavyweight-lock queue is FIFO to prevent writer starvation: once the collector is queued waiting for an incompatible lock, every later request on that table — even plain `AccessShareLock` reads from unrelated backends — queues behind it too. The whole table (and anything using it, e.g. a stats-backup step) serializes on a request that will never be granted.\n\nCommit `34fbfd18354` (#1846, \"process SIGUSR1 in pgstat after InitPostgres()\") only fixed latch-wakeup plumbing (SIGUSR1 had been `SIG_IGN` for a process that now needs cross-process latch wakeups) — it does not bound the lock wait, so the hang can still occur.\n\n## Proposed fix\n\n**File:** `contrib/zdb/src/storage/pg_stat_backup.c`\n\nIn `BackupPgStat()` (line 207) and `RestorePgStatFromBackup()` (line 233): add `#include \"storage/proc.h\"` (declares `extern PGDLLIMPORT int LockTimeout;`, default 0 = disabled) and bound the wait:\n\n```c\nstatic bool\nBackupPgStat(List *dbs)\n{\n\tint old_lock_timeout = LockTimeout;\n\tbool ret = false;\n\n\tif (IS_QUERY_HOTSTANDBY() || !gPgStatBackupEnable)\n\t\treturn ret;\n\n\tLockTimeout = 3000; /* bound catalog lock wait; collector must never block forever */\n\n\tPG_TRY();\n\t{\n\t\tStartTransactionCommand();\n\t\tret = DoBackup(dbs);\n\t\tCommitTransactionCommand();\n\t}\n\tPG_CATCH();\n\t{\n\t\tHOLD_INTERRUPTS();\n\t\tEmitErrorReport();\n\t\tAbortOutOfAnyTransaction();\n\t\tFlushErrorState();\n\t\tRESUME_INTERRUPTS();\n\t}\n\tPG_END_TRY();\n\n\tLockTimeout = old_lock_timeout;\n\treturn ret;\n}\n```\n\nSame pattern in `RestorePgStatFromBackup()`. This requires no new error-handling: a `LockTimeout` expiry raises `ERROR(\"canceling statement due to lock timeout\")`, which the existing `PG_CATCH` already absorbs into `ret = false`.\n- Periodic-backup caller (`pgstat.c:5804-5805`) already treats `false` as `elog(WARNING, ...)` and just retries on the next interval — no behavior change needed there.\n- Startup-restore caller (`pgstat.c:6570-6572`) already treats `false` as \"skip the backup-restore optimization,\" falling through to normal from-scratch stats init — also safe as-is.\n\nSo it's a pure bounded-wait addition — no new states to handle.\n\n**Longer-term (optional, not required for correctness):** move the backup work off the collector process entirely into a bgworker, following the existing pattern in `contrib/zdb/src/storage/zdb_async_job_bgworker.c`. That fully restores vanilla Postgres's \"collector never blocks\" invariant instead of merely bounding the blocked time to a few seconds.\n\nWant me to go ahead and apply the `LockTimeout` fix to `pg_stat_backup.c`?","input_tokens":1586,"output_tokens":3737,"duration_s":55.17884693,"timed_out":false}} +{"kind":"verdict","task_id":"fix-003","arm":"memory","run":0,"verdict":{"score":2,"points_hit":[],"points_missed":["bug is in PgstatCollectorMain() SIGUSR1 handling","collector ignores SIGUSR1 via SIG_IGN","shared latch wakeup requirement after InitProcess/InitPostgres","fix: install pgstat_sigusr1_handler calling latch_sigusr1_handler","errno save/restore in handler"],"reasoning":"The agent misidentified the root cause as an unbounded heap_open lock wait in pg_stat_backup.c rather than the actual SIGUSR1/SIG_IGN latch-wakeup bug in PgstatCollectorMain(); it even explicitly dismissed the real fix (commit about SIGUSR1) as insufficient, directly contradicting the rubric's required answer. None of the rubric's answer points are hit."}} +{"kind":"run","task_id":"fix-003","arm":"memory","run":1,"result":{"task_id":"fix-003","arm":"memory","result_text":"Confirmed. Here's the analysis.\n\n## Root cause\n\nCommit `09a6167597f` (#1192, \"back up pg_stat to heap table\") changed `PgstatCollectorMain()` (`src/backend/postmaster/pgstat.c:5694-5697`) to call `InitProcess()` + `InitPostgres()`. That turns the stats collector into a *real* backend that participates in the lock manager — breaking vanilla PostgreSQL's invariant that the collector never takes heavyweight locks and therefore can never block.\n\nTwo call sites now run real heap-catalog transactions with **no timeout**:\n\n- **Periodic backup**, `pgstat.c:5794-5807` (main loop): calls `BackupPgStat_hook` → `BackupPgStat()` in `contrib/zdb/src/storage/pg_stat_backup.c:208`\n- **Startup restore**, `pgstat.c:6571` (before the collector enters its message loop, only when the stats file is missing): calls `RestorePgStatFromBackup_hook` → `RestorePgStatFromBackup()` at `pg_stat_backup.c:234`\n\nBoth wrap `StartTransactionCommand()` around a call that does `heap_open(relyt_stats_backup, ...)`:\n- `UpdateRelytStatsBackup()` — `pg_stat_backup.c:335` (`RowExclusiveLock`)\n- `GetRelytStatsBackupByDBID()` — `pg_stat_backup.c:388` (`AccessShareLock`)\n- `RestoreRelytStatsFromBackup()` — `pg_stat_backup.c:448` (`AccessShareLock`)\n\nNone of these set `lock_timeout`/`statement_timeout`. If any other session holds a conflicting lock on `relyt_stats_backup` — idle-in-transaction, `TRUNCATE`/`ALTER`/`VACUUM FULL`/`REINDEX`, a hot-upgrade step — the collector's `heap_open()` blocks in `LockAcquire` indefinitely. It's not a deadlock cycle, so `CheckDeadLock()` never fires.\n\n**Why this freezes *other* backends, not just the collector:** PostgreSQL's heavyweight lock manager grants FIFO — a later request, even a *compatible* one (e.g. `AccessShareLock`), must still queue behind an earlier *incompatible* waiter to prevent writer starvation. So once the collector is stuck waiting on `relyt_stats_backup`, every other backend that subsequently wants any lock on that table (including plain reads) queues behind it too. That's the \"backends blocked on a stats-backup step\" symptom — they're not IPC-waiting on the collector directly, they're stuck in the lock queue behind the collector's stuck request.\n\nNote: commit `34fbfd18354` (#1846, \"process SIGUSR1 in pgstat after InitPostgres()\") only fixed latch-wakeup plumbing (SIGUSR1 had been left `SIG_IGN` for a process that now needs cross-process latch wakeups) — it doesn't bound the lock wait, so the hang can still occur depending on lock-contention timing.\n\n## Proposed fix\n\nIn `contrib/zdb/src/storage/pg_stat_backup.c`:\n\n1. `#include \"storage/proc.h\"` for `extern PGDLLIMPORT int LockTimeout;` (declared `proc.h:385`, default `0` = disabled).\n2. In `BackupPgStat()` (`pg_stat_backup.c:208`) and `RestorePgStatFromBackup()` (`pg_stat_backup.c:234`), save the old `LockTimeout`, set a short bound (e.g. 3000–5000ms) before `StartTransactionCommand()`, and restore the old value after `PG_END_TRY()`.\n\n```c\nstatic bool\nBackupPgStat(List *dbs)\n{\n bool ret = false;\n int old_lock_timeout = LockTimeout;\n\n if (IS_QUERY_HOTSTANDBY() || !gPgStatBackupEnable)\n return ret;\n\n LockTimeout = 3000; /* ms; bound the collector's lock wait */\n\n PG_TRY();\n {\n StartTransactionCommand();\n ret = DoBackup(dbs);\n CommitTransactionCommand();\n }\n PG_CATCH();\n {\n HOLD_INTERRUPTS();\n EmitErrorReport();\n AbortOutOfAnyTransaction();\n FlushErrorState();\n RESUME_INTERRUPTS();\n }\n PG_END_TRY();\n\n LockTimeout = old_lock_timeout;\n return ret;\n}\n```\n(same pattern in `RestorePgStatFromBackup()`)\n\nA `LockTimeout` expiry raises `ERROR: canceling statement due to lock timeout`, which the existing `PG_TRY`/`PG_CATCH` + `AbortOutOfAnyTransaction()`/`FlushErrorState()` already absorbs into `ret = false` — no new error path needed:\n- periodic-backup caller (`pgstat.c:5801`) already does `elog(WARNING, ...)` and retries next interval;\n- startup-restore caller (`pgstat.c:6570-6573`) already treats `false` as \"no backup available\" and falls through to normal from-scratch stats init, which is safe.\n\nThis is a pure bounded-wait addition — turns an indefinite freeze into a bounded warning-and-retry, with no behavior change on the non-contended path (`LockTimeout` defaults to 0/disabled otherwise, so this only takes effect while the collector holds it set).\n\n**Longer-term (not required for correctness):** move the backup/restore off the collector process entirely into a bgworker, following the pattern already in `contrib/zdb/src/storage/zdb_async_job_bgworker.c`. That fully restores vanilla Postgres's \"collector never blocks\" invariant instead of just bounding the blocking window.\n\nWant me to implement this fix?","input_tokens":16,"output_tokens":4070,"duration_s":78.776123697,"timed_out":false}} +{"kind":"verdict","task_id":"fix-003","arm":"memory","run":1,"verdict":{"score":3,"points_hit":["The collector process's SIGUSR1 handling is mentioned (references commit 34fbfd18354 fixing SIGUSR1/latch plumbing)"],"points_missed":["Does not identify PgstatCollectorMain() SIG_IGN on SIGUSR1 as the root cause of the hang being investigated; instead treats it as an already-fixed prior issue and proposes an unrelated LockTimeout fix for a different (heap-lock) hang scenario","Does not propose installing pgstat_sigusr1_handler calling latch_sigusr1_handler as the fix","Does not mention errno save/restore pattern consistent with pgstat_sighup_handler"],"reasoning":"The agent explicitly dismisses the SIGUSR1/latch fix as already resolved and orthogonal, and instead diagnoses a different root cause (unbounded heavyweight lock wait) with a different fix (LockTimeout), missing the rubric's core claim entirely."}} +{"kind":"run","task_id":"fix-004","arm":"no-memory","run":0,"result":{"task_id":"fix-004","arm":"no-memory","result_text":"I traced and verified the cleanup path directly in the source (not just from the initial scan). Here's the confirmed root cause and a concrete fix proposal.\n\n## Pipeline (confirmed)\n\n```\nDROP TABLE → SetFlagForDroppingTable (zdb_aocsam.c:6020) writes a WORK-space tombstone in FDB\n ↓ (driven by autovacuum, contrib/zdb/src/zdb.c:5695 → VacuumDroppedRelations)\nzdb_vacuum.c:7213 VacuumDroppedRelations()\n ├─ BackupDroppedRelations() → GetDroppedRelations(WORK) → DispatchBackupTaskForDroppedTable\n └─ GCDroppedRelations() → GetDroppedRelations(BACKUP) → DispatchGCTaskForDroppedTable\n```\n\nBoth stages loop `for (shardid = 0; shardid \u003c totalShardNum; ++shardid)`, where `totalShardNum = GetZDBShardNumByRelfilenode(relfilenode)` (`zdb_shard_stats.c:537`) — this just scans `zdb_shard_stats` **rows currently present** for that relfilenode and returns `max(shardid)+1` (verified at `zdb_shard_stats.c:560-573`; the scan key is only on `relfilenode`, it doesn't care about `relid`).\n\nThere's deliberately no retention window here (`deletedBackupInterval/deletedGCInterval = 0`, hardcoded at `zdb_vacuum.c:6696`/`:6977`) — that part is intentional (fixed previously in commit `d3aedae9cfd`), so it's not the bug.\n\n## Root cause: a one-way \"shard-count ratchet\" in the BACKUP post-commit path\n\nIn the autovacuum post-commit hook, the BACKUP-task branch (`zdb_vacuum.c:2962-3011`) does this **every round**:\n\n```c\n// zdb_vacuum.c:2974-2986\nif (!OidIsValid(currentVacuumTaskTxnRelid) \u0026\u0026 OidIsValid(currentVacuumTaskTxnRelfilenode))\n{\n RemoveZDBShardStatsByRefilenode(currentVacuumTaskTxnRelfilenode, false); // unconditional\n}\nfor (int i = 0; i \u003c currentTaskTxnShardStatsNum; ++i)\n UpdateZDBShardStatsByBackupTask(\u0026currentTaskTxnShardStats[i]); // only reported shards\n```\n\n`RemoveZDBShardStatsByRefilenode(relfilenode, false)` (`zdb_shard_stats.c:382-429`) deletes **all** rows for that relfilenode with `relid \u003e InvalidOid` — i.e. every shard's leftover \"live\" stats row from before the drop — regardless of which shards actually succeeded this round. `currentTaskTxnShardStats[]` is populated in `HandleBackupTaskResultForDroppedTable` (`zdb_vacuum.c:6758-6858`) strictly from QE results with `PGRES_TUPLES_OK`/`PGRES_COMMAND_OK` (`:6783-6794` — any other status just `elog(WARNING); continue`s, silently dropping that shard for this round).\n\n**Net effect:** on the first backup round after a DROP, if any single shard's dispatch/report fails for an ordinary transient reason (segment blip, gmeta hiccup, timeout) — more likely on a large, multi-shard table needing many rounds to drain — its old row is deleted and **never reinserted**, because nothing else ever recreates a shard_stats row except a successful round for that exact shard. If the shard that's lost happens to be the current highest shard id, `GetZDBShardNumByRelfilenode` permanently returns a smaller `totalShardNum` next round, so `GenerateBackupTaskForDroppedTable`'s loop (`zdb_vacuum.c:6725`) never iterates that shard index again. GC mirrors the same `totalShardNum` computation, so it never looks for that shard's BACKUP-space files either. gmeta correctly refuses to clear the tombstone while files remain (this is the invariant `d3aedae9cfd` established), so `GetDroppedRelations` re-surfaces the relfilenode **forever** — matching exactly the \"large table, lingers indefinitely\" symptom.\n\nThe GC-task branch (`zdb_vacuum.c:3013-3057`) does **not** have this flaw — it only deletes shard_stats rows once `CheckDroppedRelationsExist` confirms via gmeta that both FDB spaces are truly empty (`:3033-3038`). That's the safe pattern the BACKUP branch should follow.\n\n## Proposed fix\n\nThe blanket delete exists because `UpdateZDBShardStatsByBackupTask` looks up rows by `(relid=InvalidOid, relfilenode, shardid)` (`zdb_shard_stats.c:232-237`), so it can never find/replace the *old* row still keyed by the real pre-drop `relid` — hence the \"delete everything, then reinsert only what we have\" approach. The fix is to scope the delete to exactly the shards being reinserted this round, not the whole relfilenode:\n\n1. Add a shard-scoped variant, e.g. `RemoveZDBShardStatsByRefilenodeAndShard(Oid relFileNode, int16 shardid, bool dropped)` in `zdb_shard_stats.c`, by adding a third `ScanKeyInit` on `Anum_zdb_shard_stats_shardid` to the existing `RemoveZDBShardStatsByRefilenode` scan (`zdb_shard_stats.c:404-422`).\n2. In `zdb_vacuum.c:2974-2986`, drop the unconditional relfilenode-wide `RemoveZDBShardStatsByRefilenode(..., false)` call. Instead, inside the `for (int i = 0; i \u003c currentTaskTxnShardStatsNum; ++i)` loop, call the new per-shard delete for `currentTaskTxnShardStats[i].shardid` immediately before `UpdateZDBShardStatsByBackupTask(\u0026currentTaskTxnShardStats[i])`.\n\nThis means a shard whose round fails simply keeps its old (stale-relid) row untouched — `GetZDBShardNumByRelfilenode` still counts it (it doesn't filter by relid), so it's retried next round exactly like today's \"no throttle\" design intends, and gets properly converted to a `relid=InvalidOid` row once it actually succeeds.\n\n## Suggested regression test\nAdd a case alongside `contrib/zdb/regress/sql/backup_and_gc/drop_table_backup_segfile_leak.sql` that uses a fault injector to force one shard's BACKUP dispatch to return an error on round 1 (while others succeed), then asserts `GetZDBShardNumByRelfilenode`-driven scheduling still eventually drains that shard instead of shrinking `totalShardNum` and abandoning it.\n\nWant me to implement this (the new scoped delete function + the call-site change + the regression test)?","input_tokens":16,"output_tokens":14323,"duration_s":883.800568442,"timed_out":false}} +{"kind":"verdict","task_id":"fix-004","arm":"no-memory","run":0,"verdict":{"score":4,"points_hit":["Relevant code correctly identified in contrib/zdb/src/storage/zdb_vacuum.c","Acknowledges the retention window (deletedBackupInterval/deletedGCInterval=0) is already set correctly and not the bug"],"points_missed":["Did not identify GenerateBackupTaskForDroppedTable() and GenerateGCTaskForDroppedTable() or the schedule-interval throttle as the root cause per rubric","Did not propose dropping the schedule-interval check so any shard without a RUNNING task is always dispatched","Invented a completely different root cause (shard-stats ratchet/RemoveZDBShardStatsByRefilenode) not aligned with the rubric's expected answer, with fabricated-sounding line numbers and commit hashes not verifiable as grounded in actual investigation","Did not flag that the true tombstone/key-lifecycle root cause lies in gmeta and should not be guessed at — instead asserts gmeta 'correctly refuses to clear the tombstone' as an established fact without flagging uncertainty, violating the gotcha condition"],"reasoning":"The agent found the right file and correctly noted the retention window was already fixed, but proposed an entirely different (and seemingly fabricated, with suspiciously precise but unverifiable line numbers and a specific commit hash) root cause instead of the rubric's schedule-interval throttle fix, and confidently asserted gmeta-side behavior as fact rather than flagging it as unverified, violating the gotcha condition."}} +{"kind":"run","task_id":"fix-004","arm":"no-memory","run":1,"result":{"task_id":"fix-004","arm":"no-memory","result_text":"","input_tokens":60,"output_tokens":12400,"duration_s":211.667123154,"timed_out":false}} +{"kind":"verdict","task_id":"fix-004","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":null,"points_missed":null,"reasoning":"timed out or empty result"}} +{"kind":"run","task_id":"fix-004","arm":"memory","run":0,"result":{"task_id":"fix-004","arm":"memory","result_text":"## Summary\n\n**Root cause:** In `contrib/zdb/src/storage/zdb_vacuum.c`, the per-shard BACKUP/GC task result handlers for dropped tables — `HandleBackupTaskResultForDroppedTable` (~line 6757) and `HandleGCTaskResultForDroppedTable` (~line 7037) — only handled a segment task failure by logging a warning and `continue`-ing:\n\n```c\nelog(WARNING, \"Execute BACKUP task failed for dropped table %u with status %d\", ...);\ncontinue;\n```\n\nThat shard's `VacuumTask` never got transitioned out of `RUNNING`. `FAILED` was only ever assigned in the `PG_CATCH()` block, which fires solely on a thrown C exception — not on a segment cleanly returning a bad `PGresult` (e.g. a transient error, timeout, or segment restart).\n\nSince `GenerateBackupTaskForDroppedTable`/`GenerateGCTaskForDroppedTable` only re-schedule a shard once its `RUNNING` task disappears (they check `GetZDBVacuumTask(..., RUNNING, ...)`), a single stuck shard permanently blocked retry for that shard — forever, since there's no RUNNING-task timeout. That in turn keeps the FDB drop tombstone (gated by an emptiness check in `gmeta`'s `ZdbClearDropTable`) from ever clearing for the *whole relation*, so both the Parquet files and FDB metadata linger indefinitely — matching the reported symptom, and disproportionately hitting large (multi-shard) tables since they have more chances for one shard to hit a transient failure.\n\nConfirmed via a working precedent in the same file: the MERGE-task handler (~line 4490) already does this correctly, tracking `allSuccess` and calling `UpdateZDBVacuumTaskListStatus(mergeTasks, FAILED)` on any clean failure — the dropped-table handlers were just missing the equivalent branch.\n\n**Fix applied** (`contrib/zdb/src/storage/zdb_vacuum.c`): added an `else UpdateZDBVacuumTaskStatus(task, FAILED)` branch in both handlers' per-shard loops, mirroring the MERGE pattern. Once a shard's task is `FAILED` rather than stuck `RUNNING`, the next autovacuum cycle's `Generate*ForDroppedTable` sees it as no-longer-running and reschedules it — restoring retry instead of permanent orphaning. This is a minimal, low-risk change (2 small additions) that doesn't touch the retention-window logic (which is correctly hardcoded to 0 for dropped tables already) or the FDB-side safety gate (which correctly still refuses to clear the tombstone until data is actually gone).\n\n**Gap worth closing separately:** none of the existing tests in `contrib/zdb/regress/sql/backup_and_gc/` inject a segment-side task failure — only happy-path fault-injector suspend/resume scenarios exist, which is why this went unnoticed. Worth adding a regression test (e.g. a new fault-injector point forcing one shard's dropped-table BACKUP or GC task to fail) to verify it now retries instead of stalling. Want me to add that test, or build/run the existing `backup_and_gc` suite to confirm nothing regressed?","input_tokens":34,"output_tokens":14020,"duration_s":751.204518854,"timed_out":false}} +{"kind":"run","task_id":"fix-004","arm":"memory","run":1,"result":{"task_id":"fix-004","arm":"memory","result_text":"## Investigation\n\nThe dropped-table cleanup path is (in `contrib/zdb/src/storage/zdb_vacuum.c` unless noted):\n\n`DROP TABLE` → `heap_drop_with_catalog` → ZDB hook → `ClearMetaForDroppingTable` (`zdb_aocsam.c`) → `SetFlagForDroppingTable` → `DropTableRequest` → gmeta `GMetaZdbDropTable`, which writes **one tombstone key per relfilenode** in FDB WORK space (not per-shard).\n\nThat tombstone doesn't get cleared until BACKUP and GC have run to completion on **every shard** of the dropped relation:\n- `VacuumDroppedRelations` (~7212) is driven off autovacuum's `do_autovacuum()` tail.\n- `GenerateBackupTaskForDroppedTable` / `GenerateGCTaskForDroppedTable` (~6686, and the GC counterpart) build a `couldSchedule[shard]` array and set `couldSchedule[shard] = false` for any shard that still has a `RUNNING` `VacuumTask` row — with no timeout, so a shard only becomes re-schedulable once its RUNNING task disappears.\n- `deletedBackupInterval`/`deletedGCInterval` are hardcoded to `0` for dropped tables (correctly — no rollback window needed once dropped), so the retention window itself was not the culprit.\n\n**Root cause**: `HandleBackupTaskResultForDroppedTable` (~6757) and `HandleGCTaskResultForDroppedTable` (~7037) process per-shard dispatch results. When a segment's `PGresult` status isn't `PGRES_TUPLES_OK`/`PGRES_COMMAND_OK`, the original code just did `elog(WARNING, ...); continue;` and never called `UpdateZDBVacuumTaskStatus(task, FAILED)`. `FAILED` was only ever set from the `PG_CATCH()` block, which only fires on a thrown C-level exception — not a cleanly-returned bad `PGresult`. So a single shard that failed cleanly (not via exception) left its task stuck at `RUNNING` forever, which permanently blocked `couldSchedule[shard]` from ever going back to `true` for that shard — stalling BACKUP/GC, and therefore the tombstone-clear (and `zdb_shard_stats`/`zdb_backup_gc_records` row purge via `ZDBAfterVacuumCommit`/`CheckDroppedRelationsExist`), for the **whole table** indefinitely. Bigger, more shard-heavy tables are more likely to hit at least one flaky/failing shard, which matches \"large tables in particular.\"\n\nThe correct pattern already existed for the MERGE-task handler (~4490-4548): it tracks per-shard success and explicitly marks failed shards `FAILED` so they get retried next cycle.\n\n## Fix\n\nAdd the missing `else` branch mirroring the MERGE-task pattern in both dropped-table result handlers, so a cleanly-failed shard's task transitions out of `RUNNING` and becomes eligible for retry on the next autovacuum cycle:\n\n- `HandleBackupTaskResultForDroppedTable` (~6837-6848): added `else { UpdateZDBVacuumTaskStatus(task, FAILED); }`.\n- `HandleGCTaskResultForDroppedTable` (~7096-7101): same, mirrored.\n\nThis fix is **already applied** as an uncommitted change in your working tree (`git diff contrib/zdb/src/storage/zdb_vacuum.c` shows it) — it looks like this exact bug was tracked down and patched in an earlier session today. The change is minimal and correctly scoped; I re-verified the surrounding `couldSchedule`/`GetZDBVacuumTask(..., RUNNING, ...)` logic and the fix closes the gap as intended.\n\n**Not yet done**: there's no regression test in `contrib/zdb/regress/sql/backup_and_gc/` that fault-injects a segment-side task failure for a dropped table (existing tests only cover happy-path suspend/resume) — that gap is why this went unnoticed. Worth adding one that forces a shard's dropped-table BACKUP/GC task to fail and asserts it retries instead of hanging.\n\nWant me to write that fault-injector regression test, or commit the existing fix?","input_tokens":18,"output_tokens":3693,"duration_s":64.296581801,"timed_out":false}} +{"kind":"run","task_id":"fix-005","arm":"no-memory","run":0,"result":{"task_id":"fix-005","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":3.737184962,"timed_out":false}} +{"kind":"run","task_id":"fix-005","arm":"no-memory","run":1,"result":{"task_id":"fix-005","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.759142904,"timed_out":false}} +{"kind":"run","task_id":"fix-005","arm":"memory","run":0,"result":{"task_id":"fix-005","arm":"memory","result_text":"","input_tokens":413,"output_tokens":10573,"duration_s":166.910557309,"timed_out":false}} +{"kind":"verdict","task_id":"fix-005","arm":"memory","run":0,"verdict":{"score":0,"points_hit":null,"points_missed":null,"reasoning":"timed out or empty result"}} +{"kind":"run","task_id":"fix-005","arm":"memory","run":1,"result":{"task_id":"fix-005","arm":"memory","result_text":"## Where the list lives\n\nThe exclusion list is the catalog table **`pg_catalog.relyt_non_dispatched_func`**, populated by `INSERT` statements in the `contrib/relyt/relyt--*.sql` upgrade scripts. The current tail-end registration is in `contrib/relyt/relyt--3.42.sql:4114-4124` (comment literally says *\"NOTICE: EXTENSION UPGRADE NEED ADD NEW FUNCTION INTO PG_CATALOG.RELYT_NON_DISPATCHED_FUNC TO AVOID DISPATCHING TO EDPS\"*):\n\n```sql\nINSERT INTO pg_catalog.relyt_non_dispatched_func SELECT oid, proname, pronamespace, pronargs, prorettype, proargtypes\nFROM pg_catalog.pg_proc\nWHERE proname = 'get_relyt_platform_userinfo' or ... or proname = 'log' or proname = 'regexp_matches'\nON CONFLICT DO NOTHING;\n```\n\n`regexp_matches` **is already in this list** — it's not a missing-entry problem.\n\nThe list is consulted at runtime by `FuncDispatchIsRestricted(Oid procOid)` in `contrib/zdb/src/storage/zdb_syntax_check.c:2006-2063`, which looks the OID up in `relyt_non_dispatched_func` (and separately flags any non-`pg_catalog`/`information_schema`/`gp_toolkit` function as restricted too).\n\nThat check is invoked from `RangeVarWalker` in `contrib/zdb/src/dispatch/dispatcher.c:409-423`, which walks every `FuncExpr` in the query and increments `ctx-\u003erestricted_funcs` when `FuncDispatchIsRestricted()` returns true.\n\n## The actual bug\n\n`GetPlanType()` (`contrib/zdb/src/dispatch/dispatcher.c:482-639`) captures that counter into `numRestrictedFunc` but only *checks* it in one branch — the no-table, function-only case:\n\n```c\n// line 629\nif (totalTables == 0 \u0026\u0026 numRestrictedFunc == 0 \u0026\u0026 numTotalFunc \u003e 0)\n return PT_ONLY_CAN_DISPATCHED_FUNC;\n```\n\nEvery other return path ignores it:\n- **SELECT over a full ZDB tablescan** — line 633-634: `if (totalTables \u003e 0 \u0026\u0026 dispatchTableCnt == totalTables) return PT_FULL_ZDB_TABLESCAN;` — unconditional, no `numRestrictedFunc` check.\n- **UPDATE/DELETE** — line 512-513: `PT_DML_CAN_DISPATCH` returned based only on `dispatchTableCnt == totalTables \u0026\u0026 realTimeTables == 0 \u0026\u0026 gEnableDMLDispatch`.\n- **INSERT ... SELECT** — line 517-589: doesn't even thread `context-\u003erestricted_funcs` into `numRestrictedFunc` in this branch at all.\n\nThose three plan types (`PT_FULL_ZDB_TABLESCAN`, `PT_DML_CAN_DISPATCH`, `PT_IS_INSERT_SELECT`) are exactly the ones that trigger the swift/EDPS dispatch decision at `dispatcher.c:1286-1289`. So `SELECT regexp_matches(col, pattern) FROM some_zdb_table` (or the same call inside an UPDATE/DELETE/INSERT-SELECT over ZDB tables) walks past the restricted-function guard because a table is present, gets classified `PT_FULL_ZDB_TABLESCAN`, and is dispatched to swift/EDPS anyway — even though `regexp_matches` is correctly flagged as restricted.\n\n## Minimal fix\n\nGate the three overlooked return points on `numRestrictedFunc == 0`, and fix the INSERT branch to actually populate `numRestrictedFunc` from `context-\u003erestricted_funcs` (it currently doesn't capture it at all):\n\n- `dispatcher.c:512` — `if (dispatchTableCnt == totalTables \u0026\u0026 realTimeTables == 0 \u0026\u0026 gEnableDMLDispatch \u0026\u0026 numRestrictedFunc == 0)`\n- `dispatcher.c:524-529` (INSERT branch) — add `numRestrictedFunc = context-\u003erestricted_funcs;`, then check it alongside the existing conditions at line 554/559/584\n- `dispatcher.c:633` — `if (totalTables \u003e 0 \u0026\u0026 dispatchTableCnt == totalTables \u0026\u0026 numRestrictedFunc == 0)`\n\nFalling through when restricted functions are present already routes to `PT_NON_ZDB_TABLESCAN`/`PT_PARTIAL_ZDB_TABLESCAN`, which `dispatcher.c:1286-1289` never dispatches to swift — so the fallback path already exists and works, it just needs to be reached.\n\nWant me to implement this fix?","input_tokens":16,"output_tokens":5092,"duration_s":353.956097129,"timed_out":false}} +{"kind":"run","task_id":"gotcha-001","arm":"no-memory","run":0,"result":{"task_id":"gotcha-001","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.749804959,"timed_out":false}} +{"kind":"run","task_id":"gotcha-001","arm":"no-memory","run":1,"result":{"task_id":"gotcha-001","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.6641877919999999,"timed_out":false}} +{"kind":"run","task_id":"gotcha-001","arm":"memory","run":0,"result":{"task_id":"gotcha-001","arm":"memory","result_text":"This is a known gotcha with this repo — it's actually documented in CLAUDE.md's pre-build checklist, and your symptoms match it exactly.\n\n**Root cause:** Rust's incremental build cache for `delta-kernel-rs` can short-circuit and skip regenerating/copying the FFI header (`delta_kernel_ffi.h`) even after a rebase pulls in new delta-kernel-rs commits, because Rust's `target/` cache thinks nothing changed. But the C side (`zdb_deltalake_export.c`) has already been updated upstream to reference new enum variants/types (like `PrimitiveType`) that only exist in the new header. `neon_build_extensions.sh` does call `delta-kernel-rs/relyt/build.sh`, but it doesn't force a rebuild, so the stale header/lib stick around. That gives you:\n- **Compile time**: `PrimitiveType* undeclared` (old header still installed)\n- **Runtime**: `Create write context failed [errno: -1, msg: Schema inconsistent]` (old `.so` still installed, schema enum mismatch between Rust and C)\n\n`git submodule update --init --recursive` alone won't fix this — it updates the submodule checkout, but not the *built/installed* header and lib, which is what's stale.\n\n**Fix — force-refresh the installed artifacts:**\n```bash\ncd /workspace/phoenix\nrm -f contrib/zdb/third_party/install/include/delta_kernel_ffi.h\nrm -f contrib/zdb/third_party/install/lib/libdelta_kernel_ffi.so\nsource /opt/rh/gcc-toolset-11/enable\n(cd contrib/zdb/third_party \u0026\u0026 ./delta-kernel-rs/relyt/build.sh release install)\n```\n\nThen rerun your full Neon build (`sh neon/cicd/build.sh`). That should pick up the freshly regenerated header and resolve both the compile-time `PrimitiveType` error and the runtime schema-inconsistent error.","input_tokens":2,"output_tokens":1146,"duration_s":17.129380833,"timed_out":false}} +{"kind":"run","task_id":"gotcha-001","arm":"memory","run":1,"result":{"task_id":"gotcha-001","arm":"memory","result_text":"These files are dated Jun 16 — stale relative to your rebase. Run:\n\n```bash\nrm -f contrib/zdb/third_party/install/include/delta_kernel_ffi.h\nrm -f contrib/zdb/third_party/install/lib/libdelta_kernel_ffi.so\nsource /opt/rh/gcc-toolset-11/enable\n(cd contrib/zdb/third_party \u0026\u0026 ./delta-kernel-rs/relyt/build.sh release install)\n```\n\nThen rerun the full build (`sh neon/cicd/build.sh`). This forces cargo to actually regenerate and reinstall the header/lib rather than trusting its incremental cache, so the C side picks up whatever new enum variants/schema changes landed upstream.\n\nIf it still fails after that, check whether `contrib/zdb/third_party/delta-kernel-rs`'s submodule pointer actually moved in your rebase (`git submodule status contrib/zdb/third_party/delta-kernel-rs` before/after) — if it didn't move at all, the bug is more likely a genuine mismatch between `zdb_deltalake_export.c` and the Rust crate version rather than a stale-cache issue, and worth flagging rather than just re-forcing the build.","input_tokens":4,"output_tokens":1218,"duration_s":20.086726338,"timed_out":false}} +{"kind":"run","task_id":"gotcha-002","arm":"no-memory","run":0,"result":{"task_id":"gotcha-002","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.746015987,"timed_out":false}} +{"kind":"run","task_id":"gotcha-002","arm":"no-memory","run":1,"result":{"task_id":"gotcha-002","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.644330941,"timed_out":false}} +{"kind":"run","task_id":"gotcha-002","arm":"memory","run":0,"result":{"task_id":"gotcha-002","arm":"memory","result_text":"For a \"nonexistent relation\" OID in a ZDB regression test, don't just grab a small round number — a few things can bite you:\n\n**What to pick:** Use a large constant clearly outside any range the test run could ever allocate — something like `2147483647` (INT32_MAX, still a valid `oid` since `oid` is unsigned) or `4000000000`. Cast it explicitly as `::oid` (or whatever the function's actual parameter type is), not `::regclass` — if the parameter type is `regclass`, PostgreSQL validates the OID exists *during the cast itself* and throws `ERROR: relation with OID ... does not exist` before your function ever runs. That may be the exact behavior you want to test, but if you're trying to exercise the *function's own* not-found handling, you need a raw `oid`/`int4` literal so the cast succeeds and the function gets to do its own lookup.\n\n**What to avoid:**\n\n1. **Small OIDs (\u003c 16384 / `FirstNormalObjectId`)** — these are reserved for objects created by `initdb` (system catalogs, types, etc.). A hardcoded small number like `1`, `100`, or `1259` risks accidentally *matching a real system object* rather than testing \"doesn't exist,\" making the test meaningless or non-portable across PG/GPDB versions.\n2. **OID `0`** — that's `InvalidOid`, and many code paths special-case it as \"not specified\" rather than \"not found,\" so it exercises a different branch than a genuine nonexistent-but-well-formed OID.\n3. **Assuming \"OID\" means `pg_class.oid`** — verify this against the actual C function signature first. Per this repo's own testing conventions (see CLAUDE.md's regression-test guidance), several ZDB introspection UDFs (`zdb_get_metainfo_for_file`, `zdb_get_metainfo_for_drop_flag`, `zdb_get_metainfo_for_visimap`) key off **`relfilenode`**, not `pg_class.oid` — those are different values. If the function you're testing is one of these, double-check which one the parameter actually is before hardcoding anything.\n4. **Test-order fragility in a shared schedule** — this repo's regress schedule runs tests against a shared cluster/FDB instance, and OIDs are handed out from a single ascending counter (in GPDB, synced across coordinator + segments). A fixed huge constant is safe in practice because the counter only grows from wherever the cluster started — it won't ever climb into the billions during a test run — but don't pick something \"currently unused\" that's only slightly larger than what today's schedule happens to allocate, since adding tests later could eventually reach it.\n\n**More robust alternative:** if you don't need a literal for a byte-exact expected `.out` file, derive the OID dynamically instead of hardcoding — e.g. create a throwaway table, capture its OID, then drop it (guarantees non-existence right up until the moment you use it, and guarantees no collision regardless of what else the shared schedule has allocated). If you do need a fixed literal for deterministic expected output, add a one-line comment explaining why the constant is assumed safe.","input_tokens":6,"output_tokens":4382,"duration_s":61.288005916,"timed_out":false}} +{"kind":"run","task_id":"gotcha-002","arm":"memory","run":1,"result":{"task_id":"gotcha-002","arm":"memory","result_text":"That's enough confirmation of the general pattern in this codebase (`OidIsValid` short-circuits before real lookups). I have everything needed to answer directly.\n\n## What to hardcode\n\nUse a large, clearly-out-of-range literal — something like `999999999`. Concretely:\n\n```sql\nselect * from zdb_get_metainfo_for_file(relid := 999999999);\n```\n\nBefore committing the test, verify it's actually free right now (`select relname from pg_class where oid = 999999999;` should return 0 rows), and `grep -rn 999999999 contrib/zdb/regress/` to make sure no other test in the schedule reserves that same value.\n\n## Things to be careful about\n\n1. **Don't use `0`.** Several of these introspection functions (`zdb_get_metainfo_for_file` etc.) declare the parameter as `IN relid oid = 0`, and this codebase's C side generally treats `0`/`InvalidOid` as a distinct sentinel checked via `OidIsValid()` *before* any real catalog/FDB lookup happens (see the `OidIsValid(relid)` early-return pattern in `relyt_external_schema.c`). Testing with `0` may exercise that early-return branch instead of the actual \"relation not found\" path you're trying to cover — those are different code paths with potentially different behavior.\n\n2. **Don't cast a made-up name to `::regclass`.** If you write `'no_such_table'::regclass`, PostgreSQL itself raises `relation \"no_such_table\" does not exist` at parse/lookup time, before your function ever runs — you'd be testing Postgres's name resolution, not your function's OID-not-found handling. Since these functions take a bare `oid` argument (not `regclass`), pass the raw integer literal directly.\n\n3. **Avoid OIDs below `FirstNormalObjectId` (16384 in PG12).** Values in that range are reserved for bootstrap/system catalog objects and could coincidentally match a real system OID, giving you a false \"found\" result instead of exercising the not-found path.\n\n4. **Watch for collisions with this suite's other explicit-OID tests.** `sql/create_with_oid.sql` already hardcodes specific OIDs (`123456`, `234567`, `234568`) via `WITH (tableoid=...)`. Since `isolation2test` is shared across the whole schedule (per the test-isolation notes in CLAUDE.md), pick a value far from anything already claimed elsewhere, and grep for it to confirm no other test explicitly assigns that same OID later in the run — GPDB's OID counter only increases, but an explicit `tableoid=` assignment could still land exactly on your \"nonexistent\" value if you pick something in active use.\n\n5. **MPP note:** for a relation-level OID (not relfilenode/shard-level), coordinator and segments are catalog-synced for user relations, so \"doesn't exist\" on one implies \"doesn't exist\" everywhere — you don't need per-segment variants of this literal.","input_tokens":12,"output_tokens":5205,"duration_s":76.701958644,"timed_out":false}} +{"kind":"run","task_id":"gotcha-003","arm":"no-memory","run":0,"result":{"task_id":"gotcha-003","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.740793637,"timed_out":false}} +{"kind":"run","task_id":"gotcha-003","arm":"no-memory","run":1,"result":{"task_id":"gotcha-003","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.806603182,"timed_out":false}} +{"kind":"run","task_id":"gotcha-003","arm":"memory","run":0,"result":{"task_id":"gotcha-003","arm":"memory","result_text":"That's the exact issue CLAUDE.md's test-isolation section flags. Here's why it happens and how to fix it.\n\n## Why it's flaky\n\n`zdb_get_metainfo_for_file`, `zdb_get_metainfo_for_drop_flag`, and `zdb_get_metainfo_for_visimap` are **global** FDB introspection UDFs — they scan all keys under their respective prefix (file/`S`, drop-flag/`D`, visimap/`V`) across the whole FDB cluster, not just your table. All tests in a schedule share one cluster and one FDB instance, so:\n\n- Other tests' tombstone/segfile/drop-flag/visimap keys are present at the same time your test runs.\n- Autovacuum or a lingering background worker from a prior test can add/remove keys concurrently.\n- A bare `count(*)` counts *all* of those, so the number your test expects (e.g. `0` after DROP) is really \"0 plus however much junk from neighbors happened to exist at that instant\" — which varies run to run and schedule position.\n\nPassing in isolation \"works\" only because in that run there's no other test's data around to pollute the count.\n\n## Fix: scope by relfilenode\n\nCapture the table's `relfilenode` **before** the DROP, then JOIN every metainfo query against it instead of counting rows directly. This is exactly the pattern in `contrib/zdb/regress/sql/backup_and_gc/setup_backup.sql` (used by `drop_table_left_meta.sql` and friends):\n\n```sql\ncreate table if not exists relfilenode_record_for_drop_tbl(relname text, relfilenode oid, relid oid);\n\n-- before DROP:\ninsert into relfilenode_record_for_drop_tbl\n select relname, relfilenode, oid from pg_class where oid = 'my_test_tbl'::regclass;\n\ndrop table my_test_tbl;\n\n-- then scope every introspection query:\nselect count(*) from\n (select tablespace, dbid, relationid, xmin from zdb_get_metainfo_for_file(2, \u003cspace\u003e, 'S')) a\n join\n (select relfilenode from relfilenode_record_for_drop_tbl where relname = 'my_test_tbl') b\n on a.relationid = b.relfilenode;\n```\n\nSame join pattern for `zdb_get_metainfo_for_drop_flag(...)` and `zdb_get_metainfo_for_visimap(...)`.\n\nTwo more things to check while you're in there (also called out in CLAUDE.md):\n- `relfilenode_record_for_drop_tbl` should be `create table if not exists` (shared helper, other tests may have created it already) and you should `drop table relfilenode_record_for_drop_tbl;` in teardown so it doesn't leak into later tests.\n- If your test also touches `pg_catalog.zdb_*` catalog tables (`zdb_backup_gc_records`, `zdb_shard_stats`, etc.), those are global too — filter by `relfilenode` there as well.\n- If you rely on \"no background GC happened\" between DROP and your check, confirm no leftover autovacuum worker can still be running — use the fault-injector pattern in `drop_table_left_meta.sql` or drive cleanup synchronously via `relyt_vacuum_dropped_relation()` instead of a timing assumption.","input_tokens":10,"output_tokens":2102,"duration_s":31.751931134,"timed_out":false}} +{"kind":"run","task_id":"gotcha-003","arm":"memory","run":1,"result":{"task_id":"gotcha-003","arm":"memory","result_text":"Now I have the full picture. Here's the answer:\n\n**Why it goes flaky:** `zdb_get_metainfo_for_file` / `zdb_get_metainfo_for_drop_flag` / `zdb_get_metainfo_for_visimap` are global FDB introspection UDFs — they scan the whole FDB keyspace for that record type, not just your table. In a shared schedule, every test runs against the same `isolation2test` DB and FDB instance, so a bare `select count(*) from zdb_get_metainfo_for_file(...)` picks up tombstone/segfile/visimap keys left behind by *other* tests that ran earlier (or concurrently, if the schedule parallelizes tests in the same group). Alone, your test is the only writer, so the count happens to match; in the full schedule it doesn't.\n\n**The fix — join on `relfilenode`, captured before the DROP:**\n\n1. Before dropping your table, snapshot its relfilenode into a helper table:\n```sql\ncreate table if not exists relfilenode_record_for_drop_tbl(relname text, relfilenode oid, relid oid) using heap;\ninsert into relfilenode_record_for_drop_tbl\n select relname, relfilenode, oid from pg_class where oid = '\u003cyour_table\u003e'::regclass;\n-- if partitioned, also insert each leaf partition's row here\n```\n(`create table if not exists` — this name is shared/reused across tests, so don't `create table` unconditionally.)\n\n2. Then DROP the table.\n\n3. Scope every metadata-count query to that relfilenode via a JOIN, instead of a bare count:\n```sql\nSELECT count(*) FROM\n(\n SELECT tablespace, dbid, relationid, xmin FROM zdb_get_metainfo_for_file(2, '\u003cspace\u003e', 'S')\n) a\nJOIN\n(\n SELECT relfilenode FROM relfilenode_record_for_drop_tbl WHERE relname = '\u003cyour_table\u003e'\n) b ON a.relationid = b.relfilenode;\n```\nSame pattern for `_for_drop_flag` (join on `relationid = relfilenode`) and `_for_visimap`. This is exactly what `contrib/zdb/regress/sql/backup_and_gc/setup_backup.sql` and `drop_table_left_meta.sql` do (`get_meta_file_count`, `get_meta_visimap_count`, `get_meta_drop_flag_count`).\n\n4. Also give your helper/test tables distinctive names and DROP them in teardown, and make sure you're not relying on autovacuum having *not* run — if your assertion is \"no leftover GC record yet,\" pin autovacuum with a fault injector or drive it synchronously via `relyt_vacuum_dropped_relation()` rather than assuming no background worker touched it (per the CLAUDE.md isolation guidance for this test dir).","input_tokens":8,"output_tokens":1603,"duration_s":28.275010324,"timed_out":false}} +{"kind":"run","task_id":"gotcha-004","arm":"no-memory","run":0,"result":{"task_id":"gotcha-004","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.651025616,"timed_out":false}} +{"kind":"run","task_id":"gotcha-004","arm":"no-memory","run":1,"result":{"task_id":"gotcha-004","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.8088880010000001,"timed_out":false}} +{"kind":"run","task_id":"gotcha-004","arm":"memory","run":0,"result":{"task_id":"gotcha-004","arm":"memory","result_text":"Two separate mechanisms explain the \"still flaky\" behavior, both confirmed in `contrib/zdb/src/storage/zdb_vacuum*.c`:\n\n**1. Dropped-table / aborted-txn GC isn't gated by `relyt.autovacuum` at all.**\nThe regular vacuum-decision path (`ZDBNeedsVacAnalyze`, `zdb_vacuum.c:1893,2019`) does check `gZdbAutoVacuum`. But the reserved-worker pool that sweeps dropped tables and aborted transactions is scheduled independently:\n\n```c\n// contrib/zdb/src/storage/zdb_vacuum_reserved_worker.c:267-279\nstatic int GetReservedWorkerTaskMask(void) {\n if (gAutoVacuumMaxReservedWorkerForGCAborted \u003e 0) {\n ... if (active \u003c gAutoVacuumMaxReservedWorkerForGCAborted)\n mask |= RW_GC_ABORT_TXN_TASK;\n }\n ...\n}\n```\n\nThis only checks `relyt.autovacuum_max_reserved_workers_for_gc_aborted` (default `1`) / `relyt.autovacuum_max_reserved_workers_for_merge` — never `relyt.autovacuum`/`gZdbAutoVacuum`. So a dropped table or aborted txn left over from an *earlier* test in the schedule can still get swept by a reserved worker mid-assertion, even with `relyt.autovacuum = off`.\n\n**2. Race on the path that *is* gated.**\nToggling the GUC only stops *new* vacuum decisions; a worker already forked and mid-vacuum before your `ALTER SYSTEM SET` + reload took effect runs to completion regardless.\n\n**Secondary gap worth closing anyway:** a bare `select pg_reload_conf();` only reloads the coordinator's config — it does not reach segments. `ALTER SYSTEM SET` itself *is* dispatched cluster-wide (`utility.c:834-841`, via `CdbDispatchUtilityStatement` when `Gp_role == GP_ROLE_DISPATCH`), so segments' `postgresql.auto.conf` gets updated, but they won't reload it in-memory unless you also dispatch the reload. The existing suite's idiom for this is:\n\n```sql\nselect * from pg_reload_conf() union all select pg_reload_conf() from gp_dist_random('gp_id');\n```\n(see `contrib/zdb/regress/sql/backup_and_gc/drop_table_left_meta.sql:9,595`)\n\n### What to do instead\n\n- Don't rely on `relyt.autovacuum=off` as a correctness guarantee for dropped-table/aborted-txn state — that's exactly why CLAUDE.md calls for pinning the fault-injection points (`auto_vac_before_backup_dropped_table`, `auto_vac_after_backup_dropped_table`, `auto_vac_after_gc_dropped_table` — see `drop_table_left_meta.sql`) or driving cleanup synchronously via `relyt_vacuum_dropped_relation()` / `relyt_vacuum_aborted_txn()` UDFs instead of waiting on/racing background workers.\n- If you still want a belt-and-suspenders GUC block, also set `relyt.autovacuum_max_reserved_workers_for_gc_aborted = 0` and `relyt.autovacuum_max_reserved_workers_for_merge = 0` in setup, since those are what actually gate the reserved-worker sweep.\n- Always reload cluster-wide, not just the coordinator, using the `gp_dist_random('gp_id')` pattern above.\n- Teardown must mirror setup exactly: `alter system reset` every GUC you touched (`relyt.autovacuum`, and the reserved-worker counts if used), then reload cluster-wide again. Since these are `ALTER SYSTEM SET`, they persist cluster-wide into every later test in the schedule until explicitly reset — this is the GUC-leakage gotcha already documented in CLAUDE.md.","input_tokens":36,"output_tokens":18246,"duration_s":220.077740779,"timed_out":false}} +{"kind":"run","task_id":"gotcha-004","arm":"memory","run":1,"result":{"task_id":"gotcha-004","arm":"memory","result_text":"## Why it's still flaky\n\n`alter system set relyt.autovacuum = off; select pg_reload_conf();` only stops the launcher from **scheduling new** GC/autovacuum work going forward. It doesn't touch:\n\n1. **Workers already in flight.** If a previous test in the schedule dropped a table (or otherwise made something GC-eligible) shortly before your setup ran, the launcher may have already forked a worker on the coordinator or on a segment. That worker keeps running to completion — it doesn't re-check `relyt.autovacuum` mid-run. Your `off` write loses the race with an already-launched worker.\n2. **Segments not reloaded.** `pg_reload_conf()` by itself only reloads the coordinator's postmaster. Each segment has its own postmaster and its own launcher that can independently decide to spawn a GC worker. You need to fan the reload out, e.g.:\n ```sql\n select * from pg_reload_conf() union all select pg_reload_conf() from gp_dist_random('gp_id');\n ```\n (this is exactly the pattern in `contrib/zdb/regress/sql/backup_and_gc/setup_backup.sql` / `drop_table_left_meta.sql`).\n3. **Some GC paths aren't gated by `relyt.autovacuum` at all.** Backup/GC of a *dropped* table's metadata (backup tasks → GC tasks, see `HandleBackupTaskResultForDroppedTable` / `HandleGCTaskResultForDroppedTable` in `contrib/zdb/src/storage/zdb_vacuum.c`) is driven by its own naptime/interval GUCs (`relyt.vacuum_deleted_backup_interval`, `relyt.vacuum_dropped_relation_num`, `autovacuum_naptime`, `relyt.autovacuum_max_workers`, `relyt.autovacuum_max_reserved_workers_for_gc_aborted`). If those are left at aggressive values from an earlier test, background reclaim can still fire even with `relyt.autovacuum = off`.\n\nTurning the GUC off is a race, not a guarantee. The reliable way to get a deterministic \"nothing runs in the background\" window is to **pin the worker with a fault injector** rather than relying on the GUC alone — CLAUDE.md calls this out, and `contrib/zdb/regress/sql/backup_and_gc/drop_table_left_meta.sql` is the reference implementation:\n\n```sql\nselect gp_inject_fault('auto_vac_before_backup_dropped_table', 'suspend', '', 'isolation2test', '', 1, 1, 0, dbid, -1)\n from gp_segment_configuration where role = 'p';\n-- ... trigger the condition ...\nselect gp_inject_fault('auto_vac_before_backup_dropped_table', 'wait_until_triggered', '', 'isolation2test', '', 1, 1, 1, dbid, -1)\n from gp_segment_configuration where role = 'p';\n-- now you have a guaranteed clean window; assert here\nselect gp_inject_fault('auto_vac_before_backup_dropped_table', 'reset', '', 'isolation2test', '', 1, 1, 0, dbid, -1)\n from gp_segment_configuration where role = 'p';\n```\nInjection points available: `auto_vac_before_backup_dropped_table`, `auto_vac_after_backup_dropped_table`, `auto_vac_after_gc_dropped_table`. Or drive GC synchronously with `relyt_vacuum_dropped_relation()` / `relyt_vacuum_aborted_txn()` instead of waiting on the background worker.\n\n## What teardown needs\n\nTwo independent things leak into later tests if you skip them:\n\n1. **Every GUC you touched, reset — and reloaded everywhere:**\n ```sql\n alter system reset relyt.autovacuum;\n alter system reset relyt.autovacuum_max_workers;\n alter system reset relyt.autovacuum_max_reserved_workers_for_gc_aborted;\n alter system reset relyt.vacuum_deleted_backup_interval;\n alter system reset relyt.vacuum_dropped_relation_num;\n alter system reset autovacuum_naptime;\n -- add any others your setup touched\n select * from pg_reload_conf() union all select pg_reload_conf() from gp_dist_random('gp_id');\n ```\n `alter system set` persists across the whole schedule (unlike a session `SET`), so a stray un-reset GUC is exactly the kind of thing that makes some *other* test flaky next.\n\n2. **Any fault injector left `suspend`ed or in a triggered state must be `reset`.** If you `suspend` a point and the test errors out before reaching your `reset` call, the next test's background worker will block on that suspend point forever (or your suspend will silently swallow the next test's GC too). Reset unconditionally at the top of teardown, not just on the happy path — e.g. wrap assertions so teardown still runs, or reset the fault points first thing before resetting GUCs.\n\nOrder in teardown: reset fault injector points → `alter system reset ...` for every touched GUC → reload conf fanned out to segments. That mirrors `drop_table_left_meta.sql`'s teardown block (lines ~587-593 reset the GUCs; the fault points are reset immediately after each phase rather than batched at the end — do the same if you suspend more than one point).","input_tokens":12,"output_tokens":4571,"duration_s":70.691986631,"timed_out":false}} +{"kind":"run","task_id":"gotcha-005","arm":"no-memory","run":0,"result":{"task_id":"gotcha-005","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.68328275,"timed_out":false}} +{"kind":"run","task_id":"gotcha-005","arm":"no-memory","run":1,"result":{"task_id":"gotcha-005","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.834204164,"timed_out":false}} +{"kind":"run","task_id":"gotcha-005","arm":"memory","run":0,"result":{"task_id":"gotcha-005","arm":"memory","result_text":"The diff isn't about your query results being wrong — it's a formatting mismatch between two different test runners.\n\n`contrib/zdb/regress` tests run under `installcheck-default` via `pg_isolation2_regress`, which drives sessions through **psycopg2**. If you generated your `expected/foo.out` by running the SQL through plain `pg_regress` (psql-based), the output format differs even when the underlying query results are identical:\n\n- Command tags differ (e.g. row-count suffixes like `INSERT 0 N`, `CREATE`/`DROP` formatting)\n- Column alignment/padding differs (isolation2 output left-justifies with `ljust`, leaving trailing whitespace)\n- SQL echo style differs (single-line echo of the statement before its result)\n\nSo `pg_regress` output is never a valid basis for these `expected/*.out` files — the two executors just don't agree on formatting, independent of correctness.\n\nFix: let `make install installcheck-default` run and fail, then look at `contrib/zdb/regress/results/foo.out` (the actual output it produced). That's the format your expected file must match byte-for-byte (including trailing spaces). If the query results in there are correct, just copy/adapt `results/foo.out` into `expected/foo.out` rather than hand-authoring from `pg_regress` output. You can still hand-write expected files for cases you haven't run yet — just model the formatting on a real `results/` sample from an isolation2 run, not from `pg_regress`.","input_tokens":2,"output_tokens":1194,"duration_s":18.72659794,"timed_out":false}} +{"kind":"run","task_id":"gotcha-005","arm":"memory","run":1,"result":{"task_id":"gotcha-005","arm":"memory","result_text":"That's the exact gotcha documented in this project's CLAUDE.md: `installcheck-default` runs through `pg_isolation2_regress` (the psycopg2-based executor), not plain `pg_regress` (psql-based). The two executors produce structurally different output even for identical query results, so an expected file captured via `pg_regress` will diff against `installcheck-default` no matter how correct the results are.\n\nConcretely, the differences that trip this up:\n- **Command tags** — psycopg2-driven isolation2 output includes/omits `CREATE`/`INSERT N`/`DROP` tags differently than psql's.\n- **SQL echo** — isolation2 echoes each statement as a single line; psql's multi-line echo/formatting differs.\n- **Column alignment \u0026 trailing whitespace** — isolation2's result tables use `ljust`-style left-alignment with trailing spaces on each row; psql right-pads/formats differently.\n\nFix: don't hand-capture via `pg_regress`. Instead run the test through the real path and diff against what it actually produces:\n\n```bash\nsource /workspace/phoenix/neon/pg_install/v12/greenplum_path.sh\npsql -c \"drop database if exists isolation2test;\"\ncd /workspace/phoenix/contrib/zdb/regress\nmake install installcheck-default\n```\n\nThis will fail (since your expected file is wrong), but it drops the actual output under `results/foo.out`. Copy that into `expected/foo.out` (after eyeballing it for correctness), then rerun `installcheck-default` to confirm it now passes clean.\n\nIf you do want a hand-written expected file (e.g. to pin down desired behavior before the feature exists), CLAUDE.md notes you can still write it manually — but format it by referencing real output in `results/` (single-line SQL echo, proper command tags, left-aligned columns with trailing spaces), not by running it through `pg_regress`.","input_tokens":6,"output_tokens":1276,"duration_s":25.0244487,"timed_out":false}} +{"kind":"verdict","task_id":"fix-004","arm":"memory","run":0,"verdict":{"score":1,"points_hit":[],"points_missed":["did not identify GenerateBackupTaskForDroppedTable/GenerateGCTaskForDroppedTable throttle+retention issue","did not identify that retention window should be 0 and throttle should be dropped for dropped-table path","did not flag gmeta-side tombstone/key lifecycle as unverified external root cause — instead fabricated a detailed gmeta behavior (ZdbClearDropTable emptiness check) without flagging it as unread/unverified"],"reasoning":"The agent invented an entirely different root cause (missing FAILED status transition in task result handlers) rather than identifying the actual throttle/retention-window issue in the two Generate* functions specified by the rubric, and it confidently asserted specific gmeta-side behavior (ZdbClearDropTable) without flagging it as unverified/out-of-worktree, directly violating the gotcha condition."}} +{"kind":"verdict","task_id":"fix-004","arm":"memory","run":1,"verdict":{"score":1,"points_hit":[],"points_missed":["did not identify GenerateBackupTaskForDroppedTable/GenerateGCTaskForDroppedTable as the relevant functions/mechanism","did not identify the schedule-interval throttle + retention window reuse as the actual bug","did not propose dropping the throttle / setting deletedBackupInterval/deletedGCInternal to 0","incorrectly claims deletedBackupInterval/deletedGCInterval are already hardcoded to 0 and not the culprit, contradicting the actual root cause","invented an unverified root cause (missing FAILED status transition) not supported by the rubric, and fabricated claim that a fix is already applied in the working tree","did not flag the gmeta tombstone/key lifecycle issue as out-of-worktree and unverified — instead fabricated a different root cause on the Phoenix side without proper verification caveats"],"reasoning":"答案完全偏离了 rubric 中描述的真实根因(调度节流阈值与保留窗口复用问题),转而虚构了一个未经验证的根因(FAILED 状态未设置)并且错误地声称修复已经存在于工作目录中,这是严重的臆造行为,违反了'不要在没有验证的情况下臆测行为'的核心要求。同时完全没有提及 gmeta 侧墓碑/key 生命周期问题,也没有恰当地将其标记为超出当前工作区、未经验证。"}} +{"kind":"verdict","task_id":"fix-005","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":[],"points_missed":["The list lives in contrib/relyt/ SQL files, in pg_catalog.relyt_non_dispatched_func","Populated via INSERT...SELECT...FROM pg_proc WHERE proname OR-chain with ON CONFLICT DO NOTHING","Fix is adding proname = 'regexp_matches' clause","Fix must be carried forward into newest relyt--*.sql version"],"reasoning":"The agent's final answer is an authentication failure message with no actual content addressing the task, so no rubric points can be awarded."}} +{"kind":"verdict","task_id":"fix-005","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":[],"points_missed":["The list lives in contrib/relyt/ SQL files with pg_catalog.relyt_non_dispatched_func catalog table","Populated via INSERT...SELECT...FROM pg_proc WHERE proname OR-chain with ON CONFLICT DO NOTHING","Fix is adding 'or proname = 'regexp_matches'' clause","Fix must be carried forward into newest relyt--*.sql version"],"reasoning":"The agent's response is an authentication failure message, not an actual answer to the question, so no rubric points were addressed at all."}} +{"kind":"verdict","task_id":"fix-005","arm":"memory","run":1,"verdict":{"score":6,"points_hit":["The list lives in contrib/relyt/ SQL files (relyt--*.sql), in the pg_catalog.relyt_non_dispatched_func catalog table","It's populated via an INSERT ... SELECT ... FROM pg_catalog.pg_proc WHERE proname = '...' OR proname = '...' style statement with ON CONFLICT DO NOTHING","Acknowledged the need to carry the fix forward into the newest version (relyt--3.42.sql specifically identified as the tail-end)"],"points_missed":["The core rubric fix — adding 'or proname = 'regexp_matches'' to the WHERE list — is claimed to already be present, contradicting the premise of the task that regexp_matches is being dispatched due to missing exclusion; agent instead invented a different root cause (dispatcher.c logic gaps) not supported by the rubric"],"reasoning":"The agent correctly identified the storage location and INSERT pattern of the exclusion list, but rejected the actual rubric-specified fix (adding regexp_matches to the proname list) by claiming it already exists, and instead proposed an entirely different fix in dispatcher.c that the rubric does not credit. This is a confident but likely incorrect answer that misses the central 'minimal fix' the rubric is grading for."}} +{"kind":"verdict","task_id":"gotcha-001","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":[],"points_missed":["stale delta-kernel-rs FFI header cause","fix via rm header/lib","source gcc-toolset-11","rebuild via build.sh release install","must redo after every rebase"],"reasoning":"The agent's final answer is an authentication failure message with no actual content addressing the question, so it fails to hit any rubric points and violates the gotchas by providing no guidance at all."}} +{"kind":"verdict","task_id":"gotcha-001","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":[],"points_missed":["stale delta-kernel-rs FFI header explanation","fix commands to delete header/lib","source gcc-toolset-11","rebuild via build.sh","note this must be redone every rebase"],"reasoning":"代理最终未能给出任何答案,仅返回身份验证失败的错误信息,未涉及任何评分要点,也未能避免任何反面扣分项(因为根本没有作答)。"}} +{"kind":"verdict","task_id":"gotcha-001","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["identified stale delta_kernel_ffi.h due to Rust incremental build short-circuit","gave correct rm commands for header and .so","included sourcing gcc-toolset-11 before rebuild","gave correct rebuild command via delta-kernel-rs/relyt/build.sh release install","did not assume submodule update alone was sufficient","did not skip gcc-toolset-11 sourcing","did not suggest simply re-running full build without the fix"],"points_missed":["did not explicitly emphasize this must be repeated after every rebase/pull/branch switch, not just once"],"reasoning":"Answer correctly diagnoses root cause, gives exact fix commands in right order including gcc-toolset-11 sourcing, and avoids all three gotchas; only minor omission is not stating the fix must be reapplied after every future rebase."}} +{"kind":"verdict","task_id":"gotcha-001","arm":"memory","run":1,"verdict":{"score":10,"points_hit":["identifies stale delta-kernel-rs FFI header as likely cause despite submodule update","correct fix commands: rm header and lib","sources gcc-toolset-11 before rebuild","correct rebuild command via delta-kernel-rs/relyt/build.sh release install","did not assume submodule update alone was sufficient","did not skip gcc-toolset-11 sourcing","did not blindly assume rerunning full build alone would fix it (explicitly forces regeneration first)"],"points_missed":["does not explicitly state this must be redone after every rebase/pull/branch switch as a recurring gotcha, only implies it via 'stale relative to your rebase'"],"reasoning":"The answer correctly identifies the stale FFI header as the root cause, provides the exact fix commands in the right order including sourcing gcc-toolset-11, and avoids all three gotchas by not blindly trusting submodule update or the full build's incremental cache. It only lacks an explicit statement that this must be repeated after every rebase, a minor omission given the correct diagnostic reasoning shown in the fallback branch."}} +{"kind":"verdict","task_id":"gotcha-002","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":[],"points_missed":["Use a large OID value such as 9999999, not a small one","Small hardcoded OIDs can collide with a real relation's OID","This convention comes from a real fix changing 88888 to 9999999","The test should assert expected error behavior, not depend on coincidental OID unused-ness"],"reasoning":"The agent's answer is an authentication failure message with no actual content addressing the question; no rubric points were addressed."}} +{"kind":"verdict","task_id":"gotcha-002","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":[],"points_missed":["Use a large OID value such as 9999999, not a small one","Small hardcoded OIDs can collide with real relation OIDs","Reference to commit changing 88888 to 9999999","Phrase test to assert expected error behavior, not rely on coincidental unused OID"],"reasoning":"The agent's response is an authentication failure error message, not an actual answer to the question. There is no content to award any rubric points for."}} +{"kind":"verdict","task_id":"gotcha-002","arm":"memory","run":0,"verdict":{"score":6,"points_hit":["did NOT suggest a small/low-numbered arbitrary OID like 88888 or similar","did NOT ignore the cross-cluster-state collision risk (discussed FirstNormalObjectId/OID counter growth)"],"points_missed":["Did not recommend the specific convention value 9999999","Did not cite the actual commit history changing async_load_job/simple_load tests from 88888 to 9999999","Suggested INT32_MAX/4000000000 which diverges from the repo's established convention rather than confirming it"],"reasoning":"The agent correctly avoided small OIDs and discussed collision risk in depth, but never identified the actual convention value (9999999) or the historical commit that established it, instead inventing an alternative (INT32_MAX/4 billion) not grounded in repo history — missing the core factual answer despite good reasoning around the surrounding gotchas."}} +{"kind":"verdict","task_id":"gotcha-002","arm":"memory","run":1,"verdict":{"score":6,"points_hit":["Suggested a large, clearly-out-of-range OID value (999999999) rather than a small one","Explicitly warned against small/low-numbered OIDs (called out FirstNormalObjectId/16384 boundary and collision risk with reserved/system ranges)","Addressed cross-cluster-state style collision risk conceptually (grep for existing usages, verify not in use, avoid collision with other test's hardcoded OIDs)"],"points_missed":["Did not identify or reference the actual convention/precedent in this codebase: the real fix changing async_load_job/simple_load tests from 88888 to 9999999","Did not use the specific established convention value 9999999","Did not explicitly frame the advice around the documented real-world incident of 88888 colliding with a real relation OID in some cluster states","Advice to check 'is it free right now' via pg_class lookup somewhat undercuts the point that the test should not depend on the OID being unused only by coincidence"],"reasoning":"The agent independently derived a reasonable and defensible answer (large OID, avoid small/system-range values, watch for collisions) but missed the specific codebase precedent (88888→9999999 fix) that the rubric centers on, and its 'verify OID is free right now' suggestion runs counter to the rubric's point that the test shouldn't rely on incidental non-collision at test-writing time."}} +{"kind":"verdict","task_id":"gotcha-003","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":[],"points_missed":["global FDB key scoping explanation","bare count(*) mixing tombstone/segfile/visimap keys","relfilenode join fix","pg_catalog.zdb_* global scope caveat","reference to setup_backup.sql pattern"],"reasoning":"The agent's final answer is an authentication failure message with no actual content addressing the question, so it hits none of the rubric points and violates both gotchas by providing no guidance at all."}} +{"kind":"verdict","task_id":"gotcha-003","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":[],"points_missed":["FDB-introspection UDFs return global keys not scoped to test table","bare count(*) mixes in state from other tests","fix via capturing relfilenode and joining","pg_catalog.zdb_* tables also need relfilenode filtering","reference to setup_backup.sql example"],"reasoning":"The agent's answer is an authentication failure message with no actual content addressing the question, so it fails to hit any rubric points and also violates both gotchas by providing no guidance at all."}} +{"kind":"verdict","task_id":"gotcha-003","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["identifies UDFs as global FDB introspection scans not scoped to caller's table","explains bare count(*) mixes in other tests' leftover keys causing flakiness","provides fix: capture relfilenode before DROP into temp table, JOIN every metainfo query on it","notes pg_catalog.zdb_* catalog tables (zdb_backup_gc_records, zdb_shard_stats) are also global and need relfilenode filtering","references contrib/zdb/regress/sql/backup_and_gc/setup_backup.sql as the pattern source","did not use a bare count(*) without filtering","did not assume UDFs are already scoped"],"points_missed":[],"reasoning":"The answer correctly identifies the root cause (global FDB scans mixing in other tests' keys), gives the exact fix (relfilenode capture + join), extends it to pg_catalog.zdb_* tables, and cites the correct reference file — all rubric points are covered with no gotchas violated."}} +{"kind":"verdict","task_id":"gotcha-003","arm":"memory","run":1,"verdict":{"score":10,"points_hit":["FDB-introspection UDFs return global keys not scoped to caller's table","bare count(*) mixes in leftover keys from other tests in shared schedule/FDB instance","fix: capture relfilenode into temp table before DROP, then JOIN metadata queries on it","cites setup_backup.sql / drop_table_left_meta.sql as reference pattern","did not use bare count(*) without filtering","did not assume UDFs are pre-scoped to caller's table"],"points_missed":["did not mention pg_catalog.zdb_* catalog tables (zdb_backup_gc_records, zdb_shard_stats) also being global and needing relfilenode filtering"],"reasoning":"The answer correctly identifies the global-scope root cause, provides the exact relfilenode-join fix with correct SQL, and cites the right reference files, satisfying nearly all rubric points and no gotchas; it only omits the pg_catalog.zdb_* global-table caveat."}} +{"kind":"verdict","task_id":"gotcha-004","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":[],"points_missed":["autovacuum worker already running not killed by GUC flip","use fault injector or synchronous vacuum UDFs","ALTER SYSTEM SET persists across schedule","teardown must reset GUC + reload conf"],"reasoning":"The agent's answer failed entirely (authentication error), providing no content addressing any rubric point."}} +{"kind":"verdict","task_id":"gotcha-004","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":[],"points_missed":["autovacuum worker in-flight from earlier test","fault injector or synchronous vacuum UDFs to pin/prevent GC","ALTER SYSTEM SET persists across tests","teardown must include alter system reset + pg_reload_conf()"],"reasoning":"The agent's answer failed to authenticate and produced no content addressing the question, so no rubric points can be awarded."}} +{"kind":"verdict","task_id":"gotcha-004","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["Autovacuum worker spawned earlier may still be running/in-flight despite GUC flip (explicit race explanation)","Recommends pinning with fault injector (auto_vac_before/after_backup_dropped_table, auto_vac_after_gc_dropped_table) or driving synchronously via relyt_vacuum_dropped_relation()/relyt_vacuum_aborted_txn()","Notes ALTER SYSTEM SET + pg_reload_conf() persists cluster-wide into later tests unlike session SET","Teardown must alter system reset every touched GUC then reload, explicitly stated"],"points_missed":[],"reasoning":"答案完整命中所有四个评分点,并额外补充了 reserved-worker GC 路径不受 relyt.autovacuum 门控的深入细节和集群范围 reload 的注意事项,未违反任何gotcha。"}} +{"kind":"verdict","task_id":"gotcha-004","arm":"memory","run":1,"verdict":{"score":10,"points_hit":["An autovacuum worker spawned by an earlier test may already be running even after the GUC is flipped off","To reliably prevent background GC, pin the worker with a fault injector (auto_vac_before_backup_dropped_table etc.) or drive it synchronously with relyt_vacuum_dropped_relation()/relyt_vacuum_aborted_txn()","ALTER SYSTEM SET + pg_reload_conf() persists into every subsequent test, so it must be undone","Teardown includes alter system reset relyt.autovacuum (and other touched GUCs) followed by pg_reload_conf(), plus resetting session-level SETs"],"points_missed":[],"reasoning":"The answer correctly identifies the in-flight-worker race as the root cause, recommends the exact fault-injector points and synchronous UDFs from the rubric, and gives a thorough teardown with alter system reset + pg_reload_conf(), also adding extra correct nuance about fault-injector cleanup and segment-wide reload without violating either gotcha."}} +{"kind":"verdict","task_id":"gotcha-005","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":[],"points_missed":["installcheck-default runs through pg_isolation2_regress (psycopg2-based executor), not pg_regress","cannot validate expected .out via pg_regress; format differs","expected file format must match pg_isolation2_regress conventions","reference actual output under results/ for exact format"],"reasoning":"Agent's answer is an authentication failure with no content addressing the question at all, so none of the rubric points were addressed."}} +{"kind":"verdict","task_id":"gotcha-005","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":[],"points_missed":["installcheck-default runs through pg_isolation2_regress not pg_regress","cannot validate expected .out via pg_regress","format must match pg_isolation2_regress conventions","reference actual results/ output"],"reasoning":"The agent's answer is an authentication failure message with no substantive content addressing the question at all."}} +{"kind":"verdict","task_id":"gotcha-005","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["installcheck-default runs through pg_isolation2_regress (psycopg2-based) not pg_regress (psql-based) — different formats","cannot validate expected .out via pg_regress; command tags, column alignment, trailing whitespace differ","hand-writing is fine but must match pg_isolation2_regress conventions (single-line echo, command tags, ljust alignment/trailing spaces)","reference actual output under results/ to get exact format","did not validate/generate via pg_regress (avoided gotcha)","did not assume the two executors produce same formatting (avoided gotcha)"],"points_missed":[],"reasoning":"The answer correctly diagnoses the executor mismatch, explains the specific formatting differences, and recommends using results/foo.out as the format reference rather than pg_regress — hitting every rubric point without triggering either gotcha."}} +{"kind":"verdict","task_id":"gotcha-005","arm":"memory","run":1,"verdict":{"score":10,"points_hit":["installcheck-default runs through pg_isolation2_regress (psycopg2-based executor), not pg_regress (psql-based executor) — the two executors produce different output formats","You cannot validate an expected .out file by running it through pg_regress; the command tags, column alignment, and trailing whitespace differ between the two executors","It is fine to hand-write the expected file, but its format must match pg_isolation2_regress conventions: single-line SQL echo, command tags like CREATE/INSERT N/DROP, left-justified (ljust) column alignment with trailing spaces","Reference the actual output already produced under results/ in the regress directory to get the exact format right, rather than guessing"],"points_missed":[],"reasoning":"答案准确指出了 pg_isolation2_regress 与 pg_regress 执行器差异的根本原因,列出了命令标签、单行 SQL 回显、列对齐和尾随空格等具体差异点,并给出了参考 results/ 目录实际输出来修正 expected 文件的正确做法,完整覆盖了 rubric 的所有得分点,且未违反任何 gotcha。"}} diff --git a/eval/results/wave1-report.md b/eval/results/wave1-report.md new file mode 100644 index 00000000..fc878d8b --- /dev/null +++ b/eval/results/wave1-report.md @@ -0,0 +1,118 @@ +# Wave 1 Acceptance Report — Phase 2 Optimization Loop + +**Date**: 2026-07-28 +**Branch**: `feat/phase2-wave1` (final L3 at binary version 0.4.0, slim protocol active) +**Baseline**: `2adc24c` scorecards (v0.3.0) +**Spec**: `docs/superpowers/specs/2026-07-27-phase2-optimization-loop-design.md` + +## Anchor verdicts + +| Anchor | Baseline | Wave 1 | Target | Verdict | +|---|---|---|---|---| +| L3 memory uplift | 2.267 | **2.983** (+31.6%) | ≥ 4.53 | **NOT MET** (66% of target) | +| L2 injected tokens/session | 7824 | **6084** (−22.2%) | ≤ 3912 | **NOT MET** (harness metric; see both-views note) | + +Both anchors improved materially; neither reached its ×2/÷2 target. Per the +spec, this report states achieved ratios and bottlenecks without dressing up. + +**Both-views note (L2)**: the harness metric counts `SKILL.md` (1982 tok) as a +static per-session slice, but the skill is loaded on demand in real Claude Code +sessions (Task 4 measurement caveat, ledger W1-T4). Excluding it, the +real-session injected cost is **3885 tok** (hook 2844 + MCP instr 184 + +context 240 + search 205.6×3), which is *below* the 3912 target. We report the +harness metric as the official verdict because it is the instrument the spec +anchored; the real-session view says the remaining gap is concentrated in one +place (the hook, see bottlenecks). + +## Mandatory no-memory comparison (R5 guardrail) + +| Arm | Final mean (N=2, 15 tasks) | Baseline mean | +|---|---|---| +| memory | 6.967 | 6.000 | +| no-memory | 3.983 | 3.733 | + +All 60 verdicts judged (0 failures after checkpoint re-judge; see incidents). + +## Per-category L3 (memory / no-memory / uplift) + +| Category | Baseline | Wave 1 | Movement | +|---|---|---|---| +| architecture-qa | 8.00 / 8.90 / **−0.90** | 10.00 / 9.25 / **+0.75** | memory drag CURED; memory now a small net positive | +| gotcha | 8.50 / 0.00 / **+8.50** | 9.20 / 0.00 / **+9.20** | advantage widened (pinned core memory) | +| small-fix | 1.50 / 2.30 / **−0.80** | 1.70 / 2.70 / **−1.00** | still a drag; near-floor scores for both arms | + +Output-token overhead of the memory arm: baseline +48% (337k vs 228k) → +Wave 1 **+9%** (283k vs 260k) — the answer-first rules removed most of the +memory-driven verbosity. Empty-result (turn-starved) runs: 8/60 → 6/60. + +## Round ledger (what shipped, what each round measured) + +| Round | Change | Gate result | Kept | +|---|---|---|---| +| R1 (T2–T5) | slim protocol 1265B + MCP instructions 736B, protocol-mode revived | recall@5 flat 0.796; R1-controlled slices −336 tok measured (−869 real-session) | ✓ | +| R2 (T6–T7) | answer-first rules in all 12 agent-facing surfaces; slim survives compaction | targeted fix/arch memory mean 2.70→5.00; gotcha held; saves still occur (audit) | ✓ | +| R3 (T8–T10) | mem_search index mode + budget; layered mem_context ≤400 tok | tokens/query 991→205.6; context 1636→88; recall@5 flat; distractor 0.133; gotcha not starved | ✓ | +| R4 (T11) | pinned core-memory block (1KB cap) + pin criterion; 2 real phoenix gotcha facts pinned | targeted gotcha memory mean 8.75 ≥ 8.5 | ✓ | + +All five optimization items kept; zero reverts. Corpus note: the live phoenix +corpus grew 6→15 facts during the wave (our own L3 memory-arm saves + real +user sessions) — raw L2 comparisons across rounds carry that organic-growth +term; like-for-like slice deltas are recorded per round in the ledger. + +## Bottleneck analysis (what blocks each anchor) + +**Uplift (2.983 vs 4.53).** The gap decomposes cleanly: +- gotcha is nearly saturated (9.20 with a 10 ceiling) and arch is saturated + (10.00) — together they cannot contribute more than ~0.5 additional uplift. +- **small-fix is the entire remaining lever**: memory is still −1.00 there, + and both arms score near the floor (1.70/2.70). The tasks require sustained + multi-file investigation; current memories (facts, conventions) do not carry + the *procedural* knowledge these tasks need. Wave 2 candidates from the + research matrix: episodic memory type (worked examples of past + investigations), relations expansion (bugfix ↔ causing decision), and + turn-budget/tooling improvements orthogonal to memory. +- Arithmetic reality check: with gotcha+arch saturated, reaching uplift 4.53 + requires small-fix uplift ≈ +3.9 (from −1.00) — i.e. memory must make fix + tasks *succeed* (score ~6) where both arms now fail. That is a Wave 2/3 + research problem (procedural memory), not a tuning problem. + +**Tokens (6084 vs 3912, harness metric).** +- `static_hook_tokens` 2844 is now 47% of the composite and is dominated by + the session-start hook's non-protocol output (status block, session + bootstrap, import notices) — the protocol text itself is only ~316 tok of + it. The hook's operational chrome is the next frontier. +- `static_skill_tokens` 1982 is a measurement artifact (on-demand in real + sessions) — resolving the instrument-vs-reality question (measure a real + session transcript, or move the metric to the real-session basis) should be + a Wave 2 decision before more optimization is spent chasing it. +- Search/context slices are now capped by construction (index mode + budget + + layered context); further cuts there risk starving retrieval. + +## Incidents (this acceptance run) + +- The overnight full L3 hit 25/60 judge failures (`claude` exit 1, empty + stderr, all 3 retries; concentrated on gotcha/fix-004/fix-005 — the + tool-temptation-shaped prompts). Manual reproduction of the same judge + call succeeded the next morning: transient API-side failure under batch + load, not the Task-9-era tool-use bug (the `--tools ""` hardening was + active). Recovered via the checkpoint sidecar: 25 failed verdicts stripped, + resume re-judged exactly those (35 complete / 25 re-judge / 0 to run) — no + task re-runs, 0 failures on the second pass. The >20% warning fired as + designed and the first-pass means were correctly treated as unreliable. +- `~/.engram/memorylake.json` was clobbered twice more by cmd/engram tests + during the wave (user-accepted live-testing posture); restored from backup + before each eval round per the standing ledger rule. + +## Assumptions unchanged from baseline + +`search-calls=3.0` per session (placeholder), tokenizer approx-bytes/4, +L1 corpus frozen at 54 QA cases, judge prompts/rubrics frozen, N=2. +L1 payload metric is a floor (envelope framing uncounted — see +`SearchPayloadTokens` doc comment). + +## Recommendation + +Ship Wave 1 (all rounds kept; both anchors materially improved; agent-facing +behavior strictly better). Open Wave 2 with two mandates: (a) procedural +memory for small-fix uplift — the only lever with headroom; (b) hook-chrome +slimming + L2 instrument decision — the only lever left for the token anchor. From 0fb16637d0795bf804bedbdb1f8b9372343c69d7 Mon Sep 17 00:00:00 2001 From: zhishu <weichuangxian@hotmail.com> Date: Tue, 28 Jul 2026 03:34:15 +0000 Subject: [PATCH 29/29] fix(eval): correct auth-artifact-contaminated L3 anchors on both sides; honest wave1 report --- eval/results/2adc24c-2026-07-24-l3.json | 122 +++++------ .../2adc24c-2026-07-24-l3.json.runs.jsonl | 48 ++--- eval/results/baseline-report.md | 10 + eval/results/w1-final-l3.json | 126 +++++------ eval/results/w1-final-l3.json.runs.jsonl | 48 ++--- eval/results/wave1-report.md | 203 +++++++++--------- 6 files changed, 283 insertions(+), 274 deletions(-) diff --git a/eval/results/2adc24c-2026-07-24-l3.json b/eval/results/2adc24c-2026-07-24-l3.json index 50c2c68f..cff48fae 100644 --- a/eval/results/2adc24c-2026-07-24-l3.json +++ b/eval/results/2adc24c-2026-07-24-l3.json @@ -1,14 +1,14 @@ { - "git_sha": "eab9e3a", - "date": "2026-07-27", + "git_sha": "393d2d1", + "date": "2026-07-28", "suite": "l3", "metrics": { "judge_failed_count": 0, "judge_failed_memory": 0, "judge_failed_no-memory": 0, "mean_score_memory": 6, - "mean_score_no-memory": 3.7333333333333334, - "uplift": 2.2666666666666666 + "mean_score_no-memory": 6.366666666666666, + "uplift": -0.36666666666666625 }, "per_item": [ { @@ -410,24 +410,24 @@ { "id": "fix-005/no-memory/run0", "values": { - "duration_s": 3.356385088, - "input_tokens": 0, - "output_tokens": 0, + "duration_s": 242.114994445, + "input_tokens": 50, + "output_tokens": 7701, "score": 0, "timed_out": 0 }, - "note": "The agent's final answer is an authentication failure message with no actual content addressing the task, so no rubric points can be awarded." + "note": "timed out or empty result" }, { "id": "fix-005/no-memory/run1", "values": { - "duration_s": 1.579985746, - "input_tokens": 0, - "output_tokens": 0, + "duration_s": 310.571667917, + "input_tokens": 50, + "output_tokens": 10113, "score": 0, "timed_out": 0 }, - "note": "The agent's final answer is an authentication failure message, containing no actual answer to the question at all." + "note": "timed out or empty result" }, { "id": "fix-005/memory/run0", @@ -454,24 +454,24 @@ { "id": "gotcha-001/no-memory/run0", "values": { - "duration_s": 1.712972945, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 21.990569661, + "input_tokens": 2, + "output_tokens": 905, + "score": 10, "timed_out": 0 }, - "note": "The agent's final answer is an authentication failure message with no actual content addressing the question, so no rubric points are satisfied." + "note": "答案准确指出了 stale header 的根本原因、正确的修复命令序列,并且强调了 submodule update 本身不够、需要 source gcc-toolset-11,未犯任何 gotcha 错误;虽未显式强调'每次 rebase 后都要做',但整体回答完整覆盖了核心技术点。" }, { "id": "gotcha-001/no-memory/run1", "values": { - "duration_s": 1.661255938, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 32.239170343, + "input_tokens": 4, + "output_tokens": 978, + "score": 10, "timed_out": 0 }, - "note": "答案为认证失败的错误信息,未提供任何实质性内容,完全没有回答问题。" + "note": "Answer covers all required fix steps in correct order with correct rationale, correctly avoids all three gotchas, and even notes the recurring nature of the issue implicitly via the memory-save offer." }, { "id": "gotcha-001/memory/run0", @@ -498,24 +498,24 @@ { "id": "gotcha-002/no-memory/run0", "values": { - "duration_s": 1.6510939900000001, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 95.160335691, + "input_tokens": 8, + "output_tokens": 5616, + "score": 3, "timed_out": 0 }, - "note": "代理未能给出任何回答(认证失败/会话过期),因此没有涉及任何评分点,也未能避免任何反向扣分项相关的错误建议。" + "note": "The agent avoided recommending a small OID and raised some collision concerns, but never landed on the actual expected answer (9999999) or referenced the real historical fix; instead it proposed an overengineered create-then-drop workaround not grounded in the rubric's actual convention, missing most of the key answer points." }, { "id": "gotcha-002/no-memory/run1", "values": { - "duration_s": 1.814133258, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 107.043903255, + "input_tokens": 10, + "output_tokens": 4223, + "score": 8, "timed_out": 0 }, - "note": "The agent's response was a failure message (\"Failed to authenticate: OAuth session expired\") with no actual answer content provided, so no rubric points can be credited." + "note": "答案在避免小OID冲突和强调不应仅依赖巧合未被占用这两点上表现扎实,甚至给出了更稳健的drop-then-reference方案,但完全没有提到题目期望的具体约定值9999999及其来源commit,导致关键的'convention来源'得分点缺失。" }, { "id": "gotcha-002/memory/run0", @@ -542,24 +542,24 @@ { "id": "gotcha-003/no-memory/run0", "values": { - "duration_s": 1.6964209129999999, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 53.345444116, + "input_tokens": 8, + "output_tokens": 1635, + "score": 10, "timed_out": 0 }, - "note": "The agent's response is an authentication failure error message with no actual answer content, so none of the rubric's answer points were addressed." + "note": "答案完整覆盖了所有 rubric 要点:全局作用域根因、flaky 原因、relfilenode 捕获+JOIN 的修复方案、pg_catalog.zdb_* 同样问题、并引用了 setup_backup.sql 作为参考模式,没有触犯任何 gotcha(未使用未过滤的裸 count(*),也未假设 UDF 已按表作用域限定)。" }, { "id": "gotcha-003/no-memory/run1", "values": { - "duration_s": 1.6048469779999999, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 30.132138815, + "input_tokens": 6, + "output_tokens": 1182, + "score": 10, "timed_out": 0 }, - "note": "The agent's response is an authentication failure message with no actual content addressing the question, so it hits none of the rubric's answer points and violates both gotcha conditions by providing no guidance at all." + "note": "The answer correctly identifies the global-scope root cause, mixing of stale metadata, provides the relfilenode-capture-and-join fix with a concrete example, and cites the reference file; it omits mentioning the analogous pg_catalog.zdb_* catalog table issue but covers everything else including a bonus note on autovacuum interference." }, { "id": "gotcha-003/memory/run0", @@ -586,24 +586,24 @@ { "id": "gotcha-004/no-memory/run0", "values": { - "duration_s": 1.661434657, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 371.084979463, + "input_tokens": 28, + "output_tokens": 9497, + "score": 10, "timed_out": 0 }, - "note": "Agent's final answer is an authentication failure message with no actual content addressing the rubric." + "note": "The answer correctly identifies the race condition with already-dispatched workers, recommends the exact fault-injector/synchronous-UDF pattern from drop_table_left_meta.sql, and provides a complete teardown with alter system reset + pg_reload_conf(). No gotchas violated; all four answer points are covered thoroughly, plus additional correct detail about segment dispatch." }, { "id": "gotcha-004/no-memory/run1", "values": { - "duration_s": 1.6136045810000001, - "input_tokens": 0, - "output_tokens": 0, + "duration_s": 673.368612249, + "input_tokens": 4, + "output_tokens": 1819, "score": 0, "timed_out": 0 }, - "note": "The agent's answer is a failure message (authentication error), containing no actual content addressing the question, so no rubric points can be awarded." + "note": "The agent gave no substantive answer at all, only a placeholder saying it would investigate and report back later; none of the required content or gotchas were addressed." }, { "id": "gotcha-004/memory/run0", @@ -630,24 +630,24 @@ { "id": "gotcha-005/no-memory/run0", "values": { - "duration_s": 1.573921274, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 43.355494326, + "input_tokens": 2, + "output_tokens": 825, + "score": 10, "timed_out": 0 }, - "note": "The agent's final answer is an authentication failure with no actual content addressing the question, so no rubric points can be credited." + "note": "Answer correctly identifies the executor mismatch, lists the specific formatting differences (single-line echo, command tags, ljust trailing whitespace), and recommends using results/ output as the basis instead of pg_regress; no gotchas were violated." }, { "id": "gotcha-005/no-memory/run1", "values": { - "duration_s": 1.6243846469999998, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 57.606347328, + "input_tokens": 4, + "output_tokens": 1057, + "score": 8, "timed_out": 0 }, - "note": "The agent's answer is an authentication failure message, not a substantive response, so no rubric points were addressed at all." + "note": "The answer correctly identifies the root cause (different executors) and correct fix (use results/ output as template, don't validate with pg_regress), hitting all gotcha avoidances, but is less specific than the rubric's detailed format description." }, { "id": "gotcha-005/memory/run0", diff --git a/eval/results/2adc24c-2026-07-24-l3.json.runs.jsonl b/eval/results/2adc24c-2026-07-24-l3.json.runs.jsonl index dd77960f..8f606010 100644 --- a/eval/results/2adc24c-2026-07-24-l3.json.runs.jsonl +++ b/eval/results/2adc24c-2026-07-24-l3.json.runs.jsonl @@ -69,52 +69,52 @@ {"kind":"run","task_id":"fix-004","arm":"memory","run":0,"result":{"task_id":"fix-004","arm":"memory","result_text":"","input_tokens":0,"output_tokens":0,"duration_s":1200.033846549,"timed_out":true}} {"kind":"verdict","task_id":"fix-004","arm":"memory","run":0,"verdict":{"score":0,"points_hit":null,"points_missed":null,"reasoning":"timed out or empty result"}} {"kind":"run","task_id":"fix-004","arm":"memory","run":1,"result":{"task_id":"fix-004","arm":"memory","result_text":"## Findings\n\nI traced the dropped-table cleanup pipeline on the Phoenix/PG side (all in `contrib/zdb/src/`), verifying each function directly rather than relying on naming/comments. Two independent issues explain the reported symptom; I verified both against the actual code (not the gmeta submodule, which I only read for context on the emptiness-check semantics).\n\n### Background: the pipeline\n`DROP TABLE` on a ZDB relation writes an FDB \"tombstone\" via `ClearMetaForDroppingTable`/`SetFlagForDroppingTable` (`zdb_aocsam.c:6020-6047`), hooked from `heap_drop_with_catalog()`. Each autovacuum cycle (`src/backend/postmaster/autovacuum.c:2956-2963` → `VacuumDroppedRelations`, `zdb_vacuum.c:7213`) then:\n1. `BackupDroppedRelations()` (`zdb_vacuum.c:7289`) moves WORK-space files to BACKUP space, per shard.\n2. `GCDroppedRelations()` (`zdb_vacuum.c:7385`) physically deletes BACKUP-space files/keys, per shard.\n\nBoth fan out per-shard tasks based on `totalShardNum = GetZDBShardNumByRelfilenode(relfilenode)` (`zdb_shard_stats.c:537`), which derives shard count purely from surviving rows in the `zdb_shard_stats` catalog (chosen deliberately — regress tests confirm \"drop table will not remove records in zdb_shard_stats\" so the async cleanup can still look them up post-drop). Confirmed there's already an April 2026 fix (commit `d3aedae9cfd`, merged, ancestor of current HEAD) that removed a time-based retention delay for dropped tables specifically — so a fixed retention window is *not* the current cause.\n\n### Root cause 1 (high confidence, no special precondition): unfair, capped scan in `GetDroppedRelations`\n`GetDroppedRelations()` (`zdb_aocsam.c:6067-6120`) does a **fresh FDB range scan from the start of the tombstone keyspace on every single call**, capped at `gZdbVacuumDroppedRelationNum` (GUC `relyt.vacuum_dropped_relation_num`, default **8**, hard max **128** — `zdb_vacuum.c:1457-1466`). There is no continuation cursor — `InitMetaInfoIterRequest` (`comm/request.c:2176`) takes no offset/watermark.\n\nMeanwhile, large dropped tables can legitimately need many autovacuum cycles to fully drain (bounded per-cycle by `relyt.vacuum_backup_file_num_per_task`/`relyt.vacuum_gc_file_num_per_task`, default 10000 files/shard/task — code comment at `zdb_vacuum.c:6683`: *\"file_num_per_task may require many re-dispatches to drain WORK\"*). Because the same leading N tombstones (in FDB key order) are re-fetched every cycle, if the number of concurrently-pending dropped tables in a space ever exceeds the cap — plausible for a data-warehouse workload doing bulk partition-retention drops — any tombstone sorting after the first N is **never even discovered**, let alone scheduled, until the ones ahead of it fully clear. Under sustained drop volume this can starve individual tables indefinitely.\n\n**Fix**: give `GetDroppedRelations()` (`zdb_aocsam.c:6067`) a resume cursor — track the last-seen relfilenode per (database, space) and pass it as an exclusive lower bound to the FDB filter (the filter already carries a `relNode` field, currently hardcoded to `InvalidOid`/wildcard at `zdb_aocsam.c:6083`) so consecutive cycles advance through the whole backlog round-robin instead of always restarting at the top. Reset the cursor once a sweep returns fewer than `limitNum` results (end of range reached).\n\n### Root cause 2 (verified code asymmetry, needs a stuck-task precondition to manifest): premature `zdb_shard_stats` deletion\nIn `ZDBAfterVacuumCommit()` (`zdb_vacuum.c:2836`), the `isZDBBackupTaskTxn` branch (`zdb_vacuum.c:2974-2979`) unconditionally wipes `zdb_shard_stats` rows for the dropped relfilenode after **every** BACKUP-task-commit:\n```c\nif (!OidIsValid(currentVacuumTaskTxnRelid) \u0026\u0026 OidIsValid(currentVacuumTaskTxnRelfilenode))\n RemoveZDBShardStatsByRefilenode(currentVacuumTaskTxnRelfilenode, false);\n```\nthen re-inserts only the shards reported back in *this cycle's* dispatch result (`currentTaskTxnShardStats[]`, lines 2980-2986). Contrast this with the **GC**-task branch 60 lines later (`zdb_vacuum.c:3030-3040`), which only removes shard-stats rows after explicitly confirming via `CheckDroppedRelationsExist()` on both WORK and BACKUP spaces that cleanup is actually complete — the correct pattern.\n\nEvery shard requested in a cycle normally does report back (`ExecuteBackupTask`, `zdb_vacuum_backup_task.c:280-284`, always fills `ZDBVacuumShardStats` even when `dead_files==0`), so this is usually a harmless churn. But `GenerateBackupTaskForDroppedTable`/`GenerateGCTaskForDroppedTable` (`zdb_vacuum.c:6686`, `6968`) skip generating a task for any shard that already has a `RUNNING` task (lines 6710-6721), with **no staleness/timeout check**. If a dispatch ever dies mid-flight (worker kill, segment crash, dispatch error) leaving a task row stuck at `RUNNING`, that shard is silently skipped forever after — and since the wipe above isn't gated on \"is cleanup actually done,\" that shard's bookkeeping row is deleted and never reinserted. `GetZDBShardNumByRelfilenode` then permanently under-counts, later cycles only ever regenerate tasks for the reduced shard set, and the gmeta-side emptiness gate (which correctly scans the *full* shard range) permanently refuses to clear the tombstone — files and FDB metadata for the unreachable shards linger forever, exactly matching \"sometimes ... almost indefinitely.\"\n\n**Fix**: apply the same gate used in the GC branch — only call `RemoveZDBShardStatsByRefilenode(relfilenode, false)` in the BACKUP branch after confirming via `CheckDroppedRelationsExist(UDF_DATA_SPACE_WORK, relfilenode)` that the WORK tombstone is actually gone, not merely \"a dispatch round completed.\" As defense-in-depth, also consider adding a staleness bound to the `RUNNING`-task filter in `GenerateBackupTaskForDroppedTable`/`GenerateGCTaskForDroppedTable` so a crashed dispatch can't permanently pin a shard out of rotation.\n\n---\n\nRoot cause 1 is the one I'd fix first — it requires no special precondition and directly matches a DW workload with periodic bulk-partition drops. Root cause 2 is a real, verified asymmetry worth hardening regardless, though it needs a stuck-`RUNNING`-task trigger to actually cause permanent loss. Want me to implement either fix?","input_tokens":42,"output_tokens":37095,"duration_s":860.8803802,"timed_out":false}} -{"kind":"run","task_id":"fix-005","arm":"no-memory","run":0,"result":{"task_id":"fix-005","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":3.356385088,"timed_out":false}} -{"kind":"run","task_id":"fix-005","arm":"no-memory","run":1,"result":{"task_id":"fix-005","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.579985746,"timed_out":false}} {"kind":"run","task_id":"fix-005","arm":"memory","run":0,"result":{"task_id":"fix-005","arm":"memory","result_text":"Confirmed with direct grep. Here's the answer.\n\n## Where the list lives\n\nIt's not a hardcoded C array — it's a live catalog table, `pg_catalog.relyt_non_dispatched_func`, created by `contrib/zdb/zdb--1.3--1.4.sql:271-278`, and checked at plan time by `FuncDispatchIsRestricted(Oid procOid)` in `contrib/zdb/src/storage/zdb_syntax_check.c:2007-2063`:\n\n1. Look up `procOid` in `relyt_non_dispatched_func` (lines 2020-2042) → found ⇒ restricted.\n2. Otherwise, check `pronamespace` — if not `pg_catalog`/`information_schema`/`gp_toolkit`, treat as user-defined ⇒ restricted; **builtin `pg_catalog` functions fall through as dispatchable** (lines 2044-2062).\n\nThis feeds `contrib/zdb/src/dispatch/dispatcher.c`: `RangeVarWalker()` (lines 409-422) counts `restricted_funcs`/`total_funcs` per `FuncExpr`; `GetPlanType()` (lines 629-630) returns `PT_ONLY_CAN_DISPATCHED_FUNC` when `numRestrictedFunc == 0 \u0026\u0026 numTotalFunc \u003e 0`; that plan type is one of the ones that sets `queryCanDispatch = true` at line 1288, sending the query to swift/EDPS.\n\n## Why `regexp_matches` slips through\n\n`regexp_matches` **is** in the exclusion list, but only via `contrib/relyt`'s SQL, not `contrib/zdb`'s. It first appears in `contrib/relyt/relyt--3.22.sql` and is still in the current default `relyt--3.43.sql:4123` (`... or proname = 'log' or proname = 'regexp_matches'`).\n\nThe catch: `relyt`'s version files (`relyt--3.X.sql`) are **full snapshots**, not an incremental `X--Y` upgrade chain (there's exactly one incremental `relyt--3.37--3.38.sql`, and it's unrelated new work, not historical). So the `regexp_matches` insert only runs when relyt is freshly `CREATE EXTENSION`'d at 3.22+ against a snapshot script — it is never re-applied by `zdb`'s own incremental chain, which touches `relyt_non_dispatched_func` exactly once, at creation (`zdb--1.3--1.4.sql`), with only generic prefixes (`pg_%`, `zdb_%`, `gp_%`, `relyt_%`, `gp_toolkit`). Any cluster that got to its current state via `ALTER EXTENSION zdb UPDATE ...` without a corresponding relyt snapshot reinstall never gets the `regexp_matches` row, so `FuncDispatchIsRestricted()` returns `false` for it (it's a plain `pg_catalog` builtin) and it dispatches. I couldn't confirm this against a live cluster here (none running), so treat the \"why this specific cluster is missing the row\" part as an inference from the SQL/upgrade-path structure, not a verified runtime fact — worth confirming with `SELECT 1 FROM pg_catalog.relyt_non_dispatched_func WHERE proname='regexp_matches';` on the affected cluster.\n\n## Minimal fix\n\n**Hotfix (affected clusters now):**\n```sql\nINSERT INTO pg_catalog.relyt_non_dispatched_func\nSELECT oid, proname, pronamespace, pronargs, prorettype, proargtypes\nFROM pg_catalog.pg_proc WHERE proname = 'regexp_matches'\nON CONFLICT DO NOTHING;\n```\nNo restart needed — the check re-queries the table on every plan.\n\n**Repo fix:** add a proper `contrib/zdb/zdb--3.43--3.44.sql` incremental script with that same `INSERT`, and bump `zdb.control`'s `default_version`, so the row gets (re-)applied via zdb's own upgrade chain instead of depending on relyt happening to be reinstalled from a matching snapshot.","input_tokens":18,"output_tokens":6426,"duration_s":518.958535364,"timed_out":false}} {"kind":"run","task_id":"fix-005","arm":"memory","run":1,"result":{"task_id":"fix-005","arm":"memory","result_text":"","input_tokens":50,"output_tokens":7650,"duration_s":153.047904483,"timed_out":false}} {"kind":"verdict","task_id":"fix-005","arm":"memory","run":1,"verdict":{"score":0,"points_hit":null,"points_missed":null,"reasoning":"timed out or empty result"}} -{"kind":"run","task_id":"gotcha-001","arm":"no-memory","run":0,"result":{"task_id":"gotcha-001","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.712972945,"timed_out":false}} -{"kind":"run","task_id":"gotcha-001","arm":"no-memory","run":1,"result":{"task_id":"gotcha-001","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.661255938,"timed_out":false}} {"kind":"run","task_id":"gotcha-001","arm":"memory","run":0,"result":{"task_id":"gotcha-001","arm":"memory","result_text":"Understood — I won't run that. Here's the fix to run yourself:\n\n```bash\nrm -f contrib/zdb/third_party/install/include/delta_kernel_ffi.h\nrm -f contrib/zdb/third_party/install/lib/libdelta_kernel_ffi.so\nsource /opt/rh/gcc-toolset-11/enable\n(cd contrib/zdb/third_party \u0026\u0026 ./delta-kernel-rs/relyt/build.sh release install)\n```\n\nThen rerun your full Neon build. The installed header/lib I checked are timestamped Jun 16 — stale relative to today's rebase — which is exactly the scenario CLAUDE.md's pre-build section warns about: `git submodule update` alone doesn't force `delta-kernel-rs` to regenerate its FFI header/lib, and Cargo's incremental cache can skip the copy step even when the build script does run. Deleting those two files first forces a real rebuild.","input_tokens":6,"output_tokens":1906,"duration_s":28.903611707,"timed_out":false}} {"kind":"run","task_id":"gotcha-001","arm":"memory","run":1,"result":{"task_id":"gotcha-001","arm":"memory","result_text":"No problem — you can run this yourself. This is exactly the fix documented in `CLAUDE.md`'s pre-build step 2:\n\n```bash\nrm -f contrib/zdb/third_party/install/include/delta_kernel_ffi.h\nrm -f contrib/zdb/third_party/install/lib/libdelta_kernel_ffi.so\nsource /opt/rh/gcc-toolset-11/enable\ncd /workspace/phoenix/contrib/zdb/third_party \u0026\u0026 ./delta-kernel-rs/relyt/build.sh release install\n```\n\nThen rerun the full Neon build. After that, the header will have the new enum variants (fixing the compile error) and the `.so` will match, so the schema-inconsistent runtime error should also disappear.\n\n**Why `git submodule update` alone didn't catch this:** it correctly moves the submodule pointer, but Rust's incremental `target/` cache in `delta-kernel-rs` can decide \"nothing to rebuild\" and skip the step that copies the freshly generated header/lib into `third_party/install/`. Deleting the installed copies forces a fresh copy regardless of Rust's cache state.","input_tokens":10,"output_tokens":2244,"duration_s":36.61647057,"timed_out":false}} -{"kind":"run","task_id":"gotcha-002","arm":"no-memory","run":0,"result":{"task_id":"gotcha-002","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.6510939900000001,"timed_out":false}} -{"kind":"run","task_id":"gotcha-002","arm":"no-memory","run":1,"result":{"task_id":"gotcha-002","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.814133258,"timed_out":false}} {"kind":"run","task_id":"gotcha-002","arm":"memory","run":0,"result":{"task_id":"gotcha-002","arm":"memory","result_text":"Good, that confirms a real, concrete hazard in this codebase rather than a generic OID concern. Here's what I found and what I'd recommend:\n\n**What OID to hardcode**\n\nTwo viable options, in order of preference:\n\n1. **Capture-and-drop a real OID** (most robust): `CREATE TABLE tmp_x (...); SELECT oid, relfilenode... INTO temp table; DROP TABLE tmp_x;` then immediately use that captured value in the negative-case query, before anything else is created in the session. This guarantees the OID genuinely doesn't exist at the moment you probe it — no guessing required, no magic constant to justify.\n2. **A hardcoded literal below `FirstNormalObjectId` (16384)** — PostgreSQL's OID generator never assigns new relation/table OIDs below this threshold post-`initdb`, so a small literal (e.g. `100`) can't collide with anything created during a test run. Avoid `0` (`InvalidOid`) specifically — per this repo's own convention (see `CLAUDE.md`'s note on FDB introspection UDFs), some ZDB metadata/introspection functions treat missing/zero identifiers as \"no filter\" and fall back to a *global* scope rather than \"not found.\" If the function you're testing has that behavior, OID `0` will silently pass the wrong code path instead of exercising the not-found path.\n\n**What to be careful about — this is the real gotcha here, not a generic one**\n\nI checked, and this repo explicitly supports assigning arbitrary OIDs via `CREATE TABLE ... WITH (tableoid=N, relfilenode=M)` (see `contrib/zdb/regress/sql/create_with_oid.sql`). That test hardcodes `tableoid` values `234567–234570` and `relfilenode` values `773240/773241/773250`, and `vacuum/vacuum_priority.sql` hardcodes `relfilenode=20240701/20240710`. Both files run in the same shared schedules as many other tests (`zdb_schedule`, `datapage_v2_schedule`, `querycache_schedule`), all against the same `isolation2test` database per the test-isolation notes in `CLAUDE.md`.\n\nSo if you pick an arbitrary \"looks unused\" literal like `773242` or `234571` for your \"nonexistent relation\" test, you're one lucky guess away from colliding with an OID some *other* test in the same schedule explicitly assigns later in the run — which would flip your negative test into a false positive/negative depending on ordering. Before hardcoding any literal, grep the full `sql/` tree for that exact number (as I did above) to confirm it's not claimed elsewhere, and prefer a value clearly outside the ranges already in use (e.g. well below 16384, since none of the existing explicit assignments fall there) or use the capture-and-drop technique to sidestep the guessing game entirely.","input_tokens":8,"output_tokens":5291,"duration_s":71.095905152,"timed_out":false}} {"kind":"run","task_id":"gotcha-002","arm":"memory","run":1,"result":{"task_id":"gotcha-002","arm":"memory","result_text":"Don't hardcode a fixed literal like `999999999`. Two concrete risks:\n\n1. **Collision on a long-lived shared test cluster**: per this repo's isolation2 test model, all tests in a schedule share the same cluster/database, and OIDs are assigned monotonically from a global counter that keeps climbing across every regression run this cluster has ever done. A number that's \"obviously nonexistent\" today can eventually collide as more objects accumulate. Also avoid anything below PG12's `FirstNormalObjectId` (16384) — that range is reserved for system catalog objects and could coincidentally hit a real one.\n2. **Expected-output determinism**: if you instead try to derive the OID dynamically (e.g. via `\\gset` and interpolate `:var` into the query), the isolation2/pg_regress echo will print the *substituted* literal value in the command text, so your `expected/*.out` would be non-deterministic across runs/environments.\n\nThe pattern that avoids both problems, and matches how this codebase already does relfilenode-safety in `setup_backup.sql`: compute the nonexistent OID inline via a static query with no interpolation, e.g. `select ... from zdb_get_fileinfo((select max(oid)+1 from pg_class))` — the OID value itself never appears in the echoed SQL text (only the query is echoed, not the resolved value), so the expected output stays stable, and `max(oid)+1` is guaranteed not to exist at the moment the statement runs. Just make sure whichever introspection function you're testing returns a row-count/boolean rather than an error message that embeds the OID, or that non-determinism leaks back in through the error text.","input_tokens":6,"output_tokens":4117,"duration_s":57.878009281,"timed_out":false}} -{"kind":"run","task_id":"gotcha-003","arm":"no-memory","run":0,"result":{"task_id":"gotcha-003","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.6964209129999999,"timed_out":false}} -{"kind":"run","task_id":"gotcha-003","arm":"no-memory","run":1,"result":{"task_id":"gotcha-003","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.6048469779999999,"timed_out":false}} {"kind":"run","task_id":"gotcha-003","arm":"memory","run":0,"result":{"task_id":"gotcha-003","arm":"memory","result_text":"Root cause: `zdb_get_metainfo_for_file`, `zdb_get_metainfo_for_drop_flag`, and `zdb_get_metainfo_for_visimap` are global FDB introspection UDFs — they return metadata keys for *all* tables/relfilenodes in the FDB namespace, not just the one you dropped. When your test runs alone, the metadata store is otherwise empty, so `count(*)` happens to equal \"just my table's rows.\" Once other tests in the schedule run in the same cluster/FDB instance, they leave behind tombstone/segfile/visimap keys for their own tables, so your bare `count(*)` picks those up too and the assertion becomes order-dependent (flaky).\n\nFix: scope the query to your table's `relfilenode` via a join, capturing it into a temp/helper table *before* the DROP (since after DROP you can no longer look it up from catalogs). The existing pattern in `contrib/zdb/regress/sql/backup_and_gc/setup_backup.sql` does exactly this:\n\n```sql\nCREATE OR REPLACE FUNCTION get_meta_file_count(_tblname text, _space int)\nRETURNS int AS $func$\nDECLARE\n _sql text; _result int;\nBEGIN\n _sql := format($$\n SELECT count(*) FROM\n (\n SELECT tablespace, dbid, relationid, xmin FROM zdb_get_metainfo_for_file(2, %L, 'S')\n ) a\n JOIN\n (\n SELECT relfilenode FROM relfilenode_record_for_drop_tbl WHERE relname = %L\n ) b ON a.relationid = b.relfilenode\n $$, _space, _tblname);\n EXECUTE _sql INTO _result;\n RETURN _result;\nEND $func$ LANGUAGE plpgsql;\n```\n\nApply the same join pattern for `zdb_get_metainfo_for_drop_flag` and `zdb_get_metainfo_for_visimap` (drop_flag uses a `case when count(*) \u003e 1 then 2 ... ` shape since duplicate drop-flag rows are valid).\n\nSteps to adopt this in your test:\n1. Before `DROP TABLE`, `INSERT INTO relfilenode_record_for_drop_tbl SELECT relfilenode, relname FROM pg_class WHERE relname = '\u003cyour_table\u003e'` (create that helper table with `IF NOT EXISTS` if it isn't already shared/created elsewhere in the schedule).\n2. Do the `DROP TABLE`.\n3. Replace your bare `select count(*) from zdb_get_metainfo_for_file(...)` with `select get_meta_file_count('\u003cyour_table\u003e', \u003cspace\u003e)` (and the visimap/drop_flag equivalents), joining on the captured relfilenode.\n4. In teardown, clean up your rows from `relfilenode_record_for_drop_tbl` (or use a distinctive table name) so you don't pollute later tests the same way.\n\nThis is also called out in this repo's CLAUDE.md under \"Isolate new regression tests from surrounding tests\" — worth a re-read since it lists the other common flakiness sources (autovacuum interactions, GUC leakage, table-name collisions) in case your test hits those too.","input_tokens":6,"output_tokens":1776,"duration_s":27.957103653,"timed_out":false}} {"kind":"run","task_id":"gotcha-003","arm":"memory","run":1,"result":{"task_id":"gotcha-003","arm":"memory","result_text":"**Why it's flaky:** `zdb_get_metainfo_for_file` / `_for_drop_flag` / `_for_visimap` are global FDB introspection UDFs — they return *every* matching key in that tablespace's FDB range, not just the ones for your table. Since:\n\n- All isolation2 tests in a schedule share one `isolation2test` database and one FDB instance, and\n- Isolation2 schedules run groups of tests **concurrently**, plus earlier tests may leave GC/tombstone/segfile/visimap keys around before autovacuum/backup-GC clears them,\n\na bare `select count(*) from zdb_get_metainfo_for_file(...)` picks up keys belonging to other tests' tables that happen to exist at that moment — not just your table's. That's exactly the \"FDB introspection UDFs are global\" pitfall called out in CLAUDE.md's test-isolation section.\n\n**Fix:** scope every count by your table's `relfilenode`, captured *before* the `DROP TABLE` (you can't look it up from `pg_class` afterward). The existing pattern in `contrib/zdb/regress/sql/backup_and_gc/setup_backup.sql:1-34` and `drop_table_left_meta.sql:12-27` does this:\n\n```sql\n-- before DROP:\ncreate table if not exists relfilenode_record_for_drop_tbl(relname text, relfilenode oid, relid oid) using heap;\ninsert into relfilenode_record_for_drop_tbl\n select relname, relfilenode, oid from pg_class where oid = 'your_test_tbl'::regclass;\n\n-- after DROP, scope the metainfo query with a JOIN instead of a bare count:\nSELECT count(*) FROM\n(\n SELECT tablespace, dbid, relationid, xmin FROM zdb_get_metainfo_for_file(2, \u003cspace\u003e, 'S')\n) a\nJOIN\n(\n SELECT relfilenode FROM relfilenode_record_for_drop_tbl WHERE relname = 'your_test_tbl'\n) b ON a.relationid = b.relfilenode;\n```\n\nSame join pattern for `zdb_get_metainfo_for_drop_flag` and `zdb_get_metainfo_for_visimap` — `relationid` from the metainfo functions is the relfilenode, so join on that instead of counting globally. Also give `relfilenode_record_for_drop_tbl` and your test table distinctive names (per CLAUDE.md's table-name-collision guidance) so a stale row from a different test's table doesn't accidentally match.","input_tokens":14,"output_tokens":2776,"duration_s":57.015890844,"timed_out":false}} -{"kind":"run","task_id":"gotcha-004","arm":"no-memory","run":0,"result":{"task_id":"gotcha-004","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.661434657,"timed_out":false}} -{"kind":"run","task_id":"gotcha-004","arm":"no-memory","run":1,"result":{"task_id":"gotcha-004","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.6136045810000001,"timed_out":false}} {"kind":"run","task_id":"gotcha-004","arm":"memory","run":0,"result":{"task_id":"gotcha-004","arm":"memory","result_text":"Two likely culprits, both visible in existing tests here.\n\n**1. `pg_reload_conf()` on the coordinator doesn't reach segments.**\n`ALTER SYSTEM SET` + a bare `select pg_reload_conf();` only reloads the postmaster you're connected to (the coordinator). Autovacuum workers run per-segment, so if segment postmasters never reload, they're still running under the old `relyt.autovacuum = on`. Every real test that disables it does the union-all-with-gp_dist_random dance to reload cluster-wide, e.g. `contrib/zdb/regress/sql/backup_and_gc/drop_table_left_meta.sql:9`:\n```sql\nselect * from pg_reload_conf() union all select pg_reload_conf() from gp_dist_random('gp_id');\n```\nIf your setup only does the coordinator-only form, that's almost certainly your leak.\n\n**2. The GUC only gates *new* worker launches, not one already in flight.**\nIf a previous test in the schedule kicked off an autovacuum/GC worker right before your test's `ALTER SYSTEM SET ... off` lands, that worker keeps running to completion — disabling the GUC doesn't kill it. This is exactly why `drop_table_left_meta.sql` doesn't rely on the flag alone for precise timing assertions; it also uses `gp_inject_fault` (`auto_vac_before_backup_dropped_table`, `auto_vac_after_backup_dropped_table`, `auto_vac_after_gc_dropped_table` with `suspend`/`wait_until_triggered`/`reset`) to deterministically pin execution at a specific point. If your assertions care about exact GC state, disabling autovacuum alone is a race — you need a fault injector or a synchronous UDF (`relyt_vacuum_dropped_relation()` / `relyt_vacuum_aborted_txn()`) to drive the work instead of hoping nothing fires in the background.\n\n**Teardown, matching the setup exactly:**\n```sql\nalter system reset relyt.autovacuum;\n-- reset any other relyt.autovacuum_* GUCs you touched in setup, e.g.:\n-- alter system reset relyt.autovacuum_max_workers;\n-- alter system reset relyt.autovacuum_max_reserved_workers_for_gc_aborted;\nselect * from pg_reload_conf() union all select pg_reload_conf() from gp_dist_random('gp_id');\n```\nIf you used `gp_inject_fault(..., 'suspend', ...)`, make sure every injection point gets a matching `'reset'` in teardown even on the failure path — a suspended fault left armed will hang or skew the next test that happens to hit the same injection point.\n\nNet: the setup line in your test (`select pg_reload_conf();` coordinator-only) is the most likely gap — switch both setup and teardown to the cluster-wide reload form, and if precise no-GC timing matters, add the fault-injector pattern rather than trusting the GUC alone.","input_tokens":6,"output_tokens":2462,"duration_s":33.710925752,"timed_out":false}} {"kind":"run","task_id":"gotcha-004","arm":"memory","run":1,"result":{"task_id":"gotcha-004","arm":"memory","result_text":"Confirmed in code — the mechanism matches what CLAUDE.md flags, and I can point to exactly where.\n\n**Why it's still flaky despite `alter system set relyt.autovacuum = off; select pg_reload_conf();`**\n\n`gZdbAutoVacuum` is only checked at the *scheduling gate*, not enforced as a kill-switch on in-flight work — see `contrib/zdb/src/storage/zdb_vacuum.c:1893` (`if (!gZdbAutoVacuum || !av_enabled) return;`) and `:2019`. Both checks happen at the top of a scheduling decision. Anything that already passed the gate before your reload landed — an autovacuum worker process already forked by a previous test, or a task already mid-flight on a segment — runs to completion regardless of the new GUC value. `pg_reload_conf()` updates GUCs read by *future* checks in already-running backends; it doesn't cancel or preempt anything in progress.\n\nConcrete ways this bites you in a shared schedule:\n\n1. **Asynchronous/partial reload across segments.** The pattern in `contrib/zdb/regress/sql/backup_and_gc/drop_table_left_meta.sql:9` deliberately does `select * from pg_reload_conf() union all select pg_reload_conf() from gp_dist_random('gp_id')` — reload on coordinator *and every segment*. If your setup only reloads on the coordinator, segment-side autovacuum launchers keep the old value and keep scheduling.\n2. **A prior test left a fault injector parked in `suspend`.** If an earlier test used `gp_inject_fault('auto_vac_before_backup_dropped_table'/'auto_vac_after_backup_dropped_table'/'auto_vac_after_gc_dropped_table', 'suspend', ...)` and its teardown didn't `reset` it (e.g., it failed an assertion before reaching cleanup), a worker can be sitting mid-task and resume — apparently out of nowhere — during your test.\n3. **A prior test also touched base `autovacuum`, `autovacuum_naptime`, or `relyt.autovacuum_max_workers`** (as in that same file, naptime=5) and didn't reset them. Your test's `relyt.autovacuum=off` only gates ZDB-specific scheduling; it doesn't stop the standard PG autovacuum launcher cycle those other GUCs still control.\n\n**What your teardown should do**\n\nMirror `drop_table_left_meta.sql`'s teardown exactly — reset every GUC you (or your setup) touched, and reload on all segments, not just the coordinator:\n```sql\nalter system reset relyt.autovacuum;\n-- also reset anything else you changed in setup, e.g.:\n-- alter system reset autovacuum;\n-- alter system reset autovacuum_naptime;\nselect * from pg_reload_conf() union all select pg_reload_conf() from gp_dist_random('gp_id');\n```\nAlways `reset` any fault injector you `suspend`, even on the failure path — a leftover `suspend` parks a worker that later tests will trip over. If you truly need zero background GC during your assertions rather than \"no new GC scheduled,\" don't lean on the racy GUC toggle at all — suspend the specific injection points (`auto_vac_before_backup_dropped_table` / `auto_vac_after_backup_dropped_table` / `auto_vac_after_gc_dropped_table`) around your critical section like that test does, or drive GC synchronously via `relyt_vacuum_dropped_relation()` / `relyt_vacuum_aborted_txn()` instead of waiting on timing.","input_tokens":12,"output_tokens":5230,"duration_s":68.710802434,"timed_out":false}} -{"kind":"run","task_id":"gotcha-005","arm":"no-memory","run":0,"result":{"task_id":"gotcha-005","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.573921274,"timed_out":false}} -{"kind":"run","task_id":"gotcha-005","arm":"no-memory","run":1,"result":{"task_id":"gotcha-005","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.6243846469999998,"timed_out":false}} {"kind":"run","task_id":"gotcha-005","arm":"memory","run":0,"result":{"task_id":"gotcha-005","arm":"memory","result_text":"That's a known gotcha in this repo: `installcheck-default` runs through `pg_isolation2_regress` (psycopg2-based driver), not `pg_regress` (psql-based driver) — they're different executors with different output formatting even for identical queries. Differences typically show up as:\n\n- SQL echoed on a single line (vs. psql's multi-line echo)\n- Command tags like `CREATE`, `INSERT 0 N`, `DROP` included/formatted differently\n- Column alignment and trailing whitespace (isolation2's output uses left-justified columns with trailing spaces)\n\nSo capturing output via `pg_regress` and diffing against `installcheck-default` will show spurious diffs even when the actual query results/semantics are identical.\n\nFix: regenerate your expected file from the actual `installcheck-default` run instead:\n\n```bash\nsource /workspace/phoenix/neon/pg_install/v12/greenplum_path.sh\npsql -c \"drop database if exists isolation2test;\"\ncd /workspace/phoenix/contrib/zdb/regress\nmake install installcheck-default\n```\n\nThis will fail and drop the actual output under `results/\u003cyour_test\u003e.out`. Diff that against your hand-written `expected/foo.out` to see the formatting differences, then copy `results/foo.out` → `expected/foo.out` (after visually confirming the actual data/values are correct) rather than trying to hand-match `pg_regress`-style formatting.","input_tokens":2,"output_tokens":800,"duration_s":13.139279373,"timed_out":false}} {"kind":"run","task_id":"gotcha-005","arm":"memory","run":1,"result":{"task_id":"gotcha-005","arm":"memory","result_text":"That mismatch is expected, and it's called out in this repo's CLAUDE.md testing notes: `make installcheck-default` runs `pg_isolation2_regress`, not `pg_regress`. The two use different clients — `pg_isolation2_regress` drives queries through **psycopg2**, while `pg_regress` drives them through **psql**. Their output formatting differs even when the underlying query results are identical:\n\n- Command tags (e.g. `CREATE`, `INSERT N`, `DROP`) appear differently\n- Column alignment/padding differs\n- Trailing whitespace on lines differs\n\nSo an `expected/foo.out` captured by running the SQL through `pg_regress` will not byte-match what `installcheck-default` produces, even though the data is correct.\n\nFix: regenerate (or hand-adjust) the expected file against the actual `results/foo.out` produced by `make install installcheck-default` itself — that's the only authoritative format. You can hand-write the file, but format it to match a real `results/` output from that runner (single-line SQL echo, command tags after each statement, left-justified/padded columns with trailing spaces), not `pg_regress` output.","input_tokens":2,"output_tokens":987,"duration_s":15.190477344,"timed_out":false}} {"kind":"verdict","task_id":"fix-004","arm":"memory","run":1,"verdict":{"score":3,"points_hit":["did NOT invent or assume the internal behavior of the gmeta-side fix without flagging it as unverified"],"points_missed":["Did not identify the relevant code as GenerateBackupTaskForDroppedTable() and GenerateGCTaskForDroppedTable() reusing live-table scheduling knobs","Did not identify the schedule-interval throttle and deletedBackupInterval/deletedGCInterval retention window as the actual bugs","Did not propose the fix direction of always dispatching for shards without RUNNING tasks and setting retention intervals to 0","Invented two entirely different root causes (missing resume cursor in GetDroppedRelations, premature zdb_shard_stats deletion) not supported by the rubric and not actually verified against the described bug"],"reasoning":"The agent's investigation went down a completely different path than the rubric's expected root cause (scheduling throttle + retention window reuse in GenerateBackupTaskForDroppedTable/GenerateGCTaskForDroppedTable), instead fabricating two speculative alternative root causes based on inference rather than the actual mechanism described; it did avoid guessing at gmeta internals, which earns partial credit, but otherwise misses nearly all core rubric points."}} -{"kind":"verdict","task_id":"fix-005","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":[],"points_missed":["The list lives in contrib/relyt/ SQL files in pg_catalog.relyt_non_dispatched_func","Populated via INSERT...SELECT...FROM pg_catalog.pg_proc WHERE proname OR-chain with ON CONFLICT DO NOTHING","Fix is adding regexp_matches as another or proname clause","Fix must be carried forward into newest/current relyt--*.sql version"],"reasoning":"The agent's final answer is an authentication failure message with no actual content addressing the task, so no rubric points can be awarded."}} -{"kind":"verdict","task_id":"fix-005","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":[],"points_missed":["The list lives in contrib/relyt/ SQL files (pg_catalog.relyt_non_dispatched_func)","Populated via INSERT...SELECT...FROM pg_proc WHERE proname OR-chain with ON CONFLICT DO NOTHING","Fix is adding 'or proname = 'regexp_matches'' clause","Fix must be carried forward into newest relyt--*.sql version"],"reasoning":"The agent's final answer is an authentication failure message, containing no actual answer to the question at all."}} {"kind":"verdict","task_id":"fix-005","arm":"memory","run":0,"verdict":{"score":3,"points_hit":["Identifies that a catalog table (relyt_non_dispatched_func) exists and drives dispatch exclusion","Fix conceptually adds regexp_matches to that table via INSERT ... ON CONFLICT DO NOTHING"],"points_missed":["Misidentifies primary location as contrib/zdb/zdb--1.3--1.4.sql rather than contrib/relyt/ SQL files (relyt--3.22.sql and later) as the rubric specifies","Does not describe the actual population mechanism as one big OR-chain of 'proname = ...' literals in a single INSERT...SELECT from pg_proc — instead invents a narrative about relyt snapshot vs zdb incremental chains not being fixed","Fails the core required fix: adding `regexp_matches` as another `or proname = 'regexp_matches'` clause in the relyt--*.sql WHERE list — instead proposes a new zdb incremental script, contradicting the rubric's actual fix location","Does not carry the fix forward into the current/newest relyt--*.sql version as required; in fact agent claims regexp_matches is already present in relyt--3.43.sql which is inconsistent with the premise of the bug and rubric"],"reasoning":"The agent correctly found a related catalog table and INSERT-based population pattern but attributed it to the wrong file (zdb--1.3--1.4.sql) rather than the actual location in contrib/relyt/relyt--*.sql per the rubric, and proposed an incorrect fix (new zdb migration) instead of the required minimal fix of adding the proname clause to relyt--*.sql, also incorrectly claiming regexp_matches is already present in the current file which contradicts the task's premise."}} -{"kind":"verdict","task_id":"gotcha-001","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":[],"points_missed":["stale delta-kernel-rs FFI header explanation","fix via rm header/lib","source gcc-toolset-11","rebuild via build.sh release install","must repeat after every rebase"],"reasoning":"The agent's final answer is an authentication failure message with no actual content addressing the question, so no rubric points are satisfied."}} -{"kind":"verdict","task_id":"gotcha-001","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":[],"points_missed":["stale FFI header explanation","rm header/lib fix","source gcc-toolset-11","rebuild via build.sh","must be done after every rebase"],"reasoning":"答案为认证失败的错误信息,未提供任何实质性内容,完全没有回答问题。"}} {"kind":"verdict","task_id":"gotcha-001","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["identified stale delta-kernel-rs FFI header as cause despite submodule update","correct fix: rm installed header and lib","sourced gcc-toolset-11 before rebuild","correct rebuild command via delta-kernel-rs/relyt/build.sh release install","noted this must be redone each rebase, not just once, since incremental cache can skip header copy"],"points_missed":[],"reasoning":"Answer correctly identifies the stale FFI header root cause, gives the exact fix commands including sourcing gcc-toolset-11, and explains why submodule update alone and a plain rebuild wouldn't suffice, hitting all rubric points with no gotchas violated."}} {"kind":"verdict","task_id":"gotcha-001","arm":"memory","run":1,"verdict":{"score":10,"points_hit":["identified stale delta-kernel-rs FFI header despite submodule update","fix: rm installed header and .so","sourcing gcc-toolset-11 before rebuild","rebuild via delta-kernel-rs/relyt/build.sh release install","explained why submodule update alone is insufficient (Rust incremental cache skips copy step)"],"points_missed":["did not explicitly reiterate that this must be redone after every rebase/pull/branch switch going forward"],"reasoning":"Answer covers all core fix steps correctly and in right order, explains root cause accurately, and avoids all gotchas (doesn't rely on just rerunning full build, doesn't assume submodule update alone suffices, sources gcc-toolset-11 first); minor omission is not restating the recurring nature of the issue beyond this instance."}} -{"kind":"verdict","task_id":"gotcha-002","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":[],"points_missed":["Use a large OID value such as 9999999, not a small one","Small hardcoded OIDs can collide with a real relation's OID","This convention comes from a real fix commit","Test should assert expected error behavior, not depend on OID being unused by coincidence"],"reasoning":"代理未能给出任何回答(认证失败/会话过期),因此没有涉及任何评分点,也未能避免任何反向扣分项相关的错误建议。"}} -{"kind":"verdict","task_id":"gotcha-002","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":[],"points_missed":["Use a large OID value such as 9999999, not a small one","Small hardcoded OIDs can collide with a real relation's OID","Convention comes from real fix (88888 to 9999999)","Test should assert expected error behavior, not depend on OID being unused by coincidence"],"reasoning":"The agent's response was a failure message (\"Failed to authenticate: OAuth session expired\") with no actual answer content provided, so no rubric points can be credited."}} {"kind":"verdict","task_id":"gotcha-002","arm":"memory","run":0,"verdict":{"score":4,"points_hit":["Test should assert expected 'relation not found' behavior rather than relying on coincidence (implied via capture-and-drop robust option)"],"points_missed":["Did not recommend 9999999 as the large OID value","Did not identify the small/large OID collision risk framing from the actual repo convention","Did not reference the real commit/history showing 88888→9999999 change","Actually suggested a small OID (100, below FirstNormalObjectId) as a viable option, contradicting the expected 'use large OID' guidance"],"reasoning":"The agent gave a thoughtful, well-researched answer but landed on the opposite convention from the rubric: it recommended small OIDs (or capture-and-drop) rather than a large OID like 9999999, and never surfaced the actual repo history/convention behind that choice. This violates the 'did NOT suggest a small/low-numbered arbitrary OID' gotcha, incurring a deduction, while still partially addressing collision-risk reasoning in a different (Reverse) direction."}} {"kind":"verdict","task_id":"gotcha-002","arm":"memory","run":1,"verdict":{"score":3,"points_hit":["did NOT suggest a small/low-numbered arbitrary OID like 88888 or similar","did NOT ignore the cross-cluster-state collision risk (raised a different but related determinism concern)"],"points_missed":["Did not recommend the specific convention of using 9999999","Did not mention this convention comes from the real commit fixing async_load_job/simple_load tests from 88888 to 9999999","Actually recommended against a fixed literal at all, contradicting the expected answer of using a large hardcoded OID like 9999999","Did not frame the test around asserting expected 'relation not found' error/behavior as the core recommendation"],"reasoning":"The agent correctly flags collision risk and avoids suggesting a small OID, but it explicitly rejects using a hardcoded large literal (the rubric's core recommended answer) in favor of a dynamic max(oid)+1 approach, missing the specific 9999999 convention and its historical basis entirely."}} -{"kind":"verdict","task_id":"gotcha-003","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":[],"points_missed":["global FDB key scoping explanation","bare count(*) mixing issue","relfilenode capture+JOIN fix","pg_catalog.zdb_* filtering","reference to setup_backup.sql example"],"reasoning":"The agent's response is an authentication failure error message with no actual answer content, so none of the rubric's answer points were addressed."}} -{"kind":"verdict","task_id":"gotcha-003","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":[],"points_missed":["FDB-introspection UDFs return global keys","bare count(*) mixes in other tests' state","fix via relfilenode capture + JOIN","pg_catalog.zdb_* catalog tables need same filtering","reference to setup_backup.sql pattern"],"reasoning":"The agent's response is an authentication failure message with no actual content addressing the question, so it hits none of the rubric's answer points and violates both gotcha conditions by providing no guidance at all."}} {"kind":"verdict","task_id":"gotcha-003","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["FDB introspection UDFs return global keys not scoped to test table","bare count(*) mixes in other tests' leftover tombstone/segfile/visimap keys","fix via capturing relfilenode into temp table before DROP and joining","cites setup_backup.sql as reference pattern","did not use bare count(*) without filtering","did not assume UDFs are pre-scoped"],"points_missed":["did not explicitly mention pg_catalog.zdb_* catalog tables (zdb_backup_gc_records, zdb_shard_stats) also being global and needing relfilenode filtering"],"reasoning":"Answer correctly identifies root cause, flakiness mechanism, and the relfilenode-join fix with the exact reference file, avoiding both gotchas; it only omits the additional point about pg_catalog.zdb_* catalog tables sharing the same global-scope issue."}} {"kind":"verdict","task_id":"gotcha-003","arm":"memory","run":1,"verdict":{"score":10,"points_hit":["FDB-introspection UDFs return global keys not scoped to test's table","bare count(*) mixes in tombstone/segfile/visimap keys from other tests in same schedule/FDB instance","fix: capture relfilenode into temp table before DROP, then JOIN metainfo queries on relfilenode","cites setup_backup.sql as reference pattern","did not use bare count(*)","did not assume UDFs are pre-scoped"],"points_missed":["did not explicitly mention pg_catalog.zdb_* catalog tables (zdb_backup_gc_records, zdb_shard_stats) needing the same relfilenode-filter treatment"],"reasoning":"Answer correctly identifies the root cause (global FDB keys), the flakiness mechanism, and provides the exact relfilenode-capture-and-join fix with a concrete code example and correct reference file citation, matching nearly all rubric points; it only omits the pg_catalog.zdb_* tables aspect."}} -{"kind":"verdict","task_id":"gotcha-004","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":[],"points_missed":["autovacuum worker already in-flight explanation","fault injector / synchronous vacuum solution","ALTER SYSTEM SET persistence explanation","teardown reset guidance"],"reasoning":"Agent's final answer is an authentication failure message with no actual content addressing the rubric."}} -{"kind":"verdict","task_id":"gotcha-004","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":[],"points_missed":["autovacuum worker already running not killed by GUC flip","fault injector or synchronous vacuum UDF guidance","ALTER SYSTEM SET persistence across schedule","teardown with alter system reset + pg_reload_conf"],"reasoning":"The agent's answer is a failure message (authentication error), containing no actual content addressing the question, so no rubric points can be awarded."}} {"kind":"verdict","task_id":"gotcha-004","arm":"memory","run":0,"verdict":{"score":8,"points_hit":["An autovacuum worker spawned by an earlier test may already be running/in-flight even after the GUC is flipped off, since the GUC change doesn't kill an already-running worker","To reliably prevent background GC, pin the worker with a fault injector (auto_vac_before_backup_dropped_table etc.) or drive the work synchronously via relyt_vacuum_dropped_relation()/relyt_vacuum_aborted_txn()","ALTER SYSTEM SET + pg_reload_conf() persists into every subsequent test in the schedule, so it must be undone","Teardown includes alter system reset relyt.autovacuum + pg_reload_conf(), plus resetting session-level SETs"],"points_missed":[],"reasoning":"Answer hits all four core rubric points accurately and matches the codebase's documented patterns (fault injector names, synchronous UDFs, teardown reset). It adds a plausible but unverified/extraneous claim about coordinator-only reload not reaching segments, which isn't in the rubric and is somewhat speculative, but doesn't contradict or replace the required points, so no gotcha deduction applies."}} {"kind":"verdict","task_id":"gotcha-004","arm":"memory","run":1,"verdict":{"score":10,"points_hit":["An autovacuum worker spawned by an earlier test may already be running/in-flight even after the GUC is flipped off","Recommends pinning worker with fault injector (auto_vac_before_backup_dropped_table / auto_vac_after_backup_dropped_table / auto_vac_after_gc_dropped_table) or driving work synchronously via relyt_vacuum_dropped_relation() / relyt_vacuum_aborted_txn()","Notes ALTER SYSTEM SET + pg_reload_conf() persists into subsequent tests in schedule","Teardown includes alter system reset relyt.autovacuum + pg_reload_conf(), plus resetting session-level SETs and fault injectors"],"points_missed":[],"reasoning":"所有四个要点均被覆盖:解释了已运行的worker不受GUC影响、给出fault injector/同步方案、指出ALTER SYSTEM的持久性问题、并给出包含reset+reload的teardown模板;未违反任何gotcha。"}} -{"kind":"verdict","task_id":"gotcha-005","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":[],"points_missed":["installcheck-default runs through pg_isolation2_regress not pg_regress","cannot validate expected .out via pg_regress","format must match pg_isolation2_regress conventions","reference results/ for exact format"],"reasoning":"The agent's final answer is an authentication failure with no actual content addressing the question, so no rubric points can be credited."}} -{"kind":"verdict","task_id":"gotcha-005","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":[],"points_missed":["installcheck-default uses pg_isolation2_regress not pg_regress","cannot validate via pg_regress","format must match pg_isolation2_regress conventions","reference results/ for exact format"],"reasoning":"The agent's answer is an authentication failure message, not a substantive response, so no rubric points were addressed at all."}} {"kind":"verdict","task_id":"gotcha-005","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["installcheck-default runs through pg_isolation2_regress (psycopg2-based executor), not pg_regress (psql-based executor) — the two executors produce different output formats","You cannot validate an expected .out file by running it through pg_regress; the command tags, column alignment, and trailing whitespace differ between the two executors","It is fine to hand-write the expected file, but its format must match pg_isolation2_regress conventions: single-line SQL echo, command tags like CREATE/INSERT N/DROP, left-justified (ljust) column alignment with trailing spaces","Reference the actual output already produced under results/ in the regress directory to get the exact format right, rather than guessing"],"points_missed":[],"reasoning":"答案准确识别了根本原因(两种执行器格式不同),明确指出不能用pg_regress验证,并给出了正确的修复方法——参考results/目录下installcheck-default实际产生的输出来生成expected文件,完全符合评分标准的所有要点。"}} {"kind":"verdict","task_id":"gotcha-005","arm":"memory","run":1,"verdict":{"score":10,"points_hit":["installcheck-default runs through pg_isolation2_regress (psycopg2-based executor), not pg_regress (psql-based executor)","You cannot validate an expected .out file by running it through pg_regress; format differs","Format must match pg_isolation2_regress conventions: command tags, column alignment, trailing whitespace","Reference actual output under results/ to get exact format right"],"points_missed":[],"reasoning":"Agent correctly identified the executor mismatch, explained the specific formatting differences (command tags, alignment, trailing whitespace), and directed the user to regenerate/validate against results/foo.out rather than pg_regress. Both gotchas are avoided — no pg_regress validation suggested, no assumption of identical output."}} +{"kind":"run","task_id":"fix-005","arm":"no-memory","run":0,"result":{"task_id":"fix-005","arm":"no-memory","result_text":"","input_tokens":50,"output_tokens":7701,"duration_s":242.114994445,"timed_out":false}} +{"kind":"verdict","task_id":"fix-005","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":null,"points_missed":null,"reasoning":"timed out or empty result"}} +{"kind":"run","task_id":"fix-005","arm":"no-memory","run":1,"result":{"task_id":"fix-005","arm":"no-memory","result_text":"","input_tokens":50,"output_tokens":10113,"duration_s":310.571667917,"timed_out":false}} +{"kind":"verdict","task_id":"fix-005","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":null,"points_missed":null,"reasoning":"timed out or empty result"}} +{"kind":"run","task_id":"gotcha-001","arm":"no-memory","run":0,"result":{"task_id":"gotcha-001","arm":"no-memory","result_text":"This is a known issue, documented in the project's pre-build steps. The cause: Rust's incremental build for `delta-kernel-rs` can short-circuit and skip regenerating/copying the FFI header even though `zdb_deltalake_export.c` now references new enum variants from an updated submodule — `git submodule update --init --recursive` alone doesn't force that regeneration.\n\nFix — force-refresh the installed header/lib and rebuild delta-kernel-rs:\n\n```bash\ncd /workspace/phoenix\nrm -f contrib/zdb/third_party/install/include/delta_kernel_ffi.h\nrm -f contrib/zdb/third_party/install/lib/libdelta_kernel_ffi.so\nsource /opt/rh/gcc-toolset-11/enable\n(cd contrib/zdb/third_party \u0026\u0026 ./delta-kernel-rs/relyt/build.sh release install)\n```\n\nThen rerun your full build. Both symptoms you're seeing (`PrimitiveType* undeclared` at compile time, `Schema inconsistent` at runtime) are the two faces of the same stale-header problem — worth double-checking after this that the header's timestamp/content actually changed before kicking off the full rebuild.","input_tokens":2,"output_tokens":905,"duration_s":21.990569661,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-001","arm":"no-memory","run":0,"verdict":{"score":10,"points_hit":["stale delta-kernel-rs FFI header cause due to Rust incremental build short-circuit","fix: force-delete installed header and lib","sourcing gcc-toolset-11 before rebuild","correct rebuild command via delta-kernel-rs/relyt/build.sh release install","did not assume submodule update alone was sufficient","did not skip gcc-toolset-11 sourcing","did not just re-run full build blindly"],"points_missed":[],"reasoning":"答案准确指出了 stale header 的根本原因、正确的修复命令序列,并且强调了 submodule update 本身不够、需要 source gcc-toolset-11,未犯任何 gotcha 错误;虽未显式强调'每次 rebase 后都要做',但整体回答完整覆盖了核心技术点。"}} +{"kind":"run","task_id":"gotcha-001","arm":"no-memory","run":1,"result":{"task_id":"gotcha-001","arm":"no-memory","result_text":"Both artifacts are present and stale. Run:\n\n```bash\nrm -f contrib/zdb/third_party/install/include/delta_kernel_ffi.h\nrm -f contrib/zdb/third_party/install/lib/libdelta_kernel_ffi.so\nsource /opt/rh/gcc-toolset-11/enable\n(cd contrib/zdb/third_party \u0026\u0026 ./delta-kernel-rs/relyt/build.sh release install)\n```\n\nThen rerun the full Neon build (`sh neon/cicd/build.sh`). Deleting the files removes Rust's ability to short-circuit the copy step, so the header/lib get regenerated from the current submodule commit and stay in sync with what `zdb_deltalake_export.c` expects.\n\nIf you want, I can save a memory noting this is easy to half-do (submodule update alone isn't sufficient) so it comes up automatically next time you hit this class of error — want me to?","input_tokens":4,"output_tokens":978,"duration_s":32.239170343,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-001","arm":"no-memory","run":1,"verdict":{"score":10,"points_hit":["Identified stale delta-kernel-rs FFI header due to Rust incremental build short-circuiting","Correct fix: rm header and lib","Sourced gcc-toolset-11 before rebuild","Correct rebuild command","Did not rely on submodule update alone","Did not just rerun full build blindly (explained why deletion is necessary first, then rerun full build)","Did not skip gcc-toolset-11 sourcing"],"points_missed":[],"reasoning":"Answer covers all required fix steps in correct order with correct rationale, correctly avoids all three gotchas, and even notes the recurring nature of the issue implicitly via the memory-save offer."}} +{"kind":"run","task_id":"gotcha-002","arm":"no-memory","run":0,"result":{"task_id":"gotcha-002","arm":"no-memory","result_text":"Good — the codebase already has a pattern for this: several tests explicitly assign OIDs via `WITH (tableoid=…, relfilenode=…)` (e.g. `contrib/zdb/regress/sql/create_with_oid.sql`, `vacuum/vacuum_priority.sql`).\n\n**Recommendation:** don't gamble on a hardcoded \"looks unused\" literal. Instead, create a table with an explicit `relfilenode=`/`tableoid=` (a memorable value, e.g. a date-encoded one like the existing `20240701` convention), capture its oid, then `DROP TABLE` it and use that now-freed oid as your \"nonexistent relation\" input. That guarantees the oid is truly absent *at test run time*, which a fixed magic number can't promise.\n\nGotchas if you hardcode a literal anyway:\n- **Don't use small numbers** (\u003c 16384, `FirstNormalObjectId`) — those are reserved for pinned system catalog/type/proc objects, so a \"safe-looking\" small oid may actually collide with a real system object.\n- **Don't use `0`** — many code paths special-case `InvalidOid` differently from \"not found,\" so it won't exercise the real negative-lookup path.\n- **The oid counter is cluster-wide** (shared across databases and segments, and persists across `dropdb`/`createdb` in the dev cluster your test schedule reuses) — a fixed high literal could theoretically get consumed over many local iterations, though realistically that's remote unless you go absurdly high (billions).\n- If the target function is one of the FDB/gmeta introspection UDFs (`zdb_get_metainfo_for_*`), first verify in the C source whether it actually keys on relation oid or relfilenode — CLAUDE.md flags these as relfilenode-keyed and global, so \"oid\" in a wrapper's SQL signature may not mean what it sounds like.","input_tokens":8,"output_tokens":5616,"duration_s":95.160335691,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-002","arm":"no-memory","run":0,"verdict":{"score":3,"points_hit":["did NOT suggest a small/low-numbered arbitrary OID like 88888","did NOT ignore the cross-cluster-state collision risk (mentions oid counter concerns)"],"points_missed":["did not recommend the large OID value 9999999","did not identify that small hardcoded OIDs like 88888 can collide with real relation OIDs across cluster states","did not reference the actual commit/history changing async_load_job/simple_load tests from 88888 to 9999999","did not clearly phrase recommendation around asserting expected 'relation not found' behavior rather than depending on OID being coincidentally unused"],"reasoning":"The agent avoided recommending a small OID and raised some collision concerns, but never landed on the actual expected answer (9999999) or referenced the real historical fix; instead it proposed an overengineered create-then-drop workaround not grounded in the rubric's actual convention, missing most of the key answer points."}} +{"kind":"run","task_id":"gotcha-002","arm":"no-memory","run":1,"result":{"task_id":"gotcha-002","arm":"no-memory","result_text":"Short answer: don't hand-pick a magic number — capture a real, then-dropped OID instead. If you must hardcode one, avoid two specific traps:\n\n**Avoid OID 0 (`InvalidOid`).** A lot of PostgreSQL/Greenplum code special-cases `InvalidOid` (skips a lookup, takes an early-return branch, or errors with a different message) rather than going through the normal \"relation not found\" path. If the introspection function checks `OidIsValid()` before doing its catalog/FDB lookup, testing with `0` exercises that check, not the \"nonexistent relation\" path you actually care about.\n\n**Avoid small numbers (\u003c 16384, `FirstNormalObjectId`).** Those are reserved for bootstrap/system catalog objects and are pinned for the life of the cluster. Depending on what the target function does with the OID (e.g. if it does a `pg_class` lookup rather than going straight to relfilenode/FDB), a small hardcoded value has a real chance of resolving to an actual system catalog relation instead of \"nothing,\" silently turning your negative test into something else.\n\nGiven those, a hardcoded value should be a large, implausible-to-allocate OID — e.g. something like `3000000000` (or near `4294967295`, the max for the unsigned 4-byte `oid` type). GPDB's OID counter starts around 16384 and increments once per new object cluster-wide; it won't reach billions within any single test run, so this is safe from accidental collision, and OIDs aren't reused after drop except on 32-bit wraparound.\n\n**Better than hardcoding**, and the pattern used in Postgres core's own regression tests for this exact scenario:\n```sql\ncreate table zdb_negtest_tmp(a int);\nselect oid as negtest_oid from pg_class where relname = 'zdb_negtest_tmp' \\gset\ndrop table zdb_negtest_tmp;\n-- :negtest_oid is now a guaranteed-dangling OID\nselect zdb_get_metainfo_for_file(:negtest_oid);\n```\nThis is self-documenting (it's obviously \"was real, now isn't\"), immune to any assumptions about reserved ranges, and side-steps the `InvalidOid` special-casing issue entirely.\n\nOne more thing specific to this codebase per `CLAUDE.md`: several ZDB introspection UDFs (`zdb_get_metainfo_for_file`, `_for_drop_flag`, `_for_visimap`) return **global** FDB keys, not scoped to a relation. Worth double-checking that for a genuinely nonexistent OID the function actually returns empty/errors, rather than something that happens to still match leftover global state from an earlier test in the same schedule.","input_tokens":10,"output_tokens":4223,"duration_s":107.043903255,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-002","arm":"no-memory","run":1,"verdict":{"score":8,"points_hit":["Explicitly warns against small hardcoded OIDs like 88888 (calls out the exact number and the collision risk)","Explains the cross-cluster-state collision risk in detail (system catalog ranges, reserved OIDs)","Recommends the test assert 'relation not found' behavior rather than relying on OID being unused by coincidence, and even proposes a more robust drop-then-reference pattern"],"points_missed":["Never mentions the specific expected value 9999999, nor the convention that large simple values like that are the norm","Does not reference the actual commit history (async_load_job/simple_load tests changing from 88888 to 9999999) that motivates this convention"],"reasoning":"答案在避免小OID冲突和强调不应仅依赖巧合未被占用这两点上表现扎实,甚至给出了更稳健的drop-then-reference方案,但完全没有提到题目期望的具体约定值9999999及其来源commit,导致关键的'convention来源'得分点缺失。"}} +{"kind":"run","task_id":"gotcha-003","arm":"no-memory","run":0,"result":{"task_id":"gotcha-003","arm":"no-memory","result_text":"I have the full pattern now. Here's the answer:\n\n**Why it's flaky:** `zdb_get_metainfo_for_file` / `_for_drop_flag` / `_for_visimap` read FDB directly and return **every** key in the relevant keyspace cluster-wide — they don't filter by table. In isolation your test is the only thing touching FDB, so a bare `count(*)` happens to equal your table's row count. Run inside the full schedule, other tests' tombstone/segfile/visimap/drop-flag keys (left over from prior tests, or from concurrent/autovacuum activity) are still present in FDB and get counted too, so the total no longer matches what you expect — and the exact number depends on scheduling/ordering, hence \"flaky.\"\n\n**Fix:** capture the table's `relfilenode` *before* the `DROP`, then join every introspection query against it instead of counting rows directly. Concretely:\n\n1. Before dropping, save the relfilenode:\n```sql\ncreate table if not exists relfilenode_record_for_drop_tbl(relname text, relfilenode oid);\ninsert into relfilenode_record_for_drop_tbl\n select relname, relfilenode from pg_class where oid = 'your_tbl'::regclass;\n```\n\n2. Drop the table.\n\n3. Scope every metainfo count to that relfilenode via a join, e.g.:\n```sql\nselect count(*) from\n(\n select tablespace, dbid, relationid, xmin from zdb_get_metainfo_for_file(2, 'your_tbl', 'S')\n) a\njoin\n(\n select relfilenode from relfilenode_record_for_drop_tbl where relname = 'your_tbl'\n) b on a.relationid = b.relfilenode;\n```\nSame pattern for `zdb_get_metainfo_for_drop_flag` (join on `'D'` variant) and `zdb_get_metainfo_for_visimap` (join on `'V'` variant) — see `contrib/zdb/regress/sql/backup_and_gc/setup_backup.sql` (`get_meta_file_count` / `get_meta_visimap_count` / `get_meta_drop_flag_count`) for the exact reusable helper functions, and `contrib/zdb/regress/sql/con_update_and_compact/drop_table.sql` for the capture-before-drop usage pattern. Drop the `relfilenode_record_for_drop_tbl` helper table (or use distinctly-named test rows) in teardown so it doesn't leak state to later tests either.\n\nThis also fixes the `zdb_shard_stats`/`zdb_backup_gc_records` catalog-table flakiness the same way, since those are global too.","input_tokens":8,"output_tokens":1635,"duration_s":53.345444116,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-003","arm":"no-memory","run":0,"verdict":{"score":10,"points_hit":["FDB-introspection UDFs return global FDB keys, not scoped to caller's table","bare count(*) mixes in leftover keys from other tests in the schedule/cluster","fix: capture relfilenode before DROP into temp table, JOIN introspection queries on it","pg_catalog.zdb_* catalog tables (zdb_shard_stats/zdb_backup_gc_records) have same global-scope issue and need relfilenode filtering","cites setup_backup.sql as reference pattern"],"points_missed":[],"reasoning":"答案完整覆盖了所有 rubric 要点:全局作用域根因、flaky 原因、relfilenode 捕获+JOIN 的修复方案、pg_catalog.zdb_* 同样问题、并引用了 setup_backup.sql 作为参考模式,没有触犯任何 gotcha(未使用未过滤的裸 count(*),也未假设 UDF 已按表作用域限定)。"}} +{"kind":"run","task_id":"gotcha-003","arm":"no-memory","run":1,"result":{"task_id":"gotcha-003","arm":"no-memory","result_text":"Why it's flaky: `zdb_get_metainfo_for_file`/`_for_drop_flag`/`_for_visimap` return **global** FDB key scans, not results scoped to your table. When run in a shared schedule, every test runs against the same `isolation2test` database/FDB instance, so tombstone, segfile, and visimap keys left behind by other tests (or still in-flight autovacuum/GC work from earlier tests) show up in your `count(*)` too. Your test passes alone because there's no other test's metadata around to pollute the count — under the full schedule, the count becomes nondeterministic based on run order/timing.\n\nFix: capture your table's `relfilenode` **before** the `DROP TABLE`, then `JOIN` the metainfo UDF output on that relfilenode instead of taking a bare count. The existing pattern for this lives in `contrib/zdb/regress/sql/backup_and_gc/setup_backup.sql` (`get_meta_file_count`/`get_meta_visimap_count`/`get_meta_drop_flag_count`) and is used in `contrib/zdb/regress/sql/con_update_and_compact/drop_table.sql`. Concretely:\n\n```sql\ncreate table if not exists relfilenode_record_for_drop_tbl(relname text, relfilenode oid, relid oid);\n\ninsert into relfilenode_record_for_drop_tbl\n select relname, relfilenode, oid from pg_class where oid = 'your_tbl'::regclass;\n\ndrop table your_tbl;\n\nselect count(*) from\n (select relationid from zdb_get_metainfo_for_file(2, '\u003cspace\u003e', 'S')) a\n join\n (select relfilenode from relfilenode_record_for_drop_tbl where relname = 'your_tbl') b\n on a.relationid = b.relfilenode;\n```\n\nSame join pattern for the drop-flag and visimap variants. Also make sure your test doesn't leak session/system GUCs and either pins autovacuum with a fault injector or drives cleanup synchronously via `relyt_vacuum_dropped_relation()`/`relyt_vacuum_aborted_txn()`, per the same isolation notes in CLAUDE.md — otherwise a background autovacuum worker from an earlier test can still be racing your assertion even with the relfilenode join.","input_tokens":6,"output_tokens":1182,"duration_s":30.132138815,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-003","arm":"no-memory","run":1,"verdict":{"score":10,"points_hit":["These FDB-introspection UDFs return global FDB keys by default, not scoped to the current test's table","A bare count(*) mixes in tombstone/segfile/visimap keys left behind by other tests in the same schedule/cluster/FDB instance","Fix: capture the test table's relfilenode into a temp table before the DROP, then JOIN every FDB-introspection query on that relfilenode","contrib/zdb/regress/sql/backup_and_gc/setup_backup.sql is a reference example of this relfilenode-join pattern","did NOT use a bare count(*) without filtering by relfilenode","did NOT assume these introspection UDFs are already scoped to the caller's table"],"points_missed":["pg_catalog.zdb_* catalog tables (e.g. zdb_backup_gc_records, zdb_shard_stats) have the same global-scope problem and must also be filtered by relfilenode — not explicitly mentioned"],"reasoning":"The answer correctly identifies the global-scope root cause, mixing of stale metadata, provides the relfilenode-capture-and-join fix with a concrete example, and cites the reference file; it omits mentioning the analogous pg_catalog.zdb_* catalog table issue but covers everything else including a bonus note on autovacuum interference."}} +{"kind":"run","task_id":"gotcha-004","arm":"no-memory","run":0,"result":{"task_id":"gotcha-004","arm":"no-memory","result_text":"Two things are compounding to make this flaky, and both are visible in `contrib/zdb/src/storage/zdb_vacuum.c` and the existing test `contrib/zdb/regress/sql/backup_and_gc/drop_table_left_meta.sql`.\n\n## Why GC still runs despite `alter system set relyt.autovacuum = off; select pg_reload_conf();`\n\n**1. It's a race, not a hard stop.** The decision to *initiate* GC/backup/merge is made only on the coordinator (`IS_PRIMARY_MASTER()` gate, `zdb_vacuum.c:1887-1894`), on its own cadence (`autovacuum_naptime`, possibly stretched by `relyt.adaptive_autovacuum_naptime`/`relyt.max_autovacuum_naptime`). If a previous test's autovacuum launcher already decided to dispatch a task *before* your `ALTER SYSTEM SET ... off` landed, that background worker is already forked and running — flipping the GUC blocks new decisions, it doesn't cancel work already dispatched to segments. That worker can finish (and GC your test's rows) well inside your test's window.\n\n**2. `pg_reload_conf()` alone doesn't reach segments.** `ALTER SYSTEM SET` itself *is* auto-dispatched to every segment (`utility.c:834-841`, via `CdbDispatchUtilityStatement`), so the config files do get updated cluster-wide. But a bare `select pg_reload_conf();` only signals the backend/postmaster you're connected to — the coordinator. Segment postmasters keep running on stale config until they get their own SIGHUP. The existing test does this correctly:\n\n```sql\nselect * from pg_reload_conf() union all select pg_reload_conf() from gp_dist_random('gp_id');\n```\n\nIf your setup only does the plain `select pg_reload_conf();`, segments can lag behind.\n\n## What to do instead\n\nPrefer **deterministic control** over racing the GUC, following the pattern already established in `drop_table_left_meta.sql`:\n\n- **Pin the exact point with fault injectors** rather than trusting timing: `gp_inject_fault('auto_vac_before_backup_dropped_table', 'suspend', ...)` / `auto_vac_after_backup_dropped_table` / `auto_vac_after_gc_dropped_table`. This guarantees nothing GCs mid-assertion regardless of launcher timing.\n- Or **drive GC synchronously** via `relyt_vacuum_dropped_relation()` / `relyt_vacuum_aborted_txn()` instead of waiting on autovacuum at all.\n- If you do rely on the GUC, reload on **all segments**, not just the coordinator (the `gp_dist_random('gp_id')` union above).\n\n## Teardown, so you don't poison later tests\n\nMirror `drop_table_left_meta.sql:586-596` — reset every GUC you altered, and reload everywhere:\n\n```sql\n-- reset any fault injectors you set, first — a left-over 'suspend' hangs the next test's worker forever\nselect gp_inject_fault('auto_vac_before_backup_dropped_table', 'reset', '', 'isolation2test', '', 1, 1, 0, dbid, -1)\n from gp_segment_configuration where role = 'p' and content = -1;\n-- ...same for any other injection points you touched\n\nalter system reset relyt.autovacuum;\n-- reset any other relyt.* GUCs you touched (naptime, max_workers, etc.)\nselect * from pg_reload_conf() union all select pg_reload_conf() from gp_dist_random('gp_id');\n```\n\nAlso worth checking: if your assertions use the FDB-introspection UDFs (`zdb_get_metainfo_for_*`, `zdb_backup_gc_records`, etc.), scope them by your table's `relfilenode` per the project convention — those are global, so even a GC event from something unrelated to your test won't false-positive your counts.","input_tokens":28,"output_tokens":9497,"duration_s":371.084979463,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-004","arm":"no-memory","run":0,"verdict":{"score":10,"points_hit":["Explained already-running/in-flight autovacuum worker from earlier test isn't killed by GUC flip","Recommended fault injector pinning (auto_vac_before/after_backup_dropped_table, auto_vac_after_gc_dropped_table) or synchronous drive via relyt_vacuum_dropped_relation()/relyt_vacuum_aborted_txn()","Noted ALTER SYSTEM SET + pg_reload_conf() persists across schedule and must be undone","Teardown includes alter system reset relyt.autovacuum plus pg_reload_conf(), and resetting session-level GUCs"],"points_missed":[],"reasoning":"The answer correctly identifies the race condition with already-dispatched workers, recommends the exact fault-injector/synchronous-UDF pattern from drop_table_left_meta.sql, and provides a complete teardown with alter system reset + pg_reload_conf(). No gotchas violated; all four answer points are covered thoroughly, plus additional correct detail about segment dispatch."}} +{"kind":"run","task_id":"gotcha-004","arm":"no-memory","run":1,"result":{"task_id":"gotcha-004","arm":"no-memory","result_text":"Investigating the actual GUC/GC code paths now — I'll report back once the agent returns with concrete findings.","input_tokens":4,"output_tokens":1819,"duration_s":673.368612249,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-004","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":[],"points_missed":["autovacuum worker already in-flight explanation","fault injector / synchronous UDF teardown guidance","ALTER SYSTEM SET persistence explanation","alter system reset + pg_reload_conf() teardown"],"reasoning":"The agent gave no substantive answer at all, only a placeholder saying it would investigate and report back later; none of the required content or gotchas were addressed."}} +{"kind":"run","task_id":"gotcha-005","arm":"no-memory","run":0,"result":{"task_id":"gotcha-005","arm":"no-memory","result_text":"That's a known gotcha called out in this repo's testing notes: `make installcheck-default` runs through **`pg_isolation2_regress`**, not `pg_regress` — and the two use different clients with different output formatting, even for identical SQL/results:\n\n- `pg_isolation2_regress` drives queries via **psycopg2**\n- plain `pg_regress` drives queries via **psql**\n\nSo an expected file captured from a `pg_regress` run will differ from what `installcheck-default` produces even when the actual query results are correct. Concretely, the formatting differences include:\n- SQL echoed back as a single line (vs. psql's multi-line echo)\n- Command tags like `CREATE`, `INSERT 0 N`, `DROP` appearing where psql might format differently\n- Column values left-justified (`ljust`) with trailing whitespace, which psql doesn't produce the same way\n\n**Fix**: don't generate the expected `.out` from `pg_regress`. Instead run the test once via the actual `installcheck-default` path and use its `results/foo.out` as the basis for `expected/foo.out` (diff it against what you expect semantically, then hand-tune). From `contrib/zdb/regress/`:\n\n```bash\nmake install installcheck-default # let it fail/generate results/foo.out\ndiff results/foo.out expected/foo.out # inspect, then copy results/foo.out → expected/foo.out if correct\n```\n\nIf you're hand-writing it (e.g. for a not-yet-passing case), model the formatting on an existing file's `results/` output rather than a `pg_regress` capture.","input_tokens":2,"output_tokens":825,"duration_s":43.355494326,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-005","arm":"no-memory","run":0,"verdict":{"score":10,"points_hit":["installcheck-default runs through pg_isolation2_regress (psycopg2-based executor), not pg_regress (psql-based executor)","You cannot validate an expected .out file by running it through pg_regress; formats differ","Format must match pg_isolation2_regress conventions: single-line SQL echo, command tags, ljust alignment with trailing spaces","Reference actual output already produced under results/ rather than guessing"],"points_missed":[],"reasoning":"Answer correctly identifies the executor mismatch, lists the specific formatting differences (single-line echo, command tags, ljust trailing whitespace), and recommends using results/ output as the basis instead of pg_regress; no gotchas were violated."}} +{"kind":"run","task_id":"gotcha-005","arm":"no-memory","run":1,"result":{"task_id":"gotcha-005","arm":"no-memory","result_text":"Concretely:\n\n1. Run `make install installcheck-default` once (it'll fail the diff, that's fine) — it drops the actual output in `contrib/zdb/regress/results/foo.out`.\n2. Inspect `results/foo.out` and use *that* formatting as the template for your `expected/foo.out`, keeping the query results/values you want to assert but matching the real command-tag/alignment/whitespace style.\n3. Re-run `installcheck-default` to confirm a clean diff.\n\nDon't validate the expected file with `pg_regress` — always confirm against the actual `installcheck-default`/`pg_isolation2_regress` run.","input_tokens":4,"output_tokens":1057,"duration_s":57.606347328,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-005","arm":"no-memory","run":1,"verdict":{"score":8,"points_hit":["installcheck-default runs through pg_isolation2_regress (psycopg2-based executor), not pg_regress — different formats","expected file format must match pg_isolation2_regress conventions (command tags, alignment)","Reference actual output already produced under results/ rather than guessing","did NOT validate using pg_regress","did NOT assume pg_regress and pg_isolation2_regress produce same format"],"points_missed":["Did not explicitly name the specific format details (single-line SQL echo, ljust column alignment with trailing spaces, CREATE/INSERT N/DROP tags) — only vaguely referenced 'command-tag/alignment/whitespace style'"],"reasoning":"The answer correctly identifies the root cause (different executors) and correct fix (use results/ output as template, don't validate with pg_regress), hitting all gotcha avoidances, but is less specific than the rubric's detailed format description."}} diff --git a/eval/results/baseline-report.md b/eval/results/baseline-report.md index 9ed5642b..ae6dc093 100644 --- a/eval/results/baseline-report.md +++ b/eval/results/baseline-report.md @@ -1,5 +1,15 @@ # Baseline Report — Memory Eval Foundation (Phase 1) +> **CORRECTION (2026-07-28)**: the original run's 12 no-memory tuples (all 10 +> gotcha + fix-005 ×2) had silently died on `OAuth session expired` and were +> scored 0, inflating the headline uplift. After checkpoint re-running exactly +> those tuples, the corrected baseline is **memory 6.000 / no-memory 6.367 / +> uplift −0.367** (gotcha no-memory = 7.90, not 0.00 — the no-memory arm reads +> the same CLAUDE.md the gotcha tasks were mined from). The corrected sidecar +> and scorecard are committed; the analysis below reflects the ORIGINAL +> (artifact-contaminated) numbers and is retained as a historical record — +> read `wave1-report.md` for the corrected picture and its implications. + **Date**: 2026-07-24 (L3 re-judge completed 2026-07-27) **Baseline commit**: `2adc24c` (engram, branch feat/memory-eval-foundation) **Spec**: `docs/superpowers/specs/2026-07-24-memory-eval-optimization-design.md` diff --git a/eval/results/w1-final-l3.json b/eval/results/w1-final-l3.json index 6b518e17..671d82df 100644 --- a/eval/results/w1-final-l3.json +++ b/eval/results/w1-final-l3.json @@ -1,5 +1,5 @@ { - "git_sha": "c1d0c4f", + "git_sha": "393d2d1", "date": "2026-07-28", "suite": "l3", "metrics": { @@ -7,8 +7,8 @@ "judge_failed_memory": 0, "judge_failed_no-memory": 0, "mean_score_memory": 6.966666666666667, - "mean_score_no-memory": 3.9833333333333334, - "uplift": 2.9833333333333334 + "mean_score_no-memory": 7.15, + "uplift": -0.18333333333333357 }, "per_item": [ { @@ -410,24 +410,24 @@ { "id": "fix-005/no-memory/run0", "values": { - "duration_s": 3.737184962, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 688.419679019, + "input_tokens": 12, + "output_tokens": 3284, + "score": 3, "timed_out": 0 }, - "note": "The agent's final answer is an authentication failure message with no actual content addressing the task, so no rubric points can be awarded." + "note": "The agent correctly identified the relyt_non_dispatched_func catalog table as the mechanism but misattributed its location (zdb vs relyt SQL files) and misdescribed the population pattern (LIKE-pattern matching vs OR-chain of proname literals), leading to an incorrect minimal fix that doesn't match the rubric's specified approach of adding an OR clause to the existing INSERT statement." }, { "id": "fix-005/no-memory/run1", "values": { - "duration_s": 1.759142904, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 519.458599621, + "input_tokens": 50, + "output_tokens": 12701, + "score": 3, "timed_out": 0 }, - "note": "The agent's response is an authentication failure message, not an actual answer to the question, so no rubric points were addressed at all." + "note": "The agent correctly located the catalog table but got the actual bug and fix badly wrong: it claims regexp_matches is already present in the exclusion list and instead proposes a speculative dispatcher.c code change, which contradicts the rubric's expected minimal fix (adding a proname clause to the SQL INSERT statement) and shows a load-bearing unverified claim presented as fact." }, { "id": "fix-005/memory/run0", @@ -454,24 +454,24 @@ { "id": "gotcha-001/no-memory/run0", "values": { - "duration_s": 1.749804959, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 67.027073199, + "input_tokens": 6, + "output_tokens": 1563, + "score": 10, "timed_out": 0 }, - "note": "The agent's final answer is an authentication failure message with no actual content addressing the question, so it fails to hit any rubric points and violates the gotchas by providing no guidance at all." + "note": "The answer correctly diagnoses the stale FFI header/incremental cache issue, gives the exact fix commands in the right order including sourcing gcc-toolset-11, and avoids all three gotchas (doesn't just rerun build, doesn't assume submodule update fixes it, doesn't skip toolset sourcing). Fully matches the rubric." }, { "id": "gotcha-001/no-memory/run1", "values": { - "duration_s": 1.6641877919999999, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 67.664663928, + "input_tokens": 6, + "output_tokens": 2855, + "score": 10, "timed_out": 0 }, - "note": "代理最终未能给出任何答案,仅返回身份验证失败的错误信息,未涉及任何评分要点,也未能避免任何反面扣分项(因为根本没有作答)。" + "note": "The answer correctly identifies the stale Rust incremental cache root cause, distinguishes compile vs runtime symptoms, gives the exact fix commands with gcc-toolset-11 sourced first, and explicitly notes this must be redone per rebase — matching all rubric points with no gotchas violated." }, { "id": "gotcha-001/memory/run0", @@ -498,24 +498,24 @@ { "id": "gotcha-002/no-memory/run0", "values": { - "duration_s": 1.746015987, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 72.764542688, + "input_tokens": 4, + "output_tokens": 2641, + "score": 3, "timed_out": 0 }, - "note": "The agent's answer is an authentication failure message with no actual content addressing the question; no rubric points were addressed." + "note": "The agent correctly avoided small OIDs and correctly identified cluster-state collision risk, hitting the core gotcha points, but it did not converge on the expected concrete answer (9999999) or reference the actual historical convention/commit, and instead proposed an alternative (INT32_MAX or dynamically computed OID) that diverges from the rubric's expected guidance in the appendix." }, { "id": "gotcha-002/no-memory/run1", "values": { - "duration_s": 1.644330941, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 403.471910898, + "input_tokens": 22, + "output_tokens": 8822, + "score": 8, "timed_out": 0 }, - "note": "The agent's response is an authentication failure error message, not an actual answer to the question. There is no content to award any rubric points for." + "note": "答案很好地满足了核心规则(大OID、避免小OID碰撞风险、断言错误行为而非依赖巧合),且明确点出了两个gotcha点(未建议88888类小值、未忽略碰撞风险),但未能提及该惯例源自real commit(88888→9999999)这一具体历史依据,故扣去该部分得分。" }, { "id": "gotcha-002/memory/run0", @@ -542,24 +542,24 @@ { "id": "gotcha-003/no-memory/run0", "values": { - "duration_s": 1.740793637, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 122.805342121, + "input_tokens": 6, + "output_tokens": 1411, + "score": 10, "timed_out": 0 }, - "note": "The agent's final answer is an authentication failure message with no actual content addressing the question, so it hits none of the rubric points and violates both gotchas by providing no guidance at all." + "note": "Answer correctly identifies the root cause (global FDB keys, not table-scoped), explains the flakiness mechanism, and gives the exact relfilenode-capture-and-join fix with a concrete code example referencing the correct file; it omits the pg_catalog.zdb_* global-table caveat." }, { "id": "gotcha-003/no-memory/run1", "values": { - "duration_s": 1.806603182, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 98.38825361, + "input_tokens": 8, + "output_tokens": 1548, + "score": 10, "timed_out": 0 }, - "note": "The agent's answer is an authentication failure message with no actual content addressing the question, so it fails to hit any rubric points and also violates both gotchas by providing no guidance at all." + "note": "The answer correctly identifies the root cause (global FDB keys, not scoped), gives the exact relfilenode-capture-and-join fix, and cites the correct reference file with matching helper function names, hitting nearly all rubric points; it only omits the specific mention of pg_catalog.zdb_* catalog tables needing the same treatment." }, { "id": "gotcha-003/memory/run0", @@ -586,24 +586,24 @@ { "id": "gotcha-004/no-memory/run0", "values": { - "duration_s": 1.651025616, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 542.541928725, + "input_tokens": 28, + "output_tokens": 12047, + "score": 8, "timed_out": 0 }, - "note": "The agent's answer failed entirely (authentication error), providing no content addressing any rubric point." + "note": "The answer covers all four core rubric points (stale in-flight worker, fault-injector/synchronous mitigation, GUC persistence across schedule, correct teardown with alter system reset + pg_reload_conf), avoiding both gotchas. However, it leads with a speculative and unverified claim about pg_reload_conf() dispatch behavior on segments, stated with specific line-number citations as if verified, which is a confident-but-unconfirmed assertion not supported by the rubric and adds risk of misleading the user." }, { "id": "gotcha-004/no-memory/run1", "values": { - "duration_s": 1.8088880010000001, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 247.618378029, + "input_tokens": 70, + "output_tokens": 6948, + "score": 10, "timed_out": 0 }, - "note": "The agent's answer failed to authenticate and produced no content addressing the question, so no rubric points can be awarded." + "note": "答案准确覆盖了所有四个得分点:明确指出GUC翻转不会终止已经在途的autovacuum worker,推荐使用drop_table_left_meta.sql中的具体fault injector注入点或relyt_vacuum_dropped_relation()/relyt_vacuum_aborted_txn()同步驱动,正确说明ALTER SYSTEM SET会泄漏到后续测试,并给出包含alter system reset + pg_reload_conf()的完整teardown。两个gotcha也均未违反。" }, { "id": "gotcha-004/memory/run0", @@ -630,24 +630,24 @@ { "id": "gotcha-005/no-memory/run0", "values": { - "duration_s": 1.68328275, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 49.178871609, + "input_tokens": 2, + "output_tokens": 724, + "score": 10, "timed_out": 0 }, - "note": "Agent's answer is an authentication failure with no content addressing the question at all, so none of the rubric points were addressed." + "note": "答案完整覆盖了所有得分点:正确识别两种执行器的区别、未误用pg_regress验证、给出了正确的hand-writing格式要求,并建议参照results/目录下的实际输出而非猜测格式,未触犯任何gotcha扣分项。" }, { "id": "gotcha-005/no-memory/run1", "values": { - "duration_s": 1.834204164, - "input_tokens": 0, - "output_tokens": 0, - "score": 0, + "duration_s": 46.746539234, + "input_tokens": 2, + "output_tokens": 863, + "score": 10, "timed_out": 0 }, - "note": "The agent's answer is an authentication failure message with no substantive content addressing the question at all." + "note": "The answer correctly identifies the executor mismatch, explains the format differences, and instructs the user to regenerate/copy from results/ produced by installcheck-default rather than pg_regress, hitting all rubric points with no gotchas violated." }, { "id": "gotcha-005/memory/run0", diff --git a/eval/results/w1-final-l3.json.runs.jsonl b/eval/results/w1-final-l3.json.runs.jsonl index be89bbaf..82647f8e 100644 --- a/eval/results/w1-final-l3.json.runs.jsonl +++ b/eval/results/w1-final-l3.json.runs.jsonl @@ -68,53 +68,53 @@ {"kind":"verdict","task_id":"fix-004","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":null,"points_missed":null,"reasoning":"timed out or empty result"}} {"kind":"run","task_id":"fix-004","arm":"memory","run":0,"result":{"task_id":"fix-004","arm":"memory","result_text":"## Summary\n\n**Root cause:** In `contrib/zdb/src/storage/zdb_vacuum.c`, the per-shard BACKUP/GC task result handlers for dropped tables — `HandleBackupTaskResultForDroppedTable` (~line 6757) and `HandleGCTaskResultForDroppedTable` (~line 7037) — only handled a segment task failure by logging a warning and `continue`-ing:\n\n```c\nelog(WARNING, \"Execute BACKUP task failed for dropped table %u with status %d\", ...);\ncontinue;\n```\n\nThat shard's `VacuumTask` never got transitioned out of `RUNNING`. `FAILED` was only ever assigned in the `PG_CATCH()` block, which fires solely on a thrown C exception — not on a segment cleanly returning a bad `PGresult` (e.g. a transient error, timeout, or segment restart).\n\nSince `GenerateBackupTaskForDroppedTable`/`GenerateGCTaskForDroppedTable` only re-schedule a shard once its `RUNNING` task disappears (they check `GetZDBVacuumTask(..., RUNNING, ...)`), a single stuck shard permanently blocked retry for that shard — forever, since there's no RUNNING-task timeout. That in turn keeps the FDB drop tombstone (gated by an emptiness check in `gmeta`'s `ZdbClearDropTable`) from ever clearing for the *whole relation*, so both the Parquet files and FDB metadata linger indefinitely — matching the reported symptom, and disproportionately hitting large (multi-shard) tables since they have more chances for one shard to hit a transient failure.\n\nConfirmed via a working precedent in the same file: the MERGE-task handler (~line 4490) already does this correctly, tracking `allSuccess` and calling `UpdateZDBVacuumTaskListStatus(mergeTasks, FAILED)` on any clean failure — the dropped-table handlers were just missing the equivalent branch.\n\n**Fix applied** (`contrib/zdb/src/storage/zdb_vacuum.c`): added an `else UpdateZDBVacuumTaskStatus(task, FAILED)` branch in both handlers' per-shard loops, mirroring the MERGE pattern. Once a shard's task is `FAILED` rather than stuck `RUNNING`, the next autovacuum cycle's `Generate*ForDroppedTable` sees it as no-longer-running and reschedules it — restoring retry instead of permanent orphaning. This is a minimal, low-risk change (2 small additions) that doesn't touch the retention-window logic (which is correctly hardcoded to 0 for dropped tables already) or the FDB-side safety gate (which correctly still refuses to clear the tombstone until data is actually gone).\n\n**Gap worth closing separately:** none of the existing tests in `contrib/zdb/regress/sql/backup_and_gc/` inject a segment-side task failure — only happy-path fault-injector suspend/resume scenarios exist, which is why this went unnoticed. Worth adding a regression test (e.g. a new fault-injector point forcing one shard's dropped-table BACKUP or GC task to fail) to verify it now retries instead of stalling. Want me to add that test, or build/run the existing `backup_and_gc` suite to confirm nothing regressed?","input_tokens":34,"output_tokens":14020,"duration_s":751.204518854,"timed_out":false}} {"kind":"run","task_id":"fix-004","arm":"memory","run":1,"result":{"task_id":"fix-004","arm":"memory","result_text":"## Investigation\n\nThe dropped-table cleanup path is (in `contrib/zdb/src/storage/zdb_vacuum.c` unless noted):\n\n`DROP TABLE` → `heap_drop_with_catalog` → ZDB hook → `ClearMetaForDroppingTable` (`zdb_aocsam.c`) → `SetFlagForDroppingTable` → `DropTableRequest` → gmeta `GMetaZdbDropTable`, which writes **one tombstone key per relfilenode** in FDB WORK space (not per-shard).\n\nThat tombstone doesn't get cleared until BACKUP and GC have run to completion on **every shard** of the dropped relation:\n- `VacuumDroppedRelations` (~7212) is driven off autovacuum's `do_autovacuum()` tail.\n- `GenerateBackupTaskForDroppedTable` / `GenerateGCTaskForDroppedTable` (~6686, and the GC counterpart) build a `couldSchedule[shard]` array and set `couldSchedule[shard] = false` for any shard that still has a `RUNNING` `VacuumTask` row — with no timeout, so a shard only becomes re-schedulable once its RUNNING task disappears.\n- `deletedBackupInterval`/`deletedGCInterval` are hardcoded to `0` for dropped tables (correctly — no rollback window needed once dropped), so the retention window itself was not the culprit.\n\n**Root cause**: `HandleBackupTaskResultForDroppedTable` (~6757) and `HandleGCTaskResultForDroppedTable` (~7037) process per-shard dispatch results. When a segment's `PGresult` status isn't `PGRES_TUPLES_OK`/`PGRES_COMMAND_OK`, the original code just did `elog(WARNING, ...); continue;` and never called `UpdateZDBVacuumTaskStatus(task, FAILED)`. `FAILED` was only ever set from the `PG_CATCH()` block, which only fires on a thrown C-level exception — not a cleanly-returned bad `PGresult`. So a single shard that failed cleanly (not via exception) left its task stuck at `RUNNING` forever, which permanently blocked `couldSchedule[shard]` from ever going back to `true` for that shard — stalling BACKUP/GC, and therefore the tombstone-clear (and `zdb_shard_stats`/`zdb_backup_gc_records` row purge via `ZDBAfterVacuumCommit`/`CheckDroppedRelationsExist`), for the **whole table** indefinitely. Bigger, more shard-heavy tables are more likely to hit at least one flaky/failing shard, which matches \"large tables in particular.\"\n\nThe correct pattern already existed for the MERGE-task handler (~4490-4548): it tracks per-shard success and explicitly marks failed shards `FAILED` so they get retried next cycle.\n\n## Fix\n\nAdd the missing `else` branch mirroring the MERGE-task pattern in both dropped-table result handlers, so a cleanly-failed shard's task transitions out of `RUNNING` and becomes eligible for retry on the next autovacuum cycle:\n\n- `HandleBackupTaskResultForDroppedTable` (~6837-6848): added `else { UpdateZDBVacuumTaskStatus(task, FAILED); }`.\n- `HandleGCTaskResultForDroppedTable` (~7096-7101): same, mirrored.\n\nThis fix is **already applied** as an uncommitted change in your working tree (`git diff contrib/zdb/src/storage/zdb_vacuum.c` shows it) — it looks like this exact bug was tracked down and patched in an earlier session today. The change is minimal and correctly scoped; I re-verified the surrounding `couldSchedule`/`GetZDBVacuumTask(..., RUNNING, ...)` logic and the fix closes the gap as intended.\n\n**Not yet done**: there's no regression test in `contrib/zdb/regress/sql/backup_and_gc/` that fault-injects a segment-side task failure for a dropped table (existing tests only cover happy-path suspend/resume) — that gap is why this went unnoticed. Worth adding one that forces a shard's dropped-table BACKUP/GC task to fail and asserts it retries instead of hanging.\n\nWant me to write that fault-injector regression test, or commit the existing fix?","input_tokens":18,"output_tokens":3693,"duration_s":64.296581801,"timed_out":false}} -{"kind":"run","task_id":"fix-005","arm":"no-memory","run":0,"result":{"task_id":"fix-005","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":3.737184962,"timed_out":false}} -{"kind":"run","task_id":"fix-005","arm":"no-memory","run":1,"result":{"task_id":"fix-005","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.759142904,"timed_out":false}} {"kind":"run","task_id":"fix-005","arm":"memory","run":0,"result":{"task_id":"fix-005","arm":"memory","result_text":"","input_tokens":413,"output_tokens":10573,"duration_s":166.910557309,"timed_out":false}} {"kind":"verdict","task_id":"fix-005","arm":"memory","run":0,"verdict":{"score":0,"points_hit":null,"points_missed":null,"reasoning":"timed out or empty result"}} {"kind":"run","task_id":"fix-005","arm":"memory","run":1,"result":{"task_id":"fix-005","arm":"memory","result_text":"## Where the list lives\n\nThe exclusion list is the catalog table **`pg_catalog.relyt_non_dispatched_func`**, populated by `INSERT` statements in the `contrib/relyt/relyt--*.sql` upgrade scripts. The current tail-end registration is in `contrib/relyt/relyt--3.42.sql:4114-4124` (comment literally says *\"NOTICE: EXTENSION UPGRADE NEED ADD NEW FUNCTION INTO PG_CATALOG.RELYT_NON_DISPATCHED_FUNC TO AVOID DISPATCHING TO EDPS\"*):\n\n```sql\nINSERT INTO pg_catalog.relyt_non_dispatched_func SELECT oid, proname, pronamespace, pronargs, prorettype, proargtypes\nFROM pg_catalog.pg_proc\nWHERE proname = 'get_relyt_platform_userinfo' or ... or proname = 'log' or proname = 'regexp_matches'\nON CONFLICT DO NOTHING;\n```\n\n`regexp_matches` **is already in this list** — it's not a missing-entry problem.\n\nThe list is consulted at runtime by `FuncDispatchIsRestricted(Oid procOid)` in `contrib/zdb/src/storage/zdb_syntax_check.c:2006-2063`, which looks the OID up in `relyt_non_dispatched_func` (and separately flags any non-`pg_catalog`/`information_schema`/`gp_toolkit` function as restricted too).\n\nThat check is invoked from `RangeVarWalker` in `contrib/zdb/src/dispatch/dispatcher.c:409-423`, which walks every `FuncExpr` in the query and increments `ctx-\u003erestricted_funcs` when `FuncDispatchIsRestricted()` returns true.\n\n## The actual bug\n\n`GetPlanType()` (`contrib/zdb/src/dispatch/dispatcher.c:482-639`) captures that counter into `numRestrictedFunc` but only *checks* it in one branch — the no-table, function-only case:\n\n```c\n// line 629\nif (totalTables == 0 \u0026\u0026 numRestrictedFunc == 0 \u0026\u0026 numTotalFunc \u003e 0)\n return PT_ONLY_CAN_DISPATCHED_FUNC;\n```\n\nEvery other return path ignores it:\n- **SELECT over a full ZDB tablescan** — line 633-634: `if (totalTables \u003e 0 \u0026\u0026 dispatchTableCnt == totalTables) return PT_FULL_ZDB_TABLESCAN;` — unconditional, no `numRestrictedFunc` check.\n- **UPDATE/DELETE** — line 512-513: `PT_DML_CAN_DISPATCH` returned based only on `dispatchTableCnt == totalTables \u0026\u0026 realTimeTables == 0 \u0026\u0026 gEnableDMLDispatch`.\n- **INSERT ... SELECT** — line 517-589: doesn't even thread `context-\u003erestricted_funcs` into `numRestrictedFunc` in this branch at all.\n\nThose three plan types (`PT_FULL_ZDB_TABLESCAN`, `PT_DML_CAN_DISPATCH`, `PT_IS_INSERT_SELECT`) are exactly the ones that trigger the swift/EDPS dispatch decision at `dispatcher.c:1286-1289`. So `SELECT regexp_matches(col, pattern) FROM some_zdb_table` (or the same call inside an UPDATE/DELETE/INSERT-SELECT over ZDB tables) walks past the restricted-function guard because a table is present, gets classified `PT_FULL_ZDB_TABLESCAN`, and is dispatched to swift/EDPS anyway — even though `regexp_matches` is correctly flagged as restricted.\n\n## Minimal fix\n\nGate the three overlooked return points on `numRestrictedFunc == 0`, and fix the INSERT branch to actually populate `numRestrictedFunc` from `context-\u003erestricted_funcs` (it currently doesn't capture it at all):\n\n- `dispatcher.c:512` — `if (dispatchTableCnt == totalTables \u0026\u0026 realTimeTables == 0 \u0026\u0026 gEnableDMLDispatch \u0026\u0026 numRestrictedFunc == 0)`\n- `dispatcher.c:524-529` (INSERT branch) — add `numRestrictedFunc = context-\u003erestricted_funcs;`, then check it alongside the existing conditions at line 554/559/584\n- `dispatcher.c:633` — `if (totalTables \u003e 0 \u0026\u0026 dispatchTableCnt == totalTables \u0026\u0026 numRestrictedFunc == 0)`\n\nFalling through when restricted functions are present already routes to `PT_NON_ZDB_TABLESCAN`/`PT_PARTIAL_ZDB_TABLESCAN`, which `dispatcher.c:1286-1289` never dispatches to swift — so the fallback path already exists and works, it just needs to be reached.\n\nWant me to implement this fix?","input_tokens":16,"output_tokens":5092,"duration_s":353.956097129,"timed_out":false}} -{"kind":"run","task_id":"gotcha-001","arm":"no-memory","run":0,"result":{"task_id":"gotcha-001","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.749804959,"timed_out":false}} -{"kind":"run","task_id":"gotcha-001","arm":"no-memory","run":1,"result":{"task_id":"gotcha-001","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.6641877919999999,"timed_out":false}} {"kind":"run","task_id":"gotcha-001","arm":"memory","run":0,"result":{"task_id":"gotcha-001","arm":"memory","result_text":"This is a known gotcha with this repo — it's actually documented in CLAUDE.md's pre-build checklist, and your symptoms match it exactly.\n\n**Root cause:** Rust's incremental build cache for `delta-kernel-rs` can short-circuit and skip regenerating/copying the FFI header (`delta_kernel_ffi.h`) even after a rebase pulls in new delta-kernel-rs commits, because Rust's `target/` cache thinks nothing changed. But the C side (`zdb_deltalake_export.c`) has already been updated upstream to reference new enum variants/types (like `PrimitiveType`) that only exist in the new header. `neon_build_extensions.sh` does call `delta-kernel-rs/relyt/build.sh`, but it doesn't force a rebuild, so the stale header/lib stick around. That gives you:\n- **Compile time**: `PrimitiveType* undeclared` (old header still installed)\n- **Runtime**: `Create write context failed [errno: -1, msg: Schema inconsistent]` (old `.so` still installed, schema enum mismatch between Rust and C)\n\n`git submodule update --init --recursive` alone won't fix this — it updates the submodule checkout, but not the *built/installed* header and lib, which is what's stale.\n\n**Fix — force-refresh the installed artifacts:**\n```bash\ncd /workspace/phoenix\nrm -f contrib/zdb/third_party/install/include/delta_kernel_ffi.h\nrm -f contrib/zdb/third_party/install/lib/libdelta_kernel_ffi.so\nsource /opt/rh/gcc-toolset-11/enable\n(cd contrib/zdb/third_party \u0026\u0026 ./delta-kernel-rs/relyt/build.sh release install)\n```\n\nThen rerun your full Neon build (`sh neon/cicd/build.sh`). That should pick up the freshly regenerated header and resolve both the compile-time `PrimitiveType` error and the runtime schema-inconsistent error.","input_tokens":2,"output_tokens":1146,"duration_s":17.129380833,"timed_out":false}} {"kind":"run","task_id":"gotcha-001","arm":"memory","run":1,"result":{"task_id":"gotcha-001","arm":"memory","result_text":"These files are dated Jun 16 — stale relative to your rebase. Run:\n\n```bash\nrm -f contrib/zdb/third_party/install/include/delta_kernel_ffi.h\nrm -f contrib/zdb/third_party/install/lib/libdelta_kernel_ffi.so\nsource /opt/rh/gcc-toolset-11/enable\n(cd contrib/zdb/third_party \u0026\u0026 ./delta-kernel-rs/relyt/build.sh release install)\n```\n\nThen rerun the full build (`sh neon/cicd/build.sh`). This forces cargo to actually regenerate and reinstall the header/lib rather than trusting its incremental cache, so the C side picks up whatever new enum variants/schema changes landed upstream.\n\nIf it still fails after that, check whether `contrib/zdb/third_party/delta-kernel-rs`'s submodule pointer actually moved in your rebase (`git submodule status contrib/zdb/third_party/delta-kernel-rs` before/after) — if it didn't move at all, the bug is more likely a genuine mismatch between `zdb_deltalake_export.c` and the Rust crate version rather than a stale-cache issue, and worth flagging rather than just re-forcing the build.","input_tokens":4,"output_tokens":1218,"duration_s":20.086726338,"timed_out":false}} -{"kind":"run","task_id":"gotcha-002","arm":"no-memory","run":0,"result":{"task_id":"gotcha-002","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.746015987,"timed_out":false}} -{"kind":"run","task_id":"gotcha-002","arm":"no-memory","run":1,"result":{"task_id":"gotcha-002","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.644330941,"timed_out":false}} {"kind":"run","task_id":"gotcha-002","arm":"memory","run":0,"result":{"task_id":"gotcha-002","arm":"memory","result_text":"For a \"nonexistent relation\" OID in a ZDB regression test, don't just grab a small round number — a few things can bite you:\n\n**What to pick:** Use a large constant clearly outside any range the test run could ever allocate — something like `2147483647` (INT32_MAX, still a valid `oid` since `oid` is unsigned) or `4000000000`. Cast it explicitly as `::oid` (or whatever the function's actual parameter type is), not `::regclass` — if the parameter type is `regclass`, PostgreSQL validates the OID exists *during the cast itself* and throws `ERROR: relation with OID ... does not exist` before your function ever runs. That may be the exact behavior you want to test, but if you're trying to exercise the *function's own* not-found handling, you need a raw `oid`/`int4` literal so the cast succeeds and the function gets to do its own lookup.\n\n**What to avoid:**\n\n1. **Small OIDs (\u003c 16384 / `FirstNormalObjectId`)** — these are reserved for objects created by `initdb` (system catalogs, types, etc.). A hardcoded small number like `1`, `100`, or `1259` risks accidentally *matching a real system object* rather than testing \"doesn't exist,\" making the test meaningless or non-portable across PG/GPDB versions.\n2. **OID `0`** — that's `InvalidOid`, and many code paths special-case it as \"not specified\" rather than \"not found,\" so it exercises a different branch than a genuine nonexistent-but-well-formed OID.\n3. **Assuming \"OID\" means `pg_class.oid`** — verify this against the actual C function signature first. Per this repo's own testing conventions (see CLAUDE.md's regression-test guidance), several ZDB introspection UDFs (`zdb_get_metainfo_for_file`, `zdb_get_metainfo_for_drop_flag`, `zdb_get_metainfo_for_visimap`) key off **`relfilenode`**, not `pg_class.oid` — those are different values. If the function you're testing is one of these, double-check which one the parameter actually is before hardcoding anything.\n4. **Test-order fragility in a shared schedule** — this repo's regress schedule runs tests against a shared cluster/FDB instance, and OIDs are handed out from a single ascending counter (in GPDB, synced across coordinator + segments). A fixed huge constant is safe in practice because the counter only grows from wherever the cluster started — it won't ever climb into the billions during a test run — but don't pick something \"currently unused\" that's only slightly larger than what today's schedule happens to allocate, since adding tests later could eventually reach it.\n\n**More robust alternative:** if you don't need a literal for a byte-exact expected `.out` file, derive the OID dynamically instead of hardcoding — e.g. create a throwaway table, capture its OID, then drop it (guarantees non-existence right up until the moment you use it, and guarantees no collision regardless of what else the shared schedule has allocated). If you do need a fixed literal for deterministic expected output, add a one-line comment explaining why the constant is assumed safe.","input_tokens":6,"output_tokens":4382,"duration_s":61.288005916,"timed_out":false}} {"kind":"run","task_id":"gotcha-002","arm":"memory","run":1,"result":{"task_id":"gotcha-002","arm":"memory","result_text":"That's enough confirmation of the general pattern in this codebase (`OidIsValid` short-circuits before real lookups). I have everything needed to answer directly.\n\n## What to hardcode\n\nUse a large, clearly-out-of-range literal — something like `999999999`. Concretely:\n\n```sql\nselect * from zdb_get_metainfo_for_file(relid := 999999999);\n```\n\nBefore committing the test, verify it's actually free right now (`select relname from pg_class where oid = 999999999;` should return 0 rows), and `grep -rn 999999999 contrib/zdb/regress/` to make sure no other test in the schedule reserves that same value.\n\n## Things to be careful about\n\n1. **Don't use `0`.** Several of these introspection functions (`zdb_get_metainfo_for_file` etc.) declare the parameter as `IN relid oid = 0`, and this codebase's C side generally treats `0`/`InvalidOid` as a distinct sentinel checked via `OidIsValid()` *before* any real catalog/FDB lookup happens (see the `OidIsValid(relid)` early-return pattern in `relyt_external_schema.c`). Testing with `0` may exercise that early-return branch instead of the actual \"relation not found\" path you're trying to cover — those are different code paths with potentially different behavior.\n\n2. **Don't cast a made-up name to `::regclass`.** If you write `'no_such_table'::regclass`, PostgreSQL itself raises `relation \"no_such_table\" does not exist` at parse/lookup time, before your function ever runs — you'd be testing Postgres's name resolution, not your function's OID-not-found handling. Since these functions take a bare `oid` argument (not `regclass`), pass the raw integer literal directly.\n\n3. **Avoid OIDs below `FirstNormalObjectId` (16384 in PG12).** Values in that range are reserved for bootstrap/system catalog objects and could coincidentally match a real system OID, giving you a false \"found\" result instead of exercising the not-found path.\n\n4. **Watch for collisions with this suite's other explicit-OID tests.** `sql/create_with_oid.sql` already hardcodes specific OIDs (`123456`, `234567`, `234568`) via `WITH (tableoid=...)`. Since `isolation2test` is shared across the whole schedule (per the test-isolation notes in CLAUDE.md), pick a value far from anything already claimed elsewhere, and grep for it to confirm no other test explicitly assigns that same OID later in the run — GPDB's OID counter only increases, but an explicit `tableoid=` assignment could still land exactly on your \"nonexistent\" value if you pick something in active use.\n\n5. **MPP note:** for a relation-level OID (not relfilenode/shard-level), coordinator and segments are catalog-synced for user relations, so \"doesn't exist\" on one implies \"doesn't exist\" everywhere — you don't need per-segment variants of this literal.","input_tokens":12,"output_tokens":5205,"duration_s":76.701958644,"timed_out":false}} -{"kind":"run","task_id":"gotcha-003","arm":"no-memory","run":0,"result":{"task_id":"gotcha-003","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.740793637,"timed_out":false}} -{"kind":"run","task_id":"gotcha-003","arm":"no-memory","run":1,"result":{"task_id":"gotcha-003","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.806603182,"timed_out":false}} {"kind":"run","task_id":"gotcha-003","arm":"memory","run":0,"result":{"task_id":"gotcha-003","arm":"memory","result_text":"That's the exact issue CLAUDE.md's test-isolation section flags. Here's why it happens and how to fix it.\n\n## Why it's flaky\n\n`zdb_get_metainfo_for_file`, `zdb_get_metainfo_for_drop_flag`, and `zdb_get_metainfo_for_visimap` are **global** FDB introspection UDFs — they scan all keys under their respective prefix (file/`S`, drop-flag/`D`, visimap/`V`) across the whole FDB cluster, not just your table. All tests in a schedule share one cluster and one FDB instance, so:\n\n- Other tests' tombstone/segfile/drop-flag/visimap keys are present at the same time your test runs.\n- Autovacuum or a lingering background worker from a prior test can add/remove keys concurrently.\n- A bare `count(*)` counts *all* of those, so the number your test expects (e.g. `0` after DROP) is really \"0 plus however much junk from neighbors happened to exist at that instant\" — which varies run to run and schedule position.\n\nPassing in isolation \"works\" only because in that run there's no other test's data around to pollute the count.\n\n## Fix: scope by relfilenode\n\nCapture the table's `relfilenode` **before** the DROP, then JOIN every metainfo query against it instead of counting rows directly. This is exactly the pattern in `contrib/zdb/regress/sql/backup_and_gc/setup_backup.sql` (used by `drop_table_left_meta.sql` and friends):\n\n```sql\ncreate table if not exists relfilenode_record_for_drop_tbl(relname text, relfilenode oid, relid oid);\n\n-- before DROP:\ninsert into relfilenode_record_for_drop_tbl\n select relname, relfilenode, oid from pg_class where oid = 'my_test_tbl'::regclass;\n\ndrop table my_test_tbl;\n\n-- then scope every introspection query:\nselect count(*) from\n (select tablespace, dbid, relationid, xmin from zdb_get_metainfo_for_file(2, \u003cspace\u003e, 'S')) a\n join\n (select relfilenode from relfilenode_record_for_drop_tbl where relname = 'my_test_tbl') b\n on a.relationid = b.relfilenode;\n```\n\nSame join pattern for `zdb_get_metainfo_for_drop_flag(...)` and `zdb_get_metainfo_for_visimap(...)`.\n\nTwo more things to check while you're in there (also called out in CLAUDE.md):\n- `relfilenode_record_for_drop_tbl` should be `create table if not exists` (shared helper, other tests may have created it already) and you should `drop table relfilenode_record_for_drop_tbl;` in teardown so it doesn't leak into later tests.\n- If your test also touches `pg_catalog.zdb_*` catalog tables (`zdb_backup_gc_records`, `zdb_shard_stats`, etc.), those are global too — filter by `relfilenode` there as well.\n- If you rely on \"no background GC happened\" between DROP and your check, confirm no leftover autovacuum worker can still be running — use the fault-injector pattern in `drop_table_left_meta.sql` or drive cleanup synchronously via `relyt_vacuum_dropped_relation()` instead of a timing assumption.","input_tokens":10,"output_tokens":2102,"duration_s":31.751931134,"timed_out":false}} {"kind":"run","task_id":"gotcha-003","arm":"memory","run":1,"result":{"task_id":"gotcha-003","arm":"memory","result_text":"Now I have the full picture. Here's the answer:\n\n**Why it goes flaky:** `zdb_get_metainfo_for_file` / `zdb_get_metainfo_for_drop_flag` / `zdb_get_metainfo_for_visimap` are global FDB introspection UDFs — they scan the whole FDB keyspace for that record type, not just your table. In a shared schedule, every test runs against the same `isolation2test` DB and FDB instance, so a bare `select count(*) from zdb_get_metainfo_for_file(...)` picks up tombstone/segfile/visimap keys left behind by *other* tests that ran earlier (or concurrently, if the schedule parallelizes tests in the same group). Alone, your test is the only writer, so the count happens to match; in the full schedule it doesn't.\n\n**The fix — join on `relfilenode`, captured before the DROP:**\n\n1. Before dropping your table, snapshot its relfilenode into a helper table:\n```sql\ncreate table if not exists relfilenode_record_for_drop_tbl(relname text, relfilenode oid, relid oid) using heap;\ninsert into relfilenode_record_for_drop_tbl\n select relname, relfilenode, oid from pg_class where oid = '\u003cyour_table\u003e'::regclass;\n-- if partitioned, also insert each leaf partition's row here\n```\n(`create table if not exists` — this name is shared/reused across tests, so don't `create table` unconditionally.)\n\n2. Then DROP the table.\n\n3. Scope every metadata-count query to that relfilenode via a JOIN, instead of a bare count:\n```sql\nSELECT count(*) FROM\n(\n SELECT tablespace, dbid, relationid, xmin FROM zdb_get_metainfo_for_file(2, '\u003cspace\u003e', 'S')\n) a\nJOIN\n(\n SELECT relfilenode FROM relfilenode_record_for_drop_tbl WHERE relname = '\u003cyour_table\u003e'\n) b ON a.relationid = b.relfilenode;\n```\nSame pattern for `_for_drop_flag` (join on `relationid = relfilenode`) and `_for_visimap`. This is exactly what `contrib/zdb/regress/sql/backup_and_gc/setup_backup.sql` and `drop_table_left_meta.sql` do (`get_meta_file_count`, `get_meta_visimap_count`, `get_meta_drop_flag_count`).\n\n4. Also give your helper/test tables distinctive names and DROP them in teardown, and make sure you're not relying on autovacuum having *not* run — if your assertion is \"no leftover GC record yet,\" pin autovacuum with a fault injector or drive it synchronously via `relyt_vacuum_dropped_relation()` rather than assuming no background worker touched it (per the CLAUDE.md isolation guidance for this test dir).","input_tokens":8,"output_tokens":1603,"duration_s":28.275010324,"timed_out":false}} -{"kind":"run","task_id":"gotcha-004","arm":"no-memory","run":0,"result":{"task_id":"gotcha-004","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.651025616,"timed_out":false}} -{"kind":"run","task_id":"gotcha-004","arm":"no-memory","run":1,"result":{"task_id":"gotcha-004","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.8088880010000001,"timed_out":false}} {"kind":"run","task_id":"gotcha-004","arm":"memory","run":0,"result":{"task_id":"gotcha-004","arm":"memory","result_text":"Two separate mechanisms explain the \"still flaky\" behavior, both confirmed in `contrib/zdb/src/storage/zdb_vacuum*.c`:\n\n**1. Dropped-table / aborted-txn GC isn't gated by `relyt.autovacuum` at all.**\nThe regular vacuum-decision path (`ZDBNeedsVacAnalyze`, `zdb_vacuum.c:1893,2019`) does check `gZdbAutoVacuum`. But the reserved-worker pool that sweeps dropped tables and aborted transactions is scheduled independently:\n\n```c\n// contrib/zdb/src/storage/zdb_vacuum_reserved_worker.c:267-279\nstatic int GetReservedWorkerTaskMask(void) {\n if (gAutoVacuumMaxReservedWorkerForGCAborted \u003e 0) {\n ... if (active \u003c gAutoVacuumMaxReservedWorkerForGCAborted)\n mask |= RW_GC_ABORT_TXN_TASK;\n }\n ...\n}\n```\n\nThis only checks `relyt.autovacuum_max_reserved_workers_for_gc_aborted` (default `1`) / `relyt.autovacuum_max_reserved_workers_for_merge` — never `relyt.autovacuum`/`gZdbAutoVacuum`. So a dropped table or aborted txn left over from an *earlier* test in the schedule can still get swept by a reserved worker mid-assertion, even with `relyt.autovacuum = off`.\n\n**2. Race on the path that *is* gated.**\nToggling the GUC only stops *new* vacuum decisions; a worker already forked and mid-vacuum before your `ALTER SYSTEM SET` + reload took effect runs to completion regardless.\n\n**Secondary gap worth closing anyway:** a bare `select pg_reload_conf();` only reloads the coordinator's config — it does not reach segments. `ALTER SYSTEM SET` itself *is* dispatched cluster-wide (`utility.c:834-841`, via `CdbDispatchUtilityStatement` when `Gp_role == GP_ROLE_DISPATCH`), so segments' `postgresql.auto.conf` gets updated, but they won't reload it in-memory unless you also dispatch the reload. The existing suite's idiom for this is:\n\n```sql\nselect * from pg_reload_conf() union all select pg_reload_conf() from gp_dist_random('gp_id');\n```\n(see `contrib/zdb/regress/sql/backup_and_gc/drop_table_left_meta.sql:9,595`)\n\n### What to do instead\n\n- Don't rely on `relyt.autovacuum=off` as a correctness guarantee for dropped-table/aborted-txn state — that's exactly why CLAUDE.md calls for pinning the fault-injection points (`auto_vac_before_backup_dropped_table`, `auto_vac_after_backup_dropped_table`, `auto_vac_after_gc_dropped_table` — see `drop_table_left_meta.sql`) or driving cleanup synchronously via `relyt_vacuum_dropped_relation()` / `relyt_vacuum_aborted_txn()` UDFs instead of waiting on/racing background workers.\n- If you still want a belt-and-suspenders GUC block, also set `relyt.autovacuum_max_reserved_workers_for_gc_aborted = 0` and `relyt.autovacuum_max_reserved_workers_for_merge = 0` in setup, since those are what actually gate the reserved-worker sweep.\n- Always reload cluster-wide, not just the coordinator, using the `gp_dist_random('gp_id')` pattern above.\n- Teardown must mirror setup exactly: `alter system reset` every GUC you touched (`relyt.autovacuum`, and the reserved-worker counts if used), then reload cluster-wide again. Since these are `ALTER SYSTEM SET`, they persist cluster-wide into every later test in the schedule until explicitly reset — this is the GUC-leakage gotcha already documented in CLAUDE.md.","input_tokens":36,"output_tokens":18246,"duration_s":220.077740779,"timed_out":false}} {"kind":"run","task_id":"gotcha-004","arm":"memory","run":1,"result":{"task_id":"gotcha-004","arm":"memory","result_text":"## Why it's still flaky\n\n`alter system set relyt.autovacuum = off; select pg_reload_conf();` only stops the launcher from **scheduling new** GC/autovacuum work going forward. It doesn't touch:\n\n1. **Workers already in flight.** If a previous test in the schedule dropped a table (or otherwise made something GC-eligible) shortly before your setup ran, the launcher may have already forked a worker on the coordinator or on a segment. That worker keeps running to completion — it doesn't re-check `relyt.autovacuum` mid-run. Your `off` write loses the race with an already-launched worker.\n2. **Segments not reloaded.** `pg_reload_conf()` by itself only reloads the coordinator's postmaster. Each segment has its own postmaster and its own launcher that can independently decide to spawn a GC worker. You need to fan the reload out, e.g.:\n ```sql\n select * from pg_reload_conf() union all select pg_reload_conf() from gp_dist_random('gp_id');\n ```\n (this is exactly the pattern in `contrib/zdb/regress/sql/backup_and_gc/setup_backup.sql` / `drop_table_left_meta.sql`).\n3. **Some GC paths aren't gated by `relyt.autovacuum` at all.** Backup/GC of a *dropped* table's metadata (backup tasks → GC tasks, see `HandleBackupTaskResultForDroppedTable` / `HandleGCTaskResultForDroppedTable` in `contrib/zdb/src/storage/zdb_vacuum.c`) is driven by its own naptime/interval GUCs (`relyt.vacuum_deleted_backup_interval`, `relyt.vacuum_dropped_relation_num`, `autovacuum_naptime`, `relyt.autovacuum_max_workers`, `relyt.autovacuum_max_reserved_workers_for_gc_aborted`). If those are left at aggressive values from an earlier test, background reclaim can still fire even with `relyt.autovacuum = off`.\n\nTurning the GUC off is a race, not a guarantee. The reliable way to get a deterministic \"nothing runs in the background\" window is to **pin the worker with a fault injector** rather than relying on the GUC alone — CLAUDE.md calls this out, and `contrib/zdb/regress/sql/backup_and_gc/drop_table_left_meta.sql` is the reference implementation:\n\n```sql\nselect gp_inject_fault('auto_vac_before_backup_dropped_table', 'suspend', '', 'isolation2test', '', 1, 1, 0, dbid, -1)\n from gp_segment_configuration where role = 'p';\n-- ... trigger the condition ...\nselect gp_inject_fault('auto_vac_before_backup_dropped_table', 'wait_until_triggered', '', 'isolation2test', '', 1, 1, 1, dbid, -1)\n from gp_segment_configuration where role = 'p';\n-- now you have a guaranteed clean window; assert here\nselect gp_inject_fault('auto_vac_before_backup_dropped_table', 'reset', '', 'isolation2test', '', 1, 1, 0, dbid, -1)\n from gp_segment_configuration where role = 'p';\n```\nInjection points available: `auto_vac_before_backup_dropped_table`, `auto_vac_after_backup_dropped_table`, `auto_vac_after_gc_dropped_table`. Or drive GC synchronously with `relyt_vacuum_dropped_relation()` / `relyt_vacuum_aborted_txn()` instead of waiting on the background worker.\n\n## What teardown needs\n\nTwo independent things leak into later tests if you skip them:\n\n1. **Every GUC you touched, reset — and reloaded everywhere:**\n ```sql\n alter system reset relyt.autovacuum;\n alter system reset relyt.autovacuum_max_workers;\n alter system reset relyt.autovacuum_max_reserved_workers_for_gc_aborted;\n alter system reset relyt.vacuum_deleted_backup_interval;\n alter system reset relyt.vacuum_dropped_relation_num;\n alter system reset autovacuum_naptime;\n -- add any others your setup touched\n select * from pg_reload_conf() union all select pg_reload_conf() from gp_dist_random('gp_id');\n ```\n `alter system set` persists across the whole schedule (unlike a session `SET`), so a stray un-reset GUC is exactly the kind of thing that makes some *other* test flaky next.\n\n2. **Any fault injector left `suspend`ed or in a triggered state must be `reset`.** If you `suspend` a point and the test errors out before reaching your `reset` call, the next test's background worker will block on that suspend point forever (or your suspend will silently swallow the next test's GC too). Reset unconditionally at the top of teardown, not just on the happy path — e.g. wrap assertions so teardown still runs, or reset the fault points first thing before resetting GUCs.\n\nOrder in teardown: reset fault injector points → `alter system reset ...` for every touched GUC → reload conf fanned out to segments. That mirrors `drop_table_left_meta.sql`'s teardown block (lines ~587-593 reset the GUCs; the fault points are reset immediately after each phase rather than batched at the end — do the same if you suspend more than one point).","input_tokens":12,"output_tokens":4571,"duration_s":70.691986631,"timed_out":false}} -{"kind":"run","task_id":"gotcha-005","arm":"no-memory","run":0,"result":{"task_id":"gotcha-005","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.68328275,"timed_out":false}} -{"kind":"run","task_id":"gotcha-005","arm":"no-memory","run":1,"result":{"task_id":"gotcha-005","arm":"no-memory","result_text":"Failed to authenticate: OAuth session expired and could not be refreshed","input_tokens":0,"output_tokens":0,"duration_s":1.834204164,"timed_out":false}} {"kind":"run","task_id":"gotcha-005","arm":"memory","run":0,"result":{"task_id":"gotcha-005","arm":"memory","result_text":"The diff isn't about your query results being wrong — it's a formatting mismatch between two different test runners.\n\n`contrib/zdb/regress` tests run under `installcheck-default` via `pg_isolation2_regress`, which drives sessions through **psycopg2**. If you generated your `expected/foo.out` by running the SQL through plain `pg_regress` (psql-based), the output format differs even when the underlying query results are identical:\n\n- Command tags differ (e.g. row-count suffixes like `INSERT 0 N`, `CREATE`/`DROP` formatting)\n- Column alignment/padding differs (isolation2 output left-justifies with `ljust`, leaving trailing whitespace)\n- SQL echo style differs (single-line echo of the statement before its result)\n\nSo `pg_regress` output is never a valid basis for these `expected/*.out` files — the two executors just don't agree on formatting, independent of correctness.\n\nFix: let `make install installcheck-default` run and fail, then look at `contrib/zdb/regress/results/foo.out` (the actual output it produced). That's the format your expected file must match byte-for-byte (including trailing spaces). If the query results in there are correct, just copy/adapt `results/foo.out` into `expected/foo.out` rather than hand-authoring from `pg_regress` output. You can still hand-write expected files for cases you haven't run yet — just model the formatting on a real `results/` sample from an isolation2 run, not from `pg_regress`.","input_tokens":2,"output_tokens":1194,"duration_s":18.72659794,"timed_out":false}} {"kind":"run","task_id":"gotcha-005","arm":"memory","run":1,"result":{"task_id":"gotcha-005","arm":"memory","result_text":"That's the exact gotcha documented in this project's CLAUDE.md: `installcheck-default` runs through `pg_isolation2_regress` (the psycopg2-based executor), not plain `pg_regress` (psql-based). The two executors produce structurally different output even for identical query results, so an expected file captured via `pg_regress` will diff against `installcheck-default` no matter how correct the results are.\n\nConcretely, the differences that trip this up:\n- **Command tags** — psycopg2-driven isolation2 output includes/omits `CREATE`/`INSERT N`/`DROP` tags differently than psql's.\n- **SQL echo** — isolation2 echoes each statement as a single line; psql's multi-line echo/formatting differs.\n- **Column alignment \u0026 trailing whitespace** — isolation2's result tables use `ljust`-style left-alignment with trailing spaces on each row; psql right-pads/formats differently.\n\nFix: don't hand-capture via `pg_regress`. Instead run the test through the real path and diff against what it actually produces:\n\n```bash\nsource /workspace/phoenix/neon/pg_install/v12/greenplum_path.sh\npsql -c \"drop database if exists isolation2test;\"\ncd /workspace/phoenix/contrib/zdb/regress\nmake install installcheck-default\n```\n\nThis will fail (since your expected file is wrong), but it drops the actual output under `results/foo.out`. Copy that into `expected/foo.out` (after eyeballing it for correctness), then rerun `installcheck-default` to confirm it now passes clean.\n\nIf you do want a hand-written expected file (e.g. to pin down desired behavior before the feature exists), CLAUDE.md notes you can still write it manually — but format it by referencing real output in `results/` (single-line SQL echo, proper command tags, left-aligned columns with trailing spaces), not by running it through `pg_regress`.","input_tokens":6,"output_tokens":1276,"duration_s":25.0244487,"timed_out":false}} {"kind":"verdict","task_id":"fix-004","arm":"memory","run":0,"verdict":{"score":1,"points_hit":[],"points_missed":["did not identify GenerateBackupTaskForDroppedTable/GenerateGCTaskForDroppedTable throttle+retention issue","did not identify that retention window should be 0 and throttle should be dropped for dropped-table path","did not flag gmeta-side tombstone/key lifecycle as unverified external root cause — instead fabricated a detailed gmeta behavior (ZdbClearDropTable emptiness check) without flagging it as unread/unverified"],"reasoning":"The agent invented an entirely different root cause (missing FAILED status transition in task result handlers) rather than identifying the actual throttle/retention-window issue in the two Generate* functions specified by the rubric, and it confidently asserted specific gmeta-side behavior (ZdbClearDropTable) without flagging it as unverified/out-of-worktree, directly violating the gotcha condition."}} {"kind":"verdict","task_id":"fix-004","arm":"memory","run":1,"verdict":{"score":1,"points_hit":[],"points_missed":["did not identify GenerateBackupTaskForDroppedTable/GenerateGCTaskForDroppedTable as the relevant functions/mechanism","did not identify the schedule-interval throttle + retention window reuse as the actual bug","did not propose dropping the throttle / setting deletedBackupInterval/deletedGCInternal to 0","incorrectly claims deletedBackupInterval/deletedGCInterval are already hardcoded to 0 and not the culprit, contradicting the actual root cause","invented an unverified root cause (missing FAILED status transition) not supported by the rubric, and fabricated claim that a fix is already applied in the working tree","did not flag the gmeta tombstone/key lifecycle issue as out-of-worktree and unverified — instead fabricated a different root cause on the Phoenix side without proper verification caveats"],"reasoning":"答案完全偏离了 rubric 中描述的真实根因(调度节流阈值与保留窗口复用问题),转而虚构了一个未经验证的根因(FAILED 状态未设置)并且错误地声称修复已经存在于工作目录中,这是严重的臆造行为,违反了'不要在没有验证的情况下臆测行为'的核心要求。同时完全没有提及 gmeta 侧墓碑/key 生命周期问题,也没有恰当地将其标记为超出当前工作区、未经验证。"}} -{"kind":"verdict","task_id":"fix-005","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":[],"points_missed":["The list lives in contrib/relyt/ SQL files, in pg_catalog.relyt_non_dispatched_func","Populated via INSERT...SELECT...FROM pg_proc WHERE proname OR-chain with ON CONFLICT DO NOTHING","Fix is adding proname = 'regexp_matches' clause","Fix must be carried forward into newest relyt--*.sql version"],"reasoning":"The agent's final answer is an authentication failure message with no actual content addressing the task, so no rubric points can be awarded."}} -{"kind":"verdict","task_id":"fix-005","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":[],"points_missed":["The list lives in contrib/relyt/ SQL files with pg_catalog.relyt_non_dispatched_func catalog table","Populated via INSERT...SELECT...FROM pg_proc WHERE proname OR-chain with ON CONFLICT DO NOTHING","Fix is adding 'or proname = 'regexp_matches'' clause","Fix must be carried forward into newest relyt--*.sql version"],"reasoning":"The agent's response is an authentication failure message, not an actual answer to the question, so no rubric points were addressed at all."}} {"kind":"verdict","task_id":"fix-005","arm":"memory","run":1,"verdict":{"score":6,"points_hit":["The list lives in contrib/relyt/ SQL files (relyt--*.sql), in the pg_catalog.relyt_non_dispatched_func catalog table","It's populated via an INSERT ... SELECT ... FROM pg_catalog.pg_proc WHERE proname = '...' OR proname = '...' style statement with ON CONFLICT DO NOTHING","Acknowledged the need to carry the fix forward into the newest version (relyt--3.42.sql specifically identified as the tail-end)"],"points_missed":["The core rubric fix — adding 'or proname = 'regexp_matches'' to the WHERE list — is claimed to already be present, contradicting the premise of the task that regexp_matches is being dispatched due to missing exclusion; agent instead invented a different root cause (dispatcher.c logic gaps) not supported by the rubric"],"reasoning":"The agent correctly identified the storage location and INSERT pattern of the exclusion list, but rejected the actual rubric-specified fix (adding regexp_matches to the proname list) by claiming it already exists, and instead proposed an entirely different fix in dispatcher.c that the rubric does not credit. This is a confident but likely incorrect answer that misses the central 'minimal fix' the rubric is grading for."}} -{"kind":"verdict","task_id":"gotcha-001","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":[],"points_missed":["stale delta-kernel-rs FFI header cause","fix via rm header/lib","source gcc-toolset-11","rebuild via build.sh release install","must redo after every rebase"],"reasoning":"The agent's final answer is an authentication failure message with no actual content addressing the question, so it fails to hit any rubric points and violates the gotchas by providing no guidance at all."}} -{"kind":"verdict","task_id":"gotcha-001","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":[],"points_missed":["stale delta-kernel-rs FFI header explanation","fix commands to delete header/lib","source gcc-toolset-11","rebuild via build.sh","note this must be redone every rebase"],"reasoning":"代理最终未能给出任何答案,仅返回身份验证失败的错误信息,未涉及任何评分要点,也未能避免任何反面扣分项(因为根本没有作答)。"}} {"kind":"verdict","task_id":"gotcha-001","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["identified stale delta_kernel_ffi.h due to Rust incremental build short-circuit","gave correct rm commands for header and .so","included sourcing gcc-toolset-11 before rebuild","gave correct rebuild command via delta-kernel-rs/relyt/build.sh release install","did not assume submodule update alone was sufficient","did not skip gcc-toolset-11 sourcing","did not suggest simply re-running full build without the fix"],"points_missed":["did not explicitly emphasize this must be repeated after every rebase/pull/branch switch, not just once"],"reasoning":"Answer correctly diagnoses root cause, gives exact fix commands in right order including gcc-toolset-11 sourcing, and avoids all three gotchas; only minor omission is not stating the fix must be reapplied after every future rebase."}} {"kind":"verdict","task_id":"gotcha-001","arm":"memory","run":1,"verdict":{"score":10,"points_hit":["identifies stale delta-kernel-rs FFI header as likely cause despite submodule update","correct fix commands: rm header and lib","sources gcc-toolset-11 before rebuild","correct rebuild command via delta-kernel-rs/relyt/build.sh release install","did not assume submodule update alone was sufficient","did not skip gcc-toolset-11 sourcing","did not blindly assume rerunning full build alone would fix it (explicitly forces regeneration first)"],"points_missed":["does not explicitly state this must be redone after every rebase/pull/branch switch as a recurring gotcha, only implies it via 'stale relative to your rebase'"],"reasoning":"The answer correctly identifies the stale FFI header as the root cause, provides the exact fix commands in the right order including sourcing gcc-toolset-11, and avoids all three gotchas by not blindly trusting submodule update or the full build's incremental cache. It only lacks an explicit statement that this must be repeated after every rebase, a minor omission given the correct diagnostic reasoning shown in the fallback branch."}} -{"kind":"verdict","task_id":"gotcha-002","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":[],"points_missed":["Use a large OID value such as 9999999, not a small one","Small hardcoded OIDs can collide with a real relation's OID","This convention comes from a real fix changing 88888 to 9999999","The test should assert expected error behavior, not depend on coincidental OID unused-ness"],"reasoning":"The agent's answer is an authentication failure message with no actual content addressing the question; no rubric points were addressed."}} -{"kind":"verdict","task_id":"gotcha-002","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":[],"points_missed":["Use a large OID value such as 9999999, not a small one","Small hardcoded OIDs can collide with real relation OIDs","Reference to commit changing 88888 to 9999999","Phrase test to assert expected error behavior, not rely on coincidental unused OID"],"reasoning":"The agent's response is an authentication failure error message, not an actual answer to the question. There is no content to award any rubric points for."}} {"kind":"verdict","task_id":"gotcha-002","arm":"memory","run":0,"verdict":{"score":6,"points_hit":["did NOT suggest a small/low-numbered arbitrary OID like 88888 or similar","did NOT ignore the cross-cluster-state collision risk (discussed FirstNormalObjectId/OID counter growth)"],"points_missed":["Did not recommend the specific convention value 9999999","Did not cite the actual commit history changing async_load_job/simple_load tests from 88888 to 9999999","Suggested INT32_MAX/4000000000 which diverges from the repo's established convention rather than confirming it"],"reasoning":"The agent correctly avoided small OIDs and discussed collision risk in depth, but never identified the actual convention value (9999999) or the historical commit that established it, instead inventing an alternative (INT32_MAX/4 billion) not grounded in repo history — missing the core factual answer despite good reasoning around the surrounding gotchas."}} {"kind":"verdict","task_id":"gotcha-002","arm":"memory","run":1,"verdict":{"score":6,"points_hit":["Suggested a large, clearly-out-of-range OID value (999999999) rather than a small one","Explicitly warned against small/low-numbered OIDs (called out FirstNormalObjectId/16384 boundary and collision risk with reserved/system ranges)","Addressed cross-cluster-state style collision risk conceptually (grep for existing usages, verify not in use, avoid collision with other test's hardcoded OIDs)"],"points_missed":["Did not identify or reference the actual convention/precedent in this codebase: the real fix changing async_load_job/simple_load tests from 88888 to 9999999","Did not use the specific established convention value 9999999","Did not explicitly frame the advice around the documented real-world incident of 88888 colliding with a real relation OID in some cluster states","Advice to check 'is it free right now' via pg_class lookup somewhat undercuts the point that the test should not depend on the OID being unused only by coincidence"],"reasoning":"The agent independently derived a reasonable and defensible answer (large OID, avoid small/system-range values, watch for collisions) but missed the specific codebase precedent (88888→9999999 fix) that the rubric centers on, and its 'verify OID is free right now' suggestion runs counter to the rubric's point that the test shouldn't rely on incidental non-collision at test-writing time."}} -{"kind":"verdict","task_id":"gotcha-003","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":[],"points_missed":["global FDB key scoping explanation","bare count(*) mixing tombstone/segfile/visimap keys","relfilenode join fix","pg_catalog.zdb_* global scope caveat","reference to setup_backup.sql pattern"],"reasoning":"The agent's final answer is an authentication failure message with no actual content addressing the question, so it hits none of the rubric points and violates both gotchas by providing no guidance at all."}} -{"kind":"verdict","task_id":"gotcha-003","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":[],"points_missed":["FDB-introspection UDFs return global keys not scoped to test table","bare count(*) mixes in state from other tests","fix via capturing relfilenode and joining","pg_catalog.zdb_* tables also need relfilenode filtering","reference to setup_backup.sql example"],"reasoning":"The agent's answer is an authentication failure message with no actual content addressing the question, so it fails to hit any rubric points and also violates both gotchas by providing no guidance at all."}} {"kind":"verdict","task_id":"gotcha-003","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["identifies UDFs as global FDB introspection scans not scoped to caller's table","explains bare count(*) mixes in other tests' leftover keys causing flakiness","provides fix: capture relfilenode before DROP into temp table, JOIN every metainfo query on it","notes pg_catalog.zdb_* catalog tables (zdb_backup_gc_records, zdb_shard_stats) are also global and need relfilenode filtering","references contrib/zdb/regress/sql/backup_and_gc/setup_backup.sql as the pattern source","did not use a bare count(*) without filtering","did not assume UDFs are already scoped"],"points_missed":[],"reasoning":"The answer correctly identifies the root cause (global FDB scans mixing in other tests' keys), gives the exact fix (relfilenode capture + join), extends it to pg_catalog.zdb_* tables, and cites the correct reference file — all rubric points are covered with no gotchas violated."}} {"kind":"verdict","task_id":"gotcha-003","arm":"memory","run":1,"verdict":{"score":10,"points_hit":["FDB-introspection UDFs return global keys not scoped to caller's table","bare count(*) mixes in leftover keys from other tests in shared schedule/FDB instance","fix: capture relfilenode into temp table before DROP, then JOIN metadata queries on it","cites setup_backup.sql / drop_table_left_meta.sql as reference pattern","did not use bare count(*) without filtering","did not assume UDFs are pre-scoped to caller's table"],"points_missed":["did not mention pg_catalog.zdb_* catalog tables (zdb_backup_gc_records, zdb_shard_stats) also being global and needing relfilenode filtering"],"reasoning":"The answer correctly identifies the global-scope root cause, provides the exact relfilenode-join fix with correct SQL, and cites the right reference files, satisfying nearly all rubric points and no gotchas; it only omits the pg_catalog.zdb_* global-table caveat."}} -{"kind":"verdict","task_id":"gotcha-004","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":[],"points_missed":["autovacuum worker already running not killed by GUC flip","use fault injector or synchronous vacuum UDFs","ALTER SYSTEM SET persists across schedule","teardown must reset GUC + reload conf"],"reasoning":"The agent's answer failed entirely (authentication error), providing no content addressing any rubric point."}} -{"kind":"verdict","task_id":"gotcha-004","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":[],"points_missed":["autovacuum worker in-flight from earlier test","fault injector or synchronous vacuum UDFs to pin/prevent GC","ALTER SYSTEM SET persists across tests","teardown must include alter system reset + pg_reload_conf()"],"reasoning":"The agent's answer failed to authenticate and produced no content addressing the question, so no rubric points can be awarded."}} {"kind":"verdict","task_id":"gotcha-004","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["Autovacuum worker spawned earlier may still be running/in-flight despite GUC flip (explicit race explanation)","Recommends pinning with fault injector (auto_vac_before/after_backup_dropped_table, auto_vac_after_gc_dropped_table) or driving synchronously via relyt_vacuum_dropped_relation()/relyt_vacuum_aborted_txn()","Notes ALTER SYSTEM SET + pg_reload_conf() persists cluster-wide into later tests unlike session SET","Teardown must alter system reset every touched GUC then reload, explicitly stated"],"points_missed":[],"reasoning":"答案完整命中所有四个评分点,并额外补充了 reserved-worker GC 路径不受 relyt.autovacuum 门控的深入细节和集群范围 reload 的注意事项,未违反任何gotcha。"}} {"kind":"verdict","task_id":"gotcha-004","arm":"memory","run":1,"verdict":{"score":10,"points_hit":["An autovacuum worker spawned by an earlier test may already be running even after the GUC is flipped off","To reliably prevent background GC, pin the worker with a fault injector (auto_vac_before_backup_dropped_table etc.) or drive it synchronously with relyt_vacuum_dropped_relation()/relyt_vacuum_aborted_txn()","ALTER SYSTEM SET + pg_reload_conf() persists into every subsequent test, so it must be undone","Teardown includes alter system reset relyt.autovacuum (and other touched GUCs) followed by pg_reload_conf(), plus resetting session-level SETs"],"points_missed":[],"reasoning":"The answer correctly identifies the in-flight-worker race as the root cause, recommends the exact fault-injector points and synchronous UDFs from the rubric, and gives a thorough teardown with alter system reset + pg_reload_conf(), also adding extra correct nuance about fault-injector cleanup and segment-wide reload without violating either gotcha."}} -{"kind":"verdict","task_id":"gotcha-005","arm":"no-memory","run":0,"verdict":{"score":0,"points_hit":[],"points_missed":["installcheck-default runs through pg_isolation2_regress (psycopg2-based executor), not pg_regress","cannot validate expected .out via pg_regress; format differs","expected file format must match pg_isolation2_regress conventions","reference actual output under results/ for exact format"],"reasoning":"Agent's answer is an authentication failure with no content addressing the question at all, so none of the rubric points were addressed."}} -{"kind":"verdict","task_id":"gotcha-005","arm":"no-memory","run":1,"verdict":{"score":0,"points_hit":[],"points_missed":["installcheck-default runs through pg_isolation2_regress not pg_regress","cannot validate expected .out via pg_regress","format must match pg_isolation2_regress conventions","reference actual results/ output"],"reasoning":"The agent's answer is an authentication failure message with no substantive content addressing the question at all."}} {"kind":"verdict","task_id":"gotcha-005","arm":"memory","run":0,"verdict":{"score":10,"points_hit":["installcheck-default runs through pg_isolation2_regress (psycopg2-based) not pg_regress (psql-based) — different formats","cannot validate expected .out via pg_regress; command tags, column alignment, trailing whitespace differ","hand-writing is fine but must match pg_isolation2_regress conventions (single-line echo, command tags, ljust alignment/trailing spaces)","reference actual output under results/ to get exact format","did not validate/generate via pg_regress (avoided gotcha)","did not assume the two executors produce same formatting (avoided gotcha)"],"points_missed":[],"reasoning":"The answer correctly diagnoses the executor mismatch, explains the specific formatting differences, and recommends using results/foo.out as the format reference rather than pg_regress — hitting every rubric point without triggering either gotcha."}} {"kind":"verdict","task_id":"gotcha-005","arm":"memory","run":1,"verdict":{"score":10,"points_hit":["installcheck-default runs through pg_isolation2_regress (psycopg2-based executor), not pg_regress (psql-based executor) — the two executors produce different output formats","You cannot validate an expected .out file by running it through pg_regress; the command tags, column alignment, and trailing whitespace differ between the two executors","It is fine to hand-write the expected file, but its format must match pg_isolation2_regress conventions: single-line SQL echo, command tags like CREATE/INSERT N/DROP, left-justified (ljust) column alignment with trailing spaces","Reference the actual output already produced under results/ in the regress directory to get the exact format right, rather than guessing"],"points_missed":[],"reasoning":"答案准确指出了 pg_isolation2_regress 与 pg_regress 执行器差异的根本原因,列出了命令标签、单行 SQL 回显、列对齐和尾随空格等具体差异点,并给出了参考 results/ 目录实际输出来修正 expected 文件的正确做法,完整覆盖了 rubric 的所有得分点,且未违反任何 gotcha。"}} +{"kind":"run","task_id":"fix-005","arm":"no-memory","run":0,"result":{"task_id":"fix-005","arm":"no-memory","result_text":"Good, I have everything needed to answer now.\n\n## Where the exclusion list lives\n\n`pg_catalog.relyt_non_dispatched_func` — a catalog table created and one-time-seeded in `contrib/zdb/zdb--1.3--1.4.sql:271-303`. It's populated by `LIKE`-pattern matches against `proname` (`pg_%`, `gp_%`, `zdb_%`, `relyt_%`, `generate_%`, `has_%`, `current_%`, etc.) plus an explicit `('version','format','set_config')` list, resolved to OIDs at extension-upgrade time. No upgrade script since 1.3→1.4 (current version is 3.43) has ever added or removed rows from it.\n\nThe check is `FuncDispatchIsRestricted(Oid procOid)` in `contrib/zdb/src/storage/zdb_syntax_check.c:2006-2063`:\n1. Exact-OID lookup in `relyt_non_dispatched_func` → restricted if found.\n2. Otherwise, if the function's namespace isn't `pg_catalog`/`information_schema`/`gp_toolkit` → restricted (catches user-defined functions).\n3. Otherwise → **not restricted** (dispatchable).\n\nThere's no check on `provolatile`/`proisstrict`/`proretset` anywhere — it's purely name-pattern + namespace, an allowlist-by-omission for builtins.\n\n## Why `regexp_matches` gets dispatched — two independent causes\n\n1. **It's not in the seed list.** `regexp_matches` (both overloads, `pg_proc.dat:3502-3508`) lives in `pg_catalog` and matches none of the `LIKE` patterns or the explicit name list, so `FuncDispatchIsRestricted` returns `false`.\n2. **Even if it were added, it wouldn't matter for the reported case.** In `GetPlanType()` (`contrib/zdb/src/dispatch/dispatcher.c:483-641`), `numRestrictedFunc` is only consulted in one branch:\n ```c\n if (totalTables == 0 \u0026\u0026 numRestrictedFunc == 0 \u0026\u0026 numTotalFunc \u003e 0)\n return PT_ONLY_CAN_DISPATCHED_FUNC;\n if (totalTables == 0)\n return PT_IS_NOT_TABLESCAN;\n if (totalTables \u003e 0 \u0026\u0026 dispatchTableCnt == totalTables)\n return PT_FULL_ZDB_TABLESCAN; // \u003c-- numRestrictedFunc never checked here\n ```\n Any query with a `FROM` clause (`SELECT regexp_matches(col, pat) FROM zdb_table`) has `totalTables \u003e 0`, so it falls straight to `PT_FULL_ZDB_TABLESCAN`/`PT_PARTIAL_ZDB_TABLESCAN`, and `SwiftDispatchPlanner()` (`dispatcher.c:1286-1289`) dispatches without ever re-checking `numRestrictedFunc`. This was true from the very first commit that introduced the mechanism (`adb558ce0fe`, \"support dispatch function to edps\") — the blacklist was only ever wired up for the narrow no-FROM-clause case like `SELECT now()`.\n\n## Minimal fix\n\nTwo small, independent changes are needed (verified this branch has no existing diff on this code vs. `realtime`, and it's unrelated to the SP2 direct-dispatch work in the recent commits — that's GPORCA single-segment dispatch on distribution-key equality, a different \"dispatch\"):\n\n1. **Add `regexp_matches` to the exclusion list for existing installs**: create `contrib/zdb/zdb--3.43--3.44.sql` (new version) with an `INSERT INTO pg_catalog.relyt_non_dispatched_func ... WHERE proname = 'regexp_matches' AND pronamespace = 'pg_catalog'::regnamespace`, plus the matching down-migration and a bump to `zdb.control`/`RELYT_VERSION`. (Also worth auditing for other unlisted SRFs like `regexp_split_to_table`/`regexp_split_to_array`/`unnest` while at it, but that's a broader call — not making that change without confirming scope with you.)\n2. **Wire the check into the table-scan branches** in `GetPlanType()` (`dispatcher.c:629-637`) — require `numRestrictedFunc == 0` before returning `PT_FULL_ZDB_TABLESCAN` (fall back to `PT_PARTIAL_ZDB_TABLESCAN` or a no-dispatch type otherwise), since without this, step 1 alone is a no-op for any real table-scan query.\n\nWant me to implement both, or just scope it to `regexp_matches` and confirm the fix pattern first?","input_tokens":12,"output_tokens":3284,"duration_s":688.419679019,"timed_out":false}} +{"kind":"verdict","task_id":"fix-005","arm":"no-memory","run":0,"verdict":{"score":3,"points_hit":["Identified pg_catalog.relyt_non_dispatched_func as the exclusion list mechanism"],"points_missed":["Wrong location: claims list lives in contrib/zdb/zdb--1.3--1.4.sql, not contrib/relyt/relyt--3.22.sql (and later versioned relyt--*.sql) as the rubric specifies","Wrong population mechanism: claims LIKE-pattern matches on proname plus explicit ('version','format','set_config') list, not the INSERT...SELECT...WHERE proname='...' OR proname='...' OR-chain with ON CONFLICT DO NOTHING","Minimal fix proposed is a new migration file with INSERT WHERE proname='regexp_matches' AND pronamespace check — not the specified fix of adding 'or proname = \\'regexp_matches\\'' clause to the existing OR-chain in relyt--*.sql","Does not mention carrying the fix forward into the newest/current relyt--*.sql version as a full catalog rebuild script"],"reasoning":"The agent correctly identified the relyt_non_dispatched_func catalog table as the mechanism but misattributed its location (zdb vs relyt SQL files) and misdescribed the population pattern (LIKE-pattern matching vs OR-chain of proname literals), leading to an incorrect minimal fix that doesn't match the rubric's specified approach of adding an OR clause to the existing INSERT statement."}} +{"kind":"run","task_id":"fix-005","arm":"no-memory","run":1,"result":{"task_id":"fix-005","arm":"no-memory","result_text":"I have everything I need. Let me give the answer.\n\n## Where the exclusion list lives\n\nThe list is the real (heap) catalog table **`pg_catalog.relyt_non_dispatched_func`**, created in `contrib/zdb/zdb--1.3--1.4.sql:271` and populated with `INSERT ... SELECT oid, proname, ... FROM pg_catalog.pg_proc WHERE ...` statements scattered across the `contrib/relyt/relyt--X.sql` version scripts (each version can add more excluded functions). `regexp_matches` is in fact already listed there (`contrib/relyt/relyt--3.43.sql:4123`).\n\nIt's checked at plan time by `FuncDispatchIsRestricted()` (`contrib/zdb/src/storage/zdb_syntax_check.c:2006-2063`), which returns `true` if the func OID is in that table, or if the function isn't in `pg_catalog`/`information_schema`/`gp_toolkit` (i.e. user-defined).\n\nThe walker `RangeVarWalker()` in `contrib/zdb/src/dispatch/dispatcher.c:409-423` calls this for every `FuncExpr` node and increments `ctx-\u003erestricted_funcs` when true, alongside `ctx-\u003etotal_funcs`.\n\n## The actual bug\n\n`GetPlanType()` (`dispatcher.c:482`) computes `numRestrictedFunc`/`numTotalFunc` from that walker, but only **consults** them in the table-less branch:\n\n```c\n// dispatcher.c:629\nif (totalTables == 0 \u0026\u0026 numRestrictedFunc == 0 \u0026\u0026 numTotalFunc \u003e 0)\n return PT_ONLY_CAN_DISPATCHED_FUNC;\nif (totalTables == 0)\n return PT_IS_NOT_TABLESCAN;\nif (totalTables \u003e 0 \u0026\u0026 dispatchTableCnt == totalTables)\n return PT_FULL_ZDB_TABLESCAN; // \u003c-- numRestrictedFunc never checked here\nelse if (zdbTables == 0)\n return PT_NON_ZDB_TABLESCAN;\nelse\n return PT_PARTIAL_ZDB_TABLESCAN;\n```\n\nSo `SELECT regexp_matches(col, 'pat') FROM zdb_tbl` has `totalTables=1`, `dispatchTableCnt==totalTables`, hits the `PT_FULL_ZDB_TABLESCAN` branch, and `PT_FULL_ZDB_TABLESCAN` is exactly what `IsSwiftPlanner()` uses at `dispatcher.c:1286` to decide to dispatch the whole query to swift/EDPS — the restricted-func count is silently dropped whenever the query also touches a fully-ZDB table set. It's only honored for the degenerate no-table case.\n\n## Minimal fix\n\nGate the `PT_FULL_ZDB_TABLESCAN` branch on `numRestrictedFunc`, falling back the same way the existing catalog-table guard does (`dispatcher.c:615-616`):\n\n```c\nif (totalTables \u003e 0 \u0026\u0026 dispatchTableCnt == totalTables)\n{\n if (numRestrictedFunc \u003e 0)\n return PT_NON_ZDB_TABLESCAN;\n return PT_FULL_ZDB_TABLESCAN;\n}\nelse if (zdbTables == 0)\n```\n\nThat forces any query containing a coordinator-only function (per `relyt_non_dispatched_func`) to fall back to normal execution instead of being fully dispatched to swift, regardless of whether the scanned tables are all-ZDB. Want me to apply this edit?","input_tokens":50,"output_tokens":12701,"duration_s":519.458599621,"timed_out":false}} +{"kind":"verdict","task_id":"fix-005","arm":"no-memory","run":1,"verdict":{"score":3,"points_hit":["The list lives in a pg_catalog table (relyt_non_dispatched_func) populated via versioned relyt--*.sql / zdb--*.sql scripts"],"points_missed":["Did not identify the specific INSERT...SELECT...FROM pg_catalog.pg_proc WHERE proname = '...' OR proname = '...' style with ON CONFLICT DO NOTHING as the population mechanism","Did not give the minimal fix of adding 'or proname = regexp_matches' to that WHERE clause — instead claims regexp_matches is already listed and proposes an unrelated, much larger fix in dispatcher.c gating PT_FULL_ZDB_TABLESCAN on numRestrictedFunc","Did not address carrying the fix forward into the newest/current relyt--*.sql version since it didn't identify the correct fix location at all"],"reasoning":"The agent correctly located the catalog table but got the actual bug and fix badly wrong: it claims regexp_matches is already present in the exclusion list and instead proposes a speculative dispatcher.c code change, which contradicts the rubric's expected minimal fix (adding a proname clause to the SQL INSERT statement) and shows a load-bearing unverified claim presented as fact."}} +{"kind":"run","task_id":"gotcha-001","arm":"no-memory","run":0,"result":{"task_id":"gotcha-001","arm":"no-memory","result_text":"No problem — you can run it yourself. Summary:\n\n**Cause**: `delta_kernel_ffi.h` / `libdelta_kernel_ffi.so` under `contrib/zdb/third_party/install/` are stale (dated Jun 16), left over from before your rebase. `neon_build_extensions.sh` does invoke `delta-kernel-rs/relyt/build.sh`, but Rust's incremental cache thinks nothing changed and skips regenerating/copying the header — while `zdb_deltalake_export.c` was updated on main to reference new enum variants, causing the `PrimitiveType* undeclared` compile error, or a runtime ABI/schema mismatch (`Schema inconsistent`) if you're linking against the old `.so`.\n\n**Fix** — force-delete the installed artifacts and rebuild the crate directly (bypasses Cargo's incremental short-circuit):\n\n```bash\nrm -f contrib/zdb/third_party/install/include/delta_kernel_ffi.h\nrm -f contrib/zdb/third_party/install/lib/libdelta_kernel_ffi.so\nsource /opt/rh/gcc-toolset-11/enable\ncd contrib/zdb/third_party \u0026\u0026 ./delta-kernel-rs/relyt/build.sh release install\n```\n\nThen rerun `sh neon/cicd/build.sh` (or your usual full build). If it still fails, check whether `delta-kernel-rs` submodule itself is actually at the new commit (`git submodule status contrib/zdb/third_party/delta-kernel-rs` vs what main expects) — if the header regenerates but still lacks the new variant, the submodule pointer likely didn't move.","input_tokens":6,"output_tokens":1563,"duration_s":67.027073199,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-001","arm":"no-memory","run":0,"verdict":{"score":10,"points_hit":["Correctly identifies stale delta-kernel-rs FFI header due to Rust incremental build short-circuit skipping header copy","Correct fix: rm -f installed header and .so","Sources gcc-toolset-11 before rebuilding","Correct rebuild command via delta-kernel-rs/relyt/build.sh release install","Implicitly frames this as needed after rebase (context of question)","Did not just re-run full build assuming auto-pickup","Did not assume submodule update alone was sufficient","Did not skip gcc-toolset-11"],"points_missed":[],"reasoning":"The answer correctly diagnoses the stale FFI header/incremental cache issue, gives the exact fix commands in the right order including sourcing gcc-toolset-11, and avoids all three gotchas (doesn't just rerun build, doesn't assume submodule update fixes it, doesn't skip toolset sourcing). Fully matches the rubric."}} +{"kind":"run","task_id":"gotcha-001","arm":"no-memory","run":1,"result":{"task_id":"gotcha-001","arm":"no-memory","result_text":"This is the documented gotcha in `CLAUDE.md`'s pre-build section — a stale Rust incremental-build cache for `delta-kernel-rs`, not a submodule sync problem.\n\n**Root cause:** `zdb_deltalake_export.c` uses `PrimitiveType*` enum variants (`PrimitiveTypeInt32`, `PrimitiveTypeUtf8`, etc.) from the generated FFI header `delta_kernel_ffi.h`. That header is produced by `contrib/zdb/third_party/delta-kernel-rs/relyt/build.sh`, which is invoked by `neon_build_extensions.sh` during the full Neon build. But cargo's incremental build cache (`target/`) can decide \"nothing changed\" and skip regenerating/copying the header+lib, even when the C consumer was updated upstream to reference new enum variants that only exist in the newer generated header. That's exactly what you're hitting:\n- Compile-time symptom: `PrimitiveType* undeclared` — the *stale installed* header doesn't have the new variant.\n- Runtime symptom (if it happened to compile against an old header/lib combo, or an old `.so` is still loaded): `Create write context failed [errno: -1, msg: Schema inconsistent]` — schema written using one enum layout, read back expecting another.\n\nIn your checked-out worktree here, the installed header (`contrib/zdb/third_party/install/include/delta_kernel_ffi.h`, dated Jun 16) already defines all the `PrimitiveType*` variants the current `zdb_deltalake_export.c` expects, and the `delta-kernel-rs` submodule is clean/pinned correctly (`v0.16.0-28-g995f844`, Jun 5). So the submodule itself is fine — the fix is purely to force the header/lib to regenerate rather than trust cargo's cache.\n\n**Fix** (from `CLAUDE.md`, run before rebuilding):\n```bash\nrm -f contrib/zdb/third_party/install/include/delta_kernel_ffi.h\nrm -f contrib/zdb/third_party/install/lib/libdelta_kernel_ffi.so\nsource /opt/rh/gcc-toolset-11/enable\n(cd contrib/zdb/third_party \u0026\u0026 ./delta-kernel-rs/relyt/build.sh release install)\n```\nThen rerun the full build (`sh neon/cicd/build.sh` or `RELYT_WITH_NEON=false sh neon/cicd/build.sh`). Deleting the artifacts forces `build.sh` to actually regenerate and copy the header/`.so`, sidestepping cargo's \"nothing changed\" short-circuit.\n\nIf you still see the error after that, also check for a stale `target/` dir under `contrib/zdb/third_party/delta-kernel-rs/target` — you can `cargo clean` there (or `rm -rf` it) as a harder reset, since a corrupted/partial incremental cache is the only way the delete-and-rebuild step above wouldn't fix it.","input_tokens":6,"output_tokens":2855,"duration_s":67.664663928,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-001","arm":"no-memory","run":1,"verdict":{"score":10,"points_hit":["stale delta-kernel-rs FFI header due to Rust incremental build cache short-circuit","fix: force-delete installed header and lib before rebuild","must source gcc-toolset-11 before rebuilding","correct rebuild command via delta-kernel-rs/relyt/build.sh","notes this recurs after every rebase/pull/branch switch","did not assume submodule update alone was sufficient","did not just rerun full build assuming auto-pickup","did not skip sourcing gcc-toolset-11"],"points_missed":[],"reasoning":"The answer correctly identifies the stale Rust incremental cache root cause, distinguishes compile vs runtime symptoms, gives the exact fix commands with gcc-toolset-11 sourced first, and explicitly notes this must be redone per rebase — matching all rubric points with no gotchas violated."}} +{"kind":"run","task_id":"gotcha-002","arm":"no-memory","run":0,"result":{"task_id":"gotcha-002","arm":"no-memory","result_text":"No existing convention for this in the ZDB regress suite that I could find, so here's the reasoning from first principles:\n\n**Don't hardcode a small/arbitrary literal.** The main risk is that OIDs in Postgres/Greenplum are assigned sequentially from a shared counter, so a value that's \"obviously fake\" today can become real:\n\n- **Collides with a system catalog OID.** Anything below `FirstNormalObjectId` (16384) risks matching a bootstrap catalog object, which can also shift across GPDB/extension versions — you'd get a different error path (e.g. \"permission denied\"/internal catalog reference) instead of the \"relation does not exist\" case you're testing.\n- **Collides with a real, transient OID.** Because `contrib/zdb/regress` tests share one cluster/database (`isolation2test`) across a schedule, the OID counter keeps advancing as earlier tests create/drop tables, indexes, temp tables, etc. A small-ish literal like `99999` might actually get allocated to some other test's object by the time yours runs — silently turning your negative test into a false positive/flaky test depending on run order. This is the same class of flakiness the CLAUDE.md testing section calls out for FDB introspection UDFs and relfilenode scoping.\n- **Unsigned vs signed handling.** `Oid` is an unsigned 4-byte int, but if the C code you're exercising casts it through a signed `int32` anywhere, a literal above `2^31-1` (2147483647) could get sign-misinterpreted. Sticking to a value ≤ `INT32_MAX` avoids that class of bug entirely.\n\n**Recommendation:** use a large, fixed sentinel near but not exceeding `INT32_MAX`, e.g. `2147483647`, which is astronomically unlikely to ever be reached by the OID counter in a single test run/environment, and is safe under signed-int32 truncation. If you want to be fully rigorous instead of relying on \"large enough,\" compute it dynamically in the test so it's provably unused at run time:\n\n```sql\nSELECT (SELECT max(oid) + 1000000 FROM pg_class) AS fake_oid \\gset\n-- then use :fake_oid in your metadata/introspection call\n```\n\nThat sidesteps the whole \"is this number safe\" question and matches the spirit of the relfilenode-scoping guidance already in CLAUDE.md — don't assume a magic constant is safe against catalog/state drift, derive uniqueness from the live catalog instead.","input_tokens":4,"output_tokens":2641,"duration_s":72.764542688,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-002","arm":"no-memory","run":0,"verdict":{"score":3,"points_hit":["Small hardcoded OIDs risk colliding with real relations in some cluster states — agent identified this general collision risk, especially cross-test OID counter advancement","did NOT ignore the cross-cluster-state collision risk","did NOT suggest a small/low-numbered arbitrary OID like 88888"],"points_missed":["Did not recommend 9999999 specifically; instead recommended INT32_MAX (2147483647) or dynamic computation, deviating from the expected convention","Did not mention the real commit/history changing async_load_job/simple_load tests from 88888 to 9999999","Did not phrase the answer around asserting expected 'relation not found' behavior rather than depending on OID being coincidentally unused — instead leaned toward a 'provably unused' dynamic OID approach, which is a different philosophy than the rubric's expected answer"],"reasoning":"The agent correctly avoided small OIDs and correctly identified cluster-state collision risk, hitting the core gotcha points, but it did not converge on the expected concrete answer (9999999) or reference the actual historical convention/commit, and instead proposed an alternative (INT32_MAX or dynamically computed OID) that diverges from the rubric's expected guidance in the appendix."}} +{"kind":"run","task_id":"gotcha-002","arm":"no-memory","run":1,"result":{"task_id":"gotcha-002","arm":"no-memory","result_text":"Based on what I found in the codebase, here's the guidance:\n\n**Recommended OID:** pick something clearly outside any realistic allocation range for a freshly-initdb'd test database — e.g. `4000000000` (Oid is uint32, max ~4.29B). Avoid small \"plausible\" numbers.\n\n**Why, and what to watch out for:**\n\n1. **Don't go below `FirstNormalObjectId` (16384)** — defined in `src/include/catalog/pg_magic_oid.h` (`FirstBootstrapObjectId = 12500`, `FirstNormalObjectId = 16384`). OIDs below that are reserved for bootstrap/system catalog objects, and many of them *do* correspond to real `pg_class` rows (system catalogs, toast tables, etc.). A \"nonexistent\" OID that accidentally lands on a real system relation defeats the test or exercises a different code path than you intend.\n\n2. **Don't use `0`** — that's `InvalidOid` and can take a different error path than a normal \"doesn't exist\" OID, depending on the function.\n\n3. **Test-schedule pollution risk**: per this repo's isolation2 test conventions, many tests share the same `isolation2test` database and cluster across a schedule, and OIDs are assigned monotonically from a shared counter. A \"low-ish\" literal like `99999` has a small but real chance of eventually being assigned to a real relation created later in a long/growing schedule, making the test flaky over time. A large literal (billions) sidesteps this for any realistic run.\n\n4. **Check what the target function actually does with the OID before writing assertions** — I looked at a few existing zdb C functions that take `PG_GETARG_OID(0)`:\n - Functions like `zdb_get_overlap_depth`/`zdb_get_global_score` in `zdb_vacuum_stats_funcs.c` and the mapping-file functions call `relation_open(relid, AccessShareLock)` directly — this throws a hard backend `ERROR: could not open relation with OID %u` for a nonexistent OID (via `RelationIdGetRelation`). Your test should expect an `ERROR`, not an empty/NULL result.\n - By contrast, the FDB/gmeta introspection UDFs (`zdb_get_metainfo_for_file`, etc., noted in CLAUDE.md) are global key scans, not OID lookups — they won't error on a bogus OID/relfilenode, they'll just return no matching rows. Confirm which category your target function falls into, since the two need different expected-output assertions.\n\n5. **If the error message embeds the OID literally** (I saw `\"Cannot found zdb table with oid %u\"` in `zdb_mapping_file_funcs.c`), your hardcoded number will show up verbatim in the expected `.out` file — that's fine and reproducible *because* it's a literal you chose. Don't be tempted to instead capture a real OID from a dropped table to make the test \"more realistic\" — that value differs across environments/catalog history and will break comparison against a static expected file unless masked. This repo already has a masking convention for exactly this (see the `-- m/\"table_oid\": \\d+/`-style comments in `contrib/zdb/regress/sql/copy_from_s3.sql`) — use that if you ever need a dynamic OID in output.\n\nIf you tell me which specific function you're targeting, I can check whether it's dispatched to segments (there's a `dispatch_metadata.c` in the dispatch layer) and confirm the exact error path.","input_tokens":22,"output_tokens":8822,"duration_s":403.471910898,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-002","arm":"no-memory","run":1,"verdict":{"score":8,"points_hit":["Use a large OID value (recommended 4000000000, satisfies 'large not small')","Small hardcoded OIDs can collide with real relation OIDs across cluster states — explicitly discussed","Test should assert expected error/relation-not-found behavior, not depend on coincidental unused OID","Did not suggest small/low-numbered OID like 88888","Did not ignore cross-cluster-state collision risk"],"points_missed":["Did not mention the specific real-world provenance: the async_load_job/simple_load commit changing 88888 to 9999999"],"reasoning":"答案很好地满足了核心规则(大OID、避免小OID碰撞风险、断言错误行为而非依赖巧合),且明确点出了两个gotcha点(未建议88888类小值、未忽略碰撞风险),但未能提及该惯例源自real commit(88888→9999999)这一具体历史依据,故扣去该部分得分。"}} +{"kind":"run","task_id":"gotcha-003","arm":"no-memory","run":0,"result":{"task_id":"gotcha-003","arm":"no-memory","result_text":"This confirms the pattern already documented in CLAUDE.md. Here's the answer:\n\n## Why it's flaky\n\n`zdb_get_metainfo_for_file` / `zdb_get_metainfo_for_drop_flag` / `zdb_get_metainfo_for_visimap` are FDB introspection UDFs that return **all keys in that FDB key-range for the whole cluster**, not just the ones for your table. In the shared schedule, every test connects to the same `isolation2test` database and shares the same FDB instance, so a bare `count(*)` also counts:\n\n- tombstone/segfile/visimap keys left behind by *other* tests that ran earlier (or are running concurrently) in the schedule\n- keys from tables your test itself created earlier in the same script, if you didn't already clean those up\n\nAlone, the FDB namespace is otherwise empty, so the count happens to equal exactly what your test produced. In the full schedule, leftover state from sibling tests pollutes the count and it goes flaky (usually too high, occasionally racy if another test's own async cleanup is landing at the same time).\n\n## The fix\n\nCapture the dropped table's `relfilenode` *before* the `DROP`, then join every introspection query against it, scoping the count to just that relation. This is exactly the pattern in `contrib/zdb/regress/sql/backup_and_gc/setup_backup.sql`:\n\n```sql\n-- before DROP\ncreate table if not exists relfilenode_record_for_drop_tbl(relname text, relfilenode oid, relid oid);\ninsert into relfilenode_record_for_drop_tbl\n select relname, relfilenode, oid from pg_class where oid = 'your_tbl'::regclass;\n\n-- after DROP, in place of the bare count(*)\nSELECT count(*) FROM\n(\n SELECT tablespace, dbid, relationid, xmin FROM zdb_get_metainfo_for_file(2, \u003cspace\u003e, 'S')\n) a\nJOIN\n(\n SELECT relfilenode FROM relfilenode_record_for_drop_tbl WHERE relname = 'your_tbl'\n) b ON a.relationid = b.relfilenode\n```\n\nSame shape for `zdb_get_metainfo_for_visimap` (flag `'V'`) and `zdb_get_metainfo_for_drop_flag` (flag `'D'`) — see `get_meta_visimap_count`/`get_meta_drop_flag_count` in that same file, which you can call directly instead of re-deriving the JOIN.\n\nAlso worth double-checking for your specific test:\n- If it relies on \"no background GC yet\", make sure autovacuum is actually pinned (fault injector, per `drop_table_left_meta.sql`) or drive cleanup synchronously via `relyt_vacuum_dropped_relation()`/`relyt_vacuum_aborted_txn()` rather than racing a real autovacuum worker from an earlier test.\n- Reset any session/system GUCs you changed, since `ALTER SYSTEM SET` leaks into later tests in the schedule.","input_tokens":6,"output_tokens":1411,"duration_s":122.805342121,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-003","arm":"no-memory","run":0,"verdict":{"score":10,"points_hit":["FDB-introspection UDFs return global keys not scoped to caller's table","bare count(*) mixes in leftover keys from other tests in shared schedule/cluster/FDB instance","fix: capture relfilenode into temp table before DROP, then JOIN introspection queries on it","referenced setup_backup.sql as the pattern example","did not use bare count(*) without filtering","did not assume UDFs are already scoped"],"points_missed":["did not explicitly mention pg_catalog.zdb_* catalog tables (zdb_backup_gc_records, zdb_shard_stats) also being global and needing relfilenode filtering"],"reasoning":"Answer correctly identifies the root cause (global FDB keys, not table-scoped), explains the flakiness mechanism, and gives the exact relfilenode-capture-and-join fix with a concrete code example referencing the correct file; it omits the pg_catalog.zdb_* global-table caveat."}} +{"kind":"run","task_id":"gotcha-003","arm":"no-memory","run":1,"result":{"task_id":"gotcha-003","arm":"no-memory","result_text":"Now I have the concrete pattern. Here's the answer:\n\n**Why it goes flaky in the full schedule:** `zdb_get_metainfo_for_file`, `zdb_get_metainfo_for_drop_flag`, and `zdb_get_metainfo_for_visimap` are FDB-introspection UDFs that return **all** keys under a given `(tablespace, dbid)` scope globally — they don't filter by table/relfilenode. When your test runs in isolation, your table's rows are the only ones in that scope, so a bare `count(*)` happens to match. In the full schedule, other tests in the same run share the same cluster/FDB instance and tablespace, and their own segfile/tombstone/visimap/drop-flag keys (created before, during, or even concurrently via autovacuum) show up in the same result set, inflating or otherwise perturbing your count nondeterministically.\n\n**Fix:** scope every query to your test table's `relfilenode`, captured *before* the `DROP TABLE` (the OID becomes unrecoverable once the table is gone). The established pattern in this repo is in `contrib/zdb/regress/sql/backup_and_gc/setup_backup.sql`:\n\n1. Create (or reuse, via `create table if not exists`) a helper table to stash the relfilenode:\n```sql\ncreate table if not exists relfilenode_record_for_drop_tbl(relname text, relfilenode oid, relid oid);\ninsert into relfilenode_record_for_drop_tbl\n select relname, relfilenode, oid from pg_class where oid = 'your_test_tbl'::regclass;\n```\n2. Do the `DROP TABLE your_test_tbl;`\n3. Join the introspection UDF against the captured relfilenode instead of counting raw rows:\n```sql\nSELECT count(*) FROM\n (SELECT tablespace, dbid, relationid, xmin FROM zdb_get_metainfo_for_file(2, \u003cspace\u003e, 'S')) a\n JOIN\n (SELECT relfilenode FROM relfilenode_record_for_drop_tbl WHERE relname = 'your_test_tbl') b\n ON a.relationid = b.relfilenode;\n```\nSame shape for `zdb_get_metainfo_for_drop_flag(..., 'D')` and `zdb_get_metainfo_for_visimap(..., 'V')` — just swap the UDF call inside the inner subquery (see `get_meta_file_count`/`get_meta_visimap_count`/`get_meta_drop_flag_count` in that same file, which wrap this exact pattern as reusable plpgsql functions).\n\nAlso worth double-checking per the isolation guidance in CLAUDE.md: reset any session/system GUCs you touch, and if your assertion depends on \"no leftover metadata *yet*,\" make sure autovacuum isn't racing you — drive cleanup synchronously via `relyt_vacuum_dropped_relation()`/`relyt_vacuum_aborted_txn()` rather than relying on timing, or use a fault injector to pin autovacuum like `drop_table_left_meta.sql` does.","input_tokens":8,"output_tokens":1548,"duration_s":98.38825361,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-003","arm":"no-memory","run":1,"verdict":{"score":10,"points_hit":["FDB-introspection UDFs return global keys not scoped to current test's table","bare count(*) mixes in leftover keys from other tests in the same schedule/cluster/FDB instance","fix: capture relfilenode into temp table before DROP, then JOIN introspection queries on it","references contrib/zdb/regress/sql/backup_and_gc/setup_backup.sql as the pattern source","did not use bare count(*) without filtering","did not assume UDFs are already scoped to caller's table"],"points_missed":["did not explicitly call out pg_catalog.zdb_* catalog tables (zdb_backup_gc_records, zdb_shard_stats) as having the same global-scope issue"],"reasoning":"The answer correctly identifies the root cause (global FDB keys, not scoped), gives the exact relfilenode-capture-and-join fix, and cites the correct reference file with matching helper function names, hitting nearly all rubric points; it only omits the specific mention of pg_catalog.zdb_* catalog tables needing the same treatment."}} +{"kind":"run","task_id":"gotcha-004","arm":"no-memory","run":0,"result":{"task_id":"gotcha-004","arm":"no-memory","result_text":"Root cause, verified in the code (not guessed):\n\n**The GUC reload isn't actually cluster-wide.** `select pg_reload_conf();` with no `FROM` clause is a plain function call with no table reference, so in GPDB it executes only on the coordinator (QD) backend — it is never dispatched to segments. Meanwhile `ALTER SYSTEM SET` *is* dispatched to every segment (`src/backend/tcop/utility.c:834-841`, `T_AlterSystemStmt` calls `CdbDispatchUtilityStatement` when `Gp_role == GP_ROLE_DISPATCH`). So `postgresql.auto.conf` gets updated everywhere, but only the coordinator gets the SIGHUP that makes the new value live. Segment postmasters — where ZDB's autovacuum/merge/GC workers actually run, since that's where the columnar data lives — keep whatever stale `relyt.autovacuum` value was already in memory until something else SIGHUPs them.\n\nThat's exactly why it's *occasional*: whether a given segment still has a stale \"on\" in memory (and whether a worker there happens to fire during your test window) depends on what earlier tests in the schedule did to that segment's config, not on your test's own setup step.\n\nThis repo already has the correct idiom for a cluster-wide reload, used elsewhere (`contrib/zdb/regress/sql/all_type.sql:3`, `shard_level_parallel.sql:6`, `explain_dispatch2swift.sql:3`, and the union form in `contrib/zdb/perf/test_autovacuum_backup_gc.py:27`):\n\n```sql\nselect pg_reload_conf();\nselect pg_reload_conf() from gp_dist_random('gp_id');\n```\n\n`gp_dist_random('gp_id')` forces the function to execute on every segment; without it you're only ever reloading the coordinator. If your setup is missing that second line, that alone would explain the flakiness.\n\n**Second, independent cause** (already called out in this repo's CLAUDE.md testing notes): even with a correct cluster-wide reload, an autovacuum worker that was already forked and already past the `gZdbAutoVacuum` gate in `ZDBNeedsVacAnalyze` (`contrib/zdb/src/storage/zdb_vacuum.c:2019`) before your reload lands will finish its *current* backup/merge/GC step regardless — the GUC only blocks the *next* decision to vacuum, it doesn't preempt in-flight work. If you need a hard guarantee, pin the worker with the fault-injector points from `contrib/zdb/regress/sql/backup_and_gc/drop_table_left_meta.sql` (`auto_vac_before_backup_dropped_table` / `auto_vac_after_backup_dropped_table` / `auto_vac_after_gc_dropped_table`), or drive GC synchronously via `relyt_vacuum_dropped_relation()` / `relyt_vacuum_aborted_txn()` instead of trusting \"GUC off ⇒ no background GC\" as a timing guarantee.\n\n**Teardown**, symmetric with setup so you don't leave segments desynced for the next test in the schedule:\n\n```sql\nalter system reset relyt.autovacuum;\nselect pg_reload_conf();\nselect pg_reload_conf() from gp_dist_random('gp_id');\n```\n\nand if you used fault injectors, reset each one you set (`gp_inject_fault(\u003cname\u003e, 'reset', '', 'isolation2test', '', 1, 1, 0, dbid, -1)`), following the pattern in `drop_table_left_meta.sql`.","input_tokens":28,"output_tokens":12047,"duration_s":542.541928725,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-004","arm":"no-memory","run":0,"verdict":{"score":8,"points_hit":["An autovacuum worker spawned earlier may already be in-flight and finish its current step regardless of the GUC flip — correctly identified and explained (already past the gate, only blocks next decision, not in-flight work)","Correctly recommends pinning with fault injectors (auto_vac_before_backup_dropped_table / auto_vac_after_backup_dropped_table / auto_vac_after_gc_dropped_table) or driving synchronously via relyt_vacuum_dropped_relation()/relyt_vacuum_aborted_txn()","Correctly notes ALTER SYSTEM SET + pg_reload_conf() persists into subsequent tests in the schedule","Teardown includes alter system reset relyt.autovacuum followed by pg_reload_conf(), plus resetting fault injectors used"],"points_missed":["Introduces an unverified/speculative primary root cause (pg_reload_conf() not being cluster-wide without gp_dist_random('gp_id')) that isn't part of the rubric and isn't confirmed against actual code behavior — presented with high confidence including fabricated-sounding line number citations, which risks misleading the user despite the correct secondary explanation being present"],"reasoning":"The answer covers all four core rubric points (stale in-flight worker, fault-injector/synchronous mitigation, GUC persistence across schedule, correct teardown with alter system reset + pg_reload_conf), avoiding both gotchas. However, it leads with a speculative and unverified claim about pg_reload_conf() dispatch behavior on segments, stated with specific line-number citations as if verified, which is a confident-but-unconfirmed assertion not supported by the rubric and adds risk of misleading the user."}} +{"kind":"run","task_id":"gotcha-004","arm":"no-memory","run":1,"result":{"task_id":"gotcha-004","arm":"no-memory","result_text":"Confirmed by reading `zdb_vacuum.c` and the existing `drop_table_left_meta.sql` test — here's what's actually happening.\n\n## Why it's still flaky\n\n**1. The GUC is checked once per decision, not polled during execution.** `gZdbAutoVacuum` (backing `relyt.autovacuum`) is only consulted inside `ZDBNeedsVacAnalyze` (`zdb_vacuum.c:1893`, `:2019`) — a single gate checked when the autovacuum worker is *deciding* whether to schedule work for a table. Once a PLAN → MERGE/BACKUP/GC pipeline for a shard has already been dispatched (including async EDPS-dispatched tasks), flipping the GUC afterward does nothing to stop it — it runs to completion. If a worker was forked or a task dispatched in the window right before your `alter system set ... off; pg_reload_conf()`, it finishes anyway.\n\n**2. `pg_reload_conf()` alone only reloads the backend you're connected to.** In this MPP setup you need it on every segment too. The existing test does this correctly:\n```sql\nselect * from pg_reload_conf() union all select pg_reload_conf() from gp_dist_random('gp_id');\n```\nA bare `select pg_reload_conf();` leaves segment-side autovacuum workers running with the old value.\n\n**3. `relyt.autovacuum` only gates ZDB's own autovacuum logic**, not the base PostgreSQL `autovacuum` GUC. Heap-based catalog/metadata tables can still get vacuumed by the standard daemon regardless.\n\n**4. Leakage from an earlier test in the schedule.** As the project's own testing notes point out, `alter system set` persists across tests. If the test before yours didn't `alter system reset` it, or left a `gp_inject_fault` suspend point armed, your setup can race against work that test kicked off.\n\n**5. Global introspection scope.** Even when no GC actually touched *your* table, `zdb_get_metainfo_for_*` UDFs and `pg_catalog.zdb_*` views are cluster-wide. An unrelated table's leftover async task can make a bare `count(*)` look like GC ran against yours.\n\n## What to do instead\n\nDon't race the GUC + timer-driven launcher for precise \"no GC happened\" assertions — pin it deterministically with fault injectors, the way `drop_table_left_meta.sql` does: `gp_inject_fault('auto_vac_before_backup_dropped_table', 'suspend', ...)` before the operation, `'wait_until_triggered'` to confirm the worker actually reached (or didn't reach) that point, then `'reset'`. If you need synchronous, on-demand GC instead of waiting on autovacuum at all, drive it explicitly via `relyt_vacuum_dropped_relation()` / `relyt_vacuum_aborted_txn()`.\n\n## Teardown checklist\n\n```sql\n-- undo every GUC you touched — reset, don't just re-set to the default value\nalter system reset relyt.autovacuum;\n-- ... reset any other relyt.* / autovacuum_* GUCs you set ...\n\n-- reload on coordinator AND every segment\nselect * from pg_reload_conf() union all select pg_reload_conf() from gp_dist_random('gp_id');\n\n-- reset every fault injector you armed — a lingering 'suspend' hangs\n-- the NEXT test's worker if it hits the same injection point\nselect gp_inject_fault('\u003cpoint\u003e', 'reset', '', 'isolation2test', '', 1, 1, 0, dbid, -1)\n from gp_segment_configuration where role = 'p' and content = -1;\n```\n\nAnd scope any FDB-introspection assertions by `relfilenode` captured before DROP, per the existing convention — that alone would explain some of the flakiness even if GC timing were perfect.","input_tokens":70,"output_tokens":6948,"duration_s":247.618378029,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-004","arm":"no-memory","run":1,"verdict":{"score":10,"points_hit":["autovacuum worker spawned earlier may already be running/in-flight even after GUC flip (explicitly covered in point 1 and point 4 of answer)","recommends pinning with fault injector using exact injection points from drop_table_left_meta.sql, or driving synchronously via relyt_vacuum_dropped_relation()/relyt_vacuum_aborted_txn()","explicitly notes ALTER SYSTEM SET + pg_reload_conf() persists into every subsequent test in the schedule (leakage point)","teardown includes alter system reset relyt.autovacuum + pg_reload_conf(), plus resetting session-level SETs and fault injectors"],"points_missed":[],"reasoning":"答案准确覆盖了所有四个得分点:明确指出GUC翻转不会终止已经在途的autovacuum worker,推荐使用drop_table_left_meta.sql中的具体fault injector注入点或relyt_vacuum_dropped_relation()/relyt_vacuum_aborted_txn()同步驱动,正确说明ALTER SYSTEM SET会泄漏到后续测试,并给出包含alter system reset + pg_reload_conf()的完整teardown。两个gotcha也均未违反。"}} +{"kind":"run","task_id":"gotcha-005","arm":"no-memory","run":0,"result":{"task_id":"gotcha-005","arm":"no-memory","result_text":"That's a known gotcha documented in this repo's CLAUDE.md: `make installcheck-default` runs through `pg_isolation2_regress`, which uses a **psycopg2**-based executor, not `psql`. Capturing your expected output via plain `pg_regress` (psql-based) gives you a different output format — differences typically show up in:\n\n- Command tags (`CREATE TABLE`, `INSERT 0 N`, etc. presence/format)\n- Column alignment / padding\n- Trailing whitespace on lines\n- How the SQL statement itself is echoed back\n\nSince the two executors aren't interchangeable for generating expected files, the fix is:\n\n1. Run your test once via `make install installcheck-default` (even though it'll fail/diff).\n2. Look in `contrib/zdb/regress/results/foo.out` — that's the *actual* psycopg2-executor output.\n3. Diff that against your hand-written expected file to see the format mismatch, then copy/adjust `results/foo.out` into `expected/foo.out` (or hand-edit to match that format precisely — single-line SQL echo, command tags, left-justified columns with trailing spaces).\n\nIf the query results are semantically right but formatting differs, just promote the actual `results/foo.out` to `expected/foo.out` rather than trying to hand-craft it further.","input_tokens":2,"output_tokens":724,"duration_s":49.178871609,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-005","arm":"no-memory","run":0,"verdict":{"score":10,"points_hit":["identified installcheck-default runs via pg_isolation2_regress (psycopg2) not pg_regress (psql)","explicitly states two executors aren't interchangeable for generating expected files (did not assume same formatting)","correctly states hand-writing is fine but format must match pg_isolation2_regress conventions (command tags, alignment, trailing whitespace, single-line echo)","recommends referencing actual results/foo.out output rather than guessing","did not validate/generate expected file via pg_regress"],"points_missed":[],"reasoning":"答案完整覆盖了所有得分点:正确识别两种执行器的区别、未误用pg_regress验证、给出了正确的hand-writing格式要求,并建议参照results/目录下的实际输出而非猜测格式,未触犯任何gotcha扣分项。"}} +{"kind":"run","task_id":"gotcha-005","arm":"no-memory","run":1,"result":{"task_id":"gotcha-005","arm":"no-memory","result_text":"That's a known gotcha documented in this repo's CLAUDE.md: `installcheck-default` runs through `pg_isolation2_regress`, which executes tests via **psycopg2**, not `pg_regress`'s **psql** driver. The two produce structurally different output — command tags, column alignment/padding, trailing whitespace, etc. all differ — even when the underlying query results are identical. So an expected file captured from a raw `pg_regress` run will diff against `installcheck-default`, regardless of whether your data/logic is correct.\n\nFix: regenerate your expected file from the actual `installcheck-default` run, not `pg_regress`:\n\n```bash\nsource /workspace/phoenix/neon/pg_install/v12/greenplum_path.sh\npsql -c \"drop database if exists isolation2test;\"\ncd /workspace/phoenix/contrib/zdb/regress\nmake install installcheck-default # will fail/diff, but generates results/foo.out\n```\n\nThen inspect `contrib/zdb/regress/results/foo.out` — that's the real output format for this harness (single-line SQL echo, `CREATE`/`INSERT N`/`DROP` command tags, left-justified columns with trailing spaces). Once you've verified its contents are actually correct, copy it over:\n\n```bash\ncp results/foo.out expected/foo.out\n```\n\nAvoid hand-writing the file against `pg_regress` output going forward — always seed it from `results/` after an `installcheck-default` run.","input_tokens":2,"output_tokens":863,"duration_s":46.746539234,"timed_out":false}} +{"kind":"verdict","task_id":"gotcha-005","arm":"no-memory","run":1,"verdict":{"score":10,"points_hit":["installcheck-default runs through pg_isolation2_regress (psycopg2-based executor), not pg_regress (psql-based executor) — the two executors produce different output formats","You cannot validate an expected .out file by running it through pg_regress; the command tags, column alignment, and trailing whitespace differ between the two executors","It is fine to hand-write the expected file, but its format must match pg_isolation2_regress conventions: single-line SQL echo, command tags like CREATE/INSERT N/DROP, left-justified (ljust) column alignment with trailing spaces","Reference the actual output already produced under results/ in the regress directory to get the exact format right, rather than guessing"],"points_missed":[],"reasoning":"The answer correctly identifies the executor mismatch, explains the format differences, and instructs the user to regenerate/copy from results/ produced by installcheck-default rather than pg_regress, hitting all rubric points with no gotchas violated."}} diff --git a/eval/results/wave1-report.md b/eval/results/wave1-report.md index fc878d8b..5d8ad318 100644 --- a/eval/results/wave1-report.md +++ b/eval/results/wave1-report.md @@ -1,118 +1,117 @@ -# Wave 1 Acceptance Report — Phase 2 Optimization Loop +# Wave 1 Acceptance Report — Phase 2 Optimization Loop (CORRECTED) -**Date**: 2026-07-28 -**Branch**: `feat/phase2-wave1` (final L3 at binary version 0.4.0, slim protocol active) -**Baseline**: `2adc24c` scorecards (v0.3.0) +**Date**: 2026-07-28 (supersedes the 2026-07-28 first version, which was +invalidated by an infrastructure artifact — see "The auth artifact" below) +**Branch**: `feat/phase2-wave1` (final L3 at binary version 0.4.0, slim active) +**Baseline**: `2adc24c` scorecards, corrected in place (same artifact) **Spec**: `docs/superpowers/specs/2026-07-27-phase2-optimization-loop-design.md` -## Anchor verdicts +## The auth artifact (read this first) -| Anchor | Baseline | Wave 1 | Target | Verdict | -|---|---|---|---|---| -| L3 memory uplift | 2.267 | **2.983** (+31.6%) | ≥ 4.53 | **NOT MET** (66% of target) | -| L2 injected tokens/session | 7824 | **6084** (−22.2%) | ≤ 3912 | **NOT MET** (harness metric; see both-views note) | - -Both anchors improved materially; neither reached its ×2/÷2 target. Per the -spec, this report states achieved ratios and bottlenecks without dressing up. - -**Both-views note (L2)**: the harness metric counts `SKILL.md` (1982 tok) as a -static per-session slice, but the skill is loaded on demand in real Claude Code -sessions (Task 4 measurement caveat, ledger W1-T4). Excluding it, the -real-session injected cost is **3885 tok** (hook 2844 + MCP instr 184 + -context 240 + search 205.6×3), which is *below* the 3912 target. We report the -harness metric as the official verdict because it is the instrument the spec -anchored; the real-session view says the remaining gap is concentrated in one -place (the hook, see bottlenecks). - -## Mandatory no-memory comparison (R5 guardrail) +The whole-branch review discovered that in BOTH the baseline L3 run and the +Wave-1 final L3 run, the same 12 no-memory tuples (all 10 gotcha runs + +fix-005 ×2) had died in ~1.7s with `OAuth session expired`, produced zero +output, and were scored 0. The baseline's headline "gotcha: memory 8.50 vs +no-memory 0.00" — the story the program's uplift target was anchored on — +was an artifact, contradicted by this branch's own targeted subset runs where +the no-memory arm scored 8–10 on the same tasks. Both sidecars were repaired +via checkpoint-resume (only the 12 broken tuples re-ran, per side; every +valid run was reused). All numbers below are from the corrected scorecards. -| Arm | Final mean (N=2, 15 tasks) | Baseline mean | -|---|---|---| -| memory | 6.967 | 6.000 | -| no-memory | 3.983 | 3.733 | +## Anchor verdicts (corrected) -All 60 verdicts judged (0 failures after checkpoint re-judge; see incidents). +| Anchor | Baseline (corrected) | Wave 1 (corrected) | Original target | Verdict | +|---|---|---|---|---| +| L3 memory uplift | **−0.367** (6.000 vs 6.367) | **−0.183** (6.967 vs 7.150) | ≥ 4.53 | **INVALID TARGET** — anchored on the artifact (2.267 was never real) | +| L2 injected tokens/session | 7824 | **6084** harness / **3916** real-session | ≤ 3912 | **NOT MET** on the harness metric (−22%); real-session view lands at the line (3916 vs 3912) | + +**What is honestly true about effect:** +- Memory currently provides **no net e2e uplift on this task set** — in either + version. Wave 1 moved uplift from −0.367 to −0.183 and the memory arm's + absolute mean from 6.000 to 6.967 (+0.967, the largest defensible + effect-side claim), while cutting the memory arm's output-token overhead + from +48% to +9%. +- The ×2 uplift target is void: it was 2× an artifact. Wave 2 must re-anchor + the effect goal on corrected data (and a redesigned task set — below). + +**L2 note (both views):** the harness metric counts `SKILL.md` (2168 tok on +the final card) as a per-session static slice; in real Claude Code sessions +the skill loads on demand. Excluding it: hook 2875 + MCP instr 184 + context +240 + search 205.6×3 ≈ **3916** — within 4 tokens of the 3912 target. The +harness number (6084) remains the official verdict. + +## Per-category L3, corrected (memory / no-memory / uplift) + +| Category | Baseline | Wave 1 | Reading | +|---|---|---|---| +| architecture-qa | 8.00 / 8.90 / **−0.90** | 10.00 / 9.25 / **+0.75** | R2 answer-first genuinely cured the drag — this movement is real and survives correction | +| gotcha | 8.50 / 7.90 / **+0.60** | 9.20 / 8.90 / **+0.30** | the "+8.50" story was the artifact; the no-memory arm reads the same CLAUDE.md the tasks were mined from | +| small-fix | 1.50 / 2.30 / **−0.80** | 1.70 / 3.30 / **−1.60** | both arms improved; no-memory improved more — memory is still a drag here | -## Per-category L3 (memory / no-memory / uplift) +Memory-arm output tokens: 337k → 283k (−16%); overhead vs no-memory +48% → +9%. -| Category | Baseline | Wave 1 | Movement | -|---|---|---|---| -| architecture-qa | 8.00 / 8.90 / **−0.90** | 10.00 / 9.25 / **+0.75** | memory drag CURED; memory now a small net positive | -| gotcha | 8.50 / 0.00 / **+8.50** | 9.20 / 0.00 / **+9.20** | advantage widened (pinned core memory) | -| small-fix | 1.50 / 2.30 / **−0.80** | 1.70 / 2.70 / **−1.00** | still a drag; near-floor scores for both arms | +## What Wave 1 actually proved -Output-token overhead of the memory arm: baseline +48% (337k vs 228k) → -Wave 1 **+9%** (283k vs 260k) — the answer-first rules removed most of the -memory-driven verbosity. Empty-result (turn-starved) runs: 8/60 → 6/60. +1. **Cost engineering works**: retrieval 517→206 tok/query, context 1636→240, + instructions 787→184, composite −22% (−50% on the real-session view) — with + recall@5 flat (0.796) and distractor ratio not worse (0.133). +2. **Answer-first works**: the one category where memory was measurably + hurting for a behavioral reason (arch, −0.90) flipped to +0.75, and the + fix-003-style "saved to memory instead of answering" failure disappeared. +3. **The e2e task set cannot demonstrate memory value as designed.** Gotcha + tasks test knowledge that lives in CLAUDE.md — which both arms read. The + uplift a memory system can add is bounded by how much task-relevant + knowledge exists ONLY in memory. On this task set that bound is near zero. + This is the single most important finding for Wave 2. -## Round ledger (what shipped, what each round measured) +## Round ledger | Round | Change | Gate result | Kept | |---|---|---|---| -| R1 (T2–T5) | slim protocol 1265B + MCP instructions 736B, protocol-mode revived | recall@5 flat 0.796; R1-controlled slices −336 tok measured (−869 real-session) | ✓ | -| R2 (T6–T7) | answer-first rules in all 12 agent-facing surfaces; slim survives compaction | targeted fix/arch memory mean 2.70→5.00; gotcha held; saves still occur (audit) | ✓ | -| R3 (T8–T10) | mem_search index mode + budget; layered mem_context ≤400 tok | tokens/query 991→205.6; context 1636→88; recall@5 flat; distractor 0.133; gotcha not starved | ✓ | -| R4 (T11) | pinned core-memory block (1KB cap) + pin criterion; 2 real phoenix gotcha facts pinned | targeted gotcha memory mean 8.75 ≥ 8.5 | ✓ | - -All five optimization items kept; zero reverts. Corpus note: the live phoenix -corpus grew 6→15 facts during the wave (our own L3 memory-arm saves + real -user sessions) — raw L2 comparisons across rounds carry that organic-growth -term; like-for-like slice deltas are recorded per round in the ledger. - -## Bottleneck analysis (what blocks each anchor) - -**Uplift (2.983 vs 4.53).** The gap decomposes cleanly: -- gotcha is nearly saturated (9.20 with a 10 ceiling) and arch is saturated - (10.00) — together they cannot contribute more than ~0.5 additional uplift. -- **small-fix is the entire remaining lever**: memory is still −1.00 there, - and both arms score near the floor (1.70/2.70). The tasks require sustained - multi-file investigation; current memories (facts, conventions) do not carry - the *procedural* knowledge these tasks need. Wave 2 candidates from the - research matrix: episodic memory type (worked examples of past - investigations), relations expansion (bugfix ↔ causing decision), and - turn-budget/tooling improvements orthogonal to memory. -- Arithmetic reality check: with gotcha+arch saturated, reaching uplift 4.53 - requires small-fix uplift ≈ +3.9 (from −1.00) — i.e. memory must make fix - tasks *succeed* (score ~6) where both arms now fail. That is a Wave 2/3 - research problem (procedural memory), not a tuning problem. - -**Tokens (6084 vs 3912, harness metric).** -- `static_hook_tokens` 2844 is now 47% of the composite and is dominated by - the session-start hook's non-protocol output (status block, session - bootstrap, import notices) — the protocol text itself is only ~316 tok of - it. The hook's operational chrome is the next frontier. -- `static_skill_tokens` 1982 is a measurement artifact (on-demand in real - sessions) — resolving the instrument-vs-reality question (measure a real - session transcript, or move the metric to the real-session basis) should be - a Wave 2 decision before more optimization is spent chasing it. -- Search/context slices are now capped by construction (index mode + budget + - layered context); further cuts there risk starving retrieval. - -## Incidents (this acceptance run) - -- The overnight full L3 hit 25/60 judge failures (`claude` exit 1, empty - stderr, all 3 retries; concentrated on gotcha/fix-004/fix-005 — the - tool-temptation-shaped prompts). Manual reproduction of the same judge - call succeeded the next morning: transient API-side failure under batch - load, not the Task-9-era tool-use bug (the `--tools ""` hardening was - active). Recovered via the checkpoint sidecar: 25 failed verdicts stripped, - resume re-judged exactly those (35 complete / 25 re-judge / 0 to run) — no - task re-runs, 0 failures on the second pass. The >20% warning fired as - designed and the first-pass means were correctly treated as unreliable. -- `~/.engram/memorylake.json` was clobbered twice more by cmd/engram tests - during the wave (user-accepted live-testing posture); restored from backup - before each eval round per the standing ledger rule. - -## Assumptions unchanged from baseline - -`search-calls=3.0` per session (placeholder), tokenizer approx-bytes/4, -L1 corpus frozen at 54 QA cases, judge prompts/rubrics frozen, N=2. -L1 payload metric is a floor (envelope framing uncounted — see -`SearchPayloadTokens` doc comment). +| R1 | slim protocol + trimmed instructions | recall flat; controlled slices −336 tok (−869 real-session) | ✓ | +| R2 | answer-first everywhere | targeted fix/arch memory 2.70→5.00; arch drag cured in final | ✓ | +| R3 | search index mode + layered context | 991→205.6 tok/query; context ≤400; retrieval not starved | ✓ | +| R4 | pinned core memory (1KB cap) | targeted gotcha memory 8.75 ≥ 8.5 | ✓ | + +All rounds kept on their own gates (which measured the memory arm and cost — +both real); zero reverts. Corpus grew 6→15 facts organically during the wave. + +## Wave 2 mandates (from corrected data) + +1. **Re-anchor the effect goal.** Corrected baseline uplift is −0.367. A + meaningful Wave-2 goal is "uplift > 0 with p<0.05 on a redesigned task + set", not a multiple of a broken number. +2. **Redesign the e2e task set** so memory can matter: tasks whose critical + knowledge exists only in memory (cross-session decisions, user + preferences, investigation state never committed to the repo), with + repo-knowledge tasks kept as a control group. The knowledge-isolation + property must be verified at construction (no-memory arm dry run). +3. **Procedural memory for small-fix** remains the research lever (memory + −1.60 there), but only after the task set can measure it. +4. **Hook chrome slimming** (2875 tok, 47% of composite) + the L2 + instrument decision (SKILL counting) close the token anchor. +5. **Harness guard**: treat CLI-error result_texts (auth failures) as run + failures with retry, never scoreable zeros — the artifact class that + invalidated the original numbers. + +## Incidents (acceptance runs) + +- 25/60 judge failures overnight (transient claude exit-1 storm) — recovered + via checkpoint re-judge (0 failures on retry). +- The 12-tuple auth artifact above — recovered via checkpoint re-run of + exactly those tuples on both sides. +- `~/.engram/memorylake.json` clobbered twice by cmd/engram tests + (user-accepted posture); restored from backup before each eval round. + +## Assumptions unchanged + +`search-calls=3.0`, tokenizer approx-bytes/4, frozen L1 corpus (54 QA), +frozen judge prompts/rubrics, N=2, L1 payload metric is a floor. ## Recommendation -Ship Wave 1 (all rounds kept; both anchors materially improved; agent-facing -behavior strictly better). Open Wave 2 with two mandates: (a) procedural -memory for small-fix uplift — the only lever with headroom; (b) hook-chrome -slimming + L2 instrument decision — the only lever left for the token anchor. +Ship Wave 1: every kept change is defensible on corrected data (cost −22% +harness / −50% real-session at flat retrieval quality; memory-arm e2e mean ++0.967; answer-first cured a real behavioral failure; verbosity overhead ++48%→+9%). Do NOT carry the old uplift narrative forward — Wave 2 starts +from uplift ≈ −0.2 and a task-set redesign.