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
57 changes: 56 additions & 1 deletion apps/server/src/terminal/Layers/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
type PtySpawnInput,
PtySpawnError,
} from "../Services/PTY.ts";
import { makeTerminalManagerWithOptions } from "./Manager.ts";
import { __testing, makeTerminalManagerWithOptions } from "./Manager.ts";

class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{
readonly message: string;
Expand Down Expand Up @@ -835,6 +835,61 @@ it.layer(
}),
);

it("treats an idle POSIX login shell child as no running subprocess", () => {
expect(
__testing.inspectPosixProcessTree({
terminalPid: 9000,
childPid: 9001,
childCommand: "bash",
platform: "darwin",
processTable: [
{ pid: 9000, ppid: 1, command: "node-pty" },
{ pid: 9001, ppid: 9000, command: "bash" },
],
}),
).toEqual({ hasRunningSubprocess: false, childCommand: null, processIds: [] });
});

it("reports the first non-shell POSIX descendant as the running subprocess", () => {
expect(
__testing.inspectPosixProcessTree({
terminalPid: 9000,
childPid: 9001,
childCommand: "bash",
platform: "darwin",
processTable: [
{ pid: 9000, ppid: 1, command: "node-pty" },
{ pid: 9001, ppid: 9000, command: "bash" },
{ pid: 9002, ppid: 9001, command: "pnpm" },
{ pid: 9003, ppid: 9002, command: "node" },
],
}),
).toEqual({
hasRunningSubprocess: true,
childCommand: "pnpm",
processIds: [9000, 9001, 9002, 9003],
});
});

it("keeps a non-shell direct POSIX child marked as running", () => {
expect(
__testing.inspectPosixProcessTree({
terminalPid: 9000,
childPid: 9001,
childCommand: "vim",
platform: "darwin",
processTable: [
{ pid: 9000, ppid: 1, command: "node-pty" },
{ pid: 9001, ppid: 9000, command: "vim" },
],
}),
).toEqual({
hasRunningSubprocess: true,
childCommand: "vim",
processIds: [9000, 9001],
});
});

