@quintinshaw/pi-dynamic-workflows 1.3.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 CHANGED
@@ -133,7 +133,8 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
133
133
  - **Structured output** — JSON-Schema-validated subagent results
134
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
135
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, and remove background runs; runs started with `background: true` are reachable from the command
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
137
138
  - **Safety limits** — 1000-agent cap (`maxAgents`), per-agent timeout (`agentTimeoutMs`), recoverable-vs-fatal error classification
138
139
  - **Live progress + token/cost display**, `Esc` to abort
139
140
  - **Log persistence** to `.pi/workflows/runs/`
@@ -142,7 +143,6 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
142
143
 
143
144
  Tracked toward closer parity with Claude Code dynamic workflows:
144
145
 
145
- - **Resume** — journaled results, replay the unchanged prefix, run the rest live
146
146
  - **Worktree isolation** for parallel edits, and **bundled `/deep-research`**
147
147
  - **Saved workflows** as `/<name>` slash commands
148
148
 
@@ -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. */
@@ -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 a paused workflow.
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
  /**
@@ -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: "", // Don't persist script for security
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 a paused workflow.
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 !== "paused")
212
+ if (!persisted?.script || persisted.status === "completed")
191
213
  return false;
192
- // For now, resume creates a fresh run with completed agents' results cached
193
- // Full resume would require re-executing the script with cached results
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
  /**
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quintinshaw/pi-dynamic-workflows",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Claude-Code-style dynamic workflow orchestration for Pi.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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 {
@@ -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(managed: ManagedRun, script: string, args?: unknown): Promise<WorkflowRunResult> {
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: "", // Don't persist script for security
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 a paused workflow.
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 !== "paused") return false;
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
- // For now, resume creates a fresh run with completed agents' results cached
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.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}`);