@llm4ts/flow 2.8.0 → 2.9.1

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 (56) 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/FlowEvents.d.ts +45 -0
  21. package/dist/FlowEvents.d.ts.map +1 -1
  22. package/dist/FlowEvents.js +87 -7
  23. package/dist/FlowEvents.js.map +1 -1
  24. package/dist/Judgment.d.ts +2 -2
  25. package/dist/Judgment.d.ts.map +1 -1
  26. package/dist/Persistence.d.ts +2 -2
  27. package/dist/Persistence.d.ts.map +1 -1
  28. package/dist/PlanExecution.d.ts +1 -1
  29. package/dist/PlanExecution.d.ts.map +1 -1
  30. package/dist/ProgramJudge.d.ts +1 -1
  31. package/dist/ProgramJudge.d.ts.map +1 -1
  32. package/dist/Replay.d.ts +2 -2
  33. package/dist/Replay.d.ts.map +1 -1
  34. package/dist/Review.d.ts +3 -3
  35. package/dist/Review.d.ts.map +1 -1
  36. package/dist/ReviewCache.d.ts +1 -1
  37. package/dist/ReviewCache.d.ts.map +1 -1
  38. package/dist/Roster.d.ts +164 -0
  39. package/dist/Roster.d.ts.map +1 -0
  40. package/dist/Roster.js +583 -0
  41. package/dist/Roster.js.map +1 -0
  42. package/dist/RosterSeats.d.ts +71 -0
  43. package/dist/RosterSeats.d.ts.map +1 -0
  44. package/dist/RosterSeats.js +167 -0
  45. package/dist/RosterSeats.js.map +1 -0
  46. package/dist/Stories.d.ts +9 -5
  47. package/dist/Stories.d.ts.map +1 -1
  48. package/dist/Stories.js +95 -24
  49. package/dist/Stories.js.map +1 -1
  50. package/package.json +4 -2
  51. package/src/FlowContext.ts +19 -1
  52. package/src/FlowError.ts +32 -0
  53. package/src/FlowEvents.ts +108 -7
  54. package/src/Roster.ts +849 -0
  55. package/src/RosterSeats.ts +360 -0
  56. package/src/Stories.ts +129 -28
