@wichayutdew/pi-workflows 0.2.2 → 0.2.3
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 +21 -201
- package/README.md +191 -104
- package/agents/step.md +15 -1
- package/dist/index.js +1803 -463
- package/examples/mr-comments.workflow.yaml +4 -4
- package/examples/prompts/mr-comments/implement.md +10 -5
- package/examples/prompts/mr-comments/plan.md +10 -5
- package/examples/prompts/mr-comments/verify.md +5 -4
- package/examples/settings.yaml +3 -1
- package/package.json +10 -6
- package/schemas/settings.schema.json +8 -0
- package/schemas/workflow.schema.json +10 -2
- package/src/command-names.ts +0 -1
- package/src/commands.ts +0 -6
- package/src/config/ceiling.ts +8 -0
- package/src/config/load.ts +3 -9
- package/src/config/types.ts +15 -3
- package/src/config/validate.ts +147 -22
- package/src/engine/state.ts +7 -0
- package/src/engine/transitions.ts +52 -7
- package/src/harness.ts +701 -74
- package/src/index.ts +6 -2
- package/src/integrations/prompt-gate.ts +14 -13
- package/src/integrations/subagents/child-runtime.ts +187 -69
- package/src/integrations/subagents/client.ts +4 -3
- package/src/integrations/subagents/diagnostics.ts +799 -0
- package/src/integrations/subagents/protocol.ts +86 -15
- package/src/policy/approved-commands.ts +212 -5
- package/src/policy/bash.ts +0 -9
- package/src/prompt.ts +106 -7
- package/src/runtime/serial-task-queue.ts +5 -1
- package/src/workflow-status.ts +244 -35
package/src/harness.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import {
|
|
2
|
+
import { constants, mkdtempSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { lstat, open, rm } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import type {
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
ExtensionCommandContext,
|
|
9
9
|
ExtensionContext,
|
|
10
10
|
} from '@earendil-works/pi-coding-agent';
|
|
11
|
+
import type { KeyId } from '@earendil-works/pi-tui';
|
|
11
12
|
import {
|
|
12
13
|
registerHarnessCommands,
|
|
13
14
|
type WorkflowCommandController,
|
|
@@ -16,6 +17,7 @@ import { loadCatalog } from './config/load.ts';
|
|
|
16
17
|
import { hasRuntimeCommandConflict } from './config/command-conflicts.ts';
|
|
17
18
|
import {
|
|
18
19
|
DEFAULT_SETTINGS,
|
|
20
|
+
DEFAULT_STATUS_SHORTCUT,
|
|
19
21
|
type LoadedWorkflow,
|
|
20
22
|
type WorkflowCatalog,
|
|
21
23
|
type WorkflowStep,
|
|
@@ -28,6 +30,7 @@ import {
|
|
|
28
30
|
attachGateReviewId,
|
|
29
31
|
beginGate,
|
|
30
32
|
failGate,
|
|
33
|
+
failRun,
|
|
31
34
|
pauseRun,
|
|
32
35
|
reconcileRun,
|
|
33
36
|
resolveGate,
|
|
@@ -63,29 +66,66 @@ import {
|
|
|
63
66
|
type SubagentDelegationResponse,
|
|
64
67
|
type SubagentDelegationUpdate,
|
|
65
68
|
} from './integrations/subagents/protocol.ts';
|
|
69
|
+
import {
|
|
70
|
+
deriveSubagentSessionRoot,
|
|
71
|
+
failedToolName,
|
|
72
|
+
formatToolFailureDiagnostic,
|
|
73
|
+
readToolFailureDiagnostic,
|
|
74
|
+
type ToolFailureDiagnostic,
|
|
75
|
+
} from './integrations/subagents/diagnostics.ts';
|
|
66
76
|
import { preflightStep } from './preflight.ts';
|
|
67
77
|
import {
|
|
68
78
|
buildDelegatedStepTask,
|
|
69
79
|
buildMainStepTask,
|
|
70
80
|
buildMainWorkflowNotice,
|
|
81
|
+
toolRetryTask,
|
|
71
82
|
} from './prompt.ts';
|
|
72
|
-
import {
|
|
83
|
+
import {
|
|
84
|
+
narrowApprovedBashCommands,
|
|
85
|
+
resolveReviewedRepositoryCwd,
|
|
86
|
+
reviewedCommandShapeError,
|
|
87
|
+
} from './policy/approved-commands.ts';
|
|
73
88
|
import {
|
|
74
89
|
MainStepRuntime,
|
|
75
90
|
type MainStepExecution,
|
|
76
91
|
} from './runtime/main-step-runtime.ts';
|
|
92
|
+
import { WORKFLOW_COMPLETION_PARAMETERS } from './runtime/completion-tool.ts';
|
|
77
93
|
import { SerialTaskQueue } from './runtime/serial-task-queue.ts';
|
|
78
94
|
import type { WorkflowStepResult } from './runtime/step-result.ts';
|
|
79
95
|
import { formatWorkflowList } from './workflow-list.ts';
|
|
80
96
|
import {
|
|
81
|
-
|
|
82
|
-
showWorkflowStatus,
|
|
97
|
+
formatShortcutLabel,
|
|
98
|
+
showWorkflowStatus as showWorkflowStatusOverlay,
|
|
99
|
+
workflowStatusIcon,
|
|
83
100
|
type WorkflowStatusExecution,
|
|
84
101
|
type WorkflowStatusSnapshot,
|
|
85
102
|
} from './workflow-status.ts';
|
|
86
103
|
|
|
87
104
|
const STATE_ENTRY_TYPE = 'pi-workflows-state-v1';
|
|
88
105
|
const STATUS_KEY = 'pi-workflows';
|
|
106
|
+
const LEGACY_PROGRESS_WIDGET_KEY = 'pi-workflows-progress';
|
|
107
|
+
const STATUS_REFRESH_INTERVAL_MS = 250;
|
|
108
|
+
const MAX_TOOL_FAILURE_RETRIES = 1;
|
|
109
|
+
|
|
110
|
+
function isRetryableToolFailure(reason: string): boolean {
|
|
111
|
+
return failedToolName(reason) !== undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isSafeToRetryDelegation(
|
|
115
|
+
policy: ChildStepPolicy,
|
|
116
|
+
replayExplicitlyAuthorized: boolean,
|
|
117
|
+
diagnostic: ToolFailureDiagnostic | undefined,
|
|
118
|
+
): boolean {
|
|
119
|
+
const tools = new Set(policy.permissions.tools);
|
|
120
|
+
return (
|
|
121
|
+
diagnostic?.replaySafe === true &&
|
|
122
|
+
!tools.has('edit') &&
|
|
123
|
+
!tools.has('write') &&
|
|
124
|
+
(replayExplicitlyAuthorized ||
|
|
125
|
+
policy.permissions.bash.mode === 'deny' ||
|
|
126
|
+
policy.permissions.bash.mode === 'read-only')
|
|
127
|
+
);
|
|
128
|
+
}
|
|
89
129
|
|
|
90
130
|
interface ActiveDelegation {
|
|
91
131
|
requestId: string;
|
|
@@ -96,10 +136,244 @@ interface ActiveDelegation {
|
|
|
96
136
|
resultDirectory: string;
|
|
97
137
|
policy: ChildStepPolicy;
|
|
98
138
|
agent: string;
|
|
139
|
+
trustedSessionRoot?: string;
|
|
140
|
+
retryToolFailures: boolean;
|
|
141
|
+
toolFailureRetryCount: number;
|
|
142
|
+
retryDiagnostic?: ToolFailureDiagnostic;
|
|
99
143
|
progress?: string;
|
|
100
144
|
cancelling?: boolean;
|
|
101
145
|
}
|
|
102
146
|
|
|
147
|
+
const MAX_FAILURE_FIELD_CHARS = 1_600;
|
|
148
|
+
const MAX_DELEGATED_RESULT_BYTES = 1024 * 1024;
|
|
149
|
+
|
|
150
|
+
function boundedFailureField(value: string): string {
|
|
151
|
+
if (value.length <= MAX_FAILURE_FIELD_CHARS) return value;
|
|
152
|
+
const marker = '… [truncated] …';
|
|
153
|
+
const available = MAX_FAILURE_FIELD_CHARS - marker.length - 2;
|
|
154
|
+
const startLength = Math.ceil(available / 2);
|
|
155
|
+
const endLength = Math.floor(available / 2);
|
|
156
|
+
return `${value.slice(0, startLength)}\n${marker}\n${value.slice(-endLength)}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function readStableDelegatedResult(
|
|
160
|
+
active: ActiveDelegation,
|
|
161
|
+
): Promise<string> {
|
|
162
|
+
const expectedPath = join(active.resultDirectory, 'result.json');
|
|
163
|
+
if (active.policy.resultPath !== expectedPath) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
'delegated result path does not match its private directory',
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
const inspected = await lstat(expectedPath);
|
|
169
|
+
if (inspected.isSymbolicLink() || !inspected.isFile()) {
|
|
170
|
+
throw new Error('delegated result is not a regular file');
|
|
171
|
+
}
|
|
172
|
+
const handle = await open(
|
|
173
|
+
expectedPath,
|
|
174
|
+
constants.O_RDONLY | constants.O_NOFOLLOW,
|
|
175
|
+
);
|
|
176
|
+
try {
|
|
177
|
+
const beforeRead = await handle.stat();
|
|
178
|
+
if (!beforeRead.isFile() || beforeRead.size > MAX_DELEGATED_RESULT_BYTES) {
|
|
179
|
+
throw new Error('delegated result is not a bounded regular file');
|
|
180
|
+
}
|
|
181
|
+
const value = await handle.readFile({ encoding: 'utf8' });
|
|
182
|
+
const afterRead = await handle.stat();
|
|
183
|
+
if (
|
|
184
|
+
afterRead.dev !== beforeRead.dev ||
|
|
185
|
+
afterRead.ino !== beforeRead.ino ||
|
|
186
|
+
afterRead.size !== beforeRead.size ||
|
|
187
|
+
afterRead.mtimeMs !== beforeRead.mtimeMs
|
|
188
|
+
) {
|
|
189
|
+
throw new Error('delegated result changed while it was being read');
|
|
190
|
+
}
|
|
191
|
+
return value;
|
|
192
|
+
} finally {
|
|
193
|
+
await handle.close();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function rejectedRecoveryReason(
|
|
198
|
+
failure: DelegationFailureDetails,
|
|
199
|
+
error: unknown,
|
|
200
|
+
): string {
|
|
201
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
202
|
+
return `${failure.reason}\nRecovery rejected: ${boundedFailureField(detail)}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function completionMatchesResult(
|
|
206
|
+
diagnostic: NonNullable<DelegationFailureDetails['diagnostic']>,
|
|
207
|
+
result: WorkflowStepResult,
|
|
208
|
+
policy: ChildStepPolicy,
|
|
209
|
+
): boolean {
|
|
210
|
+
const value = diagnostic.completionValue;
|
|
211
|
+
if (!value) return false;
|
|
212
|
+
const expectedKeys = [
|
|
213
|
+
'outcome',
|
|
214
|
+
'summary',
|
|
215
|
+
...(result.artifact === undefined ? [] : ['artifact']),
|
|
216
|
+
].sort();
|
|
217
|
+
const actualKeys = Object.keys(value).sort();
|
|
218
|
+
if (
|
|
219
|
+
actualKeys.length !== expectedKeys.length ||
|
|
220
|
+
!actualKeys.every((key, index) => key === expectedKeys[index])
|
|
221
|
+
) {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
const completion = parseDelegatedStepResult(
|
|
226
|
+
{
|
|
227
|
+
...value,
|
|
228
|
+
version: 1,
|
|
229
|
+
policyDigest: policy.policyDigest,
|
|
230
|
+
},
|
|
231
|
+
policy,
|
|
232
|
+
);
|
|
233
|
+
return (
|
|
234
|
+
completion.outcome === result.outcome &&
|
|
235
|
+
completion.summary === result.summary &&
|
|
236
|
+
completion.artifact === result.artifact
|
|
237
|
+
);
|
|
238
|
+
} catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function recoveredProjectionError(
|
|
244
|
+
active: ActiveDelegation,
|
|
245
|
+
response: SubagentDelegationResponse,
|
|
246
|
+
diagnostic: NonNullable<DelegationFailureDetails['diagnostic']>,
|
|
247
|
+
): string | undefined {
|
|
248
|
+
if (response.agent !== active.agent) {
|
|
249
|
+
return `terminal agent identity is ${JSON.stringify(response.agent)}; expected ${JSON.stringify(active.agent)}`;
|
|
250
|
+
}
|
|
251
|
+
if (response.childIndex !== 0) {
|
|
252
|
+
return `terminal child index is ${JSON.stringify(response.childIndex)}; expected 0`;
|
|
253
|
+
}
|
|
254
|
+
if (
|
|
255
|
+
typeof response.exitCode !== 'number' ||
|
|
256
|
+
!Number.isSafeInteger(response.exitCode) ||
|
|
257
|
+
response.exitCode <= 0
|
|
258
|
+
) {
|
|
259
|
+
return `terminal exit code is ${JSON.stringify(response.exitCode)}; expected a positive safe integer`;
|
|
260
|
+
}
|
|
261
|
+
const execution = response.execution;
|
|
262
|
+
if (!execution) return 'terminal response has no execution projection';
|
|
263
|
+
if (execution.status !== 'failed' || execution.success !== false) {
|
|
264
|
+
return `execution projection is ${JSON.stringify({
|
|
265
|
+
status: execution.status,
|
|
266
|
+
success: execution.success,
|
|
267
|
+
})}; expected failed/false`;
|
|
268
|
+
}
|
|
269
|
+
if (execution.exitCode !== response.exitCode) {
|
|
270
|
+
return `execution exit code ${JSON.stringify(execution.exitCode)} does not match terminal exit code ${JSON.stringify(response.exitCode)}`;
|
|
271
|
+
}
|
|
272
|
+
if (
|
|
273
|
+
typeof response.error !== 'string' ||
|
|
274
|
+
!response.error ||
|
|
275
|
+
typeof execution.error !== 'string' ||
|
|
276
|
+
execution.error !== response.error
|
|
277
|
+
) {
|
|
278
|
+
return 'terminal and execution errors are missing or do not match exactly';
|
|
279
|
+
}
|
|
280
|
+
const warnings = response.warnings as unknown;
|
|
281
|
+
if (
|
|
282
|
+
warnings !== undefined &&
|
|
283
|
+
(!Array.isArray(warnings) ||
|
|
284
|
+
warnings.some(
|
|
285
|
+
(warning) => typeof warning !== 'string' || warning.trim().length > 0,
|
|
286
|
+
))
|
|
287
|
+
) {
|
|
288
|
+
return `terminal response contains warning evidence: ${JSON.stringify(warnings)}`;
|
|
289
|
+
}
|
|
290
|
+
if (
|
|
291
|
+
diagnostic.transcriptToolCount !== undefined &&
|
|
292
|
+
response.toolCount !== undefined &&
|
|
293
|
+
response.toolCount !== diagnostic.transcriptToolCount
|
|
294
|
+
) {
|
|
295
|
+
return `terminal tool count ${response.toolCount} does not match transcript tool count ${diagnostic.transcriptToolCount}`;
|
|
296
|
+
}
|
|
297
|
+
if (
|
|
298
|
+
diagnostic.transcriptTurnCount !== undefined &&
|
|
299
|
+
response.turns !== undefined &&
|
|
300
|
+
response.turns !== diagnostic.transcriptTurnCount
|
|
301
|
+
) {
|
|
302
|
+
return `terminal turn count ${response.turns} does not match transcript turn count ${diagnostic.transcriptTurnCount}`;
|
|
303
|
+
}
|
|
304
|
+
const toolFailure = response.error.match(
|
|
305
|
+
/^\s*([a-z][\w-]*) failed\s*\(exit\s+(\d+)\)\s*:/i,
|
|
306
|
+
);
|
|
307
|
+
if (!toolFailure) {
|
|
308
|
+
return 'terminal error is not a recognized "<tool> failed (exit N): <detail>" failure';
|
|
309
|
+
}
|
|
310
|
+
const terminalTool = toolFailure[1]!;
|
|
311
|
+
const terminalExitCode = Number(toolFailure[2]);
|
|
312
|
+
if (
|
|
313
|
+
terminalTool.toLowerCase() !== diagnostic.tool.toLowerCase() ||
|
|
314
|
+
terminalExitCode !== response.exitCode
|
|
315
|
+
) {
|
|
316
|
+
return `terminal tool/exit ${JSON.stringify({
|
|
317
|
+
tool: terminalTool,
|
|
318
|
+
exitCode: terminalExitCode,
|
|
319
|
+
})} does not match the correlated failure ${JSON.stringify({
|
|
320
|
+
tool: diagnostic.tool,
|
|
321
|
+
exitCode: response.exitCode,
|
|
322
|
+
})}`;
|
|
323
|
+
}
|
|
324
|
+
const unsafeFlag = (
|
|
325
|
+
[
|
|
326
|
+
['interrupted', execution.interrupted],
|
|
327
|
+
['timedOut', execution.timedOut],
|
|
328
|
+
['stopped', execution.stopped],
|
|
329
|
+
['detached', execution.detached],
|
|
330
|
+
] as const
|
|
331
|
+
).find(([, enabled]) => enabled === true)?.[0];
|
|
332
|
+
return unsafeFlag
|
|
333
|
+
? `execution projection reports ${unsafeFlag}=true`
|
|
334
|
+
: undefined;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
interface DelegationFailureDetails {
|
|
338
|
+
reason: string;
|
|
339
|
+
diagnostic?: Awaited<ReturnType<typeof readToolFailureDiagnostic>>;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function delegationFailureDetails(
|
|
343
|
+
active: ActiveDelegation,
|
|
344
|
+
response: SubagentDelegationResponse,
|
|
345
|
+
): Promise<DelegationFailureDetails> {
|
|
346
|
+
const error =
|
|
347
|
+
response.error ??
|
|
348
|
+
response.execution?.error ??
|
|
349
|
+
'The subagent returned no terminal error details.';
|
|
350
|
+
const diagnostic = await readToolFailureDiagnostic(
|
|
351
|
+
response.sessionFile,
|
|
352
|
+
active.trustedSessionRoot,
|
|
353
|
+
response.runId !== undefined && response.childIndex !== undefined
|
|
354
|
+
? { runId: response.runId, childIndex: response.childIndex }
|
|
355
|
+
: undefined,
|
|
356
|
+
failedToolName(error),
|
|
357
|
+
error,
|
|
358
|
+
);
|
|
359
|
+
const exitCode = response.exitCode ?? response.execution?.exitCode;
|
|
360
|
+
const reason = [
|
|
361
|
+
`Subagent "${active.agent}" ${response.status.replaceAll('_', ' ')}.`,
|
|
362
|
+
...(diagnostic ? formatToolFailureDiagnostic(diagnostic) : []),
|
|
363
|
+
...(exitCode !== undefined ? [`Subagent exit code: ${exitCode}`] : []),
|
|
364
|
+
`Terminal error: ${boundedFailureField(error)}`,
|
|
365
|
+
...(diagnostic && response.sessionFile
|
|
366
|
+
? [
|
|
367
|
+
`Diagnostic session: ${boundedFailureField(response.sessionFile.replaceAll(/\s+/g, ' '))}`,
|
|
368
|
+
]
|
|
369
|
+
: []),
|
|
370
|
+
].join('\n');
|
|
371
|
+
return {
|
|
372
|
+
reason,
|
|
373
|
+
...(diagnostic ? { diagnostic } : {}),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
103
377
|
interface MainStepIdentity {
|
|
104
378
|
runId: string;
|
|
105
379
|
stepId: string;
|
|
@@ -115,6 +389,12 @@ interface ActivePromptReview {
|
|
|
115
389
|
abortController: AbortController;
|
|
116
390
|
}
|
|
117
391
|
|
|
392
|
+
interface WorkflowStartContext {
|
|
393
|
+
context: ExtensionContext;
|
|
394
|
+
skills: () => readonly { name: string }[] | undefined;
|
|
395
|
+
waitForIdle: () => Promise<void>;
|
|
396
|
+
}
|
|
397
|
+
|
|
118
398
|
function emptyCatalog(): WorkflowCatalog {
|
|
119
399
|
return {
|
|
120
400
|
workflows: new Map(),
|
|
@@ -135,6 +415,30 @@ function formatDiagnostics(catalog: WorkflowCatalog): string {
|
|
|
135
415
|
].join('\n');
|
|
136
416
|
}
|
|
137
417
|
|
|
418
|
+
function skillNamesFromSystemPrompt(
|
|
419
|
+
systemPrompt: string,
|
|
420
|
+
): Array<{ name: string }> {
|
|
421
|
+
const sections = [
|
|
422
|
+
...systemPrompt.matchAll(
|
|
423
|
+
/<available_skills>([\s\S]*?)<\/available_skills>/g,
|
|
424
|
+
),
|
|
425
|
+
];
|
|
426
|
+
const section = sections.at(-1)?.[1] ?? '';
|
|
427
|
+
return [...section.matchAll(/<name>([^<]+)<\/name>/g)].map((match) => ({
|
|
428
|
+
name: match[1]!.trim(),
|
|
429
|
+
}));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function waitForEventContextIdle(ctx: ExtensionContext): Promise<void> {
|
|
433
|
+
const deadline = Date.now() + 30_000;
|
|
434
|
+
while (!ctx.isIdle()) {
|
|
435
|
+
if (Date.now() >= deadline) {
|
|
436
|
+
throw new Error('Timed out waiting for the interrupted Pi turn to stop');
|
|
437
|
+
}
|
|
438
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
138
442
|
export class WorkflowHarness implements WorkflowCommandController {
|
|
139
443
|
private readonly pi: ExtensionAPI;
|
|
140
444
|
private readonly subagents: SubagentDelegationClient;
|
|
@@ -150,12 +454,24 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
150
454
|
private registeredWorkflowCommands = new Set<string>();
|
|
151
455
|
private catalogLoadSequence = 0;
|
|
152
456
|
private readonly mutationQueue = new SerialTaskQueue();
|
|
153
|
-
|
|
154
|
-
|
|
457
|
+
private readonly statusShortcut: KeyId;
|
|
458
|
+
private readonly statusShortcutLabel: string;
|
|
459
|
+
private statusRefreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
460
|
+
private statusOverlayOpen = false;
|
|
461
|
+
private legacyProgressWidgetContext: ExtensionContext | undefined;
|
|
462
|
+
|
|
463
|
+
constructor(
|
|
464
|
+
pi: ExtensionAPI,
|
|
465
|
+
statusShortcut: KeyId = DEFAULT_STATUS_SHORTCUT,
|
|
466
|
+
) {
|
|
155
467
|
this.pi = pi;
|
|
468
|
+
this.statusShortcut = statusShortcut;
|
|
469
|
+
this.statusShortcutLabel = formatShortcutLabel(statusShortcut);
|
|
156
470
|
this.subagents = new SubagentDelegationClient(pi.events);
|
|
157
471
|
this.mainSteps = new MainStepRuntime(pi);
|
|
158
472
|
registerHarnessCommands(pi, this);
|
|
473
|
+
this.registerWorkflowStatusShortcut();
|
|
474
|
+
this.registerMultilineCommandInput();
|
|
159
475
|
this.registerLifecycle();
|
|
160
476
|
this.registerPolicy();
|
|
161
477
|
this.registerPlannotatorResults();
|
|
@@ -165,6 +481,49 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
165
481
|
return [...this.catalog.workflows.keys()].sort();
|
|
166
482
|
}
|
|
167
483
|
|
|
484
|
+
private registerMultilineCommandInput(): void {
|
|
485
|
+
this.pi.on('input', async (event, ctx) => {
|
|
486
|
+
if (
|
|
487
|
+
event.source === 'extension' ||
|
|
488
|
+
event.images?.length ||
|
|
489
|
+
!event.text.startsWith('/')
|
|
490
|
+
) {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const newline = event.text.indexOf('\n');
|
|
494
|
+
if (newline === -1) return;
|
|
495
|
+
const command = event.text.slice(1, newline).replace(/\r$/, '');
|
|
496
|
+
if (!this.registeredWorkflowCommands.has(command)) return;
|
|
497
|
+
const workflow = [...this.catalog.workflows.values()].find(
|
|
498
|
+
(candidate) => candidate.definition.command === command,
|
|
499
|
+
);
|
|
500
|
+
if (!workflow) return;
|
|
501
|
+
|
|
502
|
+
const input = event.text.slice(newline + 1);
|
|
503
|
+
const skills = skillNamesFromSystemPrompt(ctx.getSystemPrompt());
|
|
504
|
+
try {
|
|
505
|
+
await this.enqueueMutation(ctx, (sessionEpoch) =>
|
|
506
|
+
this.startNow(
|
|
507
|
+
workflow.definition.id,
|
|
508
|
+
input,
|
|
509
|
+
{
|
|
510
|
+
context: ctx,
|
|
511
|
+
skills: () => skills,
|
|
512
|
+
waitForIdle: () => waitForEventContextIdle(ctx),
|
|
513
|
+
},
|
|
514
|
+
sessionEpoch,
|
|
515
|
+
),
|
|
516
|
+
);
|
|
517
|
+
} catch (error) {
|
|
518
|
+
ctx.ui.notify(
|
|
519
|
+
`Cannot start workflow: ${error instanceof Error ? error.message : String(error)}`,
|
|
520
|
+
'error',
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
return { action: 'handled' as const };
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
168
527
|
async list(ctx: ExtensionCommandContext): Promise<void> {
|
|
169
528
|
const workflows = [...this.catalog.workflows.values()].sort((left, right) =>
|
|
170
529
|
left.definition.id.localeCompare(right.definition.id),
|
|
@@ -191,16 +550,26 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
191
550
|
ctx: ExtensionCommandContext,
|
|
192
551
|
): Promise<void> {
|
|
193
552
|
return this.enqueueMutation(ctx, (sessionEpoch) =>
|
|
194
|
-
this.startNow(
|
|
553
|
+
this.startNow(
|
|
554
|
+
workflowId,
|
|
555
|
+
input,
|
|
556
|
+
{
|
|
557
|
+
context: ctx,
|
|
558
|
+
skills: () => ctx.getSystemPromptOptions().skills,
|
|
559
|
+
waitForIdle: () => ctx.waitForIdle(),
|
|
560
|
+
},
|
|
561
|
+
sessionEpoch,
|
|
562
|
+
),
|
|
195
563
|
);
|
|
196
564
|
}
|
|
197
565
|
|
|
198
566
|
private async startNow(
|
|
199
567
|
workflowId: string,
|
|
200
568
|
input: string,
|
|
201
|
-
|
|
569
|
+
startContext: WorkflowStartContext,
|
|
202
570
|
sessionEpoch: number,
|
|
203
571
|
): Promise<void> {
|
|
572
|
+
const { context: ctx } = startContext;
|
|
204
573
|
if (this.activeDelegation) {
|
|
205
574
|
ctx.ui.notify(
|
|
206
575
|
`Cannot start a workflow while subagent "${this.activeDelegation.agent}" is still cancelling`,
|
|
@@ -221,7 +590,7 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
221
590
|
}
|
|
222
591
|
if (!ctx.isIdle()) {
|
|
223
592
|
ctx.abort();
|
|
224
|
-
await
|
|
593
|
+
await startContext.waitForIdle();
|
|
225
594
|
}
|
|
226
595
|
if (!this.sessionActive || this.sessionEpoch !== sessionEpoch) {
|
|
227
596
|
ctx.ui.notify(
|
|
@@ -231,7 +600,7 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
231
600
|
return;
|
|
232
601
|
}
|
|
233
602
|
|
|
234
|
-
this.captureSkills(
|
|
603
|
+
this.captureSkills(startContext.skills());
|
|
235
604
|
if (!(await this.reloadCatalog(ctx, false))) {
|
|
236
605
|
ctx.ui.notify(
|
|
237
606
|
'Workflow start was superseded by a newer configuration load',
|
|
@@ -271,6 +640,7 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
271
640
|
this.persist();
|
|
272
641
|
this.isolateMainSessionTools();
|
|
273
642
|
this.updateStatus();
|
|
643
|
+
this.openWorkflowStatus(ctx);
|
|
274
644
|
this.launchCurrentStep(workflow);
|
|
275
645
|
}
|
|
276
646
|
|
|
@@ -510,7 +880,7 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
510
880
|
|
|
511
881
|
const preflightErrors = this.preflight(workflow, this.run.currentStepId);
|
|
512
882
|
if (preflightErrors.length > 0) {
|
|
513
|
-
this.run =
|
|
883
|
+
this.run = failRun(
|
|
514
884
|
this.run,
|
|
515
885
|
`Step preflight failed: ${preflightErrors.join('; ')}`,
|
|
516
886
|
Date.now(),
|
|
@@ -591,19 +961,6 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
591
961
|
await this.reloadCatalog(ctx, true);
|
|
592
962
|
}
|
|
593
963
|
|
|
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
964
|
private workflowStatusSnapshot(): WorkflowStatusSnapshot | undefined {
|
|
608
965
|
if (!this.run) return undefined;
|
|
609
966
|
const workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
@@ -662,6 +1019,7 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
662
1019
|
if (this.run) this.restoreBaselineTools();
|
|
663
1020
|
this.run = undefined;
|
|
664
1021
|
this.latestContext = undefined;
|
|
1022
|
+
this.stopStatusRefresh();
|
|
665
1023
|
});
|
|
666
1024
|
}
|
|
667
1025
|
|
|
@@ -672,7 +1030,7 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
672
1030
|
if (!this.run || this.run.status !== 'running') return;
|
|
673
1031
|
const workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
674
1032
|
if (!workflow) {
|
|
675
|
-
this.run =
|
|
1033
|
+
this.run = failRun(
|
|
676
1034
|
this.run,
|
|
677
1035
|
'Workflow configuration disappeared; reload or restore it',
|
|
678
1036
|
Date.now(),
|
|
@@ -683,12 +1041,15 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
683
1041
|
return;
|
|
684
1042
|
}
|
|
685
1043
|
return {
|
|
686
|
-
systemPrompt: `${event.systemPrompt}\n\n${buildMainWorkflowNotice(workflow, this.run)}`,
|
|
1044
|
+
systemPrompt: `${event.systemPrompt}\n\n${buildMainWorkflowNotice(workflow, this.run, this.statusShortcutLabel)}`,
|
|
687
1045
|
};
|
|
688
1046
|
});
|
|
689
1047
|
}
|
|
690
1048
|
|
|
691
|
-
private launchCurrentStep(
|
|
1049
|
+
private launchCurrentStep(
|
|
1050
|
+
workflow: LoadedWorkflow,
|
|
1051
|
+
toolRetry?: { count: number; reason: string },
|
|
1052
|
+
): void {
|
|
692
1053
|
const run = this.run;
|
|
693
1054
|
if (
|
|
694
1055
|
!run ||
|
|
@@ -712,6 +1073,18 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
712
1073
|
return;
|
|
713
1074
|
}
|
|
714
1075
|
|
|
1076
|
+
const reviewedRepository = resolveReviewedRepositoryCwd(
|
|
1077
|
+
run.reviewedArtifact ?? '',
|
|
1078
|
+
);
|
|
1079
|
+
if (reviewedRepository.kind === 'invalid') {
|
|
1080
|
+
this.pauseForExecutionFailure('Subagent step', reviewedRepository.reason);
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
const delegationCwd =
|
|
1084
|
+
reviewedRepository.kind === 'resolved'
|
|
1085
|
+
? reviewedRepository.cwd
|
|
1086
|
+
: (this.latestContext?.cwd ?? process.cwd());
|
|
1087
|
+
const runtimeAgent = subagent.agent;
|
|
715
1088
|
const requestId = `${run.runId}:${run.currentStepId}:${randomUUID()}`;
|
|
716
1089
|
const resultDirectory = mkdtempSync(join(tmpdir(), 'pi-workflows-step-'));
|
|
717
1090
|
const capabilityPath = join(resultDirectory, 'capability');
|
|
@@ -722,25 +1095,38 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
722
1095
|
flag: 'wx',
|
|
723
1096
|
mode: 0o600,
|
|
724
1097
|
});
|
|
725
|
-
const
|
|
1098
|
+
const outcomes = allowedOutcomes(workflow, run);
|
|
1099
|
+
const outcomeSet = new Set(outcomes);
|
|
1100
|
+
const approvedBashCommands = narrowApprovedBashCommands(
|
|
726
1101
|
run.reviewedArtifact ?? '',
|
|
1102
|
+
run.stepHandoff ?? '',
|
|
727
1103
|
step.permissions.bash.approvedSources ?? [],
|
|
728
1104
|
);
|
|
1105
|
+
const repositoryPolicy =
|
|
1106
|
+
reviewedRepository.kind === 'resolved'
|
|
1107
|
+
? {
|
|
1108
|
+
repositoryCwd: reviewedRepository.repositoryCwd,
|
|
1109
|
+
...(reviewedRepository.bootstrapping
|
|
1110
|
+
? { bootstrapCwd: reviewedRepository.cwd }
|
|
1111
|
+
: {}),
|
|
1112
|
+
}
|
|
1113
|
+
: {};
|
|
729
1114
|
const policyDigest = digest({
|
|
730
1115
|
version: 1,
|
|
731
1116
|
requestId,
|
|
732
|
-
agent:
|
|
1117
|
+
agent: runtimeAgent,
|
|
733
1118
|
runId: run.runId,
|
|
734
1119
|
stepId: run.currentStepId,
|
|
735
1120
|
stepDigest: run.currentStepDigest,
|
|
736
1121
|
capabilityPath,
|
|
737
1122
|
resultPath,
|
|
738
1123
|
approvedBashCommands,
|
|
1124
|
+
...repositoryPolicy,
|
|
739
1125
|
});
|
|
740
1126
|
const policy: ChildStepPolicy = {
|
|
741
1127
|
version: 1,
|
|
742
1128
|
requestId,
|
|
743
|
-
agent:
|
|
1129
|
+
agent: runtimeAgent,
|
|
744
1130
|
workflowId: workflow.definition.id,
|
|
745
1131
|
runId: run.runId,
|
|
746
1132
|
stepId: run.currentStepId,
|
|
@@ -751,10 +1137,19 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
751
1137
|
resultPath,
|
|
752
1138
|
permissions: structuredClone(step.permissions),
|
|
753
1139
|
...(approvedBashCommands.length > 0 ? { approvedBashCommands } : {}),
|
|
754
|
-
|
|
1140
|
+
...repositoryPolicy,
|
|
1141
|
+
outcomes,
|
|
1142
|
+
pauseOutcomes: Object.entries(step.transitions)
|
|
1143
|
+
.filter(
|
|
1144
|
+
([outcome, target]) => target === '$pause' && outcomeSet.has(outcome),
|
|
1145
|
+
)
|
|
1146
|
+
.map(([outcome]) => outcome),
|
|
755
1147
|
summaryMaxChars: workflow.definition.summaryMaxChars,
|
|
756
1148
|
...(step.gate ? { gateSubmitOutcome: step.gate.submitOutcome } : {}),
|
|
757
1149
|
};
|
|
1150
|
+
const trustedSessionRoot = deriveSubagentSessionRoot(
|
|
1151
|
+
this.latestContext?.sessionManager.getSessionFile(),
|
|
1152
|
+
);
|
|
758
1153
|
const active: ActiveDelegation = {
|
|
759
1154
|
requestId,
|
|
760
1155
|
runId: run.runId,
|
|
@@ -764,24 +1159,33 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
764
1159
|
resultDirectory,
|
|
765
1160
|
policy,
|
|
766
1161
|
agent: subagent.agent,
|
|
1162
|
+
...(trustedSessionRoot ? { trustedSessionRoot } : {}),
|
|
1163
|
+
retryToolFailures: subagent.retryToolFailures,
|
|
1164
|
+
toolFailureRetryCount: toolRetry?.count ?? 0,
|
|
767
1165
|
};
|
|
768
1166
|
const request: SubagentDelegationRequest = {
|
|
769
1167
|
version: 1,
|
|
770
1168
|
requestId,
|
|
771
|
-
agent:
|
|
772
|
-
task:
|
|
773
|
-
|
|
774
|
-
|
|
1169
|
+
agent: runtimeAgent,
|
|
1170
|
+
task: [
|
|
1171
|
+
buildDelegatedStepTask(workflow, run, encodeChildPolicy(policy)),
|
|
1172
|
+
...(toolRetry ? [toolRetryTask(toolRetry.reason)] : []),
|
|
1173
|
+
].join('\n\n'),
|
|
1174
|
+
// Workflow steps are isolation boundaries. Never fork the parent or a
|
|
1175
|
+
// sibling step's transcript; pass only the explicit workflow handoff.
|
|
1176
|
+
context: 'fresh',
|
|
1177
|
+
cwd: delegationCwd,
|
|
775
1178
|
timeoutMs: subagent.timeoutMs,
|
|
776
1179
|
skill:
|
|
777
1180
|
step.permissions.skills.length > 0
|
|
778
1181
|
? [...step.permissions.skills]
|
|
779
1182
|
: false,
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
1183
|
+
output: false,
|
|
1184
|
+
outputSchema: WORKFLOW_COMPLETION_PARAMETERS as unknown as Record<
|
|
1185
|
+
string,
|
|
1186
|
+
unknown
|
|
1187
|
+
>,
|
|
1188
|
+
agentContract: { version: 1 },
|
|
785
1189
|
artifacts: subagent.artifacts,
|
|
786
1190
|
...(subagent.model ? { model: subagent.model } : {}),
|
|
787
1191
|
...(subagent.turnBudget
|
|
@@ -819,8 +1223,9 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
819
1223
|
run: WorkflowRun,
|
|
820
1224
|
step: WorkflowStep,
|
|
821
1225
|
): void {
|
|
822
|
-
const approvedBashCommands =
|
|
1226
|
+
const approvedBashCommands = narrowApprovedBashCommands(
|
|
823
1227
|
run.reviewedArtifact ?? '',
|
|
1228
|
+
run.stepHandoff ?? '',
|
|
824
1229
|
step.permissions.bash.approvedSources ?? [],
|
|
825
1230
|
);
|
|
826
1231
|
const identity: MainStepIdentity = {
|
|
@@ -1011,19 +1416,35 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
1011
1416
|
) {
|
|
1012
1417
|
return;
|
|
1013
1418
|
}
|
|
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
1419
|
const workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
1023
1420
|
const step = workflow?.definition.steps[this.run.currentStepId];
|
|
1024
1421
|
if (!workflow || !step) {
|
|
1025
1422
|
throw new Error('Active workflow configuration is unavailable');
|
|
1026
1423
|
}
|
|
1424
|
+
let recoveredTerminalFailure: DelegationFailureDetails | undefined;
|
|
1425
|
+
if (response.status !== 'completed') {
|
|
1426
|
+
const failure = await delegationFailureDetails(active, response);
|
|
1427
|
+
if (failure.diagnostic) {
|
|
1428
|
+
active.retryDiagnostic = failure.diagnostic;
|
|
1429
|
+
} else {
|
|
1430
|
+
delete active.retryDiagnostic;
|
|
1431
|
+
}
|
|
1432
|
+
if (
|
|
1433
|
+
response.status !== 'failed' ||
|
|
1434
|
+
failure.diagnostic?.completionAfterFailure !== true
|
|
1435
|
+
) {
|
|
1436
|
+
throw new Error(failure.reason);
|
|
1437
|
+
}
|
|
1438
|
+
const projectionError = recoveredProjectionError(
|
|
1439
|
+
active,
|
|
1440
|
+
response,
|
|
1441
|
+
failure.diagnostic,
|
|
1442
|
+
);
|
|
1443
|
+
if (projectionError) {
|
|
1444
|
+
throw new Error(rejectedRecoveryReason(failure, projectionError));
|
|
1445
|
+
}
|
|
1446
|
+
recoveredTerminalFailure = failure;
|
|
1447
|
+
}
|
|
1027
1448
|
const requiredSkillWarning =
|
|
1028
1449
|
step.requires.skills.length > 0
|
|
1029
1450
|
? response.warnings?.find((warning) => /skill/i.test(warning))
|
|
@@ -1034,10 +1455,65 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
1034
1455
|
);
|
|
1035
1456
|
}
|
|
1036
1457
|
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1458
|
+
let serializedResult: string;
|
|
1459
|
+
try {
|
|
1460
|
+
serializedResult = await readStableDelegatedResult(active);
|
|
1461
|
+
} catch (error) {
|
|
1462
|
+
if (recoveredTerminalFailure) {
|
|
1463
|
+
throw new Error(
|
|
1464
|
+
rejectedRecoveryReason(recoveredTerminalFailure, error),
|
|
1465
|
+
{ cause: error },
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
if (
|
|
1469
|
+
(error as { code?: unknown } | null | undefined)?.code === 'ENOENT'
|
|
1470
|
+
) {
|
|
1471
|
+
throw new Error(
|
|
1472
|
+
`Subagent "${active.agent}" completed without producing the required correlated structured_output result`,
|
|
1473
|
+
{ cause: error },
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
throw error;
|
|
1477
|
+
}
|
|
1478
|
+
let result: WorkflowStepResult;
|
|
1479
|
+
try {
|
|
1480
|
+
const rawResult = JSON.parse(serializedResult) as unknown;
|
|
1481
|
+
result = parseDelegatedStepResult(rawResult, active.policy);
|
|
1482
|
+
} catch (error) {
|
|
1483
|
+
if (recoveredTerminalFailure) {
|
|
1484
|
+
throw new Error(
|
|
1485
|
+
rejectedRecoveryReason(recoveredTerminalFailure, error),
|
|
1486
|
+
{ cause: error },
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
throw error;
|
|
1490
|
+
}
|
|
1491
|
+
if (
|
|
1492
|
+
recoveredTerminalFailure?.diagnostic &&
|
|
1493
|
+
!completionMatchesResult(
|
|
1494
|
+
recoveredTerminalFailure.diagnostic,
|
|
1495
|
+
result,
|
|
1496
|
+
active.policy,
|
|
1497
|
+
)
|
|
1498
|
+
) {
|
|
1499
|
+
throw new Error(
|
|
1500
|
+
rejectedRecoveryReason(
|
|
1501
|
+
recoveredTerminalFailure,
|
|
1502
|
+
'structured_output transcript value does not match the correlated result',
|
|
1503
|
+
),
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
if (recoveredTerminalFailure) {
|
|
1507
|
+
const falsePositive =
|
|
1508
|
+
recoveredTerminalFailure.diagnostic?.correlation ===
|
|
1509
|
+
'successful-output-before-completion';
|
|
1510
|
+
this.latestContext?.ui.notify(
|
|
1511
|
+
falsePositive
|
|
1512
|
+
? `Accepted "${active.stepId}" because the trusted child transcript proved the terminal tool error was a false positive and produced a matching structured result`
|
|
1513
|
+
: `Accepted "${active.stepId}" because the child resolved an earlier tool failure and produced a valid structured result`,
|
|
1514
|
+
'warning',
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1041
1517
|
if (step.gate?.submitOutcome === result.outcome) {
|
|
1042
1518
|
await this.submitGate(
|
|
1043
1519
|
workflow,
|
|
@@ -1057,9 +1533,10 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
1057
1533
|
);
|
|
1058
1534
|
this.settleAfterTransition(workflow);
|
|
1059
1535
|
} catch (error) {
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1536
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1537
|
+
if (!this.retryDelegationAfterToolFailure(active, reason)) {
|
|
1538
|
+
this.pauseForDelegationFailure(reason);
|
|
1539
|
+
}
|
|
1063
1540
|
} finally {
|
|
1064
1541
|
await this.cleanupDelegation(active);
|
|
1065
1542
|
if (active.cancelling) this.releaseMainAfterCancellation(active);
|
|
@@ -1098,6 +1575,43 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
1098
1575
|
await rm(active.resultDirectory, { recursive: true, force: true });
|
|
1099
1576
|
}
|
|
1100
1577
|
|
|
1578
|
+
private retryDelegationAfterToolFailure(
|
|
1579
|
+
active: ActiveDelegation,
|
|
1580
|
+
reason: string,
|
|
1581
|
+
): boolean {
|
|
1582
|
+
if (
|
|
1583
|
+
active.toolFailureRetryCount >= MAX_TOOL_FAILURE_RETRIES ||
|
|
1584
|
+
!isRetryableToolFailure(reason) ||
|
|
1585
|
+
!isSafeToRetryDelegation(
|
|
1586
|
+
active.policy,
|
|
1587
|
+
active.retryToolFailures,
|
|
1588
|
+
active.retryDiagnostic,
|
|
1589
|
+
) ||
|
|
1590
|
+
!this.sessionActive ||
|
|
1591
|
+
this.sessionEpoch !== active.sessionEpoch ||
|
|
1592
|
+
!this.run ||
|
|
1593
|
+
this.run.status !== 'running' ||
|
|
1594
|
+
this.run.runId !== active.runId ||
|
|
1595
|
+
this.run.currentStepId !== active.stepId ||
|
|
1596
|
+
this.run.currentStepDigest !== active.stepDigest ||
|
|
1597
|
+
this.activeDelegation
|
|
1598
|
+
) {
|
|
1599
|
+
return false;
|
|
1600
|
+
}
|
|
1601
|
+
const workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
1602
|
+
if (!workflow) return false;
|
|
1603
|
+
|
|
1604
|
+
this.latestContext?.ui.notify(
|
|
1605
|
+
`Retrying "${active.stepId}" after a tool failure (${active.toolFailureRetryCount + 1}/${MAX_TOOL_FAILURE_RETRIES})`,
|
|
1606
|
+
'warning',
|
|
1607
|
+
);
|
|
1608
|
+
this.launchCurrentStep(workflow, {
|
|
1609
|
+
count: active.toolFailureRetryCount + 1,
|
|
1610
|
+
reason,
|
|
1611
|
+
});
|
|
1612
|
+
return true;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1101
1615
|
private pauseForDelegationFailure(reason: string): void {
|
|
1102
1616
|
this.pauseForExecutionFailure('Subagent step', reason);
|
|
1103
1617
|
}
|
|
@@ -1105,7 +1619,7 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
1105
1619
|
private pauseForExecutionFailure(label: string, reason: string): void {
|
|
1106
1620
|
if (!this.run || this.run.status !== 'running') return;
|
|
1107
1621
|
this.mainSteps.deactivate();
|
|
1108
|
-
this.run =
|
|
1622
|
+
this.run = failRun(this.run, `${label} failed: ${reason}`, Date.now());
|
|
1109
1623
|
this.persist();
|
|
1110
1624
|
if (this.activeDelegation) {
|
|
1111
1625
|
this.isolateMainSessionTools();
|
|
@@ -1126,7 +1640,7 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
1126
1640
|
active.cancelling = true;
|
|
1127
1641
|
active.progress = 'cancellation unconfirmed';
|
|
1128
1642
|
if (this.run?.status === 'running') {
|
|
1129
|
-
this.run =
|
|
1643
|
+
this.run = failRun(
|
|
1130
1644
|
this.run,
|
|
1131
1645
|
`Subagent step failed: ${reason}`,
|
|
1132
1646
|
Date.now(),
|
|
@@ -1169,6 +1683,34 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
1169
1683
|
const step = workflow.definition.steps[originalRun.currentStepId];
|
|
1170
1684
|
if (!step?.gate) throw new Error('Current step has no gate');
|
|
1171
1685
|
|
|
1686
|
+
const commandShapeError = reviewedCommandShapeError(artifact);
|
|
1687
|
+
if (commandShapeError) {
|
|
1688
|
+
const awaitingReview = beginGate(
|
|
1689
|
+
workflow,
|
|
1690
|
+
originalRun,
|
|
1691
|
+
outcome,
|
|
1692
|
+
artifact,
|
|
1693
|
+
requestId,
|
|
1694
|
+
Date.now(),
|
|
1695
|
+
);
|
|
1696
|
+
this.run = resolveGate(
|
|
1697
|
+
workflow,
|
|
1698
|
+
awaitingReview,
|
|
1699
|
+
{
|
|
1700
|
+
approved: false,
|
|
1701
|
+
feedback: commandShapeError,
|
|
1702
|
+
resolvedAt: Date.now(),
|
|
1703
|
+
},
|
|
1704
|
+
Date.now(),
|
|
1705
|
+
);
|
|
1706
|
+
this.latestContext?.ui.notify(
|
|
1707
|
+
`Plan contract needs repair before review: ${commandShapeError}`,
|
|
1708
|
+
'warning',
|
|
1709
|
+
);
|
|
1710
|
+
this.settleAfterTransition(workflow);
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1172
1714
|
this.run = beginGate(
|
|
1173
1715
|
workflow,
|
|
1174
1716
|
originalRun,
|
|
@@ -1213,10 +1755,7 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
1213
1755
|
if (response.status !== 'handled') {
|
|
1214
1756
|
const reason = response.error ?? 'Plannotator is unavailable';
|
|
1215
1757
|
const gateFailed = failGate(this.run, reason, Date.now());
|
|
1216
|
-
this.run =
|
|
1217
|
-
this.run.status === 'paused'
|
|
1218
|
-
? pauseRun(gateFailed, reason, Date.now())
|
|
1219
|
-
: gateFailed;
|
|
1758
|
+
this.run = failRun(gateFailed, reason, Date.now());
|
|
1220
1759
|
this.persist();
|
|
1221
1760
|
if (this.run.status === 'running') {
|
|
1222
1761
|
this.isolateMainSessionTools();
|
|
@@ -1250,6 +1789,7 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
1250
1789
|
this.pausePromptGate(
|
|
1251
1790
|
pendingGate.requestId,
|
|
1252
1791
|
'Built-in review requires Pi TUI or RPC mode; resume there to continue',
|
|
1792
|
+
false,
|
|
1253
1793
|
);
|
|
1254
1794
|
return;
|
|
1255
1795
|
}
|
|
@@ -1304,6 +1844,7 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
1304
1844
|
this.pausePromptGate(
|
|
1305
1845
|
active.requestId,
|
|
1306
1846
|
`Built-in review failed: ${reason}`,
|
|
1847
|
+
true,
|
|
1307
1848
|
);
|
|
1308
1849
|
})
|
|
1309
1850
|
.catch((error: unknown) => {
|
|
@@ -1335,6 +1876,7 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
1335
1876
|
this.pausePromptGate(
|
|
1336
1877
|
active.requestId,
|
|
1337
1878
|
'Built-in review was dismissed; resume to reopen it',
|
|
1879
|
+
false,
|
|
1338
1880
|
);
|
|
1339
1881
|
return;
|
|
1340
1882
|
}
|
|
@@ -1360,6 +1902,7 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
1360
1902
|
this.pausePromptGate(
|
|
1361
1903
|
active.requestId,
|
|
1362
1904
|
'Built-in review finished, but workflow configuration is unavailable',
|
|
1905
|
+
true,
|
|
1363
1906
|
);
|
|
1364
1907
|
return;
|
|
1365
1908
|
}
|
|
@@ -1370,11 +1913,16 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
1370
1913
|
this.pausePromptGate(
|
|
1371
1914
|
active.requestId,
|
|
1372
1915
|
`Cannot apply built-in review: ${error instanceof Error ? error.message : String(error)}`,
|
|
1916
|
+
true,
|
|
1373
1917
|
);
|
|
1374
1918
|
}
|
|
1375
1919
|
}
|
|
1376
1920
|
|
|
1377
|
-
private pausePromptGate(
|
|
1921
|
+
private pausePromptGate(
|
|
1922
|
+
requestId: string,
|
|
1923
|
+
reason: string,
|
|
1924
|
+
failed: boolean,
|
|
1925
|
+
): void {
|
|
1378
1926
|
if (
|
|
1379
1927
|
!this.run ||
|
|
1380
1928
|
this.run.pendingGate?.provider !== 'prompt' ||
|
|
@@ -1383,7 +1931,9 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
1383
1931
|
return;
|
|
1384
1932
|
}
|
|
1385
1933
|
if (this.run.status === 'awaiting-gate') {
|
|
1386
|
-
this.run =
|
|
1934
|
+
this.run = failed
|
|
1935
|
+
? failRun(this.run, reason, Date.now())
|
|
1936
|
+
: pauseRun(this.run, reason, Date.now());
|
|
1387
1937
|
}
|
|
1388
1938
|
this.persist();
|
|
1389
1939
|
this.restoreBaselineTools();
|
|
@@ -1445,7 +1995,7 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
1445
1995
|
|
|
1446
1996
|
const workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
1447
1997
|
if (!workflow) {
|
|
1448
|
-
this.run =
|
|
1998
|
+
this.run = failRun(
|
|
1449
1999
|
this.run,
|
|
1450
2000
|
'Gate result arrived, but workflow configuration is unavailable',
|
|
1451
2001
|
Date.now(),
|
|
@@ -1459,7 +2009,7 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
1459
2009
|
this.run = resolveGate(workflow, this.run, resolution, Date.now());
|
|
1460
2010
|
this.settleAfterTransition(workflow);
|
|
1461
2011
|
} catch (error) {
|
|
1462
|
-
this.run =
|
|
2012
|
+
this.run = failRun(
|
|
1463
2013
|
this.run,
|
|
1464
2014
|
`Cannot apply gate result: ${error instanceof Error ? error.message : String(error)}`,
|
|
1465
2015
|
Date.now(),
|
|
@@ -1475,7 +2025,7 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
1475
2025
|
if (this.run.status === 'running') {
|
|
1476
2026
|
const preflightErrors = this.preflight(workflow, this.run.currentStepId);
|
|
1477
2027
|
if (preflightErrors.length > 0) {
|
|
1478
|
-
this.run =
|
|
2028
|
+
this.run = failRun(
|
|
1479
2029
|
this.run,
|
|
1480
2030
|
`Step preflight failed: ${preflightErrors.join('; ')}`,
|
|
1481
2031
|
Date.now(),
|
|
@@ -1610,6 +2160,15 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
1610
2160
|
return false;
|
|
1611
2161
|
}
|
|
1612
2162
|
this.latestContext = ctx;
|
|
2163
|
+
if (catalog.settings.statusShortcut !== this.statusShortcut) {
|
|
2164
|
+
catalog.diagnostics.push({
|
|
2165
|
+
level: 'warning',
|
|
2166
|
+
path: join(catalog.userDirectory, 'settings.yaml'),
|
|
2167
|
+
message:
|
|
2168
|
+
`settings.statusShortcut is "${catalog.settings.statusShortcut}", ` +
|
|
2169
|
+
`but the active shortcut is "${this.statusShortcut}"; run Pi /reload to apply shortcut changes`,
|
|
2170
|
+
});
|
|
2171
|
+
}
|
|
1613
2172
|
const availableCommands = this.pi.getCommands();
|
|
1614
2173
|
for (const [workflowId, workflow] of catalog.workflows) {
|
|
1615
2174
|
const command = workflow.definition.command;
|
|
@@ -1658,19 +2217,87 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
1658
2217
|
}
|
|
1659
2218
|
|
|
1660
2219
|
private updateStatus(): void {
|
|
2220
|
+
this.refreshStatusWhileRunning();
|
|
1661
2221
|
if (!this.latestContext) return;
|
|
2222
|
+
if (this.legacyProgressWidgetContext !== this.latestContext) {
|
|
2223
|
+
this.latestContext.ui.setWidget(LEGACY_PROGRESS_WIDGET_KEY, undefined);
|
|
2224
|
+
this.legacyProgressWidgetContext = this.latestContext;
|
|
2225
|
+
}
|
|
1662
2226
|
if (!this.run) {
|
|
1663
2227
|
this.latestContext.ui.setStatus(STATUS_KEY, undefined);
|
|
1664
2228
|
return;
|
|
1665
2229
|
}
|
|
1666
|
-
const
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
2230
|
+
const snapshot = this.workflowStatusSnapshot();
|
|
2231
|
+
if (this.run.status !== 'running') {
|
|
2232
|
+
this.latestContext.ui.setStatus(STATUS_KEY, undefined);
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
1671
2235
|
this.latestContext.ui.setStatus(
|
|
1672
2236
|
STATUS_KEY,
|
|
1673
|
-
`${this.run
|
|
2237
|
+
`${workflowStatusIcon(this.run, snapshot?.now)} ${this.run.workflowId}: working · ${this.statusShortcutLabel}`,
|
|
1674
2238
|
);
|
|
1675
2239
|
}
|
|
2240
|
+
|
|
2241
|
+
private refreshStatusWhileRunning(): void {
|
|
2242
|
+
if (this.run?.status === 'running' && this.latestContext) {
|
|
2243
|
+
if (this.statusRefreshTimer) return;
|
|
2244
|
+
this.statusRefreshTimer = setInterval(
|
|
2245
|
+
() => this.updateStatus(),
|
|
2246
|
+
STATUS_REFRESH_INTERVAL_MS,
|
|
2247
|
+
);
|
|
2248
|
+
this.statusRefreshTimer.unref?.();
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
this.stopStatusRefresh();
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
private stopStatusRefresh(): void {
|
|
2255
|
+
if (this.statusRefreshTimer) clearInterval(this.statusRefreshTimer);
|
|
2256
|
+
this.statusRefreshTimer = undefined;
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
private registerWorkflowStatusShortcut(): void {
|
|
2260
|
+
this.pi.registerShortcut(this.statusShortcut, {
|
|
2261
|
+
description: 'Toggle workflow status',
|
|
2262
|
+
handler: async (ctx) => {
|
|
2263
|
+
this.latestContext = ctx;
|
|
2264
|
+
if (this.statusOverlayOpen) return;
|
|
2265
|
+
if (!this.run) {
|
|
2266
|
+
ctx.ui.notify('No workflow checkpoint in this session', 'info');
|
|
2267
|
+
return;
|
|
2268
|
+
}
|
|
2269
|
+
await this.showWorkflowStatus(ctx);
|
|
2270
|
+
},
|
|
2271
|
+
});
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
private openWorkflowStatus(ctx: ExtensionContext): void {
|
|
2275
|
+
void this.showWorkflowStatus(ctx).catch((error: unknown) => {
|
|
2276
|
+
ctx.ui.notify(
|
|
2277
|
+
`Cannot open workflow status: ${error instanceof Error ? error.message : String(error)}`,
|
|
2278
|
+
'error',
|
|
2279
|
+
);
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
private async showWorkflowStatus(ctx: ExtensionContext): Promise<void> {
|
|
2284
|
+
if (
|
|
2285
|
+
this.statusOverlayOpen ||
|
|
2286
|
+
!this.run ||
|
|
2287
|
+
!ctx.hasUI ||
|
|
2288
|
+
ctx.mode !== 'tui'
|
|
2289
|
+
) {
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
this.statusOverlayOpen = true;
|
|
2293
|
+
try {
|
|
2294
|
+
await showWorkflowStatusOverlay(
|
|
2295
|
+
ctx,
|
|
2296
|
+
() => this.workflowStatusSnapshot(),
|
|
2297
|
+
this.statusShortcut,
|
|
2298
|
+
);
|
|
2299
|
+
} finally {
|
|
2300
|
+
this.statusOverlayOpen = false;
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
1676
2303
|
}
|