@llm4ts/flow 2.7.2 → 2.9.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/FlowContext.d.ts +15 -1
- package/dist/FlowContext.d.ts.map +1 -1
- package/dist/FlowContext.js.map +1 -1
- package/dist/FlowError.d.ts +40 -1
- package/dist/FlowError.d.ts.map +1 -1
- package/dist/FlowError.js +58 -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/Roster.d.ts +163 -0
- package/dist/Roster.d.ts.map +1 -0
- package/dist/Roster.js +560 -0
- package/dist/Roster.js.map +1 -0
- package/dist/RosterSeats.d.ts +71 -0
- package/dist/RosterSeats.d.ts.map +1 -0
- package/dist/RosterSeats.js +144 -0
- package/dist/RosterSeats.js.map +1 -0
- package/dist/Stories.d.ts +54 -8
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +413 -51
- 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 +4 -2
- package/src/Chat.ts +45 -6
- package/src/FlowContext.ts +19 -1
- package/src/FlowError.ts +65 -3
- package/src/GitTool.ts +99 -5
- package/src/Perimeter.ts +85 -1
- package/src/Roster.ts +824 -0
- package/src/RosterSeats.ts +306 -0
- package/src/Stories.ts +588 -88
- 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"
|
|
@@ -17,30 +18,51 @@ import type { LlmError } from "@llm4ts/core/Errors"
|
|
|
17
18
|
import { BoardItem, type BoardSyncShape } from "./BoardSync.ts"
|
|
18
19
|
import { makeChat } from "./Chat.ts"
|
|
19
20
|
import { implementPlanFlow } from "./Flow.ts"
|
|
20
|
-
import type { FlowContextShape } from "./FlowContext.ts"
|
|
21
|
-
import {
|
|
21
|
+
import type { ContextOptions, FlowContextShape } from "./FlowContext.ts"
|
|
22
|
+
import {
|
|
23
|
+
describeFlowError,
|
|
24
|
+
rosterExhaustedPrefix,
|
|
25
|
+
EpicCheckoutDirty,
|
|
26
|
+
MissingDependency,
|
|
27
|
+
PerimeterViolation,
|
|
28
|
+
StoryFailed,
|
|
29
|
+
type FlowError
|
|
30
|
+
} from "./FlowError.ts"
|
|
22
31
|
import { Info } from "./FlowEvents.ts"
|
|
23
|
-
import {
|
|
32
|
+
import { statusPaths, type GitToolShape } from "./GitTool.ts"
|
|
33
|
+
import {
|
|
34
|
+
checkPerimeter,
|
|
35
|
+
enforcePerimeter,
|
|
36
|
+
isWithinPerimeter,
|
|
37
|
+
perimeterGate,
|
|
38
|
+
strayTasks,
|
|
39
|
+
type PerimeterCheck
|
|
40
|
+
} from "./Perimeter.ts"
|
|
24
41
|
import {
|
|
25
42
|
loadVersioned,
|
|
26
43
|
makePlanStore,
|
|
27
44
|
saveVersioned,
|
|
28
45
|
type PlainFileStoreShape
|
|
29
46
|
} from "./Persistence.ts"
|
|
30
|
-
import
|
|
47
|
+
import { Plan } from "./Plan.ts"
|
|
31
48
|
import { stage } from "./PlanExecution.ts"
|
|
32
49
|
import { planFrom } from "./Planner.ts"
|
|
33
|
-
import
|
|
50
|
+
import { ReviewResult } from "./Review.ts"
|
|
34
51
|
import type { Reviewer } from "./Reviewer.ts"
|
|
35
52
|
import {
|
|
36
53
|
dependentsOf,
|
|
54
|
+
ownerOf,
|
|
55
|
+
pathsNamedIn,
|
|
37
56
|
readyStories,
|
|
57
|
+
relationOf,
|
|
38
58
|
storyHash,
|
|
39
59
|
topologicalWaves,
|
|
40
60
|
validateStoryPlan,
|
|
41
61
|
type Story,
|
|
42
|
-
type StoryPlan
|
|
62
|
+
type StoryPlan,
|
|
63
|
+
type StoryRelation
|
|
43
64
|
} from "./StoryPlan.ts"
|
|
65
|
+
import { isOutageMessage } from "./TransientRetry.ts"
|
|
44
66
|
|
|
45
67
|
const join = (root: string, path: string): string =>
|
|
46
68
|
`${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`
|
|
@@ -68,7 +90,9 @@ export class StoryState extends Schema.Class<StoryState>("StoryState")({
|
|
|
68
90
|
hash: Schema.String,
|
|
69
91
|
branch: Schema.String,
|
|
70
92
|
worktree: Schema.String,
|
|
71
|
-
status: StoryStatus
|
|
93
|
+
status: StoryStatus,
|
|
94
|
+
/** The roster executor that last held the story's coder (ADR 0019); preferred on resume. */
|
|
95
|
+
executor: Schema.optionalKey(Schema.String)
|
|
72
96
|
}) {}
|
|
73
97
|
|
|
74
98
|
export const EpicReportVersion = 1
|
|
@@ -85,6 +109,8 @@ export class StoryOutcome extends Schema.Class<StoryOutcome>("StoryOutcome")({
|
|
|
85
109
|
/** Failure reason, or what a waiting story waits for. */
|
|
86
110
|
reason: Schema.optionalKey(Schema.String),
|
|
87
111
|
judge: Schema.optionalKey(Schema.String),
|
|
112
|
+
/** The roster executor(s) that coded the story, in order ("codex → claude"). */
|
|
113
|
+
executor: Schema.optionalKey(Schema.String),
|
|
88
114
|
/** ESTIMATES, never measurements (ADR 0012). */
|
|
89
115
|
estimatedTokens: Schema.optionalKey(Schema.Int),
|
|
90
116
|
estimatedCostUsd: Schema.optionalKey(Schema.Number)
|
|
@@ -138,6 +164,27 @@ export const renderEpicReport = (report: EpicReport): string => {
|
|
|
138
164
|
if (cost.length > 0) {
|
|
139
165
|
lines.push(`- Estimated cost: ${money(cost.reduce((sum, value) => sum + value, 0))}`)
|
|
140
166
|
}
|
|
167
|
+
const coded = report.stories.filter((story) => story.executor !== undefined)
|
|
168
|
+
if (coded.length > 0) {
|
|
169
|
+
// A story is counted for the executor that finished it; a handover chain
|
|
170
|
+
// is shown in full next to the story.
|
|
171
|
+
const byExecutor = new Map<string, { stories: Array<string>; tokens: number; cost: number }>()
|
|
172
|
+
for (const story of coded) {
|
|
173
|
+
const chain = story.executor ?? ""
|
|
174
|
+
const last = chain.split(" → ").at(-1) ?? chain
|
|
175
|
+
const entry = byExecutor.get(last) ?? { stories: [], tokens: 0, cost: 0 }
|
|
176
|
+
entry.stories.push(chain === last ? story.id : `${story.id} (${chain})`)
|
|
177
|
+
entry.tokens += story.estimatedTokens ?? 0
|
|
178
|
+
entry.cost += story.estimatedCostUsd ?? 0
|
|
179
|
+
byExecutor.set(last, entry)
|
|
180
|
+
}
|
|
181
|
+
lines.push("", "## By executor", "")
|
|
182
|
+
for (const [executor, entry] of byExecutor) {
|
|
183
|
+
lines.push(
|
|
184
|
+
`- ${executor}: ${entry.stories.join(", ")} — ~${entry.tokens} tokens, ${money(entry.cost)} (estimated)`
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
141
188
|
lines.push("")
|
|
142
189
|
return lines.join("\n")
|
|
143
190
|
}
|
|
@@ -172,8 +219,43 @@ export const blockedOnIn = (text: string): string | undefined => {
|
|
|
172
219
|
const bullets = (items: ReadonlyArray<string>): string =>
|
|
173
220
|
items.length === 0 ? "- (none)" : items.map((item) => `- ${item}`).join("\n")
|
|
174
221
|
|
|
222
|
+
/** Where a story runs, which is what lets its rules name the other stories and checkouts. */
|
|
223
|
+
export interface StoryWhereabouts {
|
|
224
|
+
readonly plan: StoryPlan
|
|
225
|
+
/** The story's own worktree — the coder's working directory. */
|
|
226
|
+
readonly worktree: string
|
|
227
|
+
/** The epic branch's checkout, which no coder may touch. */
|
|
228
|
+
readonly epicCheckout: string
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const relationNotes: Readonly<Record<Exclude<StoryRelation, "self">, string>> = {
|
|
232
|
+
dependency: "merged into the epic before you started — it is in your working tree; read it",
|
|
233
|
+
dependent:
|
|
234
|
+
"built AFTER you, on top of your work — leave it alone (wiring your work in is that story's job)",
|
|
235
|
+
parallel: "built at the same time by another coder — leave it alone"
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const whereaboutsRules = (story: Story, where: StoryWhereabouts): ReadonlyArray<string> => {
|
|
239
|
+
const others = where.plan.stories
|
|
240
|
+
.filter((other) => other.id !== story.id)
|
|
241
|
+
.map((other) => {
|
|
242
|
+
const relation = relationOf(where.plan, story, other)
|
|
243
|
+
const note = relation === "self" ? "" : relationNotes[relation]
|
|
244
|
+
return `${other.owned.join(", ")} — story ${other.id}: ${note}`
|
|
245
|
+
})
|
|
246
|
+
return [
|
|
247
|
+
"",
|
|
248
|
+
`Your working directory is ${where.worktree}. Run every command there. Never cd into, read`,
|
|
249
|
+
`from, or write to any other checkout — above all not ${where.epicCheckout}, the epic`,
|
|
250
|
+
"branch's checkout: a change there breaks every other story's merge.",
|
|
251
|
+
"",
|
|
252
|
+
"Paths other stories own — never create or change them, and none of them is a reason to stop:",
|
|
253
|
+
bullets(others)
|
|
254
|
+
]
|
|
255
|
+
}
|
|
256
|
+
|
|
175
257
|
/** The hard rules every story coder receives; the perimeter check enforces them afterwards. */
|
|
176
|
-
export const perimeterRules = (story: Story): string =>
|
|
258
|
+
export const perimeterRules = (story: Story, where?: StoryWhereabouts): string =>
|
|
177
259
|
[
|
|
178
260
|
`You are implementing ONE story of a larger epic: "${story.title}" (id: ${story.id}).`,
|
|
179
261
|
"",
|
|
@@ -185,6 +267,7 @@ export const perimeterRules = (story: Story): string =>
|
|
|
185
267
|
"",
|
|
186
268
|
"What this story must provide for other stories:",
|
|
187
269
|
bullets(story.provides),
|
|
270
|
+
...(where === undefined ? [] : whereaboutsRules(story, where)),
|
|
188
271
|
"",
|
|
189
272
|
"Rules:",
|
|
190
273
|
"- Do not touch any path outside the owned list; a change there fails the story.",
|
|
@@ -204,7 +287,10 @@ export const storyTaskPlanInstructions = (story: Story): string =>
|
|
|
204
287
|
[
|
|
205
288
|
"You are planning the implementation of ONE story inside a larger epic. Break the story",
|
|
206
289
|
"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
|
|
290
|
+
"observable outcome. Every task must stay inside the story's owned paths:",
|
|
291
|
+
bullets(story.owned),
|
|
292
|
+
"Never plan a task that creates or changes anything outside them — registering or wiring",
|
|
293
|
+
"the story in elsewhere (the app's composition point, a shared kit file) is another story's job.",
|
|
208
294
|
`Use exactly this epicId: "${story.id}".`,
|
|
209
295
|
'Respond only with JSON: {"epicId":"' + story.id + '","tasks":',
|
|
210
296
|
'[{"title":"...","description":"...","completed":false}]}'
|
|
@@ -218,6 +304,100 @@ export const defaultPlanTasks = (
|
|
|
218
304
|
): Effect.Effect<Plan, FlowError> =>
|
|
219
305
|
planFrom(seats.context.coder, prompt, storyTaskPlanInstructions(story))
|
|
220
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Asks the planner again, once, when its tasks name paths outside the story;
|
|
309
|
+
* tasks that still name ANOTHER story's paths are then dropped (that work is
|
|
310
|
+
* planned elsewhere) and any remaining stray is left to the perimeter gate.
|
|
311
|
+
*/
|
|
312
|
+
export const planWithinPerimeter = (
|
|
313
|
+
plan: StoryPlan,
|
|
314
|
+
story: Story,
|
|
315
|
+
planTasks: (prompt: string) => Effect.Effect<Plan, FlowError>,
|
|
316
|
+
prompt: string,
|
|
317
|
+
events: FlowContextShape["events"]
|
|
318
|
+
): Effect.Effect<Plan, FlowError> =>
|
|
319
|
+
Effect.gen(function* () {
|
|
320
|
+
const first = yield* planTasks(prompt)
|
|
321
|
+
const strays = strayTasks(plan, story, first.tasks)
|
|
322
|
+
if (strays.length === 0) {
|
|
323
|
+
return first
|
|
324
|
+
}
|
|
325
|
+
const describe = strays.map((stray) => `- "${stray.task.title}": ${stray.paths.join(", ")}`)
|
|
326
|
+
yield* events.publish(
|
|
327
|
+
Info.make({
|
|
328
|
+
message: `story ${story.id}: ${strays.length} planned task(s) name paths outside the story; re-planning:\n${describe.join("\n")}`
|
|
329
|
+
})
|
|
330
|
+
)
|
|
331
|
+
const second = yield* planTasks(
|
|
332
|
+
[
|
|
333
|
+
prompt,
|
|
334
|
+
"",
|
|
335
|
+
"Your previous task list named paths outside this story's owned paths:",
|
|
336
|
+
...describe,
|
|
337
|
+
"Plan again: only work inside the owned paths. Work on any other path is not this story's."
|
|
338
|
+
].join("\n")
|
|
339
|
+
)
|
|
340
|
+
const remaining = strayTasks(plan, story, second.tasks)
|
|
341
|
+
const foreign = new Set(remaining.filter((stray) => stray.foreign).map((stray) => stray.task))
|
|
342
|
+
const kept = second.tasks.filter((task) => !foreign.has(task))
|
|
343
|
+
if (remaining.length > 0) {
|
|
344
|
+
yield* events.publish(
|
|
345
|
+
Info.make({
|
|
346
|
+
message:
|
|
347
|
+
`story ${story.id}: the re-plan still names paths outside the story` +
|
|
348
|
+
(foreign.size > 0
|
|
349
|
+
? `; dropped ${foreign.size} task(s) that belong to other stories`
|
|
350
|
+
: "")
|
|
351
|
+
})
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
return kept.length === 0 ? second : Plan.make({ ...second, tasks: kept })
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Why a BLOCKED_ON claim is wrong, when the plan alone can tell: the named
|
|
359
|
+
* path is the story's own, a dependency's (already merged into the worktree),
|
|
360
|
+
* or a dependent's (built later on top of this story). Undefined when the
|
|
361
|
+
* plan cannot settle it — a path no story owns, or a parallel story's.
|
|
362
|
+
*/
|
|
363
|
+
export const blockedRebuttal = (
|
|
364
|
+
plan: StoryPlan,
|
|
365
|
+
story: Story,
|
|
366
|
+
need: string
|
|
367
|
+
): string | undefined => {
|
|
368
|
+
for (const path of pathsNamedIn(plan, need)) {
|
|
369
|
+
const owner = ownerOf(plan, path)
|
|
370
|
+
if (owner === undefined) {
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
373
|
+
switch (relationOf(plan, story, owner)) {
|
|
374
|
+
case "self":
|
|
375
|
+
return `${path} is under your own owned paths: building it is your job, not another story's.`
|
|
376
|
+
case "dependent":
|
|
377
|
+
return (
|
|
378
|
+
`${path} belongs to story '${owner.id}', which runs AFTER you and builds on your work. ` +
|
|
379
|
+
"Leave it alone: it is neither missing nor a blocker for this story."
|
|
380
|
+
)
|
|
381
|
+
case "dependency":
|
|
382
|
+
return (
|
|
383
|
+
`${path} belongs to story '${owner.id}', merged into the epic branch before you started, ` +
|
|
384
|
+
`so it is already in your working tree: read it there. That story provides: ${owner.provides.join("; ")}.`
|
|
385
|
+
)
|
|
386
|
+
case "parallel":
|
|
387
|
+
continue
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return undefined
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** A second opinion on a BLOCKED_ON claim the plan cannot settle. */
|
|
394
|
+
export class BlockedVerdict extends Schema.Class<BlockedVerdict>("BlockedVerdict")({
|
|
395
|
+
/** True: the work is genuinely missing and owned by no dependency. */
|
|
396
|
+
real: Schema.Boolean,
|
|
397
|
+
/** Why — shown to the coder when the claim is rejected. */
|
|
398
|
+
reason: Schema.String
|
|
399
|
+
}) {}
|
|
400
|
+
|
|
221
401
|
// ---- BLOCKED_ON detection ---------------------------------------------------
|
|
222
402
|
|
|
223
403
|
const watchStream = (
|
|
@@ -269,7 +449,10 @@ export interface StoriesOptions {
|
|
|
269
449
|
/** Default `epic/<epicId>`. */
|
|
270
450
|
readonly epicBranch?: string
|
|
271
451
|
/** Seats rooted in a worktree; the executor never resolves seats itself. */
|
|
272
|
-
readonly contextFor: (
|
|
452
|
+
readonly contextFor: (
|
|
453
|
+
workDir: string,
|
|
454
|
+
options?: ContextOptions
|
|
455
|
+
) => Effect.Effect<StorySeats, FlowError, Scope.Scope>
|
|
273
456
|
readonly board: BoardSyncShape
|
|
274
457
|
/**
|
|
275
458
|
* Prepares a worktree before its coder runs — a fresh checkout has no
|
|
@@ -280,7 +463,11 @@ export interface StoriesOptions {
|
|
|
280
463
|
/** The target's gates, run in a worktree per task and on the epic checkout after each merge. */
|
|
281
464
|
readonly gates: (workDir: string) => Effect.Effect<ReviewResult, FlowError>
|
|
282
465
|
/** Story-level judge over the branch's diff against the epic branch; omit to skip. */
|
|
283
|
-
readonly judge?: (
|
|
466
|
+
readonly judge?: (
|
|
467
|
+
story: Story,
|
|
468
|
+
diff: string,
|
|
469
|
+
seats: StorySeats
|
|
470
|
+
) => Effect.Effect<ReviewResult, FlowError>
|
|
284
471
|
/** Judge attempts, each but the last followed by one coder feedback round. Default 2. */
|
|
285
472
|
readonly judgeRounds?: number
|
|
286
473
|
/** Extra system context per story (house rules, shared read-only excerpts). */
|
|
@@ -297,13 +484,46 @@ export interface StoriesOptions {
|
|
|
297
484
|
readonly failFast?: boolean
|
|
298
485
|
readonly reviewers?: ReadonlyArray<Reviewer>
|
|
299
486
|
readonly maxRounds?: number
|
|
487
|
+
/**
|
|
488
|
+
* Second opinion on a BLOCKED_ON claim the plan cannot settle (a path no
|
|
489
|
+
* story owns, or a parallel story's). `real: false` sends the coder back
|
|
490
|
+
* once with the reason. Omit to accept every such claim.
|
|
491
|
+
*/
|
|
492
|
+
readonly verifyBlocked?: (
|
|
493
|
+
story: Story,
|
|
494
|
+
need: string,
|
|
495
|
+
workDir: string,
|
|
496
|
+
seats: StorySeats
|
|
497
|
+
) => Effect.Effect<BlockedVerdict, FlowError>
|
|
498
|
+
/**
|
|
499
|
+
* Waits until a serving engine that went down (`isOutageMessage`) is back;
|
|
500
|
+
* the story it interrupted is then retried once. Omit, or fail it, and the
|
|
501
|
+
* executor stops launching stories instead of failing each in turn.
|
|
502
|
+
*/
|
|
503
|
+
readonly awaitRecovery?: (reason: string) => Effect.Effect<void, FlowError>
|
|
300
504
|
}
|
|
301
505
|
|
|
506
|
+
/**
|
|
507
|
+
* A failure that says nothing about the story: the serving engine went down,
|
|
508
|
+
* or the epic checkout was dirtied. Launching more stories would only fail
|
|
509
|
+
* them the same way.
|
|
510
|
+
*/
|
|
511
|
+
type Interruption = "outage" | "halt"
|
|
512
|
+
|
|
302
513
|
interface Completion {
|
|
303
514
|
readonly story: Story
|
|
304
515
|
readonly outcome: StoryOutcome
|
|
516
|
+
readonly interruption?: Interruption
|
|
305
517
|
}
|
|
306
518
|
|
|
519
|
+
const combined = (first: ReviewResult, second: ReviewResult): ReviewResult =>
|
|
520
|
+
first.isClean && second.isClean
|
|
521
|
+
? first
|
|
522
|
+
: ReviewResult.make({
|
|
523
|
+
issues: [...first.issues, ...second.issues],
|
|
524
|
+
summary: [first.summary, second.summary].filter((part) => part.length > 0).join("; ")
|
|
525
|
+
})
|
|
526
|
+
|
|
307
527
|
const issueLines = (result: ReviewResult): string =>
|
|
308
528
|
result.issues.map((issue) => `- ${issue.title}: ${issue.description}`).join("\n")
|
|
309
529
|
|
|
@@ -325,6 +545,24 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
325
545
|
const statePath = (story: Story): string => join(options.stateDir, `stories/${story.id}.json`)
|
|
326
546
|
const planPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.plan.md`)
|
|
327
547
|
|
|
548
|
+
/**
|
|
549
|
+
* The epic checkout must hold nothing uncommitted: every merge and epic
|
|
550
|
+
* gate runs there, and git refuses a merge that would overwrite a stray
|
|
551
|
+
* file. A dirty checkout means a coder escaped its worktree.
|
|
552
|
+
*/
|
|
553
|
+
const epicCheckoutClean = (story?: Story): Effect.Effect<void, FlowError> =>
|
|
554
|
+
Effect.gen(function* () {
|
|
555
|
+
const paths = statusPaths(yield* context.git.status)
|
|
556
|
+
if (paths.length > 0) {
|
|
557
|
+
return yield* EpicCheckoutDirty.make({
|
|
558
|
+
checkout: context.workDir,
|
|
559
|
+
paths,
|
|
560
|
+
...(story === undefined ? {} : { story: story.id })
|
|
561
|
+
})
|
|
562
|
+
}
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
yield* epicCheckoutClean()
|
|
328
566
|
yield* stage(events, "epic branch", context.git.checkoutOrCreate(epicBranch))
|
|
329
567
|
|
|
330
568
|
const waves = topologicalWaves(plan)
|
|
@@ -350,6 +588,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
350
588
|
const integrate = (story: Story, branch: string): Effect.Effect<void, FlowError> =>
|
|
351
589
|
mergeLock.withPermit(
|
|
352
590
|
Effect.gen(function* () {
|
|
591
|
+
yield* epicCheckoutClean(story)
|
|
353
592
|
const checkpoint = yield* context.git.checkpoint
|
|
354
593
|
yield* context.git.merge(branch, `${plan.epicId}: merge story ${story.id}`)
|
|
355
594
|
const gate = yield* options.gates(context.workDir)
|
|
@@ -409,84 +648,272 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
409
648
|
return { state, alreadyMerged: false }
|
|
410
649
|
}
|
|
411
650
|
// 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
|
-
|
|
651
|
+
// `.git` FILE at its root, which is how its presence is checked). A
|
|
652
|
+
// worktree left under an older root moves to the current one, work and
|
|
653
|
+
// all, so every story of the epic runs under the same root.
|
|
654
|
+
let state = stored
|
|
655
|
+
if (stored.worktree !== worktree) {
|
|
656
|
+
if ((yield* files.read(join(stored.worktree, ".git"))) !== undefined) {
|
|
657
|
+
yield* context.git.moveWorktree(stored.worktree, worktree)
|
|
658
|
+
yield* events.publish(
|
|
659
|
+
Info.make({ message: `story ${story.id}: moved its worktree to ${worktree}` })
|
|
660
|
+
)
|
|
661
|
+
}
|
|
662
|
+
state = StoryState.make({ ...stored, worktree })
|
|
663
|
+
yield* saveVersioned(files, path, StoryStateVersion, StoryState, state)
|
|
664
|
+
}
|
|
665
|
+
const marker = yield* files.read(join(state.worktree, ".git"))
|
|
414
666
|
if (marker === undefined) {
|
|
415
|
-
yield* context.git.addWorktree(
|
|
667
|
+
yield* context.git.addWorktree(state.worktree, state.branch)
|
|
416
668
|
}
|
|
417
|
-
yield* events.publish(Info.make({ message: `story ${story.id}: resuming ${
|
|
418
|
-
return { state
|
|
669
|
+
yield* events.publish(Info.make({ message: `story ${story.id}: resuming ${state.branch}` }))
|
|
670
|
+
return { state, alreadyMerged: false }
|
|
419
671
|
})
|
|
420
672
|
|
|
673
|
+
/**
|
|
674
|
+
* Brings a story branch up to date with the epic branch. A story that
|
|
675
|
+
* started before a dependency was re-merged (or that resumes after other
|
|
676
|
+
* stories merged) would otherwise build on, and be judged against, code
|
|
677
|
+
* the epic no longer has. Conflicting hunks take the epic's side: the epic
|
|
678
|
+
* holds only other stories' merged work, and those paths are theirs.
|
|
679
|
+
*/
|
|
680
|
+
const catchUp = (story: Story, git: GitToolShape): Effect.Effect<void, FlowError> =>
|
|
681
|
+
Effect.gen(function* () {
|
|
682
|
+
if (yield* git.isAncestor(epicBranch, "HEAD")) {
|
|
683
|
+
return
|
|
684
|
+
}
|
|
685
|
+
if ((yield* git.uncommittedFiles).length > 0) {
|
|
686
|
+
yield* git.commitAll(`${story.id}: work in progress`)
|
|
687
|
+
}
|
|
688
|
+
yield* git.merge(epicBranch, `${story.id}: catch up with ${epicBranch}`, {
|
|
689
|
+
preferIncoming: true
|
|
690
|
+
})
|
|
691
|
+
yield* events.publish(
|
|
692
|
+
Info.make({ message: `story ${story.id}: caught up with ${epicBranch}` })
|
|
693
|
+
)
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
/** The perimeter over everything the branch changed, committed or not. */
|
|
697
|
+
const perimeterNow = (
|
|
698
|
+
story: Story,
|
|
699
|
+
git: GitToolShape
|
|
700
|
+
): Effect.Effect<PerimeterCheck, FlowError> =>
|
|
701
|
+
Effect.gen(function* () {
|
|
702
|
+
const committed = yield* git.changedFilesVsBase(epicBranch)
|
|
703
|
+
const uncommitted = yield* git.uncommittedFiles
|
|
704
|
+
return checkPerimeter([...new Set([...committed, ...uncommitted])], story)
|
|
705
|
+
})
|
|
706
|
+
|
|
421
707
|
/** Everything that happens inside a story's worktree: plan, implement, judge, perimeter. */
|
|
422
708
|
const implementStory = Effect.fn("@llm4ts/flow/Stories.implementStory")(function* (
|
|
423
709
|
story: Story,
|
|
424
710
|
state: StoryState
|
|
425
711
|
): Effect.fn.Return<
|
|
426
|
-
{
|
|
712
|
+
{
|
|
713
|
+
readonly judge: string | undefined
|
|
714
|
+
readonly totals: TokenUsage | undefined
|
|
715
|
+
readonly executor: string | undefined
|
|
716
|
+
},
|
|
427
717
|
FlowError,
|
|
428
718
|
Scope.Scope
|
|
429
719
|
> {
|
|
430
|
-
const seats = yield* options.contextFor(state.worktree
|
|
720
|
+
const seats = yield* options.contextFor(state.worktree, {
|
|
721
|
+
label: `story ${story.id}`,
|
|
722
|
+
...(state.executor === undefined ? {} : { prefer: state.executor })
|
|
723
|
+
})
|
|
724
|
+
const roster = seats.context.roster
|
|
725
|
+
const coderExecutor = roster === undefined ? undefined : yield* roster.executor
|
|
726
|
+
if (coderExecutor !== undefined && coderExecutor !== state.executor) {
|
|
727
|
+
yield* saveVersioned(
|
|
728
|
+
files,
|
|
729
|
+
statePath(story),
|
|
730
|
+
StoryStateVersion,
|
|
731
|
+
StoryState,
|
|
732
|
+
StoryState.make({ ...state, executor: coderExecutor })
|
|
733
|
+
)
|
|
734
|
+
}
|
|
431
735
|
const blocked = yield* Ref.make<string | undefined>(undefined)
|
|
736
|
+
const pushbacks = yield* Ref.make(0)
|
|
432
737
|
const storyContext: FlowContextShape = {
|
|
433
738
|
...seats.context,
|
|
434
739
|
coder: watchForBlockedOn(seats.context.coder, blocked)
|
|
435
740
|
}
|
|
741
|
+
const git = storyContext.git
|
|
436
742
|
const watchedSeats: StorySeats = { ...seats, context: storyContext }
|
|
437
743
|
const extra = options.system === undefined ? undefined : yield* options.system(story)
|
|
438
|
-
const system = [
|
|
744
|
+
const system = [
|
|
745
|
+
perimeterRules(story, { plan, worktree: state.worktree, epicCheckout: context.workDir }),
|
|
746
|
+
extra
|
|
747
|
+
]
|
|
439
748
|
.filter((part): part is string => part !== undefined && part.trim().length > 0)
|
|
440
749
|
.join("\n\n")
|
|
441
750
|
const prompt = storyPrompt(story)
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
751
|
+
|
|
752
|
+
// Catch up before setup: the epic may have changed the manifest too.
|
|
753
|
+
yield* catchUp(story, git)
|
|
754
|
+
if (options.setup !== undefined) {
|
|
755
|
+
yield* stage(events, `story ${story.id}: setup`, options.setup(state.worktree))
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// The target's gates plus the perimeter: a stray path is a gate failure
|
|
759
|
+
// the task review loop hands back to the coder before anything commits.
|
|
760
|
+
const gates: Effect.Effect<ReviewResult, FlowError> = Effect.gen(function* () {
|
|
761
|
+
const target = yield* options.gates(state.worktree)
|
|
762
|
+
const changed = yield* perimeterNow(story, git)
|
|
763
|
+
return combined(target, perimeterGate([...changed.sharedReadOnly, ...changed.outside], story))
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
const coderTurn = (text: string): Effect.Effect<string, FlowError> =>
|
|
767
|
+
Effect.flatMap(makeChat(storyContext.coder, { system, events, agent: "coder" }), (chat) =>
|
|
768
|
+
chat.ask(text)
|
|
458
769
|
)
|
|
459
770
|
|
|
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 })
|
|
771
|
+
const commitGreen = (message: string, why: string): Effect.Effect<void, FlowError> =>
|
|
772
|
+
Effect.gen(function* () {
|
|
773
|
+
const regated = yield* gates
|
|
774
|
+
if (!regated.isClean) {
|
|
775
|
+
return yield* failed(story, `gates broke ${why}:\n${issueLines(regated)}`)
|
|
776
|
+
}
|
|
777
|
+
yield* git.commitAll(`${story.id}: ${message}`)
|
|
476
778
|
})
|
|
477
|
-
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Runs `effect`; a BLOCKED_ON the plan or the verifier refutes sends the
|
|
782
|
+
* coder back once with the reason, then `resume` runs. Any other claim —
|
|
783
|
+
* or a second one — ends the story as a typed MissingDependency.
|
|
784
|
+
*/
|
|
785
|
+
const guarded = <A>(
|
|
786
|
+
effect: Effect.Effect<A, FlowError>,
|
|
787
|
+
resume: Effect.Effect<void, FlowError>
|
|
788
|
+
): Effect.Effect<void, FlowError> =>
|
|
789
|
+
Effect.gen(function* () {
|
|
790
|
+
const exit = yield* Effect.exit(effect)
|
|
791
|
+
const need = yield* Ref.get(blocked)
|
|
792
|
+
if (need === undefined) {
|
|
793
|
+
if (Exit.isFailure(exit)) {
|
|
794
|
+
return yield* Effect.failCause(exit.cause)
|
|
795
|
+
}
|
|
796
|
+
return
|
|
797
|
+
}
|
|
798
|
+
const missing = MissingDependency.make({ story: story.id, need })
|
|
799
|
+
if ((yield* Ref.get(pushbacks)) > 0) {
|
|
800
|
+
return yield* missing
|
|
801
|
+
}
|
|
802
|
+
let rebuttal = blockedRebuttal(plan, story, need)
|
|
803
|
+
if (rebuttal === undefined && options.verifyBlocked !== undefined) {
|
|
804
|
+
const verdict = yield* options
|
|
805
|
+
.verifyBlocked(story, need, state.worktree, watchedSeats)
|
|
806
|
+
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
807
|
+
rebuttal = verdict === undefined || verdict.real ? undefined : verdict.reason
|
|
808
|
+
}
|
|
809
|
+
if (rebuttal === undefined) {
|
|
810
|
+
return yield* missing
|
|
811
|
+
}
|
|
812
|
+
yield* Ref.set(blocked, undefined)
|
|
813
|
+
yield* Ref.update(pushbacks, (count) => count + 1)
|
|
814
|
+
yield* events.publish(
|
|
815
|
+
Info.make({ message: `story ${story.id}: BLOCKED_ON rejected — ${rebuttal}` })
|
|
816
|
+
)
|
|
817
|
+
yield* coderTurn(
|
|
818
|
+
[
|
|
819
|
+
`You stopped with "${blockedOnSentinel} ${need}".`,
|
|
820
|
+
"That claim was checked against the epic's plan and rejected:",
|
|
821
|
+
rebuttal,
|
|
822
|
+
"",
|
|
823
|
+
"It is not a missing dependency. Continue the story inside your owned paths until it is",
|
|
824
|
+
`complete, then stop. Use ${blockedOnSentinel} only for work another story owns that is`,
|
|
825
|
+
"absent from your working tree."
|
|
826
|
+
].join("\n")
|
|
827
|
+
)
|
|
828
|
+
const again = yield* Ref.get(blocked)
|
|
829
|
+
if (again !== undefined) {
|
|
830
|
+
return yield* MissingDependency.make({ story: story.id, need: again })
|
|
831
|
+
}
|
|
832
|
+
yield* commitGreen("continue after a rejected BLOCKED_ON", "after a rejected BLOCKED_ON")
|
|
833
|
+
yield* guarded(resume, Effect.void)
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Stray paths go back before a judge sees the branch: first the coder is
|
|
838
|
+
* asked to move or revert them, then whatever is left is restored from
|
|
839
|
+
* the epic branch — a deterministic rule never needs a judge round.
|
|
840
|
+
*/
|
|
841
|
+
const repairPerimeter: Effect.Effect<void, FlowError> = Effect.gen(function* () {
|
|
842
|
+
const first = yield* perimeterNow(story, git)
|
|
843
|
+
if (isWithinPerimeter(first)) {
|
|
844
|
+
return
|
|
845
|
+
}
|
|
846
|
+
const violation = PerimeterViolation.make({
|
|
847
|
+
story: story.id,
|
|
848
|
+
outside: first.outside,
|
|
849
|
+
sharedReadOnly: first.sharedReadOnly
|
|
850
|
+
})
|
|
851
|
+
yield* guarded(
|
|
852
|
+
coderTurn(
|
|
853
|
+
[
|
|
854
|
+
violation.message,
|
|
855
|
+
"",
|
|
856
|
+
"Put each of these paths back as it is on the epic branch (restore a changed file, delete",
|
|
857
|
+
"a file you created there). Work that belongs to this story moves into your owned paths.",
|
|
858
|
+
"Then stop."
|
|
859
|
+
].join("\n")
|
|
860
|
+
),
|
|
861
|
+
Effect.void
|
|
862
|
+
)
|
|
863
|
+
const second = yield* perimeterNow(story, git)
|
|
864
|
+
if (!isWithinPerimeter(second)) {
|
|
865
|
+
const stray = [...second.sharedReadOnly, ...second.outside]
|
|
866
|
+
yield* git.restorePaths(epicBranch, stray)
|
|
867
|
+
yield* events.publish(
|
|
868
|
+
Info.make({
|
|
869
|
+
message: `story ${story.id}: restored ${stray.length} path(s) outside its perimeter from ${epicBranch}: ${stray.join(", ")}`
|
|
870
|
+
})
|
|
871
|
+
)
|
|
872
|
+
}
|
|
873
|
+
yield* git.commitAll(`${story.id}: put paths outside the perimeter back`)
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
const planTasks = options.planTasks ?? defaultPlanTasks
|
|
877
|
+
const implementTasks = implementPlanFlow(storyContext, {
|
|
878
|
+
store: makePlanStore(files),
|
|
879
|
+
planPath: planPath(story),
|
|
880
|
+
plan: planWithinPerimeter(
|
|
881
|
+
plan,
|
|
882
|
+
story,
|
|
883
|
+
(text) => planTasks(watchedSeats, story, text),
|
|
884
|
+
prompt,
|
|
885
|
+
events
|
|
886
|
+
),
|
|
887
|
+
system,
|
|
888
|
+
chatPerTask: true,
|
|
889
|
+
checkoutBranch: false,
|
|
890
|
+
lint: gates,
|
|
891
|
+
// A story's final state is judged and gated downstream (judge round,
|
|
892
|
+
// perimeter check, epic gates), so a task the coder finds already
|
|
893
|
+
// satisfied — without saying the exact sentinel — must not sink the
|
|
894
|
+
// story: the option exists for pipelines shaped like this one.
|
|
895
|
+
noopTaskPolicy: "complete",
|
|
896
|
+
...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
|
|
897
|
+
...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds })
|
|
898
|
+
})
|
|
899
|
+
yield* guarded(implementTasks, implementTasks)
|
|
900
|
+
|
|
901
|
+
// Other stories may have merged while this one ran: judge the branch
|
|
902
|
+
// against the epic as it is now.
|
|
903
|
+
yield* catchUp(story, git)
|
|
904
|
+
yield* repairPerimeter
|
|
478
905
|
|
|
479
906
|
let judgeNote: string | undefined
|
|
480
907
|
if (options.judge !== undefined) {
|
|
481
908
|
const judge = options.judge
|
|
482
909
|
for (let round = 1; round <= judgeRounds; round += 1) {
|
|
483
|
-
const diff = yield*
|
|
910
|
+
const diff = yield* git.diffVsBase(epicBranch)
|
|
484
911
|
if (diff.trim().length === 0) {
|
|
485
912
|
// Nothing to judge is a deterministic failure, not a model call:
|
|
486
913
|
// a model asked to score an empty diff scores the prompt instead.
|
|
487
914
|
return yield* failed(story, "the story branch has no changes against the epic branch")
|
|
488
915
|
}
|
|
489
|
-
const verdict = yield* judge(story, diff)
|
|
916
|
+
const verdict = yield* judge(story, diff, watchedSeats)
|
|
490
917
|
if (verdict.isClean) {
|
|
491
918
|
judgeNote = `judge cleared (round ${round})`
|
|
492
919
|
break
|
|
@@ -497,35 +924,30 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
497
924
|
`judge not cleared after ${judgeRounds} round(s):\n${issueLines(verdict)}`
|
|
498
925
|
)
|
|
499
926
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
events,
|
|
503
|
-
agent: "coder"
|
|
504
|
-
})
|
|
505
|
-
yield* blockedOr(
|
|
506
|
-
feedback.ask(
|
|
927
|
+
yield* guarded(
|
|
928
|
+
coderTurn(
|
|
507
929
|
[
|
|
508
930
|
`The story "${story.title}" scored below the bar. Close these gaps without`,
|
|
509
931
|
"weakening any test and without leaving your owned paths, then stop:",
|
|
510
932
|
issueLines(verdict)
|
|
511
933
|
].join("\n")
|
|
512
|
-
)
|
|
934
|
+
),
|
|
935
|
+
Effect.void
|
|
513
936
|
)
|
|
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`)
|
|
937
|
+
yield* repairPerimeter
|
|
938
|
+
yield* commitGreen("address judge feedback", "while addressing judge feedback")
|
|
522
939
|
}
|
|
523
940
|
}
|
|
524
941
|
|
|
525
|
-
const changed = yield*
|
|
942
|
+
const changed = yield* git.changedFilesVsBase(epicBranch)
|
|
526
943
|
yield* enforcePerimeter(changed, story)
|
|
527
944
|
const totals = seats.totals === undefined ? undefined : yield* seats.totals
|
|
528
|
-
|
|
945
|
+
const history = roster === undefined ? [] : yield* roster.history
|
|
946
|
+
return {
|
|
947
|
+
judge: judgeNote,
|
|
948
|
+
totals,
|
|
949
|
+
executor: history.length === 0 ? undefined : history.join(" → ")
|
|
950
|
+
}
|
|
529
951
|
})
|
|
530
952
|
|
|
531
953
|
const runStory = Effect.fn("@llm4ts/flow/Stories.runStory")(function* (
|
|
@@ -543,17 +965,19 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
543
965
|
judge: "merged on a previous run"
|
|
544
966
|
})
|
|
545
967
|
}
|
|
546
|
-
if (options.setup !== undefined) {
|
|
547
|
-
yield* stage(events, `story ${story.id}: setup`, options.setup(state.worktree))
|
|
548
|
-
}
|
|
549
968
|
const result = yield* Effect.scoped(implementStory(story, state))
|
|
550
969
|
yield* integrate(story, state.branch)
|
|
970
|
+
const last = result.executor?.split(" → ").at(-1)
|
|
551
971
|
yield* saveVersioned(
|
|
552
972
|
files,
|
|
553
973
|
statePath(story),
|
|
554
974
|
StoryStateVersion,
|
|
555
975
|
StoryState,
|
|
556
|
-
StoryState.make({
|
|
976
|
+
StoryState.make({
|
|
977
|
+
...state,
|
|
978
|
+
status: "merged",
|
|
979
|
+
...(last === undefined ? {} : { executor: last })
|
|
980
|
+
})
|
|
557
981
|
)
|
|
558
982
|
return StoryOutcome.make({
|
|
559
983
|
id: story.id,
|
|
@@ -561,6 +985,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
561
985
|
status: "done",
|
|
562
986
|
branch: state.branch,
|
|
563
987
|
...(result.judge === undefined ? {} : { judge: result.judge }),
|
|
988
|
+
...(result.executor === undefined ? {} : { executor: result.executor }),
|
|
564
989
|
...(result.totals === undefined ? {} : { estimatedTokens: result.totals.total }),
|
|
565
990
|
...(result.totals?.costUsd === undefined ? {} : { estimatedCostUsd: result.totals.costUsd })
|
|
566
991
|
})
|
|
@@ -586,7 +1011,15 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
586
1011
|
StoryState.make({ ...stored, status: "failed" })
|
|
587
1012
|
).pipe(Effect.ignore)
|
|
588
1013
|
}
|
|
589
|
-
|
|
1014
|
+
const interruption: Interruption | undefined =
|
|
1015
|
+
error._tag === "EpicCheckoutDirty" ||
|
|
1016
|
+
error._tag === "RosterExhausted" ||
|
|
1017
|
+
reason.includes(rosterExhaustedPrefix)
|
|
1018
|
+
? "halt"
|
|
1019
|
+
: isOutageMessage(reason)
|
|
1020
|
+
? "outage"
|
|
1021
|
+
: undefined
|
|
1022
|
+
const completion: Completion = {
|
|
590
1023
|
story,
|
|
591
1024
|
outcome: StoryOutcome.make({
|
|
592
1025
|
id: story.id,
|
|
@@ -594,8 +1027,10 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
594
1027
|
status: "failed",
|
|
595
1028
|
reason,
|
|
596
1029
|
...(stored === undefined ? {} : { branch: stored.branch })
|
|
597
|
-
})
|
|
1030
|
+
}),
|
|
1031
|
+
...(interruption === undefined ? {} : { interruption })
|
|
598
1032
|
}
|
|
1033
|
+
return completion
|
|
599
1034
|
})
|
|
600
1035
|
)
|
|
601
1036
|
)
|
|
@@ -639,19 +1074,35 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
639
1074
|
}
|
|
640
1075
|
})
|
|
641
1076
|
|
|
1077
|
+
/** Why no further story may start in this run; stories already running finish. */
|
|
1078
|
+
let halted: string | undefined
|
|
1079
|
+
/** Stories retried once after the serving engine recovered. */
|
|
1080
|
+
const recovered = new Set<string>()
|
|
1081
|
+
|
|
642
1082
|
yield* Effect.scoped(
|
|
643
1083
|
Effect.gen(function* () {
|
|
644
1084
|
while (true) {
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
1085
|
+
if (halted === undefined) {
|
|
1086
|
+
const current = yield* progress
|
|
1087
|
+
const ready = readyStories(plan, current)
|
|
1088
|
+
// With a roster, never launch more stories than there are free
|
|
1089
|
+
// coders; with nothing running, launch one anyway — its lease waits
|
|
1090
|
+
// for the first executor to come back (ADR 0019).
|
|
1091
|
+
const free =
|
|
1092
|
+
context.roster === undefined
|
|
1093
|
+
? Number.POSITIVE_INFINITY
|
|
1094
|
+
: yield* context.roster.available("coder")
|
|
1095
|
+
const budget = Math.min(concurrency - current.running.size, free)
|
|
1096
|
+
const slots =
|
|
1097
|
+
current.running.size === 0 && ready.length > 0 ? Math.max(1, budget) : budget
|
|
1098
|
+
for (const story of ready.slice(0, Math.max(0, slots))) {
|
|
1099
|
+
yield* Ref.update(running, (set) => new Set([...set, story.id]))
|
|
1100
|
+
yield* Effect.forkScoped(
|
|
1101
|
+
attempt(story).pipe(
|
|
1102
|
+
Effect.flatMap((completion) => Queue.offer(completions, completion))
|
|
1103
|
+
)
|
|
653
1104
|
)
|
|
654
|
-
|
|
1105
|
+
}
|
|
655
1106
|
}
|
|
656
1107
|
if ((yield* Ref.get(running)).size === 0) {
|
|
657
1108
|
break
|
|
@@ -662,12 +1113,43 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
662
1113
|
next.delete(completion.story.id)
|
|
663
1114
|
return next
|
|
664
1115
|
})
|
|
1116
|
+
const reason = completion.outcome.reason ?? "failed"
|
|
1117
|
+
if (completion.interruption === "outage" && halted === undefined) {
|
|
1118
|
+
if (options.awaitRecovery !== undefined && !recovered.has(completion.story.id)) {
|
|
1119
|
+
recovered.add(completion.story.id)
|
|
1120
|
+
yield* events.publish(
|
|
1121
|
+
Info.make({
|
|
1122
|
+
message: `story ${completion.story.id}: the serving engine is down; waiting for it to recover, then retrying the story`
|
|
1123
|
+
})
|
|
1124
|
+
)
|
|
1125
|
+
const back = yield* Effect.result(options.awaitRecovery(reason))
|
|
1126
|
+
if (back._tag === "Success") {
|
|
1127
|
+
// Not recorded: the story is ready again and relaunches.
|
|
1128
|
+
continue
|
|
1129
|
+
}
|
|
1130
|
+
halted = `the serving engine did not recover (${describeFlowError(back.failure)})`
|
|
1131
|
+
} else {
|
|
1132
|
+
halted = `the serving engine is down: ${reason}`
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
if (completion.interruption === "halt" && halted === undefined) {
|
|
1136
|
+
halted = reason
|
|
1137
|
+
}
|
|
665
1138
|
yield* record(completion.outcome)
|
|
666
1139
|
const outcome = completion.outcome
|
|
667
1140
|
if (outcome.status === "done") {
|
|
668
1141
|
yield* board.complete(outcome.id, {
|
|
669
1142
|
...(outcome.branch === undefined ? {} : { branch: outcome.branch }),
|
|
670
|
-
...(outcome.judge === undefined
|
|
1143
|
+
...(outcome.judge === undefined && outcome.executor === undefined
|
|
1144
|
+
? {}
|
|
1145
|
+
: {
|
|
1146
|
+
detail: [
|
|
1147
|
+
outcome.judge,
|
|
1148
|
+
outcome.executor === undefined ? undefined : `coder ${outcome.executor}`
|
|
1149
|
+
]
|
|
1150
|
+
.filter((part): part is string => part !== undefined)
|
|
1151
|
+
.join(" · ")
|
|
1152
|
+
}),
|
|
671
1153
|
...(outcome.estimatedTokens === undefined
|
|
672
1154
|
? {}
|
|
673
1155
|
: { estimatedTokens: outcome.estimatedTokens }),
|
|
@@ -689,6 +1171,24 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
689
1171
|
})
|
|
690
1172
|
)
|
|
691
1173
|
|
|
1174
|
+
if (halted !== undefined) {
|
|
1175
|
+
const stopped = halted
|
|
1176
|
+
yield* events.publish(
|
|
1177
|
+
Info.make({ message: `epic ${plan.epicId}: stopped launching stories — ${stopped}` })
|
|
1178
|
+
)
|
|
1179
|
+
const known = new Set((yield* Ref.get(outcomes)).map((outcome) => outcome.id))
|
|
1180
|
+
for (const story of plan.stories) {
|
|
1181
|
+
if (known.has(story.id)) {
|
|
1182
|
+
continue
|
|
1183
|
+
}
|
|
1184
|
+
const reason = `not started: ${stopped}`
|
|
1185
|
+
yield* record(
|
|
1186
|
+
StoryOutcome.make({ id: story.id, title: story.title, status: "waiting", reason })
|
|
1187
|
+
)
|
|
1188
|
+
yield* board.wait(story.id, reason)
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
692
1192
|
const recorded = yield* Ref.get(outcomes)
|
|
693
1193
|
const ordered = plan.stories.flatMap((story) => {
|
|
694
1194
|
const outcome = recorded.find((candidate) => candidate.id === story.id)
|