@signalridge/pi-workflows 1.7.5 → 1.8.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/src/engine.ts CHANGED
@@ -20,8 +20,9 @@
20
20
  import { createHash, randomUUID } from "node:crypto";
21
21
  import type { ManagedSpawnResponse } from "@signalridge/pi-subagents-protocol";
22
22
  import { isManagedAgentTier } from "@signalridge/pi-subagents-protocol";
23
- import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
23
+ import { describeWorkflowError, WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
24
24
  import {
25
+ applyRecoveryEvent,
25
26
  buildResumeJournal,
26
27
  type CallResult,
27
28
  type CallTierIdentity,
@@ -31,6 +32,7 @@ import {
31
32
  type JournalEvent,
32
33
  type JournalWriter,
33
34
  type RecoveryTerminalResult,
35
+ type RunRecoveryEvent,
34
36
  replayJournal,
35
37
  type ScriptRun,
36
38
  type SessionEntryLike,
@@ -311,6 +313,8 @@ export class WorkflowEngine {
311
313
  private readonly runs = new Map<string, ScriptRunState>();
312
314
  /** Fired when a run settles terminal; wired by the UI surface for widget refresh. */
313
315
  onRunSettled: (() => void) | undefined;
316
+ /** Every accepted background execution, including automatic and UI resumes. */
317
+ onBackgroundExecution: ((runId: string) => void) | undefined;
314
318
  /** Optional in-process observer for phase/task/quality runtime events. */
315
319
  onRuntimeEvent: ((runId: string, event: WorkflowRuntimeEvent) => void) | undefined;
316
320
  /**
@@ -339,10 +343,14 @@ export class WorkflowEngine {
339
343
  private admissionsClosed = false;
340
344
  private pendingAdmissions = 0;
341
345
  private recoveryBranchGeneration = 0;
342
- private readonly journalRetries = new Map<string, { timer?: ReturnType<typeof setTimeout>; attempt: number }>();
346
+ private readonly journalRetries = new Map<
347
+ string,
348
+ { timer?: ReturnType<typeof setTimeout>; attempt: number; done: Promise<void>; resolve: () => void }
349
+ >();
343
350
  private readonly journalBlockedRuns = new Set<string>();
344
351
  private readonly providerResumeTimers = new Map<string, ReturnType<typeof setTimeout>>();
345
352
  private journalMutationKey: string | undefined;
353
+ private journalMutationSequence = 0;
346
354
  private readonly quarantinedRunIds = new Set<string>();
347
355
 
348
356
  constructor(
@@ -867,8 +875,52 @@ export class WorkflowEngine {
867
875
  clearTimeout(providerTimer);
868
876
  this.providerResumeTimers.delete(runId);
869
877
  }
870
- if (run.nonResumable || (isTerminalWorkflow(run.status) && run.status !== "failed" && run.status !== "stopped"))
878
+ if (
879
+ run.nonResumable ||
880
+ run.terminalIntent === "stop" ||
881
+ (isTerminalWorkflow(run.status) && run.status !== "failed" && run.status !== "stopped")
882
+ )
871
883
  return undefined;
884
+ if (isTerminalWorkflow(run.status)) {
885
+ await this.drainExecution(state, state.execution);
886
+ for (const [nodeId, agentId] of Object.entries(run.agentIds)) {
887
+ if (run.callStatus[nodeId] !== "running") continue; // Completed/local calls own no live child.
888
+ const attemptId = run.attemptIds[nodeId];
889
+ if (!attemptId) continue;
890
+ await this.stopAndQuiesce(
891
+ state,
892
+ agentId,
893
+ {
894
+ extension: "pi-workflows",
895
+ runId,
896
+ nodeId: `call-${nodeId}`,
897
+ attemptId: `attempt-${generationFromAttemptId(attemptId)}`,
898
+ },
899
+ state.executionGeneration,
900
+ );
901
+ }
902
+ if (run.nonResumable || this.runs.get(runId) !== state || epoch !== this.branchEpoch || this.lifecyclePaused)
903
+ return undefined;
904
+ if (
905
+ this.journalBlockedRuns.has(runId) ||
906
+ [...this.journalRetries.keys()].some((key) => key.startsWith(`${runId}\u0000`))
907
+ )
908
+ throw new Error("Workflow journal has not settled; retry resume after persistence recovers");
909
+ const timestamp = Date.now();
910
+ const recovery: RunRecoveryEvent = {
911
+ kind: "run_recovery",
912
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
913
+ runId,
914
+ status: "interrupted",
915
+ branchGeneration: this.recoveryBranchGeneration,
916
+ rotations: [],
917
+ recoveryId: deriveRecoveryId({ runId, rotations: [], timestamp }),
918
+ timestamp,
919
+ };
920
+ this.appendRequired(recovery);
921
+ applyRecoveryEvent(run, recovery);
922
+ state.result = undefined;
923
+ }
872
924
  if (replacementScript !== undefined) {
873
925
  const parsed = parseWorkflowScript(replacementScript);
874
926
  const revision = (run.revision ?? 0) + 1;
@@ -931,7 +983,10 @@ export class WorkflowEngine {
931
983
  );
932
984
  const execution = state.execution;
933
985
  void execution.catch(() => {});
934
- if (options.background) return { runId, status: run.status, background: true };
986
+ if (options.background) {
987
+ this.onBackgroundExecution?.(runId);
988
+ return { runId, status: run.status, background: true };
989
+ }
935
990
  try {
936
991
  const settled = await this.waitFor(runId, options.signal);
937
992
  return { runId, status: settled.status, background: false };
@@ -1170,6 +1225,9 @@ export class WorkflowEngine {
1170
1225
  recoverable: true,
1171
1226
  });
1172
1227
  }
1228
+ // A terminal frame cannot overtake accepted journal facts. In particular,
1229
+ // a delayed call_attempt must precede its result, not reset it on replay.
1230
+ await this.drainJournal(runId);
1173
1231
  const live = this.runs.get(runId);
1174
1232
  const run = live?.run;
1175
1233
  if (run && isSameBranch() && !isTerminalWorkflow(run.status)) {
@@ -1181,7 +1239,7 @@ export class WorkflowEngine {
1181
1239
  await runner.waitForCleanup?.();
1182
1240
  const run = this.runs.get(runId)?.run;
1183
1241
  if (run && isSameBranch() && !isTerminalWorkflow(run.status)) {
1184
- const message = error instanceof Error ? error.message : String(error);
1242
+ const message = describeWorkflowError(error);
1185
1243
  const workflowError = error instanceof WorkflowError ? error : wrapError(error);
1186
1244
  if (workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT) {
1187
1245
  this.setWorkflowStatus(run, "paused", message);
@@ -1417,7 +1475,36 @@ export class WorkflowEngine {
1417
1475
  state.agentWaiters.delete(pendingId);
1418
1476
  state.bufferedTerminals.delete(spawnKey);
1419
1477
  // Stop acknowledges cancellation, not provider/tool finally completion.
1420
- void this.stopAndQuiesce(state, pendingId, owner, executionGeneration).then(() => {
1478
+ void this.stopAndQuiesce(state, pendingId, owner, executionGeneration).then(async () => {
1479
+ if (
1480
+ !nested &&
1481
+ this.runs.get(runId) === state &&
1482
+ state.executionGeneration === executionGeneration &&
1483
+ !run.nonResumable
1484
+ ) {
1485
+ const result = resultFromLifecycle(
1486
+ "stopped",
1487
+ pendingId,
1488
+ undefined,
1489
+ "workflow call cancelled",
1490
+ run.compactions[nodeId] ?? 0,
1491
+ undefined,
1492
+ resolvedTierIdentity?.tier ?? tier,
1493
+ );
1494
+ result.attemptId = attemptId;
1495
+ try {
1496
+ await this.drainJournal(runId);
1497
+ this.commitCallTerminal(runId, nodeId, result, true);
1498
+ } catch (error) {
1499
+ this.fenceExecution(
1500
+ state,
1501
+ executionGeneration,
1502
+ "Could not persist cancelled call terminal fact; run is non-resumable",
1503
+ );
1504
+ waiter.reject(error);
1505
+ return;
1506
+ }
1507
+ }
1421
1508
  waiter.reject(
1422
1509
  new WorkflowError("workflow aborted", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true }),
1423
1510
  );
@@ -1521,6 +1608,27 @@ export class WorkflowEngine {
1521
1608
  compactionCount: 0,
1522
1609
  updatedAt: Date.now(),
1523
1610
  };
1611
+ // Checkpoints are local calls, not managed children. Give their completed
1612
+ // result a journal identity without fabricating a manager record.
1613
+ if (!run.attemptIds[nodeId]) {
1614
+ const generation = (state.generations.get(nodeId) ?? 0) + 1;
1615
+ const attemptId = `${runId}/${nodeId}/attempt-${generation}`;
1616
+ this.appendRequired({
1617
+ kind: "call_attempt",
1618
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
1619
+ runId,
1620
+ nodeId,
1621
+ attemptId,
1622
+ generation,
1623
+ owner: { extension: "pi-workflows", runId, nodeId, attemptId },
1624
+ timestamp: Date.now(),
1625
+ });
1626
+ state.generations.set(nodeId, generation);
1627
+ run.attemptTracking = true;
1628
+ run.attempts[nodeId] = generation;
1629
+ run.attemptIds[nodeId] = attemptId;
1630
+ run.callStatus[nodeId] = "running";
1631
+ }
1524
1632
  const attemptId = run.attemptIds[nodeId];
1525
1633
  if (attemptId) callResult.attemptId = attemptId;
1526
1634
  const event: JournalEvent = {
@@ -1580,7 +1688,7 @@ export class WorkflowEngine {
1580
1688
  state.run.updatedAt = Date.now();
1581
1689
  }
1582
1690
 
1583
- private commitCallTerminal(runId: string, nodeId: string, result: CallResult): void {
1691
+ private commitCallTerminal(runId: string, nodeId: string, result: CallResult, required = false): void {
1584
1692
  const state = this.runs.get(runId);
1585
1693
  if (!state) return;
1586
1694
  const run = state.run;
@@ -1595,7 +1703,8 @@ export class WorkflowEngine {
1595
1703
  ...(attemptId ? { owner: { extension: "pi-workflows", runId, nodeId, attemptId } } : {}),
1596
1704
  timestamp: Date.now(),
1597
1705
  };
1598
- this.persist(runId, `call-terminal:${nodeId}`, () => this.write(event));
1706
+ if (required) this.appendRequired(event);
1707
+ else this.persist(runId, `call-terminal:${nodeId}`, () => this.write(event));
1599
1708
  run.callResults[nodeId] = result;
1600
1709
  run.callTiers[nodeId] = result.tier === undefined ? {} : { tier: result.tier };
1601
1710
  run.callStatus[nodeId] = result.status;
@@ -1811,15 +1920,24 @@ export class WorkflowEngine {
1811
1920
  * whose appends keep failing is marked blocked (reported once, not spammed).
1812
1921
  */
1813
1922
  private scheduleJournalRetry(runId: string, key: string, mutation: () => void): void {
1814
- const retryKey = `${runId}\u0000${key}`;
1815
- if (this.journalRetries.has(retryKey)) return;
1816
- const retry = { attempt: 0, timer: undefined as ReturnType<typeof setTimeout> | undefined };
1923
+ // Every accepted fact has its own slot, including later retry generations
1924
+ // for the same call. Coalescing by node key would silently drop that fact.
1925
+ const retryKey = `${runId}\u0000${key}\u0000${++this.journalMutationSequence}`;
1926
+ const predecessors = [...this.journalRetries.entries()]
1927
+ .filter(([id]) => id.startsWith(`${runId}\u0000`))
1928
+ .map(([, pending]) => pending.done);
1929
+ let resolve!: () => void;
1930
+ const done = new Promise<void>((settled) => {
1931
+ resolve = settled;
1932
+ });
1933
+ const retry = { attempt: 0, timer: undefined as ReturnType<typeof setTimeout> | undefined, done, resolve };
1817
1934
  const attempt = (): void => {
1818
1935
  const current = this.journalRetries.get(retryKey);
1819
1936
  if (!current) return;
1820
1937
  current.timer = undefined;
1821
1938
  if (this.lifecyclePaused || !this.runs.has(runId)) {
1822
1939
  this.journalRetries.delete(retryKey);
1940
+ current.resolve();
1823
1941
  return;
1824
1942
  }
1825
1943
  const previousKey = this.journalMutationKey;
@@ -1827,9 +1945,12 @@ export class WorkflowEngine {
1827
1945
  try {
1828
1946
  mutation();
1829
1947
  this.journalRetries.delete(retryKey);
1948
+ current.resolve();
1830
1949
  } catch (error: unknown) {
1831
1950
  if (!(error instanceof JournalAppendError)) {
1832
1951
  this.journalRetries.delete(retryKey);
1952
+ current.resolve();
1953
+ this.journalBlockedRuns.add(runId);
1833
1954
  console.warn(
1834
1955
  `[pi-workflows] journal retry abandoned: ${error instanceof Error ? error.message : String(error)}`,
1835
1956
  );
@@ -1838,6 +1959,7 @@ export class WorkflowEngine {
1838
1959
  current.attempt += 1;
1839
1960
  if (current.attempt >= JOURNAL_RETRY_MAX_ATTEMPTS) {
1840
1961
  this.journalRetries.delete(retryKey);
1962
+ current.resolve();
1841
1963
  this.journalBlockedRuns.add(runId);
1842
1964
  console.warn(`[pi-workflows] journal retry exhausted for ${runId}: ${error.message}`);
1843
1965
  return;
@@ -1852,7 +1974,19 @@ export class WorkflowEngine {
1852
1974
  }
1853
1975
  };
1854
1976
  this.journalRetries.set(retryKey, retry);
1855
- queueMicrotask(attempt);
1977
+ void Promise.all(predecessors).then(attempt);
1978
+ }
1979
+
1980
+ private async drainJournal(runId: string): Promise<void> {
1981
+ let pending = [...this.journalRetries.entries()].filter(([id]) => id.startsWith(`${runId}\u0000`));
1982
+ while (pending.length > 0) {
1983
+ await Promise.all(pending.map(([, retry]) => retry.done));
1984
+ pending = [...this.journalRetries.entries()].filter(([id]) => id.startsWith(`${runId}\u0000`));
1985
+ }
1986
+ if (this.journalBlockedRuns.has(runId))
1987
+ throw new WorkflowError("Workflow journal has not settled", WorkflowErrorCode.PERSISTENCE_ERROR, {
1988
+ recoverable: true,
1989
+ });
1856
1990
  }
1857
1991
 
1858
1992
  private appendRequired(event: JournalEvent): void {
@@ -1888,6 +2022,10 @@ export class WorkflowEngine {
1888
2022
  * a transient failure, schedules a retry of the exact same append.
1889
2023
  */
1890
2024
  private persist(runId: string, key: string, mutation: () => void): void {
2025
+ if ([...this.journalRetries.keys()].some((id) => id.startsWith(`${runId}\u0000`))) {
2026
+ this.scheduleJournalRetry(runId, key, mutation);
2027
+ return;
2028
+ }
1891
2029
  const previousKey = this.journalMutationKey;
1892
2030
  this.journalMutationKey = `${runId}:${key}`;
1893
2031
  try {
@@ -1942,8 +2080,9 @@ export class WorkflowEngine {
1942
2080
  this.providerResumeTimers.set(runId, timer);
1943
2081
  }
1944
2082
  private clearJournalRetries(): void {
1945
- for (const { timer } of this.journalRetries.values()) {
2083
+ for (const { timer, resolve } of this.journalRetries.values()) {
1946
2084
  if (timer) clearTimeout(timer);
2085
+ resolve();
1947
2086
  }
1948
2087
  this.journalRetries.clear();
1949
2088
  this.journalBlockedRuns.clear();
@@ -1977,6 +2116,7 @@ export class WorkflowEngine {
1977
2116
  }),
1978
2117
  );
1979
2118
  run.status = status;
2119
+ if (status === "running") run.error = undefined;
1980
2120
  if (error !== undefined) run.error = error;
1981
2121
  if (runFinalResult !== undefined) run.finalResult = runFinalResult;
1982
2122
  if (terminalIntent !== undefined) run.terminalIntent = terminalIntent;
@@ -2081,7 +2221,7 @@ export class WorkflowEngine {
2081
2221
  return {
2082
2222
  index: Number(nodeId),
2083
2223
  status,
2084
- ...(result?.result !== undefined ? { resultPreview: String(result.result).slice(0, 300) } : {}),
2224
+ ...(result?.result !== undefined ? { resultPreview: renderWorkflowValue(result.result).slice(0, 300) } : {}),
2085
2225
  ...(result?.error ? { error: result.error.slice(0, 300) } : {}),
2086
2226
  ...(result?.tokenCount !== undefined ? { tokenCount: result.tokenCount } : {}),
2087
2227
  ...(run.agentIds[nodeId] ? { agentId: run.agentIds[nodeId] } : {}),
package/src/errors.ts CHANGED
@@ -67,6 +67,25 @@ export class WorkflowError extends Error {
67
67
  }
68
68
  }
69
69
 
70
+ // Transported VM throws must not acquire provider/abort classification merely
71
+ // because IPC reconstructed them in the host Error realm. Keep provenance and
72
+ // helper-computed text outside user data (including non-Error object throws).
73
+ const scriptThrows = new WeakMap<object, string>();
74
+
75
+ export function markScriptThrow(value: unknown, text: string): void {
76
+ if ((typeof value === "object" && value !== null) || typeof value === "function") scriptThrows.set(value, text);
77
+ }
78
+
79
+ export function scriptThrowText(value: unknown): string | undefined {
80
+ return (typeof value === "object" && value !== null) || typeof value === "function"
81
+ ? scriptThrows.get(value)
82
+ : undefined;
83
+ }
84
+
85
+ export function describeWorkflowError(value: unknown): string {
86
+ return scriptThrowText(value) ?? (value instanceof Error ? value.message : String(value));
87
+ }
88
+
70
89
  /** Narrow an unknown failure to WorkflowError. */
71
90
  export function isWorkflowError(error: unknown): error is WorkflowError {
72
91
  return error instanceof WorkflowError;
@@ -102,6 +121,13 @@ function providerResetHint(message: string): string | undefined {
102
121
  */
103
122
  export function wrapError(error: unknown, context?: { agentLabel?: string }): WorkflowError {
104
123
  if (isWorkflowError(error)) return error;
124
+ const scriptText = scriptThrowText(error);
125
+ if (scriptText !== undefined)
126
+ return new WorkflowError(scriptText, WorkflowErrorCode.AGENT_EXECUTION_ERROR, {
127
+ recoverable: true,
128
+ agentLabel: context?.agentLabel,
129
+ details: error,
130
+ });
105
131
 
106
132
  if (isAbortError(error)) {
107
133
  return new WorkflowError(