@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/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
- const probing = yield* lock.withPermit(
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,
@@ -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
- mergeLock.withPermit(
937
- Effect.gen(function* () {
938
- const lane = laneOf(story)
939
- // Merges are one at a time: how long this story queued for its turn.
940
- const turn = yield* Clock.currentTimeMillis
941
- yield* lane.publish(
942
- Timed.make({ kind: "wait", label: "merge lock", ms: turn - requested })
943
- )
944
- yield* epicCheckoutClean(story)
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
- if (gateCommands !== undefined) {
970
- const mergedHead = yield* context.git.checkpoint
971
- yield* writeBaseline(
972
- files,
973
- options.stateDir,
974
- keyFor(mergedHead),
975
- 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" })
976
1005
  )
977
- }
978
- yield* laneOf(story).publish(
979
- Info.make({ message: `story ${story.id}: merged into ${epicBranch}` })
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
- 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 ?? []
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) => planTasks(watchedSeats, story, 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 launch more stories than there are free
1828
- // coders; with nothing running, launch one anyway — its lease waits
1829
- // for the first executor to come back (ADR 0019).
1830
- const free =
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.available("coder")
1834
- const budget = Math.min(concurrency - current.running.size, free)
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
- for (const story of ready.slice(0, Math.max(0, slots))) {
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
- const completion = yield* Queue.take(completions)
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
- /** 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
  })