@signalridge/pi-workflows 1.10.0 → 1.11.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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.11.0
4
+ ### Minor Changes
5
+
6
+ - c639660: Keep pending completion reminders cancellable until parent boundaries and consume deliberately read terminal results instead of replaying stale notifications.
7
+
8
+ Subagents selectively adapt tintinweb/pi-subagents #388's completion outbox, merge eligible batches into one steering message, and retain explicit groups, attempt ownership, and Claude Code notification XML. Successful terminal result reads suppress that attempt's pending reminder; unread failures remain eligible.
9
+
10
+ Background terminal log or explicit-ID status reads consume only exit notices, preserving unread monitor matches and future completion after running reads. Workflow get/status adds explicit `consume: true` with a branch-visible acknowledgement receipt, without consuming ordinary inspections or suppressing resumed executions. Already accepted transcript messages and historical results remain unchanged.
11
+
12
+ Stopped workflow state retains the accepted terminal event timestamp across slow appends and restore. Background reads prepare job data before committing exit consumption, and post-commit status-rendering failures cannot turn a successful read into an error. Early smart-group completions retain finalized membership separately from immutable results, with partial-group labels rechecked at handoff.
13
+
14
+ ## 1.10.1
15
+ ### Patch Changes
16
+
17
+ - c00f9f9: Wait for the exact terminal journal append receipt before publishing workflow completion, and recheck soft run/phase budgets after queue admission and before retry attempts.
18
+
3
19
  ## 1.10.0
4
20
  ### Minor Changes
5
21
 
package/README.md CHANGED
@@ -62,7 +62,13 @@ This is not a wall-clock deadline for legitimate agent awaits, and never polls a
62
62
  model. Cancellation/failure closes the actual owned helper through TERM, KILL
63
63
  escalation after 250ms, and a bounded 1.5-second close wait. Managed children must
64
64
  still settle exact-owner cleanup before terminal publication or resumability; a
65
- cleanup failure is fenced, not claimed as success. A returned value closes new
65
+ cleanup failure is fenced, not claimed as success. Terminal status, final results,
66
+ waiters and background notifications also wait for the exact accepted terminal
67
+ transition's journal append to succeed (an append receipt, not an fsync guarantee).
68
+ A transient append rejection retains the immutable transition for bounded retries.
69
+ Exhaustion never reports success: a receipted persistence failure may settle the run;
70
+ otherwise terminal publication remains pending. Stop or branch replacement
71
+ invalidates a queued success. A returned value closes new
66
72
  root-call admission but drains every accepted native operation and nested frame
67
73
  before success; callbacks needed by those operations remain available. Infinite
68
74
  awaits in forgotten helpers still require cancellation. Ignored native RPC promises
@@ -163,6 +169,14 @@ user's own LLM and run on the user's own machine.
163
169
  - `phase(title, { budget })`, `log(message)`, `args`, `cwd`, `process.cwd()`,
164
170
  `budget` (`{ total, spent(), remaining() }`).
165
171
 
172
+ Run and phase token budgets are soft pre-dispatch gates: they recheck completed
173
+ spend after acquiring runtime capacity and before every provider-agent attempt,
174
+ including empty-output retries and schema repairs. Queued calls do not retain an
175
+ earlier budget approval. Attempts already running can overshoot; this is not a
176
+ hard token or cost cap. Nested live calls share run spend, while phase allowances
177
+ remain frame-local and queued calls keep their captured phase allowance. Journal
178
+ replay neither re-executes nor charges completed calls.
179
+
166
180
  ### Resume
167
181
 
168
182
  A script edit replays the longest unchanged prefix from the journal and runs
@@ -255,6 +269,17 @@ The passive progress panel uses a terminal-height row budget, prioritizes live c
255
269
 
256
270
  ### Background result notifications
257
271
 
272
+ To explicitly read and acknowledge a terminal result, call
273
+ `workflow_control({ action: "get", run_id: "...", consume: true })` (`status`
274
+ is an alias). A `notificationConsumed: true` receipt means an acknowledgement
275
+ was observed on the active branch and the pending reminder was consumed.
276
+ Ordinary `get`/`status`, `list`, running results, and cancelled/stale reads do
277
+ not consume future completion. Consumption is scoped to that execution; a
278
+ later resumed execution remains eligible. Identity comes from the durable terminal
279
+ journal entry, not a runtime counter that restarts on restore. Older markers
280
+ without that evidence are not retroactively consumed. Receipts do not delete run history
281
+ or retract messages already handed to Pi.
282
+
258
283
  Completed background runs are buffered in branch-local pending-delivery markers
259
284
  (up to 256, expiring after seven days). Notifications wait for an owned, idle
260
285
  session with no queued input, input preparation, compaction, or navigation in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-workflows",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Deterministic JavaScript orchestration workflows backed by pi-subagents managed spawning.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -2,7 +2,7 @@
2
2
  name: workflow-authoring
3
3
  description: Design, write, review, and debug Pi JavaScript workflows that coordinate multiple subagents. Use when a request needs named dependencies, bounded fan-out, per-item pipelines, quality gates, saved or nested workflows, pause/resume, or workflow progress; do not use it merely to run an existing named workflow.
4
4
  metadata:
5
- version: "1.10.0"
5
+ version: "1.11.0"
6
6
  ---
7
7
 
8
8
  # Workflow authoring
@@ -2,7 +2,7 @@
2
2
  # Workflow capability index
3
3
 
4
4
  Contract format: `2.0.0`<br>
