Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 151b705b47 fix(pi): preserve recovery before prompt acknowledgement
Own intentional-close lifecycle reporting at Pi event publication so pending RPC rejections and process exits cannot consume unfinished work. Preserve cleanup output and errors from open sessions.
2026-11-03 20:48:18 +00:00
paseo-bot[bot] d7b941680a fix(pi): preserve restart recovery during graceful shutdown
Intentional runtime release emitted a failed turn and consumed the saved recovery intent. Keep process-exit cleanup while reserving turn failure for exits of a live session.
2026-11-03 20:40:03 +00:00
3 changed files with 60 additions and 0 deletions
@@ -2183,6 +2183,48 @@ describe("PiRpcAgentSession", () => {
]);
});
test.each([false, true])(
"closing Pi releases an active turn without publishing a terminal outcome (autonomous=%s)",
async (autonomous) => {
const { pi, session, events } = await createSession();
if (autonomous) {
pi.latestSession().emit({ type: "agent_start" });
pi.latestSession().emit({ type: "turn_start" });
} else {
await session.startTurn("keep working");
}
const before = events.turnLifecycleEvents();
await session.close();
expect(pi.latestSession().closeCalls).toBe(1);
expect(events.turnLifecycleEvents()).toEqual(before);
},
);
test("closing Pi before prompt acknowledgement preserves the unfinished turn", async () => {
const { pi, session, events } = await createSession();
pi.latestSession().holdNextPrompt();
await session.startTurn("keep working");
expect(pi.latestSession().prompts).toHaveLength(1);
const before = events.turnLifecycleEvents();
await session.close();
expect(events.turnLifecycleEvents()).toEqual(before);
});
test("a rejected prompt still fails an open Pi session", async () => {
const { pi, session, events } = await createSession();
pi.latestSession().holdNextPrompt();
await session.startTurn("keep working");
await pi.latestSession().failHeldPrompt(new Error("Prompt rejected"));
await expect(events.nextTurnFailure()).resolves.toMatchObject({ error: "Prompt rejected" });
await session.close();
});
test("fails the active turn when the Pi process exits mid-turn", async () => {
const { pi, session, events } = await createSession();
@@ -1729,6 +1729,17 @@ export class PiRpcAgentSession implements AgentSession {
}
private publish(event: AgentStreamEvent): void {
// Runtime release preserves unfinished work for restart recovery. Cleanup
// may still emit timeline updates, but must not settle or restart its turn.
if (
this.closed &&
(event.type === "turn_started" ||
event.type === "turn_completed" ||
event.type === "turn_canceled" ||
event.type === "turn_failed")
) {
return;
}
for (const subscriber of this.subscribers) subscriber(event);
}
@@ -427,6 +427,13 @@ export class FakePiSession implements PiRuntimeSession {
async close(): Promise<void> {
this.closeCalls += 1;
if (this.activeHeldPrompt) {
await this.failHeldPrompt(new Error("Pi RPC session is closed"));
}
this.emit({
type: "process_exit",
error: "Pi RPC process exited with code 0 and signal null",
});
}
emit(event: PiRuntimeEvent): void {