@signalridge/pi-workflows 1.6.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 +149 -0
- package/README.md +25 -3
- package/package.json +4 -4
- package/skills/workflow-authoring/SKILL.md +23 -4
- package/skills/workflow-authoring/examples/defensive-json-parsing.js +1 -1
- package/skills/workflow-authoring/examples/phased-budgets.js +1 -1
- package/skills/workflow-authoring/examples/saved-nested-workflows.js +1 -1
- package/skills/workflow-authoring/examples/structured-output.js +1 -1
- package/skills/workflow-authoring/references/capabilities.md +4 -4
- package/skills/workflow-authoring/references/capability-details.md +12 -6
- package/skills/workflow-authoring/references/lifecycle.md +5 -1
- package/skills/workflow-authoring/references/review.md +4 -1
- package/skills/workflow-patterns/SKILL.md +1 -1
- package/skills/workflow-review/SKILL.md +6 -4
- package/src/capabilities.ts +15 -5
- package/src/engine.ts +210 -33
- package/src/index.ts +72 -32
- package/src/rpc-client.ts +22 -4
- package/src/runtime.ts +167 -13
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;
|
|
@@ -136,12 +140,32 @@ interface AgentLifecycleWaiter {
|
|
|
136
140
|
generation: number;
|
|
137
141
|
onUsage?: (usage: AgentUsage) => void;
|
|
138
142
|
onHistory?: (history: unknown[]) => void;
|
|
143
|
+
/** Told once when the host reports this agent left the queue and started. */
|
|
144
|
+
onRunning?: () => void;
|
|
145
|
+
startedNotified: boolean;
|
|
139
146
|
executionGeneration: number;
|
|
140
147
|
resolve: (response: ManagedSpawnResponse) => void;
|
|
141
148
|
reject: (error: unknown) => void;
|
|
142
149
|
settled: boolean;
|
|
143
150
|
}
|
|
144
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Tell a waiter once that its agent started.
|
|
154
|
+
*
|
|
155
|
+
* Once, because the wall clock is restarted from this point: a repeated report
|
|
156
|
+
* — a duplicate event, or a reply that raced its own lifecycle event — would
|
|
157
|
+
* keep pushing the deadline out and quietly turn the timeout off.
|
|
158
|
+
*/
|
|
159
|
+
function notifyAgentRunning(waiter: AgentLifecycleWaiter): void {
|
|
160
|
+
if (waiter.startedNotified || waiter.settled) return;
|
|
161
|
+
waiter.startedNotified = true;
|
|
162
|
+
try {
|
|
163
|
+
waiter.onRunning?.();
|
|
164
|
+
} catch {
|
|
165
|
+
/* the clock is best effort; never fail a live agent over it */
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
145
169
|
interface AgentTerminalDelivery {
|
|
146
170
|
response: ManagedSpawnResponse;
|
|
147
171
|
usage?: AgentUsage;
|
|
@@ -432,6 +456,10 @@ export class WorkflowEngine {
|
|
|
432
456
|
if (updateCallTierIdentity(state, callNodeId, data.tier)) {
|
|
433
457
|
this.persistCallTierIdentity(state, callNodeId);
|
|
434
458
|
}
|
|
459
|
+
// A start is the moment the host admitted this agent from its queue. It is
|
|
460
|
+
// the only signal that separates waiting for a slot from doing the work, so
|
|
461
|
+
// the call's wall clock is restarted here rather than at dispatch.
|
|
462
|
+
if (eventName === "subagents:started" && waiterMatches && waiter) notifyAgentRunning(waiter);
|
|
435
463
|
// Created/started lifecycle facts carry managed identity before the RPC
|
|
436
464
|
// reply is necessarily available. They are observation-only here; terminal
|
|
437
465
|
// ownership is handled below once a completion/failure fact arrives.
|
|
@@ -730,6 +758,7 @@ export class WorkflowEngine {
|
|
|
730
758
|
this.setWorkflowStatus(run, "running");
|
|
731
759
|
state.executionGeneration += 1;
|
|
732
760
|
state.execution = this.execute(runId, script, startOptions, undefined, state.executionGeneration, protocol);
|
|
761
|
+
const execution = state.execution;
|
|
733
762
|
// A run-fatal abort/stop already journals its terminal state; never let a
|
|
734
763
|
// leftover rejection become an unhandled promise rejection.
|
|
735
764
|
void state.execution.catch(() => {});
|
|
@@ -747,6 +776,10 @@ export class WorkflowEngine {
|
|
|
747
776
|
};
|
|
748
777
|
} catch (error: unknown) {
|
|
749
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);
|
|
750
783
|
const current = this.runs.get(runId)?.run ?? run;
|
|
751
784
|
return { runId: current.runId, status: current.status, background: false, waitAborted: true };
|
|
752
785
|
}
|
|
@@ -771,6 +804,7 @@ export class WorkflowEngine {
|
|
|
771
804
|
if (!state) return undefined;
|
|
772
805
|
if (state.lifecycleSuspended) return undefined;
|
|
773
806
|
const run = state.run;
|
|
807
|
+
if (run.status === "running" || run.status === "pausing" || run.status === "stopping") return undefined;
|
|
774
808
|
const providerTimer = this.providerResumeTimers.get(runId);
|
|
775
809
|
if (providerTimer) {
|
|
776
810
|
clearTimeout(providerTimer);
|
|
@@ -838,13 +872,16 @@ export class WorkflowEngine {
|
|
|
838
872
|
state.executionGeneration,
|
|
839
873
|
protocol,
|
|
840
874
|
);
|
|
841
|
-
|
|
875
|
+
const execution = state.execution;
|
|
876
|
+
void execution.catch(() => {});
|
|
842
877
|
if (options.background) return { runId, status: run.status, background: true };
|
|
843
878
|
try {
|
|
844
879
|
const settled = await this.waitFor(runId, options.signal);
|
|
845
880
|
return { runId, status: settled.status, background: false };
|
|
846
881
|
} catch (error: unknown) {
|
|
847
882
|
if (!(error instanceof WorkflowWaitAbortedError)) throw error;
|
|
883
|
+
state.controller.abort();
|
|
884
|
+
await this.drainExecution(state, execution);
|
|
848
885
|
return { runId, status: run.status, background: false, waitAborted: true };
|
|
849
886
|
}
|
|
850
887
|
}
|
|
@@ -872,21 +909,7 @@ export class WorkflowEngine {
|
|
|
872
909
|
// resumed from the journal. The script's in-flight agents are stopped by
|
|
873
910
|
// the runtime's abort signal via the dispatch waiters.
|
|
874
911
|
state.controller.abort();
|
|
875
|
-
this.
|
|
876
|
-
this.write({
|
|
877
|
-
kind: "run_recovery",
|
|
878
|
-
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
879
|
-
runId,
|
|
880
|
-
status: "interrupted",
|
|
881
|
-
branchGeneration: this.recoveryBranchGeneration,
|
|
882
|
-
rotations: [],
|
|
883
|
-
recoveryId: deriveRecoveryId({ runId, rotations: [], timestamp: Date.now() }),
|
|
884
|
-
timestamp: Date.now(),
|
|
885
|
-
}),
|
|
886
|
-
);
|
|
887
|
-
run.status = "interrupted";
|
|
888
|
-
run.updatedAt = Date.now();
|
|
889
|
-
this.settleWaiters(runId, run);
|
|
912
|
+
await this.drainExecution(state, state.execution);
|
|
890
913
|
} else if (run.status !== "paused" && run.status !== "interrupted") {
|
|
891
914
|
throw new Error(`cannot pause workflow in state ${run.status}`);
|
|
892
915
|
}
|
|
@@ -993,11 +1016,34 @@ export class WorkflowEngine {
|
|
|
993
1016
|
const state = this.runs.get(runId);
|
|
994
1017
|
if (!state) throw new Error(`workflow run not found: ${runId}`);
|
|
995
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;
|
|
996
1023
|
|
|
1024
|
+
const cleanupTasks = new Set<Promise<void>>();
|
|
997
1025
|
const runner: WorkflowAgentRunner = {
|
|
998
1026
|
run: async (prompt: string, runOptions: AgentRunOptions = {}) => {
|
|
999
|
-
|
|
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
|
+
}
|
|
1000
1045
|
},
|
|
1046
|
+
waitForCleanup: () => this.drainExecution(state, Promise.all([...cleanupTasks])),
|
|
1001
1047
|
};
|
|
1002
1048
|
|
|
1003
1049
|
try {
|
|
@@ -1010,12 +1056,16 @@ export class WorkflowEngine {
|
|
|
1010
1056
|
: {
|
|
1011
1057
|
routingPolicy: protocol.routingPolicy,
|
|
1012
1058
|
routingPolicyFingerprint: protocol.routingPolicyFingerprint,
|
|
1059
|
+
// Read live at each start and resume, never frozen onto the run:
|
|
1060
|
+
// the pool is a host setting the user can change between them, and
|
|
1061
|
+
// a run that outlived a narrowing would dispatch into a queue.
|
|
1062
|
+
...(protocol.maxConcurrent === undefined ? {} : { hostMaxConcurrent: protocol.maxConcurrent }),
|
|
1013
1063
|
}),
|
|
1014
1064
|
concurrency: options.concurrency,
|
|
1015
1065
|
agentRetries: options.agentRetries,
|
|
1016
1066
|
tokenBudget: options.tokenBudget,
|
|
1017
1067
|
agentTimeoutMs: options.agentTimeoutMs,
|
|
1018
|
-
signal:
|
|
1068
|
+
signal: executionSignal,
|
|
1019
1069
|
maxAgents: options.maxAgents,
|
|
1020
1070
|
runId,
|
|
1021
1071
|
executionNonce: `${generation}-${randomUUID()}`,
|
|
@@ -1050,6 +1100,12 @@ export class WorkflowEngine {
|
|
|
1050
1100
|
}
|
|
1051
1101
|
},
|
|
1052
1102
|
});
|
|
1103
|
+
await runner.waitForCleanup?.();
|
|
1104
|
+
if (executionSignal.aborted) {
|
|
1105
|
+
throw new WorkflowError("workflow execution aborted", WorkflowErrorCode.WORKFLOW_ABORTED, {
|
|
1106
|
+
recoverable: true,
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1053
1109
|
const live = this.runs.get(runId);
|
|
1054
1110
|
const run = live?.run;
|
|
1055
1111
|
if (run && state.executionGeneration === generation && !isTerminalWorkflow(run.status)) {
|
|
@@ -1058,6 +1114,7 @@ export class WorkflowEngine {
|
|
|
1058
1114
|
}
|
|
1059
1115
|
return result;
|
|
1060
1116
|
} catch (error: unknown) {
|
|
1117
|
+
await runner.waitForCleanup?.();
|
|
1061
1118
|
const run = this.runs.get(runId)?.run;
|
|
1062
1119
|
if (run && state.executionGeneration === generation && !isTerminalWorkflow(run.status)) {
|
|
1063
1120
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -1066,8 +1123,21 @@ export class WorkflowEngine {
|
|
|
1066
1123
|
this.setWorkflowStatus(run, "paused", message);
|
|
1067
1124
|
this.cleanupAgents(runId);
|
|
1068
1125
|
this.scheduleProviderResume(runId, workflowError.resetHint);
|
|
1069
|
-
} else if (workflowError.code === WorkflowErrorCode.WORKFLOW_ABORTED &&
|
|
1070
|
-
this.
|
|
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();
|
|
1071
1141
|
} else {
|
|
1072
1142
|
this.setWorkflowStatus(run, "failed", message);
|
|
1073
1143
|
this.cleanupAgents(runId);
|
|
@@ -1091,7 +1161,7 @@ export class WorkflowEngine {
|
|
|
1091
1161
|
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
1092
1162
|
const state = this.runs.get(runId);
|
|
1093
1163
|
if (!state) throw new Error(`workflow run not found: ${runId}`);
|
|
1094
|
-
if (state.lifecycleSuspended || state.controller.signal.aborted) {
|
|
1164
|
+
if (state.lifecycleSuspended || state.controller.signal.aborted || runOptions.signal?.aborted) {
|
|
1095
1165
|
throw new WorkflowError("workflow branch is quiescing", WorkflowErrorCode.WORKFLOW_ABORTED, {
|
|
1096
1166
|
recoverable: true,
|
|
1097
1167
|
});
|
|
@@ -1192,6 +1262,19 @@ export class WorkflowEngine {
|
|
|
1192
1262
|
let response: ManagedSpawnResponse | string;
|
|
1193
1263
|
try {
|
|
1194
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;
|
|
1195
1278
|
} finally {
|
|
1196
1279
|
externalSignal.removeEventListener("abort", onPendingAbort);
|
|
1197
1280
|
state.pendingSpawns.delete(spawnKey);
|
|
@@ -1203,9 +1286,7 @@ export class WorkflowEngine {
|
|
|
1203
1286
|
externalSignal.aborted
|
|
1204
1287
|
) {
|
|
1205
1288
|
state.bufferedTerminals.delete(spawnKey);
|
|
1206
|
-
await this.
|
|
1207
|
-
.stopOwned?.(responseId, { extension: "pi-workflows", runId, nodeId: spawnNodeId, attemptId: spawnAttemptId })
|
|
1208
|
-
.catch(() => {});
|
|
1289
|
+
await this.stopAndQuiesce(state, responseId, owner, executionGeneration);
|
|
1209
1290
|
throw new WorkflowError(
|
|
1210
1291
|
"workflow dispatch became stale during pause/resume",
|
|
1211
1292
|
WorkflowErrorCode.WORKFLOW_ABORTED,
|
|
@@ -1266,15 +1347,12 @@ export class WorkflowEngine {
|
|
|
1266
1347
|
waiter.settled = true;
|
|
1267
1348
|
state.agentWaiters.delete(pendingId);
|
|
1268
1349
|
state.bufferedTerminals.delete(spawnKey);
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
})
|
|
1276
|
-
.catch(() => {});
|
|
1277
|
-
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
|
+
});
|
|
1278
1356
|
}
|
|
1279
1357
|
};
|
|
1280
1358
|
|
|
@@ -1287,11 +1365,17 @@ export class WorkflowEngine {
|
|
|
1287
1365
|
executionGeneration,
|
|
1288
1366
|
onUsage: runOptions.onUsage,
|
|
1289
1367
|
onHistory: runOptions.onHistory,
|
|
1368
|
+
onRunning: runOptions.onRunning,
|
|
1369
|
+
startedNotified: false,
|
|
1290
1370
|
settled: false,
|
|
1291
1371
|
resolve,
|
|
1292
1372
|
reject,
|
|
1293
1373
|
};
|
|
1294
1374
|
state.agentWaiters.set(pendingId, waiter);
|
|
1375
|
+
// A spawn that was admitted straight away is already running, and its
|
|
1376
|
+
// `subagents:started` event fired before this waiter existed. Only a
|
|
1377
|
+
// reply that says "queued" still has a start to wait for.
|
|
1378
|
+
if (typeof response !== "string" && response.state !== "queued") notifyAgentRunning(waiter);
|
|
1295
1379
|
externalSignal.addEventListener("abort", waitAbort, { once: true });
|
|
1296
1380
|
if (externalSignal.aborted) {
|
|
1297
1381
|
waitAbort();
|
|
@@ -1498,6 +1582,99 @@ export class WorkflowEngine {
|
|
|
1498
1582
|
}
|
|
1499
1583
|
}
|
|
1500
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
|
+
|
|
1501
1678
|
private async stop(state: ScriptRunState): Promise<void> {
|
|
1502
1679
|
const run = state.run;
|
|
1503
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";
|
|
@@ -36,11 +36,40 @@ const WorkflowToolSchema = Type.Object(
|
|
|
36
36
|
name: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
|
37
37
|
args: Type.Optional(Type.Any()),
|
|
38
38
|
background: Type.Optional(Type.Boolean()),
|
|
39
|
-
maxAgents: Type.Optional(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
maxAgents: Type.Optional(
|
|
40
|
+
Type.Number({
|
|
41
|
+
minimum: 1,
|
|
42
|
+
description:
|
|
43
|
+
"Cap on real agent dispatches for this run. Default 1000, which no ordinary script reaches. Omit it: a value below what the script dispatches stops the run partway with AGENT_LIMIT_EXCEEDED.",
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
concurrency: Type.Optional(
|
|
47
|
+
Type.Number({
|
|
48
|
+
minimum: 1,
|
|
49
|
+
description:
|
|
50
|
+
"Agents in flight at once. Defaults to the host's own subagent pool size, which is also the ceiling. Omit it: a lower number runs the same work more slowly and buys nothing, and a higher one is clamped.",
|
|
51
|
+
}),
|
|
52
|
+
),
|
|
53
|
+
agentRetries: Type.Optional(
|
|
54
|
+
Type.Number({
|
|
55
|
+
minimum: 0,
|
|
56
|
+
maximum: 3,
|
|
57
|
+
description: "Retries for a recoverable agent failure. Default 0; a failed call returns null to the script.",
|
|
58
|
+
}),
|
|
59
|
+
),
|
|
60
|
+
tokenBudget: Type.Optional(
|
|
61
|
+
Type.Number({
|
|
62
|
+
minimum: 1,
|
|
63
|
+
description: "Soft token ceiling for the whole run. Default unlimited; exhausting a set budget aborts the run.",
|
|
64
|
+
}),
|
|
65
|
+
),
|
|
66
|
+
agentTimeoutMs: Type.Optional(
|
|
67
|
+
Type.Number({
|
|
68
|
+
minimum: 1,
|
|
69
|
+
description:
|
|
70
|
+
"Wall clock per agent, there to bound a hang. Default 1800000 (30min), and it counts from when the agent actually starts, not from dispatch. An agent's real budget is the host's turn, tool and token ceiling. Omit it: a short value fails an entire fan-out as timeouts.",
|
|
71
|
+
}),
|
|
72
|
+
),
|
|
44
73
|
resumeFromRunId: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
|
45
74
|
},
|
|
46
75
|
{ additionalProperties: false },
|
|
@@ -128,8 +157,14 @@ function formatStart(result: ScriptStartResult): string {
|
|
|
128
157
|
`Workflow ${result.background ? "started in background" : result.status}: ${result.runId}`,
|
|
129
158
|
`status=${result.status}`,
|
|
130
159
|
];
|
|
131
|
-
if (result.waitAborted)
|
|
132
|
-
lines.push(
|
|
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
|
+
}
|
|
133
168
|
if (result.result) lines.push("", result.result.slice(0, 8_000));
|
|
134
169
|
if (result.error) lines.push("", `error=${result.error.slice(0, 2_000)}`);
|
|
135
170
|
return lines.join("\n");
|
|
@@ -139,7 +174,7 @@ function formatStart(result: ScriptStartResult): string {
|
|
|
139
174
|
export function buildAdHocWorkflowScript(prompt: string): string {
|
|
140
175
|
return `export const meta = { name: "ad-hoc", description: "Plan, execute, and synthesize a bounded multi-agent task graph" };
|
|
141
176
|
const originalPrompt = ${JSON.stringify(prompt)};
|
|
142
|
-
const plan = await agent("Design a bounded execution plan for this user task. Return 1-
|
|
177
|
+
const plan = await agent("Design a bounded execution plan for this user task. Return 1-24 independent or dependency-linked tasks. Use unique short ids, concise descriptions, exact worker prompts, and only dependencies that appear in the task list. Prefer independent tasks when possible, and prefer narrow tasks over few broad ones: a task asked one question is read further into than a task asked four. User task:\\n\\n" + originalPrompt, {
|
|
143
178
|
label: "planner",
|
|
144
179
|
strength: "medium",
|
|
145
180
|
schema: {
|
|
@@ -148,14 +183,14 @@ const plan = await agent("Design a bounded execution plan for this user task. Re
|
|
|
148
183
|
tasks: {
|
|
149
184
|
type: "array",
|
|
150
185
|
minItems: 1,
|
|
151
|
-
maxItems:
|
|
186
|
+
maxItems: 24,
|
|
152
187
|
items: {
|
|
153
188
|
type: "object",
|
|
154
189
|
properties: {
|
|
155
190
|
id: { type: "string", minLength: 1, maxLength: 64 },
|
|
156
191
|
description: { type: "string", minLength: 1, maxLength: 500 },
|
|
157
192
|
prompt: { type: "string", minLength: 1, maxLength: 8000 },
|
|
158
|
-
dependsOn: { type: "array", maxItems:
|
|
193
|
+
dependsOn: { type: "array", maxItems: 24, items: { type: "string", minLength: 1, maxLength: 64 } },
|
|
159
194
|
},
|
|
160
195
|
required: ["id", "description", "prompt"],
|
|
161
196
|
additionalProperties: false,
|
|
@@ -308,6 +343,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
308
343
|
const MAX_PENDING_DELIVERIES = 256;
|
|
309
344
|
const PENDING_DELIVERY_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
310
345
|
let widgetTimer: ReturnType<typeof setInterval> | undefined;
|
|
346
|
+
let widgetUi: ExtensionContext["ui"] | undefined;
|
|
311
347
|
|
|
312
348
|
const currentEngine = (): WorkflowEngine => {
|
|
313
349
|
if (!active || !engine || engine.isDisposed())
|
|
@@ -316,12 +352,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
316
352
|
};
|
|
317
353
|
|
|
318
354
|
const clearWidget = (): void => {
|
|
319
|
-
|
|
320
|
-
tui?.setWidget?.("pi-workflows", undefined);
|
|
355
|
+
widgetUi?.setWidget("pi-workflows", undefined);
|
|
321
356
|
};
|
|
322
357
|
const refreshWidget = (): void => {
|
|
323
|
-
const tui =
|
|
324
|
-
if (!tui
|
|
358
|
+
const tui = widgetUi;
|
|
359
|
+
if (!tui) return;
|
|
325
360
|
const workflowEngine = engine;
|
|
326
361
|
if (!active || !workflowEngine || workflowEngine.isDisposed()) {
|
|
327
362
|
tui.setWidget("pi-workflows", undefined);
|
|
@@ -346,15 +381,6 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
346
381
|
};
|
|
347
382
|
const notifyRunChanged = (): void => refreshWidget();
|
|
348
383
|
|
|
349
|
-
const branchEntries = (): SessionEntryLike[] => {
|
|
350
|
-
const ctx = (pi as unknown as { currentSessionManager?: { getBranch?: () => unknown } }).currentSessionManager;
|
|
351
|
-
try {
|
|
352
|
-
return (ctx?.getBranch?.() as SessionEntryLike[] | undefined) ?? [];
|
|
353
|
-
} catch {
|
|
354
|
-
return [];
|
|
355
|
-
}
|
|
356
|
-
};
|
|
357
|
-
|
|
358
384
|
const DEFAULT_EXCLUDED_TOOLS = ["workflow", "workflow_control"] as const;
|
|
359
385
|
|
|
360
386
|
/**
|
|
@@ -406,6 +432,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
406
432
|
active = true;
|
|
407
433
|
const workflowEngine = currentEngine();
|
|
408
434
|
workflowEngine.onRunSettled = notifyRunChanged;
|
|
435
|
+
workflowEngine.onRuntimeEvent = notifyRunChanged;
|
|
409
436
|
// `control("resume")` and the provider-limit retry start executions with no
|
|
410
437
|
// caller to pass options for them, and the strength table is deliberately
|
|
411
438
|
// never frozen onto a run. Without this they would resume with no table and
|
|
@@ -427,9 +454,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
427
454
|
promptGuidelines: [
|
|
428
455
|
"Provide either a full `script` (raw JavaScript with the meta contract first) or a `name` of a saved/built-in workflow.",
|
|
429
456
|
"Use orchestrate([{ id, dependsOn, run }]) for named dependency graphs; use parallel(() => agent(...), ...) for a simple fan-out and pipeline(items, ...stages) for sequential per-item stages.",
|
|
457
|
+
"Size the fan-out to the work: one agent per independent piece of evidence the task actually has. Ten to thirty agents is an ordinary review or audit, and a script that splits a broad task four ways is usually four agents each doing one agent's job. The gain is coverage, not speed — how many run at once is the host's pool, not the script's choice.",
|
|
458
|
+
"Pass script/name/args/background and nothing else unless the user named a specific limit. maxAgents, concurrency, agentRetries, tokenBudget and agentTimeoutMs already default to tuned values, and each one stops or fails the run when it is set too low.",
|
|
430
459
|
"Use background: true for long runs; retrieve results with workflow_control.",
|
|
431
460
|
"Use resumeFromRunId with an edited script to replay the unchanged prefix from cache and re-run the rest live.",
|
|
432
|
-
"
|
|
461
|
+
"Inside the script, never name a model, a thinking level, or a turn limit: `strength` is the only routing a call may express.",
|
|
433
462
|
],
|
|
434
463
|
parameters: WorkflowToolSchema,
|
|
435
464
|
renderCall(args, theme) {
|
|
@@ -478,13 +507,13 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
478
507
|
// Foreground checkpoint confirmation only when the run is not
|
|
479
508
|
// background — background runs are headless by contract.
|
|
480
509
|
confirm:
|
|
481
|
-
params.background === false && ctx?.hasUI
|
|
482
|
-
? async (promptText, checkpointOptions) => {
|
|
510
|
+
params.background === false && ctx?.hasUI
|
|
511
|
+
? async (promptText, checkpointOptions, signal) => {
|
|
483
512
|
if (checkpointOptions.kind === "input")
|
|
484
|
-
return ctx.ui.input(promptText, String(checkpointOptions.default ?? ""));
|
|
513
|
+
return ctx.ui.input(promptText, String(checkpointOptions.default ?? ""), { signal });
|
|
485
514
|
if (checkpointOptions.kind === "select")
|
|
486
|
-
return ctx.ui.select(promptText, checkpointOptions.choices ?? []);
|
|
487
|
-
return ctx.ui.confirm(promptText, promptText);
|
|
515
|
+
return ctx.ui.select(promptText, checkpointOptions.choices ?? [], { signal });
|
|
516
|
+
return ctx.ui.confirm(promptText, promptText, { signal });
|
|
488
517
|
}
|
|
489
518
|
: undefined,
|
|
490
519
|
loadSavedWorkflow: resolveNestedWorkflow,
|
|
@@ -494,7 +523,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
494
523
|
const replacement = params.script !== undefined || params.name !== undefined ? script : undefined;
|
|
495
524
|
const resumed = await workflowEngine.resume(
|
|
496
525
|
params.resumeFromRunId,
|
|
497
|
-
|
|
526
|
+
ctx.sessionManager.getBranch(),
|
|
498
527
|
options,
|
|
499
528
|
replacement,
|
|
500
529
|
);
|
|
@@ -929,7 +958,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
929
958
|
if (event.text.includes(WORKFLOW_ARMED_DIRECTIVE)) return { action: "continue" as const };
|
|
930
959
|
const effortDirective =
|
|
931
960
|
effortLevel === "ultra"
|
|
932
|
-
? 'Effort: ULTRA. Be exhaustive: use broad parallel review, verification, and completeness checks
|
|
961
|
+
? 'Effort: ULTRA. Be exhaustive: use broad parallel review, verification, and completeness checks. Width is where the exhaustiveness goes: keep steps at strength: "medium" and reserve "high" for a step whose error is expensive to reverse, if the run has one at all.'
|
|
933
962
|
: effortLevel === "high"
|
|
934
963
|
? "Effort: HIGH. Be thorough: use several independent reviewers and an adversarial verification pass."
|
|
935
964
|
: "";
|
|
@@ -1085,6 +1114,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1085
1114
|
} finally {
|
|
1086
1115
|
workflowEngine.resumeLifecycle();
|
|
1087
1116
|
branchQuiesce = undefined;
|
|
1117
|
+
refreshWidget();
|
|
1088
1118
|
}
|
|
1089
1119
|
});
|
|
1090
1120
|
};
|
|
@@ -1226,6 +1256,10 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1226
1256
|
{
|
|
1227
1257
|
append(event) {
|
|
1228
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
|
+
});
|
|
1229
1263
|
},
|
|
1230
1264
|
},
|
|
1231
1265
|
() => {
|
|
@@ -1257,7 +1291,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1257
1291
|
return;
|
|
1258
1292
|
}
|
|
1259
1293
|
|
|
1294
|
+
if (generation !== lifecycleGeneration) {
|
|
1295
|
+
recoveredEngine.dispose();
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1260
1298
|
engine = recoveredEngine;
|
|
1299
|
+
widgetUi = ctx.mode === "tui" ? ctx.ui : undefined;
|
|
1261
1300
|
registerSurface();
|
|
1262
1301
|
|
|
1263
1302
|
// Bind per-session delivery endpoint (A10): capture the session-bound send
|
|
@@ -1351,6 +1390,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1351
1390
|
// engine quiescence; the old session must not accept another run.
|
|
1352
1391
|
active = false;
|
|
1353
1392
|
clearWidget();
|
|
1393
|
+
widgetUi = undefined;
|
|
1354
1394
|
if (widgetTimer) {
|
|
1355
1395
|
clearInterval(widgetTimer);
|
|
1356
1396
|
widgetTimer = undefined;
|