Skip to content

Commit 300c8ce

Browse files
committed
perf: local-first writes + native-fs binary install — 60x faster brain ops
Two compounding fixes that drop fstack-brain ops from ~3s to ~50ms. == Local-first writes == All write commands (intent write/infer/ship, decide write, handoff write/auto, log-edit, heartbeat) used to perform 3-5 sequential Supabase round-trips per invocation. Replaced with a write-locally-then-flush pattern: - brain/cli/src/queue.ts (NEW) - enqueue() appends a JSONL line to ~/.fstack/queue/writes.jsonl - drainQueue() flushes to Supabase, called by all read commands - getCachedIntent/setCachedIntent — local cache for read-after-write - reserveDecisionNumber — local counter, bootstrap from Supabase once - Lock via atomic mkdir to prevent concurrent drains - brain/cli/src/context.ts - Split buildCtx into: * buildCtx (sync, lite) — git ops only, no Supabase. For writes. * buildCtxFull (async) — full ensureRepo+ensureBranch with local cache (~/.fstack/cache/repos.json, branches.json). For reads. - First buildCtxFull call resolves+caches IDs; subsequent calls hit cache and avoid Supabase. - All write commands switched to buildCtx (lite). Returns synchronously, queues the brain write, exits. - All read commands (sync, presence, standup, why, decide search, handoff list, conflict-precheck) use buildCtxFull and call drainQueue before querying Supabase. Just-written entries surface in the next read. - New 'flush' subcommand for explicit drain. - New 'queue' subcommand for queue depth. Failure semantics: a failed flush row stays in the queue and retries on next drain. Local cache is authoritative for the writer until flushed. Local artifacts (ADR markdown files for /decide) are written immediately regardless of brain reachability. == Native-fs binary install == bin/fstack-brain-setup now COPIES the compiled binary to ~/.local/bin instead of symlinking to the source dist/. The symlink path forced Bun to load the 88MB compiled binary off /mnt/<windows>/ on WSL every invocation, costing ~800ms per call across the WSL filesystem boundary. Native ext4 makes it ~40ms. == Measured impact == decide write : 3.0s -> 55ms (55x) intent get : 2.8s -> 47ms (60x) heartbeat : 3.0s -> 50ms (60x) bun cold : 2.8s -> 43ms (65x) Brain writes from hooks (PostToolUse log-edit on every Edit/Write) now finish in tens of ms, well within Claude Code's hook latency budget. /decide and /intent finally feel weightless. Resolves intent: 'Local-first writes — make every brain write feel weightless' (ba76ee6f-b618-4b15-9fa2-6872c3b75a18).
1 parent 8796509 commit 300c8ce

16 files changed

Lines changed: 941 additions & 182 deletions

bin/fstack-brain-setup

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,14 @@ mkdir -p dist
3838
bun build ./src/index.ts --compile --outfile dist/fstack-brain --target=bun
3939
cd "$FSTACK_ROOT"
4040

41-
# 3. Symlink binary
41+
# 3. Install binary on native filesystem (cp, not symlink)
42+
# A symlink to /mnt/<windows-drive>/ on WSL costs ~800ms per invocation
43+
# loading the 88MB binary across the WSL fs boundary. cp to ~/.local/bin
44+
# (native ext4) drops cold-start to ~40ms.
4245
mkdir -p "$(dirname "$BIN_TARGET")"
43-
ln -sf "$CLI_DIR/dist/fstack-brain" "$BIN_TARGET"
44-
log "linked $CLI_DIR/dist/fstack-brain → $BIN_TARGET"
46+
cp -f "$CLI_DIR/dist/fstack-brain" "$BIN_TARGET"
47+
chmod +x "$BIN_TARGET"
48+
log "installed $BIN_TARGET (copied from $CLI_DIR/dist/fstack-brain)"
4549

4650
# Verify on PATH
4751
if ! command -v fstack-brain >/dev/null 2>&1; then

