@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
package/src/Roster.ts ADDED
@@ -0,0 +1,824 @@
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
+ /**
237
+ * The exclusion a failure earns, or none. `recentRateLimits` holds the
238
+ * executor's rate-limit instants inside the window, this one included.
239
+ */
240
+ export const exclusionFor = (
241
+ error: LlmError,
242
+ spec: ExecutorSpec,
243
+ now: number,
244
+ recentRateLimits: number
245
+ ): Exclusion | undefined => {
246
+ switch (error._tag) {
247
+ case "UsageLimitError": {
248
+ const reset = error.resetAt === undefined ? undefined : error.resetAt.epochMilliseconds
249
+ const until =
250
+ reset !== undefined && reset > now
251
+ ? reset
252
+ : now + Duration.toMillis(usageLimitCooldown(spec))
253
+ return Exclusion.make({
254
+ id: spec.id,
255
+ kind: "until",
256
+ until,
257
+ reason: `usage limit (${error.message.slice(0, 160)})`
258
+ })
259
+ }
260
+ case "RateLimitError":
261
+ return recentRateLimits >= rateLimitsToExclude
262
+ ? Exclusion.make({
263
+ id: spec.id,
264
+ kind: "until",
265
+ until: now + Duration.toMillis(rateLimitCooldown(spec)),
266
+ reason: `${recentRateLimits} rate limits within ${Duration.format(rateLimitWindow)}`
267
+ })
268
+ : undefined
269
+ case "AuthenticationError":
270
+ return Exclusion.make({
271
+ id: spec.id,
272
+ kind: "run",
273
+ reason: `not signed in (${error.message.slice(0, 160)})`
274
+ })
275
+ case "ProviderError":
276
+ if (!isOutage(error)) {
277
+ return undefined
278
+ }
279
+ return spec.health === undefined
280
+ ? Exclusion.make({
281
+ id: spec.id,
282
+ kind: "until",
283
+ until: now + Duration.toMillis(outageCooldown(spec)),
284
+ reason: `serving engine down (${error.message.slice(0, 160)})`
285
+ })
286
+ : Exclusion.make({
287
+ id: spec.id,
288
+ kind: "health",
289
+ until: now,
290
+ reason: `serving engine down (${error.message.slice(0, 160)})`
291
+ })
292
+ default:
293
+ return undefined
294
+ }
295
+ }
296
+
297
+ export const describeExclusion = (exclusion: Exclusion): string => {
298
+ switch (exclusion.kind) {
299
+ case "until":
300
+ return `until ${exclusion.until === undefined ? "?" : describeUntil(exclusion.until)}: ${exclusion.reason}`
301
+ case "health":
302
+ return `until its health check answers: ${exclusion.reason}`
303
+ case "run":
304
+ return `for this run: ${exclusion.reason}`
305
+ case "manual":
306
+ return `paused${exclusion.until === undefined ? "" : ` until ${describeUntil(exclusion.until)}`}: ${exclusion.reason}`
307
+ }
308
+ }
309
+
310
+ // ---- Persisted state -----------------------------------------------------------
311
+
312
+ export interface RosterStateStoreShape {
313
+ readonly load: Effect.Effect<ReadonlyArray<Exclusion>, FlowError>
314
+ /** Replaces the entries of `ids` with `exclusions`, keeping everyone else's. */
315
+ readonly save: (
316
+ ids: ReadonlyArray<string>,
317
+ exclusions: ReadonlyArray<Exclusion>
318
+ ) => Effect.Effect<void, FlowError>
319
+ }
320
+
321
+ /** Only exclusions that outlive a run are written; expired ones are dropped on load. */
322
+ const persisted = (exclusion: Exclusion): boolean => exclusion.kind !== "run"
323
+
324
+ export const makeRosterStateStore = (
325
+ files: PlainFileStoreShape,
326
+ path: string
327
+ ): RosterStateStoreShape => {
328
+ const read = Effect.gen(function* () {
329
+ const state = yield* loadVersioned(files, path, RosterStateVersion, RosterState)
330
+ const now = yield* Clock.currentTimeMillis
331
+ return (state?.exclusions ?? []).filter(
332
+ (exclusion) =>
333
+ persisted(exclusion) &&
334
+ !(exclusion.kind === "until" && (exclusion.until ?? 0) <= now) &&
335
+ !(exclusion.kind === "manual" && exclusion.until !== undefined && exclusion.until <= now)
336
+ )
337
+ })
338
+ return {
339
+ load: read,
340
+ save: (ids, exclusions) =>
341
+ Effect.gen(function* () {
342
+ const others = (yield* read).filter((exclusion) => !ids.includes(exclusion.id))
343
+ yield* saveVersioned(
344
+ files,
345
+ path,
346
+ RosterStateVersion,
347
+ RosterState,
348
+ RosterState.make({ exclusions: [...others, ...exclusions.filter(persisted)] })
349
+ )
350
+ })
351
+ }
352
+ }
353
+
354
+ // ---- The roster ------------------------------------------------------------------
355
+
356
+ export interface Lease {
357
+ readonly executor: ExecutorSpec
358
+ readonly role: Role
359
+ /** Frees the slot; idempotent, and also run when the lease's scope closes. */
360
+ readonly release: Effect.Effect<void>
361
+ }
362
+
363
+ export interface LeaseOptions {
364
+ /** Executors this lease must not use (independence, or a handover away from one). */
365
+ readonly avoid?: ReadonlyArray<string>
366
+ /** Taken first while free: the executor a resumed context held before. */
367
+ readonly prefer?: string
368
+ /** Who is asking, for the events (a story's worktree). */
369
+ readonly label?: string
370
+ }
371
+
372
+ export interface ExecutorStatus {
373
+ readonly executor: ExecutorSpec
374
+ readonly busy: number
375
+ readonly busyCoders: number
376
+ readonly exclusion?: Exclusion
377
+ }
378
+
379
+ export interface RosterShape {
380
+ readonly executors: ReadonlyArray<ExecutorSpec>
381
+ /** Waits for a slot; fails `RosterExhausted` when none can ever come. */
382
+ readonly lease: (
383
+ role: Role,
384
+ options?: LeaseOptions
385
+ ) => Effect.Effect<Lease, RosterExhausted, Scope.Scope>
386
+ /** A slot now, or `undefined`. */
387
+ readonly tryLease: (
388
+ role: Role,
389
+ options?: LeaseOptions
390
+ ) => Effect.Effect<Lease | undefined, never, Scope.Scope>
391
+ /** Whether an executor outside `avoid` has the role and is not out for the run. */
392
+ readonly canEverServe: (role: Role, avoid?: ReadonlyArray<string>) => Effect.Effect<boolean>
393
+ /** Free slots for `role` now, across executors in the round. */
394
+ readonly available: (role: Role) => Effect.Effect<number>
395
+ /** Configured slots for `role`. */
396
+ readonly slots: (role: Role) => number
397
+ /** Classifies a failure of `id`'s call; returns the exclusion it earned, if any. */
398
+ readonly report: (id: string, error: LlmError) => Effect.Effect<Exclusion | undefined>
399
+ readonly exclude: (exclusion: Exclusion) => Effect.Effect<void>
400
+ readonly resume: (id: string) => Effect.Effect<void>
401
+ readonly snapshot: Effect.Effect<ReadonlyArray<ExecutorStatus>>
402
+ }
403
+
404
+ export interface RosterOptions {
405
+ readonly executors: ReadonlyArray<ExecutorSpec>
406
+ readonly events: FlowEventsShape
407
+ /** Exclusions that outlive a run; omit to keep them in memory. */
408
+ readonly state?: RosterStateStoreShape
409
+ /** Answers whether a health URL is up; omit and `health` exclusions wait out the outage cooldown. */
410
+ readonly probe?: (url: string) => Effect.Effect<boolean>
411
+ }
412
+
413
+ interface Usage {
414
+ busy: number
415
+ busyCoders: number
416
+ }
417
+
418
+ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
419
+ options: RosterOptions
420
+ ): Effect.fn.Return<RosterShape, FlowError> {
421
+ const { executors, events } = options
422
+ const lock = yield* Semaphore.make(1)
423
+ const usage = new Map<string, Usage>(
424
+ executors.map((spec) => [spec.id, { busy: 0, busyCoders: 0 }])
425
+ )
426
+ const exclusions = new Map<string, Exclusion>()
427
+ const rateLimits = new Map<string, ReadonlyArray<number>>()
428
+ const lastLeased = new Map<string, number>()
429
+ let leases = 0
430
+ const changed = yield* Ref.make(yield* Deferred.make<void>())
431
+ const known = new Set(executors.map((spec) => spec.id))
432
+
433
+ for (const exclusion of options.state === undefined ? [] : yield* options.state.load) {
434
+ if (known.has(exclusion.id)) {
435
+ exclusions.set(exclusion.id, exclusion)
436
+ }
437
+ }
438
+
439
+ const say = (message: string): Effect.Effect<void> =>
440
+ events.publish(Info.make({ message: `roster: ${message}` }))
441
+
442
+ const signal: Effect.Effect<void> = Effect.gen(function* () {
443
+ const next = yield* Deferred.make<void>()
444
+ const previous = yield* Ref.getAndSet(changed, next)
445
+ yield* Deferred.succeed(previous, undefined)
446
+ })
447
+
448
+ // Suspended: the exclusions are read when the save runs, not when it is built.
449
+ const persist: Effect.Effect<void> = Effect.suspend(() =>
450
+ options.state === undefined
451
+ ? Effect.void
452
+ : options.state
453
+ .save(
454
+ executors.map((spec) => spec.id),
455
+ [...exclusions.values()]
456
+ )
457
+ .pipe(Effect.catch((error) => say(`could not save the roster state: ${error.message}`)))
458
+ )
459
+
460
+ /** Ends every exclusion whose time has come; returns the ids that came back. */
461
+ const expire = (now: number): ReadonlyArray<string> => {
462
+ const back: Array<string> = []
463
+ for (const [id, exclusion] of exclusions) {
464
+ const timed = exclusion.kind === "until" || exclusion.kind === "manual"
465
+ if (timed && exclusion.until !== undefined && exclusion.until <= now) {
466
+ exclusions.delete(id)
467
+ back.push(id)
468
+ }
469
+ }
470
+ return back
471
+ }
472
+
473
+ const freeFor = (spec: ExecutorSpec, role: Role): boolean => {
474
+ const used = usage.get(spec.id) ?? { busy: 0, busyCoders: 0 }
475
+ if (used.busy >= slotsOf(spec)) {
476
+ return false
477
+ }
478
+ return role !== "coder" || used.busyCoders < coderSlotsOf(spec)
479
+ }
480
+
481
+ const eligible = (role: Role, avoid: ReadonlyArray<string>): ReadonlyArray<ExecutorSpec> =>
482
+ executors.filter(
483
+ (spec) => hasRole(spec, role) && !avoid.includes(spec.id) && !exclusions.has(spec.id)
484
+ )
485
+
486
+ const pick = (role: Role, leaseOptions: LeaseOptions): ExecutorSpec | undefined => {
487
+ const avoid = leaseOptions.avoid ?? []
488
+ const candidates = eligible(role, avoid).filter((spec) => freeFor(spec, role))
489
+ const preferred = candidates.find((spec) => spec.id === leaseOptions.prefer)
490
+ if (preferred !== undefined) {
491
+ return preferred
492
+ }
493
+ return [...candidates].sort(
494
+ (left, right) =>
495
+ priorityOf(left, role) - priorityOf(right, role) ||
496
+ (lastLeased.get(left.id) ?? -1) - (lastLeased.get(right.id) ?? -1)
497
+ )[0]
498
+ }
499
+
500
+ const release = (spec: ExecutorSpec, role: Role): Effect.Effect<void> =>
501
+ lock
502
+ .withPermit(
503
+ Effect.sync(() => {
504
+ const used = usage.get(spec.id)
505
+ if (used !== undefined) {
506
+ used.busy = Math.max(0, used.busy - 1)
507
+ if (role === "coder") {
508
+ used.busyCoders = Math.max(0, used.busyCoders - 1)
509
+ }
510
+ }
511
+ })
512
+ )
513
+ .pipe(Effect.andThen(signal))
514
+
515
+ const leaseOf = (spec: ExecutorSpec, role: Role): Effect.Effect<Lease, never, Scope.Scope> =>
516
+ Effect.gen(function* () {
517
+ const done = yield* Ref.make(false)
518
+ const free = Effect.flatMap(Ref.getAndSet(done, true), (was) =>
519
+ was ? Effect.void : release(spec, role)
520
+ )
521
+ yield* Effect.addFinalizer(() => free)
522
+ return { executor: spec, role, release: free }
523
+ })
524
+
525
+ /** One attempt: expire, pick, take the slot — all under the lock. */
526
+ const attempt = (
527
+ role: Role,
528
+ leaseOptions: LeaseOptions
529
+ ): Effect.Effect<ExecutorSpec | undefined> =>
530
+ Effect.gen(function* () {
531
+ const now = yield* Clock.currentTimeMillis
532
+ const { spec, back } = yield* lock.withPermit(
533
+ Effect.sync(() => {
534
+ const back = expire(now)
535
+ const spec = pick(role, leaseOptions)
536
+ if (spec !== undefined) {
537
+ const used = usage.get(spec.id)
538
+ if (used !== undefined) {
539
+ used.busy += 1
540
+ if (role === "coder") {
541
+ used.busyCoders += 1
542
+ }
543
+ }
544
+ leases += 1
545
+ lastLeased.set(spec.id, leases)
546
+ }
547
+ return { spec, back }
548
+ })
549
+ )
550
+ if (back.length > 0) {
551
+ yield* say(`${back.join(", ")} back in the round`)
552
+ yield* persist
553
+ }
554
+ return spec
555
+ })
556
+
557
+ const canEverServe = (role: Role, avoid: ReadonlyArray<string> = []): Effect.Effect<boolean> =>
558
+ lock.withPermit(
559
+ Effect.sync(() =>
560
+ executors.some(
561
+ (spec) =>
562
+ hasRole(spec, role) &&
563
+ !avoid.includes(spec.id) &&
564
+ exclusions.get(spec.id)?.kind !== "run"
565
+ )
566
+ )
567
+ )
568
+
569
+ /** Probes every `health` exclusion whose next probe is due; a live one comes back. */
570
+ const probeDue: Effect.Effect<void> = Effect.gen(function* () {
571
+ const probe = options.probe
572
+ const now = yield* Clock.currentTimeMillis
573
+ const due = yield* lock.withPermit(
574
+ Effect.sync(() =>
575
+ [...exclusions.values()].filter(
576
+ (exclusion) => exclusion.kind === "health" && (exclusion.until ?? 0) <= now
577
+ )
578
+ )
579
+ )
580
+ for (const exclusion of due) {
581
+ const spec = executors.find((candidate) => candidate.id === exclusion.id)
582
+ const url = spec?.health
583
+ const up =
584
+ probe === undefined || url === undefined
585
+ ? // Nothing to probe: the outage cooldown decides, measured from now.
586
+ false
587
+ : yield* probe(url)
588
+ yield* lock.withPermit(
589
+ Effect.sync(() => {
590
+ if (up) {
591
+ exclusions.delete(exclusion.id)
592
+ } else if (probe === undefined || url === undefined) {
593
+ exclusions.set(
594
+ exclusion.id,
595
+ Exclusion.make({
596
+ ...exclusion,
597
+ kind: "until",
598
+ until:
599
+ now +
600
+ Duration.toMillis(spec === undefined ? Duration.minutes(5) : outageCooldown(spec))
601
+ })
602
+ )
603
+ } else {
604
+ exclusions.set(
605
+ exclusion.id,
606
+ Exclusion.make({ ...exclusion, until: now + Duration.toMillis(healthProbeInterval) })
607
+ )
608
+ }
609
+ })
610
+ )
611
+ if (up) {
612
+ yield* say(`${exclusion.id} answers its health check again; back in the round`)
613
+ yield* persist
614
+ yield* signal
615
+ }
616
+ }
617
+ })
618
+
619
+ /** Sleeps until something that could free a slot happens. */
620
+ const waitForChange = (role: Role): Effect.Effect<void> =>
621
+ Effect.gen(function* () {
622
+ const wake = yield* Ref.get(changed)
623
+ const now = yield* Clock.currentTimeMillis
624
+ const deadlines = yield* lock.withPermit(
625
+ Effect.sync(() =>
626
+ [...exclusions.values()]
627
+ .filter((exclusion) => {
628
+ const spec = executors.find((candidate) => candidate.id === exclusion.id)
629
+ return spec !== undefined && hasRole(spec, role) && exclusion.kind !== "run"
630
+ })
631
+ .flatMap((exclusion) =>
632
+ exclusion.until === undefined ? [] : [Math.max(0, exclusion.until - now)]
633
+ )
634
+ )
635
+ )
636
+ const soonest = deadlines.length === 0 ? undefined : Math.min(...deadlines)
637
+ yield* soonest === undefined
638
+ ? Deferred.await(wake)
639
+ : Effect.raceFirst(Deferred.await(wake), Effect.sleep(Duration.millis(soonest)))
640
+ })
641
+
642
+ const waitingReport = (role: Role, avoid: ReadonlyArray<string>): Effect.Effect<string> =>
643
+ lock.withPermit(
644
+ Effect.sync(() =>
645
+ executors
646
+ .filter((spec) => hasRole(spec, role) && !avoid.includes(spec.id))
647
+ .map((spec) => {
648
+ const exclusion = exclusions.get(spec.id)
649
+ return exclusion === undefined
650
+ ? `${spec.id} busy`
651
+ : `${spec.id} ${describeExclusion(exclusion)}`
652
+ })
653
+ .join("; ")
654
+ )
655
+ )
656
+
657
+ const lease = (
658
+ role: Role,
659
+ leaseOptions: LeaseOptions = {}
660
+ ): Effect.Effect<Lease, RosterExhausted, Scope.Scope> =>
661
+ Effect.gen(function* () {
662
+ const avoid = leaseOptions.avoid ?? []
663
+ let announced = false
664
+ while (true) {
665
+ const spec = yield* attempt(role, leaseOptions)
666
+ if (spec !== undefined) {
667
+ yield* say(
668
+ `${spec.id} takes ${role}${leaseOptions.label === undefined ? "" : ` for ${leaseOptions.label}`}`
669
+ )
670
+ return yield* leaseOf(spec, role)
671
+ }
672
+ if (!(yield* canEverServe(role, avoid))) {
673
+ const reasons = yield* lock.withPermit(
674
+ Effect.sync(() =>
675
+ executors
676
+ .filter((spec) => hasRole(spec, role) && !avoid.includes(spec.id))
677
+ .map((spec) => {
678
+ const exclusion = exclusions.get(spec.id)
679
+ return `${spec.id} ${exclusion === undefined ? "unavailable" : describeExclusion(exclusion)}`
680
+ })
681
+ )
682
+ )
683
+ return yield* RosterExhausted.make({
684
+ role,
685
+ reasons: reasons.length === 0 ? ["no executor in the roster takes this role"] : reasons
686
+ })
687
+ }
688
+ if (!announced) {
689
+ announced = true
690
+ yield* say(
691
+ `waiting for an executor to take ${role}${leaseOptions.label === undefined ? "" : ` for ${leaseOptions.label}`}: ${yield* waitingReport(role, avoid)}`
692
+ )
693
+ }
694
+ yield* probeDue
695
+ const recovered = yield* attempt(role, leaseOptions)
696
+ if (recovered !== undefined) {
697
+ yield* say(
698
+ `${recovered.id} takes ${role}${leaseOptions.label === undefined ? "" : ` for ${leaseOptions.label}`}`
699
+ )
700
+ return yield* leaseOf(recovered, role)
701
+ }
702
+ const probing = yield* lock.withPermit(
703
+ Effect.sync(() =>
704
+ [...exclusions.values()].some((exclusion) => exclusion.kind === "health")
705
+ )
706
+ )
707
+ yield* probing
708
+ ? Effect.raceFirst(waitForChange(role), Effect.sleep(healthProbeInterval))
709
+ : waitForChange(role)
710
+ }
711
+ })
712
+
713
+ const tryLease = (
714
+ role: Role,
715
+ leaseOptions: LeaseOptions = {}
716
+ ): Effect.Effect<Lease | undefined, never, Scope.Scope> =>
717
+ Effect.flatMap(attempt(role, leaseOptions), (spec) =>
718
+ spec === undefined ? Effect.succeed(undefined) : leaseOf(spec, role)
719
+ )
720
+
721
+ const exclude = (exclusion: Exclusion): Effect.Effect<void> =>
722
+ Effect.gen(function* () {
723
+ if (!known.has(exclusion.id)) {
724
+ return
725
+ }
726
+ yield* lock.withPermit(Effect.sync(() => exclusions.set(exclusion.id, exclusion)))
727
+ yield* say(`${exclusion.id} out of the round ${describeExclusion(exclusion)}`)
728
+ yield* persist
729
+ yield* signal
730
+ })
731
+
732
+ const report = (id: string, error: LlmError): Effect.Effect<Exclusion | undefined> =>
733
+ Effect.gen(function* () {
734
+ const spec = executors.find((candidate) => candidate.id === id)
735
+ if (spec === undefined) {
736
+ return undefined
737
+ }
738
+ const now = yield* Clock.currentTimeMillis
739
+ const windowStart = now - Duration.toMillis(rateLimitWindow)
740
+ const recent =
741
+ error._tag === "RateLimitError"
742
+ ? yield* lock.withPermit(
743
+ Effect.sync(() => {
744
+ const kept = [...(rateLimits.get(id) ?? []).filter((at) => at > windowStart), now]
745
+ rateLimits.set(id, kept)
746
+ return kept.length
747
+ })
748
+ )
749
+ : 0
750
+ const exclusion = exclusionFor(error, spec, now, recent)
751
+ if (exclusion !== undefined) {
752
+ if (error._tag === "RateLimitError") {
753
+ yield* lock.withPermit(Effect.sync(() => rateLimits.delete(id)))
754
+ }
755
+ yield* exclude(exclusion)
756
+ }
757
+ return exclusion
758
+ })
759
+
760
+ const resume = (id: string): Effect.Effect<void> =>
761
+ Effect.gen(function* () {
762
+ const removed = yield* lock.withPermit(Effect.sync(() => exclusions.delete(id)))
763
+ if (removed) {
764
+ yield* say(`${id} resumed`)
765
+ yield* persist
766
+ yield* signal
767
+ }
768
+ })
769
+
770
+ const available = (role: Role): Effect.Effect<number> =>
771
+ Effect.gen(function* () {
772
+ const now = yield* Clock.currentTimeMillis
773
+ return yield* lock.withPermit(
774
+ Effect.sync(() => {
775
+ expire(now)
776
+ return eligible(role, []).reduce((sum, spec) => {
777
+ const used = usage.get(spec.id) ?? { busy: 0, busyCoders: 0 }
778
+ const free =
779
+ role === "coder"
780
+ ? Math.min(slotsOf(spec) - used.busy, coderSlotsOf(spec) - used.busyCoders)
781
+ : slotsOf(spec) - used.busy
782
+ return sum + Math.max(0, free)
783
+ }, 0)
784
+ })
785
+ )
786
+ })
787
+
788
+ const slots = (role: Role): number =>
789
+ executors
790
+ .filter((spec) => hasRole(spec, role))
791
+ .reduce((sum, spec) => sum + (role === "coder" ? coderSlotsOf(spec) : slotsOf(spec)), 0)
792
+
793
+ const snapshot: Effect.Effect<ReadonlyArray<ExecutorStatus>> = Effect.gen(function* () {
794
+ const now = yield* Clock.currentTimeMillis
795
+ return yield* lock.withPermit(
796
+ Effect.sync(() => {
797
+ expire(now)
798
+ return executors.map((spec) => {
799
+ const used = usage.get(spec.id) ?? { busy: 0, busyCoders: 0 }
800
+ const exclusion = exclusions.get(spec.id)
801
+ return {
802
+ executor: spec,
803
+ busy: used.busy,
804
+ busyCoders: used.busyCoders,
805
+ ...(exclusion === undefined ? {} : { exclusion })
806
+ }
807
+ })
808
+ })
809
+ )
810
+ })
811
+
812
+ return {
813
+ executors,
814
+ lease,
815
+ tryLease,
816
+ canEverServe,
817
+ available,
818
+ slots,
819
+ report,
820
+ exclude,
821
+ resume,
822
+ snapshot
823
+ }
824
+ })