@llm4ts/flow 2.19.0 → 2.20.0

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.
@@ -51,6 +51,8 @@ export interface FlowContextShape {
51
51
  * this context's coder. Absent on a run with one executor per seat.
52
52
  */
53
53
  readonly roster?: RosterView
54
+ /** The trace this run writes (ADR 0022), when it writes one: the root context only. */
55
+ readonly trace?: { readonly runId: string; readonly path: string }
54
56
  }
55
57
 
56
58
  export class FlowContext extends Context.Service<FlowContext, FlowContextShape>()(
package/src/FlowEvents.ts CHANGED
@@ -181,6 +181,106 @@ export class BlackboardRun extends Schema.TaggedClass<BlackboardRun>()("Blackboa
181
181
  result: RunResult
182
182
  }) {}
183
183
 
184
+ /** One rubric dimension of a story verdict: its score out of `max`. */
185
+ export const JudgedDimension = Schema.Struct({
186
+ id: Schema.String,
187
+ score: Schema.Number,
188
+ max: Schema.Number
189
+ })
190
+ export type JudgedDimension = typeof JudgedDimension.Type
191
+
192
+ /** The story judge ruled on a story's branch (ADR 0022): one event per judge round. */
193
+ export class StoryJudged extends Schema.TaggedClass<StoryJudged>()("StoryJudged", {
194
+ lane: Schema.String,
195
+ round: Schema.Int,
196
+ cleared: Schema.Boolean,
197
+ /** Issues the coder gets back when it is not cleared. */
198
+ issues: Schema.Int,
199
+ /** The rubric's scores, when the judge reports them. */
200
+ dimensions: Schema.Array(JudgedDimension)
201
+ }) {}
202
+
203
+ /**
204
+ * The executor roster's events (ADR 0019, 0022). `label` names who holds the
205
+ * lease — a story's id, or "the run" — so a view can put an executor on its lane.
206
+ */
207
+ export class ExecutorLeased extends Schema.TaggedClass<ExecutorLeased>()("ExecutorLeased", {
208
+ executor: Schema.String,
209
+ role: Schema.String,
210
+ label: Schema.optionalKey(Schema.String),
211
+ /** Took the role on its own context's coder slot: nobody independent could. */
212
+ borrowed: Schema.optionalKey(Schema.Boolean)
213
+ }) {}
214
+
215
+ export class ExecutorReleased extends Schema.TaggedClass<ExecutorReleased>()("ExecutorReleased", {
216
+ executor: Schema.String,
217
+ role: Schema.String,
218
+ label: Schema.optionalKey(Schema.String)
219
+ }) {}
220
+
221
+ export class ExecutorExcluded extends Schema.TaggedClass<ExecutorExcluded>()("ExecutorExcluded", {
222
+ executor: Schema.String,
223
+ /** How long and why, as the roster describes it ("for this run: not logged in"). */
224
+ reason: Schema.String
225
+ }) {}
226
+
227
+ export class ExecutorResumed extends Schema.TaggedClass<ExecutorResumed>()("ExecutorResumed", {
228
+ executor: Schema.String,
229
+ /** `expired`: its exclusion ran out; `health`: its engine answers again; `resumed`: by hand. */
230
+ why: Schema.Literals(["expired", "health", "resumed"])
231
+ }) {}
232
+
233
+ export class ExecutorHandedOver extends Schema.TaggedClass<ExecutorHandedOver>()(
234
+ "ExecutorHandedOver",
235
+ {
236
+ from: Schema.String,
237
+ role: Schema.String,
238
+ label: Schema.optionalKey(Schema.String),
239
+ reason: Schema.String,
240
+ /** `call`: one call moves to another executor; `coder`: a context's coder changes hands. */
241
+ scope: Schema.Literals(["call", "coder"])
242
+ }
243
+ ) {}
244
+
245
+ export type RosterEvent =
246
+ | ExecutorLeased
247
+ | ExecutorReleased
248
+ | ExecutorExcluded
249
+ | ExecutorResumed
250
+ | ExecutorHandedOver
251
+
252
+ const resumedWords: Readonly<Record<ExecutorResumed["why"], string>> = {
253
+ expired: "back in the round",
254
+ health: "answers its health check again; back in the round",
255
+ resumed: "resumed"
256
+ }
257
+
258
+ /**
259
+ * The line the classic terminal shows for a roster event — the same words
260
+ * the roster used to publish as `Info` — or `undefined` for one it does not
261
+ * show (a release) and for any other event.
262
+ */
263
+ export const rosterEventMessage = (event: FlowEvent): string | undefined => {
264
+ const forLabel = (label: string | undefined): string =>
265
+ label === undefined ? "" : ` for ${label}`
266
+ switch (event._tag) {
267
+ case "ExecutorLeased":
268
+ return event.borrowed === true
269
+ ? `roster: ${event.executor} takes ${event.role}${forLabel(event.label)} on its own coder's slot — not independent (no other executor can take ${event.role})`
270
+ : `roster: ${event.executor} takes ${event.role}${forLabel(event.label)}`
271
+ case "ExecutorExcluded":
272
+ return `roster: ${event.executor} out of the round ${event.reason}`
273
+ case "ExecutorResumed":
274
+ return `roster: ${event.executor} ${resumedWords[event.why]}`
275
+ case "ExecutorHandedOver":
276
+ return event.scope === "coder"
277
+ ? `roster: ${event.label ?? "the run"}: handing the coder over from ${event.from} (${event.reason})`
278
+ : `roster: ${event.role}${forLabel(event.label)} moves off ${event.from} (${event.reason})`
279
+ default:
280
+ return undefined
281
+ }
282
+ }
283
+
184
284
  export const FlowEvent = Schema.Union([
185
285
  JudgmentObserved,
186
286
  BlackboardRun,
@@ -197,7 +297,13 @@ export const FlowEvent = Schema.Union([
197
297
  CapabilityUsedEvent,
198
298
  CapabilityDeniedEvent,
199
299
  CapabilityUnenforceable,
200
- Declassified
300
+ Declassified,
301
+ ExecutorLeased,
302
+ ExecutorReleased,
303
+ ExecutorExcluded,
304
+ ExecutorResumed,
305
+ ExecutorHandedOver,
306
+ StoryJudged
201
307
  ])
202
308
  export type FlowEvent = typeof FlowEvent.Type
203
309
 
@@ -20,11 +20,17 @@ export class TraceLine extends Schema.Class<TraceLine>("TraceLine")({
20
20
  fields: Schema.Record(Schema.String, Schema.Json)
21
21
  }) {}
22
22
 
23
+ /** How a run ended, as its trace's last line says (ADR 0022). */
24
+ export const RunOutcome = Schema.Literals(["completed", "failed", "interrupted"])
25
+ export type RunOutcome = typeof RunOutcome.Type
26
+
23
27
  export interface FlowRecorderShape extends StreamRecorder {
24
28
  readonly runId: string
25
29
  readonly tracePath: string
26
30
  readonly record: (event: FlowEvent) => Effect.Effect<void>
27
31
  readonly consume: (hub: FlowEventHub) => Effect.Effect<void, never, Scope.Scope>
32
+ /** Appends the `RunEnded` line: a reader knows the run is over, not stalled. */
33
+ readonly end: (outcome: RunOutcome) => Effect.Effect<void>
28
34
  /** Waits for this recorder to catch up; `false` means it gave up first. */
29
35
  readonly awaitDrained: (hub: FlowEventHub, timeout?: Duration.Input) => Effect.Effect<boolean>
30
36
  }
@@ -104,6 +110,7 @@ export const makeFlowRecorder = Effect.fn("@llm4ts/flow/FlowRecorder.make")(func
104
110
  Effect.asVoid
105
111
  )
106
112
  }),