package/src/Roster.ts ADDED
@@ -0,0 +1,849 @@
1
+ // The executor roster (ADR 0019): executors — a harness and a model — leased
2
+ // by role, with slots, per-role priorities, automatic and manual exclusion,
3
+ // and exclusion state that outlives a run. The roster decides WHO serves a
4
+ // call; the runner turns an executor into a seat (`RosterSeats.ts`). Only
5
+ // infrastructure signals exclude an executor: the quality of its work fails
6
+ // the work, never the executor.
7
+ import * as Clock from "effect/Clock"
8
+ import * as Deferred from "effect/Deferred"
9
+ import * as Duration from "effect/Duration"
10
+ import * as Effect from "effect/Effect"
11
+ import * as Ref from "effect/Ref"
12
+ import * as Schema from "effect/Schema"
13
+ import type * as Scope from "effect/Scope"
14
+ import * as Semaphore from "effect/Semaphore"
15
+ import type { LlmError } from "@llm4ts/core/Errors"
16
+ import { RosterExhausted, type FlowError } from "./FlowError.ts"
17
+ import { Info, type FlowEventsShape } from "./FlowEvents.ts"
18
+ import { loadVersioned, saveVersioned, type PlainFileStoreShape } from "./Persistence.ts"
19
+ import { isOutage } from "./TransientRetry.ts"
20
+
21
+ // ---- Schemas ----------------------------------------------------------------
22
+
23
+ export const Role = Schema.Literals(["planner", "coder", "reviewer", "judge", "verifier"])
24
+ export type Role = typeof Role.Type
25
+
26
+ export const roles: ReadonlyArray<Role> = ["planner", "coder", "reviewer", "judge", "verifier"]
27
+
28
+ /** How long each exclusion lasts, as duration strings ("30 minutes"). */
29
+ export class Cooldown extends Schema.Class<Cooldown>("Cooldown")({
30
+ usageLimit: Schema.optionalKey(Schema.String),
31
+ rateLimit: Schema.optionalKey(Schema.String),
32
+ outage: Schema.optionalKey(Schema.String)
33
+ }) {}
34
+
35
+ /** One executor: a harness and a model, the roles it takes, its slots and priorities. */
36
+ export class ExecutorSpec extends Schema.Class<ExecutorSpec>("ExecutorSpec")({
37
+ id: Schema.String,
38
+ /** A CLI harness (`pi`, `claude`, `codex`, `opencode`, …) or an API provider (`lm-studio`, …). */
39
+ harness: Schema.String,
40
+ model: Schema.optionalKey(Schema.String),
41
+ flags: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
42
+ /** Environment for the harness process; secrets only as `${VAR}` references. */
43
+ env: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
44
+ /** Endpoint for an API harness. */
45
+ baseUrl: Schema.optionalKey(Schema.String),
46
+ roles: Schema.Array(Schema.String),
47
+ slots: Schema.optionalKey(Schema.Number),
48
+ /** Slots coders may use; default `slots - 1` when the executor also reasons. */
49
+ coderSlots: Schema.optionalKey(Schema.Number),
50
+ /** Lower first. A number for every role, or per role with a `default`. */
51
+ priority: Schema.optionalKey(
52
+ Schema.Union([Schema.Number, Schema.Record(Schema.String, Schema.Number)])
53
+ ),
54
+ /** A URL that answers 2xx when the executor's serving engine is up. */
55
+ health: Schema.optionalKey(Schema.String),
56
+ cooldown: Schema.optionalKey(Cooldown),
57
+ /** In a repository roster: removes the user roster's entry with this id. */
58
+ disabled: Schema.optionalKey(Schema.Boolean)
59
+ }) {}
60
+
61
+ export class RosterDocument extends Schema.Class<RosterDocument>("RosterDocument")({
62
+ executors: Schema.Array(ExecutorSpec)
63
+ }) {}
64
+
65
+ export const ExclusionKind = Schema.Literals(["until", "health", "run", "manual"])
66
+ export type ExclusionKind = typeof ExclusionKind.Type
67
+
68
+ /**
69
+ * Why an executor is out of the pick-up round, and until when: `until` an
70
+ * instant, `health` its health URL answers, `run` the end of this run,
71
+ * `manual` an operator's resume.
72
+ */
73
+ export class Exclusion extends Schema.Class<Exclusion>("Exclusion")({
74
+ id: Schema.String,
75
+ reason: Schema.String,
76
+ kind: ExclusionKind,
77
+ /** Epoch milliseconds; for `until`, and as the next probe time for `health`. */
78
+ until: Schema.optionalKey(Schema.Number)
79
+ }) {}
80
+
81
+ export const RosterStateVersion = 1
82
+
83
+ export class RosterState extends Schema.Class<RosterState>("RosterState")({
84
+ exclusions: Schema.Array(Exclusion)
85
+ }) {}
86
+
87
+ // ---- Pure helpers -------------------------------------------------------------
88
+
89
+ const reasoningRoles: ReadonlyArray<Role> = ["planner", "reviewer", "judge", "verifier"]
90
+
91
+ export const isRole = (value: string): value is Role => roles.some((role) => role === value)
92
+
93
+ export const rolesOf = (spec: ExecutorSpec): ReadonlyArray<Role> => spec.roles.filter(isRole)
94
+
95
+ export const hasRole = (spec: ExecutorSpec, role: Role): boolean => rolesOf(spec).includes(role)
96
+
97
+ export const slotsOf = (spec: ExecutorSpec): number => Math.max(1, Math.floor(spec.slots ?? 1))
98
+
99
+ /** Slots coders may hold: one stays free for reasoning on an executor that does both. */
100
+ export const coderSlotsOf = (spec: ExecutorSpec): number => {
101
+ const slots = slotsOf(spec)
102
+ if (spec.coderSlots !== undefined) {
103
+ return Math.max(0, Math.min(slots, Math.floor(spec.coderSlots)))
104
+ }
105
+ const reasons = rolesOf(spec).some((role) => reasoningRoles.includes(role))
106
+ return hasRole(spec, "coder") && reasons && slots > 1 ? slots - 1 : slots
107
+ }
108
+
109
+ export const priorityOf = (spec: ExecutorSpec, role: Role): number => {
110
+ const priority = spec.priority
111
+ if (priority === undefined) {
112
+ return 1
113
+ }
114
+ if (typeof priority === "number") {
115
+ return priority
116
+ }
117
+ return priority[role] ?? priority.default ?? 1
118
+ }
119
+
120
+ const units: ReadonlyArray<readonly [RegExp, number]> = [
121
+ [/^(ms|millis|milliseconds?)$/, 1],
122
+ [/^(s|secs?|seconds?)$/, 1_000],
123
+ [/^(m|mins?|minutes?)$/, 60_000],
124
+ [/^(h|hours?)$/, 3_600_000]
125
+ ]
126
+
127
+ /** "30 minutes", "90s", "2h" — the cooldown vocabulary of a roster file. */
128
+ export const parseDuration = (text: string): Duration.Duration | undefined => {
129
+ const match = /^\s*(\d+(?:\.\d+)?)\s*([a-z]+)\s*$/i.exec(text)
130
+ if (match === null) {
131
+ return undefined
132
+ }
133
+ const amount = Number(match[1])
134
+ const unit = (match[2] ?? "").toLowerCase()
135
+ const scale = units.find(([pattern]) => pattern.test(unit))?.[1]
136
+ return scale === undefined ? undefined : Duration.millis(amount * scale)
137
+ }
138
+
139
+ const durationOr = (text: string | undefined, fallback: Duration.Input): Duration.Duration =>
140
+ (text === undefined ? undefined : parseDuration(text)) ?? Duration.fromInputUnsafe(fallback)
141
+
142
+ export const usageLimitCooldown = (spec: ExecutorSpec): Duration.Duration =>
143
+ durationOr(spec.cooldown?.usageLimit, "30 minutes")
144
+ export const rateLimitCooldown = (spec: ExecutorSpec): Duration.Duration =>
145
+ durationOr(spec.cooldown?.rateLimit, "10 minutes")
146
+ export const outageCooldown = (spec: ExecutorSpec): Duration.Duration =>
147
+ durationOr(spec.cooldown?.outage, "5 minutes")
148
+
149
+ /** Rate limits in this window count towards an exclusion. */
150
+ export const rateLimitWindow = Duration.minutes(10)
151
+ /** Rate limits within the window that exclude an executor. */
152
+ export const rateLimitsToExclude = 3
153
+ /** How often a health-excluded executor is probed while someone waits. */
154
+ export const healthProbeInterval = Duration.seconds(15)
155
+
156
+ /**
157
+ * Every violation of a roster document, in one pass: duplicate ids, unknown
158
+ * or missing roles, impossible slot counts, unparseable cooldowns. Harness
159
+ * checks need the connector table and live in the runner.
160
+ */
161
+ export const rosterViolations = (document: RosterDocument): ReadonlyArray<string> => {
162
+ const violations: Array<string> = []
163
+ const seen = new Set<string>()
164
+ for (const spec of document.executors) {
165
+ const where = `executor '${spec.id}'`
166
+ if (spec.id.trim().length === 0) {
167
+ violations.push("an executor has an empty id")
168
+ }
169
+ if (seen.has(spec.id)) {
170
+ violations.push(`${where} is listed twice`)
171
+ }
172
+ seen.add(spec.id)
173
+ if (spec.harness.trim().length === 0) {
174
+ violations.push(`${where} has no harness`)
175
+ }
176
+ const unknown = spec.roles.filter((role) => !isRole(role))
177
+ if (unknown.length > 0) {
178
+ violations.push(
179
+ `${where} has unknown role(s) ${unknown.join(", ")} (roles: ${roles.join(", ")})`
180
+ )
181
+ }
182
+ if (rolesOf(spec).length === 0) {
183
+ violations.push(`${where} takes no role`)
184
+ }
185
+ if (spec.slots !== undefined && (!Number.isInteger(spec.slots) || spec.slots < 1)) {
186
+ violations.push(`${where} needs a whole number of slots, at least 1`)
187
+ }
188
+ if (
189
+ spec.coderSlots !== undefined &&
190
+ (!Number.isInteger(spec.coderSlots) || spec.coderSlots < 0 || spec.coderSlots > slotsOf(spec))
191
+ ) {
192
+ violations.push(`${where} has coderSlots outside 0..slots`)
193
+ }
194
+ for (const [name, text] of Object.entries(spec.cooldown ?? {})) {
195
+ if (typeof text === "string" && parseDuration(text) === undefined) {
196
+ violations.push(`${where} has an unreadable ${name} cooldown '${text}'`)
197
+ }
198
+ }
199
+ }
200
+ return violations
201
+ }
202
+
203
+ /**
204
+ * A repository roster over the user's: an entry replaces the user entry
205
+ * with the same id, `disabled: true` removes it, a new id is added.
206
+ */
207
+ export const mergeRosterDocuments = (
208
+ user: RosterDocument | undefined,
209
+ repo: RosterDocument | undefined
210
+ ): RosterDocument => {
211
+ const merged = new Map<string, ExecutorSpec>()
212
+ for (const spec of user?.executors ?? []) {
213
+ merged.set(spec.id, spec)
214
+ }
215
+ for (const spec of repo?.executors ?? []) {
216
+ merged.set(spec.id, spec)
217
+ }
218
+ return RosterDocument.make({
219
+ executors: [...merged.values()].filter((spec) => spec.disabled !== true)
220
+ })
221
+ }
222
+
223
+ /** Only the named executors (`--executors a,b`); an empty list keeps all. */
224
+ export const narrowRoster = (
225
+ document: RosterDocument,
226
+ ids: ReadonlyArray<string>
227
+ ): RosterDocument =>
228
+ ids.length === 0
229
+ ? document
230
+ : RosterDocument.make({
231
+ executors: document.executors.filter((spec) => ids.includes(spec.id))
232
+ })
233
+
234
+ const describeUntil = (until: number): string => new Date(until).toISOString()
235
+
236
+ // The executor names a model its harness cannot serve (a typo, a model the
237
+ // account has no access to): every call will fail the same way, so it sits
238
+ // the run out. claude says "There's an issue with the selected model".
239
+ const modelUnavailableSignals: ReadonlyArray<string> = [
240
+ "issue with the selected model",
241
+ "may not exist or you may not have access",
242
+ "model not found",
243
+ "model_not_found",
244
+ "unknown model",
245
+ "invalid model",
246
+ "does not exist or you do not have access"
247
+ ]
248
+
249
+ export const isModelUnavailableMessage = (message: string): boolean => {
250
+ const normalized = message.toLowerCase()
251
+ return modelUnavailableSignals.some((signal) => normalized.includes(signal))
252
+ }
253
+
254
+ /**
255
+ * The exclusion a failure earns, or none. `recentRateLimits` holds the
256
+ * executor's rate-limit instants inside the window, this one included.
257
+ */
258
+ export const exclusionFor = (
259
+ error: LlmError,
260
+ spec: ExecutorSpec,
261
+ now: number,
262
+ recentRateLimits: number
263
+ ): Exclusion | undefined => {
264
+ if (isModelUnavailableMessage(error.message)) {
265
+ return Exclusion.make({
266
+ id: spec.id,
267
+ kind: "run",
268
+ reason: `its model${spec.model === undefined ? "" : ` '${spec.model}'`} is unavailable (${error.message.slice(0, 200)})`
269
+ })
270
+ }
271
+ switch (error._tag) {
272
+ case "UsageLimitError": {
273
+ const reset = error.resetAt === undefined ? undefined : error.resetAt.epochMilliseconds
274
+ const until =
275
+ reset !== undefined && reset > now
276
+ ? reset
277
+ : now + Duration.toMillis(usageLimitCooldown(spec))
278
+ return Exclusion.make({
279
+ id: spec.id,
280
+ kind: "until",
281
+ until,
282
+ reason: `usage limit (${error.message.slice(0, 160)})`
283
+ })
284
+ }
285
+ case "RateLimitError":
286
+ return recentRateLimits >= rateLimitsToExclude
287
+ ? Exclusion.make({
288
+ id: spec.id,
289
+ kind: "until",
290
+ until: now + Duration.toMillis(rateLimitCooldown(spec)),
291
+ reason: `${recentRateLimits} rate limits within ${Duration.format(rateLimitWindow)}`
292
+ })
293
+ : undefined
294
+ case "AuthenticationError":
295
+ return Exclusion.make({
296
+ id: spec.id,
297
+ kind: "run",
298
+ reason: `not signed in (${error.message.slice(0, 160)})`
299
+ })
300
+ case "ProviderError":
301
+ if (!isOutage(error)) {
302
+ return undefined
303
+ }
304
+ return spec.health === undefined
305
+ ? Exclusion.make({
306
+ id: spec.id,
307
+ kind: "until",
308
+ until: now + Duration.toMillis(outageCooldown(spec)),
309
+ reason: `serving engine down (${error.message.slice(0, 160)})`
310
+ })
311
+ : Exclusion.make({
312
+ id: spec.id,
313
+ kind: "health",
314
+ until: now,
315
+ reason: `serving engine down (${error.message.slice(0, 160)})`
316
+ })
317
+ default:
318
+ return undefined
319
+ }
320
+ }
321
+
322
+ export const describeExclusion = (exclusion: Exclusion): string => {
323
+ switch (exclusion.kind) {
324
+ case "until":
325
+ return `until ${exclusion.until === undefined ? "?" : describeUntil(exclusion.until)}: ${exclusion.reason}`
326
+ case "health":
327
+ return `until its health check answers: ${exclusion.reason}`
328
+ case "run":
329
+ return `for this run: ${exclusion.reason}`
330
+ case "manual":
331
+ return `paused${exclusion.until === undefined ? "" : ` until ${describeUntil(exclusion.until)}`}: ${exclusion.reason}`
332
+ }
333
+ }
334
+
335
+ // ---- Persisted state -----------------------------------------------------------
336
+
337
+ export interface RosterStateStoreShape {
338
+ readonly load: Effect.Effect<ReadonlyArray<Exclusion>, FlowError>
339
+ /** Replaces the entries of `ids` with `exclusions`, keeping everyone else's. */
340
+ readonly save: (
341
+ ids: ReadonlyArray<string>,
342
+ exclusions: ReadonlyArray<Exclusion>
343
+ ) => Effect.Effect<void, FlowError>
344
+ }
345
+
346
+ /** Only exclusions that outlive a run are written; expired ones are dropped on load. */
347
+ const persisted = (exclusion: Exclusion): boolean => exclusion.kind !== "run"
348
+
349
+ export const makeRosterStateStore = (
350
+ files: PlainFileStoreShape,
351
+ path: string
352
+ ): RosterStateStoreShape => {
353
+ const read = Effect.gen(function* () {
354
+ const state = yield* loadVersioned(files, path, RosterStateVersion, RosterState)
355
+ const now = yield* Clock.currentTimeMillis
356
+ return (state?.exclusions ?? []).filter(
357
+ (exclusion) =>
358
+ persisted(exclusion) &&
359
+ !(exclusion.kind === "until" && (exclusion.until ?? 0) <= now) &&
360
+ !(exclusion.kind === "manual" && exclusion.until !== undefined && exclusion.until <= now)
361
+ )
362
+ })
363
+ return {
364
+ load: read,
365
+ save: (ids, exclusions) =>
366
+ Effect.gen(function* () {
367
+ const others = (yield* read).filter((exclusion) => !ids.includes(exclusion.id))
368
+ yield* saveVersioned(
369
+ files,
370
+ path,
371
+ RosterStateVersion,
372
+ RosterState,
373
+ RosterState.make({ exclusions: [...others, ...exclusions.filter(persisted)] })
374
+ )
375
+ })
376
+ }
377
+ }
378
+
379
+ // ---- The roster ------------------------------------------------------------------
380
+
381
+ export interface Lease {
382
+ readonly executor: ExecutorSpec
383
+ readonly role: Role
384
+ /** Frees the slot; idempotent, and also run when the lease's scope closes. */
385
+ readonly release: Effect.Effect<void>
386
+ }
387
+
388
+ export interface LeaseOptions {
389
+ /** Executors this lease must not use (independence, or a handover away from one). */
390
+ readonly avoid?: ReadonlyArray<string>
391
+ /** Taken first while free: the executor a resumed context held before. */
392
+ readonly prefer?: string
393
+ /** Who is asking, for the events (a story's worktree). */
394
+ readonly label?: string
395
+ }
396
+
397
+ export interface ExecutorStatus {
398
+ readonly executor: ExecutorSpec
399
+ readonly busy: number
400
+ readonly busyCoders: number
401
+ readonly exclusion?: Exclusion
402
+ }
403
+
404
+ export interface RosterShape {
405
+ readonly executors: ReadonlyArray<ExecutorSpec>
406
+ /** Waits for a slot; fails `RosterExhausted` when none can ever come. */
407
+ readonly lease: (
408
+ role: Role,
409
+ options?: LeaseOptions
410
+ ) => Effect.Effect<Lease, RosterExhausted, Scope.Scope>
411
+ /** A slot now, or `undefined`. */
412
+ readonly tryLease: (
413
+ role: Role,
414
+ options?: LeaseOptions
415
+ ) => Effect.Effect<Lease | undefined, never, Scope.Scope>
416
+ /** Whether an executor outside `avoid` has the role and is not out for the run. */
417
+ readonly canEverServe: (role: Role, avoid?: ReadonlyArray<string>) => Effect.Effect<boolean>
418
+ /** Free slots for `role` now, across executors in the round. */
419
+ readonly available: (role: Role) => Effect.Effect<number>
420
+ /** Configured slots for `role`. */
421
+ readonly slots: (role: Role) => number
422
+ /** Classifies a failure of `id`'s call; returns the exclusion it earned, if any. */
423
+ readonly report: (id: string, error: LlmError) => Effect.Effect<Exclusion | undefined>
424
+ readonly exclude: (exclusion: Exclusion) => Effect.Effect<void>
425
+ readonly resume: (id: string) => Effect.Effect<void>
426
+ readonly snapshot: Effect.Effect<ReadonlyArray<ExecutorStatus>>
427
+ }
428
+
429
+ export interface RosterOptions {
430
+ readonly executors: ReadonlyArray<ExecutorSpec>
431
+ readonly events: FlowEventsShape
432
+ /** Exclusions that outlive a run; omit to keep them in memory. */
433
+ readonly state?: RosterStateStoreShape
434
+ /** Answers whether a health URL is up; omit and `health` exclusions wait out the outage cooldown. */
435
+ readonly probe?: (url: string) => Effect.Effect<boolean>
436
+ }
437
+
438
+ interface Usage {
439
+ busy: number
440
+ busyCoders: number
441
+ }
442
+
443
+ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
444
+ options: RosterOptions
445
+ ): Effect.fn.Return<RosterShape, FlowError> {
446
+ const { executors, events } = options
447
+ const lock = yield* Semaphore.make(1)
448
+ const usage = new Map<string, Usage>(
449
+ executors.map((spec) => [spec.id, { busy: 0, busyCoders: 0 }])
450
+ )
451
+ const exclusions = new Map<string, Exclusion>()
452
+ const rateLimits = new Map<string, ReadonlyArray<number>>()
453
+ const lastLeased = new Map<string, number>()
454
+ let leases = 0
455
+ const changed = yield* Ref.make(yield* Deferred.make<void>())
456
+ const known = new Set(executors.map((spec) => spec.id))
457
+
458
+ for (const exclusion of options.state === undefined ? [] : yield* options.state.load) {
459
+ if (known.has(exclusion.id)) {
460
+ exclusions.set(exclusion.id, exclusion)
461
+ }
462
+ }
463
+
464
+ const say = (message: string): Effect.Effect<void> =>
465
+ events.publish(Info.make({ message: `roster: ${message}` }))
466
+
467
+ const signal: Effect.Effect<void> = Effect.gen(function* () {
468
+ const next = yield* Deferred.make<void>()
469
+ const previous = yield* Ref.getAndSet(changed, next)
470
+ yield* Deferred.succeed(previous, undefined)
471
+ })
472
+
473
+ // Suspended: the exclusions are read when the save runs, not when it is built.
474
+ const persist: Effect.Effect<void> = Effect.suspend(() =>
475
+ options.state === undefined
476
+ ? Effect.void
477
+ : options.state
478
+ .save(
479
+ executors.map((spec) => spec.id),
480
+ [...exclusions.values()]
481
+ )
482
+ .pipe(Effect.catch((error) => say(`could not save the roster state: ${error.message}`)))
483
+ )
484
+
485
+ /** Ends every exclusion whose time has come; returns the ids that came back. */
486
+ const expire = (now: number): ReadonlyArray<string> => {
487
+ const back: Array<string> = []
488
+ for (const [id, exclusion] of exclusions) {
489
+ const timed = exclusion.kind === "until" || exclusion.kind === "manual"
490
+ if (timed && exclusion.until !== undefined && exclusion.until <= now) {
491
+ exclusions.delete(id)
492
+ back.push(id)
493
+ }
494
+ }
495
+ return back
496
+ }
497
+
498
+ const freeFor = (spec: ExecutorSpec, role: Role): boolean => {
499
+ const used = usage.get(spec.id) ?? { busy: 0, busyCoders: 0 }
500
+ if (used.busy >= slotsOf(spec)) {
501
+ return false
502
+ }
503
+ return role !== "coder" || used.busyCoders < coderSlotsOf(spec)
504
+ }
505
+
506
+ const eligible = (role: Role, avoid: ReadonlyArray<string>): ReadonlyArray<ExecutorSpec> =>
507
+ executors.filter(
508
+ (spec) => hasRole(spec, role) && !avoid.includes(spec.id) && !exclusions.has(spec.id)
509
+ )
510
+
511
+ const pick = (role: Role, leaseOptions: LeaseOptions): ExecutorSpec | undefined => {
512
+ const avoid = leaseOptions.avoid ?? []
513
+ const candidates = eligible(role, avoid).filter((spec) => freeFor(spec, role))
514
+ const preferred = candidates.find((spec) => spec.id === leaseOptions.prefer)
515
+ if (preferred !== undefined) {
516
+ return preferred
517
+ }
518
+ return [...candidates].sort(
519
+ (left, right) =>
520
+ priorityOf(left, role) - priorityOf(right, role) ||
521
+ (lastLeased.get(left.id) ?? -1) - (lastLeased.get(right.id) ?? -1)
522
+ )[0]
523
+ }
524
+
525
+ const release = (spec: ExecutorSpec, role: Role): Effect.Effect<void> =>
526
+ lock
527
+ .withPermit(
528
+ Effect.sync(() => {
529
+ const used = usage.get(spec.id)
530
+ if (used !== undefined) {
531
+ used.busy = Math.max(0, used.busy - 1)
532
+ if (role === "coder") {
533
+ used.busyCoders = Math.max(0, used.busyCoders - 1)
534
+ }
535
+ }
536
+ })
537
+ )
538
+ .pipe(Effect.andThen(signal))
539
+
540
+ const leaseOf = (spec: ExecutorSpec, role: Role): Effect.Effect<Lease, never, Scope.Scope> =>
541
+ Effect.gen(function* () {
542
+ const done = yield* Ref.make(false)
543
+ const free = Effect.flatMap(Ref.getAndSet(done, true), (was) =>
544
+ was ? Effect.void : release(spec, role)
545
+ )
546
+ yield* Effect.addFinalizer(() => free)
547
+ return { executor: spec, role, release: free }
548
+ })
549
+
550
+ /** One attempt: expire, pick, take the slot — all under the lock. */
551
+ const attempt = (
552
+ role: Role,
553
+ leaseOptions: LeaseOptions
554
+ ): Effect.Effect<ExecutorSpec | undefined> =>
555
+ Effect.gen(function* () {
556
+ const now = yield* Clock.currentTimeMillis
557
+ const { spec, back } = yield* lock.withPermit(
558
+ Effect.sync(() => {
559
+ const back = expire(now)
560
+ const spec = pick(role, leaseOptions)
561
+ if (spec !== undefined) {
562
+ const used = usage.get(spec.id)
563
+ if (used !== undefined) {
564
+ used.busy += 1
565
+ if (role === "coder") {
566
+ used.busyCoders += 1
567
+ }
568
+ }
569
+ leases += 1
570
+ lastLeased.set(spec.id, leases)
571
+ }
572
+ return { spec, back }
573
+ })
574
+ )
575
+ if (back.length > 0) {
576
+ yield* say(`${back.join(", ")} back in the round`)
577
+ yield* persist
578
+ }
579
+ return spec
580
+ })
581
+
582
+ const canEverServe = (role: Role, avoid: ReadonlyArray<string> = []): Effect.Effect<boolean> =>
583
+ lock.withPermit(
584
+ Effect.sync(() =>
585
+ executors.some(
586
+ (spec) =>
587
+ hasRole(spec, role) &&
588
+ !avoid.includes(spec.id) &&
589
+ exclusions.get(spec.id)?.kind !== "run"
590
+ )
591
+ )
592
+ )
593
+
594
+ /** Probes every `health` exclusion whose next probe is due; a live one comes back. */
595
+ const probeDue: Effect.Effect<void> = Effect.gen(function* () {
596
+ const probe = options.probe
597
+ const now = yield* Clock.currentTimeMillis
598
+ const due = yield* lock.withPermit(
599
+ Effect.sync(() =>
600
+ [...exclusions.values()].filter(
601
+ (exclusion) => exclusion.kind === "health" && (exclusion.until ?? 0) <= now
602
+ )
603
+ )
604
+ )
605
+ for (const exclusion of due) {
606
+ const spec = executors.find((candidate) => candidate.id === exclusion.id)
607
+ const url = spec?.health
608
+ const up =
609
+ probe === undefined || url === undefined
610
+ ? // Nothing to probe: the outage cooldown decides, measured from now.
611
+ false
612
+ : yield* probe(url)
613
+ yield* lock.withPermit(
614
+ Effect.sync(() => {
615
+ if (up) {
616
+ exclusions.delete(exclusion.id)
617
+ } else if (probe === undefined || url === undefined) {
618
+ exclusions.set(
619
+ exclusion.id,
620
+ Exclusion.make({
621
+ ...exclusion,
622
+ kind: "until",
623
+ until:
624
+ now +
625
+ Duration.toMillis(spec === undefined ? Duration.minutes(5) : outageCooldown(spec))
626
+ })
627
+ )
628
+ } else {
629
+ exclusions.set(
630
+ exclusion.id,
631
+ Exclusion.make({ ...exclusion, until: now + Duration.toMillis(healthProbeInterval) })
632
+ )
633
+ }
634
+ })
635
+ )
636
+ if (up) {
637
+ yield* say(`${exclusion.id} answers its health check again; back in the round`)
638
+ yield* persist
639
+ yield* signal
640
+ }
641
+ }
642
+ })
643
+
644
+ /** Sleeps until something that could free a slot happens. */
645
+ const waitForChange = (role: Role): Effect.Effect<void> =>
646
+ Effect.gen(function* () {
647
+ const wake = yield* Ref.get(changed)
648
+ const now = yield* Clock.currentTimeMillis
649
+ const deadlines = yield* lock.withPermit(
650
+ Effect.sync(() =>
651
+ [...exclusions.values()]
652
+ .filter((exclusion) => {
653
+ const spec = executors.find((candidate) => candidate.id === exclusion.id)
654
+ return spec !== undefined && hasRole(spec, role) && exclusion.kind !== "run"
655
+ })
656
+ .flatMap((exclusion) =>
657
+ exclusion.until === undefined ? [] : [Math.max(0, exclusion.until - now)]
658
+ )
659
+ )
660
+ )
661
+ const soonest = deadlines.length === 0 ? undefined : Math.min(...deadlines)
662
+ yield* soonest === undefined
663
+ ? Deferred.await(wake)
664
+ : Effect.raceFirst(Deferred.await(wake), Effect.sleep(Duration.millis(soonest)))
665
+ })
666
+
667
+ const waitingReport = (role: Role, avoid: ReadonlyArray<string>): Effect.Effect<string> =>
668
+ lock.withPermit(
669
+ Effect.sync(() =>
670
+ executors
671
+ .filter((spec) => hasRole(spec, role) && !avoid.includes(spec.id))
672
+ .map((spec) => {
673
+ const exclusion = exclusions.get(spec.id)
674
+ return exclusion === undefined
675
+ ? `${spec.id} busy`
676
+ : `${spec.id} ${describeExclusion(exclusion)}`
677
+ })
678
+ .join("; ")
679
+ )
680
+ )
681
+
682
+ const lease = (
683
+ role: Role,
684
+ leaseOptions: LeaseOptions = {}
685
+ ): Effect.Effect<Lease, RosterExhausted, Scope.Scope> =>
686
+ Effect.gen(function* () {
687
+ const avoid = leaseOptions.avoid ?? []
688
+ let announced = false
689
+ while (true) {
690
+ const spec = yield* attempt(role, leaseOptions)
691
+ if (spec !== undefined) {
692
+ yield* say(
693
+ `${spec.id} takes ${role}${leaseOptions.label === undefined ? "" : ` for ${leaseOptions.label}`}`
694
+ )
695
+ return yield* leaseOf(spec, role)
696
+ }
697
+ if (!(yield* canEverServe(role, avoid))) {
698
+ const reasons = yield* lock.withPermit(
699
+ Effect.sync(() =>
700
+ executors
701
+ .filter((spec) => hasRole(spec, role) && !avoid.includes(spec.id))
702
+ .map((spec) => {
703
+ const exclusion = exclusions.get(spec.id)
704
+ return `${spec.id} ${exclusion === undefined ? "unavailable" : describeExclusion(exclusion)}`
705
+ })
706
+ )
707
+ )
708
+ return yield* RosterExhausted.make({
709
+ role,
710
+ reasons: reasons.length === 0 ? ["no executor in the roster takes this role"] : reasons
711
+ })
712
+ }
713
+ if (!announced) {
714
+ announced = true
715
+ yield* say(
716
+ `waiting for an executor to take ${role}${leaseOptions.label === undefined ? "" : ` for ${leaseOptions.label}`}: ${yield* waitingReport(role, avoid)}`
717
+ )
718
+ }
719
+ yield* probeDue
720
+ const recovered = yield* attempt(role, leaseOptions)
721
+ if (recovered !== undefined) {
722
+ yield* say(
723
+ `${recovered.id} takes ${role}${leaseOptions.label === undefined ? "" : ` for ${leaseOptions.label}`}`
724
+ )
725
+ return yield* leaseOf(recovered, role)
726
+ }
727
+ const probing = yield* lock.withPermit(
728
+ Effect.sync(() =>
729
+ [...exclusions.values()].some((exclusion) => exclusion.kind === "health")
730
+ )
731
+ )
732
+ yield* probing
733
+ ? Effect.raceFirst(waitForChange(role), Effect.sleep(healthProbeInterval))
734
+ : waitForChange(role)
735
+ }
736
+ })
737
+
738
+ const tryLease = (
739
+ role: Role,
740
+ leaseOptions: LeaseOptions = {}
741
+ ): Effect.Effect<Lease | undefined, never, Scope.Scope> =>
742
+ Effect.flatMap(attempt(role, leaseOptions), (spec) =>
743
+ spec === undefined ? Effect.succeed(undefined) : leaseOf(spec, role)
744
+ )
745
+
746
+ const exclude = (exclusion: Exclusion): Effect.Effect<void> =>
747
+ Effect.gen(function* () {
748
+ if (!known.has(exclusion.id)) {
749
+ return
750
+ }
751
+ yield* lock.withPermit(Effect.sync(() => exclusions.set(exclusion.id, exclusion)))
752
+ yield* say(`${exclusion.id} out of the round ${describeExclusion(exclusion)}`)
753
+ yield* persist
754
+ yield* signal
755
+ })
756
+
757
+ const report = (id: string, error: LlmError): Effect.Effect<Exclusion | undefined> =>
758
+ Effect.gen(function* () {
759
+ const spec = executors.find((candidate) => candidate.id === id)
760
+ if (spec === undefined) {
761
+ return undefined
762
+ }
763
+ const now = yield* Clock.currentTimeMillis
764
+ const windowStart = now - Duration.toMillis(rateLimitWindow)
765
+ const recent =
766
+ error._tag === "RateLimitError"
767
+ ? yield* lock.withPermit(
768
+ Effect.sync(() => {
769
+ const kept = [...(rateLimits.get(id) ?? []).filter((at) => at > windowStart), now]
770
+ rateLimits.set(id, kept)
771
+ return kept.length
772
+ })
773
+ )
774
+ : 0
775
+ const exclusion = exclusionFor(error, spec, now, recent)
776
+ if (exclusion !== undefined) {
777
+ if (error._tag === "RateLimitError") {
778
+ yield* lock.withPermit(Effect.sync(() => rateLimits.delete(id)))
779
+ }
780
+ yield* exclude(exclusion)
781
+ }
782
+ return exclusion
783
+ })
784
+
785
+ const resume = (id: string): Effect.Effect<void> =>
786
+ Effect.gen(function* () {
787
+ const removed = yield* lock.withPermit(Effect.sync(() => exclusions.delete(id)))
788
+ if (removed) {
789
+ yield* say(`${id} resumed`)
790
+ yield* persist
791
+ yield* signal
792
+ }
793
+ })
794
+
795
+ const available = (role: Role): Effect.Effect<number> =>
796
+ Effect.gen(function* () {
797
+ const now = yield* Clock.currentTimeMillis
798
+ return yield* lock.withPermit(
799
+ Effect.sync(() => {
800
+ expire(now)
801
+ return eligible(role, []).reduce((sum, spec) => {
802
+ const used = usage.get(spec.id) ?? { busy: 0, busyCoders: 0 }
803
+ const free =
804
+ role === "coder"
805
+ ? Math.min(slotsOf(spec) - used.busy, coderSlotsOf(spec) - used.busyCoders)
806
+ : slotsOf(spec) - used.busy
807
+ return sum + Math.max(0, free)
808
+ }, 0)
809
+ })
810
+ )
811
+ })
812
+
813
+ const slots = (role: Role): number =>
814
+ executors
815
+ .filter((spec) => hasRole(spec, role))
816
+ .reduce((sum, spec) => sum + (role === "coder" ? coderSlotsOf(spec) : slotsOf(spec)), 0)
817
+
818
+ const snapshot: Effect.Effect<ReadonlyArray<ExecutorStatus>> = Effect.gen(function* () {
819
+ const now = yield* Clock.currentTimeMillis
820
+ return yield* lock.withPermit(
821
+ Effect.sync(() => {
822
+ expire(now)
823
+ return executors.map((spec) => {
824
+ const used = usage.get(spec.id) ?? { busy: 0, busyCoders: 0 }
825
+ const exclusion = exclusions.get(spec.id)
826
+ return {
827
+ executor: spec,
828
+ busy: used.busy,
829
+ busyCoders: used.busyCoders,
830
+ ...(exclusion === undefined ? {} : { exclusion })
831
+ }
832
+ })
833
+ })
834
+ )
835
+ })
836
+
837
+ return {
838
+ executors,
839
+ lease,
840
+ tryLease,
841
+ canEverServe,
842
+ available,
843
+ slots,
844
+ report,
845
+ exclude,
846
+ resume,
847
+ snapshot
848
+ }
849
+ })