Skip to content

Commit cf4feb2

Browse files
centdixclaudegithub-actions[bot]
authored
Replace blocking Bun.spawnSync with async Bun.spawn across backend (#9)
* Make PR fetching non-blocking with async Bun.spawn and parallel repo queries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fetch only open PRs to avoid GitHub GraphQL 504 timeouts on large repos Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Make CI log fetching non-blocking with async Bun.spawn Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Replace Bun.spawnSync cat with native Bun.file for .env.local reads and writes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Replace blocking sleep subprocess with await Bun.sleep in sandbox worktree creation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Make sendPrompt async to fix event loop blocking on large payloads Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Replace working status spinner with static ellipsis icon Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add root CLAUDE.md with feature implementation workflow and debugging guidelines Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Send Enter from frontend via terminal WebSocket after CI fix prompt is pasted - Backend no longer sends send-keys Enter (frontend owns submission) - Add sendInput() export to Terminal.svelte - Add onfixsuccess callback to CiDetailsDialog, close dialog then send \r after 300ms delay - Flush FileSink after every write() to ensure single-byte input is sent immediately Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Use named ENTER_DELAY_MS const (200ms) for post-paste Enter delay Backend already awaits paste-buffer completion before returning, frontend already awaits the API response, so 200ms is sufficient as a PTY flush safety buffer (down from 300ms). Co-authored-by: centdix <centdix@users.noreply.github.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: centdix <centdix@users.noreply.github.com>
1 parent 3ef0ad4 commit cf4feb2

10 files changed

Lines changed: 192 additions & 88 deletions

File tree

CLAUDE.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Workmux
2+
3+
Git worktree manager with integrated terminals. Bun backend + Svelte 5 frontend.
4+
5+
## Implementing a new feature
6+
7+
Follow these steps in order. Do not skip ahead.
8+
9+
### 1. Understand the scope
10+
11+
Read the relevant CLAUDE.md before writing any code:
12+
13+
- **Backend work**[`backend/CLAUDE.md`](backend/CLAUDE.md) — Bun APIs, REST/WebSocket conventions, module structure.
14+
- **Frontend work**[`frontend/CLAUDE.md`](frontend/CLAUDE.md) — Svelte 5 runes, component patterns, styling rules.
15+
- **Full-stack feature** → Read both. Start with the backend.
16+
17+
### 2. Types first, code second
18+
19+
1. Define the data types/interfaces that the feature needs.
20+
- Backend types go in the relevant module file.
21+
- Frontend shared types go in `frontend/src/lib/types.ts` (types and interfaces only — no runtime logic).
22+
2. Define the API contract — endpoint path, request body, response shape — before implementing either side.
23+
3. On the frontend, add the typed fetch call to `frontend/src/lib/api.ts` before building UI.
24+
25+
### 3. Build incrementally
26+
27+
- **Backend**: implement the handler, delegate logic to pure testable functions, wire it into `server.ts` routing.
28+
- **Frontend**: build the component with mock data first, then connect the real API.
29+
- Test each layer independently before integrating.
30+
31+
### 4. DRY — no exceptions
32+
33+
- If a UI pattern already exists in another component, extract it into a shared component immediately. Do not copy-paste.
34+
- If a helper function is needed in more than one file, put it in a shared `lib/` utility. Never duplicate logic across files.
35+
- Check existing components and utilities before creating new ones.
36+
37+
### 5. Keep it minimal
38+
39+
- Only implement what was asked for. No speculative features, no extra configurability, no "while I'm here" refactors.
40+
- Don't add comments, docstrings, or type annotations to code you didn't change.
41+
- Don't add error handling for scenarios that can't happen. Trust internal code and framework guarantees.
42+
43+
## Debugging
44+
45+
When you are uncertain about the root cause of an issue, **add extensive debug logging before guessing at a fix**. This is mandatory, not optional.
46+
47+
### The rule
48+
49+
If you are not 100% sure where a bug comes from, do not propose a speculative fix. Instead:
50+
51+
1. **Add `console.log` / `console.debug` statements** at every relevant point in the code path — function entry/exit, variable values, branch decisions, API request/response payloads, WebSocket message contents.
52+
2. **Log enough context** to pinpoint the problem: timestamps, identifiers (worktree name, session id), the actual values vs. what you expected.
53+
3. **Run the code** with the logging in place and read the output.
54+
4. **Only then** propose a fix based on what the logs reveal.
55+
5. **Remove the debug logging** after the fix is confirmed.
56+
57+
### Why
58+
59+
- A targeted fix based on observed data is worth ten speculative patches.
60+
- Logging proves your assumption before you change production logic.
61+
- It avoids the cycle of guess-fix-test-revert that wastes time.
62+
63+
### What to log
64+
65+
- Function arguments and return values at the boundary where the bug might live.
66+
- Conditional branches — log which branch was taken and why.
67+
- Async timing — log before and after `await` calls to catch ordering issues.
68+
- State mutations — log the before/after value of any state that's being updated.
69+
- External process output — log stdout/stderr from spawned commands (`Bun.spawn`, `Bun.$`).

backend/src/env.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
/** Read key=value pairs from a worktree's .env.local file. */
2-
export function readEnvLocal(wtDir: string): Record<string, string> {
2+
export async function readEnvLocal(wtDir: string): Promise<Record<string, string>> {
33
try {
4-
const content = Bun.spawnSync(["cat", `${wtDir}/.env.local`], { stdout: "pipe" });
5-
const text = new TextDecoder().decode(content.stdout).trim();
4+
const text = (await Bun.file(`${wtDir}/.env.local`).text()).trim();
65
const env: Record<string, string> = {};
76
for (const line of text.split("\n")) {
87
const match = line.match(/^(\w+)=(.*)$/);
@@ -14,15 +13,12 @@ export function readEnvLocal(wtDir: string): Record<string, string> {
1413
}
1514
}
1615

17-
1816
/** Upsert a key=value pair in a worktree's .env.local file. */
19-
export function upsertEnvLocal(wtDir: string, key: string, value: string): void {
17+
export async function upsertEnvLocal(wtDir: string, key: string, value: string): Promise<void> {
2018
const filePath = `${wtDir}/.env.local`;
21-
const file = Bun.file(filePath);
2219
let lines: string[] = [];
2320
try {
24-
const text = Bun.spawnSync(["cat", filePath], { stdout: "pipe" });
25-
const content = new TextDecoder().decode(text.stdout).trim();
21+
const content = (await Bun.file(filePath).text()).trim();
2622
if (content) lines = content.split("\n");
2723
} catch {
2824
// File doesn't exist yet, start with empty lines
@@ -36,5 +32,5 @@ export function upsertEnvLocal(wtDir: string, key: string, value: string): void
3632
lines.push(`${key}=${value}`);
3733
}
3834

39-
Bun.write(file, lines.join("\n") + "\n");
35+
await Bun.write(filePath, lines.join("\n") + "\n");
4036
}

backend/src/pr.ts

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -68,22 +68,23 @@ function mapChecks(checks: GhCheckEntry[]): CiCheck[] {
6868
}
6969

7070
/** Fetch all PRs from a repo via gh CLI. Returns a map of branch name → PrEntry. */
71-
export function fetchAllPrs(repoSlug?: string, repoLabel?: string): Map<string, PrEntry> {
72-
const args = ["gh", "pr", "list", "--state", "all", "--json", "number,headRefName,state,statusCheckRollup,url", "--limit", "100"];
71+
export async function fetchAllPrs(repoSlug?: string, repoLabel?: string): Promise<Map<string, PrEntry>> {
72+
const args = ["gh", "pr", "list", "--state", "open", "--json", "number,headRefName,state,statusCheckRollup,url", "--limit", "50"];
7373
if (repoSlug) args.push("--repo", repoSlug);
7474

75-
const result = Bun.spawnSync(args, { stdout: "pipe", stderr: "pipe" });
75+
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
76+
const exitCode = await proc.exited;
7677

7778
const prs = new Map<string, PrEntry>();
78-
if (result.exitCode !== 0) {
79-
const stderr = new TextDecoder().decode(result.stderr).trim();
79+
if (exitCode !== 0) {
80+
const stderr = (await new Response(proc.stderr).text()).trim();
8081
const label = repoSlug ?? "current";
8182
console.error(`[pr] gh pr list failed for ${label}: ${stderr}`);
8283
return prs;
8384
}
8485

8586
try {
86-
const entries: GhPrEntry[] = JSON.parse(new TextDecoder().decode(result.stdout));
87+
const entries: GhPrEntry[] = JSON.parse(await new Response(proc.stdout).text());
8788
for (const entry of entries) {
8889
// If multiple PRs for same branch in same repo, the first (most recent) wins
8990
if (prs.has(entry.headRefName)) continue;
@@ -105,15 +106,15 @@ export function fetchAllPrs(repoSlug?: string, repoLabel?: string): Map<string,
105106
}
106107

107108
/** Sync PR status to .env.local for all worktrees that have PRs. */
108-
export function syncPrStatus(
109+
export async function syncPrStatus(
109110
getWorktreePaths: () => Map<string, string>,
110111
linkedRepos: LinkedRepoConfig[],
111-
): void {
112-
// Fetch PRs from current repo (repo label = "") + each linked repo (repo label = alias)
113-
const allRepoResults: Map<string, PrEntry>[] = [fetchAllPrs()];
114-
for (const { repo, alias } of linkedRepos) {
115-
allRepoResults.push(fetchAllPrs(repo, alias));
116-
}
112+
): Promise<void> {
113+
// Fetch current repo + all linked repos in parallel
114+
const allRepoResults = await Promise.all([
115+
fetchAllPrs(),
116+
...linkedRepos.map(({ repo, alias }) => fetchAllPrs(repo, alias)),
117+
]);
117118

118119
// Group by branch → PrEntry[]
119120
const branchPrs = new Map<string, PrEntry[]>();
@@ -135,7 +136,7 @@ export function syncPrStatus(
135136
if (!wtDir || seen.has(wtDir)) continue;
136137
seen.add(wtDir);
137138

138-
upsertEnvLocal(wtDir, "PR_DATA", JSON.stringify(entries));
139+
await upsertEnvLocal(wtDir, "PR_DATA", JSON.stringify(entries));
139140
}
140141

141142
console.log(`[pr] synced ${seen.size} worktree(s) with PR data from ${allRepoResults.length} repo(s)`);
@@ -145,14 +146,18 @@ export function syncPrStatus(
145146
export function startPrMonitor(
146147
getWorktreePaths: () => Map<string, string>,
147148
linkedRepos: LinkedRepoConfig[],
148-
intervalMs: number = 15_000,
149+
intervalMs: number = 20_000,
149150
): () => void {
150-
// Run once immediately
151-
syncPrStatus(getWorktreePaths, linkedRepos);
151+
const run = (): void => {
152+
syncPrStatus(getWorktreePaths, linkedRepos).catch((err: unknown) => {
153+
console.error(`[pr] sync error: ${err}`);
154+
});
155+
};
156+
157+
// Run once immediately (non-blocking)
158+
run();
152159

153-
const timer = setInterval(() => {
154-
syncPrStatus(getWorktreePaths, linkedRepos);
155-
}, intervalMs);
160+
const timer = setInterval(run, intervalMs);
156161

157162
return (): void => {
158163
clearInterval(timer);

backend/src/server.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ async function handleApi(req: Request, url: URL): Promise<Response> {
227227
s.worktree.includes(wt.branch) || s.worktree.startsWith(wt.branch)
228228
);
229229
const wtDir = wtPaths.get(wt.branch);
230-
const env = wtDir ? readEnvLocal(wtDir) : {};
230+
const env = wtDir ? await readEnvLocal(wtDir) : {};
231231
const services = await Promise.all(
232232
config.services.map(async (svc) => {
233233
const port = env[svc.portEnv] ? parseInt(env[svc.portEnv]) : null;
@@ -296,7 +296,7 @@ async function handleApi(req: Request, url: URL): Promise<Response> {
296296
const body = await req.json() as { text?: string; preamble?: string };
297297
if (!body.text) return errorResponse("Missing 'text' field", 400);
298298
console.log(`[worktree:send] name=${name} text="${body.text.slice(0, 80)}"`);
299-
const result = sendPrompt(name, body.text, 0, body.preamble);
299+
const result = await sendPrompt(name, body.text, 0, body.preamble);
300300
if (!result.ok) return errorResponse(result.error, 404);
301301
return jsonResponse({ ok: true });
302302
}
@@ -314,17 +314,18 @@ async function handleApi(req: Request, url: URL): Promise<Response> {
314314
if (parts[0] === "ci-logs" && parts.length === 2 && method === "GET") {
315315
const runId = parts[1];
316316
if (!/^\d+$/.test(runId)) return errorResponse("Invalid run ID", 400);
317-
const result = Bun.spawnSync(["gh", "run", "view", runId, "--log-failed"], {
317+
const proc = Bun.spawn(["gh", "run", "view", runId, "--log-failed"], {
318318
stdout: "pipe",
319319
stderr: "pipe",
320320
});
321+
const exitCode = await proc.exited;
321322

322-
if (result.exitCode === 0) {
323-
const logs = new TextDecoder().decode(result.stdout);
323+
if (exitCode === 0) {
324+
const logs = await new Response(proc.stdout).text();
324325
return jsonResponse({ logs });
325326
}
326327

327-
const stderr = new TextDecoder().decode(result.stderr).trim();
328+
const stderr = (await new Response(proc.stderr).text()).trim();
328329
return errorResponse(`Failed to fetch logs: ${stderr || "unknown error"}`, 502);
329330
}
330331

backend/src/terminal.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,9 @@ export function write(worktreeName: string, data: string): void {
163163
console.log(`[term:${ts()}] write(${worktreeName}) NO STDIN - input dropped (${data.length} bytes)`);
164164
return;
165165
}
166-
(session.proc.stdin as FileSink).write(new TextEncoder().encode(data));
166+
const sink = session.proc.stdin as FileSink;
167+
sink.write(new TextEncoder().encode(data));
168+
sink.flush();
167169
}
168170

169171
export function resize(worktreeName: string, cols: number, rows: number): void {

backend/src/workmux.ts

Lines changed: 46 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ export async function addWorktree(
250250

251251
// Read worktree dir from git (tmux pane may not have cd'd yet with -C)
252252
const wtDir = findWorktreeDir(branch);
253-
const env = readEnvLocal(wtDir);
253+
const env = await readEnvLocal(wtDir);
254254
console.log(`[workmux:add] branch=${branch} dir=${wtDir} env=${JSON.stringify(env)}`);
255255

256256
// Append profile to .env.local (worktree-env creates it, we just add to it)
@@ -297,7 +297,7 @@ export async function addWorktree(
297297
const dockerExec = `docker exec -it -w ${wtDir} ${containerName} bash`;
298298
Bun.spawnSync(["tmux", "send-keys", "-t", `${windowTarget}.0`, dockerExec, "Enter"]);
299299
// Wait for shell to be ready, then chain entrypoint → agent
300-
Bun.spawnSync(["sleep", "0.5"]);
300+
await Bun.sleep(500);
301301
const entrypointThenAgent = `/usr/local/bin/entrypoint.sh && ${agentCmd}`;
302302
console.log(`[workmux] sending to ${windowTarget}.0:\n${entrypointThenAgent}`);
303303
Bun.spawnSync(["tmux", "send-keys", "-t", `${windowTarget}.0`, entrypointThenAgent, "Enter"]);
@@ -325,27 +325,52 @@ export async function removeWorktree(name: string): Promise<string> {
325325
return result;
326326
}
327327

328-
export function sendPrompt(
328+
const TMUX_TIMEOUT_MS = 5_000;
329+
330+
/** Run a tmux subprocess and await exit with a timeout. Kills the process on timeout. */
331+
async function tmuxExec(args: string[], opts: { stdin?: Uint8Array } = {}): Promise<{ exitCode: number; stderr: string }> {
332+
const proc = Bun.spawn(args, {
333+
stdin: opts.stdin ?? "ignore",
334+
stdout: "ignore",
335+
stderr: "pipe",
336+
});
337+
338+
const timeout = Bun.sleep(TMUX_TIMEOUT_MS).then(() => {
339+
proc.kill();
340+
return "timeout" as const;
341+
});
342+
343+
const result = await Promise.race([proc.exited, timeout]);
344+
if (result === "timeout") {
345+
return { exitCode: -1, stderr: "timed out after 5s (agent may be busy)" };
346+
}
347+
const stderr = (await new Response(proc.stderr).text()).trim();
348+
return { exitCode: result, stderr };
349+
}
350+
351+
export async function sendPrompt(
329352
branch: string,
330353
text: string,
331354
pane = 0,
332355
preamble?: string,
333-
): { ok: true } | { ok: false; error: string } {
356+
): Promise<{ ok: true } | { ok: false; error: string }> {
334357
const windowName = `wm-${branch}`;
335-
const session = findWorktreeSession(windowName);
358+
const session = await findWorktreeSession(windowName);
336359
if (!session) {
337360
return { ok: false, error: `tmux window "${windowName}" not found` };
338361
}
339362
const target = `${session}:${windowName}.${pane}`;
363+
console.log(`[send:${branch}] target=${target} textBytes=${text.length}${preamble ? ` preamble=${preamble.length}b` : ""}`);
340364

341365
// Type the preamble as regular keystrokes so it shows inline in the agent,
342366
// then paste the bulk payload via a tmux buffer (appears as [pasted text]).
343367
if (preamble) {
344-
const pre = Bun.spawnSync(["tmux", "send-keys", "-t", target, "-l", "--", preamble], { stderr: "pipe" });
345-
if (pre.exitCode !== 0) {
346-
const stderr = new TextDecoder().decode(pre.stderr).trim();
347-
return { ok: false, error: `send-keys preamble failed (exit ${pre.exitCode})${stderr ? `: ${stderr}` : ""}` };
368+
console.log(`[send:${branch}] send-keys preamble start`);
369+
const { exitCode, stderr } = await tmuxExec(["tmux", "send-keys", "-t", target, "-l", "--", preamble]);
370+
if (exitCode !== 0) {
371+
return { ok: false, error: `send-keys preamble failed${stderr ? `: ${stderr}` : ""}` };
348372
}
373+
console.log(`[send:${branch}] send-keys preamble done`);
349374
}
350375

351376
const cleaned = text.replace(/\0/g, "");
@@ -356,41 +381,31 @@ export function sendPrompt(
356381

357382
// Load text into a named tmux buffer via stdin — avoids all send-keys
358383
// escaping/chunking issues and handles any text size in a single operation.
359-
const load = Bun.spawnSync(["tmux", "load-buffer", "-b", bufName, "-"], {
360-
stdin: new TextEncoder().encode(cleaned),
361-
stderr: "pipe",
362-
});
384+
console.log(`[send:${branch}] load-buffer start buf=${bufName}`);
385+
const load = await tmuxExec(["tmux", "load-buffer", "-b", bufName, "-"], { stdin: new TextEncoder().encode(cleaned) });
363386
if (load.exitCode !== 0) {
364-
const stderr = new TextDecoder().decode(load.stderr).trim();
365-
return { ok: false, error: `load-buffer failed (exit ${load.exitCode})${stderr ? `: ${stderr}` : ""}` };
387+
return { ok: false, error: `load-buffer failed${load.stderr ? `: ${load.stderr}` : ""}` };
366388
}
389+
console.log(`[send:${branch}] load-buffer done`);
367390

368391
// Paste buffer into target pane; -d deletes the buffer after pasting.
369-
const paste = Bun.spawnSync(["tmux", "paste-buffer", "-b", bufName, "-t", target, "-d"], {
370-
stderr: "pipe",
371-
});
392+
console.log(`[send:${branch}] paste-buffer start`);
393+
const paste = await tmuxExec(["tmux", "paste-buffer", "-b", bufName, "-t", target, "-d"]);
372394
if (paste.exitCode !== 0) {
373-
const stderr = new TextDecoder().decode(paste.stderr).trim();
374-
return { ok: false, error: `paste-buffer failed (exit ${paste.exitCode})${stderr ? `: ${stderr}` : ""}` };
375-
}
376-
377-
// Submit
378-
const enter = Bun.spawnSync(["tmux", "send-keys", "-t", target, "Enter"], { stderr: "pipe" });
379-
if (enter.exitCode !== 0) {
380-
const stderr = new TextDecoder().decode(enter.stderr).trim();
381-
return { ok: false, error: `send-keys Enter failed (exit ${enter.exitCode})${stderr ? `: ${stderr}` : ""}` };
395+
return { ok: false, error: `paste-buffer failed${paste.stderr ? `: ${paste.stderr}` : ""}` };
382396
}
397+
console.log(`[send:${branch}] done`);
383398

384399
return { ok: true };
385400
}
386401

387-
function findWorktreeSession(windowName: string): string | null {
388-
const result = Bun.spawnSync(
402+
async function findWorktreeSession(windowName: string): Promise<string | null> {
403+
const proc = Bun.spawn(
389404
["tmux", "list-windows", "-a", "-F", "#{session_name}:#{window_name}"],
390405
{ stdout: "pipe", stderr: "pipe" }
391406
);
392-
if (result.exitCode !== 0) return null;
393-
const output = new TextDecoder().decode(result.stdout).trim();
407+
if (await proc.exited !== 0) return null;
408+
const output = (await new Response(proc.stdout).text()).trim();
394409
if (!output) return null;
395410
for (const line of output.split("\n")) {
396411
const colonIdx = line.indexOf(":");

0 commit comments

Comments
 (0)