113
+ end: (outcome) => append("RunEnded", { outcome }),
107
114
  awaitDrained: (hub, timeout) => awaitConsumed(hub, consumed, timeout)
108
115
  }
109
116
  return recorder
package/src/Roster.ts CHANGED
@@ -14,7 +14,14 @@ import type * as Scope from "effect/Scope"
14
14
  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
- import { Info, type FlowEventsShape } from "./FlowEvents.ts"
17
+ import {
18
+ ExecutorExcluded,
19
+ ExecutorLeased,
20
+ ExecutorReleased,
21
+ ExecutorResumed,
22
+ Info,
23
+ type FlowEventsShape
24
+ } from "./FlowEvents.ts"
18
25
  import { loadVersioned, saveVersioned, type PlainFileStoreShape } from "./Persistence.ts"
19
26
  import { isOutage } from "./TransientRetry.ts"
20
27
 
@@ -541,11 +548,22 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
541
548
  )
542
549
  .pipe(Effect.andThen(signal))
543
550
 
544
- const leaseOf = (spec: ExecutorSpec, role: Role): Effect.Effect<Lease, never, Scope.Scope> =>
551
+ const leaseOf = (
552
+ spec: ExecutorSpec,
553
+ role: Role,
554
+ label: string | undefined
555
+ ): Effect.Effect<Lease, never, Scope.Scope> =>
545
556
  Effect.gen(function* () {
557
+ const labelled = label === undefined ? {} : { label }
558
+ yield* events.publish(ExecutorLeased.make({ executor: spec.id, role, ...labelled }))
546
559
  const done = yield* Ref.make(false)
547
560
  const free = Effect.flatMap(Ref.getAndSet(done, true), (was) =>
548
- was ? Effect.void : release(spec, role)
561
+ was
562
+ ? Effect.void
563
+ : Effect.andThen(
564
+ release(spec, role),
565
+ events.publish(ExecutorReleased.make({ executor: spec.id, role, ...labelled }))
566
+ )
549
567
  )
550
568
  yield* Effect.addFinalizer(() => free)
551
569
  return { executor: spec, role, release: free }
@@ -577,7 +595,9 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
577
595
  })
