@llm4ts/flow 2.8.0 → 2.9.1

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 (56) hide show
  1. package/dist/Approval.d.ts +1 -1
  2. package/dist/Approval.d.ts.map +1 -1
  3. package/dist/Artifacts.d.ts +2 -2
  4. package/dist/Artifacts.d.ts.map +1 -1
  5. package/dist/BenchReport.d.ts +2 -2
  6. package/dist/BenchReport.d.ts.map +1 -1
  7. package/dist/CostLedger.d.ts +2 -2
  8. package/dist/CostLedger.d.ts.map +1 -1
  9. package/dist/Equiv.d.ts +3 -3
  10. package/dist/Equiv.d.ts.map +1 -1
  11. package/dist/Flow.d.ts +1 -1
  12. package/dist/Flow.d.ts.map +1 -1
  13. package/dist/FlowContext.d.ts +15 -1
  14. package/dist/FlowContext.d.ts.map +1 -1
  15. package/dist/FlowContext.js.map +1 -1
  16. package/dist/FlowError.d.ts +24 -1
  17. package/dist/FlowError.d.ts.map +1 -1
  18. package/dist/FlowError.js +29 -0
  19. package/dist/FlowError.js.map +1 -1
  20. package/dist/FlowEvents.d.ts +45 -0
  21. package/dist/FlowEvents.d.ts.map +1 -1
  22. package/dist/FlowEvents.js +87 -7
  23. package/dist/FlowEvents.js.map +1 -1
  24. package/dist/Judgment.d.ts +2 -2
  25. package/dist/Judgment.d.ts.map +1 -1
  26. package/dist/Persistence.d.ts +2 -2
  27. package/dist/Persistence.d.ts.map +1 -1
  28. package/dist/PlanExecution.d.ts +1 -1
  29. package/dist/PlanExecution.d.ts.map +1 -1
  30. package/dist/ProgramJudge.d.ts +1 -1
  31. package/dist/ProgramJudge.d.ts.map +1 -1
  32. package/dist/Replay.d.ts +2 -2
  33. package/dist/Replay.d.ts.map +1 -1
  34. package/dist/Review.d.ts +3 -3
  35. package/dist/Review.d.ts.map +1 -1
  36. package/dist/ReviewCache.d.ts +1 -1
  37. package/dist/ReviewCache.d.ts.map +1 -1
  38. package/dist/Roster.d.ts +164 -0
  39. package/dist/Roster.d.ts.map +1 -0
  40. package/dist/Roster.js +583 -0
  41. package/dist/Roster.js.map +1 -0
  42. package/dist/RosterSeats.d.ts +71 -0
  43. package/dist/RosterSeats.d.ts.map +1 -0
  44. package/dist/RosterSeats.js +167 -0
  45. package/dist/RosterSeats.js.map +1 -0
  46. package/dist/Stories.d.ts +9 -5
  47. package/dist/Stories.d.ts.map +1 -1
  48. package/dist/Stories.js +95 -24
  49. package/dist/Stories.js.map +1 -1
  50. package/package.json +4 -2
  51. package/src/FlowContext.ts +19 -1
  52. package/src/FlowError.ts +32 -0
  53. package/src/FlowEvents.ts +108 -7
  54. package/src/Roster.ts +849 -0
  55. package/src/RosterSeats.ts +360 -0
  56. package/src/Stories.ts +129 -28
package/src/Stories.ts CHANGED
@@ -18,16 +18,17 @@ import type { LlmError } from "@llm4ts/core/Errors"
18
18
  import { BoardItem, type BoardSyncShape } from "./BoardSync.ts"
19
19
  import { makeChat } from "./Chat.ts"
20
20
  import { implementPlanFlow } from "./Flow.ts"
21
- import type { FlowContextShape } from "./FlowContext.ts"
21
+ import type { ContextOptions, FlowContextShape } from "./FlowContext.ts"
22
22
  import {
23
23
  describeFlowError,
24
+ rosterExhaustedPrefix,
24
25
  EpicCheckoutDirty,
25
26
  MissingDependency,
26
27
  PerimeterViolation,
27
28
  StoryFailed,
28
29
  type FlowError
29
30
  } from "./FlowError.ts"
30
- import { Info } from "./FlowEvents.ts"
31
+ import { Info, withLane, type FlowEventsShape } from "./FlowEvents.ts"
31
32
  import { statusPaths, type GitToolShape } from "./GitTool.ts"