5
- Contract content / skill / extension: `1.10.0`
5
+ Contract content / skill / extension: `1.11.0`
6
6
 
7
7
  This compact generated index covers supported runtime globals and workflow-tool inputs. For constraints, compatibility behavior, internal boundaries, and dynamic-reference ownership, follow the [exhaustive generated facts](capability-details.md).
8
8
 
@@ -2,7 +2,7 @@
2
2
  # Exhaustive workflow capability facts
3
3
 
4
4
  Contract format: `2.0.0`<br>
5
- Contract content / skill / extension: `1.10.0`
5
+ Contract content / skill / extension: `1.11.0`
6
6
 
7
7
  Every exact fact below is projected from the installed extension's capability contract. Explanatory judgment belongs in the hand-written references next to this file.
8
8
 
@@ -8,7 +8,7 @@ Bound the work where the work is decided: graph tasks, fan-out items, loop round
8
8
 
9
9
  That pool is typically small — 4 slots unless the host was configured otherwise — so assume a wide fan-out runs in waves, not all at once. This does not change how wide to make it: the agents all run either way, and the reason to split work is coverage rather than wall-clock. It does mean a script should not promise the user a fast answer because it fanned out, and should not add a checkpoint or a progress phase that only makes sense if everything starts together.
10
10
 
11
- Enter a phase budget with `phase("Name", { budget: N })`; phase metadata does not carry budgets. `N` is a token allowance, not a call or round count: size it for the intended agent work instead of copying a small iteration limit. Token and phase budgets are soft pre-call gates. Spend lands after agents finish, so concurrent work can overshoot. A phase budget gates later calls in that phase; it neither reserves tokens nor cancels active calls. `budget.spent()` and `budget.remaining()` include nested work.
11
+ Enter a phase budget with `phase("Name", { budget: N })`; phase metadata does not carry budgets. `N` is a token allowance, not a call or round count: size it for the intended agent work instead of copying a small iteration limit. Token and phase budgets are soft pre-dispatch gates, rechecked after acquiring runtime capacity and before every provider-agent attempt (including empty-output retries and schema repairs). Queued calls keep their captured phase allowance, not an earlier budget approval or a later phase rebase. Spend lands after agents finish, so concurrent work can overshoot; these gates are not hard token or cost caps. Phase allowances remain frame-local; a phase budget neither reserves tokens nor cancels active calls. `budget.spent()` and `budget.remaining()` include shared nested run spend.
12
12
 
13
13
  ## Checkpoints
14
14
 
@@ -30,6 +30,8 @@ Resume replays only the longest unchanged prefix of journaled calls. Once one ca
30
30
 
31
31
  Only a call that finishes with a real result is journaled. A call whose every attempt was recoverable (including one that only ever produced `AGENT_EMPTY_OUTPUT`) contributes no journal entry, so resuming that run reruns exactly that call and everything lexically after it live; the earlier, already-succeeded prefix still replays from cache. Schema repair retries include the concrete validation errors in the next prompt.
32
32
 
33
+ Terminal status, final results, waiters and background notifications wait for the exact accepted terminal transition's journal append receipt, as well as existing owned-cleanup fences. This is an append guarantee, not an fsync guarantee. Transient append rejection retains the immutable transition for bounded retries. Exhaustion never reports success: a receipted persistence failure may settle the run; otherwise terminal publication remains pending. Stop or branch replacement invalidates a queued success.
34
+
33
35
  The runtime blocks common accidental nondeterminism, but this is not a security boundary. Pass timestamps, randomness, and external decisions through `args`.
34
36
 
35
37
  ## Nesting and shared state
@@ -2,7 +2,7 @@
2
2
  name: workflow-patterns
3
3
  description: Choose and run one of Pi's built-in multi-agent workflows for research, adversarial review, code review, multi-perspective analysis, or codebase auditing. Use when the request matches a named pattern; use workflow-authoring instead for a new script or custom topology.
4
4
  metadata:
5
- version: "1.10.0"
5
+ version: "1.11.0"
6
6
  ---
7
7
 
8
8
  # Built-in workflow patterns
@@ -2,7 +2,7 @@
2
2
  name: workflow-review
3
3
  description: Audit an existing Pi JavaScript workflow for topology, prompt/data flow, limits, failure handling, cancellation, resume safety, and publication compatibility. Use when reviewing or debugging a workflow script or saved run; do not use to run a standard named workflow.
4
4
  metadata:
5
- version: "1.10.0"
5
+ version: "1.11.0"
6
6
  ---
7
7
 
8
8
  # Workflow review
package/src/engine.ts CHANGED
@@ -245,6 +245,8 @@ export interface ScriptRunState {
245
245
  >;
246
246
  /** Journal retry bookkeeping. */
247
247
  pendingJournal: Set<string>;
248
+ /** Exact terminal append still awaiting its receipt; not a published run status. */
249
+ pendingTerminal?: object;
248
250
  /** Whether a lifecycle suspension is in effect (branch change). */
249
251
  lifecycleSuspended: boolean;
250
252
  }
