diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index ccf29b17b0..41f784349e 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -7317,6 +7317,7 @@ test("ignores stale autonomous terminals without lowering the active turn lifecy const running = manager.getAgent(snapshot.id); expect(running?.lifecycle).toBe("running"); expect(running ? toAgentPayload(running).activeTurn?.turnId : null).toBe("turn-b"); + expect(running?.lastError).toBeUndefined(); }); const active = manager.getAgent(snapshot.id)!; @@ -7359,7 +7360,7 @@ test("ignores stale autonomous terminals without lowering the active turn lifecy const stillRunning = manager.getAgent(snapshot.id); expect(stillRunning?.lifecycle).toBe("running"); expect(stillRunning ? toAgentPayload(stillRunning).activeTurn?.turnId : null).toBe("turn-b"); - expect(stillRunning?.lastError).toBe("turn-b marker"); + expect(stillRunning?.lastError).toBeUndefined(); expect(stillRunning?.lastUsage).toEqual({ inputTokens: 7 }); expect(stillRunning?.pendingPermissions.has("turn-b-permission")).toBe(true); expect(manager.getTimeline(snapshot.id)).toEqual(timelineBeforeStaleTerminals); @@ -8683,6 +8684,118 @@ test("clearAgentAttention on errored agent stays cleared until a new error trans expect(persistedAfterSecondFailure?.attentionReason).toBe("error"); }); +test.each([ + ["autonomous", "turn_completed", "idle", "finished", undefined], + ["autonomous", "turn_failed", "error", "error", "Retry failed."], + ["foreground", "turn_completed", "idle", "finished", undefined], + ["foreground", "turn_failed", "error", "error", "Retry failed."], +] as const)( + "%s recovery clears error attention before %s", + async (mode, terminal, status, expectedReason, lastError) => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-recovery-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + class RecoverySession extends TestAgentSession { + override async startTurn(): Promise<{ turnId: string }> { + return { turnId: "recovered-turn" }; + } + } + + class RecoveryClient extends TestAgentClient { + readonly session = new RecoverySession({ provider: "codex", cwd: workdir }); + + override async createSession(): Promise { + return this.session; + } + } + + const client = new RecoveryClient(); + const attentionReasons: Array<"finished" | "error" | "permission"> = []; + const manager = new AgentManager({ + clients: { codex: client }, + registry: storage, + logger, + onAgentAttention: ({ reason }) => attentionReasons.push(reason), + }); + onTestFinished(async () => { + await manager.closeAgent(agent.id); + await manager.flush(); + rmSync(workdir, { recursive: true, force: true }); + }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + + const running = waitForAgentLifecycle(manager, agent.id, "running"); + client.session.pushEvent({ type: "turn_started", provider: "codex", turnId: "failed-turn" }); + await running; + const failed = waitForAgentLifecycle(manager, agent.id, "error"); + client.session.pushEvent({ + type: "turn_failed", + provider: "codex", + turnId: "failed-turn", + error: "Selected model is at capacity.", + }); + await failed; + await vi.waitFor(() => expect(manager.hasInFlightRun(agent.id)).toBe(false)); + expect(toAgentPayload(manager.getAgent(agent.id)!)).toMatchObject({ + status: "error", + attentionReason: "error", + lastError: "Selected model is at capacity.", + }); + + const recovered = waitForAgentLifecycle(manager, agent.id, "running"); + const stream = + mode === "foreground" + ? drainAsyncGenerator(manager.streamAgent(agent.id, "Continue after capacity recovers.")) + : null; + if (mode === "autonomous") { + client.session.pushEvent({ + type: "turn_started", + provider: "codex", + turnId: "recovered-turn", + }); + } + await recovered; + await manager.flush(); + expect(toAgentPayload(manager.getAgent(agent.id)!)).toMatchObject({ + status: "running", + requiresAttention: false, + attentionReason: null, + }); + expect(manager.getAgent(agent.id)?.lastError).toBeUndefined(); + expect(await storage.get(agent.id)).toMatchObject({ + lastStatus: "running", + requiresAttention: false, + attentionReason: null, + }); + expect((await storage.get(agent.id))?.lastError).toBeUndefined(); + + const settled = waitForAgentLifecycle(manager, agent.id, status); + client.session.pushEvent({ + type: terminal, + provider: "codex", + turnId: "recovered-turn", + ...(terminal === "turn_failed" ? { error: "Retry failed." } : {}), + }); + await settled; + await stream; + await manager.flush(); + expect(toAgentPayload(manager.getAgent(agent.id)!)).toMatchObject({ + status, + requiresAttention: true, + attentionReason: expectedReason, + }); + expect(manager.getAgent(agent.id)?.lastError).toBe(lastError); + expect(await storage.get(agent.id)).toMatchObject({ + lastStatus: status, + requiresAttention: true, + attentionReason: expectedReason, + }); + expect((await storage.get(agent.id))?.lastError).toBe(lastError); + expect(attentionReasons).toEqual(["error", expectedReason]); + }, +); + test("streamAgent clears pending run when startTurn fails before a turn id exists", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-start-turn-failure-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 94250e5cdd..5c01be317e 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -2840,6 +2840,7 @@ export class AgentManager { this.runs.settleForegroundStart(pendingRun, { status: "started", turnId }); agent.activeForegroundTurnId = turnId; this.openActiveTurn(agent, turnId, turnStartedAt); + agent.attention = { requiresAttention: false }; agent.lifecycle = "running"; this.touchUpdatedAt(agent); // AgentManager owns the accepted-turn boundary. Publish liveness before the canonical @@ -4952,6 +4953,8 @@ export class AgentManager { if (eventTurnId) { this.openActiveTurn(agent, eventTurnId, new Date()); } + agent.lastError = undefined; + agent.attention = { requiresAttention: false }; agent.lifecycle = "running"; this.emitState(agent); }