Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ Always Bun: `bun <pi-process.js> --mode rpc …`. `@getpie/server#build` emits t
Always Node. Desktop spawns Electron-as-Node (`Pie Helper` + asar `server.mjs`, `ELECTRON_RUN_AS_NODE`). CLI uses `process.execPath`. The live terminal is `node-pty`. Bun is only for pie-pi-process (PATH `bun` plus the package export).
_Avoid_: spawning the shebang `pi` binary under Bun; using a user-installed `pi` as `pie-pi-process`; a Node spawn path for pie-pi-process; a Bun runtime for the daemon or live terminal

**Runtime idle suspend**:
A held Pi runtime that stays at phase `idle` for `PIE_SESSION_RUNTIME_IDLE_MS` (default 5 minutes; `0` disables) is suspended: the process is killed without sealing the session, and `session.runtime.stopped` is published. The next prompt re-`ensureRuntime`s. Clients clear live turn UI but do not treat this as terminal error.

**Private modules** (no Context tags, never wired directly):
`harness/session.ts` — **PiAgentSession**, one session as this server sees it: seq stamping, phase, buffers, pending requests, and the single-flight lifecycle of the runtime it _optionally_ owns. `harness/session-fold.ts` — the pure state fold. `harness/session-repository.ts` — metadata store over `storage/sessions/`.

Expand Down
13 changes: 13 additions & 0 deletions apps/app/src/features/chat/runtime/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,19 @@ describe("Chat truncated buffers", () => {
});

