@llm4ts/flow 2.40.0 → 2.41.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 (45) 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/FlowEvents.d.ts +65 -5
  14. package/dist/FlowEvents.d.ts.map +1 -1
  15. package/dist/FlowEvents.js +84 -6
  16. package/dist/FlowEvents.js.map +1 -1
  17. package/dist/PlanExecution.d.ts +2 -0
  18. package/dist/PlanExecution.d.ts.map +1 -1
  19. package/dist/PlanExecution.js +31 -2
  20. package/dist/PlanExecution.js.map +1 -1
  21. package/dist/Roster.d.ts +4 -2
  22. package/dist/Roster.d.ts.map +1 -1
  23. package/dist/Roster.js +50 -28
  24. package/dist/Roster.js.map +1 -1
  25. package/dist/RosterSeats.d.ts +2 -0
  26. package/dist/RosterSeats.d.ts.map +1 -1
  27. package/dist/RosterSeats.js +1 -0
  28. package/dist/RosterSeats.js.map +1 -1
  29. package/dist/Stories.d.ts.map +1 -1
  30. package/dist/Stories.js +4 -2
  31. package/dist/Stories.js.map +1 -1
  32. package/dist/Transcript.d.ts +4 -0
  33. package/dist/Transcript.d.ts.map +1 -1
  34. package/dist/Transcript.js +4 -0
  35. package/dist/Transcript.js.map +1 -1
  36. package/package.json +2 -2
  37. package/src/Activity.ts +73 -7
  38. package/src/BoardSync.ts +20 -0
  39. package/src/CostReport.ts +51 -6
  40. package/src/FlowEvents.ts +95 -6
  41. package/src/PlanExecution.ts +45 -2
  42. package/src/Roster.ts +60 -34
  43. package/src/RosterSeats.ts +3 -0
  44. package/src/Stories.ts +4 -2
  45. package/src/Transcript.ts +6 -0
package/src/CostReport.ts CHANGED
@@ -3,7 +3,7 @@ import * as Option from "effect/Option"
3
3
  import * as Schema from "effect/Schema"
4
4
  import type { TokenUsage } from "@llm4ts/core/Models"
5
5
  import { isEstimatedModel } from "./EstimatedUsage.ts"
6
- import { TokensUsed } from "./FlowEvents.ts"
6
+ import { TokensUsed, cloneName } from "./FlowEvents.ts"
7
7
  import type { TraceLine } from "./FlowRecorder.ts"
8
8
  import { estimateCostUsd, PricesAsOf } from "./PriceList.ts"
9
9
 
@@ -31,7 +31,10 @@ export class UsageSample extends Schema.Class<UsageSample>("UsageSample")({
31
31
  total: Schema.Int,
32
32
  cached: Schema.optionalKey(Schema.Int),
33
33
  costUsd: Schema.optionalKey(Schema.Number),
34
- estimated: Schema.Boolean
34
+ estimated: Schema.Boolean,
35
+ /** The lane's executor and clone, when the usage came from a roster lane (ADR 0033). */
36
+ executor: Schema.optionalKey(Schema.String),
37
+ clone: Schema.optionalKey(Schema.Int)
35
38
  }) {}
36
39
 
37
40
  export class UsageTotals extends Schema.Class<UsageTotals>("UsageTotals")({
@@ -87,6 +90,13 @@ export class UsageProjection extends Schema.Class<UsageProjection>("UsageProject
87
90
  costUsdPerDay: Schema.optionalKey(Schema.Number)
88
91
  }) {}
89
92
 
93
+ /** One executor clone's usage (`codex#2`): the coder's calls on a lane that holds that clone. */
94
+ export class ExecutorUsage extends Schema.Class<ExecutorUsage>("ExecutorUsage")({
95
+ executor: Schema.String,
96
+ totals: UsageTotals,
97
+ share: Schema.Number
98
+ }) {}
99
+
90
100
  export class CostReport extends Schema.Class<CostReport>("CostReport")({
91
101
  schemaVersion: Schema.Int,
92
102
  timeZone: Schema.String,
@@ -102,10 +112,12 @@ export class CostReport extends Schema.Class<CostReport>("CostReport")({
102
112
  byDay: Schema.Array(UsageBucket),
103
113
  byHour: Schema.Array(UsageBucket),
104
114
  byModel: Schema.Array(ModelUsage),
105
- byAgent: Schema.Array(AgentUsage)
115
+ byAgent: Schema.Array(AgentUsage),
116
+ /** Per executor clone, for lanes served by a roster (schema 3, ADR 0033). */
117
+ byExecutor: Schema.Array(ExecutorUsage)
106
118
  }) {}
107
119
 
108
- export const CurrentCostReportSchema = 2
120
+ export const CurrentCostReportSchema = 3
109
121
 
110
122
  const tokensUsedCodec = Schema.fromJsonString(TokensUsed)
111
123
  const decodeTokensUsed = Schema.decodeUnknownOption(tokensUsedCodec)
@@ -145,7 +157,9 @@ export const usageSamplesFromTrace = (
145
157
  total: event.usage.total,
146
158
  ...(event.usage.cached === undefined ? {} : { cached: event.usage.cached }),
147
159
  ...(costUsd === undefined ? {} : { costUsd }),
148
- estimated: isEstimatedModel(model)
160
+ estimated: isEstimatedModel(model),
161
+ ...(event.executor === undefined ? {} : { executor: event.executor }),
162
+ ...(event.clone === undefined ? {} : { clone: event.clone })
149
163
  })
150
164
  ]
151
165
  })
