@henryqw/pi-subagent 15.1.3 → 16.0.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/CONTEXT.md +7 -10
- package/README.md +23 -54
- package/dist/index.d.ts +2 -1
- package/dist/index.js +9 -1
- package/dist/review-evidence.d.ts +1 -1
- package/dist/review-evidence.js +1 -1
- package/dist/worktree.d.ts +1 -1
- package/dist/worktree.js +5 -2
- package/docs/adr/001-composable-ephemeral-execution.md +2 -2
- package/docs/orchestration.md +10 -38
- package/examples/roles/implementer.md +5 -5
- package/examples/roles/reviewer.md +3 -6
- package/extensions/role-tools.ts +33 -1
- package/extensions/subagent.ts +6 -37
- package/extensions/task-name.ts +0 -2
- package/package.json +1 -5
- package/docs/adr/002-package-owned-delegate-flow-orchestration.md +0 -22
- package/docs/delegate-flow.html +0 -202
- package/docs/delegate-flow.svg +0 -181
- package/extensions/delegate-flow.ts +0 -932
- package/skills/pi-subagent-delegated-development/SKILL.md +0 -61
|
@@ -1,932 +0,0 @@
|
|
|
1
|
-
import { StringEnum, type Usage } from "@earendil-works/pi-ai";
|
|
2
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { PROFILE_NAMES } from "@henryqw/pi-task-models";
|
|
4
|
-
import {
|
|
5
|
-
addUsage,
|
|
6
|
-
capEphemeralSubagentOutput as capOutput,
|
|
7
|
-
createChildWorktree,
|
|
8
|
-
EphemeralSubagentError,
|
|
9
|
-
inspectIndexFlags,
|
|
10
|
-
inspectWorktreeDirty,
|
|
11
|
-
prepareExactReviewEvidence,
|
|
12
|
-
WorktreeSetupError,
|
|
13
|
-
type EphemeralSubagentActivityEvent,
|
|
14
|
-
type EphemeralSubagentExecutor,
|
|
15
|
-
type EphemeralSubagentResult,
|
|
16
|
-
type ResolvedRoleLaunch,
|
|
17
|
-
type Role,
|
|
18
|
-
type WorktreeInfo,
|
|
19
|
-
} from "@henryqw/pi-subagent";
|
|
20
|
-
import { Type, type Static } from "typebox";
|
|
21
|
-
import { Check } from "typebox/value";
|
|
22
|
-
import { MODEL_CLASS_GUIDANCE } from "./model-class-policy.ts";
|
|
23
|
-
import { TASK_NAME_CONTRACT, TaskNameSchema, normalizeTaskName } from "./task-name.ts";
|
|
24
|
-
|
|
25
|
-
const MAX_UNITS = 8;
|
|
26
|
-
const GIT_TIMEOUT_MS = 30_000;
|
|
27
|
-
const TRUNCATED_OUTPUT = /\n\n\[Output truncated: \d+ bytes omitted\]$/;
|
|
28
|
-
|
|
29
|
-
const ValidationSchema = Type.Object({
|
|
30
|
-
command: Type.String({ minLength: 1 }),
|
|
31
|
-
args: Type.Array(Type.String()),
|
|
32
|
-
}, { additionalProperties: false });
|
|
33
|
-
|
|
34
|
-
const ModelClassSchema = StringEnum(PROFILE_NAMES, { description: "Task model profile" });
|
|
35
|
-
|
|
36
|
-
const UnitSchema = Type.Object({
|
|
37
|
-
id: Type.String({ minLength: 1 }),
|
|
38
|
-
name: TaskNameSchema,
|
|
39
|
-
task: Type.String({ minLength: 1 }),
|
|
40
|
-
validation: Type.Array(ValidationSchema, { minItems: 1 }),
|
|
41
|
-
modelClass: Type.Optional(ModelClassSchema),
|
|
42
|
-
review: Type.Optional(Type.String({ minLength: 1 })),
|
|
43
|
-
}, { additionalProperties: false });
|
|
44
|
-
|
|
45
|
-
export const DelegateFlowSchema = Type.Object({
|
|
46
|
-
units: Type.Array(UnitSchema, { minItems: 1, maxItems: MAX_UNITS }),
|
|
47
|
-
}, { additionalProperties: false });
|
|
48
|
-
|
|
49
|
-
export const DelegateFlowContinueSchema = Type.Object({
|
|
50
|
-
guidance: Type.String({ minLength: 1 }),
|
|
51
|
-
modelClass: Type.Optional(ModelClassSchema),
|
|
52
|
-
}, { additionalProperties: false });
|
|
53
|
-
|
|
54
|
-
type FlowRequest = Static<typeof DelegateFlowSchema>;
|
|
55
|
-
type FlowUnitRequest = Static<typeof UnitSchema>;
|
|
56
|
-
type FlowModelClass = FlowUnitRequest["modelClass"];
|
|
57
|
-
type FlowClassification = "setup" | "implementer" | "validation" | "reviewer_findings" | "main" | "infrastructure" | "integration";
|
|
58
|
-
type FlowPhase = "running" | "blocked";
|
|
59
|
-
type WidgetStatus = "success" | "failure" | "aborted";
|
|
60
|
-
|
|
61
|
-
type ChildSettlement =
|
|
62
|
-
| { result: EphemeralSubagentResult }
|
|
63
|
-
| { error: unknown };
|
|
64
|
-
|
|
65
|
-
type UnitState = {
|
|
66
|
-
request: FlowUnitRequest;
|
|
67
|
-
modelClass: FlowModelClass;
|
|
68
|
-
widgetTaskId: string;
|
|
69
|
-
worktree: WorktreeInfo;
|
|
70
|
-
base: string;
|
|
71
|
-
implementation?: ChildSettlement;
|
|
72
|
-
repairUsed: boolean;
|
|
73
|
-
worktreeRetained: boolean;
|
|
74
|
-
branchRetained: boolean;
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
type MainState = {
|
|
78
|
-
root: string;
|
|
79
|
-
branchRef: string;
|
|
80
|
-
expectedHead: string;
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
type BlockedState = {
|
|
84
|
-
unit: UnitState;
|
|
85
|
-
classification: Exclude<FlowClassification, "setup" | "main" | "infrastructure" | "integration">;
|
|
86
|
-
diagnostic: string;
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
type SetupRecovery = {
|
|
90
|
-
id: string;
|
|
91
|
-
path: string;
|
|
92
|
-
branch: string;
|
|
93
|
-
base: string;
|
|
94
|
-
diagnostic: string;
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
type FlowState = {
|
|
98
|
-
phase: FlowPhase;
|
|
99
|
-
generation: number;
|
|
100
|
-
sessionController: AbortController;
|
|
101
|
-
implementer: Role;
|
|
102
|
-
reviewer?: Role;
|
|
103
|
-
main?: MainState;
|
|
104
|
-
units: UnitState[];
|
|
105
|
-
setupRecoveries: SetupRecovery[];
|
|
106
|
-
index: number;
|
|
107
|
-
blocked?: BlockedState;
|
|
108
|
-
completed: Array<{ id: string; noOp: boolean }>;
|
|
109
|
-
warnings: string[];
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
type UsageMeter = { usage?: Usage };
|
|
113
|
-
|
|
114
|
-
type CommandResult = {
|
|
115
|
-
stdout: string;
|
|
116
|
-
stderr: string;
|
|
117
|
-
code: number;
|
|
118
|
-
killed: boolean;
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
export interface DelegateFlowRuntime {
|
|
122
|
-
executor: EphemeralSubagentExecutor;
|
|
123
|
-
maxRuntimeMs: number;
|
|
124
|
-
getSessionGeneration: () => number;
|
|
125
|
-
loadRoles: () => Role[];
|
|
126
|
-
resolveLaunch: (role: Role, modelClass: FlowModelClass, ctx: ExtensionContext) => ResolvedRoleLaunch;
|
|
127
|
-
startWidget: (
|
|
128
|
-
id: string,
|
|
129
|
-
taskId: string,
|
|
130
|
-
role: string,
|
|
131
|
-
model: string,
|
|
132
|
-
thinkingLevel: string | undefined,
|
|
133
|
-
name: string,
|
|
134
|
-
ctx: ExtensionContext,
|
|
135
|
-
) => void;
|
|
136
|
-
setWidgetTaskRetained: (taskId: string, retained: boolean) => void;
|
|
137
|
-
updateWidgetTokens: (id: string, tokens: number) => void;
|
|
138
|
-
updateWidgetActivity: (id: string, event: EphemeralSubagentActivityEvent) => void;
|
|
139
|
-
finishWidget: (id: string, status: WidgetStatus) => void;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function text(value: string, field: string): string {
|
|
143
|
-
const normalized = value.trim();
|
|
144
|
-
if (!normalized || value.includes("\0")) throw new Error(`${field} must be non-empty text without NUL bytes.`);
|
|
145
|
-
return normalized;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function argument(value: string, field: string): string {
|
|
149
|
-
if (value.includes("\0")) throw new Error(`${field} must not contain NUL bytes.`);
|
|
150
|
-
return value;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export function parseDelegateFlow(value: unknown): FlowRequest {
|
|
154
|
-
if (!Check(DelegateFlowSchema, value)) throw new Error("delegate_flow must match the declared tool schema.");
|
|
155
|
-
const ids = new Set<string>();
|
|
156
|
-
return {
|
|
157
|
-
units: value.units.map((unit, unitIndex) => {
|
|
158
|
-
const id = text(unit.id, `units[${unitIndex}].id`);
|
|
159
|
-
if (ids.has(id)) throw new Error(`delegate_flow unit IDs must be unique; duplicate ${JSON.stringify(id)}.`);
|
|
160
|
-
ids.add(id);
|
|
161
|
-
return {
|
|
162
|
-
id,
|
|
163
|
-
name: normalizeTaskName(unit.name, `units[${unitIndex}].name`),
|
|
164
|
-
task: text(unit.task, `units[${unitIndex}].task`),
|
|
165
|
-
validation: unit.validation.map((validation, validationIndex) => ({
|
|
166
|
-
command: text(validation.command, `units[${unitIndex}].validation[${validationIndex}].command`),
|
|
167
|
-
args: validation.args.map((value, argumentIndex) => argument(value, `units[${unitIndex}].validation[${validationIndex}].args[${argumentIndex}]`)),
|
|
168
|
-
})),
|
|
169
|
-
...(unit.modelClass === undefined ? {} : { modelClass: unit.modelClass }),
|
|
170
|
-
...(unit.review === undefined ? {} : { review: text(unit.review, `units[${unitIndex}].review`) }),
|
|
171
|
-
};
|
|
172
|
-
}),
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
export function parseDelegateFlowContinue(value: unknown): Static<typeof DelegateFlowContinueSchema> {
|
|
177
|
-
if (!Check(DelegateFlowContinueSchema, value)) throw new Error("delegate_flow_continue must match the declared tool schema.");
|
|
178
|
-
return {
|
|
179
|
-
guidance: text(value.guidance, "guidance"),
|
|
180
|
-
...(value.modelClass === undefined ? {} : { modelClass: value.modelClass }),
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function unitCount(count: number): string {
|
|
185
|
-
return `${count} unit${count === 1 ? "" : "s"}`;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function errorText(error: unknown): string {
|
|
189
|
-
return capOutput(error instanceof Error ? error.message : String(error));
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function commandFailure(label: string, result: CommandResult): string {
|
|
193
|
-
return capOutput([
|
|
194
|
-
`${label} failed with exit ${result.code}${result.killed ? " (killed)" : ""}.`,
|
|
195
|
-
result.stdout ? `stdout:\n${result.stdout}` : "",
|
|
196
|
-
result.stderr ? `stderr:\n${result.stderr}` : "",
|
|
197
|
-
].filter(Boolean).join("\n"));
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function implementerTask(unit: FlowUnitRequest): string {
|
|
201
|
-
return [
|
|
202
|
-
`Flow Unit ${JSON.stringify(unit.id)} requirements:`,
|
|
203
|
-
unit.task,
|
|
204
|
-
...(unit.review === undefined ? [] : [
|
|
205
|
-
"",
|
|
206
|
-
"Review criterion to satisfy; the Reviewer alone decides approval:",
|
|
207
|
-
unit.review,
|
|
208
|
-
]),
|
|
209
|
-
"",
|
|
210
|
-
"Authoritative Flow validation (do not duplicate this final gate):",
|
|
211
|
-
...unit.validation.map((validation) => `- ${JSON.stringify(validation)}`),
|
|
212
|
-
].join("\n");
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function repairTask(unit: FlowUnitRequest, blocked: BlockedState, guidance: string): string {
|
|
216
|
-
return [
|
|
217
|
-
`Repair Flow Unit ${JSON.stringify(unit.id)} in its existing Unit Worktree.`,
|
|
218
|
-
"",
|
|
219
|
-
"Original requirements:",
|
|
220
|
-
unit.task,
|
|
221
|
-
...(unit.review === undefined ? [] : [
|
|
222
|
-
"",
|
|
223
|
-
"Review criterion to satisfy; the Reviewer alone decides approval:",
|
|
224
|
-
unit.review,
|
|
225
|
-
]),
|
|
226
|
-
"",
|
|
227
|
-
"Authoritative Flow validation (do not duplicate this final gate):",
|
|
228
|
-
...unit.validation.map((validation) => `- ${JSON.stringify(validation)}`),
|
|
229
|
-
"",
|
|
230
|
-
`Previous ${blocked.classification} block:`,
|
|
231
|
-
blocked.diagnostic,
|
|
232
|
-
"",
|
|
233
|
-
"Main guidance:",
|
|
234
|
-
guidance,
|
|
235
|
-
].join("\n");
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function reviewerTask(unit: FlowUnitRequest, review: string, packet: { base: string; tip: string; patchPath: string }): string {
|
|
239
|
-
return [
|
|
240
|
-
`Review Flow Unit ${JSON.stringify(unit.id)} for this explicit judgment criterion:`,
|
|
241
|
-
review,
|
|
242
|
-
"",
|
|
243
|
-
"Original requirements (context only):",
|
|
244
|
-
unit.task,
|
|
245
|
-
"",
|
|
246
|
-
"Declared validation already passed and is authoritative for objective verification:",
|
|
247
|
-
...unit.validation.map((validation) => `- ${JSON.stringify(validation)}`),
|
|
248
|
-
"",
|
|
249
|
-
`Review Packet: ${JSON.stringify(packet)}`,
|
|
250
|
-
"Review only the criterion above. Read the exact patch as authoritative and emit exactly PASS only when there are zero findings.",
|
|
251
|
-
].join("\n");
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/** Registers the two memory-only Flow tools on the package's sole manifest entrypoint. */
|
|
255
|
-
export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRuntime): () => void {
|
|
256
|
-
let active: FlowState | undefined;
|
|
257
|
-
|
|
258
|
-
const assertCurrent = (flow: FlowState): void => {
|
|
259
|
-
if (flow.generation !== runtime.getSessionGeneration()) {
|
|
260
|
-
throw new Error("Flow session changed while work was in flight; stale work was retained without review or integration.");
|
|
261
|
-
}
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
const bindSignal = (flow: FlowState, signal: AbortSignal | undefined): AbortSignal =>
|
|
265
|
-
signal ? AbortSignal.any([signal, flow.sessionController.signal]) : flow.sessionController.signal;
|
|
266
|
-
|
|
267
|
-
const invalidateActive = (): void => {
|
|
268
|
-
if (active?.blocked) runtime.setWidgetTaskRetained(active.blocked.unit.widgetTaskId, false);
|
|
269
|
-
active?.sessionController.abort(new Error("Flow session ended."));
|
|
270
|
-
active = undefined;
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
const execute = async (
|
|
274
|
-
command: string,
|
|
275
|
-
args: string[],
|
|
276
|
-
cwd: string,
|
|
277
|
-
signal?: AbortSignal,
|
|
278
|
-
timeout?: number,
|
|
279
|
-
): Promise<CommandResult> => {
|
|
280
|
-
signal?.throwIfAborted();
|
|
281
|
-
return await pi.exec(command, args, { cwd, signal, ...(timeout === undefined ? {} : { timeout }) });
|
|
282
|
-
};
|
|
283
|
-
|
|
284
|
-
const git = (args: string[], cwd: string, signal?: AbortSignal): Promise<CommandResult> =>
|
|
285
|
-
execute("git", ["--no-pager", ...args], cwd, signal, GIT_TIMEOUT_MS);
|
|
286
|
-
|
|
287
|
-
const requireGit = async (args: string[], cwd: string, signal?: AbortSignal): Promise<string> => {
|
|
288
|
-
const result = await git(args, cwd, signal);
|
|
289
|
-
if (signal?.aborted) signal.throwIfAborted();
|
|
290
|
-
if (result.code !== 0 || result.killed) throw new Error(commandFailure(`git ${args.join(" ")}`, result));
|
|
291
|
-
return result.stdout;
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
const oneLine = (value: string, field: string): string => {
|
|
295
|
-
const result = value.replace(/\r?\n$/, "");
|
|
296
|
-
if (!result || /[\r\n\0]/.test(result)) throw new Error(`Git returned malformed ${field}.`);
|
|
297
|
-
return result;
|
|
298
|
-
};
|
|
299
|
-
|
|
300
|
-
const oid = (value: string, field: string): string => {
|
|
301
|
-
const result = oneLine(value, field);
|
|
302
|
-
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(result)) throw new Error(`Git returned invalid ${field}.`);
|
|
303
|
-
return result;
|
|
304
|
-
};
|
|
305
|
-
|
|
306
|
-
const inspectMainClean = async (root: string, signal?: AbortSignal): Promise<string | undefined> => {
|
|
307
|
-
const refreshed = await git(["update-index", "--really-refresh"], root, signal);
|
|
308
|
-
if (signal?.aborted) signal.throwIfAborted();
|
|
309
|
-
if ((refreshed.code !== 0 && refreshed.code !== 1) || refreshed.killed) {
|
|
310
|
-
throw new Error(commandFailure("git update-index --really-refresh", refreshed));
|
|
311
|
-
}
|
|
312
|
-
const status = await requireGit(["status", "--porcelain=v1", "--untracked-files=all", "--ignore-submodules=none"], root, signal);
|
|
313
|
-
const flags = await inspectIndexFlags(root, git, signal);
|
|
314
|
-
if (flags.failure) throw new Error(`Git index inspection failed: ${flags.failure}`);
|
|
315
|
-
if (flags.hidden) return "assume-unchanged or skip-worktree index entries remain";
|
|
316
|
-
if (refreshed.code === 1 || status) return status || capOutput(refreshed.stdout || refreshed.stderr || "index refresh reported tracked changes");
|
|
317
|
-
return;
|
|
318
|
-
};
|
|
319
|
-
|
|
320
|
-
const snapshotMain = async (cwd: string, signal?: AbortSignal): Promise<MainState> => {
|
|
321
|
-
const root = oneLine(await requireGit(["rev-parse", "--show-toplevel"], cwd, signal), "Main root");
|
|
322
|
-
const branchRef = oneLine(await requireGit(["symbolic-ref", "--quiet", "HEAD"], root, signal), "Main branch");
|
|
323
|
-
const expectedHead = oid(await requireGit(["rev-parse", "--verify", "HEAD^{commit}"], root, signal), "Main HEAD");
|
|
324
|
-
const dirty = await inspectMainClean(root, signal);
|
|
325
|
-
if (dirty) throw new Error(`delegate_flow requires clean Git Main:\n${capOutput(dirty)}`);
|
|
326
|
-
return { root, branchRef, expectedHead };
|
|
327
|
-
};
|
|
328
|
-
|
|
329
|
-
const checkMain = async (main: MainState, signal?: AbortSignal): Promise<void> => {
|
|
330
|
-
const branchRef = oneLine(await requireGit(["symbolic-ref", "--quiet", "HEAD"], main.root, signal), "Main branch");
|
|
331
|
-
const head = oid(await requireGit(["rev-parse", "--verify", "HEAD^{commit}"], main.root, signal), "Main HEAD");
|
|
332
|
-
const dirty = await inspectMainClean(main.root, signal);
|
|
333
|
-
if (branchRef !== main.branchRef || head !== main.expectedHead || dirty) {
|
|
334
|
-
throw new Error(capOutput([
|
|
335
|
-
"Git Main changed outside the active Flow.",
|
|
336
|
-
`Expected branch=${JSON.stringify(main.branchRef)} HEAD=${main.expectedHead} clean=true.`,
|
|
337
|
-
`Actual branch=${JSON.stringify(branchRef)} HEAD=${head} clean=${!dirty}.`,
|
|
338
|
-
dirty ? `Status:\n${dirty}` : "",
|
|
339
|
-
].filter(Boolean).join("\n")));
|
|
340
|
-
}
|
|
341
|
-
};
|
|
342
|
-
|
|
343
|
-
const addMeterUsage = (meter: UsageMeter, usage: Usage | undefined) => {
|
|
344
|
-
meter.usage = addUsage(meter.usage, usage);
|
|
345
|
-
};
|
|
346
|
-
|
|
347
|
-
const runChild = async (
|
|
348
|
-
flow: FlowState,
|
|
349
|
-
role: Role,
|
|
350
|
-
modelClass: FlowModelClass,
|
|
351
|
-
task: string,
|
|
352
|
-
widgetName: string,
|
|
353
|
-
widgetTaskId: string,
|
|
354
|
-
cwd: string,
|
|
355
|
-
widgetId: string,
|
|
356
|
-
signal: AbortSignal | undefined,
|
|
357
|
-
ctx: ExtensionContext,
|
|
358
|
-
meter: UsageMeter,
|
|
359
|
-
): Promise<ChildSettlement> => {
|
|
360
|
-
let started = false;
|
|
361
|
-
try {
|
|
362
|
-
assertCurrent(flow);
|
|
363
|
-
const result = await runtime.executor.run({
|
|
364
|
-
signal,
|
|
365
|
-
onTokens: (tokens) => runtime.updateWidgetTokens(widgetId, tokens),
|
|
366
|
-
onActivity: (event) => runtime.updateWidgetActivity(widgetId, event),
|
|
367
|
-
prepare: async () => {
|
|
368
|
-
assertCurrent(flow);
|
|
369
|
-
const launch = runtime.resolveLaunch(role, modelClass, ctx);
|
|
370
|
-
if (launch.missingSkills.length) {
|
|
371
|
-
ctx.ui.notify(`Subagent role ${role.name} skipped unavailable Pi skills: ${launch.missingSkills.join(", ")}.`, "warning");
|
|
372
|
-
}
|
|
373
|
-
runtime.startWidget(widgetId, widgetTaskId, role.name, launch.model.id, launch.thinkingLevel, widgetName, ctx);
|
|
374
|
-
started = true;
|
|
375
|
-
return { launch, task, cwd };
|
|
376
|
-
},
|
|
377
|
-
});
|
|
378
|
-
assertCurrent(flow);
|
|
379
|
-
addMeterUsage(meter, result.usage);
|
|
380
|
-
if (started) {
|
|
381
|
-
try {
|
|
382
|
-
runtime.finishWidget(widgetId, result.outcome === "success" ? "success" : "failure");
|
|
383
|
-
} catch (error) {
|
|
384
|
-
return { error };
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
return { result };
|
|
388
|
-
} catch (error) {
|
|
389
|
-
if (error instanceof EphemeralSubagentError) addMeterUsage(meter, error.usage);
|
|
390
|
-
if (started) {
|
|
391
|
-
try {
|
|
392
|
-
runtime.finishWidget(widgetId, error instanceof EphemeralSubagentError && error.code === "aborted" ? "aborted" : "failure");
|
|
393
|
-
} catch (finishError) {
|
|
394
|
-
return { error: finishError };
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
return { error };
|
|
398
|
-
}
|
|
399
|
-
};
|
|
400
|
-
|
|
401
|
-
const settlementFailure = (settlement: ChildSettlement): string | undefined => {
|
|
402
|
-
if ("error" in settlement) return errorText(settlement.error);
|
|
403
|
-
if (settlement.result.outcome === "success") return;
|
|
404
|
-
return capOutput(
|
|
405
|
-
settlement.result.errorMessage
|
|
406
|
-
|| settlement.result.stderr.trim()
|
|
407
|
-
|| settlement.result.output
|
|
408
|
-
|| `Subagent exited with code ${settlement.result.exitCode}.`,
|
|
409
|
-
);
|
|
410
|
-
};
|
|
411
|
-
|
|
412
|
-
const cleanupUnit = async (
|
|
413
|
-
unit: UnitState,
|
|
414
|
-
main: MainState,
|
|
415
|
-
expectedTip: string,
|
|
416
|
-
signal?: AbortSignal,
|
|
417
|
-
): Promise<string | undefined> => {
|
|
418
|
-
try {
|
|
419
|
-
const branchResult = await git(["symbolic-ref", "--quiet", "HEAD"], unit.worktree.path, signal);
|
|
420
|
-
const tipResult = await git(["rev-parse", "--verify", "HEAD^{commit}"], unit.worktree.path, signal);
|
|
421
|
-
const branch = branchResult.code === 0 && !branchResult.killed ? oneLine(branchResult.stdout, "Unit cleanup branch") : "detached or unreadable";
|
|
422
|
-
const tip = tipResult.code === 0 && !tipResult.killed ? oid(tipResult.stdout, "Unit cleanup HEAD") : "unreadable";
|
|
423
|
-
if (branch !== `refs/heads/${unit.worktree.branch}` || tip !== expectedTip) {
|
|
424
|
-
return `Unit Worktree no longer matches approved state: expected branch=${JSON.stringify(`refs/heads/${unit.worktree.branch}`)} HEAD=${expectedTip}; actual branch=${JSON.stringify(branch)} HEAD=${tip}.`;
|
|
425
|
-
}
|
|
426
|
-
const inspection = await inspectWorktreeDirty(unit.worktree.path, async (args, cwd) => git(args, cwd, signal));
|
|
427
|
-
if (inspection.failure) return `Worktree cleanup inspection failed: ${inspection.failure}`;
|
|
428
|
-
if (inspection.dirty) return "Unit Worktree contains uncommitted or ignored work.";
|
|
429
|
-
const removed = await git(["worktree", "remove", unit.worktree.path], main.root, signal);
|
|
430
|
-
if (removed.code !== 0 || removed.killed) {
|
|
431
|
-
return commandFailure(`git worktree remove ${JSON.stringify(unit.worktree.path)}`, removed);
|
|
432
|
-
}
|
|
433
|
-
unit.worktreeRetained = false;
|
|
434
|
-
const deleted = await git(["branch", "-d", unit.worktree.branch], main.root, signal);
|
|
435
|
-
if (deleted.code !== 0 || deleted.killed) {
|
|
436
|
-
return commandFailure(`git branch -d ${JSON.stringify(unit.worktree.branch)}`, deleted);
|
|
437
|
-
}
|
|
438
|
-
unit.branchRetained = false;
|
|
439
|
-
return;
|
|
440
|
-
} catch (error) {
|
|
441
|
-
return errorText(error);
|
|
442
|
-
}
|
|
443
|
-
};
|
|
444
|
-
|
|
445
|
-
const retained = (flow: FlowState) => flow.units
|
|
446
|
-
.filter((unit) => unit.worktreeRetained || unit.branchRetained)
|
|
447
|
-
.map((unit) => ({
|
|
448
|
-
id: unit.request.id,
|
|
449
|
-
path: unit.worktree.path,
|
|
450
|
-
branch: unit.worktree.branch,
|
|
451
|
-
base: unit.base,
|
|
452
|
-
worktreeRetained: unit.worktreeRetained,
|
|
453
|
-
branchRetained: unit.branchRetained,
|
|
454
|
-
}));
|
|
455
|
-
|
|
456
|
-
const response = (
|
|
457
|
-
flow: FlowState,
|
|
458
|
-
outcome: "completed" | "blocked" | "failed",
|
|
459
|
-
meter: UsageMeter,
|
|
460
|
-
failure?: { classification: FlowClassification; diagnostic: string },
|
|
461
|
-
) => {
|
|
462
|
-
const retainedUnits = retained(flow);
|
|
463
|
-
const blocked = outcome === "blocked" ? flow.blocked : undefined;
|
|
464
|
-
const details = {
|
|
465
|
-
outcome,
|
|
466
|
-
completed: flow.completed,
|
|
467
|
-
...(flow.setupRecoveries.length ? { setupRecoveries: flow.setupRecoveries } : {}),
|
|
468
|
-
...(blocked === undefined ? {} : { blocked: {
|
|
469
|
-
id: blocked.unit.request.id,
|
|
470
|
-
classification: blocked.classification,
|
|
471
|
-
diagnostic: blocked.diagnostic,
|
|
472
|
-
repairAvailable: !blocked.unit.repairUsed,
|
|
473
|
-
path: blocked.unit.worktree.path,
|
|
474
|
-
branch: blocked.unit.worktree.branch,
|
|
475
|
-
} }),
|
|
476
|
-
...(failure === undefined ? {} : { failure }),
|
|
477
|
-
retained: retainedUnits,
|
|
478
|
-
warnings: flow.warnings,
|
|
479
|
-
};
|
|
480
|
-
const lines = [
|
|
481
|
-
`Flow ${outcome}.`,
|
|
482
|
-
flow.completed.length ? `Completed units: ${flow.completed.map(({ id, noOp }) => `${JSON.stringify(id)}${noOp ? " (no-op)" : ""}`).join(", ")}` : "Completed units: none.",
|
|
483
|
-
...(flow.setupRecoveries.length ? [
|
|
484
|
-
"Attempted allocations preserved without cleanup:",
|
|
485
|
-
...flow.setupRecoveries.map((recovery) => `- unit=${JSON.stringify(recovery.id)} path=${JSON.stringify(recovery.path)} branch=${JSON.stringify(recovery.branch)} base=${recovery.base}`),
|
|
486
|
-
] : []),
|
|
487
|
-
...(retainedUnits.length ? [
|
|
488
|
-
"Retained Flow state:",
|
|
489
|
-
...retainedUnits.map((unit) => `- unit=${JSON.stringify(unit.id)} path=${JSON.stringify(unit.path)} branch=${JSON.stringify(unit.branch)} base=${unit.base} worktree=${unit.worktreeRetained} branch_ref=${unit.branchRetained}`),
|
|
490
|
-
] : []),
|
|
491
|
-
...(blocked ? [
|
|
492
|
-
`Blocked unit: ${JSON.stringify(blocked.unit.request.id)}.`,
|
|
493
|
-
`Classification: ${blocked.classification}.`,
|
|
494
|
-
`Repair available: ${!blocked.unit.repairUsed}.`,
|
|
495
|
-
`Diagnostic:\n${blocked.diagnostic}`,
|
|
496
|
-
"Call delegate_flow_continue with explicit repair guidance.",
|
|
497
|
-
] : []),
|
|
498
|
-
...(failure ? [`Classification: ${failure.classification}.`, `Diagnostic:\n${failure.diagnostic}`] : []),
|
|
499
|
-
...(flow.warnings.length ? ["Warnings:", ...flow.warnings.map((warning) => `- ${warning}`)] : []),
|
|
500
|
-
];
|
|
501
|
-
return {
|
|
502
|
-
content: [{ type: "text" as const, text: capOutput(lines.join("\n")) }],
|
|
503
|
-
details,
|
|
504
|
-
...(meter.usage === undefined ? {} : { usage: meter.usage }),
|
|
505
|
-
};
|
|
506
|
-
};
|
|
507
|
-
|
|
508
|
-
const terminal = (
|
|
509
|
-
flow: FlowState,
|
|
510
|
-
classification: FlowClassification,
|
|
511
|
-
diagnostic: string,
|
|
512
|
-
meter: UsageMeter,
|
|
513
|
-
) => {
|
|
514
|
-
if (flow.blocked) runtime.setWidgetTaskRetained(flow.blocked.unit.widgetTaskId, false);
|
|
515
|
-
if (active === flow) active = undefined;
|
|
516
|
-
return response(flow, "failed", meter, { classification, diagnostic: capOutput(diagnostic) });
|
|
517
|
-
};
|
|
518
|
-
|
|
519
|
-
const block = (
|
|
520
|
-
flow: FlowState,
|
|
521
|
-
unit: UnitState,
|
|
522
|
-
classification: BlockedState["classification"],
|
|
523
|
-
diagnostic: string,
|
|
524
|
-
meter: UsageMeter,
|
|
525
|
-
) => {
|
|
526
|
-
const bounded = capOutput(diagnostic);
|
|
527
|
-
if (unit.repairUsed) return terminal(flow, classification, bounded, meter);
|
|
528
|
-
flow.phase = "blocked";
|
|
529
|
-
flow.blocked = { unit, classification, diagnostic: bounded };
|
|
530
|
-
runtime.setWidgetTaskRetained(unit.widgetTaskId, true);
|
|
531
|
-
return response(flow, "blocked", meter);
|
|
532
|
-
};
|
|
533
|
-
|
|
534
|
-
const inspectUnit = async (
|
|
535
|
-
unit: UnitState,
|
|
536
|
-
allowNoOp: boolean,
|
|
537
|
-
signal?: AbortSignal,
|
|
538
|
-
): Promise<{ tip?: string; block?: string }> => {
|
|
539
|
-
const branch = await git(["symbolic-ref", "--quiet", "HEAD"], unit.worktree.cwd, signal);
|
|
540
|
-
if (signal?.aborted) signal.throwIfAborted();
|
|
541
|
-
if (branch.code !== 0 || branch.killed) {
|
|
542
|
-
return { block: `Implementer moved Unit ${JSON.stringify(unit.request.id)} off its Flow-owned branch.` };
|
|
543
|
-
}
|
|
544
|
-
if (oneLine(branch.stdout, "Unit branch") !== `refs/heads/${unit.worktree.branch}`) {
|
|
545
|
-
return { block: `Implementer moved Unit ${JSON.stringify(unit.request.id)} off its Flow-owned branch.` };
|
|
546
|
-
}
|
|
547
|
-
const tipResult = await git(["rev-parse", "--verify", "HEAD^{commit}"], unit.worktree.cwd, signal);
|
|
548
|
-
if (signal?.aborted) signal.throwIfAborted();
|
|
549
|
-
if (tipResult.code !== 0 || tipResult.killed) return { block: `Unit ${JSON.stringify(unit.request.id)} has no readable committed HEAD.` };
|
|
550
|
-
const tip = oid(tipResult.stdout, "Unit HEAD");
|
|
551
|
-
const branchTip = oid(await requireGit(["rev-parse", "--verify", `refs/heads/${unit.worktree.branch}^{commit}`], unit.worktree.cwd, signal), "Unit branch tip");
|
|
552
|
-
if (tip !== branchTip) return { block: `Unit ${JSON.stringify(unit.request.id)} branch no longer names its checked-out HEAD.` };
|
|
553
|
-
const status = await requireGit(["status", "--porcelain=v1", "--untracked-files=all", "--ignore-submodules=none"], unit.worktree.cwd, signal);
|
|
554
|
-
if (status) return { block: `Unit Worktree is dirty:\n${capOutput(status)}` };
|
|
555
|
-
const flags = await inspectIndexFlags(unit.worktree.cwd, git, signal);
|
|
556
|
-
if (flags.failure) throw new Error(`Unit index inspection failed: ${flags.failure}`);
|
|
557
|
-
if (flags.hidden) return { block: "Unit Worktree has assume-unchanged or skip-worktree index entries." };
|
|
558
|
-
const ancestor = await git(["merge-base", "--is-ancestor", unit.base, tip], unit.worktree.cwd, signal);
|
|
559
|
-
if (signal?.aborted) signal.throwIfAborted();
|
|
560
|
-
if (ancestor.code === 1) return { block: `Unit HEAD does not descend from its Flow-owned base ${unit.base}.` };
|
|
561
|
-
if (ancestor.code !== 0 || ancestor.killed) throw new Error(commandFailure("git merge-base --is-ancestor", ancestor));
|
|
562
|
-
const countText = oneLine(await requireGit(["rev-list", "--count", `${unit.base}..${tip}`], unit.worktree.cwd, signal), "Unit commit count");
|
|
563
|
-
const count = Number.parseInt(countText, 10);
|
|
564
|
-
if (!Number.isSafeInteger(count) || count < 0) throw new Error("Git returned an invalid Unit commit count.");
|
|
565
|
-
if (!allowNoOp && count === 0) return { block: `Unit ${JSON.stringify(unit.request.id)} has no committed change.` };
|
|
566
|
-
return { tip };
|
|
567
|
-
};
|
|
568
|
-
|
|
569
|
-
const validateUnit = async (
|
|
570
|
-
unit: UnitState,
|
|
571
|
-
tip: string,
|
|
572
|
-
signal?: AbortSignal,
|
|
573
|
-
): Promise<string | undefined> => {
|
|
574
|
-
for (const [index, validation] of unit.request.validation.entries()) {
|
|
575
|
-
const result = await execute(validation.command, validation.args, unit.worktree.cwd, signal, runtime.maxRuntimeMs);
|
|
576
|
-
if (signal?.aborted) signal.throwIfAborted();
|
|
577
|
-
if (result.code !== 0 || result.killed) return commandFailure(`Validation ${index + 1}`, result);
|
|
578
|
-
}
|
|
579
|
-
const inspected = await inspectUnit(unit, true, signal);
|
|
580
|
-
if (inspected.block || inspected.tip !== tip) {
|
|
581
|
-
return capOutput([
|
|
582
|
-
"Validation changed the Flow-owned committed Unit state.",
|
|
583
|
-
`Expected branch=${JSON.stringify(unit.worktree.branch)} HEAD=${tip} clean=true.`,
|
|
584
|
-
inspected.block ?? `Actual HEAD=${inspected.tip}.`,
|
|
585
|
-
].join("\n"));
|
|
586
|
-
}
|
|
587
|
-
return;
|
|
588
|
-
};
|
|
589
|
-
|
|
590
|
-
const processFlow = async (
|
|
591
|
-
flow: FlowState,
|
|
592
|
-
toolCallId: string,
|
|
593
|
-
signal: AbortSignal | undefined,
|
|
594
|
-
ctx: ExtensionContext,
|
|
595
|
-
meter: UsageMeter,
|
|
596
|
-
emitProgress: (line: string) => void,
|
|
597
|
-
) => {
|
|
598
|
-
assertCurrent(flow);
|
|
599
|
-
const main = flow.main!;
|
|
600
|
-
while (flow.index < flow.units.length) {
|
|
601
|
-
assertCurrent(flow);
|
|
602
|
-
const unit = flow.units[flow.index]!;
|
|
603
|
-
try {
|
|
604
|
-
await checkMain(main, signal);
|
|
605
|
-
} catch (error) {
|
|
606
|
-
return terminal(flow, "main", errorText(error), meter);
|
|
607
|
-
}
|
|
608
|
-
assertCurrent(flow);
|
|
609
|
-
|
|
610
|
-
const implementationFailure = settlementFailure(unit.implementation!);
|
|
611
|
-
if (implementationFailure) return block(flow, unit, "implementer", implementationFailure, meter);
|
|
612
|
-
emitProgress(`verify/integrate · unit ${flow.index + 1}/${flow.units.length}`);
|
|
613
|
-
|
|
614
|
-
let inspected = await inspectUnit(unit, false, signal);
|
|
615
|
-
assertCurrent(flow);
|
|
616
|
-
if (inspected.block) return block(flow, unit, "implementer", inspected.block, meter);
|
|
617
|
-
let tip = inspected.tip!;
|
|
618
|
-
|
|
619
|
-
if (unit.base !== main.expectedHead) {
|
|
620
|
-
const rebaseInspection = await inspectWorktreeDirty(unit.worktree.path, async (args, cwd) => git(args, cwd, signal));
|
|
621
|
-
assertCurrent(flow);
|
|
622
|
-
if (rebaseInspection.failure) {
|
|
623
|
-
return terminal(flow, "infrastructure", `Pre-rebase Unit Worktree inspection failed: ${rebaseInspection.failure}`, meter);
|
|
624
|
-
}
|
|
625
|
-
if (rebaseInspection.dirty) {
|
|
626
|
-
return block(flow, unit, "implementer", "Unit Worktree contains uncommitted or ignored work; rebase was refused.", meter);
|
|
627
|
-
}
|
|
628
|
-
const rebased = await git(["rebase", main.expectedHead], unit.worktree.cwd, signal);
|
|
629
|
-
if (signal?.aborted) {
|
|
630
|
-
const aborted = await git(["rebase", "--abort"], unit.worktree.cwd);
|
|
631
|
-
if (aborted.code !== 0 || aborted.killed) {
|
|
632
|
-
return terminal(flow, "infrastructure", [
|
|
633
|
-
`Flow cancelled: ${errorText(signal.reason)}`,
|
|
634
|
-
commandFailure("git rebase --abort", aborted),
|
|
635
|
-
].join("\n"), meter);
|
|
636
|
-
}
|
|
637
|
-
signal.throwIfAborted();
|
|
638
|
-
}
|
|
639
|
-
assertCurrent(flow);
|
|
640
|
-
if (rebased.code !== 0 || rebased.killed) {
|
|
641
|
-
const aborted = await git(["rebase", "--abort"], unit.worktree.cwd);
|
|
642
|
-
const diagnostic = [
|
|
643
|
-
`Rebase failed; git rebase --abort ${aborted.code === 0 && !aborted.killed ? "restored the Unit Worktree for recovery." : "also failed; inspect the retained Unit Worktree."}`,
|
|
644
|
-
commandFailure(`git rebase ${main.expectedHead}`, rebased),
|
|
645
|
-
...(aborted.code === 0 && !aborted.killed ? [] : [commandFailure("git rebase --abort", aborted)]),
|
|
646
|
-
].join("\n");
|
|
647
|
-
return terminal(flow, "infrastructure", diagnostic, meter);
|
|
648
|
-
}
|
|
649
|
-
unit.base = main.expectedHead;
|
|
650
|
-
inspected = await inspectUnit(unit, true, signal);
|
|
651
|
-
assertCurrent(flow);
|
|
652
|
-
if (inspected.block) return terminal(flow, "infrastructure", inspected.block, meter);
|
|
653
|
-
tip = inspected.tip!;
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
const validationFailure = await validateUnit(unit, tip, signal);
|
|
657
|
-
assertCurrent(flow);
|
|
658
|
-
if (validationFailure) return block(flow, unit, "validation", validationFailure, meter);
|
|
659
|
-
if (unit.base === tip) {
|
|
660
|
-
try {
|
|
661
|
-
await checkMain(main, signal);
|
|
662
|
-
} catch (error) {
|
|
663
|
-
return terminal(flow, "main", errorText(error), meter);
|
|
664
|
-
}
|
|
665
|
-
assertCurrent(flow);
|
|
666
|
-
flow.completed.push({ id: unit.request.id, noOp: true });
|
|
667
|
-
const cleanupWarning = await cleanupUnit(unit, main, tip, flow.sessionController.signal);
|
|
668
|
-
assertCurrent(flow);
|
|
669
|
-
if (cleanupWarning) flow.warnings.push(`Unit ${JSON.stringify(unit.request.id)} completed as a no-op, but cleanup refused: ${cleanupWarning}`);
|
|
670
|
-
flow.index += 1;
|
|
671
|
-
continue;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
let approvedTip = tip;
|
|
675
|
-
const reviewCriterion = unit.request.review;
|
|
676
|
-
if (reviewCriterion !== undefined) {
|
|
677
|
-
const reviewer = flow.reviewer;
|
|
678
|
-
if (!reviewer) return terminal(flow, "infrastructure", "Flow Reviewer was not resolved for a unit that requires review.", meter);
|
|
679
|
-
emitProgress(`review · unit ${flow.index + 1}/${flow.units.length}`);
|
|
680
|
-
let evidence;
|
|
681
|
-
try {
|
|
682
|
-
evidence = await prepareExactReviewEvidence({ base: main.expectedHead, tip, worktree: unit.worktree.path }, signal);
|
|
683
|
-
} catch (error) {
|
|
684
|
-
return terminal(flow, "infrastructure", errorText(error), meter);
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
let review: ChildSettlement;
|
|
688
|
-
let cleanupError: unknown;
|
|
689
|
-
try {
|
|
690
|
-
assertCurrent(flow);
|
|
691
|
-
review = await runChild(
|
|
692
|
-
flow,
|
|
693
|
-
reviewer,
|
|
694
|
-
unit.modelClass,
|
|
695
|
-
reviewerTask(unit.request, reviewCriterion, { base: evidence.base, tip: evidence.tip, patchPath: evidence.patchPath }),
|
|
696
|
-
unit.request.name,
|
|
697
|
-
unit.widgetTaskId,
|
|
698
|
-
unit.worktree.cwd,
|
|
699
|
-
`${toolCallId}:flow:${flow.index}:review`,
|
|
700
|
-
signal,
|
|
701
|
-
ctx,
|
|
702
|
-
meter,
|
|
703
|
-
);
|
|
704
|
-
} finally {
|
|
705
|
-
try {
|
|
706
|
-
await evidence.cleanup();
|
|
707
|
-
} catch (error) {
|
|
708
|
-
cleanupError = error;
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
if (cleanupError !== undefined) return terminal(flow, "infrastructure", errorText(cleanupError), meter);
|
|
712
|
-
assertCurrent(flow);
|
|
713
|
-
if ("error" in review) return terminal(flow, "infrastructure", errorText(review.error), meter);
|
|
714
|
-
if (review.result.outcome !== "success") {
|
|
715
|
-
return terminal(flow, "infrastructure", settlementFailure(review)!, meter);
|
|
716
|
-
}
|
|
717
|
-
if (TRUNCATED_OUTPUT.test(review.result.output)) {
|
|
718
|
-
return terminal(flow, "infrastructure", "Reviewer transport output was truncated; approval is invalid.", meter);
|
|
719
|
-
}
|
|
720
|
-
if (review.result.output.trim() !== "PASS") {
|
|
721
|
-
return block(flow, unit, "reviewer_findings", review.result.output || "Reviewer returned no PASS approval.", meter);
|
|
722
|
-
}
|
|
723
|
-
approvedTip = evidence.tip;
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
try {
|
|
727
|
-
await checkMain(main, signal);
|
|
728
|
-
} catch (error) {
|
|
729
|
-
return terminal(flow, "main", errorText(error), meter);
|
|
730
|
-
}
|
|
731
|
-
assertCurrent(flow);
|
|
732
|
-
const merged = await git(["merge", "--no-overwrite-ignore", "--ff-only", approvedTip], main.root, signal);
|
|
733
|
-
assertCurrent(flow);
|
|
734
|
-
if (merged.code !== 0 || merged.killed) {
|
|
735
|
-
const diagnostic = commandFailure(`git merge --no-overwrite-ignore --ff-only ${approvedTip}`, merged);
|
|
736
|
-
const previousHead = main.expectedHead;
|
|
737
|
-
main.expectedHead = approvedTip;
|
|
738
|
-
try {
|
|
739
|
-
await checkMain(main);
|
|
740
|
-
} catch (error) {
|
|
741
|
-
main.expectedHead = previousHead;
|
|
742
|
-
return terminal(flow, "integration", capOutput([
|
|
743
|
-
diagnostic,
|
|
744
|
-
"Merge reported failure and Main did not reconcile to the approved clean state.",
|
|
745
|
-
errorText(error),
|
|
746
|
-
].join("\n")), meter);
|
|
747
|
-
}
|
|
748
|
-
flow.warnings.push(capOutput(`Unit ${JSON.stringify(unit.request.id)} integrated after merge reported failure: ${diagnostic}`));
|
|
749
|
-
} else main.expectedHead = approvedTip;
|
|
750
|
-
flow.completed.push({ id: unit.request.id, noOp: false });
|
|
751
|
-
try {
|
|
752
|
-
await checkMain(main);
|
|
753
|
-
} catch (error) {
|
|
754
|
-
return terminal(flow, "integration", errorText(error), meter);
|
|
755
|
-
}
|
|
756
|
-
assertCurrent(flow);
|
|
757
|
-
const cleanupWarning = await cleanupUnit(unit, main, approvedTip, flow.sessionController.signal);
|
|
758
|
-
assertCurrent(flow);
|
|
759
|
-
if (cleanupWarning) flow.warnings.push(`Unit ${JSON.stringify(unit.request.id)} integrated, but cleanup refused: ${cleanupWarning}`);
|
|
760
|
-
flow.index += 1;
|
|
761
|
-
}
|
|
762
|
-
assertCurrent(flow);
|
|
763
|
-
if (active === flow) active = undefined;
|
|
764
|
-
return response(flow, "completed", meter);
|
|
765
|
-
};
|
|
766
|
-
|
|
767
|
-
pi.registerTool({
|
|
768
|
-
name: "delegate_flow",
|
|
769
|
-
label: "Delegate Flow",
|
|
770
|
-
description: "Run 1–8 independent Implementers in isolated Unit Worktrees, validate and serially fast-forward each tip, with exact review only for units that declare a judgment criterion.",
|
|
771
|
-
promptSnippet: "Run a deterministic parallel-implementation, serial-verification Flow",
|
|
772
|
-
promptGuidelines: [
|
|
773
|
-
"Use delegate_flow only for cohesive units expected to commute: make independent commuting outcomes separate units rather than combining them merely to reduce Implementer count; sequence dependent work outside delegate_flow. On naturally multi-part work, actively look for roughly 3–5 useful units, but never manufacture units, split one invariant across multiple units, assign overlapping mutable ownership, or use a quota. Combine work that overlaps files, APIs, schemas, generated output, package metadata, lockfiles, or invariants.",
|
|
774
|
-
`${TASK_NAME_CONTRACT.promptGuidance} Each delegate_flow unit must own one concrete outcome with one focused validation story: include explicit bounded requirements and its authoritative direct command/argument validation gate. If the affected flow or scope is not yet known, perform bounded read-only discovery first. Add review only for an explicit judgment that validation cannot establish.`,
|
|
775
|
-
`For each delegate_flow unit, ${MODEL_CLASS_GUIDANCE}`,
|
|
776
|
-
"If a Flow blocks, inspect its classification and call delegate_flow_continue once with explicit repair guidance; modelClass may replace that one repair's current class.",
|
|
777
|
-
],
|
|
778
|
-
parameters: DelegateFlowSchema,
|
|
779
|
-
prepareArguments: parseDelegateFlow,
|
|
780
|
-
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
781
|
-
const request = parseDelegateFlow(params);
|
|
782
|
-
if (active) throw new Error("delegate_flow rejected because another Flow is active.");
|
|
783
|
-
const roles = runtime.loadRoles();
|
|
784
|
-
const implementer = roles.find(({ name }) => name === "implementer");
|
|
785
|
-
const needsReviewer = request.units.some(({ review }) => review !== undefined);
|
|
786
|
-
const reviewer = needsReviewer ? roles.find(({ name }) => name === "reviewer") : undefined;
|
|
787
|
-
if (!implementer) throw new Error("delegate_flow requires an implementer Role.");
|
|
788
|
-
if (needsReviewer && !reviewer) throw new Error("delegate_flow requires a reviewer Role when a unit declares review.");
|
|
789
|
-
const emitProgress = (line: string) => {
|
|
790
|
-
onUpdate?.({ content: [{ type: "text", text: line }], details: { line } });
|
|
791
|
-
};
|
|
792
|
-
const flow: FlowState = {
|
|
793
|
-
phase: "running",
|
|
794
|
-
generation: runtime.getSessionGeneration(),
|
|
795
|
-
sessionController: new AbortController(),
|
|
796
|
-
implementer,
|
|
797
|
-
...(reviewer === undefined ? {} : { reviewer }),
|
|
798
|
-
units: [],
|
|
799
|
-
setupRecoveries: [],
|
|
800
|
-
index: 0,
|
|
801
|
-
completed: [],
|
|
802
|
-
warnings: [],
|
|
803
|
-
};
|
|
804
|
-
active = flow;
|
|
805
|
-
emitProgress(`setup · ${unitCount(request.units.length)}`);
|
|
806
|
-
const operationSignal = bindSignal(flow, signal);
|
|
807
|
-
const meter: UsageMeter = {};
|
|
808
|
-
let setupComplete = false;
|
|
809
|
-
try {
|
|
810
|
-
flow.main = await snapshotMain(ctx.cwd, operationSignal);
|
|
811
|
-
assertCurrent(flow);
|
|
812
|
-
for (const [index, unit] of request.units.entries()) {
|
|
813
|
-
let worktree: WorktreeInfo | undefined;
|
|
814
|
-
try {
|
|
815
|
-
worktree = await createChildWorktree(ctx.cwd, `${toolCallId}:flow:${index}:${unit.id}`, undefined, operationSignal);
|
|
816
|
-
} catch (error) {
|
|
817
|
-
if (!(error instanceof WorktreeSetupError)) throw error;
|
|
818
|
-
flow.setupRecoveries.push({
|
|
819
|
-
id: unit.id,
|
|
820
|
-
path: error.worktree.path,
|
|
821
|
-
branch: error.worktree.branch,
|
|
822
|
-
base: error.worktree.baseCommit,
|
|
823
|
-
diagnostic: errorText(error),
|
|
824
|
-
});
|
|
825
|
-
throw error;
|
|
826
|
-
}
|
|
827
|
-
if (!worktree) throw new Error("Flow Unit Worktrees require a Git repository with a committed HEAD; generic cwd fallback is disabled.");
|
|
828
|
-
flow.units.push({
|
|
829
|
-
request: unit,
|
|
830
|
-
modelClass: unit.modelClass,
|
|
831
|
-
widgetTaskId: `${toolCallId}:flow:${index}`,
|
|
832
|
-
worktree,
|
|
833
|
-
base: worktree.baseCommit,
|
|
834
|
-
repairUsed: false,
|
|
835
|
-
worktreeRetained: true,
|
|
836
|
-
branchRetained: true,
|
|
837
|
-
});
|
|
838
|
-
assertCurrent(flow);
|
|
839
|
-
if (worktree.baseCommit !== flow.main.expectedHead) throw new Error("Git Main changed while Flow Unit Worktrees were being created.");
|
|
840
|
-
}
|
|
841
|
-
await checkMain(flow.main, operationSignal);
|
|
842
|
-
assertCurrent(flow);
|
|
843
|
-
setupComplete = true;
|
|
844
|
-
let completedImplementers = 0;
|
|
845
|
-
const settlements = await Promise.all(flow.units.map((unit, index) => runChild(
|
|
846
|
-
flow,
|
|
847
|
-
flow.implementer,
|
|
848
|
-
unit.modelClass,
|
|
849
|
-
implementerTask(unit.request),
|
|
850
|
-
unit.request.name,
|
|
851
|
-
unit.widgetTaskId,
|
|
852
|
-
unit.worktree.cwd,
|
|
853
|
-
`${toolCallId}:flow:${index}:implement`,
|
|
854
|
-
operationSignal,
|
|
855
|
-
ctx,
|
|
856
|
-
meter,
|
|
857
|
-
).then((settlement) => {
|
|
858
|
-
completedImplementers += 1;
|
|
859
|
-
emitProgress(`implement · ${completedImplementers}/${flow.units.length} complete`);
|
|
860
|
-
return settlement;
|
|
861
|
-
})));
|
|
862
|
-
for (const [index, settlement] of settlements.entries()) flow.units[index]!.implementation = settlement;
|
|
863
|
-
assertCurrent(flow);
|
|
864
|
-
if (operationSignal.aborted) operationSignal.throwIfAborted();
|
|
865
|
-
return await processFlow(flow, toolCallId, operationSignal, ctx, meter, emitProgress);
|
|
866
|
-
} catch (error) {
|
|
867
|
-
if (!setupComplete) {
|
|
868
|
-
for (const unit of [...flow.units].reverse()) {
|
|
869
|
-
const warning = flow.main
|
|
870
|
-
? await cleanupUnit(unit, flow.main, unit.base, flow.sessionController.signal)
|
|
871
|
-
: "Main identity was unavailable for cleanup.";
|
|
872
|
-
if (warning) flow.warnings.push(`Partial setup cleanup refused for ${JSON.stringify(unit.request.id)}: ${warning}`);
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
return terminal(flow, setupComplete ? "infrastructure" : "setup", errorText(error), meter);
|
|
876
|
-
}
|
|
877
|
-
},
|
|
878
|
-
});
|
|
879
|
-
|
|
880
|
-
pi.registerTool({
|
|
881
|
-
name: "delegate_flow_continue",
|
|
882
|
-
label: "Continue Delegate Flow",
|
|
883
|
-
description: "Repair the one blocked Flow Unit in its existing Unit Worktree, optionally replace its model class, then resume declared-order validation, conditional exact review, and integration.",
|
|
884
|
-
promptSnippet: "Repair and continue the blocked deterministic Flow",
|
|
885
|
-
promptGuidelines: ["Call delegate_flow_continue only after delegate_flow reports a repairable block, with explicit guidance addressing that block."],
|
|
886
|
-
parameters: DelegateFlowContinueSchema,
|
|
887
|
-
prepareArguments: parseDelegateFlowContinue,
|
|
888
|
-
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
889
|
-
const { guidance, modelClass } = parseDelegateFlowContinue(params);
|
|
890
|
-
const flow = active;
|
|
891
|
-
if (!flow) throw new Error("delegate_flow_continue requires an active blocked Flow.");
|
|
892
|
-
assertCurrent(flow);
|
|
893
|
-
if (flow.phase !== "blocked" || !flow.blocked) throw new Error("delegate_flow_continue rejected because the active Flow is not blocked.");
|
|
894
|
-
const blocked = flow.blocked;
|
|
895
|
-
const unit = blocked.unit;
|
|
896
|
-
if (unit.repairUsed) throw new Error("delegate_flow_continue repair was already used for this Unit.");
|
|
897
|
-
runtime.setWidgetTaskRetained(unit.widgetTaskId, false);
|
|
898
|
-
flow.phase = "running";
|
|
899
|
-
flow.blocked = undefined;
|
|
900
|
-
unit.repairUsed = true;
|
|
901
|
-
if (modelClass !== undefined) unit.modelClass = modelClass;
|
|
902
|
-
const operationSignal = bindSignal(flow, signal);
|
|
903
|
-
const meter: UsageMeter = {};
|
|
904
|
-
const emitProgress = (line: string) => {
|
|
905
|
-
onUpdate?.({ content: [{ type: "text", text: line }], details: { line } });
|
|
906
|
-
};
|
|
907
|
-
try {
|
|
908
|
-
emitProgress(`repair · unit ${flow.index + 1}/${flow.units.length}`);
|
|
909
|
-
unit.implementation = await runChild(
|
|
910
|
-
flow,
|
|
911
|
-
flow.implementer,
|
|
912
|
-
unit.modelClass,
|
|
913
|
-
repairTask(unit.request, blocked, guidance),
|
|
914
|
-
unit.request.name,
|
|
915
|
-
unit.widgetTaskId,
|
|
916
|
-
unit.worktree.cwd,
|
|
917
|
-
`${toolCallId}:flow:${flow.index}:repair`,
|
|
918
|
-
operationSignal,
|
|
919
|
-
ctx,
|
|
920
|
-
meter,
|
|
921
|
-
);
|
|
922
|
-
assertCurrent(flow);
|
|
923
|
-
if (operationSignal.aborted) operationSignal.throwIfAborted();
|
|
924
|
-
return await processFlow(flow, toolCallId, operationSignal, ctx, meter, emitProgress);
|
|
925
|
-
} catch (error) {
|
|
926
|
-
return terminal(flow, "infrastructure", errorText(error), meter);
|
|
927
|
-
}
|
|
928
|
-
},
|
|
929
|
-
});
|
|
930
|
-
|
|
931
|
-
return invalidateActive;
|
|
932
|
-
}
|