32
33
  import {
33
34
  checkPerimeter,
@@ -89,7 +90,9 @@ export class StoryState extends Schema.Class<StoryState>("StoryState")({
89
90
  hash: Schema.String,
90
91
  branch: Schema.String,
91
92
  worktree: Schema.String,
92
- status: StoryStatus
93
+ status: StoryStatus,
94
+ /** The roster executor that last held the story's coder (ADR 0019); preferred on resume. */
95
+ executor: Schema.optionalKey(Schema.String)
93
96
  }) {}
94
97
 
95
98
  export const EpicReportVersion = 1
@@ -106,6 +109,8 @@ export class StoryOutcome extends Schema.Class<StoryOutcome>("StoryOutcome")({
106
109
  /** Failure reason, or what a waiting story waits for. */
107
110
  reason: Schema.optionalKey(Schema.String),
108
111
  judge: Schema.optionalKey(Schema.String),
112
+ /** The roster executor(s) that coded the story, in order ("codex → claude"). */
113
+ executor: Schema.optionalKey(Schema.String),
109
114
  /** ESTIMATES, never measurements (ADR 0012). */
110
115
  estimatedTokens: Schema.optionalKey(Schema.Int),
111
116
  estimatedCostUsd: Schema.optionalKey(Schema.Number)
@@ -159,6 +164,27 @@ export const renderEpicReport = (report: EpicReport): string => {
159
164
  if (cost.length > 0) {
160
165
  lines.push(`- Estimated cost: ${money(cost.reduce((sum, value) => sum + value, 0))}`)
161
166
  }
167
+ const coded = report.stories.filter((story) => story.executor !== undefined)
168
+ if (coded.length > 0) {
169
+ // A story is counted for the executor that finished it; a handover chain
170
+ // is shown in full next to the story.
171
+ const byExecutor = new Map<string, { stories: Array<string>; tokens: number; cost: number }>()
172
+ for (const story of coded) {
173
+ const chain = story.executor ?? ""
174
+ const last = chain.split(" → ").at(-1) ?? chain
175
+ const entry = byExecutor.get(last) ?? { stories: [], tokens: 0, cost: 0 }
176
+ entry.stories.push(chain === last ? story.id : `${story.id} (${chain})`)
177
+ entry.tokens += story.estimatedTokens ?? 0
178
+ entry.cost += story.estimatedCostUsd ?? 0
179
+ byExecutor.set(last, entry)
180
+ }
181
+ lines.push("", "## By executor", "")
182
+ for (const [executor, entry] of byExecutor) {
183
+ lines.push(
184
+ `- ${executor}: ${entry.stories.join(", ")} — ~${entry.tokens} tokens, ${money(entry.cost)} (estimated)`
185
+ )
186
+ }
187
+ }
162
188
  lines.push("")
163
189
  return lines.join("\n")
164
190
  }
@@ -423,7 +449,10 @@ export interface StoriesOptions {
423
449
  /** Default `epic/<epicId>`. */
424
450
  readonly epicBranch?: string
425
451
  /** Seats rooted in a worktree; the executor never resolves seats itself. */
426
- readonly contextFor: (workDir: string) => Effect.Effect<StorySeats, FlowError, Scope.Scope>
452
+ readonly contextFor: (
453
+ workDir: string,
454
+ options?: ContextOptions
455
+ ) => Effect.Effect<StorySeats, FlowError, Scope.Scope>
427
456
  readonly board: BoardSyncShape
428
457
  /**
429
458
  * Prepares a worktree before its coder runs — a fresh checkout has no
@@ -434,7 +463,11 @@ export interface StoriesOptions {
434
463
  /** The target's gates, run in a worktree per task and on the epic checkout after each merge. */
435
464
  readonly gates: (workDir: string) => Effect.Effect<ReviewResult, FlowError>
436
465
  /** Story-level judge over the branch's diff against the epic branch; omit to skip. */
437
- readonly judge?: (story: Story, diff: string) => Effect.Effect<ReviewResult, FlowError>
466
+ readonly judge?: (
467
+ story: Story,
468
+ diff: string,
469
+ seats: StorySeats
470
+ ) => Effect.Effect<ReviewResult, FlowError>
438
471
  /** Judge attempts, each but the last followed by one coder feedback round. Default 2. */
439
472
  readonly judgeRounds?: number
440
473
  /** Extra system context per story (house rules, shared read-only excerpts). */
@@ -459,7 +492,8 @@ export interface StoriesOptions {
459
492
  readonly verifyBlocked?: (
460
493
  story: Story,
461
494
  need: string,
462
- workDir: string
495
+ workDir: string,
496
+ seats: StorySeats
463
497
  ) => Effect.Effect<BlockedVerdict, FlowError>
464
498
  /**
465
499
  * Waits until a serving engine that went down (`isOutageMessage`) is back;
@@ -528,6 +562,18 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
528
562
  }
529
563
  })
530
564
 
565
+ /** A story's own events say so (a lane), so concurrent stories stay apart on screen. */
566
+ const lanes = new Map<string, FlowEventsShape>()
567
+ const laneOf = (story: Story): FlowEventsShape => {
568
+ const known = lanes.get(story.id)
569
+ if (known !== undefined) {
570
+ return known
571
+ }
572
+ const lane = withLane(events, { lane: story.id })
573
+ lanes.set(story.id, lane)
574
+ return lane
575
+ }
576
+
531
577
  yield* epicCheckoutClean()
532
578
  yield* stage(events, "epic branch", context.git.checkoutOrCreate(epicBranch))
533
579
 
@@ -566,7 +612,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
566
612
  `epic gates failed after merging; merge undone:\n${issueLines(gate)}`
567
613
  )
568
614
  }
569
- yield* events.publish(
615
+ yield* laneOf(story).publish(
570
616
  Info.make({ message: `story ${story.id}: merged into ${epicBranch}` })
571
617
  )
572
618
  })
@@ -582,7 +628,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
582
628
  const path = statePath(story)
583
629
  let stored = yield* loadVersioned(files, path, StoryStateVersion, StoryState)
584
630
  if (stored !== undefined && stored.hash !== hash) {
585
- yield* events.publish(
631
+ yield* laneOf(story).publish(
586
632
  Info.make({
587
633
  message: `story ${story.id}: plan entry changed since its branch was created; starting over`
588
634
  })
@@ -593,7 +639,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
593
639
  .removeWorktree(stored.worktree, true)
594
640
  .pipe(
595
641
  Effect.catch((error) =>
596
- events.publish(Info.make({ message: `story ${story.id}: ${describeFlowError(error)}` }))
642
+ laneOf(story).publish(
643
+ Info.make({ message: `story ${story.id}: ${describeFlowError(error)}` })
644
+ )
597
645
  )
598
646
  )
599
647
  if (yield* context.git.branchExists(stored.branch)) {
@@ -621,7 +669,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
621
669
  if (stored.worktree !== worktree) {
622
670
  if ((yield* files.read(join(stored.worktree, ".git"))) !== undefined) {
623
671
  yield* context.git.moveWorktree(stored.worktree, worktree)
624
- yield* events.publish(
672
+ yield* laneOf(story).publish(
625
673
  Info.make({ message: `story ${story.id}: moved its worktree to ${worktree}` })
626
674
  )
627
675
  }
@@ -632,7 +680,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
632
680
  if (marker === undefined) {
633
681
  yield* context.git.addWorktree(state.worktree, state.branch)
634
682
  }
635
- yield* events.publish(Info.make({ message: `story ${story.id}: resuming ${state.branch}` }))
683
+ yield* laneOf(story).publish(
684
+ Info.make({ message: `story ${story.id}: resuming ${state.branch}` })
685
+ )
636
686
  return { state, alreadyMerged: false }
637
687
  })
638
688
 
@@ -654,7 +704,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
654
704
  yield* git.merge(epicBranch, `${story.id}: catch up with ${epicBranch}`, {
655
705
  preferIncoming: true
656
706
  })
657
- yield* events.publish(
707
+ yield* laneOf(story).publish(
658
708
  Info.make({ message: `story ${story.id}: caught up with ${epicBranch}` })
659
709
  )
660
710
  })
@@ -675,11 +725,29 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
675
725
  story: Story,
676
726
  state: StoryState
677
727
  ): Effect.fn.Return<
678
- { readonly judge: string | undefined; readonly totals: TokenUsage | undefined },
728
+ {
729
+ readonly judge: string | undefined
730
+ readonly totals: TokenUsage | undefined
731
+ readonly executor: string | undefined
732
+ },
679
733
  FlowError,
680
734
  Scope.Scope
681
735
  > {
682
- const seats = yield* options.contextFor(state.worktree)
736
+ const seats = yield* options.contextFor(state.worktree, {
737
+ label: story.id,
738
+ ...(state.executor === undefined ? {} : { prefer: state.executor })
739
+ })
740
+ const roster = seats.context.roster
741
+ const coderExecutor = roster === undefined ? undefined : yield* roster.executor
742
+ if (coderExecutor !== undefined && coderExecutor !== state.executor) {
743
+ yield* saveVersioned(
744
+ files,
745
+ statePath(story),
746
+ StoryStateVersion,
747
+ StoryState,
748
+ StoryState.make({ ...state, executor: coderExecutor })
749
+ )
750
+ }
683
751
  const blocked = yield* Ref.make<string | undefined>(undefined)
684
752
  const pushbacks = yield* Ref.make(0)
685
753
  const storyContext: FlowContextShape = {
@@ -700,7 +768,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
700
768
  // Catch up before setup: the epic may have changed the manifest too.
701
769
  yield* catchUp(story, git)
702
770
  if (options.setup !== undefined) {
703
- yield* stage(events, `story ${story.id}: setup`, options.setup(state.worktree))
771
+ yield* stage(laneOf(story), `story ${story.id}: setup`, options.setup(state.worktree))
704
772
  }
705
773
 
706
774
  // The target's gates plus the perimeter: a stray path is a gate failure
@@ -750,7 +818,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
750
818
  let rebuttal = blockedRebuttal(plan, story, need)
751
819
  if (rebuttal === undefined && options.verifyBlocked !== undefined) {
752
820
  const verdict = yield* options
753
- .verifyBlocked(story, need, state.worktree)
821
+ .verifyBlocked(story, need, state.worktree, watchedSeats)
754
822
  .pipe(Effect.catch(() => Effect.succeed(undefined)))
755
823
  rebuttal = verdict === undefined || verdict.real ? undefined : verdict.reason
756
824
  }
@@ -759,7 +827,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
759
827
  }
760
828
  yield* Ref.set(blocked, undefined)
761
829
  yield* Ref.update(pushbacks, (count) => count + 1)
762
- yield* events.publish(
830
+ yield* laneOf(story).publish(
763
831
  Info.make({ message: `story ${story.id}: BLOCKED_ON rejected — ${rebuttal}` })
764
832
  )
765
833
  yield* coderTurn(
@@ -812,7 +880,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
812
880
  if (!isWithinPerimeter(second)) {
813
881
  const stray = [...second.sharedReadOnly, ...second.outside]
814
882
  yield* git.restorePaths(epicBranch, stray)
815
- yield* events.publish(
883
+ yield* laneOf(story).publish(
816
884
  Info.make({
817
885
  message: `story ${story.id}: restored ${stray.length} path(s) outside its perimeter from ${epicBranch}: ${stray.join(", ")}`
818
886
  })
@@ -830,7 +898,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
830
898
  story,
831
899
  (text) => planTasks(watchedSeats, story, text),
832
900
  prompt,
833
- events
901
+ laneOf(story)
834
902
  ),
835
903
  system,
836
904
  chatPerTask: true,
@@ -861,7 +929,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
861
929
  // a model asked to score an empty diff scores the prompt instead.
862
930
  return yield* failed(story, "the story branch has no changes against the epic branch")
863
931
  }
864
- const verdict = yield* judge(story, diff)
932
+ const verdict = yield* judge(story, diff, watchedSeats)
865
933
  if (verdict.isClean) {
866
934
  judgeNote = `judge cleared (round ${round})`
867
935
  break
@@ -890,7 +958,12 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
890
958
  const changed = yield* git.changedFilesVsBase(epicBranch)
891
959
  yield* enforcePerimeter(changed, story)
892
960
  const totals = seats.totals === undefined ? undefined : yield* seats.totals
893
- return { judge: judgeNote, totals }
961
+ const history = roster === undefined ? [] : yield* roster.history
962
+ return {
963
+ judge: judgeNote,
964
+ totals,
965
+ executor: history.length === 0 ? undefined : history.join(" → ")
966
+ }
894
967
  })
895
968
 
896
969
  const runStory = Effect.fn("@llm4ts/flow/Stories.runStory")(function* (
@@ -899,7 +972,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
899
972
  yield* board.start(story.id)
900
973
  const { state, alreadyMerged } = yield* prepareWorktree(story)
901
974
  if (alreadyMerged) {
902
- yield* events.publish(Info.make({ message: `story ${story.id}: already merged; skipping` }))
975
+ yield* laneOf(story).publish(
976
+ Info.make({ message: `story ${story.id}: already merged; skipping` })
977
+ )
903
978
  return StoryOutcome.make({
904
979
  id: story.id,
905
980
  title: story.title,
@@ -910,12 +985,17 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
910
985
  }
911
986
  const result = yield* Effect.scoped(implementStory(story, state))
912
987
  yield* integrate(story, state.branch)
988
+ const last = result.executor?.split(" → ").at(-1)
913
989
  yield* saveVersioned(
914
990
  files,
915
991
  statePath(story),
916
992
  StoryStateVersion,
917
993
  StoryState,
918
- StoryState.make({ ...state, status: "merged" })
994
+ StoryState.make({
995
+ ...state,
996
+ status: "merged",
997
+ ...(last === undefined ? {} : { executor: last })
998
+ })
919
999
  )
920
1000
  return StoryOutcome.make({
921
1001
  id: story.id,
@@ -923,6 +1003,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
923
1003
  status: "done",
924
1004
  branch: state.branch,
925
1005
  ...(result.judge === undefined ? {} : { judge: result.judge }),
1006
+ ...(result.executor === undefined ? {} : { executor: result.executor }),
926
1007
  ...(result.totals === undefined ? {} : { estimatedTokens: result.totals.total }),
927
1008
  ...(result.totals?.costUsd === undefined ? {} : { estimatedCostUsd: result.totals.costUsd })
928
1009
  })
@@ -930,7 +1011,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
930
1011
 
931
1012
  /** A story's run as a completion — failures become outcomes, never fiber deaths. */
932
1013
  const attempt = (story: Story): Effect.Effect<Completion> =>
933
- stage(events, `story ${story.id}`, runStory(story)).pipe(
1014
+ stage(laneOf(story), `story ${story.id}`, runStory(story)).pipe(
934
1015
  Effect.map((outcome): Completion => ({ story, outcome })),
935
1016
  Effect.catch((error) =>
936
1017
  Effect.gen(function* () {
@@ -949,7 +1030,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
949
1030
  ).pipe(Effect.ignore)
950
1031
  }
951
1032
  const interruption: Interruption | undefined =
952
- error._tag === "EpicCheckoutDirty"
1033
+ error._tag === "EpicCheckoutDirty" ||
1034
+ error._tag === "RosterExhausted" ||
1035
+ reason.includes(rosterExhaustedPrefix)
953
1036
  ? "halt"
954
1037
  : isOutageMessage(reason)
955
1038
  ? "outage"
@@ -1020,7 +1103,16 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1020
1103
  if (halted === undefined) {
1021
1104
  const current = yield* progress
1022
1105
  const ready = readyStories(plan, current)
1023
- const slots = concurrency - current.running.size
1106
+ // With a roster, never launch more stories than there are free
1107
+ // coders; with nothing running, launch one anyway — its lease waits
1108
+ // for the first executor to come back (ADR 0019).
1109
+ const free =
1110
+ context.roster === undefined
1111
+ ? Number.POSITIVE_INFINITY
1112
+ : yield* context.roster.available("coder")
1113
+ const budget = Math.min(concurrency - current.running.size, free)
1114
+ const slots =
1115
+ current.running.size === 0 && ready.length > 0 ? Math.max(1, budget) : budget
1024
1116
  for (const story of ready.slice(0, Math.max(0, slots))) {
1025
1117
  yield* Ref.update(running, (set) => new Set([...set, story.id]))
1026
1118
  yield* Effect.forkScoped(
@@ -1043,7 +1135,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1043
1135
  if (completion.interruption === "outage" && halted === undefined) {
1044
1136
  if (options.awaitRecovery !== undefined && !recovered.has(completion.story.id)) {
1045
1137
  recovered.add(completion.story.id)
1046
- yield* events.publish(
1138
+ yield* laneOf(completion.story).publish(
1047
1139
  Info.make({
1048
1140
  message: `story ${completion.story.id}: the serving engine is down; waiting for it to recover, then retrying the story`
1049
1141
  })
@@ -1066,7 +1158,16 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1066
1158
  if (outcome.status === "done") {
1067
1159
  yield* board.complete(outcome.id, {
1068
1160
  ...(outcome.branch === undefined ? {} : { branch: outcome.branch }),
1069
- ...(outcome.judge === undefined ? {} : { detail: outcome.judge }),
1161
+ ...(outcome.judge === undefined && outcome.executor === undefined
1162
+ ? {}
1163
+ : {
1164
+ detail: [
1165
+ outcome.judge,
1166
+ outcome.executor === undefined ? undefined : `coder ${outcome.executor}`
1167
+ ]
1168
+ .filter((part): part is string => part !== undefined)
1169
+ .join(" · ")
1170
+ }),
1070
1171
  ...(outcome.estimatedTokens === undefined
1071
1172
  ? {}
1072
1173
  : { estimatedTokens: outcome.estimatedTokens }),