@@ -490,6 +492,8 @@ export class WorkflowEngine {
490
492
  const epoch = this.branchEpoch;
491
493
  if (
492
494
  !this.isCurrentState(state, generation, epoch) ||
495
+ !isTerminalWorkflow(state.run.status) ||
496
+ state.pendingTerminal !== undefined ||
493
497
  state.pendingCleanup.size > 0 ||
494
498
  this.terminalNotified.has(state)
495
499
  )
@@ -519,6 +523,7 @@ export class WorkflowEngine {
519
523
  state.pendingSpawns.size > 0 ||
520
524
  state.agentWaiters.size > 0 ||
521
525
  state.pendingJournal.size > 0 ||
526
+ state.pendingTerminal !== undefined ||
522
527
  state.pendingCleanup.size > 0 ||
523
528
  state.activeExecutionGenerations.size > 0 ||
524
529
  state.stopOperation !== undefined ||
@@ -1111,8 +1116,11 @@ export class WorkflowEngine {
1111
1116
  if (!(error instanceof WorkflowWaitAbortedError)) throw error;
1112
1117
  // Foreground cancellation owns execution, not just this waiter. Do not
1113
1118
  // advertise resumability until runtime children and checkpoint UI unwind.
1119
+ // A failed receipt can outlive execution; retire it before aborting too.
1120
+ state.pendingTerminal = undefined;
1114
1121
  state.controller.abort();
1115
1122
  await this.drainExecution(state, execution);
1123
+ this.recoverInterruptedExecution(state, generation, epoch);
1116
1124
  const current = this.runs.get(runId)?.run ?? run;
1117
1125
  return { runId: current.runId, status: current.status, background: false, waitAborted: true };
1118
1126
  }
@@ -1274,8 +1282,10 @@ export class WorkflowEngine {
1274
1282
  return { runId, status: settled.status, background: false };
1275
1283
  } catch (error: unknown) {
1276
1284
  if (!(error instanceof WorkflowWaitAbortedError)) throw error;
1285
+ state.pendingTerminal = undefined;
1277
1286
  state.controller.abort();
1278
1287
  await this.drainExecution(state, execution);
1288
+ this.recoverInterruptedExecution(state, generation, epoch);
1279
1289
  return { runId, status: run.status, background: false, waitAborted: true };
1280
1290
  }
1281
1291
  }
@@ -1297,6 +1307,8 @@ export class WorkflowEngine {
1297
1307
  if (action === "get") return { action, run: this.summary(run, true) };
1298
1308
  if (action === "pause") {
1299
1309
  if (run.status === "running") {
1310
+ const generation = state.executionGeneration;
1311
+ const epoch = this.branchEpoch;
1300
1312
  this.setWorkflowStatus(run, "pausing");
1301
1313
  this.reconcilePendingSpawns(state);
1302
1314
  // Abort the script execution; the run settles as interrupted and can be
@@ -1304,6 +1316,7 @@ export class WorkflowEngine {
1304
1316
  // the runtime's abort signal via the dispatch waiters.
1305
1317
  state.controller.abort();
1306
1318
  await this.drainExecution(state, state.execution);
1319
+ this.recoverInterruptedExecution(state, generation, epoch);
1307
1320
  } else if (run.status !== "paused" && run.status !== "interrupted") {
1308
1321
  throw new Error(`cannot pause workflow in state ${run.status}`);
1309
1322
  }
@@ -1381,7 +1394,15 @@ export class WorkflowEngine {
1381
1394
  }
1382
1395
 
1383
1396
  private settleWaiters(runId: string, run: ScriptRun): void {
1384
- if (this.runs.get(runId)?.run !== run || this.hasPendingCleanup(runId)) return;
1397
+ const state = this.runs.get(runId);
1398
+ if (
1399
+ state?.run !== run ||
1400
+ !this.isCurrentState(state, state.executionGeneration, this.branchEpoch) ||
1401
+ state.pendingTerminal !== undefined ||
1402
+ this.hasPendingCleanup(runId) ||
1403
+ (!isTerminalWorkflow(run.status) && run.status !== "paused" && run.status !== "interrupted")
1404
+ )
1405
+ return;
1385
1406
  const all = this.waiters.get(runId);
1386
1407
  if (!all) return;
1387
1408
  for (const waiter of [...all]) {
@@ -1396,6 +1417,43 @@ export class WorkflowEngine {
1396
1417
 
1397
1418
  // ── execution ──────────────────────────────────────────────────────────────
1398
1419
 
1420
+ private recoverInterruptedExecution(state: ScriptRunState, generation: number, epoch: number): void {
1421
+ const run = state.run;
1422
+ const current = () =>
1423
+ this.runs.get(run.runId) === state &&
1424
+ state.executionGeneration === generation &&
1425
+ this.branchEpoch === epoch &&
1426
+ !this.disposed &&
1427
+ !state.stopOperation &&
1428
+ (run.status === "running" || run.status === "pausing");
1429
+ if (!current()) return;
1430
+ // Cancellation takes ownership before interruption is visible. An abandoned
1431
+ // completed/failed receipt must neither publish later nor block old waiters.
1432
+ state.pendingTerminal = undefined;
1433
+ const timestamp = Date.now();
1434
+ const recovery: RunRecoveryEvent = {
1435
+ kind: "run_recovery",
1436
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
1437
+ runId: run.runId,
1438
+ status: "interrupted",
1439
+ branchGeneration: this.recoveryBranchGeneration,
1440
+ rotations: [],
1441
+ recoveryId: deriveRecoveryId({ runId: run.runId, rotations: [], timestamp }),
1442
+ timestamp,
1443
+ };
1444
+ this.persist(run.runId, "abort-recovery", () => this.write(recovery));
1445
+ if (!current()) return;
1446
+ run.status = "interrupted";
1447
+ run.updatedAt = timestamp;
1448
+ // A pending failed receipt can outlive execute(). In that case there is no
1449
+ // finally left to publish interruption or settle pre-existing waiters.
1450
+ if (!state.activeExecutionGenerations.has(generation)) {
1451
+ state.presentation.settle(run.status);
1452
+ this.notifyInspection(state);
1453
+ this.settleWaiters(run.runId, run);
1454
+ }
1455
+ }
1456
+
1399
1457
  private async execute(
1400
1458
  state: ScriptRunState,
1401
1459
  script: string,
@@ -1579,7 +1637,13 @@ export class WorkflowEngine {
1579
1637
  const run = live?.run;
1580
1638
  if (run && isSameBranch() && !isTerminalWorkflow(run.status)) {
1581
1639
  this.setWorkflowStatus(run, "completed", undefined, undefined, result.result);
1582
- live.result = result.result;
1640
+ // Keep execution ownership until this newly accepted terminal fact has
1641
+ // its receipt too. Pause/stop may invalidate it while retries drain.
1642
+ await this.drainJournal(runId);
1643
+ if (!isCurrent() || executionSignal.aborted || run.status !== "completed")
1644
+ throw new WorkflowError("workflow execution aborted", WorkflowErrorCode.WORKFLOW_ABORTED, {
1645
+ recoverable: true,
1646
+ });
1583
1647
  }
1584
1648
  return result;
1585
1649
  } catch (error: unknown) {
@@ -1593,20 +1657,7 @@ export class WorkflowEngine {
1593
1657
  this.cleanupAgents(runId);
1594
1658
  this.scheduleProviderResume(runId, workflowError.resetHint);
1595
1659
  } else if (workflowError.code === WorkflowErrorCode.WORKFLOW_ABORTED && executionSignal.aborted) {
1596
- this.persist(runId, "abort-recovery", () =>
1597
- this.write({
1598
- kind: "run_recovery",
1599
- schemaVersion: JOURNAL_SCHEMA_VERSION,
1600
- runId,
1601
- status: "interrupted",
1602
- branchGeneration: this.recoveryBranchGeneration,
1603
- rotations: [],
1604
- recoveryId: deriveRecoveryId({ runId, rotations: [], timestamp: Date.now() }),
1605
- timestamp: Date.now(),
1606
- }),
1607
- );
1608
- run.status = "interrupted";
1609
- run.updatedAt = Date.now();
1660
+ this.recoverInterruptedExecution(state, generation, epoch);
1610
1661
  } else {
1611
1662
  this.cleanupAgents(runId);
1612
1663
  this.setWorkflowStatus(run, "failed", message);
@@ -2382,6 +2433,7 @@ export class WorkflowEngine {
2382
2433
  const epoch = this.branchEpoch;
2383
2434
  if (!this.isCurrentState(state, generation, epoch)) return;
2384
2435
  if (run.nonResumable && run.status === "stopped") return;
2436
+ state.pendingTerminal = undefined;
2385
2437
  run.nonResumable = true;
2386
2438
  const blockedNodeIds = Object.keys(run.callStatus).filter((nodeId) => run.callStatus[nodeId] !== "completed");
2387
2439
  for (const nodeId of blockedNodeIds) run.callStatus[nodeId] = "stopped";
@@ -2562,7 +2614,9 @@ export class WorkflowEngine {
2562
2614
  run.callStatus[terminal.nodeId] = "stopped";
2563
2615
  }
2564
2616
  run.status = "stopped";
2565
- run.updatedAt = Date.now();
2617
+ // Publish the accepted event's identity, just like other terminal paths.
2618
+ // A slow append must not detach the live state from its durable receipt.
2619
+ run.updatedAt = event.timestamp;
2566
2620
  state.presentation.settle(run.status);
2567
2621
  this.notifyInspection(state);
2568
2622
  if (current()) this.notifyTerminal(state);
@@ -2573,9 +2627,9 @@ export class WorkflowEngine {
2573
2627
  /**
2574
2628
  * A Pi lifecycle event is delivered once. If its durable append is transiently
2575
2629
  * unavailable, retain the mutation in memory and retry the append with
2576
- * backoff without requiring the peer to re-emit. The in-memory run state is
2577
- * authoritative; the journal is best-effort with bounded retries, and a run
2578
- * whose appends keep failing is marked blocked (reported once, not spammed).
2630
+ * backoff without requiring the peer to re-emit. Most in-memory facts can
2631
+ * lead the journal; terminal transitions instead publish inside their exact
2632
+ * append's receipt callback. Exhausted retries mark the journal blocked.
2579
2633
  */
2580
2634
  private scheduleJournalRetry(runId: string, key: string, mutation: () => void): void {
2581
2635
  // Every accepted fact has its own slot, including later retry generations
@@ -2660,10 +2714,8 @@ export class WorkflowEngine {
2660
2714
  }
2661
2715
 
2662
2716
  private write(event: JournalEvent): void {
2663
- // The in-memory mutation is applied by the caller BEFORE calling write;
2664
- // here we only persist. A transient failure schedules a retry of this exact
2665
- // append. Terminal bookkeeping already happened, so a retry that never
2666
- // succeeds degrades to a warning rather than corrupting run state.
2717
+ // Only append here. persist turns rejection into a retry of this exact
2718
+ // fact; receipt-gated callers publish only after write returns successfully.
2667
2719
  try {
2668
2720
  this.journal.append(event);
2669
2721
  } catch (error: unknown) {
@@ -2675,9 +2727,9 @@ export class WorkflowEngine {
2675
2727
  }
2676
2728
 
2677
2729
  /**
2678
- * Persist one journal event with bounded retry. The caller applies the
2679
- * in-memory mutation itself; `persist` only makes the append durable and, on
2680
- * a transient failure, schedules a retry of the exact same append.
2730
+ * Persist one journal event with bounded retry. The caller owns in-memory
2731
+ * publication; terminal transitions do it inside mutation after write returns.
2732
+ * A transient failure schedules a retry of the exact same accepted append.
2681
2733
  */
2682
2734
  private persist(runId: string, key: string, mutation: () => void): void {
2683
2735
  const acceptedState = this.runs.get(runId);
@@ -2768,36 +2820,53 @@ export class WorkflowEngine {
2768
2820
  const generation = state?.executionGeneration ?? 0;
2769
2821
  const epoch = this.branchEpoch;
2770
2822
  if (!state || state.run !== run || !this.isCurrentState(state, generation, epoch)) return;
2771
- // Validate and isolate terminal values before best-effort persistence or any
2772
- // status mutation. A bad result must still be able to transition the run to failed.
2823
+ // Capture once: retries must append the exact accepted transition, not a
2824
+ // newly timestamped status or a result still owned by the runtime.
2773
2825
  const durableFinalResult = finalResult === undefined ? undefined : cloneWorkflowResult(finalResult);
2774
2826
  const runFinalResult = finalResult === undefined ? undefined : cloneWorkflowResult(finalResult);
2775
2827
  const timestamp = Date.now();
2776
- this.persist(run.runId, `transition:${status}`, () =>
2777
- this.write({
2778
- kind: "workflow_transition",
2779
- schemaVersion: JOURNAL_SCHEMA_VERSION,
2780
- runId: run.runId,
2781
- status,
2782
- ...(error !== undefined ? { error } : {}),
2783
- ...(durableFinalResult !== undefined ? { finalResult: durableFinalResult } : {}),
2784
- ...(terminalIntent !== undefined ? { terminalIntent } : {}),
2785
- timestamp,
2786
- }),
2787
- );
2788
- if (!this.isCurrentState(state, generation, epoch)) return;
2789
- run.status = status;
2790
- if (status === "running") run.error = undefined;
2791
- if (error !== undefined) run.error = error;
2792
- if (runFinalResult !== undefined) run.finalResult = runFinalResult;
2793
- if (terminalIntent !== undefined) run.terminalIntent = terminalIntent;
2794
- run.updatedAt = timestamp;
2795
- this.notifyInspection(state);
2796
- if (!this.isCurrentState(state, generation, epoch)) return;
2797
- if (isTerminalWorkflow(status)) {
2798
- state.bufferedTerminals.clear();
2799
- this.notifyTerminal(state);
2800
- }
2828
+ const event: JournalEvent = {
2829
+ kind: "workflow_transition",
2830
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
2831
+ runId: run.runId,
2832
+ status,
2833
+ ...(error !== undefined ? { error } : {}),
2834
+ ...(durableFinalResult !== undefined ? { finalResult: durableFinalResult } : {}),
2835
+ ...(terminalIntent !== undefined ? { terminalIntent } : {}),
2836
+ timestamp,
2837
+ };
2838
+ const terminal = isTerminalWorkflow(status);
2839
+ const intent = terminal ? {} : undefined;
2840
+ // Stop/pause takes ownership synchronously, invalidating any queued success.
2841
+ state.pendingTerminal = intent;
2842
+ const current = () =>
2843
+ this.isCurrentState(state, generation, epoch) &&
2844
+ (!terminal || (state.pendingTerminal === intent && (status !== "completed" || !state.controller.signal.aborted)));
2845
+ const publish = () => {
2846
+ if (!current()) return;
2847
+ state.pendingTerminal = undefined;
2848
+ run.status = status;
2849
+ if (status === "running") run.error = undefined;
2850
+ if (error !== undefined) run.error = error;
2851
+ if (runFinalResult !== undefined) run.finalResult = runFinalResult;
2852
+ if (status === "completed") state.result = runFinalResult;
2853
+ if (terminalIntent !== undefined) run.terminalIntent = terminalIntent;
2854
+ run.updatedAt = timestamp;
2855
+ this.notifyInspection(state);
2856
+ if (!this.isCurrentState(state, generation, epoch)) return;
2857
+ if (terminal) {
2858
+ state.bufferedTerminals.clear();
2859
+ this.notifyTerminal(state);
2860
+ }
2861
+ };
2862
+ this.persist(run.runId, `transition:${status}`, () => {
2863
+ if (!current()) return;
2864
+ this.write(event);
2865
+ // write throws on append rejection inside persist, including its retries.
2866
+ // Only a receipt from this exact append may publish terminal state/result.
2867
+ if (terminal) publish();
2868
+ });
2869
+ if (!terminal) publish();
2801
2870
  }
2802
2871
 
2803
2872
  // ── restore / summary ──────────────────────────────────────────────────────
package/src/index.ts CHANGED
@@ -94,6 +94,12 @@ const ControlSchema = Type.Object(
94
94
  { description: "Control action" },
95
95
  ),
96
96
  run_id: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
97
+ consume: Type.Optional(
98
+ Type.Boolean({
99
+ description:
100
+ "With get/status only: explicitly acknowledge a reviewed terminal result and suppress its pending completion reminder. Running results and ordinary inspections are not consumed.",
101
+ }),
102
+ ),
97
103
  // Accept camelCase and legacy workflow_run_id for interop with pi-dynamic-workflows docs
98
104
  runId: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
99
105
  workflow_run_id: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
@@ -344,6 +350,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
344
350
  let lifecycleGeneration = 0;
345
351
  let deliveryGeneration = 0;
346
352
  const deliveryObservers = new Map<string, string>();
353
+ const observedExecutions = new Map<string, string>();
347
354
  let handledTreeEvent: unknown;
348
355
  let journalBranchAnchor: string | null | undefined;
349
356
  let protocolAbortController: AbortController | undefined;
@@ -370,7 +377,10 @@ export default function piWorkflows(pi: ExtensionAPI): void {
370
377
  { generation: number; context: ExtensionContext; engine: WorkflowEngine; anchor: string | null | undefined }
371
378
  >();
372
379
  /** Pending deliveries for completed runs, including durable recovery (A10). */
373
- const pendingDeliveryMarkers = new Map<string, { text: string; timestamp: number }>();
380
+ const pendingDeliveryMarkers = new Map<
381
+ string,
382
+ { text: string; timestamp: number; executionGeneration?: number; completionId?: string }
383
+ >();
374
384
  const PENDING_DELIVERY_TYPE = "pi-workflows:pending-delivery";
375
385
  const PENDING_DELIVERY_ACK_TYPE = "pi-workflows:pending-delivery-ack";
376
386
  const MAX_PENDING_DELIVERIES = 256;
@@ -426,6 +436,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
426
436
  workflowUiSurfaceId = randomUUID();
427
437
  deliveryGeneration++;
428
438
  deliveryObservers.clear();
439
+ observedExecutions.clear();
429
440
  turnArming.retire();
430
441
  wakeAdmissions.clear();
431
442
  if (deliveryTimer) clearTimeout(deliveryTimer);
@@ -880,7 +891,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
880
891
  });
881
892
  return new Text(theme.fg("dim", expanded ? text.slice(0, 8_000) : text.slice(0, 1_000)), 0, 0);
882
893
  },
883
- async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
894
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
884
895
  try {
885
896
  const rawAction = params.action as string;
886
897
  const action = (rawAction === "status" ? "get" : rawAction) as
@@ -895,7 +906,22 @@ export default function piWorkflows(pi: ExtensionAPI): void {
895
906
  }
896
907
  const runId = normalizeControlRunId(params as unknown as Record<string, unknown>);
897
908
  const workflowEngine = currentEngine();
909
+ if (params.consume && action !== "get") throw new Error("consume is supported only with get/status.");
910
+ const execution = runId ? workflowEngine.getState(runId)?.executionGeneration : undefined;
911
+ const ownerGeneration = deliveryGeneration;
912
+ const completionId =
913
+ params.consume && action === "get" && runId ? completionReceipt(workflowEngine, runId) : undefined;
898
914
  const result = await workflowEngine.control(action, runId);
915
+ signal?.throwIfAborted();
916
+ if (params.consume) {
917
+ const notificationConsumed =
918
+ !!runId &&
919
+ execution !== undefined &&
920
+ completionId !== undefined &&
921
+ consumeReviewedResult(workflowEngine, runId, execution, completionId, ownerGeneration, ctx);
922
+ const receipt = { ...result, notificationConsumed };
923
+ return textResult(JSON.stringify(receipt), receipt);
924
+ }
899
925
  return textResult(JSON.stringify(result), result);
900
926
  } catch (error: unknown) {
901
927
  return textResult(error instanceof Error ? error.message : String(error));
@@ -1548,9 +1574,92 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1548
1574
  });
1549
1575
  };
1550
1576
 
1551
- const rememberPendingDelivery = (runId: string, text: string, timestamp = Date.now()): void => {
1552
- if (!runId || timestamp + PENDING_DELIVERY_TTL_MS < Date.now()) return;
1553
- pendingDeliveryMarkers.set(runId, { text: text.slice(0, 8_000), timestamp });
1577
+ /** Native journal identity survives restore; runtime generation counters do not. */
1578
+ function completionReceipt(workflowEngine: WorkflowEngine, runId: string): string | undefined {
1579
+ const run = workflowEngine.getState(runId)?.run;
1580
+ if (!run || !["completed", "failed", "stopped"].includes(run.status)) return;
1581
+ try {
1582
+ const branch = deliveryContext?.sessionManager.getBranch() ?? [];
1583
+ for (let index = branch.length - 1; index >= 0; index--) {
1584
+ const entry = branch[index];
1585
+ if (entry.type !== "custom" || entry.customType !== JOURNAL_ENTRY_TYPE) continue;
1586
+ const data = entry.data as Record<string, unknown> | undefined;
1587
+ if (
1588
+ data?.runId === runId &&
1589
+ data.status === run.status &&
1590
+ data.timestamp === run.updatedAt &&
1591
+ (data.kind === "workflow_transition" || data.kind === "terminal_recovery")
1592
+ )
1593
+ return entry.id;
1594
+ }
1595
+ } catch {
1596
+ // Unavailable branch evidence cannot acknowledge a result.
1597
+ }
1598
+ }
1599
+
1600
+ function consumeReviewedResult(
1601
+ workflowEngine: WorkflowEngine,
1602
+ runId: string,
1603
+ execution: number,
1604
+ completionId: string,
1605
+ generation: number,
1606
+ ctx: ExtensionContext,
1607
+ ): boolean {
1608
+ const current = () =>
1609
+ generation === deliveryGeneration &&
1610
+ workflowEngine === engine &&
1611
+ ctx.sessionManager === deliveryContext?.sessionManager &&
1612
+ ownsDeliveryBranch() &&
1613
+ workflowEngine.getState(runId)?.executionGeneration === execution &&
1614
+ completionReceipt(workflowEngine, runId) === completionId;
1615
+ if (!current()) return false;
1616
+ // An older marker without a durable receipt cannot be attributed to the
1617
+ // reviewed execution. Preserve it rather than silently migrating history.
1618
+ if (pendingDeliveryMarkers.has(runId) && pendingDeliveryMarkers.get(runId)?.completionId === undefined)
1619
+ return false;
1620
+ if (observedExecutions.get(runId) === completionId) return true;
1621
+ try {
1622
+ const previous = new Set(ctx.sessionManager.getBranch().map((entry) => entry.id));
1623
+ pi.appendEntry(PENDING_DELIVERY_ACK_TYPE, {
1624
+ runId,
1625
+ executionGeneration: execution,
1626
+ completionId,
1627
+ observed: true,
1628
+ timestamp: Date.now(),
1629
+ });
1630
+ if (!current()) return false;
1631
+ const accepted = ctx.sessionManager.getBranch().some((entry) => {
1632
+ if (previous.has(entry.id) || entry.type !== "custom" || entry.customType !== PENDING_DELIVERY_ACK_TYPE)
1633
+ return false;
1634
+ const data = entry.data as Record<string, unknown> | undefined;
1635
+ return data?.runId === runId && data.completionId === completionId && data.observed === true;
1636
+ });
1637
+ if (!accepted) return false;
1638
+ advanceDeliveryAnchor();
1639
+ observedExecutions.set(runId, completionId);
1640
+ while (observedExecutions.size > MAX_PENDING_DELIVERIES)
1641
+ observedExecutions.delete(observedExecutions.keys().next().value as string);
1642
+ if (pendingDeliveryMarkers.get(runId)?.completionId === completionId) pendingDeliveryMarkers.delete(runId);
1643
+ return true;
1644
+ } catch {
1645
+ return false;
1646
+ }
1647
+ }
1648
+
1649
+ const rememberPendingDelivery = (
1650
+ runId: string,
1651
+ text: string,
1652
+ timestamp = Date.now(),
1653
+ executionGeneration?: number,
1654
+ completionId?: string,
1655
+ ): void => {
1656
+ if (
1657
+ !runId ||
1658
+ timestamp + PENDING_DELIVERY_TTL_MS < Date.now() ||
1659
+ (completionId !== undefined && observedExecutions.get(runId) === completionId)
1660
+ )
1661
+ return;
1662
+ pendingDeliveryMarkers.set(runId, { text: text.slice(0, 8_000), timestamp, executionGeneration, completionId });
1554
1663
  for (const [id, marker] of pendingDeliveryMarkers) {
1555
1664
  if (marker.timestamp + PENDING_DELIVERY_TTL_MS < Date.now()) pendingDeliveryMarkers.delete(id);
1556
1665
  }
@@ -1582,6 +1691,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1582
1691
  !["completed", "failed", "stopped"].includes(run.status)
1583
1692
  )
1584
1693
  return;
1694
+ const completionId = completionReceipt(workflowEngine, runId);
1695
+ if (completionId !== undefined && observedExecutions.get(runId) === completionId) return;
1585
1696
  const header = `Workflow ${run.runId} ${run.status}`;
1586
1697
  const state = workflowEngine.getState(runId);
1587
1698
  const finalResult = state?.result ?? run.finalResult;
@@ -1592,10 +1703,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1592
1703
  : `result:\n${typeof finalResult === "string" ? finalResult : (JSON.stringify(finalResult, null, 2) ?? String(finalResult))}`);
1593
1704
  const text = `${header}: ${body}`;
1594
1705
  if (!ownsDeliveryBranch()) return;
1595
- rememberPendingDelivery(runId, text);
1706
+ rememberPendingDelivery(runId, text, Date.now(), executionGeneration, completionId);
1596
1707
  try {
1597
1708
  pi.appendEntry(PENDING_DELIVERY_TYPE, {
1598
1709
  runId,
1710
+ executionGeneration,
1711
+ completionId,
1599
1712
  text: text.slice(0, 8_000),
1600
1713
  status: run.status,
1601
1714
  timestamp: Date.now(),
@@ -1633,22 +1746,37 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1633
1746
  };
1634
1747
  const hydratePendingDeliveries = (entries: SessionEntryLike[]): void => {
1635
1748
  pendingDeliveryMarkers.clear();
1749
+ observedExecutions.clear();
1636
1750
  for (const entry of entries) {
1637
1751
  const shaped = entry as { type?: unknown; customType?: unknown; data?: unknown };
1638
1752
  if (shaped.type !== "custom") continue;
1639
1753
  const data = shaped.data as Record<string, unknown> | undefined;
1640
1754
  if (typeof data?.runId !== "string") continue;
1641
- if (shaped.customType === PENDING_DELIVERY_ACK_TYPE) pendingDeliveryMarkers.delete(data.runId);
1642
- else if (shaped.customType === PENDING_DELIVERY_TYPE && typeof data.text === "string") {
1755
+ if (shaped.customType === PENDING_DELIVERY_ACK_TYPE) {
1756
+ // Explicit reads acknowledge exactly one durable terminal receipt.
1757
+ // Ordinary delivery ACKs still acknowledge the actual handed-off marker.
1758
+ if (
1759
+ data.observed !== true ||
1760
+ (typeof data.completionId === "string" &&
1761
+ pendingDeliveryMarkers.get(data.runId)?.completionId === data.completionId)
1762
+ )
1763
+ pendingDeliveryMarkers.delete(data.runId);
1764
+ } else if (shaped.customType === PENDING_DELIVERY_TYPE && typeof data.text === "string") {
1643
1765
  rememberPendingDelivery(
1644
1766
  data.runId,
1645
1767
  data.text,
1646
1768
  typeof data.timestamp === "number" ? data.timestamp : Date.now(),
1769
+ typeof data.executionGeneration === "number" ? data.executionGeneration : undefined,
1770
+ typeof data.completionId === "string" ? data.completionId : undefined,
1647
1771
  );
1648
1772
  }
1649
1773
  }
1650
1774
  };
1651
1775
  const flushPendingDeliveries = (): void => {
1776
+ for (const [runId, marker] of pendingDeliveryMarkers) {
1777
+ if (marker.completionId !== undefined && observedExecutions.get(runId) === marker.completionId)
1778
+ pendingDeliveryMarkers.delete(runId);
1779
+ }
1652
1780
  if (!canDeliver() || !deliveryContext || pendingDeliveryMarkers.size === 0) return;
1653
1781
  if (typeof pi.sendMessage !== "function" || typeof pi.sendUserMessage !== "function") return;
1654
1782
  const ctx = deliveryContext;
package/src/runtime.ts CHANGED
@@ -1700,29 +1700,34 @@ export async function runWorkflow<T = unknown>(
1700
1700
  // Reserve a real dispatch synchronously — no await occurs between this
1701
1701
  // check and the increments, so a parallel() burst cannot overshoot the cap.
1702
1702
  if (shared.physicalAgentCount >= maxAgents) throw agentLimitError();
1703
- if (budget.total !== null && budget.remaining() <= 0) {
1704
- throw new WorkflowError("workflow token budget exhausted", WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED, {
1705
- recoverable: false,
1706
- });
1707
- }
1708
- // Per-phase soft sub-budget gate: warns once at ~80%, throws at 100%.
1709
- if (assignedPhase) {
1710
- const pb = state.phaseBudgets.get(assignedPhase);
1711
- if (pb) {
1712
- const phaseSpent = shared.spent - pb.startSpent;
1713
- if (phaseSpent >= pb.budget) {
1703
+ // Capture this frame's phase allowance at lexical admission. A later phase
1704
+ // change/rebase must not donate a fresh allowance to queued work or retries.
1705
+ const phaseBudget = assignedPhase ? state.phaseBudgets.get(assignedPhase) : undefined;
1706
+ const checkBudget = () => {
1707
+ if (budget.total !== null && budget.remaining() <= 0) {
1708
+ throw new WorkflowError("workflow token budget exhausted", WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED, {
1709
+ recoverable: false,
1710
+ });
1711
+ }
1712
+ // Soft gate: completed spend is shared, but active calls reserve no tokens.
1713
+ if (phaseBudget) {
1714
+ const phaseSpent = shared.spent - phaseBudget.startSpent;
1715
+ if (phaseSpent >= phaseBudget.budget) {
1714
1716
  throw new WorkflowError(
1715
- `phase "${assignedPhase}" token sub-budget exhausted (${pb.budget})`,
1717
+ `phase "${assignedPhase}" token sub-budget exhausted (${phaseBudget.budget})`,
1716
1718
  WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED,
1717
1719
  { recoverable: false },
1718
1720
  );
1719
1721
  }
1720
- if (!pb.warned && phaseSpent >= pb.budget * 0.8) {
1721
- pb.warned = true;
1722
- log(`phase "${assignedPhase}" at ${Math.round((phaseSpent / pb.budget) * 100)}% of its token sub-budget`);
1722
+ if (!phaseBudget.warned && phaseSpent >= phaseBudget.budget * 0.8) {
1723
+ phaseBudget.warned = true;
1724
+ log(
1725
+ `phase "${assignedPhase}" at ${Math.round((phaseSpent / phaseBudget.budget) * 100)}% of its token sub-budget`,
1726
+ );
1723
1727
  }
1724
1728
  }
1725
- }
1729
+ };
1730
+ checkBudget();
1726
1731
  shared.agentCount++;
1727
1732
  frameAgentCount++;
1728
1733
  shared.physicalAgentCount++;
@@ -1737,6 +1742,7 @@ export async function runWorkflow<T = unknown>(
1737
1742
  await Promise.resolve();
1738
1743
  throwIfAborted();
1739
1744
  if (batch?.cancelled) throw agentLimitError();
1745
+ checkBudget();
1740
1746
 
1741
1747
  const timeout =
1742
1748
  agentOptions.timeoutMs !== undefined ? normalizeAgentTimeout(agentOptions.timeoutMs) : agentTimeoutMs;
@@ -1787,6 +1793,9 @@ export async function runWorkflow<T = unknown>(
1787
1793
 
1788
1794
  try {
1789
1795
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1796
+ // Outside the attempt catch: a rejected admission is not a provider
1797
+ // attempt and must not be charged an estimated failure or retried.
1798
+ checkBudget();
1790
1799
  usage = undefined;
1791
1800
  const externalSignal = options.signal;
1792
1801
  let onExternalAbort: (() => void) | undefined;