Skip to content
Merged
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
80 changes: 79 additions & 1 deletion backend/src/__tests__/pr.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "bun:test";
import { parseReviewComments, mapWithConcurrency } from "../services/pr-service";
import { mapWithConcurrency, startSerializedInterval } from "../lib/async";
import { parseReviewComments } from "../services/pr-service";

describe("parseReviewComments", () => {
it("parses normal review comments", () => {
Expand Down Expand Up @@ -107,3 +108,80 @@ describe("mapWithConcurrency", () => {
expect(result).toEqual([]);
});
});

describe("startSerializedInterval", () => {
it("coalesces overlapping ticks into a single rerun", async () => {
const ticks: Array<() => void> = [];
const completions: Array<() => void> = [];
let runs = 0;
const stop = startSerializedInterval(
async () => {
runs += 1;
await new Promise<void>((resolve) => {
completions.push(resolve);
});
},
1000,
{
scheduleEvery: (handler) => {
ticks.push(handler);
return ticks.length;
},
cancelSchedule: () => {},
},
);

await Promise.resolve();
expect(runs).toBe(1);

ticks[0]!();
ticks[0]!();
expect(runs).toBe(1);

completions.shift()?.();
for (let i = 0; i < 10 && runs < 2; i += 1) {
await Promise.resolve();
}
expect(runs).toBe(2);

completions.shift()?.();
await Promise.resolve();
stop();
});

it("stops scheduling reruns after disposal", async () => {
const ticks: Array<() => void> = [];
const completions: Array<() => void> = [];
let runs = 0;
let cancelledHandle: number | null = null;
const stop = startSerializedInterval(
async () => {
runs += 1;
await new Promise<void>((resolve) => {
completions.push(resolve);
});
},
1000,
{
scheduleEvery: (handler) => {
ticks.push(handler);
return 42;
},
cancelSchedule: (handle) => {
cancelledHandle = handle;
},
},
);

await Promise.resolve();
expect(runs).toBe(1);
ticks[0]!();
stop();
completions.shift()?.();
await Promise.resolve();
await Promise.resolve();

expect(cancelledHandle === 42).toBe(true);
expect(runs).toBe(1);
});
});
89 changes: 88 additions & 1 deletion backend/src/__tests__/reconciliation-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,27 @@ class FakeTmuxGateway implements TmuxGateway {
}

class FakePortProbe implements PortProbe {
constructor(private readonly listening = new Set<number>()) {}
readonly calls: number[] = [];

constructor(
private readonly listening = new Set<number>(),
private readonly onProbe?: (port: number) => Promise<void> | void,
) {}

async isListening(port: number): Promise<boolean> {
this.calls.push(port);
await this.onProbe?.(port);
return this.listening.has(port);
}
}

function resolveProbe(fn: (() => void) | undefined, label: string): void {
if (!fn) {
throw new Error(`expected ${label} to be available`);
}
fn();
}

const TEST_CONFIG: ProjectConfig = {
name: "Project",
workspace: {
Expand Down Expand Up @@ -278,4 +292,77 @@ describe("ReconciliationService", () => {
expect(state?.agentName).toBeNull();
expect(state?.services).toEqual([]);
});

it("coalesces concurrent reconcile calls and skips fresh repeats", async () => {
const repoRoot = "/repo/project";
const managedPath = "/repo/project/__worktrees/feature-fresh";
const managedGitDir = await mkdtemp(join(tmpdir(), "webmux-reconcile-fresh-"));
tempDirs.push(managedGitDir);

await writeWorktreeMeta(managedGitDir, {
schemaVersion: 1,
worktreeId: "wt_fresh",
branch: "feature/fresh",
createdAt: "2026-03-06T00:00:00.000Z",
profile: "default",
agent: "claude",
runtime: "host",
startupEnvValues: {},
allocatedPorts: { FRONTEND_PORT: 3010 },
});

const probeState: { release?: () => void } = {};
let nowMs = 10_000;
const portProbe = new FakePortProbe(new Set([3010]), async () => {
await new Promise<void>((resolve) => {
probeState.release = resolve;
});
});
const runtime = new ProjectRuntime();
const git = new FakeGitGateway(
[
{ path: repoRoot, branch: "main", head: "aaa111", detached: false, bare: false },
{ path: managedPath, branch: "feature/fresh", head: "bbb222", detached: false, bare: false },
],
new Map([[managedPath, managedGitDir]]),
new Map([[managedPath, { dirty: false, aheadCount: 0, currentCommit: "bbb222" }]]),
);
const service = new ReconciliationService(
{
config: TEST_CONFIG,
git,
tmux: new FakeTmuxGateway([]),
portProbe,
runtime,
},
{
freshnessMs: 1000,
now: () => nowMs,
},
);

const first = service.reconcile(repoRoot);
const second = service.reconcile(repoRoot);
while (probeState.release === undefined) {
await Promise.resolve();
}

expect(portProbe.calls).toEqual([3010]);
resolveProbe(probeState.release, "the first probe release");
await Promise.all([first, second]);
expect(portProbe.calls).toEqual([3010]);

await service.reconcile(repoRoot);
expect(portProbe.calls).toEqual([3010]);

nowMs += 1001;
probeState.release = undefined;
const third = service.reconcile(repoRoot);
while (probeState.release === undefined) {
await Promise.resolve();
}
expect(portProbe.calls).toEqual([3010, 3010]);
resolveProbe(probeState.release, "the second probe release");
await third;
});
});
107 changes: 107 additions & 0 deletions backend/src/__tests__/terminal-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

const isolatedTmuxScriptPath = new URL("../../../scripts/run-with-isolated-tmux.sh", import.meta.url).pathname;

function buildEnv(overrides: Record<string, string>): Record<string, string> {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) env[key] = value;
}
return {
...env,
...overrides,
};
}

