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
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
8 changes: 0 additions & 8 deletions apps/desktop/src/main/services/chat/agentChatCliLaunch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ type LoggerForCliLaunch = {
info: (message: string, meta?: Record<string, unknown>) => void;
};

const AGENT_CHAT_CLI_KICKOFF_READY_TIMEOUT_MS = 120_000;

export type AgentChatCliLaunchDeps = {
laneService: LaneServiceForCliLaunch;
ptyService: PtyServiceForCliLaunch;
Expand Down Expand Up @@ -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 } : {}),
});

Expand Down
11 changes: 10 additions & 1 deletion apps/desktop/src/main/services/pty/ptyService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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();
}
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/main/services/pty/ptyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
57 changes: 57 additions & 0 deletions apps/desktop/src/renderer/lib/linearBatchLaunch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,63 @@ 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<NonNullable<BatchLaunchDeps["launchCli"]>>[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()],
]);
let resolveAllLaunchesStarted!: () => void;
const allLaunchesStarted = new Promise<void>((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();
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 },
);
await allLaunchesStarted;
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" }));
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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 }) => {
Expand Down
5 changes: 4 additions & 1 deletion docs/features/terminals-and-sessions/pty-and-processes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading