@llm4ts/flow 2.8.0 → 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.
Files changed (51) hide show
  1. package/dist/Approval.d.ts +1 -1
  2. package/dist/Approval.d.ts.map +1 -1
  3. package/dist/Artifacts.d.ts +2 -2
  4. package/dist/Artifacts.d.ts.map +1 -1
  5. package/dist/BenchReport.d.ts +2 -2
  6. package/dist/BenchReport.d.ts.map +1 -1
  7. package/dist/CostLedger.d.ts +2 -2
  8. package/dist/CostLedger.d.ts.map +1 -1
  9. package/dist/Equiv.d.ts +3 -3
  10. package/dist/Equiv.d.ts.map +1 -1
  11. package/dist/Flow.d.ts +1 -1
  12. package/dist/Flow.d.ts.map +1 -1
  13. package/dist/FlowContext.d.ts +15 -1
  14. package/dist/FlowContext.d.ts.map +1 -1
  15. package/dist/FlowContext.js.map +1 -1
  16. package/dist/FlowError.d.ts +24 -1
  17. package/dist/FlowError.d.ts.map +1 -1
  18. package/dist/FlowError.js +29 -0
  19. package/dist/FlowError.js.map +1 -1
  20. package/dist/Judgment.d.ts +2 -2
  21. package/dist/Judgment.d.ts.map +1 -1
  22. package/dist/Persistence.d.ts +2 -2
  23. package/dist/Persistence.d.ts.map +1 -1
  24. package/dist/PlanExecution.d.ts +1 -1
  25. package/dist/PlanExecution.d.ts.map +1 -1
  26. package/dist/ProgramJudge.d.ts +1 -1
  27. package/dist/ProgramJudge.d.ts.map +1 -1
  28. package/dist/Replay.d.ts +2 -2
  29. package/dist/Replay.d.ts.map +1 -1
  30. package/dist/Review.d.ts +3 -3
  31. package/dist/Review.d.ts.map +1 -1
  32. package/dist/ReviewCache.d.ts +1 -1
  33. package/dist/ReviewCache.d.ts.map +1 -1
  34. package/dist/Roster.d.ts +163 -0
  35. package/dist/Roster.d.ts.map +1 -0
  36. package/dist/Roster.js +560 -0
  37. package/dist/Roster.js.map +1 -0
  38. package/dist/RosterSeats.d.ts +71 -0
  39. package/dist/RosterSeats.d.ts.map +1 -0
  40. package/dist/RosterSeats.js +144 -0
  41. package/dist/RosterSeats.js.map +1 -0
  42. package/dist/Stories.d.ts +9 -5
  43. package/dist/Stories.d.ts.map +1 -1
  44. package/dist/Stories.js +70 -10
  45. package/dist/Stories.js.map +1 -1
  46. package/package.json +4 -2
  47. package/src/FlowContext.ts +19 -1
  48. package/src/FlowError.ts +32 -0
  49. package/src/Roster.ts +824 -0
  50. package/src/RosterSeats.ts +306 -0
  51. package/src/Stories.ts +97 -14
