Skip to content

Commit dc283c9

Browse files
committed
feat: resume workflow runs durably
1 parent 4667884 commit dc283c9

16 files changed

Lines changed: 2959 additions & 472 deletions

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ For an always-on exhaustive mode, use `/ultracode`; `/effort high` is the lighte
109109

110110
## Commands and run control
111111

112-
Pi can manage background runs directly with the `workflow_control` tool instead of asking you to type a command. It supports `list`, `status`, `pause`, `resume`, and `stop`; run-specific actions use the canonical run ID returned when the workflow starts. Status output includes the run state, current phase, agent counts, active labels, and recorded token total.
112+
Pi can manage background runs directly with the `workflow_control` tool instead of asking you to type a command. It supports `list`, `status`, `pause`, `resume`, `stop`, `restart`, and `remove`; run-specific actions use the canonical run ID returned when the workflow starts. Status output includes the run state, current phase, agent counts, active labels, and recorded token total. `remove` accepts only terminal runs, so stop running or paused work first.
113113

114114
| Command | Purpose |
115115
| --- | --- |
@@ -186,6 +186,8 @@ Extension state lives outside the repository under `~/.pi/workflows`:
186186

187187
Subagents are in-memory by default. Set `persistAgentSessions: true` to retain full transcripts in Pi's standard session directory. This creates one file per agent and may store sensitive material that an agent read, so enable it deliberately.
188188

189+
Run files use a versioned, backward-compatible state model with stable execution IDs, exact terminal usage, and crash-safe temp/rename writes plus backup recovery. After a crash, orphaned running work is recovered as paused and waits for an explicit resume. Resume replays the longest unchanged completed prefix—including nested workflows—then starts incomplete work fresh, without reopening child transcripts or double-charging completed usage.
190+
189191
Completed background runs persist their full result in the project run JSON. The conversation delivery includes a pointer to that file when the visible summary is shortened.
190192

191193
</details>

src/agent.ts

Lines changed: 73 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { unlinkSync, writeFileSync } from "node:fs";
33
import { join } from "node:path";
44
import type { AssistantMessage, Model, TextContent } from "@earendil-works/pi-ai";
55
import {
6+
type AgentSessionEvent,
67
AuthStorage,
78
type CreateAgentSessionOptions,
89
createAgentSession,
@@ -300,14 +301,72 @@ function warnPersistSecretsOnce(sessionDir: string): void {
300301
);
301302
}
302303

303-
/** Real token/cost usage for a single subagent run, read from the SDK session. */
304+
/** Token/cost usage for a single subagent run. */
304305
export interface AgentUsage {
305306
input: number;
306307
output: number;
307308
cacheRead: number;
308309
cacheWrite: number;
309310
total: number;
310311
cost: number;
312+
/** True only for an in-progress output-token estimate. */
313+
estimated?: boolean;
314+
}
315+
316+
/**
317+
* Convert session events into absolute cumulative usage. Exact message usage is
318+
* emitted at message_end; throttled message_update events add only a temporary
319+
* output estimate, which the next exact event replaces.
320+
*/
321+
export function createAgentUsageEventHandler(
322+
onUsage: (usage: AgentUsage) => void,
323+
now: () => number = Date.now,
324+
): (event: AgentSessionEvent) => void {
325+
const exact: AgentUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, cost: 0 };
326+
const endedMessages = new WeakSet<object>();
327+
let lastEstimateEmit = Number.NEGATIVE_INFINITY;
328+
const emit = (usage: AgentUsage) => {
329+
try {
330+
onUsage(usage);
331+
} catch {
332+
// Telemetry is best-effort; never interrupt the child session.
333+
}
334+
};
335+
336+
return (event) => {
337+
if (event.type === "message_end" && event.message.role === "assistant") {
338+
if (endedMessages.has(event.message)) return;
339+
endedMessages.add(event.message);
340+
const usage = event.message.usage;
341+
exact.input += usage.input;
342+
exact.output += usage.output;
343+
exact.cacheRead += usage.cacheRead;
344+
exact.cacheWrite += usage.cacheWrite;
345+
exact.total += usage.totalTokens;
346+
exact.cost += usage.cost.total;
347+
lastEstimateEmit = Number.NEGATIVE_INFINITY;
348+
if (exact.total > 0 || exact.cost > 0) emit({ ...exact, estimated: false });
349+
return;
350+
}
351+
352+
if (event.type !== "message_update" || event.message.role !== "assistant") return;
353+
const timestamp = now();
354+
if (timestamp - lastEstimateEmit < 250) return;
355+
lastEstimateEmit = timestamp;
356+
const textLength = event.message.content.reduce(
357+
(total, part) =>
358+
total + (part.type === "text" ? part.text.length : part.type === "thinking" ? part.thinking.length : 0),
359+
0,
360+
);
361+
const estimatedOutput = Math.ceil(textLength / 4);
362+
if (estimatedOutput <= 0) return;
363+
emit({
364+
...exact,
365+
output: exact.output + estimatedOutput,
366+
total: exact.total + estimatedOutput,
367+
estimated: true,
368+
});
369+
};
311370
}
312371

