fix(agents): clear stale error attention when a new turn starts (#8083)

This commit is contained in:
luke
2026-11-02 21:02:09 +00:00
committed by GitHub
parent 5062d5ee90
commit d2cc18b3a1
2 changed files with 117 additions and 1 deletions
@@ -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<AgentSession> {
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");
@@ -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);
}