function read(args: string[], env?: Record<string, string>): string {
const result = Bun.spawnSync(args, { env, stdout: "pipe", stderr: "pipe" });
if (result.exitCode !== 0) {
const stderr = new TextDecoder().decode(result.stderr).trim();
throw new Error(`${args.join(" ")} failed: ${stderr || `exit ${result.exitCode}`}`);
}

return new TextDecoder().decode(result.stdout).trim();
}

describe("terminal adapter", () => {
it("keeps concurrent attaches isolated by attach id", async () => {
const testRoot = await mkdtemp(join(tmpdir(), "webmux-terminal-"));
const env = buildEnv({
TMUX: "",
TMUX_TMPDIR: testRoot,
});
const runnerPath = join(testRoot, "run-terminal.ts");
const terminalModuleUrl = new URL("../adapters/terminal.ts", import.meta.url).href;

await Bun.write(
runnerPath,
[
`import { attach, cleanupStaleSessions, detach } from ${JSON.stringify(terminalModuleUrl)};`,
"",
"function run(args: string[]): void {",
' const result = Bun.spawnSync(args, { stdout: "pipe", stderr: "pipe" });',
" if (result.exitCode !== 0) {",
" const stderr = new TextDecoder().decode(result.stderr).trim();",
' throw new Error(`${args.join(" ")} failed: ${stderr || `exit ${result.exitCode}`}`);',
" }",
"}",
"",
"function read(args: string[]): string {",
' const result = Bun.spawnSync(args, { stdout: "pipe", stderr: "pipe" });',
" if (result.exitCode !== 0) {",
" const stderr = new TextDecoder().decode(result.stderr).trim();",
' throw new Error(`${args.join(" ")} failed: ${stderr || `exit ${result.exitCode}`}`);',
" }",
' return new TextDecoder().decode(result.stdout).trim();',
"}",
"",
"function listManagedSessions(): string[] {",
' return read(["tmux", "list-sessions", "-F", "#{session_name}"])',
' .split("\\n")',
" .filter(Boolean)",
' .filter((name) => name.startsWith(`wm-dash-${Bun.env.PORT || "5111"}-`));',
"}",
"",
"cleanupStaleSessions();",
'run(["tmux", "new-session", "-d", "-s", "owner", "-n", "wm-feature/search"]);',
'await attach("attach-a", { ownerSessionName: "owner", windowName: "wm-feature/search" }, 80, 24);',
'await attach("attach-b", { ownerSessionName: "owner", windowName: "wm-feature/search" }, 80, 24);',
"await Bun.sleep(200);",
"const afterAttach = listManagedSessions();",
'await detach("attach-a");',
"await Bun.sleep(100);",
"const afterFirstDetach = listManagedSessions();",
'await detach("attach-b");',
"await Bun.sleep(100);",
"const afterSecondDetach = listManagedSessions();",
'run(["tmux", "kill-session", "-t", "owner"]);',
"console.log(JSON.stringify({ afterAttach, afterFirstDetach, afterSecondDetach }));",
].join("\n"),
);

try {
const output = read(["bash", isolatedTmuxScriptPath, "bun", runnerPath], env);
const jsonLine = output
.split("\n")
.map((line) => line.trim())
.find((line) => line.startsWith("{") && line.endsWith("}"));
if (!jsonLine) {
throw new Error(`expected terminal runner output, got: ${output}`);
}
const result = JSON.parse(jsonLine) as {
afterAttach: string[];
afterFirstDetach: string[];
afterSecondDetach: string[];
};

expect(result.afterAttach).toHaveLength(2);
expect(result.afterFirstDetach).toHaveLength(1);
expect(result.afterSecondDetach).toHaveLength(0);
} finally {
await rm(testRoot, { recursive: true, force: true });
}
});
});
11 changes: 6 additions & 5 deletions backend/src/adapters/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { join } from "node:path";
import { log } from "../lib/log";

export interface RunLifecycleHookInput {
command: string;
Expand Down Expand Up @@ -52,8 +53,8 @@ export class BunLifecycleHookRunner implements LifecycleHookRunner {

async run(input: RunLifecycleHookInput): Promise<void> {
const cmd = await this.buildCommand(input.cwd, input.command);
console.debug(`[hook-runner] Spawning: ${cmd.join(" ")} cwd=${input.cwd}`);
console.debug(`[hook-runner] Env keys: ${Object.keys(input.env).join(", ")}`);
log.debug(`[hook-runner] Spawning: ${cmd.join(" ")} cwd=${input.cwd}`);
log.debug(`[hook-runner] envKeys=${Object.keys(input.env).length}`);
const proc = Bun.spawn(cmd, {
cwd: input.cwd,
env: {
Expand All @@ -70,9 +71,9 @@ export class BunLifecycleHookRunner implements LifecycleHookRunner {
new Response(proc.stderr).text(),
]);

console.debug(`[hook-runner] ${input.name} exitCode=${exitCode}`);
if (stdout.trim()) console.debug(`[hook-runner] stdout: ${stdout.trim()}`);
if (stderr.trim()) console.debug(`[hook-runner] stderr: ${stderr.trim()}`);
log.debug(`[hook-runner] ${input.name} exitCode=${exitCode}`);
if (stdout.trim()) log.debug(`[hook-runner] stdout: ${stdout.trim()}`);
if (stderr.trim()) log.debug(`[hook-runner] stderr: ${stderr.trim()}`);

if (exitCode !== 0) {
throw new Error(buildErrorMessage(input.name, exitCode, stdout, stderr));
Expand Down
Loading
Loading