@llm4ts/flow 2.7.2 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Approval.d.ts +1 -1
- package/dist/Approval.d.ts.map +1 -1
- package/dist/Artifacts.d.ts +2 -2
- package/dist/Artifacts.d.ts.map +1 -1
- package/dist/BenchReport.d.ts +2 -2
- package/dist/BenchReport.d.ts.map +1 -1
- package/dist/Chat.d.ts.map +1 -1
- package/dist/Chat.js +35 -4
- package/dist/Chat.js.map +1 -1
- package/dist/CostLedger.d.ts +2 -2
- package/dist/CostLedger.d.ts.map +1 -1
- package/dist/Equiv.d.ts +3 -3
- package/dist/Equiv.d.ts.map +1 -1
- package/dist/Flow.d.ts +1 -1
- package/dist/Flow.d.ts.map +1 -1
- package/dist/FlowError.d.ts +17 -1
- package/dist/FlowError.d.ts.map +1 -1
- package/dist/FlowError.js +29 -3
- package/dist/FlowError.js.map +1 -1
- package/dist/GitTool.d.ts +26 -2
- package/dist/GitTool.d.ts.map +1 -1
- package/dist/GitTool.js +54 -3
- package/dist/GitTool.js.map +1 -1
- package/dist/Judgment.d.ts +2 -2
- package/dist/Judgment.d.ts.map +1 -1
- package/dist/Perimeter.d.ts +22 -1
- package/dist/Perimeter.d.ts.map +1 -1
- package/dist/Perimeter.js +57 -1
- package/dist/Perimeter.js.map +1 -1
- package/dist/Persistence.d.ts +2 -2
- package/dist/Persistence.d.ts.map +1 -1
- package/dist/PlanExecution.d.ts +1 -1
- package/dist/PlanExecution.d.ts.map +1 -1
- package/dist/ProgramJudge.d.ts +1 -1
- package/dist/ProgramJudge.d.ts.map +1 -1
- package/dist/Replay.d.ts +2 -2
- package/dist/Replay.d.ts.map +1 -1
- package/dist/Review.d.ts +3 -3
- package/dist/Review.d.ts.map +1 -1
- package/dist/ReviewCache.d.ts +1 -1
- package/dist/ReviewCache.d.ts.map +1 -1
- package/dist/Stories.d.ts +47 -5
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +347 -45
- package/dist/Stories.js.map +1 -1
- package/dist/StoryPlan.d.ts +15 -0
- package/dist/StoryPlan.d.ts.map +1 -1
- package/dist/StoryPlan.js +58 -0
- package/dist/StoryPlan.js.map +1 -1
- package/dist/TransientRetry.d.ts +9 -0
- package/dist/TransientRetry.d.ts.map +1 -1
- package/dist/TransientRetry.js +43 -8
- package/dist/TransientRetry.js.map +1 -1
- package/package.json +2 -2
- package/src/Chat.ts +45 -6
- package/src/FlowError.ts +33 -3
- package/src/GitTool.ts +99 -5
- package/src/Perimeter.ts +85 -1
- package/src/Stories.ts +495 -78
- package/src/StoryPlan.ts +73 -0
- package/src/TransientRetry.ts +97 -8
package/dist/Stories.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// target's gates run on the epic branch after every merge. Scheduling,
|
|
6
6
|
// gating, resume and failure policy live here; seats come from `contextFor`.
|
|
7
7
|
import * as Effect from "effect/Effect";
|
|
8
|
+
import * as Exit from "effect/Exit";
|
|
8
9
|
import * as Queue from "effect/Queue";
|
|
9
10
|
import * as Ref from "effect/Ref";
|
|
10
11
|
import * as Schema from "effect/Schema";
|
|
@@ -13,13 +14,17 @@ import * as Stream from "effect/Stream";
|
|
|
13
14
|
import { BoardItem } from "./BoardSync.js";
|
|
14
15
|
import { makeChat } from "./Chat.js";
|
|
15
16
|
import { implementPlanFlow } from "./Flow.js";
|
|
16
|
-
import { describeFlowError, MissingDependency, StoryFailed } from "./FlowError.js";
|
|
17
|
+
import { describeFlowError, EpicCheckoutDirty, MissingDependency, PerimeterViolation, StoryFailed } from "./FlowError.js";
|
|
17
18
|
import { Info } from "./FlowEvents.js";
|
|
18
|
-
import {
|
|
19
|
+
import { statusPaths } from "./GitTool.js";
|
|
20
|
+
import { checkPerimeter, enforcePerimeter, isWithinPerimeter, perimeterGate, strayTasks } from "./Perimeter.js";
|
|
19
21
|
import { loadVersioned, makePlanStore, saveVersioned } from "./Persistence.js";
|
|
22
|
+
import { Plan } from "./Plan.js";
|
|
20
23
|
import { stage } from "./PlanExecution.js";
|
|
21
24
|
import { planFrom } from "./Planner.js";
|
|
22
|
-
import {
|
|
25
|
+
import { ReviewResult } from "./Review.js";
|
|
26
|
+
import { dependentsOf, ownerOf, pathsNamedIn, readyStories, relationOf, storyHash, topologicalWaves, validateStoryPlan } from "./StoryPlan.js";
|
|
27
|
+
import { isOutageMessage } from "./TransientRetry.js";
|
|
23
28
|
const join = (root, path) => `${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`;
|
|
24
29
|
// ---- Durable state ----------------------------------------------------------
|
|
25
30
|
export const StoryStateVersion = 1;
|
|
@@ -115,8 +120,31 @@ export const blockedOnIn = (text) => {
|
|
|
115
120
|
return need === undefined || need.length === 0 ? undefined : need;
|
|
116
121
|
};
|
|
117
122
|
const bullets = (items) => items.length === 0 ? "- (none)" : items.map((item) => `- ${item}`).join("\n");
|
|
123
|
+
const relationNotes = {
|
|
124
|
+
dependency: "merged into the epic before you started — it is in your working tree; read it",
|
|
125
|
+
dependent: "built AFTER you, on top of your work — leave it alone (wiring your work in is that story's job)",
|
|
126
|
+
parallel: "built at the same time by another coder — leave it alone"
|
|
127
|
+
};
|
|
128
|
+
const whereaboutsRules = (story, where) => {
|
|
129
|
+
const others = where.plan.stories
|
|
130
|
+
.filter((other) => other.id !== story.id)
|
|
131
|
+
.map((other) => {
|
|
132
|
+
const relation = relationOf(where.plan, story, other);
|
|
133
|
+
const note = relation === "self" ? "" : relationNotes[relation];
|
|
134
|
+
return `${other.owned.join(", ")} — story ${other.id}: ${note}`;
|
|
135
|
+
});
|
|
136
|
+
return [
|
|
137
|
+
"",
|
|
138
|
+
`Your working directory is ${where.worktree}. Run every command there. Never cd into, read`,
|
|
139
|
+
`from, or write to any other checkout — above all not ${where.epicCheckout}, the epic`,
|
|
140
|
+
"branch's checkout: a change there breaks every other story's merge.",
|
|
141
|
+
"",
|
|
142
|
+
"Paths other stories own — never create or change them, and none of them is a reason to stop:",
|
|
143
|
+
bullets(others)
|
|
144
|
+
];
|
|
145
|
+
};
|
|
118
146
|
/** The hard rules every story coder receives; the perimeter check enforces them afterwards. */
|
|
119
|
-
export const perimeterRules = (story) => [
|
|
147
|
+
export const perimeterRules = (story, where) => [
|
|
120
148
|
`You are implementing ONE story of a larger epic: "${story.title}" (id: ${story.id}).`,
|
|
121
149
|
"",
|
|
122
150
|
"Paths you own — create and change files only here:",
|
|
@@ -127,6 +155,7 @@ export const perimeterRules = (story) => [
|
|
|
127
155
|
"",
|
|
128
156
|
"What this story must provide for other stories:",
|
|
129
157
|
bullets(story.provides),
|
|
158
|
+
...(where === undefined ? [] : whereaboutsRules(story, where)),
|
|
130
159
|
"",
|
|
131
160
|
"Rules:",
|
|
132
161
|
"- Do not touch any path outside the owned list; a change there fails the story.",
|
|
@@ -142,13 +171,86 @@ export const storyPrompt = (story) => [`Story: ${story.title}`, "", story.descri
|
|
|
142
171
|
export const storyTaskPlanInstructions = (story) => [
|
|
143
172
|
"You are planning the implementation of ONE story inside a larger epic. Break the story",
|
|
144
173
|
"into an ordered list of small, independently verifiable tasks, each described by its",
|
|
145
|
-
"observable outcome. Every task must stay inside the story's owned paths
|
|
174
|
+
"observable outcome. Every task must stay inside the story's owned paths:",
|
|
175
|
+
bullets(story.owned),
|
|
176
|
+
"Never plan a task that creates or changes anything outside them — registering or wiring",
|
|
177
|
+
"the story in elsewhere (the app's composition point, a shared kit file) is another story's job.",
|
|
146
178
|
`Use exactly this epicId: "${story.id}".`,
|
|
147
179
|
'Respond only with JSON: {"epicId":"' + story.id + '","tasks":',
|
|
148
180
|
'[{"title":"...","description":"...","completed":false}]}'
|
|
149
181
|
].join("\n");
|
|
150
182
|
/** The coder plans its own tasks from the story — the orchestrator split the epic, not the story. */
|
|
151
183
|
export const defaultPlanTasks = (seats, story, prompt) => planFrom(seats.context.coder, prompt, storyTaskPlanInstructions(story));
|
|
184
|
+
/**
|
|
185
|
+
* Asks the planner again, once, when its tasks name paths outside the story;
|
|
186
|
+
* tasks that still name ANOTHER story's paths are then dropped (that work is
|
|
187
|
+
* planned elsewhere) and any remaining stray is left to the perimeter gate.
|
|
188
|
+
*/
|
|
189
|
+
export const planWithinPerimeter = (plan, story, planTasks, prompt, events) => Effect.gen(function* () {
|
|
190
|
+
const first = yield* planTasks(prompt);
|
|
191
|
+
const strays = strayTasks(plan, story, first.tasks);
|
|
192
|
+
if (strays.length === 0) {
|
|
193
|
+
return first;
|
|
194
|
+
}
|
|
195
|
+
const describe = strays.map((stray) => `- "${stray.task.title}": ${stray.paths.join(", ")}`);
|
|
196
|
+
yield* events.publish(Info.make({
|
|
197
|
+
message: `story ${story.id}: ${strays.length} planned task(s) name paths outside the story; re-planning:\n${describe.join("\n")}`
|
|
198
|
+
}));
|
|
199
|
+
const second = yield* planTasks([
|
|
200
|
+
prompt,
|
|
201
|
+
"",
|
|
202
|
+
"Your previous task list named paths outside this story's owned paths:",
|
|
203
|
+
...describe,
|
|
204
|
+
"Plan again: only work inside the owned paths. Work on any other path is not this story's."
|
|
205
|
+
].join("\n"));
|
|
206
|
+
const remaining = strayTasks(plan, story, second.tasks);
|
|
207
|
+
const foreign = new Set(remaining.filter((stray) => stray.foreign).map((stray) => stray.task));
|
|
208
|
+
const kept = second.tasks.filter((task) => !foreign.has(task));
|
|
209
|
+
if (remaining.length > 0) {
|
|
210
|
+
yield* events.publish(Info.make({
|
|
211
|
+
message: `story ${story.id}: the re-plan still names paths outside the story` +
|
|
212
|
+
(foreign.size > 0
|
|
213
|
+
? `; dropped ${foreign.size} task(s) that belong to other stories`
|
|
214
|
+
: "")
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
return kept.length === 0 ? second : Plan.make({ ...second, tasks: kept });
|
|
218
|
+
});
|
|
219
|
+
/**
|
|
220
|
+
* Why a BLOCKED_ON claim is wrong, when the plan alone can tell: the named
|
|
221
|
+
* path is the story's own, a dependency's (already merged into the worktree),
|
|
222
|
+
* or a dependent's (built later on top of this story). Undefined when the
|
|
223
|
+
* plan cannot settle it — a path no story owns, or a parallel story's.
|
|
224
|
+
*/
|
|
225
|
+
export const blockedRebuttal = (plan, story, need) => {
|
|
226
|
+
for (const path of pathsNamedIn(plan, need)) {
|
|
227
|
+
const owner = ownerOf(plan, path);
|
|
228
|
+
if (owner === undefined) {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
switch (relationOf(plan, story, owner)) {
|
|
232
|
+
case "self":
|
|
233
|
+
return `${path} is under your own owned paths: building it is your job, not another story's.`;
|
|
234
|
+
case "dependent":
|
|
235
|
+
return (`${path} belongs to story '${owner.id}', which runs AFTER you and builds on your work. ` +
|
|
236
|
+
"Leave it alone: it is neither missing nor a blocker for this story.");
|
|
237
|
+
case "dependency":
|
|
238
|
+
return (`${path} belongs to story '${owner.id}', merged into the epic branch before you started, ` +
|
|
239
|
+
`so it is already in your working tree: read it there. That story provides: ${owner.provides.join("; ")}.`);
|
|
240
|
+
case "parallel":
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return undefined;
|
|
245
|
+
};
|
|
246
|
+
/** A second opinion on a BLOCKED_ON claim the plan cannot settle. */
|
|
247
|
+
export class BlockedVerdict extends Schema.Class("BlockedVerdict")({
|
|
248
|
+
/** True: the work is genuinely missing and owned by no dependency. */
|
|
249
|
+
real: Schema.Boolean,
|
|
250
|
+
/** Why — shown to the coder when the claim is rejected. */
|
|
251
|
+
reason: Schema.String
|
|
252
|
+
}) {
|
|
253
|
+
}
|
|
152
254
|
// ---- BLOCKED_ON detection ---------------------------------------------------
|
|
153
255
|
const watchStream = (stream, blocked) => Stream.unwrap(Effect.gen(function* () {
|
|
154
256
|
const text = yield* Ref.make("");
|
|
@@ -171,6 +273,12 @@ export const watchForBlockedOn = (service, blocked) => ({
|
|
|
171
273
|
executeStream: (prompt) => watchStream(service.executeStream(prompt), blocked),
|
|
172
274
|
executeStreamWithHistory: (messages) => watchStream(service.executeStreamWithHistory(messages), blocked)
|
|
173
275
|
});
|
|
276
|
+
const combined = (first, second) => first.isClean && second.isClean
|
|
277
|
+
? first
|
|
278
|
+
: ReviewResult.make({
|
|
279
|
+
issues: [...first.issues, ...second.issues],
|
|
280
|
+
summary: [first.summary, second.summary].filter((part) => part.length > 0).join("; ")
|
|
281
|
+
});
|
|
174
282
|
const issueLines = (result) => result.issues.map((issue) => `- ${issue.title}: ${issue.description}`).join("\n");
|
|
175
283
|
const failed = (story, reason) => StoryFailed.make({ story: story.id, reason });
|
|
176
284
|
// ---- The executor -------------------------------------------------------------
|
|
@@ -183,6 +291,22 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
183
291
|
const judgeRounds = Math.max(1, options.judgeRounds ?? 2);
|
|
184
292
|
const statePath = (story) => join(options.stateDir, `stories/${story.id}.json`);
|
|
185
293
|
const planPath = (story) => join(options.stateDir, `stories/${story.id}.plan.md`);
|
|
294
|
+
/**
|
|
295
|
+
* The epic checkout must hold nothing uncommitted: every merge and epic
|
|
296
|
+
* gate runs there, and git refuses a merge that would overwrite a stray
|
|
297
|
+
* file. A dirty checkout means a coder escaped its worktree.
|
|
298
|
+
*/
|
|
299
|
+
const epicCheckoutClean = (story) => Effect.gen(function* () {
|
|
300
|
+
const paths = statusPaths(yield* context.git.status);
|
|
301
|
+
if (paths.length > 0) {
|
|
302
|
+
return yield* EpicCheckoutDirty.make({
|
|
303
|
+
checkout: context.workDir,
|
|
304
|
+
paths,
|
|
305
|
+
...(story === undefined ? {} : { story: story.id })
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
yield* epicCheckoutClean();
|
|
186
310
|
yield* stage(events, "epic branch", context.git.checkoutOrCreate(epicBranch));
|
|
187
311
|
const waves = topologicalWaves(plan);
|
|
188
312
|
const waveOf = (id) => {
|
|
@@ -201,6 +325,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
201
325
|
const mergeLock = yield* Semaphore.make(1);
|
|
202
326
|
/** Merge the story branch into the epic branch and re-gate the epic head, one story at a time. */
|
|
203
327
|
const integrate = (story, branch) => mergeLock.withPermit(Effect.gen(function* () {
|
|
328
|
+
yield* epicCheckoutClean(story);
|
|
204
329
|
const checkpoint = yield* context.git.checkpoint;
|
|
205
330
|
yield* context.git.merge(branch, `${plan.epicId}: merge story ${story.id}`);
|
|
206
331
|
const gate = yield* options.gates(context.workDir);
|
|
@@ -245,41 +370,177 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
245
370
|
return { state, alreadyMerged: false };
|
|
246
371
|
}
|
|
247
372
|
// Resume: the branch exists; the worktree may not (a worktree has a
|
|
248
|
-
// `.git` FILE at its root, which is how its presence is checked).
|
|
249
|
-
|
|
373
|
+
// `.git` FILE at its root, which is how its presence is checked). A
|
|
374
|
+
// worktree left under an older root moves to the current one, work and
|
|
375
|
+
// all, so every story of the epic runs under the same root.
|
|
376
|
+
let state = stored;
|
|
377
|
+
if (stored.worktree !== worktree) {
|
|
378
|
+
if ((yield* files.read(join(stored.worktree, ".git"))) !== undefined) {
|
|
379
|
+
yield* context.git.moveWorktree(stored.worktree, worktree);
|
|
380
|
+
yield* events.publish(Info.make({ message: `story ${story.id}: moved its worktree to ${worktree}` }));
|
|
381
|
+
}
|
|
382
|
+
state = StoryState.make({ ...stored, worktree });
|
|
383
|
+
yield* saveVersioned(files, path, StoryStateVersion, StoryState, state);
|
|
384
|
+
}
|
|
385
|
+
const marker = yield* files.read(join(state.worktree, ".git"));
|
|
250
386
|
if (marker === undefined) {
|
|
251
|
-
yield* context.git.addWorktree(
|
|
387
|
+
yield* context.git.addWorktree(state.worktree, state.branch);
|
|
252
388
|
}
|
|
253
|
-
yield* events.publish(Info.make({ message: `story ${story.id}: resuming ${
|
|
254
|
-
return { state
|
|
389
|
+
yield* events.publish(Info.make({ message: `story ${story.id}: resuming ${state.branch}` }));
|
|
390
|
+
return { state, alreadyMerged: false };
|
|
391
|
+
});
|
|
392
|
+
/**
|
|
393
|
+
* Brings a story branch up to date with the epic branch. A story that
|
|
394
|
+
* started before a dependency was re-merged (or that resumes after other
|
|
395
|
+
* stories merged) would otherwise build on, and be judged against, code
|
|
396
|
+
* the epic no longer has. Conflicting hunks take the epic's side: the epic
|
|
397
|
+
* holds only other stories' merged work, and those paths are theirs.
|
|
398
|
+
*/
|
|
399
|
+
const catchUp = (story, git) => Effect.gen(function* () {
|
|
400
|
+
if (yield* git.isAncestor(epicBranch, "HEAD")) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if ((yield* git.uncommittedFiles).length > 0) {
|
|
404
|
+
yield* git.commitAll(`${story.id}: work in progress`);
|
|
405
|
+
}
|
|
406
|
+
yield* git.merge(epicBranch, `${story.id}: catch up with ${epicBranch}`, {
|
|
407
|
+
preferIncoming: true
|
|
408
|
+
});
|
|
409
|
+
yield* events.publish(Info.make({ message: `story ${story.id}: caught up with ${epicBranch}` }));
|
|
410
|
+
});
|
|
411
|
+
/** The perimeter over everything the branch changed, committed or not. */
|
|
412
|
+
const perimeterNow = (story, git) => Effect.gen(function* () {
|
|
413
|
+
const committed = yield* git.changedFilesVsBase(epicBranch);
|
|
414
|
+
const uncommitted = yield* git.uncommittedFiles;
|
|
415
|
+
return checkPerimeter([...new Set([...committed, ...uncommitted])], story);
|
|
255
416
|
});
|
|
256
417
|
/** Everything that happens inside a story's worktree: plan, implement, judge, perimeter. */
|
|
257
418
|
const implementStory = Effect.fn("@llm4ts/flow/Stories.implementStory")(function* (story, state) {
|
|
258
419
|
const seats = yield* options.contextFor(state.worktree);
|
|
259
420
|
const blocked = yield* Ref.make(undefined);
|
|
421
|
+
const pushbacks = yield* Ref.make(0);
|
|
260
422
|
const storyContext = {
|
|
261
423
|
...seats.context,
|
|
262
424
|
coder: watchForBlockedOn(seats.context.coder, blocked)
|
|
263
425
|
};
|
|
426
|
+
const git = storyContext.git;
|
|
264
427
|
const watchedSeats = { ...seats, context: storyContext };
|
|
265
428
|
const extra = options.system === undefined ? undefined : yield* options.system(story);
|
|
266
|
-
const system = [
|
|
429
|
+
const system = [
|
|
430
|
+
perimeterRules(story, { plan, worktree: state.worktree, epicCheckout: context.workDir }),
|
|
431
|
+
extra
|
|
432
|
+
]
|
|
267
433
|
.filter((part) => part !== undefined && part.trim().length > 0)
|
|
268
434
|
.join("\n\n");
|
|
269
435
|
const prompt = storyPrompt(story);
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
436
|
+
// Catch up before setup: the epic may have changed the manifest too.
|
|
437
|
+
yield* catchUp(story, git);
|
|
438
|
+
if (options.setup !== undefined) {
|
|
439
|
+
yield* stage(events, `story ${story.id}: setup`, options.setup(state.worktree));
|
|
440
|
+
}
|
|
441
|
+
// The target's gates plus the perimeter: a stray path is a gate failure
|
|
442
|
+
// the task review loop hands back to the coder before anything commits.
|
|
443
|
+
const gates = Effect.gen(function* () {
|
|
444
|
+
const target = yield* options.gates(state.worktree);
|
|
445
|
+
const changed = yield* perimeterNow(story, git);
|
|
446
|
+
return combined(target, perimeterGate([...changed.sharedReadOnly, ...changed.outside], story));
|
|
447
|
+
});
|
|
448
|
+
const coderTurn = (text) => Effect.flatMap(makeChat(storyContext.coder, { system, events, agent: "coder" }), (chat) => chat.ask(text));
|
|
449
|
+
const commitGreen = (message, why) => Effect.gen(function* () {
|
|
450
|
+
const regated = yield* gates;
|
|
451
|
+
if (!regated.isClean) {
|
|
452
|
+
return yield* failed(story, `gates broke ${why}:\n${issueLines(regated)}`);
|
|
453
|
+
}
|
|
454
|
+
yield* git.commitAll(`${story.id}: ${message}`);
|
|
455
|
+
});
|
|
456
|
+
/**
|
|
457
|
+
* Runs `effect`; a BLOCKED_ON the plan or the verifier refutes sends the
|
|
458
|
+
* coder back once with the reason, then `resume` runs. Any other claim —
|
|
459
|
+
* or a second one — ends the story as a typed MissingDependency.
|
|
460
|
+
*/
|
|
461
|
+
const guarded = (effect, resume) => Effect.gen(function* () {
|
|
462
|
+
const exit = yield* Effect.exit(effect);
|
|
463
|
+
const need = yield* Ref.get(blocked);
|
|
464
|
+
if (need === undefined) {
|
|
465
|
+
if (Exit.isFailure(exit)) {
|
|
466
|
+
return yield* Effect.failCause(exit.cause);
|
|
467
|
+
}
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const missing = MissingDependency.make({ story: story.id, need });
|
|
471
|
+
if ((yield* Ref.get(pushbacks)) > 0) {
|
|
472
|
+
return yield* missing;
|
|
473
|
+
}
|
|
474
|
+
let rebuttal = blockedRebuttal(plan, story, need);
|
|
475
|
+
if (rebuttal === undefined && options.verifyBlocked !== undefined) {
|
|
476
|
+
const verdict = yield* options
|
|
477
|
+
.verifyBlocked(story, need, state.worktree)
|
|
478
|
+
.pipe(Effect.catch(() => Effect.succeed(undefined)));
|
|
479
|
+
rebuttal = verdict === undefined || verdict.real ? undefined : verdict.reason;
|
|
480
|
+
}
|
|
481
|
+
if (rebuttal === undefined) {
|
|
482
|
+
return yield* missing;
|
|
483
|
+
}
|
|
484
|
+
yield* Ref.set(blocked, undefined);
|
|
485
|
+
yield* Ref.update(pushbacks, (count) => count + 1);
|
|
486
|
+
yield* events.publish(Info.make({ message: `story ${story.id}: BLOCKED_ON rejected — ${rebuttal}` }));
|
|
487
|
+
yield* coderTurn([
|
|
488
|
+
`You stopped with "${blockedOnSentinel} ${need}".`,
|
|
489
|
+
"That claim was checked against the epic's plan and rejected:",
|
|
490
|
+
rebuttal,
|
|
491
|
+
"",
|
|
492
|
+
"It is not a missing dependency. Continue the story inside your owned paths until it is",
|
|
493
|
+
`complete, then stop. Use ${blockedOnSentinel} only for work another story owns that is`,
|
|
494
|
+
"absent from your working tree."
|
|
495
|
+
].join("\n"));
|
|
496
|
+
const again = yield* Ref.get(blocked);
|
|
497
|
+
if (again !== undefined) {
|
|
498
|
+
return yield* MissingDependency.make({ story: story.id, need: again });
|
|
499
|
+
}
|
|
500
|
+
yield* commitGreen("continue after a rejected BLOCKED_ON", "after a rejected BLOCKED_ON");
|
|
501
|
+
yield* guarded(resume, Effect.void);
|
|
502
|
+
});
|
|
503
|
+
/**
|
|
504
|
+
* Stray paths go back before a judge sees the branch: first the coder is
|
|
505
|
+
* asked to move or revert them, then whatever is left is restored from
|
|
506
|
+
* the epic branch — a deterministic rule never needs a judge round.
|
|
507
|
+
*/
|
|
508
|
+
const repairPerimeter = Effect.gen(function* () {
|
|
509
|
+
const first = yield* perimeterNow(story, git);
|
|
510
|
+
if (isWithinPerimeter(first)) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const violation = PerimeterViolation.make({
|
|
514
|
+
story: story.id,
|
|
515
|
+
outside: first.outside,
|
|
516
|
+
sharedReadOnly: first.sharedReadOnly
|
|
517
|
+
});
|
|
518
|
+
yield* guarded(coderTurn([
|
|
519
|
+
violation.message,
|
|
520
|
+
"",
|
|
521
|
+
"Put each of these paths back as it is on the epic branch (restore a changed file, delete",
|
|
522
|
+
"a file you created there). Work that belongs to this story moves into your owned paths.",
|
|
523
|
+
"Then stop."
|
|
524
|
+
].join("\n")), Effect.void);
|
|
525
|
+
const second = yield* perimeterNow(story, git);
|
|
526
|
+
if (!isWithinPerimeter(second)) {
|
|
527
|
+
const stray = [...second.sharedReadOnly, ...second.outside];
|
|
528
|
+
yield* git.restorePaths(epicBranch, stray);
|
|
529
|
+
yield* events.publish(Info.make({
|
|
530
|
+
message: `story ${story.id}: restored ${stray.length} path(s) outside its perimeter from ${epicBranch}: ${stray.join(", ")}`
|
|
531
|
+
}));
|
|
532
|
+
}
|
|
533
|
+
yield* git.commitAll(`${story.id}: put paths outside the perimeter back`);
|
|
534
|
+
});
|
|
535
|
+
const planTasks = options.planTasks ?? defaultPlanTasks;
|
|
536
|
+
const implementTasks = implementPlanFlow(storyContext, {
|
|
276
537
|
store: makePlanStore(files),
|
|
277
538
|
planPath: planPath(story),
|
|
278
|
-
plan: (
|
|
539
|
+
plan: planWithinPerimeter(plan, story, (text) => planTasks(watchedSeats, story, text), prompt, events),
|
|
279
540
|
system,
|
|
280
541
|
chatPerTask: true,
|
|
281
542
|
checkoutBranch: false,
|
|
282
|
-
lint:
|
|
543
|
+
lint: gates,
|
|
283
544
|
// A story's final state is judged and gated downstream (judge round,
|
|
284
545
|
// perimeter check, epic gates), so a task the coder finds already
|
|
285
546
|
// satisfied — without saying the exact sentinel — must not sink the
|
|
@@ -287,12 +548,17 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
287
548
|
noopTaskPolicy: "complete",
|
|
288
549
|
...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
|
|
289
550
|
...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds })
|
|
290
|
-
})
|
|
551
|
+
});
|
|
552
|
+
yield* guarded(implementTasks, implementTasks);
|
|
553
|
+
// Other stories may have merged while this one ran: judge the branch
|
|
554
|
+
// against the epic as it is now.
|
|
555
|
+
yield* catchUp(story, git);
|
|
556
|
+
yield* repairPerimeter;
|
|
291
557
|
let judgeNote;
|
|
292
558
|
if (options.judge !== undefined) {
|
|
293
559
|
const judge = options.judge;
|
|
294
560
|
for (let round = 1; round <= judgeRounds; round += 1) {
|
|
295
|
-
const diff = yield*
|
|
561
|
+
const diff = yield* git.diffVsBase(epicBranch);
|
|
296
562
|
if (diff.trim().length === 0) {
|
|
297
563
|
// Nothing to judge is a deterministic failure, not a model call:
|
|
298
564
|
// a model asked to score an empty diff scores the prompt instead.
|
|
@@ -306,24 +572,16 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
306
572
|
if (round >= judgeRounds) {
|
|
307
573
|
return yield* failed(story, `judge not cleared after ${judgeRounds} round(s):\n${issueLines(verdict)}`);
|
|
308
574
|
}
|
|
309
|
-
|
|
310
|
-
system,
|
|
311
|
-
events,
|
|
312
|
-
agent: "coder"
|
|
313
|
-
});
|
|
314
|
-
yield* blockedOr(feedback.ask([
|
|
575
|
+
yield* guarded(coderTurn([
|
|
315
576
|
`The story "${story.title}" scored below the bar. Close these gaps without`,
|
|
316
577
|
"weakening any test and without leaving your owned paths, then stop:",
|
|
317
578
|
issueLines(verdict)
|
|
318
|
-
].join("\n")));
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
return yield* failed(story, `gates broke while addressing judge feedback:\n${issueLines(regated)}`);
|
|
322
|
-
}
|
|
323
|
-
yield* storyContext.git.commitAll(`${story.id}: address judge feedback`);
|
|
579
|
+
].join("\n")), Effect.void);
|
|
580
|
+
yield* repairPerimeter;
|
|
581
|
+
yield* commitGreen("address judge feedback", "while addressing judge feedback");
|
|
324
582
|
}
|
|
325
583
|
}
|
|
326
|
-
const changed = yield*
|
|
584
|
+
const changed = yield* git.changedFilesVsBase(epicBranch);
|
|
327
585
|
yield* enforcePerimeter(changed, story);
|
|
328
586
|
const totals = seats.totals === undefined ? undefined : yield* seats.totals;
|
|
329
587
|
return { judge: judgeNote, totals };
|
|
@@ -341,9 +599,6 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
341
599
|
judge: "merged on a previous run"
|
|
342
600
|
});
|
|
343
601
|
}
|
|
344
|
-
if (options.setup !== undefined) {
|
|
345
|
-
yield* stage(events, `story ${story.id}: setup`, options.setup(state.worktree));
|
|
346
|
-
}
|
|
347
602
|
const result = yield* Effect.scoped(implementStory(story, state));
|
|
348
603
|
yield* integrate(story, state.branch);
|
|
349
604
|
yield* saveVersioned(files, statePath(story), StoryStateVersion, StoryState, StoryState.make({ ...state, status: "merged" }));
|
|
@@ -365,7 +620,12 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
365
620
|
if (stored !== undefined && stored.status !== "merged") {
|
|
366
621
|
yield* saveVersioned(files, path, StoryStateVersion, StoryState, StoryState.make({ ...stored, status: "failed" })).pipe(Effect.ignore);
|
|
367
622
|
}
|
|
368
|
-
|
|
623
|
+
const interruption = error._tag === "EpicCheckoutDirty"
|
|
624
|
+
? "halt"
|
|
625
|
+
: isOutageMessage(reason)
|
|
626
|
+
? "outage"
|
|
627
|
+
: undefined;
|
|
628
|
+
const completion = {
|
|
369
629
|
story,
|
|
370
630
|
outcome: StoryOutcome.make({
|
|
371
631
|
id: story.id,
|
|
@@ -373,8 +633,10 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
373
633
|
status: "failed",
|
|
374
634
|
reason,
|
|
375
635
|
...(stored === undefined ? {} : { branch: stored.branch })
|
|
376
|
-
})
|
|
636
|
+
}),
|
|
637
|
+
...(interruption === undefined ? {} : { interruption })
|
|
377
638
|
};
|
|
639
|
+
return completion;
|
|
378
640
|
})));
|
|
379
641
|
const outcomes = yield* Ref.make([]);
|
|
380
642
|
const running = yield* Ref.make(new Set());
|
|
@@ -406,14 +668,20 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
406
668
|
yield* board.wait(id, reason);
|
|
407
669
|
}
|
|
408
670
|
});
|
|
671
|
+
/** Why no further story may start in this run; stories already running finish. */
|
|
672
|
+
let halted;
|
|
673
|
+
/** Stories retried once after the serving engine recovered. */
|
|
674
|
+
const recovered = new Set();
|
|
409
675
|
yield* Effect.scoped(Effect.gen(function* () {
|
|
410
676
|
while (true) {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
677
|
+
if (halted === undefined) {
|
|
678
|
+
const current = yield* progress;
|
|
679
|
+
const ready = readyStories(plan, current);
|
|
680
|
+
const slots = concurrency - current.running.size;
|
|
681
|
+
for (const story of ready.slice(0, Math.max(0, slots))) {
|
|
682
|
+
yield* Ref.update(running, (set) => new Set([...set, story.id]));
|
|
683
|
+
yield* Effect.forkScoped(attempt(story).pipe(Effect.flatMap((completion) => Queue.offer(completions, completion))));
|
|
684
|
+
}
|
|
417
685
|
}
|
|
418
686
|
if ((yield* Ref.get(running)).size === 0) {
|
|
419
687
|
break;
|
|
@@ -424,6 +692,27 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
424
692
|
next.delete(completion.story.id);
|
|
425
693
|
return next;
|
|
426
694
|
});
|
|
695
|
+
const reason = completion.outcome.reason ?? "failed";
|
|
696
|
+
if (completion.interruption === "outage" && halted === undefined) {
|
|
697
|
+
if (options.awaitRecovery !== undefined && !recovered.has(completion.story.id)) {
|
|
698
|
+
recovered.add(completion.story.id);
|
|
699
|
+
yield* events.publish(Info.make({
|
|
700
|
+
message: `story ${completion.story.id}: the serving engine is down; waiting for it to recover, then retrying the story`
|
|
701
|
+
}));
|
|
702
|
+
const back = yield* Effect.result(options.awaitRecovery(reason));
|
|
703
|
+
if (back._tag === "Success") {
|
|
704
|
+
// Not recorded: the story is ready again and relaunches.
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
halted = `the serving engine did not recover (${describeFlowError(back.failure)})`;
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
halted = `the serving engine is down: ${reason}`;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
if (completion.interruption === "halt" && halted === undefined) {
|
|
714
|
+
halted = reason;
|
|
715
|
+
}
|
|
427
716
|
yield* record(completion.outcome);
|
|
428
717
|
const outcome = completion.outcome;
|
|
429
718
|
if (outcome.status === "done") {
|
|
@@ -450,6 +739,19 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
450
739
|
}
|
|
451
740
|
}
|
|
452
741
|
}));
|
|
742
|
+
if (halted !== undefined) {
|
|
743
|
+
const stopped = halted;
|
|
744
|
+
yield* events.publish(Info.make({ message: `epic ${plan.epicId}: stopped launching stories — ${stopped}` }));
|
|
745
|
+
const known = new Set((yield* Ref.get(outcomes)).map((outcome) => outcome.id));
|
|
746
|
+
for (const story of plan.stories) {
|
|
747
|
+
if (known.has(story.id)) {
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
const reason = `not started: ${stopped}`;
|
|
751
|
+
yield* record(StoryOutcome.make({ id: story.id, title: story.title, status: "waiting", reason }));
|
|
752
|
+
yield* board.wait(story.id, reason);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
453
755
|
const recorded = yield* Ref.get(outcomes);
|
|
454
756
|
const ordered = plan.stories.flatMap((story) => {
|
|
455
757
|
const outcome = recorded.find((candidate) => candidate.id === story.id);
|