@@ -0,0 +1,306 @@
1
+ // Seats over the executor roster (ADR 0019). A flow keeps calling an
2
+ // `LlmServiceShape`; behind it, a reasoning seat leases an executor for each
3
+ // call, and a coder seat holds one executor for its context and hands over
4
+ // to the next when that one is taken out of the round. The runner supplies
5
+ // `seatFor`, which turns an executor into a connector rooted in a directory.
6
+ import * as Effect from "effect/Effect"
7
+ import * as Ref from "effect/Ref"
8
+ import * as Scope from "effect/Scope"
9
+ import * as Semaphore from "effect/Semaphore"
10
+ import * as Stream from "effect/Stream"
11
+ import { ConfigError, type LlmError } from "@llm4ts/core/Errors"
12
+ import type { LlmServiceShape } from "@llm4ts/core/LlmService"
13
+ import { Message, type ConnectorCapabilities, type LlmChunk } from "@llm4ts/core/Models"
14
+ import { describeFlowError, type FlowError } from "./FlowError.ts"
15
+ import { Info, type FlowEventsShape } from "./FlowEvents.ts"
16
+ import {
17
+ describeExclusion,
18
+ hasRole,
19
+ type ExecutorSpec,
20
+ type Lease,
21
+ type Role,
22
+ type RosterShape
23
+ } from "./Roster.ts"
24
+
25
+ /** An executor's connector, as the runner resolved it for a role and a directory. */
26
+ export type RosterSeat = LlmServiceShape & { readonly capabilities: ConnectorCapabilities }
27
+
28
+ export interface SeatSource {
29
+ readonly seatFor: (
30
+ executor: ExecutorSpec,
31
+ role: Role,
32
+ workDir: string
33
+ ) => Effect.Effect<RosterSeat, FlowError>
34
+ }
35
+
36
+ /** What a flow context knows about the roster behind its seats. */
37
+ export interface RosterView {
38
+ /** A seat for `role`, leased per call (independent of this context's coder where possible). */
39
+ readonly forRole: (role: Role) => LlmServiceShape
40
+ /** Free slots for `role` right now. */
41
+ readonly available: (role: Role) => Effect.Effect<number>
42
+ /** Configured slots for `role`. */
43
+ readonly slots: (role: Role) => number
44
+ /** The executor holding this context's coder, once leased. */
45
+ readonly executor: Effect.Effect<string | undefined>
46
+ /** Every executor that held this context's coder, in order (handovers). */
47
+ readonly history: Effect.Effect<ReadonlyArray<string>>
48
+ }
49
+
50
+ /** A roster failure seen through an `LlmServiceShape`, whose failures are `LlmError`s. */
51
+ const asLlmError = (error: FlowError): LlmError =>
52
+ ConfigError.make({ message: describeFlowError(error) })
53
+
54
+ const say = (events: FlowEventsShape, message: string): Effect.Effect<void> =>
55
+ events.publish(Info.make({ message: `roster: ${message}` }))
56
+
57
+ /** Shared plumbing: every service method as "run this on some seat". */
58
+ interface SeatRunner {
59
+ readonly run: <A, R>(
60
+ use: (seat: RosterSeat, note: string | undefined) => Effect.Effect<A, LlmError, R>
61
+ ) => Effect.Effect<A, LlmError, R>
62
+ readonly stream: (
63
+ use: (seat: RosterSeat, note: string | undefined) => Stream.Stream<LlmChunk, LlmError>
64
+ ) => Stream.Stream<LlmChunk, LlmError>
65
+ readonly isAvailable: Effect.Effect<boolean>
66
+ }
67
+
68
+ const withNote = (note: string | undefined, prompt: string): string =>
69
+ note === undefined ? prompt : `${note}\n\n${prompt}`
70
+
71
+ const withNoteOnLast = (
72
+ note: string | undefined,
73
+ messages: ReadonlyArray<Message>
74
+ ): ReadonlyArray<Message> => {
75
+ const last = messages.at(-1)
76
+ if (note === undefined || last === undefined) {
77
+ return messages
78
+ }
79
+ return [
80
+ ...messages.slice(0, -1),
81
+ Message.make({ ...last, content: withNote(note, last.content) })
82
+ ]
83
+ }
84
+
85
+ const serviceOver = (runner: SeatRunner): LlmServiceShape => ({
86
+ executeStream: (prompt) =>
87
+ runner.stream((seat, note) => seat.executeStream(withNote(note, prompt))),
88
+ executeStreamWithHistory: (messages) =>
89
+ runner.stream((seat, note) => seat.executeStreamWithHistory(withNoteOnLast(note, messages))),
90
+ executeWithTools: (prompt, tools) =>
91
+ runner.run((seat, note) => seat.executeWithTools(withNote(note, prompt), tools)),
92
+ executeStructured: (prompt, schema, jsonSchema) =>
93
+ runner.run((seat, note) => seat.executeStructured(withNote(note, prompt), schema, jsonSchema)),
94
+ executeStructuredWithUsage: (prompt, schema, jsonSchema) =>
95
+ runner.run((seat, note) =>
96
+ seat.executeStructuredWithUsage(withNote(note, prompt), schema, jsonSchema)
97
+ ),
98
+ scoreLabels: (prompt, labels) => runner.run((seat) => seat.scoreLabels(prompt, labels)),
99
+ isAvailable: runner.isAvailable
100
+ })
101
+
102
+ // ---- Per-call seats ----------------------------------------------------------------
103
+
104
+ export interface RosterSeatOptions {
105
+ readonly events: FlowEventsShape
106
+ /** Executors this seat must not use: the context's coder, for independence. */
107
+ readonly avoid?: Effect.Effect<ReadonlyArray<string>>
108
+ /** Used without a slot when nobody outside `avoid` can ever serve the role. */
109
+ readonly borrow?: Effect.Effect<ExecutorSpec | undefined>
110
+ /** Who is asking, for the events. */
111
+ readonly label?: string
112
+ }
113
+
114
+ /**
115
+ * A seat for `role` that leases an executor for each call and releases it
116
+ * when the call (or its stream) ends. A failure is reported to the roster,
117
+ * which excludes the executor when the failure says it is out of quota or
118
+ * down; the call itself still fails — a per-call seat has nothing to hand
119
+ * over, and the retry layer below has already had its turn.
120
+ */
121
+ export const rosterSeat = (
122
+ roster: RosterShape,
123
+ source: SeatSource,
124
+ role: Role,
125
+ workDir: string,
126
+ options: RosterSeatOptions
127
+ ): LlmServiceShape => {
128
+ const acquire: Effect.Effect<ExecutorSpec, LlmError, Scope.Scope> = Effect.gen(function* () {
129
+ const avoid = options.avoid === undefined ? [] : yield* options.avoid
130
+ if (avoid.length > 0 && !(yield* roster.canEverServe(role, avoid))) {
131
+ const borrowed = options.borrow === undefined ? undefined : yield* options.borrow
132
+ if (borrowed !== undefined && hasRole(borrowed, role)) {
133
+ yield* say(
134
+ options.events,
135
+ `${borrowed.id} takes ${role}${options.label === undefined ? "" : ` for ${options.label}`} on its own coder's slot — not independent (no other executor can take ${role})`
136
+ )
137
+ return borrowed
138
+ }
139
+ }
140
+ const lease = yield* roster
141
+ .lease(role, { avoid, ...(options.label === undefined ? {} : { label: options.label }) })
142
+ .pipe(Effect.mapError(asLlmError))
143
+ return lease.executor
144
+ })
145
+
146
+ const seatOf = (executor: ExecutorSpec): Effect.Effect<RosterSeat, LlmError> =>
147
+ source.seatFor(executor, role, workDir).pipe(Effect.mapError(asLlmError))
148
+
149
+ const reportOn =
150
+ (executor: ExecutorSpec) =>
151
+ (error: LlmError): Effect.Effect<void> =>
152
+ Effect.asVoid(roster.report(executor.id, error))
153
+
154
+ return serviceOver({
155
+ run: (use) =>
156
+ Effect.scoped(
157
+ Effect.gen(function* () {
158
+ const executor = yield* acquire
159
+ const seat = yield* seatOf(executor)
160
+ return yield* use(seat, undefined).pipe(Effect.tapError(reportOn(executor)))
161
+ })
162
+ ),
163
+ stream: (use) =>
164
+ Stream.unwrap(
165
+ Effect.gen(function* () {
166
+ const executor = yield* acquire
167
+ const seat = yield* seatOf(executor)
168
+ return use(seat, undefined).pipe(Stream.tapError(reportOn(executor)))
169
+ })
170
+ ),
171
+ isAvailable: roster.canEverServe(role)
172
+ })
173
+ }
174
+
175
+ // ---- The held coder ------------------------------------------------------------------
176
+
177
+ export interface HeldCoderOptions {
178
+ readonly events: FlowEventsShape
179
+ /** Lease now (a story waiting for capacity), or on the first call (a flow's root coder). */
180
+ readonly eager: boolean
181
+ /** The executor this context held before, taken first while free. */
182
+ readonly prefer?: string
183
+ /** Handovers before a failure surfaces. Default 2. */
184
+ readonly maxHandovers?: number
185
+ readonly label?: string
186
+ }
187
+
188
+ export interface HeldCoder {
189
+ readonly service: LlmServiceShape
190
+ readonly executor: Effect.Effect<string | undefined>
191
+ readonly lease: Effect.Effect<ExecutorSpec | undefined>
192
+ readonly history: Effect.Effect<ReadonlyArray<string>>
193
+ }
194
+
195
+ export const takeoverNote = (from: string, reason: string): string =>
196
+ [
197
+ `(You are taking over this work from another coding agent ('${from}'), which had to stop:`,
198
+ `${reason}. Its changes so far are in the working tree: inspect them (git status, git diff)`,
199
+ "before continuing, and carry on from there.)"
200
+ ].join(" ")
201
+
202
+ interface Held {
203
+ readonly lease: Lease
204
+ readonly seat: RosterSeat
205
+ }
206
+
207
+ /**
208
+ * The coder of one context (a story, or a flow's root): one executor held
209
+ * for the context's lifetime. When a call fails in a way that takes that
210
+ * executor out of the round (a usage limit, a dead serving engine), the
211
+ * lease is released, the next coder leased, and the call repeated there
212
+ * with a takeover note — the chat history the flow replays, the task
213
+ * checkpoint and the working tree carry the work across.
214
+ */
215
+ export const makeHeldCoder = Effect.fn("@llm4ts/flow/RosterSeats.heldCoder")(function* (
216
+ roster: RosterShape,
217
+ source: SeatSource,
218
+ workDir: string,
219
+ options: HeldCoderOptions
220
+ ): Effect.fn.Return<HeldCoder, FlowError, Scope.Scope> {
221
+ const scope = yield* Effect.scope
222
+ const current = yield* Ref.make<Held | undefined>(undefined)
223
+ const history = yield* Ref.make<ReadonlyArray<string>>([])
224
+ const handovers = yield* Ref.make(0)
225
+ const prefer = yield* Ref.make(options.prefer)
226
+ const gate = yield* Semaphore.make(1)
227
+ const maxHandovers = options.maxHandovers ?? 2
228
+
229
+ const acquire: Effect.Effect<Held, FlowError> = gate.withPermit(
230
+ Effect.gen(function* () {
231
+ const held = yield* Ref.get(current)
232
+ if (held !== undefined) {
233
+ return held
234
+ }
235
+ const preferred = yield* Ref.get(prefer)
236
+ const lease = yield* roster
237
+ .lease("coder", {
238
+ ...(preferred === undefined ? {} : { prefer: preferred }),
239
+ ...(options.label === undefined ? {} : { label: options.label })
240
+ })
241
+ .pipe(Scope.provide(scope))
242
+ const seat = yield* source.seatFor(lease.executor, "coder", workDir)
243
+ const next = { lease, seat }
244
+ yield* Ref.set(current, next)
245
+ yield* Ref.update(history, (ids) =>
246
+ ids.at(-1) === lease.executor.id ? ids : [...ids, lease.executor.id]
247
+ )
248
+ return next
249
+ })
250
+ )
251
+
252
+ if (options.eager) {
253
+ yield* acquire
254
+ }
255
+
256
+ /** Decides whether a failure hands over; returns the takeover note, or fails with the error. */
257
+ const handover = (held: Held, error: LlmError): Effect.Effect<string, LlmError> =>
258
+ Effect.gen(function* () {
259
+ const exclusion = yield* roster.report(held.lease.executor.id, error)
260
+ if (exclusion === undefined || (yield* Ref.get(handovers)) >= maxHandovers) {
261
+ return yield* Effect.fail(error)
262
+ }
263
+ yield* Ref.update(handovers, (count) => count + 1)
264
+ yield* Ref.set(prefer, undefined)
265
+ yield* held.lease.release
266
+ yield* Ref.set(current, undefined)
267
+ yield* say(
268
+ options.events,
269
+ `${options.label ?? workDir}: handing the coder over from ${held.lease.executor.id} (${describeExclusion(exclusion)})`
270
+ )
271
+ return takeoverNote(held.lease.executor.id, exclusion.reason)
272
+ })
273
+
274
+ const run = <A, R>(
275
+ use: (seat: RosterSeat, note: string | undefined) => Effect.Effect<A, LlmError, R>,
276
+ note?: string
277
+ ): Effect.Effect<A, LlmError, R> =>
278
+ Effect.gen(function* () {
279
+ const held = yield* acquire.pipe(Effect.mapError(asLlmError))
280
+ return yield* use(held.seat, note).pipe(
281
+ Effect.catch((error) => Effect.flatMap(handover(held, error), (next) => run(use, next)))
282
+ )
283
+ })
284
+
285
+ const stream = (
286
+ use: (seat: RosterSeat, note: string | undefined) => Stream.Stream<LlmChunk, LlmError>,
287
+ note?: string
288
+ ): Stream.Stream<LlmChunk, LlmError> =>
289
+ Stream.unwrap(
290
+ Effect.map(acquire.pipe(Effect.mapError(asLlmError)), (held) =>
291
+ use(held.seat, note).pipe(
292
+ Stream.catchIf(
293
+ () => true,
294
+ (error) => Stream.unwrap(Effect.map(handover(held, error), (next) => stream(use, next)))
295
+ )
296
+ )
297
+ )
298
+ )
299
+
300
+ return {
301
+ service: serviceOver({ run, stream, isAvailable: roster.canEverServe("coder") }),
302
+ executor: Effect.map(Ref.get(current), (held) => held?.lease.executor.id),
303
+ lease: Effect.map(Ref.get(current), (held) => held?.lease.executor),
304
+ history: Ref.get(history)
305
+ }
306
+ })
package/src/Stories.ts CHANGED
@@ -18,9 +18,10 @@ import type { LlmError } from "@llm4ts/core/Errors"
18
18
  import { BoardItem, type BoardSyncShape } from "./BoardSync.ts"
