@wichayutdew/pi-workflows 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +752 -0
- package/agents/step.md +17 -0
- package/dist/index.js +4576 -0
- package/examples/mr-comments.workflow.yaml +115 -0
- package/examples/prompts/mr-comments/implement.md +8 -0
- package/examples/prompts/mr-comments/inspect.md +5 -0
- package/examples/prompts/mr-comments/plan.md +13 -0
- package/examples/prompts/mr-comments/verify.md +7 -0
- package/examples/settings.yaml +19 -0
- package/package.json +81 -0
- package/schemas/settings.schema.json +22 -0
- package/schemas/workflow.schema.json +585 -0
- package/src/command-names.ts +46 -0
- package/src/commands.ts +80 -0
- package/src/config/ceiling.ts +153 -0
- package/src/config/command-conflicts.ts +31 -0
- package/src/config/load.ts +327 -0
- package/src/config/types.ts +187 -0
- package/src/config/validate.ts +1145 -0
- package/src/digest.ts +23 -0
- package/src/engine/checkpoint.ts +30 -0
- package/src/engine/resume.ts +44 -0
- package/src/engine/state.ts +186 -0
- package/src/engine/transitions.ts +426 -0
- package/src/harness.ts +1676 -0
- package/src/index.ts +15 -0
- package/src/integrations/plannotator.ts +235 -0
- package/src/integrations/prompt-gate.ts +54 -0
- package/src/integrations/subagents/child-runtime.ts +306 -0
- package/src/integrations/subagents/client.ts +239 -0
- package/src/integrations/subagents/protocol.ts +304 -0
- package/src/policy/approved-commands.ts +225 -0
- package/src/policy/bash.ts +355 -0
- package/src/policy/completion-batch.ts +36 -0
- package/src/policy/immutable-input.ts +18 -0
- package/src/policy/tools.ts +150 -0
- package/src/preflight.ts +76 -0
- package/src/prompt.ts +146 -0
- package/src/runtime/completion-tool.ts +22 -0
- package/src/runtime/main-step-runtime.ts +227 -0
- package/src/runtime/serial-task-queue.ts +17 -0
- package/src/runtime/step-result.ts +85 -0
- package/src/workflow-list.ts +25 -0
- package/src/workflow-status.ts +611 -0
package/src/harness.ts
ADDED
|
@@ -0,0 +1,1676 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { readFile, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import type {
|
|
7
|
+
ExtensionAPI,
|
|
8
|
+
ExtensionCommandContext,
|
|
9
|
+
ExtensionContext,
|
|
10
|
+
} from '@earendil-works/pi-coding-agent';
|
|
11
|
+
import {
|
|
12
|
+
registerHarnessCommands,
|
|
13
|
+
type WorkflowCommandController,
|
|
14
|
+
} from './commands.ts';
|
|
15
|
+
import { loadCatalog } from './config/load.ts';
|
|
16
|
+
import { hasRuntimeCommandConflict } from './config/command-conflicts.ts';
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_SETTINGS,
|
|
19
|
+
type LoadedWorkflow,
|
|
20
|
+
type WorkflowCatalog,
|
|
21
|
+
type WorkflowStep,
|
|
22
|
+
} from './config/types.ts';
|
|
23
|
+
import { digest } from './digest.ts';
|
|
24
|
+
import {
|
|
25
|
+
abortRun,
|
|
26
|
+
advanceRun,
|
|
27
|
+
allowedOutcomes,
|
|
28
|
+
attachGateReviewId,
|
|
29
|
+
beginGate,
|
|
30
|
+
failGate,
|
|
31
|
+
pauseRun,
|
|
32
|
+
reconcileRun,
|
|
33
|
+
resolveGate,
|
|
34
|
+
resumeRun,
|
|
35
|
+
storeGateResolution,
|
|
36
|
+
} from './engine/transitions.ts';
|
|
37
|
+
import {
|
|
38
|
+
createRun,
|
|
39
|
+
type GateResolution,
|
|
40
|
+
type WorkflowRun,
|
|
41
|
+
} from './engine/state.ts';
|
|
42
|
+
import { readLatestCheckpoint } from './engine/checkpoint.ts';
|
|
43
|
+
import {
|
|
44
|
+
captureResumeCheckpoint,
|
|
45
|
+
matchesResumeCheckpoint,
|
|
46
|
+
} from './engine/resume.ts';
|
|
47
|
+
import {
|
|
48
|
+
parsePlannotatorResult,
|
|
49
|
+
PLANNOTATOR_RESULT_CHANNEL,
|
|
50
|
+
requestPlannotatorReview,
|
|
51
|
+
requestPlannotatorReviewStatus,
|
|
52
|
+
} from './integrations/plannotator.ts';
|
|
53
|
+
import {
|
|
54
|
+
requestPromptGateReview,
|
|
55
|
+
type PromptGateReviewResult,
|
|
56
|
+
} from './integrations/prompt-gate.ts';
|
|
57
|
+
import { SubagentDelegationClient } from './integrations/subagents/client.ts';
|
|
58
|
+
import {
|
|
59
|
+
encodeChildPolicy,
|
|
60
|
+
parseDelegatedStepResult,
|
|
61
|
+
type ChildStepPolicy,
|
|
62
|
+
type SubagentDelegationRequest,
|
|
63
|
+
type SubagentDelegationResponse,
|
|
64
|
+
type SubagentDelegationUpdate,
|
|
65
|
+
} from './integrations/subagents/protocol.ts';
|
|
66
|
+
import { preflightStep } from './preflight.ts';
|
|
67
|
+
import {
|
|
68
|
+
buildDelegatedStepTask,
|
|
69
|
+
buildMainStepTask,
|
|
70
|
+
buildMainWorkflowNotice,
|
|
71
|
+
} from './prompt.ts';
|
|
72
|
+
import { extractApprovedBashCommands } from './policy/approved-commands.ts';
|
|
73
|
+
import {
|
|
74
|
+
MainStepRuntime,
|
|
75
|
+
type MainStepExecution,
|
|
76
|
+
} from './runtime/main-step-runtime.ts';
|
|
77
|
+
import { SerialTaskQueue } from './runtime/serial-task-queue.ts';
|
|
78
|
+
import type { WorkflowStepResult } from './runtime/step-result.ts';
|
|
79
|
+
import { formatWorkflowList } from './workflow-list.ts';
|
|
80
|
+
import {
|
|
81
|
+
formatWorkflowStatusText,
|
|
82
|
+
showWorkflowStatus,
|
|
83
|
+
type WorkflowStatusExecution,
|
|
84
|
+
type WorkflowStatusSnapshot,
|
|
85
|
+
} from './workflow-status.ts';
|
|
86
|
+
|
|
87
|
+
const STATE_ENTRY_TYPE = 'pi-workflows-state-v1';
|
|
88
|
+
const STATUS_KEY = 'pi-workflows';
|
|
89
|
+
|
|
90
|
+
interface ActiveDelegation {
|
|
91
|
+
requestId: string;
|
|
92
|
+
runId: string;
|
|
93
|
+
stepId: string;
|
|
94
|
+
stepDigest: string;
|
|
95
|
+
sessionEpoch: number;
|
|
96
|
+
resultDirectory: string;
|
|
97
|
+
policy: ChildStepPolicy;
|
|
98
|
+
agent: string;
|
|
99
|
+
progress?: string;
|
|
100
|
+
cancelling?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface MainStepIdentity {
|
|
104
|
+
runId: string;
|
|
105
|
+
stepId: string;
|
|
106
|
+
stepDigest: string;
|
|
107
|
+
sessionEpoch: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface ActivePromptReview {
|
|
111
|
+
requestId: string;
|
|
112
|
+
runId: string;
|
|
113
|
+
stepId: string;
|
|
114
|
+
sessionEpoch: number;
|
|
115
|
+
abortController: AbortController;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function emptyCatalog(): WorkflowCatalog {
|
|
119
|
+
return {
|
|
120
|
+
workflows: new Map(),
|
|
121
|
+
settings: DEFAULT_SETTINGS,
|
|
122
|
+
diagnostics: [],
|
|
123
|
+
userDirectory: '',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function formatDiagnostics(catalog: WorkflowCatalog): string {
|
|
128
|
+
const shown = catalog.diagnostics
|
|
129
|
+
.slice(0, 3)
|
|
130
|
+
.map((item) => `${item.path}: ${item.message}`);
|
|
131
|
+
const remaining = catalog.diagnostics.length - shown.length;
|
|
132
|
+
return [
|
|
133
|
+
...shown,
|
|
134
|
+
...(remaining > 0 ? [`${remaining} more diagnostic(s)`] : []),
|
|
135
|
+
].join('\n');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export class WorkflowHarness implements WorkflowCommandController {
|
|
139
|
+
private readonly pi: ExtensionAPI;
|
|
140
|
+
private readonly subagents: SubagentDelegationClient;
|
|
141
|
+
private readonly mainSteps: MainStepRuntime;
|
|
142
|
+
private catalog: WorkflowCatalog = emptyCatalog();
|
|
143
|
+
private run: WorkflowRun | undefined;
|
|
144
|
+
private latestContext: ExtensionContext | undefined;
|
|
145
|
+
private availableSkills = new Set<string>();
|
|
146
|
+
private sessionActive = false;
|
|
147
|
+
private sessionEpoch = 0;
|
|
148
|
+
private activeDelegation: ActiveDelegation | undefined;
|
|
149
|
+
private activePromptReview: ActivePromptReview | undefined;
|
|
150
|
+
private registeredWorkflowCommands = new Set<string>();
|
|
151
|
+
private catalogLoadSequence = 0;
|
|
152
|
+
private readonly mutationQueue = new SerialTaskQueue();
|
|
153
|
+
|
|
154
|
+
constructor(pi: ExtensionAPI) {
|
|
155
|
+
this.pi = pi;
|
|
156
|
+
this.subagents = new SubagentDelegationClient(pi.events);
|
|
157
|
+
this.mainSteps = new MainStepRuntime(pi);
|
|
158
|
+
registerHarnessCommands(pi, this);
|
|
159
|
+
this.registerLifecycle();
|
|
160
|
+
this.registerPolicy();
|
|
161
|
+
this.registerPlannotatorResults();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
workflowIds(): string[] {
|
|
165
|
+
return [...this.catalog.workflows.keys()].sort();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async list(ctx: ExtensionCommandContext): Promise<void> {
|
|
169
|
+
const workflows = [...this.catalog.workflows.values()].sort((left, right) =>
|
|
170
|
+
left.definition.id.localeCompare(right.definition.id),
|
|
171
|
+
);
|
|
172
|
+
if (workflows.length === 0) {
|
|
173
|
+
ctx.ui.notify(
|
|
174
|
+
`No workflows loaded from ${this.catalog.userDirectory}`,
|
|
175
|
+
this.catalog.diagnostics.length > 0 ? 'warning' : 'info',
|
|
176
|
+
);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
this.pi.sendMessage({
|
|
180
|
+
customType: 'workflow-list',
|
|
181
|
+
content: formatWorkflowList(
|
|
182
|
+
workflows.map((workflow) => workflow.definition),
|
|
183
|
+
),
|
|
184
|
+
display: true,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
start(
|
|
189
|
+
workflowId: string,
|
|
190
|
+
input: string,
|
|
191
|
+
ctx: ExtensionCommandContext,
|
|
192
|
+
): Promise<void> {
|
|
193
|
+
return this.enqueueMutation(ctx, (sessionEpoch) =>
|
|
194
|
+
this.startNow(workflowId, input, ctx, sessionEpoch),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async startNow(
|
|
199
|
+
workflowId: string,
|
|
200
|
+
input: string,
|
|
201
|
+
ctx: ExtensionCommandContext,
|
|
202
|
+
sessionEpoch: number,
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
if (this.activeDelegation) {
|
|
205
|
+
ctx.ui.notify(
|
|
206
|
+
`Cannot start a workflow while subagent "${this.activeDelegation.agent}" is still cancelling`,
|
|
207
|
+
'warning',
|
|
208
|
+
);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (
|
|
212
|
+
this.run &&
|
|
213
|
+
this.run.status !== 'completed' &&
|
|
214
|
+
this.run.status !== 'aborted'
|
|
215
|
+
) {
|
|
216
|
+
ctx.ui.notify(
|
|
217
|
+
`Workflow "${this.run.workflowId}" is ${this.run.status}; resume or abort it first`,
|
|
218
|
+
'warning',
|
|
219
|
+
);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (!ctx.isIdle()) {
|
|
223
|
+
ctx.abort();
|
|
224
|
+
await ctx.waitForIdle();
|
|
225
|
+
}
|
|
226
|
+
if (!this.sessionActive || this.sessionEpoch !== sessionEpoch) {
|
|
227
|
+
ctx.ui.notify(
|
|
228
|
+
'Workflow start was superseded by a session change',
|
|
229
|
+
'warning',
|
|
230
|
+
);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
this.captureSkills(ctx.getSystemPromptOptions().skills);
|
|
235
|
+
if (!(await this.reloadCatalog(ctx, false))) {
|
|
236
|
+
ctx.ui.notify(
|
|
237
|
+
'Workflow start was superseded by a newer configuration load',
|
|
238
|
+
'warning',
|
|
239
|
+
);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (!this.sessionActive || this.sessionEpoch !== sessionEpoch) {
|
|
243
|
+
ctx.ui.notify(
|
|
244
|
+
'Workflow start was superseded by a session change',
|
|
245
|
+
'warning',
|
|
246
|
+
);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const workflow = this.catalog.workflows.get(workflowId);
|
|
250
|
+
if (!workflow) {
|
|
251
|
+
ctx.ui.notify(`Workflow "${workflowId}" is not loaded`, 'error');
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const preflightErrors = this.preflight(workflow, workflow.definition.start);
|
|
255
|
+
if (preflightErrors.length > 0) {
|
|
256
|
+
ctx.ui.notify(
|
|
257
|
+
`Cannot start workflow:\n${preflightErrors.join('\n')}`,
|
|
258
|
+
'error',
|
|
259
|
+
);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const baselineTools = this.pi.getActiveTools();
|
|
264
|
+
this.run = createRun(
|
|
265
|
+
workflow,
|
|
266
|
+
input.trim(),
|
|
267
|
+
baselineTools,
|
|
268
|
+
randomUUID(),
|
|
269
|
+
Date.now(),
|
|
270
|
+
);
|
|
271
|
+
this.persist();
|
|
272
|
+
this.isolateMainSessionTools();
|
|
273
|
+
this.updateStatus();
|
|
274
|
+
this.launchCurrentStep(workflow);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
pause(reason: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
278
|
+
return this.enqueueMutation(ctx, () => this.pauseNow(reason, ctx));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private async pauseNow(
|
|
282
|
+
reason: string,
|
|
283
|
+
ctx: ExtensionCommandContext,
|
|
284
|
+
): Promise<void> {
|
|
285
|
+
if (
|
|
286
|
+
!this.run ||
|
|
287
|
+
this.run.status === 'completed' ||
|
|
288
|
+
this.run.status === 'aborted'
|
|
289
|
+
) {
|
|
290
|
+
ctx.ui.notify('No active workflow to pause', 'warning');
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (this.run.status === 'paused') {
|
|
294
|
+
ctx.ui.notify(
|
|
295
|
+
`Workflow is already paused${this.run.pauseReason ? `: ${this.run.pauseReason}` : ''}`,
|
|
296
|
+
'info',
|
|
297
|
+
);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
this.cancelPromptReview();
|
|
301
|
+
const mainSuspended = this.mainSteps.suspend();
|
|
302
|
+
const cancellationConfirmed = await this.cancelActiveDelegation(
|
|
303
|
+
'Workflow paused by user',
|
|
304
|
+
);
|
|
305
|
+
if (!ctx.isIdle()) {
|
|
306
|
+
ctx.abort();
|
|
307
|
+
if (mainSuspended) await ctx.waitForIdle();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
this.run = pauseRun(this.run, reason, Date.now());
|
|
311
|
+
this.persist();
|
|
312
|
+
if (cancellationConfirmed) {
|
|
313
|
+
this.restoreBaselineTools();
|
|
314
|
+
} else {
|
|
315
|
+
this.isolateMainSessionTools();
|
|
316
|
+
}
|
|
317
|
+
this.updateStatus();
|
|
318
|
+
ctx.ui.notify(
|
|
319
|
+
cancellationConfirmed
|
|
320
|
+
? `Paused "${this.run.workflowId}" at step "${this.run.currentStepId}". Fix the issue, then run /workflow-resume.`
|
|
321
|
+
: `Pause recorded at "${this.run.currentStepId}", but child cancellation is not confirmed. Main tools remain isolated until it exits.`,
|
|
322
|
+
cancellationConfirmed ? 'info' : 'warning',
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
resume(ctx: ExtensionCommandContext): Promise<void> {
|
|
327
|
+
return this.enqueueMutation(ctx, () => this.resumeNow(ctx));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private async resumeNow(ctx: ExtensionCommandContext): Promise<void> {
|
|
331
|
+
if (!this.run || this.run.status !== 'paused') {
|
|
332
|
+
ctx.ui.notify('No paused workflow to resume', 'warning');
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (this.activeDelegation) {
|
|
336
|
+
ctx.ui.notify(
|
|
337
|
+
`Cannot resume while subagent "${this.activeDelegation.agent}" is still cancelling`,
|
|
338
|
+
'warning',
|
|
339
|
+
);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const checkpoint = captureResumeCheckpoint(this.run, this.sessionEpoch);
|
|
343
|
+
if (!ctx.isIdle()) {
|
|
344
|
+
ctx.abort();
|
|
345
|
+
await ctx.waitForIdle();
|
|
346
|
+
}
|
|
347
|
+
if (!matchesResumeCheckpoint(this.run, this.sessionEpoch, checkpoint)) {
|
|
348
|
+
ctx.ui.notify(
|
|
349
|
+
'Resume was superseded by another workflow or session change',
|
|
350
|
+
'warning',
|
|
351
|
+
);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
this.captureSkills(ctx.getSystemPromptOptions().skills);
|
|
355
|
+
await this.reloadCatalog(ctx, false);
|
|
356
|
+
if (!matchesResumeCheckpoint(this.run, this.sessionEpoch, checkpoint)) {
|
|
357
|
+
ctx.ui.notify(
|
|
358
|
+
'Resume was superseded by another workflow or session change',
|
|
359
|
+
'warning',
|
|
360
|
+
);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
let workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
365
|
+
if (!workflow) {
|
|
366
|
+
ctx.ui.notify(
|
|
367
|
+
`Workflow "${this.run.workflowId}" is no longer loaded; restore it or abort`,
|
|
368
|
+
'error',
|
|
369
|
+
);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const reconciled = reconcileRun(this.run, workflow, Date.now());
|
|
373
|
+
if (!reconciled.run) {
|
|
374
|
+
ctx.ui.notify(
|
|
375
|
+
reconciled.error ?? 'Cannot reconcile workflow configuration',
|
|
376
|
+
'error',
|
|
377
|
+
);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
let resumed = reconciled.run;
|
|
382
|
+
if (
|
|
383
|
+
resumed.pendingGate?.provider === 'plannotator' &&
|
|
384
|
+
!resumed.pendingGate.reviewId
|
|
385
|
+
) {
|
|
386
|
+
resumed = failGate(
|
|
387
|
+
resumed,
|
|
388
|
+
'Gate submission was interrupted before a review id was recorded; submit it again',
|
|
389
|
+
Date.now(),
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
if (
|
|
393
|
+
resumed.pendingGate?.provider === 'plannotator' &&
|
|
394
|
+
resumed.pendingGate.reviewId &&
|
|
395
|
+
!resumed.pendingGate.resolution
|
|
396
|
+
) {
|
|
397
|
+
const requestedReviewId = resumed.pendingGate.reviewId;
|
|
398
|
+
const gateStep = workflow.definition.steps[resumed.pendingGate.stepId];
|
|
399
|
+
const statusResponse = await requestPlannotatorReviewStatus(
|
|
400
|
+
this.pi.events,
|
|
401
|
+
`${resumed.runId}:review-status:${randomUUID()}`,
|
|
402
|
+
requestedReviewId,
|
|
403
|
+
gateStep?.gate?.provider === 'plannotator'
|
|
404
|
+
? gateStep.gate.timeoutMs
|
|
405
|
+
: 5_000,
|
|
406
|
+
);
|
|
407
|
+
if (!matchesResumeCheckpoint(this.run, this.sessionEpoch, checkpoint)) {
|
|
408
|
+
ctx.ui.notify(
|
|
409
|
+
'Resume was superseded by another workflow or session change',
|
|
410
|
+
'warning',
|
|
411
|
+
);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
416
|
+
if (!workflow) {
|
|
417
|
+
ctx.ui.notify(
|
|
418
|
+
`Workflow "${this.run.workflowId}" is no longer loaded; restore it or abort`,
|
|
419
|
+
'error',
|
|
420
|
+
);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const latest = reconcileRun(this.run, workflow, Date.now());
|
|
424
|
+
if (!latest.run) {
|
|
425
|
+
ctx.ui.notify(
|
|
426
|
+
latest.error ?? 'Cannot reconcile workflow configuration',
|
|
427
|
+
'error',
|
|
428
|
+
);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
resumed = latest.run;
|
|
432
|
+
|
|
433
|
+
if (
|
|
434
|
+
!resumed.pendingGate?.resolution &&
|
|
435
|
+
resumed.pendingGate?.reviewId === requestedReviewId &&
|
|
436
|
+
statusResponse.status !== 'handled'
|
|
437
|
+
) {
|
|
438
|
+
ctx.ui.notify(
|
|
439
|
+
statusResponse.error ?? 'Cannot query the pending Plannotator review',
|
|
440
|
+
'error',
|
|
441
|
+
);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (
|
|
445
|
+
!resumed.pendingGate?.resolution &&
|
|
446
|
+
resumed.pendingGate?.reviewId === requestedReviewId &&
|
|
447
|
+
statusResponse.status === 'handled' &&
|
|
448
|
+
statusResponse.result.status === 'completed'
|
|
449
|
+
) {
|
|
450
|
+
resumed = storeGateResolution(
|
|
451
|
+
resumed,
|
|
452
|
+
{
|
|
453
|
+
approved: statusResponse.result.approved,
|
|
454
|
+
feedback: statusResponse.result.feedback,
|
|
455
|
+
resolvedAt: Date.now(),
|
|
456
|
+
},
|
|
457
|
+
Date.now(),
|
|
458
|
+
);
|
|
459
|
+
} else if (
|
|
460
|
+
!resumed.pendingGate?.resolution &&
|
|
461
|
+
resumed.pendingGate?.reviewId === requestedReviewId &&
|
|
462
|
+
statusResponse.status === 'handled' &&
|
|
463
|
+
statusResponse.result.status === 'missing'
|
|
464
|
+
) {
|
|
465
|
+
resumed = failGate(
|
|
466
|
+
resumed,
|
|
467
|
+
'Plannotator no longer has the pending review; submit it again',
|
|
468
|
+
Date.now(),
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const storedResolution = resumed.pendingGate?.resolution;
|
|
473
|
+
if (storedResolution) {
|
|
474
|
+
try {
|
|
475
|
+
resumed = resolveGate(workflow, resumed, storedResolution, Date.now());
|
|
476
|
+
} catch (error) {
|
|
477
|
+
ctx.ui.notify(
|
|
478
|
+
`Cannot apply stored gate result: ${error instanceof Error ? error.message : String(error)}`,
|
|
479
|
+
'error',
|
|
480
|
+
);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
} else {
|
|
484
|
+
resumed = resumeRun(resumed, Date.now());
|
|
485
|
+
}
|
|
486
|
+
this.run = resumed;
|
|
487
|
+
|
|
488
|
+
if (this.run.status === 'awaiting-gate') {
|
|
489
|
+
this.persist();
|
|
490
|
+
this.restoreBaselineTools();
|
|
491
|
+
this.updateStatus();
|
|
492
|
+
if (this.run.pendingGate?.provider === 'prompt') {
|
|
493
|
+
this.launchPromptReview(workflow, this.run, ctx);
|
|
494
|
+
ctx.ui.notify('Workflow resumed with built-in review open', 'info');
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
ctx.ui.notify(
|
|
498
|
+
`Workflow resumed and is waiting for review ${this.run.pendingGate?.reviewId ?? ''}`.trim(),
|
|
499
|
+
'info',
|
|
500
|
+
);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (this.run.status !== 'running') {
|
|
504
|
+
this.persist();
|
|
505
|
+
this.restoreBaselineTools();
|
|
506
|
+
this.updateStatus();
|
|
507
|
+
ctx.ui.notify(`Workflow is now ${this.run.status}`, 'info');
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const preflightErrors = this.preflight(workflow, this.run.currentStepId);
|
|
512
|
+
if (preflightErrors.length > 0) {
|
|
513
|
+
this.run = pauseRun(
|
|
514
|
+
this.run,
|
|
515
|
+
`Step preflight failed: ${preflightErrors.join('; ')}`,
|
|
516
|
+
Date.now(),
|
|
517
|
+
);
|
|
518
|
+
this.persist();
|
|
519
|
+
this.restoreBaselineTools();
|
|
520
|
+
this.updateStatus();
|
|
521
|
+
ctx.ui.notify(
|
|
522
|
+
`Cannot resume workflow:\n${preflightErrors.join('\n')}`,
|
|
523
|
+
'error',
|
|
524
|
+
);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
this.persist();
|
|
529
|
+
this.isolateMainSessionTools();
|
|
530
|
+
this.updateStatus();
|
|
531
|
+
this.launchCurrentStep(workflow);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
abort(reason: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
535
|
+
return this.enqueueMutation(ctx, () => this.abortNow(reason, ctx));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private async abortNow(
|
|
539
|
+
reason: string,
|
|
540
|
+
ctx: ExtensionCommandContext,
|
|
541
|
+
): Promise<void> {
|
|
542
|
+
if (
|
|
543
|
+
!this.run ||
|
|
544
|
+
this.run.status === 'completed' ||
|
|
545
|
+
this.run.status === 'aborted'
|
|
546
|
+
) {
|
|
547
|
+
ctx.ui.notify('No active workflow to abort', 'warning');
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
this.cancelPromptReview();
|
|
551
|
+
const mainSuspended = this.mainSteps.suspend();
|
|
552
|
+
const cancellationConfirmed = await this.cancelActiveDelegation(
|
|
553
|
+
'Workflow aborted by user',
|
|
554
|
+
);
|
|
555
|
+
if (!ctx.isIdle()) {
|
|
556
|
+
ctx.abort();
|
|
557
|
+
if (mainSuspended) await ctx.waitForIdle();
|
|
558
|
+
}
|
|
559
|
+
this.run = abortRun(this.run, reason, Date.now());
|
|
560
|
+
this.persist();
|
|
561
|
+
if (cancellationConfirmed) {
|
|
562
|
+
this.restoreBaselineTools();
|
|
563
|
+
} else {
|
|
564
|
+
this.isolateMainSessionTools();
|
|
565
|
+
}
|
|
566
|
+
this.updateStatus();
|
|
567
|
+
ctx.ui.notify(
|
|
568
|
+
cancellationConfirmed
|
|
569
|
+
? `Aborted workflow "${this.run.workflowId}"`
|
|
570
|
+
: `Workflow "${this.run.workflowId}" is aborted, but its child has not confirmed cancellation; main tools remain isolated`,
|
|
571
|
+
cancellationConfirmed ? 'info' : 'warning',
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
reload(ctx: ExtensionCommandContext): Promise<void> {
|
|
576
|
+
return this.enqueueMutation(ctx, () => this.reloadNow(ctx));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private async reloadNow(ctx: ExtensionCommandContext): Promise<void> {
|
|
580
|
+
if (
|
|
581
|
+
this.run &&
|
|
582
|
+
(this.run.status === 'running' || this.run.status === 'awaiting-gate')
|
|
583
|
+
) {
|
|
584
|
+
ctx.ui.notify(
|
|
585
|
+
'Pause the workflow before reloading its configuration',
|
|
586
|
+
'warning',
|
|
587
|
+
);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
this.captureSkills(ctx.getSystemPromptOptions().skills);
|
|
591
|
+
await this.reloadCatalog(ctx, true);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async status(ctx: ExtensionCommandContext): Promise<void> {
|
|
595
|
+
const snapshot = this.workflowStatusSnapshot();
|
|
596
|
+
if (!snapshot) {
|
|
597
|
+
ctx.ui.notify('No workflow checkpoint in this session', 'info');
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (ctx.hasUI && ctx.mode === 'tui') {
|
|
601
|
+
await showWorkflowStatus(ctx, () => this.workflowStatusSnapshot());
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
ctx.ui.notify(formatWorkflowStatusText(snapshot), 'info');
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
private workflowStatusSnapshot(): WorkflowStatusSnapshot | undefined {
|
|
608
|
+
if (!this.run) return undefined;
|
|
609
|
+
const workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
610
|
+
let execution: WorkflowStatusExecution | undefined;
|
|
611
|
+
if (this.activeDelegation) {
|
|
612
|
+
execution = {
|
|
613
|
+
kind: 'subagent',
|
|
614
|
+
agent: this.activeDelegation.agent,
|
|
615
|
+
requestId: this.activeDelegation.requestId,
|
|
616
|
+
progress: this.activeDelegation.progress ?? 'starting',
|
|
617
|
+
};
|
|
618
|
+
} else if (this.mainSteps.activeStepId) {
|
|
619
|
+
execution = { kind: 'main' };
|
|
620
|
+
}
|
|
621
|
+
return {
|
|
622
|
+
run: this.run,
|
|
623
|
+
now: Date.now(),
|
|
624
|
+
...(workflow ? { workflow } : {}),
|
|
625
|
+
...(execution ? { execution } : {}),
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private registerLifecycle(): void {
|
|
630
|
+
this.pi.on('session_start', async (_event, ctx) => {
|
|
631
|
+
this.sessionEpoch += 1;
|
|
632
|
+
this.sessionActive = false;
|
|
633
|
+
this.cancelPromptReview();
|
|
634
|
+
this.mainSteps.deactivate();
|
|
635
|
+
await this.cancelActiveDelegation('Pi session changed');
|
|
636
|
+
if (this.run) this.restoreBaselineTools();
|
|
637
|
+
this.run = undefined;
|
|
638
|
+
this.latestContext = ctx;
|
|
639
|
+
if (!(await this.reloadCatalog(ctx, false))) return;
|
|
640
|
+
this.restoreFromSession(ctx);
|
|
641
|
+
this.sessionActive = true;
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
this.pi.on('session_tree', async (_event, ctx) => {
|
|
645
|
+
this.sessionEpoch += 1;
|
|
646
|
+
this.sessionActive = false;
|
|
647
|
+
this.cancelPromptReview();
|
|
648
|
+
this.mainSteps.deactivate();
|
|
649
|
+
await this.cancelActiveDelegation('Pi session tree changed');
|
|
650
|
+
this.latestContext = ctx;
|
|
651
|
+
if (!(await this.reloadCatalog(ctx, false))) return;
|
|
652
|
+
this.restoreFromSession(ctx);
|
|
653
|
+
this.sessionActive = true;
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
this.pi.on('session_shutdown', async () => {
|
|
657
|
+
this.sessionEpoch += 1;
|
|
658
|
+
this.sessionActive = false;
|
|
659
|
+
this.cancelPromptReview();
|
|
660
|
+
this.mainSteps.deactivate();
|
|
661
|
+
await this.cancelActiveDelegation('Pi session shut down');
|
|
662
|
+
if (this.run) this.restoreBaselineTools();
|
|
663
|
+
this.run = undefined;
|
|
664
|
+
this.latestContext = undefined;
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private registerPolicy(): void {
|
|
669
|
+
this.pi.on('before_agent_start', (event, ctx) => {
|
|
670
|
+
this.latestContext = ctx;
|
|
671
|
+
this.captureSkills(event.systemPromptOptions.skills);
|
|
672
|
+
if (!this.run || this.run.status !== 'running') return;
|
|
673
|
+
const workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
674
|
+
if (!workflow) {
|
|
675
|
+
this.run = pauseRun(
|
|
676
|
+
this.run,
|
|
677
|
+
'Workflow configuration disappeared; reload or restore it',
|
|
678
|
+
Date.now(),
|
|
679
|
+
);
|
|
680
|
+
this.persist();
|
|
681
|
+
this.restoreBaselineTools();
|
|
682
|
+
this.updateStatus();
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
return {
|
|
686
|
+
systemPrompt: `${event.systemPrompt}\n\n${buildMainWorkflowNotice(workflow, this.run)}`,
|
|
687
|
+
};
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private launchCurrentStep(workflow: LoadedWorkflow): void {
|
|
692
|
+
const run = this.run;
|
|
693
|
+
if (
|
|
694
|
+
!run ||
|
|
695
|
+
run.status !== 'running' ||
|
|
696
|
+
this.activeDelegation ||
|
|
697
|
+
this.mainSteps.activeStepId
|
|
698
|
+
) {
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
const step = workflow.definition.steps[run.currentStepId];
|
|
702
|
+
if (!step) {
|
|
703
|
+
this.pauseForExecutionFailure(
|
|
704
|
+
'Workflow',
|
|
705
|
+
`Step "${run.currentStepId}" is missing from the workflow`,
|
|
706
|
+
);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const subagent = step.subagent;
|
|
710
|
+
if (!subagent) {
|
|
711
|
+
this.launchMainStep(workflow, run, step);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const requestId = `${run.runId}:${run.currentStepId}:${randomUUID()}`;
|
|
716
|
+
const resultDirectory = mkdtempSync(join(tmpdir(), 'pi-workflows-step-'));
|
|
717
|
+
const capabilityPath = join(resultDirectory, 'capability');
|
|
718
|
+
const capabilityToken = randomBytes(32).toString('hex');
|
|
719
|
+
const resultPath = join(resultDirectory, 'result.json');
|
|
720
|
+
writeFileSync(capabilityPath, capabilityToken, {
|
|
721
|
+
encoding: 'utf8',
|
|
722
|
+
flag: 'wx',
|
|
723
|
+
mode: 0o600,
|
|
724
|
+
});
|
|
725
|
+
const approvedBashCommands = extractApprovedBashCommands(
|
|
726
|
+
run.reviewedArtifact ?? '',
|
|
727
|
+
step.permissions.bash.approvedSources ?? [],
|
|
728
|
+
);
|
|
729
|
+
const policyDigest = digest({
|
|
730
|
+
version: 1,
|
|
731
|
+
requestId,
|
|
732
|
+
agent: subagent.agent,
|
|
733
|
+
runId: run.runId,
|
|
734
|
+
stepId: run.currentStepId,
|
|
735
|
+
stepDigest: run.currentStepDigest,
|
|
736
|
+
capabilityPath,
|
|
737
|
+
resultPath,
|
|
738
|
+
approvedBashCommands,
|
|
739
|
+
});
|
|
740
|
+
const policy: ChildStepPolicy = {
|
|
741
|
+
version: 1,
|
|
742
|
+
requestId,
|
|
743
|
+
agent: subagent.agent,
|
|
744
|
+
workflowId: workflow.definition.id,
|
|
745
|
+
runId: run.runId,
|
|
746
|
+
stepId: run.currentStepId,
|
|
747
|
+
stepTitle: step.title,
|
|
748
|
+
policyDigest,
|
|
749
|
+
capabilityPath,
|
|
750
|
+
capabilityToken,
|
|
751
|
+
resultPath,
|
|
752
|
+
permissions: structuredClone(step.permissions),
|
|
753
|
+
...(approvedBashCommands.length > 0 ? { approvedBashCommands } : {}),
|
|
754
|
+
outcomes: allowedOutcomes(workflow, run),
|
|
755
|
+
summaryMaxChars: workflow.definition.summaryMaxChars,
|
|
756
|
+
...(step.gate ? { gateSubmitOutcome: step.gate.submitOutcome } : {}),
|
|
757
|
+
};
|
|
758
|
+
const active: ActiveDelegation = {
|
|
759
|
+
requestId,
|
|
760
|
+
runId: run.runId,
|
|
761
|
+
stepId: run.currentStepId,
|
|
762
|
+
stepDigest: run.currentStepDigest,
|
|
763
|
+
sessionEpoch: this.sessionEpoch,
|
|
764
|
+
resultDirectory,
|
|
765
|
+
policy,
|
|
766
|
+
agent: subagent.agent,
|
|
767
|
+
};
|
|
768
|
+
const request: SubagentDelegationRequest = {
|
|
769
|
+
version: 1,
|
|
770
|
+
requestId,
|
|
771
|
+
agent: subagent.agent,
|
|
772
|
+
task: buildDelegatedStepTask(workflow, run, encodeChildPolicy(policy)),
|
|
773
|
+
context: subagent.context,
|
|
774
|
+
cwd: this.latestContext?.cwd ?? process.cwd(),
|
|
775
|
+
timeoutMs: subagent.timeoutMs,
|
|
776
|
+
skill:
|
|
777
|
+
step.permissions.skills.length > 0
|
|
778
|
+
? [...step.permissions.skills]
|
|
779
|
+
: false,
|
|
780
|
+
acceptance: {
|
|
781
|
+
level: 'none',
|
|
782
|
+
reason:
|
|
783
|
+
'Pi Workflows owns correlated step completion and human-review gates',
|
|
784
|
+
},
|
|
785
|
+
artifacts: subagent.artifacts,
|
|
786
|
+
...(subagent.model ? { model: subagent.model } : {}),
|
|
787
|
+
...(subagent.turnBudget
|
|
788
|
+
? { turnBudget: structuredClone(subagent.turnBudget) }
|
|
789
|
+
: {}),
|
|
790
|
+
...(subagent.toolBudget
|
|
791
|
+
? { toolBudget: structuredClone(subagent.toolBudget) }
|
|
792
|
+
: {}),
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
this.activeDelegation = active;
|
|
796
|
+
this.updateStatus();
|
|
797
|
+
this.latestContext?.ui.notify(
|
|
798
|
+
`Delegated "${run.currentStepId}" to subagent "${subagent.agent}"`,
|
|
799
|
+
'info',
|
|
800
|
+
);
|
|
801
|
+
void this.subagents
|
|
802
|
+
.delegate(request, {
|
|
803
|
+
onUpdate: (update) => this.handleDelegationUpdate(active, update),
|
|
804
|
+
onLateTerminal: (response) =>
|
|
805
|
+
this.queueDelegationResponse(active, response),
|
|
806
|
+
})
|
|
807
|
+
.then(
|
|
808
|
+
(response) => this.queueDelegationResponse(active, response),
|
|
809
|
+
(error: unknown) =>
|
|
810
|
+
this.queueDelegationFailure(
|
|
811
|
+
active,
|
|
812
|
+
error instanceof Error ? error.message : String(error),
|
|
813
|
+
),
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
private launchMainStep(
|
|
818
|
+
workflow: LoadedWorkflow,
|
|
819
|
+
run: WorkflowRun,
|
|
820
|
+
step: WorkflowStep,
|
|
821
|
+
): void {
|
|
822
|
+
const approvedBashCommands = extractApprovedBashCommands(
|
|
823
|
+
run.reviewedArtifact ?? '',
|
|
824
|
+
step.permissions.bash.approvedSources ?? [],
|
|
825
|
+
);
|
|
826
|
+
const identity: MainStepIdentity = {
|
|
827
|
+
runId: run.runId,
|
|
828
|
+
stepId: run.currentStepId,
|
|
829
|
+
stepDigest: run.currentStepDigest,
|
|
830
|
+
sessionEpoch: this.sessionEpoch,
|
|
831
|
+
};
|
|
832
|
+
const policyDigest = digest({
|
|
833
|
+
version: 1,
|
|
834
|
+
execution: 'main',
|
|
835
|
+
workflowId: workflow.definition.id,
|
|
836
|
+
runId: run.runId,
|
|
837
|
+
stepId: run.currentStepId,
|
|
838
|
+
stepDigest: run.currentStepDigest,
|
|
839
|
+
permissions: step.permissions,
|
|
840
|
+
approvedBashCommands,
|
|
841
|
+
nonce: randomUUID(),
|
|
842
|
+
});
|
|
843
|
+
const execution: MainStepExecution = {
|
|
844
|
+
workflowId: workflow.definition.id,
|
|
845
|
+
runId: run.runId,
|
|
846
|
+
stepId: run.currentStepId,
|
|
847
|
+
stepDigest: run.currentStepDigest,
|
|
848
|
+
policyDigest,
|
|
849
|
+
step: structuredClone(step),
|
|
850
|
+
approvedBashCommands,
|
|
851
|
+
outcomes: allowedOutcomes(workflow, run),
|
|
852
|
+
summaryMaxChars: workflow.definition.summaryMaxChars,
|
|
853
|
+
...(step.gate ? { gateSubmitOutcome: step.gate.submitOutcome } : {}),
|
|
854
|
+
onSettled: (result, context) =>
|
|
855
|
+
this.queueMainStepResult(identity, result, context),
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
try {
|
|
859
|
+
this.mainSteps.activate(execution);
|
|
860
|
+
this.updateStatus();
|
|
861
|
+
this.latestContext?.ui.notify(
|
|
862
|
+
`Started "${run.currentStepId}" in the main agent`,
|
|
863
|
+
'info',
|
|
864
|
+
);
|
|
865
|
+
this.pi.sendUserMessage(buildMainStepTask(workflow, run), {
|
|
866
|
+
deliverAs: 'followUp',
|
|
867
|
+
});
|
|
868
|
+
} catch (error) {
|
|
869
|
+
this.mainSteps.deactivate();
|
|
870
|
+
this.pauseForExecutionFailure(
|
|
871
|
+
'Main-agent step',
|
|
872
|
+
error instanceof Error ? error.message : String(error),
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
private queueMainStepResult(
|
|
878
|
+
identity: MainStepIdentity,
|
|
879
|
+
result: WorkflowStepResult | undefined,
|
|
880
|
+
context: ExtensionContext,
|
|
881
|
+
): Promise<void> {
|
|
882
|
+
return this.mutationQueue
|
|
883
|
+
.run(() => this.finishMainStep(identity, result, context))
|
|
884
|
+
.catch((error: unknown) => {
|
|
885
|
+
this.pauseForExecutionFailure(
|
|
886
|
+
'Main-agent step',
|
|
887
|
+
error instanceof Error ? error.message : String(error),
|
|
888
|
+
);
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
private async finishMainStep(
|
|
893
|
+
identity: MainStepIdentity,
|
|
894
|
+
result: WorkflowStepResult | undefined,
|
|
895
|
+
context: ExtensionContext,
|
|
896
|
+
): Promise<void> {
|
|
897
|
+
this.latestContext = context;
|
|
898
|
+
if (
|
|
899
|
+
!this.sessionActive ||
|
|
900
|
+
this.sessionEpoch !== identity.sessionEpoch ||
|
|
901
|
+
!this.run ||
|
|
902
|
+
this.run.status !== 'running' ||
|
|
903
|
+
this.run.runId !== identity.runId ||
|
|
904
|
+
this.run.currentStepId !== identity.stepId ||
|
|
905
|
+
this.run.currentStepDigest !== identity.stepDigest
|
|
906
|
+
) {
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (!result) {
|
|
910
|
+
throw new Error(
|
|
911
|
+
'agent settled without calling workflow_complete_step exactly once',
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
916
|
+
const step = workflow?.definition.steps[this.run.currentStepId];
|
|
917
|
+
if (!workflow || !step) {
|
|
918
|
+
throw new Error('Active workflow configuration is unavailable');
|
|
919
|
+
}
|
|
920
|
+
if (step.gate?.submitOutcome === result.outcome) {
|
|
921
|
+
await this.submitGate(
|
|
922
|
+
workflow,
|
|
923
|
+
this.run,
|
|
924
|
+
result.outcome,
|
|
925
|
+
result.artifact ?? '',
|
|
926
|
+
);
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
this.run = advanceRun(
|
|
931
|
+
workflow,
|
|
932
|
+
this.run,
|
|
933
|
+
result.outcome,
|
|
934
|
+
result.summary,
|
|
935
|
+
Date.now(),
|
|
936
|
+
);
|
|
937
|
+
this.settleAfterTransition(workflow);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
private handleDelegationUpdate(
|
|
941
|
+
active: ActiveDelegation,
|
|
942
|
+
update: SubagentDelegationUpdate,
|
|
943
|
+
): void {
|
|
944
|
+
if (this.activeDelegation !== active) return;
|
|
945
|
+
const progress = [
|
|
946
|
+
update.currentTool ? `tool ${update.currentTool}` : undefined,
|
|
947
|
+
update.toolCount !== undefined ? `${update.toolCount} calls` : undefined,
|
|
948
|
+
update.tokens !== undefined ? `${update.tokens} tokens` : undefined,
|
|
949
|
+
].filter((part): part is string => part !== undefined);
|
|
950
|
+
active.progress = progress.join(', ') || 'running';
|
|
951
|
+
this.updateStatus();
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
private queueDelegationResponse(
|
|
955
|
+
active: ActiveDelegation,
|
|
956
|
+
response: SubagentDelegationResponse,
|
|
957
|
+
): void {
|
|
958
|
+
void this.mutationQueue
|
|
959
|
+
.run(() => this.finishDelegation(active, response))
|
|
960
|
+
.catch((error: unknown) => {
|
|
961
|
+
this.pauseForDelegationFailure(
|
|
962
|
+
error instanceof Error ? error.message : String(error),
|
|
963
|
+
);
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
private queueDelegationFailure(
|
|
968
|
+
active: ActiveDelegation,
|
|
969
|
+
reason: string,
|
|
970
|
+
): void {
|
|
971
|
+
void this.mutationQueue
|
|
972
|
+
.run(async () => {
|
|
973
|
+
if (this.activeDelegation !== active) {
|
|
974
|
+
await this.cleanupDelegation(active);
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (this.subagents.activeRequestId === active.requestId) {
|
|
978
|
+
this.retainUnconfirmedDelegation(active, reason);
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
this.activeDelegation = undefined;
|
|
982
|
+
await this.cleanupDelegation(active);
|
|
983
|
+
this.pauseForDelegationFailure(reason);
|
|
984
|
+
})
|
|
985
|
+
.catch((error: unknown) => {
|
|
986
|
+
this.pauseForDelegationFailure(
|
|
987
|
+
error instanceof Error ? error.message : String(error),
|
|
988
|
+
);
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
private async finishDelegation(
|
|
993
|
+
active: ActiveDelegation,
|
|
994
|
+
response: SubagentDelegationResponse,
|
|
995
|
+
): Promise<void> {
|
|
996
|
+
if (this.activeDelegation !== active) {
|
|
997
|
+
await this.cleanupDelegation(active);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
this.activeDelegation = undefined;
|
|
1001
|
+
|
|
1002
|
+
try {
|
|
1003
|
+
if (
|
|
1004
|
+
!this.sessionActive ||
|
|
1005
|
+
this.sessionEpoch !== active.sessionEpoch ||
|
|
1006
|
+
!this.run ||
|
|
1007
|
+
this.run.status !== 'running' ||
|
|
1008
|
+
this.run.runId !== active.runId ||
|
|
1009
|
+
this.run.currentStepId !== active.stepId ||
|
|
1010
|
+
this.run.currentStepDigest !== active.stepDigest
|
|
1011
|
+
) {
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
if (response.status !== 'completed') {
|
|
1015
|
+
throw new Error(
|
|
1016
|
+
`Subagent "${active.agent}" ${response.status.replaceAll('_', ' ')}${
|
|
1017
|
+
response.error ? `: ${response.error}` : ''
|
|
1018
|
+
}`,
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
1023
|
+
const step = workflow?.definition.steps[this.run.currentStepId];
|
|
1024
|
+
if (!workflow || !step) {
|
|
1025
|
+
throw new Error('Active workflow configuration is unavailable');
|
|
1026
|
+
}
|
|
1027
|
+
const requiredSkillWarning =
|
|
1028
|
+
step.requires.skills.length > 0
|
|
1029
|
+
? response.warnings?.find((warning) => /skill/i.test(warning))
|
|
1030
|
+
: undefined;
|
|
1031
|
+
if (requiredSkillWarning) {
|
|
1032
|
+
throw new Error(
|
|
1033
|
+
`Subagent skill preflight failed: ${requiredSkillWarning}`,
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const rawResult = JSON.parse(
|
|
1038
|
+
await readFile(active.policy.resultPath, 'utf8'),
|
|
1039
|
+
) as unknown;
|
|
1040
|
+
const result = parseDelegatedStepResult(rawResult, active.policy);
|
|
1041
|
+
if (step.gate?.submitOutcome === result.outcome) {
|
|
1042
|
+
await this.submitGate(
|
|
1043
|
+
workflow,
|
|
1044
|
+
this.run,
|
|
1045
|
+
result.outcome,
|
|
1046
|
+
result.artifact ?? '',
|
|
1047
|
+
);
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
this.run = advanceRun(
|
|
1052
|
+
workflow,
|
|
1053
|
+
this.run,
|
|
1054
|
+
result.outcome,
|
|
1055
|
+
result.summary,
|
|
1056
|
+
Date.now(),
|
|
1057
|
+
);
|
|
1058
|
+
this.settleAfterTransition(workflow);
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
this.pauseForDelegationFailure(
|
|
1061
|
+
error instanceof Error ? error.message : String(error),
|
|
1062
|
+
);
|
|
1063
|
+
} finally {
|
|
1064
|
+
await this.cleanupDelegation(active);
|
|
1065
|
+
if (active.cancelling) this.releaseMainAfterCancellation(active);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
private async cancelActiveDelegation(reason: string): Promise<boolean> {
|
|
1070
|
+
const active = this.activeDelegation;
|
|
1071
|
+
if (!active) return true;
|
|
1072
|
+
active.cancelling = true;
|
|
1073
|
+
active.progress = 'cancelling';
|
|
1074
|
+
this.updateStatus();
|
|
1075
|
+
if (this.subagents.activeRequestId !== active.requestId) {
|
|
1076
|
+
active.progress = 'cancellation unconfirmed';
|
|
1077
|
+
this.updateStatus();
|
|
1078
|
+
this.latestContext?.ui.notify(
|
|
1079
|
+
`${reason}; the delegation channel already closed without a terminal response`,
|
|
1080
|
+
'warning',
|
|
1081
|
+
);
|
|
1082
|
+
return false;
|
|
1083
|
+
}
|
|
1084
|
+
const confirmed = await this.subagents.cancelActiveAndWait();
|
|
1085
|
+
if (confirmed && this.activeDelegation === active) {
|
|
1086
|
+
this.activeDelegation = undefined;
|
|
1087
|
+
await this.cleanupDelegation(active);
|
|
1088
|
+
} else if (!confirmed) {
|
|
1089
|
+
this.latestContext?.ui.notify(
|
|
1090
|
+
`${reason}; waiting for subagent "${active.agent}" to confirm termination`,
|
|
1091
|
+
'warning',
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
return confirmed;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
private async cleanupDelegation(active: ActiveDelegation): Promise<void> {
|
|
1098
|
+
await rm(active.resultDirectory, { recursive: true, force: true });
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
private pauseForDelegationFailure(reason: string): void {
|
|
1102
|
+
this.pauseForExecutionFailure('Subagent step', reason);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
private pauseForExecutionFailure(label: string, reason: string): void {
|
|
1106
|
+
if (!this.run || this.run.status !== 'running') return;
|
|
1107
|
+
this.mainSteps.deactivate();
|
|
1108
|
+
this.run = pauseRun(this.run, `${label} failed: ${reason}`, Date.now());
|
|
1109
|
+
this.persist();
|
|
1110
|
+
if (this.activeDelegation) {
|
|
1111
|
+
this.isolateMainSessionTools();
|
|
1112
|
+
} else {
|
|
1113
|
+
this.restoreBaselineTools();
|
|
1114
|
+
}
|
|
1115
|
+
this.updateStatus();
|
|
1116
|
+
this.latestContext?.ui.notify(
|
|
1117
|
+
`Workflow paused at "${this.run.currentStepId}": ${reason}`,
|
|
1118
|
+
'error',
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
private retainUnconfirmedDelegation(
|
|
1123
|
+
active: ActiveDelegation,
|
|
1124
|
+
reason: string,
|
|
1125
|
+
): void {
|
|
1126
|
+
active.cancelling = true;
|
|
1127
|
+
active.progress = 'cancellation unconfirmed';
|
|
1128
|
+
if (this.run?.status === 'running') {
|
|
1129
|
+
this.run = pauseRun(
|
|
1130
|
+
this.run,
|
|
1131
|
+
`Subagent step failed: ${reason}`,
|
|
1132
|
+
Date.now(),
|
|
1133
|
+
);
|
|
1134
|
+
this.persist();
|
|
1135
|
+
}
|
|
1136
|
+
this.isolateMainSessionTools();
|
|
1137
|
+
this.updateStatus();
|
|
1138
|
+
this.latestContext?.ui.notify(
|
|
1139
|
+
`Workflow paused, but subagent "${active.agent}" has not confirmed termination. Main tools and resume remain blocked; restart Pi if no terminal response arrives.`,
|
|
1140
|
+
'error',
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
private releaseMainAfterCancellation(active: ActiveDelegation): void {
|
|
1145
|
+
if (this.activeDelegation === active) this.activeDelegation = undefined;
|
|
1146
|
+
if (
|
|
1147
|
+
!this.activeDelegation &&
|
|
1148
|
+
this.run &&
|
|
1149
|
+
this.run.status !== 'running' &&
|
|
1150
|
+
this.run.status !== 'awaiting-gate'
|
|
1151
|
+
) {
|
|
1152
|
+
this.restoreBaselineTools();
|
|
1153
|
+
this.updateStatus();
|
|
1154
|
+
this.latestContext?.ui.notify(
|
|
1155
|
+
`Subagent "${active.agent}" has terminated; main tools are restored`,
|
|
1156
|
+
'info',
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
private async submitGate(
|
|
1162
|
+
workflow: LoadedWorkflow,
|
|
1163
|
+
originalRun: WorkflowRun,
|
|
1164
|
+
outcome: string,
|
|
1165
|
+
artifact: string,
|
|
1166
|
+
): Promise<void> {
|
|
1167
|
+
const requestSessionEpoch = this.sessionEpoch;
|
|
1168
|
+
const requestId = `${originalRun.runId}:${originalRun.currentStepId}:${randomUUID()}`;
|
|
1169
|
+
const step = workflow.definition.steps[originalRun.currentStepId];
|
|
1170
|
+
if (!step?.gate) throw new Error('Current step has no gate');
|
|
1171
|
+
|
|
1172
|
+
this.run = beginGate(
|
|
1173
|
+
workflow,
|
|
1174
|
+
originalRun,
|
|
1175
|
+
outcome,
|
|
1176
|
+
artifact,
|
|
1177
|
+
requestId,
|
|
1178
|
+
Date.now(),
|
|
1179
|
+
);
|
|
1180
|
+
this.persist();
|
|
1181
|
+
this.restoreBaselineTools();
|
|
1182
|
+
this.updateStatus();
|
|
1183
|
+
|
|
1184
|
+
if (step.gate.provider === 'prompt') {
|
|
1185
|
+
this.launchPromptReview(workflow, this.run, this.latestContext);
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
const response = await requestPlannotatorReview(
|
|
1190
|
+
this.pi.events,
|
|
1191
|
+
requestId,
|
|
1192
|
+
artifact,
|
|
1193
|
+
`pi-workflows:${workflow.definition.id}:${originalRun.currentStepId}`,
|
|
1194
|
+
step.gate.timeoutMs,
|
|
1195
|
+
);
|
|
1196
|
+
if (
|
|
1197
|
+
!this.sessionActive ||
|
|
1198
|
+
this.sessionEpoch !== requestSessionEpoch ||
|
|
1199
|
+
!this.run ||
|
|
1200
|
+
this.run.runId !== originalRun.runId ||
|
|
1201
|
+
this.run.currentStepId !== originalRun.currentStepId ||
|
|
1202
|
+
this.run.pendingGate?.requestId !== requestId ||
|
|
1203
|
+
this.run.pendingGate.reviewId !== undefined
|
|
1204
|
+
) {
|
|
1205
|
+
throw new Error('Gate request was superseded by a workflow state change');
|
|
1206
|
+
}
|
|
1207
|
+
if (
|
|
1208
|
+
this.run.status !== 'awaiting-gate' &&
|
|
1209
|
+
!(this.run.status === 'paused' && this.run.pendingGate)
|
|
1210
|
+
) {
|
|
1211
|
+
throw new Error('Gate request was superseded by a workflow state change');
|
|
1212
|
+
}
|
|
1213
|
+
if (response.status !== 'handled') {
|
|
1214
|
+
const reason = response.error ?? 'Plannotator is unavailable';
|
|
1215
|
+
const gateFailed = failGate(this.run, reason, Date.now());
|
|
1216
|
+
this.run =
|
|
1217
|
+
this.run.status === 'paused'
|
|
1218
|
+
? pauseRun(gateFailed, reason, Date.now())
|
|
1219
|
+
: gateFailed;
|
|
1220
|
+
this.persist();
|
|
1221
|
+
if (this.run.status === 'running') {
|
|
1222
|
+
this.isolateMainSessionTools();
|
|
1223
|
+
} else {
|
|
1224
|
+
this.restoreBaselineTools();
|
|
1225
|
+
}
|
|
1226
|
+
this.updateStatus();
|
|
1227
|
+
throw new Error(reason);
|
|
1228
|
+
}
|
|
1229
|
+
this.run = attachGateReviewId(
|
|
1230
|
+
this.run,
|
|
1231
|
+
response.result.reviewId,
|
|
1232
|
+
Date.now(),
|
|
1233
|
+
);
|
|
1234
|
+
this.persist();
|
|
1235
|
+
this.updateStatus();
|
|
1236
|
+
this.latestContext?.ui.notify(
|
|
1237
|
+
`Submitted "${originalRun.currentStepId}" for Plannotator review ${response.result.reviewId}`,
|
|
1238
|
+
'info',
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
private launchPromptReview(
|
|
1243
|
+
workflow: LoadedWorkflow,
|
|
1244
|
+
run: WorkflowRun,
|
|
1245
|
+
context: ExtensionContext | undefined,
|
|
1246
|
+
): void {
|
|
1247
|
+
const pendingGate = run.pendingGate;
|
|
1248
|
+
if (!pendingGate || pendingGate.provider !== 'prompt') return;
|
|
1249
|
+
if (!context?.hasUI) {
|
|
1250
|
+
this.pausePromptGate(
|
|
1251
|
+
pendingGate.requestId,
|
|
1252
|
+
'Built-in review requires Pi TUI or RPC mode; resume there to continue',
|
|
1253
|
+
);
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
if (this.activePromptReview?.requestId === pendingGate.requestId) return;
|
|
1257
|
+
this.cancelPromptReview();
|
|
1258
|
+
|
|
1259
|
+
const active: ActivePromptReview = {
|
|
1260
|
+
requestId: pendingGate.requestId,
|
|
1261
|
+
runId: run.runId,
|
|
1262
|
+
stepId: pendingGate.stepId,
|
|
1263
|
+
sessionEpoch: this.sessionEpoch,
|
|
1264
|
+
abortController: new AbortController(),
|
|
1265
|
+
};
|
|
1266
|
+
this.activePromptReview = active;
|
|
1267
|
+
void requestPromptGateReview(
|
|
1268
|
+
context.ui,
|
|
1269
|
+
`Review ${workflow.definition.id}:${pendingGate.stepId}`,
|
|
1270
|
+
pendingGate.artifact,
|
|
1271
|
+
active.abortController.signal,
|
|
1272
|
+
).then(
|
|
1273
|
+
(result) => this.queuePromptReviewResult(active, result),
|
|
1274
|
+
(error: unknown) =>
|
|
1275
|
+
this.queuePromptReviewFailure(
|
|
1276
|
+
active,
|
|
1277
|
+
error instanceof Error ? error.message : String(error),
|
|
1278
|
+
),
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
private queuePromptReviewResult(
|
|
1283
|
+
active: ActivePromptReview,
|
|
1284
|
+
result: PromptGateReviewResult,
|
|
1285
|
+
): void {
|
|
1286
|
+
void this.mutationQueue
|
|
1287
|
+
.run(() => this.finishPromptReview(active, result))
|
|
1288
|
+
.catch((error: unknown) => {
|
|
1289
|
+
this.latestContext?.ui.notify(
|
|
1290
|
+
`Cannot apply built-in review: ${error instanceof Error ? error.message : String(error)}`,
|
|
1291
|
+
'error',
|
|
1292
|
+
);
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
private queuePromptReviewFailure(
|
|
1297
|
+
active: ActivePromptReview,
|
|
1298
|
+
reason: string,
|
|
1299
|
+
): void {
|
|
1300
|
+
void this.mutationQueue
|
|
1301
|
+
.run(async () => {
|
|
1302
|
+
if (this.activePromptReview !== active) return;
|
|
1303
|
+
this.activePromptReview = undefined;
|
|
1304
|
+
this.pausePromptGate(
|
|
1305
|
+
active.requestId,
|
|
1306
|
+
`Built-in review failed: ${reason}`,
|
|
1307
|
+
);
|
|
1308
|
+
})
|
|
1309
|
+
.catch((error: unknown) => {
|
|
1310
|
+
this.latestContext?.ui.notify(
|
|
1311
|
+
`Cannot pause failed built-in review: ${error instanceof Error ? error.message : String(error)}`,
|
|
1312
|
+
'error',
|
|
1313
|
+
);
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
private async finishPromptReview(
|
|
1318
|
+
active: ActivePromptReview,
|
|
1319
|
+
result: PromptGateReviewResult,
|
|
1320
|
+
): Promise<void> {
|
|
1321
|
+
if (this.activePromptReview !== active) return;
|
|
1322
|
+
this.activePromptReview = undefined;
|
|
1323
|
+
if (
|
|
1324
|
+
!this.sessionActive ||
|
|
1325
|
+
this.sessionEpoch !== active.sessionEpoch ||
|
|
1326
|
+
!this.run ||
|
|
1327
|
+
this.run.runId !== active.runId ||
|
|
1328
|
+
this.run.currentStepId !== active.stepId ||
|
|
1329
|
+
this.run.pendingGate?.provider !== 'prompt' ||
|
|
1330
|
+
this.run.pendingGate.requestId !== active.requestId
|
|
1331
|
+
) {
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
if (result.status === 'dismissed') {
|
|
1335
|
+
this.pausePromptGate(
|
|
1336
|
+
active.requestId,
|
|
1337
|
+
'Built-in review was dismissed; resume to reopen it',
|
|
1338
|
+
);
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
const resolution: GateResolution = {
|
|
1343
|
+
approved: result.approved,
|
|
1344
|
+
feedback: result.feedback,
|
|
1345
|
+
resolvedAt: Date.now(),
|
|
1346
|
+
};
|
|
1347
|
+
if (this.run.status === 'paused') {
|
|
1348
|
+
this.run = storeGateResolution(this.run, resolution, Date.now());
|
|
1349
|
+
this.persist();
|
|
1350
|
+
this.latestContext?.ui.notify(
|
|
1351
|
+
'Built-in review finished while paused. Run /workflow-resume to apply it.',
|
|
1352
|
+
'info',
|
|
1353
|
+
);
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
if (this.run.status !== 'awaiting-gate') return;
|
|
1357
|
+
|
|
1358
|
+
const workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
1359
|
+
if (!workflow) {
|
|
1360
|
+
this.pausePromptGate(
|
|
1361
|
+
active.requestId,
|
|
1362
|
+
'Built-in review finished, but workflow configuration is unavailable',
|
|
1363
|
+
);
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
try {
|
|
1367
|
+
this.run = resolveGate(workflow, this.run, resolution, Date.now());
|
|
1368
|
+
this.settleAfterTransition(workflow);
|
|
1369
|
+
} catch (error) {
|
|
1370
|
+
this.pausePromptGate(
|
|
1371
|
+
active.requestId,
|
|
1372
|
+
`Cannot apply built-in review: ${error instanceof Error ? error.message : String(error)}`,
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
private pausePromptGate(requestId: string, reason: string): void {
|
|
1378
|
+
if (
|
|
1379
|
+
!this.run ||
|
|
1380
|
+
this.run.pendingGate?.provider !== 'prompt' ||
|
|
1381
|
+
this.run.pendingGate.requestId !== requestId
|
|
1382
|
+
) {
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
if (this.run.status === 'awaiting-gate') {
|
|
1386
|
+
this.run = pauseRun(this.run, reason, Date.now());
|
|
1387
|
+
}
|
|
1388
|
+
this.persist();
|
|
1389
|
+
this.restoreBaselineTools();
|
|
1390
|
+
this.updateStatus();
|
|
1391
|
+
this.latestContext?.ui.notify(
|
|
1392
|
+
`Workflow paused at "${this.run.currentStepId}": ${reason}`,
|
|
1393
|
+
'warning',
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
private cancelPromptReview(): void {
|
|
1398
|
+
const active = this.activePromptReview;
|
|
1399
|
+
if (!active) return;
|
|
1400
|
+
this.activePromptReview = undefined;
|
|
1401
|
+
active.abortController.abort();
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
private registerPlannotatorResults(): void {
|
|
1405
|
+
this.pi.events.on(PLANNOTATOR_RESULT_CHANNEL, (data) => {
|
|
1406
|
+
void this.mutationQueue
|
|
1407
|
+
.run(() => this.handlePlannotatorResult(data))
|
|
1408
|
+
.catch((error: unknown) => {
|
|
1409
|
+
this.latestContext?.ui.notify(
|
|
1410
|
+
`Cannot apply Plannotator result: ${
|
|
1411
|
+
error instanceof Error ? error.message : String(error)
|
|
1412
|
+
}`,
|
|
1413
|
+
'error',
|
|
1414
|
+
);
|
|
1415
|
+
});
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
private async handlePlannotatorResult(data: unknown): Promise<void> {
|
|
1420
|
+
if (
|
|
1421
|
+
!this.sessionActive ||
|
|
1422
|
+
this.run?.pendingGate?.provider !== 'plannotator' ||
|
|
1423
|
+
!this.run.pendingGate.reviewId
|
|
1424
|
+
) {
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
const result = parsePlannotatorResult(data);
|
|
1428
|
+
if (!result || result.reviewId !== this.run.pendingGate.reviewId) return;
|
|
1429
|
+
|
|
1430
|
+
const resolution: GateResolution = {
|
|
1431
|
+
approved: result.approved,
|
|
1432
|
+
feedback: result.feedback,
|
|
1433
|
+
resolvedAt: Date.now(),
|
|
1434
|
+
};
|
|
1435
|
+
if (this.run.status === 'paused') {
|
|
1436
|
+
this.run = storeGateResolution(this.run, resolution, Date.now());
|
|
1437
|
+
this.persist();
|
|
1438
|
+
this.latestContext?.ui.notify(
|
|
1439
|
+
`Review ${result.reviewId} finished while paused. Run /workflow-resume to apply it.`,
|
|
1440
|
+
'info',
|
|
1441
|
+
);
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
if (this.run.status !== 'awaiting-gate') return;
|
|
1445
|
+
|
|
1446
|
+
const workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
1447
|
+
if (!workflow) {
|
|
1448
|
+
this.run = pauseRun(
|
|
1449
|
+
this.run,
|
|
1450
|
+
'Gate result arrived, but workflow configuration is unavailable',
|
|
1451
|
+
Date.now(),
|
|
1452
|
+
);
|
|
1453
|
+
this.persist();
|
|
1454
|
+
this.restoreBaselineTools();
|
|
1455
|
+
this.updateStatus();
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
try {
|
|
1459
|
+
this.run = resolveGate(workflow, this.run, resolution, Date.now());
|
|
1460
|
+
this.settleAfterTransition(workflow);
|
|
1461
|
+
} catch (error) {
|
|
1462
|
+
this.run = pauseRun(
|
|
1463
|
+
this.run,
|
|
1464
|
+
`Cannot apply gate result: ${error instanceof Error ? error.message : String(error)}`,
|
|
1465
|
+
Date.now(),
|
|
1466
|
+
);
|
|
1467
|
+
this.persist();
|
|
1468
|
+
this.restoreBaselineTools();
|
|
1469
|
+
this.updateStatus();
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
private settleAfterTransition(workflow: LoadedWorkflow): void {
|
|
1474
|
+
if (!this.run) return;
|
|
1475
|
+
if (this.run.status === 'running') {
|
|
1476
|
+
const preflightErrors = this.preflight(workflow, this.run.currentStepId);
|
|
1477
|
+
if (preflightErrors.length > 0) {
|
|
1478
|
+
this.run = pauseRun(
|
|
1479
|
+
this.run,
|
|
1480
|
+
`Step preflight failed: ${preflightErrors.join('; ')}`,
|
|
1481
|
+
Date.now(),
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
this.persist();
|
|
1487
|
+
if (this.run.status !== 'running') {
|
|
1488
|
+
this.restoreBaselineTools();
|
|
1489
|
+
this.updateStatus();
|
|
1490
|
+
if (this.run.status === 'completed') {
|
|
1491
|
+
this.latestContext?.ui.notify(
|
|
1492
|
+
`Workflow "${this.run.workflowId}" completed`,
|
|
1493
|
+
'info',
|
|
1494
|
+
);
|
|
1495
|
+
} else if (this.run.status === 'paused') {
|
|
1496
|
+
this.latestContext?.ui.notify(
|
|
1497
|
+
`Workflow paused: ${this.run.pauseReason ?? 'manual action required'}`,
|
|
1498
|
+
'warning',
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
this.isolateMainSessionTools();
|
|
1505
|
+
this.updateStatus();
|
|
1506
|
+
this.launchCurrentStep(workflow);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
private preflight(workflow: LoadedWorkflow, stepId: string): string[] {
|
|
1510
|
+
const step = workflow.definition.steps[stepId];
|
|
1511
|
+
if (!step) return [`step "${stepId}" does not exist`];
|
|
1512
|
+
return preflightStep(step, {
|
|
1513
|
+
tools: this.pi.getAllTools(),
|
|
1514
|
+
commands: this.pi.getCommands(),
|
|
1515
|
+
skills: this.availableSkills,
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
private isolateMainSessionTools(): void {
|
|
1520
|
+
this.pi.setActiveTools([]);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
private restoreBaselineTools(): void {
|
|
1524
|
+
this.mainSteps.release();
|
|
1525
|
+
if (this.run) {
|
|
1526
|
+
this.pi.setActiveTools(this.run.baselineTools);
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
this.pi.setActiveTools(this.pi.getActiveTools());
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
private captureSkills(skills: readonly { name: string }[] | undefined): void {
|
|
1533
|
+
if (!skills) return;
|
|
1534
|
+
this.availableSkills = new Set(skills.map((skill) => skill.name));
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
private enqueueMutation(
|
|
1538
|
+
ctx: ExtensionContext,
|
|
1539
|
+
operation: (sessionEpoch: number) => Promise<void>,
|
|
1540
|
+
): Promise<void> {
|
|
1541
|
+
if (!this.sessionActive) {
|
|
1542
|
+
ctx.ui.notify('The Pi session is still initializing', 'warning');
|
|
1543
|
+
return Promise.resolve();
|
|
1544
|
+
}
|
|
1545
|
+
const sessionEpoch = this.sessionEpoch;
|
|
1546
|
+
return this.mutationQueue.run(async () => {
|
|
1547
|
+
if (!this.sessionActive || this.sessionEpoch !== sessionEpoch) {
|
|
1548
|
+
ctx.ui.notify(
|
|
1549
|
+
'Workflow command was superseded by a session change',
|
|
1550
|
+
'warning',
|
|
1551
|
+
);
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
await operation(sessionEpoch);
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
private persist(): void {
|
|
1559
|
+
if (this.run) {
|
|
1560
|
+
this.pi.appendEntry(STATE_ENTRY_TYPE, structuredClone(this.run));
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
private restoreFromSession(ctx: ExtensionContext): void {
|
|
1565
|
+
this.latestContext = ctx;
|
|
1566
|
+
const previousBaseline = this.run?.baselineTools;
|
|
1567
|
+
const entries = ctx.sessionManager.getBranch();
|
|
1568
|
+
const checkpoint = readLatestCheckpoint(entries, STATE_ENTRY_TYPE);
|
|
1569
|
+
this.run = checkpoint.status === 'valid' ? checkpoint.run : undefined;
|
|
1570
|
+
if (checkpoint.status === 'invalid') {
|
|
1571
|
+
ctx.ui.notify(
|
|
1572
|
+
'The newest workflow checkpoint is invalid or from an unsupported version; recovery stopped',
|
|
1573
|
+
'error',
|
|
1574
|
+
);
|
|
1575
|
+
}
|
|
1576
|
+
if (
|
|
1577
|
+
this.run &&
|
|
1578
|
+
(this.run.status === 'running' || this.run.status === 'awaiting-gate')
|
|
1579
|
+
) {
|
|
1580
|
+
this.run = pauseRun(
|
|
1581
|
+
this.run,
|
|
1582
|
+
'Session was restored; inspect the checkpoint before resuming',
|
|
1583
|
+
Date.now(),
|
|
1584
|
+
);
|
|
1585
|
+
this.persist();
|
|
1586
|
+
}
|
|
1587
|
+
if (!this.run && previousBaseline) {
|
|
1588
|
+
this.pi.setActiveTools(previousBaseline);
|
|
1589
|
+
} else {
|
|
1590
|
+
this.restoreBaselineTools();
|
|
1591
|
+
}
|
|
1592
|
+
if (this.activeDelegation) this.isolateMainSessionTools();
|
|
1593
|
+
this.updateStatus();
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
private async reloadCatalog(
|
|
1597
|
+
ctx: ExtensionContext,
|
|
1598
|
+
announce: boolean,
|
|
1599
|
+
): Promise<boolean> {
|
|
1600
|
+
const loadSequence = ++this.catalogLoadSequence;
|
|
1601
|
+
const sessionEpoch = this.sessionEpoch;
|
|
1602
|
+
const catalog = await loadCatalog({
|
|
1603
|
+
cwd: ctx.cwd,
|
|
1604
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
1605
|
+
});
|
|
1606
|
+
if (
|
|
1607
|
+
loadSequence !== this.catalogLoadSequence ||
|
|
1608
|
+
sessionEpoch !== this.sessionEpoch
|
|
1609
|
+
) {
|
|
1610
|
+
return false;
|
|
1611
|
+
}
|
|
1612
|
+
this.latestContext = ctx;
|
|
1613
|
+
const availableCommands = this.pi.getCommands();
|
|
1614
|
+
for (const [workflowId, workflow] of catalog.workflows) {
|
|
1615
|
+
const command = workflow.definition.command;
|
|
1616
|
+
if (
|
|
1617
|
+
hasRuntimeCommandConflict(
|
|
1618
|
+
command,
|
|
1619
|
+
availableCommands,
|
|
1620
|
+
this.registeredWorkflowCommands,
|
|
1621
|
+
)
|
|
1622
|
+
) {
|
|
1623
|
+
catalog.workflows.delete(workflowId);
|
|
1624
|
+
catalog.diagnostics.push({
|
|
1625
|
+
level: 'error',
|
|
1626
|
+
path: workflow.sourcePath,
|
|
1627
|
+
message: `command "/${command}" conflicts with another loaded Pi resource`,
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
this.catalog = catalog;
|
|
1632
|
+
for (const workflow of catalog.workflows.values()) {
|
|
1633
|
+
this.pi.registerCommand(workflow.definition.command, {
|
|
1634
|
+
description: workflow.definition.description,
|
|
1635
|
+
handler: async (args, commandContext) =>
|
|
1636
|
+
this.start(workflow.definition.id, args, commandContext),
|
|
1637
|
+
});
|
|
1638
|
+
this.registeredWorkflowCommands.add(workflow.definition.command);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
if (announce) {
|
|
1642
|
+
const diagnosticText = formatDiagnostics(this.catalog);
|
|
1643
|
+
ctx.ui.notify(
|
|
1644
|
+
diagnosticText
|
|
1645
|
+
? `Loaded ${this.catalog.workflows.size} workflow(s)\n${diagnosticText}`
|
|
1646
|
+
: `Loaded ${this.catalog.workflows.size} workflow(s)`,
|
|
1647
|
+
diagnosticText ? 'warning' : 'info',
|
|
1648
|
+
);
|
|
1649
|
+
} else if (
|
|
1650
|
+
this.catalog.diagnostics.some((item) => item.level === 'error')
|
|
1651
|
+
) {
|
|
1652
|
+
ctx.ui.notify(
|
|
1653
|
+
`Workflow configuration errors:\n${formatDiagnostics(this.catalog)}`,
|
|
1654
|
+
'warning',
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
return true;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
private updateStatus(): void {
|
|
1661
|
+
if (!this.latestContext) return;
|
|
1662
|
+
if (!this.run) {
|
|
1663
|
+
this.latestContext.ui.setStatus(STATUS_KEY, undefined);
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
const delegation = this.activeDelegation
|
|
1667
|
+
? `; ${this.activeDelegation.agent}: ${this.activeDelegation.progress ?? 'starting'}`
|
|
1668
|
+
: this.mainSteps.activeStepId
|
|
1669
|
+
? '; main agent: running'
|
|
1670
|
+
: '';
|
|
1671
|
+
this.latestContext.ui.setStatus(
|
|
1672
|
+
STATUS_KEY,
|
|
1673
|
+
`${this.run.workflowId}: ${this.run.currentStepId} (${this.run.status}${delegation})`,
|
|
1674
|
+
);
|
|
1675
|
+
}
|
|
1676
|
+
}
|