@signalridge/pi-workflows 1.7.0 → 1.7.1

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,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.1
4
+ ### Patch Changes
5
+
6
+ - 1f586f8: Preserve resumable cancellation when a workflow pauses during successful timeout cleanup, and retain finalized subagent output alongside observer failures. Avoid retaining descendant-owned mouse gestures in bordered components. Track successful writes using final rewritten paths, and distinguish uncertain changes observed during failed calls from confirmed agent modifications while keeping both discoverable in file browsing. Preserve exact filesystem paths and lazy-directory scan state when discovering session-observed changes, including changes injected during an active scan.
7
+ - 1f586f8: - Bound workflow cancellation drains and durably fence unconfirmed cleanup, aborting sibling agents and releasing foreground callers.
8
+ - Await managed timeout cleanup before script continuation and terminal publication.
9
+ - Keep failed retry text and length responses discarded by overflow-compaction retry out of successful subagent fallbacks, while retaining valid pre-compaction and non-retried length output.
10
+ - Account for native prompts spanning reporter activation without dropping out-of-order wait debt.
11
+ - Reconcile settlement after observer-triggered manual compaction with run/session ownership.
12
+ - Preserve clipped overlay hit ranges and native container ancestry for editor autocomplete focus.
13
+ - Reconcile errored writes and edits against pre/post mutation evidence, retaining invocation cwd and releasing evidence when blocked or aborted preflight bypasses tool results.
14
+ - d9219d4: Correct shipped documentation that contradicted the code or the manifest.
15
+
16
+ - `pi-subagents`: the summary line still advertised protocol-v3 spawning while
17
+ the feature list and the managed-spawn section described v4.
18
+ - `pi-worktree`: the workspace-switching section named `/tree`; the registered
19
+ command is `/worktree`.
20
+ - `pi-workflows`: removed an orphaned sentence fragment left behind by an edit
21
+ to the saved-workflow paragraph.
22
+ - `pi-plan-mode`, `pi-goal`, `pi-usage-extension`: the stated Pi floor
23
+ (`0.80.6`, `0.42.4+`) contradicted the declared `^0.84.0` peer range, which
24
+ resolves to `0.84.x`. `pi-usage-extension` also carried a "Last updated"
25
+ stamp from a version that predates the workspace baseline.
26
+ - `pi-welcome`: the example card was a real session capture. It is now a
27
+ generic sample, and Install moved above the configuration sections instead of
28
+ sitting below them.
29
+ - 1f586f8: Preserve Codex opaque checkpoints across Pi's retry-only assistant-tail removal using optional, fingerprint-verified version-1 proof plus explicit lifecycle provenance. Preserve that provenance across same-runtime reload without confusing a new identical assistant response with the persisted tail; persisted rebuilds reset it and unknown provenance fails closed. Bound BTW context and tool-argument construction, including suffix-key storage for wide JSON-shaped objects (enumeration remains linear). Balance out-of-order native UI waiting notifications without affecting manual blocked ownership. Foreground workflow cancellation waits for exact-owner quiescence even when stop is unavailable or rejects after reconciliation already stopped the child, including cancelled spawn allocation recovery; unconfirmed cleanup becomes durably non-resumable with a diagnostic retaining any stop error. Background execution remains detached.
30
+
31
+ These are corrective changes: existing checkpoint fields, exports, and cross-extension protocols remain compatible, so no protocol or major-version bump is required.
32
+ - 1f586f8: Expand Pi peer support to `^0.84.0 || ^0.85.0`, retaining 0.84 compatibility while admitting 0.85 releases. The previous zero-major caret range excluded Pi 0.85.1. Update existing Pi development dependency pins to 0.85.1.
33
+ - 1f586f8: Keep goal iteration and continuation numbering correct when Pi compacts between tool results and the next response within one run. Preserve existing response limits and manual/post-run compaction handling.
34
+
35
+ Capture subagent invocation results from finalized assistant events with bounded message retention so compaction cannot hide a final error or mix in historical output. Recognize explicitly selected PowerShell when the host provides it, without requiring its export on older Pi versions or expanding default wildcard tools.
36
+
37
+ Use real session contexts for workflow replay and live TUI progress, release widget bindings on shutdown, and allow foreground checkpoint dialogs in RPC mode while retaining print-mode defaults. Cancel unanswered dialogs with their owning execution on tool abort, pause, stop, disposal, or fatal failure without journaling cancelled answers.
38
+
3
39
  ## 1.7.0
4
40
  ### Minor Changes
5
41
 
package/README.md CHANGED
@@ -208,7 +208,6 @@ sentence-ending dot is not a filename dot: "please run a workflow." arms, and
208
208
  turn it off entirely with `/workflows-trigger off`.
209
209
 
