From 3ea14b76b9eb9f51e55a54fcd816e368deda4cab Mon Sep 17 00:00:00 2001 From: liutiming Date: Wed, 2 Sep 2026 16:17:40 +0000 Subject: [PATCH 1/3] fix: scope /sync-gbrain's forced dream cycle to resolve_symbol_edges `gbrain dream --dream` (and --full's auto-dream) invoked the full multi-phase overnight maintenance cycle (extract_atoms, synthesize, drift, skillopt, ...) when all this stage actually needs is the resolve_symbol_edges phase that builds the call graph (code-callers/code-callees). That's minutes of unnecessary work on every forced/auto dream and effectively bolts a second maintenance engine onto gbrain's own `autopilot`, which already owns full-cycle scheduling. Pin `gbrain dream --phase resolve_symbol_edges` on both the source-scoped invocation and the no-source fallback (verified against gbrain's own arg parser that --source and --phase compose independently). Updates the dry-run preview text and --help docs to match, and adds argv-recording tests against a fake gbrain binary whose strict allowlist fails the test if any full-cycle `dream` invocation (bare `dream`, or `dream --source ` without --phase) is ever spawned. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KKBKETdxHdkM83Maw1JFeo --- bin/gstack-gbrain-sync.ts | 21 +++++-- test/gstack-gbrain-sync.test.ts | 104 +++++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 5 deletions(-) diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 4e3034b3a7..b26e052122 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -249,7 +249,9 @@ Options: --code-only Only run the code-import stage (alias for --no-memory --no-brain-sync). --dream Force the source-scoped dream cycle that builds this source's call graph (gbrain code-callers/code-callees). - Runs lock-free AFTER the sync stages. ~minutes. Default + Runs "gbrain dream --phase resolve_symbol_edges" only — + not the full overnight maintenance cycle. Runs + lock-free AFTER the sync stages. ~minutes. Default timeout 45min, override GSTACK_SYNC_DREAM_TIMEOUT_MS. --no-dream Opt out of the dream cycle that --full would auto-run. --allow-reclone Permit the code walk for URL-managed sources (remote_url set) @@ -1391,8 +1393,8 @@ export async function runDream(args: CliArgs): Promise { ok: true, duration_ms: 0, summary: sourceId - ? `would: gbrain dream --source ${sourceId} (build this source's call graph)` - : "would: gbrain dream (call-graph build)", + ? `would: gbrain dream --source ${sourceId} --phase resolve_symbol_edges (build this source's call graph)` + : "would: gbrain dream --phase resolve_symbol_edges (call-graph build)", }; } @@ -1431,9 +1433,20 @@ export async function runDream(args: CliArgs): Promise { // `gbrain doctor` recommends for stale sources) and is what actually populates // code-callers/code-callees for this worktree. Falls back to plain `dream` // only when we can't derive the source id (not in a git repo). + // + // Always pin `--phase resolve_symbol_edges`: that's the ONLY phase this + // stage needs (it's what actually builds the call graph). Without it, + // `gbrain dream` runs its full multi-phase overnight cycle (extract_atoms, + // synthesize, drift, skillopt, ...) on every sync — a second maintenance + // engine bolted onto ours, minutes slower and out of scope for a call-graph + // build. `--phase` composes independently of `--source` (verified against + // gbrain's own arg parser), so both flags apply together in the + // source-scoped case and `--phase` alone applies in the no-source fallback. const root = repoRoot(); const sourceId = root ? resolveCodeSourceId(root, gbrainEnv) : null; - const dreamArgs = sourceId ? ["dream", "--source", sourceId] : ["dream"]; + const dreamArgs = sourceId + ? ["dream", "--source", sourceId, "--phase", "resolve_symbol_edges"] + : ["dream", "--phase", "resolve_symbol_edges"]; // spawnGbrain seeds DATABASE_URL from gbrain's config via buildGbrainEnv. // diff --git a/test/gstack-gbrain-sync.test.ts b/test/gstack-gbrain-sync.test.ts index 67250dc454..57e86aefa9 100644 --- a/test/gstack-gbrain-sync.test.ts +++ b/test/gstack-gbrain-sync.test.ts @@ -227,6 +227,108 @@ esac rmSync(home, { recursive: true, force: true }); }); + it("forces --phase resolve_symbol_edges on the real (source-scoped) dream spawn", () => { + // #2860-class: the forced dream cycle must run ONLY the resolve_symbol_edges + // phase, never gbrain's full multi-phase overnight maintenance cycle (that's + // gbrain's own `autopilot`'s job, not ours). The fake gbrain below is a + // strict allowlist — any `dream` invocation that doesn't match this exact + // argv (e.g. bare `dream` or `dream --source ` with no --phase) hits + // the `*) exit 1` branch and fails the test. + const home = makeTestHome(); + const gstackHome = join(home, ".gstack"); + const repo = mkdtempSync(join(tmpdir(), "gstack-dream-phase-repo-")); + const bindir = mkdtempSync(join(tmpdir(), "gstack-dream-phase-bin-")); + const commandLog = join(home, "gbrain-commands.log"); + mkdirSync(gstackHome, { recursive: true }); + mkdirSync(join(home, ".gbrain"), { recursive: true }); + writeFileSync(join(home, ".gbrain", "config.json"), JSON.stringify({ engine: "pglite", database_url: "pglite:///test" })); + spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo, timeout: 30_000 }); + writeFileSync(join(repo, ".gbrain-source"), "client-acme-app\n"); + writeFileSync(join(bindir, "gbrain"), `#!/bin/sh +printf '%s\\n' "$*" >> "$GSTACK_TEST_GBRAIN_LOG" +case "$*" in + --version) echo 'gbrain 0.42.0.0' ;; + "sources list --json") echo '{"sources":[{"id":"client-acme-app","local_path":"${repo}","page_count":1}]}' ;; + "dream --source client-acme-app --phase resolve_symbol_edges") echo 'resolve_symbol_edges ... resolved 3' ;; + *) echo "unexpected gbrain command: $*" >&2; exit 1 ;; +esac +`); + chmodSync(join(bindir, "gbrain"), 0o755); + writeFileSync(join(bindir, "pgrep"), "#!/bin/sh\nexit 1\n"); + chmodSync(join(bindir, "pgrep"), 0o755); + + const r = spawnSync("bun", [SCRIPT, "--dream", "--no-code", "--no-memory", "--no-brain-sync", "--quiet"], { + encoding: "utf-8", + timeout: 60000, + cwd: repo, + env: { + ...process.env, + HOME: home, + GSTACK_HOME: gstackHome, + GBRAIN_HOME: "", + GSTACK_TEST_GBRAIN_LOG: commandLog, + PATH: `${bindir}:${process.env.PATH || ""}`, + }, + }); + + const commands = readFileSync(commandLog, "utf-8"); + expect(r.status).toBe(0); + expect(commands).toContain("dream --source client-acme-app --phase resolve_symbol_edges"); + expect(commands).not.toMatch(/^dream$/m); + expect(commands).not.toMatch(/^dream --source client-acme-app$/m); + rmSync(repo, { recursive: true, force: true }); + rmSync(bindir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + }); + + it("forces --phase resolve_symbol_edges on the real dream spawn even with no source id (fallback)", () => { + // Same #2860-class guarantee as above, for the no-source-id fallback path + // (repoRoot() returns null — not inside a git repo). The strict allowlist + // exits 1 on any `dream` invocation lacking --phase resolve_symbol_edges. + const home = makeTestHome(); + const gstackHome = join(home, ".gstack"); + const cwd = mkdtempSync(join(tmpdir(), "gstack-dream-phase-nosource-")); + const bindir = mkdtempSync(join(tmpdir(), "gstack-dream-phase-nosource-bin-")); + const commandLog = join(home, "gbrain-commands.log"); + mkdirSync(gstackHome, { recursive: true }); + mkdirSync(join(home, ".gbrain"), { recursive: true }); + writeFileSync(join(home, ".gbrain", "config.json"), JSON.stringify({ engine: "pglite", database_url: "pglite:///test" })); + writeFileSync(join(bindir, "gbrain"), `#!/bin/sh +printf '%s\\n' "$*" >> "$GSTACK_TEST_GBRAIN_LOG" +case "$*" in + --version) echo 'gbrain 0.42.0.0' ;; + "sources list --json") echo '{"sources":[]}' ;; + "dream --phase resolve_symbol_edges") echo 'resolve_symbol_edges ... resolved 0' ;; + *) echo "unexpected gbrain command: $*" >&2; exit 1 ;; +esac +`); + chmodSync(join(bindir, "gbrain"), 0o755); + writeFileSync(join(bindir, "pgrep"), "#!/bin/sh\nexit 1\n"); + chmodSync(join(bindir, "pgrep"), 0o755); + + const r = spawnSync("bun", [SCRIPT, "--dream", "--no-code", "--no-memory", "--no-brain-sync", "--quiet"], { + encoding: "utf-8", + timeout: 60000, + cwd, + env: { + ...process.env, + HOME: home, + GSTACK_HOME: gstackHome, + GBRAIN_HOME: "", + GSTACK_TEST_GBRAIN_LOG: commandLog, + PATH: `${bindir}:${process.env.PATH || ""}`, + }, + }); + + const commands = readFileSync(commandLog, "utf-8"); + expect(r.status).toBe(0); + expect(commands).toContain("dream --phase resolve_symbol_edges"); + expect(commands).not.toMatch(/^dream$/m); + rmSync(cwd, { recursive: true, force: true }); + rmSync(bindir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + }); + it("uses a local pin for a dry-run dream without spawning gbrain", () => { const home = makeTestHome(); const gstackHome = join(home, ".gstack"); @@ -246,7 +348,7 @@ esac }); expect(r.status).toBe(0); - expect(r.stdout).toContain("gbrain dream --source client-acme-app"); + expect(r.stdout).toContain("gbrain dream --source client-acme-app --phase resolve_symbol_edges"); rmSync(repo, { recursive: true, force: true }); rmSync(bindir, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true }); From 9b6e3eff0bcb848cb1daa6f748f2937641deb81a Mon Sep 17 00:00:00 2001 From: liutiming Date: Wed, 2 Sep 2026 16:27:13 +0000 Subject: [PATCH 2/3] test: cover --full auto-chained dream phase scoping The two existing #2860-class argv tests only exercised the explicit --dream flag. Add a third exercising the --full auto-chain path (cycle never built -> runDream), with a strict-allowlist fake gbrain that fails on any dream invocation missing --phase resolve_symbol_edges. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018px2ZBEeBDAGyzqwme4Dxp --- test/gstack-gbrain-sync.test.ts | 62 +++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/test/gstack-gbrain-sync.test.ts b/test/gstack-gbrain-sync.test.ts index 57e86aefa9..48f8150c95 100644 --- a/test/gstack-gbrain-sync.test.ts +++ b/test/gstack-gbrain-sync.test.ts @@ -329,6 +329,68 @@ esac rmSync(home, { recursive: true, force: true }); }); + it("forces --phase resolve_symbol_edges on the --full auto-chained dream (never-built cycle)", () => { + // Same #2860-class guarantee as the explicit --dream tests above, but for + // the OTHER trigger path: `--full` auto-chains a dream cycle when + // cycleCompleted() reports "never" (call graph never built). That path + // resolves cycle state via `gbrain doctor --json --fast`, then calls the + // exact same runDream(args) as --dream — but only a live spawn proves the + // auto-chained call actually carries --phase, not just the shared + // function it happens to call. The fake gbrain below is a strict + // allowlist — any command outside this exact set, or a `dream` + // invocation without --phase resolve_symbol_edges, hits the `*) exit 1` + // branch and fails the test. + const home = makeTestHome(); + const gstackHome = join(home, ".gstack"); + const repo = mkdtempSync(join(tmpdir(), "gstack-full-dream-phase-repo-")); + const bindir = mkdtempSync(join(tmpdir(), "gstack-full-dream-phase-bin-")); + const commandLog = join(home, "gbrain-commands.log"); + mkdirSync(gstackHome, { recursive: true }); + mkdirSync(join(home, ".gbrain"), { recursive: true }); + writeFileSync(join(home, ".gbrain", "config.json"), JSON.stringify({ engine: "pglite", database_url: "pglite:///test" })); + spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo, timeout: 30_000 }); + writeFileSync(join(repo, ".gbrain-source"), "client-acme-app\n"); + writeFileSync(join(bindir, "gbrain"), `#!/bin/sh +printf '%s\\n' "$*" >> "$GSTACK_TEST_GBRAIN_LOG" +case "$*" in + --version) echo 'gbrain 0.42.0.0' ;; + "sources list --json") echo '{"sources":[{"id":"client-acme-app","local_path":"${repo}","page_count":1}]}' ;; + "sync --strategy code --source client-acme-app --full --yes") ;; + "reindex-code --source client-acme-app --yes") ;; + "sources attach client-acme-app") ;; + "doctor --json --fast") echo '{"checks":[{"name":"cycle_freshness","status":"fail","message":"client-acme-app never built a call graph"}]}' ;; + "dream --source client-acme-app --phase resolve_symbol_edges") echo 'resolve_symbol_edges ... resolved 5' ;; + *) echo "unexpected gbrain command: $*" >&2; exit 1 ;; +esac +`); + chmodSync(join(bindir, "gbrain"), 0o755); + writeFileSync(join(bindir, "pgrep"), "#!/bin/sh\nexit 1\n"); + chmodSync(join(bindir, "pgrep"), 0o755); + + const r = spawnSync("bun", [SCRIPT, "--full", "--no-memory", "--no-brain-sync", "--quiet"], { + encoding: "utf-8", + timeout: 60000, + cwd: repo, + env: { + ...process.env, + HOME: home, + GSTACK_HOME: gstackHome, + GBRAIN_HOME: "", + GSTACK_TEST_GBRAIN_LOG: commandLog, + PATH: `${bindir}:${process.env.PATH || ""}`, + }, + }); + + const commands = readFileSync(commandLog, "utf-8"); + expect(r.status).toBe(0); + expect(commands).toContain("dream --source client-acme-app --phase resolve_symbol_edges"); + expect(commands).not.toMatch(/^dream$/m); + expect(commands).not.toMatch(/^dream --source client-acme-app$/m); + rmSync(repo, { recursive: true, force: true }); + rmSync(bindir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + }); + it("uses a local pin for a dry-run dream without spawning gbrain", () => { const home = makeTestHome(); const gstackHome = join(home, ".gstack"); From 5ee23579456aed5944ad644e9d8ea77f94825019 Mon Sep 17 00:00:00 2001 From: liutiming Date: Wed, 2 Sep 2026 16:27:13 +0000 Subject: [PATCH 3/3] docs: reflect --phase resolve_symbol_edges in sync-gbrain skill docs sync-gbrain/SKILL.md.tmpl still described the dream cycle as bare `gbrain dream --source `, pre-dating the phase-scoping fix. Update the --full, --dream, and D2-prompt prose to name the pinned --phase resolve_symbol_edges invocation, and regenerate SKILL.md. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018px2ZBEeBDAGyzqwme4Dxp --- sync-gbrain/SKILL.md | 10 ++++++---- sync-gbrain/SKILL.md.tmpl | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/sync-gbrain/SKILL.md b/sync-gbrain/SKILL.md index c0554ab417..cb9bcc6ec2 100644 --- a/sync-gbrain/SKILL.md +++ b/sync-gbrain/SKILL.md @@ -420,8 +420,8 @@ When the user types `/sync-gbrain`, run this skill. Argument modes (parsed by the skill itself, not a dispatcher binary): - `/sync-gbrain` — incremental sync (default; mtime fast-path; ~50ms steady-state) -- `/sync-gbrain --full` — full code reindex via `gbrain reindex-code` (~25-35 min on a big repo). Auto-builds the call graph (`gbrain dream`) **only when it was never built**. -- `/sync-gbrain --dream` — build this source's call graph (`gbrain code-callers`/`code-callees`) via a source-scoped `gbrain dream --source ` cycle; ~minutes; runs lock-free after the sync stages. Always forces, even if already built. Only produces a graph on a code-aware schema pack; otherwise the run reports a WARN explaining why the graph is still empty. +- `/sync-gbrain --full` — full code reindex via `gbrain reindex-code` (~25-35 min on a big repo). Auto-builds the call graph (`gbrain dream --phase resolve_symbol_edges`, phase-scoped) **only when it was never built**. +- `/sync-gbrain --dream` — build this source's call graph (`gbrain code-callers`/`code-callees`) via a source-scoped `gbrain dream --source --phase resolve_symbol_edges` cycle (no-source fallback: `gbrain dream --phase resolve_symbol_edges`); ~minutes; runs lock-free after the sync stages. Always forces, even if already built. Pinned to the `resolve_symbol_edges` phase only — never gbrain's full multi-phase maintenance cycle. Only produces a graph on a code-aware schema pack; otherwise the run reports a WARN explaining why the graph is still empty. - `/sync-gbrain --no-dream` — skip the dream cycle that `--full` would otherwise auto-run. - `/sync-gbrain --code-only` — only run the code stage; skip memory + brain-sync - `/sync-gbrain --dry-run` — preview what would sync; no writes anywhere @@ -653,8 +653,10 @@ If `CYCLE == never` AND the user did NOT pass `--dream`/`--full` AND Step 3 > > ELI10: `gbrain code-callers`/`code-callees` (who calls this function / what it > calls) return nothing until the `resolve_symbol_edges` phase runs for this -> source. `gbrain dream --source ` runs it (scoped to this -> worktree's code, takes a few minutes). It only produces a graph if this +> source. `gbrain dream --source --phase resolve_symbol_edges` +> runs it (scoped to this worktree's code and to that phase only — never +> gbrain's full multi-phase maintenance cycle — takes a few minutes). It only +> produces a graph if this > source's schema pack extracts code symbols; if it doesn't, the run completes > but the graph stays empty and the dream row will say so. > diff --git a/sync-gbrain/SKILL.md.tmpl b/sync-gbrain/SKILL.md.tmpl index 11471deb11..93364e6530 100644 --- a/sync-gbrain/SKILL.md.tmpl +++ b/sync-gbrain/SKILL.md.tmpl @@ -47,8 +47,8 @@ When the user types `/sync-gbrain`, run this skill. Argument modes (parsed by the skill itself, not a dispatcher binary): - `/sync-gbrain` — incremental sync (default; mtime fast-path; ~50ms steady-state) -- `/sync-gbrain --full` — full code reindex via `gbrain reindex-code` (~25-35 min on a big repo). Auto-builds the call graph (`gbrain dream`) **only when it was never built**. -- `/sync-gbrain --dream` — build this source's call graph (`gbrain code-callers`/`code-callees`) via a source-scoped `gbrain dream --source ` cycle; ~minutes; runs lock-free after the sync stages. Always forces, even if already built. Only produces a graph on a code-aware schema pack; otherwise the run reports a WARN explaining why the graph is still empty. +- `/sync-gbrain --full` — full code reindex via `gbrain reindex-code` (~25-35 min on a big repo). Auto-builds the call graph (`gbrain dream --phase resolve_symbol_edges`, phase-scoped) **only when it was never built**. +- `/sync-gbrain --dream` — build this source's call graph (`gbrain code-callers`/`code-callees`) via a source-scoped `gbrain dream --source --phase resolve_symbol_edges` cycle (no-source fallback: `gbrain dream --phase resolve_symbol_edges`); ~minutes; runs lock-free after the sync stages. Always forces, even if already built. Pinned to the `resolve_symbol_edges` phase only — never gbrain's full multi-phase maintenance cycle. Only produces a graph on a code-aware schema pack; otherwise the run reports a WARN explaining why the graph is still empty. - `/sync-gbrain --no-dream` — skip the dream cycle that `--full` would otherwise auto-run. - `/sync-gbrain --code-only` — only run the code stage; skip memory + brain-sync - `/sync-gbrain --dry-run` — preview what would sync; no writes anywhere @@ -280,8 +280,10 @@ If `CYCLE == never` AND the user did NOT pass `--dream`/`--full` AND Step 3 > > ELI10: `gbrain code-callers`/`code-callees` (who calls this function / what it > calls) return nothing until the `resolve_symbol_edges` phase runs for this -> source. `gbrain dream --source ` runs it (scoped to this -> worktree's code, takes a few minutes). It only produces a graph if this +> source. `gbrain dream --source --phase resolve_symbol_edges` +> runs it (scoped to this worktree's code and to that phase only — never +> gbrain's full multi-phase maintenance cycle — takes a few minutes). It only +> produces a graph if this > source's schema pack extracts code symbols; if it doesn't, the run completes > but the graph stays empty and the dream row will say so. >