@signalridge/pi-workflows 1.7.4 → 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(
@@ -351,6 +359,7 @@ export class WorkflowEngine {
351
359
  private readonly journal: JournalWriter,
352
360
  private readonly readEntries: () => readonly SessionEntryLike[] = () => [],
353
361
  protocolGate?: () => Promise<ManagedProtocolCheck>,
362
+ private readonly cwd: string = process.cwd(),
354
363
  ) {
355
364
  this.protocolGate =
356
365
  protocolGate ??
@@ -866,8 +875,52 @@ export class WorkflowEngine {
866
875
  clearTimeout(providerTimer);
867
876
  this.providerResumeTimers.delete(runId);
868
877
  }
869
- 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
+ )
870
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
+ }
871
924
  if (replacementScript !== undefined) {
872
925
  const parsed = parseWorkflowScript(replacementScript);
873
926
  const revision = (run.revision ?? 0) + 1;
@@ -930,7 +983,10 @@ export class WorkflowEngine {
930
983
  );
931
984
  const execution = state.execution;
932
985
  void execution.catch(() => {});
933
- 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
+ }
934
990
  try {
935
991
  const settled = await this.waitFor(runId, options.signal);
936
992
  return { runId, status: settled.status, background: false };
@@ -1110,6 +1166,7 @@ export class WorkflowEngine {
1110
1166
 
1111
1167
  try {
1112
1168
  const result = await runWorkflow(script, {
1169
+ cwd: this.cwd,
1113
1170
  args: options.args,
1114
1171
  agent: runner,
1115
1172
  mainModel: options.mainModel,
@@ -1168,6 +1225,9 @@ export class WorkflowEngine {
1168
1225
  recoverable: true,
1169
1226
  });
1170
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);
1171
1231
  const live = this.runs.get(runId);
1172
1232
  const run = live?.run;
1173
1233
  if (run && isSameBranch() && !isTerminalWorkflow(run.status)) {
@@ -1179,7 +1239,7 @@ export class WorkflowEngine {
1179
1239
  await runner.waitForCleanup?.();
1180
1240
  const run = this.runs.get(runId)?.run;
1181
1241
  if (run && isSameBranch() && !isTerminalWorkflow(run.status)) {
1182
- const message = error instanceof Error ? error.message : String(error);
1242
+ const message = describeWorkflowError(error);
1183
1243
  const workflowError = error instanceof WorkflowError ? error : wrapError(error);
1184
1244
  if (workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT) {
1185
1245
  this.setWorkflowStatus(run, "paused", message);
@@ -1415,7 +1475,36 @@ export class WorkflowEngine {
1415
1475
  state.agentWaiters.delete(pendingId);
1416
1476
  state.bufferedTerminals.delete(spawnKey);
1417
1477
  // Stop acknowledges cancellation, not provider/tool finally completion.
1418
- 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
+ }
1419
1508
  waiter.reject(
1420
1509
  new WorkflowError("workflow aborted", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true }),
1421
1510
  );
@@ -1519,6 +1608,27 @@ export class WorkflowEngine {
1519
1608
  compactionCount: 0,
1520
1609
  updatedAt: Date.now(),
1521
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
+ }
1522
1632
  const attemptId = run.attemptIds[nodeId];
1523
1633
  if (attemptId) callResult.attemptId = attemptId;
1524
1634
  const event: JournalEvent = {
@@ -1578,7 +1688,7 @@ export class WorkflowEngine {
1578
1688
  state.run.updatedAt = Date.now();
1579
1689
  }
1580
1690
 
1581
- private commitCallTerminal(runId: string, nodeId: string, result: CallResult): void {
1691
+ private commitCallTerminal(runId: string, nodeId: string, result: CallResult, required = false): void {
1582
1692
  const state = this.runs.get(runId);
1583
1693
  if (!state) return;
1584
1694
  const run = state.run;
@@ -1593,7 +1703,8 @@ export class WorkflowEngine {
1593
1703
  ...(attemptId ? { owner: { extension: "pi-workflows", runId, nodeId, attemptId } } : {}),
1594
1704
  timestamp: Date.now(),
1595
1705
  };
1596
- 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));
1597
1708
  run.callResults[nodeId] = result;
1598
1709
  run.callTiers[nodeId] = result.tier === undefined ? {} : { tier: result.tier };
1599
1710
  run.callStatus[nodeId] = result.status;
@@ -1809,15 +1920,24 @@ export class WorkflowEngine {
1809
1920
  * whose appends keep failing is marked blocked (reported once, not spammed).
1810
1921
  */
1811
1922
  private scheduleJournalRetry(runId: string, key: string, mutation: () => void): void {
1812
- const retryKey = `${runId}\u0000${key}`;
1813
- if (this.journalRetries.has(retryKey)) return;
1814
- 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 };
1815
1934
  const attempt = (): void => {
1816
1935
  const current = this.journalRetries.get(retryKey);
1817
1936
  if (!current) return;
1818
1937
  current.timer = undefined;
1819
1938
  if (this.lifecyclePaused || !this.runs.has(runId)) {
1820
1939
  this.journalRetries.delete(retryKey);
1940
+ current.resolve();
1821
1941
  return;
1822
1942
  }
1823
1943
  const previousKey = this.journalMutationKey;
@@ -1825,9 +1945,12 @@ export class WorkflowEngine {
1825
1945
  try {
1826
1946
  mutation();
1827
1947
  this.journalRetries.delete(retryKey);
1948
+ current.resolve();
1828
1949
  } catch (error: unknown) {
1829
1950
  if (!(error instanceof JournalAppendError)) {
1830
1951
  this.journalRetries.delete(retryKey);
1952
+ current.resolve();
1953
+ this.journalBlockedRuns.add(runId);
1831
1954
  console.warn(
1832
1955
  `[pi-workflows] journal retry abandoned: ${error instanceof Error ? error.message : String(error)}`,
1833
1956
  );
@@ -1836,6 +1959,7 @@ export class WorkflowEngine {
1836
1959
  current.attempt += 1;
1837
1960
  if (current.attempt >= JOURNAL_RETRY_MAX_ATTEMPTS) {
1838
1961
  this.journalRetries.delete(retryKey);
1962
+ current.resolve();
1839
1963
  this.journalBlockedRuns.add(runId);
1840
1964
  console.warn(`[pi-workflows] journal retry exhausted for ${runId}: ${error.message}`);
1841
1965
  return;
@@ -1850,7 +1974,19 @@ export class WorkflowEngine {
1850
1974
  }
1851
1975
  };
1852
1976
  this.journalRetries.set(retryKey, retry);
1853
- 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
+ });
1854
1990
  }
1855
1991
 
1856
1992
  private appendRequired(event: JournalEvent): void {
@@ -1886,6 +2022,10 @@ export class WorkflowEngine {
1886
2022
  * a transient failure, schedules a retry of the exact same append.
1887
2023
  */
1888
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
+ }
1889
2029
  const previousKey = this.journalMutationKey;
1890
2030
  this.journalMutationKey = `${runId}:${key}`;
1891
2031
  try {
@@ -1940,8 +2080,9 @@ export class WorkflowEngine {
1940
2080
  this.providerResumeTimers.set(runId, timer);
1941
2081
  }
1942
2082
  private clearJournalRetries(): void {
1943
- for (const { timer } of this.journalRetries.values()) {
2083
+ for (const { timer, resolve } of this.journalRetries.values()) {
1944
2084
  if (timer) clearTimeout(timer);
2085
+ resolve();
1945
2086
  }
1946
2087
  this.journalRetries.clear();
1947
2088
  this.journalBlockedRuns.clear();
@@ -1975,6 +2116,7 @@ export class WorkflowEngine {
1975
2116
  }),
1976
2117
  );
1977
2118
  run.status = status;
2119
+ if (status === "running") run.error = undefined;
1978
2120
  if (error !== undefined) run.error = error;
1979
2121
  if (runFinalResult !== undefined) run.finalResult = runFinalResult;
1980
2122
  if (terminalIntent !== undefined) run.terminalIntent = terminalIntent;
@@ -2079,7 +2221,7 @@ export class WorkflowEngine {
2079
2221
  return {
2080
2222
  index: Number(nodeId),
2081
2223
  status,
2082
- ...(result?.result !== undefined ? { resultPreview: String(result.result).slice(0, 300) } : {}),
2224
+ ...(result?.result !== undefined ? { resultPreview: renderWorkflowValue(result.result).slice(0, 300) } : {}),
2083
2225
  ...(result?.error ? { error: result.error.slice(0, 300) } : {}),
2084
2226
  ...(result?.tokenCount !== undefined ? { tokenCount: result.tokenCount } : {}),
2085
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(