@jameslovespancakes/pi-plus 1.0.20 → 1.0.21
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/README.md +21 -3
- package/package.json +1 -1
- package/src/domains/workflows/index.ts +56 -104
- package/src/domains/workflows/runtime/advisory-challenge.ts +3 -3
- package/src/domains/workflows/runtime/agent-attempt.ts +3 -3
- package/src/domains/workflows/runtime/agent-options.ts +18 -0
- package/src/domains/workflows/runtime/agent-runner-types.ts +7 -3
- package/src/domains/workflows/runtime/agent-runner.ts +14 -6
- package/src/domains/workflows/runtime/agent-session.ts +34 -3
- package/src/domains/workflows/runtime/cancellation.ts +5 -0
- package/src/domains/workflows/runtime/engine.ts +19 -40
- package/src/domains/workflows/runtime/journal.ts +4 -4
- package/src/domains/workflows/runtime/live-agent.ts +37 -0
- package/src/domains/workflows/runtime/model-profiles.ts +2 -6
- package/src/domains/workflows/runtime/progress-types.ts +3 -1
- package/src/domains/workflows/runtime/progress.ts +54 -14
- package/src/domains/workflows/runtime/review/review-fix-workflow.ts +3 -3
- package/src/domains/workflows/runtime/types.ts +16 -15
- package/src/domains/workflows/runtime/ui/agent-transcript.ts +59 -0
- package/src/domains/workflows/runtime/ui/workflow-format.ts +6 -2
- package/src/domains/workflows/runtime/ui/workflow-inspector.ts +75 -61
- package/src/domains/workflows/runtime/ui/workflow-widget.ts +26 -66
- package/src/domains/workflows/runtime/workflow-advisory-utils.ts +5 -5
- package/src/domains/workflows/runtime/{background-workflows.ts → workflow-lifecycle.ts} +91 -75
- package/src/domains/workflows/runtime/workflow-management.ts +66 -0
- package/src/domains/workflows/runtime/workflow-run-controller.ts +16 -20
- package/src/domains/workflows/runtime/{workflow-run-background.ts → workflow-run-delivery.ts} +3 -3
- package/src/domains/workflows/runtime/workflow-run-record.ts +7 -4
- package/src/domains/workflows/workflows/code-review.ts +1 -1
- package/src/domains/workflows/workflows/diagnose.ts +1 -1
- package/src/domains/workflows/workflows/perf-review.ts +1 -1
- package/src/domains/workflows/workflows/refactor-scout.ts +1 -1
- package/src/domains/workflows/workflows/research.ts +4 -4
- package/src/domains/workflows/runtime/background-workflow-tool.ts +0 -75
|
@@ -1,49 +1,62 @@
|
|
|
1
1
|
import { SessionManager, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { WorkflowAbortError, WorkflowPauseError } from "./cancellation.ts";
|
|
3
|
-
import type {
|
|
3
|
+
import type { WorkflowOrigin } from "./types.ts";
|
|
4
|
+
import { createWorkflowRunId } from "./journal.ts";
|
|
5
|
+
import type { ResolvedWorkflowRunOptions } from "./options.ts";
|
|
4
6
|
import {
|
|
5
7
|
transitionWorkflowRun,
|
|
6
8
|
type WorkflowRunRecord,
|
|
7
9
|
type WorkflowRunState,
|
|
8
10
|
} from "./workflow-run-record.ts";
|
|
9
|
-
import { updateWorkflowRunDelivery } from "./workflow-run-
|
|
11
|
+
import { updateWorkflowRunDelivery } from "./workflow-run-delivery.ts";
|
|
10
12
|
import { ProjectWorkflowRunStore, type WorkflowRunStore } from "./workflow-run-store.ts";
|
|
11
13
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
12
14
|
import { emptyWorkflowUsageTotals } from "./usage.ts";
|
|
13
15
|
|
|
14
|
-
const
|
|
15
|
-
const BACKGROUND_WIDGET_KEY = "workflow-background";
|
|
16
|
+
const WORKFLOW_DELIVERY_CUSTOM_TYPE = "workflow-result";
|
|
16
17
|
const SHUTDOWN_WAIT_MS = 5_000;
|
|
17
18
|
const SUMMARY_LIMIT = 500;
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
interface WorkflowStartInput {
|
|
20
21
|
readonly ctx: ExtensionContext;
|
|
21
22
|
readonly runId: string;
|
|
22
23
|
readonly name: string;
|
|
23
24
|
readonly run: (signal: AbortSignal, onStarted: () => void) => Promise<void>;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
export interface
|
|
27
|
+
export interface WorkflowCompletionDetails {
|
|
27
28
|
readonly name: string;
|
|
28
29
|
readonly result: { readonly summary: string };
|
|
29
30
|
readonly completedAt: number;
|
|
30
31
|
readonly usage: WorkflowRunRecord["usage"];
|
|
31
32
|
readonly runId: string;
|
|
32
33
|
readonly resumedFromRunId?: string;
|
|
33
|
-
readonly background: true;
|
|
34
34
|
readonly status: WorkflowRunState;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
export interface WorkflowLaunchResult {
|
|
38
|
+
readonly content: Array<{ readonly type: "text"; readonly text: string }>;
|
|
39
|
+
readonly details: Readonly<Record<string, unknown>>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function workflowUnavailableResult(mode: ExtensionContext["mode"]): WorkflowLaunchResult | undefined {
|
|
43
|
+
if (mode !== "print" && mode !== "json") return undefined;
|
|
44
|
+
return {
|
|
45
|
+
content: [{ type: "text", text: `Workflows require TUI/RPC mode; ${mode} mode exits after the prompt.` }],
|
|
46
|
+
details: { error: "workflow_unavailable", mode },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
type SessionAvailability = "available" | "missing" | "unknown";
|
|
38
51
|
|
|
39
|
-
interface
|
|
52
|
+
interface WorkflowLifecycleDependencies {
|
|
40
53
|
readonly storeForCwd?: (cwd: string) => WorkflowRunStore;
|
|
41
54
|
readonly sessionAvailability?: (cwd: string, sessionId: string) => Promise<SessionAvailability>;
|
|
42
55
|
readonly log?: (message: string) => void;
|
|
43
56
|
readonly shutdownWaitMs?: number;
|
|
44
57
|
}
|
|
45
58
|
|
|
46
|
-
interface
|
|
59
|
+
interface ActiveWorkflowRun {
|
|
47
60
|
readonly controller: AbortController;
|
|
48
61
|
readonly name: string;
|
|
49
62
|
readonly sessionId: string;
|
|
@@ -51,12 +64,12 @@ interface ActiveBackgroundRun {
|
|
|
51
64
|
readonly isSettled: () => boolean;
|
|
52
65
|
}
|
|
53
66
|
|
|
54
|
-
type
|
|
67
|
+
type WorkflowSettledListener = (ctx: ExtensionContext, runId: string) => void | Promise<void>;
|
|
55
68
|
|
|
56
|
-
/**
|
|
57
|
-
export class
|
|
58
|
-
private readonly active = new Map<string,
|
|
59
|
-
private readonly settledListeners = new Set<
|
|
69
|
+
/** The single workflow lifecycle: launch, cancellation, recovery and durable delivery. */
|
|
70
|
+
export class WorkflowLifecycle {
|
|
71
|
+
private readonly active = new Map<string, ActiveWorkflowRun>();
|
|
72
|
+
private readonly settledListeners = new Set<WorkflowSettledListener>();
|
|
60
73
|
private readonly pendingDelivery = new Map<string, Set<string>>();
|
|
61
74
|
private readonly shuttingDown = new Set<string>();
|
|
62
75
|
private readonly storeForCwd: (cwd: string) => WorkflowRunStore;
|
|
@@ -66,7 +79,7 @@ export class BackgroundWorkflowCoordinator {
|
|
|
66
79
|
|
|
67
80
|
constructor(
|
|
68
81
|
private readonly pi: Pick<ExtensionAPI, "sendMessage">,
|
|
69
|
-
dependencies:
|
|
82
|
+
dependencies: WorkflowLifecycleDependencies = {},
|
|
70
83
|
) {
|
|
71
84
|
this.storeForCwd = dependencies.storeForCwd ?? ((cwd) => new ProjectWorkflowRunStore(cwd));
|
|
72
85
|
this.sessionAvailability = dependencies.sessionAvailability ?? defaultSessionAvailability;
|
|
@@ -74,8 +87,42 @@ export class BackgroundWorkflowCoordinator {
|
|
|
74
87
|
this.shutdownWaitMs = dependencies.shutdownWaitMs ?? SHUTDOWN_WAIT_MS;
|
|
75
88
|
}
|
|
76
89
|
|
|
77
|
-
async
|
|
78
|
-
|
|
90
|
+
async launch(input: {
|
|
91
|
+
ctx: ExtensionContext;
|
|
92
|
+
name: string;
|
|
93
|
+
options: ResolvedWorkflowRunOptions;
|
|
94
|
+
execute: (ctx: ExtensionContext, options: ResolvedWorkflowRunOptions) => Promise<void>;
|
|
95
|
+
}): Promise<WorkflowLaunchResult> {
|
|
96
|
+
const unavailable = workflowUnavailableResult(input.ctx.mode);
|
|
97
|
+
if (unavailable) return unavailable;
|
|
98
|
+
const runId = createWorkflowRunId();
|
|
99
|
+
const options: ResolvedWorkflowRunOptions = {
|
|
100
|
+
...input.options, resultViewer: "skip", runId,
|
|
101
|
+
origin: workflowOrigin(input.ctx),
|
|
102
|
+
};
|
|
103
|
+
try {
|
|
104
|
+
await this.start({
|
|
105
|
+
ctx: input.ctx, runId, name: input.name,
|
|
106
|
+
run: async (signal, onStarted) => input.execute({ ...input.ctx, signal }, {
|
|
107
|
+
...options, signal,
|
|
108
|
+
onRunMetadata(metadata) {
|
|
109
|
+
onStarted();
|
|
110
|
+
return options.onRunMetadata?.(metadata);
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
const message = unknownErrorMessage(error);
|
|
116
|
+
return { content: [{ type: "text", text: `Workflow did not start: ${message}` }], details: { error: "workflow_start_failed", message, runId } };
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
content: [{ type: "text", text: `Workflow "${input.name}" started.\nRun ID: ${runId}` }],
|
|
120
|
+
details: { state: "running", name: input.name, runId },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async start(input: WorkflowStartInput): Promise<void> {
|
|
125
|
+
if (this.active.has(input.runId)) throw new Error(`Workflow ${input.runId} is already active.`);
|
|
79
126
|
|
|
80
127
|
const sessionId = input.ctx.sessionManager.getSessionId();
|
|
81
128
|
const controller = new AbortController();
|
|
@@ -94,7 +141,7 @@ export class BackgroundWorkflowCoordinator {
|
|
|
94
141
|
if (deliveryScheduled || !accepted || !settled || this.shuttingDown.has(sessionId)) return;
|
|
95
142
|
deliveryScheduled = true;
|
|
96
143
|
void this.queueOrDeliver(input.ctx, input.runId).catch((error: unknown) => {
|
|
97
|
-
this.log(`[workflow:${input.runId}]
|
|
144
|
+
this.log(`[workflow:${input.runId}] delivery failed: ${unknownErrorMessage(error)}`);
|
|
98
145
|
});
|
|
99
146
|
};
|
|
100
147
|
|
|
@@ -105,13 +152,12 @@ export class BackgroundWorkflowCoordinator {
|
|
|
105
152
|
startedSignalled = true;
|
|
106
153
|
resolveStarted?.();
|
|
107
154
|
});
|
|
108
|
-
if (!startedSignalled) rejectStarted?.(new Error("
|
|
155
|
+
if (!startedSignalled) rejectStarted?.(new Error("Workflow ended before publishing run metadata."));
|
|
109
156
|
} catch (error) {
|
|
110
157
|
if (!startedSignalled) rejectStarted?.(error);
|
|
111
158
|
} finally {
|
|
112
159
|
settled = true;
|
|
113
160
|
this.active.delete(input.runId);
|
|
114
|
-
this.updateBackgroundSurface(input.ctx);
|
|
115
161
|
await this.notifyRunSettled(input.ctx, input.runId);
|
|
116
162
|
scheduleDelivery();
|
|
117
163
|
}
|
|
@@ -126,13 +172,12 @@ export class BackgroundWorkflowCoordinator {
|
|
|
126
172
|
|
|
127
173
|
await started;
|
|
128
174
|
const record = await this.storeForCwd(input.ctx.cwd).load(input.runId);
|
|
129
|
-
if (record?.
|
|
130
|
-
controller.abort(new WorkflowAbortError("
|
|
131
|
-
throw new Error(`
|
|
175
|
+
if (!record?.background || record.background.origin.sessionId !== sessionId) {
|
|
176
|
+
controller.abort(new WorkflowAbortError("Workflow could not persist its origin metadata."));
|
|
177
|
+
throw new Error(`Workflow ${input.runId} did not create a durable run record.`);
|
|
132
178
|
}
|
|
133
179
|
|
|
134
180
|
accepted = true;
|
|
135
|
-
this.updateBackgroundSurface(input.ctx);
|
|
136
181
|
scheduleDelivery();
|
|
137
182
|
}
|
|
138
183
|
|
|
@@ -145,7 +190,7 @@ export class BackgroundWorkflowCoordinator {
|
|
|
145
190
|
);
|
|
146
191
|
}
|
|
147
192
|
|
|
148
|
-
onRunSettled(listener:
|
|
193
|
+
onRunSettled(listener: WorkflowSettledListener): () => void {
|
|
149
194
|
this.settledListeners.add(listener);
|
|
150
195
|
return () => this.settledListeners.delete(listener);
|
|
151
196
|
}
|
|
@@ -160,7 +205,7 @@ export class BackgroundWorkflowCoordinator {
|
|
|
160
205
|
const store = this.storeForCwd(ctx.cwd);
|
|
161
206
|
if (!active.isSettled()) {
|
|
162
207
|
await forceStoppedRecord(store, runId);
|
|
163
|
-
this.log(`[workflow:${runId}]
|
|
208
|
+
this.log(`[workflow:${runId}] workflow did not settle after stop; retained state was forced to stopped.`);
|
|
164
209
|
}
|
|
165
210
|
const record = await store.load(runId);
|
|
166
211
|
if (!record) throw new Error(`Workflow run ${runId} was not found after stopping.`);
|
|
@@ -182,7 +227,7 @@ export class BackgroundWorkflowCoordinator {
|
|
|
182
227
|
try {
|
|
183
228
|
records = await store.list();
|
|
184
229
|
} catch (error) {
|
|
185
|
-
this.log(`[workflow]
|
|
230
|
+
this.log(`[workflow] recovery could not load run history: ${unknownErrorMessage(error)}`);
|
|
186
231
|
return;
|
|
187
232
|
}
|
|
188
233
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
@@ -197,7 +242,7 @@ export class BackgroundWorkflowCoordinator {
|
|
|
197
242
|
sessionId,
|
|
198
243
|
this.active.has(record.runId),
|
|
199
244
|
);
|
|
200
|
-
if (!
|
|
245
|
+
if (!isPendingOutcome(record)) continue;
|
|
201
246
|
const originSessionId = record.background.origin.sessionId;
|
|
202
247
|
if (originSessionId === sessionId) {
|
|
203
248
|
await this.queueOrDeliver(ctx, record.runId);
|
|
@@ -219,7 +264,7 @@ export class BackgroundWorkflowCoordinator {
|
|
|
219
264
|
});
|
|
220
265
|
this.log(`[workflow:${record.runId}] ${message}`);
|
|
221
266
|
} catch (error) {
|
|
222
|
-
this.log(`[workflow:${record.runId}]
|
|
267
|
+
this.log(`[workflow:${record.runId}] recovery failed: ${unknownErrorMessage(error)}`);
|
|
223
268
|
}
|
|
224
269
|
}
|
|
225
270
|
}
|
|
@@ -238,12 +283,11 @@ export class BackgroundWorkflowCoordinator {
|
|
|
238
283
|
if (run.isSettled()) continue;
|
|
239
284
|
try {
|
|
240
285
|
await forcePausedRecord(store, runId);
|
|
241
|
-
this.log(`[workflow:${runId}]
|
|
286
|
+
this.log(`[workflow:${runId}] workflow did not settle during shutdown; retained state was forced to paused.`);
|
|
242
287
|
} catch (error) {
|
|
243
288
|
this.log(`[workflow:${runId}] failed to force paused state during shutdown: ${unknownErrorMessage(error)}`);
|
|
244
289
|
}
|
|
245
290
|
}
|
|
246
|
-
if (ctx.hasUI) ctx.ui.setWidget(BACKGROUND_WIDGET_KEY, undefined);
|
|
247
291
|
}
|
|
248
292
|
|
|
249
293
|
private async queueOrDeliver(ctx: ExtensionContext, runId: string): Promise<void> {
|
|
@@ -266,7 +310,7 @@ export class BackgroundWorkflowCoordinator {
|
|
|
266
310
|
try {
|
|
267
311
|
await listener(ctx, runId);
|
|
268
312
|
} catch (error) {
|
|
269
|
-
this.log(`[workflow:${runId}]
|
|
313
|
+
this.log(`[workflow:${runId}] settlement listener failed: ${unknownErrorMessage(error)}`);
|
|
270
314
|
}
|
|
271
315
|
}
|
|
272
316
|
}
|
|
@@ -280,7 +324,7 @@ export class BackgroundWorkflowCoordinator {
|
|
|
280
324
|
try {
|
|
281
325
|
if (await this.deliver(ctx, runId)) pending.delete(runId);
|
|
282
326
|
} catch (error) {
|
|
283
|
-
this.log(`[workflow:${runId}]
|
|
327
|
+
this.log(`[workflow:${runId}] delivery retry failed: ${unknownErrorMessage(error)}`);
|
|
284
328
|
}
|
|
285
329
|
}
|
|
286
330
|
if (pending.size === 0) this.pendingDelivery.delete(sessionId);
|
|
@@ -292,23 +336,6 @@ export class BackgroundWorkflowCoordinator {
|
|
|
292
336
|
this.pendingDelivery.set(sessionId, pending);
|
|
293
337
|
}
|
|
294
338
|
|
|
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
339
|
private async deliver(ctx: ExtensionContext, runId: string): Promise<boolean> {
|
|
313
340
|
const store = this.storeForCwd(ctx.cwd);
|
|
314
341
|
const record = await store.load(runId);
|
|
@@ -316,12 +343,12 @@ export class BackgroundWorkflowCoordinator {
|
|
|
316
343
|
if (record.background.origin.sessionId !== ctx.sessionManager.getSessionId()) return false;
|
|
317
344
|
if (!isDeliverableState(record.state)) return false;
|
|
318
345
|
|
|
319
|
-
if (!
|
|
320
|
-
const details =
|
|
346
|
+
if (!sessionHasDelivery(ctx, runId)) {
|
|
347
|
+
const details = workflowCompletionDetails(record);
|
|
321
348
|
this.pi.sendMessage(
|
|
322
349
|
{
|
|
323
|
-
customType:
|
|
324
|
-
content:
|
|
350
|
+
customType: WORKFLOW_DELIVERY_CUSTOM_TYPE,
|
|
351
|
+
content: formatWorkflowDelivery(details),
|
|
325
352
|
display: true,
|
|
326
353
|
details,
|
|
327
354
|
},
|
|
@@ -333,35 +360,24 @@ export class BackgroundWorkflowCoordinator {
|
|
|
333
360
|
}
|
|
334
361
|
}
|
|
335
362
|
|
|
336
|
-
function
|
|
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 {
|
|
363
|
+
export function workflowOrigin(ctx: Pick<ExtensionContext, "sessionManager">, requestedAt = Date.now()): WorkflowOrigin {
|
|
347
364
|
return { sessionId: ctx.sessionManager.getSessionId(), requestedAt };
|
|
348
365
|
}
|
|
349
366
|
|
|
350
|
-
export function
|
|
367
|
+
export function workflowCompletionDetails(record: WorkflowRunRecord): WorkflowCompletionDetails {
|
|
351
368
|
if (!isDeliverableState(record.state)) throw new Error(`Workflow run ${record.runId} has not finished or paused.`);
|
|
352
369
|
return {
|
|
353
370
|
name: record.workflow.name,
|
|
354
|
-
result: { summary:
|
|
371
|
+
result: { summary: workflowSummary(record) },
|
|
355
372
|
completedAt: record.endedAt ?? record.updatedAt,
|
|
356
373
|
usage: record.usage,
|
|
357
374
|
runId: record.runId,
|
|
358
375
|
resumedFromRunId: record.options.resumeFromRunId,
|
|
359
|
-
background: true,
|
|
360
376
|
status: record.state,
|
|
361
377
|
};
|
|
362
378
|
}
|
|
363
379
|
|
|
364
|
-
function
|
|
380
|
+
function workflowSummary(record: WorkflowRunRecord): string {
|
|
365
381
|
if (record.state !== "completed") return boundedSummary(`Workflow ${record.state}: ${record.message}`);
|
|
366
382
|
if (record.result.kind === "unavailable") {
|
|
367
383
|
return boundedSummary(`Workflow completed; retained result is unavailable: ${record.result.reason}`);
|
|
@@ -372,9 +388,9 @@ function backgroundSummary(record: WorkflowRunRecord): string {
|
|
|
372
388
|
return "Workflow completed. Open run history for the retained result.";
|
|
373
389
|
}
|
|
374
390
|
|
|
375
|
-
function
|
|
391
|
+
function formatWorkflowDelivery(details: WorkflowCompletionDetails): string {
|
|
376
392
|
return [
|
|
377
|
-
`##
|
|
393
|
+
`## Workflow: ${details.name}`,
|
|
378
394
|
"",
|
|
379
395
|
`Run ID: ${details.runId}`,
|
|
380
396
|
`State: ${details.status}`,
|
|
@@ -383,17 +399,17 @@ function formatBackgroundDelivery(details: BackgroundWorkflowResultDetails): str
|
|
|
383
399
|
].join("\n");
|
|
384
400
|
}
|
|
385
401
|
|
|
386
|
-
function
|
|
402
|
+
function sessionHasDelivery(ctx: ExtensionContext, runId: string): boolean {
|
|
387
403
|
for (const entry of ctx.sessionManager.getEntries()) {
|
|
388
404
|
if (entry.type !== "message" || entry.message.role !== "custom") continue;
|
|
389
|
-
if (entry.message.customType !==
|
|
405
|
+
if (entry.message.customType !== WORKFLOW_DELIVERY_CUSTOM_TYPE) continue;
|
|
390
406
|
const details = entry.message.details;
|
|
391
|
-
if (isRecord(details) && details.
|
|
407
|
+
if (isRecord(details) && details.runId === runId) return true;
|
|
392
408
|
}
|
|
393
409
|
return false;
|
|
394
410
|
}
|
|
395
411
|
|
|
396
|
-
function
|
|
412
|
+
function isPendingOutcome(record: WorkflowRunRecord): record is WorkflowRunRecord & { readonly background: NonNullable<WorkflowRunRecord["background"]> } {
|
|
397
413
|
return record.background?.delivery.state === "pending" && isDeliverableState(record.state);
|
|
398
414
|
}
|
|
399
415
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { WorkflowLifecycle } from "./workflow-lifecycle.ts";
|
|
3
|
+
import { validateWorkflowRunId } from "./journal.ts";
|
|
4
|
+
import type { WorkflowProgressSource } from "./types.ts";
|
|
5
|
+
import { ProjectWorkflowRunStore } from "./workflow-run-store.ts";
|
|
6
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
7
|
+
|
|
8
|
+
/** On-demand telemetry only: never inject live agent output into the parent transcript. */
|
|
9
|
+
export async function manageWorkflow(
|
|
10
|
+
params: { action: "list" | "inspect" | "stop"; runId?: string; agentId?: number; name?: string; script?: string },
|
|
11
|
+
ctx: ExtensionContext,
|
|
12
|
+
coordinator: WorkflowLifecycle,
|
|
13
|
+
sources: ReadonlyMap<string, { source: WorkflowProgressSource }>,
|
|
14
|
+
) {
|
|
15
|
+
const result = (details: Record<string, unknown>) => ({
|
|
16
|
+
content: [{ type: "text" as const, text: boundedTelemetry(details) }], details,
|
|
17
|
+
});
|
|
18
|
+
try {
|
|
19
|
+
if (params.name !== undefined || params.script !== undefined) throw new Error("Management actions do not accept name or script.");
|
|
20
|
+
const store = new ProjectWorkflowRunStore(ctx.cwd);
|
|
21
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
22
|
+
if (params.action === "list") {
|
|
23
|
+
if (params.runId || params.agentId !== undefined) throw new Error("list does not accept runId or agentId.");
|
|
24
|
+
const records = (await store.list()).filter((run) => run.background?.origin.sessionId === sessionId);
|
|
25
|
+
return result({ runs: records.sort((a, b) => b.createdAt - a.createdAt).slice(0, 30).map((run) => ({
|
|
26
|
+
runId: run.runId, name: run.workflow.name, state: run.state,
|
|
27
|
+
agents: (sources.get(run.runId)?.source.snapshot() ?? run.progress).phases.flatMap((phase) => phase.agents).map((agent) => ({ id: agent.id, label: agent.label, status: agent.status })),
|
|
28
|
+
})) });
|
|
29
|
+
}
|
|
30
|
+
if (!params.runId) throw new Error("runId is required.");
|
|
31
|
+
validateWorkflowRunId(params.runId);
|
|
32
|
+
const source = sources.get(params.runId)?.source;
|
|
33
|
+
const record = await store.load(params.runId);
|
|
34
|
+
if (!source && record?.background?.origin.sessionId !== sessionId) throw new Error("Run not found in this session.");
|
|
35
|
+
if (params.action === "stop") {
|
|
36
|
+
if (params.agentId !== undefined) {
|
|
37
|
+
if (!source?.stopAgent) throw new Error("Agent is not active in this session.");
|
|
38
|
+
source.stopAgent(params.agentId);
|
|
39
|
+
return result({ runId: params.runId, agentId: params.agentId, state: "stop_requested" });
|
|
40
|
+
}
|
|
41
|
+
const stopped = await coordinator.stop(ctx, params.runId);
|
|
42
|
+
return result({ runId: stopped.runId, state: stopped.state });
|
|
43
|
+
}
|
|
44
|
+
const snapshot = source?.snapshot() ?? record?.progress;
|
|
45
|
+
if (!snapshot) throw new Error("Run not found.");
|
|
46
|
+
if (params.agentId === undefined) return result({ snapshot });
|
|
47
|
+
const agent = snapshot.phases.flatMap((phase) => phase.agents).find((row) => row.id === params.agentId);
|
|
48
|
+
if (!agent) throw new Error("Agent not found.");
|
|
49
|
+
return result({ runId: params.runId, agent,
|
|
50
|
+
// Bounded untrusted telemetry, not instructions. Never expose thinking blocks here.
|
|
51
|
+
untrustedActivity: source?.conversation(agent.id).slice(-20) ?? [],
|
|
52
|
+
queued: source?.transcript?.(agent.id) ? {
|
|
53
|
+
steering: source.transcript(agent.id)?.steering,
|
|
54
|
+
followUp: source.transcript(agent.id)?.followUp,
|
|
55
|
+
} : undefined,
|
|
56
|
+
});
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return result({ error: unknownErrorMessage(error) });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function boundedTelemetry(details: Record<string, unknown>): string {
|
|
63
|
+
const text = JSON.stringify(details, null, 2);
|
|
64
|
+
const limit = 24_000;
|
|
65
|
+
return text.length <= limit ? text : `${text.slice(0, limit)}\n[Truncated. Inspect a specific agentId for details.]`;
|
|
66
|
+
}
|
|
@@ -3,8 +3,7 @@ import type {
|
|
|
3
3
|
ExtensionContext,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
6
|
-
import {
|
|
7
|
-
import { backgroundUnavailableResult, startBackgroundWorkflowTool } from "./background-workflow-tool.ts";
|
|
6
|
+
import { WorkflowLifecycle, workflowUnavailableResult } from "./workflow-lifecycle.ts";
|
|
8
7
|
import { validateWorkflowRunId } from "./journal.ts";
|
|
9
8
|
import { resolveWorkflowRunOptions, type ResolvedWorkflowRunOptions } from "./options.ts";
|
|
10
9
|
import type { LoadedWorkflow } from "./types.ts";
|
|
@@ -29,8 +28,7 @@ import {
|
|
|
29
28
|
WorkflowUsageLimitScheduler,
|
|
30
29
|
type WorkflowUsageLimitSchedulerClock,
|
|
31
30
|
} from "./workflow-usage-limit-scheduler.ts";
|
|
32
|
-
import { WorkflowInspector } from "./ui/workflow-inspector.ts";
|
|
33
|
-
import { WORKFLOW_VIEWER_OVERLAY_OPTIONS } from "./ui/workflow-viewer-layout.ts";
|
|
31
|
+
import { WorkflowInspector, WORKFLOW_INSPECTOR_OVERLAY_OPTIONS } from "./ui/workflow-inspector.ts";
|
|
34
32
|
import { completeCurrentArgument, splitArgumentPrefix } from "./command-completions.ts";
|
|
35
33
|
|
|
36
34
|
type WorkflowRunCompletionContext = Pick<ExtensionContext, "cwd" | "sessionManager">;
|
|
@@ -55,7 +53,7 @@ export class WorkflowRunController {
|
|
|
55
53
|
private completionContext: WorkflowRunCompletionContext | undefined;
|
|
56
54
|
|
|
57
55
|
constructor(
|
|
58
|
-
private readonly
|
|
56
|
+
private readonly lifecycle: WorkflowLifecycle,
|
|
59
57
|
private readonly dependencies: WorkflowRunControllerDependencies,
|
|
60
58
|
) {
|
|
61
59
|
this.storeForCwd = dependencies.storeForCwd ?? ((cwd) => new ProjectWorkflowRunStore(cwd));
|
|
@@ -101,7 +99,7 @@ export class WorkflowRunController {
|
|
|
101
99
|
}
|
|
102
100
|
if (!ctx.hasUI) {
|
|
103
101
|
const records = await this.listRecent(ctx.cwd);
|
|
104
|
-
ctx.ui.notify(formatWorkflowRunHistory(records, this.
|
|
102
|
+
ctx.ui.notify(formatWorkflowRunHistory(records, this.lifecycle.activeRunIds(ctx)), "info");
|
|
105
103
|
return;
|
|
106
104
|
}
|
|
107
105
|
await this.openRunSelector(ctx);
|
|
@@ -117,7 +115,7 @@ export class WorkflowRunController {
|
|
|
117
115
|
private async openRunSelector(ctx: ExtensionCommandContext): Promise<void> {
|
|
118
116
|
while (true) {
|
|
119
117
|
const records = await this.listRecent(ctx.cwd);
|
|
120
|
-
const active = this.
|
|
118
|
+
const active = this.lifecycle.activeRunIds(ctx);
|
|
121
119
|
if (records.length === 0) {
|
|
122
120
|
ctx.ui.notify(formatWorkflowRunHistory(records, active), "info");
|
|
123
121
|
return;
|
|
@@ -149,7 +147,7 @@ export class WorkflowRunController {
|
|
|
149
147
|
private async inspect(record: WorkflowRunRecord, ctx: ExtensionContext): Promise<void> {
|
|
150
148
|
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
151
149
|
ctx.ui.notify(
|
|
152
|
-
formatWorkflowRunDetails(record, this.
|
|
150
|
+
formatWorkflowRunDetails(record, this.lifecycle.activeRunIds(ctx).has(record.runId)),
|
|
153
151
|
"info",
|
|
154
152
|
);
|
|
155
153
|
return;
|
|
@@ -162,7 +160,7 @@ export class WorkflowRunController {
|
|
|
162
160
|
() => done(undefined),
|
|
163
161
|
{ label: `${record.state.toUpperCase()} outcome`, text: retainedWorkflowRunOutcome(record) },
|
|
164
162
|
),
|
|
165
|
-
|
|
163
|
+
WORKFLOW_INSPECTOR_OVERLAY_OPTIONS,
|
|
166
164
|
);
|
|
167
165
|
}
|
|
168
166
|
|
|
@@ -182,7 +180,7 @@ export class WorkflowRunController {
|
|
|
182
180
|
}
|
|
183
181
|
const available = availableWorkflowRunActions(
|
|
184
182
|
record,
|
|
185
|
-
this.
|
|
183
|
+
this.lifecycle.activeRunIds(ctx).has(record.runId),
|
|
186
184
|
);
|
|
187
185
|
if (!available.includes(action)) {
|
|
188
186
|
ctx.ui.notify(`Action ${action} is not available for ${record.state} run ${runId}.`, "warning");
|
|
@@ -204,11 +202,11 @@ export class WorkflowRunController {
|
|
|
204
202
|
error: new Error("Workflow stopped by user."),
|
|
205
203
|
});
|
|
206
204
|
await this.storeForCwd(ctx.cwd).save(stopped);
|
|
207
|
-
await this.
|
|
205
|
+
await this.lifecycle.durableRunSettled(ctx, runId);
|
|
208
206
|
ctx.ui.notify(`Workflow run ${runId} is now stopped.`, "info");
|
|
209
207
|
return;
|
|
210
208
|
}
|
|
211
|
-
const stopped = await this.
|
|
209
|
+
const stopped = await this.lifecycle.stop(ctx, runId);
|
|
212
210
|
ctx.ui.notify(`Workflow run ${runId} is now ${stopped.state}.`, "info");
|
|
213
211
|
return;
|
|
214
212
|
}
|
|
@@ -225,10 +223,10 @@ export class WorkflowRunController {
|
|
|
225
223
|
record: WorkflowRunRecord,
|
|
226
224
|
action: "resume" | "restart",
|
|
227
225
|
): Promise<string> {
|
|
228
|
-
const unavailable =
|
|
226
|
+
const unavailable = workflowUnavailableResult(ctx.mode);
|
|
229
227
|
if (unavailable) {
|
|
230
228
|
const first = unavailable.content[0];
|
|
231
|
-
throw new Error(first?.type === "text" ? first.text : "
|
|
229
|
+
throw new Error(first?.type === "text" ? first.text : "workflows are unavailable");
|
|
232
230
|
}
|
|
233
231
|
const workflow = await this.dependencies.resolveWorkflow(record.workflow.name);
|
|
234
232
|
if (!workflow) throw new Error(`registered workflow ${record.workflow.name} is unavailable`);
|
|
@@ -242,7 +240,6 @@ export class WorkflowRunController {
|
|
|
242
240
|
throw new Error("workflow source changed, so journal replay cannot resume safely");
|
|
243
241
|
}
|
|
244
242
|
const options = resolveWorkflowRunOptions({
|
|
245
|
-
inspect: false,
|
|
246
243
|
perf: record.options.perf,
|
|
247
244
|
concurrency: record.options.concurrency ?? undefined,
|
|
248
245
|
parallelSubmissionLimit: record.options.parallelSubmissionLimit ?? undefined,
|
|
@@ -259,13 +256,12 @@ export class WorkflowRunController {
|
|
|
259
256
|
resultViewer: "skip",
|
|
260
257
|
resumeFromRunId: action === "resume" ? record.runId : undefined,
|
|
261
258
|
});
|
|
262
|
-
const result = await
|
|
263
|
-
coordinator: this.background,
|
|
259
|
+
const result = await this.lifecycle.launch({
|
|
264
260
|
ctx,
|
|
265
261
|
name: workflow.meta.name,
|
|
266
262
|
options,
|
|
267
|
-
execute: (
|
|
268
|
-
this.dependencies.execute(
|
|
263
|
+
execute: (runCtx, runOptions) =>
|
|
264
|
+
this.dependencies.execute(runCtx, workflow.meta.name, workflow, runOptions),
|
|
269
265
|
});
|
|
270
266
|
const first = result.content[0];
|
|
271
267
|
const message = first?.type === "text" ? first.text : `Workflow ${action} started.`;
|
|
@@ -314,7 +310,7 @@ export class WorkflowRunController {
|
|
|
314
310
|
const action = parts.completed[0];
|
|
315
311
|
if (!action || !isWorkflowRunLifecycleAction(action)) return null;
|
|
316
312
|
const records = await this.listRecent(ctx.cwd);
|
|
317
|
-
const active = this.
|
|
313
|
+
const active = this.lifecycle.activeRunIds(ctx);
|
|
318
314
|
return completeCurrentArgument(
|
|
319
315
|
argumentPrefix,
|
|
320
316
|
records
|
package/src/domains/workflows/runtime/{workflow-run-background.ts → workflow-run-delivery.ts}
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { WorkflowOrigin } from "./types.ts";
|
|
2
2
|
import type { WorkflowRunRecord } from "./workflow-run-record.ts";
|
|
3
3
|
|
|
4
4
|
const MAX_BACKGROUND_TEXT = 2_048;
|
|
@@ -9,12 +9,12 @@ export type WorkflowRunDelivery =
|
|
|
9
9
|
| { readonly state: "unavailable"; readonly attemptedAt: number; readonly message: string };
|
|
10
10
|
|
|
11
11
|
export interface PersistedWorkflowBackground {
|
|
12
|
-
readonly origin:
|
|
12
|
+
readonly origin: WorkflowOrigin;
|
|
13
13
|
readonly delivery: WorkflowRunDelivery;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export function createPersistedWorkflowBackground(
|
|
17
|
-
origin:
|
|
17
|
+
origin: WorkflowOrigin | undefined,
|
|
18
18
|
): PersistedWorkflowBackground | undefined {
|
|
19
19
|
if (!origin) return undefined;
|
|
20
20
|
return {
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
createPersistedWorkflowBackground,
|
|
15
15
|
isPersistedWorkflowBackground,
|
|
16
16
|
type PersistedWorkflowBackground,
|
|
17
|
-
} from "./workflow-run-
|
|
17
|
+
} from "./workflow-run-delivery.ts";
|
|
18
18
|
|
|
19
19
|
export const WORKFLOW_RUN_RECORD_VERSION = 1;
|
|
20
20
|
|
|
@@ -137,7 +137,8 @@ export function createWorkflowRunRecord(input: {
|
|
|
137
137
|
createdAt: input.progress.startedAt,
|
|
138
138
|
updatedAt: input.progress.startedAt,
|
|
139
139
|
progress: compactWorkflowProgress(input.progress),
|
|
140
|
-
|
|
140
|
+
// Keep the v1 serialized field name so existing records remain readable.
|
|
141
|
+
background: createPersistedWorkflowBackground(input.options.origin),
|
|
141
142
|
};
|
|
142
143
|
}
|
|
143
144
|
|
|
@@ -242,7 +243,7 @@ function persistedWorkflowRunOptions(
|
|
|
242
243
|
argumentsPresent: boolean,
|
|
243
244
|
): PersistedWorkflowRunOptions {
|
|
244
245
|
return {
|
|
245
|
-
inspect:
|
|
246
|
+
inspect: false, // Legacy record field; inspection is now independent of execution.
|
|
246
247
|
perf: options.perf,
|
|
247
248
|
concurrency: options.concurrency,
|
|
248
249
|
parallelSubmissionLimit: options.parallelSubmissionLimit,
|
|
@@ -274,6 +275,8 @@ function compactWorkflowProgress(snapshot: WorkflowProgressSnapshot): WorkflowPr
|
|
|
274
275
|
id: agent.id,
|
|
275
276
|
label: boundedText(agent.label),
|
|
276
277
|
model: agent.model === undefined ? undefined : boundedText(agent.model),
|
|
278
|
+
modelName: agent.modelName === undefined ? undefined : boundedText(agent.modelName),
|
|
279
|
+
thinkingLevel: agent.thinkingLevel === undefined ? undefined : boundedText(agent.thinkingLevel),
|
|
277
280
|
status: agent.status,
|
|
278
281
|
startedAt: agent.startedAt,
|
|
279
282
|
doneAt: agent.doneAt,
|
|
@@ -563,7 +566,7 @@ function isPhaseSnapshot(value: unknown): boolean {
|
|
|
563
566
|
|
|
564
567
|
function isAgentSnapshot(value: unknown): boolean {
|
|
565
568
|
if (!isRecord(value) || !isFiniteNumber(value.id) || typeof value.label !== "string") return false;
|
|
566
|
-
if (value.status !== "queued" && value.status !== "running" && value.status !== "done" && value.status !== "failed") return false;
|
|
569
|
+
if (value.status !== "queued" && value.status !== "running" && value.status !== "stopping" && value.status !== "stopped" && value.status !== "done" && value.status !== "failed") return false;
|
|
567
570
|
if (!isFiniteNumber(value.toolUses)) return false;
|
|
568
571
|
if (value.startedAt !== undefined && !isFiniteNumber(value.startedAt)) return false;
|
|
569
572
|
if (value.doneAt !== undefined && !isFiniteNumber(value.doneAt)) return false;
|
|
@@ -119,7 +119,7 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
|
|
|
119
119
|
"Then: list the changed files, summarize the change in one paragraph (mention the PR if one was found), " +
|
|
120
120
|
"and read any relevant AGENTS.md or project docs noting conventions a reviewer should know.\n" +
|
|
121
121
|
"Return diffCommand exactly as a reviewer should run it. Structured output only.",
|
|
122
|
-
{ phase: "Scope", label: "scope", tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS,
|
|
122
|
+
{ phase: "Scope", label: "scope", tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS, ...api.modelProfile("medium"), schema: ScopeSchema },
|
|
123
123
|
);
|
|
124
124
|
|
|
125
125
|
if (!scope) {
|
|
@@ -65,7 +65,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
65
65
|
"Inspect relevant files, package/test configuration, and safe diagnostic commands. " +
|
|
66
66
|
"Safe commands are read-only commands such as status, grep, listing files, typecheck/test commands, or commands explicitly requested by the user. " +
|
|
67
67
|
"Do not run mutation, install, commit, network, or destructive commands. Return scoped files, observations, and constraints. Structured output only.",
|
|
68
|
-
{ phase: "Scope", label: "scope", tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS,
|
|
68
|
+
{ phase: "Scope", label: "scope", tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS, ...api.modelProfile("medium"), schema: ScopeSchema },
|
|
69
69
|
);
|
|
70
70
|
|
|
71
71
|
if (!scope) {
|