@llm4ts/flow 2.40.1 → 2.42.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/Activity.d.ts +6 -1
- package/dist/Activity.d.ts.map +1 -1
- package/dist/Activity.js +53 -5
- package/dist/Activity.js.map +1 -1
- package/dist/BoardSync.d.ts +3 -0
- package/dist/BoardSync.d.ts.map +1 -1
- package/dist/BoardSync.js +14 -0
- package/dist/BoardSync.js.map +1 -1
- package/dist/CostReport.d.ts +14 -1
- package/dist/CostReport.d.ts.map +1 -1
- package/dist/CostReport.js +43 -6
- package/dist/CostReport.js.map +1 -1
- package/dist/Flow.d.ts +26 -1
- package/dist/Flow.d.ts.map +1 -1
- package/dist/Flow.js +155 -133
- package/dist/Flow.js.map +1 -1
- package/dist/FlowContext.d.ts +5 -0
- package/dist/FlowContext.d.ts.map +1 -1
- package/dist/FlowContext.js.map +1 -1
- package/dist/FlowEvents.d.ts +69 -5
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +92 -6
- package/dist/FlowEvents.js.map +1 -1
- package/dist/Perimeter.d.ts +7 -0
- package/dist/Perimeter.d.ts.map +1 -1
- package/dist/Perimeter.js +31 -0
- package/dist/Perimeter.js.map +1 -1
- package/dist/Plan.d.ts +24 -0
- package/dist/Plan.d.ts.map +1 -1
- package/dist/Plan.js +52 -0
- package/dist/Plan.js.map +1 -1
- package/dist/PlanExecution.d.ts +39 -2
- package/dist/PlanExecution.d.ts.map +1 -1
- package/dist/PlanExecution.js +137 -3
- package/dist/PlanExecution.js.map +1 -1
- package/dist/Roster.d.ts +4 -2
- package/dist/Roster.d.ts.map +1 -1
- package/dist/Roster.js +50 -28
- package/dist/Roster.js.map +1 -1
- package/dist/RosterSeats.d.ts +11 -2
- package/dist/RosterSeats.d.ts.map +1 -1
- package/dist/RosterSeats.js +12 -5
- package/dist/RosterSeats.js.map +1 -1
- package/dist/Stories.d.ts +7 -0
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +157 -13
- package/dist/Stories.js.map +1 -1
- package/dist/Transcript.d.ts +4 -0
- package/dist/Transcript.d.ts.map +1 -1
- package/dist/Transcript.js +4 -0
- package/dist/Transcript.js.map +1 -1
- package/package.json +2 -2
- package/src/Activity.ts +73 -7
- package/src/BoardSync.ts +20 -0
- package/src/CostReport.ts +51 -6
- package/src/Flow.ts +108 -34
- package/src/FlowContext.ts +5 -0
- package/src/FlowEvents.ts +103 -6
- package/src/Perimeter.ts +36 -0
- package/src/Plan.ts +71 -0
- package/src/PlanExecution.ts +233 -4
- package/src/Roster.ts +60 -34
- package/src/RosterSeats.ts +31 -7
- package/src/Stories.ts +230 -17
- package/src/Transcript.ts +6 -0
package/src/Plan.ts
CHANGED
|
@@ -120,3 +120,74 @@ export const parsePlan = Effect.fn("@llm4ts/flow/Plan.parse")(function* (
|
|
|
120
120
|
...(brief === undefined ? {} : { brief })
|
|
121
121
|
})
|
|
122
122
|
})
|
|
123
|
+
|
|
124
|
+
// ---- The task graph (ADR 0034) ------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The tasks a task waits for, from its description's `Depends on:` line
|
|
128
|
+
* (`1, 3`, or `none`). Without the line it waits for every earlier task, so
|
|
129
|
+
* a plan written before ADR 0034 runs in order. Only earlier tasks count: a
|
|
130
|
+
* forward or self reference is dropped, so the graph never has a cycle, and
|
|
131
|
+
* a line left naming none of them is read as no line.
|
|
132
|
+
*/
|
|
133
|
+
export const dependsOnOf = (description: string, index: number): ReadonlyArray<number> => {
|
|
134
|
+
const earlier = Array.from({ length: Math.max(0, index - 1) }, (_, position) => position + 1)
|
|
135
|
+
const match = /\bDepends on:\s*([^\n.]*)/iu.exec(description)
|
|
136
|
+
if (match === null) {
|
|
137
|
+
return earlier
|
|
138
|
+
}
|
|
139
|
+
const text = match[1] ?? ""
|
|
140
|
+
if (/^\s*none\b/iu.test(text)) {
|
|
141
|
+
return []
|
|
142
|
+
}
|
|
143
|
+
const named = [
|
|
144
|
+
...new Set(
|
|
145
|
+
text
|
|
146
|
+
.split(/[^\d]+/u)
|
|
147
|
+
.filter((part) => part.length > 0)
|
|
148
|
+
.map((part) => Number.parseInt(part, 10))
|
|
149
|
+
.filter((number) => Number.isInteger(number) && number >= 1 && number < index)
|
|
150
|
+
)
|
|
151
|
+
]
|
|
152
|
+
return named.length === 0 ? earlier : named
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The paths a task says it changes, from its `Owns:` line; empty when it
|
|
157
|
+
* says none. The line ends at a newline, the next label, or a period that
|
|
158
|
+
* ends the sentence (the planner writes one-line JSON descriptions).
|
|
159
|
+
*/
|
|
160
|
+
export const ownsOf = (description: string): ReadonlyArray<string> => {
|
|
161
|
+
const match = /\bOwns:\s*(.*?)(?=\s+(?:Depends on|Satisfies):|\.\s|\.$|\n|$)/iu.exec(description)
|
|
162
|
+
return match === null
|
|
163
|
+
? []
|
|
164
|
+
: (match[1] ?? "")
|
|
165
|
+
.split(/[,\s]+/u)
|
|
166
|
+
.map((path) =>
|
|
167
|
+
path
|
|
168
|
+
.trim()
|
|
169
|
+
.replace(/^`|`$/gu, "")
|
|
170
|
+
.replace(/[.;]+$/u, "")
|
|
171
|
+
)
|
|
172
|
+
.filter((path) => path.length > 0 && path.toLowerCase() !== "none")
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** One task of a plan as the scheduler sees it: its 1-based index and what it waits for. */
|
|
176
|
+
export interface TaskNode {
|
|
177
|
+
readonly index: number
|
|
178
|
+
readonly task: Task
|
|
179
|
+
readonly dependsOn: ReadonlyArray<number>
|
|
180
|
+
readonly owns: ReadonlyArray<string>
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export const taskGraph = (plan: Plan): ReadonlyArray<TaskNode> =>
|
|
184
|
+
plan.tasks.map((task, position) => ({
|
|
185
|
+
index: position + 1,
|
|
186
|
+
task,
|
|
187
|
+
dependsOn: dependsOnOf(task.description, position + 1),
|
|
188
|
+
owns: ownsOf(task.description)
|
|
189
|
+
}))
|
|
190
|
+
|
|
191
|
+
/** Whether some task of the plan may start without the task right before it. */
|
|
192
|
+
export const hasParallelTasks = (plan: Plan): boolean =>
|
|
193
|
+
taskGraph(plan).some((node) => node.index > 1 && !node.dependsOn.includes(node.index - 1))
|
package/src/PlanExecution.ts
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import * as Effect from "effect/Effect"
|
|
2
|
-
import
|
|
2
|
+
import * as Exit from "effect/Exit"
|
|
3
|
+
import * as Fiber from "effect/Fiber"
|
|
4
|
+
import * as Queue from "effect/Queue"
|
|
5
|
+
import * as Semaphore from "effect/Semaphore"
|
|
6
|
+
import {
|
|
7
|
+
StageCompleted,
|
|
8
|
+
StageFailed,
|
|
9
|
+
StageStarted,
|
|
10
|
+
TaskCompleted,
|
|
11
|
+
TaskStarted,
|
|
12
|
+
TasksPlanned,
|
|
13
|
+
type FlowEventsShape
|
|
14
|
+
} from "./FlowEvents.ts"
|
|
3
15
|
import { withKindSpan, type SpanOptions } from "./Spans.ts"
|
|
4
|
-
import { describeFlowError, type FlowError } from "./FlowError.ts"
|
|
5
|
-
import type
|
|
16
|
+
import { FlowAborted, describeFlowError, type FlowError } from "./FlowError.ts"
|
|
17
|
+
import { taskGraph, type Plan, type Task, type TaskNode } from "./Plan.ts"
|
|
6
18
|
import type { PlanStoreShape } from "./Persistence.ts"
|
|
7
19
|
|
|
8
20
|
const errorMessage = describeFlowError
|
|
@@ -35,6 +47,26 @@ export const stage = <A, E, R>(
|
|
|
35
47
|
)
|
|
36
48
|
)
|
|
37
49
|
|
|
50
|
+
/** The acceptance criteria a task's description names: `Satisfies: 1, 3` → `[1, 3]` (ADR 0033). */
|
|
51
|
+
export const satisfiesOf = (description: string): ReadonlyArray<number> | undefined => {
|
|
52
|
+
// The planner writes JSON, so the line is usually the description's last sentence.
|
|
53
|
+
const match = /\bSatisfies:\s*([^\n]*)/iu.exec(description)
|
|
54
|
+
if (match === null) {
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
const numbers = (match[1] ?? "")
|
|
58
|
+
.split(/[^\d]+/u)
|
|
59
|
+
.filter((part) => part.length > 0)
|
|
60
|
+
.map((part) => Number.parseInt(part, 10))
|
|
61
|
+
.filter((number) => Number.isInteger(number) && number > 0)
|
|
62
|
+
return numbers.length === 0 ? undefined : numbers
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const withSatisfies = (description: string): { readonly satisfies?: ReadonlyArray<number> } => {
|
|
66
|
+
const satisfies = satisfiesOf(description)
|
|
67
|
+
return satisfies === undefined ? {} : { satisfies }
|
|
68
|
+
}
|
|
69
|
+
|
|
38
70
|
export const implementTaskLoop = Effect.fn("@llm4ts/flow/PlanExecution.implementTaskLoop")(
|
|
39
71
|
function* <E, R>(
|
|
40
72
|
store: PlanStoreShape,
|
|
@@ -44,11 +76,208 @@ export const implementTaskLoop = Effect.fn("@llm4ts/flow/PlanExecution.implement
|
|
|
44
76
|
perTask: (task: Task, planSoFar: Plan) => Effect.Effect<void, E, R>
|
|
45
77
|
): Effect.fn.Return<Plan, E | FlowError, R> {
|
|
46
78
|
let current = plan
|
|
47
|
-
|
|
79
|
+
const count = plan.tasks.length
|
|
80
|
+
yield* events.publish(
|
|
81
|
+
TasksPlanned.make({
|
|
82
|
+
tasks: plan.tasks.map((task) => ({
|
|
83
|
+
title: task.title,
|
|
84
|
+
completed: task.completed,
|
|
85
|
+
...withSatisfies(task.description)
|
|
86
|
+
}))
|
|
87
|
+
})
|
|
88
|
+
)
|
|
89
|
+
for (const [position, task] of plan.tasks.entries()) {
|
|
48
90
|
if (!task.completed) {
|
|
91
|
+
const index = position + 1
|
|
92
|
+
yield* events.publish(
|
|
93
|
+
TaskStarted.make({ index, count, title: task.title, ...withSatisfies(task.description) })
|
|
94
|
+
)
|
|
49
95
|
yield* stage(events, task.title, perTask(task, current))
|
|
50
96
|
current = current.complete(task.title)
|
|
51
97
|
yield* store.save(planPath, current)
|
|
98
|
+
yield* events.publish(TaskCompleted.make({ index, count, title: task.title }))
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return current
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
// ---- Tasks beside the held coder (ADR 0034) -----------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* How a task tried beside the held coder ended: `merged` into the story,
|
|
109
|
+
* `declined` (no free coder, or it could not finish there) or `conflict`
|
|
110
|
+
* (its merge back conflicted). The last two go back to the held coder; a
|
|
111
|
+
* conflicting task runs after the others, on the merged state.
|
|
112
|
+
*/
|
|
113
|
+
export type AsideOutcome = "merged" | "declined" | "conflict"
|
|
114
|
+
|
|
115
|
+
export interface AsideRequest {
|
|
116
|
+
readonly node: TaskNode
|
|
117
|
+
/** The plan's size, for `task n/m`. */
|
|
118
|
+
readonly count: number
|
|
119
|
+
readonly planSoFar: Plan
|
|
120
|
+
/** Runs `effect` while no task runs at home: the merge back into the story. */
|
|
121
|
+
readonly exclusive: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
|
122
|
+
/** Says the task started, on the coder it got; call it once that coder is held. */
|
|
123
|
+
readonly announce: (coder: {
|
|
124
|
+
readonly executor?: string
|
|
125
|
+
readonly clone?: number
|
|
126
|
+
}) => Effect.Effect<void>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface ParallelTasks<E, R> {
|
|
130
|
+
/** Coders one story may hold at once, its own included; 1 runs every task at home. */
|
|
131
|
+
readonly concurrency: number
|
|
132
|
+
readonly aside: (request: AsideRequest) => Effect.Effect<AsideOutcome, E, R>
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A task's run that ended: at home (`undefined`) or aside (its outcome). */
|
|
136
|
+
interface Finished<E> {
|
|
137
|
+
readonly kind: "home" | "aside"
|
|
138
|
+
readonly node: TaskNode
|
|
139
|
+
readonly exit: Exit.Exit<AsideOutcome | undefined, E>
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The task loop over the plan's graph (ADR 0034): the held coder runs one
|
|
144
|
+
* ready task at a time, in plan order, as `implementTaskLoop` does; up to
|
|
145
|
+
* `concurrency - 1` other ready tasks are offered to `aside`, which runs
|
|
146
|
+
* each on another coder and merges it back under `exclusive`. A task is
|
|
147
|
+
* ready when the tasks it depends on are complete; a plan whose every task
|
|
148
|
+
* depends on the one before runs exactly as `implementTaskLoop`.
|
|
149
|
+
*/
|
|
150
|
+
export const implementTaskGraph = Effect.fn("@llm4ts/flow/PlanExecution.implementTaskGraph")(
|
|
151
|
+
function* <E, R, E2, R2>(
|
|
152
|
+
store: PlanStoreShape,
|
|
153
|
+
events: FlowEventsShape,
|
|
154
|
+
planPath: string,
|
|
155
|
+
plan: Plan,
|
|
156
|
+
perTask: (task: Task, planSoFar: Plan) => Effect.Effect<void, E, R>,
|
|
157
|
+
parallel: ParallelTasks<E2, R2>
|
|
158
|
+
): Effect.fn.Return<Plan, E | E2 | FlowError, R | R2> {
|
|
159
|
+
const graph = taskGraph(plan)
|
|
160
|
+
const count = graph.length
|
|
161
|
+
yield* events.publish(
|
|
162
|
+
TasksPlanned.make({
|
|
163
|
+
tasks: plan.tasks.map((task) => ({
|
|
164
|
+
title: task.title,
|
|
165
|
+
completed: task.completed,
|
|
166
|
+
...withSatisfies(task.description)
|
|
167
|
+
}))
|
|
168
|
+
})
|
|
169
|
+
)
|
|
170
|
+
let current = plan
|
|
171
|
+
const done = new Set(graph.filter((node) => node.task.completed).map((node) => node.index))
|
|
172
|
+
/** Tasks that go to the held coder only, and those that wait for every other one. */
|
|
173
|
+
const homeOnly = new Set<number>()
|
|
174
|
+
const last = new Set<number>()
|
|
175
|
+
const running = new Map<number, "home" | "aside">()
|
|
176
|
+
const fibers: Array<Fiber.Fiber<unknown, unknown>> = []
|
|
177
|
+
const finished = yield* Queue.unbounded<Finished<E | E2>>()
|
|
178
|
+
const home = yield* Semaphore.make(1)
|
|
179
|
+
const exclusive = <A, EX, RX>(effect: Effect.Effect<A, EX, RX>) => home.withPermit(effect)
|
|
180
|
+
|
|
181
|
+
const started = (
|
|
182
|
+
node: TaskNode,
|
|
183
|
+
extra: Partial<Pick<TaskStarted, "parallel" | "executor" | "clone">> = {}
|
|
184
|
+
) =>
|
|
185
|
+
events.publish(
|
|
186
|
+
TaskStarted.make({
|
|
187
|
+
index: node.index,
|
|
188
|
+
count,
|
|
189
|
+
title: node.task.title,
|
|
190
|
+
...withSatisfies(node.task.description),
|
|
191
|
+
...extra
|
|
192
|
+
})
|
|
193
|
+
)
|
|
194
|
+
const complete = (node: TaskNode) =>
|
|
195
|
+
Effect.gen(function* () {
|
|
196
|
+
current = current.complete(node.task.title)
|
|
197
|
+
yield* store.save(planPath, current)
|
|
198
|
+
done.add(node.index)
|
|
199
|
+
yield* events.publish(
|
|
200
|
+
TaskCompleted.make({ index: node.index, count, title: node.task.title })
|
|
201
|
+
)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
while (done.size < count) {
|
|
205
|
+
const ready = graph.filter(
|
|
206
|
+
(node) =>
|
|
207
|
+
!done.has(node.index) &&
|
|
208
|
+
!running.has(node.index) &&
|
|
209
|
+
node.dependsOn.every((index) => done.has(index))
|
|
210
|
+
)
|
|
211
|
+
if (![...running.values()].includes("home")) {
|
|
212
|
+
const pick = ready.find((node) => !last.has(node.index)) ?? ready[0]
|
|
213
|
+
if (pick !== undefined) {
|
|
214
|
+
running.set(pick.index, "home")
|
|
215
|
+
const snapshot = current
|
|
216
|
+
fibers.push(
|
|
217
|
+
yield* Effect.forkChild(
|
|
218
|
+
exclusive(
|
|
219
|
+
Effect.andThen(
|
|
220
|
+
started(pick),
|
|
221
|
+
stage(events, pick.task.title, perTask(pick.task, snapshot))
|
|
222
|
+
)
|
|
223
|
+
).pipe(
|
|
224
|
+
Effect.as(undefined),
|
|
225
|
+
Effect.exit,
|
|
226
|
+
Effect.flatMap((exit) => Queue.offer(finished, { kind: "home", node: pick, exit }))
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (const node of ready) {
|
|
233
|
+
if (running.size >= parallel.concurrency) {
|
|
234
|
+
break
|
|
235
|
+
}
|
|
236
|
+
if (running.has(node.index) || homeOnly.has(node.index)) {
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
running.set(node.index, "aside")
|
|
240
|
+
const request: AsideRequest = {
|
|
241
|
+
node,
|
|
242
|
+
count,
|
|
243
|
+
planSoFar: current,
|
|
244
|
+
exclusive,
|
|
245
|
+
announce: (coder) =>
|
|
246
|
+
started(node, {
|
|
247
|
+
parallel: true,
|
|
248
|
+
...(coder.executor === undefined ? {} : { executor: coder.executor }),
|
|
249
|
+
...(coder.clone === undefined ? {} : { clone: coder.clone })
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
fibers.push(
|
|
253
|
+
yield* Effect.forkChild(
|
|
254
|
+
parallel.aside(request).pipe(
|
|
255
|
+
Effect.exit,
|
|
256
|
+
Effect.flatMap((exit) => Queue.offer(finished, { kind: "aside", node, exit }))
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
if (running.size === 0) {
|
|
262
|
+
return yield* FlowAborted.make({
|
|
263
|
+
message: `plan ${plan.epicId}: no task can start, though ${count - done.size} are not done`
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
const next = yield* Queue.take(finished)
|
|
267
|
+
running.delete(next.node.index)
|
|
268
|
+
if (Exit.isFailure(next.exit)) {
|
|
269
|
+
yield* Fiber.interruptAll(fibers)
|
|
270
|
+
return yield* Effect.failCause(next.exit.cause)
|
|
271
|
+
}
|
|
272
|
+
if (next.kind === "home") {
|
|
273
|
+
yield* complete(next.node)
|
|
274
|
+
} else if (next.exit.value === "merged") {
|
|
275
|
+
yield* complete(next.node)
|
|
276
|
+
} else {
|
|
277
|
+
homeOnly.add(next.node.index)
|
|
278
|
+
if (next.exit.value === "conflict") {
|
|
279
|
+
last.add(next.node.index)
|
|
280
|
+
}
|
|
52
281
|
}
|
|
53
282
|
}
|
|
54
283
|
return current
|
package/src/Roster.ts
CHANGED
|
@@ -55,7 +55,7 @@ export class ExecutorSpec extends Schema.Class<ExecutorSpec>("ExecutorSpec")({
|
|
|
55
55
|
baseUrl: Schema.optionalKey(Schema.String),
|
|
56
56
|
roles: Schema.Array(Schema.String),
|
|
57
57
|
slots: Schema.optionalKey(Schema.Number),
|
|
58
|
-
/** Slots coders may use; default
|
|
58
|
+
/** Slots coders may use; default: every slot (ADR 0033). */
|
|
59
59
|
coderSlots: Schema.optionalKey(Schema.Number),
|
|
60
60
|
/** Lower first. A number for every role, or per role with a `default`. */
|
|
61
61
|
priority: Schema.optionalKey(
|
|
@@ -105,8 +105,6 @@ export class RosterState extends Schema.Class<RosterState>("RosterState")({
|
|
|
105
105
|
|
|
106
106
|
// ---- Pure helpers -------------------------------------------------------------
|
|
107
107
|
|
|
108
|
-
const reasoningRoles: ReadonlyArray<Role> = ["planner", "reviewer", "judge", "verifier"]
|
|
109
|
-
|
|
110
108
|
export const isRole = (value: string): value is Role => roles.some((role) => role === value)
|
|
111
109
|
|
|
112
110
|
export const rolesOf = (spec: ExecutorSpec): ReadonlyArray<Role> => spec.roles.filter(isRole)
|
|
@@ -115,14 +113,12 @@ export const hasRole = (spec: ExecutorSpec, role: Role): boolean => rolesOf(spec
|
|
|
115
113
|
|
|
116
114
|
export const slotsOf = (spec: ExecutorSpec): number => Math.max(1, Math.floor(spec.slots ?? 1))
|
|
117
115
|
|
|
118
|
-
/** Slots coders may hold:
|
|
116
|
+
/** Slots coders may hold: every clone, unless the roster sets `coderSlots` lower (ADR 0033). */
|
|
119
117
|
export const coderSlotsOf = (spec: ExecutorSpec): number => {
|
|
120
118
|
const slots = slotsOf(spec)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const reasons = rolesOf(spec).some((role) => reasoningRoles.includes(role))
|
|
125
|
-
return hasRole(spec, "coder") && reasons && slots > 1 ? slots - 1 : slots
|
|
119
|
+
return spec.coderSlots === undefined
|
|
120
|
+
? slots
|
|
121
|
+
: Math.max(0, Math.min(slots, Math.floor(spec.coderSlots)))
|
|
126
122
|
}
|
|
127
123
|
|
|
128
124
|
/** The effort a role runs at on this executor (ADR 0029); absent is the harness's default. */
|
|
@@ -419,6 +415,8 @@ export interface Lease {
|
|
|
419
415
|
readonly role: Role
|
|
420
416
|
/** Frees the slot; idempotent, and also run when the lease's scope closes. */
|
|
421
417
|
readonly release: Effect.Effect<void>
|
|
418
|
+
/** Which of the executor's clones this is, from 1; absent on a borrowed lease (ADR 0033). */
|
|
419
|
+
readonly clone: number | undefined
|
|
422
420
|
/** Took no slot: the context's own coder executor serves the call (see `LeaseOptions.borrow`). */
|
|
423
421
|
readonly borrowed: boolean
|
|
424
422
|
/** Set when the executor is the context's own coder (not independent), and why; see `ExecutorLeased.because`. */
|
|
@@ -522,6 +520,12 @@ interface Usage {
|
|
|
522
520
|
busyCoders: number
|
|
523
521
|
}
|
|
524
522
|
|
|
523
|
+
/** A slot taken under the lock: the executor and which of its clones. */
|
|
524
|
+
interface Taken {
|
|
525
|
+
readonly spec: ExecutorSpec
|
|
526
|
+
readonly clone: number
|
|
527
|
+
}
|
|
528
|
+
|
|
525
529
|
export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
526
530
|
options: RosterOptions
|
|
527
531
|
): Effect.fn.Return<RosterShape, FlowError> {
|
|
@@ -534,6 +538,21 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
534
538
|
const rateLimits = new Map<string, ReadonlyArray<number>>()
|
|
535
539
|
const lastLeased = new Map<string, number>()
|
|
536
540
|
let leases = 0
|
|
541
|
+
/** Clone numbers in use per executor; a lease takes the lowest free one (ADR 0033). */
|
|
542
|
+
const clones = new Map<string, Set<number>>(executors.map((spec) => [spec.id, new Set<number>()]))
|
|
543
|
+
const takeClone = (spec: ExecutorSpec): number => {
|
|
544
|
+
const used = clones.get(spec.id) ?? new Set<number>()
|
|
545
|
+
let clone = 1
|
|
546
|
+
while (used.has(clone)) {
|
|
547
|
+
clone += 1
|
|
548
|
+
}
|
|
549
|
+
used.add(clone)
|
|
550
|
+
clones.set(spec.id, used)
|
|
551
|
+
return clone
|
|
552
|
+
}
|
|
553
|
+
const freeClone = (spec: ExecutorSpec, clone: number): void => {
|
|
554
|
+
clones.get(spec.id)?.delete(clone)
|
|
555
|
+
}
|
|
537
556
|
const changed = yield* Ref.make(yield* Deferred.make<void>())
|
|
538
557
|
const known = new Set(executors.map((spec) => spec.id))
|
|
539
558
|
|
|
@@ -611,7 +630,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
611
630
|
: best
|
|
612
631
|
}
|
|
613
632
|
|
|
614
|
-
const release = (spec: ExecutorSpec, role: Role): Effect.Effect<void> =>
|
|
633
|
+
const release = (spec: ExecutorSpec, role: Role, clone: number): Effect.Effect<void> =>
|
|
615
634
|
lock
|
|
616
635
|
.withPermit(
|
|
617
636
|
Effect.sync(() => {
|
|
@@ -622,33 +641,42 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
622
641
|
used.busyCoders = Math.max(0, used.busyCoders - 1)
|
|
623
642
|
}
|
|
624
643
|
}
|
|
644
|
+
freeClone(spec, clone)
|
|
625
645
|
})
|
|
626
646
|
)
|
|
627
647
|
.pipe(Effect.andThen(signal))
|
|
628
648
|
|
|
629
649
|
const leaseOf = (
|
|
630
|
-
|
|
650
|
+
taken: Taken,
|
|
631
651
|
role: Role,
|
|
632
652
|
who: LeaseOptions,
|
|
633
653
|
because?: "nobody" | "held" | "out"
|
|
634
654
|
): Effect.Effect<Lease, never, Scope.Scope> =>
|
|
635
655
|
Effect.gen(function* () {
|
|
656
|
+
const { spec, clone } = taken
|
|
636
657
|
const labelled = who.label === undefined ? {} : { label: who.label }
|
|
637
658
|
const own = because === undefined ? {} : { because }
|
|
638
659
|
yield* events.publish(
|
|
639
|
-
ExecutorLeased.make({
|
|
660
|
+
ExecutorLeased.make({
|
|
661
|
+
executor: spec.id,
|
|
662
|
+
role,
|
|
663
|
+
clone,
|
|
664
|
+
...labelled,
|
|
665
|
+
...purposeOf(who),
|
|
666
|
+
...own
|
|
667
|
+
})
|
|
640
668
|
)
|
|
641
669
|
const done = yield* Ref.make(false)
|
|
642
670
|
const free = Effect.flatMap(Ref.getAndSet(done, true), (was) =>
|
|
643
671
|
was
|
|
644
672
|
? Effect.void
|
|
645
673
|
: Effect.andThen(
|
|
646
|
-
release(spec, role),
|
|
647
|
-
events.publish(ExecutorReleased.make({ executor: spec.id, role, ...labelled }))
|
|
674
|
+
release(spec, role, clone),
|
|
675
|
+
events.publish(ExecutorReleased.make({ executor: spec.id, role, clone, ...labelled }))
|
|
648
676
|
)
|
|
649
677
|
)
|
|
650
678
|
yield* Effect.addFinalizer(() => free)
|
|
651
|
-
return { executor: spec, role, release: free, borrowed: false, ...own }
|
|
679
|
+
return { executor: spec, role, clone, release: free, borrowed: false, ...own }
|
|
652
680
|
})
|
|
653
681
|
|
|
654
682
|
/** A lease over the context's own coder executor: no slot taken, none freed. */
|
|
@@ -677,32 +705,30 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
677
705
|
: events.publish(ExecutorReleased.make({ executor: spec.id, role, ...labelled }))
|
|
678
706
|
)
|
|
679
707
|
yield* Effect.addFinalizer(() => free)
|
|
680
|
-
return { executor: spec, role, release: free, borrowed: true, because }
|
|
708
|
+
return { executor: spec, role, clone: undefined, release: free, borrowed: true, because }
|
|
681
709
|
})
|
|
682
710
|
|
|
683
|
-
/** One attempt: expire, pick, take the slot — all under the lock. */
|
|
684
|
-
const attempt = (
|
|
685
|
-
role: Role,
|
|
686
|
-
leaseOptions: LeaseOptions
|
|
687
|
-
): Effect.Effect<ExecutorSpec | undefined> =>
|
|
711
|
+
/** One attempt: expire, pick, take the slot and its clone number — all under the lock. */
|
|
712
|
+
const attempt = (role: Role, leaseOptions: LeaseOptions): Effect.Effect<Taken | undefined> =>
|
|
688
713
|
Effect.gen(function* () {
|
|
689
714
|
const now = yield* Clock.currentTimeMillis
|
|
690
|
-
const {
|
|
715
|
+
const { taken, back } = yield* lock.withPermit(
|
|
691
716
|
Effect.sync(() => {
|
|
692
717
|
const back = expire(now)
|
|
693
718
|
const spec = pick(role, leaseOptions)
|
|
694
|
-
if (spec
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
719
|
+
if (spec === undefined) {
|
|
720
|
+
return { taken: undefined, back }
|
|
721
|
+
}
|
|
722
|
+
const used = usage.get(spec.id)
|
|
723
|
+
if (used !== undefined) {
|
|
724
|
+
used.busy += 1
|
|
725
|
+
if (role === "coder") {
|
|
726
|
+
used.busyCoders += 1
|
|
701
727
|
}
|
|
702
|
-
leases += 1
|
|
703
|
-
lastLeased.set(spec.id, leases)
|
|
704
728
|
}
|
|
705
|
-
|
|
729
|
+
leases += 1
|
|
730
|
+
lastLeased.set(spec.id, leases)
|
|
731
|
+
return { taken: { spec, clone: takeClone(spec) }, back }
|
|
706
732
|
})
|
|
707
733
|
)
|
|
708
734
|
if (back.length > 0) {
|
|
@@ -711,7 +737,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
711
737
|
)
|
|
712
738
|
yield* persist
|
|
713
739
|
}
|
|
714
|
-
return
|
|
740
|
+
return taken
|
|
715
741
|
})
|
|
716
742
|
|
|
717
743
|
const canEverServe = (role: Role, avoid: ReadonlyArray<string> = []): Effect.Effect<boolean> =>
|
|
@@ -880,7 +906,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
880
906
|
prefer: borrow.id
|
|
881
907
|
})
|
|
882
908
|
yield* waited
|
|
883
|
-
return own !== undefined && own.id === borrow.id
|
|
909
|
+
return own !== undefined && own.spec.id === borrow.id
|
|
884
910
|
? yield* leaseOf(own, role, leaseOptions, because)
|
|
885
911
|
: own !== undefined
|
|
886
912
|
? // Someone came back between the check and the pick: independent after all.
|
package/src/RosterSeats.ts
CHANGED
|
@@ -11,7 +11,7 @@ import * as Stream from "effect/Stream"
|
|
|
11
11
|
import { ConfigError, type LlmError } from "@llm4ts/core/Errors"
|
|
12
12
|
import type { LlmServiceShape } from "@llm4ts/core/LlmService"
|
|
13
13
|
import { Message, type ConnectorCapabilities, type LlmChunk } from "@llm4ts/core/Models"
|
|
14
|
-
import { describeFlowError, type FlowError } from "./FlowError.ts"
|
|
14
|
+
import { describeFlowError, type FlowError, RosterExhausted } from "./FlowError.ts"
|
|
15
15
|
import { ExecutorHandedOver, type FlowEventsShape } from "./FlowEvents.ts"
|
|
16
16
|
import { callPurpose } from "./Timing.ts"
|
|
17
17
|
import {
|
|
@@ -48,6 +48,8 @@ export interface RosterView {
|
|
|
48
48
|
readonly capacityChanged: (role: Role, from: number) => Effect.Effect<number>
|
|
49
49
|
/** The executor holding this context's coder, once leased. */
|
|
50
50
|
readonly executor: Effect.Effect<string | undefined>
|
|
51
|
+
/** Which clone of it (ADR 0033), once leased. */
|
|
52
|
+
readonly clone?: Effect.Effect<number | undefined>
|
|
51
53
|
/** Every executor that held this context's coder, in order (handovers). */
|
|
52
54
|
readonly history: Effect.Effect<ReadonlyArray<string>>
|
|
53
55
|
/** The autonomy contract profile an executor's roster entry asks for (ADR 0027), when it does. */
|
|
@@ -253,11 +255,18 @@ export interface HeldCoderOptions {
|
|
|
253
255
|
/** Handovers before a failure surfaces. Default 2. */
|
|
254
256
|
readonly maxHandovers?: number
|
|
255
257
|
readonly label?: string
|
|
258
|
+
/**
|
|
259
|
+
* Lease only a slot that is free now; none fails `RosterExhausted` at
|
|
260
|
+
* once instead of waiting (a task run aside, ADR 0034).
|
|
261
|
+
*/
|
|
262
|
+
readonly ifFree?: boolean
|
|
256
263
|
}
|
|
257
264
|
|
|
258
265
|
export interface HeldCoder {
|
|
259
266
|
readonly service: LlmServiceShape
|
|
260
267
|
readonly executor: Effect.Effect<string | undefined>
|
|
268
|
+
/** The held coder's clone number, when it holds a slot (ADR 0033). */
|
|
269
|
+
readonly clone: Effect.Effect<number | undefined>
|
|
261
270
|
readonly lease: Effect.Effect<ExecutorSpec | undefined>
|
|
262
271
|
readonly history: Effect.Effect<ReadonlyArray<string>>
|
|
263
272
|
}
|
|
@@ -303,12 +312,26 @@ export const makeHeldCoder = Effect.fn("@llm4ts/flow/RosterSeats.heldCoder")(fun
|
|
|
303
312
|
return held
|
|
304
313
|
}
|
|
305
314
|
const preferred = yield* Ref.get(prefer)
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
.
|
|
315
|
+
const leaseOptions = {
|
|
316
|
+
...(preferred === undefined ? {} : { prefer: preferred }),
|
|
317
|
+
...(options.label === undefined ? {} : { label: options.label })
|
|
318
|
+
}
|
|
319
|
+
const lease =
|
|
320
|
+
options.ifFree === true
|
|
321
|
+
? yield* roster.tryLease("coder", leaseOptions).pipe(
|
|
322
|
+
Scope.provide(scope),
|
|
323
|
+
Effect.flatMap((free) =>
|
|
324
|
+
free === undefined
|
|
325
|
+
? Effect.fail(
|
|
326
|
+
RosterExhausted.make({
|
|
327
|
+
role: "coder",
|
|
328
|
+
reasons: ["no coder slot is free now"]
|
|
329
|
+
})
|
|
330
|
+
)
|
|
331
|
+
: Effect.succeed(free)
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
: yield* roster.lease("coder", leaseOptions).pipe(Scope.provide(scope))
|
|
312
335
|
const seat = yield* source.seatFor(lease.executor, "coder", workDir)
|
|
313
336
|
const next = { lease, seat }
|
|
314
337
|
yield* Ref.set(current, next)
|
|
@@ -375,6 +398,7 @@ export const makeHeldCoder = Effect.fn("@llm4ts/flow/RosterSeats.heldCoder")(fun
|
|
|
375
398
|
return {
|
|
376
399
|
service: serviceOver({ run, stream, isAvailable: roster.canEverServe("coder") }),
|
|
377
400
|
executor: Effect.map(Ref.get(current), (held) => held?.lease.executor.id),
|
|
401
|
+
clone: Effect.map(Ref.get(current), (held) => held?.lease.clone),
|
|
378
402
|
lease: Effect.map(Ref.get(current), (held) => held?.lease.executor),
|
|
379
403
|
history: Ref.get(history)
|
|
380
404
|
}
|