@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.
Files changed (65) hide show
  1. package/dist/Activity.d.ts +6 -1
  2. package/dist/Activity.d.ts.map +1 -1
  3. package/dist/Activity.js +53 -5
  4. package/dist/Activity.js.map +1 -1
  5. package/dist/BoardSync.d.ts +3 -0
  6. package/dist/BoardSync.d.ts.map +1 -1
  7. package/dist/BoardSync.js +14 -0
  8. package/dist/BoardSync.js.map +1 -1
  9. package/dist/CostReport.d.ts +14 -1
  10. package/dist/CostReport.d.ts.map +1 -1
  11. package/dist/CostReport.js +43 -6
  12. package/dist/CostReport.js.map +1 -1
  13. package/dist/Flow.d.ts +26 -1
  14. package/dist/Flow.d.ts.map +1 -1
  15. package/dist/Flow.js +155 -133
  16. package/dist/Flow.js.map +1 -1
  17. package/dist/FlowContext.d.ts +5 -0
  18. package/dist/FlowContext.d.ts.map +1 -1
  19. package/dist/FlowContext.js.map +1 -1
  20. package/dist/FlowEvents.d.ts +69 -5
  21. package/dist/FlowEvents.d.ts.map +1 -1
  22. package/dist/FlowEvents.js +92 -6
  23. package/dist/FlowEvents.js.map +1 -1
  24. package/dist/Perimeter.d.ts +7 -0
  25. package/dist/Perimeter.d.ts.map +1 -1
  26. package/dist/Perimeter.js +31 -0
  27. package/dist/Perimeter.js.map +1 -1
  28. package/dist/Plan.d.ts +24 -0
  29. package/dist/Plan.d.ts.map +1 -1
  30. package/dist/Plan.js +52 -0
  31. package/dist/Plan.js.map +1 -1
  32. package/dist/PlanExecution.d.ts +39 -2
  33. package/dist/PlanExecution.d.ts.map +1 -1
  34. package/dist/PlanExecution.js +137 -3
  35. package/dist/PlanExecution.js.map +1 -1
  36. package/dist/Roster.d.ts +4 -2
  37. package/dist/Roster.d.ts.map +1 -1
  38. package/dist/Roster.js +50 -28
  39. package/dist/Roster.js.map +1 -1
  40. package/dist/RosterSeats.d.ts +11 -2
  41. package/dist/RosterSeats.d.ts.map +1 -1
  42. package/dist/RosterSeats.js +12 -5
  43. package/dist/RosterSeats.js.map +1 -1
  44. package/dist/Stories.d.ts +7 -0
  45. package/dist/Stories.d.ts.map +1 -1
  46. package/dist/Stories.js +157 -13
  47. package/dist/Stories.js.map +1 -1
  48. package/dist/Transcript.d.ts +4 -0
  49. package/dist/Transcript.d.ts.map +1 -1
  50. package/dist/Transcript.js +4 -0
  51. package/dist/Transcript.js.map +1 -1
  52. package/package.json +2 -2
  53. package/src/Activity.ts +73 -7
  54. package/src/BoardSync.ts +20 -0
  55. package/src/CostReport.ts +51 -6
  56. package/src/Flow.ts +108 -34
  57. package/src/FlowContext.ts +5 -0
  58. package/src/FlowEvents.ts +103 -6
  59. package/src/Perimeter.ts +36 -0
  60. package/src/Plan.ts +71 -0
  61. package/src/PlanExecution.ts +233 -4
  62. package/src/Roster.ts +60 -34
  63. package/src/RosterSeats.ts +31 -7
  64. package/src/Stories.ts +230 -17
  65. 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))
@@ -1,8 +1,20 @@
1
1
  import * as Effect from "effect/Effect"
2
- import { StageCompleted, StageFailed, StageStarted, type FlowEventsShape } from "./FlowEvents.ts"
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 { Plan, Task } from "./Plan.ts"
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
- for (const task of plan.tasks) {
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 `slots - 1` when the executor also reasons. */
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: one stays free for reasoning on an executor that does both. */
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
- if (spec.coderSlots !== undefined) {
122
- return Math.max(0, Math.min(slots, Math.floor(spec.coderSlots)))
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
- spec: ExecutorSpec,
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({ executor: spec.id, role, ...labelled, ...purposeOf(who), ...own })
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 { spec, back } = yield* lock.withPermit(
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 !== undefined) {
695
- const used = usage.get(spec.id)
696
- if (used !== undefined) {
697
- used.busy += 1
698
- if (role === "coder") {
699
- used.busyCoders += 1
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
- return { spec, back }
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 spec
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.
@@ -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 lease = yield* roster
307
- .lease("coder", {
308
- ...(preferred === undefined ? {} : { prefer: preferred }),
309
- ...(options.label === undefined ? {} : { label: options.label })
310
- })
311
- .pipe(Scope.provide(scope))
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
  }