@signalridge/pi-workflows 1.11.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +15 -1
- 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-authoring/references/runtime.md +3 -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 +63 -9
- package/src/errors.ts +14 -0
- package/src/index.ts +28 -18
- package/src/journal.ts +13 -4
- package/src/rpc-client.ts +33 -20
- package/src/runtime.ts +58 -29
- package/src/ui-overview.ts +97 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.12.0
|
|
4
|
+
### Minor Changes
|
|
5
|
+
|
|
6
|
+
- 3096585: Give Workflows one rich below-editor overview and keep Subagents Fleet limited to independent agents. Preserve workflow navigation, completed-call token semantics, periodic projection/footer updates, and exact retained-child access through the owner navigator and explicit `/agents` retained-child paths, including when Fleet is disabled. No execution/model policy or managed/UI protocol changes.
|
|
7
|
+
|
|
8
|
+
Update both packages together for the new layout: older Subagents can still duplicate the new overview; newer Subagents paired with older Workflows leaves the older upper progress panel. Existing running sessions do not acquire these UI changes until a later normal activation.
|
|
9
|
+
|
|
10
|
+
### Patch Changes
|
|
11
|
+
|
|
12
|
+
- 3096585: Do not charge managed-request preflight rejections as submissions, and finalize schema-invalid logical calls with their exact settled attempt metadata. Count all owner-side workflows in the passive overview without expanding the bounded peer snapshot.
|
|
13
|
+
- 3096585: Validate complete managed requests before allocating dispatch leases, preserving local validation diagnostics and completed resume prefixes. Apply structured-output constraints conjunctively and extract balanced JSON from large malformed replies in linear time without changing bounded repair behavior.
|
|
14
|
+
- 3096585: Validate persisted transcript pointers with the managed protocol contract and settle logical calls when a token budget prevents a repair attempt, preserving the completed child's usage and ownership on restore.
|
|
15
|
+
- Updated dependencies [3096585]
|
|
16
|
+
- Updated dependencies [3096585]
|
|
17
|
+
- @signalridge/pi-ui@1.5.0
|
|
18
|
+
|
|
19
|
+
## 1.11.1
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- 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.
|
|
23
|
+
- 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.
|
|
24
|
+
- 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.
|
|
25
|
+
- 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.
|
|
26
|
+
- Inherit nested workers' thinking from their immediate spawning session while preserving explicit policy precedence.
|
|
27
|
+
- 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.
|
|
28
|
+
- 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.
|
|
29
|
+
- 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.
|
|
30
|
+
- Updated dependencies [da9ed0f]
|
|
31
|
+
- @signalridge/pi-ui@1.4.1
|
|
32
|
+
|
|
3
33
|
## 1.11.0
|
|
4
34
|
### Minor Changes
|
|
5
35
|
|
package/README.md
CHANGED
|
@@ -265,7 +265,13 @@ Phase entry, task attempts and call attribution come from fenced runtime observa
|
|
|
265
265
|
|
|
266
266
|
A selected root agent call can open its retained conversation through the subagent owner's optional exact-attempt UI endpoint. Unknown or reused old attempts are not redirected to a newer child. Workflow controls remain here; steering, child stop, sessions, model policy and foreground handoff remain in `pi-subagents`. The optional UI protocol does not change the required managed v4 handshake.
|
|
267
267
|
|
|
268
|
-
|
|
268
|
+
Workflows owns one rich passive overview **below the editor**; there is no second upper workflow list. Rows put the workflow name first, followed by semantic status, the observed current phase, running/total call counts, done/failed counts, elapsed time and available **settled-call tokens**. Tokens are completed call-result totals, not live in-flight usage; unknown is omitted and an explicit zero stays zero. Paused, stopping and childless runs remain visible; recent terminal runs linger for four seconds, while all retained history stays in `/workflows`.
|
|
269
|
+
|
|
270
|
+
`/workflows-progress compact|detailed|status|max <N>` retains its settings: `status` reports them, `detailed` adds bounded call rows, and `max` caps shown runs/calls. The panel uses a terminal-height row budget, prioritizes live calls and reports overflow rather than growing as runs × calls. Names/results are sanitized only in display copies; journals and stored source remain unchanged. The panel takes no empty-prompt arrow keys: `/workflows` is the authoritative navigator. Closing or disposing presentation never stops execution. Fullscreen, regular terminal, RPC and print paths retain their distinct public API boundaries.
|
|
271
|
+
|
|
272
|
+
Subagents Fleet lists independent agents only. For retained children, use a selected root call in `/workflows`, or `/agents → Retained workflow children` to open the owner inspector or directly inspect the bounded child list. The latter also works when the workflow UI peer is absent or Fleet is disabled. `/agents → Agent runs` retains individual sessions and child steering/stop controls; eligible independent foreground agents keep handoff. Workflow controls and model/execution policy do not move across owners. The optional validated UI query/change/open bridge remains available to external observers and is not a usage RPC.
|
|
273
|
+
|
|
274
|
+
**Mixed-version UI caveat:** update both packages together for this ownership layout. An older Subagents may still render its own workflow overview alongside the new panel. A newer Subagents with older Workflows hides owned Fleet rows but leaves the older upper progress list in place. Managed protocol v4 and exact-attempt conversation admission are unchanged; no untyped event guessing or automatic legacy-peer suppression is used.
|
|
269
275
|
|
|
270
276
|
### Background result notifications
|
|
271
277
|
|
|
@@ -306,6 +312,14 @@ awaits while navigation or shutdown retires the owner, the obsolete
|
|
|
306
312
|
extension-sourced wake is handled without starting a turn. Interactive/RPC
|
|
307
313
|
input and other extensions' messages are not consumed by this guard.
|
|
308
314
|
|
|
315
|
+
No wake is sent while the session's latest meaningful response overflowed the model's
|
|
316
|
+
context window and no compaction followed it: that turn would resend the same
|
|
317
|
+
over-window context and fail again. Results are still delivered and acknowledged
|
|
318
|
+
as context, and one warning per overflow episode asks to run `/compact` (or
|
|
319
|
+
`/codex-compact` when installed) or start a new session. A compaction or a later
|
|
320
|
+
successful response restores normal wakes. Aborted responses and transient errors
|
|
321
|
+
(including HTTP 503 and rate limits) do not prove context recovery.
|
|
322
|
+
|
|
309
323
|
This is a handoff check, not a navigation lock: public hooks cannot fence an
|
|
310
324
|
arbitrary later async input or preparation handler that navigates after our
|
|
311
325
|
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.12.0",
|
|
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.
|
|
26
|
+
"@signalridge/pi-ui": "^1.5.0",
|
|
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.12.0"
|
|
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.12.0`
|
|
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.12.0`
|
|
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
|
|
|
@@ -21,7 +21,9 @@ Call `agent(prompt, { label, schema?, strength? })`; it returns text, a schema-v
|
|
|
21
21
|
|
|
22
22
|
Use `agent(prompt, { thread: "implementer" })` when the same managed subagent must receive a later follow-up with its complete conversation intact. Reuse a thread name sequentially; never put same-thread calls in one `parallel()` batch. Threads are scoped to one workflow run/session and cannot use worktree isolation. A pause or process restart may require a fresh thread session.
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
Managed requests are validated locally before dispatch. The complete effective prompt must be nonblank and at most 100,000 characters after trimming, including schema instructions and any repair feedback. Leave room for both when sizing a prompt; aggregate compact summaries or bounded excerpts instead of concatenating full worker outputs. An oversized request throws a validation error without allocating a child or invalidating the completed journal prefix. A submitted request with a lost/ambiguous reply still requires positive exact-owner cleanup; a missing reconciliation record is not proof of cleanup.
|
|
25
|
+
|
|
26
|
+
When JavaScript reads fields, pass a small plain JSON Schema. Supported constraints are conjunctive: `const` and `enum` do not bypass type, required/properties/items, bounds, or `additionalProperties: false`. Unsupported keywords are rejected, not silently ignored. JSON extraction accepts prose/fences and balanced nested values, handles escaped strings, and can recover a later balanced candidate after a malformed prefix in linear time; balanced but invalid JSON still enters bounded repair. Schema noncompliance after repair throws and bypasses agent retries. Catch it only to return an explicit incomplete outcome without reading missing fields. Return objects, arrays, strings, numbers, booleans, and `null`—not functions, promises, cycles, `BigInt`, or runtime handles.
|
|
25
27
|
|
|
26
28
|
## Routing and support
|
|
27
29
|
|
|
@@ -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.12.0"
|
|
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.12.0"
|
|
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
|
@@ -20,7 +20,13 @@
|
|
|
20
20
|
import { createHash, randomUUID } from "node:crypto";
|
|
21
21
|
import type { ManagedSpawnResponse } from "@signalridge/pi-subagents-protocol";
|
|
22
22
|
import { isManagedAgentTier } from "@signalridge/pi-subagents-protocol";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
AllocationFreeDispatchError,
|
|
25
|
+
describeWorkflowError,
|
|
26
|
+
WorkflowError,
|
|
27
|
+
WorkflowErrorCode,
|
|
28
|
+
wrapError,
|
|
29
|
+
} from "./errors.js";
|
|
24
30
|
import { type WorkflowInspection, type WorkflowInspectionHeader, WorkflowPresentation } from "./inspection.js";
|
|
25
31
|
import {
|
|
26
32
|
applyRecoveryEvent,
|
|
@@ -46,6 +52,7 @@ import {
|
|
|
46
52
|
type DispatchTask,
|
|
47
53
|
type ManagedProtocolCheck,
|
|
48
54
|
type ManagedSpawnClient,
|
|
55
|
+
prepareManagedSpawnRequest,
|
|
49
56
|
type WorkflowEventBus,
|
|
50
57
|
} from "./rpc-client.js";
|
|
51
58
|
import {
|
|
@@ -179,6 +186,8 @@ interface ManagedCallFacts {
|
|
|
179
186
|
executionGeneration: number;
|
|
180
187
|
attemptId: string;
|
|
181
188
|
agentId: string;
|
|
189
|
+
/** Exact-owner terminal admission, not merely a queued/running identity. */
|
|
190
|
+
settled?: boolean;
|
|
182
191
|
tokenCount?: number;
|
|
183
192
|
outputFile?: string;
|
|
184
193
|
compactionCount: number;
|
|
@@ -788,6 +797,7 @@ export class WorkflowEngine {
|
|
|
788
797
|
facts.agentId !== agentId
|
|
789
798
|
)
|
|
790
799
|
return;
|
|
800
|
+
facts.settled = true;
|
|
791
801
|
facts.tokenCount = terminal.tokenCount;
|
|
792
802
|
facts.outputFile = terminal.outputFile;
|
|
793
803
|
facts.compactionCount = Math.max(facts.compactionCount, terminal.compactionCount);
|
|
@@ -1564,6 +1574,12 @@ export class WorkflowEngine {
|
|
|
1564
1574
|
onAgentEnd: (event) => {
|
|
1565
1575
|
if (!isPresentationCurrent()) return;
|
|
1566
1576
|
const nodeId = event.id.startsWith(`${runId}:`) ? event.id.slice(runId.length + 1) : undefined;
|
|
1577
|
+
// A completed child is not a successful logical call until runtime
|
|
1578
|
+
// validation accepts it. Exhausted schema/repair failures must settle
|
|
1579
|
+
// the current journal attempt too, not just its volatile presentation.
|
|
1580
|
+
if (nodeId !== undefined && event.error !== undefined)
|
|
1581
|
+
this.finalizeLogicalCallFailure(state, nodeId, event.error);
|
|
1582
|
+
if (!isPresentationCurrent()) return;
|
|
1567
1583
|
const persisted = nodeId === undefined ? undefined : state.run.callResults[nodeId];
|
|
1568
1584
|
const persistedTokens =
|
|
1569
1585
|
nodeId !== undefined && persisted?.attemptId === state.run.attemptIds[nodeId]
|
|
@@ -1712,7 +1728,6 @@ export class WorkflowEngine {
|
|
|
1712
1728
|
const spawnNodeId = `call-${nodeId}`;
|
|
1713
1729
|
const base = state.generations.get(nodeId) ?? 0;
|
|
1714
1730
|
const generation = base + (runOptions.attempt ?? 1);
|
|
1715
|
-
state.generations.set(nodeId, generation);
|
|
1716
1731
|
const spawnAttemptId = `attempt-${generation}`;
|
|
1717
1732
|
const attemptId = `${runId}/${nodeId}/attempt-${generation}`;
|
|
1718
1733
|
|
|
@@ -1725,13 +1740,6 @@ export class WorkflowEngine {
|
|
|
1725
1740
|
{ recoverable: false },
|
|
1726
1741
|
);
|
|
1727
1742
|
}
|
|
1728
|
-
// This dispatch is authoritative for the current script revision. Do not
|
|
1729
|
-
// carry an identity from the previous script: deleting `tier` must clear
|
|
1730
|
-
// the recorded one instead of silently preserving the old route.
|
|
1731
|
-
const tierIdentity: CallTierIdentity = tier === undefined ? {} : { tier };
|
|
1732
|
-
state.callTiers.set(nodeId, tierIdentity);
|
|
1733
|
-
run.callTiers[nodeId] = tierIdentity;
|
|
1734
|
-
state.managedCallFacts.delete(nodeId);
|
|
1735
1743
|
const task: DispatchTask = {
|
|
1736
1744
|
subagent_type: type,
|
|
1737
1745
|
prompt,
|
|
@@ -1743,6 +1751,25 @@ export class WorkflowEngine {
|
|
|
1743
1751
|
...(runOptions.isolation === undefined ? {} : { isolation: runOptions.isolation }),
|
|
1744
1752
|
};
|
|
1745
1753
|
|
|
1754
|
+
// Validate the complete effective wire request (schema/repair text included)
|
|
1755
|
+
// before any attempt fact or submitted lease. A deterministic caller error
|
|
1756
|
+
// owns no remote allocation; cleanup must not mask its original diagnostic.
|
|
1757
|
+
try {
|
|
1758
|
+
prepareManagedSpawnRequest(task, runId, spawnNodeId, spawnAttemptId);
|
|
1759
|
+
} catch (error: unknown) {
|
|
1760
|
+
// This proof is minted only on the allocation-free side of dispatch;
|
|
1761
|
+
// errors from client.spawn below remain conservatively chargeable.
|
|
1762
|
+
throw new AllocationFreeDispatchError(error);
|
|
1763
|
+
}
|
|
1764
|
+
state.generations.set(nodeId, generation);
|
|
1765
|
+
// This dispatch is authoritative for the current script revision. Do not
|
|
1766
|
+
// carry an identity from the previous script: deleting `tier` must clear
|
|
1767
|
+
// the recorded one instead of silently preserving the old route.
|
|
1768
|
+
const tierIdentity: CallTierIdentity = tier === undefined ? {} : { tier };
|
|
1769
|
+
state.callTiers.set(nodeId, tierIdentity);
|
|
1770
|
+
run.callTiers[nodeId] = tierIdentity;
|
|
1771
|
+
state.managedCallFacts.delete(nodeId);
|
|
1772
|
+
|
|
1746
1773
|
// Journal the attempt rotation only for top-level calls. A nested frame is
|
|
1747
1774
|
// represented by its parent workflow_result replay boundary instead of
|
|
1748
1775
|
// leaking child call facts into the parent's numeric call namespace.
|
|
@@ -2235,6 +2262,33 @@ export class WorkflowEngine {
|
|
|
2235
2262
|
state.run.updatedAt = Date.now();
|
|
2236
2263
|
}
|
|
2237
2264
|
|
|
2265
|
+
private finalizeLogicalCallFailure(state: ScriptRunState, nodeId: string, error: string): void {
|
|
2266
|
+
const run = state.run;
|
|
2267
|
+
const facts = state.managedCallFacts.get(nodeId);
|
|
2268
|
+
// No guesses about unknown remote admissions, stale attempts, nested calls,
|
|
2269
|
+
// or failures already committed by dispatch's lifecycle terminal path.
|
|
2270
|
+
if (
|
|
2271
|
+
run.callStatus[nodeId] !== "running" ||
|
|
2272
|
+
!facts?.settled ||
|
|
2273
|
+
facts.executionGeneration !== state.executionGeneration ||
|
|
2274
|
+
facts.attemptId !== run.attemptIds[nodeId]
|
|
2275
|
+
)
|
|
2276
|
+
return;
|
|
2277
|
+
const result = resultFromLifecycle(
|
|
2278
|
+
"failed",
|
|
2279
|
+
facts.agentId,
|
|
2280
|
+
undefined,
|
|
2281
|
+
error,
|
|
2282
|
+
facts.compactionCount,
|
|
2283
|
+
facts.tokenCount,
|
|
2284
|
+
state.callTiers.get(nodeId)?.tier,
|
|
2285
|
+
);
|
|
2286
|
+
result.attemptId = facts.attemptId;
|
|
2287
|
+
if (facts.outputFile !== undefined) result.outputFile = facts.outputFile;
|
|
2288
|
+
// No call hash/store delta: invalid output can never enter the replay cache.
|
|
2289
|
+
this.commitCallTerminal(run.runId, nodeId, result);
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2238
2292
|
private commitCallTerminal(runId: string, nodeId: string, result: CallResult, required = false): void {
|
|
2239
2293
|
const state = this.runs.get(runId);
|
|
2240
2294
|
if (!state) return;
|
package/src/errors.ts
CHANGED
|
@@ -67,6 +67,20 @@ export class WorkflowError extends Error {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Host-only proof that effective managed-request validation rejected before any
|
|
72
|
+
* attempt rotation or remote allocation. Never infer this from a code/message,
|
|
73
|
+
* or attach it to an error received after submitting a spawn.
|
|
74
|
+
*/
|
|
75
|
+
export class AllocationFreeDispatchError extends WorkflowError {
|
|
76
|
+
constructor(cause: unknown) {
|
|
77
|
+
super(cause instanceof Error ? cause.message : String(cause), WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
|
|
78
|
+
recoverable: false,
|
|
79
|
+
details: cause,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
70
84
|
// Transported VM throws must not acquire provider/abort classification merely
|
|
71
85
|
// because IPC reconstructed them in the host Error realm. Keep provenance and
|
|
72
86
|
// helper-computed text outside user data (including non-Error object throws).
|
package/src/index.ts
CHANGED
|
@@ -2,12 +2,13 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { Text
|
|
5
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
6
6
|
import { sanitizeDisplayText } from "@signalridge/pi-ui";
|
|
7
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 {
|
|
@@ -25,8 +26,9 @@ import {
|
|
|
25
26
|
WORKFLOW_STRENGTHS,
|
|
26
27
|
type WorkflowStrengthTable,
|
|
27
28
|
} from "./strengths.js";
|
|
28
|
-
import {
|
|
29
|
+
import { showWorkflowNavigator } from "./ui.js";
|
|
29
30
|
import { buildWorkflowUiSnapshot, openOwnedConversation, WorkflowUiBridge, type WorkflowUiSurface } from "./ui-bus.js";
|
|
31
|
+
import { overviewRuns, workflowOverviewLines } from "./ui-overview.js";
|
|
30
32
|
import {
|
|
31
33
|
loadProjectWorkflowSettings,
|
|
32
34
|
loadWorkflowSettings,
|
|
@@ -361,6 +363,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
361
363
|
let keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
|
|
362
364
|
/** Public sends are void: acceptance is checked against the owning branch. */
|
|
363
365
|
let deliveryContext: ExtensionContext | undefined;
|
|
366
|
+
const overflowWakes = new OverflowWakeGuard(pi, "workflow results are added to the conversation");
|
|
364
367
|
let deliveryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
365
368
|
let inputPreparing = false;
|
|
366
369
|
const turnArming = new WorkflowTurnArming();
|
|
@@ -507,11 +510,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
507
510
|
.map((summary) => workflowEngine.getRun(String(summary.runId)))
|
|
508
511
|
.filter((run): run is NonNullable<typeof run> => run !== undefined);
|
|
509
512
|
if (statusSource?.()) tui.setStatus("pi-workflows", workflowStatusSummary(runs));
|
|
510
|
-
const
|
|
513
|
+
const hasProgress = overviewRuns(runs).length > 0;
|
|
511
514
|
const surfaceId = workflowUiSurfaceId;
|
|
515
|
+
const snapshot = buildWorkflowUiSnapshot(workflowEngine, surfaceId);
|
|
512
516
|
tui.setWidget(
|
|
513
517
|
"pi-workflows",
|
|
514
|
-
|
|
518
|
+
hasProgress
|
|
515
519
|
? (host, theme) => ({
|
|
516
520
|
render(width) {
|
|
517
521
|
if (
|
|
@@ -519,29 +523,33 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
519
523
|
engine !== workflowEngine ||
|
|
520
524
|
workflowEngine.isDisposed() ||
|
|
521
525
|
workflowUiSurfaceId !== surfaceId ||
|
|
526
|
+
!ownsDeliveryBranch() ||
|
|
522
527
|
!Number.isFinite(width) ||
|
|
523
528
|
width < 1
|
|
524
529
|
)
|
|
525
530
|
return [];
|
|
526
531
|
const height = Number.isFinite(host.terminal.rows) ? Math.max(0, host.terminal.rows) : 24;
|
|
527
|
-
const rowBudget = Math.max(
|
|
528
|
-
return
|
|
532
|
+
const rowBudget = Math.max(0, Math.min(12, Math.floor(height / 4)));
|
|
533
|
+
return workflowOverviewLines(
|
|
529
534
|
runs,
|
|
535
|
+
snapshot.runs,
|
|
536
|
+
theme,
|
|
537
|
+
width,
|
|
538
|
+
rowBudget,
|
|
530
539
|
workflowSettings.progressMode,
|
|
531
540
|
workflowSettings.maxAgentsShown,
|
|
532
|
-
rowBudget,
|
|
533
|
-
).map((line, index) =>
|
|
534
|
-
truncateToWidth(theme.fg(index === 0 ? "accent" : "muted", line), Math.floor(width)),
|
|
535
541
|
);
|
|
536
542
|
},
|
|
537
543
|
invalidate() {},
|
|
538
544
|
})
|
|
539
545
|
: undefined,
|
|
546
|
+
{ placement: "belowEditor" },
|
|
540
547
|
);
|
|
548
|
+
// Publication/footer cadence is independent of passive row visibility.
|
|
541
549
|
workflowUiBridge?.publish();
|
|
542
|
-
if (
|
|
550
|
+
if (hasProgress && !widgetTimer) {
|
|
543
551
|
widgetTimer = setInterval(refreshWidget, 2_000);
|
|
544
|
-
} else if (!
|
|
552
|
+
} else if (!hasProgress && widgetTimer) {
|
|
545
553
|
clearInterval(widgetTimer);
|
|
546
554
|
widgetTimer = undefined;
|
|
547
555
|
}
|
|
@@ -763,9 +771,6 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
763
771
|
// re-run every finished call untabled.
|
|
764
772
|
workflowEngine.strengths = () => workflowSettings.strengths;
|
|
765
773
|
workflowEngine.loadSavedWorkflow = resolveNestedWorkflow;
|
|
766
|
-
// Command/tool registrations persist across session replacement, but the
|
|
767
|
-
// widget belongs to the newly restored engine and must be refreshed too.
|
|
768
|
-
refreshWidget();
|
|
769
774
|
if (surfaceRegistered) return;
|
|
770
775
|
surfaceRegistered = true;
|
|
771
776
|
|
|
@@ -1808,7 +1813,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1808
1813
|
try {
|
|
1809
1814
|
if (!isCurrent()) return;
|
|
1810
1815
|
// Never use a custom-message triggerTurn: that skips input preparation
|
|
1811
|
-
// and before_agent_start. The void API is NOT an
|
|
1816
|
+
// (and, before Pi 1.2, before_agent_start). The void API is NOT an
|
|
1817
|
+
// acceptance promise.
|
|
1812
1818
|
const missing = batch.filter(([runId]) => !persisted(runId));
|
|
1813
1819
|
if (missing.length > 0)
|
|
1814
1820
|
pi.sendMessage(
|
|
@@ -1842,7 +1848,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1842
1848
|
}
|
|
1843
1849
|
// An earlier input hook can await while the same manager navigates. Keep
|
|
1844
1850
|
// exact owner evidence until our input hook consumes this private wake.
|
|
1845
|
-
|
|
1851
|
+
// An overflowed parent would resend the same over-window context: the
|
|
1852
|
+
// acknowledged results above wait for the next run instead.
|
|
1853
|
+
if (isCurrent() && engine && !overflowWakes.holds(ctx)) {
|
|
1846
1854
|
const wake = `${DELIVERY_WAKE_PREFIX}${randomUUID()}] ${DELIVERY_WAKE_TEXT}`;
|
|
1847
1855
|
wakeAdmissions.set(wake, { generation, context: ctx, engine, anchor: deliveryBranchAnchor });
|
|
1848
1856
|
while (wakeAdmissions.size > MAX_WAKE_ADMISSIONS) {
|
|
@@ -2003,7 +2011,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
2003
2011
|
return;
|
|
2004
2012
|
}
|
|
2005
2013
|
engine = recoveredEngine;
|
|
2006
|
-
widgetUi = ctx.mode === "tui" ? ctx.ui : undefined;
|
|
2014
|
+
widgetUi = ctx.mode === "tui" && ctx.hasUI ? ctx.ui : undefined;
|
|
2007
2015
|
registerSurface();
|
|
2008
2016
|
|
|
2009
2017
|
// Public API methods target the current session: generation + branch
|
|
@@ -2012,7 +2020,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
2012
2020
|
deliveryBranchAnchor = journalBranchAnchor;
|
|
2013
2021
|
registerStatusActions();
|
|
2014
2022
|
workflowUiBridge ??= new WorkflowUiBridge(pi.events, workflowUiSurface);
|
|
2015
|
-
|
|
2023
|
+
// Bind the presentation only after its session/branch source is installed.
|
|
2024
|
+
refreshWidget();
|
|
2025
|
+
if (!widgetUi) workflowUiBridge.publish();
|
|
2016
2026
|
inputPreparing = false;
|
|
2017
2027
|
compacting = false;
|
|
2018
2028
|
navigating = false;
|
package/src/journal.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { createHash } from "node:crypto";
|
|
26
26
|
import type { ManagedOwner } from "@signalridge/pi-subagents-protocol";
|
|
27
|
-
import { isManagedAgentTier } from "@signalridge/pi-subagents-protocol";
|
|
27
|
+
import { isManagedAgentTier, parseManagedTerminalSnapshot } from "@signalridge/pi-subagents-protocol";
|
|
28
28
|
import {
|
|
29
29
|
MAX_CALLS_PER_WORKFLOW,
|
|
30
30
|
normalizeAgentRetries,
|
|
@@ -402,6 +402,17 @@ function parseResult(raw: unknown, label: string): CallResult {
|
|
|
402
402
|
}
|
|
403
403
|
const updatedAt = validTimestamp(raw.updatedAt, label);
|
|
404
404
|
const tierIdentity = parseCallTierIdentity(raw, label);
|
|
405
|
+
// Transcript pointers have their own managed protocol contract (including
|
|
406
|
+
// normalization and the 2000-character bound), not the journal identity cap.
|
|
407
|
+
const outputFile =
|
|
408
|
+
raw.outputFile === undefined
|
|
409
|
+
? undefined
|
|
410
|
+
: parseManagedTerminalSnapshot({
|
|
411
|
+
status,
|
|
412
|
+
outputFile: raw.outputFile,
|
|
413
|
+
compactionCount,
|
|
414
|
+
completedAt: updatedAt,
|
|
415
|
+
}).outputFile;
|
|
405
416
|
const result: CallResult = {
|
|
406
417
|
status,
|
|
407
418
|
compactionCount,
|
|
@@ -413,9 +424,7 @@ function parseResult(raw: unknown, label: string): CallResult {
|
|
|
413
424
|
: { attemptId: boundedString(raw.attemptId, `${label}.attemptId`, JOURNAL_ID_LIMIT) }),
|
|
414
425
|
...(raw.result === undefined ? {} : { result: raw.result }),
|
|
415
426
|
...(raw.error === undefined ? {} : { error: boundedString(raw.error, `${label}.error`, JOURNAL_ERROR_LIMIT) }),
|
|
416
|
-
...(
|
|
417
|
-
? {}
|
|
418
|
-
: { outputFile: boundedString(raw.outputFile, `${label}.outputFile`, JOURNAL_ID_LIMIT) }),
|
|
427
|
+
...(outputFile === undefined ? {} : { outputFile }),
|
|
419
428
|
...(raw.tokenCount === undefined
|
|
420
429
|
? {}
|
|
421
430
|
: {
|
package/src/rpc-client.ts
CHANGED
|
@@ -6,10 +6,12 @@ import type {
|
|
|
6
6
|
} from "@signalridge/pi-subagents-protocol";
|
|
7
7
|
import {
|
|
8
8
|
PROTOCOL_VERSION,
|
|
9
|
+
parseManagedSpawnRequest,
|
|
9
10
|
parseManagedSpawnResponse,
|
|
10
11
|
parseProtocolPing,
|
|
11
12
|
requiredCapabilitiesMatch,
|
|
12
13
|
} from "@signalridge/pi-subagents-protocol";
|
|
14
|
+
import { WorkflowError, WorkflowErrorCode } from "./errors.js";
|
|
13
15
|
import type { WorkflowOwner } from "./journal.js";
|
|
14
16
|
|
|
15
17
|
/** Minimal dispatch task for a script-run agent() call. */
|
|
@@ -298,6 +300,31 @@ export interface ManagedSpawnClient {
|
|
|
298
300
|
checkProtocol?: () => Promise<ManagedProtocolCheck>;
|
|
299
301
|
}
|
|
300
302
|
|
|
303
|
+
/** Shared wire preflight, also used before the engine submits an allocation lease. */
|
|
304
|
+
export function prepareManagedSpawnRequest(task: DispatchTask, runId: string, nodeId: string, attemptId = "attempt-1") {
|
|
305
|
+
try {
|
|
306
|
+
return parseManagedSpawnRequest({
|
|
307
|
+
requestId: requestId(),
|
|
308
|
+
spawnKey: `${runId}/${nodeId}/${attemptId}`,
|
|
309
|
+
type: task.subagent_type,
|
|
310
|
+
prompt: task.prompt,
|
|
311
|
+
description: task.description,
|
|
312
|
+
...(task.tier === undefined ? {} : { tier: task.tier }),
|
|
313
|
+
...(task.toolset === undefined ? {} : { toolset: task.toolset }),
|
|
314
|
+
...(task.thread === undefined ? {} : { thread: task.thread }),
|
|
315
|
+
...(task.excludeTools === undefined ? {} : { excludeTools: task.excludeTools }),
|
|
316
|
+
...(task.isolation === undefined ? {} : { isolation: task.isolation }),
|
|
317
|
+
owner: { extension: "pi-workflows", runId, nodeId, attemptId },
|
|
318
|
+
});
|
|
319
|
+
} catch (error: unknown) {
|
|
320
|
+
throw new WorkflowError(
|
|
321
|
+
error instanceof Error ? error.message : String(error),
|
|
322
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
323
|
+
{ recoverable: false, details: error },
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
301
328
|
/** Event-bus-only client for the additive pi-subagents managed protocol. */
|
|
302
329
|
export function createManagedSpawnClient(
|
|
303
330
|
events: WorkflowEventBus,
|
|
@@ -325,29 +352,15 @@ export function createManagedSpawnClient(
|
|
|
325
352
|
* fingerprint still reuses the persisted tombstone (per-call cache).
|
|
326
353
|
*/
|
|
327
354
|
async spawn(task, runId, nodeId, attemptId, signal) {
|
|
328
|
-
|
|
329
|
-
|
|
355
|
+
// Outside the transport catch: a local rejection never emitted a request,
|
|
356
|
+
// so it must not create reconciliation or owned-cleanup uncertainty.
|
|
357
|
+
const request = prepareManagedSpawnRequest(task, runId, nodeId, attemptId);
|
|
358
|
+
const { spawnKey, owner } = request;
|
|
330
359
|
const requestSignal =
|
|
331
360
|
signal && sessionSignal ? AbortSignal.any([signal, sessionSignal]) : (signal ?? sessionSignal);
|
|
332
361
|
let data: unknown;
|
|
333
362
|
try {
|
|
334
|
-
data = await call<unknown>(
|
|
335
|
-
"subagents:rpc:spawn-managed",
|
|
336
|
-
{
|
|
337
|
-
requestId: requestId(),
|
|
338
|
-
spawnKey,
|
|
339
|
-
type: task.subagent_type,
|
|
340
|
-
prompt: task.prompt,
|
|
341
|
-
description: task.description,
|
|
342
|
-
...(task.tier === undefined ? {} : { tier: task.tier }),
|
|
343
|
-
...(task.toolset === undefined ? {} : { toolset: task.toolset }),
|
|
344
|
-
...(task.thread === undefined ? {} : { thread: task.thread }),
|
|
345
|
-
...(task.excludeTools === undefined ? {} : { excludeTools: task.excludeTools }),
|
|
346
|
-
...(task.isolation === undefined ? {} : { isolation: task.isolation }),
|
|
347
|
-
owner: { extension: "pi-workflows", runId, nodeId, attemptId: effectiveAttemptId },
|
|
348
|
-
},
|
|
349
|
-
requestSignal,
|
|
350
|
-
);
|
|
363
|
+
data = await call<unknown>("subagents:rpc:spawn-managed", { ...request }, requestSignal);
|
|
351
364
|
} catch (error: unknown) {
|
|
352
365
|
// Reconciliation must not inherit the signal that caused the original
|
|
353
366
|
// request to fail: shutdown/abort can happen after allocation but before
|
|
@@ -357,7 +370,7 @@ export function createManagedSpawnClient(
|
|
|
357
370
|
{
|
|
358
371
|
requestId: requestId(),
|
|
359
372
|
spawnKey,
|
|
360
|
-
owner
|
|
373
|
+
owner,
|
|
361
374
|
},
|
|
362
375
|
undefined,
|
|
363
376
|
5_000,
|
package/src/runtime.ts
CHANGED
|
@@ -25,7 +25,7 @@ import type { ManagedRoutingPolicy } from "@signalridge/pi-subagents-protocol";
|
|
|
25
25
|
import { agentTierPolicyIdentity, isManagedAgentTier } from "@signalridge/pi-subagents-protocol";
|
|
26
26
|
import { parse } from "acorn";
|
|
27
27
|
import { checkRuntimeBindings } from "./capabilities.js";
|
|
28
|
-
import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
|
|
28
|
+
import { AllocationFreeDispatchError, WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
|
|
29
29
|
import {
|
|
30
30
|
executeOrchestration,
|
|
31
31
|
normalizeOrchestration,
|
|
@@ -1038,31 +1038,34 @@ async function withTimeout<T>(
|
|
|
1038
1038
|
|
|
1039
1039
|
/** Extract the first balanced JSON value ({...} or [...]) from text. */
|
|
1040
1040
|
export function findJsonBlock(text: string): string | undefined {
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1041
|
+
// Suffix summaries avoid rescanning after every malformed opening bracket.
|
|
1042
|
+
// stringEnd[i] is the position after a closing quote when already in a string
|
|
1043
|
+
// at i; contentEnd[i] is the first unconsumed closing bracket after skipping
|
|
1044
|
+
// balanced children/strings, or -1 for an incomplete/mismatched suffix.
|
|
1045
|
+
// Compute candidates even inside strings: an unmatched/malformed outer prefix
|
|
1046
|
+
// must still allow a later, independently balanced candidate, as before.
|
|
1047
|
+
const stringEnd = new Int32Array(text.length + 2);
|
|
1048
|
+
const contentEnd = new Int32Array(text.length + 1);
|
|
1049
|
+
stringEnd[text.length] = stringEnd[text.length + 1] = contentEnd[text.length] = -1;
|
|
1050
|
+
let start = -1;
|
|
1051
|
+
let end = -1;
|
|
1052
|
+
for (let i = text.length - 1; i >= 0; i--) {
|
|
1053
|
+
const char = text[i];
|
|
1054
|
+
stringEnd[i] = char === '"' ? i + 1 : stringEnd[i + (char === "\\" ? 2 : 1)];
|
|
1055
|
+
if (char === "}" || char === "]") contentEnd[i] = i;
|
|
1056
|
+
else if (char === '"') {
|
|
1057
|
+
const after = stringEnd[i + 1];
|
|
1058
|
+
contentEnd[i] = after < 0 ? -1 : contentEnd[after];
|
|
1059
|
+
} else if (char === "{" || char === "[") {
|
|
1060
|
+
const close = contentEnd[i + 1];
|
|
1061
|
+
if (close >= 0 && text[close] === (char === "{" ? "}" : "]")) {
|
|
1062
|
+
start = i;
|
|
1063
|
+
end = close + 1;
|
|
1064
|
+
contentEnd[i] = contentEnd[end];
|
|
1065
|
+
} else contentEnd[i] = -1;
|
|
1066
|
+
} else contentEnd[i] = contentEnd[i + 1];
|
|
1064
1067
|
}
|
|
1065
|
-
return undefined;
|
|
1068
|
+
return start < 0 ? undefined : text.slice(start, end);
|
|
1066
1069
|
}
|
|
1067
1070
|
|
|
1068
1071
|
// ── Minimal plain-JSON-Schema validator ──────────────────────────────────────
|
|
@@ -1078,13 +1081,11 @@ function validateValue(value: unknown, schema: SchemaNode, path: string): string
|
|
|
1078
1081
|
if (schema.const !== undefined) {
|
|
1079
1082
|
if (JSON.stringify(value) !== JSON.stringify(schema.const))
|
|
1080
1083
|
errors.push(`${path}: expected const ${JSON.stringify(schema.const)}`);
|
|
1081
|
-
return errors;
|
|
1082
1084
|
}
|
|
1083
1085
|
if (schema.enum !== undefined) {
|
|
1084
1086
|
if (!(schema.enum as unknown[]).some((e) => JSON.stringify(e) === JSON.stringify(value))) {
|
|
1085
1087
|
errors.push(`${path}: value not in enum`);
|
|
1086
1088
|
}
|
|
1087
|
-
return errors;
|
|
1088
1089
|
}
|
|
1089
1090
|
const type = schema.type;
|
|
1090
1091
|
if (type !== undefined) {
|
|
@@ -1790,12 +1791,34 @@ export async function runWorkflow<T = unknown>(
|
|
|
1790
1791
|
? `\n\nReply with a single raw JSON value (object or array) matching exactly this JSON Schema:\n${JSON.stringify(schema)}\nDo not wrap it in prose or markdown fences.`
|
|
1791
1792
|
: "";
|
|
1792
1793
|
let repairFeedback = "";
|
|
1794
|
+
let previousAttempt: { tokens: number; tokenUsage?: AgentUsage; tier?: string } | undefined;
|
|
1793
1795
|
|
|
1794
1796
|
try {
|
|
1795
1797
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1796
1798
|
// Outside the attempt catch: a rejected admission is not a provider
|
|
1797
1799
|
// attempt and must not be charged an estimated failure or retried.
|
|
1798
|
-
|
|
1800
|
+
try {
|
|
1801
|
+
checkBudget();
|
|
1802
|
+
} catch (error) {
|
|
1803
|
+
const workflowError = wrapError(error, { agentLabel: label });
|
|
1804
|
+
// A between-attempt denial ends the logical call, but it neither
|
|
1805
|
+
// starts nor charges another physical child. Preserve the previous
|
|
1806
|
+
// attempt's observations; the engine independently requires exact
|
|
1807
|
+
// settled-generation facts before making a durable terminal result.
|
|
1808
|
+
if (previousAttempt && !isAborted() && workflowError.code === WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED) {
|
|
1809
|
+
options.onAgentEnd?.({
|
|
1810
|
+
id: deltaKey,
|
|
1811
|
+
label,
|
|
1812
|
+
...phaseObservation(assignedPhase),
|
|
1813
|
+
result: null,
|
|
1814
|
+
...previousAttempt,
|
|
1815
|
+
error: workflowError.message,
|
|
1816
|
+
errorCode: workflowError.code,
|
|
1817
|
+
recoverable: workflowError.recoverable,
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1820
|
+
throw error;
|
|
1821
|
+
}
|
|
1799
1822
|
usage = undefined;
|
|
1800
1823
|
const externalSignal = options.signal;
|
|
1801
1824
|
let onExternalAbort: (() => void) | undefined;
|
|
@@ -1964,7 +1987,12 @@ export async function runWorkflow<T = unknown>(
|
|
|
1964
1987
|
if (isAborted()) throw error;
|
|
1965
1988
|
|
|
1966
1989
|
const workflowError = wrapError(error, { agentLabel: label });
|
|
1967
|
-
const
|
|
1990
|
+
const allocationFree = error instanceof AllocationFreeDispatchError;
|
|
1991
|
+
// Only trusted local preflight proves this attempt never submitted.
|
|
1992
|
+
// A rejected repair still owns the logical call's earlier dispatch;
|
|
1993
|
+
// neither transport text nor a lost reply can refund that lease.
|
|
1994
|
+
if (allocationFree && attempt === 1) shared.physicalAgentCount--;
|
|
1995
|
+
const tokens = allocationFree ? 0 : recordTokens(null);
|
|
1968
1996
|
// This attempt's store writes must not survive it — a failed attempt
|
|
1969
1997
|
// shares this call's deltaKey with every other attempt, so without
|
|
1970
1998
|
// rolling back here its writes would stay live in the store and merge
|
|
@@ -1977,6 +2005,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1977
2005
|
`agent "${label}" attempt ${attempt}/${maxAttempts} failed: ${workflowError.code} ${workflowError.message}; retrying`,
|
|
1978
2006
|
);
|
|
1979
2007
|
options.onRetrySpend?.(tokens);
|
|
2008
|
+
previousAttempt = { tokens, tokenUsage: usage, tier: resolvedTier };
|
|
1980
2009
|
continue;
|
|
1981
2010
|
}
|
|
1982
2011
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { WorkflowUiRunSummary } from "@signalridge/pi-subagents-protocol";
|
|
4
|
+
import type { ScriptRun } from "./journal.js";
|
|
5
|
+
import { sanitizeDisplayText } from "./safe-text.js";
|
|
6
|
+
import { callIndexes, isTerminal, statusMark } from "./ui-presentation.js";
|
|
7
|
+
|
|
8
|
+
export const OVERVIEW_LINGER_MS = 4_000;
|
|
9
|
+
|
|
10
|
+
/** Terminal history remains in /workflows, not an indefinitely growing passive list. */
|
|
11
|
+
export function overviewRuns(runs: readonly ScriptRun[], now = Date.now()): ScriptRun[] {
|
|
12
|
+
return runs.filter((run) => !isTerminal(run.status) || now - run.updatedAt < OVERVIEW_LINGER_MS);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function statusColor(status: string): "success" | "error" | "warning" | "accent" | "muted" {
|
|
16
|
+
if (status === "completed") return "success";
|
|
17
|
+
if (status === "failed") return "error";
|
|
18
|
+
if (["paused", "pausing", "interrupted", "stopping", "stopped"].includes(status)) return "warning";
|
|
19
|
+
return status === "running" ? "accent" : "muted";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function tokens(count: number): string {
|
|
23
|
+
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
24
|
+
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
|
|
25
|
+
return String(count);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The workflow owner's passive overview. Authority/phase/settled usage come from
|
|
30
|
+
* its existing projection, never from child records or in-flight usage polling.
|
|
31
|
+
* No keyboard listener: /workflows is the sole workflow navigator.
|
|
32
|
+
*/
|
|
33
|
+
export function workflowOverviewLines(
|
|
34
|
+
runs: readonly ScriptRun[],
|
|
35
|
+
summaries: readonly WorkflowUiRunSummary[],
|
|
36
|
+
theme: Pick<Theme, "fg" | "bold">,
|
|
37
|
+
width: number,
|
|
38
|
+
maxRows: number,
|
|
39
|
+
mode: "compact" | "detailed" = "compact",
|
|
40
|
+
maxAgentsShown = 6,
|
|
41
|
+
now = Date.now(),
|
|
42
|
+
): string[] {
|
|
43
|
+
if (!Number.isFinite(width) || width < 1 || !Number.isFinite(maxRows) || maxRows < 1) return [];
|
|
44
|
+
const columns = Math.floor(width);
|
|
45
|
+
const rows = Math.min(32, Math.floor(maxRows));
|
|
46
|
+
const max = Number.isFinite(maxAgentsShown) ? Math.max(1, Math.min(32, Math.floor(maxAgentsShown))) : 6;
|
|
47
|
+
const available = new Map(overviewRuns(runs, now).map((run) => [run.runId, run]));
|
|
48
|
+
const entries = summaries.filter((summary) => available.has(summary.runId));
|
|
49
|
+
if (!available.size) return [];
|
|
50
|
+
// The peer bridge caps publication at 256 summaries. It supplies bounded row
|
|
51
|
+
// metadata, never authority over how many owner-side runs exist or are live.
|
|
52
|
+
const active = [...available.values()].filter((run) => !isTerminal(run.status)).length;
|
|
53
|
+
const lines = [theme.fg("muted", `Workflows · ${active} active · /workflows`)];
|
|
54
|
+
if (rows === 1) return lines.map((line) => truncateToWidth(line, columns));
|
|
55
|
+
let shown = Math.min(max, entries.length, rows - 1);
|
|
56
|
+
if (available.size > shown && shown + 2 > rows) shown = Math.max(0, shown - 1);
|
|
57
|
+
const hidden = available.size - shown;
|
|
58
|
+
for (const [index, summary] of entries.slice(0, shown).entries()) {
|
|
59
|
+
const run = available.get(summary.runId);
|
|
60
|
+
if (!run) continue;
|
|
61
|
+
const color = statusColor(summary.status);
|
|
62
|
+
const phase = summary.currentPhase ? ` · ${sanitizeDisplayText(summary.currentPhase, 80)}` : "";
|
|
63
|
+
const stats = `${summary.runningCalls}/${summary.callCount} in flight · ${summary.completedCalls} done · ${summary.failedCalls} failed`;
|
|
64
|
+
// These are settled-call totals (including explicit zero), NOT live usage.
|
|
65
|
+
const usage = summary.tokenCount === undefined ? "" : ` · ${tokens(summary.tokenCount)} settled tokens`;
|
|
66
|
+
const metadata = `${phase} · ${stats}${summary.restored ? " · restored" : ""}`;
|
|
67
|
+
const left = ` ${theme.fg(color, "▸")} ${theme.bold(sanitizeDisplayText(summary.name, 80))} · ${theme.fg(color, summary.status)}${theme.fg("muted", metadata)}`;
|
|
68
|
+
const right = theme.fg("muted", `${Math.floor(summary.elapsedMs / 1_000)}s${usage}`);
|
|
69
|
+
const runLine = lines.length;
|
|
70
|
+
if (columns >= 48) {
|
|
71
|
+
// Preserve elapsed/settled usage on ordinary terminals, but never let
|
|
72
|
+
// secondary stats consume the primary name on a narrow viewport.
|
|
73
|
+
const boundedRight = truncateToWidth(right, Math.floor(columns / 3));
|
|
74
|
+
const boundedLeft = truncateToWidth(left, columns - visibleWidth(boundedRight) - 1);
|
|
75
|
+
const gap = Math.max(1, columns - visibleWidth(boundedLeft) - visibleWidth(boundedRight));
|
|
76
|
+
lines.push(boundedLeft + " ".repeat(gap) + boundedRight);
|
|
77
|
+
} else lines.push(`${left} ${right}`);
|
|
78
|
+
if (mode !== "detailed") continue;
|
|
79
|
+
const calls = callIndexes(run);
|
|
80
|
+
const live = calls.filter((id) => run.callStatus[id] === "running");
|
|
81
|
+
const liveIds = new Set(live);
|
|
82
|
+
const prioritized = [...live, ...calls.filter((id) => !liveIds.has(id))];
|
|
83
|
+
const reserved = shown - index - 1 + (hidden > 0 ? 1 : 0);
|
|
84
|
+
const callRows = Math.max(0, Math.min(max, rows - lines.length - reserved));
|
|
85
|
+
const visible = prioritized.slice(0, callRows);
|
|
86
|
+
for (const id of visible) {
|
|
87
|
+
const status = run.callStatus[id] ?? run.callResults[id]?.status ?? "unknown";
|
|
88
|
+
lines.push(` ${theme.fg(statusColor(status), statusMark(status))} ${theme.fg("muted", `${id}: ${status}`)}`);
|
|
89
|
+
}
|
|
90
|
+
const omitted = calls.length - visible.length;
|
|
91
|
+
if (omitted > 0)
|
|
92
|
+
lines[visible.length ? lines.length - 1 : runLine] += theme.fg("muted", ` · ${omitted} more calls`);
|
|
93
|
+
}
|
|
94
|
+
if (hidden > 0) lines.push(theme.fg("muted", ` ${hidden} more workflows · /workflows`));
|
|
95
|
+
if (lines.length < rows) lines.push(""); // One separator before the next dock/footer surface.
|
|
96
|
+
return lines.map((line) => truncateToWidth(line, columns));
|
|
97
|
+
}
|