@signalridge/pi-workflows 1.7.3 → 1.7.4
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 +10 -0
- package/README.md +5 -1
- package/package.json +3 -3
- 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 +2 -1
- package/src/engine.ts +86 -10
- package/src/index.ts +108 -44
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.7.4
|
|
4
|
+
### Patch Changes
|
|
5
|
+
|
|
6
|
+
- 8190516: Clarify that keyword arming guides the model but is not an execution-time permission gate; direct workflow calls remain governed by the host's normal tool permissions.
|
|
7
|
+
- 8190516: Extend the tested Pi host compatibility range through 0.87.x while retaining supported older hosts. Validate the published extensions against Pi 0.87.1 and adapt changed provider, session-context, and lifecycle contracts where necessary.
|
|
8
|
+
- 8190516: Add Pi 0.99.1 to the supported host peer ranges while retaining Pi 0.84–0.87 compatibility. Align Pi development dependency pins with 0.99.1 for validation against the new host.
|
|
9
|
+
- 8190516: Retain Pi's tree-navigation attempt signal for branch ownership and deduplication. Active child or workflow work now causes a non-destructive preflight veto rather than pre-veto quiescence; accepted tree changes fence late old-branch callbacks. A second attempt rechecks live work instead of reusing state from a canceled attempt. Pi still lacks an atomic post-veto, pre-leaf commit hook: work started during asynchronous summarization may need to be quarantined on commit.
|
|
10
|
+
- 8190516: Skip the full Pi session-branch scan when the workflow journal's anchored leaf is still current. Keep the existing ancestry check when the leaf changes or cannot be reported, so navigated-away branches cannot receive old journal entries.
|
|
11
|
+
- 8190516: Block session switches and tree navigation while subagents, workflows, or provider cleanup remain unsettled, without stopping work on a cancellable attempt. Confirmed shutdown coordinates workflow-owned cleanup before retiring the subagent RPC responder; confirmed tree changes fence stale callbacks and keep old terminal facts off the new branch. Pi's hook ordering still leaves a narrow, non-atomic interval between preflight and commit, so new work during summarization is quarantined on commit rather than guaranteed to finish.
|
|
12
|
+
|
|
3
13
|
## 1.7.3
|
|
4
14
|
### Patch Changes
|
|
5
15
|
|
package/README.md
CHANGED
|
@@ -198,6 +198,10 @@ tell the model the `workflow` tool is authorized for this turn.
|
|
|
198
198
|
Arming **authorizes; it does not compel**. The annotation says the model may run
|
|
199
199
|
a workflow and may still decline, so "how do workflows work?" stays an ordinary
|
|
200
200
|
question with an ordinary answer. Nothing is swallowed and no UI opens.
|
|
201
|
+
This is **model guidance, not an execution permission gate**: the extension does
|
|
202
|
+
not enforce the keyword inside `workflow.execute`. Direct tool calls remain
|
|
203
|
+
subject to Pi's ordinary tool permissions; do not use the trigger as a security
|
|
204
|
+
boundary.
|
|
201
205
|
|
|
202
206
|
Only a standalone word arms. A workflow is also a thing people write code about,
|
|
203
207
|
so none of `myworkflow`, `workflow_name`, `WorkflowEngine`, `--workflow-id`,
|
|
@@ -392,4 +396,4 @@ used it.
|
|
|
392
396
|
- **Agent registry and tier catalogue ownership.** `agentType` and the tier catalogue resolve in pi-subagents; the workflow package supplies only a tier key its own `strengths` table chose, avoiding a second model/thinking policy.
|
|
393
397
|
- **Schema validation is client-side.** The managed request does not assume a structured-output tool; the runtime asks for JSON, parses, validates (including `additionalProperties`), and repairs across bounded attempts.
|
|
394
398
|
|
|
395
|
-
State is persisted as `pi.appendEntry("pi-workflows:journal", ...)` custom entries (schema v4), including frozen invocation args, resolved tier identity, terminal result previews, script revisions, named nested-workflow result boundaries, and durable removals. Interrupted/provider-limited runs replay from the journal; pre-schema-v4 journals are quarantined rather than replayed. Foreground `AbortSignal`s stop owned children through explicit owner-scoped stop/quiescence, and dispose/branch replacement reject stale waiters.
|
|
399
|
+
State is persisted as `pi.appendEntry("pi-workflows:journal", ...)` custom entries (schema v4), including frozen invocation args, resolved tier identity, terminal result previews, script revisions, named nested-workflow result boundaries, and durable removals. Interrupted/provider-limited runs replay from the journal; pre-schema-v4 journals are quarantined rather than replayed. Foreground `AbortSignal`s stop owned children through explicit owner-scoped stop/quiescence, and dispose/branch replacement reject stale waiters. For tree navigation, bus and native pre-tree notifications share quiescence within one host signal; a later attempt rechecks runs started since a veto, after any earlier cleanup settles. This only prevents reuse of stale quiescence: Pi does not provide a post-veto, precommit hook, so the initial canceled attempt can still interrupt active runs. Hosts without a pre-tree signal retain legacy deduplication.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signalridge/pi-workflows",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.4",
|
|
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 || ^0.85.0",
|
|
19
|
-
"@earendil-works/pi-tui": "^0.84.0 || ^0.85.0",
|
|
18
|
+
"@earendil-works/pi-coding-agent": "^0.84.0 || ^0.85.0 || ^0.86.0 || ^0.87.0 || ^0.99.1",
|
|
19
|
+
"@earendil-works/pi-tui": "^0.84.0 || ^0.85.0 || ^0.86.0 || ^0.87.0 || ^0.99.1",
|
|
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.
|
|
5
|
+
version: "1.7.4"
|
|
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.
|
|
5
|
+
Contract content / skill / extension: `1.7.4`
|
|
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.
|
|
5
|
+
Contract content / skill / extension: `1.7.4`
|
|
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.
|
|
5
|
+
version: "1.7.4"
|
|
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.
|
|
5
|
+
version: "1.7.4"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Workflow review
|
package/src/arming.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* arming.ts —
|
|
2
|
+
* arming.ts — a keyword that tells the model a workflow is appropriate this turn.
|
|
3
|
+
* It is advisory prompt guidance, not an execution-time permission gate.
|
|
3
4
|
*
|
|
4
5
|
* Typing the bounded word `workflow` (or `workflows`, or a configured synonym)
|
|
5
6
|
* in an ordinary message is an explicit opt-in to multi-agent orchestration, so
|
package/src/engine.ts
CHANGED
|
@@ -335,6 +335,9 @@ export class WorkflowEngine {
|
|
|
335
335
|
private disposed = false;
|
|
336
336
|
private lifecyclePaused = false;
|
|
337
337
|
private branchQuiescePromise: Promise<WorkflowQuiesceResult> | undefined;
|
|
338
|
+
private branchEpoch = 0;
|
|
339
|
+
private admissionsClosed = false;
|
|
340
|
+
private pendingAdmissions = 0;
|
|
338
341
|
private recoveryBranchGeneration = 0;
|
|
339
342
|
private readonly journalRetries = new Map<string, { timer?: ReturnType<typeof setTimeout>; attempt: number }>();
|
|
340
343
|
private readonly journalBlockedRuns = new Set<string>();
|
|
@@ -378,6 +381,38 @@ export class WorkflowEngine {
|
|
|
378
381
|
return this.disposed;
|
|
379
382
|
}
|
|
380
383
|
|
|
384
|
+
/** A synchronous snapshot for cancellable navigation, not an admission lock. */
|
|
385
|
+
hasUnsettledWork(): boolean {
|
|
386
|
+
return (
|
|
387
|
+
this.pendingAdmissions > 0 ||
|
|
388
|
+
this.branchQuiescePromise !== undefined ||
|
|
389
|
+
this.journalRetries.size > 0 ||
|
|
390
|
+
this.providerResumeTimers.size > 0 ||
|
|
391
|
+
[...this.runs.values()].some(
|
|
392
|
+
(state) =>
|
|
393
|
+
state.run.status === "running" ||
|
|
394
|
+
state.run.status === "pausing" ||
|
|
395
|
+
state.run.status === "stopping" ||
|
|
396
|
+
state.pendingSpawns.size > 0 ||
|
|
397
|
+
state.agentWaiters.size > 0 ||
|
|
398
|
+
state.pendingJournal.size > 0,
|
|
399
|
+
)
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Pi has committed the new leaf: stop old callbacks without journaling there. */
|
|
404
|
+
fenceCommittedBranch(): void {
|
|
405
|
+
if (this.disposed || this.lifecyclePaused) return;
|
|
406
|
+
this.branchEpoch++;
|
|
407
|
+
this.admissionsClosed = true;
|
|
408
|
+
this.suspendLifecycle();
|
|
409
|
+
this.clearJournalRetries();
|
|
410
|
+
for (const state of this.runs.values()) {
|
|
411
|
+
state.lifecycleSuspended = true;
|
|
412
|
+
state.controller.abort(new DOMException("Workflow branch replaced", "AbortError"));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
381
416
|
private attachLifecycle(): () => void {
|
|
382
417
|
const unsubs = [
|
|
383
418
|
"subagents:created",
|
|
@@ -579,6 +614,7 @@ export class WorkflowEngine {
|
|
|
579
614
|
async quiesceForBranchChange(): Promise<WorkflowQuiesceResult> {
|
|
580
615
|
if (this.disposed) return { settled: true, pending: [] };
|
|
581
616
|
if (this.branchQuiescePromise) return this.branchQuiescePromise;
|
|
617
|
+
this.admissionsClosed = true;
|
|
582
618
|
this.clearJournalRetries();
|
|
583
619
|
|
|
584
620
|
// Suspend and abort synchronously, before any await can let a stale runtime
|
|
@@ -661,8 +697,18 @@ export class WorkflowEngine {
|
|
|
661
697
|
*/
|
|
662
698
|
async start(script: string, options: ScriptStartOptions = {}): Promise<ScriptStartResult> {
|
|
663
699
|
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
664
|
-
|
|
700
|
+
if (this.lifecyclePaused || this.admissionsClosed) throw new Error("Workflow branch is not ready");
|
|
701
|
+
const epoch = this.branchEpoch;
|
|
702
|
+
this.pendingAdmissions++;
|
|
703
|
+
let protocol: ManagedProtocolCheck;
|
|
704
|
+
try {
|
|
705
|
+
protocol = await this.awaitProtocol();
|
|
706
|
+
} finally {
|
|
707
|
+
this.pendingAdmissions--;
|
|
708
|
+
}
|
|
665
709
|
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
710
|
+
if (epoch !== this.branchEpoch || this.lifecyclePaused || this.admissionsClosed)
|
|
711
|
+
throw new Error("Workflow branch changed before admission");
|
|
666
712
|
const { meta } = parseWorkflowScript(script);
|
|
667
713
|
const now = Date.now();
|
|
668
714
|
const runId = randomUUID();
|
|
@@ -798,8 +844,18 @@ export class WorkflowEngine {
|
|
|
798
844
|
replacementScript?: string,
|
|
799
845
|
): Promise<ScriptStartResult | undefined> {
|
|
800
846
|
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
801
|
-
|
|
847
|
+
if (this.lifecyclePaused || this.admissionsClosed) throw new Error("Workflow branch is not ready");
|
|
848
|
+
const epoch = this.branchEpoch;
|
|
849
|
+
this.pendingAdmissions++;
|
|
850
|
+
let protocol: ManagedProtocolCheck;
|
|
851
|
+
try {
|
|
852
|
+
protocol = await this.awaitProtocol();
|
|
853
|
+
} finally {
|
|
854
|
+
this.pendingAdmissions--;
|
|
855
|
+
}
|
|
802
856
|
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
857
|
+
if (epoch !== this.branchEpoch || this.lifecyclePaused || this.admissionsClosed)
|
|
858
|
+
throw new Error("Workflow branch changed before admission");
|
|
803
859
|
const state = this.runs.get(runId);
|
|
804
860
|
if (!state) return undefined;
|
|
805
861
|
if (state.lifecycleSuspended) return undefined;
|
|
@@ -1016,6 +1072,13 @@ export class WorkflowEngine {
|
|
|
1016
1072
|
const state = this.runs.get(runId);
|
|
1017
1073
|
if (!state) throw new Error(`workflow run not found: ${runId}`);
|
|
1018
1074
|
const generation = executionGeneration ?? state.executionGeneration;
|
|
1075
|
+
const epoch = this.branchEpoch;
|
|
1076
|
+
const isSameBranch = () =>
|
|
1077
|
+
this.runs.get(runId) === state &&
|
|
1078
|
+
state.executionGeneration === generation &&
|
|
1079
|
+
this.branchEpoch === epoch &&
|
|
1080
|
+
!this.disposed;
|
|
1081
|
+
const isCurrent = () => isSameBranch() && !state.lifecycleSuspended && !this.lifecyclePaused;
|
|
1019
1082
|
const executionSignal =
|
|
1020
1083
|
!options.background && options.signal
|
|
1021
1084
|
? AbortSignal.any([state.controller.signal, options.signal])
|
|
@@ -1024,8 +1087,7 @@ export class WorkflowEngine {
|
|
|
1024
1087
|
const cleanupTasks = new Set<Promise<void>>();
|
|
1025
1088
|
const runner: WorkflowAgentRunner = {
|
|
1026
1089
|
run: async (prompt: string, runOptions: AgentRunOptions = {}) => {
|
|
1027
|
-
if (
|
|
1028
|
-
throw new WorkflowError("stale workflow execution", WorkflowErrorCode.WORKFLOW_ABORTED);
|
|
1090
|
+
if (!isCurrent()) throw new WorkflowError("stale workflow execution", WorkflowErrorCode.WORKFLOW_ABORTED);
|
|
1029
1091
|
const dispatch = this.dispatchAgent(runId, prompt, runOptions);
|
|
1030
1092
|
const settled = dispatch.then(
|
|
1031
1093
|
() => {},
|
|
@@ -1076,18 +1138,18 @@ export class WorkflowEngine {
|
|
|
1076
1138
|
confirm: options.confirm,
|
|
1077
1139
|
loadSavedWorkflow: options.loadSavedWorkflow,
|
|
1078
1140
|
onAgentJournal: (entry) => {
|
|
1079
|
-
if (
|
|
1141
|
+
if (!isCurrent()) return;
|
|
1080
1142
|
// Nested workflow frames keep their own lifecycle visibility, but their
|
|
1081
1143
|
// individual call facts are not top-level replay keys. The parent
|
|
1082
1144
|
// workflow_result fact below owns the nested replay boundary.
|
|
1083
1145
|
if (entry.runId === undefined || entry.runId === runId) this.journalCallResult(runId, entry);
|
|
1084
1146
|
},
|
|
1085
1147
|
onWorkflowJournal: (entry) => {
|
|
1086
|
-
if (
|
|
1148
|
+
if (!isCurrent()) return;
|
|
1087
1149
|
this.journalWorkflowResult(runId, entry);
|
|
1088
1150
|
},
|
|
1089
1151
|
onRuntimeEvent: (event) => {
|
|
1090
|
-
if (
|
|
1152
|
+
if (!isCurrent()) return;
|
|
1091
1153
|
try {
|
|
1092
1154
|
this.events.emit("pi-workflows:runtime", { runId, event: cloneRuntimeEvent(event) });
|
|
1093
1155
|
} catch {
|
|
@@ -1108,7 +1170,7 @@ export class WorkflowEngine {
|
|
|
1108
1170
|
}
|
|
1109
1171
|
const live = this.runs.get(runId);
|
|
1110
1172
|
const run = live?.run;
|
|
1111
|
-
if (run &&
|
|
1173
|
+
if (run && isSameBranch() && !isTerminalWorkflow(run.status)) {
|
|
1112
1174
|
this.setWorkflowStatus(run, "completed", undefined, undefined, result.result);
|
|
1113
1175
|
live.result = result.result;
|
|
1114
1176
|
}
|
|
@@ -1116,7 +1178,7 @@ export class WorkflowEngine {
|
|
|
1116
1178
|
} catch (error: unknown) {
|
|
1117
1179
|
await runner.waitForCleanup?.();
|
|
1118
1180
|
const run = this.runs.get(runId)?.run;
|
|
1119
|
-
if (run &&
|
|
1181
|
+
if (run && isSameBranch() && !isTerminalWorkflow(run.status)) {
|
|
1120
1182
|
const message = error instanceof Error ? error.message : String(error);
|
|
1121
1183
|
const workflowError = error instanceof WorkflowError ? error : wrapError(error);
|
|
1122
1184
|
if (workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT) {
|
|
@@ -1147,7 +1209,7 @@ export class WorkflowEngine {
|
|
|
1147
1209
|
} finally {
|
|
1148
1210
|
// Reject any waiters that never settled (agents left in flight).
|
|
1149
1211
|
const run = this.runs.get(runId)?.run;
|
|
1150
|
-
if (run &&
|
|
1212
|
+
if (run && isSameBranch()) this.settleWaiters(runId, run);
|
|
1151
1213
|
}
|
|
1152
1214
|
}
|
|
1153
1215
|
|
|
@@ -1168,6 +1230,7 @@ export class WorkflowEngine {
|
|
|
1168
1230
|
}
|
|
1169
1231
|
const run = state.run;
|
|
1170
1232
|
const executionGeneration = state.executionGeneration;
|
|
1233
|
+
const dispatchEpoch = this.branchEpoch;
|
|
1171
1234
|
const callIndex = runOptions.callIndex ?? state.generations.size;
|
|
1172
1235
|
const frameRunId = runOptions.runId ?? runId;
|
|
1173
1236
|
const nested = frameRunId !== runId;
|
|
@@ -1282,6 +1345,10 @@ export class WorkflowEngine {
|
|
|
1282
1345
|
const responseId = typeof response === "string" ? response : response.id;
|
|
1283
1346
|
if (
|
|
1284
1347
|
state.executionGeneration !== executionGeneration ||
|
|
1348
|
+
this.runs.get(runId) !== state ||
|
|
1349
|
+
this.branchEpoch !== dispatchEpoch ||
|
|
1350
|
+
state.lifecycleSuspended ||
|
|
1351
|
+
this.lifecyclePaused ||
|
|
1285
1352
|
state.controller.signal.aborted ||
|
|
1286
1353
|
externalSignal.aborted
|
|
1287
1354
|
) {
|
|
@@ -1399,6 +1466,14 @@ export class WorkflowEngine {
|
|
|
1399
1466
|
externalSignal.removeEventListener("abort", waitAbort);
|
|
1400
1467
|
});
|
|
1401
1468
|
|
|
1469
|
+
if (
|
|
1470
|
+
this.runs.get(runId) !== state ||
|
|
1471
|
+
this.branchEpoch !== dispatchEpoch ||
|
|
1472
|
+
state.lifecycleSuspended ||
|
|
1473
|
+
this.lifecyclePaused
|
|
1474
|
+
) {
|
|
1475
|
+
throw new WorkflowError("stale workflow terminal", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true });
|
|
1476
|
+
}
|
|
1402
1477
|
const snapshot = terminal.terminal;
|
|
1403
1478
|
if (!snapshot)
|
|
1404
1479
|
throw new WorkflowError("subagent settled without a terminal snapshot", WorkflowErrorCode.AGENT_EXECUTION_ERROR, {
|
|
@@ -1924,6 +1999,7 @@ export class WorkflowEngine {
|
|
|
1924
1999
|
}
|
|
1925
2000
|
this.rejectWaiters(new WorkflowWaitAbortedError());
|
|
1926
2001
|
this.runs.clear();
|
|
2002
|
+
this.admissionsClosed = false;
|
|
1927
2003
|
this.recoveryBranchGeneration = branchGeneration;
|
|
1928
2004
|
const runs = replayJournal(entries, {
|
|
1929
2005
|
onInvalid: (diagnostic) => console.warn(`[pi-workflows] ${diagnostic}`),
|
package/src/index.ts
CHANGED
|
@@ -331,8 +331,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
331
331
|
let active = false;
|
|
332
332
|
let surfaceRegistered = false;
|
|
333
333
|
const registeredWorkflowCommands = new Set<string>();
|
|
334
|
-
let
|
|
334
|
+
let shutdownQuiesce: Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> | undefined;
|
|
335
335
|
let lifecycleGeneration = 0;
|
|
336
|
+
let deliveryGeneration = 0;
|
|
337
|
+
let handledTreeEvent: unknown;
|
|
338
|
+
let journalBranchAnchor: string | null | undefined;
|
|
336
339
|
let protocolAbortController: AbortController | undefined;
|
|
337
340
|
let sessionCwd = process.cwd();
|
|
338
341
|
let workflowSettings = loadWorkflowSettings(sessionCwd);
|
|
@@ -356,6 +359,54 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
356
359
|
return engine;
|
|
357
360
|
};
|
|
358
361
|
|
|
362
|
+
// Public bus requests are synchronous snapshots, not a lock across Pi's
|
|
363
|
+
// later summarization/leaf replacement. Both extensions can veto in any load
|
|
364
|
+
// order without importing one another's source.
|
|
365
|
+
pi.events.on("pi:navigation-preflight", (raw: unknown) => {
|
|
366
|
+
if (!active || !engine?.hasUnsettledWork()) return;
|
|
367
|
+
const report = (raw as { report?: unknown } | null)?.report;
|
|
368
|
+
if (typeof report === "function") report("workflows");
|
|
369
|
+
});
|
|
370
|
+
const navigationPreflight = (ctx: ExtensionContext): { cancel: true } | undefined => {
|
|
371
|
+
let busy = false;
|
|
372
|
+
pi.events.emit("pi:navigation-preflight", {
|
|
373
|
+
report: () => {
|
|
374
|
+
busy = true;
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
if (!busy) return undefined;
|
|
378
|
+
const notice =
|
|
379
|
+
"Navigation blocked while agents or workflows are active or cleaning up. Wait for them to finish, or stop them explicitly and retry.";
|
|
380
|
+
if (ctx.hasUI) ctx.ui.notify(notice, "warning");
|
|
381
|
+
else console.warn(`[pi-workflows] ${notice}`);
|
|
382
|
+
return { cancel: true };
|
|
383
|
+
};
|
|
384
|
+
const beginShutdownQuiesce = (): Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> => {
|
|
385
|
+
if (shutdownQuiesce) return shutdownQuiesce;
|
|
386
|
+
const closingEngine = engine;
|
|
387
|
+
if (!closingEngine || closingEngine.isDisposed()) return Promise.resolve({ settled: true, pending: [] });
|
|
388
|
+
shutdownQuiesce = closingEngine.quiesceForBranchChange().catch((error: unknown) => ({
|
|
389
|
+
settled: false,
|
|
390
|
+
pending: [],
|
|
391
|
+
diagnostic: error instanceof Error ? error.message : String(error),
|
|
392
|
+
}));
|
|
393
|
+
return shutdownQuiesce;
|
|
394
|
+
};
|
|
395
|
+
// Subagents starts this handshake before retiring the owned-agent RPC. When
|
|
396
|
+
// workflows shuts down first, its native hook performs the same operation.
|
|
397
|
+
pi.events.on("pi-workflows:shutdown-quiesce", (raw: unknown) => {
|
|
398
|
+
if (!active || !engine) return;
|
|
399
|
+
const respond = (raw as { respond?: unknown } | null)?.respond;
|
|
400
|
+
if (typeof respond === "function") respond(beginShutdownQuiesce());
|
|
401
|
+
});
|
|
402
|
+
pi.events.on("subagents:session_tree_committed", (raw: unknown) => {
|
|
403
|
+
if (!active || !engine) return;
|
|
404
|
+
const event = (raw as { event?: unknown } | null)?.event;
|
|
405
|
+
if (!event || event === handledTreeEvent) return;
|
|
406
|
+
handledTreeEvent = event;
|
|
407
|
+
engine.fenceCommittedBranch();
|
|
408
|
+
});
|
|
409
|
+
|
|
359
410
|
const clearWidget = (): void => {
|
|
360
411
|
widgetUi?.setWidget("pi-workflows", undefined);
|
|
361
412
|
};
|
|
@@ -1098,58 +1149,38 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1098
1149
|
registeredWorkflowCommands.add(name);
|
|
1099
1150
|
}
|
|
1100
1151
|
|
|
1101
|
-
//
|
|
1102
|
-
//
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
try {
|
|
1107
|
-
workflowEngine = currentEngine();
|
|
1108
|
-
} catch {
|
|
1109
|
-
return Promise.resolve({ settled: true, pending: [] });
|
|
1110
|
-
}
|
|
1111
|
-
branchQuiesce = workflowEngine.quiesceForBranchChange().then((result) => {
|
|
1112
|
-
if (!result.settled) {
|
|
1113
|
-
console.warn(
|
|
1114
|
-
`[pi-workflows] ${result.diagnostic ?? "branch quiescence did not settle; stale events are quarantined"}`,
|
|
1115
|
-
);
|
|
1116
|
-
}
|
|
1117
|
-
return result;
|
|
1118
|
-
});
|
|
1119
|
-
}
|
|
1120
|
-
return branchQuiesce;
|
|
1121
|
-
};
|
|
1122
|
-
|
|
1123
|
-
pi.events.on("subagents:session_before_tree", () => {
|
|
1124
|
-
void beginBranchQuiesce();
|
|
1125
|
-
});
|
|
1126
|
-
|
|
1127
|
-
pi.on("session_before_tree", async () => {
|
|
1128
|
-
await beginBranchQuiesce();
|
|
1129
|
-
});
|
|
1130
|
-
pi.on("session_tree", (_event, ctx) => {
|
|
1131
|
-
let entries: SessionEntryLike[];
|
|
1132
|
-
try {
|
|
1133
|
-
entries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
|
|
1134
|
-
} catch {
|
|
1135
|
-
return;
|
|
1136
|
-
}
|
|
1152
|
+
// Neither before-event is a commit point. Pi can veto in a later handler,
|
|
1153
|
+
// or summarization may abort after every handler has run.
|
|
1154
|
+
pi.on("session_before_switch", (_event, ctx) => navigationPreflight(ctx));
|
|
1155
|
+
pi.on("session_before_tree", (_event, ctx) => navigationPreflight(ctx));
|
|
1156
|
+
pi.on("session_tree", (event, ctx) => {
|
|
1137
1157
|
let workflowEngine: WorkflowEngine;
|
|
1138
1158
|
try {
|
|
1139
1159
|
workflowEngine = currentEngine();
|
|
1140
1160
|
} catch {
|
|
1141
1161
|
return;
|
|
1142
1162
|
}
|
|
1143
|
-
|
|
1163
|
+
if (handledTreeEvent !== event) {
|
|
1164
|
+
// A controller abort can synchronously call stop-owned RPC. Detach the
|
|
1165
|
+
// old subagent records first, regardless of extension load order, so its
|
|
1166
|
+
// managed-stop tombstone cannot be appended on the committed new leaf.
|
|
1167
|
+
pi.events.emit("pi-workflows:session_tree_committed", { event });
|
|
1168
|
+
workflowEngine.fenceCommittedBranch();
|
|
1169
|
+
}
|
|
1170
|
+
handledTreeEvent = event;
|
|
1171
|
+
deliveryGeneration++;
|
|
1144
1172
|
try {
|
|
1173
|
+
const entries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
|
|
1174
|
+
journalBranchAnchor =
|
|
1175
|
+
ctx.sessionManager.getLeafId?.() ?? (entries.at(-1) as { id?: string } | undefined)?.id ?? null;
|
|
1145
1176
|
workflowEngine.restore(entries);
|
|
1177
|
+
workflowEngine.resumeLifecycle();
|
|
1146
1178
|
} catch (error: unknown) {
|
|
1179
|
+
active = false; // never resume an old branch after a failed restore
|
|
1147
1180
|
console.warn(
|
|
1148
1181
|
`[pi-workflows] session tree recovery failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1149
1182
|
);
|
|
1150
1183
|
} finally {
|
|
1151
|
-
workflowEngine.resumeLifecycle();
|
|
1152
|
-
branchQuiesce = undefined;
|
|
1153
1184
|
refreshWidget();
|
|
1154
1185
|
}
|
|
1155
1186
|
});
|
|
@@ -1170,8 +1201,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1170
1201
|
|
|
1171
1202
|
/** Background-run result delivery: fail-closed, session-bound (A10). */
|
|
1172
1203
|
const deliverBackgroundResult = async (engine: WorkflowEngine, runId: string): Promise<void> => {
|
|
1204
|
+
const generation = deliveryGeneration;
|
|
1173
1205
|
const run = await engine.waitFor(runId).catch(() => undefined);
|
|
1174
|
-
if (!run) return;
|
|
1206
|
+
if (!run || generation !== deliveryGeneration) return;
|
|
1175
1207
|
const header = `Workflow ${run.runId} ${run.status}`;
|
|
1176
1208
|
const state = engine.getState(runId);
|
|
1177
1209
|
const finalResult = state?.result ?? run.finalResult;
|
|
@@ -1283,6 +1315,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1283
1315
|
protocolError = `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${detail}`;
|
|
1284
1316
|
});
|
|
1285
1317
|
|
|
1318
|
+
journalBranchAnchor =
|
|
1319
|
+
ctx.sessionManager.getLeafId?.() ?? (initialEntries.at(-1) as { id?: string } | undefined)?.id ?? null;
|
|
1286
1320
|
let recoveredEngine: WorkflowEngine | undefined;
|
|
1287
1321
|
let recoveryError: unknown;
|
|
1288
1322
|
for (let attempt = 0; attempt < 3 && !recoveredEngine; attempt += 1) {
|
|
@@ -1291,7 +1325,24 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1291
1325
|
client,
|
|
1292
1326
|
{
|
|
1293
1327
|
append(event) {
|
|
1328
|
+
if (generation !== lifecycleGeneration) throw new Error("workflow session retired before journal append");
|
|
1329
|
+
// Every append moves Pi's leaf. Remember the new leaf afterwards;
|
|
1330
|
+
// unrelated appends beneath it remain valid, but a navigated-away
|
|
1331
|
+
// branch must never receive an old terminal fact. Only traverse the
|
|
1332
|
+
// branch when the leaf has moved (or the host cannot report it).
|
|
1333
|
+
if (journalBranchAnchor && ctx.sessionManager.getLeafId?.() !== journalBranchAnchor) {
|
|
1334
|
+
const branch = ctx.sessionManager.getBranch() as Array<{ id?: string }>;
|
|
1335
|
+
if (!branch.some((entry) => entry.id === journalBranchAnchor)) {
|
|
1336
|
+
throw new Error("workflow journal branch changed before append");
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1294
1339
|
pi.appendEntry(JOURNAL_ENTRY_TYPE, event);
|
|
1340
|
+
// Some hosts cannot report a leaf ID. Read the new branch tip after
|
|
1341
|
+
// every append rather than retaining the old (now-ancestor) anchor:
|
|
1342
|
+
// a later run on a sibling would otherwise pass the ancestry check.
|
|
1343
|
+
journalBranchAnchor =
|
|
1344
|
+
ctx.sessionManager.getLeafId?.() ?? (ctx.sessionManager.getBranch() as Array<{ id?: string }>).at(-1)?.id;
|
|
1345
|
+
if (!journalBranchAnchor) throw new Error("workflow journal leaf unavailable after append");
|
|
1295
1346
|
// Journal writes precede some state mutations; refresh once applied.
|
|
1296
1347
|
queueMicrotask(() => {
|
|
1297
1348
|
if (generation === lifecycleGeneration) refreshWidget();
|
|
@@ -1421,7 +1472,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1421
1472
|
});
|
|
1422
1473
|
|
|
1423
1474
|
pi.on("session_shutdown", async () => {
|
|
1424
|
-
|
|
1475
|
+
deliveryGeneration += 1;
|
|
1425
1476
|
// Invalidate all long-lived command/tool/widget closures before awaiting
|
|
1426
1477
|
// engine quiescence; the old session must not accept another run.
|
|
1427
1478
|
active = false;
|
|
@@ -1433,13 +1484,26 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1433
1484
|
}
|
|
1434
1485
|
const closingEngine = engine;
|
|
1435
1486
|
if (closingEngine) {
|
|
1436
|
-
|
|
1487
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1488
|
+
const result = await Promise.race([
|
|
1489
|
+
beginShutdownQuiesce(),
|
|
1490
|
+
new Promise<undefined>((resolve) => {
|
|
1491
|
+
timer = setTimeout(() => resolve(undefined), 6_000);
|
|
1492
|
+
}),
|
|
1493
|
+
]);
|
|
1494
|
+
if (timer) clearTimeout(timer);
|
|
1495
|
+
if (!result?.settled) {
|
|
1496
|
+
console.warn(
|
|
1497
|
+
`[pi-workflows] shutdown quiescence timed out or failed; ${result?.diagnostic ?? `pending: ${result?.pending.join(", ") ?? "unknown"}`}; late callbacks fenced`,
|
|
1498
|
+
);
|
|
1499
|
+
}
|
|
1437
1500
|
}
|
|
1501
|
+
lifecycleGeneration += 1;
|
|
1438
1502
|
protocolAbortController?.abort();
|
|
1439
1503
|
protocolAbortController = undefined;
|
|
1440
1504
|
closingEngine?.dispose();
|
|
1441
1505
|
engine = undefined;
|
|
1442
|
-
|
|
1506
|
+
shutdownQuiesce = undefined;
|
|
1443
1507
|
protocolCheck = undefined;
|
|
1444
1508
|
protocolProbe = undefined;
|
|
1445
1509
|
protocolInFlight = undefined;
|