@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/dist/Stories.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// target's gates run on the epic branch after every merge. Scheduling,
|
|
6
6
|
// gating, resume and failure policy live here; seats come from `contextFor`.
|
|
7
7
|
import * as Effect from "effect/Effect";
|
|
8
|
+
import * as Exit from "effect/Exit";
|
|
8
9
|
import * as Queue from "effect/Queue";
|
|
9
10
|
import * as Ref from "effect/Ref";
|
|
10
11
|
import * as Schema from "effect/Schema";
|
|
@@ -13,13 +14,17 @@ import * as Stream from "effect/Stream";
|
|
|
13
14
|
import { BoardItem } from "./BoardSync.js";
|
|
14
15
|
import { makeChat } from "./Chat.js";
|
|
15
16
|
import { implementPlanFlow } from "./Flow.js";
|
|
16
|
-
import { describeFlowError, MissingDependency, StoryFailed } from "./FlowError.js";
|
|
17
|
+
import { describeFlowError, rosterExhaustedPrefix, EpicCheckoutDirty, MissingDependency, PerimeterViolation, StoryFailed } from "./FlowError.js";
|
|
17
18
|
import { Info } from "./FlowEvents.js";
|
|
18
|
-
import {
|
|
19
|
+
import { statusPaths } from "./GitTool.js";
|
|
20
|
+
import { checkPerimeter, enforcePerimeter, isWithinPerimeter, perimeterGate, strayTasks } from "./Perimeter.js";
|
|
19
21
|
import { loadVersioned, makePlanStore, saveVersioned } from "./Persistence.js";
|
|
22
|
+
import { Plan } from "./Plan.js";
|
|
20
23
|
import { stage } from "./PlanExecution.js";
|
|
21
24
|
import { planFrom } from "./Planner.js";
|
|
22
|
-
import {
|
|
25
|
+
import { ReviewResult } from "./Review.js";
|
|
26
|
+
import { dependentsOf, ownerOf, pathsNamedIn, readyStories, relationOf, storyHash, topologicalWaves, validateStoryPlan } from "./StoryPlan.js";
|
|
27
|
+
import { isOutageMessage } from "./TransientRetry.js";
|
|
23
28
|
const join = (root, path) => `${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`;
|
|
24
29
|
// ---- Durable state ----------------------------------------------------------
|
|
25
30
|
export const StoryStateVersion = 1;
|
|
@@ -31,7 +36,9 @@ export class StoryState extends Schema.Class("StoryState")({
|
|
|
31
36
|
hash: Schema.String,
|
|
32
37
|
branch: Schema.String,
|
|
33
38
|
worktree: Schema.String,
|
|
34
|
-
status: StoryStatus
|
|
39
|
+
status: StoryStatus,
|
|
40
|
+
/** The roster executor that last held the story's coder (ADR 0019); preferred on resume. */
|
|
41
|
+
executor: Schema.optionalKey(Schema.String)
|
|
35
42
|
}) {
|
|
36
43
|
}
|
|
37
44
|
export const EpicReportVersion = 1;
|
|
@@ -45,6 +52,8 @@ export class StoryOutcome extends Schema.Class("StoryOutcome")({
|
|
|
45
52
|
/** Failure reason, or what a waiting story waits for. */
|
|
46
53
|
reason: Schema.optionalKey(Schema.String),
|
|
47
54
|
judge: Schema.optionalKey(Schema.String),
|
|
55
|
+
/** The roster executor(s) that coded the story, in order ("codex → claude"). */
|
|
56
|
+
executor: Schema.optionalKey(Schema.String),
|
|
48
57
|
/** ESTIMATES, never measurements (ADR 0012). */
|
|
49
58
|
estimatedTokens: Schema.optionalKey(Schema.Int),
|
|
50
59
|
estimatedCostUsd: Schema.optionalKey(Schema.Number)
|
|
@@ -88,6 +97,25 @@ export const renderEpicReport = (report) => {
|
|
|
88
97
|
if (cost.length > 0) {
|
|
89
98
|
lines.push(`- Estimated cost: ${money(cost.reduce((sum, value) => sum + value, 0))}`);
|
|
90
99
|
}
|
|
100
|
+
const coded = report.stories.filter((story) => story.executor !== undefined);
|
|
101
|
+
if (coded.length > 0) {
|
|
102
|
+
// A story is counted for the executor that finished it; a handover chain
|
|
103
|
+
// is shown in full next to the story.
|
|
104
|
+
const byExecutor = new Map();
|
|
105
|
+
for (const story of coded) {
|
|
106
|
+
const chain = story.executor ?? "";
|
|
107
|
+
const last = chain.split(" → ").at(-1) ?? chain;
|
|
108
|
+
const entry = byExecutor.get(last) ?? { stories: [], tokens: 0, cost: 0 };
|
|
109
|
+
entry.stories.push(chain === last ? story.id : `${story.id} (${chain})`);
|
|
110
|
+
entry.tokens += story.estimatedTokens ?? 0;
|
|
111
|
+
entry.cost += story.estimatedCostUsd ?? 0;
|
|
112
|
+
byExecutor.set(last, entry);
|
|
113
|
+
}
|
|
114
|
+
lines.push("", "## By executor", "");
|
|
115
|
+
for (const [executor, entry] of byExecutor) {
|
|
116
|
+
lines.push(`- ${executor}: ${entry.stories.join(", ")} — ~${entry.tokens} tokens, ${money(entry.cost)} (estimated)`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
91
119
|
lines.push("");
|
|
92
120
|
return lines.join("\n");
|
|
93
121
|
};
|
|
@@ -115,8 +143,31 @@ export const blockedOnIn = (text) => {
|
|
|
115
143
|
return need === undefined || need.length === 0 ? undefined : need;
|
|
116
144
|
};
|
|
117
145
|
const bullets = (items) => items.length === 0 ? "- (none)" : items.map((item) => `- ${item}`).join("\n");
|
|
146
|
+
const relationNotes = {
|
|
147
|
+
dependency: "merged into the epic before you started — it is in your working tree; read it",
|
|
148
|
+
dependent: "built AFTER you, on top of your work — leave it alone (wiring your work in is that story's job)",
|
|
149
|
+
parallel: "built at the same time by another coder — leave it alone"
|
|
150
|
+
};
|
|
151
|
+
const whereaboutsRules = (story, where) => {
|
|
152
|
+
const others = where.plan.stories
|
|
153
|
+
.filter((other) => other.id !== story.id)
|
|
154
|
+
.map((other) => {
|
|
155
|
+
const relation = relationOf(where.plan, story, other);
|
|
156
|
+
const note = relation === "self" ? "" : relationNotes[relation];
|
|
157
|
+
return `${other.owned.join(", ")} — story ${other.id}: ${note}`;
|
|
158
|
+
});
|
|
159
|
+
return [
|
|
160
|
+
"",
|
|
161
|
+
`Your working directory is ${where.worktree}. Run every command there. Never cd into, read`,
|
|
162
|
+
`from, or write to any other checkout — above all not ${where.epicCheckout}, the epic`,
|
|
163
|
+
"branch's checkout: a change there breaks every other story's merge.",
|
|
164
|
+
"",
|
|
165
|
+
"Paths other stories own — never create or change them, and none of them is a reason to stop:",
|
|
166
|
+
bullets(others)
|
|
167
|
+
];
|
|
168
|
+
};
|
|
118
169
|
/** The hard rules every story coder receives; the perimeter check enforces them afterwards. */
|
|
119
|
-
export const perimeterRules = (story) => [
|
|
170
|
+
export const perimeterRules = (story, where) => [
|
|
120
171
|
`You are implementing ONE story of a larger epic: "${story.title}" (id: ${story.id}).`,
|
|
121
172
|
"",
|
|
122
173
|
"Paths you own — create and change files only here:",
|
|
@@ -127,6 +178,7 @@ export const perimeterRules = (story) => [
|
|
|
127
178
|
"",
|
|
128
179
|
"What this story must provide for other stories:",
|
|
129
180
|
bullets(story.provides),
|
|
181
|
+
...(where === undefined ? [] : whereaboutsRules(story, where)),
|
|
130
182
|
"",
|
|
131
183
|
"Rules:",
|
|
132
184
|
"- Do not touch any path outside the owned list; a change there fails the story.",
|
|
@@ -142,13 +194,86 @@ export const storyPrompt = (story) => [`Story: ${story.title}`, "", story.descri
|
|
|
142
194
|
export const storyTaskPlanInstructions = (story) => [
|
|
143
195
|
"You are planning the implementation of ONE story inside a larger epic. Break the story",
|
|
144
196
|
"into an ordered list of small, independently verifiable tasks, each described by its",
|
|
145
|
-
"observable outcome. Every task must stay inside the story's owned paths
|
|
197
|
+
"observable outcome. Every task must stay inside the story's owned paths:",
|
|
198
|
+
bullets(story.owned),
|
|
199
|
+
"Never plan a task that creates or changes anything outside them — registering or wiring",
|
|
200
|
+
"the story in elsewhere (the app's composition point, a shared kit file) is another story's job.",
|
|
146
201
|
`Use exactly this epicId: "${story.id}".`,
|
|
147
202
|
'Respond only with JSON: {"epicId":"' + story.id + '","tasks":',
|
|
148
203
|
'[{"title":"...","description":"...","completed":false}]}'
|
|
149
204
|
].join("\n");
|
|
150
205
|
/** The coder plans its own tasks from the story — the orchestrator split the epic, not the story. */
|
|
151
206
|
export const defaultPlanTasks = (seats, story, prompt) => planFrom(seats.context.coder, prompt, storyTaskPlanInstructions(story));
|
|
207
|
+
/**
|
|
208
|
+
* Asks the planner again, once, when its tasks name paths outside the story;
|
|
209
|
+
* tasks that still name ANOTHER story's paths are then dropped (that work is
|
|
210
|
+
* planned elsewhere) and any remaining stray is left to the perimeter gate.
|
|
211
|
+
*/
|
|
212
|
+
export const planWithinPerimeter = (plan, story, planTasks, prompt, events) => Effect.gen(function* () {
|
|
213
|
+
const first = yield* planTasks(prompt);
|
|
214
|
+
const strays = strayTasks(plan, story, first.tasks);
|
|
215
|
+
if (strays.length === 0) {
|
|
216
|
+
return first;
|
|
217
|
+
}
|
|
218
|
+
const describe = strays.map((stray) => `- "${stray.task.title}": ${stray.paths.join(", ")}`);
|
|
219
|
+
yield* events.publish(Info.make({
|
|
220
|
+
message: `story ${story.id}: ${strays.length} planned task(s) name paths outside the story; re-planning:\n${describe.join("\n")}`
|
|
221
|
+
}));
|
|
222
|
+
const second = yield* planTasks([
|
|
223
|
+
prompt,
|
|
224
|
+
"",
|
|
225
|
+
"Your previous task list named paths outside this story's owned paths:",
|
|
226
|
+
...describe,
|
|
227
|
+
"Plan again: only work inside the owned paths. Work on any other path is not this story's."
|
|
228
|
+
].join("\n"));
|
|
229
|
+
const remaining = strayTasks(plan, story, second.tasks);
|
|
230
|
+
const foreign = new Set(remaining.filter((stray) => stray.foreign).map((stray) => stray.task));
|
|
231
|
+
const kept = second.tasks.filter((task) => !foreign.has(task));
|
|
232
|
+
if (remaining.length > 0) {
|
|
233
|
+
yield* events.publish(Info.make({
|
|
234
|
+
message: `story ${story.id}: the re-plan still names paths outside the story` +
|
|
235
|
+
(foreign.size > 0
|
|
236
|
+
? `; dropped ${foreign.size} task(s) that belong to other stories`
|
|
237
|
+
: "")
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
240
|
+
return kept.length === 0 ? second : Plan.make({ ...second, tasks: kept });
|
|
241
|
+
});
|
|
242
|
+
/**
|
|
243
|
+
* Why a BLOCKED_ON claim is wrong, when the plan alone can tell: the named
|
|
244
|
+
* path is the story's own, a dependency's (already merged into the worktree),
|
|
245
|
+
* or a dependent's (built later on top of this story). Undefined when the
|
|
246
|
+
* plan cannot settle it — a path no story owns, or a parallel story's.
|
|
247
|
+
*/
|
|
248
|
+
export const blockedRebuttal = (plan, story, need) => {
|
|
249
|
+
for (const path of pathsNamedIn(plan, need)) {
|
|
250
|
+
const owner = ownerOf(plan, path);
|
|
251
|
+
if (owner === undefined) {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
switch (relationOf(plan, story, owner)) {
|
|
255
|
+
case "self":
|
|
256
|
+
return `${path} is under your own owned paths: building it is your job, not another story's.`;
|
|
257
|
+
case "dependent":
|
|
258
|
+
return (`${path} belongs to story '${owner.id}', which runs AFTER you and builds on your work. ` +
|
|
259
|
+
"Leave it alone: it is neither missing nor a blocker for this story.");
|
|
260
|
+
case "dependency":
|
|
261
|
+
return (`${path} belongs to story '${owner.id}', merged into the epic branch before you started, ` +
|
|
262
|
+
`so it is already in your working tree: read it there. That story provides: ${owner.provides.join("; ")}.`);
|
|
263
|
+
case "parallel":
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return undefined;
|
|
268
|
+
};
|
|
269
|
+
/** A second opinion on a BLOCKED_ON claim the plan cannot settle. */
|
|
270
|
+
export class BlockedVerdict extends Schema.Class("BlockedVerdict")({
|
|
271
|
+
/** True: the work is genuinely missing and owned by no dependency. */
|
|
272
|
+
real: Schema.Boolean,
|
|
273
|
+
/** Why — shown to the coder when the claim is rejected. */
|
|
274
|
+
reason: Schema.String
|
|
275
|
+
}) {
|
|
276
|
+
}
|
|
152
277
|
// ---- BLOCKED_ON detection ---------------------------------------------------
|
|
153
278
|
const watchStream = (stream, blocked) => Stream.unwrap(Effect.gen(function* () {
|
|
154
279
|
const text = yield* Ref.make("");
|
|
@@ -171,6 +296,12 @@ export const watchForBlockedOn = (service, blocked) => ({
|
|
|
171
296
|
executeStream: (prompt) => watchStream(service.executeStream(prompt), blocked),
|
|
172
297
|
executeStreamWithHistory: (messages) => watchStream(service.executeStreamWithHistory(messages), blocked)
|
|
173
298
|
});
|
|
299
|
+
const combined = (first, second) => first.isClean && second.isClean
|
|
300
|
+
? first
|
|
301
|
+
: ReviewResult.make({
|
|
302
|
+
issues: [...first.issues, ...second.issues],
|
|
303
|
+
summary: [first.summary, second.summary].filter((part) => part.length > 0).join("; ")
|
|
304
|
+
});
|
|
174
305
|
const issueLines = (result) => result.issues.map((issue) => `- ${issue.title}: ${issue.description}`).join("\n");
|
|
175
306
|
const failed = (story, reason) => StoryFailed.make({ story: story.id, reason });
|
|
176
307
|
// ---- The executor -------------------------------------------------------------
|
|
@@ -183,6 +314,22 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
183
314
|
const judgeRounds = Math.max(1, options.judgeRounds ?? 2);
|
|
184
315
|
const statePath = (story) => join(options.stateDir, `stories/${story.id}.json`);
|
|
185
316
|
const planPath = (story) => join(options.stateDir, `stories/${story.id}.plan.md`);
|
|
317
|
+
/**
|
|
318
|
+
* The epic checkout must hold nothing uncommitted: every merge and epic
|
|
319
|
+
* gate runs there, and git refuses a merge that would overwrite a stray
|
|
320
|
+
* file. A dirty checkout means a coder escaped its worktree.
|
|
321
|
+
*/
|
|
322
|
+
const epicCheckoutClean = (story) => Effect.gen(function* () {
|
|
323
|
+
const paths = statusPaths(yield* context.git.status);
|
|
324
|
+
if (paths.length > 0) {
|
|
325
|
+
return yield* EpicCheckoutDirty.make({
|
|
326
|
+
checkout: context.workDir,
|
|
327
|
+
paths,
|
|
328
|
+
...(story === undefined ? {} : { story: story.id })
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
yield* epicCheckoutClean();
|
|
186
333
|
yield* stage(events, "epic branch", context.git.checkoutOrCreate(epicBranch));
|
|
187
334
|
const waves = topologicalWaves(plan);
|
|
188
335
|
const waveOf = (id) => {
|
|
@@ -201,6 +348,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
201
348
|
const mergeLock = yield* Semaphore.make(1);
|
|
202
349
|
/** Merge the story branch into the epic branch and re-gate the epic head, one story at a time. */
|
|
203
350
|
const integrate = (story, branch) => mergeLock.withPermit(Effect.gen(function* () {
|
|
351
|
+
yield* epicCheckoutClean(story);
|
|
204
352
|
const checkpoint = yield* context.git.checkpoint;
|
|
205
353
|
yield* context.git.merge(branch, `${plan.epicId}: merge story ${story.id}`);
|
|
206
354
|
const gate = yield* options.gates(context.workDir);
|
|
@@ -245,41 +393,185 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
245
393
|
return { state, alreadyMerged: false };
|
|
246
394
|
}
|
|
247
395
|
// Resume: the branch exists; the worktree may not (a worktree has a
|
|
248
|
-
// `.git` FILE at its root, which is how its presence is checked).
|
|
249
|
-
|
|
396
|
+
// `.git` FILE at its root, which is how its presence is checked). A
|
|
397
|
+
// worktree left under an older root moves to the current one, work and
|
|
398
|
+
// all, so every story of the epic runs under the same root.
|
|
399
|
+
let state = stored;
|
|
400
|
+
if (stored.worktree !== worktree) {
|
|
401
|
+
if ((yield* files.read(join(stored.worktree, ".git"))) !== undefined) {
|
|
402
|
+
yield* context.git.moveWorktree(stored.worktree, worktree);
|
|
403
|
+
yield* events.publish(Info.make({ message: `story ${story.id}: moved its worktree to ${worktree}` }));
|
|
404
|
+
}
|
|
405
|
+
state = StoryState.make({ ...stored, worktree });
|
|
406
|
+
yield* saveVersioned(files, path, StoryStateVersion, StoryState, state);
|
|
407
|
+
}
|
|
408
|
+
const marker = yield* files.read(join(state.worktree, ".git"));
|
|
250
409
|
if (marker === undefined) {
|
|
251
|
-
yield* context.git.addWorktree(
|
|
410
|
+
yield* context.git.addWorktree(state.worktree, state.branch);
|
|
411
|
+
}
|
|
412
|
+
yield* events.publish(Info.make({ message: `story ${story.id}: resuming ${state.branch}` }));
|
|
413
|
+
return { state, alreadyMerged: false };
|
|
414
|
+
});
|
|
415
|
+
/**
|
|
416
|
+
* Brings a story branch up to date with the epic branch. A story that
|
|
417
|
+
* started before a dependency was re-merged (or that resumes after other
|
|
418
|
+
* stories merged) would otherwise build on, and be judged against, code
|
|
419
|
+
* the epic no longer has. Conflicting hunks take the epic's side: the epic
|
|
420
|
+
* holds only other stories' merged work, and those paths are theirs.
|
|
421
|
+
*/
|
|
422
|
+
const catchUp = (story, git) => Effect.gen(function* () {
|
|
423
|
+
if (yield* git.isAncestor(epicBranch, "HEAD")) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if ((yield* git.uncommittedFiles).length > 0) {
|
|
427
|
+
yield* git.commitAll(`${story.id}: work in progress`);
|
|
252
428
|
}
|
|
253
|
-
yield*
|
|
254
|
-
|
|
429
|
+
yield* git.merge(epicBranch, `${story.id}: catch up with ${epicBranch}`, {
|
|
430
|
+
preferIncoming: true
|
|
431
|
+
});
|
|
432
|
+
yield* events.publish(Info.make({ message: `story ${story.id}: caught up with ${epicBranch}` }));
|
|
433
|
+
});
|
|
434
|
+
/** The perimeter over everything the branch changed, committed or not. */
|
|
435
|
+
const perimeterNow = (story, git) => Effect.gen(function* () {
|
|
436
|
+
const committed = yield* git.changedFilesVsBase(epicBranch);
|
|
437
|
+
const uncommitted = yield* git.uncommittedFiles;
|
|
438
|
+
return checkPerimeter([...new Set([...committed, ...uncommitted])], story);
|
|
255
439
|
});
|
|
256
440
|
/** Everything that happens inside a story's worktree: plan, implement, judge, perimeter. */
|
|
257
441
|
const implementStory = Effect.fn("@llm4ts/flow/Stories.implementStory")(function* (story, state) {
|
|
258
|
-
const seats = yield* options.contextFor(state.worktree
|
|
442
|
+
const seats = yield* options.contextFor(state.worktree, {
|
|
443
|
+
label: `story ${story.id}`,
|
|
444
|
+
...(state.executor === undefined ? {} : { prefer: state.executor })
|
|
445
|
+
});
|
|
446
|
+
const roster = seats.context.roster;
|
|
447
|
+
const coderExecutor = roster === undefined ? undefined : yield* roster.executor;
|
|
448
|
+
if (coderExecutor !== undefined && coderExecutor !== state.executor) {
|
|
449
|
+
yield* saveVersioned(files, statePath(story), StoryStateVersion, StoryState, StoryState.make({ ...state, executor: coderExecutor }));
|
|
450
|
+
}
|
|
259
451
|
const blocked = yield* Ref.make(undefined);
|
|
452
|
+
const pushbacks = yield* Ref.make(0);
|
|
260
453
|
const storyContext = {
|
|
261
454
|
...seats.context,
|
|
262
455
|
coder: watchForBlockedOn(seats.context.coder, blocked)
|
|
263
456
|
};
|
|
457
|
+
const git = storyContext.git;
|
|
264
458
|
const watchedSeats = { ...seats, context: storyContext };
|
|
265
459
|
const extra = options.system === undefined ? undefined : yield* options.system(story);
|
|
266
|
-
const system = [
|
|
460
|
+
const system = [
|
|
461
|
+
perimeterRules(story, { plan, worktree: state.worktree, epicCheckout: context.workDir }),
|
|
462
|
+
extra
|
|
463
|
+
]
|
|
267
464
|
.filter((part) => part !== undefined && part.trim().length > 0)
|
|
268
465
|
.join("\n\n");
|
|
269
466
|
const prompt = storyPrompt(story);
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
467
|
+
// Catch up before setup: the epic may have changed the manifest too.
|
|
468
|
+
yield* catchUp(story, git);
|
|
469
|
+
if (options.setup !== undefined) {
|
|
470
|
+
yield* stage(events, `story ${story.id}: setup`, options.setup(state.worktree));
|
|
471
|
+
}
|
|
472
|
+
// The target's gates plus the perimeter: a stray path is a gate failure
|
|
473
|
+
// the task review loop hands back to the coder before anything commits.
|
|
474
|
+
const gates = Effect.gen(function* () {
|
|
475
|
+
const target = yield* options.gates(state.worktree);
|
|
476
|
+
const changed = yield* perimeterNow(story, git);
|
|
477
|
+
return combined(target, perimeterGate([...changed.sharedReadOnly, ...changed.outside], story));
|
|
478
|
+
});
|
|
479
|
+
const coderTurn = (text) => Effect.flatMap(makeChat(storyContext.coder, { system, events, agent: "coder" }), (chat) => chat.ask(text));
|
|
480
|
+
const commitGreen = (message, why) => Effect.gen(function* () {
|
|
481
|
+
const regated = yield* gates;
|
|
482
|
+
if (!regated.isClean) {
|
|
483
|
+
return yield* failed(story, `gates broke ${why}:\n${issueLines(regated)}`);
|
|
484
|
+
}
|
|
485
|
+
yield* git.commitAll(`${story.id}: ${message}`);
|
|
486
|
+
});
|
|
487
|
+
/**
|
|
488
|
+
* Runs `effect`; a BLOCKED_ON the plan or the verifier refutes sends the
|
|
489
|
+
* coder back once with the reason, then `resume` runs. Any other claim —
|
|
490
|
+
* or a second one — ends the story as a typed MissingDependency.
|
|
491
|
+
*/
|
|
492
|
+
const guarded = (effect, resume) => Effect.gen(function* () {
|
|
493
|
+
const exit = yield* Effect.exit(effect);
|
|
494
|
+
const need = yield* Ref.get(blocked);
|
|
495
|
+
if (need === undefined) {
|
|
496
|
+
if (Exit.isFailure(exit)) {
|
|
497
|
+
return yield* Effect.failCause(exit.cause);
|
|
498
|
+
}
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const missing = MissingDependency.make({ story: story.id, need });
|
|
502
|
+
if ((yield* Ref.get(pushbacks)) > 0) {
|
|
503
|
+
return yield* missing;
|
|
504
|
+
}
|
|
505
|
+
let rebuttal = blockedRebuttal(plan, story, need);
|
|
506
|
+
if (rebuttal === undefined && options.verifyBlocked !== undefined) {
|
|
507
|
+
const verdict = yield* options
|
|
508
|
+
.verifyBlocked(story, need, state.worktree, watchedSeats)
|
|
509
|
+
.pipe(Effect.catch(() => Effect.succeed(undefined)));
|
|
510
|
+
rebuttal = verdict === undefined || verdict.real ? undefined : verdict.reason;
|
|
511
|
+
}
|
|
512
|
+
if (rebuttal === undefined) {
|
|
513
|
+
return yield* missing;
|
|
514
|
+
}
|
|
515
|
+
yield* Ref.set(blocked, undefined);
|
|
516
|
+
yield* Ref.update(pushbacks, (count) => count + 1);
|
|
517
|
+
yield* events.publish(Info.make({ message: `story ${story.id}: BLOCKED_ON rejected — ${rebuttal}` }));
|
|
518
|
+
yield* coderTurn([
|
|
519
|
+
`You stopped with "${blockedOnSentinel} ${need}".`,
|
|
520
|
+
"That claim was checked against the epic's plan and rejected:",
|
|
521
|
+
rebuttal,
|
|
522
|
+
"",
|
|
523
|
+
"It is not a missing dependency. Continue the story inside your owned paths until it is",
|
|
524
|
+
`complete, then stop. Use ${blockedOnSentinel} only for work another story owns that is`,
|
|
525
|
+
"absent from your working tree."
|
|
526
|
+
].join("\n"));
|
|
527
|
+
const again = yield* Ref.get(blocked);
|
|
528
|
+
if (again !== undefined) {
|
|
529
|
+
return yield* MissingDependency.make({ story: story.id, need: again });
|
|
530
|
+
}
|
|
531
|
+
yield* commitGreen("continue after a rejected BLOCKED_ON", "after a rejected BLOCKED_ON");
|
|
532
|
+
yield* guarded(resume, Effect.void);
|
|
533
|
+
});
|
|
534
|
+
/**
|
|
535
|
+
* Stray paths go back before a judge sees the branch: first the coder is
|
|
536
|
+
* asked to move or revert them, then whatever is left is restored from
|
|
537
|
+
* the epic branch — a deterministic rule never needs a judge round.
|
|
538
|
+
*/
|
|
539
|
+
const repairPerimeter = Effect.gen(function* () {
|
|
540
|
+
const first = yield* perimeterNow(story, git);
|
|
541
|
+
if (isWithinPerimeter(first)) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const violation = PerimeterViolation.make({
|
|
545
|
+
story: story.id,
|
|
546
|
+
outside: first.outside,
|
|
547
|
+
sharedReadOnly: first.sharedReadOnly
|
|
548
|
+
});
|
|
549
|
+
yield* guarded(coderTurn([
|
|
550
|
+
violation.message,
|
|
551
|
+
"",
|
|
552
|
+
"Put each of these paths back as it is on the epic branch (restore a changed file, delete",
|
|
553
|
+
"a file you created there). Work that belongs to this story moves into your owned paths.",
|
|
554
|
+
"Then stop."
|
|
555
|
+
].join("\n")), Effect.void);
|
|
556
|
+
const second = yield* perimeterNow(story, git);
|
|
557
|
+
if (!isWithinPerimeter(second)) {
|
|
558
|
+
const stray = [...second.sharedReadOnly, ...second.outside];
|
|
559
|
+
yield* git.restorePaths(epicBranch, stray);
|
|
560
|
+
yield* events.publish(Info.make({
|
|
561
|
+
message: `story ${story.id}: restored ${stray.length} path(s) outside its perimeter from ${epicBranch}: ${stray.join(", ")}`
|
|
562
|
+
}));
|
|
563
|
+
}
|
|
564
|
+
yield* git.commitAll(`${story.id}: put paths outside the perimeter back`);
|
|
565
|
+
});
|
|
566
|
+
const planTasks = options.planTasks ?? defaultPlanTasks;
|
|
567
|
+
const implementTasks = implementPlanFlow(storyContext, {
|
|
276
568
|
store: makePlanStore(files),
|
|
277
569
|
planPath: planPath(story),
|
|
278
|
-
plan: (
|
|
570
|
+
plan: planWithinPerimeter(plan, story, (text) => planTasks(watchedSeats, story, text), prompt, events),
|
|
279
571
|
system,
|
|
280
572
|
chatPerTask: true,
|
|
281
573
|
checkoutBranch: false,
|
|
282
|
-
lint:
|
|
574
|
+
lint: gates,
|
|
283
575
|
// A story's final state is judged and gated downstream (judge round,
|
|
284
576
|
// perimeter check, epic gates), so a task the coder finds already
|
|
285
577
|
// satisfied — without saying the exact sentinel — must not sink the
|
|
@@ -287,18 +579,23 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
287
579
|
noopTaskPolicy: "complete",
|
|
288
580
|
...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
|
|
289
581
|
...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds })
|
|
290
|
-
})
|
|
582
|
+
});
|
|
583
|
+
yield* guarded(implementTasks, implementTasks);
|
|
584
|
+
// Other stories may have merged while this one ran: judge the branch
|
|
585
|
+
// against the epic as it is now.
|
|
586
|
+
yield* catchUp(story, git);
|
|
587
|
+
yield* repairPerimeter;
|
|
291
588
|
let judgeNote;
|
|
292
589
|
if (options.judge !== undefined) {
|
|
293
590
|
const judge = options.judge;
|
|
294
591
|
for (let round = 1; round <= judgeRounds; round += 1) {
|
|
295
|
-
const diff = yield*
|
|
592
|
+
const diff = yield* git.diffVsBase(epicBranch);
|
|
296
593
|
if (diff.trim().length === 0) {
|
|
297
594
|
// Nothing to judge is a deterministic failure, not a model call:
|
|
298
595
|
// a model asked to score an empty diff scores the prompt instead.
|
|
299
596
|
return yield* failed(story, "the story branch has no changes against the epic branch");
|
|
300
597
|
}
|
|
301
|
-
const verdict = yield* judge(story, diff);
|
|
598
|
+
const verdict = yield* judge(story, diff, watchedSeats);
|
|
302
599
|
if (verdict.isClean) {
|
|
303
600
|
judgeNote = `judge cleared (round ${round})`;
|
|
304
601
|
break;
|
|
@@ -306,27 +603,24 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
306
603
|
if (round >= judgeRounds) {
|
|
307
604
|
return yield* failed(story, `judge not cleared after ${judgeRounds} round(s):\n${issueLines(verdict)}`);
|
|
308
605
|
}
|
|
309
|
-
|
|
310
|
-
system,
|
|
311
|
-
events,
|
|
312
|
-
agent: "coder"
|
|
313
|
-
});
|
|
314
|
-
yield* blockedOr(feedback.ask([
|
|
606
|
+
yield* guarded(coderTurn([
|
|
315
607
|
`The story "${story.title}" scored below the bar. Close these gaps without`,
|
|
316
608
|
"weakening any test and without leaving your owned paths, then stop:",
|
|
317
609
|
issueLines(verdict)
|
|
318
|
-
].join("\n")));
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
return yield* failed(story, `gates broke while addressing judge feedback:\n${issueLines(regated)}`);
|
|
322
|
-
}
|
|
323
|
-
yield* storyContext.git.commitAll(`${story.id}: address judge feedback`);
|
|
610
|
+
].join("\n")), Effect.void);
|
|
611
|
+
yield* repairPerimeter;
|
|
612
|
+
yield* commitGreen("address judge feedback", "while addressing judge feedback");
|
|
324
613
|
}
|
|
325
614
|
}
|
|
326
|
-
const changed = yield*
|
|
615
|
+
const changed = yield* git.changedFilesVsBase(epicBranch);
|
|
327
616
|
yield* enforcePerimeter(changed, story);
|
|
328
617
|
const totals = seats.totals === undefined ? undefined : yield* seats.totals;
|
|
329
|
-
|
|
618
|
+
const history = roster === undefined ? [] : yield* roster.history;
|
|
619
|
+
return {
|
|
620
|
+
judge: judgeNote,
|
|
621
|
+
totals,
|
|
622
|
+
executor: history.length === 0 ? undefined : history.join(" → ")
|
|
623
|
+
};
|
|
330
624
|
});
|
|
331
625
|
const runStory = Effect.fn("@llm4ts/flow/Stories.runStory")(function* (story) {
|
|
332
626
|
yield* board.start(story.id);
|
|
@@ -341,18 +635,21 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
341
635
|
judge: "merged on a previous run"
|
|
342
636
|
});
|
|
343
637
|
}
|
|
344
|
-
if (options.setup !== undefined) {
|
|
345
|
-
yield* stage(events, `story ${story.id}: setup`, options.setup(state.worktree));
|
|
346
|
-
}
|
|
347
638
|
const result = yield* Effect.scoped(implementStory(story, state));
|
|
348
639
|
yield* integrate(story, state.branch);
|
|
349
|
-
|
|
640
|
+
const last = result.executor?.split(" → ").at(-1);
|
|
641
|
+
yield* saveVersioned(files, statePath(story), StoryStateVersion, StoryState, StoryState.make({
|
|
642
|
+
...state,
|
|
643
|
+
status: "merged",
|
|
644
|
+
...(last === undefined ? {} : { executor: last })
|
|
645
|
+
}));
|
|
350
646
|
return StoryOutcome.make({
|
|
351
647
|
id: story.id,
|
|
352
648
|
title: story.title,
|
|
353
649
|
status: "done",
|
|
354
650
|
branch: state.branch,
|
|
355
651
|
...(result.judge === undefined ? {} : { judge: result.judge }),
|
|
652
|
+
...(result.executor === undefined ? {} : { executor: result.executor }),
|
|
356
653
|
...(result.totals === undefined ? {} : { estimatedTokens: result.totals.total }),
|
|
357
654
|
...(result.totals?.costUsd === undefined ? {} : { estimatedCostUsd: result.totals.costUsd })
|
|
358
655
|
});
|
|
@@ -365,7 +662,14 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
365
662
|
if (stored !== undefined && stored.status !== "merged") {
|
|
366
663
|
yield* saveVersioned(files, path, StoryStateVersion, StoryState, StoryState.make({ ...stored, status: "failed" })).pipe(Effect.ignore);
|
|
367
664
|
}
|
|
368
|
-
|
|
665
|
+
const interruption = error._tag === "EpicCheckoutDirty" ||
|
|
666
|
+
error._tag === "RosterExhausted" ||
|
|
667
|
+
reason.includes(rosterExhaustedPrefix)
|
|
668
|
+
? "halt"
|
|
669
|
+
: isOutageMessage(reason)
|
|
670
|
+
? "outage"
|
|
671
|
+
: undefined;
|
|
672
|
+
const completion = {
|
|
369
673
|
story,
|
|
370
674
|
outcome: StoryOutcome.make({
|
|
371
675
|
id: story.id,
|
|
@@ -373,8 +677,10 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
373
677
|
status: "failed",
|
|
374
678
|
reason,
|
|
375
679
|
...(stored === undefined ? {} : { branch: stored.branch })
|
|
376
|
-
})
|
|
680
|
+
}),
|
|
681
|
+
...(interruption === undefined ? {} : { interruption })
|
|
377
682
|
};
|
|
683
|
+
return completion;
|
|
378
684
|
})));
|
|
379
685
|
const outcomes = yield* Ref.make([]);
|
|
380
686
|
const running = yield* Ref.make(new Set());
|
|
@@ -406,14 +712,27 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
406
712
|
yield* board.wait(id, reason);
|
|
407
713
|
}
|
|
408
714
|
});
|
|
715
|
+
/** Why no further story may start in this run; stories already running finish. */
|
|
716
|
+
let halted;
|
|
717
|
+
/** Stories retried once after the serving engine recovered. */
|
|
718
|
+
const recovered = new Set();
|
|
409
719
|
yield* Effect.scoped(Effect.gen(function* () {
|
|
410
720
|
while (true) {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
721
|
+
if (halted === undefined) {
|
|
722
|
+
const current = yield* progress;
|
|
723
|
+
const ready = readyStories(plan, current);
|
|
724
|
+
// With a roster, never launch more stories than there are free
|
|
725
|
+
// coders; with nothing running, launch one anyway — its lease waits
|
|
726
|
+
// for the first executor to come back (ADR 0019).
|
|
727
|
+
const free = context.roster === undefined
|
|
728
|
+
? Number.POSITIVE_INFINITY
|
|
729
|
+
: yield* context.roster.available("coder");
|
|
730
|
+
const budget = Math.min(concurrency - current.running.size, free);
|
|
731
|
+
const slots = current.running.size === 0 && ready.length > 0 ? Math.max(1, budget) : budget;
|
|
732
|
+
for (const story of ready.slice(0, Math.max(0, slots))) {
|
|
733
|
+
yield* Ref.update(running, (set) => new Set([...set, story.id]));
|
|
734
|
+
yield* Effect.forkScoped(attempt(story).pipe(Effect.flatMap((completion) => Queue.offer(completions, completion))));
|
|
735
|
+
}
|
|
417
736
|
}
|
|
418
737
|
if ((yield* Ref.get(running)).size === 0) {
|
|
419
738
|
break;
|
|
@@ -424,12 +743,42 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
424
743
|
next.delete(completion.story.id);
|
|
425
744
|
return next;
|
|
426
745
|
});
|
|
746
|
+
const reason = completion.outcome.reason ?? "failed";
|
|
747
|
+
if (completion.interruption === "outage" && halted === undefined) {
|
|
748
|
+
if (options.awaitRecovery !== undefined && !recovered.has(completion.story.id)) {
|
|
749
|
+
recovered.add(completion.story.id);
|
|
750
|
+
yield* events.publish(Info.make({
|
|
751
|
+
message: `story ${completion.story.id}: the serving engine is down; waiting for it to recover, then retrying the story`
|
|
752
|
+
}));
|
|
753
|
+
const back = yield* Effect.result(options.awaitRecovery(reason));
|
|
754
|
+
if (back._tag === "Success") {
|
|
755
|
+
// Not recorded: the story is ready again and relaunches.
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
halted = `the serving engine did not recover (${describeFlowError(back.failure)})`;
|
|
759
|
+
}
|
|
760
|
+
else {
|
|
761
|
+
halted = `the serving engine is down: ${reason}`;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
if (completion.interruption === "halt" && halted === undefined) {
|
|
765
|
+
halted = reason;
|
|
766
|
+
}
|
|
427
767
|
yield* record(completion.outcome);
|
|
428
768
|
const outcome = completion.outcome;
|
|
429
769
|
if (outcome.status === "done") {
|
|
430
770
|
yield* board.complete(outcome.id, {
|
|
431
771
|
...(outcome.branch === undefined ? {} : { branch: outcome.branch }),
|
|
432
|
-
...(outcome.judge === undefined
|
|
772
|
+
...(outcome.judge === undefined && outcome.executor === undefined
|
|
773
|
+
? {}
|
|
774
|
+
: {
|
|
775
|
+
detail: [
|
|
776
|
+
outcome.judge,
|
|
777
|
+
outcome.executor === undefined ? undefined : `coder ${outcome.executor}`
|
|
778
|
+
]
|
|
779
|
+
.filter((part) => part !== undefined)
|
|
780
|
+
.join(" · ")
|
|
781
|
+
}),
|
|
433
782
|
...(outcome.estimatedTokens === undefined
|
|
434
783
|
? {}
|
|
435
784
|
: { estimatedTokens: outcome.estimatedTokens }),
|
|
@@ -450,6 +799,19 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
450
799
|
}
|
|
451
800
|
}
|
|
452
801
|
}));
|
|
802
|
+
if (halted !== undefined) {
|
|
803
|
+
const stopped = halted;
|
|
804
|
+
yield* events.publish(Info.make({ message: `epic ${plan.epicId}: stopped launching stories — ${stopped}` }));
|
|
805
|
+
const known = new Set((yield* Ref.get(outcomes)).map((outcome) => outcome.id));
|
|
806
|
+
for (const story of plan.stories) {
|
|
807
|
+
if (known.has(story.id)) {
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
const reason = `not started: ${stopped}`;
|
|
811
|
+
yield* record(StoryOutcome.make({ id: story.id, title: story.title, status: "waiting", reason }));
|
|
812
|
+
yield* board.wait(story.id, reason);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
453
815
|
const recorded = yield* Ref.get(outcomes);
|
|
454
816
|
const ordered = plan.stories.flatMap((story) => {
|
|
455
817
|
const outcome = recorded.find((candidate) => candidate.id === story.id);
|