@llm4ts/flow 2.35.0 → 2.35.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 +15 -1
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +25 -1
- package/dist/FlowEvents.js.map +1 -1
- package/dist/GitTool.d.ts.map +1 -1
- package/dist/GitTool.js +2 -2
- package/dist/GitTool.js.map +1 -1
- package/dist/Review.d.ts.map +1 -1
- package/dist/Review.js +6 -2
- package/dist/Review.js.map +1 -1
- package/dist/Roster.d.ts +12 -0
- package/dist/Roster.d.ts.map +1 -1
- package/dist/Roster.js +36 -5
- package/dist/Roster.js.map +1 -1
- package/dist/RosterSeats.d.ts +4 -0
- package/dist/RosterSeats.d.ts.map +1 -1
- package/dist/RosterSeats.js.map +1 -1
- package/dist/Stories.d.ts +20 -0
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +95 -14
- package/dist/Stories.js.map +1 -1
- package/dist/Timing.d.ts +7 -3
- package/dist/Timing.d.ts.map +1 -1
- package/dist/Timing.js +10 -5
- package/dist/Timing.js.map +1 -1
- package/package.json +2 -2
- package/src/FlowEvents.ts +25 -1
- package/src/GitTool.ts +7 -3
- package/src/Review.ts +10 -0
- package/src/Roster.ts +63 -8
- package/src/RosterSeats.ts +4 -0
- package/src/Stories.ts +177 -54
- package/src/Timing.ts +27 -14
package/src/Roster.ts
CHANGED
|
@@ -15,6 +15,7 @@ import * as Semaphore from "effect/Semaphore"
|
|
|
15
15
|
import type { LlmError } from "@llm4ts/core/Errors"
|
|
16
16
|
import { RosterExhausted, type FlowError } from "./FlowError.ts"
|
|
17
17
|
import {
|
|
18
|
+
Began,
|
|
18
19
|
ExecutorExcluded,
|
|
19
20
|
ExecutorLeased,
|
|
20
21
|
ExecutorReleased,
|
|
@@ -461,6 +462,18 @@ export interface RosterShape {
|
|
|
461
462
|
readonly available: (role: Role) => Effect.Effect<number>
|
|
462
463
|
/** Configured slots for `role`. */
|
|
463
464
|
readonly slots: (role: Role) => number
|
|
465
|
+
/**
|
|
466
|
+
* Slots for `role` on the executors in the round, whether held or not: what
|
|
467
|
+
* the round could serve at once. A call holding a slot for a moment does not
|
|
468
|
+
* lower it; an executor out of the round does.
|
|
469
|
+
*/
|
|
470
|
+
readonly capacity: (role: Role) => Effect.Effect<number>
|
|
471
|
+
/**
|
|
472
|
+
* Waits until `capacity(role)` is no longer `from` and returns the new
|
|
473
|
+
* value: an executor resumed, its exclusion expired, its health came back,
|
|
474
|
+
* or one left the round. Returns at once when it already differs.
|
|
475
|
+
*/
|
|
476
|
+
readonly capacityChanged: (role: Role, from: number) => Effect.Effect<number>
|
|
464
477
|
/** Classifies a failure of `id`'s call; returns the exclusion it earned, if any. */
|
|
465
478
|
readonly report: (id: string, error: LlmError) => Effect.Effect<Exclusion | undefined>
|
|
466
479
|
readonly exclude: (exclusion: Exclusion) => Effect.Effect<void>
|
|
@@ -768,6 +781,17 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
768
781
|
: Effect.raceFirst(Deferred.await(wake), Effect.sleep(Duration.millis(soonest)))
|
|
769
782
|
})
|
|
770
783
|
|
|
784
|
+
/** `waitForChange`, waking at least every probe interval while a health exclusion waits. */
|
|
785
|
+
const waitOrProbe = (role: Role): Effect.Effect<void> =>
|
|
786
|
+
Effect.gen(function* () {
|
|
787
|
+
const probing = yield* lock.withPermit(
|
|
788
|
+
Effect.sync(() => [...exclusions.values()].some((exclusion) => exclusion.kind === "health"))
|
|
789
|
+
)
|
|
790
|
+
yield* probing
|
|
791
|
+
? Effect.raceFirst(waitForChange(role), Effect.sleep(healthProbeInterval))
|
|
792
|
+
: waitForChange(role)
|
|
793
|
+
})
|
|
794
|
+
|
|
771
795
|
const waitingReport = (role: Role, avoid: ReadonlyArray<string>): Effect.Effect<string> =>
|
|
772
796
|
lock.withPermit(
|
|
773
797
|
Effect.sync(() =>
|
|
@@ -874,6 +898,13 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
874
898
|
}
|
|
875
899
|
if (!announced) {
|
|
876
900
|
announced = true
|
|
901
|
+
yield* events.publish(
|
|
902
|
+
Began.make({
|
|
903
|
+
kind: "wait",
|
|
904
|
+
label: `roster ${role}`,
|
|
905
|
+
...(leaseOptions.label === undefined ? {} : { lane: leaseOptions.label })
|
|
906
|
+
})
|
|
907
|
+
)
|
|
877
908
|
yield* say(
|
|
878
909
|
`waiting for an executor to take ${role}${leaseOptions.label === undefined ? "" : ` for ${leaseOptions.label}`}: ${yield* waitingReport(role, avoid)}`
|
|
879
910
|
)
|
|
@@ -884,14 +915,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
884
915
|
yield* waited
|
|
885
916
|
return yield* leaseOf(recovered, role, leaseOptions.label)
|
|
886
917
|
}
|
|
887
|
-
|
|
888
|
-
Effect.sync(() =>
|
|
889
|
-
[...exclusions.values()].some((exclusion) => exclusion.kind === "health")
|
|
890
|
-
)
|
|
891
|
-
)
|
|
892
|
-
yield* probing
|
|
893
|
-
? Effect.raceFirst(waitForChange(role), Effect.sleep(healthProbeInterval))
|
|
894
|
-
: waitForChange(role)
|
|
918
|
+
yield* waitOrProbe(role)
|
|
895
919
|
}
|
|
896
920
|
})
|
|
897
921
|
|
|
@@ -977,6 +1001,35 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
977
1001
|
.filter((spec) => hasRole(spec, role))
|
|
978
1002
|
.reduce((sum, spec) => sum + (role === "coder" ? coderSlotsOf(spec) : slotsOf(spec)), 0)
|
|
979
1003
|
|
|
1004
|
+
const capacity = (role: Role): Effect.Effect<number> =>
|
|
1005
|
+
Effect.gen(function* () {
|
|
1006
|
+
const now = yield* Clock.currentTimeMillis
|
|
1007
|
+
return yield* lock.withPermit(
|
|
1008
|
+
Effect.sync(() => {
|
|
1009
|
+
expire(now)
|
|
1010
|
+
return eligible(role, []).reduce(
|
|
1011
|
+
(sum, spec) => sum + (role === "coder" ? coderSlotsOf(spec) : slotsOf(spec)),
|
|
1012
|
+
0
|
|
1013
|
+
)
|
|
1014
|
+
})
|
|
1015
|
+
)
|
|
1016
|
+
})
|
|
1017
|
+
|
|
1018
|
+
const capacityChanged = (role: Role, from: number): Effect.Effect<number> =>
|
|
1019
|
+
Effect.gen(function* () {
|
|
1020
|
+
while (true) {
|
|
1021
|
+
yield* probeDue
|
|
1022
|
+
// The wake is taken before the count is read, so a change between
|
|
1023
|
+
// the two still ends the wait.
|
|
1024
|
+
const wake = yield* Ref.get(changed)
|
|
1025
|
+
const now = yield* capacity(role)
|
|
1026
|
+
if (now !== from) {
|
|
1027
|
+
return now
|
|
1028
|
+
}
|
|
1029
|
+
yield* Effect.raceFirst(Deferred.await(wake), waitOrProbe(role))
|
|
1030
|
+
}
|
|
1031
|
+
})
|
|
1032
|
+
|
|
980
1033
|
const snapshot: Effect.Effect<ReadonlyArray<ExecutorStatus>> = Effect.gen(function* () {
|
|
981
1034
|
const now = yield* Clock.currentTimeMillis
|
|
982
1035
|
return yield* lock.withPermit(
|
|
@@ -1005,6 +1058,8 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
1005
1058
|
independenceBlocked,
|
|
1006
1059
|
available,
|
|
1007
1060
|
slots,
|
|
1061
|
+
capacity,
|
|
1062
|
+
capacityChanged,
|
|
1008
1063
|
report,
|
|
1009
1064
|
exclude,
|
|
1010
1065
|
resume,
|
package/src/RosterSeats.ts
CHANGED
|
@@ -41,6 +41,10 @@ export interface RosterView {
|
|
|
41
41
|
readonly available: (role: Role) => Effect.Effect<number>
|
|
42
42
|
/** Configured slots for `role`. */
|
|
43
43
|
readonly slots: (role: Role) => number
|
|
44
|
+
/** Slots for `role` on the executors in the round, held or not. */
|
|
45
|
+
readonly capacity: (role: Role) => Effect.Effect<number>
|
|
46
|
+
/** Waits until `capacity(role)` is no longer `from`; returns the new value. */
|
|
47
|
+
readonly capacityChanged: (role: Role, from: number) => Effect.Effect<number>
|
|
44
48
|
/** The executor holding this context's coder, once leased. */
|
|
45
49
|
readonly executor: Effect.Effect<string | undefined>
|
|
46
50
|
/** Every executor that held this context's coder, in order (handovers). */
|
package/src/Stories.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import * as Clock from "effect/Clock"
|
|
8
8
|
import * as Effect from "effect/Effect"
|
|
9
9
|
import * as Exit from "effect/Exit"
|
|
10
|
+
import * as Option from "effect/Option"
|
|
10
11
|
import * as Queue from "effect/Queue"
|
|
11
12
|
import * as Ref from "effect/Ref"
|
|
12
13
|
import * as Schema from "effect/Schema"
|
|
@@ -41,6 +42,7 @@ import {
|
|
|
41
42
|
type FlowError
|
|
42
43
|
} from "./FlowError.ts"
|
|
43
44
|
import {
|
|
45
|
+
Began,
|
|
44
46
|
EvidenceChecked,
|
|
45
47
|
Info,
|
|
46
48
|
JudgedDimension,
|
|
@@ -780,6 +782,55 @@ export const setupRecoveryPrompt = (command: string): string =>
|
|
|
780
782
|
const failed = (story: Story, reason: string): StoryFailed =>
|
|
781
783
|
StoryFailed.make({ story: story.id, reason })
|
|
782
784
|
|
|
785
|
+
/** What the story scheduler can say about stories it is not starting. */
|
|
786
|
+
export interface LaunchPicture {
|
|
787
|
+
readonly epicId: string
|
|
788
|
+
readonly running: number
|
|
789
|
+
readonly concurrency: number
|
|
790
|
+
/** The roster's coder capacity; absent without a roster. */
|
|
791
|
+
readonly capacity?: number
|
|
792
|
+
/** Ready stories left for a coder slot to free. */
|
|
793
|
+
readonly readyWaiting: number
|
|
794
|
+
/** Stories whose dependencies are not merged yet, with the ones they wait for. */
|
|
795
|
+
readonly blocked: ReadonlyArray<{ readonly id: string; readonly on: ReadonlyArray<string> }>
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const shownBlocked = 3
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* One line on why fewer stories run than the cap allows, or `undefined`
|
|
802
|
+
* when nothing waits: dependencies first (the common case), then capacity.
|
|
803
|
+
*/
|
|
804
|
+
export const launchSummary = (picture: LaunchPicture): string | undefined => {
|
|
805
|
+
if (picture.blocked.length === 0 && picture.readyWaiting === 0) {
|
|
806
|
+
return undefined
|
|
807
|
+
}
|
|
808
|
+
const cap =
|
|
809
|
+
picture.capacity === undefined
|
|
810
|
+
? `concurrency ${picture.concurrency}`
|
|
811
|
+
: `concurrency ${picture.concurrency}, ${picture.capacity} coder slot(s)`
|
|
812
|
+
const blocked =
|
|
813
|
+
picture.blocked.length === 0
|
|
814
|
+
? []
|
|
815
|
+
: [
|
|
816
|
+
`${picture.blocked.length} waiting on dependencies: ${picture.blocked
|
|
817
|
+
.slice(0, shownBlocked)
|
|
818
|
+
.map((story) => `${story.id} ← ${story.on.join(", ")}`)
|
|
819
|
+
.join("; ")}${
|
|
820
|
+
picture.blocked.length > shownBlocked
|
|
821
|
+
? ` (+${picture.blocked.length - shownBlocked} more)`
|
|
822
|
+
: ""
|
|
823
|
+
}`
|
|
824
|
+
]
|
|
825
|
+
const starved =
|
|
826
|
+
picture.readyWaiting === 0 ? [] : [`${picture.readyWaiting} ready, waiting for a coder slot`]
|
|
827
|
+
return [
|
|
828
|
+
`epic ${picture.epicId}: ${picture.running} running (${cap})`,
|
|
829
|
+
...blocked,
|
|
830
|
+
...starved
|
|
831
|
+
].join(" · ")
|
|
832
|
+
}
|
|
833
|
+
|
|
783
834
|
// ---- The executor -------------------------------------------------------------
|
|
784
835
|
|
|
785
836
|
export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(function* (
|
|
@@ -933,52 +984,56 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
933
984
|
/** Merge the story branch into the epic branch and re-gate the epic head, one story at a time. */
|
|
934
985
|
const integrate = (story: Story, branch: string): Effect.Effect<void, FlowError> =>
|
|
935
986
|
Effect.flatMap(Clock.currentTimeMillis, (requested) =>
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
const checkpoint = yield* context.git.checkpoint
|
|
946
|
-
yield* timeEffect(
|
|
947
|
-
lane,
|
|
948
|
-
context.git.merge(branch, `${plan.epicId}: merge story ${story.id}`),
|
|
949
|
-
(ms, failed) =>
|
|
950
|
-
Timed.make({ kind: "merge", label: "merge", ms, ...(failed ? { failed } : {}) })
|
|
951
|
-
)
|
|
952
|
-
const gate = yield* options.gates(context.workDir, lane)
|
|
953
|
-
// Charged only with what the merge added: the head before it had
|
|
954
|
-
// a baseline (run start or the previous merge).
|
|
955
|
-
const before = yield* ensureBaseline(
|
|
956
|
-
checkpoint,
|
|
957
|
-
Effect.succeed(ReviewResult.make({ issues: [] })),
|
|
958
|
-
context.workDir
|
|
959
|
-
)
|
|
960
|
-
const verdict = triageGates(gate, before, rootsOf(context.workDir))
|
|
961
|
-
if (!verdict.blocking.isClean) {
|
|
962
|
-
// Never leave a red epic head for the next story to inherit.
|
|
963
|
-
yield* context.git.rollback(checkpoint)
|
|
964
|
-
return yield* failed(
|
|
965
|
-
story,
|
|
966
|
-
`epic gates failed after merging; merge undone:\n${issueLines(verdict.blocking)}`
|
|
987
|
+
Effect.andThen(
|
|
988
|
+
laneOf(story).publish(Began.make({ kind: "wait", label: "merge lock" })),
|
|
989
|
+
mergeLock.withPermit(
|
|
990
|
+
Effect.gen(function* () {
|
|
991
|
+
const lane = laneOf(story)
|
|
992
|
+
// Merges are one at a time: how long this story queued for its turn.
|
|
993
|
+
const turn = yield* Clock.currentTimeMillis
|
|
994
|
+
yield* lane.publish(
|
|
995
|
+
Timed.make({ kind: "wait", label: "merge lock", ms: turn - requested })
|
|
967
996
|
)
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
997
|
+
yield* epicCheckoutClean(story)
|
|
998
|
+
const checkpoint = yield* context.git.checkpoint
|
|
999
|
+
yield* timeEffect(
|
|
1000
|
+
lane,
|
|
1001
|
+
context.git.merge(branch, `${plan.epicId}: merge story ${story.id}`),
|
|
1002
|
+
(ms, failed) =>
|
|
1003
|
+
Timed.make({ kind: "merge", label: "merge", ms, ...(failed ? { failed } : {}) }),
|
|
1004
|
+
() => Began.make({ kind: "merge", label: "merge" })
|
|
976
1005
|
)
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1006
|
+
const gate = yield* options.gates(context.workDir, lane)
|
|
1007
|
+
// Charged only with what the merge added: the head before it had
|
|
1008
|
+
// a baseline (run start or the previous merge).
|
|
1009
|
+
const before = yield* ensureBaseline(
|
|
1010
|
+
checkpoint,
|
|
1011
|
+
Effect.succeed(ReviewResult.make({ issues: [] })),
|
|
1012
|
+
context.workDir
|
|
1013
|
+
)
|
|
1014
|
+
const verdict = triageGates(gate, before, rootsOf(context.workDir))
|
|
1015
|
+
if (!verdict.blocking.isClean) {
|
|
1016
|
+
// Never leave a red epic head for the next story to inherit.
|
|
1017
|
+
yield* context.git.rollback(checkpoint)
|
|
1018
|
+
return yield* failed(
|
|
1019
|
+
story,
|
|
1020
|
+
`epic gates failed after merging; merge undone:\n${issueLines(verdict.blocking)}`
|
|
1021
|
+
)
|
|
1022
|
+
}
|
|
1023
|
+
if (gateCommands !== undefined) {
|
|
1024
|
+
const mergedHead = yield* context.git.checkpoint
|
|
1025
|
+
yield* writeBaseline(
|
|
1026
|
+
files,
|
|
1027
|
+
options.stateDir,
|
|
1028
|
+
keyFor(mergedHead),
|
|
1029
|
+
yield* baselineOf(mergedHead, gate, context.workDir)
|
|
1030
|
+
)
|
|
1031
|
+
}
|
|
1032
|
+
yield* laneOf(story).publish(
|
|
1033
|
+
Info.make({ message: `story ${story.id}: merged into ${epicBranch}` })
|
|
1034
|
+
)
|
|
1035
|
+
})
|
|
1036
|
+
)
|
|
982
1037
|
)
|
|
983
1038
|
)
|
|
984
1039
|
|
|
@@ -1277,7 +1332,13 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1277
1332
|
: { rerunTest: options.testGate(state.worktree, laneOf(story)) })
|
|
1278
1333
|
}
|
|
1279
1334
|
if (gateCommands !== undefined) {
|
|
1280
|
-
|
|
1335
|
+
// Recorded here, before the first task, so the lane says what the time goes to.
|
|
1336
|
+
const baseline = yield* stage(
|
|
1337
|
+
laneOf(story),
|
|
1338
|
+
`story ${story.id}: baseline gates`,
|
|
1339
|
+
storyBaseline
|
|
1340
|
+
)
|
|
1341
|
+
const inherited = baseline?.failingLines ?? []
|
|
1281
1342
|
if (inherited.length > 0) {
|
|
1282
1343
|
inheritedByStory.set(story.id, inherited)
|
|
1283
1344
|
yield* appendFindings(
|
|
@@ -1423,7 +1484,12 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1423
1484
|
plan: planWithinPerimeter(
|
|
1424
1485
|
plan,
|
|
1425
1486
|
story,
|
|
1426
|
-
(text) =>
|
|
1487
|
+
(text) =>
|
|
1488
|
+
stage(
|
|
1489
|
+
laneOf(story),
|
|
1490
|
+
`story ${story.id}: plan tasks`,
|
|
1491
|
+
planTasks(watchedSeats, story, text)
|
|
1492
|
+
),
|
|
1427
1493
|
prompt,
|
|
1428
1494
|
laneOf(story)
|
|
1429
1495
|
),
|
|
@@ -1815,26 +1881,34 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1815
1881
|
|
|
1816
1882
|
/** Why no further story may start in this run; stories already running finish. */
|
|
1817
1883
|
let halted: string | undefined
|
|
1884
|
+
/** The last launch summary said, so a picture is told once, when it changes. */
|
|
1885
|
+
let toldLaunch: string | undefined
|
|
1818
1886
|
/** Stories retried once after the serving engine recovered. */
|
|
1819
1887
|
const recovered = new Set<string>()
|
|
1820
1888
|
|
|
1821
1889
|
yield* Effect.scoped(
|
|
1822
1890
|
Effect.gen(function* () {
|
|
1823
1891
|
while (true) {
|
|
1892
|
+
/** The roster's coder capacity when a ready story was left waiting for it. */
|
|
1893
|
+
let starvedAt: number | undefined
|
|
1824
1894
|
if (halted === undefined) {
|
|
1825
1895
|
const current = yield* progress
|
|
1826
1896
|
const ready = readyStories(plan, current)
|
|
1827
|
-
// With a roster, never
|
|
1828
|
-
//
|
|
1829
|
-
//
|
|
1830
|
-
|
|
1897
|
+
// With a roster, never run more stories than the round has coder
|
|
1898
|
+
// slots: each running story holds one. Capacity, not the slots free
|
|
1899
|
+
// this instant — a review or judge call holding a slot for a moment
|
|
1900
|
+
// must not keep a story from starting; its lease waits for that call.
|
|
1901
|
+
// With nothing running, launch one anyway — its lease waits for the
|
|
1902
|
+
// first executor to come back (ADR 0019).
|
|
1903
|
+
const capacity =
|
|
1831
1904
|
context.roster === undefined
|
|
1832
1905
|
? Number.POSITIVE_INFINITY
|
|
1833
|
-
: yield* context.roster.
|
|
1834
|
-
const budget = Math.min(concurrency - current.running.size
|
|
1906
|
+
: yield* context.roster.capacity("coder")
|
|
1907
|
+
const budget = Math.min(concurrency, capacity) - current.running.size
|
|
1835
1908
|
const slots =
|
|
1836
1909
|
current.running.size === 0 && ready.length > 0 ? Math.max(1, budget) : budget
|
|
1837
|
-
|
|
1910
|
+
const launching = ready.slice(0, Math.max(0, slots))
|
|
1911
|
+
for (const story of launching) {
|
|
1838
1912
|
yield* Ref.update(running, (set) => new Set([...set, story.id]))
|
|
1839
1913
|
yield* Effect.forkScoped(
|
|
1840
1914
|
attempt(story).pipe(
|
|
@@ -1842,11 +1916,60 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1842
1916
|
)
|
|
1843
1917
|
)
|
|
1844
1918
|
}
|
|
1919
|
+
if (
|
|
1920
|
+
context.roster !== undefined &&
|
|
1921
|
+
ready.length > launching.length &&
|
|
1922
|
+
current.running.size + launching.length < concurrency
|
|
1923
|
+
) {
|
|
1924
|
+
starvedAt = capacity
|
|
1925
|
+
}
|
|
1926
|
+
// Say why fewer stories run than the cap allows: a plan that
|
|
1927
|
+
// chains its stories looks exactly like a scheduler that stalled.
|
|
1928
|
+
const started = new Set([...current.running, ...launching.map((story) => story.id)])
|
|
1929
|
+
const said = launchSummary({
|
|
1930
|
+
epicId: plan.epicId,
|
|
1931
|
+
running: started.size,
|
|
1932
|
+
concurrency,
|
|
1933
|
+
...(context.roster === undefined ? {} : { capacity }),
|
|
1934
|
+
readyWaiting: ready.length - launching.length,
|
|
1935
|
+
blocked: plan.stories.flatMap((story) =>
|
|
1936
|
+
current.done.has(story.id) ||
|
|
1937
|
+
current.failed.has(story.id) ||
|
|
1938
|
+
current.waiting.has(story.id) ||
|
|
1939
|
+
started.has(story.id) ||
|
|
1940
|
+
ready.includes(story)
|
|
1941
|
+
? []
|
|
1942
|
+
: [
|
|
1943
|
+
{
|
|
1944
|
+
id: story.id,
|
|
1945
|
+
on: story.dependsOn.filter((dependency) => !current.done.has(dependency))
|
|
1946
|
+
}
|
|
1947
|
+
]
|
|
1948
|
+
)
|
|
1949
|
+
})
|
|
1950
|
+
if (said !== undefined && said !== toldLaunch) {
|
|
1951
|
+
yield* events.publish(Info.make({ message: said }))
|
|
1952
|
+
}
|
|
1953
|
+
toldLaunch = said
|
|
1845
1954
|
}
|
|
1846
1955
|
if ((yield* Ref.get(running)).size === 0) {
|
|
1847
1956
|
break
|
|
1848
1957
|
}
|
|
1849
|
-
|
|
1958
|
+
// A story waiting only for coder capacity starts as soon as the round
|
|
1959
|
+
// grows (an executor back from a cooldown, a health probe, a resume),
|
|
1960
|
+
// not when the next running story happens to end.
|
|
1961
|
+
const roster = context.roster
|
|
1962
|
+
const next: Option.Option<Completion> =
|
|
1963
|
+
starvedAt === undefined || roster === undefined
|
|
1964
|
+
? Option.some(yield* Queue.take(completions))
|
|
1965
|
+
: yield* Effect.raceFirst(
|
|
1966
|
+
Effect.map(Queue.take(completions), Option.some),
|
|
1967
|
+
Effect.as(roster.capacityChanged("coder", starvedAt), Option.none<Completion>())
|
|
1968
|
+
)
|
|
1969
|
+
if (Option.isNone(next)) {
|
|
1970
|
+
continue
|
|
1971
|
+
}
|
|
1972
|
+
const completion = next.value
|
|
1850
1973
|
yield* Ref.update(running, (set) => {
|
|
1851
1974
|
const next = new Set(set)
|
|
1852
1975
|
next.delete(completion.story.id)
|
package/src/Timing.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { Message, type LlmChunk, type TokenUsage } from "@llm4ts/core/Models"
|
|
|
13
13
|
import { redactText } from "@llm4ts/core/observability/Redaction"
|
|
14
14
|
import { toolCategory } from "./Activity.ts"
|
|
15
15
|
import { estimateUsage, estimatedModelLabel, type EstimatedUsageOptions } from "./EstimatedUsage.ts"
|
|
16
|
-
import { Timed, type FlowEventsShape } from "./FlowEvents.ts"
|
|
16
|
+
import { Began, Timed, type FlowEventsShape } from "./FlowEvents.ts"
|
|
17
17
|
import {
|
|
18
18
|
attr,
|
|
19
19
|
currentKindSpan,
|
|
@@ -192,16 +192,24 @@ const reportedMs = (chunk: LlmChunk, key: string): number | undefined => {
|
|
|
192
192
|
return Number.isFinite(value) ? value : undefined
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
/**
|
|
195
|
+
/**
|
|
196
|
+
* Times `effect`, publishing what `timed` makes of its duration whether it
|
|
197
|
+
* succeeds or fails; with `began`, also what it makes of the start, when the
|
|
198
|
+
* effect starts, so a screen shows the work while it runs.
|
|
199
|
+
*/
|
|
196
200
|
export const timeEffect = <A, E, R>(
|
|
197
201
|
events: FlowEventsShape,
|
|
198
202
|
effect: Effect.Effect<A, E, R>,
|
|
199
|
-
timed: (ms: number, failed: boolean, role: string | undefined) => Timed
|
|
203
|
+
timed: (ms: number, failed: boolean, role: string | undefined) => Timed,
|
|
204
|
+
began?: (role: string | undefined) => Began
|
|
200
205
|
): Effect.Effect<A, E, R> =>
|
|
201
206
|
Effect.flatMap(Effect.all([Clock.currentTimeMillis, TimedRole]), ([start, role]) =>
|
|
202
|
-
Effect.
|
|
203
|
-
Effect.
|
|
204
|
-
|
|
207
|
+
Effect.andThen(
|
|
208
|
+
began === undefined ? Effect.void : events.publish(began(role)),
|
|
209
|
+
Effect.onExit(effect, (exit) =>
|
|
210
|
+
Effect.flatMap(Clock.currentTimeMillis, (end) =>
|
|
211
|
+
events.publish(timed(end - start, Exit.isFailure(exit), role))
|
|
212
|
+
)
|
|
205
213
|
)
|
|
206
214
|
)
|
|
207
215
|
)
|
|
@@ -239,7 +247,7 @@ const tracedEffect = <A, E, R>(
|
|
|
239
247
|
...promptContent(content, [Message.make({ role: "User", content: prompt })])
|
|
240
248
|
}
|
|
241
249
|
},
|
|
242
|
-
timeEffect(events, effect, timed).pipe(
|
|
250
|
+
timeEffect(events, effect, timed, () => Began.make({ kind: "model", label: role })).pipe(
|
|
243
251
|
Effect.onExit((exit) => {
|
|
244
252
|
const known = Exit.isSuccess(exit)
|
|
245
253
|
? usageOf(exit.value)
|
|
@@ -282,6 +290,7 @@ const timedStream = <E>(
|
|
|
282
290
|
// The call's LLM span (ADR 0026), parented to the nearest kind span and
|
|
283
291
|
// ended when the stream does; each harness tool call is a TOOL child,
|
|
284
292
|
// opened on `tool_use` and closed on `tool_result`.
|
|
293
|
+
yield* events.publish(Began.make({ kind: "model", label: role }))
|
|
285
294
|
const parent = yield* currentKindSpan
|
|
286
295
|
const span = yield* Effect.makeSpan(role, {
|
|
287
296
|
kind: "client",
|
|
@@ -488,12 +497,16 @@ export const timedSeat = (
|
|
|
488
497
|
export const timedJudgment = (judgment: JudgmentShape, events: FlowEventsShape): JudgmentShape => ({
|
|
489
498
|
...judgment,
|
|
490
499
|
judge: (input) =>
|
|
491
|
-
timeEffect(
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
500
|
+
timeEffect(
|
|
501
|
+
events,
|
|
502
|
+
judgment.judge(input),
|
|
503
|
+
(ms, failed, role) =>
|
|
504
|
+
Timed.make({
|
|
505
|
+
kind: "model",
|
|
506
|
+
label: role ?? "judgment",
|
|
507
|
+
ms,
|
|
508
|
+
...(failed ? { failed: true } : {})
|
|
509
|
+
}),
|
|
510
|
+
(role) => Began.make({ kind: "model", label: role ?? "judgment" })
|
|
498
511
|
)
|
|
499
512
|
})
|