@signalridge/pi-workflows 1.10.1 → 1.11.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 +25 -0
- package/README.md +19 -0
- package/package.json +3 -2
- 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 +7 -1
- package/src/context-overflow.ts +98 -0
- package/src/engine.ts +3 -1
- package/src/index.ts +143 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.11.1
|
|
4
|
+
### Patch Changes
|
|
5
|
+
|
|
6
|
+
- da9ed0f: - Reject rename hardlink aliases and retargeted queue identities before writes; support all LSP newline styles and identify possibly partially modified targets on write failure.
|
|
7
|
+
- Fence pending physical Plan model mutations at cancellable navigation/replacement gates, reconcile handoffs by native selection chronology, and scope thinking-only restoration to its originating model.
|
|
8
|
+
- Compose Goal guidance through an owned structured prompt section. Resolve overflow usage against physical response models and invalidate stale size evidence after persisted context edits in each independent continuation helper.
|
|
9
|
+
- Persist queued Rules reminders/offers only at authenticated native admission, match file URL paths, and provide usable rule-file references when read_rule is unavailable.
|
|
10
|
+
- Inherit nested workers' thinking from their immediate spawning session while preserving explicit policy precedence.
|
|
11
|
+
- Refresh skill discovery for custom Analytics runs without consuming pending user attribution, and finalize successful Codex diagnostics only after UI cleanup and the final owned result fence.
|
|
12
|
+
- da9ed0f: Restore workflow keyword and standing `/effort high|ultra` authorization on Pi 1.2 and later. Those hosts report the session as busy while its own run starts, so the hidden guidance that authorizes the `workflow` tool was silently dropped for every idle prompt. The idle check now applies only on hosts that do not identify the starting run, and runs started by a custom message never consume an armed prompt.
|
|
13
|
+
- da9ed0f: Stop settled workflow results from waking a session whose context overflowed the model window. While the latest response on the branch is a context overflow with no compaction after it, results are still delivered and acknowledged as context but no wake is sent, and one warning per overflow episode asks to run `/compact` (or `/codex-compact` when installed) or start a new session. A compaction or a later successful response restores normal wakes. Previously each wake resent the same over-window context and failed again. The package now declares `@earendil-works/pi-ai` as a host peer dependency.
|
|
14
|
+
- Updated dependencies [da9ed0f]
|
|
15
|
+
- @signalridge/pi-ui@1.4.1
|
|
16
|
+
|
|
17
|
+
## 1.11.0
|
|
18
|
+
### Minor Changes
|
|
19
|
+
|
|
20
|
+
- c639660: Keep pending completion reminders cancellable until parent boundaries and consume deliberately read terminal results instead of replaying stale notifications.
|
|
21
|
+
|
|
22
|
+
Subagents selectively adapt tintinweb/pi-subagents #388's completion outbox, merge eligible batches into one steering message, and retain explicit groups, attempt ownership, and Claude Code notification XML. Successful terminal result reads suppress that attempt's pending reminder; unread failures remain eligible.
|
|
23
|
+
|
|
24
|
+
Background terminal log or explicit-ID status reads consume only exit notices, preserving unread monitor matches and future completion after running reads. Workflow get/status adds explicit `consume: true` with a branch-visible acknowledgement receipt, without consuming ordinary inspections or suppressing resumed executions. Already accepted transcript messages and historical results remain unchanged.
|
|
25
|
+
|
|
26
|
+
Stopped workflow state retains the accepted terminal event timestamp across slow appends and restore. Background reads prepare job data before committing exit consumption, and post-commit status-rendering failures cannot turn a successful read into an error. Early smart-group completions retain finalized membership separately from immutable results, with partial-group labels rechecked at handoff.
|
|
27
|
+
|
|
3
28
|
## 1.10.1
|
|
4
29
|
### Patch Changes
|
|
5
30
|
|
package/README.md
CHANGED
|
@@ -269,6 +269,17 @@ The passive progress panel uses a terminal-height row budget, prioritizes live c
|
|
|
269
269
|
|
|
270
270
|
### Background result notifications
|
|
271
271
|
|
|
272
|
+
To explicitly read and acknowledge a terminal result, call
|
|
273
|
+
`workflow_control({ action: "get", run_id: "...", consume: true })` (`status`
|
|
274
|
+
is an alias). A `notificationConsumed: true` receipt means an acknowledgement
|
|
275
|
+
was observed on the active branch and the pending reminder was consumed.
|
|
276
|
+
Ordinary `get`/`status`, `list`, running results, and cancelled/stale reads do
|
|
277
|
+
not consume future completion. Consumption is scoped to that execution; a
|
|
278
|
+
later resumed execution remains eligible. Identity comes from the durable terminal
|
|
279
|
+
journal entry, not a runtime counter that restarts on restore. Older markers
|
|
280
|
+
without that evidence are not retroactively consumed. Receipts do not delete run history
|
|
281
|
+
or retract messages already handed to Pi.
|
|
282
|
+
|
|
272
283
|
Completed background runs are buffered in branch-local pending-delivery markers
|
|
273
284
|
(up to 256, expiring after seven days). Notifications wait for an owned, idle
|
|
274
285
|
session with no queued input, input preparation, compaction, or navigation in
|
|
@@ -295,6 +306,14 @@ awaits while navigation or shutdown retires the owner, the obsolete
|
|
|
295
306
|
extension-sourced wake is handled without starting a turn. Interactive/RPC
|
|
296
307
|
input and other extensions' messages are not consumed by this guard.
|
|
297
308
|
|
|
309
|
+
No wake is sent while the session's latest meaningful response overflowed the model's
|
|
310
|
+
context window and no compaction followed it: that turn would resend the same
|
|
311
|
+
over-window context and fail again. Results are still delivered and acknowledged
|
|
312
|
+
as context, and one warning per overflow episode asks to run `/compact` (or
|
|
313
|
+
`/codex-compact` when installed) or start a new session. A compaction or a later
|
|
314
|
+
successful response restores normal wakes. Aborted responses and transient errors
|
|
315
|
+
(including HTTP 503 and rate limits) do not prove context recovery.
|
|
316
|
+
|
|
298
317
|
This is a handoff check, not a navigation lock: public hooks cannot fence an
|
|
299
318
|
arbitrary later async input or preparation handler that navigates after our
|
|
300
319
|
input hook has accepted the wake. Earlier transforms must preserve the private
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signalridge/pi-workflows",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.1",
|
|
4
4
|
"description": "Deterministic JavaScript orchestration workflows backed by pi-subagents managed spawning.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"lifecycle": "stable"
|
|
16
16
|
},
|
|
17
17
|
"peerDependencies": {
|
|
18
|
+
"@earendil-works/pi-ai": "*",
|
|
18
19
|
"@earendil-works/pi-coding-agent": "*",
|
|
19
20
|
"@earendil-works/pi-tui": "*",
|
|
20
21
|
"@signalridge/pi-subagents": ">=1.9.0",
|
|
@@ -22,7 +23,7 @@
|
|
|
22
23
|
},
|
|
23
24
|
"dependencies": {
|
|
24
25
|
"@signalridge/pi-subagents-protocol": "^1.6.0",
|
|
25
|
-
"@signalridge/pi-ui": "^1.4.
|
|
26
|
+
"@signalridge/pi-ui": "^1.4.1",
|
|
26
27
|
"acorn": "^8.16.0"
|
|
27
28
|
},
|
|
28
29
|
"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.
|
|
5
|
+
version: "1.11.1"
|
|
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.
|
|
5
|
+
Contract content / skill / extension: `1.11.1`
|
|
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.
|
|
5
|
+
Contract content / skill / extension: `1.11.1`
|
|
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.
|
|
5
|
+
version: "1.11.1"
|
|
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.
|
|
5
|
+
version: "1.11.1"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Workflow review
|
package/src/arming.ts
CHANGED
|
@@ -235,15 +235,21 @@ export class WorkflowTurnArming {
|
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
consume(event: BeforeAgentStartEvent, ctx: ExtensionContext): void {
|
|
238
|
+
// Pi 1.2+ also announces idle custom-message runs; only a user prompt can
|
|
239
|
+
// carry the armed input, and earlier hosts never reached here for them.
|
|
240
|
+
if (event.messageType === "custom") return;
|
|
238
241
|
const pending = this.pending;
|
|
239
242
|
const ambiguous = this.ambiguous;
|
|
240
243
|
this.pending = undefined; // one-use, also for mismatched/consumed prompts
|
|
241
244
|
this.current = undefined;
|
|
242
245
|
try {
|
|
246
|
+
// Pi 1.2+ (which reports messageType) counts the session busy while its
|
|
247
|
+
// own run prepares and queues concurrent sends into it. Earlier hosts were
|
|
248
|
+
// still idle here, so a busy session meant another run had started.
|
|
243
249
|
if (
|
|
244
250
|
ambiguous ||
|
|
245
251
|
!pending?.content ||
|
|
246
|
-
!ctx.isIdle() ||
|
|
252
|
+
(event.messageType === undefined && !ctx.isIdle()) ||
|
|
247
253
|
event.prompt !== pending.text ||
|
|
248
254
|
inputKey("", event.images) !== pending.images ||
|
|
249
255
|
ctx.sessionManager.getSessionId() !== pending.sessionId ||
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overflow-aware waking. After a response overflowed the model's context window and no
|
|
3
|
+
* compaction followed, a turn this extension starts resends the same over-window context and
|
|
4
|
+
* fails again, and each failure is still a billed request. Wakes are held until a compaction
|
|
5
|
+
* or a later successful response ends the episode.
|
|
6
|
+
*/
|
|
7
|
+
import { isContextOverflow } from "@earendil-works/pi-ai";
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
type EpisodeContext = Pick<ExtensionContext, "sessionManager"> & {
|
|
11
|
+
modelRegistry?: { find(provider: string, model: string): { contextWindow?: number } | undefined };
|
|
12
|
+
};
|
|
13
|
+
export type OverflowContext = EpisodeContext & Pick<ExtensionContext, "hasUI" | "ui">;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The first overflowed response of the current episode: the latest meaningful response on the
|
|
17
|
+
* branch overflowed and neither compaction nor a successful response followed. Aborted and
|
|
18
|
+
* transient error responses say nothing about the context size and are skipped.
|
|
19
|
+
*/
|
|
20
|
+
export function overflowEpisode(ctx: EpisodeContext): string | undefined {
|
|
21
|
+
try {
|
|
22
|
+
const branch = ctx.sessionManager.getBranch();
|
|
23
|
+
const projection = ctx.sessionManager.buildSessionProjection?.();
|
|
24
|
+
const projected = projection
|
|
25
|
+
? new Set(
|
|
26
|
+
projection.entries
|
|
27
|
+
.filter((entry) => entry.messages.some((message) => message.role === "assistant"))
|
|
28
|
+
.map((entry) => entry.sourceEntry.id),
|
|
29
|
+
)
|
|
30
|
+
: undefined;
|
|
31
|
+
let contextEdited = false;
|
|
32
|
+
let episode: string | undefined;
|
|
33
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
34
|
+
const entry = branch[index];
|
|
35
|
+
if (entry.type === "compaction") break;
|
|
36
|
+
if (entry.type === "context_edit") {
|
|
37
|
+
contextEdited = true;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (entry.type !== "message" || entry.message.role !== "assistant" || entry.message.stopReason === "aborted")
|
|
41
|
+
continue;
|
|
42
|
+
const explicit = entry.message.stopReason === "error" && isContextOverflow(entry.message);
|
|
43
|
+
if (entry.message.stopReason === "error" && !explicit) continue;
|
|
44
|
+
// Pi can omit a failed recovery attempt before a cancelled compaction.
|
|
45
|
+
// That alone does not repair its oversized input; invalidate usage only.
|
|
46
|
+
if (!explicit && projected && !projected.has(entry.id)) break;
|
|
47
|
+
const contextWindow = ctx.modelRegistry?.find(entry.message.provider, entry.message.model)?.contextWindow;
|
|
48
|
+
if (!explicit && (contextEdited || !isContextOverflow(entry.message, contextWindow))) {
|
|
49
|
+
if (entry.message.stopReason === "error") continue;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
episode = entry.id;
|
|
53
|
+
}
|
|
54
|
+
return episode;
|
|
55
|
+
} catch {
|
|
56
|
+
// A replaced session's context is stale; never hold a wake on a guess.
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function overflowNotice(held: string, codexCompact: boolean): string {
|
|
62
|
+
return (
|
|
63
|
+
`Context exceeds the model's window; ${held} without starting a turn. ` +
|
|
64
|
+
`Run ${codexCompact ? "/compact or /codex-compact" : "/compact"}, or start a new session.`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One guard per extension instance, so the user sees one notice per overflow episode. */
|
|
69
|
+
export class OverflowWakeGuard {
|
|
70
|
+
private noticed: string | undefined;
|
|
71
|
+
constructor(
|
|
72
|
+
private readonly pi: Pick<ExtensionAPI, "getCommands">,
|
|
73
|
+
private readonly held: string,
|
|
74
|
+
) {}
|
|
75
|
+
|
|
76
|
+
/** True while a turn must not start; the first hold of an episode tells the user why. */
|
|
77
|
+
holds(ctx: OverflowContext | undefined): boolean {
|
|
78
|
+
const episode = ctx ? overflowEpisode(ctx) : undefined;
|
|
79
|
+
if (!ctx || episode === undefined) return false;
|
|
80
|
+
if (episode !== this.noticed) {
|
|
81
|
+
this.noticed = episode;
|
|
82
|
+
try {
|
|
83
|
+
if (ctx.hasUI) ctx.ui.notify(overflowNotice(this.held, this.hasCommand("codex-compact")), "warning");
|
|
84
|
+
} catch {
|
|
85
|
+
// A disposed UI must not block the delivery that follows.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private hasCommand(name: string): boolean {
|
|
92
|
+
try {
|
|
93
|
+
return this.pi.getCommands().some((command) => command.name === name);
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/engine.ts
CHANGED
|
@@ -2614,7 +2614,9 @@ export class WorkflowEngine {
|
|
|
2614
2614
|
run.callStatus[terminal.nodeId] = "stopped";
|
|
2615
2615
|
}
|
|
2616
2616
|
run.status = "stopped";
|
|
2617
|
-
|
|
2617
|
+
// Publish the accepted event's identity, just like other terminal paths.
|
|
2618
|
+
// A slow append must not detach the live state from its durable receipt.
|
|
2619
|
+
run.updatedAt = event.timestamp;
|
|
2618
2620
|
state.presentation.settle(run.status);
|
|
2619
2621
|
this.notifyInspection(state);
|
|
2620
2622
|
if (current()) this.notifyTerminal(state);
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { Type } from "@sinclair/typebox";
|
|
|
8
8
|
import { DEFAULT_TRIGGER_WORD, hasTriggerWord, WORKFLOW_ARMING_MESSAGE_TYPE, WorkflowTurnArming } from "./arming.js";
|
|
9
9
|
import { BUILTIN_WORKFLOWS, validateBuiltinArgs } from "./builtins.js";
|
|
10
10
|
import { type CommandResult, resolveCodeReviewScope } from "./code-review-scope.js";
|
|
11
|
+
import { OverflowWakeGuard } from "./context-overflow.js";
|
|
11
12
|
import { type ScriptStartOptions, type ScriptStartResult, WorkflowEngine, WorkflowWaitAbortedError } from "./engine.js";
|
|
12
13
|
import { JOURNAL_ENTRY_TYPE, type SessionEntryLike } from "./journal.js";
|
|
13
14
|
import {
|
|
@@ -94,6 +95,12 @@ const ControlSchema = Type.Object(
|
|
|
94
95
|
{ description: "Control action" },
|
|
95
96
|
),
|
|
96
97
|
run_id: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
|
98
|
+
consume: Type.Optional(
|
|
99
|
+
Type.Boolean({
|
|
100
|
+
description:
|
|
101
|
+
"With get/status only: explicitly acknowledge a reviewed terminal result and suppress its pending completion reminder. Running results and ordinary inspections are not consumed.",
|
|
102
|
+
}),
|
|
103
|
+
),
|
|
97
104
|
// Accept camelCase and legacy workflow_run_id for interop with pi-dynamic-workflows docs
|
|
98
105
|
runId: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
|
99
106
|
workflow_run_id: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
|
@@ -344,6 +351,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
344
351
|
let lifecycleGeneration = 0;
|
|
345
352
|
let deliveryGeneration = 0;
|
|
346
353
|
const deliveryObservers = new Map<string, string>();
|
|
354
|
+
const observedExecutions = new Map<string, string>();
|
|
347
355
|
let handledTreeEvent: unknown;
|
|
348
356
|
let journalBranchAnchor: string | null | undefined;
|
|
349
357
|
let protocolAbortController: AbortController | undefined;
|
|
@@ -354,6 +362,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
354
362
|
let keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
|
|
355
363
|
/** Public sends are void: acceptance is checked against the owning branch. */
|
|
356
364
|
let deliveryContext: ExtensionContext | undefined;
|
|
365
|
+
const overflowWakes = new OverflowWakeGuard(pi, "workflow results are added to the conversation");
|
|
357
366
|
let deliveryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
358
367
|
let inputPreparing = false;
|
|
359
368
|
const turnArming = new WorkflowTurnArming();
|
|
@@ -370,7 +379,10 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
370
379
|
{ generation: number; context: ExtensionContext; engine: WorkflowEngine; anchor: string | null | undefined }
|
|
371
380
|
>();
|
|
372
381
|
/** Pending deliveries for completed runs, including durable recovery (A10). */
|
|
373
|
-
const pendingDeliveryMarkers = new Map<
|
|
382
|
+
const pendingDeliveryMarkers = new Map<
|
|
383
|
+
string,
|
|
384
|
+
{ text: string; timestamp: number; executionGeneration?: number; completionId?: string }
|
|
385
|
+
>();
|
|
374
386
|
const PENDING_DELIVERY_TYPE = "pi-workflows:pending-delivery";
|
|
375
387
|
const PENDING_DELIVERY_ACK_TYPE = "pi-workflows:pending-delivery-ack";
|
|
376
388
|
const MAX_PENDING_DELIVERIES = 256;
|
|
@@ -426,6 +438,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
426
438
|
workflowUiSurfaceId = randomUUID();
|
|
427
439
|
deliveryGeneration++;
|
|
428
440
|
deliveryObservers.clear();
|
|
441
|
+
observedExecutions.clear();
|
|
429
442
|
turnArming.retire();
|
|
430
443
|
wakeAdmissions.clear();
|
|
431
444
|
if (deliveryTimer) clearTimeout(deliveryTimer);
|
|
@@ -880,7 +893,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
880
893
|
});
|
|
881
894
|
return new Text(theme.fg("dim", expanded ? text.slice(0, 8_000) : text.slice(0, 1_000)), 0, 0);
|
|
882
895
|
},
|
|
883
|
-
async execute(_toolCallId, params,
|
|
896
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
884
897
|
try {
|
|
885
898
|
const rawAction = params.action as string;
|
|
886
899
|
const action = (rawAction === "status" ? "get" : rawAction) as
|
|
@@ -895,7 +908,22 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
895
908
|
}
|
|
896
909
|
const runId = normalizeControlRunId(params as unknown as Record<string, unknown>);
|
|
897
910
|
const workflowEngine = currentEngine();
|
|
911
|
+
if (params.consume && action !== "get") throw new Error("consume is supported only with get/status.");
|
|
912
|
+
const execution = runId ? workflowEngine.getState(runId)?.executionGeneration : undefined;
|
|
913
|
+
const ownerGeneration = deliveryGeneration;
|
|
914
|
+
const completionId =
|
|
915
|
+
params.consume && action === "get" && runId ? completionReceipt(workflowEngine, runId) : undefined;
|
|
898
916
|
const result = await workflowEngine.control(action, runId);
|
|
917
|
+
signal?.throwIfAborted();
|
|
918
|
+
if (params.consume) {
|
|
919
|
+
const notificationConsumed =
|
|
920
|
+
!!runId &&
|
|
921
|
+
execution !== undefined &&
|
|
922
|
+
completionId !== undefined &&
|
|
923
|
+
consumeReviewedResult(workflowEngine, runId, execution, completionId, ownerGeneration, ctx);
|
|
924
|
+
const receipt = { ...result, notificationConsumed };
|
|
925
|
+
return textResult(JSON.stringify(receipt), receipt);
|
|
926
|
+
}
|
|
899
927
|
return textResult(JSON.stringify(result), result);
|
|
900
928
|
} catch (error: unknown) {
|
|
901
929
|
return textResult(error instanceof Error ? error.message : String(error));
|
|
@@ -1548,9 +1576,92 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1548
1576
|
});
|
|
1549
1577
|
};
|
|
1550
1578
|
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1579
|
+
/** Native journal identity survives restore; runtime generation counters do not. */
|
|
1580
|
+
function completionReceipt(workflowEngine: WorkflowEngine, runId: string): string | undefined {
|
|
1581
|
+
const run = workflowEngine.getState(runId)?.run;
|
|
1582
|
+
if (!run || !["completed", "failed", "stopped"].includes(run.status)) return;
|
|
1583
|
+
try {
|
|
1584
|
+
const branch = deliveryContext?.sessionManager.getBranch() ?? [];
|
|
1585
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
1586
|
+
const entry = branch[index];
|
|
1587
|
+
if (entry.type !== "custom" || entry.customType !== JOURNAL_ENTRY_TYPE) continue;
|
|
1588
|
+
const data = entry.data as Record<string, unknown> | undefined;
|
|
1589
|
+
if (
|
|
1590
|
+
data?.runId === runId &&
|
|
1591
|
+
data.status === run.status &&
|
|
1592
|
+
data.timestamp === run.updatedAt &&
|
|
1593
|
+
(data.kind === "workflow_transition" || data.kind === "terminal_recovery")
|
|
1594
|
+
)
|
|
1595
|
+
return entry.id;
|
|
1596
|
+
}
|
|
1597
|
+
} catch {
|
|
1598
|
+
// Unavailable branch evidence cannot acknowledge a result.
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
function consumeReviewedResult(
|
|
1603
|
+
workflowEngine: WorkflowEngine,
|
|
1604
|
+
runId: string,
|
|
1605
|
+
execution: number,
|
|
1606
|
+
completionId: string,
|
|
1607
|
+
generation: number,
|
|
1608
|
+
ctx: ExtensionContext,
|
|
1609
|
+
): boolean {
|
|
1610
|
+
const current = () =>
|
|
1611
|
+
generation === deliveryGeneration &&
|
|
1612
|
+
workflowEngine === engine &&
|
|
1613
|
+
ctx.sessionManager === deliveryContext?.sessionManager &&
|
|
1614
|
+
ownsDeliveryBranch() &&
|
|
1615
|
+
workflowEngine.getState(runId)?.executionGeneration === execution &&
|
|
1616
|
+
completionReceipt(workflowEngine, runId) === completionId;
|
|
1617
|
+
if (!current()) return false;
|
|
1618
|
+
// An older marker without a durable receipt cannot be attributed to the
|
|
1619
|
+
// reviewed execution. Preserve it rather than silently migrating history.
|
|
1620
|
+
if (pendingDeliveryMarkers.has(runId) && pendingDeliveryMarkers.get(runId)?.completionId === undefined)
|
|
1621
|
+
return false;
|
|
1622
|
+
if (observedExecutions.get(runId) === completionId) return true;
|
|
1623
|
+
try {
|
|
1624
|
+
const previous = new Set(ctx.sessionManager.getBranch().map((entry) => entry.id));
|
|
1625
|
+
pi.appendEntry(PENDING_DELIVERY_ACK_TYPE, {
|
|
1626
|
+
runId,
|
|
1627
|
+
executionGeneration: execution,
|
|
1628
|
+
completionId,
|
|
1629
|
+
observed: true,
|
|
1630
|
+
timestamp: Date.now(),
|
|
1631
|
+
});
|
|
1632
|
+
if (!current()) return false;
|
|
1633
|
+
const accepted = ctx.sessionManager.getBranch().some((entry) => {
|
|
1634
|
+
if (previous.has(entry.id) || entry.type !== "custom" || entry.customType !== PENDING_DELIVERY_ACK_TYPE)
|
|
1635
|
+
return false;
|
|
1636
|
+
const data = entry.data as Record<string, unknown> | undefined;
|
|
1637
|
+
return data?.runId === runId && data.completionId === completionId && data.observed === true;
|
|
1638
|
+
});
|
|
1639
|
+
if (!accepted) return false;
|
|
1640
|
+
advanceDeliveryAnchor();
|
|
1641
|
+
observedExecutions.set(runId, completionId);
|
|
1642
|
+
while (observedExecutions.size > MAX_PENDING_DELIVERIES)
|
|
1643
|
+
observedExecutions.delete(observedExecutions.keys().next().value as string);
|
|
1644
|
+
if (pendingDeliveryMarkers.get(runId)?.completionId === completionId) pendingDeliveryMarkers.delete(runId);
|
|
1645
|
+
return true;
|
|
1646
|
+
} catch {
|
|
1647
|
+
return false;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
const rememberPendingDelivery = (
|
|
1652
|
+
runId: string,
|
|
1653
|
+
text: string,
|
|
1654
|
+
timestamp = Date.now(),
|
|
1655
|
+
executionGeneration?: number,
|
|
1656
|
+
completionId?: string,
|
|
1657
|
+
): void => {
|
|
1658
|
+
if (
|
|
1659
|
+
!runId ||
|
|
1660
|
+
timestamp + PENDING_DELIVERY_TTL_MS < Date.now() ||
|
|
1661
|
+
(completionId !== undefined && observedExecutions.get(runId) === completionId)
|
|
1662
|
+
)
|
|
1663
|
+
return;
|
|
1664
|
+
pendingDeliveryMarkers.set(runId, { text: text.slice(0, 8_000), timestamp, executionGeneration, completionId });
|
|
1554
1665
|
for (const [id, marker] of pendingDeliveryMarkers) {
|
|
1555
1666
|
if (marker.timestamp + PENDING_DELIVERY_TTL_MS < Date.now()) pendingDeliveryMarkers.delete(id);
|
|
1556
1667
|
}
|
|
@@ -1582,6 +1693,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1582
1693
|
!["completed", "failed", "stopped"].includes(run.status)
|
|
1583
1694
|
)
|
|
1584
1695
|
return;
|
|
1696
|
+
const completionId = completionReceipt(workflowEngine, runId);
|
|
1697
|
+
if (completionId !== undefined && observedExecutions.get(runId) === completionId) return;
|
|
1585
1698
|
const header = `Workflow ${run.runId} ${run.status}`;
|
|
1586
1699
|
const state = workflowEngine.getState(runId);
|
|
1587
1700
|
const finalResult = state?.result ?? run.finalResult;
|
|
@@ -1592,10 +1705,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1592
1705
|
: `result:\n${typeof finalResult === "string" ? finalResult : (JSON.stringify(finalResult, null, 2) ?? String(finalResult))}`);
|
|
1593
1706
|
const text = `${header}: ${body}`;
|
|
1594
1707
|
if (!ownsDeliveryBranch()) return;
|
|
1595
|
-
rememberPendingDelivery(runId, text);
|
|
1708
|
+
rememberPendingDelivery(runId, text, Date.now(), executionGeneration, completionId);
|
|
1596
1709
|
try {
|
|
1597
1710
|
pi.appendEntry(PENDING_DELIVERY_TYPE, {
|
|
1598
1711
|
runId,
|
|
1712
|
+
executionGeneration,
|
|
1713
|
+
completionId,
|
|
1599
1714
|
text: text.slice(0, 8_000),
|
|
1600
1715
|
status: run.status,
|
|
1601
1716
|
timestamp: Date.now(),
|
|
@@ -1633,22 +1748,37 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1633
1748
|
};
|
|
1634
1749
|
const hydratePendingDeliveries = (entries: SessionEntryLike[]): void => {
|
|
1635
1750
|
pendingDeliveryMarkers.clear();
|
|
1751
|
+
observedExecutions.clear();
|
|
1636
1752
|
for (const entry of entries) {
|
|
1637
1753
|
const shaped = entry as { type?: unknown; customType?: unknown; data?: unknown };
|
|
1638
1754
|
if (shaped.type !== "custom") continue;
|
|
1639
1755
|
const data = shaped.data as Record<string, unknown> | undefined;
|
|
1640
1756
|
if (typeof data?.runId !== "string") continue;
|
|
1641
|
-
if (shaped.customType === PENDING_DELIVERY_ACK_TYPE)
|
|
1642
|
-
|
|
1757
|
+
if (shaped.customType === PENDING_DELIVERY_ACK_TYPE) {
|
|
1758
|
+
// Explicit reads acknowledge exactly one durable terminal receipt.
|
|
1759
|
+
// Ordinary delivery ACKs still acknowledge the actual handed-off marker.
|
|
1760
|
+
if (
|
|
1761
|
+
data.observed !== true ||
|
|
1762
|
+
(typeof data.completionId === "string" &&
|
|
1763
|
+
pendingDeliveryMarkers.get(data.runId)?.completionId === data.completionId)
|
|
1764
|
+
)
|
|
1765
|
+
pendingDeliveryMarkers.delete(data.runId);
|
|
1766
|
+
} else if (shaped.customType === PENDING_DELIVERY_TYPE && typeof data.text === "string") {
|
|
1643
1767
|
rememberPendingDelivery(
|
|
1644
1768
|
data.runId,
|
|
1645
1769
|
data.text,
|
|
1646
1770
|
typeof data.timestamp === "number" ? data.timestamp : Date.now(),
|
|
1771
|
+
typeof data.executionGeneration === "number" ? data.executionGeneration : undefined,
|
|
1772
|
+
typeof data.completionId === "string" ? data.completionId : undefined,
|
|
1647
1773
|
);
|
|
1648
1774
|
}
|
|
1649
1775
|
}
|
|
1650
1776
|
};
|
|
1651
1777
|
const flushPendingDeliveries = (): void => {
|
|
1778
|
+
for (const [runId, marker] of pendingDeliveryMarkers) {
|
|
1779
|
+
if (marker.completionId !== undefined && observedExecutions.get(runId) === marker.completionId)
|
|
1780
|
+
pendingDeliveryMarkers.delete(runId);
|
|
1781
|
+
}
|
|
1652
1782
|
if (!canDeliver() || !deliveryContext || pendingDeliveryMarkers.size === 0) return;
|
|
1653
1783
|
if (typeof pi.sendMessage !== "function" || typeof pi.sendUserMessage !== "function") return;
|
|
1654
1784
|
const ctx = deliveryContext;
|
|
@@ -1680,7 +1810,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1680
1810
|
try {
|
|
1681
1811
|
if (!isCurrent()) return;
|
|
1682
1812
|
// Never use a custom-message triggerTurn: that skips input preparation
|
|
1683
|
-
// and before_agent_start. The void API is NOT an
|
|
1813
|
+
// (and, before Pi 1.2, before_agent_start). The void API is NOT an
|
|
1814
|
+
// acceptance promise.
|
|
1684
1815
|
const missing = batch.filter(([runId]) => !persisted(runId));
|
|
1685
1816
|
if (missing.length > 0)
|
|
1686
1817
|
pi.sendMessage(
|
|
@@ -1714,7 +1845,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1714
1845
|
}
|
|
1715
1846
|
// An earlier input hook can await while the same manager navigates. Keep
|
|
1716
1847
|
// exact owner evidence until our input hook consumes this private wake.
|
|
1717
|
-
|
|
1848
|
+
// An overflowed parent would resend the same over-window context: the
|
|
1849
|
+
// acknowledged results above wait for the next run instead.
|
|
1850
|
+
if (isCurrent() && engine && !overflowWakes.holds(ctx)) {
|
|
1718
1851
|
const wake = `${DELIVERY_WAKE_PREFIX}${randomUUID()}] ${DELIVERY_WAKE_TEXT}`;
|
|
1719
1852
|
wakeAdmissions.set(wake, { generation, context: ctx, engine, anchor: deliveryBranchAnchor });
|
|
1720
1853
|
while (wakeAdmissions.size > MAX_WAKE_ADMISSIONS) {
|