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
84 changes: 84 additions & 0 deletions apps/desktop/src/main/services/processes/processService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1000,6 +1000,90 @@ describe("processService PTY-backed run commands", () => {
}
});

it("writes PTY startup failures into the run transcript", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-process-start-failure-"));
const dbPath = path.join(tmpDir, "kv.sqlite");
const projectId = "proj-start-failure";
const logger = createLogger();
const db = await openKvDb(dbPath, createLogger());
const now = "2026-03-24T12:00:00.000Z";
const sessionStore = new Map<string, { id: string; transcriptPath: string }>();
const dataListeners = new Set<(event: { laneId: string; ptyId: string; sessionId: string; data: string }) => void>();
const exitListeners = new Set<(event: { laneId: string; ptyId: string; sessionId: string; exitCode: number | null }) => void>();

db.run(
"insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)",
[projectId, tmpDir, "test", "main", now, now],
);
db.run(
`insert into lanes(
id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path,
attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
["lane-fail", projectId, "Lane Fail", null, "worktree", "main", "feature/fail", tmpDir, null, 0, null, null, null, null, "active", now, null],
);

const config = makeMinimalConfig([
{ id: "bad-command", command: ["./missing-script.sh", "dev"], cwd: "." },
]);
const ptyService = {
create: vi.fn(async (args: any) => {
const transcriptPath = path.join(tmpDir, ".ade", "transcripts", `${args.sessionId}.log`);
fs.mkdirSync(path.dirname(transcriptPath), { recursive: true });
fs.writeFileSync(transcriptPath, "", "utf8");
sessionStore.set(args.sessionId, { id: args.sessionId, transcriptPath });
throw new Error("spawn ./missing-script.sh ENOENT");
}),
dispose: vi.fn(),
onData: vi.fn((listener: (event: { laneId: string; ptyId: string; sessionId: string; data: string }) => void) => {
dataListeners.add(listener);
return () => dataListeners.delete(listener);
}),
onExit: vi.fn((listener: (event: { laneId: string; ptyId: string; sessionId: string; exitCode: number | null }) => void) => {
exitListeners.add(listener);
return () => exitListeners.delete(listener);
}),
} as any;
const service = createProcessService({
db,
projectId,
logger,
laneService: {
getLaneWorktreePath: () => tmpDir,
list: async () => [makeLaneSummary(tmpDir, "lane-fail")],
} as any,
projectConfigService: {
get: () => config,
getEffective: () => config.effective,
getExecutableConfig: () => config.effective,
} as any,
sessionService: {
get: vi.fn((sessionId: string) => sessionStore.get(sessionId) ?? null),
} as any,
ptyService,
broadcastEvent: () => {},
});

try {
await expect(service.start({ laneId: "lane-fail", processId: "bad-command" })).rejects.toThrow("ENOENT");
const runtime = service.listRuntime("lane-fail")[0]!;
expect(runtime.status).toBe("crashed");
const tail = service.getLogTail({
laneId: "lane-fail",
processId: "bad-command",
runId: runtime.runId,
});
expect(tail).toContain("failed to start");
expect(tail).toContain("./missing-script.sh dev");
expect(tail).toContain(`[ADE] Working directory: ${fs.realpathSync(tmpDir)}`);
expect(tail).toContain("spawn ./missing-script.sh ENOENT");
} finally {
service.disposeAll();
db.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});

it("getLogTail({ runId }) returns only the specified run's transcript", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-process-logtail-"));
const dbPath = path.join(tmpDir, "kv.sqlite");
Expand Down
24 changes: 22 additions & 2 deletions apps/desktop/src/main/services/processes/processService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,9 +536,10 @@ export function createProcessService({
const handleStartFailure = (args: {
entry: ManagedProcessEntry;
startedAt: string;
cwd: string;
error: unknown;
}) => {
const { entry, startedAt, error } = args;
const { entry, startedAt, cwd, error } = args;
const endedAt = nowIso();
if (entry.sessionId) sessionToRunId.delete(entry.sessionId);
if (entry.ptyId) ptyToRunId.delete(entry.ptyId);
Expand All @@ -555,6 +556,25 @@ export function createProcessService({
entry.runtime.exitCode = null;
entry.runtime.lastExitCode = null;
entry.runtime.logPath = entry.transcriptPath;
if (entry.transcriptPath) {
try {
fs.mkdirSync(path.dirname(entry.transcriptPath), { recursive: true });
fs.appendFileSync(
entry.transcriptPath,
[
"",
`[ADE] Process '${entry.definition.name || entry.definition.id}' failed to start.`,
`[ADE] Command: ${entry.definition.command.join(" ")}`,
`[ADE] Working directory: ${cwd}`,
`[ADE] Error: ${error instanceof Error ? error.message : String(error)}`,
"",
].join("\n"),
"utf8",
);
} catch {
// Best-effort: the renderer still receives the thrown startup error.
}
}
emitRuntime(entry);

upsertRunStart(entry.runId, entry.laneId, entry.processId, startedAt, entry.transcriptPath ?? "");
Expand Down Expand Up @@ -666,7 +686,7 @@ export function createProcessService({
});
return cloneRuntime(entry.runtime);
} catch (error) {
handleStartFailure({ entry, startedAt, error });
handleStartFailure({ entry, startedAt, cwd, error });
throw error;
}
};
Expand Down
31 changes: 31 additions & 0 deletions apps/desktop/src/main/services/pty/ptyService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,37 @@ describe("ptyService", () => {
expect(mockPty.write).toHaveBeenCalledWith("codex --no-alt-screen \"ADE session guidance\"\r");
});

it("falls back to a shell exec command when direct command spawn fails before a terminal attaches", async () => {
setPlatform("darwin");
const { service, mockPty, loadPty } = createHarness();
const spawn = vi.fn((command: string) => {
if (command === "./scripts/dogfood.sh") throw new Error("ENOENT");
return mockPty;
});
loadPty.mockImplementationOnce(() => ({ spawn: spawn as any }));

await service.create({
laneId: "lane-1",
title: "Run command",
cols: 80,
rows: 24,
command: "./scripts/dogfood.sh",
args: ["onboarding fixes", "quote's ok"],
});

expect(spawn).toHaveBeenCalledWith(
"./scripts/dogfood.sh",
["onboarding fixes", "quote's ok"],
expect.any(Object),
);
expect(spawn).toHaveBeenCalledWith(
expect.stringMatching(/(?:zsh|bash|sh)$/),
expect.any(Array),
expect.any(Object),
);
expect(mockPty.write).toHaveBeenCalledWith("exec ./scripts/dogfood.sh 'onboarding fixes' 'quote'\\''s ok'\r");
});

it("wraps direct Windows command shims through cmd.exe", async () => {
setPlatform("win32");
const harness = createHarness();
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/main/services/pty/ptyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,17 @@ function resolveShellCandidates(): ShellSpec[] {
return uniq.map((file) => ({ file, args: [] }));
}

function quotePosixShellArg(value: string): string {
if (!value.length) return "''";
if (/^[a-zA-Z0-9_./:@%+=,-]+$/.test(value)) return value;
return `'${value.replace(/'/g, "'\\''")}'`;
}

