@llm4ts/flow 0.15.0 → 0.16.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/BenchReport.d.ts +2 -2
- package/dist/BenchReport.d.ts.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 +10 -0
- package/dist/FlowContext.d.ts.map +1 -1
- package/dist/FlowContext.js.map +1 -1
- package/dist/FlowError.d.ts +42 -1
- package/dist/FlowError.d.ts.map +1 -1
- package/dist/FlowError.js +60 -1
- package/dist/FlowError.js.map +1 -1
- package/dist/GitTool.d.ts +15 -1
- package/dist/GitTool.d.ts.map +1 -1
- package/dist/GitTool.js +30 -2
- package/dist/GitTool.js.map +1 -1
- package/dist/Perimeter.d.ts +13 -0
- package/dist/Perimeter.d.ts.map +1 -0
- package/dist/Perimeter.js +34 -0
- package/dist/Perimeter.js.map +1 -0
- 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 +2 -2
- 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 +109 -0
- package/dist/Stories.d.ts.map +1 -0
- package/dist/Stories.js +432 -0
- package/dist/Stories.js.map +1 -0
- package/dist/StoryPlan.d.ts +81 -0
- package/dist/StoryPlan.d.ts.map +1 -0
- package/dist/StoryPlan.js +242 -0
- package/dist/StoryPlan.js.map +1 -0
- package/dist/TransientRetry.d.ts +18 -0
- package/dist/TransientRetry.d.ts.map +1 -1
- package/dist/TransientRetry.js +35 -3
- package/dist/TransientRetry.js.map +1 -1
- package/package.json +5 -2
- package/src/FlowContext.ts +10 -0
- package/src/FlowError.ts +74 -1
- package/src/GitTool.ts +74 -4
- package/src/Perimeter.ts +49 -0
- package/src/Stories.ts +670 -0
- package/src/StoryPlan.ts +353 -0
- package/src/TransientRetry.ts +76 -7
package/src/Stories.ts
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
// The parallel story executor (ADR 0013). One story = one worktree on its
|
|
2
|
+
// own branch, created only once every predecessor has merged into the epic
|
|
3
|
+
// branch; the inner loop is the unchanged `implementPlanFlow`; a story
|
|
4
|
+
// merges back only after its judge and perimeter checks pass, and the
|
|
5
|
+
// target's gates run on the epic branch after every merge. Scheduling,
|
|
6
|
+
// gating, resume and failure policy live here; seats come from `contextFor`.
|
|
7
|
+
import * as Effect from "effect/Effect"
|
|
8
|
+
import * as Queue from "effect/Queue"
|
|
9
|
+
import * as Ref from "effect/Ref"
|
|
10
|
+
import * as Schema from "effect/Schema"
|
|
11
|
+
import type * as Scope from "effect/Scope"
|
|
12
|
+
import * as Semaphore from "effect/Semaphore"
|
|
13
|
+
import * as Stream from "effect/Stream"
|
|
14
|
+
import type { LlmServiceShape } from "@llm4ts/core/LlmService"
|
|
15
|
+
import type { LlmChunk, TokenUsage } from "@llm4ts/core/Models"
|
|
16
|
+
import type { LlmError } from "@llm4ts/core/Errors"
|
|
17
|
+
import { BoardItem, type BoardSyncShape } from "./BoardSync.ts"
|
|
18
|
+
import { makeChat } from "./Chat.ts"
|
|
19
|
+
import { implementPlanFlow } from "./Flow.ts"
|
|
20
|
+
import type { FlowContextShape } from "./FlowContext.ts"
|
|
21
|
+
import { describeFlowError, MissingDependency, StoryFailed, type FlowError } from "./FlowError.ts"
|
|
22
|
+
import { Info } from "./FlowEvents.ts"
|
|
23
|
+
import { enforcePerimeter } from "./Perimeter.ts"
|
|
24
|
+
import {
|
|
25
|
+
loadVersioned,
|
|
26
|
+
makePlanStore,
|
|
27
|
+
saveVersioned,
|
|
28
|
+
type PlainFileStoreShape
|
|
29
|
+
} from "./Persistence.ts"
|
|
30
|
+
import type { Plan } from "./Plan.ts"
|
|
31
|
+
import { stage } from "./PlanExecution.ts"
|
|
32
|
+
import { planFrom } from "./Planner.ts"
|
|
33
|
+
import type { ReviewResult } from "./Review.ts"
|
|
34
|
+
import type { Reviewer } from "./Reviewer.ts"
|
|
35
|
+
import {
|
|
36
|
+
dependentsOf,
|
|
37
|
+
readyStories,
|
|
38
|
+
storyHash,
|
|
39
|
+
topologicalWaves,
|
|
40
|
+
validateStoryPlan,
|
|
41
|
+
type Story,
|
|
42
|
+
type StoryPlan
|
|
43
|
+
} from "./StoryPlan.ts"
|
|
44
|
+
|
|
45
|
+
const join = (root: string, path: string): string =>
|
|
46
|
+
`${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`
|
|
47
|
+
|
|
48
|
+
// ---- Story seats ----------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/** A flow context rooted in a story's worktree, plus that story's own usage totals. */
|
|
51
|
+
export interface StorySeats {
|
|
52
|
+
readonly context: FlowContextShape
|
|
53
|
+
/** Running usage of this story's seats — estimates where the backend reports none. */
|
|
54
|
+
readonly totals?: Effect.Effect<TokenUsage | undefined>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- Durable state ----------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
export const StoryStateVersion = 1
|
|
60
|
+
|
|
61
|
+
export const StoryStatus = Schema.Literals(["started", "merged", "failed"])
|
|
62
|
+
export type StoryStatus = typeof StoryStatus.Type
|
|
63
|
+
|
|
64
|
+
/** What the executor remembers about a story between runs. */
|
|
65
|
+
export class StoryState extends Schema.Class<StoryState>("StoryState")({
|
|
66
|
+
id: Schema.String,
|
|
67
|
+
/** `storyHash` of the plan entry the branch was created from. */
|
|
68
|
+
hash: Schema.String,
|
|
69
|
+
branch: Schema.String,
|
|
70
|
+
worktree: Schema.String,
|
|
71
|
+
status: StoryStatus
|
|
72
|
+
}) {}
|
|
73
|
+
|
|
74
|
+
export const EpicReportVersion = 1
|
|
75
|
+
|
|
76
|
+
export const OutcomeStatus = Schema.Literals(["done", "failed", "skipped"])
|
|
77
|
+
export type OutcomeStatus = typeof OutcomeStatus.Type
|
|
78
|
+
|
|
79
|
+
export class StoryOutcome extends Schema.Class<StoryOutcome>("StoryOutcome")({
|
|
80
|
+
id: Schema.String,
|
|
81
|
+
title: Schema.String,
|
|
82
|
+
status: OutcomeStatus,
|
|
83
|
+
branch: Schema.optionalKey(Schema.String),
|
|
84
|
+
/** Failure or skip reason. */
|
|
85
|
+
reason: Schema.optionalKey(Schema.String),
|
|
86
|
+
judge: Schema.optionalKey(Schema.String),
|
|
87
|
+
/** ESTIMATES, never measurements (ADR 0012). */
|
|
88
|
+
estimatedTokens: Schema.optionalKey(Schema.Int),
|
|
89
|
+
estimatedCostUsd: Schema.optionalKey(Schema.Number)
|
|
90
|
+
}) {}
|
|
91
|
+
|
|
92
|
+
export class EpicReport extends Schema.Class<EpicReport>("EpicReport")({
|
|
93
|
+
epicId: Schema.String,
|
|
94
|
+
epicBranch: Schema.String,
|
|
95
|
+
/** Always true: the figures come from character-count estimates. */
|
|
96
|
+
estimated: Schema.Boolean,
|
|
97
|
+
stories: Schema.Array(StoryOutcome)
|
|
98
|
+
}) {
|
|
99
|
+
count(status: OutcomeStatus): number {
|
|
100
|
+
return this.stories.filter((story) => story.status === status).length
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const money = (value: number): string => `~$${value.toFixed(2)}`
|
|
105
|
+
|
|
106
|
+
export const renderEpicReport = (report: EpicReport): string => {
|
|
107
|
+
const lines: Array<string> = [
|
|
108
|
+
`# Epic report: ${report.epicId}`,
|
|
109
|
+
"",
|
|
110
|
+
"> Token and cost figures are ESTIMATES from character counts (ADR 0012):",
|
|
111
|
+
"> the CLI seats report no usage. They are not measurements.",
|
|
112
|
+
"",
|
|
113
|
+
`- Epic branch: \`${report.epicBranch}\``,
|
|
114
|
+
`- Stories: ${report.stories.length} (done ${report.count("done")}, failed ${report.count("failed")}, skipped ${report.count("skipped")})`,
|
|
115
|
+
"",
|
|
116
|
+
"| Story | Status | Branch | Est. tokens | Est. cost | Note |",
|
|
117
|
+
"| --- | --- | --- | --- | --- | --- |"
|
|
118
|
+
]
|
|
119
|
+
for (const story of report.stories) {
|
|
120
|
+
const note = story.reason ?? story.judge ?? ""
|
|
121
|
+
lines.push(
|
|
122
|
+
`| ${story.id} | ${story.status} | ${story.branch === undefined ? "—" : `\`${story.branch}\``} | ${
|
|
123
|
+
story.estimatedTokens === undefined ? "—" : `~${story.estimatedTokens}`
|
|
124
|
+
} | ${story.estimatedCostUsd === undefined ? "—" : money(story.estimatedCostUsd)} | ${note.replace(/\|/g, "\\|").replace(/\s+/g, " ")} |`
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
const tokens = report.stories.flatMap((story) =>
|
|
128
|
+
story.estimatedTokens === undefined ? [] : [story.estimatedTokens]
|
|
129
|
+
)
|
|
130
|
+
const cost = report.stories.flatMap((story) =>
|
|
131
|
+
story.estimatedCostUsd === undefined ? [] : [story.estimatedCostUsd]
|
|
132
|
+
)
|
|
133
|
+
lines.push("")
|
|
134
|
+
if (tokens.length > 0) {
|
|
135
|
+
lines.push(`- Estimated tokens: ~${tokens.reduce((sum, value) => sum + value, 0)}`)
|
|
136
|
+
}
|
|
137
|
+
if (cost.length > 0) {
|
|
138
|
+
lines.push(`- Estimated cost: ${money(cost.reduce((sum, value) => sum + value, 0))}`)
|
|
139
|
+
}
|
|
140
|
+
lines.push("")
|
|
141
|
+
return lines.join("\n")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- Prompts ----------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
export const blockedOnSentinel = "BLOCKED_ON:"
|
|
147
|
+
|
|
148
|
+
const blockedPattern = /BLOCKED_ON:\s*(.+)/
|
|
149
|
+
|
|
150
|
+
/** The text after the sentinel, if the coder ended a turn with it. */
|
|
151
|
+
export const blockedOnIn = (text: string): string | undefined => {
|
|
152
|
+
const match = blockedPattern.exec(text)
|
|
153
|
+
const need = match?.[1]?.trim()
|
|
154
|
+
return need === undefined || need.length === 0 ? undefined : need
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const bullets = (items: ReadonlyArray<string>): string =>
|
|
158
|
+
items.length === 0 ? "- (none)" : items.map((item) => `- ${item}`).join("\n")
|
|
159
|
+
|
|
160
|
+
/** The hard rules every story coder receives; the perimeter check enforces them afterwards. */
|
|
161
|
+
export const perimeterRules = (story: Story): string =>
|
|
162
|
+
[
|
|
163
|
+
`You are implementing ONE story of a larger epic: "${story.title}" (id: ${story.id}).`,
|
|
164
|
+
"",
|
|
165
|
+
"Paths you own — create and change files only here:",
|
|
166
|
+
bullets(story.owned),
|
|
167
|
+
"",
|
|
168
|
+
"Shared paths — read them for conventions, NEVER modify them:",
|
|
169
|
+
bullets(story.sharedReadOnly),
|
|
170
|
+
"",
|
|
171
|
+
"What this story must provide for other stories:",
|
|
172
|
+
bullets(story.provides),
|
|
173
|
+
"",
|
|
174
|
+
"Rules:",
|
|
175
|
+
"- Do not touch any path outside the owned list; a change there fails the story.",
|
|
176
|
+
"- If the story needs something that does not exist and is not yours to build, do not",
|
|
177
|
+
` build it. Reply with exactly \`${blockedOnSentinel} <what you need and which path>\` and stop.`,
|
|
178
|
+
"- Everything you provide must be implemented completely: other stories depend on it."
|
|
179
|
+
].join("\n")
|
|
180
|
+
|
|
181
|
+
export const storyPrompt = (story: Story): string =>
|
|
182
|
+
[`Story: ${story.title}`, "", story.description.trim()].join("\n")
|
|
183
|
+
|
|
184
|
+
export const storyTaskPlanInstructions = (story: Story): string =>
|
|
185
|
+
[
|
|
186
|
+
"You are planning the implementation of ONE story inside a larger epic. Break the story",
|
|
187
|
+
"into an ordered list of small, independently verifiable tasks, each described by its",
|
|
188
|
+
"observable outcome. Every task must stay inside the story's owned paths.",
|
|
189
|
+
`Use exactly this epicId: "${story.id}".`,
|
|
190
|
+
'Respond only with JSON: {"epicId":"' + story.id + '","tasks":',
|
|
191
|
+
'[{"title":"...","description":"...","completed":false}]}'
|
|
192
|
+
].join("\n")
|
|
193
|
+
|
|
194
|
+
/** The coder plans its own tasks from the story — the orchestrator split the epic, not the story. */
|
|
195
|
+
export const defaultPlanTasks = (
|
|
196
|
+
seats: StorySeats,
|
|
197
|
+
story: Story,
|
|
198
|
+
prompt: string
|
|
199
|
+
): Effect.Effect<Plan, FlowError> =>
|
|
200
|
+
planFrom(seats.context.coder, prompt, storyTaskPlanInstructions(story))
|
|
201
|
+
|
|
202
|
+
// ---- BLOCKED_ON detection ---------------------------------------------------
|
|
203
|
+
|
|
204
|
+
const watchStream = (
|
|
205
|
+
stream: Stream.Stream<LlmChunk, LlmError>,
|
|
206
|
+
blocked: Ref.Ref<string | undefined>
|
|
207
|
+
): Stream.Stream<LlmChunk, LlmError> =>
|
|
208
|
+
Stream.unwrap(
|
|
209
|
+
Effect.gen(function* () {
|
|
210
|
+
const text = yield* Ref.make("")
|
|
211
|
+
const tapped = stream.pipe(
|
|
212
|
+
Stream.tap((chunk) => Ref.update(text, (current) => current + chunk.delta))
|
|
213
|
+
)
|
|
214
|
+
const check = Stream.fromEffect(
|
|
215
|
+
Effect.gen(function* () {
|
|
216
|
+
const need = blockedOnIn(yield* Ref.get(text))
|
|
217
|
+
if (need !== undefined) {
|
|
218
|
+
yield* Ref.set(blocked, need)
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
).pipe(Stream.drain)
|
|
222
|
+
return Stream.concat(tapped, check)
|
|
223
|
+
})
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* A coder whose turns are watched for the `BLOCKED_ON:` sentinel. The story
|
|
228
|
+
* loop reads the ref after the coder stops, so a blocked turn ends the story
|
|
229
|
+
* as a typed `MissingDependency` instead of an empty-diff abort.
|
|
230
|
+
*/
|
|
231
|
+
export const watchForBlockedOn = (
|
|
232
|
+
service: LlmServiceShape,
|
|
233
|
+
blocked: Ref.Ref<string | undefined>
|
|
234
|
+
): LlmServiceShape => ({
|
|
235
|
+
...service,
|
|
236
|
+
executeStream: (prompt) => watchStream(service.executeStream(prompt), blocked),
|
|
237
|
+
executeStreamWithHistory: (messages) =>
|
|
238
|
+
watchStream(service.executeStreamWithHistory(messages), blocked)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// ---- Options ----------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
export interface StoriesOptions {
|
|
244
|
+
readonly plan: StoryPlan
|
|
245
|
+
readonly files: PlainFileStoreShape
|
|
246
|
+
/** The executor's own durable state: story states, task plans, the report. */
|
|
247
|
+
readonly stateDir: string
|
|
248
|
+
/** Where story worktrees are created (`<worktreeRoot>/<story-id>`). */
|
|
249
|
+
readonly worktreeRoot: string
|
|
250
|
+
/** Default `epic/<epicId>`. */
|
|
251
|
+
readonly epicBranch?: string
|
|
252
|
+
/** Seats rooted in a worktree; the executor never resolves seats itself. */
|
|
253
|
+
readonly contextFor: (workDir: string) => Effect.Effect<StorySeats, FlowError, Scope.Scope>
|
|
254
|
+
readonly board: BoardSyncShape
|
|
255
|
+
/** The target's gates, run in a worktree per task and on the epic checkout after each merge. */
|
|
256
|
+
readonly gates: (workDir: string) => Effect.Effect<ReviewResult, FlowError>
|
|
257
|
+
/** Story-level judge over the branch's diff against the epic branch; omit to skip. */
|
|
258
|
+
readonly judge?: (story: Story, diff: string) => Effect.Effect<ReviewResult, FlowError>
|
|
259
|
+
/** Judge attempts, each but the last followed by one coder feedback round. Default 2. */
|
|
260
|
+
readonly judgeRounds?: number
|
|
261
|
+
/** Extra system context per story (house rules, shared read-only excerpts). */
|
|
262
|
+
readonly system?: (story: Story) => Effect.Effect<string, FlowError>
|
|
263
|
+
/** How a story's task plan is produced. Default: the story's own coder plans it. */
|
|
264
|
+
readonly planTasks?: (
|
|
265
|
+
seats: StorySeats,
|
|
266
|
+
story: Story,
|
|
267
|
+
prompt: string
|
|
268
|
+
) => Effect.Effect<Plan, FlowError>
|
|
269
|
+
/** Stories implemented at once. Default 3. */
|
|
270
|
+
readonly concurrency?: number
|
|
271
|
+
/** Stop the epic at the first failed story instead of skipping its dependents. */
|
|
272
|
+
readonly failFast?: boolean
|
|
273
|
+
readonly reviewers?: ReadonlyArray<Reviewer>
|
|
274
|
+
readonly maxRounds?: number
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
interface Completion {
|
|
278
|
+
readonly story: Story
|
|
279
|
+
readonly outcome: StoryOutcome
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const issueLines = (result: ReviewResult): string =>
|
|
283
|
+
result.issues.map((issue) => `- ${issue.title}: ${issue.description}`).join("\n")
|
|
284
|
+
|
|
285
|
+
const failed = (story: Story, reason: string): StoryFailed =>
|
|
286
|
+
StoryFailed.make({ story: story.id, reason })
|
|
287
|
+
|
|
288
|
+
// ---- The executor -------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(function* (
|
|
291
|
+
context: FlowContextShape,
|
|
292
|
+
options: StoriesOptions
|
|
293
|
+
): Effect.fn.Return<EpicReport, FlowError> {
|
|
294
|
+
const plan = yield* validateStoryPlan(options.plan)
|
|
295
|
+
const { files, board } = options
|
|
296
|
+
const events = context.events
|
|
297
|
+
const epicBranch = options.epicBranch ?? `epic/${plan.epicId}`
|
|
298
|
+
const concurrency = Math.max(1, options.concurrency ?? 3)
|
|
299
|
+
const judgeRounds = Math.max(1, options.judgeRounds ?? 2)
|
|
300
|
+
const statePath = (story: Story): string => join(options.stateDir, `stories/${story.id}.json`)
|
|
301
|
+
const planPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.plan.md`)
|
|
302
|
+
|
|
303
|
+
yield* stage(events, "epic branch", context.git.checkoutOrCreate(epicBranch))
|
|
304
|
+
|
|
305
|
+
const waves = topologicalWaves(plan)
|
|
306
|
+
const waveOf = (id: string): string | undefined => {
|
|
307
|
+
const index = waves.findIndex((wave) => wave.includes(id))
|
|
308
|
+
return index < 0 ? undefined : `${index + 1}`
|
|
309
|
+
}
|
|
310
|
+
yield* board.plan(
|
|
311
|
+
plan.stories.map((story) => {
|
|
312
|
+
const wave = waveOf(story.id)
|
|
313
|
+
return BoardItem.make({
|
|
314
|
+
id: story.id,
|
|
315
|
+
title: story.title,
|
|
316
|
+
status: "planned",
|
|
317
|
+
...(wave === undefined ? {} : { wave })
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
const mergeLock = yield* Semaphore.make(1)
|
|
323
|
+
|
|
324
|
+
/** Merge the story branch into the epic branch and re-gate the epic head, one story at a time. */
|
|
325
|
+
const integrate = (story: Story, branch: string): Effect.Effect<void, FlowError> =>
|
|
326
|
+
mergeLock.withPermit(
|
|
327
|
+
Effect.gen(function* () {
|
|
328
|
+
const checkpoint = yield* context.git.checkpoint
|
|
329
|
+
yield* context.git.merge(branch, `${plan.epicId}: merge story ${story.id}`)
|
|
330
|
+
const gate = yield* options.gates(context.workDir)
|
|
331
|
+
if (!gate.isClean) {
|
|
332
|
+
// Never leave a red epic head for the next story to inherit.
|
|
333
|
+
yield* context.git.rollback(checkpoint)
|
|
334
|
+
return yield* failed(
|
|
335
|
+
story,
|
|
336
|
+
`epic gates failed after merging; merge undone:\n${issueLines(gate)}`
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
yield* events.publish(
|
|
340
|
+
Info.make({ message: `story ${story.id}: merged into ${epicBranch}` })
|
|
341
|
+
)
|
|
342
|
+
})
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
/** The worktree and branch for a story, honouring the hash-guarded resume rules. */
|
|
346
|
+
const prepareWorktree = Effect.fn("@llm4ts/flow/Stories.prepareWorktree")(function* (
|
|
347
|
+
story: Story
|
|
348
|
+
): Effect.fn.Return<{ readonly state: StoryState; readonly alreadyMerged: boolean }, FlowError> {
|
|
349
|
+
const hash = storyHash(story)
|
|
350
|
+
const branch = `story/${plan.epicId}/${story.id}`
|
|
351
|
+
const worktree = join(options.worktreeRoot, story.id)
|
|
352
|
+
const path = statePath(story)
|
|
353
|
+
let stored = yield* loadVersioned(files, path, StoryStateVersion, StoryState)
|
|
354
|
+
if (stored !== undefined && stored.hash !== hash) {
|
|
355
|
+
yield* events.publish(
|
|
356
|
+
Info.make({
|
|
357
|
+
message: `story ${story.id}: plan entry changed since its branch was created; starting over`
|
|
358
|
+
})
|
|
359
|
+
)
|
|
360
|
+
// The old worktree may already be gone (an operator cleaned up); that is
|
|
361
|
+
// not a failure of this run, so removal errors are reported and dropped.
|
|
362
|
+
yield* context.git
|
|
363
|
+
.removeWorktree(stored.worktree, true)
|
|
364
|
+
.pipe(
|
|
365
|
+
Effect.catch((error) =>
|
|
366
|
+
events.publish(Info.make({ message: `story ${story.id}: ${describeFlowError(error)}` }))
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
if (yield* context.git.branchExists(stored.branch)) {
|
|
370
|
+
yield* context.git.deleteBranch(stored.branch)
|
|
371
|
+
}
|
|
372
|
+
stored = undefined
|
|
373
|
+
}
|
|
374
|
+
if (stored !== undefined && stored.status === "merged") {
|
|
375
|
+
return { state: stored, alreadyMerged: true }
|
|
376
|
+
}
|
|
377
|
+
if (stored === undefined) {
|
|
378
|
+
yield* context.git.addWorktreeNewBranch(worktree, branch, epicBranch)
|
|
379
|
+
const state = StoryState.make({ id: story.id, hash, branch, worktree, status: "started" })
|
|
380
|
+
yield* saveVersioned(files, path, StoryStateVersion, StoryState, state)
|
|
381
|
+
return { state, alreadyMerged: false }
|
|
382
|
+
}
|
|
383
|
+
// Resume: the branch exists; the worktree may not (a worktree has a
|
|
384
|
+
// `.git` FILE at its root, which is how its presence is checked).
|
|
385
|
+
const marker = yield* files.read(join(stored.worktree, ".git"))
|
|
386
|
+
if (marker === undefined) {
|
|
387
|
+
yield* context.git.addWorktree(stored.worktree, stored.branch)
|
|
388
|
+
}
|
|
389
|
+
yield* events.publish(Info.make({ message: `story ${story.id}: resuming ${stored.branch}` }))
|
|
390
|
+
return { state: stored, alreadyMerged: false }
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
/** Everything that happens inside a story's worktree: plan, implement, judge, perimeter. */
|
|
394
|
+
const implementStory = Effect.fn("@llm4ts/flow/Stories.implementStory")(function* (
|
|
395
|
+
story: Story,
|
|
396
|
+
state: StoryState
|
|
397
|
+
): Effect.fn.Return<
|
|
398
|
+
{ readonly judge: string | undefined; readonly totals: TokenUsage | undefined },
|
|
399
|
+
FlowError,
|
|
400
|
+
Scope.Scope
|
|
401
|
+
> {
|
|
402
|
+
const seats = yield* options.contextFor(state.worktree)
|
|
403
|
+
const blocked = yield* Ref.make<string | undefined>(undefined)
|
|
404
|
+
const storyContext: FlowContextShape = {
|
|
405
|
+
...seats.context,
|
|
406
|
+
coder: watchForBlockedOn(seats.context.coder, blocked)
|
|
407
|
+
}
|
|
408
|
+
const watchedSeats: StorySeats = { ...seats, context: storyContext }
|
|
409
|
+
const extra = options.system === undefined ? undefined : yield* options.system(story)
|
|
410
|
+
const system = [perimeterRules(story), extra]
|
|
411
|
+
.filter((part): part is string => part !== undefined && part.trim().length > 0)
|
|
412
|
+
.join("\n\n")
|
|
413
|
+
const prompt = storyPrompt(story)
|
|
414
|
+
const blockedOr = <A>(effect: Effect.Effect<A, FlowError>): Effect.Effect<A, FlowError> =>
|
|
415
|
+
effect.pipe(
|
|
416
|
+
Effect.catch((error) =>
|
|
417
|
+
Effect.flatMap(Ref.get(blocked), (need) =>
|
|
418
|
+
need === undefined
|
|
419
|
+
? Effect.fail(error)
|
|
420
|
+
: Effect.fail(MissingDependency.make({ story: story.id, need }))
|
|
421
|
+
)
|
|
422
|
+
),
|
|
423
|
+
Effect.tap(() =>
|
|
424
|
+
Effect.flatMap(Ref.get(blocked), (need) =>
|
|
425
|
+
need === undefined
|
|
426
|
+
? Effect.void
|
|
427
|
+
: Effect.fail(MissingDependency.make({ story: story.id, need }))
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
yield* blockedOr(
|
|
433
|
+
implementPlanFlow(storyContext, {
|
|
434
|
+
store: makePlanStore(files),
|
|
435
|
+
planPath: planPath(story),
|
|
436
|
+
plan: (options.planTasks ?? defaultPlanTasks)(watchedSeats, story, prompt),
|
|
437
|
+
system,
|
|
438
|
+
chatPerTask: true,
|
|
439
|
+
checkoutBranch: false,
|
|
440
|
+
lint: options.gates(state.worktree),
|
|
441
|
+
...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
|
|
442
|
+
...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds })
|
|
443
|
+
})
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
let judgeNote: string | undefined
|
|
447
|
+
if (options.judge !== undefined) {
|
|
448
|
+
const judge = options.judge
|
|
449
|
+
for (let round = 1; round <= judgeRounds; round += 1) {
|
|
450
|
+
const diff = yield* storyContext.git.diffVsBase(epicBranch)
|
|
451
|
+
const verdict = yield* judge(story, diff)
|
|
452
|
+
if (verdict.isClean) {
|
|
453
|
+
judgeNote = `judge cleared (round ${round})`
|
|
454
|
+
break
|
|
455
|
+
}
|
|
456
|
+
if (round >= judgeRounds) {
|
|
457
|
+
return yield* failed(
|
|
458
|
+
story,
|
|
459
|
+
`judge not cleared after ${judgeRounds} round(s):\n${issueLines(verdict)}`
|
|
460
|
+
)
|
|
461
|
+
}
|
|
462
|
+
const feedback = yield* makeChat(storyContext.coder, {
|
|
463
|
+
system,
|
|
464
|
+
events,
|
|
465
|
+
agent: "coder"
|
|
466
|
+
})
|
|
467
|
+
yield* blockedOr(
|
|
468
|
+
feedback.ask(
|
|
469
|
+
[
|
|
470
|
+
`The story "${story.title}" scored below the bar. Close these gaps without`,
|
|
471
|
+
"weakening any test and without leaving your owned paths, then stop:",
|
|
472
|
+
issueLines(verdict)
|
|
473
|
+
].join("\n")
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
const regated = yield* options.gates(state.worktree)
|
|
477
|
+
if (!regated.isClean) {
|
|
478
|
+
return yield* failed(
|
|
479
|
+
story,
|
|
480
|
+
`gates broke while addressing judge feedback:\n${issueLines(regated)}`
|
|
481
|
+
)
|
|
482
|
+
}
|
|
483
|
+
yield* storyContext.git.commitAll(`${story.id}: address judge feedback`)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const changed = yield* storyContext.git.changedFilesVsBase(epicBranch)
|
|
488
|
+
yield* enforcePerimeter(changed, story)
|
|
489
|
+
const totals = seats.totals === undefined ? undefined : yield* seats.totals
|
|
490
|
+
return { judge: judgeNote, totals }
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
const runStory = Effect.fn("@llm4ts/flow/Stories.runStory")(function* (
|
|
494
|
+
story: Story
|
|
495
|
+
): Effect.fn.Return<StoryOutcome, FlowError> {
|
|
496
|
+
yield* board.start(story.id)
|
|
497
|
+
const { state, alreadyMerged } = yield* prepareWorktree(story)
|
|
498
|
+
if (alreadyMerged) {
|
|
499
|
+
yield* events.publish(Info.make({ message: `story ${story.id}: already merged; skipping` }))
|
|
500
|
+
return StoryOutcome.make({
|
|
501
|
+
id: story.id,
|
|
502
|
+
title: story.title,
|
|
503
|
+
status: "done",
|
|
504
|
+
branch: state.branch,
|
|
505
|
+
judge: "merged on a previous run"
|
|
506
|
+
})
|
|
507
|
+
}
|
|
508
|
+
const result = yield* Effect.scoped(implementStory(story, state))
|
|
509
|
+
yield* integrate(story, state.branch)
|
|
510
|
+
yield* saveVersioned(
|
|
511
|
+
files,
|
|
512
|
+
statePath(story),
|
|
513
|
+
StoryStateVersion,
|
|
514
|
+
StoryState,
|
|
515
|
+
StoryState.make({ ...state, status: "merged" })
|
|
516
|
+
)
|
|
517
|
+
return StoryOutcome.make({
|
|
518
|
+
id: story.id,
|
|
519
|
+
title: story.title,
|
|
520
|
+
status: "done",
|
|
521
|
+
branch: state.branch,
|
|
522
|
+
...(result.judge === undefined ? {} : { judge: result.judge }),
|
|
523
|
+
...(result.totals === undefined ? {} : { estimatedTokens: result.totals.total }),
|
|
524
|
+
...(result.totals?.costUsd === undefined ? {} : { estimatedCostUsd: result.totals.costUsd })
|
|
525
|
+
})
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
/** A story's run as a completion — failures become outcomes, never fiber deaths. */
|
|
529
|
+
const attempt = (story: Story): Effect.Effect<Completion> =>
|
|
530
|
+
stage(events, `story ${story.id}`, runStory(story)).pipe(
|
|
531
|
+
Effect.map((outcome): Completion => ({ story, outcome })),
|
|
532
|
+
Effect.catch((error) =>
|
|
533
|
+
Effect.gen(function* () {
|
|
534
|
+
const reason = describeFlowError(error)
|
|
535
|
+
const path = statePath(story)
|
|
536
|
+
const stored = yield* loadVersioned(files, path, StoryStateVersion, StoryState).pipe(
|
|
537
|
+
Effect.catch(() => Effect.succeed(undefined))
|
|
538
|
+
)
|
|
539
|
+
if (stored !== undefined && stored.status !== "merged") {
|
|
540
|
+
yield* saveVersioned(
|
|
541
|
+
files,
|
|
542
|
+
path,
|
|
543
|
+
StoryStateVersion,
|
|
544
|
+
StoryState,
|
|
545
|
+
StoryState.make({ ...stored, status: "failed" })
|
|
546
|
+
).pipe(Effect.ignore)
|
|
547
|
+
}
|
|
548
|
+
return {
|
|
549
|
+
story,
|
|
550
|
+
outcome: StoryOutcome.make({
|
|
551
|
+
id: story.id,
|
|
552
|
+
title: story.title,
|
|
553
|
+
status: "failed",
|
|
554
|
+
reason,
|
|
555
|
+
...(stored === undefined ? {} : { branch: stored.branch })
|
|
556
|
+
})
|
|
557
|
+
}
|
|
558
|
+
})
|
|
559
|
+
)
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
const outcomes = yield* Ref.make<ReadonlyArray<StoryOutcome>>([])
|
|
563
|
+
const running = yield* Ref.make<ReadonlySet<string>>(new Set())
|
|
564
|
+
const completions = yield* Queue.unbounded<Completion>()
|
|
565
|
+
|
|
566
|
+
const progress = Effect.gen(function* () {
|
|
567
|
+
const known = yield* Ref.get(outcomes)
|
|
568
|
+
const byStatus = (status: OutcomeStatus): ReadonlySet<string> =>
|
|
569
|
+
new Set(known.filter((outcome) => outcome.status === status).map((outcome) => outcome.id))
|
|
570
|
+
return {
|
|
571
|
+
done: byStatus("done"),
|
|
572
|
+
failed: byStatus("failed"),
|
|
573
|
+
skipped: byStatus("skipped"),
|
|
574
|
+
running: yield* Ref.get(running)
|
|
575
|
+
}
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
const record = (outcome: StoryOutcome): Effect.Effect<void> =>
|
|
579
|
+
Ref.update(outcomes, (current) => [...current, outcome])
|
|
580
|
+
|
|
581
|
+
const skipDependents = (story: Story): Effect.Effect<void, FlowError> =>
|
|
582
|
+
Effect.gen(function* () {
|
|
583
|
+
const current = yield* progress
|
|
584
|
+
for (const id of dependentsOf(plan, story.id)) {
|
|
585
|
+
const dependent = plan.story(id)
|
|
586
|
+
if (
|
|
587
|
+
dependent === undefined ||
|
|
588
|
+
current.done.has(id) ||
|
|
589
|
+
current.failed.has(id) ||
|
|
590
|
+
current.skipped.has(id)
|
|
591
|
+
) {
|
|
592
|
+
continue
|
|
593
|
+
}
|
|
594
|
+
const reason = `blocked by ${story.id}`
|
|
595
|
+
yield* record(StoryOutcome.make({ id, title: dependent.title, status: "skipped", reason }))
|
|
596
|
+
yield* board.skip(id, reason)
|
|
597
|
+
}
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
yield* Effect.scoped(
|
|
601
|
+
Effect.gen(function* () {
|
|
602
|
+
while (true) {
|
|
603
|
+
const current = yield* progress
|
|
604
|
+
const ready = readyStories(plan, current)
|
|
605
|
+
const slots = concurrency - current.running.size
|
|
606
|
+
for (const story of ready.slice(0, Math.max(0, slots))) {
|
|
607
|
+
yield* Ref.update(running, (set) => new Set([...set, story.id]))
|
|
608
|
+
yield* Effect.forkScoped(
|
|
609
|
+
attempt(story).pipe(
|
|
610
|
+
Effect.flatMap((completion) => Queue.offer(completions, completion))
|
|
611
|
+
)
|
|
612
|
+
)
|
|
613
|
+
}
|
|
614
|
+
if ((yield* Ref.get(running)).size === 0) {
|
|
615
|
+
break
|
|
616
|
+
}
|
|
617
|
+
const completion = yield* Queue.take(completions)
|
|
618
|
+
yield* Ref.update(running, (set) => {
|
|
619
|
+
const next = new Set(set)
|
|
620
|
+
next.delete(completion.story.id)
|
|
621
|
+
return next
|
|
622
|
+
})
|
|
623
|
+
yield* record(completion.outcome)
|
|
624
|
+
const outcome = completion.outcome
|
|
625
|
+
if (outcome.status === "done") {
|
|
626
|
+
yield* board.complete(outcome.id, {
|
|
627
|
+
...(outcome.branch === undefined ? {} : { branch: outcome.branch }),
|
|
628
|
+
...(outcome.judge === undefined ? {} : { detail: outcome.judge }),
|
|
629
|
+
...(outcome.estimatedTokens === undefined
|
|
630
|
+
? {}
|
|
631
|
+
: { estimatedTokens: outcome.estimatedTokens }),
|
|
632
|
+
...(outcome.estimatedCostUsd === undefined
|
|
633
|
+
? {}
|
|
634
|
+
: { estimatedCostUsd: outcome.estimatedCostUsd })
|
|
635
|
+
})
|
|
636
|
+
} else {
|
|
637
|
+
yield* board.fail(outcome.id, outcome.reason ?? "failed")
|
|
638
|
+
if (options.failFast === true) {
|
|
639
|
+
return yield* StoryFailed.make({
|
|
640
|
+
story: outcome.id,
|
|
641
|
+
reason: outcome.reason ?? "failed"
|
|
642
|
+
})
|
|
643
|
+
}
|
|
644
|
+
yield* skipDependents(completion.story)
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
})
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
const recorded = yield* Ref.get(outcomes)
|
|
651
|
+
const ordered = plan.stories.flatMap((story) => {
|
|
652
|
+
const outcome = recorded.find((candidate) => candidate.id === story.id)
|
|
653
|
+
return outcome === undefined ? [] : [outcome]
|
|
654
|
+
})
|
|
655
|
+
const report = EpicReport.make({
|
|
656
|
+
epicId: plan.epicId,
|
|
657
|
+
epicBranch,
|
|
658
|
+
estimated: true,
|
|
659
|
+
stories: ordered
|
|
660
|
+
})
|
|
661
|
+
yield* saveVersioned(
|
|
662
|
+
files,
|
|
663
|
+
join(options.stateDir, "report.json"),
|
|
664
|
+
EpicReportVersion,
|
|
665
|
+
EpicReport,
|
|
666
|
+
report
|
|
667
|
+
)
|
|
668
|
+
yield* files.writeAtomic(join(options.stateDir, "report.md"), renderEpicReport(report))
|
|
669
|
+
return report
|
|
670
|
+
})
|