Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions bin/gstack-gbrain-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -1391,8 +1393,8 @@ export async function runDream(args: CliArgs): Promise<StageResult> {
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)",
};
}

Expand Down Expand Up @@ -1431,9 +1433,20 @@ export async function runDream(args: CliArgs): Promise<StageResult> {
// `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.
//
Expand Down
10 changes: 6 additions & 4 deletions sync-gbrain/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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 <id> --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
Expand Down Expand Up @@ -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 <this source>` runs it (scoped to this
> worktree's code, takes a few minutes). It only produces a graph if this
> source. `gbrain dream --source <this 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.
>
Expand Down
10 changes: 6 additions & 4 deletions sync-gbrain/SKILL.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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 <id> --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
Expand Down Expand Up @@ -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 <this source>` runs it (scoped to this
> worktree's code, takes a few minutes). It only produces a graph if this
> source. `gbrain dream --source <this 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.
>
Expand Down
166 changes: 165 additions & 1 deletion test/gstack-gbrain-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,170 @@ 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 <id>` 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("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");
Expand All @@ -246,7 +410,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 });
Expand Down