mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
fix(agents): clear stale error attention when a new turn starts (#8083)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user