@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
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
|
+
})
|