313372
/**
@@ -346,10 +405,9 @@ export interface AgentRunOptions<TSchemaDef extends TSchema | undefined = undefi
346405
instructions?: string;
347406
signal?: AbortSignal;
348407
/**
349-
* Called once with this subagent's real usage, read from the session right
350-
* before disposal. Fires on both the success and error paths so partial
351-
* usage is never lost — but NOT when the provider reported no usage at all
352-
* (all-zero stats), so consumers keep their scalar fallback.
408+
* Called with absolute cumulative usage during the run and once more with
409+
* authoritative session totals before disposal. Streaming estimates have
410+
* `estimated: true`; message-boundary and terminal updates are exact.
353411
*/
354412
onUsage?: (usage: AgentUsage) => void;
355413
/**
@@ -583,7 +641,7 @@ export class WorkflowAgent {
583641
}
584642

585643
let removeAbortListener: (() => void) | undefined;
586-
let removeHistoryListener: (() => void) | undefined;
644+
let removeSessionListener: (() => void) | undefined;
587645
let lastHistoryEmit = 0;
588646
const emitHistory = () => options.onHistory?.(compactAgentHistory(session.messages));
589647
const maybeEmitHistory = () => {
@@ -593,15 +651,19 @@ export class WorkflowAgent {
593651
lastHistoryEmit = now;
594652
emitHistory();
595653
};
654+
const handleUsageEvent = options.onUsage ? createAgentUsageEventHandler(options.onUsage) : undefined;
596655
try {
597656
if (options.signal?.aborted) throw new Error("Subagent was aborted");
598657
if (options.signal) {
599658
const onAbort = () => void session.abort();
600659
options.signal.addEventListener("abort", onAbort, { once: true });
601660
removeAbortListener = () => options.signal?.removeEventListener("abort", onAbort);
602661
}
603-
if (options.onHistory) {
604-
removeHistoryListener = session.subscribe(() => maybeEmitHistory());
662+
if (options.onHistory || handleUsageEvent) {
663+
removeSessionListener = session.subscribe((event) => {
664+
maybeEmitHistory();
665+
handleUsageEvent?.(event);
666+
});
605667
}
606668

607669
await session.prompt(this.buildPrompt(prompt, options as AgentRunOptions<any>, Boolean(options.schema)));
@@ -630,17 +692,17 @@ export class WorkflowAgent {
630692
return text as AgentRunResult<TSchemaDef>;
631693
} finally {
632694
removeAbortListener?.();
633-
removeHistoryListener?.();
695+
removeSessionListener?.();
634696
try {
635697
emitHistory();
636698
} catch {
637699
// History is diagnostic only; never let it mask the real result/error.
638700
}
639-
// Read real usage before disposing — dispose tears down the session state.
701+
// Emit authoritative terminal usage before disposing the session state.
640702
if (options.onUsage) {
641703
try {
642704
const usage = usageFromStats(session.getSessionStats());
643-
if (usage) options.onUsage(usage);
705+
if (usage) options.onUsage({ ...usage, estimated: false });
644706
} catch {
645707
// Usage is best-effort; never let stats failure mask the real result/error.
646708
}

src/display.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ import type { AgentHistoryEntry } from "./agent-history.js";
44
import type { WorkflowErrorCode } from "./errors.js";
55
import type { WorkflowMeta } from "./workflow.js";
66

7-
export type WorkflowAgentStatus = "queued" | "running" | "done" | "error" | "skipped";
7+
export type WorkflowAgentStatus = "queued" | "running" | "paused" | "done" | "error" | "skipped";
88

99
export interface WorkflowAgentSnapshot {
1010
id: number;
11+
/** Stable invocation identity (`runId:callIndex`). */
12+
executionId?: string;
13+
callIndex?: number;
1114
label: string;
1215
phase?: string;
1316
prompt: string;
@@ -19,6 +22,10 @@ export interface WorkflowAgentSnapshot {
1922
history?: AgentHistoryEntry[];
2023
/** Tokens used by this agent (a scalar estimate when the provider reports no usage). */
2124
tokens?: number;
25+
/** Whether tokens is a live streaming estimate. */
26+
tokensEstimated?: boolean;
27+
/** Exact or provisional cumulative usage for this invocation. */
28+
usage?: AgentUsage;
2229
/** Per-agent token usage breakdown (fresh input+output vs cached), when known. */
2330
tokenUsage?: AgentUsage;
2431
/** The model this agent ran on (provider/id), when known. */
@@ -247,7 +254,7 @@ const NO_THEME: ThemeLike = { fg: (_c, t) => t, bold: (t) => t };
247254
/** The bracketed per-agent token cell (" [89 tok · 3,000 cached]"), or "" when nothing is known yet. */
248255
function agentTokenCell(agent: WorkflowAgentSnapshot, theme: ThemeLike): string {
249256
const segment = fmtTokenSegment(tokenFigures(agent.tokenUsage, agent.tokens), fmtFull);
250-
return segment ? theme.fg("dim", ` [${segment}]`) : "";
257+
return segment ? theme.fg("dim", ` [${agent.tokensEstimated ? "~" : ""}${segment}]`) : "";
251258
}
252259

253260
export function renderWorkflowLines(
@@ -338,6 +345,8 @@ export function statusIcon(status: WorkflowAgentStatus): string {
338345
return "○";
339346
case "running":
340347
return "●";
348+
case "paused":
349+
return "⏸";
341350
case "done":
342351
return "✓";
343352
case "error":

0 commit comments

Comments
 (0)