@llm4ts/flow 2.35.1 → 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.map +1 -1
- package/dist/Roster.js +6 -1
- package/dist/Roster.js.map +1 -1
- package/dist/Stories.d.ts +20 -0
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +65 -6
- 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 +8 -0
- package/src/Stories.ts +141 -46
- package/src/Timing.ts +27 -14
package/src/Stories.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
type FlowError
|
|
43
43
|
} from "./FlowError.ts"
|
|
44
44
|
import {
|
|
45
|
+
Began,
|
|
45
46
|
EvidenceChecked,
|
|
46
47
|
Info,
|
|
47
48
|
JudgedDimension,
|
|
@@ -781,6 +782,55 @@ export const setupRecoveryPrompt = (command: string): string =>
|
|
|
781
782
|
const failed = (story: Story, reason: string): StoryFailed =>
|
|
782
783
|
StoryFailed.make({ story: story.id, reason })
|
|
783
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
|
+
|
|
784
834
|
// ---- The executor -------------------------------------------------------------
|
|
785
835
|
|
|
786
836
|
export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(function* (
|
|
@@ -934,52 +984,56 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
934
984
|
/** Merge the story branch into the epic branch and re-gate the epic head, one story at a time. */
|
|
935
985
|
const integrate = (story: Story, branch: string): Effect.Effect<void, FlowError> =>
|
|
936
986
|
Effect.flatMap(Clock.currentTimeMillis, (requested) =>
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
const checkpoint = yield* context.git.checkpoint
|
|
947
|
-
yield* timeEffect(
|
|
948
|
-
lane,
|
|
949
|
-
context.git.merge(branch, `${plan.epicId}: merge story ${story.id}`),
|
|
950
|
-
(ms, failed) =>
|
|
951
|
-
Timed.make({ kind: "merge", label: "merge", ms, ...(failed ? { failed } : {}) })
|
|
952
|
-
)
|
|
953
|
-
const gate = yield* options.gates(context.workDir, lane)
|
|
954
|
-
// Charged only with what the merge added: the head before it had
|
|
955
|
-
// a baseline (run start or the previous merge).
|
|
956
|
-
const before = yield* ensureBaseline(
|
|
957
|
-
checkpoint,
|
|
958
|
-
Effect.succeed(ReviewResult.make({ issues: [] })),
|
|
959
|
-
context.workDir
|
|
960
|
-
)
|
|
961
|
-
const verdict = triageGates(gate, before, rootsOf(context.workDir))
|
|
962
|
-
if (!verdict.blocking.isClean) {
|
|
963
|
-
// Never leave a red epic head for the next story to inherit.
|
|
964
|
-
yield* context.git.rollback(checkpoint)
|
|
965
|
-
return yield* failed(
|
|
966
|
-
story,
|
|
967
|
-
`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 })
|
|
968
996
|
)
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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" })
|
|
977
1005
|
)
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
+
)
|
|
983
1037
|
)
|
|
984
1038
|
)
|
|
985
1039
|
|
|
@@ -1278,7 +1332,13 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1278
1332
|
: { rerunTest: options.testGate(state.worktree, laneOf(story)) })
|
|
1279
1333
|
}
|
|
1280
1334
|
if (gateCommands !== undefined) {
|
|
1281
|
-
|
|
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 ?? []
|
|
1282
1342
|
if (inherited.length > 0) {
|
|
1283
1343
|
inheritedByStory.set(story.id, inherited)
|
|
1284
1344
|
yield* appendFindings(
|
|
@@ -1424,7 +1484,12 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1424
1484
|
plan: planWithinPerimeter(
|
|
1425
1485
|
plan,
|
|
1426
1486
|
story,
|
|
1427
|
-
(text) =>
|
|
1487
|
+
(text) =>
|
|
1488
|
+
stage(
|
|
1489
|
+
laneOf(story),
|
|
1490
|
+
`story ${story.id}: plan tasks`,
|
|
1491
|
+
planTasks(watchedSeats, story, text)
|
|
1492
|
+
),
|
|
1428
1493
|
prompt,
|
|
1429
1494
|
laneOf(story)
|
|
1430
1495
|
),
|
|
@@ -1816,6 +1881,8 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1816
1881
|
|
|
1817
1882
|
/** Why no further story may start in this run; stories already running finish. */
|
|
1818
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
|
|
1819
1886
|
/** Stories retried once after the serving engine recovered. */
|
|
1820
1887
|
const recovered = new Set<string>()
|
|
1821
1888
|
|
|
@@ -1856,6 +1923,34 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1856
1923
|
) {
|
|
1857
1924
|
starvedAt = capacity
|
|
1858
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
|
|
1859
1954
|
}
|
|
1860
1955
|
if ((yield* Ref.get(running)).size === 0) {
|
|
1861
1956
|
break
|
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
|
})
|