fix: use live merge-base for task diff scope

The in-progress/in-review Changes tab was inflating file counts by
preferring a stale task.baseCommitSha over the live merge-base with the
base branch. Once upstream commits are merged into a feature branch,
baseCommitSha..HEAD includes every upstream file as well, producing
counts far larger than the branch's own changes.

resolveDiffBase now prefers merge-base(HEAD, [origin/]baseBranch), falling
back to baseCommitSha only when no merge-base is available or when the
merge-base equals HEAD (task sitting on the base branch with no
divergence, e.g. unit-test scenarios).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-23 16:21:34 -07:00
parent e8895c76b8
commit c250470b94
4 changed files with 322 additions and 50 deletions

View File

@@ -4435,16 +4435,27 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
*/
async function resolveDiffBase(task: { baseCommitSha?: string; baseBranch?: string }, cwd: string): Promise<string | undefined> {
const baseBranch = task.baseBranch ?? "main";
let mergeBase: string | undefined;
try {
try {
const base = (await runGitCommand(["merge-base", "HEAD", `origin/${baseBranch}`], cwd, 5000)).trim();
if (base) return base;
mergeBase = (await runGitCommand(["merge-base", "HEAD", `origin/${baseBranch}`], cwd, 5000)).trim() || undefined;
} catch {
const base = (await runGitCommand(["merge-base", "HEAD", baseBranch], cwd, 5000)).trim();
if (base) return base;
mergeBase = (await runGitCommand(["merge-base", "HEAD", baseBranch], cwd, 5000)).trim() || undefined;
}
} catch {
// merge-base unavailable — base branch may no longer exist locally
// base branch may no longer exist locally
}
// If the merge-base equals HEAD, we're on the base branch with no feature
// divergence — the live merge-base would give an empty diff, so prefer the
// task-scoped baseCommitSha instead.
if (mergeBase) {
try {
const head = (await runGitCommand(["rev-parse", "HEAD"], cwd, 5000)).trim();
if (head && head !== mergeBase) return mergeBase;
} catch {
return mergeBase;
}
}
if (task.baseCommitSha) {

View File

@@ -5,6 +5,7 @@ import http from "node:http";
import { createHmac } from "node:crypto";
import express from "express";
import { createServer, setupTerminalWebSocket } from "./server.js";
import { toSessionTag } from "./terminal-websocket-diagnostics.js";
import type { TaskStore } from "@fusion/core";
import { get as performGet, request as performRequest } from "./test-request.js";
@@ -78,6 +79,35 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
} as unknown as TaskStore;
}
function createTerminalLoggerHarness() {
const terminalLogger = {
scope: "server:terminal",
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: vi.fn(),
};
terminalLogger.child.mockReturnValue(terminalLogger);
const runtimeLogger = {
scope: "server",
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: vi.fn(),
};
runtimeLogger.child.mockImplementation((scope: string) => {
if (scope === "terminal") {
return terminalLogger;
}
return runtimeLogger;
});
return { runtimeLogger, terminalLogger };
}
async function GET(app: ReturnType<typeof createServer>, path: string): Promise<{ status: number; body: unknown; headers: Record<string, unknown> }> {
const res = await performGet(app, path);
return res;
@@ -475,15 +505,17 @@ describe("Terminal WebSocket heartbeat", () => {
let app: ReturnType<typeof express>;
let server: http.Server;
let store: TaskStore;
let runtimeLogger: ReturnType<typeof createTerminalLoggerHarness>["runtimeLogger"];
let terminalLogger: ReturnType<typeof createTerminalLoggerHarness>["terminalLogger"];
beforeEach(() => {
app = express();
server = http.createServer(app);
store = createMockStore();
vi.useFakeTimers();
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {});
vi.spyOn(console, "log").mockImplementation(() => {});
const loggerHarness = createTerminalLoggerHarness();
runtimeLogger = loggerHarness.runtimeLogger;
terminalLogger = loggerHarness.terminalLogger;
});
afterEach(() => {
@@ -520,8 +552,11 @@ describe("Terminal WebSocket heartbeat", () => {
}
/** Setup terminal WebSocket and trigger a connection */
function setupAndConnect(ws: any, req: any): void {
const wss = setupTerminalWebSocket(app, server, store);
function setupAndConnect(ws: any, req: any, options: Record<string, unknown> = {}): void {
setupTerminalWebSocket(app, server, store, {
runtimeLogger: runtimeLogger as any,
...options,
});
// The function sets up wss on the server's upgrade event.
// We need to access the WebSocketServer directly to emit a connection.
@@ -579,10 +614,26 @@ describe("Terminal WebSocket heartbeat", () => {
// Don't send pong — missed pong #1
vi.advanceTimersByTime(30000);
expect(ws.terminate).not.toHaveBeenCalled();
expect(terminalLogger.info).toHaveBeenCalledWith(
"Missed terminal websocket pong",
expect.objectContaining({
sessionTag: toSessionTag("session-2"),
missedPongs: 1,
maxMissedPongs: 2,
}),
);
// Don't send pong — missed pong #2: should terminate
vi.advanceTimersByTime(30000);
expect(ws.terminate).toHaveBeenCalled();
expect(terminalLogger.warn).toHaveBeenCalledWith(
"Terminating terminal websocket after missed pong threshold",
expect.objectContaining({
sessionTag: toSessionTag("session-2"),
missedPongs: 2,
maxMissedPongs: 2,
}),
);
});
it("resets missed pong counter on successful pong", () => {
@@ -620,14 +671,68 @@ describe("Terminal WebSocket heartbeat", () => {
expect(ws.terminate).not.toHaveBeenCalled();
});
it("logs warning for stale session reconnect", () => {
it("logs structured error and closes 4510 when scoped store resolution fails", async () => {
const ws = createMockWs();
const req = createMockReq("stale-session");
const req = {
url: "/api/terminal/ws?sessionId=session-4510&projectId=proj-a",
headers: { host: "localhost:3000" },
};
const engineManager = {
getEngine: vi.fn(() => {
throw new Error("scope lookup failed");
}),
};
setupAndConnect(ws, req, { engineManager });
await Promise.resolve();
expect(ws.close).toHaveBeenCalledWith(4510, "Failed to resolve project scope");
expect(terminalLogger.error).toHaveBeenCalledWith(
"Failed to resolve project scope",
expect.objectContaining({
projectId: "proj-a",
error: "scope lookup failed",
}),
);
});
it("logs redacted cwd mismatch context and closes 4503", () => {
const ws = createMockWs();
const req = createMockReq("cross-project-session-1");
mockTerminalService.getSession.mockReturnValue({
id: "cross-project-session-1",
shell: "/bin/bash",
cwd: "/Users/alice/private/other-project",
lastActivityAt: new Date(),
});
setupAndConnect(ws, req);
expect(ws.close).toHaveBeenCalledWith(4503, "Session does not belong to this project");
expect(terminalLogger.warn).toHaveBeenCalledWith(
"Rejected terminal session outside scoped project root",
expect.objectContaining({
sessionTag: toSessionTag("cross-project-session-1"),
sessionCwdHint: expect.stringContaining("<redacted>/"),
scopedRootHint: expect.stringContaining("<redacted>/"),
}),
);
expect(terminalLogger.warn).not.toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ sessionCwd: "/Users/alice/private/other-project" }),
);
});
it("logs structured warning for stale session reconnect with bounded context", () => {
const ws = createMockWs();
const req = createMockReq("stale-session-123456");
// Session last active 10 minutes ago (past the 5-minute threshold)
const tenMinutesAgo = new Date(Date.now() - 600_000);
mockTerminalService.getSession.mockReturnValue({
id: "stale-session",
id: "stale-session-123456",
shell: "/bin/bash",
cwd: "/fake/root",
lastActivityAt: tenMinutesAgo,
@@ -635,11 +740,16 @@ describe("Terminal WebSocket heartbeat", () => {
setupAndConnect(ws, req);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining("stale-session"),
expect(terminalLogger.warn).toHaveBeenCalledWith(
"Terminal reconnect may target stale PTY session",
expect.objectContaining({
sessionTag: toSessionTag("stale-session-123456"),
idleMs: 600_000,
}),
);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining("PTY may be stale"),
expect(terminalLogger.warn).not.toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ sessionId: "stale-session-123456" }),
);
});
@@ -657,8 +767,9 @@ describe("Terminal WebSocket heartbeat", () => {
setupAndConnect(ws, req);
expect(console.warn).not.toHaveBeenCalledWith(
expect.stringContaining("PTY may be stale"),
expect(terminalLogger.warn).not.toHaveBeenCalledWith(
"Terminal reconnect may target stale PTY session",
expect.anything(),
);
});
});
@@ -667,7 +778,8 @@ describe("Terminal stale-session eviction", () => {
let app: ReturnType<typeof express>;
let server: http.Server;
let store: TaskStore;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let runtimeLogger: ReturnType<typeof createTerminalLoggerHarness>["runtimeLogger"];
let terminalLogger: ReturnType<typeof createTerminalLoggerHarness>["terminalLogger"];
beforeEach(() => {
app = express();
@@ -675,8 +787,9 @@ describe("Terminal stale-session eviction", () => {
store = createMockStore();
vi.useFakeTimers();
mockTerminalService.evictStaleSessions.mockReset().mockReturnValue(0);
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "log").mockImplementation(() => {});
const loggerHarness = createTerminalLoggerHarness();
runtimeLogger = loggerHarness.runtimeLogger;
terminalLogger = loggerHarness.terminalLogger;
});
afterEach(() => {
@@ -686,36 +799,35 @@ describe("Terminal stale-session eviction", () => {
});
it("calls evictStaleSessions on each 60s interval tick", () => {
setupTerminalWebSocket(app, server, store);
setupTerminalWebSocket(app, server, store, { runtimeLogger: runtimeLogger as any });
vi.advanceTimersByTime(60_000);
expect(mockTerminalService.evictStaleSessions).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(60_000);
expect(mockTerminalService.evictStaleSessions).toHaveBeenCalledTimes(2);
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
expect.stringContaining("Stale session eviction failed"),
expect(terminalLogger.error).not.toHaveBeenCalledWith(
"Stale session eviction failed",
expect.anything(),
);
});
it("logs error and continues when evictStaleSessions throws", () => {
it("logs structured error and continues when evictStaleSessions throws", () => {
mockTerminalService.evictStaleSessions.mockImplementation(() => {
throw new Error("simulated eviction failure");
});
setupTerminalWebSocket(app, server, store);
setupTerminalWebSocket(app, server, store, { runtimeLogger: runtimeLogger as any });
vi.advanceTimersByTime(60_000);
expect(consoleErrorSpy).toHaveBeenCalled();
const failureCall = consoleErrorSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[terminal] Stale session eviction failed"),
);
expect(failureCall).toBeDefined();
expect(failureCall?.[0]).toEqual(expect.stringContaining("[terminal] Stale session eviction failed"));
expect(failureCall?.[1]).toEqual(
expect.objectContaining({ error: "simulated eviction failure" }),
expect(terminalLogger.error).toHaveBeenCalledWith(
"Stale session eviction failed",
expect.objectContaining({
error: "simulated eviction failure",
errorName: "Error",
errorMessage: "simulated eviction failure",
}),
);
vi.advanceTimersByTime(60_000);
@@ -723,7 +835,7 @@ describe("Terminal stale-session eviction", () => {
});
it("stops eviction interval when server closes", () => {
setupTerminalWebSocket(app, server, store);
setupTerminalWebSocket(app, server, store, { runtimeLogger: runtimeLogger as any });
server.emit("close");

View File

@@ -21,6 +21,7 @@ import { WebSocketManager, type BadgeSnapshot } from "./websocket.js";
import type { BadgePubSub } from "./badge-pubsub.js";
import { createBadgePubSub, type BadgePubSubMessage } from "./badge-pubsub.js";
import { createRuntimeLogger, type RuntimeLogger } from "./runtime-logger.js";
import { createTerminalWebSocketDiagnostics } from "./terminal-websocket-diagnostics.js";
import {
AiSessionStore,
SESSION_CLEANUP_DEFAULT_MAX_AGE_MS,
@@ -975,7 +976,7 @@ export function setupTerminalWebSocket(
// Resolve the daemon token once so every upgrade picks up the same value.
const wsDaemonToken = getDaemonToken(options);
const terminalLogger = options?.runtimeLogger?.child("terminal") ?? createRuntimeLogger("terminal");
const terminalDiagnostics = createTerminalWebSocketDiagnostics(options?.runtimeLogger);
server.on("upgrade", (req, socket, head) => {
const pathname = new URL(req.url || "", `http://${req.headers.host}`).pathname;
@@ -1028,8 +1029,9 @@ export function setupTerminalWebSocket(
terminalService = getTerminalService(scopedRootDir);
}
} catch (err) {
terminalLogger.error("Failed to resolve project scope", {
error: err instanceof Error ? err.message : String(err),
terminalDiagnostics.scopeResolutionFailed({
projectId,
error: err,
});
ws.close(4510, "Failed to resolve project scope");
return;
@@ -1044,7 +1046,12 @@ export function setupTerminalWebSocket(
// Security check: reject sessions that don't belong to this project's root
// Session cwd must be within the resolved project root
if (!session.cwd.startsWith(scopedRootDir)) {
terminalLogger.warn(`Session ${sessionId} cwd ${session.cwd} does not belong to project root ${scopedRootDir}`);
terminalDiagnostics.crossProjectCwdRejected({
sessionId,
projectId,
sessionCwd: session.cwd,
scopedRootDir,
});
ws.close(4503, "Session does not belong to this project");
return;
}
@@ -1060,9 +1067,11 @@ export function setupTerminalWebSocket(
// Detect potentially stale sessions on reconnect
const idleMs = Date.now() - session.lastActivityAt.getTime();
if (idleMs > STALE_SESSION_THRESHOLD_MS) {
terminalLogger.warn(
`Session ${sessionId} reconnect after ${Math.round(idleMs / 1000)}s idle — PTY may be stale`,
);
terminalDiagnostics.staleReconnect({
sessionId,
idleMs,
staleThresholdMs: STALE_SESSION_THRESHOLD_MS,
});
}
// Send scrollback buffer first
@@ -1095,7 +1104,11 @@ export function setupTerminalWebSocket(
try {
ws.send(JSON.stringify({ type: "exit", exitCode }));
const idleSec = id ? Math.round((Date.now() - (terminalService.getSession(id)?.lastActivityAt?.getTime() ?? Date.now())) / 1000) : 0;
terminalLogger.info(`Session ${id} exited with code ${exitCode} (was ${idleSec}s idle)`);
terminalDiagnostics.ptyExit({
sessionId: id,
exitCode,
idleSeconds: idleSec,
});
} catch {
// WebSocket might be closing
}
@@ -1107,11 +1120,19 @@ export function setupTerminalWebSocket(
if (!isAlive) {
missedPongs++;
if (missedPongs >= MAX_MISSED_PONGS) {
terminalLogger.warn(`Connection dead after ${missedPongs} missed pongs, terminating`);
terminalDiagnostics.heartbeatTerminating({
sessionId,
missedPongs,
maxMissedPongs: MAX_MISSED_PONGS,
});
ws.terminate();
return;
}
terminalLogger.info(`Missed pong #${missedPongs}, waiting for response...`);
terminalDiagnostics.heartbeatMissed({
sessionId,
missedPongs,
maxMissedPongs: MAX_MISSED_PONGS,
});
return;
}
isAlive = false;
@@ -1184,9 +1205,7 @@ export function setupTerminalWebSocket(
try {
defaultTerminalService.evictStaleSessions();
} catch (err) {
terminalLogger.error("Stale session eviction failed", {
error: err instanceof Error ? err.message : String(err),
});
terminalDiagnostics.staleEvictionFailed({ error: err });
}
}, 60_000);
@@ -1195,7 +1214,7 @@ export function setupTerminalWebSocket(
clearInterval(staleEvictionInterval);
});
terminalLogger.info("WebSocket server mounted at /api/terminal/ws");
terminalDiagnostics.mounted({ path: "/api/terminal/ws" });
}
export function setupBadgeWebSocket(

View File

@@ -0,0 +1,130 @@
import { basename, normalize, sep } from "node:path";
import { createRuntimeLogger, type RuntimeLogger } from "./runtime-logger.js";
export interface TerminalWebSocketDiagnostics {
scopeResolutionFailed(context: { projectId?: string; error: unknown }): void;
crossProjectCwdRejected(context: {
sessionId: string;
projectId?: string;
sessionCwd: string;
scopedRootDir: string;
}): void;
staleReconnect(context: { sessionId: string; idleMs: number; staleThresholdMs: number }): void;
heartbeatMissed(context: { sessionId: string; missedPongs: number; maxMissedPongs: number }): void;
heartbeatTerminating(context: { sessionId: string; missedPongs: number; maxMissedPongs: number }): void;
ptyExit(context: { sessionId: string; exitCode: number | null; idleSeconds: number }): void;
staleEvictionFailed(context: { error: unknown }): void;
mounted(context: { path: string }): void;
}
/**
* Creates a bounded identifier suitable for diagnostics.
*
* Contract:
* - Never emits the full raw session id
* - Preserves enough entropy to correlate events in a single incident
* - Output format is stable for test assertions
*/
export function toSessionTag(sessionId: string): string {
const normalized = sessionId.trim();
if (normalized.length <= 8) {
return `${normalized}#${normalized.length}`;
}
return `${normalized.slice(0, 4)}${normalized.slice(-4)}#${normalized.length}`;
}
/**
* Redacts an absolute filesystem path while preserving mismatch debugging clues.
*
* Contract:
* - Removes leading absolute path segments
* - Includes only the last two segments + depth metadata
* - Stable string formatting for test assertions
*/
export function toRedactedPathHint(pathValue: string): string {
const normalized = normalize(pathValue);
const segments = normalized.split(sep).filter(Boolean);
if (segments.length === 0) {
return "<empty>";
}
const tail = segments.slice(-2).join("/");
return `<redacted>/${tail} (depth:${segments.length})`;
}
function normalizeError(error: unknown): Record<string, unknown> {
if (error instanceof Error) {
return {
error: error.message,
errorName: error.name,
errorMessage: error.message,
errorStack: error.stack,
};
}
const fallback = String(error);
return {
error: fallback,
errorMessage: fallback,
};
}
export function createTerminalWebSocketDiagnostics(runtimeLogger?: RuntimeLogger): TerminalWebSocketDiagnostics {
const logger = runtimeLogger?.child("terminal") ?? createRuntimeLogger("terminal");
return {
scopeResolutionFailed({ projectId, error }) {
logger.error("Failed to resolve project scope", {
projectId,
...normalizeError(error),
});
},
crossProjectCwdRejected({ sessionId, projectId, sessionCwd, scopedRootDir }) {
logger.warn("Rejected terminal session outside scoped project root", {
sessionTag: toSessionTag(sessionId),
projectId,
sessionCwdHint: toRedactedPathHint(sessionCwd),
scopedRootHint: toRedactedPathHint(scopedRootDir),
sessionCwdBase: basename(sessionCwd),
scopedRootBase: basename(scopedRootDir),
});
},
staleReconnect({ sessionId, idleMs, staleThresholdMs }) {
logger.warn("Terminal reconnect may target stale PTY session", {
sessionTag: toSessionTag(sessionId),
idleMs,
staleThresholdMs,
});
},
heartbeatMissed({ sessionId, missedPongs, maxMissedPongs }) {
logger.info("Missed terminal websocket pong", {
sessionTag: toSessionTag(sessionId),
missedPongs,
maxMissedPongs,
});
},
heartbeatTerminating({ sessionId, missedPongs, maxMissedPongs }) {
logger.warn("Terminating terminal websocket after missed pong threshold", {
sessionTag: toSessionTag(sessionId),
missedPongs,
maxMissedPongs,
});
},
ptyExit({ sessionId, exitCode, idleSeconds }) {
logger.info("Terminal PTY exited", {
sessionTag: toSessionTag(sessionId),
exitCode,
idleSeconds,
});
},
staleEvictionFailed({ error }) {
logger.error("Stale session eviction failed", {
...normalizeError(error),
});
},
mounted({ path }) {
logger.info("WebSocket server mounted", {
path,
});
},
};
}