@llm4ts/flow 2.9.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.
@@ -125,49 +125,103 @@ export const rosterSeat = (
125
125
  workDir: string,
126
126
  options: RosterSeatOptions
127
127
  ): LlmServiceShape => {
128
- const acquire: Effect.Effect<ExecutorSpec, LlmError, Scope.Scope> = Effect.gen(function* () {
129
- const avoid = options.avoid === undefined ? [] : yield* options.avoid
130
- if (avoid.length > 0 && !(yield* roster.canEverServe(role, avoid))) {
131
- const borrowed = options.borrow === undefined ? undefined : yield* options.borrow
132
- if (borrowed !== undefined && hasRole(borrowed, role)) {
128
+ const acquire = (
129
+ alsoAvoid: ReadonlyArray<string>
130
+ ): Effect.Effect<ExecutorSpec, LlmError, Scope.Scope> =>
131
+ Effect.gen(function* () {
132
+ const avoid = [...(options.avoid === undefined ? [] : yield* options.avoid), ...alsoAvoid]
133
+ if (avoid.length > 0 && !(yield* roster.canEverServe(role, avoid))) {
134
+ const borrowed = options.borrow === undefined ? undefined : yield* options.borrow
135
+ if (borrowed !== undefined && hasRole(borrowed, role)) {
136
+ yield* say(
137
+ options.events,
138
+ `${borrowed.id} takes ${role}${options.label === undefined ? "" : ` for ${options.label}`} on its own coder's slot — not independent (no other executor can take ${role})`
139
+ )
140
+ return borrowed
141
+ }
142
+ }
143
+ const lease = yield* roster
144
+ .lease(role, { avoid, ...(options.label === undefined ? {} : { label: options.label }) })
145
+ .pipe(Effect.mapError(asLlmError))
146
+ return lease.executor
147
+ })
148
+
149
+ const seatOf = (executor: ExecutorSpec): Effect.Effect<RosterSeat, LlmError> =>
150
+ source.seatFor(executor, role, workDir).pipe(Effect.mapError(asLlmError))
151
+
152
+ /**
153
+ * After a failure that took `executor` out of the round, whether another
154
+ * executor can still take the call: the call moves once, never twice — a
155
+ * second failure is the call's own, not the executor's.
156
+ */
157
+ const retryElsewhere = (
158
+ executor: ExecutorSpec,
159
+ error: LlmError,
160
+ tried: ReadonlyArray<string>
161
+ ): Effect.Effect<boolean> =>
162
+ Effect.gen(function* () {
163
+ const exclusion = yield* roster.report(executor.id, error)
164
+ if (exclusion === undefined || tried.length > 0) {
165
+ return false
166
+ }
167
+ const avoid = options.avoid === undefined ? [] : yield* options.avoid
168
+ const other = yield* roster.canEverServe(role, [...avoid, executor.id])
169
+ if (other) {
133
170
  yield* say(
134
171
  options.events,
135
- `${borrowed.id} takes ${role}${options.label === undefined ? "" : ` for ${options.label}`} on its own coder's slot — not independent (no other executor can take ${role})`
172
+ `${role}${options.label === undefined ? "" : ` for ${options.label}`} moves off ${executor.id} (${describeExclusion(exclusion)})`
136
173
  )
137
- return borrowed
138
174
  }
139
- }
140
- const lease = yield* roster
141
- .lease(role, { avoid, ...(options.label === undefined ? {} : { label: options.label }) })
142
- .pipe(Effect.mapError(asLlmError))
143
- return lease.executor
144
- })
175
+ return other
176
+ })
145
177
 
146
- const seatOf = (executor: ExecutorSpec): Effect.Effect<RosterSeat, LlmError> =>
147
- source.seatFor(executor, role, workDir).pipe(Effect.mapError(asLlmError))
178
+ const runOn = <A, R>(
179
+ use: (seat: RosterSeat, note: string | undefined) => Effect.Effect<A, LlmError, R>,
180
+ tried: ReadonlyArray<string>
181
+ ): Effect.Effect<A, LlmError, R> =>
182
+ // The slot is held for the call and released before any retry.
183
+ Effect.scoped(
184
+ Effect.gen(function* () {
185
+ const executor = yield* acquire(tried)
186
+ const seat = yield* seatOf(executor)
187
+ const outcome = yield* Effect.result(use(seat, undefined))
188
+ return { executor, outcome }
189
+ })
190
+ ).pipe(
191
+ Effect.flatMap(({ executor, outcome }) =>
192
+ outcome._tag === "Success"
193
+ ? Effect.succeed(outcome.success)
194
+ : Effect.flatMap(retryElsewhere(executor, outcome.failure, tried), (again) =>
195
+ again ? runOn(use, [...tried, executor.id]) : Effect.fail(outcome.failure)
196
+ )
197
+ )
198
+ )
148
199
 
149
- const reportOn =
150
- (executor: ExecutorSpec) =>
151
- (error: LlmError): Effect.Effect<void> =>
152
- Effect.asVoid(roster.report(executor.id, error))
200
+ const streamOn = (
201
+ use: (seat: RosterSeat, note: string | undefined) => Stream.Stream<LlmChunk, LlmError>,
202
+ tried: ReadonlyArray<string>
203
+ ): Stream.Stream<LlmChunk, LlmError> =>
204
+ Stream.unwrap(
205
+ Effect.gen(function* () {
206
+ const executor = yield* acquire(tried)
207
+ const seat = yield* seatOf(executor)
208
+ return use(seat, undefined).pipe(
209
+ Stream.catchIf(
210
+ () => true,
211
+ (error) =>
212
+ Stream.unwrap(
213
+ Effect.map(retryElsewhere(executor, error, tried), (again) =>
214
+ again ? streamOn(use, [...tried, executor.id]) : Stream.fail(error)
215
+ )
216
+ )
217
+ )
218
+ )
219
+ })
220
+ )
153
221
 
154
222
  return serviceOver({
155
- run: (use) =>
156
- Effect.scoped(
157
- Effect.gen(function* () {
158
- const executor = yield* acquire
159
- const seat = yield* seatOf(executor)
160
- return yield* use(seat, undefined).pipe(Effect.tapError(reportOn(executor)))
161
- })
162
- ),
163
- stream: (use) =>
164
- Stream.unwrap(
165
- Effect.gen(function* () {
166
- const executor = yield* acquire
167
- const seat = yield* seatOf(executor)
168
- return use(seat, undefined).pipe(Stream.tapError(reportOn(executor)))
169
- })
170
- ),
223
+ run: (use) => runOn(use, []),
224
+ stream: (use) => streamOn(use, []),
171
225
  isAvailable: roster.canEverServe(role)
172
226
  })
173
227
  }
package/src/Stories.ts CHANGED
@@ -28,7 +28,7 @@ import {
28
28
  StoryFailed,
29
29
  type FlowError
30
30
  } from "./FlowError.ts"
31
- import { Info } from "./FlowEvents.ts"
31
+ import { Info, withLane, type FlowEventsShape } from "./FlowEvents.ts"
32
32
  import { statusPaths, type GitToolShape } from "./GitTool.ts"
33
33
  import {
34
34
  checkPerimeter,
@@ -562,6 +562,18 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
562
562
  }
563
563
  })