brain/cli/src/commands/conflict-precheck.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { buildCtx } from "../context.ts";
1+
import { buildCtxFull } from "../context.ts";
22
import {
33
activeIntentForBranch,
44
listOtherActiveIntents,
@@ -17,7 +17,7 @@ import { emit } from "../output.ts";
1717
export async function conflictPrecheck() {
1818
let ctx;
1919
try {
20-
ctx = await buildCtx();
20+
ctx = await buildCtxFull();
2121
} catch {
2222
// Not in a repo / no config — silent pass-through.
2323
emit("(precheck skipped — no fstack context)", { ok: true, skipped: true });

brain/cli/src/commands/decide.ts

Lines changed: 64 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,42 @@
1-
import { buildCtx } from "../context.ts";
2-
import {
3-
nextDecisionNumber,
4-
writeDecision,
5-
searchDecisions,
6-
} from "../client.ts";
1+
import { buildCtx, buildCtxFull } from "../context.ts";
2+
import { searchDecisions, ensureRepo, ensureBranch, ensureFile } from "../client.ts";
73
import { emit, emitError } from "../output.ts";
84
import { writeFileSync, mkdirSync, existsSync } from "node:fs";
95
import { join } from "node:path";
6+
import { defaultBranch } from "../git.ts";
7+
import { enqueue, reserveDecisionNumber, drainQueue, loadCounters } from "../queue.ts";
108

119
/**
12-
* decide write — create a new ADR row + docs/decisions/NNNN-slug.md file.
10+
* decide write — local-first.
11+
*
12+
* Returns in <100ms by:
13+
* 1. Reserving the decision number locally (cached counter)
14+
* 2. Writing the ADR markdown file immediately
15+
* 3. Queueing the Supabase insert for next drain
16+
*
17+
* The ADR file is durable; the brain row is eventually consistent.
1318
*/
1419
export async function decideWrite(args: { title?: string; body?: string }) {
1520
if (!args.title) emitError("decide write: --title required", 2);
1621
if (!args.body) emitError("decide write: --body required", 2);
1722

18-
const ctx = await buildCtx();
19-
const number = await nextDecisionNumber(ctx.db, ctx.repoId);
20-
21-
const decision = await writeDecision(ctx.db, {
22-
repoId: ctx.repoId,
23-
number,
24-
title: args.title,
25-
body: args.body,
26-
authoredBy: ctx.cfg.agent_id,
27-
});
23+
const ctx = buildCtx();
24+
// reserveDecisionNumber needs a real repoId only on FIRST call per machine
25+
// (to bootstrap the local counter from Supabase max). On subsequent calls,
26+
// it hits the local counter cache and skips Supabase entirely.
27+
const counters = loadCounters();
28+
let repoIdForBootstrap = ctx.repoId;
29+
if (counters[ctx.repoCanonical] === undefined) {
30+
// Cache miss — need a real repoId to bootstrap. One-time hit.
31+
repoIdForBootstrap = await ensureRepo(ctx.db, ctx.repoCanonical, defaultBranch());
32+
}
33+
const number = await reserveDecisionNumber(ctx.db, ctx.repoCanonical, repoIdForBootstrap);
34+
const id = crypto.randomUUID();
35+
const now = new Date().toISOString();
36+
const timeline = [{ at: now, who: ctx.cfg.agent_id, event: "authored" }];
2837

29-
// Also write the markdown ADR file
30-
const slug = args.title
38+
// 1. Write the ADR file immediately — durable artifact
39+
const slug = args.title!
3140
.toLowerCase()
3241
.replace(/[^a-z0-9]+/g, "-")
3342
.replace(/(^-|-$)/g, "")
@@ -41,27 +50,57 @@ export async function decideWrite(args: { title?: string; body?: string }) {
4150
"",
4251
`**Status:** accepted`,
4352
`**Authored by:** ${ctx.cfg.agent_id}`,
44-
`**Date:** ${new Date().toISOString().slice(0, 10)}`,
53+
`**Date:** ${now.slice(0, 10)}`,
4554
"",
4655
args.body,
4756
].join("\n");
4857
writeFileSync(adrPath, md, "utf8");
4958

59+
// 2. Queue the Supabase write
60+
enqueue({
61+
op: "decide_write",
62+
payload: {
63+
id,
64+
repo_canonical: ctx.repoCanonical,
65+
number,
66+
title: args.title,
67+
body: args.body,
68+
authored_by: ctx.cfg.agent_id,
69+
timeline,
70+
created_at: now,
71+
},
72+
});
73+
5074
emit(
51-
`decision ${num} written — '${args.title}' (${adrPath})`,
52-
{ ok: true, decision, file: adrPath }
75+
`decision ${num} written — '${args.title}' (${adrPath}) [queued for brain]`,
76+
{
77+
ok: true,
78+
decision: { id, number, title: args.title, authored_by: ctx.cfg.agent_id },
79+
file: adrPath,
80+
queued: true,
81+
}
5382
);
5483
}
5584

5685
/**
57-
* decide search — keyword search across decisions in this repo.
86+
* decide search — drain queue first so just-written decisions surface, then
87+
* hit Supabase.
5888
*/
5989
export async function decideSearch(args: { query?: string; limit?: number }) {
6090
if (!args.query) emitError("decide search: --query required", 2);
61-
const ctx = await buildCtx();
91+
const ctx = await buildCtxFull();
92+
93+
// Drain queued writes so search sees fresh data
94+
await drainQueue(
95+
ctx.db,
96+
async (canonical) => ensureRepo(ctx.db, canonical, defaultBranch()),
97+
async (repoId, branchName) => ensureBranch(ctx.db, repoId, branchName),
98+
async (repoId, path) => ensureFile(ctx.db, repoId, path)
99+
);
100+
62101
const rows = await searchDecisions(ctx.db, {
63102
repoId: ctx.repoId,
64-
query: args.query,
103+
query: args.query!,
65104
limit: args.limit ?? 10,
66105
});
67106
if (rows.length === 0) {

brain/cli/src/commands/flush.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { buildCtxFull } from "../context.ts";
2+
import { ensureRepo, ensureBranch, ensureFile } from "../client.ts";
3+
import { defaultBranch } from "../git.ts";
4+
import { drainQueue, queueDepth } from "../queue.ts";
5+
import { emit } from "../output.ts";
6+
7+
/**
8+
* flush — manually drain the local write queue to Supabase.
9+
* Read commands drain automatically; this is for explicit user invocation
10+
* (e.g. before going offline) and for debugging.
11+
*/
12+
export async function flushCmd() {
13+
const before = queueDepth();
14+
if (before === 0) {
15+
emit("(queue empty, nothing to flush)", { ok: true, flushed: 0 });
16+
return;
17+
}
18+
const ctx = await buildCtxFull();
19+
const result = await drainQueue(
20+
ctx.db,
21+
async (c) => ensureRepo(ctx.db, c, defaultBranch()),
22+
async (r, b) => ensureBranch(ctx.db, r, b),
23+
async (r, p) => ensureFile(ctx.db, r, p)
24+
);
25+
const lines = [`flushed ${result.flushed} of ${before} entries`];
26+
if (result.remaining > 0) {
27+
lines.push(`${result.remaining} entries remain (will retry on next read)`);
28+
}
29+
if (result.errors.length > 0) {
30+
lines.push("errors:");
31+
for (const e of result.errors.slice(0, 5)) lines.push(` • ${e}`);
32+
}
33+
emit(lines.join("\n"), { ok: true, ...result, depth_before: before });
34+
}
35+
36+
export async function queueStatusCmd() {
37+
const depth = queueDepth();
38+
emit(`queue depth: ${depth}`, { ok: true, depth });
39+
}

brain/cli/src/commands/handoff.ts

Lines changed: 72 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
import { buildCtx } from "../context.ts";
1+
import { buildCtx, buildCtxFull } from "../context.ts";
22
import {
3-
activeIntentForBranch,
4-
writeHandoff,
53
openHandoffs,
4+
ensureRepo,
5+
ensureBranch,
6+
ensureFile,
67
} from "../client.ts";
7-
import { uncommittedFiles } from "../git.ts";
8+
import { uncommittedFiles, defaultBranch } from "../git.ts";
89
import { emit } from "../output.ts";
10+
import { enqueue, drainQueue, getCachedIntent } from "../queue.ts";
911

1012
/**
11-
* handoff write — explicit rich handoff (called by /handoff slash command).
13+
* handoff write — local-first. Queues brain insert.
1214
*/
1315
export async function handoffWrite(args: {
1416
note?: string;
@@ -20,38 +22,47 @@ export async function handoffWrite(args: {
2022
emit("handoff write: --note required", { ok: false });
2123
process.exit(2);
2224
}
23-
const ctx = await buildCtx();
24-
const intent = await activeIntentForBranch(ctx.db, ctx.cfg.agent_id, ctx.branchId);
25+
const ctx = buildCtx();
26+
const intent = getCachedIntent(ctx.repoCanonical, ctx.branchName, ctx.cfg.agent_id);
2527

26-
const handoff = await writeHandoff(ctx.db, {
27-
repoId: ctx.repoId,
28-
intentId: intent?.id,
29-
fromAgent: ctx.cfg.agent_id,
30-
toAgent: args.toAgent,
31-
branchName: ctx.branchName,
28+
const id = crypto.randomUUID();
29+
const now = new Date().toISOString();
30+
const handoff = {
31+
id,
32+
repo_canonical: ctx.repoCanonical,
33+
intent_id: intent?.id,
34+
from_agent: ctx.cfg.agent_id,
35+
to_agent: args.toAgent,
36+
branch_name: ctx.branchName,
3237
note: args.note,
3338
blocker: args.blocker,
34-
nextStep: args.nextStep,
35-
uncommittedFiles: uncommittedFiles(),
36-
autoGenerated: false,
39+
next_step: args.nextStep,
40+
uncommitted_files: uncommittedFiles(),
41+
auto_generated: false,
42+
created_at: now,
43+
};
44+
45+
enqueue({ op: "handoff_write", payload: handoff });
46+
emit(`handoff ok — '${args.note.slice(0, 60)}...' [queued for brain]`, {
47+
ok: true,
48+
handoff,
49+
queued: true,
3750
});
38-
emit(`handoff ok — '${args.note.slice(0, 60)}...'`, { ok: true, handoff });
3951
}
4052

4153
/**
42-
* handoff auto — called by SessionEnd hook. Quietly writes a stub handoff
43-
* if there's an active intent and uncommitted changes.
54+
* handoff auto — SessionEnd hook. Quietly writes a stub if active intent
55+
* + uncommitted files. Local-first.
4456
*/
4557
export async function handoffAuto() {
4658
let ctx;
4759
try {
48-
ctx = await buildCtx();
60+
ctx = buildCtx();
4961
} catch {
50-
// Not in a repo or no config — silent.
5162
emit("(auto-handoff skipped — no repo/config)", { ok: true, skipped: true });
5263
return;
5364
}
54-
const intent = await activeIntentForBranch(ctx.db, ctx.cfg.agent_id, ctx.branchId);
65+
const intent = getCachedIntent(ctx.repoCanonical, ctx.branchName, ctx.cfg.agent_id);
5566
if (!intent) {
5667
emit("auto-handoff: no active intent, skipping", { ok: false });
5768
return;
@@ -62,24 +73,53 @@ export async function handoffAuto() {
6273
return;
6374
}
6475

65-
await writeHandoff(ctx.db, {
66-
repoId: ctx.repoId,
67-
intentId: intent.id,
68-
fromAgent: ctx.cfg.agent_id,
69-
branchName: ctx.branchName,
70-
note: `(auto) Session ended with ${uncommitted.length} uncommitted file(s) on '${intent.title}'`,
71-
uncommittedFiles: uncommitted,
72-
autoGenerated: true,
76+
const id = crypto.randomUUID();
77+
const now = new Date().toISOString();
78+
enqueue({
79+
op: "handoff_write",
80+
payload: {
81+
id,
82+
repo_canonical: ctx.repoCanonical,
83+
intent_id: intent.id,
84+
from_agent: ctx.cfg.agent_id,
85+
branch_name: ctx.branchName,
86+
note: `(auto) Session ended with ${uncommitted.length} uncommitted file(s) on '${intent.title}'`,
87+
uncommitted_files: uncommitted,
88+
auto_generated: true,
89+
created_at: now,
90+
},
7391
});
74-
emit(`auto-handoff ok — ${uncommitted.length} files in flight`, {
92+
93+
// Best-effort drain on session end so the handoff lands before the user closes
94+
try {
95+
await drainQueue(
96+
ctx.db,
97+
async (c) => ensureRepo(ctx.db, c, defaultBranch()),
98+
async (r, b) => ensureBranch(ctx.db, r, b),
99+
async (r, p) => ensureFile(ctx.db, r, p)
100+
);
101+
} catch {
102+
// Best-effort; queue persists regardless
103+
}
104+
105+
emit(`auto-handoff ok — ${uncommitted.length} files in flight [queued]`, {
75106
ok: true,
76107
intent_id: intent.id,
77108
uncommitted,
78109
});
79110
}
80111

112+
/**
113+
* handoff list — drain queue first so just-written handoffs surface.
114+
*/
81115
export async function handoffsList() {
82-
const ctx = await buildCtx();
116+
const ctx = await buildCtxFull();
117+
await drainQueue(
118+
ctx.db,
119+
async (c) => ensureRepo(ctx.db, c, defaultBranch()),
120+
async (r, b) => ensureBranch(ctx.db, r, b),
121+
async (r, p) => ensureFile(ctx.db, r, p)
122+
);
83123
const handoffs = await openHandoffs(ctx.db, {
84124
repoId: ctx.repoId,
85125
toAgent: ctx.cfg.agent_id,

0 commit comments

Comments
 (0)