210
210
  Saved workflows use project scope first and a user scope fallback; writes are atomic and names are path-safe.
211
- written atomically with a backup.
212
211
 
213
212
  ## Model routing
214
213
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-workflows",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "Deterministic JavaScript orchestration workflows backed by pi-subagents managed spawning.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,8 +15,8 @@
15
15
  "lifecycle": "stable"
16
16
  },
17
17
  "peerDependencies": {
18
- "@earendil-works/pi-coding-agent": "^0.84.0",
19
- "@earendil-works/pi-tui": "^0.84.0",
18
+ "@earendil-works/pi-coding-agent": "^0.84.0 || ^0.85.0",
19
+ "@earendil-works/pi-tui": "^0.84.0 || ^0.85.0",
20
20
  "@signalridge/pi-subagents": ">=1.9.0"
21
21
  },
22
22
  "dependencies": {
@@ -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.7.0"
5
+ version: "1.7.1"
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.7.0`
5
+ Contract content / skill / extension: `1.7.1`
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.7.0`
5
+ Contract content / skill / extension: `1.7.1`
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
 
@@ -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.7.0"
5
+ version: "1.7.1"
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.7.0"
5
+ version: "1.7.1"
6
6
  ---
7
7
 
8
8
  # Workflow review
package/src/engine.ts CHANGED
@@ -66,6 +66,9 @@ import type { WorkflowStrengthTable } from "./strengths.js";
66
66
  export const MAX_ATTEMPTS_PER_NODE = 3;
67
67
 
68
68
  const BRANCH_QUIESCE_TIMEOUT_MS = 8_000;
69
+ // Internal deadline, longer than exact-owner RPC cleanup. Scripts can await
70
+ // arbitrary promises, so cancellation cannot rely on cooperative JS unwinding.
71
+ export const EXECUTION_DRAIN_TIMEOUT_MS = 10_000;
69
72
  const MAX_WAITERS = 256;
70
73
  const JOURNAL_RETRY_INITIAL_DELAY_MS = 25;
71
74
  const JOURNAL_RETRY_MAX_DELAY_MS = 2_000;
@@ -116,6 +119,7 @@ export interface ScriptStartOptions {
116
119
  choices?: string[];
117
120
  timeoutMs?: number;
118
121
  },
122
+ signal?: AbortSignal,
119
123
  ) => Promise<unknown>;
120
124
  /** Resolve a nested `workflow(name)` reference to the script it names. */
121
125
  loadSavedWorkflow?: (name: string) => string | undefined;
@@ -754,6 +758,7 @@ export class WorkflowEngine {
754
758
  this.setWorkflowStatus(run, "running");
755
759
  state.executionGeneration += 1;
756
760
  state.execution = this.execute(runId, script, startOptions, undefined, state.executionGeneration, protocol);
761
+ const execution = state.execution;
757
762
  // A run-fatal abort/stop already journals its terminal state; never let a
758
763
  // leftover rejection become an unhandled promise rejection.
759
764
  void state.execution.catch(() => {});
@@ -771,6 +776,10 @@ export class WorkflowEngine {
771
776
  };
772
777
  } catch (error: unknown) {
773
778
  if (!(error instanceof WorkflowWaitAbortedError)) throw error;
779
+ // Foreground cancellation owns execution, not just this waiter. Do not
780
+ // advertise resumability until runtime children and checkpoint UI unwind.
781
+ state.controller.abort();
782
+ await this.drainExecution(state, execution);
774
783
  const current = this.runs.get(runId)?.run ?? run;
775
784
  return { runId: current.runId, status: current.status, background: false, waitAborted: true };
776
785
  }