function buildDirectCommandShellFallback(command: string, args: string[]): string | null {
if (process.platform === "win32") return null;
return ["exec", command, ...args].map(quotePosixShellArg).join(" ");
}

function clampDims(cols: number, rows: number): { cols: number; rows: number } {
const safeCols = Number.isFinite(cols) ? Math.max(20, Math.min(400, Math.floor(cols))) : 80;
const safeRows = Number.isFinite(rows) ? Math.max(6, Math.min(200, Math.floor(rows))) : 24;
Expand Down Expand Up @@ -1568,6 +1579,8 @@ export function createPtyService({
launchedDirectCommand = true;
} catch (err) {
lastErr = err;
const shellFallbackCmd = buildDirectCommandShellFallback(directCommand, directArgs);
if (shellFallbackCmd) startupCommand ||= shellFallbackCmd;
}
}
if (!created && (!directCommand || startupCommand)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ type ChatTerminalDrawerProps = {
onToggle: () => void;
laneId: string;
chatSessionId?: string | null;
autoCreateOnOpen?: boolean;
createRequestNonce?: number;
disposeTabsOnUnmount?: boolean;
emptyMessage?: string;
onCreateError?: (message: string) => void;
revealRequest?: {
terminalId: string;
ptyId: string;
Expand Down Expand Up @@ -125,6 +130,11 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({
onToggle,
laneId,
chatSessionId,
autoCreateOnOpen = true,
createRequestNonce = 0,
disposeTabsOnUnmount = false,
emptyMessage = "Create a terminal to start working in this chat.",
onCreateError,
revealRequest,
}: ChatTerminalDrawerProps) {
const uiStateKey = drawerStateKey(chatSessionId, laneId);
Expand All @@ -140,6 +150,9 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({
const pendingAutoCreateRef = useRef(false);
const tabsRef = useRef<TabEntry[]>([]);
const restoringUiStateRef = useRef(false);
const lastHandledCreateRequestRef = useRef(0);
const createRequestHandledThisOpenRef = useRef(false);
const revealHandledThisOpenRef = useRef(false);
// revealRequest is edge-triggered (the parent re-uses the same prop slot
// across renders). Track the (chatKey, nonce) we've already applied so a
// stale request from a previous chat doesn't keep blocking the new chat's
Expand All @@ -148,6 +161,11 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({

tabsRef.current = tabs;

const reportCreateError = useCallback((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
onCreateError?.(message);
}, [onCreateError]);

useEffect(() => {
restoringUiStateRef.current = true;
previousOpenRef.current = open;
Expand Down Expand Up @@ -220,10 +238,12 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({

setTabs((prev) => [...prev, nextEntry]);
setActiveTabId(tabId);
} catch (error) {
reportCreateError(error);
} finally {
setCreatingTab(false);
}
}, [chatSessionId, creatingTab, laneId]);
}, [chatSessionId, creatingTab, laneId, reportCreateError]);

useEffect(() => {
if (!chatSessionId) return;
Expand Down Expand Up @@ -278,6 +298,7 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({
);
if (existing) {
setActiveTabId(existing.id);
revealHandledThisOpenRef.current = true;
return;
}
const tabId = `chat-term-${revealRequest.terminalId}`;
Expand All @@ -290,8 +311,17 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({
};
setTabs((prev) => [...prev, nextEntry]);
setActiveTabId(tabId);
revealHandledThisOpenRef.current = true;
}, [revealRequest, uiStateKey]);

useEffect(() => {
if (!open || creatingTab || createRequestNonce <= 0 || lastHandledCreateRequestRef.current === createRequestNonce) return;
lastHandledCreateRequestRef.current = createRequestNonce;
pendingAutoCreateRef.current = false;
createRequestHandledThisOpenRef.current = true;
void createTab();
Comment thread
arul28 marked this conversation as resolved.
}, [createRequestNonce, createTab, creatingTab, open]);

useEffect(() => {
const wasOpen = previousOpenRef.current;
previousOpenRef.current = open;
Expand All @@ -302,21 +332,31 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({
}

if (!wasOpen) pendingAutoCreateRef.current = true;
if (createRequestHandledThisOpenRef.current) {
createRequestHandledThisOpenRef.current = false;
pendingAutoCreateRef.current = false;
return;
}
if (revealHandledThisOpenRef.current) {
revealHandledThisOpenRef.current = false;
pendingAutoCreateRef.current = false;
return;
}
// Treat already-consumed reveal requests as null so switching chats with
// a stale revealRequest in props doesn't block auto-create on the new
// drawer.
const last = lastHandledRevealRef.current;
const revealActive = revealRequest != null
&& !(last && last.chatKey === uiStateKey && last.nonce === revealRequest.nonce);
if (revealActive || tabs.length > 0) {
if (!autoCreateOnOpen || revealActive || tabs.length > 0) {
pendingAutoCreateRef.current = false;
return;
}
if (!pendingAutoCreateRef.current || creatingTab || restoringTabs) return;

pendingAutoCreateRef.current = false;
void createTab();
}, [createTab, creatingTab, open, restoringTabs, revealRequest, tabs.length, uiStateKey]);
}, [autoCreateOnOpen, createTab, creatingTab, open, restoringTabs, revealRequest, tabs.length, uiStateKey]);

useEffect(() => {
if (tabs.length > 0) {
Expand All @@ -329,7 +369,9 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({
}, [creatingTab, onToggle, open, tabs.length]);

useEffect(() => {
const unsubscribe = window.ade.pty.onExit((ev: PtyExitEvent) => {
const ptyBridge = window.ade?.pty;
if (!ptyBridge?.onExit) return undefined;
const unsubscribe = ptyBridge.onExit((ev: PtyExitEvent) => {
setTabs((prev) => prev.map((tab) => (
tab.ptyId === ev.ptyId
? { ...tab, exited: true }
Expand All @@ -339,6 +381,13 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({
return unsubscribe;
}, []);

useEffect(() => () => {
if (!disposeTabsOnUnmount) return;
for (const tab of tabsRef.current) {
window.ade.pty.dispose({ ptyId: tab.ptyId, sessionId: tab.sessionId }).catch(() => {});
}
}, [disposeTabsOnUnmount]);

// Drop drawer tabs when their session is deleted from the sidebar so the user
// can't keep working in a shell whose backing session no longer exists.
useEffect(() => {
Expand Down Expand Up @@ -513,7 +562,7 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({
/>
) : (
<div className="flex h-full items-center justify-center px-4 font-mono text-[11px] text-muted-fg">
Create a terminal to start working in this chat.
{emptyMessage}
</div>
)}
</div>
Expand Down
Loading
Loading