@@ -320,6 +334,26 @@ export const buildCostReport = (
320
334
  right.totals.total - left.totals.total || left.agent.localeCompare(right.agent)
321
335
  )
322
336
 
337
+ // Coder clones only: a sample that names the lane's executor without its
338
+ // clone is a reviewer's or judge's, which ran elsewhere (ADR 0033).
339
+ const byExecutorGroups = new Map<string, Array<UsageSample>>()
340
+ for (const sample of ordered) {
341
+ if (sample.executor !== undefined && sample.clone !== undefined) {
342
+ const key = cloneName(sample.executor, sample.clone)
343
+ byExecutorGroups.set(key, [...(byExecutorGroups.get(key) ?? []), sample])
344
+ }
345
+ }
346
+ const byExecutor = [...byExecutorGroups.entries()]
347
+ .map(([executor, members]) => {
348
+ const executorTotals = totalsOf(members)
349
+ return ExecutorUsage.make({
350
+ executor,
351
+ totals: executorTotals,
352
+ share: tokens === 0 ? 0 : executorTotals.total / tokens
353
+ })
354
+ })
355
+ .sort((left, right) => left.executor.localeCompare(right.executor))
356
+
323
357
  return CostReport.make({
324
358
  schemaVersion: CurrentCostReportSchema,
325
359
  timeZone: zoneLabel(timeZone),
@@ -349,7 +383,8 @@ export const buildCostReport = (
349
383
  byDay,
350
384
  byHour,
351
385
  byModel,
352
- byAgent
386
+ byAgent,
387
+ byExecutor
353
388
  })
354
389
  }
355
390
 
@@ -427,6 +462,16 @@ export const renderCostReport = (report: CostReport): string => {
427
462
  )
428
463
  )
429
464
  }