19
19
  import { makeChat } from "./Chat.ts"
20
20
  import { implementPlanFlow } from "./Flow.ts"
21
- import type { FlowContextShape } from "./FlowContext.ts"
21
+ import type { ContextOptions, FlowContextShape } from "./FlowContext.ts"
22
22
  import {
23
23
  describeFlowError,
24
+ rosterExhaustedPrefix,
24
25
  EpicCheckoutDirty,
25
26
  MissingDependency,
26
27
  PerimeterViolation,
@@ -89,7 +90,9 @@ export class StoryState extends Schema.Class<StoryState>("StoryState")({
89
90
  hash: Schema.String,
90
91
  branch: Schema.String,
91
92
  worktree: Schema.String,
92
- 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)
93
96
  }) {}
94
97
 
95
98
  export const EpicReportVersion = 1
@@ -106,6 +109,8 @@ export class StoryOutcome extends Schema.Class<StoryOutcome>("StoryOutcome")({
106
109
  /** Failure reason, or what a waiting story waits for. */
107
110
  reason: Schema.optionalKey(Schema.String),
108
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),
109
114
  /** ESTIMATES, never measurements (ADR 0012). */
110
115
  estimatedTokens: Schema.optionalKey(Schema.Int),
111
116
  estimatedCostUsd: Schema.optionalKey(Schema.Number)
@@ -159,6 +164,27 @@ export const renderEpicReport = (report: EpicReport): string => {
159
164
  if (cost.length > 0) {
160
165
  lines.push(`- Estimated cost: ${money(cost.reduce((sum, value) => sum + value, 0))}`)
161
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
+ }
162
188
  lines.push("")
163
189
  return lines.join("\n")
164
190
  }
@@ -423,7 +449,10 @@ export interface StoriesOptions {
423
449
  /** Default `epic/<epicId>`. */
424
450
  readonly epicBranch?: string
425
451
  /** Seats rooted in a worktree; the executor never resolves seats itself. */
426
- readonly contextFor: (workDir: string) => Effect.Effect<StorySeats, FlowError, Scope.Scope>
452
+ readonly contextFor: (
453
+ workDir: string,
454
+ options?: ContextOptions
455
+ ) => Effect.Effect<StorySeats, FlowError, Scope.Scope>
427
456
  readonly board: BoardSyncShape
428
457
  /**
429
458
  * Prepares a worktree before its coder runs — a fresh checkout has no
@@ -434,7 +463,11 @@ export interface StoriesOptions {
434
463
  /** The target's gates, run in a worktree per task and on the epic checkout after each merge. */
435
464
  readonly gates: (workDir: string) => Effect.Effect<ReviewResult, FlowError>
436
465
  /** Story-level judge over the branch's diff against the epic branch; omit to skip. */
437
- readonly judge?: (story: Story, diff: string) => Effect.Effect<ReviewResult, FlowError>
466
+ readonly judge?: (
467
+ story: Story,
468
+ diff: string,
469
+ seats: StorySeats
470
+ ) => Effect.Effect<ReviewResult, FlowError>
438
471
  /** Judge attempts, each but the last followed by one coder feedback round. Default 2. */
439
472
  readonly judgeRounds?: number
440
473
  /** Extra system context per story (house rules, shared read-only excerpts). */
@@ -459,7 +492,8 @@ export interface StoriesOptions {
459
492
  readonly verifyBlocked?: (
460
493
  story: Story,
461
494
  need: string,
462
- workDir: string
495
+ workDir: string,
496
+ seats: StorySeats
463
497
  ) => Effect.Effect<BlockedVerdict, FlowError>
464
498
  /**
465
499
  * Waits until a serving engine that went down (`isOutageMessage`) is back;
@@ -675,11 +709,29 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
675
709
  story: Story,
676
710
  state: StoryState
677
711
  ): Effect.fn.Return<
678
- { readonly judge: string | undefined; readonly totals: TokenUsage | undefined },
712
+ {
713
+ readonly judge: string | undefined
714
+ readonly totals: TokenUsage | undefined
715
+ readonly executor: string | undefined
716
+ },
679
717
  FlowError,
680
718
  Scope.Scope
681
719
  > {
682
- 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
+ }
683
735
  const blocked = yield* Ref.make<string | undefined>(undefined)
684
736
  const pushbacks = yield* Ref.make(0)
685
737
  const storyContext: FlowContextShape = {
@@ -750,7 +802,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
750
802
  let rebuttal = blockedRebuttal(plan, story, need)
751
803
  if (rebuttal === undefined && options.verifyBlocked !== undefined) {
752
804
  const verdict = yield* options
753
- .verifyBlocked(story, need, state.worktree)
805
+ .verifyBlocked(story, need, state.worktree, watchedSeats)
754
806
  .pipe(Effect.catch(() => Effect.succeed(undefined)))
755
807
  rebuttal = verdict === undefined || verdict.real ? undefined : verdict.reason
756
808
  }
@@ -861,7 +913,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
861
913
  // a model asked to score an empty diff scores the prompt instead.
862
914
  return yield* failed(story, "the story branch has no changes against the epic branch")
863
915
  }
864
- const verdict = yield* judge(story, diff)
916
+ const verdict = yield* judge(story, diff, watchedSeats)
865
917
  if (verdict.isClean) {
866
918
  judgeNote = `judge cleared (round ${round})`
867
919
  break
@@ -890,7 +942,12 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
890
942
  const changed = yield* git.changedFilesVsBase(epicBranch)
891
943
  yield* enforcePerimeter(changed, story)
892
944
  const totals = seats.totals === undefined ? undefined : yield* seats.totals
893
- return { judge: judgeNote, totals }
945
+ const history = roster === undefined ? [] : yield* roster.history
946
+ return {
947
+ judge: judgeNote,
948
+ totals,
949
+ executor: history.length === 0 ? undefined : history.join(" → ")
950
+ }
894
951
  })
895
952
 
896
953
  const runStory = Effect.fn("@llm4ts/flow/Stories.runStory")(function* (
@@ -910,12 +967,17 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
910
967
  }
911
968
  const result = yield* Effect.scoped(implementStory(story, state))
912
969
  yield* integrate(story, state.branch)
970
+ const last = result.executor?.split(" → ").at(-1)
913
971
  yield* saveVersioned(
914
972
  files,
915
973
  statePath(story),
916
974
  StoryStateVersion,
917
975
  StoryState,
918
- StoryState.make({ ...state, status: "merged" })
976
+ StoryState.make({
977
+ ...state,
978
+ status: "merged",
979
+ ...(last === undefined ? {} : { executor: last })
980
+ })
919
981
  )
920
982
  return StoryOutcome.make({
921
983
  id: story.id,
@@ -923,6 +985,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
923
985
  status: "done",
924
986
  branch: state.branch,
925
987
  ...(result.judge === undefined ? {} : { judge: result.judge }),
988
+ ...(result.executor === undefined ? {} : { executor: result.executor }),
926
989
  ...(result.totals === undefined ? {} : { estimatedTokens: result.totals.total }),
927
990
  ...(result.totals?.costUsd === undefined ? {} : { estimatedCostUsd: result.totals.costUsd })
928
991
  })
@@ -949,7 +1012,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
949
1012
  ).pipe(Effect.ignore)
950
1013
  }
951
1014
  const interruption: Interruption | undefined =
952
- error._tag === "EpicCheckoutDirty"
1015
+ error._tag === "EpicCheckoutDirty" ||
1016
+ error._tag === "RosterExhausted" ||
1017
+ reason.includes(rosterExhaustedPrefix)
953
1018
  ? "halt"
954
1019
  : isOutageMessage(reason)
955
1020
  ? "outage"
@@ -1020,7 +1085,16 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1020
1085
  if (halted === undefined) {
1021
1086
  const current = yield* progress
1022
1087
  const ready = readyStories(plan, current)
1023
- const slots = concurrency - current.running.size
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
1024
1098
  for (const story of ready.slice(0, Math.max(0, slots))) {
1025
1099
  yield* Ref.update(running, (set) => new Set([...set, story.id]))
1026
1100
  yield* Effect.forkScoped(
@@ -1066,7 +1140,16 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1066
1140
  if (outcome.status === "done") {
1067
1141
  yield* board.complete(outcome.id, {
1068
1142
  ...(outcome.branch === undefined ? {} : { branch: outcome.branch }),
1069
- ...(outcome.judge === undefined ? {} : { detail: outcome.judge }),
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
+ }),
1070
1153
  ...(outcome.estimatedTokens === undefined
1071
1154
  ? {}
1072
1155
  : { estimatedTokens: outcome.estimatedTokens }),