describe("Chat lifecycle", () => {
it("treats runtime.stopped as idle/ready, not a terminal error", async () => {
const { chat, live, attach } = makeChat();
await attach({});
live(1, { type: "session.turn.started", turnId: "t1", phase: "running" });
live(2, {
type: "session.runtime.stopped",
reason: "idle",
phase: "idle",
});
expect(chat.store.getState().status).toBe("ready");
expect(chat.store.getState().error).toBeUndefined();
});

it("copies the crashed phase into an error status", async () => {
const { chat, attach, live } = makeChat();
await attach({});
Expand Down
11 changes: 11 additions & 0 deletions apps/app/src/features/chat/runtime/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,17 @@ export class Chat {
this.#state.clearPendingRequests();
this.#state.clearPendingPrompt();
break;
// Server killed an idle Pi process; session stays. Clear live turn UI
// the same way as a crash, but phase stays idle → composer ready.
// The EventBus subscription keeps flowing; the next prompt re-spawns
// the process via ensureRuntime — no client re-attach required.
case "session.runtime.stopped":
for (const fold of this.#turnFolds.values()) fold.close();
this.#turnFolds.clear();
this.#state.clearPendingRequests();
this.#state.clearPendingPrompt();
this.#state.retryNotice = undefined;
break;
}
// Status is copied off the event (the runtime stamps its post-event
// phase), never derived from event types here. Lifecycle events only:
Expand Down
9 changes: 8 additions & 1 deletion packages/contract/src/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,9 @@ export const SessionScopedEventTypes = [
"session.request.rejected",
"session.queue.updated",
"session.crashed",
// Runtime was stopped on purpose (e.g. idle timeout). Session stays;
// the next prompt re-acquires a process. Phase is idle — not crashed.
"session.runtime.stopped",
] as const;
export type SessionScopedEventType = (typeof SessionScopedEventTypes)[number];

Expand Down Expand Up @@ -255,7 +258,11 @@ export type SessionScopedEventBody =
readonly steering: ReadonlyArray<string>;
readonly followUp: ReadonlyArray<string>;
}
| { readonly type: "session.crashed"; readonly reason: string };
| { readonly type: "session.crashed"; readonly reason: string }
// Process gone, session kept. `reason` is a short machine token
// (`idle` today). Clients clear live turn UI but must not treat this
// as a terminal error — prompting will resume the runtime.
| { readonly type: "session.runtime.stopped"; readonly reason: string };

/** A session-scoped event before the server's `HarnessAgentSession` stamps its `seq`. */
export type SessionScopedEventDraft = { readonly ref: SessionRef } & SessionScopedEventBody;
Expand Down
5 changes: 5 additions & 0 deletions packages/server/src/harness/events/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export const SessionCrashed = defineEvent({
type: "session.crashed",
schema: { ...sid, reason: Schema.String },
});
export const SessionRuntimeStopped = defineEvent({
type: "session.runtime.stopped",
schema: { ...sid, reason: Schema.String },
});
export const SessionCreated = defineEvent({
type: "session.created",
schema: { sessionId: Schema.String },
Expand Down Expand Up @@ -107,6 +111,7 @@ export const SessionEventDefs = [
SessionRequestReplied,
SessionQueueUpdated,
SessionCrashed,
SessionRuntimeStopped,
] as const;
export type SessionEvent = EventValue<(typeof SessionEventDefs)[number]>;

Expand Down
14 changes: 14 additions & 0 deletions packages/server/src/harness/runtime-idle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* How long a live Pi runtime may sit at phase `idle` before the session
* suspends it (kills the process, keeps the session). Overridable for tests
* and ops via `PIE_SESSION_RUNTIME_IDLE_MS` (milliseconds). `0` disables.
*/
export const DEFAULT_SESSION_RUNTIME_IDLE_MS = 5 * 60 * 1000;

export const sessionRuntimeIdleMs = (env: NodeJS.ProcessEnv = process.env): number => {
const raw = env.PIE_SESSION_RUNTIME_IDLE_MS?.trim();
if (raw === undefined || raw === "") return DEFAULT_SESSION_RUNTIME_IDLE_MS;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_SESSION_RUNTIME_IDLE_MS;
return Math.floor(parsed);
};
13 changes: 13 additions & 0 deletions packages/server/src/harness/session-fold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ export const toWireBody = (
};
case "session.crashed":
return { type: "session.crashed", reason: event.reason };
case "session.runtime.stopped":
return { type: "session.runtime.stopped", reason: event.reason };
default: {
const exhaustive: never = event;
return exhaustive;
Expand Down Expand Up @@ -261,6 +263,17 @@ export const foldSessionEvent = (
pendingRequests: new Map(),
pendingPrompt: emptyPendingPrompt,
};
case "session.runtime.stopped":
// Intentional stop (idle timeout): back to idle, clear live turn
// state. seq/cursor keep going so clients do not rewind.
return {
...base,
phase: "idle",
activeTurn: null,
activePrompt: null,
pendingRequests: new Map(),
pendingPrompt: emptyPendingPrompt,
};
default: {
const exhaustive: never = event;
return exhaustive;
Expand Down
9 changes: 9 additions & 0 deletions packages/server/src/harness/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export type PiAgentSessionManagerShape = {
* for reconnecting clients).
*/
readonly close: (ref: SessionRef) => Effect.Effect<void>;
/** Reserve prompt admission before looking up a runtime; released with the caller scope. */
readonly reserveAdmission: (ref: SessionRef) => Effect.Effect<void, never, Scope.Scope>;
/**
* The status/snapshot of a session. Total on purpose: a ref with nothing
* live in memory — the ordinary state of every persisted session after a
Expand Down Expand Up @@ -222,6 +224,12 @@ export const makePiAgentSessionManager = (
Effect.flatMap((runtime) => (runtime ? Effect.succeed(runtime) : acquireVia(ref, acquire))),
);

const reserveAdmission: PiAgentSessionManagerShape["reserveAdmission"] = (ref) =>
sessionFor(ref).pipe(
Effect.flatMap((session) => session.reserveAdmission),
Effect.flatMap((reserved) => (reserved ? Effect.void : reserveAdmission(ref))),
);

const peek = (ref: SessionRef): Effect.Effect<PiAgentRuntime | undefined> =>
withSession<PiAgentRuntime | undefined>(ref, (session) => session.peekRuntime, undefined);

Expand Down Expand Up @@ -292,6 +300,7 @@ export const makePiAgentSessionManager = (
),
),
peek,
reserveAdmission,
close,
status: (ref) => withSession(ref, (session) => session.status, toStatus(initialSessionState)),
snapshot: (ref) =>
Expand Down
3 changes: 2 additions & 1 deletion packages/server/src/harness/session-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,9 +459,10 @@ export const PiAgentSessionServiceCoreLayer: Layer.Layer<
> =>
Effect.gen(function* () {
const resolved = yield* resolveWorkspace(ref);
yield* manager.reserveAdmission(ref);
const runtime = yield* ensureRuntimeForPrompt(ref, resolved);
return yield* runtime.prompt(userInput);
});
}).pipe(Effect.scoped);

const readHistory = (
ref: SessionRef,
Expand Down
Loading
Loading