@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.
- package/dist/Approval.d.ts +1 -1
- package/dist/Approval.d.ts.map +1 -1
- package/dist/Artifacts.d.ts +2 -2
- package/dist/Artifacts.d.ts.map +1 -1
- package/dist/BenchReport.d.ts +2 -2
- package/dist/BenchReport.d.ts.map +1 -1
- package/dist/CostLedger.d.ts +2 -2
- package/dist/CostLedger.d.ts.map +1 -1
- package/dist/Equiv.d.ts +3 -3
- package/dist/Equiv.d.ts.map +1 -1
- package/dist/Flow.d.ts +1 -1
- package/dist/Flow.d.ts.map +1 -1
- package/dist/FlowContext.d.ts +15 -1
- package/dist/FlowContext.d.ts.map +1 -1
- package/dist/FlowContext.js.map +1 -1
- package/dist/FlowError.d.ts +24 -1
- package/dist/FlowError.d.ts.map +1 -1
- package/dist/FlowError.js +29 -0
- package/dist/FlowError.js.map +1 -1
- 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/Judgment.d.ts +2 -2
- package/dist/Judgment.d.ts.map +1 -1
- package/dist/Persistence.d.ts +2 -2
- package/dist/Persistence.d.ts.map +1 -1
- package/dist/PlanExecution.d.ts +1 -1
- package/dist/PlanExecution.d.ts.map +1 -1
- package/dist/ProgramJudge.d.ts +1 -1
- package/dist/ProgramJudge.d.ts.map +1 -1
- package/dist/Replay.d.ts +2 -2
- package/dist/Replay.d.ts.map +1 -1
- package/dist/Review.d.ts +3 -3
- package/dist/Review.d.ts.map +1 -1
- package/dist/ReviewCache.d.ts +1 -1
- package/dist/ReviewCache.d.ts.map +1 -1
- package/dist/Roster.d.ts +164 -0
- package/dist/Roster.d.ts.map +1 -0
- package/dist/Roster.js +583 -0
- package/dist/Roster.js.map +1 -0
- package/dist/RosterSeats.d.ts +71 -0
- package/dist/RosterSeats.d.ts.map +1 -0
- package/dist/RosterSeats.js +167 -0
- package/dist/RosterSeats.js.map +1 -0
- package/dist/Stories.d.ts +9 -5
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +95 -24
- package/dist/Stories.js.map +1 -1
- package/package.json +4 -2
- package/src/FlowContext.ts +19 -1
- package/src/FlowError.ts +32 -0
- package/src/FlowEvents.ts +108 -7
- package/src/Roster.ts +849 -0
- package/src/RosterSeats.ts +360 -0
- 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: (
|
|
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?: (
|
|
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*
|
|
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*
|
|
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
|
-
|
|
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*
|
|
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*
|
|
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*
|
|
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
|
-
{
|
|
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(
|
|
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*
|
|
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*
|
|
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
|
-
|
|
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
|
-
|
|
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*
|
|
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({
|
|
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(
|
|
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
|
-
|
|
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*
|
|
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
|
|
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 }),
|