it.effect("does not invoke subprocess polling until a terminal session is running", () =>
Effect.gen(function* () {
let checks = 0;
Expand Down
119 changes: 99 additions & 20 deletions apps/server/src/terminal/Layers/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ const DEFAULT_OPEN_ROWS = 30;
const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
const MAX_TERMINAL_LABEL_LENGTH = 128;
const INTERACTIVE_SHELL_COMMANDS = new Set([
"bash",
"csh",
"fish",
"powershell",
"pwsh",
"sh",
"tcsh",
"zsh",
]);

class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()(
"TerminalSubprocessCheckError",
Expand Down Expand Up @@ -191,6 +201,75 @@ function normalizeChildCommandName(raw: string, platform: NodeJS.Platform): stri
return withoutExe.length > 0 ? withoutExe : null;
}

function isInteractiveShellCommand(command: string | null): boolean {
return command !== null && INTERACTIVE_SHELL_COMMANDS.has(command.trim().toLowerCase());
}

interface ProcessTableEntry {
readonly pid: number;
readonly ppid: number;
readonly command: string | null;
}

function inspectPosixProcessTree(input: {
readonly terminalPid: number;
readonly childPid: number;
readonly childCommand: string | null;
readonly platform: NodeJS.Platform;
readonly processTable: ReadonlyArray<ProcessTableEntry>;
}): TerminalSubprocessInspectResult {
const childrenByParent = new Map<number, number[]>();
const commandByPid = new Map<number, string>();
for (const entry of input.processTable) {
const children = childrenByParent.get(entry.ppid) ?? [];
children.push(entry.pid);
childrenByParent.set(entry.ppid, children);
if (entry.command !== null && entry.command.trim().length > 0) {
commandByPid.set(entry.pid, entry.command);
}
}

const processIds = new Set<number>([input.terminalPid]);
const pending = [input.terminalPid];
while (pending.length > 0) {
const parentPid = pending.pop();
if (parentPid === undefined) continue;
for (const child of childrenByParent.get(parentPid) ?? []) {
if (processIds.has(child)) continue;
processIds.add(child);
pending.push(child);
}
}

let normalized = input.childCommand;
if (isInteractiveShellCommand(normalized)) {
normalized = null;
const descendantPending = [...(childrenByParent.get(input.childPid) ?? [])];
while (descendantPending.length > 0) {
const pid = descendantPending.shift();
if (pid === undefined) continue;
const descendantCommand = normalizeChildCommandName(
commandByPid.get(pid) ?? "",
input.platform,
);
if (descendantCommand !== null && !isInteractiveShellCommand(descendantCommand)) {
normalized = descendantCommand;
break;
}
descendantPending.push(...(childrenByParent.get(pid) ?? []));
}
if (normalized === null) {
return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
}
}

return {
hasRunningSubprocess: true,
childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
processIds: [...processIds],
};
}

function terminalWireLabel(session: TerminalSessionState): string {
if (session.hasRunningSubprocess && session.childCommandLabel) {
const trimmed = session.childCommandLabel.trim();
Expand Down Expand Up @@ -606,7 +685,7 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func
const runPs = processRunner
.run({
command: "ps",
args: ["-eo", "pid=,ppid="],
args: ["-eo", "pid=,ppid=,comm="],
timeout: "1 second",
maxOutputBytes: 262_144,
outputMode: "truncate",
Expand Down Expand Up @@ -688,36 +767,32 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func
}

const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null;
const processIds = new Set<number>([terminalPid]);
const psResult = yield* Effect.exit(runPs);
if (psResult._tag === "Success" && psResult.value.code === 0) {
const childrenByParent = new Map<number, number[]>();
const processTable: ProcessTableEntry[] = [];
for (const line of psResult.value.stdout.split(/\r?\n/g)) {
const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
const [pidRaw, ppidRaw, ...commandParts] = line.trim().split(/\s+/g);
const pid = Number(pidRaw);
const ppid = Number(ppidRaw);
if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
const children = childrenByParent.get(ppid) ?? [];
children.push(pid);
childrenByParent.set(ppid, children);
}
const pending = [terminalPid];
while (pending.length > 0) {
const parentPid = pending.pop();
if (parentPid === undefined) continue;
for (const child of childrenByParent.get(parentPid) ?? []) {
if (processIds.has(child)) continue;
processIds.add(child);
pending.push(child);
}
const command = commandParts.join(" ").trim();
processTable.push({ pid, ppid, command: command.length > 0 ? command : null });
}
} else {
processIds.add(childPid);
return inspectPosixProcessTree({
terminalPid,
childPid,
childCommand: normalized,
platform,
processTable,
});
}
if (isInteractiveShellCommand(normalized)) {
return { hasRunningSubprocess: false, childCommand: null, processIds: [] };

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.

PS failure misreports busy shell

High Severity

When the full ps process-table pass fails after the direct PTY child is classified as an interactive shell, POSIX subprocess inspection returns idle (hasRunningSubprocess: false) without walking descendants. A command still running under that shell can be missed, so the UI may treat the action terminal as free and send a project script while work is still active.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 777cece. Configure here.

}
return {
hasRunningSubprocess: true,
childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
processIds: [...processIds],
processIds: [terminalPid, childPid],
};
});

Expand Down Expand Up @@ -2486,3 +2561,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
export const TerminalManagerLive = Layer.effect(TerminalManager, makeTerminalManager()).pipe(
Layer.provide(ProcessRunner.layer),
);

export const __testing = {
inspectPosixProcessTree,
};
91 changes: 63 additions & 28 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ import {
import {
DEFAULT_INTERACTION_MODE,
DEFAULT_RUNTIME_MODE,
DEFAULT_THREAD_TERMINAL_ID,
MAX_TERMINALS_PER_GROUP,
type ChatMessage,
type SessionPhase,
Expand Down Expand Up @@ -136,6 +135,10 @@ import {
nextProjectScriptId,
projectScriptIdFromCommand,
} from "~/projectScripts";
import {
resolveProjectActionTerminalId,
terminalSessionIsReadyForProjectActionInput,
} from "~/projectScriptTerminals";
import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
import { useSettings } from "../hooks/useSettings";
Expand Down Expand Up @@ -177,6 +180,7 @@ import {
serverEnvironment,
} from "../state/server";
import { terminalEnvironment } from "../state/terminal";
import { projectActionTerminalEnvironment } from "../state/projectActionTerminal";
import { threadEnvironment } from "../state/threads";
import { vcsEnvironment } from "../state/vcs";
import { useEnvironments, usePrimaryEnvironment } from "../state/environments";
Expand Down Expand Up @@ -986,6 +990,10 @@ function ChatViewContent(props: ChatViewProps) {
});
const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
const waitForProjectActionTerminalReady = useAtomCommand(
projectActionTerminalEnvironment.waitForInputReady,
"project action terminal wait for input ready",
);
const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close");
const createThread = useAtomCommand(threadEnvironment.create, { reportFailure: false });
const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false });
Expand Down Expand Up @@ -2437,11 +2445,6 @@ function ChatViewContent(props: ChatViewProps) {
});
}
const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot;
const baseTerminalId =
terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID;
const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId);
const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;
const shouldCreateNewTerminal = wantsNewTerminal;
const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null;

