@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/CHANGELOG.md +16 -0
- package/README.md +145 -10
- package/package.json +2 -1
- package/skills/workflow-authoring/SKILL.md +1 -1
- package/skills/workflow-authoring/references/capabilities.md +1 -1
- package/skills/workflow-authoring/references/capability-details.md +1 -1
- package/skills/workflow-patterns/SKILL.md +1 -1
- package/skills/workflow-review/SKILL.md +1 -1
- package/src/arming.ts +264 -3
- package/src/engine.ts +154 -14
- package/src/errors.ts +26 -0
- package/src/index.ts +376 -172
- package/src/journal.ts +5 -0
- package/src/runtime.ts +40 -20
- package/src/safe-text.ts +4 -35
- package/src/ui.ts +38 -14
- package/src/vm-bootstrap.ts +218 -0
- package/src/vm-helper.ts +502 -0
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<
|
|
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 (
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1815
|
-
|
|
1816
|
-
const
|
|
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
|
-
|
|
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:
|
|
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(
|