pi-workflow-engine 0.10.3 → 0.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/.pi/extensions/pi-workflow-engine/index.ts +418 -213
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +147 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +399 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +91 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +154 -530
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +368 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +87 -39
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +89 -0
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +10 -3
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +55 -3
- package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +230 -61
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +38 -55
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +70 -17
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -63
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +357 -75
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +10 -5
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +156 -66
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
- package/.pi/extensions/pi-workflow-engine/src/options.ts +96 -6
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +74 -122
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +701 -0
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
- package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +18 -22
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +128 -56
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +294 -0
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
- package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
- package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +150 -0
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +401 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +87 -25
- package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -70
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
- package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +30 -3
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +321 -43
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +62 -64
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +17 -17
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +15 -16
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +15 -15
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
- package/README.md +82 -207
- package/USAGE.md +295 -21
- package/package.json +9 -9
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import { SessionManager, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { WorkflowAbortError, WorkflowPauseError } from "./cancellation.ts";
|
|
3
|
+
import type { WorkflowBackgroundOrigin } from "./types.ts";
|
|
4
|
+
import {
|
|
5
|
+
transitionWorkflowRun,
|
|
6
|
+
type WorkflowRunRecord,
|
|
7
|
+
type WorkflowRunState,
|
|
8
|
+
} from "./workflow-run-record.ts";
|
|
9
|
+
import { updateWorkflowRunDelivery } from "./workflow-run-background.ts";
|
|
10
|
+
import { ProjectWorkflowRunStore, type WorkflowRunStore } from "./workflow-run-store.ts";
|
|
11
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
12
|
+
import { emptyWorkflowUsageTotals } from "./usage.ts";
|
|
13
|
+
|
|
14
|
+
const BACKGROUND_DELIVERY_CUSTOM_TYPE = "workflow-result";
|
|
15
|
+
const BACKGROUND_WIDGET_KEY = "workflow-background";
|
|
16
|
+
const SHUTDOWN_WAIT_MS = 5_000;
|
|
17
|
+
const SUMMARY_LIMIT = 500;
|
|
18
|
+
|
|
19
|
+
export interface BackgroundWorkflowStartInput {
|
|
20
|
+
readonly ctx: ExtensionContext;
|
|
21
|
+
readonly runId: string;
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly run: (signal: AbortSignal, onStarted: () => void) => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface BackgroundWorkflowResultDetails {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly result: { readonly summary: string };
|
|
29
|
+
readonly completedAt: number;
|
|
30
|
+
readonly usage: WorkflowRunRecord["usage"];
|
|
31
|
+
readonly runId: string;
|
|
32
|
+
readonly resumedFromRunId?: string;
|
|
33
|
+
readonly background: true;
|
|
34
|
+
readonly status: WorkflowRunState;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type SessionAvailability = "available" | "missing" | "unknown";
|
|
38
|
+
|
|
39
|
+
interface BackgroundWorkflowDependencies {
|
|
40
|
+
readonly storeForCwd?: (cwd: string) => WorkflowRunStore;
|
|
41
|
+
readonly sessionAvailability?: (cwd: string, sessionId: string) => Promise<SessionAvailability>;
|
|
42
|
+
readonly log?: (message: string) => void;
|
|
43
|
+
readonly shutdownWaitMs?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ActiveBackgroundRun {
|
|
47
|
+
readonly controller: AbortController;
|
|
48
|
+
readonly name: string;
|
|
49
|
+
readonly sessionId: string;
|
|
50
|
+
readonly settled: Promise<void>;
|
|
51
|
+
readonly isSettled: () => boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type BackgroundWorkflowSettledListener = (ctx: ExtensionContext, runId: string) => void | Promise<void>;
|
|
55
|
+
|
|
56
|
+
/** Owns background runs for one extension instance and routes their durable completion delivery. */
|
|
57
|
+
export class BackgroundWorkflowCoordinator {
|
|
58
|
+
private readonly active = new Map<string, ActiveBackgroundRun>();
|
|
59
|
+
private readonly settledListeners = new Set<BackgroundWorkflowSettledListener>();
|
|
60
|
+
private readonly pendingDelivery = new Map<string, Set<string>>();
|
|
61
|
+
private readonly shuttingDown = new Set<string>();
|
|
62
|
+
private readonly storeForCwd: (cwd: string) => WorkflowRunStore;
|
|
63
|
+
private readonly sessionAvailability: (cwd: string, sessionId: string) => Promise<SessionAvailability>;
|
|
64
|
+
private readonly log: (message: string) => void;
|
|
65
|
+
private readonly shutdownWaitMs: number;
|
|
66
|
+
|
|
67
|
+
constructor(
|
|
68
|
+
private readonly pi: Pick<ExtensionAPI, "sendMessage">,
|
|
69
|
+
dependencies: BackgroundWorkflowDependencies = {},
|
|
70
|
+
) {
|
|
71
|
+
this.storeForCwd = dependencies.storeForCwd ?? ((cwd) => new ProjectWorkflowRunStore(cwd));
|
|
72
|
+
this.sessionAvailability = dependencies.sessionAvailability ?? defaultSessionAvailability;
|
|
73
|
+
this.log = dependencies.log ?? ((message) => process.stderr.write(`${message}\n`));
|
|
74
|
+
this.shutdownWaitMs = dependencies.shutdownWaitMs ?? SHUTDOWN_WAIT_MS;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async start(input: BackgroundWorkflowStartInput): Promise<void> {
|
|
78
|
+
if (this.active.has(input.runId)) throw new Error(`Background workflow ${input.runId} is already active.`);
|
|
79
|
+
|
|
80
|
+
const sessionId = input.ctx.sessionManager.getSessionId();
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
let accepted = false;
|
|
83
|
+
let deliveryScheduled = false;
|
|
84
|
+
let settled = false;
|
|
85
|
+
let startedSignalled = false;
|
|
86
|
+
let resolveStarted: (() => void) | undefined;
|
|
87
|
+
let rejectStarted: ((error: unknown) => void) | undefined;
|
|
88
|
+
const started = new Promise<void>((resolve, reject) => {
|
|
89
|
+
resolveStarted = resolve;
|
|
90
|
+
rejectStarted = reject;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const scheduleDelivery = (): void => {
|
|
94
|
+
if (deliveryScheduled || !accepted || !settled || this.shuttingDown.has(sessionId)) return;
|
|
95
|
+
deliveryScheduled = true;
|
|
96
|
+
void this.queueOrDeliver(input.ctx, input.runId).catch((error: unknown) => {
|
|
97
|
+
this.log(`[workflow:${input.runId}] background delivery failed: ${unknownErrorMessage(error)}`);
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const settledPromise = (async () => {
|
|
102
|
+
try {
|
|
103
|
+
await input.run(controller.signal, () => {
|
|
104
|
+
if (startedSignalled) return;
|
|
105
|
+
startedSignalled = true;
|
|
106
|
+
resolveStarted?.();
|
|
107
|
+
});
|
|
108
|
+
if (!startedSignalled) rejectStarted?.(new Error("Background workflow ended before publishing run metadata."));
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (!startedSignalled) rejectStarted?.(error);
|
|
111
|
+
} finally {
|
|
112
|
+
settled = true;
|
|
113
|
+
this.active.delete(input.runId);
|
|
114
|
+
this.updateBackgroundSurface(input.ctx);
|
|
115
|
+
await this.notifyRunSettled(input.ctx, input.runId);
|
|
116
|
+
scheduleDelivery();
|
|
117
|
+
}
|
|
118
|
+
})();
|
|
119
|
+
this.active.set(input.runId, {
|
|
120
|
+
controller,
|
|
121
|
+
name: input.name,
|
|
122
|
+
sessionId,
|
|
123
|
+
settled: settledPromise,
|
|
124
|
+
isSettled: () => settled,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await started;
|
|
128
|
+
const record = await this.storeForCwd(input.ctx.cwd).load(input.runId);
|
|
129
|
+
if (record?.state !== "running" || !record.background || record.background.origin.sessionId !== sessionId) {
|
|
130
|
+
controller.abort(new WorkflowAbortError("Background workflow could not persist its origin metadata."));
|
|
131
|
+
throw new Error(`Background workflow ${input.runId} did not create a durable run record.`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
accepted = true;
|
|
135
|
+
this.updateBackgroundSurface(input.ctx);
|
|
136
|
+
scheduleDelivery();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
activeRunIds(ctx: Pick<ExtensionContext, "sessionManager">): ReadonlySet<string> {
|
|
140
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
141
|
+
return new Set(
|
|
142
|
+
[...this.active.entries()]
|
|
143
|
+
.filter(([, run]) => run.sessionId === sessionId)
|
|
144
|
+
.map(([runId]) => runId),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
onRunSettled(listener: BackgroundWorkflowSettledListener): () => void {
|
|
149
|
+
this.settledListeners.add(listener);
|
|
150
|
+
return () => this.settledListeners.delete(listener);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async stop(ctx: ExtensionContext, runId: string): Promise<WorkflowRunRecord> {
|
|
154
|
+
const active = this.active.get(runId);
|
|
155
|
+
if (!active || active.sessionId !== ctx.sessionManager.getSessionId()) {
|
|
156
|
+
throw new Error(`Workflow run ${runId} is not active in this session.`);
|
|
157
|
+
}
|
|
158
|
+
active.controller.abort(new WorkflowAbortError("Workflow stopped by user."));
|
|
159
|
+
await waitForRuns([active.settled], this.shutdownWaitMs);
|
|
160
|
+
const store = this.storeForCwd(ctx.cwd);
|
|
161
|
+
if (!active.isSettled()) {
|
|
162
|
+
await forceStoppedRecord(store, runId);
|
|
163
|
+
this.log(`[workflow:${runId}] background workflow did not settle after stop; retained state was forced to stopped.`);
|
|
164
|
+
}
|
|
165
|
+
const record = await store.load(runId);
|
|
166
|
+
if (!record) throw new Error(`Workflow run ${runId} was not found after stopping.`);
|
|
167
|
+
if (record.state !== "stopped") throw new Error(`Workflow run ${runId} settled as ${record.state} instead of stopped.`);
|
|
168
|
+
return record;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async agentSettled(ctx: ExtensionContext): Promise<void> {
|
|
172
|
+
await this.flushPending(ctx);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async durableRunSettled(ctx: ExtensionContext, runId: string): Promise<void> {
|
|
176
|
+
await this.queueOrDeliver(ctx, runId);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async sessionStarted(ctx: ExtensionContext): Promise<void> {
|
|
180
|
+
const store = this.storeForCwd(ctx.cwd);
|
|
181
|
+
let records: WorkflowRunRecord[];
|
|
182
|
+
try {
|
|
183
|
+
records = await store.list();
|
|
184
|
+
} catch (error) {
|
|
185
|
+
this.log(`[workflow] background recovery could not load run history: ${unknownErrorMessage(error)}`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
189
|
+
const availability = new Map<string, SessionAvailability>();
|
|
190
|
+
|
|
191
|
+
for (const loadedRecord of records) {
|
|
192
|
+
let record = loadedRecord;
|
|
193
|
+
try {
|
|
194
|
+
record = await reconcileInterruptedRun(
|
|
195
|
+
store,
|
|
196
|
+
record,
|
|
197
|
+
sessionId,
|
|
198
|
+
this.active.has(record.runId),
|
|
199
|
+
);
|
|
200
|
+
if (!isPendingBackgroundOutcome(record)) continue;
|
|
201
|
+
const originSessionId = record.background.origin.sessionId;
|
|
202
|
+
if (originSessionId === sessionId) {
|
|
203
|
+
await this.queueOrDeliver(ctx, record.runId);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let status = availability.get(originSessionId);
|
|
208
|
+
if (!status) {
|
|
209
|
+
status = await this.sessionAvailability(ctx.cwd, originSessionId);
|
|
210
|
+
availability.set(originSessionId, status);
|
|
211
|
+
}
|
|
212
|
+
if (status !== "missing") continue;
|
|
213
|
+
|
|
214
|
+
const message = `Originating pi session ${originSessionId} is unavailable; result remains in workflow run history.`;
|
|
215
|
+
await markDelivery(store, record.runId, {
|
|
216
|
+
state: "unavailable",
|
|
217
|
+
attemptedAt: Date.now(),
|
|
218
|
+
message,
|
|
219
|
+
});
|
|
220
|
+
this.log(`[workflow:${record.runId}] ${message}`);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
this.log(`[workflow:${record.runId}] background recovery failed: ${unknownErrorMessage(error)}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async sessionShutdown(ctx: ExtensionContext): Promise<void> {
|
|
228
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
229
|
+
this.shuttingDown.add(sessionId);
|
|
230
|
+
this.pendingDelivery.delete(sessionId);
|
|
231
|
+
const runs = [...this.active.entries()].filter(([, run]) => run.sessionId === sessionId);
|
|
232
|
+
for (const [, run] of runs) {
|
|
233
|
+
run.controller.abort(new WorkflowPauseError());
|
|
234
|
+
}
|
|
235
|
+
await waitForRuns(runs.map(([, run]) => run.settled), this.shutdownWaitMs);
|
|
236
|
+
const store = this.storeForCwd(ctx.cwd);
|
|
237
|
+
for (const [runId, run] of runs) {
|
|
238
|
+
if (run.isSettled()) continue;
|
|
239
|
+
try {
|
|
240
|
+
await forcePausedRecord(store, runId);
|
|
241
|
+
this.log(`[workflow:${runId}] background workflow did not settle during shutdown; retained state was forced to paused.`);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
this.log(`[workflow:${runId}] failed to force paused state during shutdown: ${unknownErrorMessage(error)}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (ctx.hasUI) ctx.ui.setWidget(BACKGROUND_WIDGET_KEY, undefined);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private async queueOrDeliver(ctx: ExtensionContext, runId: string): Promise<void> {
|
|
250
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
251
|
+
if (this.shuttingDown.has(sessionId)) return;
|
|
252
|
+
if (ctx.isIdle()) {
|
|
253
|
+
try {
|
|
254
|
+
if (await this.deliver(ctx, runId)) return;
|
|
255
|
+
} catch (error) {
|
|
256
|
+
this.addPending(sessionId, runId);
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
this.addPending(sessionId, runId);
|
|
261
|
+
if (ctx.isIdle()) await this.flushPending(ctx);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private async notifyRunSettled(ctx: ExtensionContext, runId: string): Promise<void> {
|
|
265
|
+
for (const listener of this.settledListeners) {
|
|
266
|
+
try {
|
|
267
|
+
await listener(ctx, runId);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
this.log(`[workflow:${runId}] background settlement listener failed: ${unknownErrorMessage(error)}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private async flushPending(ctx: ExtensionContext): Promise<void> {
|
|
275
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
276
|
+
if (this.shuttingDown.has(sessionId)) return;
|
|
277
|
+
const pending = this.pendingDelivery.get(sessionId);
|
|
278
|
+
if (!pending) return;
|
|
279
|
+
for (const runId of [...pending]) {
|
|
280
|
+
try {
|
|
281
|
+
if (await this.deliver(ctx, runId)) pending.delete(runId);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
this.log(`[workflow:${runId}] background delivery retry failed: ${unknownErrorMessage(error)}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (pending.size === 0) this.pendingDelivery.delete(sessionId);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private addPending(sessionId: string, runId: string): void {
|
|
290
|
+
const pending = this.pendingDelivery.get(sessionId) ?? new Set<string>();
|
|
291
|
+
pending.add(runId);
|
|
292
|
+
this.pendingDelivery.set(sessionId, pending);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private updateBackgroundSurface(ctx: ExtensionContext): void {
|
|
296
|
+
if (!ctx.hasUI) return;
|
|
297
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
298
|
+
const runs = [...this.active.entries()]
|
|
299
|
+
.filter(([, run]) => run.sessionId === sessionId)
|
|
300
|
+
.map(([runId, run]) => ({ runId, name: run.name }));
|
|
301
|
+
if (runs.length === 0) {
|
|
302
|
+
ctx.ui.setWidget(BACKGROUND_WIDGET_KEY, undefined);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
ctx.ui.setWidget(
|
|
306
|
+
BACKGROUND_WIDGET_KEY,
|
|
307
|
+
[formatBackgroundActivity(runs, ctx.ui.theme)],
|
|
308
|
+
{ placement: "aboveEditor" },
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async deliver(ctx: ExtensionContext, runId: string): Promise<boolean> {
|
|
313
|
+
const store = this.storeForCwd(ctx.cwd);
|
|
314
|
+
const record = await store.load(runId);
|
|
315
|
+
if (!record?.background || record.background.delivery.state !== "pending") return true;
|
|
316
|
+
if (record.background.origin.sessionId !== ctx.sessionManager.getSessionId()) return false;
|
|
317
|
+
if (!isDeliverableState(record.state)) return false;
|
|
318
|
+
|
|
319
|
+
if (!sessionHasBackgroundDelivery(ctx, runId)) {
|
|
320
|
+
const details = backgroundResultDetails(record);
|
|
321
|
+
this.pi.sendMessage(
|
|
322
|
+
{
|
|
323
|
+
customType: BACKGROUND_DELIVERY_CUSTOM_TYPE,
|
|
324
|
+
content: formatBackgroundDelivery(details),
|
|
325
|
+
display: true,
|
|
326
|
+
details,
|
|
327
|
+
},
|
|
328
|
+
{ triggerTurn: false },
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
await markDelivery(store, runId, { state: "delivered", deliveredAt: Date.now() });
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function formatBackgroundActivity(
|
|
337
|
+
runs: readonly { readonly runId: string; readonly name: string }[],
|
|
338
|
+
theme: ExtensionContext["ui"]["theme"],
|
|
339
|
+
): string {
|
|
340
|
+
const visible = runs.slice(0, 2).map((run) => `${run.name} ${run.runId.slice(0, 8)}`);
|
|
341
|
+
const hidden = runs.length - visible.length;
|
|
342
|
+
const suffix = hidden > 0 ? ` · +${hidden} more` : "";
|
|
343
|
+
return `${theme.fg("accent", "●")} ${theme.bold("Background workflows")} ${theme.fg("dim", `· ${visible.join(" · ")}${suffix}`)}`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function backgroundOrigin(ctx: Pick<ExtensionContext, "sessionManager">, requestedAt = Date.now()): WorkflowBackgroundOrigin {
|
|
347
|
+
return { sessionId: ctx.sessionManager.getSessionId(), requestedAt };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function backgroundResultDetails(record: WorkflowRunRecord): BackgroundWorkflowResultDetails {
|
|
351
|
+
if (!isDeliverableState(record.state)) throw new Error(`Workflow run ${record.runId} has not finished or paused.`);
|
|
352
|
+
return {
|
|
353
|
+
name: record.workflow.name,
|
|
354
|
+
result: { summary: backgroundSummary(record) },
|
|
355
|
+
completedAt: record.endedAt ?? record.updatedAt,
|
|
356
|
+
usage: record.usage,
|
|
357
|
+
runId: record.runId,
|
|
358
|
+
resumedFromRunId: record.options.resumeFromRunId,
|
|
359
|
+
background: true,
|
|
360
|
+
status: record.state,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function backgroundSummary(record: WorkflowRunRecord): string {
|
|
365
|
+
if (record.state !== "completed") return boundedSummary(`Workflow ${record.state}: ${record.message}`);
|
|
366
|
+
if (record.result.kind === "unavailable") {
|
|
367
|
+
return boundedSummary(`Workflow completed; retained result is unavailable: ${record.result.reason}`);
|
|
368
|
+
}
|
|
369
|
+
const value = record.result.value;
|
|
370
|
+
if (typeof value === "string") return boundedSummary(value);
|
|
371
|
+
if (isRecord(value) && typeof value.summary === "string") return boundedSummary(value.summary);
|
|
372
|
+
return "Workflow completed. Open run history for the retained result.";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function formatBackgroundDelivery(details: BackgroundWorkflowResultDetails): string {
|
|
376
|
+
return [
|
|
377
|
+
`## Background workflow: ${details.name}`,
|
|
378
|
+
"",
|
|
379
|
+
`Run ID: ${details.runId}`,
|
|
380
|
+
`State: ${details.status}`,
|
|
381
|
+
"",
|
|
382
|
+
details.result.summary,
|
|
383
|
+
].join("\n");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function sessionHasBackgroundDelivery(ctx: ExtensionContext, runId: string): boolean {
|
|
387
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
388
|
+
if (entry.type !== "message" || entry.message.role !== "custom") continue;
|
|
389
|
+
if (entry.message.customType !== BACKGROUND_DELIVERY_CUSTOM_TYPE) continue;
|
|
390
|
+
const details = entry.message.details;
|
|
391
|
+
if (isRecord(details) && details.background === true && details.runId === runId) return true;
|
|
392
|
+
}
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function isPendingBackgroundOutcome(record: WorkflowRunRecord): record is WorkflowRunRecord & { readonly background: NonNullable<WorkflowRunRecord["background"]> } {
|
|
397
|
+
return record.background?.delivery.state === "pending" && isDeliverableState(record.state);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function isDeliverableState(state: WorkflowRunState): state is "completed" | "failed" | "stopped" | "paused" {
|
|
401
|
+
return state === "completed" || state === "failed" || state === "stopped" || state === "paused";
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function markDelivery(
|
|
405
|
+
store: WorkflowRunStore,
|
|
406
|
+
runId: string,
|
|
407
|
+
delivery: Parameters<typeof updateWorkflowRunDelivery>[1],
|
|
408
|
+
): Promise<void> {
|
|
409
|
+
const latest = await store.load(runId);
|
|
410
|
+
if (!latest?.background || latest.background.delivery.state !== "pending") return;
|
|
411
|
+
await store.save(updateWorkflowRunDelivery(latest, delivery));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function forcePausedRecord(store: WorkflowRunStore, runId: string): Promise<void> {
|
|
415
|
+
const record = await store.load(runId);
|
|
416
|
+
if (!record || (record.state !== "queued" && record.state !== "running")) return;
|
|
417
|
+
await store.save(transitionWorkflowRun(record, {
|
|
418
|
+
state: "paused",
|
|
419
|
+
progress: record.progress,
|
|
420
|
+
message: "Workflow paused because its host session shut down",
|
|
421
|
+
}));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function forceStoppedRecord(store: WorkflowRunStore, runId: string): Promise<void> {
|
|
425
|
+
const record = await store.load(runId);
|
|
426
|
+
if (!record || (record.state !== "queued" && record.state !== "running")) return;
|
|
427
|
+
await store.save(transitionWorkflowRun(record, {
|
|
428
|
+
state: "stopped",
|
|
429
|
+
progress: record.progress,
|
|
430
|
+
usage: record.usage ?? record.progress.usage ?? {
|
|
431
|
+
agents: [],
|
|
432
|
+
totals: emptyWorkflowUsageTotals(),
|
|
433
|
+
assistantMessages: 0,
|
|
434
|
+
},
|
|
435
|
+
error: new WorkflowAbortError("Workflow stopped by user."),
|
|
436
|
+
}));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function reconcileInterruptedRun(
|
|
440
|
+
store: WorkflowRunStore,
|
|
441
|
+
record: WorkflowRunRecord,
|
|
442
|
+
sessionId: string,
|
|
443
|
+
active: boolean,
|
|
444
|
+
): Promise<WorkflowRunRecord> {
|
|
445
|
+
if (
|
|
446
|
+
active
|
|
447
|
+
|| record.background?.delivery.state !== "pending"
|
|
448
|
+
|| record.background.origin.sessionId !== sessionId
|
|
449
|
+
|| (record.state !== "queued" && record.state !== "running")
|
|
450
|
+
) {
|
|
451
|
+
return record;
|
|
452
|
+
}
|
|
453
|
+
const paused = transitionWorkflowRun(record, {
|
|
454
|
+
state: "paused",
|
|
455
|
+
progress: record.progress,
|
|
456
|
+
message: "Workflow paused because its host process ended before completion",
|
|
457
|
+
});
|
|
458
|
+
await store.save(paused);
|
|
459
|
+
return paused;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function defaultSessionAvailability(cwd: string, sessionId: string): Promise<SessionAvailability> {
|
|
463
|
+
try {
|
|
464
|
+
const sessions = await SessionManager.list(cwd);
|
|
465
|
+
return sessions.some((session) => session.id === sessionId) ? "available" : "missing";
|
|
466
|
+
} catch {
|
|
467
|
+
return "unknown";
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function waitForRuns(runs: readonly Promise<void>[], timeoutMs: number): Promise<void> {
|
|
472
|
+
if (runs.length === 0) return;
|
|
473
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
474
|
+
try {
|
|
475
|
+
await Promise.race([
|
|
476
|
+
Promise.allSettled(runs),
|
|
477
|
+
new Promise<void>((resolve) => {
|
|
478
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
479
|
+
}),
|
|
480
|
+
]);
|
|
481
|
+
} finally {
|
|
482
|
+
if (timer) clearTimeout(timer);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function boundedSummary(value: string): string {
|
|
487
|
+
return value.length <= SUMMARY_LIMIT ? value : `${value.slice(0, SUMMARY_LIMIT - 1)}…`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
491
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
492
|
+
}
|
|
@@ -17,8 +17,8 @@ export class WorkflowBudgetExceededError extends Error {
|
|
|
17
17
|
/**
|
|
18
18
|
* The budget handle exposed on `WorkflowApi`. Mirrors the built-in Workflow tool:
|
|
19
19
|
* `total` is the output-token ceiling (or `null` when unset), `spent()` is the
|
|
20
|
-
* output tokens consumed by
|
|
21
|
-
* headroom left (`Infinity` when there is no ceiling).
|
|
20
|
+
* output tokens consumed by recorded subagent attempts this run, and
|
|
21
|
+
* `remaining()` is the headroom left (`Infinity` when there is no ceiling).
|
|
22
22
|
*
|
|
23
23
|
* `spent()`/`remaining()` are LIVE — they re-read usage on every call so that
|
|
24
24
|
* `while (budget.total && budget.remaining() > N) { await agent(...) }` loops and
|
|
@@ -26,12 +26,19 @@ export class WorkflowBudgetExceededError extends Error {
|
|
|
26
26
|
*/
|
|
27
27
|
export interface WorkflowBudget {
|
|
28
28
|
readonly total: number | null;
|
|
29
|
-
/** Output tokens consumed by
|
|
29
|
+
/** Output tokens consumed by recorded subagent attempts this run (sub-workflows included). */
|
|
30
30
|
spent(): number;
|
|
31
31
|
/** `total - spent()` clamped at 0, or `Infinity` when `total` is null. */
|
|
32
32
|
remaining(): number;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** Refuse a new model turn after the run has consumed its output-token budget. */
|
|
36
|
+
export function assertWorkflowBudgetAvailable(budget: WorkflowBudget): void {
|
|
37
|
+
if (budget.total !== null && budget.remaining() <= 0) {
|
|
38
|
+
throw new WorkflowBudgetExceededError(budget.total, budget.spent());
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
35
42
|
/**
|
|
36
43
|
* Build a budget backed by the run's usage sink. `spent()` reads
|
|
37
44
|
* `usage.snapshot().totals.output` — the same output-token sum surfaced in the
|
|
@@ -5,9 +5,17 @@ export class WorkflowAbortError extends Error {
|
|
|
5
5
|
}
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/** Abort reason used when a durable run can be resumed after its host session shuts down. */
|
|
9
|
+
export class WorkflowPauseError extends WorkflowAbortError {
|
|
10
|
+
constructor(message = "Workflow paused because its host session shut down") {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "WorkflowPauseError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
export function abortReason(signal: AbortSignal | undefined): Error {
|
|
9
17
|
const reason = signal?.reason;
|
|
10
|
-
if (reason
|
|
18
|
+
if (safeInstanceOf(reason, Error)) return reason;
|
|
11
19
|
if (typeof reason === "string" && reason.length > 0) return new WorkflowAbortError(reason);
|
|
12
20
|
return new WorkflowAbortError();
|
|
13
21
|
}
|
|
@@ -18,11 +26,21 @@ export function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
|
18
26
|
|
|
19
27
|
export function isFatalWorkflowError(error: unknown, signal: AbortSignal | undefined): boolean {
|
|
20
28
|
if (signal?.aborted) return true;
|
|
21
|
-
if (error
|
|
22
|
-
if (typeof DOMException !== "undefined" && error
|
|
29
|
+
if (safeInstanceOf(error, WorkflowAbortError)) return true;
|
|
30
|
+
if (typeof DOMException !== "undefined" && safeInstanceOf(error, DOMException)) {
|
|
31
|
+
try {
|
|
32
|
+
return error.name === "AbortError";
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
23
37
|
return false;
|
|
24
38
|
}
|
|
25
39
|
|
|
40
|
+
export function isWorkflowPauseError(error: unknown): error is WorkflowPauseError {
|
|
41
|
+
return safeInstanceOf(error, WorkflowPauseError);
|
|
42
|
+
}
|
|
43
|
+
|
|
26
44
|
export function linkAbortSignal(parent: AbortSignal | undefined, controller: AbortController): () => void {
|
|
27
45
|
if (!parent) return () => {};
|
|
28
46
|
if (parent.aborted) {
|
|
@@ -33,3 +51,37 @@ export function linkAbortSignal(parent: AbortSignal | undefined, controller: Abo
|
|
|
33
51
|
parent.addEventListener("abort", onAbort, { once: true });
|
|
34
52
|
return () => parent.removeEventListener("abort", onAbort);
|
|
35
53
|
}
|
|
54
|
+
|
|
55
|
+
export async function raceWithAbort<T>(operation: () => Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
|
56
|
+
throwIfAborted(signal);
|
|
57
|
+
if (!signal) return await operation();
|
|
58
|
+
|
|
59
|
+
return await new Promise<T>((resolve, reject) => {
|
|
60
|
+
let settled = false;
|
|
61
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
62
|
+
const finish = (action: () => void) => {
|
|
63
|
+
if (settled) return;
|
|
64
|
+
settled = true;
|
|
65
|
+
cleanup();
|
|
66
|
+
action();
|
|
67
|
+
};
|
|
68
|
+
const onAbort = () => finish(() => reject(abortReason(signal)));
|
|
69
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
70
|
+
if (signal.aborted) {
|
|
71
|
+
onAbort();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
void operation().then(
|
|
75
|
+
(value) => finish(() => resolve(value)),
|
|
76
|
+
(error: unknown) => finish(() => reject(error)),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function safeInstanceOf<T>(value: unknown, constructor: abstract new (...args: never[]) => T): value is T {
|
|
82
|
+
try {
|
|
83
|
+
return value instanceof constructor;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { fuzzyFilter, type AutocompleteItem } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
interface ArgumentCompletionCandidate {
|
|
4
|
+
readonly value: string;
|
|
5
|
+
readonly description?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface ArgumentPrefixParts {
|
|
9
|
+
readonly completed: readonly string[];
|
|
10
|
+
readonly current: string;
|
|
11
|
+
readonly base: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Split a slash-command argument prefix while retaining the text Pi will replace. */
|
|
15
|
+
export function splitArgumentPrefix(argumentPrefix: string): ArgumentPrefixParts {
|
|
16
|
+
const match = /(\S*)$/.exec(argumentPrefix);
|
|
17
|
+
const current = match?.[1] ?? "";
|
|
18
|
+
const base = argumentPrefix.slice(0, argumentPrefix.length - current.length);
|
|
19
|
+
const completed = base.trim().split(/\s+/).filter(Boolean);
|
|
20
|
+
return { completed, current, base };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Complete the current argument and return full replacement values, as Pi expects. */
|
|
24
|
+
export function completeCurrentArgument(
|
|
25
|
+
argumentPrefix: string,
|
|
26
|
+
candidates: readonly ArgumentCompletionCandidate[],
|
|
27
|
+
): AutocompleteItem[] | null {
|
|
28
|
+
const { current, base } = splitArgumentPrefix(argumentPrefix);
|
|
29
|
+
const matches = fuzzyFilter([...candidates], current, (candidate) => candidate.value)
|
|
30
|
+
.map((candidate) => ({
|
|
31
|
+
value: `${base}${candidate.value}`,
|
|
32
|
+
label: candidate.value,
|
|
33
|
+
...(candidate.description === undefined ? {} : { description: candidate.description }),
|
|
34
|
+
}));
|
|
35
|
+
return matches.length > 0 ? matches : null;
|
|
36
|
+
}
|