@llm4ts/flow 2.9.0 → 2.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/FlowEvents.d.ts +45 -0
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +87 -7
- package/dist/FlowEvents.js.map +1 -1
- package/dist/Roster.d.ts +1 -0
- package/dist/Roster.d.ts.map +1 -1
- package/dist/Roster.js +23 -0
- package/dist/Roster.js.map +1 -1
- package/dist/RosterSeats.d.ts.map +1 -1
- package/dist/RosterSeats.js +36 -13
- package/dist/RosterSeats.js.map +1 -1
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +26 -15
- package/dist/Stories.js.map +1 -1
- package/package.json +2 -2
- package/src/FlowEvents.ts +108 -7
- package/src/Roster.ts +25 -0
- package/src/RosterSeats.ts +89 -35
- package/src/Stories.ts +33 -15
package/src/RosterSeats.ts
CHANGED
|
@@ -125,49 +125,103 @@ export const rosterSeat = (
|
|
|
125
125
|
workDir: string,
|
|
126
126
|
options: RosterSeatOptions
|
|
127
127
|
): LlmServiceShape => {
|
|
128
|
-
const acquire
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
`${
|
|
172
|
+
`${role}${options.label === undefined ? "" : ` for ${options.label}`} moves off ${executor.id} (${describeExclusion(exclusion)})`
|
|
136
173
|
)
|
|
137
|
-
return borrowed
|
|
138
174
|
}
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
147
|
-
|
|
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
|
|
150
|
-
(
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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*
|
|
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*
|
|
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
|
-
|
|
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*
|
|
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*
|
|
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*
|
|
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:
|
|
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(
|
|
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*
|
|
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*
|
|
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
|
-
|
|
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*
|
|
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(
|
|
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*
|
|
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
|
})
|