@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.
- 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/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 +24 -1
- package/dist/FlowError.d.ts.map +1 -1
- package/dist/FlowError.js +29 -0
- package/dist/FlowError.js.map +1 -1
- package/dist/Judgment.d.ts +2 -2
- package/dist/Judgment.d.ts.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 +9 -5
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +70 -10
- package/dist/Stories.js.map +1 -1
- package/package.json +4 -2
- package/src/FlowContext.ts +19 -1
- package/src/FlowError.ts +32 -0
- package/src/Roster.ts +824 -0
- package/src/RosterSeats.ts +306 -0
- 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: (
|
|
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?: (
|
|
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
|
-
{
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
|
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 }),
|