From 15ebaac45e2286c998776b865414324d0f1a63a1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:46:25 -0400 Subject: [PATCH 1/2] fix: track Linear CLI launches before kickoff readiness --- .../services/chat/agentChatCliLaunch.test.ts | 9 ++-- .../main/services/chat/agentChatCliLaunch.ts | 8 --- .../src/main/services/pty/ptyService.test.ts | 11 +++- .../src/main/services/pty/ptyService.ts | 9 ++++ .../renderer/lib/linearBatchLaunch.test.ts | 52 +++++++++++++++++++ 5 files changed, 76 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatCliLaunch.test.ts b/apps/desktop/src/main/services/chat/agentChatCliLaunch.test.ts index 54fec1498..3877750f6 100644 --- a/apps/desktop/src/main/services/chat/agentChatCliLaunch.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatCliLaunch.test.ts @@ -126,14 +126,15 @@ describe("launchAgentChatCli worktree-path resolution", () => { }); describe("launchAgentChatCli attached issue ids", () => { - it("awaits delayed kickoff input so slow CLI readiness cannot silently drop the prompt", async () => { + it("returns the durable terminal session before delayed kickoff input readiness", async () => { const deps = makeDeps(); - await launchAgentChatCli(makeArgs(), deps); + const result = await launchAgentChatCli(makeArgs(), deps); const createArg = deps.create.mock.calls[0]?.[0] as PtyCreateArgs; expect(createArg.initialInput).toContain("Resolve the attached issue"); - expect(createArg.awaitInitialInput).toBe(true); - expect(createArg.initialInputReadyTimeoutMs).toBe(120_000); + expect(createArg).not.toHaveProperty("awaitInitialInput"); + expect(createArg).not.toHaveProperty("initialInputReadyTimeoutMs"); + expect(result.sessionId).toBe(createArg.sessionId); }); it("returns only well-formed attached issue ids and drops malformed entries", async () => { diff --git a/apps/desktop/src/main/services/chat/agentChatCliLaunch.ts b/apps/desktop/src/main/services/chat/agentChatCliLaunch.ts index 733c0ec84..9a4ce5fb1 100644 --- a/apps/desktop/src/main/services/chat/agentChatCliLaunch.ts +++ b/apps/desktop/src/main/services/chat/agentChatCliLaunch.ts @@ -23,8 +23,6 @@ type LoggerForCliLaunch = { info: (message: string, meta?: Record) => void; }; -const AGENT_CHAT_CLI_KICKOFF_READY_TIMEOUT_MS = 120_000; - export type AgentChatCliLaunchDeps = { laneService: LaneServiceForCliLaunch; ptyService: PtyServiceForCliLaunch; @@ -120,12 +118,6 @@ export async function launchAgentChatCli( ...(launch.initialInputDelayMs !== undefined ? { initialInputDelayMs: launch.initialInputDelayMs } : {}), - ...(launch.initialInput !== undefined - ? { - awaitInitialInput: true, - initialInputReadyTimeoutMs: AGENT_CHAT_CLI_KICKOFF_READY_TIMEOUT_MS, - } - : {}), ...(launch.env ? { env: launch.env } : {}), }); diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index ba2832643..44690268a 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -1084,7 +1084,7 @@ describe("ptyService", () => { it("rejects awaited initialInput when the agent CLI never becomes ready", async () => { vi.useFakeTimers(); try { - const { service, mockPty, logger } = createHarness(); + const { service, mockPty, logger, sessionService } = createHarness(); const pending = service.create({ laneId: "lane-1", @@ -1120,6 +1120,15 @@ describe("ptyService", () => { "pty.initial_input_skipped_not_ready", expect.objectContaining({ provider: "codex" }), ); + expect(logger.warn).toHaveBeenCalledWith( + "pty.initial_input_await_failed_closing", + expect.objectContaining({ toolType: "codex" }), + ); + expect(mockPty.kill).toHaveBeenCalledWith("SIGTERM"); + expect(sessionService.end).toHaveBeenCalledWith(expect.objectContaining({ + exitCode: 1, + status: "failed", + })); } finally { vi.useRealTimers(); } diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 16af1d362..e58e7f80a 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -3857,6 +3857,15 @@ export function createPtyService({ if (initialInputDelayMs > 0) await delay(initialInputDelayMs); await writeInitialInput(); } catch (err) { + logger.warn("pty.initial_input_await_failed_closing", { + ptyId, + sessionId, + cwd, + toolType: toolTypeHint, + err: String(err), + }); + terminatePtyProcessTree(entry, "SIGTERM", logger); + closeEntry(ptyId, 1); throw err; } } else if (initialInputDelayMs > 0) { diff --git a/apps/desktop/src/renderer/lib/linearBatchLaunch.test.ts b/apps/desktop/src/renderer/lib/linearBatchLaunch.test.ts index e56c6c1d6..681edb49c 100644 --- a/apps/desktop/src/renderer/lib/linearBatchLaunch.test.ts +++ b/apps/desktop/src/renderer/lib/linearBatchLaunch.test.ts @@ -315,6 +315,58 @@ describe("runBatchLaunch", () => { expect(result.createdSessionIds).toEqual(["cli-1"]); }); + it("records every concurrent delayed CLI launch by its returned terminal session id", async () => { + type LaunchCliArgs = Parameters>[0]; + const createDeferred = () => { + let resolve!: (value: { sessionId: string }) => void; + const promise = new Promise<{ sessionId: string }>((done) => { + resolve = done; + }); + return { promise, resolve }; + }; + const deferredByIssue = new Map([ + ["a", createDeferred()], + ["b", createDeferred()], + ["c", createDeferred()], + ]); + const createLane = vi.fn(async (args: { linearIssue: { id: string } }) => ({ id: `lane-${args.linearIssue.id}` })); + const launch = vi.fn(async () => ({ id: "chat-sess" })); + const launchCli = vi.fn(async (args: LaunchCliArgs) => { + const issueId = args.linearIssues[0]?.id; + const deferred = issueId ? deferredByIssue.get(issueId) : null; + if (!deferred) throw new Error(`missing deferred for ${issueId ?? "unknown issue"}`); + return deferred.promise; + }); + const onItem = vi.fn(); + const entries = ["a", "b", "c"].map((id) => ({ + issue: makeIssue({ id, identifier: `ENG-${id.toUpperCase()}` }), + config: makeConfig({ sessionType: "cli" }), + })); + + const launched = runBatchLaunch( + entries, + { createLane, launch, launchCli }, + { onItem, concurrency: 3 }, + ); + for (let i = 0; i < 5 && launchCli.mock.calls.length < 3; i += 1) { + await Promise.resolve(); + } + expect(launchCli).toHaveBeenCalledTimes(3); + + deferredByIssue.get("b")?.resolve({ sessionId: "cli-b" }); + deferredByIssue.get("c")?.resolve({ sessionId: "cli-c" }); + deferredByIssue.get("a")?.resolve({ sessionId: "cli-a" }); + + const result = await launched; + expect(launch).not.toHaveBeenCalled(); + expect(result.failedIssueIds).toHaveLength(0); + expect(result.createdSessionIds).toEqual(expect.arrayContaining(["cli-a", "cli-b", "cli-c"])); + expect(result.createdSessionIds).toHaveLength(3); + expect(onItem).toHaveBeenCalledWith("a", expect.objectContaining({ sessionId: "cli-a", status: "done" })); + expect(onItem).toHaveBeenCalledWith("b", expect.objectContaining({ sessionId: "cli-b", status: "done" })); + expect(onItem).toHaveBeenCalledWith("c", expect.objectContaining({ sessionId: "cli-c", status: "done" })); + }); + it("launches into an existing lane without creating or rolling one back", async () => { const createLane = vi.fn(async () => ({ id: "lane-new" })); const launch = vi.fn(async (args: { laneId: string }) => { From 05dec451f9751ca915c6dfd464c85a8f45967f8d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:01:10 -0400 Subject: [PATCH 2/2] test: finalize Linear CLI launch coverage --- .../src/renderer/lib/linearBatchLaunch.test.ts | 11 ++++++++--- .../terminals-and-sessions/pty-and-processes.md | 5 ++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/lib/linearBatchLaunch.test.ts b/apps/desktop/src/renderer/lib/linearBatchLaunch.test.ts index 681edb49c..5b33dc51e 100644 --- a/apps/desktop/src/renderer/lib/linearBatchLaunch.test.ts +++ b/apps/desktop/src/renderer/lib/linearBatchLaunch.test.ts @@ -329,12 +329,19 @@ describe("runBatchLaunch", () => { ["b", createDeferred()], ["c", createDeferred()], ]); + let resolveAllLaunchesStarted!: () => void; + const allLaunchesStarted = new Promise((resolve) => { + resolveAllLaunchesStarted = resolve; + }); const createLane = vi.fn(async (args: { linearIssue: { id: string } }) => ({ id: `lane-${args.linearIssue.id}` })); const launch = vi.fn(async () => ({ id: "chat-sess" })); const launchCli = vi.fn(async (args: LaunchCliArgs) => { const issueId = args.linearIssues[0]?.id; const deferred = issueId ? deferredByIssue.get(issueId) : null; if (!deferred) throw new Error(`missing deferred for ${issueId ?? "unknown issue"}`); + if (launchCli.mock.calls.length === deferredByIssue.size) { + resolveAllLaunchesStarted(); + } return deferred.promise; }); const onItem = vi.fn(); @@ -348,9 +355,7 @@ describe("runBatchLaunch", () => { { createLane, launch, launchCli }, { onItem, concurrency: 3 }, ); - for (let i = 0; i < 5 && launchCli.mock.calls.length < 3; i += 1) { - await Promise.resolve(); - } + await allLaunchesStarted; expect(launchCli).toHaveBeenCalledTimes(3); deferredByIssue.get("b")?.resolve({ sessionId: "cli-b" }); diff --git a/docs/features/terminals-and-sessions/pty-and-processes.md b/docs/features/terminals-and-sessions/pty-and-processes.md index af7ff17a4..b7fc44f45 100644 --- a/docs/features/terminals-and-sessions/pty-and-processes.md +++ b/docs/features/terminals-and-sessions/pty-and-processes.md @@ -240,7 +240,10 @@ Each live PTY has an entry in the `ptys` map keyed by `ptyId` with: was disposed in the meantime. When `awaitInitialInput` is false, a readiness/write failure is logged and the PTY is preserved; ADE no longer kills or ends the session just because the first input could - not be delivered. Returns `{ ptyId, sessionId, pid }`. + not be delivered. When a caller explicitly sets `awaitInitialInput`, + readiness/write failure is treated as startup failure: the process + tree is terminated and the session is ended as `failed`. Returns + `{ ptyId, sessionId, pid }`. The launch env is built layer by layer: `process.env`, the lane runtime env (from `getLaneRuntimeEnv`), the caller's `args.env`, then