@llm4ts/flow 2.35.1 → 2.35.3

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/Review.ts CHANGED
@@ -28,7 +28,9 @@ import {
28
28
  passedCountIn
29
29
  } from "./OracleGuard.ts"
30
30
  import { cachedReview, fingerprintOf } from "./ReviewCache.ts"
31
+ import { withCallPurpose } from "./Timing.ts"
31
32
  import {
33
+ Began,
32
34
  ReviewFindingDemoted,
33
35
  Info,
34
36
  JudgmentObserved,
@@ -493,6 +495,8 @@ export const lintCommand = Effect.fn("@llm4ts/flow/Review.lintCommand")(function
493
495
  }
494
496
  const label = command.join(" ")
495
497
  const started = yield* Clock.currentTimeMillis
498
+ // A gate or a setup can run for many minutes: say it started, not only when it ends.
499
+ yield* events.publish(Began.make({ kind: "gate", label }))
496
500
  const run = guarded(
497
501
  Capabilities.Exec(executable),
498
502
  `lint: ${label}`,
@@ -526,6 +530,13 @@ export const lintCommand = Effect.fn("@llm4ts/flow/Review.lintCommand")(function
526
530
  ran === undefined ? Effect.void : Effect.annotateCurrentSpan(attr.gateExit, ran.exitCode)
527
531
  )
528
532
  )
533
+ ).pipe(
534
+ // A command that could not run still ends what its `Began` started.
535
+ Effect.tapError(() =>
536
+ Effect.flatMap(Clock.currentTimeMillis, (now) =>
537
+ events.publish(Timed.make({ kind: "gate", label, ms: now - started, failed: true }))
538
+ )
539
+ )
529
540
  )
530
541
  const failed = result === undefined || result.exitCode !== 0
531
542
  yield* events.publish(
@@ -895,14 +906,21 @@ export const reviewAndFixLoop = Effect.fn("@llm4ts/flow/Review.reviewAndFixLoop"
895
906
  const chosen = screened.reviewers
896
907
  const cache = options.cache
897
908
  const votes = Math.max(1, options.votes ?? 1)
909
+ // Each lens is its own call; the roster's lease says which, so four
910
+ // parallel reviewer leases read as four questions, not one repeated.
911
+ const lensCall = (lens: Reviewer, vote: number): Effect.Effect<ReviewResult, FlowError> =>
912
+ withCallPurpose(
913
+ `${lens.name} lens${votes > 1 && lens.name === adversarialReviewer.name ? ` · vote ${vote + 1} of ${votes}` : ""}`,
914
+ reviewWith(options.reviewerService, options.events, lens, options.taskTitle, diff)
915
+ )
898
916
  const reviewVote = (lens: Reviewer, vote: number): Effect.Effect<ReviewResult, FlowError> =>
899
917
  cache === undefined
900
- ? reviewWith(options.reviewerService, options.events, lens, options.taskTitle, diff)
918
+ ? lensCall(lens, vote)
901
919
  : cachedReview(
902
920
  cache.files,
903
921
  join(cache.dir, `${fileSlug(lens.name)}${vote === 0 ? "" : `-vote${vote}`}.json`),
904
922
  fingerprintOf([lens.name, lensPrompt(lens), String(vote), options.taskTitle, diff]),
905
- reviewWith(options.reviewerService, options.events, lens, options.taskTitle, diff)
923
+ lensCall(lens, vote)
906
924
  )
907
925
  // Votes multiply only the adversarial lens: its recall is what independent
908
926
  // eyes improve; the concern lenses are cheap scoped questions.
@@ -925,6 +943,21 @@ export const reviewAndFixLoop = Effect.fn("@llm4ts/flow/Review.reviewAndFixLoop"
925
943
  }))
926
944
  )
927
945
  const parallelism = options.parallelism ?? 0
