@llm4ts/flow 2.7.0 → 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/CostTracker.d.ts +4 -2
- package/dist/CostTracker.d.ts.map +1 -1
- package/dist/CostTracker.js +2 -5
- package/dist/CostTracker.js.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/FlowEvents.d.ts +16 -0
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +25 -1
- package/dist/FlowEvents.js.map +1 -1
- package/dist/FlowRecorder.d.ts +4 -2
- package/dist/FlowRecorder.d.ts.map +1 -1
- package/dist/FlowRecorder.js +2 -5
- package/dist/FlowRecorder.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/JudgmentLog.d.ts +3 -1
- package/dist/JudgmentLog.d.ts.map +1 -1
- package/dist/JudgmentLog.js +2 -6
- package/dist/JudgmentLog.js.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/CostTracker.ts +5 -14
- package/src/FlowError.ts +33 -3
- package/src/FlowEvents.ts +42 -2
- package/src/FlowRecorder.ts +5 -14
- package/src/GitTool.ts +99 -5
- package/src/JudgmentLog.ts +6 -14
- 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/src/Stories.ts
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"
|
|
@@ -18,29 +19,49 @@ import { BoardItem, type BoardSyncShape } from "./BoardSync.ts"
|
|
|
18
19
|
import { makeChat } from "./Chat.ts"
|
|
19
20
|
import { implementPlanFlow } from "./Flow.ts"
|
|
20
21
|
import type { FlowContextShape } from "./FlowContext.ts"
|
|
21
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
describeFlowError,
|
|
24
|
+
EpicCheckoutDirty,
|
|
25
|
+
MissingDependency,
|
|
26
|
+
PerimeterViolation,
|
|
27
|
+
StoryFailed,
|
|
28
|
+
type FlowError
|
|
29
|
+
} from "./FlowError.ts"
|
|
22
30
|
import { Info } from "./FlowEvents.ts"
|
|
23
|
-
import {
|
|
31
|
+
import { statusPaths, type GitToolShape } from "./GitTool.ts"
|
|
32
|
+
import {
|
|
33
|
+
checkPerimeter,
|
|
34
|
+
enforcePerimeter,
|
|
35
|
+
isWithinPerimeter,
|
|
36
|
+
perimeterGate,
|
|
37
|
+
strayTasks,
|
|
38
|
+
type PerimeterCheck
|
|
39
|
+
} from "./Perimeter.ts"
|
|
24
40
|
import {
|
|
25
41
|
loadVersioned,
|
|
26
42
|
makePlanStore,
|
|
27
43
|
saveVersioned,
|
|
28
44
|
type PlainFileStoreShape
|
|
29
45
|
} from "./Persistence.ts"
|
|
30
|
-
import
|
|
46
|
+
import { Plan } from "./Plan.ts"
|
|
31
47
|
import { stage } from "./PlanExecution.ts"
|
|
32
48
|
import { planFrom } from "./Planner.ts"
|
|
33
|
-
import
|
|
49
|
+
import { ReviewResult } from "./Review.ts"
|
|
34
50
|
import type { Reviewer } from "./Reviewer.ts"
|
|
35
51
|
import {
|
|
36
52
|
dependentsOf,
|
|
53
|
+
ownerOf,
|
|
54
|
+
pathsNamedIn,
|
|
37
55
|
readyStories,
|
|
56
|
+
relationOf,
|
|
38
57
|
storyHash,
|
|
39
58
|
topologicalWaves,
|
|
40
59
|
validateStoryPlan,
|
|
41
60
|
type Story,
|
|
42
|
-
type StoryPlan
|
|
61
|
+
type StoryPlan,
|
|
62
|
+
type StoryRelation
|
|
43
63
|
} from "./StoryPlan.ts"
|
|
64
|
+
import { isOutageMessage } from "./TransientRetry.ts"
|
|
44
65
|
|
|
45
66
|
const join = (root: string, path: string): string =>
|
|
46
67
|
`${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`
|
|
@@ -172,8 +193,43 @@ export const blockedOnIn = (text: string): string | undefined => {
|
|
|
172
193
|
const bullets = (items: ReadonlyArray<string>): string =>
|
|
173
194
|
items.length === 0 ? "- (none)" : items.map((item) => `- ${item}`).join("\n")
|
|
174
195
|
|
|
196
|
+
/** Where a story runs, which is what lets its rules name the other stories and checkouts. */
|
|
197
|
+
export interface StoryWhereabouts {
|
|
198
|
+
readonly plan: StoryPlan
|
|
199
|
+
/** The story's own worktree — the coder's working directory. */
|
|
200
|
+
readonly worktree: string
|
|
201
|
+
/** The epic branch's checkout, which no coder may touch. */
|
|
202
|
+
readonly epicCheckout: string
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const relationNotes: Readonly<Record<Exclude<StoryRelation, "self">, string>> = {
|
|
206
|
+
dependency: "merged into the epic before you started — it is in your working tree; read it",
|
|
207
|
+
dependent:
|
|
208
|
+
"built AFTER you, on top of your work — leave it alone (wiring your work in is that story's job)",
|
|
209
|
+
parallel: "built at the same time by another coder — leave it alone"
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const whereaboutsRules = (story: Story, where: StoryWhereabouts): ReadonlyArray<string> => {
|
|
213
|
+
const others = where.plan.stories
|
|
214
|
+
.filter((other) => other.id !== story.id)
|
|
215
|
+
.map((other) => {
|
|
216
|
+
const relation = relationOf(where.plan, story, other)
|
|
217
|
+
const note = relation === "self" ? "" : relationNotes[relation]
|
|
218
|
+
return `${other.owned.join(", ")} — story ${other.id}: ${note}`
|
|
219
|
+
})
|
|
220
|
+
return [
|
|
221
|
+
"",
|
|
222
|
+
`Your working directory is ${where.worktree}. Run every command there. Never cd into, read`,
|
|
223
|
+
`from, or write to any other checkout — above all not ${where.epicCheckout}, the epic`,
|
|
224
|
+
"branch's checkout: a change there breaks every other story's merge.",
|
|
225
|
+
"",
|
|
226
|
+
"Paths other stories own — never create or change them, and none of them is a reason to stop:",
|
|
227
|
+
bullets(others)
|
|
228
|
+
]
|
|
229
|
+
}
|
|
230
|
+
|
|
175
231
|
/** The hard rules every story coder receives; the perimeter check enforces them afterwards. */
|
|
176
|
-
export const perimeterRules = (story: Story): string =>
|
|
232
|
+
export const perimeterRules = (story: Story, where?: StoryWhereabouts): string =>
|
|
177
233
|
[
|
|
178
234
|
`You are implementing ONE story of a larger epic: "${story.title}" (id: ${story.id}).`,
|
|
179
235
|
"",
|
|
@@ -185,6 +241,7 @@ export const perimeterRules = (story: Story): string =>
|
|
|
185
241
|
"",
|
|
186
242
|
"What this story must provide for other stories:",
|
|
187
243
|
bullets(story.provides),
|
|
244
|
+
...(where === undefined ? [] : whereaboutsRules(story, where)),
|
|
188
245
|
"",
|
|
189
246
|
"Rules:",
|
|
190
247
|
"- Do not touch any path outside the owned list; a change there fails the story.",
|
|
@@ -204,7 +261,10 @@ export const storyTaskPlanInstructions = (story: Story): string =>
|
|
|
204
261
|
[
|
|
205
262
|
"You are planning the implementation of ONE story inside a larger epic. Break the story",
|
|
206
263
|
"into an ordered list of small, independently verifiable tasks, each described by its",
|
|
207
|
-
"observable outcome. Every task must stay inside the story's owned paths
|
|
264
|
+
"observable outcome. Every task must stay inside the story's owned paths:",
|
|
265
|
+
bullets(story.owned),
|
|
266
|
+
"Never plan a task that creates or changes anything outside them — registering or wiring",
|
|
267
|
+
"the story in elsewhere (the app's composition point, a shared kit file) is another story's job.",
|
|
208
268
|
`Use exactly this epicId: "${story.id}".`,
|
|
209
269
|
'Respond only with JSON: {"epicId":"' + story.id + '","tasks":',
|
|
210
270
|
'[{"title":"...","description":"...","completed":false}]}'
|
|
@@ -218,6 +278,100 @@ export const defaultPlanTasks = (
|
|
|
218
278
|
): Effect.Effect<Plan, FlowError> =>
|
|
219
279
|
planFrom(seats.context.coder, prompt, storyTaskPlanInstructions(story))
|
|
220
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Asks the planner again, once, when its tasks name paths outside the story;
|
|
283
|
+
* tasks that still name ANOTHER story's paths are then dropped (that work is
|
|
284
|
+
* planned elsewhere) and any remaining stray is left to the perimeter gate.
|
|
285
|
+
*/
|
|
286
|
+
export const planWithinPerimeter = (
|
|
287
|
+
plan: StoryPlan,
|
|
288
|
+
story: Story,
|
|
289
|
+
planTasks: (prompt: string) => Effect.Effect<Plan, FlowError>,
|
|
290
|
+
prompt: string,
|
|
291
|
+
events: FlowContextShape["events"]
|
|
292
|
+
): Effect.Effect<Plan, FlowError> =>
|
|
293
|
+
Effect.gen(function* () {
|
|
294
|
+
const first = yield* planTasks(prompt)
|
|
295
|
+
const strays = strayTasks(plan, story, first.tasks)
|
|
296
|
+
if (strays.length === 0) {
|
|
297
|
+
return first
|
|
298
|
+
}
|
|
299
|
+
const describe = strays.map((stray) => `- "${stray.task.title}": ${stray.paths.join(", ")}`)
|
|
300
|
+
yield* events.publish(
|
|
301
|
+
Info.make({
|
|
302
|
+
message: `story ${story.id}: ${strays.length} planned task(s) name paths outside the story; re-planning:\n${describe.join("\n")}`
|
|
303
|
+
})
|
|
304
|
+
)
|
|
305
|
+
const second = yield* planTasks(
|
|
306
|
+
[
|
|
307
|
+
prompt,
|
|
308
|
+
"",
|
|
309
|
+
"Your previous task list named paths outside this story's owned paths:",
|
|
310
|
+
...describe,
|
|
311
|
+
"Plan again: only work inside the owned paths. Work on any other path is not this story's."
|
|
312
|
+
].join("\n")
|
|
313
|
+
)
|
|
314
|
+
const remaining = strayTasks(plan, story, second.tasks)
|
|
315
|
+
const foreign = new Set(remaining.filter((stray) => stray.foreign).map((stray) => stray.task))
|
|
316
|
+
const kept = second.tasks.filter((task) => !foreign.has(task))
|
|
317
|
+
if (remaining.length > 0) {
|
|
318
|
+
yield* events.publish(
|
|
319
|
+
Info.make({
|
|
320
|
+
message:
|
|
321
|
+
`story ${story.id}: the re-plan still names paths outside the story` +
|
|
322
|
+
(foreign.size > 0
|
|
323
|
+
? `; dropped ${foreign.size} task(s) that belong to other stories`
|
|
324
|
+
: "")
|
|
325
|
+
})
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
return kept.length === 0 ? second : Plan.make({ ...second, tasks: kept })
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Why a BLOCKED_ON claim is wrong, when the plan alone can tell: the named
|
|
333
|
+
* path is the story's own, a dependency's (already merged into the worktree),
|
|
334
|
+
* or a dependent's (built later on top of this story). Undefined when the
|
|
335
|
+
* plan cannot settle it — a path no story owns, or a parallel story's.
|
|
336
|
+
*/
|
|
337
|
+
export const blockedRebuttal = (
|
|
338
|
+
plan: StoryPlan,
|
|
339
|
+
story: Story,
|
|
340
|
+
need: string
|
|
341
|
+
): string | undefined => {
|
|
342
|
+
for (const path of pathsNamedIn(plan, need)) {
|
|
343
|
+
const owner = ownerOf(plan, path)
|
|
344
|
+
if (owner === undefined) {
|
|
345
|
+
continue
|
|
346
|
+
}
|
|
347
|
+
switch (relationOf(plan, story, owner)) {
|
|
348
|
+
case "self":
|
|
349
|
+
return `${path} is under your own owned paths: building it is your job, not another story's.`
|
|
350
|
+
case "dependent":
|
|
351
|
+
return (
|
|
352
|
+
`${path} belongs to story '${owner.id}', which runs AFTER you and builds on your work. ` +
|
|
353
|
+
"Leave it alone: it is neither missing nor a blocker for this story."
|
|
354
|
+
)
|
|
355
|
+
case "dependency":
|
|
356
|
+
return (
|
|
357
|
+
`${path} belongs to story '${owner.id}', merged into the epic branch before you started, ` +
|
|
358
|
+
`so it is already in your working tree: read it there. That story provides: ${owner.provides.join("; ")}.`
|
|
359
|
+
)
|
|
360
|
+
case "parallel":
|
|
361
|
+
continue
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return undefined
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** A second opinion on a BLOCKED_ON claim the plan cannot settle. */
|
|
368
|
+
export class BlockedVerdict extends Schema.Class<BlockedVerdict>("BlockedVerdict")({
|
|
369
|
+
/** True: the work is genuinely missing and owned by no dependency. */
|
|
370
|
+
real: Schema.Boolean,
|
|
371
|
+
/** Why — shown to the coder when the claim is rejected. */
|
|
372
|
+
reason: Schema.String
|
|
373
|
+
}) {}
|
|
374
|
+
|
|
221
375
|
// ---- BLOCKED_ON detection ---------------------------------------------------
|
|
222
376
|
|
|
223
377
|
const watchStream = (
|
|
@@ -297,13 +451,45 @@ export interface StoriesOptions {
|
|
|
297
451
|
readonly failFast?: boolean
|
|
298
452
|
readonly reviewers?: ReadonlyArray<Reviewer>
|
|
299
453
|
readonly maxRounds?: number
|
|
454
|
+
/**
|
|
455
|
+
* Second opinion on a BLOCKED_ON claim the plan cannot settle (a path no
|
|
456
|
+
* story owns, or a parallel story's). `real: false` sends the coder back
|
|
457
|
+
* once with the reason. Omit to accept every such claim.
|
|
458
|
+
*/
|
|
459
|
+
readonly verifyBlocked?: (
|
|
460
|
+
story: Story,
|
|
461
|
+
need: string,
|
|
462
|
+
workDir: string
|
|
463
|
+
) => Effect.Effect<BlockedVerdict, FlowError>
|
|
464
|
+
/**
|
|
465
|
+
* Waits until a serving engine that went down (`isOutageMessage`) is back;
|
|
466
|
+
* the story it interrupted is then retried once. Omit, or fail it, and the
|
|
467
|
+
* executor stops launching stories instead of failing each in turn.
|
|
468
|
+
*/
|
|
469
|
+
readonly awaitRecovery?: (reason: string) => Effect.Effect<void, FlowError>
|
|
300
470
|
}
|
|
301
471
|
|
|
472
|
+
/**
|
|
473
|
+
* A failure that says nothing about the story: the serving engine went down,
|
|
474
|
+
* or the epic checkout was dirtied. Launching more stories would only fail
|
|
475
|
+
* them the same way.
|
|
476
|
+
*/
|
|
477
|
+
type Interruption = "outage" | "halt"
|
|
478
|
+
|
|
302
479
|
interface Completion {
|
|
303
480
|
readonly story: Story
|
|
304
481
|
readonly outcome: StoryOutcome
|
|
482
|
+
readonly interruption?: Interruption
|
|
305
483
|
}
|
|
306
484
|
|
|
485
|
+
const combined = (first: ReviewResult, second: ReviewResult): ReviewResult =>
|
|
486
|
+
first.isClean && second.isClean
|
|
487
|
+
? first
|
|
488
|
+
: ReviewResult.make({
|
|
489
|
+
issues: [...first.issues, ...second.issues],
|
|
490
|
+
summary: [first.summary, second.summary].filter((part) => part.length > 0).join("; ")
|
|
491
|
+
})
|
|
492
|
+
|
|
307
493
|
const issueLines = (result: ReviewResult): string =>
|
|
308
494
|
result.issues.map((issue) => `- ${issue.title}: ${issue.description}`).join("\n")
|
|
309
495
|
|
|
@@ -325,6 +511,24 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
325
511
|
const statePath = (story: Story): string => join(options.stateDir, `stories/${story.id}.json`)
|
|
326
512
|
const planPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.plan.md`)
|
|
327
513
|
|
|
514
|
+
/**
|
|
515
|
+
* The epic checkout must hold nothing uncommitted: every merge and epic
|
|
516
|
+
* gate runs there, and git refuses a merge that would overwrite a stray
|
|
517
|
+
* file. A dirty checkout means a coder escaped its worktree.
|
|
518
|
+
*/
|
|
519
|
+
const epicCheckoutClean = (story?: Story): Effect.Effect<void, FlowError> =>
|
|
520
|
+
Effect.gen(function* () {
|
|
521
|
+
const paths = statusPaths(yield* context.git.status)
|
|
522
|
+
if (paths.length > 0) {
|
|
523
|
+
return yield* EpicCheckoutDirty.make({
|
|
524
|
+
checkout: context.workDir,
|
|
525
|
+
paths,
|
|
526
|
+
...(story === undefined ? {} : { story: story.id })
|
|
527
|
+
})
|
|
528
|
+
}
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
yield* epicCheckoutClean()
|
|
328
532
|
yield* stage(events, "epic branch", context.git.checkoutOrCreate(epicBranch))
|
|
329
533
|
|
|
330
534
|
const waves = topologicalWaves(plan)
|
|
@@ -350,6 +554,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
350
554
|
const integrate = (story: Story, branch: string): Effect.Effect<void, FlowError> =>
|
|
351
555
|
mergeLock.withPermit(
|
|
352
556
|
Effect.gen(function* () {
|
|
557
|
+
yield* epicCheckoutClean(story)
|
|
353
558
|
const checkpoint = yield* context.git.checkpoint
|
|
354
559
|
yield* context.git.merge(branch, `${plan.epicId}: merge story ${story.id}`)
|
|
355
560
|
const gate = yield* options.gates(context.workDir)
|
|
@@ -409,15 +614,62 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
409
614
|
return { state, alreadyMerged: false }
|
|
410
615
|
}
|
|
411
616
|
// Resume: the branch exists; the worktree may not (a worktree has a
|
|
412
|
-
// `.git` FILE at its root, which is how its presence is checked).
|
|
413
|
-
|
|
617
|
+
// `.git` FILE at its root, which is how its presence is checked). A
|
|
618
|
+
// worktree left under an older root moves to the current one, work and
|
|
619
|
+
// all, so every story of the epic runs under the same root.
|
|
620
|
+
let state = stored
|
|
621
|
+
if (stored.worktree !== worktree) {
|
|
622
|
+
if ((yield* files.read(join(stored.worktree, ".git"))) !== undefined) {
|
|
623
|
+
yield* context.git.moveWorktree(stored.worktree, worktree)
|
|
624
|
+
yield* events.publish(
|
|
625
|
+
Info.make({ message: `story ${story.id}: moved its worktree to ${worktree}` })
|
|
626
|
+
)
|
|
627
|
+
}
|
|
628
|
+
state = StoryState.make({ ...stored, worktree })
|
|
629
|
+
yield* saveVersioned(files, path, StoryStateVersion, StoryState, state)
|
|
630
|
+
}
|
|
631
|
+
const marker = yield* files.read(join(state.worktree, ".git"))
|
|
414
632
|
if (marker === undefined) {
|
|
415
|
-
yield* context.git.addWorktree(
|
|
633
|
+
yield* context.git.addWorktree(state.worktree, state.branch)
|
|
416
634
|
}
|
|
417
|
-
yield* events.publish(Info.make({ message: `story ${story.id}: resuming ${
|
|
418
|
-
return { state
|
|
635
|
+
yield* events.publish(Info.make({ message: `story ${story.id}: resuming ${state.branch}` }))
|
|
636
|
+
return { state, alreadyMerged: false }
|
|
419
637
|
})
|
|
420
638
|
|
|
639
|
+
/**
|
|
640
|
+
* Brings a story branch up to date with the epic branch. A story that
|
|
641
|
+
* started before a dependency was re-merged (or that resumes after other
|
|
642
|
+
* stories merged) would otherwise build on, and be judged against, code
|
|
643
|
+
* the epic no longer has. Conflicting hunks take the epic's side: the epic
|
|
644
|
+
* holds only other stories' merged work, and those paths are theirs.
|
|
645
|
+
*/
|
|
646
|
+
const catchUp = (story: Story, git: GitToolShape): Effect.Effect<void, FlowError> =>
|
|
647
|
+
Effect.gen(function* () {
|
|
648
|
+
if (yield* git.isAncestor(epicBranch, "HEAD")) {
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
if ((yield* git.uncommittedFiles).length > 0) {
|
|
652
|
+
yield* git.commitAll(`${story.id}: work in progress`)
|
|
653
|
+
}
|
|
654
|
+
yield* git.merge(epicBranch, `${story.id}: catch up with ${epicBranch}`, {
|
|
655
|
+
preferIncoming: true
|
|
656
|
+
})
|
|
657
|
+
yield* events.publish(
|
|
658
|
+
Info.make({ message: `story ${story.id}: caught up with ${epicBranch}` })
|
|
659
|
+
)
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
/** The perimeter over everything the branch changed, committed or not. */
|
|
663
|
+
const perimeterNow = (
|
|
664
|
+
story: Story,
|
|
665
|
+
git: GitToolShape
|
|
666
|
+
): Effect.Effect<PerimeterCheck, FlowError> =>
|
|
667
|
+
Effect.gen(function* () {
|
|
668
|
+
const committed = yield* git.changedFilesVsBase(epicBranch)
|
|
669
|
+
const uncommitted = yield* git.uncommittedFiles
|
|
670
|
+
return checkPerimeter([...new Set([...committed, ...uncommitted])], story)
|
|
671
|
+
})
|
|
672
|
+
|
|
421
673
|
/** Everything that happens inside a story's worktree: plan, implement, judge, perimeter. */
|
|
422
674
|
const implementStory = Effect.fn("@llm4ts/flow/Stories.implementStory")(function* (
|
|
423
675
|
story: Story,
|
|
@@ -429,58 +681,181 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
429
681
|
> {
|
|
430
682
|
const seats = yield* options.contextFor(state.worktree)
|
|
431
683
|
const blocked = yield* Ref.make<string | undefined>(undefined)
|
|
684
|
+
const pushbacks = yield* Ref.make(0)
|
|
432
685
|
const storyContext: FlowContextShape = {
|
|
433
686
|
...seats.context,
|
|
434
687
|
coder: watchForBlockedOn(seats.context.coder, blocked)
|
|
435
688
|
}
|
|
689
|
+
const git = storyContext.git
|
|
436
690
|
const watchedSeats: StorySeats = { ...seats, context: storyContext }
|
|
437
691
|
const extra = options.system === undefined ? undefined : yield* options.system(story)
|
|
438
|
-
const system = [
|
|
692
|
+
const system = [
|
|
693
|
+
perimeterRules(story, { plan, worktree: state.worktree, epicCheckout: context.workDir }),
|
|
694
|
+
extra
|
|
695
|
+
]
|
|
439
696
|
.filter((part): part is string => part !== undefined && part.trim().length > 0)
|
|
440
697
|
.join("\n\n")
|
|
441
698
|
const prompt = storyPrompt(story)
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
699
|
+
|
|
700
|
+
// Catch up before setup: the epic may have changed the manifest too.
|
|
701
|
+
yield* catchUp(story, git)
|
|
702
|
+
if (options.setup !== undefined) {
|
|
703
|
+
yield* stage(events, `story ${story.id}: setup`, options.setup(state.worktree))
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// The target's gates plus the perimeter: a stray path is a gate failure
|
|
707
|
+
// the task review loop hands back to the coder before anything commits.
|
|
708
|
+
const gates: Effect.Effect<ReviewResult, FlowError> = Effect.gen(function* () {
|
|
709
|
+
const target = yield* options.gates(state.worktree)
|
|
710
|
+
const changed = yield* perimeterNow(story, git)
|
|
711
|
+
return combined(target, perimeterGate([...changed.sharedReadOnly, ...changed.outside], story))
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
const coderTurn = (text: string): Effect.Effect<string, FlowError> =>
|
|
715
|
+
Effect.flatMap(makeChat(storyContext.coder, { system, events, agent: "coder" }), (chat) =>
|
|
716
|
+
chat.ask(text)
|
|
458
717
|
)
|
|
459
718
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
checkoutBranch: false,
|
|
468
|
-
lint: options.gates(state.worktree),
|
|
469
|
-
// A story's final state is judged and gated downstream (judge round,
|
|
470
|
-
// perimeter check, epic gates), so a task the coder finds already
|
|
471
|
-
// satisfied — without saying the exact sentinel — must not sink the
|
|
472
|
-
// story: the option exists for pipelines shaped like this one.
|
|
473
|
-
noopTaskPolicy: "complete",
|
|
474
|
-
...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
|
|
475
|
-
...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds })
|
|
719
|
+
const commitGreen = (message: string, why: string): Effect.Effect<void, FlowError> =>
|
|
720
|
+
Effect.gen(function* () {
|
|
721
|
+
const regated = yield* gates
|
|
722
|
+
if (!regated.isClean) {
|
|
723
|
+
return yield* failed(story, `gates broke ${why}:\n${issueLines(regated)}`)
|
|
724
|
+
}
|
|
725
|
+
yield* git.commitAll(`${story.id}: ${message}`)
|
|
476
726
|
})
|
|
477
|
-
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Runs `effect`; a BLOCKED_ON the plan or the verifier refutes sends the
|
|
730
|
+
* coder back once with the reason, then `resume` runs. Any other claim —
|
|
731
|
+
* or a second one — ends the story as a typed MissingDependency.
|
|
732
|
+
*/
|
|
733
|
+
const guarded = <A>(
|
|
734
|
+
effect: Effect.Effect<A, FlowError>,
|
|
735
|
+
resume: Effect.Effect<void, FlowError>
|
|
736
|
+
): Effect.Effect<void, FlowError> =>
|
|
737
|
+
Effect.gen(function* () {
|
|
738
|
+
const exit = yield* Effect.exit(effect)
|
|
739
|
+
const need = yield* Ref.get(blocked)
|
|
740
|
+
if (need === undefined) {
|
|
741
|
+
if (Exit.isFailure(exit)) {
|
|
742
|
+
return yield* Effect.failCause(exit.cause)
|
|
743
|
+
}
|
|
744
|
+
return
|
|
745
|
+
}
|
|
746
|
+
const missing = MissingDependency.make({ story: story.id, need })
|
|
747
|
+
if ((yield* Ref.get(pushbacks)) > 0) {
|
|
748
|
+
return yield* missing
|
|
749
|
+
}
|
|
750
|
+
let rebuttal = blockedRebuttal(plan, story, need)
|
|
751
|
+
if (rebuttal === undefined && options.verifyBlocked !== undefined) {
|
|
752
|
+
const verdict = yield* options
|
|
753
|
+
.verifyBlocked(story, need, state.worktree)
|
|
754
|
+
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
755
|
+
rebuttal = verdict === undefined || verdict.real ? undefined : verdict.reason
|
|
756
|
+
}
|
|
757
|
+
if (rebuttal === undefined) {
|
|
758
|
+
return yield* missing
|
|
759
|
+
}
|
|
760
|
+
yield* Ref.set(blocked, undefined)
|
|
761
|
+
yield* Ref.update(pushbacks, (count) => count + 1)
|
|
762
|
+
yield* events.publish(
|
|
763
|
+
Info.make({ message: `story ${story.id}: BLOCKED_ON rejected — ${rebuttal}` })
|
|
764
|
+
)
|
|
765
|
+
yield* coderTurn(
|
|
766
|
+
[
|
|
767
|
+
`You stopped with "${blockedOnSentinel} ${need}".`,
|
|
768
|
+
"That claim was checked against the epic's plan and rejected:",
|
|
769
|
+
rebuttal,
|
|
770
|
+
"",
|
|
771
|
+
"It is not a missing dependency. Continue the story inside your owned paths until it is",
|
|
772
|
+
`complete, then stop. Use ${blockedOnSentinel} only for work another story owns that is`,
|
|
773
|
+
"absent from your working tree."
|
|
774
|
+
].join("\n")
|
|
775
|
+
)
|
|
776
|
+
const again = yield* Ref.get(blocked)
|
|
777
|
+
if (again !== undefined) {
|
|
778
|
+
return yield* MissingDependency.make({ story: story.id, need: again })
|
|
779
|
+
}
|
|
780
|
+
yield* commitGreen("continue after a rejected BLOCKED_ON", "after a rejected BLOCKED_ON")
|
|
781
|
+
yield* guarded(resume, Effect.void)
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Stray paths go back before a judge sees the branch: first the coder is
|
|
786
|
+
* asked to move or revert them, then whatever is left is restored from
|
|
787
|
+
* the epic branch — a deterministic rule never needs a judge round.
|
|
788
|
+
*/
|
|
789
|
+
const repairPerimeter: Effect.Effect<void, FlowError> = Effect.gen(function* () {
|
|
790
|
+
const first = yield* perimeterNow(story, git)
|
|
791
|
+
if (isWithinPerimeter(first)) {
|
|
792
|
+
return
|
|
793
|
+
}
|
|
794
|
+
const violation = PerimeterViolation.make({
|
|
795
|
+
story: story.id,
|
|
796
|
+
outside: first.outside,
|
|
797
|
+
sharedReadOnly: first.sharedReadOnly
|
|
798
|
+
})
|
|
799
|
+
yield* guarded(
|
|
800
|
+
coderTurn(
|
|
801
|
+
[
|
|
802
|
+
violation.message,
|
|
803
|
+
"",
|
|
804
|
+
"Put each of these paths back as it is on the epic branch (restore a changed file, delete",
|
|
805
|
+
"a file you created there). Work that belongs to this story moves into your owned paths.",
|
|
806
|
+
"Then stop."
|
|
807
|
+
].join("\n")
|
|
808
|
+
),
|
|
809
|
+
Effect.void
|
|
810
|
+
)
|
|
811
|
+
const second = yield* perimeterNow(story, git)
|
|
812
|
+
if (!isWithinPerimeter(second)) {
|
|
813
|
+
const stray = [...second.sharedReadOnly, ...second.outside]
|
|
814
|
+
yield* git.restorePaths(epicBranch, stray)
|
|
815
|
+
yield* events.publish(
|
|
816
|
+
Info.make({
|
|
817
|
+
message: `story ${story.id}: restored ${stray.length} path(s) outside its perimeter from ${epicBranch}: ${stray.join(", ")}`
|
|
818
|
+
})
|
|
819
|
+
)
|
|
820
|
+
}
|
|
821
|
+
yield* git.commitAll(`${story.id}: put paths outside the perimeter back`)
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
const planTasks = options.planTasks ?? defaultPlanTasks
|
|
825
|
+
const implementTasks = implementPlanFlow(storyContext, {
|
|
826
|
+
store: makePlanStore(files),
|
|
827
|
+
planPath: planPath(story),
|
|
828
|
+
plan: planWithinPerimeter(
|
|
829
|
+
plan,
|
|
830
|
+
story,
|
|
831
|
+
(text) => planTasks(watchedSeats, story, text),
|
|
832
|
+
prompt,
|
|
833
|
+
events
|
|
834
|
+
),
|
|
835
|
+
system,
|
|
836
|
+
chatPerTask: true,
|
|
837
|
+
checkoutBranch: false,
|
|
838
|
+
lint: gates,
|
|
839
|
+
// A story's final state is judged and gated downstream (judge round,
|
|
840
|
+
// perimeter check, epic gates), so a task the coder finds already
|
|
841
|
+
// satisfied — without saying the exact sentinel — must not sink the
|
|
842
|
+
// story: the option exists for pipelines shaped like this one.
|
|
843
|
+
noopTaskPolicy: "complete",
|
|
844
|
+
...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
|
|
845
|
+
...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds })
|
|
846
|
+
})
|
|
847
|
+
yield* guarded(implementTasks, implementTasks)
|
|
848
|
+
|
|
849
|
+
// Other stories may have merged while this one ran: judge the branch
|
|
850
|
+
// against the epic as it is now.
|
|
851
|
+
yield* catchUp(story, git)
|
|
852
|
+
yield* repairPerimeter
|
|
478
853
|
|
|
479
854
|
let judgeNote: string | undefined
|
|
480
855
|
if (options.judge !== undefined) {
|
|
481
856
|
const judge = options.judge
|
|
482
857
|
for (let round = 1; round <= judgeRounds; round += 1) {
|
|
483
|
-
const diff = yield*
|
|
858
|
+
const diff = yield* git.diffVsBase(epicBranch)
|
|
484
859
|
if (diff.trim().length === 0) {
|
|
485
860
|
// Nothing to judge is a deterministic failure, not a model call:
|
|
486
861
|
// a model asked to score an empty diff scores the prompt instead.
|
|
@@ -497,32 +872,22 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
497
872
|
`judge not cleared after ${judgeRounds} round(s):\n${issueLines(verdict)}`
|
|
498
873
|
)
|
|
499
874
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
events,
|
|
503
|
-
agent: "coder"
|
|
504
|
-
})
|
|
505
|
-
yield* blockedOr(
|
|
506
|
-
feedback.ask(
|
|
875
|
+
yield* guarded(
|
|
876
|
+
coderTurn(
|
|
507
877
|
[
|
|
508
878
|
`The story "${story.title}" scored below the bar. Close these gaps without`,
|
|
509
879
|
"weakening any test and without leaving your owned paths, then stop:",
|
|
510
880
|
issueLines(verdict)
|
|
511
881
|
].join("\n")
|
|
512
|
-
)
|
|
882
|
+
),
|
|
883
|
+
Effect.void
|
|
513
884
|
)
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
return yield* failed(
|
|
517
|
-
story,
|
|
518
|
-
`gates broke while addressing judge feedback:\n${issueLines(regated)}`
|
|
519
|
-
)
|
|
520
|
-
}
|
|
521
|
-
yield* storyContext.git.commitAll(`${story.id}: address judge feedback`)
|
|
885
|
+
yield* repairPerimeter
|
|
886
|
+
yield* commitGreen("address judge feedback", "while addressing judge feedback")
|
|
522
887
|
}
|
|
523
888
|
}
|
|
524
889
|
|
|
525
|
-
const changed = yield*
|
|
890
|
+
const changed = yield* git.changedFilesVsBase(epicBranch)
|
|
526
891
|
yield* enforcePerimeter(changed, story)
|
|
527
892
|
const totals = seats.totals === undefined ? undefined : yield* seats.totals
|
|
528
893
|
return { judge: judgeNote, totals }
|
|
@@ -543,9 +908,6 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
543
908
|
judge: "merged on a previous run"
|
|
544
909
|
})
|
|
545
910
|
}
|
|
546
|
-
if (options.setup !== undefined) {
|
|
547
|
-
yield* stage(events, `story ${story.id}: setup`, options.setup(state.worktree))
|
|
548
|
-
}
|
|
549
911
|
const result = yield* Effect.scoped(implementStory(story, state))
|
|
550
912
|
yield* integrate(story, state.branch)
|
|
551
913
|
yield* saveVersioned(
|
|
@@ -586,7 +948,13 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
586
948
|
StoryState.make({ ...stored, status: "failed" })
|
|
587
949
|
).pipe(Effect.ignore)
|
|
588
950
|
}
|
|
589
|
-
|
|
951
|
+
const interruption: Interruption | undefined =
|
|
952
|
+
error._tag === "EpicCheckoutDirty"
|
|
953
|
+
? "halt"
|
|
954
|
+
: isOutageMessage(reason)
|
|
955
|
+
? "outage"
|
|
956
|
+
: undefined
|
|
957
|
+
const completion: Completion = {
|
|
590
958
|
story,
|
|
591
959
|
outcome: StoryOutcome.make({
|
|
592
960
|
id: story.id,
|
|
@@ -594,8 +962,10 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
594
962
|
status: "failed",
|
|
595
963
|
reason,
|
|
596
964
|
...(stored === undefined ? {} : { branch: stored.branch })
|
|
597
|
-
})
|
|
965
|
+
}),
|
|
966
|
+
...(interruption === undefined ? {} : { interruption })
|
|
598
967
|
}
|
|
968
|
+
return completion
|
|
599
969
|
})
|
|
600
970
|
)
|
|
601
971
|
)
|
|
@@ -639,19 +1009,26 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
639
1009
|
}
|
|
640
1010
|
})
|
|
641
1011
|
|
|
1012
|
+
/** Why no further story may start in this run; stories already running finish. */
|
|
1013
|
+
let halted: string | undefined
|
|
1014
|
+
/** Stories retried once after the serving engine recovered. */
|
|
1015
|
+
const recovered = new Set<string>()
|
|
1016
|
+
|
|
642
1017
|
yield* Effect.scoped(
|
|
643
1018
|
Effect.gen(function* () {
|
|
644
1019
|
while (true) {
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
1020
|
+
if (halted === undefined) {
|
|
1021
|
+
const current = yield* progress
|
|
1022
|
+
const ready = readyStories(plan, current)
|
|
1023
|
+
const slots = concurrency - current.running.size
|
|
1024
|
+
for (const story of ready.slice(0, Math.max(0, slots))) {
|
|
1025
|
+
yield* Ref.update(running, (set) => new Set([...set, story.id]))
|
|
1026
|
+
yield* Effect.forkScoped(
|
|
1027
|
+
attempt(story).pipe(
|
|
1028
|
+
Effect.flatMap((completion) => Queue.offer(completions, completion))
|
|
1029
|
+
)
|
|
653
1030
|
)
|
|
654
|
-
|
|
1031
|
+
}
|
|
655
1032
|
}
|
|
656
1033
|
if ((yield* Ref.get(running)).size === 0) {
|
|
657
1034
|
break
|
|
@@ -662,6 +1039,28 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
662
1039
|
next.delete(completion.story.id)
|
|
663
1040
|
return next
|
|
664
1041
|
})
|
|
1042
|
+
const reason = completion.outcome.reason ?? "failed"
|
|
1043
|
+
if (completion.interruption === "outage" && halted === undefined) {
|
|
1044
|
+
if (options.awaitRecovery !== undefined && !recovered.has(completion.story.id)) {
|
|
1045
|
+
recovered.add(completion.story.id)
|
|
1046
|
+
yield* events.publish(
|
|
1047
|
+
Info.make({
|
|
1048
|
+
message: `story ${completion.story.id}: the serving engine is down; waiting for it to recover, then retrying the story`
|
|
1049
|
+
})
|
|
1050
|
+
)
|
|
1051
|
+
const back = yield* Effect.result(options.awaitRecovery(reason))
|
|
1052
|
+
if (back._tag === "Success") {
|
|
1053
|
+
// Not recorded: the story is ready again and relaunches.
|
|
1054
|
+
continue
|
|
1055
|
+
}
|
|
1056
|
+
halted = `the serving engine did not recover (${describeFlowError(back.failure)})`
|
|
1057
|
+
} else {
|
|
1058
|
+
halted = `the serving engine is down: ${reason}`
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
if (completion.interruption === "halt" && halted === undefined) {
|
|
1062
|
+
halted = reason
|
|
1063
|
+
}
|
|
665
1064
|
yield* record(completion.outcome)
|
|
666
1065
|
const outcome = completion.outcome
|
|
667
1066
|
if (outcome.status === "done") {
|
|
@@ -689,6 +1088,24 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
689
1088
|
})
|
|
690
1089
|
)
|
|
691
1090
|
|
|
1091
|
+
if (halted !== undefined) {
|
|
1092
|
+
const stopped = halted
|
|
1093
|
+
yield* events.publish(
|
|
1094
|
+
Info.make({ message: `epic ${plan.epicId}: stopped launching stories — ${stopped}` })
|
|
1095
|
+
)
|
|
1096
|
+
const known = new Set((yield* Ref.get(outcomes)).map((outcome) => outcome.id))
|
|
1097
|
+
for (const story of plan.stories) {
|
|
1098
|
+
if (known.has(story.id)) {
|
|
1099
|
+
continue
|
|
1100
|
+
}
|
|
1101
|
+
const reason = `not started: ${stopped}`
|
|
1102
|
+
yield* record(
|
|
1103
|
+
StoryOutcome.make({ id: story.id, title: story.title, status: "waiting", reason })
|
|
1104
|
+
)
|
|
1105
|
+
yield* board.wait(story.id, reason)
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
692
1109
|
const recorded = yield* Ref.get(outcomes)
|
|
693
1110
|
const ordered = plan.stories.flatMap((story) => {
|
|
694
1111
|
const outcome = recorded.find((candidate) => candidate.id === story.id)
|