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
69 changes: 69 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6739,6 +6739,75 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("skips closing terminals on archive while a sibling thread shares the worktree", () =>
Effect.gen(function* () {
const threadId = ThreadId.make("thread-archive-shared-worktree");
const siblingThreadId = ThreadId.make("thread-archive-shared-sibling");
const worktreePath = "/tmp/worktrees/shared";
const effects: string[] = [];
const now = "2026-01-01T00:00:00.000Z";

yield* buildAppUnderTest({
layers: {
terminalManager: {
close: (input) =>
Effect.sync(() => {
effects.push(`terminal.close:${input.threadId}`);
}),
},
orchestrationEngine: {
dispatch: (command) =>
Effect.sync(() => {
effects.push(`dispatch:${command.type}`);
return { sequence: 1 };
}),
},
projectionSnapshotQuery: {
getThreadShellById: () =>
Effect.succeed(
Option.some(
makeDefaultOrchestrationThreadShell({
id: threadId,
updatedAt: now,
worktreePath,
}),
),
),
getShellSnapshot: () =>
Effect.succeed({
snapshotSequence: 1,
projects: [],
threads: [
makeDefaultOrchestrationThreadShell({
id: siblingThreadId,
updatedAt: now,
worktreePath,
}),
],
updatedAt: now,
}),
},
},
});

const wsUrl = yield* getWsServerUrl("/ws");
yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
type: "thread.archive",
commandId: CommandId.make("cmd-thread-archive-shared-worktree"),
threadId,
}),
),
);

// Terminals are worktree-scoped on the client: the sibling still uses
// the checkout, so its terminals (e.g. a running dev server) must
// survive the archive.
assert.deepEqual(effects, ["dispatch:thread.archive"]);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect(
"bootstraps first-send worktree turns on the server before dispatching turn start",
() =>
Expand Down
66 changes: 46 additions & 20 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
OrchestrationDispatchCommandError,
type OrchestrationEvent,
type OrchestrationShellStreamEvent,
type OrchestrationThreadShell,
type OrchestrationShellStreamItem,
type OrchestrationThreadStreamItem,
OrchestrationGetFullThreadDiffError,
Expand Down Expand Up @@ -1100,6 +1101,13 @@ const makeWsRpcLayer = (
};
});

// Mirrors the client's worktree identity rule (worktreeCleanup.ts):
// blank and null both mean "the project's local checkout".
const normalizeWorktreePathForArchive = (worktreePath: string | null): string | null => {
const trimmed = worktreePath?.trim();
return trimmed !== undefined && trimmed.length > 0 ? trimmed : null;
};

const refreshGitStatus = (cwd: string) =>
vcsStatusBroadcaster
.refreshStatus(cwd)
Expand All @@ -1111,21 +1119,17 @@ const makeWsRpcLayer = (
ORCHESTRATION_WS_METHODS.dispatchCommand,
Effect.gen(function* () {
const normalizedCommand = yield* normalizeDispatchCommand(command);
const shouldStopSessionAfterArchive =
const threadShellBeforeArchive =
normalizedCommand.type === "thread.archive"
? yield* projectionSnapshotQuery
.getThreadShellById(normalizedCommand.threadId)
.pipe(
Effect.map(
Option.match({
onNone: () => false,
onSome: (thread) =>
thread.session !== null && thread.session.status !== "stopped",
}),
),
Effect.orElseSucceed(() => false),
)
: false;
.pipe(Effect.orElseSucceed(() => Option.none<OrchestrationThreadShell>()))
: Option.none<OrchestrationThreadShell>();
const shouldStopSessionAfterArchive = Option.match(threadShellBeforeArchive, {
onNone: () => false,
onSome: (thread) =>
thread.session !== null && thread.session.status !== "stopped",
});
const result = yield* dispatchNormalizedCommand(normalizedCommand);
if (normalizedCommand.type === "thread.archive") {
if (shouldStopSessionAfterArchive) {
Expand All @@ -1150,14 +1154,36 @@ const makeWsRpcLayer = (
);
}

yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe(
Effect.catch((error) =>
Effect.logWarning("failed to close thread terminals after archive", {
threadId: normalizedCommand.threadId,
error: error.message,
}),
),
);
// Terminals are worktree-scoped on the client (every thread in
// a checkout shares one set of ptys via a canonical thread id),
// so archiving one thread must not kill sessions that sibling
// threads still use — e.g. a running dev server.
const worktreeStillInUse = yield* Option.match(threadShellBeforeArchive, {
onNone: () => Effect.succeed(false),
onSome: (archivedThread) =>
projectionSnapshotQuery.getShellSnapshot().pipe(
Effect.map((snapshot) =>
snapshot.threads.some(
(thread) =>
thread.id !== archivedThread.id &&
thread.projectId === archivedThread.projectId &&
normalizeWorktreePathForArchive(thread.worktreePath) ===
normalizeWorktreePathForArchive(archivedThread.worktreePath),
Comment on lines +1168 to +1171

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Close terminals after the last live sibling is archived

When the final unarchived thread in a checkout is archived after any sibling was archived earlier, shell snapshots still contain that earlier thread with a non-null archivedAt, and this predicate counts it as continued use. The close branch is therefore skipped, leaving the canonical checkout PTYs and subprocesses—including dev servers—running indefinitely with no live thread from which to access them. This check must consider only non-archived siblings and, when none remain, close the checkout's canonical terminal owner rather than assuming the just-archived thread owns the sessions.

Useful? React with 👍 / 👎.

),
),
Effect.orElseSucceed(() => false),
),
});
if (!worktreeStillInUse) {
yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe(
Effect.catch((error) =>
Effect.logWarning("failed to close thread terminals after archive", {
threadId: normalizedCommand.threadId,
error: error.message,
}),
),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Archive closes wrong terminal owner

High Severity

Terminal closure and UI state clearing logic incorrectly targets the specific archived or deleted thread ID. Client terminal sessions, however, are shared across a worktree and associated with a canonical thread ID. This mismatch can lead to PTY and dev server leaks when a worktree is fully parked, as the canonical session may not be closed. It can also cause unintended UI state resets for other active threads in the same worktree.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 48a7020. Configure here.

}
}
return result;
}).pipe(
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/browser/openFileInPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
rememberPreviewUrl,
} from "~/previewStateStore";
import { useRightPanelStore } from "~/rightPanelStore";
import { resolveWorktreeCanonicalThreadRef } from "~/worktreeScope";

export const isBrowserPreviewFile = (path: string): boolean =>
/\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? "");
Expand All @@ -41,9 +42,12 @@ export async function openUrlInPreview<E>(input: {
readonly url: string;
readonly openPreview: OpenPreviewMutation<E>;
}): Promise<AtomCommandResult<void, E>> {
// Preview sessions are worktree-scoped: open through the worktree's
// canonical thread id so sibling threads share one set of tabs.
const canonicalRef = resolveWorktreeCanonicalThreadRef(input.threadRef);
const result = await input.openPreview({
environmentId: input.threadRef.environmentId,
input: { threadId: input.threadRef.threadId, url: input.url },
environmentId: canonicalRef.environmentId,
input: { threadId: canonicalRef.threadId, url: input.url },
});
return mapAtomCommandResult(result, (snapshot) => {
applyPreviewServerSnapshot(input.threadRef, snapshot);
Expand Down
Loading
Loading