465
+ if (report.byExecutor.length > 0) {
466
+ sections.push(
467
+ "",
468
+ "by executor clone:",
469
+ ...report.byExecutor.map(
470
+ (entry) =>
471
+ ` ${entry.executor} ${thousands(entry.totals.total)} tokens (${Math.round(entry.share * 100)}%)${money(entry.totals.costUsd)}`
472
+ )
473
+ )
474
+ }
430
475
  const notes = [
431
476
  `costs are backend-reported where available, otherwise estimated from the pricing table (rates as of ${report.pricesAsOf} — may be stale)`,
432
477
  ...(report.estimated.requests === 0
package/src/FlowEvents.ts CHANGED
@@ -88,6 +88,8 @@ export class Info extends Schema.TaggedClass<Info>()("Info", {
88
88
  export class ToolUse extends Schema.TaggedClass<ToolUse>()("ToolUse", {
89
89
  tool: Schema.String,
90
90
  args: Schema.String,
91
+ /** The tool call this one runs inside, when a harness delegated it to a sub-agent (ADR 0033). */
92
+ parent: Schema.optionalKey(Schema.String),
91
93
  /** The concurrent unit (a story) this event belongs to; absent for run-wide events. */
92
94
  lane: Schema.optionalKey(Schema.String),
93
95
  /** The roster executor working that lane, when there is one (ADR 0019). */
@@ -109,7 +111,9 @@ export class TokensUsed extends Schema.TaggedClass<TokensUsed>()("TokensUsed", {
109
111
  /** The concurrent unit (a story) this event belongs to; absent for run-wide events. */
110
112
  lane: Schema.optionalKey(Schema.String),
111
113
  /** The roster executor working that lane, when there is one (ADR 0019). */
112
- executor: Schema.optionalKey(Schema.String)
114
+ executor: Schema.optionalKey(Schema.String),
115
+ /** The executor's clone that made the call, when the lane's coder holds a slot (ADR 0033). */
116
+ clone: Schema.optionalKey(Schema.Int)
113
117
  }) {}
114
118
 
115
119
  /** One issue a review round found, as the operator sees it. */
@@ -270,9 +274,54 @@ export class StoryJudged extends Schema.TaggedClass<StoryJudged>()("StoryJudged"
270
274
  * The executor roster's events (ADR 0019, 0022). `label` names who holds the
271
275
  * lease — a story's id, or "the run" — so a view can put an executor on its lane.
272
276
  */
277
+ /** One task of a story's plan as the task loop announces it (ADR 0033). */
278
+ export const PlannedTask = Schema.Struct({
279
+ title: Schema.String,
280
+ completed: Schema.Boolean,
281
+ /** The acceptance criteria the task's description names (`Satisfies: 1, 3`). */
282
+ satisfies: Schema.optionalKey(Schema.Array(Schema.Int))
283
+ })
284
+
285
+ /** The task loop's plan, before its first task: every task in order (ADR 0033). */
286
+ export class TasksPlanned extends Schema.TaggedClass<TasksPlanned>()("TasksPlanned", {
287
+ tasks: Schema.Array(PlannedTask),
288
+ lane: Schema.optionalKey(Schema.String),
289
+ executor: Schema.optionalKey(Schema.String)
290
+ }) {}
291
+
292
+ /** A task begins: its position in the plan, from 1, and the plan's size (ADR 0033). */
293
+ export class TaskStarted extends Schema.TaggedClass<TaskStarted>()("TaskStarted", {
294
+ index: Schema.Int,
295
+ count: Schema.Int,
296
+ title: Schema.String,
297
+ satisfies: Schema.optionalKey(Schema.Array(Schema.Int)),
298
+ lane: Schema.optionalKey(Schema.String),
299
+ executor: Schema.optionalKey(Schema.String)
300
+ }) {}
301
+
302
+ /** A task is ticked in the plan (ADR 0033). */
303
+ export class TaskCompleted extends Schema.TaggedClass<TaskCompleted>()("TaskCompleted", {
304
+ index: Schema.Int,
305
+ count: Schema.Int,
306
+ title: Schema.String,
307
+ lane: Schema.optionalKey(Schema.String),
308
+ executor: Schema.optionalKey(Schema.String)
309
+ }) {}
310
+
311
+ /** A story moved on the epic's board (ADR 0033); the statuses are `BoardSync`'s. */
312
+ export class StoryStatusChanged extends Schema.TaggedClass<StoryStatusChanged>()(
313
+ "StoryStatusChanged",
314
+ {
315
+ id: Schema.String,
316
+ status: Schema.Literals(["planned", "active", "waiting", "done", "failed", "skipped"])
317
+ }
318
+ ) {}
319
+
273
320
  export class ExecutorLeased extends Schema.TaggedClass<ExecutorLeased>()("ExecutorLeased", {
274
321
  executor: Schema.String,
275
322
  role: Schema.String,
323
+ /** Which of the executor's clones took the slot, from 1 (ADR 0033); absent when borrowed or before 2.41. */
324
+ clone: Schema.optionalKey(Schema.Int),
276
325
  label: Schema.optionalKey(Schema.String),
277
326
  /** What the call is for beyond its role: a review lens, a vote. */
278
327
  purpose: Schema.optionalKey(Schema.String),
@@ -290,6 +339,8 @@ export class ExecutorLeased extends Schema.TaggedClass<ExecutorLeased>()("Execut
290
339
  export class ExecutorReleased extends Schema.TaggedClass<ExecutorReleased>()("ExecutorReleased", {
291
340
  executor: Schema.String,
292
341
  role: Schema.String,
342
+ /** Which of the executor's clones freed the slot (ADR 0033); absent when borrowed or before 2.41. */
343
+ clone: Schema.optionalKey(Schema.Int),
293
344
  label: Schema.optionalKey(Schema.String)
294
345
  }) {}
295
346
 
@@ -335,12 +386,16 @@ const resumedWords: Readonly<Record<ExecutorResumed["why"], string>> = {
335
386
  * the roster used to publish as `Info` — or `undefined` for one it does not
336
387
  * show (a release) and for any other event.
337
388
  */
389
+ /** `codex#2` for a numbered clone, the bare executor otherwise (ADR 0033). */
390
+ export const cloneName = (executor: string, clone: number | undefined): string =>
391
+ clone === undefined ? executor : `${executor}#${clone}`
392
+
338
393
  export const rosterEventMessage = (event: FlowEvent): string | undefined => {
339
394
  const forLabel = (label: string | undefined): string =>
340
395
  label === undefined ? "" : ` for ${label}`
341
396
  switch (event._tag) {
342
397
  case "ExecutorLeased": {
343
- const taken = `roster: ${event.executor} takes ${event.role}${forLabel(event.label)}${
398
+ const taken = `roster: ${cloneName(event.executor, event.clone)} takes ${event.role}${forLabel(event.label)}${
344
399
  event.purpose === undefined ? "" : ` · ${event.purpose}`
345
400
  }`
346
401
  if (event.because === undefined && event.borrowed !== true) {
@@ -395,7 +450,11 @@ export const FlowEvent = Schema.Union([
395
450
  ExecutorHandedOver,
396
451
  StoryJudged,
397
452
  Timed,
398
- Began
453
+ Began,
454
+ TasksPlanned,
455
+ TaskStarted,
456
+ TaskCompleted,
457
+ StoryStatusChanged
399
458
  ])
400
459
  export type FlowEvent = typeof FlowEvent.Type
401
460
 
@@ -419,6 +478,8 @@ export interface Lane {
419
478
  readonly lane: string
420
479
  /** The executor working it now; read at every publish, so a handover shows. */
421
480
  readonly executor?: Effect.Effect<string | undefined>
481
+ /** The coder's clone number, read at every publish as `executor` is (ADR 0033). */
482
+ readonly clone?: Effect.Effect<number | undefined>
422
483
  /** The lane's working directory, dropped from tool arguments (the lane already names it). */
423
484
  readonly workDir?: string
424
485
  }
@@ -436,10 +497,33 @@ const stamped = (
436
497
  event: FlowEvent,
437
498
  lane: string,
438
499
  executor: string | undefined,
439
- workDir: string | undefined
500
+ workDir: string | undefined,
501
+ clone: number | undefined
440
502
  ): FlowEvent => {
441
503
  const tags = { lane, ...(executor === undefined ? {} : { executor }) }
504
+ const usageTags = { ...tags, ...(clone === undefined ? {} : { clone }) }
442
505
  switch (event._tag) {
506
+ case "TasksPlanned":
507
+ return event.lane !== undefined ? event : TasksPlanned.make({ tasks: event.tasks, ...tags })
508
+ case "TaskStarted":
509
+ return event.lane !== undefined
510
+ ? event
511
+ : TaskStarted.make({
512
+ index: event.index,
513
+ count: event.count,
514
+ title: event.title,
515
+ ...(event.satisfies === undefined ? {} : { satisfies: event.satisfies }),
516
+ ...tags
517
+ })
518
+ case "TaskCompleted":
519
+ return event.lane !== undefined
520
+ ? event
521
+ : TaskCompleted.make({
522
+ index: event.index,
523
+ count: event.count,
524
+ title: event.title,
525
+ ...tags
526
+ })
443
527
  case "StageStarted":
444
528
  return event.lane !== undefined ? event : StageStarted.make({ stage: event.stage, ...tags })
445
529
  case "StageCompleted":
@@ -456,6 +540,7 @@ const stamped = (
456
540
  : ToolUse.make({
457
541
  tool: event.tool,
458
542
  args: shortenLaneArgs(event.args, workDir),
543
+ ...(event.parent === undefined ? {} : { parent: event.parent }),
459
544
  ...tags
460
545
  })
461
546
  case "AssistantMessage":
@@ -485,7 +570,9 @@ const stamped = (
485
570
  agent: event.agent,
486
571
  usage: event.usage,
487
572
  ...(event.model === undefined ? {} : { model: event.model }),
488
- ...tags
573
+ // The lane's clone is its coder's: a reviewer or judge on the
574
+ // lane's events ran on another executor (ADR 0033).
575
+ ...(event.agent === "coder" ? usageTags : tags)
489
576
  })
490
577
  // The lane's executor is its coder's: it names a coder call or tool, not
491
578
  // a reviewer's or judge's call made on another executor.
@@ -523,7 +610,9 @@ const stamped = (
523
610
  export const withLane = (events: FlowEventsShape, lane: Lane): FlowEventsShape => ({
524
611
  publish: (event) =>
525
612
  Effect.flatMap(lane.executor ?? Effect.succeed(undefined), (executor) =>
526
- events.publish(stamped(event, lane.lane, executor, lane.workDir))
613
+ Effect.flatMap(lane.clone ?? Effect.succeed(undefined), (clone) =>
614
+ events.publish(stamped(event, lane.lane, executor, lane.workDir, clone))
615
+ )
527
616
  )
528
617
  })
529
618
 
@@ -1,5 +1,13 @@
1
1
  import * as Effect from "effect/Effect"
2
- import { StageCompleted, StageFailed, StageStarted, type FlowEventsShape } from "./FlowEvents.ts"
2
+ import {
3
+ StageCompleted,
4
+ StageFailed,
5
+ StageStarted,
6
+ TaskCompleted,
7
+ TaskStarted,
8
+ TasksPlanned,
9
+ type FlowEventsShape
10
+ } from "./FlowEvents.ts"
3
11
  import { withKindSpan, type SpanOptions } from "./Spans.ts"
4
12
  import { describeFlowError, type FlowError } from "./FlowError.ts"
5
13
  import type { Plan, Task } from "./Plan.ts"
@@ -35,6 +43,26 @@ export const stage = <A, E, R>(
35
43
  )
36
44
  )
37
45
 
46
+ /** The acceptance criteria a task's description names: `Satisfies: 1, 3` → `[1, 3]` (ADR 0033). */
47
+ export const satisfiesOf = (description: string): ReadonlyArray<number> | undefined => {
48
+ // The planner writes JSON, so the line is usually the description's last sentence.
49
+ const match = /\bSatisfies:\s*([^\n]*)/iu.exec(description)
50
+ if (match === null) {
51
+ return undefined
52
+ }
53
+ const numbers = (match[1] ?? "")
54
+ .split(/[^\d]+/u)
55
+ .filter((part) => part.length > 0)
56
+ .map((part) => Number.parseInt(part, 10))
57
+ .filter((number) => Number.isInteger(number) && number > 0)
58
+ return numbers.length === 0 ? undefined : numbers
59
+ }
60
+
61
+ const withSatisfies = (description: string): { readonly satisfies?: ReadonlyArray<number> } => {
62
+ const satisfies = satisfiesOf(description)
63
+ return satisfies === undefined ? {} : { satisfies }
64
+ }
65
+
38
66
  export const implementTaskLoop = Effect.fn("@llm4ts/flow/PlanExecution.implementTaskLoop")(
39
67
  function* <E, R>(
40
68
  store: PlanStoreShape,
@@ -44,11 +72,26 @@ export const implementTaskLoop = Effect.fn("@llm4ts/flow/PlanExecution.implement
44
72
  perTask: (task: Task, planSoFar: Plan) => Effect.Effect<void, E, R>
45
73
  ): Effect.fn.Return<Plan, E | FlowError, R> {
46
74
  let current = plan
47
- for (const task of plan.tasks) {
75
+ const count = plan.tasks.length
76
+ yield* events.publish(
77
+ TasksPlanned.make({
78
+ tasks: plan.tasks.map((task) => ({
79
+ title: task.title,
80
+ completed: task.completed,
81
+ ...withSatisfies(task.description)
82
+ }))
83
+ })
84
+ )
85
+ for (const [position, task] of plan.tasks.entries()) {
48
86
  if (!task.completed) {
87
+ const index = position + 1
88
+ yield* events.publish(
89
+ TaskStarted.make({ index, count, title: task.title, ...withSatisfies(task.description) })
90
+ )
49
91
  yield* stage(events, task.title, perTask(task, current))
50
92
  current = current.complete(task.title)
51
93
  yield* store.save(planPath, current)
94
+ yield* events.publish(TaskCompleted.make({ index, count, title: task.title }))
52
95
  }
53
96
  }
54
97
  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.
@@ -258,6 +258,8 @@ export interface HeldCoderOptions {
258
258
  export interface HeldCoder {
259
259
  readonly service: LlmServiceShape
260
260
  readonly executor: Effect.Effect<string | undefined>
261
+ /** The held coder's clone number, when it holds a slot (ADR 0033). */
262
+ readonly clone: Effect.Effect<number | undefined>
261
263
  readonly lease: Effect.Effect<ExecutorSpec | undefined>
262
264
  readonly history: Effect.Effect<ReadonlyArray<string>>
263
265
  }
@@ -375,6 +377,7 @@ export const makeHeldCoder = Effect.fn("@llm4ts/flow/RosterSeats.heldCoder")(fun
375
377
  return {
376
378
  service: serviceOver({ run, stream, isAvailable: roster.canEverServe("coder") }),
377
379
  executor: Effect.map(Ref.get(current), (held) => held?.lease.executor.id),
380
+ clone: Effect.map(Ref.get(current), (held) => held?.lease.clone),
378
381
  lease: Effect.map(Ref.get(current), (held) => held?.lease.executor),
379
382
  history: Ref.get(history)
380
383
  }
package/src/Stories.ts CHANGED
@@ -18,7 +18,7 @@ import type { LlmServiceShape } from "@llm4ts/core/LlmService"
18
18
  import { cap } from "./Context.ts"
19
19
  import type { LlmChunk, TokenUsage } from "@llm4ts/core/Models"
20
20
  import type { LlmError } from "@llm4ts/core/Errors"
21
- import { BoardItem, type BoardSyncShape } from "./BoardSync.ts"
21
+ import { BoardItem, eventedBoard, type BoardSyncShape } from "./BoardSync.ts"
22
22
  import { makeChat } from "./Chat.ts"
23
23
  import { implementPlanFlow } from "./Flow.ts"
24
24
  import {
@@ -873,8 +873,10 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
873
873
  options: StoriesOptions
874
874
  ): Effect.fn.Return<EpicReport, FlowError> {
875
875
  const plan = yield* validateStoryPlan(options.plan)
876
- const { files, board } = options
876
+ const { files } = options
877
877
  const events = context.events
878
+ // Every board move is also an event, so the trace and the dashboard see it (ADR 0033).
879
+ const board = eventedBoard(options.board, events)
878
880
  const epicBranch = options.epicBranch ?? `epic/${plan.epicId}`
879
881
  const concurrency = Math.max(1, options.concurrency ?? 3)
880
882
  const judgeRounds = Math.max(1, options.judgeRounds ?? 2)
package/src/Transcript.ts CHANGED
@@ -22,6 +22,8 @@ const Call = Schema.TaggedStruct("Call", {
22
22
  call: Schema.String,
23
23
  role: Schema.String,
24
24
  executor: Schema.optionalKey(Schema.String),
25
+ /** The executor's clone, when the seat's coder holds a slot (ADR 0033). */
26
+ clone: Schema.optionalKey(Schema.Int),
25
27
  /** The system prompt, on a chat's first call only. */
26
28
  system: Schema.optionalKey(Schema.String),
27
29
  /** The input: a chat's newest message, or a one-shot prompt. */
@@ -96,6 +98,8 @@ export interface TranscriptSeatOptions {
96
98
  readonly role: string
97
99
  /** Who serves the seat, read at each call (a roster hands the coder over). */
98
100
  readonly executor?: Effect.Effect<string | undefined>
101
+ /** Which clone of that executor, read at each call (ADR 0033). */
102
+ readonly clone?: Effect.Effect<number | undefined>
99
103
  }
100
104
 
101
105
  /** A seat whose every call is written to `sink`, as it happens. */
@@ -115,12 +119,14 @@ export const transcriptSeat = (
115
119
  const start = yield* Clock.currentTimeMillis
116
120
  const role = yield* roleOr(options.role)
117
121
  const executor = options.executor === undefined ? undefined : yield* options.executor
122
+ const clone = options.clone === undefined ? undefined : yield* options.clone
118
123
  yield* write({
119
124
  _tag: "Call",
120
125
  at: start,
121
126
  call,
122
127
  role,
123
128
  ...(executor === undefined ? {} : { executor }),
129
+ ...(clone === undefined ? {} : { clone }),
124
130
  ...(extra.system === undefined ? {} : { system: clean(extra.system, inputChars) }),
125
131
  input: clean(input, inputChars),
126
132
  ...(extra.earlier === undefined ? {} : { earlier: extra.earlier })