578
596
  )
579
597
  if (back.length > 0) {
580
- yield* say(`${back.join(", ")} back in the round`)
598
+ yield* Effect.forEach(back, (id) =>
599
+ events.publish(ExecutorResumed.make({ executor: id, why: "expired" }))
600
+ )
581
601
  yield* persist
582
602
  }
583
603
  return spec
@@ -638,7 +658,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
638
658
  })
639
659
  )
640
660
  if (up) {
641
- yield* say(`${exclusion.id} answers its health check again; back in the round`)
661
+ yield* events.publish(ExecutorResumed.make({ executor: exclusion.id, why: "health" }))
642
662
  yield* persist
643
663
  yield* signal
644
664
  }
@@ -693,10 +713,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
693
713
  while (true) {
694
714
  const spec = yield* attempt(role, leaseOptions)
695
715
  if (spec !== undefined) {
696
- yield* say(
697
- `${spec.id} takes ${role}${leaseOptions.label === undefined ? "" : ` for ${leaseOptions.label}`}`
698
- )
699
- return yield* leaseOf(spec, role)
716
+ return yield* leaseOf(spec, role, leaseOptions.label)
700
717
  }
701
718
  if (!(yield* canEverServe(role, avoid))) {
702
719
  const reasons = yield* lock.withPermit(
@@ -723,10 +740,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
723
740
  yield* probeDue
724
741
  const recovered = yield* attempt(role, leaseOptions)
725
742
  if (recovered !== undefined) {
726
- yield* say(
727
- `${recovered.id} takes ${role}${leaseOptions.label === undefined ? "" : ` for ${leaseOptions.label}`}`
728
- )
729
- return yield* leaseOf(recovered, role)
743
+ return yield* leaseOf(recovered, role, leaseOptions.label)
730
744
  }
731
745
  const probing = yield* lock.withPermit(
732
746
  Effect.sync(() =>
@@ -744,7 +758,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
744
758
  leaseOptions: LeaseOptions = {}
745
759
  ): Effect.Effect<Lease | undefined, never, Scope.Scope> =>
746
760
  Effect.flatMap(attempt(role, leaseOptions), (spec) =>
747
- spec === undefined ? Effect.succeed(undefined) : leaseOf(spec, role)
761
+ spec === undefined ? Effect.succeed(undefined) : leaseOf(spec, role, leaseOptions.label)
748
762
  )
749
763
 
750
764
  const exclude = (exclusion: Exclusion): Effect.Effect<void> =>
@@ -753,7 +767,9 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
753
767
  return
754
768
  }
755
769
  yield* lock.withPermit(Effect.sync(() => exclusions.set(exclusion.id, exclusion)))
756
- yield* say(`${exclusion.id} out of the round ${describeExclusion(exclusion)}`)
770
+ yield* events.publish(
771
+ ExecutorExcluded.make({ executor: exclusion.id, reason: describeExclusion(exclusion) })
772
+ )
757
773
  yield* persist
758
774
  yield* signal
759
775
  })
@@ -790,7 +806,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
790
806
  Effect.gen(function* () {
791
807
  const removed = yield* lock.withPermit(Effect.sync(() => exclusions.delete(id)))
792
808
  if (removed) {
793
- yield* say(`${id} resumed`)
809
+ yield* events.publish(ExecutorResumed.make({ executor: id, why: "resumed" }))
794
810
  yield* persist
795
811
  yield* signal
796
812
  }
@@ -12,7 +12,7 @@ import { ConfigError, type LlmError } from "@llm4ts/core/Errors"
12
12
  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
- import { Info, type FlowEventsShape } from "./FlowEvents.ts"
15
+ import { ExecutorHandedOver, ExecutorLeased, type FlowEventsShape } from "./FlowEvents.ts"
16
16
  import {
17
17
  describeExclusion,
18
18
  hasRole,
@@ -51,9 +51,6 @@ export interface RosterView {
51
51
  const asLlmError = (error: FlowError): LlmError =>
52
52
  ConfigError.make({ message: describeFlowError(error) })
53
53
 
54
- const say = (events: FlowEventsShape, message: string): Effect.Effect<void> =>
55
- events.publish(Info.make({ message: `roster: ${message}` }))
56
-
57
54
  /** Shared plumbing: every service method as "run this on some seat". */
58
55
  interface SeatRunner {
59
56
  readonly run: <A, R>(
@@ -133,9 +130,13 @@ export const rosterSeat = (
133
130
  if (avoid.length > 0 && !(yield* roster.canEverServe(role, avoid))) {
134
131
  const borrowed = options.borrow === undefined ? undefined : yield* options.borrow
135
132
  if (borrowed !== undefined && hasRole(borrowed, role)) {
136
- yield* say(
137
- options.events,
138
- `${borrowed.id} takes ${role}${options.label === undefined ? "" : ` for ${options.label}`} on its own coder's slot — not independent (no other executor can take ${role})`
133
+ yield* options.events.publish(
134
+ ExecutorLeased.make({
135
+ executor: borrowed.id,
136
+ role,
137
+ ...(options.label === undefined ? {} : { label: options.label }),
138
+ borrowed: true
139
+ })
139
140
  )
140
141
  return borrowed
141
142
  }
@@ -167,9 +168,14 @@ export const rosterSeat = (
167
168
  const avoid = options.avoid === undefined ? [] : yield* options.avoid
168
169
  const other = yield* roster.canEverServe(role, [...avoid, executor.id])
169
170
  if (other) {
170
- yield* say(
171
- options.events,
172
- `${role}${options.label === undefined ? "" : ` for ${options.label}`} moves off ${executor.id} (${describeExclusion(exclusion)})`
171
+ yield* options.events.publish(
172
+ ExecutorHandedOver.make({
173
+ from: executor.id,
174
+ role,
175
+ ...(options.label === undefined ? {} : { label: options.label }),
176
+ reason: describeExclusion(exclusion),
177
+ scope: "call"
178
+ })
173
179
  )
174
180
  }
175
181
  return other
@@ -318,9 +324,14 @@ export const makeHeldCoder = Effect.fn("@llm4ts/flow/RosterSeats.heldCoder")(fun
318
324
  yield* Ref.set(prefer, undefined)
319
325
  yield* held.lease.release
320
326
  yield* Ref.set(current, undefined)
321
- yield* say(
322
- options.events,
323
- `${options.label ?? workDir}: handing the coder over from ${held.lease.executor.id} (${describeExclusion(exclusion)})`
327
+ yield* options.events.publish(
328
+ ExecutorHandedOver.make({
329
+ from: held.lease.executor.id,
330
+ role: "coder",
331
+ label: options.label ?? workDir,
332
+ reason: describeExclusion(exclusion),
333
+ scope: "coder"
334
+ })
324
335
  )
325
336
  return takeoverNote(held.lease.executor.id, exclusion.reason)
326
337
  })
package/src/Stories.ts CHANGED
@@ -28,7 +28,7 @@ import {
28
28
  StoryFailed,
29
29
  type FlowError
30
30
  } from "./FlowError.ts"
31
- import { Info, withLane, type FlowEventsShape } from "./FlowEvents.ts"
31
+ import { Info, JudgedDimension, StoryJudged, withLane, type FlowEventsShape } from "./FlowEvents.ts"
32
32
  import { statusPaths, type GitToolShape } from "./GitTool.ts"
33
33
  import {
34
34
  checkPerimeter,
@@ -70,6 +70,11 @@ const join = (root: string, path: string): string =>
70
70
  // ---- Story seats ----------------------------------------------------------
71
71
 
72
72
  /** A flow context rooted in a story's worktree, plus that story's own usage totals. */
73
+ /** A story judge's review with the rubric's scores; a plain `ReviewResult` has none. */
74
+ export class StoryVerdict extends ReviewResult.extend<StoryVerdict>("StoryVerdict")({
75
+ dimensions: Schema.Array(JudgedDimension)
76
+ }) {}
77
+
73
78
  export interface StorySeats {
74
79
  readonly context: FlowContextShape
75
80
  /** Running usage of this story's seats — estimates where the backend reports none. */
@@ -986,6 +991,15 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
986
991
  return yield* failed(story, "the story branch has no changes against the epic branch")
987
992
  }
988
993
  const verdict = yield* judge(story, diff, watchedSeats)
994
+ yield* laneOf(story).publish(
995
+ StoryJudged.make({
996
+ lane: story.id,
997
+ round,
998
+ cleared: verdict.isClean,
999
+ issues: verdict.issues.length,
1000
+ dimensions: verdict instanceof StoryVerdict ? verdict.dimensions : []
1001
+ })
1002
+ )
989
1003
  if (verdict.isClean) {
990
1004
  judgeNote = `judge cleared (round ${round})`
991
1005
  break