946
+ if (chosen.length > 1) {
947
+ yield* options.events.publish(
948
+ Info.make({
949
+ message: `review round ${round} of "${options.taskTitle}": ${chosen.length} lenses ${
950
+ parallelism === 1 ? "one at a time" : "in parallel"
951
+ } (${chosen
952
+ .map((lens) =>
953
+ lens.name === adversarialReviewer.name && votes > 1
954
+ ? `${lens.name} ×${votes}`
955
+ : lens.name
956
+ )
957
+ .join(", ")})`
958
+ })
959
+ )
960
+ }
928
961
  const results =
929
962
  parallelism > 0
930
963
  ? yield* Effect.forEach(chosen, run, { concurrency: parallelism })
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,
@@ -406,6 +407,8 @@ export interface LeaseOptions {
406
407
  readonly prefer?: string
407
408
  /** Who is asking, for the events (a story's worktree). */
408
409
  readonly label?: string
410
+ /** What the call is for beyond its role (a review lens), for the events. */
411
+ readonly purpose?: string
409
412
  /**
410
413
  * The executor holding this context's coder, taken when independence
411
414
  * cannot be had now: no executor outside `avoid` can ever take the role,
@@ -515,6 +518,9 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
515
518
  }
516
519
  }
517
520
 
521
+ const purposeOf = (who: LeaseOptions): { readonly purpose?: string } =>
522
+ who.purpose === undefined ? {} : { purpose: who.purpose }
523
+
518
524
  const say = (message: string): Effect.Effect<void> =>
519
525
  events.publish(Info.make({ message: `roster: ${message}` }))
520
526
 
@@ -598,13 +604,15 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
598
604
  const leaseOf = (
599
605
  spec: ExecutorSpec,
600
606
  role: Role,
601
- label: string | undefined,
607
+ who: LeaseOptions,
602
608
  because?: "nobody" | "held" | "out"
603
609
  ): Effect.Effect<Lease, never, Scope.Scope> =>
604
610
  Effect.gen(function* () {
605
- const labelled = label === undefined ? {} : { label }
611
+ const labelled = who.label === undefined ? {} : { label: who.label }
606
612
  const own = because === undefined ? {} : { because }
607
- yield* events.publish(ExecutorLeased.make({ executor: spec.id, role, ...labelled, ...own }))
613
+ yield* events.publish(
614
+ ExecutorLeased.make({ executor: spec.id, role, ...labelled, ...purposeOf(who), ...own })
615
+ )
608
616
  const done = yield* Ref.make(false)
609
617
  const free = Effect.flatMap(Ref.getAndSet(done, true), (was) =>
610
618
  was
@@ -622,13 +630,20 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
622
630
  const borrowedLease = (
623
631
  spec: ExecutorSpec,
624
632
  role: Role,
625
- label: string | undefined,
633
+ who: LeaseOptions,
626
634
  because: "nobody" | "held" | "out"
627
635
  ): Effect.Effect<Lease, never, Scope.Scope> =>
628
636
  Effect.gen(function* () {
629
- const labelled = label === undefined ? {} : { label }
637
+ const labelled = who.label === undefined ? {} : { label: who.label }
630
638
  yield* events.publish(
631
- ExecutorLeased.make({ executor: spec.id, role, ...labelled, borrowed: true, because })
639
+ ExecutorLeased.make({
640
+ executor: spec.id,
641
+ role,
642
+ ...labelled,
643
+ ...purposeOf(who),
644
+ borrowed: true,
645
+ because
646
+ })
632
647
  )
633
648
  const done = yield* Ref.make(false)
634
649
  const free = Effect.flatMap(Ref.getAndSet(done, true), (was) =>
@@ -841,11 +856,11 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
841
856
  })
842
857
  yield* waited
843
858
  return own !== undefined && own.id === borrow.id
844
- ? yield* leaseOf(own, role, leaseOptions.label, because)
859
+ ? yield* leaseOf(own, role, leaseOptions, because)
845
860
  : own !== undefined
846
861
  ? // Someone came back between the check and the pick: independent after all.
847
- yield* leaseOf(own, role, leaseOptions.label)
848
- : yield* borrowedLease(borrow, role, leaseOptions.label, because)
862
+ yield* leaseOf(own, role, leaseOptions)
863
+ : yield* borrowedLease(borrow, role, leaseOptions, because)
849
864
  })
850
865
  let announced = false
851
866
  const asked = yield* Clock.currentTimeMillis
@@ -866,7 +881,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
866
881
  const spec = yield* attempt(role, leaseOptions)
867
882
  if (spec !== undefined) {
868
883
  yield* waited
869
- return yield* leaseOf(spec, role, leaseOptions.label)
884
+ return yield* leaseOf(spec, role, leaseOptions)
870
885
  }
871
886
  if (!(yield* canEverServe(role, avoid))) {
872
887
  const own = yield* takeOwn("nobody")
@@ -897,15 +912,22 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
897
912
  }
898
913
  if (!announced) {
899
914
  announced = true
915
+ yield* events.publish(
916
+ Began.make({
917
+ kind: "wait",
918
+ label: `roster ${role}`,
919
+ ...(leaseOptions.label === undefined ? {} : { lane: leaseOptions.label })
920
+ })
921
+ )
900
922
  yield* say(
901
- `waiting for an executor to take ${role}${leaseOptions.label === undefined ? "" : ` for ${leaseOptions.label}`}: ${yield* waitingReport(role, avoid)}`
923
+ `waiting for an executor to take ${role}${leaseOptions.label === undefined ? "" : ` for ${leaseOptions.label}`}${leaseOptions.purpose === undefined ? "" : ` (${leaseOptions.purpose})`}: ${yield* waitingReport(role, avoid)}`
902
924
  )
903
925
  }
904
926
  yield* probeDue
905
927
  const recovered = yield* attempt(role, leaseOptions)
906
928
  if (recovered !== undefined) {
907
929
  yield* waited
908
- return yield* leaseOf(recovered, role, leaseOptions.label)
930
+ return yield* leaseOf(recovered, role, leaseOptions)
909
931
  }
910
932
  yield* waitOrProbe(role)
911
933
  }
@@ -916,7 +938,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
916
938
  leaseOptions: LeaseOptions = {}
917
939
  ): Effect.Effect<Lease | undefined, never, Scope.Scope> =>
918
940
  Effect.flatMap(attempt(role, leaseOptions), (spec) =>
919
- spec === undefined ? Effect.succeed(undefined) : leaseOf(spec, role, leaseOptions.label)
941
+ spec === undefined ? Effect.succeed(undefined) : leaseOf(spec, role, leaseOptions)
920
942
  )
921
943
 
922
944
  const exclude = (exclusion: Exclusion): Effect.Effect<void> =>
@@ -13,6 +13,7 @@ import type { LlmServiceShape } from "@llm4ts/core/LlmService"
13
13
  import { Message, type ConnectorCapabilities, type LlmChunk } from "@llm4ts/core/Models"
14
14
  import { describeFlowError, type FlowError } from "./FlowError.ts"
15
15
  import { ExecutorHandedOver, type FlowEventsShape } from "./FlowEvents.ts"
16
+ import { callPurpose } from "./Timing.ts"
16
17
  import {
17
18
  describeExclusion,
18
19
  hasRole,
@@ -141,10 +142,12 @@ export const rosterSeat = (
141
142
  // The roster decides who serves: an independent executor, or, when
142
143
  // none can ever serve or every one is held by a coder, the context's own.
143
144
  const borrow = options.borrow === undefined ? undefined : yield* options.borrow
145
+ const purpose = yield* callPurpose
144
146
  const lease = yield* roster
145
147
  .lease(role, {
146
148
  avoid,
147
149
  ...(options.label === undefined ? {} : { label: options.label }),
150
+ ...(purpose === undefined ? {} : { purpose }),
148
151
  ...(borrow === undefined ? {} : { borrow })
149
152
  })
150
153
  .pipe(Effect.mapError(asLlmError))
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,
@@ -178,6 +178,22 @@ const TimedRole = Context.Reference<string | undefined>("@llm4ts/flow/Timing/Tim
178
178
  defaultValue: () => undefined
179
179
  })
180
180
 
181
+ /**
182
+ * What a call is for, beyond its role: a review lens, a vote. A roster
183
+ * lease names it (`topolino takes reviewer for home · adversarial lens`),
184
+ * so parallel calls of one role read as the different questions they are.
185
+ */
186
+ const CallPurpose = Context.Reference<string | undefined>("@llm4ts/flow/Timing/CallPurpose", {
187
+ defaultValue: () => undefined
188
+ })
189
+
190
+ /** The purpose the current call is made for, when its caller named one. */
191
+ export const callPurpose: Effect.Effect<string | undefined> = CallPurpose
192
+
193
+ /** Runs `effect`'s calls under `purpose`, for the roster's lease events. */
194
+ export const withCallPurpose = <A, E, R>(purpose: string, effect: Effect.Effect<A, E, R>) =>
195
+ Effect.provideService(effect, CallPurpose, purpose)
196
+
181
197
  /** The role the current call is made for: the caller's, or else `label`. */
182
198
  export const roleOr = (label: string): Effect.Effect<string> =>
183
199
  Effect.map(TimedRole, (role) => role ?? label)
@@ -192,16 +208,24 @@ const reportedMs = (chunk: LlmChunk, key: string): number | undefined => {
192
208
  return Number.isFinite(value) ? value : undefined
193
209
  }
194
210
 
195
- /** Times `effect`, publishing what `timed` makes of its duration whether it succeeds or fails. */
211
+ /**
212
+ * Times `effect`, publishing what `timed` makes of its duration whether it
213
+ * succeeds or fails; with `began`, also what it makes of the start, when the
214
+ * effect starts, so a screen shows the work while it runs.
215
+ */
196
216
  export const timeEffect = <A, E, R>(
197
217
  events: FlowEventsShape,
198
218
  effect: Effect.Effect<A, E, R>,
199
- timed: (ms: number, failed: boolean, role: string | undefined) => Timed
219
+ timed: (ms: number, failed: boolean, role: string | undefined) => Timed,
220
+ began?: (role: string | undefined) => Began
200
221
  ): Effect.Effect<A, E, R> =>
201
222
  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))