@@ -795,6 +804,7 @@ export class WorkflowEngine {
795
804
  if (!state) return undefined;
796
805
  if (state.lifecycleSuspended) return undefined;
797
806
  const run = state.run;
807
+ if (run.status === "running" || run.status === "pausing" || run.status === "stopping") return undefined;
798
808
  const providerTimer = this.providerResumeTimers.get(runId);
799
809
  if (providerTimer) {
800
810
  clearTimeout(providerTimer);
@@ -862,13 +872,16 @@ export class WorkflowEngine {
862
872
  state.executionGeneration,
863
873
  protocol,
864
874
  );
865
- void state.execution.catch(() => {});
875
+ const execution = state.execution;
876
+ void execution.catch(() => {});
866
877
  if (options.background) return { runId, status: run.status, background: true };
867
878
  try {
868
879
  const settled = await this.waitFor(runId, options.signal);
869
880
  return { runId, status: settled.status, background: false };
870
881
  } catch (error: unknown) {
871
882
  if (!(error instanceof WorkflowWaitAbortedError)) throw error;
883
+ state.controller.abort();
884
+ await this.drainExecution(state, execution);
872
885
  return { runId, status: run.status, background: false, waitAborted: true };
873
886
  }
874
887
  }
@@ -896,21 +909,7 @@ export class WorkflowEngine {
896
909
  // resumed from the journal. The script's in-flight agents are stopped by
897
910
  // the runtime's abort signal via the dispatch waiters.
898
911
  state.controller.abort();
899
- this.persist(runId, "pause-recovery", () =>
900
- this.write({
901
- kind: "run_recovery",
902
- schemaVersion: JOURNAL_SCHEMA_VERSION,
903
- runId,
904
- status: "interrupted",
905
- branchGeneration: this.recoveryBranchGeneration,
906
- rotations: [],
907
- recoveryId: deriveRecoveryId({ runId, rotations: [], timestamp: Date.now() }),
908
- timestamp: Date.now(),
909
- }),
910
- );
911
- run.status = "interrupted";
912
- run.updatedAt = Date.now();
913
- this.settleWaiters(runId, run);
912
+ await this.drainExecution(state, state.execution);
914
913
  } else if (run.status !== "paused" && run.status !== "interrupted") {
915
914
  throw new Error(`cannot pause workflow in state ${run.status}`);
916
915
  }
@@ -1017,11 +1016,34 @@ export class WorkflowEngine {
1017
1016
  const state = this.runs.get(runId);
1018
1017
  if (!state) throw new Error(`workflow run not found: ${runId}`);
1019
1018
  const generation = executionGeneration ?? state.executionGeneration;
1019
+ const executionSignal =
1020
+ !options.background && options.signal
1021
+ ? AbortSignal.any([state.controller.signal, options.signal])
1022
+ : state.controller.signal;
1020
1023
 
1024
+ const cleanupTasks = new Set<Promise<void>>();
1021
1025
  const runner: WorkflowAgentRunner = {
1022
1026
  run: async (prompt: string, runOptions: AgentRunOptions = {}) => {
1023
- return this.dispatchAgent(runId, prompt, runOptions);
1027
+ if (state.executionGeneration !== generation)
1028
+ throw new WorkflowError("stale workflow execution", WorkflowErrorCode.WORKFLOW_ABORTED);
1029
+ const dispatch = this.dispatchAgent(runId, prompt, runOptions);
1030
+ const settled = dispatch.then(
1031
+ () => {},
1032
+ () => {},
1033
+ );
1034
+ const trackCleanup = () => {
1035
+ cleanupTasks.add(settled);
1036
+ void settled.then(() => cleanupTasks.delete(settled));
1037
+ };
1038
+ runOptions.signal?.addEventListener("abort", trackCleanup, { once: true });
1039
+ if (runOptions.signal?.aborted) trackCleanup();
1040
+ try {
1041
+ return await dispatch;
1042
+ } finally {
1043
+ runOptions.signal?.removeEventListener("abort", trackCleanup);
1044
+ }
1024
1045
  },
1046
+ waitForCleanup: () => this.drainExecution(state, Promise.all([...cleanupTasks])),
1025
1047
  };
1026
1048
 
1027
1049
  try {
@@ -1043,7 +1065,7 @@ export class WorkflowEngine {
1043
1065
  agentRetries: options.agentRetries,
1044
1066
  tokenBudget: options.tokenBudget,
1045
1067
  agentTimeoutMs: options.agentTimeoutMs,
1046
- signal: state.controller.signal,
1068
+ signal: executionSignal,
1047
1069
  maxAgents: options.maxAgents,
1048
1070
  runId,
1049
1071
  executionNonce: `${generation}-${randomUUID()}`,
@@ -1078,6 +1100,12 @@ export class WorkflowEngine {
1078
1100
  }
1079
1101
  },
1080
1102
  });
1103
+ await runner.waitForCleanup?.();
1104
+ if (executionSignal.aborted) {
1105
+ throw new WorkflowError("workflow execution aborted", WorkflowErrorCode.WORKFLOW_ABORTED, {
1106
+ recoverable: true,
1107
+ });
1108
+ }
1081
1109
  const live = this.runs.get(runId);
1082
1110
  const run = live?.run;
1083
1111
  if (run && state.executionGeneration === generation && !isTerminalWorkflow(run.status)) {
@@ -1086,6 +1114,7 @@ export class WorkflowEngine {
1086
1114
  }
1087
1115
  return result;
1088
1116
  } catch (error: unknown) {
1117
+ await runner.waitForCleanup?.();
1089
1118
  const run = this.runs.get(runId)?.run;
1090
1119
  if (run && state.executionGeneration === generation && !isTerminalWorkflow(run.status)) {
1091
1120
  const message = error instanceof Error ? error.message : String(error);
@@ -1094,8 +1123,21 @@ export class WorkflowEngine {
1094
1123
  this.setWorkflowStatus(run, "paused", message);
1095
1124
  this.cleanupAgents(runId);
1096
1125
  this.scheduleProviderResume(runId, workflowError.resetHint);
1097
- } else if (workflowError.code === WorkflowErrorCode.WORKFLOW_ABORTED && state.controller.signal.aborted) {
1098
- this.setWorkflowStatus(run, "interrupted");
1126
+ } else if (workflowError.code === WorkflowErrorCode.WORKFLOW_ABORTED && executionSignal.aborted) {
1127
+ this.persist(runId, "abort-recovery", () =>
1128
+ this.write({
1129
+ kind: "run_recovery",
1130
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
1131
+ runId,
1132
+ status: "interrupted",
1133
+ branchGeneration: this.recoveryBranchGeneration,
1134
+ rotations: [],
1135
+ recoveryId: deriveRecoveryId({ runId, rotations: [], timestamp: Date.now() }),
1136
+ timestamp: Date.now(),
1137
+ }),
1138
+ );
1139
+ run.status = "interrupted";
1140
+ run.updatedAt = Date.now();
1099
1141
  } else {
1100
1142
  this.setWorkflowStatus(run, "failed", message);
1101
1143
  this.cleanupAgents(runId);
@@ -1119,7 +1161,7 @@ export class WorkflowEngine {
1119
1161
  if (this.disposed) throw new WorkflowEngineDisposedError();
1120
1162
  const state = this.runs.get(runId);
1121
1163
  if (!state) throw new Error(`workflow run not found: ${runId}`);
1122
- if (state.lifecycleSuspended || state.controller.signal.aborted) {
1164
+ if (state.lifecycleSuspended || state.controller.signal.aborted || runOptions.signal?.aborted) {
1123
1165
  throw new WorkflowError("workflow branch is quiescing", WorkflowErrorCode.WORKFLOW_ABORTED, {
1124
1166
  recoverable: true,
1125
1167
  });
@@ -1220,6 +1262,19 @@ export class WorkflowEngine {
1220
1262
  let response: ManagedSpawnResponse | string;
1221
1263
  try {
1222
1264
  response = await this.client.spawn(task, runId, spawnNodeId, spawnAttemptId, externalSignal);
1265
+ } catch (error: unknown) {
1266
+ if (externalSignal.aborted) {
1267
+ const reconciled = await this.client.reconcileManaged?.(spawnKey, owner).catch(() => undefined);
1268
+ const id = typeof reconciled === "string" ? reconciled : reconciled?.id;
1269
+ await this.stopAndQuiesce(
1270
+ state,
1271
+ id ?? spawnKey,
1272
+ owner,
1273
+ executionGeneration,
1274
+ id ? undefined : "cancelled spawn allocation could not be confirmed",
1275
+ );
1276
+ }
1277
+ throw error;
1223
1278
  } finally {
1224
1279
  externalSignal.removeEventListener("abort", onPendingAbort);
1225
1280
  state.pendingSpawns.delete(spawnKey);
@@ -1231,9 +1286,7 @@ export class WorkflowEngine {
1231
1286
  externalSignal.aborted
1232
1287
  ) {
1233
1288
  state.bufferedTerminals.delete(spawnKey);
1234
- await this.client
1235
- .stopOwned?.(responseId, { extension: "pi-workflows", runId, nodeId: spawnNodeId, attemptId: spawnAttemptId })
1236
- .catch(() => {});
1289
+ await this.stopAndQuiesce(state, responseId, owner, executionGeneration);
1237
1290
  throw new WorkflowError(
1238
1291
  "workflow dispatch became stale during pause/resume",
1239
1292
  WorkflowErrorCode.WORKFLOW_ABORTED,
@@ -1294,15 +1347,12 @@ export class WorkflowEngine {
1294
1347
  waiter.settled = true;
1295
1348
  state.agentWaiters.delete(pendingId);
1296
1349
  state.bufferedTerminals.delete(spawnKey);
1297
- void this.client
1298
- .stopOwned?.(pendingId, {
1299
- extension: "pi-workflows",
1300
- runId,
1301
- nodeId: spawnNodeId,
1302
- attemptId: spawnAttemptId,
1303
- })
1304
- .catch(() => {});
1305
- waiter.reject(new WorkflowError("workflow aborted", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true }));
1350
+ // Stop acknowledges cancellation, not provider/tool finally completion.
1351
+ void this.stopAndQuiesce(state, pendingId, owner, executionGeneration).then(() => {
1352
+ waiter.reject(
1353
+ new WorkflowError("workflow aborted", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true }),
1354
+ );
1355
+ });
1306
1356
  }
1307
1357
  };
1308
1358
 
@@ -1532,6 +1582,99 @@ export class WorkflowEngine {
1532
1582
  }
1533
1583
  }
1534
1584
 
1585
+ private async stopAndQuiesce(
1586
+ state: ScriptRunState,
1587
+ agentId: string,
1588
+ owner: WorkflowOwner,
1589
+ executionGeneration: number,
1590
+ unconfirmed?: string,
1591
+ ): Promise<void> {
1592
+ let timer: ReturnType<typeof setTimeout> | undefined;
1593
+ let stopError: unknown;
1594
+ try {
1595
+ if (unconfirmed) throw new Error(unconfirmed);
1596
+ await Promise.race([
1597
+ (async () => {
1598
+ try {
1599
+ if (!this.client.stopOwned) throw new Error("owned stop RPC unavailable");
1600
+ await this.client.stopOwned(agentId, owner);
1601
+ } catch (error: unknown) {
1602
+ // Reconciliation may already have stopped the record. Only exact-owner
1603
+ // quiescence confirms that provider/tool cleanup has actually settled.
1604
+ stopError = error;
1605
+ }
1606
+ if (!this.client.quiesceOwned) throw new Error("owned cleanup RPC unavailable");
1607
+ const result = await this.client.quiesceOwned(owner.runId, [agentId], BRANCH_QUIESCE_TIMEOUT_MS, [owner]);
1608
+ if (!result?.settled || result.pending.length !== 0) throw new Error("owned agents are not quiescent");
1609
+ })(),
1610
+ new Promise<never>((_resolve, reject) => {
1611
+ timer = setTimeout(() => reject(new Error("owned cleanup timed out")), BRANCH_QUIESCE_TIMEOUT_MS + 1_000);
1612
+ }),
1613
+ ]);
1614
+ } catch (error: unknown) {
1615
+ // Never turn uncertain cleanup into a resumable interruption. Existing
1616
+ // terminal recovery facts durably fence this run without a protocol change.
1617
+ if (this.runs.get(owner.runId) !== state || state.executionGeneration !== executionGeneration) return;
1618
+ const stopDiagnostic = stopError === undefined ? "" : `; stop failed: ${String(stopError)}`;
1619
+ const diagnostic = `Owned cleanup unconfirmed for ${agentId}: ${error instanceof Error ? error.message : String(error)}${stopDiagnostic}; run is non-resumable`;
1620
+ this.fenceExecution(state, executionGeneration, diagnostic);
1621
+ } finally {
1622
+ if (timer !== undefined) clearTimeout(timer);
1623
+ }
1624
+ }
1625
+
1626
+ private async drainExecution(state: ScriptRunState, execution?: Promise<unknown>): Promise<void> {
1627
+ if (!execution) return;
1628
+ const generation = state.executionGeneration;
1629
+ let timer: ReturnType<typeof setTimeout> | undefined;
1630
+ try {
1631
+ await Promise.race([
1632
+ execution.catch(() => {}),
1633
+ new Promise<void>((resolve) => {
1634
+ timer = setTimeout(() => {
1635
+ this.fenceExecution(state, generation, "Workflow execution cleanup timed out; run is non-resumable");
1636
+ resolve();
1637
+ }, EXECUTION_DRAIN_TIMEOUT_MS);
1638
+ }),
1639
+ ]);
1640
+ } finally {
1641
+ if (timer !== undefined) clearTimeout(timer);
1642
+ }
1643
+ }
1644
+
1645
+ private fenceExecution(state: ScriptRunState, generation: number, diagnostic: string): void {
1646
+ const run = state.run;
1647
+ if (this.runs.get(run.runId) !== state || state.executionGeneration !== generation) return;
1648
+ if (run.nonResumable && run.status === "stopped") return;
1649
+ run.nonResumable = true;
1650
+ const blockedNodeIds = Object.keys(run.callStatus).filter((nodeId) => run.callStatus[nodeId] !== "completed");
1651
+ for (const nodeId of blockedNodeIds) run.callStatus[nodeId] = "stopped";
1652
+ run.status = "stopped";
1653
+ run.error = diagnostic;
1654
+ run.terminalIntent = "stop";
1655
+ run.updatedAt = Date.now();
1656
+ const event: TerminalRecoveryEvent = {
1657
+ kind: "terminal_recovery",
1658
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
1659
+ runId: run.runId,
1660
+ status: "stopped",
1661
+ terminalIntent: "stop",
1662
+ branchGeneration: this.recoveryBranchGeneration,
1663
+ terminalResults: [],
1664
+ blockedNodeIds,
1665
+ error: diagnostic,
1666
+ recoveryId: deriveRecoveryId({ runId: run.runId, rotations: [], timestamp: Date.now() }),
1667
+ timestamp: Date.now(),
1668
+ };
1669
+ this.persist(run.runId, "cleanup-unconfirmed", () => this.write(event));
1670
+ // Fence before aborting: synchronous abort handlers may re-enter cleanup.
1671
+ // Exact-owner cleanup continues, but no later result may revive this run.
1672
+ state.executionGeneration += 1;
1673
+ state.controller.abort();
1674
+ this.cleanupAgents(run.runId);
1675
+ this.settleWaiters(run.runId, run);
1676
+ }
1677
+
1535
1678
  private async stop(state: ScriptRunState): Promise<void> {
1536
1679
  const run = state.run;
1537
1680
  if (isTerminalWorkflow(run.status)) return;
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { defineTool } from "@earendil-works/pi-coding-agent";
4
4
  import { Text } from "@earendil-works/pi-tui";
5
5
  import { Type } from "@sinclair/typebox";
@@ -157,8 +157,14 @@ function formatStart(result: ScriptStartResult): string {
157
157
  `Workflow ${result.background ? "started in background" : result.status}: ${result.runId}`,
158
158
  `status=${result.status}`,
159
159
  ];
160
- if (result.waitAborted)
161
- lines.push("wait_aborted=true", "The workflow run continues; retrieve it with workflow_control.");
160
+ if (result.waitAborted) {
161
+ lines.push(
162
+ "wait_aborted=true",
163
+ result.background
164
+ ? "Only the wait was cancelled; retrieve the detached run with workflow_control."
165
+ : "Foreground execution was cancelled; inspect its status with workflow_control before resuming.",
166
+ );
167
+ }
162
168
  if (result.result) lines.push("", result.result.slice(0, 8_000));
163
169
  if (result.error) lines.push("", `error=${result.error.slice(0, 2_000)}`);
164
170
  return lines.join("\n");
@@ -337,6 +343,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
337
343
  const MAX_PENDING_DELIVERIES = 256;
338
344
  const PENDING_DELIVERY_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
339
345
  let widgetTimer: ReturnType<typeof setInterval> | undefined;
346
+ let widgetUi: ExtensionContext["ui"] | undefined;
340
347
 
341
348
  const currentEngine = (): WorkflowEngine => {
342
349
  if (!active || !engine || engine.isDisposed())
@@ -345,12 +352,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
345
352
  };
346
353
 
347
354
  const clearWidget = (): void => {
348
- const tui = (pi as unknown as { ui?: { setWidget?: (key: string, content: string[] | undefined) => void } }).ui;
349
- tui?.setWidget?.("pi-workflows", undefined);
355
+ widgetUi?.setWidget("pi-workflows", undefined);
350
356
  };
351
357
  const refreshWidget = (): void => {
352
- const tui = (pi as unknown as { ui?: { setWidget?: (key: string, content: string[] | undefined) => void } }).ui;
353
- if (!tui?.setWidget) return;
358
+ const tui = widgetUi;
359
+ if (!tui) return;
354
360
  const workflowEngine = engine;
355
361
  if (!active || !workflowEngine || workflowEngine.isDisposed()) {
356
362
  tui.setWidget("pi-workflows", undefined);
@@ -375,15 +381,6 @@ export default function piWorkflows(pi: ExtensionAPI): void {
375
381
  };
376
382
  const notifyRunChanged = (): void => refreshWidget();
377
383
 
378
- const branchEntries = (): SessionEntryLike[] => {
379
- const ctx = (pi as unknown as { currentSessionManager?: { getBranch?: () => unknown } }).currentSessionManager;
380
- try {
381
- return (ctx?.getBranch?.() as SessionEntryLike[] | undefined) ?? [];
382
- } catch {
383
- return [];
384
- }
385
- };
386
-
387
384
  const DEFAULT_EXCLUDED_TOOLS = ["workflow", "workflow_control"] as const;
388
385
 
389
386
  /**
@@ -435,6 +432,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
435
432
  active = true;
436
433
  const workflowEngine = currentEngine();
437
434
  workflowEngine.onRunSettled = notifyRunChanged;
435
+ workflowEngine.onRuntimeEvent = notifyRunChanged;
438
436
  // `control("resume")` and the provider-limit retry start executions with no
439
437
  // caller to pass options for them, and the strength table is deliberately
440
438
  // never frozen onto a run. Without this they would resume with no table and
@@ -509,13 +507,13 @@ export default function piWorkflows(pi: ExtensionAPI): void {
509
507
  // Foreground checkpoint confirmation only when the run is not
510
508
  // background — background runs are headless by contract.
511
509
  confirm:
512
- params.background === false && ctx?.hasUI && ctx.mode === "tui"
513
- ? async (promptText, checkpointOptions) => {
510
+ params.background === false && ctx?.hasUI
511
+ ? async (promptText, checkpointOptions, signal) => {
514
512
  if (checkpointOptions.kind === "input")
515
- return ctx.ui.input(promptText, String(checkpointOptions.default ?? ""));
513
+ return ctx.ui.input(promptText, String(checkpointOptions.default ?? ""), { signal });
516
514
  if (checkpointOptions.kind === "select")
517
- return ctx.ui.select(promptText, checkpointOptions.choices ?? []);
518
- return ctx.ui.confirm(promptText, promptText);
515
+ return ctx.ui.select(promptText, checkpointOptions.choices ?? [], { signal });
516
+ return ctx.ui.confirm(promptText, promptText, { signal });
519
517
  }
520
518
  : undefined,
521
519
  loadSavedWorkflow: resolveNestedWorkflow,
@@ -525,7 +523,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
525
523
  const replacement = params.script !== undefined || params.name !== undefined ? script : undefined;
526
524
  const resumed = await workflowEngine.resume(
527
525
  params.resumeFromRunId,
528
- branchEntries(),
526
+ ctx.sessionManager.getBranch(),
529
527
  options,
530
528
  replacement,
531
529
  );
@@ -1116,6 +1114,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1116
1114
  } finally {
1117
1115
  workflowEngine.resumeLifecycle();
1118
1116
  branchQuiesce = undefined;
1117
+ refreshWidget();
1119
1118
  }
1120
1119
  });
1121
1120
  };
@@ -1257,6 +1256,10 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1257
1256
  {
1258
1257
  append(event) {
1259
1258
  pi.appendEntry(JOURNAL_ENTRY_TYPE, event);
1259
+ // Journal writes precede some state mutations; refresh once applied.
1260
+ queueMicrotask(() => {
1261
+ if (generation === lifecycleGeneration) refreshWidget();
1262
+ });
1260
1263
  },
1261
1264
  },
1262
1265
  () => {
@@ -1288,7 +1291,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1288
1291
  return;
1289
1292
  }
1290
1293
 
1294
+ if (generation !== lifecycleGeneration) {
1295
+ recoveredEngine.dispose();
1296
+ return;
1297
+ }
1291
1298
  engine = recoveredEngine;
1299
+ widgetUi = ctx.mode === "tui" ? ctx.ui : undefined;
1292
1300
  registerSurface();
1293
1301
 
1294
1302
  // Bind per-session delivery endpoint (A10): capture the session-bound send
@@ -1382,6 +1390,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1382
1390
  // engine quiescence; the old session must not accept another run.
1383
1391
  active = false;
1384
1392
  clearWidget();
1393
+ widgetUi = undefined;
1385
1394
  if (widgetTimer) {
1386
1395
  clearInterval(widgetTimer);
1387
1396
  widgetTimer = undefined;
package/src/rpc-client.ts CHANGED
@@ -380,7 +380,7 @@ export function createManagedSpawnClient(
380
380
  agentId,
381
381
  owner,
382
382
  },
383
- sessionSignal,
383
+ undefined,
384
384
  );
385
385
  },
386
386
  async reconcileManaged(spawnKey, owner) {
@@ -403,10 +403,15 @@ export function createManagedSpawnClient(
403
403
  ...(owners ? { owners } : {}),
404
404
  timeoutMs,
405
405
  },
406
- sessionSignal,
406
+ undefined,
407
407
  timeoutMs + 1_000,
408
408
  );
409
- if (!isRecord(data) || typeof data.settled !== "boolean" || !Array.isArray(data.pending)) {
409
+ if (
410
+ !isRecord(data) ||
411
+ typeof data.settled !== "boolean" ||
412
+ !Array.isArray(data.pending) ||
413
+ !data.pending.every((id) => typeof id === "string" && id.length > 0)
414
+ ) {
410
415
  throw new Error("subagents quiescence returned an invalid response");
411
416
  }
412
417
  const pending = data.pending.filter((id): id is string => typeof id === "string");
package/src/runtime.ts CHANGED
@@ -116,6 +116,8 @@ export type Limiter = <T>(fn: () => Promise<T>) => Promise<T>;
116
116
  /** The dispatch boundary the engine implements over spawn-managed RPC. */
117
117
  export interface WorkflowAgentRunner {
118
118
  run(prompt: string, options?: AgentRunOptions): Promise<unknown>;
119
+ /** Managed runners bound and confirm cleanup of aborted dispatches. */
120
+ waitForCleanup?(): Promise<void>;
119
121
  }
120
122
 
121
123
  export interface AgentRunOptions {
@@ -300,7 +302,7 @@ export interface WorkflowRunOptions {
300
302
  sharedStore?: SharedStore;
301
303
  /** Resolve a nested `workflow(name)` reference to the script it names. */
302
304
  loadSavedWorkflow?: (name: string) => string | undefined;
303
- confirm?: (promptText: string, options: CheckpointOptions) => Promise<unknown>;
305
+ confirm?: (promptText: string, options: CheckpointOptions, signal?: AbortSignal) => Promise<unknown>;
304
306
  onLog?: (message: string) => void;
305
307
  onPhase?: (title: string) => void;
306
308
  onRuntimeEvent?: (event: WorkflowRuntimeEvent) => void;
@@ -921,6 +923,7 @@ async function withTimeout<T>(
921
923
  * must degrade to the old behaviour, not to an agent that can hang forever.
922
924
  */
923
925
  onArm?: (restart: () => void) => void,
926
+ waitForCleanup?: () => Promise<void>,
924
927
  ): Promise<T> {
925
928
  if (ms === null) {
926
929
  onArm?.(() => {});
@@ -934,9 +937,11 @@ async function withTimeout<T>(
934
937
  // finished agent long after the fact, so settlement makes it inert here
935
938
  // rather than relying on every holder to guard its own call.
936
939
  let settled = false;
940
+ let timedOut = false;
937
941
 
938
942
  const timeoutPromise = new Promise<never>((_, reject) => {
939
943
  const fire = () => {
944
+ timedOut = true;
940
945
  try {
941
946
  onTimeout?.();
942
947
  } catch {
@@ -963,6 +968,9 @@ async function withTimeout<T>(
963
968
  } finally {
964
969
  settled = true;
965
970
  if (timeoutId) clearTimeout(timeoutId);
971
+ // The timeout wins the race immediately, but managed cleanup must settle
972
+ // (or durably fence the run) before script continuation and journal output.
973
+ if (timedOut) await waitForCleanup?.();
966
974
  }
967
975
  }
968
976
 
@@ -1722,6 +1730,7 @@ export async function runWorkflow<T = unknown>(
1722
1730
  (restart) => {
1723
1731
  restartTimeout = restart;
1724
1732
  },
1733
+ options.agent?.waitForCleanup,
1725
1734
  );
1726
1735
 
1727
1736
  throwIfAborted();
@@ -1789,6 +1798,18 @@ export async function runWorkflow<T = unknown>(
1789
1798
  });
1790
1799
  return result;
1791
1800
  } catch (error) {
1801
+ // A timeout waits for managed cleanup before reaching this catch.
1802
+ // Explicit cancellation may arrive during that await; it wins only
1803
+ // over this recoverable timeout, never over a fatal cleanup fence.
1804
+ if (
1805
+ options.signal?.aborted &&
1806
+ !shared.runFatalController.signal.aborted &&
1807
+ error instanceof WorkflowError &&
1808
+ error.recoverable &&
1809
+ error.code === WorkflowErrorCode.AGENT_TIMEOUT
1810
+ ) {
1811
+ throwIfAborted();
1812
+ }
1792
1813
  if (isAborted()) throw error;
1793
1814
 
1794
1815
  const workflowError = wrapError(error, { agentLabel: label });
@@ -2285,7 +2306,34 @@ export async function runWorkflow<T = unknown>(
2285
2306
 
2286
2307
  let reply: unknown;
2287
2308
  if (options.confirm) {
2288
- reply = await options.confirm(promptText, checkpointOptions);
2309
+ // Host-only cancellation must not affect author options or replay hashes.
2310
+ const signal = options.signal
2311
+ ? AbortSignal.any([options.signal, shared.runFatalController.signal])
2312
+ : shared.runFatalController.signal;
2313
+ const confirm = options.confirm;
2314
+ let onAbort: (() => void) | undefined;
2315
+ try {
2316
+ const cancelled = new Promise<never>((_resolve, reject) => {
2317
+ onAbort = () => {
2318
+ try {
2319
+ throwIfAborted();
2320
+ } catch (error) {
2321
+ reject(error);
2322
+ }
2323
+ };
2324
+ signal.addEventListener("abort", onAbort, { once: true });
2325
+ if (signal.aborted) onAbort();
2326
+ });
2327
+ // Older host callbacks may ignore the optional signal. Still release
2328
+ // the runtime wait, and observe any eventual callback rejection.
2329
+ const answer = Promise.resolve().then(() => {
2330
+ throwIfAborted();
2331
+ return confirm(promptText, checkpointOptions, signal);
2332
+ });
2333
+ reply = await Promise.race([answer, cancelled]);
2334
+ } finally {
2335
+ if (onAbort) signal.removeEventListener("abort", onAbort);
2336
+ }
2289
2337
  } else if (checkpointOptions.headless === "abort") {
2290
2338
  throw new WorkflowError(
2291
2339
  `checkpoint "${promptText}" needs human input but none is available (headless run)`,