@quintinshaw/pi-dynamic-workflows 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/run-persistence.d.ts +6 -0
- package/dist/workflow-commands.d.ts +8 -0
- package/dist/workflow-commands.js +111 -0
- package/dist/workflow-manager.d.ts +8 -2
- package/dist/workflow-manager.js +50 -6
- package/dist/workflow-tool.d.ts +6 -0
- package/dist/workflow-tool.js +4 -4
- package/dist/workflow.d.ts +13 -0
- package/dist/workflow.js +28 -0
- package/extensions/workflow.ts +9 -2
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/run-persistence.ts +2 -0
- package/src/workflow-commands.ts +117 -0
- package/src/workflow-manager.ts +62 -7
- package/src/workflow-tool.ts +9 -5
- package/src/workflow.ts +52 -0
package/README.md
CHANGED
|
@@ -46,6 +46,18 @@ The model writes a workflow script and calls the `workflow` tool. Live progress
|
|
|
46
46
|
|
|
47
47
|
Press `Esc` to cancel a running run; active subagents are aborted and surfaced as skipped.
|
|
48
48
|
|
|
49
|
+
### Background runs & `/workflows`
|
|
50
|
+
|
|
51
|
+
Ask for a background workflow (the model passes `background: true`) and it runs without blocking your session. Manage it with the `/workflows` command:
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
/workflows # list runs (default)
|
|
55
|
+
/workflows status <id> # show a run's progress
|
|
56
|
+
/workflows stop <id> # abort a running run
|
|
57
|
+
/workflows pause <id> # pause a running run
|
|
58
|
+
/workflows rm <id> # remove a run from the list
|
|
59
|
+
```
|
|
60
|
+
|
|
49
61
|
## Workflow script shape
|
|
50
62
|
|
|
51
63
|
A workflow is plain JavaScript. The first statement must export literal metadata:
|
|
@@ -121,6 +133,8 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
|
|
|
121
133
|
- **Structured output** — JSON-Schema-validated subagent results
|
|
122
134
|
- **Real token & cost accounting** — read from each subagent's SDK session (input / output / total / cost), with a character estimate only as fallback when a provider reports no usage; `budget` gates on the real total
|
|
123
135
|
- **Real per-agent / per-phase model routing** — `opts.model` and `meta.phases[].model` actually select the model (resolved against your authed model registry), with graceful fallback
|
|
136
|
+
- **`/workflows` command** — list, inspect, stop, pause, **resume**, and remove background runs; runs started with `background: true` are reachable from the command
|
|
137
|
+
- **Resume** — each agent result is journaled by a deterministic call index; resuming replays the unchanged prefix from cache (no re-run, no tokens) and runs only new or edited calls live
|
|
124
138
|
- **Safety limits** — 1000-agent cap (`maxAgents`), per-agent timeout (`agentTimeoutMs`), recoverable-vs-fatal error classification
|
|
125
139
|
- **Live progress + token/cost display**, `Esc` to abort
|
|
126
140
|
- **Log persistence** to `.pi/workflows/runs/`
|
|
@@ -129,8 +143,6 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
|
|
|
129
143
|
|
|
130
144
|
Tracked toward closer parity with Claude Code dynamic workflows:
|
|
131
145
|
|
|
132
|
-
- **Command surface** — `/workflows` (list / status / stop) and reachable background runs
|
|
133
|
-
- **Resume** — journaled results, replay the unchanged prefix, run the rest live
|
|
134
146
|
- **Worktree isolation** for parallel edits, and **bundled `/deep-research`**
|
|
135
147
|
- **Saved workflows** as `/<name>` slash commands
|
|
136
148
|
|
package/dist/index.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export type { StructuredOutputCapture, StructuredOutputToolOptions } from "./str
|
|
|
20
20
|
export { createStructuredOutputTool } from "./structured-output.js";
|
|
21
21
|
export type { AgentOptions, WorkflowMeta, WorkflowMetaPhase, WorkflowRunOptions, WorkflowRunResult, } from "./workflow.js";
|
|
22
22
|
export { parseWorkflowScript, runWorkflow } from "./workflow.js";
|
|
23
|
+
export { registerWorkflowCommands } from "./workflow-commands.js";
|
|
23
24
|
export type { ManagedRun, WorkflowManagerOptions } from "./workflow-manager.js";
|
|
24
25
|
export { WorkflowManager } from "./workflow-manager.js";
|
|
25
26
|
export type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ export { buildModelRoutingInstructions, parseModelRoutingFromMeta, resolveModelF
|
|
|
10
10
|
export { createRunPersistence, generateRunId } from "./run-persistence.js";
|
|
11
11
|
export { createStructuredOutputTool } from "./structured-output.js";
|
|
12
12
|
export { parseWorkflowScript, runWorkflow } from "./workflow.js";
|
|
13
|
+
export { registerWorkflowCommands } from "./workflow-commands.js";
|
|
13
14
|
export { WorkflowManager } from "./workflow-manager.js";
|
|
14
15
|
export { createWorkflowStorage } from "./workflow-saved.js";
|
|
15
16
|
export { createWorkflowTool } from "./workflow-tool.js";
|
|
@@ -33,6 +33,12 @@ export interface PersistedRunState {
|
|
|
33
33
|
output: number;
|
|
34
34
|
total: number;
|
|
35
35
|
};
|
|
36
|
+
/** Cached agent results for resume, keyed by deterministic call index. */
|
|
37
|
+
journal?: Array<{
|
|
38
|
+
index: number;
|
|
39
|
+
hash: string;
|
|
40
|
+
result: unknown;
|
|
41
|
+
}>;
|
|
36
42
|
}
|
|
37
43
|
export interface RunPersistence {
|
|
38
44
|
/** Save current run state. */
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/workflows` slash command: list, inspect, and control background workflow runs.
|
|
3
|
+
* Shares the extension's single WorkflowManager so background runs are reachable.
|
|
4
|
+
*/
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { WorkflowManager } from "./workflow-manager.js";
|
|
7
|
+
/** Register the `/workflows` command against the shared manager. Idempotent. */
|
|
8
|
+
export declare function registerWorkflowCommands(pi: ExtensionAPI, manager: WorkflowManager): void;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/workflows` slash command: list, inspect, and control background workflow runs.
|
|
3
|
+
* Shares the extension's single WorkflowManager so background runs are reachable.
|
|
4
|
+
*/
|
|
5
|
+
import { renderWorkflowText } from "./display.js";
|
|
6
|
+
const STATUS_ICON = {
|
|
7
|
+
pending: "·",
|
|
8
|
+
running: "◆",
|
|
9
|
+
paused: "⏸",
|
|
10
|
+
completed: "✓",
|
|
11
|
+
failed: "✗",
|
|
12
|
+
aborted: "⊘",
|
|
13
|
+
};
|
|
14
|
+
const USAGE = "Usage: /workflows [list] | status <id> | stop <id> | pause <id> | resume <id> | rm <id>";
|
|
15
|
+
function summarizeRun(run) {
|
|
16
|
+
const icon = STATUS_ICON[run.status] ?? "?";
|
|
17
|
+
const done = run.agents.filter((a) => a.status === "done").length;
|
|
18
|
+
const total = run.agents.length;
|
|
19
|
+
const tokens = run.tokenUsage ? ` · ${run.tokenUsage.total.toLocaleString()} tok` : "";
|
|
20
|
+
return `${icon} ${run.runId} ${run.workflowName} [${run.status}] ${done}/${total} agents${tokens}`;
|
|
21
|
+
}
|
|
22
|
+
function renderPersistedStatus(run) {
|
|
23
|
+
const lines = [`${STATUS_ICON[run.status] ?? "?"} ${run.workflowName} (${run.runId}) — ${run.status}`];
|
|
24
|
+
if (run.currentPhase)
|
|
25
|
+
lines.push(` phase: ${run.currentPhase}`);
|
|
26
|
+
for (const agent of run.agents) {
|
|
27
|
+
const icon = agent.status === "done" ? "✓" : agent.status === "error" ? "✗" : agent.status === "running" ? "◆" : "·";
|
|
28
|
+
lines.push(` ${icon} ${agent.label}`);
|
|
29
|
+
}
|
|
30
|
+
if (run.tokenUsage)
|
|
31
|
+
lines.push(` tokens: ${run.tokenUsage.total.toLocaleString()}`);
|
|
32
|
+
if (run.durationMs)
|
|
33
|
+
lines.push(` duration: ${(run.durationMs / 1000).toFixed(1)}s`);
|
|
34
|
+
return lines.join("\n");
|
|
35
|
+
}
|
|
36
|
+
/** Register the `/workflows` command against the shared manager. Idempotent. */
|
|
37
|
+
export function registerWorkflowCommands(pi, manager) {
|
|
38
|
+
try {
|
|
39
|
+
const taken = (pi.getCommands?.() ?? []).some((c) => c.name === "workflows");
|
|
40
|
+
if (taken)
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// getCommands may be unavailable in some hosts; fall through and try to register.
|
|
45
|
+
}
|
|
46
|
+
pi.registerCommand("workflows", {
|
|
47
|
+
description: "List and control background workflow runs",
|
|
48
|
+
async handler(args, ctx) {
|
|
49
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
50
|
+
const sub = (parts[0] ?? "list").toLowerCase();
|
|
51
|
+
const id = parts[1];
|
|
52
|
+
const print = (text) => pi.sendMessage({ customType: "workflows", content: text, display: true });
|
|
53
|
+
switch (sub) {
|
|
54
|
+
case "list": {
|
|
55
|
+
const runs = manager.listRuns();
|
|
56
|
+
if (!runs.length) {
|
|
57
|
+
await print("No workflow runs yet. Start one with a background workflow (background: true).");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
await print(["Workflow runs:", ...runs.map(summarizeRun), "", USAGE].join("\n"));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
case "status": {
|
|
64
|
+
if (!id) {
|
|
65
|
+
ctx.ui.notify(USAGE, "warning");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const live = manager.getSnapshot(id);
|
|
69
|
+
if (live) {
|
|
70
|
+
await print(renderWorkflowText(live, false));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const run = manager.listRuns().find((r) => r.runId === id);
|
|
74
|
+
if (!run) {
|
|
75
|
+
ctx.ui.notify(`No workflow run "${id}"`, "error");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
await print(renderPersistedStatus(run));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
case "stop": {
|
|
82
|
+
if (!id)
|
|
83
|
+
return ctx.ui.notify(USAGE, "warning");
|
|
84
|
+
ctx.ui.notify(manager.stop(id) ? `Stopped ${id}` : `Cannot stop ${id} (not running)`, manager.getRun(id) ? "info" : "warning");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
case "pause": {
|
|
88
|
+
if (!id)
|
|
89
|
+
return ctx.ui.notify(USAGE, "warning");
|
|
90
|
+
ctx.ui.notify(manager.pause(id) ? `Paused ${id}` : `Cannot pause ${id} (not running)`, "info");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
case "resume": {
|
|
94
|
+
if (!id)
|
|
95
|
+
return ctx.ui.notify(USAGE, "warning");
|
|
96
|
+
const ok = await manager.resume(id);
|
|
97
|
+
ctx.ui.notify(ok ? `Resumed ${id}` : `Resume not available for ${id} yet`, ok ? "info" : "warning");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
case "rm": {
|
|
101
|
+
if (!id)
|
|
102
|
+
return ctx.ui.notify(USAGE, "warning");
|
|
103
|
+
ctx.ui.notify(manager.deleteRun(id) ? `Removed ${id}` : `No run ${id}`, "info");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
default:
|
|
107
|
+
ctx.ui.notify(`Unknown subcommand "${sub}". ${USAGE}`, "warning");
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -5,7 +5,7 @@ import { EventEmitter } from "node:events";
|
|
|
5
5
|
import type { WorkflowSnapshot } from "./display.js";
|
|
6
6
|
import { WorkflowError } from "./errors.js";
|
|
7
7
|
import { type PersistedRunState, type RunPersistence, type RunStatus } from "./run-persistence.js";
|
|
8
|
-
import { type WorkflowRunResult } from "./workflow.js";
|
|
8
|
+
import { type JournalEntry, type WorkflowRunResult } from "./workflow.js";
|
|
9
9
|
export interface ManagedRun {
|
|
10
10
|
runId: string;
|
|
11
11
|
status: RunStatus;
|
|
@@ -14,6 +14,11 @@ export interface ManagedRun {
|
|
|
14
14
|
error?: WorkflowError;
|
|
15
15
|
controller: AbortController;
|
|
16
16
|
startedAt: Date;
|
|
17
|
+
/** The real script, kept so the run can be resumed. */
|
|
18
|
+
script: string;
|
|
19
|
+
args?: unknown;
|
|
20
|
+
/** Accumulated agent results for resume (deterministic call index -> result). */
|
|
21
|
+
journal: JournalEntry[];
|
|
17
22
|
}
|
|
18
23
|
export interface WorkflowManagerOptions {
|
|
19
24
|
cwd?: string;
|
|
@@ -44,7 +49,8 @@ export declare class WorkflowManager extends EventEmitter {
|
|
|
44
49
|
*/
|
|
45
50
|
pause(runId: string): boolean;
|
|
46
51
|
/**
|
|
47
|
-
* Resume
|
|
52
|
+
* Resume an interrupted run: replay journaled results for the unchanged prefix
|
|
53
|
+
* and run the rest live. Returns false if there is nothing resumable.
|
|
48
54
|
*/
|
|
49
55
|
resume(runId: string): Promise<boolean>;
|
|
50
56
|
/**
|
package/dist/workflow-manager.js
CHANGED
|
@@ -40,6 +40,9 @@ export class WorkflowManager extends EventEmitter {
|
|
|
40
40
|
},
|
|
41
41
|
controller,
|
|
42
42
|
startedAt: new Date(),
|
|
43
|
+
script,
|
|
44
|
+
args,
|
|
45
|
+
journal: [],
|
|
43
46
|
};
|
|
44
47
|
this.runs.set(runId, managed);
|
|
45
48
|
// Persist initial state
|
|
@@ -82,17 +85,28 @@ export class WorkflowManager extends EventEmitter {
|
|
|
82
85
|
},
|
|
83
86
|
controller,
|
|
84
87
|
startedAt: new Date(),
|
|
88
|
+
script,
|
|
89
|
+
args,
|
|
90
|
+
journal: [],
|
|
85
91
|
};
|
|
86
92
|
this.runs.set(runId, managed);
|
|
87
93
|
return this.executeRun(managed, script, args);
|
|
88
94
|
}
|
|
89
|
-
async executeRun(managed, script, args) {
|
|
95
|
+
async executeRun(managed, script, args, resumeJournal) {
|
|
90
96
|
try {
|
|
91
97
|
const result = await runWorkflow(script, {
|
|
92
98
|
cwd: this.cwd,
|
|
93
99
|
args,
|
|
94
100
|
signal: managed.controller.signal,
|
|
95
101
|
concurrency: this.concurrency,
|
|
102
|
+
resumeJournal,
|
|
103
|
+
resumeFromRunId: resumeJournal ? managed.runId : undefined,
|
|
104
|
+
onAgentJournal: (entry) => {
|
|
105
|
+
// Append (crash-safe-ish): keep the latest entry per index, then persist.
|
|
106
|
+
managed.journal = managed.journal.filter((e) => e.index !== entry.index);
|
|
107
|
+
managed.journal.push(entry);
|
|
108
|
+
this.persistRun(managed);
|
|
109
|
+
},
|
|
96
110
|
onLog: (message) => {
|
|
97
111
|
managed.snapshot.logs.push(message);
|
|
98
112
|
this.emit("log", { runId: managed.runId, message });
|
|
@@ -152,7 +166,11 @@ export class WorkflowManager extends EventEmitter {
|
|
|
152
166
|
this.persistence.save({
|
|
153
167
|
runId: managed.runId,
|
|
154
168
|
workflowName: managed.snapshot.name,
|
|
155
|
-
script
|
|
169
|
+
// Persist the real script + journal so the run can be resumed. Runs live
|
|
170
|
+
// under .pi/workflows/runs/ — protect via directory permissions, not blanking.
|
|
171
|
+
script: managed.script,
|
|
172
|
+
args: managed.args,
|
|
173
|
+
journal: managed.journal,
|
|
156
174
|
status: managed.status,
|
|
157
175
|
phases: managed.snapshot.phases,
|
|
158
176
|
currentPhase: managed.snapshot.currentPhase,
|
|
@@ -183,15 +201,41 @@ export class WorkflowManager extends EventEmitter {
|
|
|
183
201
|
return true;
|
|
184
202
|
}
|
|
185
203
|
/**
|
|
186
|
-
* Resume
|
|
204
|
+
* Resume an interrupted run: replay journaled results for the unchanged prefix
|
|
205
|
+
* and run the rest live. Returns false if there is nothing resumable.
|
|
187
206
|
*/
|
|
188
207
|
async resume(runId) {
|
|
208
|
+
const active = this.runs.get(runId);
|
|
209
|
+
if (active?.status === "running")
|
|
210
|
+
return false; // already running
|
|
189
211
|
const persisted = this.persistence.load(runId);
|
|
190
|
-
if (persisted?.status
|
|
212
|
+
if (!persisted?.script || persisted.status === "completed")
|
|
191
213
|
return false;
|
|
192
|
-
|
|
193
|
-
|
|
214
|
+
const controller = new AbortController();
|
|
215
|
+
const managed = {
|
|
216
|
+
runId,
|
|
217
|
+
status: "running",
|
|
218
|
+
snapshot: {
|
|
219
|
+
name: persisted.workflowName,
|
|
220
|
+
phases: persisted.phases ?? [],
|
|
221
|
+
logs: persisted.logs ?? [],
|
|
222
|
+
agents: [],
|
|
223
|
+
agentCount: 0,
|
|
224
|
+
runningCount: 0,
|
|
225
|
+
doneCount: 0,
|
|
226
|
+
errorCount: 0,
|
|
227
|
+
},
|
|
228
|
+
controller,
|
|
229
|
+
startedAt: new Date(),
|
|
230
|
+
script: persisted.script,
|
|
231
|
+
args: persisted.args,
|
|
232
|
+
journal: persisted.journal ?? [],
|
|
233
|
+
};
|
|
234
|
+
this.runs.set(runId, managed);
|
|
235
|
+
const resumeJournal = new Map((persisted.journal ?? []).map((e) => [e.index, e]));
|
|
194
236
|
this.emit("resumed", { runId });
|
|
237
|
+
// Run in the background; executeRun records status/errors on the managed run.
|
|
238
|
+
void this.executeRun(managed, persisted.script, persisted.args, resumeJournal).catch(() => { });
|
|
195
239
|
return true;
|
|
196
240
|
}
|
|
197
241
|
/**
|
package/dist/workflow-tool.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
+
import { WorkflowManager } from "./workflow-manager.js";
|
|
4
|
+
import { type WorkflowStorage } from "./workflow-saved.js";
|
|
3
5
|
declare const workflowToolSchema: Type.TObject<{
|
|
4
6
|
script: Type.TString;
|
|
5
7
|
args: Type.TOptional<Type.TAny>;
|
|
@@ -17,6 +19,10 @@ export type WorkflowToolInput = {
|
|
|
17
19
|
export interface WorkflowToolOptions {
|
|
18
20
|
cwd?: string;
|
|
19
21
|
concurrency?: number;
|
|
22
|
+
/** Shared manager so background runs are reachable from the `/workflows` command. */
|
|
23
|
+
manager?: WorkflowManager;
|
|
24
|
+
/** Shared saved-workflow storage. */
|
|
25
|
+
storage?: WorkflowStorage;
|
|
20
26
|
}
|
|
21
27
|
export declare function createWorkflowTool(options?: WorkflowToolOptions): ToolDefinition<typeof workflowToolSchema, any>;
|
|
22
28
|
export {};
|
package/dist/workflow-tool.js
CHANGED
|
@@ -27,8 +27,8 @@ const workflowToolSchema = Type.Object({
|
|
|
27
27
|
})),
|
|
28
28
|
});
|
|
29
29
|
export function createWorkflowTool(options = {}) {
|
|
30
|
-
const manager = new WorkflowManager({ cwd: options.cwd, concurrency: options.concurrency });
|
|
31
|
-
const _storage = createWorkflowStorage(options.cwd ?? process.cwd());
|
|
30
|
+
const manager = options.manager ?? new WorkflowManager({ cwd: options.cwd, concurrency: options.concurrency });
|
|
31
|
+
const _storage = options.storage ?? createWorkflowStorage(options.cwd ?? process.cwd());
|
|
32
32
|
return defineTool({
|
|
33
33
|
name: "workflow",
|
|
34
34
|
label: "Workflow",
|
|
@@ -70,8 +70,8 @@ export function createWorkflowTool(options = {}) {
|
|
|
70
70
|
text: [
|
|
71
71
|
`Workflow "${parsed.meta.name}" started in background.`,
|
|
72
72
|
`Run ID: ${runId}`,
|
|
73
|
-
`Use /
|
|
74
|
-
`Use /
|
|
73
|
+
`Use /workflows status ${runId} to check progress.`,
|
|
74
|
+
`Use /workflows stop ${runId} to cancel.`,
|
|
75
75
|
].join("\n"),
|
|
76
76
|
},
|
|
77
77
|
],
|
package/dist/workflow.d.ts
CHANGED
|
@@ -11,6 +11,13 @@ export interface WorkflowMeta {
|
|
|
11
11
|
whenToUse?: string;
|
|
12
12
|
phases?: WorkflowMetaPhase[];
|
|
13
13
|
}
|
|
14
|
+
/** One cached agent() result, keyed by its deterministic call index. */
|
|
15
|
+
export interface JournalEntry {
|
|
16
|
+
index: number;
|
|
17
|
+
/** sha256 of the call's identity (prompt + model + phase + agentType + schema). */
|
|
18
|
+
hash: string;
|
|
19
|
+
result: unknown;
|
|
20
|
+
}
|
|
14
21
|
export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
15
22
|
args?: unknown;
|
|
16
23
|
agent?: Pick<WorkflowAgent, "run">;
|
|
@@ -25,6 +32,12 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
|
25
32
|
persistLogs?: boolean;
|
|
26
33
|
/** Run ID for persistence. Auto-generated if not provided. */
|
|
27
34
|
runId?: string;
|
|
35
|
+
/** Resume: cached agent results keyed by deterministic call index. */
|
|
36
|
+
resumeJournal?: Map<number, JournalEntry>;
|
|
37
|
+
/** Resume: the run being resumed (informational; enables resume mode). */
|
|
38
|
+
resumeFromRunId?: string;
|
|
39
|
+
/** Called after each live agent completes so the caller can persist the journal. */
|
|
40
|
+
onAgentJournal?: (entry: JournalEntry) => void;
|
|
28
41
|
onLog?: (message: string) => void;
|
|
29
42
|
onPhase?: (title: string) => void;
|
|
30
43
|
onAgentStart?: (event: {
|
package/dist/workflow.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import vm from "node:vm";
|
|
2
3
|
import { parse } from "acorn";
|
|
3
4
|
import { WorkflowAgent } from "./agent.js";
|
|
@@ -25,6 +26,7 @@ export async function runWorkflow(script, options = {}) {
|
|
|
25
26
|
logs: [],
|
|
26
27
|
phases: [],
|
|
27
28
|
agentCount: 0,
|
|
29
|
+
callSeq: 0,
|
|
28
30
|
spent: 0,
|
|
29
31
|
tokenUsage: { input: 0, output: 0, total: 0, cost: 0 },
|
|
30
32
|
};
|
|
@@ -67,6 +69,20 @@ export async function runWorkflow(script, options = {}) {
|
|
|
67
69
|
const requestedLabel = agentOptions.label?.trim();
|
|
68
70
|
// Precedence: explicit agentOptions.model > phase model (meta.phases[].model).
|
|
69
71
|
const modelSpec = agentOptions.model ?? resolveModelForPhase(assignedPhase, routingConfig);
|
|
72
|
+
// Deterministic resume key: assigned at lexical call time, before the limiter,
|
|
73
|
+
// so parallel()/pipeline() fan-out is reproducible for a fixed script.
|
|
74
|
+
const callIndex = state.callSeq++;
|
|
75
|
+
const callHash = hashAgentCall(prompt, modelSpec, assignedPhase, agentOptions);
|
|
76
|
+
// Resume: replay a cached result for an unchanged call (matching hash), without
|
|
77
|
+
// consuming a concurrency slot, tokens, or a real subagent run.
|
|
78
|
+
const cached = options.resumeJournal?.get(callIndex);
|
|
79
|
+
if (cached && cached.hash === callHash) {
|
|
80
|
+
state.agentCount++;
|
|
81
|
+
const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
|
|
82
|
+
options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
|
|
83
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result: cached.result, tokens: 0 });
|
|
84
|
+
return cached.result;
|
|
85
|
+
}
|
|
70
86
|
return limiter(async () => {
|
|
71
87
|
state.agentCount++;
|
|
72
88
|
const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
|
|
@@ -101,6 +117,7 @@ export async function runWorkflow(script, options = {}) {
|
|
|
101
117
|
}), timeout, `Agent "${label}" timed out after ${timeout}ms`);
|
|
102
118
|
throwIfAborted();
|
|
103
119
|
const tokens = recordTokens(result);
|
|
120
|
+
options.onAgentJournal?.({ index: callIndex, hash: callHash, result });
|
|
104
121
|
options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens });
|
|
105
122
|
return result;
|
|
106
123
|
}
|
|
@@ -348,6 +365,17 @@ function createLimiter(limit) {
|
|
|
348
365
|
function defaultAgentLabel(phase, index) {
|
|
349
366
|
return phase ? `${phase} agent ${index}` : `agent ${index}`;
|
|
350
367
|
}
|
|
368
|
+
/** Stable identity hash for an agent() call — a cache miss on resume when anything changes. */
|
|
369
|
+
function hashAgentCall(prompt, model, phase, options) {
|
|
370
|
+
const identity = JSON.stringify({
|
|
371
|
+
prompt,
|
|
372
|
+
model: model ?? null,
|
|
373
|
+
phase: phase ?? null,
|
|
374
|
+
agentType: options.agentType ?? null,
|
|
375
|
+
schema: options.schema ?? null,
|
|
376
|
+
});
|
|
377
|
+
return createHash("sha256").update(identity).digest("hex");
|
|
378
|
+
}
|
|
351
379
|
function buildAgentInstructions(phase, options) {
|
|
352
380
|
const lines = [];
|
|
353
381
|
if (phase)
|
package/extensions/workflow.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { createWorkflowTool } from "../src/index.js";
|
|
2
|
+
import { createWorkflowStorage, createWorkflowTool, registerWorkflowCommands, WorkflowManager } from "../src/index.js";
|
|
3
3
|
|
|
4
4
|
export default function extension(pi: ExtensionAPI) {
|
|
5
|
-
|
|
5
|
+
// Single manager/storage shared by the workflow tool and the /workflows command,
|
|
6
|
+
// so background runs started by the tool are reachable from the command.
|
|
7
|
+
const cwd = process.cwd();
|
|
8
|
+
const manager = new WorkflowManager({ cwd });
|
|
9
|
+
const storage = createWorkflowStorage(cwd);
|
|
10
|
+
|
|
11
|
+
const workflowTool = createWorkflowTool({ cwd, manager, storage });
|
|
6
12
|
pi.registerTool(workflowTool);
|
|
13
|
+
registerWorkflowCommands(pi, manager);
|
|
7
14
|
|
|
8
15
|
pi.on("session_start", () => {
|
|
9
16
|
const active = pi.getActiveTools();
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -47,6 +47,7 @@ export type {
|
|
|
47
47
|
WorkflowRunResult,
|
|
48
48
|
} from "./workflow.js";
|
|
49
49
|
export { parseWorkflowScript, runWorkflow } from "./workflow.js";
|
|
50
|
+
export { registerWorkflowCommands } from "./workflow-commands.js";
|
|
50
51
|
export type { ManagedRun, WorkflowManagerOptions } from "./workflow-manager.js";
|
|
51
52
|
export { WorkflowManager } from "./workflow-manager.js";
|
|
52
53
|
export type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
|
package/src/run-persistence.ts
CHANGED
|
@@ -40,6 +40,8 @@ export interface PersistedRunState {
|
|
|
40
40
|
output: number;
|
|
41
41
|
total: number;
|
|
42
42
|
};
|
|
43
|
+
/** Cached agent results for resume, keyed by deterministic call index. */
|
|
44
|
+
journal?: Array<{ index: number; hash: string; result: unknown }>;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
export interface RunPersistence {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/workflows` slash command: list, inspect, and control background workflow runs.
|
|
3
|
+
* Shares the extension's single WorkflowManager so background runs are reachable.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { renderWorkflowText } from "./display.js";
|
|
8
|
+
import type { PersistedRunState } from "./run-persistence.js";
|
|
9
|
+
import type { WorkflowManager } from "./workflow-manager.js";
|
|
10
|
+
|
|
11
|
+
const STATUS_ICON: Record<string, string> = {
|
|
12
|
+
pending: "·",
|
|
13
|
+
running: "◆",
|
|
14
|
+
paused: "⏸",
|
|
15
|
+
completed: "✓",
|
|
16
|
+
failed: "✗",
|
|
17
|
+
aborted: "⊘",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const USAGE = "Usage: /workflows [list] | status <id> | stop <id> | pause <id> | resume <id> | rm <id>";
|
|
21
|
+
|
|
22
|
+
function summarizeRun(run: PersistedRunState): string {
|
|
23
|
+
const icon = STATUS_ICON[run.status] ?? "?";
|
|
24
|
+
const done = run.agents.filter((a) => a.status === "done").length;
|
|
25
|
+
const total = run.agents.length;
|
|
26
|
+
const tokens = run.tokenUsage ? ` · ${run.tokenUsage.total.toLocaleString()} tok` : "";
|
|
27
|
+
return `${icon} ${run.runId} ${run.workflowName} [${run.status}] ${done}/${total} agents${tokens}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function renderPersistedStatus(run: PersistedRunState): string {
|
|
31
|
+
const lines = [`${STATUS_ICON[run.status] ?? "?"} ${run.workflowName} (${run.runId}) — ${run.status}`];
|
|
32
|
+
if (run.currentPhase) lines.push(` phase: ${run.currentPhase}`);
|
|
33
|
+
for (const agent of run.agents) {
|
|
34
|
+
const icon =
|
|
35
|
+
agent.status === "done" ? "✓" : agent.status === "error" ? "✗" : agent.status === "running" ? "◆" : "·";
|
|
36
|
+
lines.push(` ${icon} ${agent.label}`);
|
|
37
|
+
}
|
|
38
|
+
if (run.tokenUsage) lines.push(` tokens: ${run.tokenUsage.total.toLocaleString()}`);
|
|
39
|
+
if (run.durationMs) lines.push(` duration: ${(run.durationMs / 1000).toFixed(1)}s`);
|
|
40
|
+
return lines.join("\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Register the `/workflows` command against the shared manager. Idempotent. */
|
|
44
|
+
export function registerWorkflowCommands(pi: ExtensionAPI, manager: WorkflowManager): void {
|
|
45
|
+
try {
|
|
46
|
+
const taken = (pi.getCommands?.() ?? []).some((c: { name: string }) => c.name === "workflows");
|
|
47
|
+
if (taken) return;
|
|
48
|
+
} catch {
|
|
49
|
+
// getCommands may be unavailable in some hosts; fall through and try to register.
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
pi.registerCommand("workflows", {
|
|
53
|
+
description: "List and control background workflow runs",
|
|
54
|
+
async handler(args: string, ctx: ExtensionCommandContext) {
|
|
55
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
56
|
+
const sub = (parts[0] ?? "list").toLowerCase();
|
|
57
|
+
const id = parts[1];
|
|
58
|
+
const print = (text: string) => pi.sendMessage({ customType: "workflows", content: text, display: true });
|
|
59
|
+
|
|
60
|
+
switch (sub) {
|
|
61
|
+
case "list": {
|
|
62
|
+
const runs = manager.listRuns();
|
|
63
|
+
if (!runs.length) {
|
|
64
|
+
await print("No workflow runs yet. Start one with a background workflow (background: true).");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
await print(["Workflow runs:", ...runs.map(summarizeRun), "", USAGE].join("\n"));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
case "status": {
|
|
71
|
+
if (!id) {
|
|
72
|
+
ctx.ui.notify(USAGE, "warning");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const live = manager.getSnapshot(id);
|
|
76
|
+
if (live) {
|
|
77
|
+
await print(renderWorkflowText(live, false));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const run = manager.listRuns().find((r) => r.runId === id);
|
|
81
|
+
if (!run) {
|
|
82
|
+
ctx.ui.notify(`No workflow run "${id}"`, "error");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
await print(renderPersistedStatus(run));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
case "stop": {
|
|
89
|
+
if (!id) return ctx.ui.notify(USAGE, "warning");
|
|
90
|
+
ctx.ui.notify(
|
|
91
|
+
manager.stop(id) ? `Stopped ${id}` : `Cannot stop ${id} (not running)`,
|
|
92
|
+
manager.getRun(id) ? "info" : "warning",
|
|
93
|
+
);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
case "pause": {
|
|
97
|
+
if (!id) return ctx.ui.notify(USAGE, "warning");
|
|
98
|
+
ctx.ui.notify(manager.pause(id) ? `Paused ${id}` : `Cannot pause ${id} (not running)`, "info");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
case "resume": {
|
|
102
|
+
if (!id) return ctx.ui.notify(USAGE, "warning");
|
|
103
|
+
const ok = await manager.resume(id);
|
|
104
|
+
ctx.ui.notify(ok ? `Resumed ${id}` : `Resume not available for ${id} yet`, ok ? "info" : "warning");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
case "rm": {
|
|
108
|
+
if (!id) return ctx.ui.notify(USAGE, "warning");
|
|
109
|
+
ctx.ui.notify(manager.deleteRun(id) ? `Removed ${id}` : `No run ${id}`, "info");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
default:
|
|
113
|
+
ctx.ui.notify(`Unknown subcommand "${sub}". ${USAGE}`, "warning");
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
package/src/workflow-manager.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type RunPersistence,
|
|
13
13
|
type RunStatus,
|
|
14
14
|
} from "./run-persistence.js";
|
|
15
|
-
import { parseWorkflowScript, runWorkflow, type WorkflowRunResult } from "./workflow.js";
|
|
15
|
+
import { type JournalEntry, parseWorkflowScript, runWorkflow, type WorkflowRunResult } from "./workflow.js";
|
|
16
16
|
|
|
17
17
|
export interface ManagedRun {
|
|
18
18
|
runId: string;
|
|
@@ -22,6 +22,11 @@ export interface ManagedRun {
|
|
|
22
22
|
error?: WorkflowError;
|
|
23
23
|
controller: AbortController;
|
|
24
24
|
startedAt: Date;
|
|
25
|
+
/** The real script, kept so the run can be resumed. */
|
|
26
|
+
script: string;
|
|
27
|
+
args?: unknown;
|
|
28
|
+
/** Accumulated agent results for resume (deterministic call index -> result). */
|
|
29
|
+
journal: JournalEntry[];
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export interface WorkflowManagerOptions {
|
|
@@ -67,6 +72,9 @@ export class WorkflowManager extends EventEmitter {
|
|
|
67
72
|
},
|
|
68
73
|
controller,
|
|
69
74
|
startedAt: new Date(),
|
|
75
|
+
script,
|
|
76
|
+
args,
|
|
77
|
+
journal: [],
|
|
70
78
|
};
|
|
71
79
|
|
|
72
80
|
this.runs.set(runId, managed);
|
|
@@ -115,19 +123,35 @@ export class WorkflowManager extends EventEmitter {
|
|
|
115
123
|
},
|
|
116
124
|
controller,
|
|
117
125
|
startedAt: new Date(),
|
|
126
|
+
script,
|
|
127
|
+
args,
|
|
128
|
+
journal: [],
|
|
118
129
|
};
|
|
119
130
|
|
|
120
131
|
this.runs.set(runId, managed);
|
|
121
132
|
return this.executeRun(managed, script, args);
|
|
122
133
|
}
|
|
123
134
|
|
|
124
|
-
private async executeRun(
|
|
135
|
+
private async executeRun(
|
|
136
|
+
managed: ManagedRun,
|
|
137
|
+
script: string,
|
|
138
|
+
args?: unknown,
|
|
139
|
+
resumeJournal?: Map<number, JournalEntry>,
|
|
140
|
+
): Promise<WorkflowRunResult> {
|
|
125
141
|
try {
|
|
126
142
|
const result = await runWorkflow(script, {
|
|
127
143
|
cwd: this.cwd,
|
|
128
144
|
args,
|
|
129
145
|
signal: managed.controller.signal,
|
|
130
146
|
concurrency: this.concurrency,
|
|
147
|
+
resumeJournal,
|
|
148
|
+
resumeFromRunId: resumeJournal ? managed.runId : undefined,
|
|
149
|
+
onAgentJournal: (entry) => {
|
|
150
|
+
// Append (crash-safe-ish): keep the latest entry per index, then persist.
|
|
151
|
+
managed.journal = managed.journal.filter((e) => e.index !== entry.index);
|
|
152
|
+
managed.journal.push(entry);
|
|
153
|
+
this.persistRun(managed);
|
|
154
|
+
},
|
|
131
155
|
onLog: (message) => {
|
|
132
156
|
managed.snapshot.logs.push(message);
|
|
133
157
|
this.emit("log", { runId: managed.runId, message });
|
|
@@ -197,7 +221,11 @@ export class WorkflowManager extends EventEmitter {
|
|
|
197
221
|
this.persistence.save({
|
|
198
222
|
runId: managed.runId,
|
|
199
223
|
workflowName: managed.snapshot.name,
|
|
200
|
-
script
|
|
224
|
+
// Persist the real script + journal so the run can be resumed. Runs live
|
|
225
|
+
// under .pi/workflows/runs/ — protect via directory permissions, not blanking.
|
|
226
|
+
script: managed.script,
|
|
227
|
+
args: managed.args,
|
|
228
|
+
journal: managed.journal,
|
|
201
229
|
status: managed.status,
|
|
202
230
|
phases: managed.snapshot.phases,
|
|
203
231
|
currentPhase: managed.snapshot.currentPhase,
|
|
@@ -230,15 +258,42 @@ export class WorkflowManager extends EventEmitter {
|
|
|
230
258
|
}
|
|
231
259
|
|
|
232
260
|
/**
|
|
233
|
-
* Resume
|
|
261
|
+
* Resume an interrupted run: replay journaled results for the unchanged prefix
|
|
262
|
+
* and run the rest live. Returns false if there is nothing resumable.
|
|
234
263
|
*/
|
|
235
264
|
async resume(runId: string): Promise<boolean> {
|
|
265
|
+
const active = this.runs.get(runId);
|
|
266
|
+
if (active?.status === "running") return false; // already running
|
|
267
|
+
|
|
236
268
|
const persisted = this.persistence.load(runId);
|
|
237
|
-
if (persisted?.status
|
|
269
|
+
if (!persisted?.script || persisted.status === "completed") return false;
|
|
270
|
+
|
|
271
|
+
const controller = new AbortController();
|
|
272
|
+
const managed: ManagedRun = {
|
|
273
|
+
runId,
|
|
274
|
+
status: "running",
|
|
275
|
+
snapshot: {
|
|
276
|
+
name: persisted.workflowName,
|
|
277
|
+
phases: persisted.phases ?? [],
|
|
278
|
+
logs: persisted.logs ?? [],
|
|
279
|
+
agents: [],
|
|
280
|
+
agentCount: 0,
|
|
281
|
+
runningCount: 0,
|
|
282
|
+
doneCount: 0,
|
|
283
|
+
errorCount: 0,
|
|
284
|
+
},
|
|
285
|
+
controller,
|
|
286
|
+
startedAt: new Date(),
|
|
287
|
+
script: persisted.script,
|
|
288
|
+
args: persisted.args,
|
|
289
|
+
journal: persisted.journal ?? [],
|
|
290
|
+
};
|
|
291
|
+
this.runs.set(runId, managed);
|
|
238
292
|
|
|
239
|
-
|
|
240
|
-
// Full resume would require re-executing the script with cached results
|
|
293
|
+
const resumeJournal = new Map((persisted.journal ?? []).map((e) => [e.index, e] as const));
|
|
241
294
|
this.emit("resumed", { runId });
|
|
295
|
+
// Run in the background; executeRun records status/errors on the managed run.
|
|
296
|
+
void this.executeRun(managed, persisted.script, persisted.args, resumeJournal).catch(() => {});
|
|
242
297
|
return true;
|
|
243
298
|
}
|
|
244
299
|
|
package/src/workflow-tool.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { WorkflowError, WorkflowErrorCode } from "./errors.js";
|
|
13
13
|
import { parseWorkflowScript, runWorkflow, type WorkflowRunResult } from "./workflow.js";
|
|
14
14
|
import { WorkflowManager } from "./workflow-manager.js";
|
|
15
|
-
import { createWorkflowStorage } from "./workflow-saved.js";
|
|
15
|
+
import { createWorkflowStorage, type WorkflowStorage } from "./workflow-saved.js";
|
|
16
16
|
|
|
17
17
|
const workflowToolSchema = Type.Object({
|
|
18
18
|
script: Type.String({
|
|
@@ -54,11 +54,15 @@ export type WorkflowToolInput = {
|
|
|
54
54
|
export interface WorkflowToolOptions {
|
|
55
55
|
cwd?: string;
|
|
56
56
|
concurrency?: number;
|
|
57
|
+
/** Shared manager so background runs are reachable from the `/workflows` command. */
|
|
58
|
+
manager?: WorkflowManager;
|
|
59
|
+
/** Shared saved-workflow storage. */
|
|
60
|
+
storage?: WorkflowStorage;
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefinition<typeof workflowToolSchema, any> {
|
|
60
|
-
const manager = new WorkflowManager({ cwd: options.cwd, concurrency: options.concurrency });
|
|
61
|
-
const _storage = createWorkflowStorage(options.cwd ?? process.cwd());
|
|
64
|
+
const manager = options.manager ?? new WorkflowManager({ cwd: options.cwd, concurrency: options.concurrency });
|
|
65
|
+
const _storage = options.storage ?? createWorkflowStorage(options.cwd ?? process.cwd());
|
|
62
66
|
|
|
63
67
|
return defineTool({
|
|
64
68
|
name: "workflow",
|
|
@@ -103,8 +107,8 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin
|
|
|
103
107
|
text: [
|
|
104
108
|
`Workflow "${parsed.meta.name}" started in background.`,
|
|
105
109
|
`Run ID: ${runId}`,
|
|
106
|
-
`Use /
|
|
107
|
-
`Use /
|
|
110
|
+
`Use /workflows status ${runId} to check progress.`,
|
|
111
|
+
`Use /workflows stop ${runId} to cancel.`,
|
|
108
112
|
].join("\n"),
|
|
109
113
|
},
|
|
110
114
|
],
|
package/src/workflow.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import vm from "node:vm";
|
|
2
3
|
import type { Node } from "acorn";
|
|
3
4
|
import { parse } from "acorn";
|
|
@@ -22,6 +23,14 @@ export interface WorkflowMeta {
|
|
|
22
23
|
phases?: WorkflowMetaPhase[];
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/** One cached agent() result, keyed by its deterministic call index. */
|
|
27
|
+
export interface JournalEntry {
|
|
28
|
+
index: number;
|
|
29
|
+
/** sha256 of the call's identity (prompt + model + phase + agentType + schema). */
|
|
30
|
+
hash: string;
|
|
31
|
+
result: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
26
35
|
args?: unknown;
|
|
27
36
|
agent?: Pick<WorkflowAgent, "run">;
|
|
@@ -36,6 +45,12 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
|
36
45
|
persistLogs?: boolean;
|
|
37
46
|
/** Run ID for persistence. Auto-generated if not provided. */
|
|
38
47
|
runId?: string;
|
|
48
|
+
/** Resume: cached agent results keyed by deterministic call index. */
|
|
49
|
+
resumeJournal?: Map<number, JournalEntry>;
|
|
50
|
+
/** Resume: the run being resumed (informational; enables resume mode). */
|
|
51
|
+
resumeFromRunId?: string;
|
|
52
|
+
/** Called after each live agent completes so the caller can persist the journal. */
|
|
53
|
+
onAgentJournal?: (entry: JournalEntry) => void;
|
|
39
54
|
onLog?: (message: string) => void;
|
|
40
55
|
onPhase?: (title: string) => void;
|
|
41
56
|
onAgentStart?: (event: { label: string; phase?: string; prompt: string; model?: string }) => void;
|
|
@@ -75,6 +90,8 @@ interface RuntimeState {
|
|
|
75
90
|
logs: string[];
|
|
76
91
|
phases: string[];
|
|
77
92
|
agentCount: number;
|
|
93
|
+
/** Monotonic, assigned at lexical agent() call time — the stable resume key. */
|
|
94
|
+
callSeq: number;
|
|
78
95
|
spent: number;
|
|
79
96
|
tokenUsage: {
|
|
80
97
|
input: number;
|
|
@@ -112,6 +129,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
112
129
|
logs: [],
|
|
113
130
|
phases: [],
|
|
114
131
|
agentCount: 0,
|
|
132
|
+
callSeq: 0,
|
|
115
133
|
spent: 0,
|
|
116
134
|
tokenUsage: { input: 0, output: 0, total: 0, cost: 0 },
|
|
117
135
|
};
|
|
@@ -170,6 +188,22 @@ export async function runWorkflow<T = unknown>(
|
|
|
170
188
|
// Precedence: explicit agentOptions.model > phase model (meta.phases[].model).
|
|
171
189
|
const modelSpec = agentOptions.model ?? resolveModelForPhase(assignedPhase, routingConfig);
|
|
172
190
|
|
|
191
|
+
// Deterministic resume key: assigned at lexical call time, before the limiter,
|
|
192
|
+
// so parallel()/pipeline() fan-out is reproducible for a fixed script.
|
|
193
|
+
const callIndex = state.callSeq++;
|
|
194
|
+
const callHash = hashAgentCall(prompt, modelSpec, assignedPhase, agentOptions);
|
|
195
|
+
|
|
196
|
+
// Resume: replay a cached result for an unchanged call (matching hash), without
|
|
197
|
+
// consuming a concurrency slot, tokens, or a real subagent run.
|
|
198
|
+
const cached = options.resumeJournal?.get(callIndex);
|
|
199
|
+
if (cached && cached.hash === callHash) {
|
|
200
|
+
state.agentCount++;
|
|
201
|
+
const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
|
|
202
|
+
options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
|
|
203
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result: cached.result, tokens: 0 });
|
|
204
|
+
return cached.result;
|
|
205
|
+
}
|
|
206
|
+
|
|
173
207
|
return limiter(async () => {
|
|
174
208
|
state.agentCount++;
|
|
175
209
|
const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
|
|
@@ -214,6 +248,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
214
248
|
throwIfAborted();
|
|
215
249
|
|
|
216
250
|
const tokens = recordTokens(result);
|
|
251
|
+
options.onAgentJournal?.({ index: callIndex, hash: callHash, result });
|
|
217
252
|
options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens });
|
|
218
253
|
return result;
|
|
219
254
|
} catch (error) {
|
|
@@ -481,6 +516,23 @@ function defaultAgentLabel(phase: string | undefined, index: number): string {
|
|
|
481
516
|
return phase ? `${phase} agent ${index}` : `agent ${index}`;
|
|
482
517
|
}
|
|
483
518
|
|
|
519
|
+
/** Stable identity hash for an agent() call — a cache miss on resume when anything changes. */
|
|
520
|
+
function hashAgentCall(
|
|
521
|
+
prompt: string,
|
|
522
|
+
model: string | undefined,
|
|
523
|
+
phase: string | undefined,
|
|
524
|
+
options: AgentOptions,
|
|
525
|
+
): string {
|
|
526
|
+
const identity = JSON.stringify({
|
|
527
|
+
prompt,
|
|
528
|
+
model: model ?? null,
|
|
529
|
+
phase: phase ?? null,
|
|
530
|
+
agentType: options.agentType ?? null,
|
|
531
|
+
schema: options.schema ?? null,
|
|
532
|
+
});
|
|
533
|
+
return createHash("sha256").update(identity).digest("hex");
|
|
534
|
+
}
|
|
535
|
+
|
|
484
536
|
function buildAgentInstructions(phase: string | undefined, options: AgentOptions): string | undefined {
|
|
485
537
|
const lines = [];
|
|
486
538
|
if (phase) lines.push(`Workflow phase: ${phase}`);
|