diff --git a/packages/cli/__tests__/commands/send.test.ts b/packages/cli/__tests__/commands/send.test.ts index 66b6ff0c0..6d61f5794 100644 --- a/packages/cli/__tests__/commands/send.test.ts +++ b/packages/cli/__tests__/commands/send.test.ts @@ -401,6 +401,39 @@ describe("send command", () => { expect(mockTmux).not.toHaveBeenCalledWith("has-session", "-t", expect.any(String)); }); + it("fails loudly when lifecycle delivery fails for an AO session", async () => { + mockConfigRef.current = makeConfig(); + mockSessionManager.get.mockResolvedValue({ + id: "app-1", + projectId: "my-app", + status: "killed", + activity: "exited", + branch: null, + issueId: null, + pr: null, + workspacePath: null, + runtimeHandle: { id: "tmux-target-1", runtimeName: "tmux", data: {} }, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: { agent: "opencode" }, + }); + mockSessionManager.send.mockRejectedValue( + new Error("Cannot send to session app-1: session is not running (restore timed out)"), + ); + + await expect( + program.parseAsync(["node", "test", "send", "app-1", "hello"]), + ).rejects.toThrow("process.exit(1)"); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Cannot send to session app-1: session is not running"), + ); + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining("Message sent and processing"), + ); + }); + it("passes file contents through SessionManager.send for AO sessions", async () => { mockConfigRef.current = makeConfig(); mockSessionManager.get.mockResolvedValue({ diff --git a/packages/cli/src/commands/send.ts b/packages/cli/src/commands/send.ts index 661cb199b..94efc1a18 100644 --- a/packages/cli/src/commands/send.ts +++ b/packages/cli/src/commands/send.ts @@ -184,8 +184,13 @@ export function registerSend(program: Command): void { } if (existingSession && sessionManager) { - await sessionManager.send(session, message); - console.log(chalk.green("Message sent and processing")); + try { + await sessionManager.send(session, message); + console.log(chalk.green("Message sent and processing")); + } catch (err) { + console.error(chalk.red(err instanceof Error ? err.message : String(err))); + process.exit(1); + } return; } diff --git a/packages/core/src/__tests__/session-manager/communication.test.ts b/packages/core/src/__tests__/session-manager/communication.test.ts index b46e9ec63..ef9ae4ab1 100644 --- a/packages/core/src/__tests__/session-manager/communication.test.ts +++ b/packages/core/src/__tests__/session-manager/communication.test.ts @@ -86,6 +86,43 @@ describe("send", () => { ); }); + it("throws when a killed session cannot be restored to a ready state for delivery", async () => { + vi.useFakeTimers(); + try { + const wsPath = join(tmpDir, "ws-app-1"); + mkdirSync(wsPath, { recursive: true }); + + writeMetadata(sessionsDir, "app-1", { + worktree: wsPath, + branch: "feat/TEST-1", + status: "killed", + project: "my-app", + issue: "TEST-1", + runtimeHandle: JSON.stringify(makeHandle("rt-old")), + }); + + vi.mocked(mockRuntime.isAlive).mockImplementation(async (handle) => handle.id !== "rt-restored"); + vi.mocked(mockAgent.isProcessRunning).mockImplementation( + async (handle) => handle.id !== "rt-restored", + ); + vi.mocked(mockRuntime.create).mockResolvedValue(makeHandle("rt-restored")); + vi.mocked(mockRuntime.getOutput).mockResolvedValue(""); + vi.mocked(mockAgent.detectActivity).mockReturnValue("idle"); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const sendPromise = sm.send("app-1", "hello"); + const rejection = expect(sendPromise).rejects.toThrow( + "Cannot send to session app-1: session is not running", + ); + + await vi.runAllTimersAsync(); + await rejection; + expect(mockRuntime.sendMessage).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it("waits for spawning sessions to become interactive before considering restore", async () => { writeMetadata(sessionsDir, "app-1", { worktree: "/tmp", @@ -137,6 +174,42 @@ describe("send", () => { expect(mockRuntime.sendMessage).toHaveBeenCalled(); }); + it("resolves on restored session when confirmation never flips (soft success)", async () => { + // Regression test: on a restored session (post-#1074 fix), if + // sendMessage fires but the confirmation heuristics never flip, + // send() must still resolve — otherwise the lifecycle-manager's + // dispatch-hash never updates and the message re-sends next poll, + // reintroducing the duplicate-message bug that 77685a5 removed. + const wsPath = join(tmpDir, "ws-app-1"); + mkdirSync(wsPath, { recursive: true }); + + writeMetadata(sessionsDir, "app-1", { + worktree: wsPath, + branch: "feat/TEST-1", + status: "working", + project: "my-app", + issue: "TEST-1", + runtimeHandle: JSON.stringify(makeHandle("rt-old")), + }); + + // rt-old is dead → restore kicks in → rt-restored is ready + vi.mocked(mockRuntime.isAlive).mockImplementation(async (handle) => handle.id !== "rt-old"); + vi.mocked(mockAgent.isProcessRunning).mockImplementation( + async (handle) => handle.id !== "rt-old", + ); + vi.mocked(mockRuntime.create).mockResolvedValue(makeHandle("rt-restored")); + // Steady output — confirmation heuristics will never flip + vi.mocked(mockRuntime.getOutput).mockResolvedValue("steady output"); + vi.mocked(mockAgent.detectActivity).mockReturnValue("idle"); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await expect(sm.send("app-1", "retry after restore")).resolves.toBeUndefined(); + expect(mockRuntime.sendMessage).toHaveBeenCalledWith( + makeHandle("rt-restored"), + "retry after restore", + ); + }); + it("throws for nonexistent session", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); await expect(sm.send("nope", "hello")).rejects.toThrow("not found"); diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 43c021331..29a4b20ad 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -2271,10 +2271,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } }; - const waitForRestoredSession = async (restoredSession: Session): Promise => { + const waitForRestoredSession = async (restoredSession: Session): Promise => { const handle = restoredSession.runtimeHandle; if (!handle) { - return; + return false; } const deadline = Date.now() + SEND_RESTORE_READY_TIMEOUT_MS; @@ -2292,11 +2292,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM foregroundCommand === null || foregroundCommand === agentPlugin.processName; if (runtimeAlive && foregroundReady && (processRunning || output.trim().length > 0)) { - return; + return true; } if (Date.now() >= deadline) { - return; + return false; } await sleep(SEND_RESTORE_READY_POLL_MS); @@ -2310,7 +2310,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM try { const restored = await restore(sessionId); - await waitForRestoredSession(restored); + const ready = await waitForRestoredSession(restored); + if (!ready) { + throw new Error("restored session did not become ready for delivery"); + } return restored; } catch (err) { const detail = err instanceof Error ? err.message : String(err); @@ -2414,7 +2417,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM try { await sendWithConfirmation(prepared); } catch (err) { - const shouldRetryWithRestore = prepared.restoredAt === undefined && isRestorable(prepared); + const shouldRetryWithRestore = + prepared.restoredAt === undefined && isRestorable(prepared); if (!shouldRetryWithRestore) { if (err instanceof Error) {