564
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
+
565
577
  yield* epicCheckoutClean()
566
578
  yield* stage(events, "epic branch", context.git.checkoutOrCreate(epicBranch))
567
579
 
@@ -600,7 +612,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
600
612
  `epic gates failed after merging; merge undone:\n${issueLines(gate)}`
601
613
  )
602
614
  }
603
- yield* events.publish(
615
+ yield* laneOf(story).publish(
604
616
  Info.make({ message: `story ${story.id}: merged into ${epicBranch}` })
605
617
  )
606
618
  })
@@ -616,7 +628,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
616
628
  const path = statePath(story)
617
629
  let stored = yield* loadVersioned(files, path, StoryStateVersion, StoryState)
618
630
  if (stored !== undefined && stored.hash !== hash) {
619
- yield* events.publish(
631
+ yield* laneOf(story).publish(
620
632
  Info.make({
621
633
  message: `story ${story.id}: plan entry changed since its branch was created; starting over`
622
634
  })
@@ -627,7 +639,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
627
639
  .removeWorktree(stored.worktree, true)
628
640
  .pipe(
629
641
  Effect.catch((error) =>
630
- events.publish(Info.make({ message: `story ${story.id}: ${describeFlowError(error)}` }))
642
+ laneOf(story).publish(
643
+ Info.make({ message: `story ${story.id}: ${describeFlowError(error)}` })
644
+ )
631
645
  )
632
646
  )
633
647
  if (yield* context.git.branchExists(stored.branch)) {
@@ -655,7 +669,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
655
669
  if (stored.worktree !== worktree) {
656
670
  if ((yield* files.read(join(stored.worktree, ".git"))) !== undefined) {
657
671
  yield* context.git.moveWorktree(stored.worktree, worktree)
658
- yield* events.publish(
672
+ yield* laneOf(story).publish(
659
673
  Info.make({ message: `story ${story.id}: moved its worktree to ${worktree}` })
660
674
  )
661
675
  }
@@ -666,7 +680,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
666
680
  if (marker === undefined) {
667
681
  yield* context.git.addWorktree(state.worktree, state.branch)
668
682
  }
669
- 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
+ )
670
686
  return { state, alreadyMerged: false }
671
687
  })
672
688
 
@@ -688,7 +704,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
688
704
  yield* git.merge(epicBranch, `${story.id}: catch up with ${epicBranch}`, {
689
705
  preferIncoming: true
690
706
  })
691
- yield* events.publish(
707
+ yield* laneOf(story).publish(
692
708
  Info.make({ message: `story ${story.id}: caught up with ${epicBranch}` })
693
709
  )
694
710
  })
@@ -718,7 +734,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
718
734
  Scope.Scope
719
735
  > {
720
736
  const seats = yield* options.contextFor(state.worktree, {
721
- label: `story ${story.id}`,
737
+ label: story.id,
722
738
  ...(state.executor === undefined ? {} : { prefer: state.executor })
723
739
  })
724
740
  const roster = seats.context.roster
@@ -752,7 +768,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
752
768
  // Catch up before setup: the epic may have changed the manifest too.
753
769
  yield* catchUp(story, git)
754
770
  if (options.setup !== undefined) {
755
- yield* stage(events, `story ${story.id}: setup`, options.setup(state.worktree))
771
+ yield* stage(laneOf(story), `story ${story.id}: setup`, options.setup(state.worktree))
756
772
  }
757
773
 
758
774
  // The target's gates plus the perimeter: a stray path is a gate failure
@@ -811,7 +827,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
811
827
  }
812
828
  yield* Ref.set(blocked, undefined)
813
829
  yield* Ref.update(pushbacks, (count) => count + 1)
814
- yield* events.publish(
830
+ yield* laneOf(story).publish(
815
831
  Info.make({ message: `story ${story.id}: BLOCKED_ON rejected — ${rebuttal}` })
816
832
  )
817
833
  yield* coderTurn(
@@ -864,7 +880,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
864
880
  if (!isWithinPerimeter(second)) {
865
881
  const stray = [...second.sharedReadOnly, ...second.outside]
866
882
  yield* git.restorePaths(epicBranch, stray)
867
- yield* events.publish(
883
+ yield* laneOf(story).publish(
868
884
  Info.make({
869
885
  message: `story ${story.id}: restored ${stray.length} path(s) outside its perimeter from ${epicBranch}: ${stray.join(", ")}`
870
886
  })
@@ -882,7 +898,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
882
898
  story,
883
899
  (text) => planTasks(watchedSeats, story, text),
884
900
  prompt,
885
- events
901
+ laneOf(story)
886
902
  ),
887
903
  system,
888
904
  chatPerTask: true,
@@ -956,7 +972,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
956
972
  yield* board.start(story.id)
957
973
  const { state, alreadyMerged } = yield* prepareWorktree(story)
958
974
  if (alreadyMerged) {
959
- 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
+ )
960
978
  return StoryOutcome.make({
961
979
  id: story.id,
962
980
  title: story.title,
@@ -993,7 +1011,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
993
1011
 
994
1012
  /** A story's run as a completion — failures become outcomes, never fiber deaths. */
995
1013
  const attempt = (story: Story): Effect.Effect<Completion> =>
996
- stage(events, `story ${story.id}`, runStory(story)).pipe(
1014
+ stage(laneOf(story), `story ${story.id}`, runStory(story)).pipe(
997
1015
  Effect.map((outcome): Completion => ({ story, outcome })),
998
1016
  Effect.catch((error) =>
999
1017
  Effect.gen(function* () {
@@ -1117,7 +1135,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1117
1135
  if (completion.interruption === "outage" && halted === undefined) {
1118
1136
  if (options.awaitRecovery !== undefined && !recovered.has(completion.story.id)) {
1119
1137
  recovered.add(completion.story.id)
1120
- yield* events.publish(
1138
+ yield* laneOf(completion.story).publish(
1121
1139
  Info.make({
1122
1140
  message: `story ${completion.story.id}: the serving engine is down; waiting for it to recover, then retrying the story`
1123
1141
  })