@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/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
- mergeLock.withPermit(
938
- Effect.gen(function* () {
939
- const lane = laneOf(story)
940
- // Merges are one at a time: how long this story queued for its turn.
941
- const turn = yield* Clock.currentTimeMillis
942
- yield* lane.publish(
943
- Timed.make({ kind: "wait", label: "merge lock", ms: turn - requested })
944
- )
945
- yield* epicCheckoutClean(story)
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
- if (gateCommands !== undefined) {
971
- const mergedHead = yield* context.git.checkpoint
972
- yield* writeBaseline(
973
- files,
974
- options.stateDir,
975
- keyFor(mergedHead),
976
- yield* baselineOf(mergedHead, gate, context.workDir)
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
- yield* laneOf(story).publish(
980
- Info.make({ message: `story ${story.id}: merged into ${epicBranch}` })
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
- const inherited = (yield* storyBaseline)?.failingLines ?? []
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) => planTasks(watchedSeats, story, 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
- /** Times `effect`, publishing what `timed` makes of its duration whether it succeeds or fails. */
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.onExit(effect, (exit) =>
203
- Effect.flatMap(Clock.currentTimeMillis, (end) =>
204
- events.publish(timed(end - start, Exit.isFailure(exit), role))
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(events, judgment.judge(input), (ms, failed, role) =>
492
- Timed.make({
493
- kind: "model",
494
- label: role ?? "judgment",
495
- ms,
496
- ...(failed ? { failed: true } : {})
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
  })