223
+ Effect.andThen(
224
+ began === undefined ? Effect.void : events.publish(began(role)),
225
+ Effect.onExit(effect, (exit) =>
226
+ Effect.flatMap(Clock.currentTimeMillis, (end) =>
227
+ events.publish(timed(end - start, Exit.isFailure(exit), role))
228
+ )
205
229
  )
206
230
  )
207
231
  )
@@ -239,7 +263,7 @@ const tracedEffect = <A, E, R>(
239
263
  ...promptContent(content, [Message.make({ role: "User", content: prompt })])
240
264
  }
241
265
  },
242
- timeEffect(events, effect, timed).pipe(
266
+ timeEffect(events, effect, timed, () => Began.make({ kind: "model", label: role })).pipe(
243
267
  Effect.onExit((exit) => {
244
268
  const known = Exit.isSuccess(exit)
245
269
  ? usageOf(exit.value)
@@ -282,6 +306,7 @@ const timedStream = <E>(
282
306
  // The call's LLM span (ADR 0026), parented to the nearest kind span and
283
307
  // ended when the stream does; each harness tool call is a TOOL child,
284
308
  // opened on `tool_use` and closed on `tool_result`.
309
+ yield* events.publish(Began.make({ kind: "model", label: role }))
285
310
  const parent = yield* currentKindSpan
286
311
  const span = yield* Effect.makeSpan(role, {
287
312
  kind: "client",
@@ -488,12 +513,16 @@ export const timedSeat = (
488
513
  export const timedJudgment = (judgment: JudgmentShape, events: FlowEventsShape): JudgmentShape => ({
489
514
  ...judgment,
490
515
  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
- })
516
+ timeEffect(
517
+ events,
518
+ judgment.judge(input),
519
+ (ms, failed, role) =>
520
+ Timed.make({
521
+ kind: "model",
522
+ label: role ?? "judgment",
523
+ ms,
524
+ ...(failed ? { failed: true } : {})
525
+ }),
526
+ (role) => Began.make({ kind: "model", label: role ?? "judgment" })
498
527
  )
499
528
  })