setTerminalUiLaunchContext({
Expand All @@ -2462,28 +2465,50 @@ function ChatViewContent(props: ChatViewProps) {
worktreePath: targetWorktreePath,
...(options?.env ? { extraEnv: options.env } : {}),
});
const targetTerminalId = shouldCreateNewTerminal
? nextTerminalId(activeKnownTerminalIds)
: baseTerminalId;
const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal
? {
threadId: activeThreadId,
terminalId: targetTerminalId,
cwd: targetCwd,
...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}),
env: runtimeEnv,
cols: SCRIPT_TERMINAL_COLS,
rows: SCRIPT_TERMINAL_ROWS,
}
: {
threadId: activeThreadId,
terminalId: targetTerminalId,
cwd: targetCwd,
...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}),
env: runtimeEnv,
};
const reusableTerminalById = new Map(
activeThreadKnownSessions.map((session) => [session.target.terminalId, session] as const),
);
const effectiveRunningTerminalIds = runningTerminalIds.filter((terminalId) => {
const session = reusableTerminalById.get(terminalId);
if (!session) {
return true;
}
return !terminalSessionIsReadyForProjectActionInput({
summary: session.state.summary,
buffer: session.state.buffer,
targetCwd,
targetWorktreePath,
});
});
const targetTerminalId =
options?.preferNewTerminal === true
? nextTerminalId(activeKnownTerminalIds)
: resolveProjectActionTerminalId({
scriptId: script.id,
terminalIds: activeKnownTerminalIds,
runningTerminalIds: effectiveRunningTerminalIds,
});
const isKnownServerTerminal = activeServerOrderedTerminalIds.includes(targetTerminalId);
const isVisibleTerminal = terminalUiState.terminalIds.includes(targetTerminalId);
const targetSession = reusableTerminalById.get(targetTerminalId) ?? null;
const canWriteImmediately = terminalSessionIsReadyForProjectActionInput({
summary: targetSession?.state.summary ?? null,
buffer: targetSession?.state.buffer ?? "",
targetCwd,
targetWorktreePath,
});
const openTerminalInput: TerminalOpenInput = {
threadId: activeThreadId,
terminalId: targetTerminalId,
cwd: targetCwd,
...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}),
env: runtimeEnv,
...(!isKnownServerTerminal
? { cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS }
: {}),
};

if (shouldCreateNewTerminal) {
if (!isVisibleTerminal) {
storeNewTerminal(activeThreadRef, targetTerminalId);
} else {
storeSetActiveTerminal(activeThreadRef, targetTerminalId);
Expand All @@ -2501,6 +2526,13 @@ function ChatViewContent(props: ChatViewProps) {
return;
}

if (!canWriteImmediately) {
await waitForProjectActionTerminalReady({
environmentId,
input: openTerminalInput,
});
}

const writeResult = await writeTerminal({
environmentId,
input: {
Expand Down Expand Up @@ -2528,11 +2560,14 @@ function ChatViewContent(props: ChatViewProps) {
storeNewTerminal,
storeSetActiveTerminal,
setLastInvokedScriptByProjectId,
activeThreadKnownSessions,
environmentId,
openTerminal,
activeKnownTerminalIds,
activeServerOrderedTerminalIds,
runningTerminalIds,
terminalUiState.activeTerminalId,
terminalUiState.terminalIds,
waitForProjectActionTerminalReady,
writeTerminal,
],
);
Expand Down
Loading
Loading