@signalridge/pi-workflows 1.7.3 → 1.7.5
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 +20 -0
- package/README.md +5 -1
- package/package.json +6 -6
- 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 +88 -10
- package/src/index.ts +135 -50
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.7.5
|
|
4
|
+
### Patch Changes
|
|
5
|
+
|
|
6
|
+
- 3364cc0: Declare `@sinclair/typebox` as a host-provided peer dependency with a `*` range instead of a runtime dependency, avoiding duplicate runtime modules and Pi extension-loader warnings.
|
|
7
|
+
- 3364cc0: Adopt Pi 1.0.4's host-provided dependency contract: declare imported Pi and TypeBox modules as `*` peers rather than installing duplicate runtime modules, and align Pi development pins with the repository's tested 1.0.4 baseline. The wildcard identifies host module ownership; it does not promise compatibility with every Pi version.
|
|
8
|
+
- 3364cc0: Bind code-review discovery and workflow executions (including resumes and nested scripts) to the session working directory. Preserve structured user content when deduplicating the workflow directive.
|
|
9
|
+
- Updated dependencies [3364cc0]
|
|
10
|
+
- Updated dependencies [3364cc0]
|
|
11
|
+
- @signalridge/pi-subagents-protocol@1.5.1
|
|
12
|
+
|
|
13
|
+
## 1.7.4
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- 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.
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
- 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.
|
|
20
|
+
- 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.
|
|
21
|
+
- 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.
|
|
22
|
+
|
|
3
23
|
## 1.7.3
|
|
4
24
|
### Patch Changes
|
|
5
25
|
|
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.5",
|
|
4
4
|
"description": "Deterministic JavaScript orchestration workflows backed by pi-subagents managed spawning.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,13 +15,13 @@
|
|
|
15
15
|
"lifecycle": "stable"
|
|
16
16
|
},
|
|
17
17
|
"peerDependencies": {
|
|
18
|
-
"@earendil-works/pi-coding-agent": "
|
|
19
|
-
"@earendil-works/pi-tui": "
|
|
20
|
-
"@signalridge/pi-subagents": ">=1.9.0"
|
|
18
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
19
|
+
"@earendil-works/pi-tui": "*",
|
|
20
|
+
"@signalridge/pi-subagents": ">=1.9.0",
|
|
21
|
+
"@sinclair/typebox": "*"
|
|
21
22
|
},
|
|
22
23
|
"dependencies": {
|
|
23
|
-
"@signalridge/pi-subagents-protocol": "^1.5.
|
|
24
|
-
"@sinclair/typebox": "^0.34.50",
|
|
24
|
+
"@signalridge/pi-subagents-protocol": "^1.5.1",
|
|
25
25
|
"acorn": "^8.16.0"
|
|
26
26
|
},
|
|
27
27
|
"pi": {
|
|
@@ -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.5"
|
|
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.5`
|
|
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.5`
|
|
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.5"
|
|
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.5"
|
|
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>();
|
|
@@ -348,6 +351,7 @@ export class WorkflowEngine {
|
|
|
348
351
|
private readonly journal: JournalWriter,
|
|
349
352
|
private readonly readEntries: () => readonly SessionEntryLike[] = () => [],
|
|
350
353
|
protocolGate?: () => Promise<ManagedProtocolCheck>,
|
|
354
|
+
private readonly cwd: string = process.cwd(),
|
|
351
355
|
) {
|
|
352
356
|
this.protocolGate =
|
|
353
357
|
protocolGate ??
|
|
@@ -378,6 +382,38 @@ export class WorkflowEngine {
|
|
|
378
382
|
return this.disposed;
|
|
379
383
|
}
|
|
380
384
|
|
|
385
|
+
/** A synchronous snapshot for cancellable navigation, not an admission lock. */
|
|
386
|
+
hasUnsettledWork(): boolean {
|
|
387
|
+
return (
|
|
388
|
+
this.pendingAdmissions > 0 ||
|
|
389
|
+
this.branchQuiescePromise !== undefined ||
|
|
390
|
+
this.journalRetries.size > 0 ||
|
|
391
|
+
this.providerResumeTimers.size > 0 ||
|
|
392
|
+
[...this.runs.values()].some(
|
|
393
|
+
(state) =>
|
|
394
|
+
state.run.status === "running" ||
|
|
395
|
+
state.run.status === "pausing" ||
|
|
396
|
+
state.run.status === "stopping" ||
|
|
397
|
+
state.pendingSpawns.size > 0 ||
|
|
398
|
+
state.agentWaiters.size > 0 ||
|
|
399
|
+
state.pendingJournal.size > 0,
|
|
400
|
+
)
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Pi has committed the new leaf: stop old callbacks without journaling there. */
|
|
405
|
+
fenceCommittedBranch(): void {
|
|
406
|
+
if (this.disposed || this.lifecyclePaused) return;
|
|
407
|
+
this.branchEpoch++;
|
|
408
|
+
this.admissionsClosed = true;
|
|
409
|
+
this.suspendLifecycle();
|
|
410
|
+
this.clearJournalRetries();
|
|
411
|
+
for (const state of this.runs.values()) {
|
|
412
|
+
state.lifecycleSuspended = true;
|
|
413
|
+
state.controller.abort(new DOMException("Workflow branch replaced", "AbortError"));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
381
417
|
private attachLifecycle(): () => void {
|
|
382
418
|
const unsubs = [
|
|
383
419
|
"subagents:created",
|
|
@@ -579,6 +615,7 @@ export class WorkflowEngine {
|
|
|
579
615
|
async quiesceForBranchChange(): Promise<WorkflowQuiesceResult> {
|
|
580
616
|
if (this.disposed) return { settled: true, pending: [] };
|
|
581
617
|
if (this.branchQuiescePromise) return this.branchQuiescePromise;
|
|
618
|
+
this.admissionsClosed = true;
|
|
582
619
|
this.clearJournalRetries();
|
|
583
620
|
|
|
584
621
|
// Suspend and abort synchronously, before any await can let a stale runtime
|
|
@@ -661,8 +698,18 @@ export class WorkflowEngine {
|
|
|
661
698
|
*/
|
|
662
699
|
async start(script: string, options: ScriptStartOptions = {}): Promise<ScriptStartResult> {
|
|
663
700
|
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
664
|
-
|
|
701
|
+
if (this.lifecyclePaused || this.admissionsClosed) throw new Error("Workflow branch is not ready");
|
|
702
|
+
const epoch = this.branchEpoch;
|
|
703
|
+
this.pendingAdmissions++;
|
|
704
|
+
let protocol: ManagedProtocolCheck;
|
|
705
|
+
try {
|
|
706
|
+
protocol = await this.awaitProtocol();
|
|
707
|
+
} finally {
|
|
708
|
+
this.pendingAdmissions--;
|
|
709
|
+
}
|
|
665
710
|
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
711
|
+
if (epoch !== this.branchEpoch || this.lifecyclePaused || this.admissionsClosed)
|
|
712
|
+
throw new Error("Workflow branch changed before admission");
|
|
666
713
|
const { meta } = parseWorkflowScript(script);
|
|
667
714
|
const now = Date.now();
|
|
668
715
|
const runId = randomUUID();
|
|
@@ -798,8 +845,18 @@ export class WorkflowEngine {
|
|
|
798
845
|
replacementScript?: string,
|
|
799
846
|
): Promise<ScriptStartResult | undefined> {
|
|
800
847
|
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
801
|
-
|
|
848
|
+
if (this.lifecyclePaused || this.admissionsClosed) throw new Error("Workflow branch is not ready");
|
|
849
|
+
const epoch = this.branchEpoch;
|
|
850
|
+
this.pendingAdmissions++;
|
|
851
|
+
let protocol: ManagedProtocolCheck;
|
|
852
|
+
try {
|
|
853
|
+
protocol = await this.awaitProtocol();
|
|
854
|
+
} finally {
|
|
855
|
+
this.pendingAdmissions--;
|
|
856
|
+
}
|
|
802
857
|
if (this.disposed) throw new WorkflowEngineDisposedError();
|
|
858
|
+
if (epoch !== this.branchEpoch || this.lifecyclePaused || this.admissionsClosed)
|
|
859
|
+
throw new Error("Workflow branch changed before admission");
|
|
803
860
|
const state = this.runs.get(runId);
|
|
804
861
|
if (!state) return undefined;
|
|
805
862
|
if (state.lifecycleSuspended) return undefined;
|
|
@@ -1016,6 +1073,13 @@ export class WorkflowEngine {
|
|
|
1016
1073
|
const state = this.runs.get(runId);
|
|
1017
1074
|
if (!state) throw new Error(`workflow run not found: ${runId}`);
|
|
1018
1075
|
const generation = executionGeneration ?? state.executionGeneration;
|
|
1076
|
+
const epoch = this.branchEpoch;
|
|
1077
|
+
const isSameBranch = () =>
|
|
1078
|
+
this.runs.get(runId) === state &&
|
|
1079
|
+
state.executionGeneration === generation &&
|
|
1080
|
+
this.branchEpoch === epoch &&
|
|
1081
|
+
!this.disposed;
|
|
1082
|
+
const isCurrent = () => isSameBranch() && !state.lifecycleSuspended && !this.lifecyclePaused;
|
|
1019
1083
|
const executionSignal =
|
|
1020
1084
|
!options.background && options.signal
|
|
1021
1085
|
? AbortSignal.any([state.controller.signal, options.signal])
|
|
@@ -1024,8 +1088,7 @@ export class WorkflowEngine {
|
|
|
1024
1088
|
const cleanupTasks = new Set<Promise<void>>();
|
|
1025
1089
|
const runner: WorkflowAgentRunner = {
|
|
1026
1090
|
run: async (prompt: string, runOptions: AgentRunOptions = {}) => {
|
|
1027
|
-
if (
|
|
1028
|
-
throw new WorkflowError("stale workflow execution", WorkflowErrorCode.WORKFLOW_ABORTED);
|
|
1091
|
+
if (!isCurrent()) throw new WorkflowError("stale workflow execution", WorkflowErrorCode.WORKFLOW_ABORTED);
|
|
1029
1092
|
const dispatch = this.dispatchAgent(runId, prompt, runOptions);
|
|
1030
1093
|
const settled = dispatch.then(
|
|
1031
1094
|
() => {},
|
|
@@ -1048,6 +1111,7 @@ export class WorkflowEngine {
|
|
|
1048
1111
|
|
|
1049
1112
|
try {
|
|
1050
1113
|
const result = await runWorkflow(script, {
|
|
1114
|
+
cwd: this.cwd,
|
|
1051
1115
|
args: options.args,
|
|
1052
1116
|
agent: runner,
|
|
1053
1117
|
mainModel: options.mainModel,
|
|
@@ -1076,18 +1140,18 @@ export class WorkflowEngine {
|
|
|
1076
1140
|
confirm: options.confirm,
|
|
1077
1141
|
loadSavedWorkflow: options.loadSavedWorkflow,
|
|
1078
1142
|
onAgentJournal: (entry) => {
|
|
1079
|
-
if (
|
|
1143
|
+
if (!isCurrent()) return;
|
|
1080
1144
|
// Nested workflow frames keep their own lifecycle visibility, but their
|
|
1081
1145
|
// individual call facts are not top-level replay keys. The parent
|
|
1082
1146
|
// workflow_result fact below owns the nested replay boundary.
|
|
1083
1147
|
if (entry.runId === undefined || entry.runId === runId) this.journalCallResult(runId, entry);
|
|
1084
1148
|
},
|
|
1085
1149
|
onWorkflowJournal: (entry) => {
|
|
1086
|
-
if (
|
|
1150
|
+
if (!isCurrent()) return;
|
|
1087
1151
|
this.journalWorkflowResult(runId, entry);
|
|
1088
1152
|
},
|
|
1089
1153
|
onRuntimeEvent: (event) => {
|
|
1090
|
-
if (
|
|
1154
|
+
if (!isCurrent()) return;
|
|
1091
1155
|
try {
|
|
1092
1156
|
this.events.emit("pi-workflows:runtime", { runId, event: cloneRuntimeEvent(event) });
|
|
1093
1157
|
} catch {
|
|
@@ -1108,7 +1172,7 @@ export class WorkflowEngine {
|
|
|
1108
1172
|
}
|
|
1109
1173
|
const live = this.runs.get(runId);
|
|
1110
1174
|
const run = live?.run;
|
|
1111
|
-
if (run &&
|
|
1175
|
+
if (run && isSameBranch() && !isTerminalWorkflow(run.status)) {
|
|
1112
1176
|
this.setWorkflowStatus(run, "completed", undefined, undefined, result.result);
|
|
1113
1177
|
live.result = result.result;
|
|
1114
1178
|
}
|
|
@@ -1116,7 +1180,7 @@ export class WorkflowEngine {
|
|
|
1116
1180
|
} catch (error: unknown) {
|
|
1117
1181
|
await runner.waitForCleanup?.();
|
|
1118
1182
|
const run = this.runs.get(runId)?.run;
|
|
1119
|
-
if (run &&
|
|
1183
|
+
if (run && isSameBranch() && !isTerminalWorkflow(run.status)) {
|
|
1120
1184
|
const message = error instanceof Error ? error.message : String(error);
|
|
1121
1185
|
const workflowError = error instanceof WorkflowError ? error : wrapError(error);
|
|
1122
1186
|
if (workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT) {
|
|
@@ -1147,7 +1211,7 @@ export class WorkflowEngine {
|
|
|
1147
1211
|
} finally {
|
|
1148
1212
|
// Reject any waiters that never settled (agents left in flight).
|
|
1149
1213
|
const run = this.runs.get(runId)?.run;
|
|
1150
|
-
if (run &&
|
|
1214
|
+
if (run && isSameBranch()) this.settleWaiters(runId, run);
|
|
1151
1215
|
}
|
|
1152
1216
|
}
|
|
1153
1217
|
|
|
@@ -1168,6 +1232,7 @@ export class WorkflowEngine {
|
|
|
1168
1232
|
}
|
|
1169
1233
|
const run = state.run;
|
|
1170
1234
|
const executionGeneration = state.executionGeneration;
|
|
1235
|
+
const dispatchEpoch = this.branchEpoch;
|
|
1171
1236
|
const callIndex = runOptions.callIndex ?? state.generations.size;
|
|
1172
1237
|
const frameRunId = runOptions.runId ?? runId;
|
|
1173
1238
|
const nested = frameRunId !== runId;
|
|
@@ -1282,6 +1347,10 @@ export class WorkflowEngine {
|
|
|
1282
1347
|
const responseId = typeof response === "string" ? response : response.id;
|
|
1283
1348
|
if (
|
|
1284
1349
|
state.executionGeneration !== executionGeneration ||
|
|
1350
|
+
this.runs.get(runId) !== state ||
|
|
1351
|
+
this.branchEpoch !== dispatchEpoch ||
|
|
1352
|
+
state.lifecycleSuspended ||
|
|
1353
|
+
this.lifecyclePaused ||
|
|
1285
1354
|
state.controller.signal.aborted ||
|
|
1286
1355
|
externalSignal.aborted
|
|
1287
1356
|
) {
|
|
@@ -1399,6 +1468,14 @@ export class WorkflowEngine {
|
|
|
1399
1468
|
externalSignal.removeEventListener("abort", waitAbort);
|
|
1400
1469
|
});
|
|
1401
1470
|
|
|
1471
|
+
if (
|
|
1472
|
+
this.runs.get(runId) !== state ||
|
|
1473
|
+
this.branchEpoch !== dispatchEpoch ||
|
|
1474
|
+
state.lifecycleSuspended ||
|
|
1475
|
+
this.lifecyclePaused
|
|
1476
|
+
) {
|
|
1477
|
+
throw new WorkflowError("stale workflow terminal", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true });
|
|
1478
|
+
}
|
|
1402
1479
|
const snapshot = terminal.terminal;
|
|
1403
1480
|
if (!snapshot)
|
|
1404
1481
|
throw new WorkflowError("subagent settled without a terminal snapshot", WorkflowErrorCode.AGENT_EXECUTION_ERROR, {
|
|
@@ -1924,6 +2001,7 @@ export class WorkflowEngine {
|
|
|
1924
2001
|
}
|
|
1925
2002
|
this.rejectWaiters(new WorkflowWaitAbortedError());
|
|
1926
2003
|
this.runs.clear();
|
|
2004
|
+
this.admissionsClosed = false;
|
|
1927
2005
|
this.recoveryBranchGeneration = branchGeneration;
|
|
1928
2006
|
const runs = replayJournal(entries, {
|
|
1929
2007
|
onInvalid: (diagnostic) => console.warn(`[pi-workflows] ${diagnostic}`),
|
package/src/index.ts
CHANGED
|
@@ -118,9 +118,10 @@ function textResult(text: string, details: unknown = {}) {
|
|
|
118
118
|
* Run one command for diff discovery. argv array, never a shell string — a
|
|
119
119
|
* branch name or path reaches this from user input.
|
|
120
120
|
*/
|
|
121
|
-
function runCommand(file: string, args: string[]): CommandResult {
|
|
121
|
+
function runCommand(file: string, args: string[], cwd: string): CommandResult {
|
|
122
122
|
try {
|
|
123
123
|
const stdout = execFileSync(file, args, {
|
|
124
|
+
cwd,
|
|
124
125
|
encoding: "utf8",
|
|
125
126
|
maxBuffer: 32 * 1024 * 1024,
|
|
126
127
|
stdio: ["ignore", "pipe", "ignore"],
|
|
@@ -331,8 +332,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
331
332
|
let active = false;
|
|
332
333
|
let surfaceRegistered = false;
|
|
333
334
|
const registeredWorkflowCommands = new Set<string>();
|
|
334
|
-
let
|
|
335
|
+
let shutdownQuiesce: Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> | undefined;
|
|
335
336
|
let lifecycleGeneration = 0;
|
|
337
|
+
let deliveryGeneration = 0;
|
|
338
|
+
let handledTreeEvent: unknown;
|
|
339
|
+
let journalBranchAnchor: string | null | undefined;
|
|
336
340
|
let protocolAbortController: AbortController | undefined;
|
|
337
341
|
let sessionCwd = process.cwd();
|
|
338
342
|
let workflowSettings = loadWorkflowSettings(sessionCwd);
|
|
@@ -356,6 +360,54 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
356
360
|
return engine;
|
|
357
361
|
};
|
|
358
362
|
|
|
363
|
+
// Public bus requests are synchronous snapshots, not a lock across Pi's
|
|
364
|
+
// later summarization/leaf replacement. Both extensions can veto in any load
|
|
365
|
+
// order without importing one another's source.
|
|
366
|
+
pi.events.on("pi:navigation-preflight", (raw: unknown) => {
|
|
367
|
+
if (!active || !engine?.hasUnsettledWork()) return;
|
|
368
|
+
const report = (raw as { report?: unknown } | null)?.report;
|
|
369
|
+
if (typeof report === "function") report("workflows");
|
|
370
|
+
});
|
|
371
|
+
const navigationPreflight = (ctx: ExtensionContext): { cancel: true } | undefined => {
|
|
372
|
+
let busy = false;
|
|
373
|
+
pi.events.emit("pi:navigation-preflight", {
|
|
374
|
+
report: () => {
|
|
375
|
+
busy = true;
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
if (!busy) return undefined;
|
|
379
|
+
const notice =
|
|
380
|
+
"Navigation blocked while agents or workflows are active or cleaning up. Wait for them to finish, or stop them explicitly and retry.";
|
|
381
|
+
if (ctx.hasUI) ctx.ui.notify(notice, "warning");
|
|
382
|
+
else console.warn(`[pi-workflows] ${notice}`);
|
|
383
|
+
return { cancel: true };
|
|
384
|
+
};
|
|
385
|
+
const beginShutdownQuiesce = (): Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> => {
|
|
386
|
+
if (shutdownQuiesce) return shutdownQuiesce;
|
|
387
|
+
const closingEngine = engine;
|
|
388
|
+
if (!closingEngine || closingEngine.isDisposed()) return Promise.resolve({ settled: true, pending: [] });
|
|
389
|
+
shutdownQuiesce = closingEngine.quiesceForBranchChange().catch((error: unknown) => ({
|
|
390
|
+
settled: false,
|
|
391
|
+
pending: [],
|
|
392
|
+
diagnostic: error instanceof Error ? error.message : String(error),
|
|
393
|
+
}));
|
|
394
|
+
return shutdownQuiesce;
|
|
395
|
+
};
|
|
396
|
+
// Subagents starts this handshake before retiring the owned-agent RPC. When
|
|
397
|
+
// workflows shuts down first, its native hook performs the same operation.
|
|
398
|
+
pi.events.on("pi-workflows:shutdown-quiesce", (raw: unknown) => {
|
|
399
|
+
if (!active || !engine) return;
|
|
400
|
+
const respond = (raw as { respond?: unknown } | null)?.respond;
|
|
401
|
+
if (typeof respond === "function") respond(beginShutdownQuiesce());
|
|
402
|
+
});
|
|
403
|
+
pi.events.on("subagents:session_tree_committed", (raw: unknown) => {
|
|
404
|
+
if (!active || !engine) return;
|
|
405
|
+
const event = (raw as { event?: unknown } | null)?.event;
|
|
406
|
+
if (!event || event === handledTreeEvent) return;
|
|
407
|
+
handledTreeEvent = event;
|
|
408
|
+
engine.fenceCommittedBranch();
|
|
409
|
+
});
|
|
410
|
+
|
|
359
411
|
const clearWidget = (): void => {
|
|
360
412
|
widgetUi?.setWidget("pi-workflows", undefined);
|
|
361
413
|
};
|
|
@@ -997,11 +1049,29 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
997
1049
|
}
|
|
998
1050
|
}
|
|
999
1051
|
const message = lastUserMessage;
|
|
1000
|
-
if (lastUserIndex < 0 || !message || !("content" in message)
|
|
1001
|
-
|
|
1002
|
-
|
|
1052
|
+
if (lastUserIndex < 0 || !message || !("content" in message)) return;
|
|
1053
|
+
let seen = false;
|
|
1054
|
+
const deduplicate = (text: string): string =>
|
|
1055
|
+
text.replaceAll(WORKFLOW_ARMED_DIRECTIVE, (directive) => {
|
|
1056
|
+
if (seen) return "";
|
|
1057
|
+
seen = true;
|
|
1058
|
+
return directive;
|
|
1059
|
+
});
|
|
1060
|
+
const content =
|
|
1061
|
+
typeof message.content === "string"
|
|
1062
|
+
? deduplicate(message.content)
|
|
1063
|
+
: message.content.map((part) => {
|
|
1064
|
+
if (part.type !== "text") return part;
|
|
1065
|
+
const text = deduplicate(part.text);
|
|
1066
|
+
return text === part.text ? part : { ...part, text };
|
|
1067
|
+
});
|
|
1068
|
+
if (
|
|
1069
|
+
content === message.content ||
|
|
1070
|
+
(Array.isArray(content) && content.every((part, index) => part === message.content[index]))
|
|
1071
|
+
)
|
|
1072
|
+
return;
|
|
1003
1073
|
const messages = event.messages.slice();
|
|
1004
|
-
messages[lastUserIndex] = { ...message, content
|
|
1074
|
+
messages[lastUserIndex] = { ...message, content };
|
|
1005
1075
|
return { messages };
|
|
1006
1076
|
});
|
|
1007
1077
|
|
|
@@ -1064,7 +1134,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1064
1134
|
// takes a diff rather than something typed, so it resolves one first.
|
|
1065
1135
|
let scriptArgs: Record<string, unknown> | undefined;
|
|
1066
1136
|
if (name === "code-review") {
|
|
1067
|
-
const
|
|
1137
|
+
const cwd = ctx.cwd;
|
|
1138
|
+
const scope = resolveCodeReviewScope(text, (file, argv) => runCommand(file, argv, cwd));
|
|
1068
1139
|
for (const notice of scope.notices) ctx.ui.notify(notice, "warning");
|
|
1069
1140
|
if (!scope.diff.trim()) return;
|
|
1070
1141
|
scriptArgs = { diff: scope.diff, diffSource: scope.diffSource };
|
|
@@ -1098,58 +1169,38 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1098
1169
|
registeredWorkflowCommands.add(name);
|
|
1099
1170
|
}
|
|
1100
1171
|
|
|
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
|
-
}
|
|
1172
|
+
// Neither before-event is a commit point. Pi can veto in a later handler,
|
|
1173
|
+
// or summarization may abort after every handler has run.
|
|
1174
|
+
pi.on("session_before_switch", (_event, ctx) => navigationPreflight(ctx));
|
|
1175
|
+
pi.on("session_before_tree", (_event, ctx) => navigationPreflight(ctx));
|
|
1176
|
+
pi.on("session_tree", (event, ctx) => {
|
|
1137
1177
|
let workflowEngine: WorkflowEngine;
|
|
1138
1178
|
try {
|
|
1139
1179
|
workflowEngine = currentEngine();
|
|
1140
1180
|
} catch {
|
|
1141
1181
|
return;
|
|
1142
1182
|
}
|
|
1143
|
-
|
|
1183
|
+
if (handledTreeEvent !== event) {
|
|
1184
|
+
// A controller abort can synchronously call stop-owned RPC. Detach the
|
|
1185
|
+
// old subagent records first, regardless of extension load order, so its
|
|
1186
|
+
// managed-stop tombstone cannot be appended on the committed new leaf.
|
|
1187
|
+
pi.events.emit("pi-workflows:session_tree_committed", { event });
|
|
1188
|
+
workflowEngine.fenceCommittedBranch();
|
|
1189
|
+
}
|
|
1190
|
+
handledTreeEvent = event;
|
|
1191
|
+
deliveryGeneration++;
|
|
1144
1192
|
try {
|
|
1193
|
+
const entries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
|
|
1194
|
+
journalBranchAnchor =
|
|
1195
|
+
ctx.sessionManager.getLeafId?.() ?? (entries.at(-1) as { id?: string } | undefined)?.id ?? null;
|
|
1145
1196
|
workflowEngine.restore(entries);
|
|
1197
|
+
workflowEngine.resumeLifecycle();
|
|
1146
1198
|
} catch (error: unknown) {
|
|
1199
|
+
active = false; // never resume an old branch after a failed restore
|
|
1147
1200
|
console.warn(
|
|
1148
1201
|
`[pi-workflows] session tree recovery failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1149
1202
|
);
|
|
1150
1203
|
} finally {
|
|
1151
|
-
workflowEngine.resumeLifecycle();
|
|
1152
|
-
branchQuiesce = undefined;
|
|
1153
1204
|
refreshWidget();
|
|
1154
1205
|
}
|
|
1155
1206
|
});
|
|
@@ -1170,8 +1221,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1170
1221
|
|
|
1171
1222
|
/** Background-run result delivery: fail-closed, session-bound (A10). */
|
|
1172
1223
|
const deliverBackgroundResult = async (engine: WorkflowEngine, runId: string): Promise<void> => {
|
|
1224
|
+
const generation = deliveryGeneration;
|
|
1173
1225
|
const run = await engine.waitFor(runId).catch(() => undefined);
|
|
1174
|
-
if (!run) return;
|
|
1226
|
+
if (!run || generation !== deliveryGeneration) return;
|
|
1175
1227
|
const header = `Workflow ${run.runId} ${run.status}`;
|
|
1176
1228
|
const state = engine.getState(runId);
|
|
1177
1229
|
const finalResult = state?.result ?? run.finalResult;
|
|
@@ -1283,6 +1335,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1283
1335
|
protocolError = `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${detail}`;
|
|
1284
1336
|
});
|
|
1285
1337
|
|
|
1338
|
+
journalBranchAnchor =
|
|
1339
|
+
ctx.sessionManager.getLeafId?.() ?? (initialEntries.at(-1) as { id?: string } | undefined)?.id ?? null;
|
|
1286
1340
|
let recoveredEngine: WorkflowEngine | undefined;
|
|
1287
1341
|
let recoveryError: unknown;
|
|
1288
1342
|
for (let attempt = 0; attempt < 3 && !recoveredEngine; attempt += 1) {
|
|
@@ -1291,7 +1345,24 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1291
1345
|
client,
|
|
1292
1346
|
{
|
|
1293
1347
|
append(event) {
|
|
1348
|
+
if (generation !== lifecycleGeneration) throw new Error("workflow session retired before journal append");
|
|
1349
|
+
// Every append moves Pi's leaf. Remember the new leaf afterwards;
|
|
1350
|
+
// unrelated appends beneath it remain valid, but a navigated-away
|
|
1351
|
+
// branch must never receive an old terminal fact. Only traverse the
|
|
1352
|
+
// branch when the leaf has moved (or the host cannot report it).
|
|
1353
|
+
if (journalBranchAnchor && ctx.sessionManager.getLeafId?.() !== journalBranchAnchor) {
|
|
1354
|
+
const branch = ctx.sessionManager.getBranch() as Array<{ id?: string }>;
|
|
1355
|
+
if (!branch.some((entry) => entry.id === journalBranchAnchor)) {
|
|
1356
|
+
throw new Error("workflow journal branch changed before append");
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1294
1359
|
pi.appendEntry(JOURNAL_ENTRY_TYPE, event);
|
|
1360
|
+
// Some hosts cannot report a leaf ID. Read the new branch tip after
|
|
1361
|
+
// every append rather than retaining the old (now-ancestor) anchor:
|
|
1362
|
+
// a later run on a sibling would otherwise pass the ancestry check.
|
|
1363
|
+
journalBranchAnchor =
|
|
1364
|
+
ctx.sessionManager.getLeafId?.() ?? (ctx.sessionManager.getBranch() as Array<{ id?: string }>).at(-1)?.id;
|
|
1365
|
+
if (!journalBranchAnchor) throw new Error("workflow journal leaf unavailable after append");
|
|
1295
1366
|
// Journal writes precede some state mutations; refresh once applied.
|
|
1296
1367
|
queueMicrotask(() => {
|
|
1297
1368
|
if (generation === lifecycleGeneration) refreshWidget();
|
|
@@ -1306,6 +1377,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1306
1377
|
}
|
|
1307
1378
|
},
|
|
1308
1379
|
awaitProtocol,
|
|
1380
|
+
sessionCwd,
|
|
1309
1381
|
);
|
|
1310
1382
|
try {
|
|
1311
1383
|
const entries =
|
|
@@ -1421,7 +1493,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1421
1493
|
});
|
|
1422
1494
|
|
|
1423
1495
|
pi.on("session_shutdown", async () => {
|
|
1424
|
-
|
|
1496
|
+
deliveryGeneration += 1;
|
|
1425
1497
|
// Invalidate all long-lived command/tool/widget closures before awaiting
|
|
1426
1498
|
// engine quiescence; the old session must not accept another run.
|
|
1427
1499
|
active = false;
|
|
@@ -1433,13 +1505,26 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1433
1505
|
}
|
|
1434
1506
|
const closingEngine = engine;
|
|
1435
1507
|
if (closingEngine) {
|
|
1436
|
-
|
|
1508
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1509
|
+
const result = await Promise.race([
|
|
1510
|
+
beginShutdownQuiesce(),
|
|
1511
|
+
new Promise<undefined>((resolve) => {
|
|
1512
|
+
timer = setTimeout(() => resolve(undefined), 6_000);
|
|
1513
|
+
}),
|
|
1514
|
+
]);
|
|
1515
|
+
if (timer) clearTimeout(timer);
|
|
1516
|
+
if (!result?.settled) {
|
|
1517
|
+
console.warn(
|
|
1518
|
+
`[pi-workflows] shutdown quiescence timed out or failed; ${result?.diagnostic ?? `pending: ${result?.pending.join(", ") ?? "unknown"}`}; late callbacks fenced`,
|
|
1519
|
+
);
|
|
1520
|
+
}
|
|
1437
1521
|
}
|
|
1522
|
+
lifecycleGeneration += 1;
|
|
1438
1523
|
protocolAbortController?.abort();
|
|
1439
1524
|
protocolAbortController = undefined;
|
|
1440
1525
|
closingEngine?.dispose();
|
|
1441
1526
|
engine = undefined;
|
|
1442
|
-
|
|
1527
|
+
shutdownQuiesce = undefined;
|
|
1443
1528
|
protocolCheck = undefined;
|
|
1444
1529
|
protocolProbe = undefined;
|
|
1445
1530
|
protocolInFlight = undefined;
|