@llm4ts/runner 2.8.0 → 2.9.1

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/FlowRunner.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path"
2
2
  import * as Clock from "effect/Clock"
3
3
  import * as Effect from "effect/Effect"
4
+ import * as Ref from "effect/Ref"
4
5
  import type * as Scope from "effect/Scope"
5
6
  import type { ConnectorConfig } from "@llm4ts/core/ConnectorConfig"
6
7
  import { CliConnectorConfig, defaultReasoningConfig } from "@llm4ts/core/ConnectorConfig"
@@ -13,6 +14,7 @@ import { makeTypeSafeJudgment } from "@llm4ts/core/judgment/TypeSafeJudgment"
13
14
  import * as Redacted from "effect/Redacted"
14
15
  import type { LlmServiceShape } from "@llm4ts/core/LlmService"
15
16
  import type { ConnectorCapabilities, TokenUsage } from "@llm4ts/core/Models"
17
+ import { ConnectorCapabilities as ConnectorCapabilitiesClass } from "@llm4ts/core/Models"
16
18
  import type { ProcessExecutorShape } from "@llm4ts/core/ProcessExecutor"
17
19
  import type { TemporaryFilesShape } from "@llm4ts/core/TemporaryFiles"
18
20
  import type { GeminiCliExecutorShape } from "@llm4ts/core/providers/GeminiCliProvider"
@@ -35,7 +37,12 @@ import {
35
37
  type CostBudget
36
38
  } from "@llm4ts/flow/CostLedger"
37
39
  import { estimatedUsageOptionsFromEnv, makeEstimatedUsageMeter } from "@llm4ts/flow/EstimatedUsage"
38
- import { FlowLlmError, describeFlowError, type FlowError } from "@llm4ts/flow/FlowError"
40
+ import {
41
+ FlowLlmError,
42
+ RosterInvalid,
43
+ describeFlowError,
44
+ type FlowError
45
+ } from "@llm4ts/flow/FlowError"
39
46
  import {
40
47
  defaultDrainTimeout,
41
48
  FlowEvents,
@@ -43,15 +50,39 @@ import {
43
50
  Info,
44
51
  TokensUsed,
45
52
  makeFlowEventHub,
46
- type FlowEventHub
53
+ withLane,
54
+ type FlowEventHub,
55
+ type FlowEventsShape
47
56
  } from "@llm4ts/flow/FlowEvents"
48
- import { FlowContext, type FlowContextShape } from "@llm4ts/flow/FlowContext"
57
+ import { FlowContext, type ContextOptions, type FlowContextShape } from "@llm4ts/flow/FlowContext"
49
58
  import { makeFlowRecorder } from "@llm4ts/flow/FlowRecorder"
50
59
  import { makeJudgmentLog, type JudgmentLogShape } from "@llm4ts/flow/JudgmentLog"
51
60
  import { makeGitHubTool } from "@llm4ts/flow/GitHubTool"
52
61
  import { makeGitTool } from "@llm4ts/flow/GitTool"
53
62
  import type { PlainFileStoreShape } from "@llm4ts/flow/Persistence"
54
63
  import { makeTransientRetry } from "@llm4ts/flow/TransientRetry"
64
+ import {
65
+ Exclusion,
66
+ makeRoster,
67
+ makeRosterStateStore,
68
+ priorityOf,
69
+ rolesOf,
70
+ type ExecutorSpec,
71
+ type Role,
72
+ type RosterDocument,
73
+ type RosterShape
74
+ } from "@llm4ts/flow/Roster"
75
+ import {
76
+ makeHeldCoder,
77
+ rosterSeat,
78
+ type HeldCoder,
79
+ type RosterSeat,
80
+ type RosterView,
81
+ type SeatSource
82
+ } from "@llm4ts/flow/RosterSeats"
83
+ import * as Semaphore from "effect/Semaphore"
84
+ import * as ScopeModule from "effect/Scope"
85
+ import { executorConfig, httpProbe, loadRosterDocument, rosterStatePath } from "./ExecutorRoster.ts"
55
86
  import { nodeHttpClient } from "./NodeHttpClient.ts"
56
87
  import { nodeGeminiCliExecutor } from "./NodeGeminiCliExecutor.ts"
57
88
  import { nodePlainFileStore } from "./NodePlainFileStore.ts"
@@ -158,10 +189,19 @@ export interface FlowRunnerOptions {
158
189
  * nothing (see the connector capability matrix).
159
190
  */
160
191
  readonly budget?: CostBudget
192
+ /**
193
+ * The executor roster (ADR 0019): every seat is then served from it and
194
+ * the coder/reasoning/reviewer configs above are ignored. Omitted, the
195
+ * runner loads one (`LLM4TS_ROSTER`, the user's and the repository's
196
+ * roster files); `"none"` runs with one executor per seat.
197
+ */
198
+ readonly roster?: RosterDocument | "none"
161
199
  }
162
200
 
163
201
  export interface FlowRunnerBundle {
164
202
  readonly context: FlowContextShape
203
+ /** The executor roster in force (ADR 0019), when the run has one. */
204
+ readonly roster?: RosterDocument
165
205
  readonly events: FlowEventHub
166
206
  /** The run's cost tracker, already subscribed to `events`. */
167
207
  readonly tracker: CostTracker
@@ -407,15 +447,16 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
407
447
  readonly reviewers: ReadonlyArray<LlmServiceShape>
408
448
  readonly judgment: JudgmentShape
409
449
  },
410
- rebind: boolean
450
+ rebind: boolean,
451
+ laneEvents: FlowEventsShape = events
411
452
  ): FlowContextShape =>
412
453
  FlowContext.of({
413
454
  reasoning: seats.reasoning,
414
455
  coder: seats.coder,
415
456
  judgment: seats.judgment,
416
- git: makeGitTool(dependencies.process, workDir, events),
417
- hosting: makeGitHubTool(dependencies.process, workDir, events),
418
- events,
457
+ git: makeGitTool(dependencies.process, workDir, laneEvents),
458
+ hosting: makeGitHubTool(dependencies.process, workDir, laneEvents),
459
+ events: laneEvents,
419
460
  reviewers: seats.reviewers,
420
461
  coderCapabilities: seats.coder.capabilities,
421
462
  userPrompt: options.userPrompt,
@@ -425,11 +466,253 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
425
466
  // `contextFor` of its own — nothing in the design nests worktrees.
426
467
  ...(rebind
427
468
  ? {
428
- contextFor: (directory: string) =>
429
- Effect.map(seatsFor(directory), (rebound) => contextAt(directory, rebound, false))
469
+ contextFor: (directory: string, contextOptions?: ContextOptions) =>
470
+ Effect.map(seatsFor(directory), (rebound) =>
471
+ contextAt(
472
+ directory,
473
+ rebound,
474
+ false,
475
+ contextOptions?.label === undefined
476
+ ? events
477
+ : withLane(events, { lane: contextOptions.label, workDir: directory })
478
+ )
479
+ )
430
480
  }
431
481
  : {})
432
482
  })
483
+ /**
484
+ * One look at every executor before the first lease: a health URL that
485
+ * does not answer waits in the round for its engine; a harness that cannot
486
+ * start (not installed, not signed in) sits the run out.
487
+ */
488
+ const probeExecutors = (
489
+ roster: RosterShape,
490
+ seatFor: (
491
+ executor: ExecutorSpec,
492
+ role: Role,
493
+ workDir: string
494
+ ) => Effect.Effect<RosterSeat, FlowError>,
495
+ workDir: string
496
+ ): Effect.Effect<void> =>
497
+ Effect.forEach(
498
+ roster.executors,
499
+ (executor) =>
500
+ Effect.gen(function* () {
501
+ if (executor.health !== undefined) {
502
+ if (!(yield* httpProbe(executor.health))) {
503
+ yield* roster.exclude(
504
+ Exclusion.make({
505
+ id: executor.id,
506
+ kind: "health",
507
+ until: yield* Clock.currentTimeMillis,
508
+ reason: `its health check ${executor.health} does not answer`
509
+ })
510
+ )
511
+ }
512
+ return
513
+ }
514
+ const role = rolesOf(executor)[0] ?? "coder"
515
+ const seat = yield* Effect.result(seatFor(executor, role, workDir))
516
+ if (seat._tag === "Failure") {
517
+ yield* roster.exclude(
518
+ Exclusion.make({
519
+ id: executor.id,
520
+ kind: "run",
521
+ reason: `could not start: ${describeFlowError(seat.failure)}`
522
+ })
523
+ )
524
+ return
525
+ }
526
+ if (!(yield* seat.success.isAvailable)) {
527
+ yield* roster.exclude(
528
+ Exclusion.make({
529
+ id: executor.id,
530
+ kind: "run",
531
+ reason: `its harness '${executor.harness}' is not available (not installed, or not signed in)`
532
+ })
533
+ )
534
+ }
535
+ }),
536
+ { discard: true, concurrency: "unbounded" }
537
+ )
538
+
539
+ /**
540
+ * Every seat from the roster (ADR 0019): a lazily held coder at the root,
541
+ * an eagerly held one per `contextFor` (a story waits there for a slot),
542
+ * and reasoning seats leased per call, independent of the coder.
543
+ */
544
+ const rosterContext = Effect.fn("@llm4ts/runner/FlowRunner.rosterContext")(function* (
545
+ document: RosterDocument
546
+ ): Effect.fn.Return<FlowContextShape, FlowError, Scope.Scope> {
547
+ const runScope = yield* Effect.scope
548
+ const roster = yield* makeRoster({
549
+ executors: document.executors,
550
+ events,
551
+ state: makeRosterStateStore(dependencies.files, rosterStatePath(runEnvironment)),
552
+ probe: httpProbe
553
+ })
554
+ const seatCache = new Map<string, RosterSeat>()
555
+ const cacheLock = yield* Semaphore.make(1)
556
+ const seatFor = (
557
+ executor: ExecutorSpec,
558
+ role: Role,
559
+ workDir: string
560
+ ): Effect.Effect<RosterSeat, FlowError> =>
561
+ cacheLock.withPermit(
562
+ Effect.gen(function* () {
563
+ const readOnly = role !== "coder"
564
+ const key = `${executor.id}\u0000${readOnly ? "ro" : "rw"}\u0000${workDir}`
565
+ const cached = seatCache.get(key)
566
+ if (cached !== undefined) {
567
+ return cached
568
+ }
569
+ const configuration = executorConfig(executor, readOnly, runEnvironment)
570
+ if (configuration === undefined) {
571
+ return yield* RosterInvalid.make({
572
+ violations: [`executor '${executor.id}' has an unknown harness '${executor.harness}'`]
573
+ })
574
+ }
575
+ const seat: RosterSeat = yield* resolveSeat(
576
+ prepareConnector(bridged(configuration), workDir, runEnvironment)
577
+ ).pipe(ScopeModule.provide(runScope))
578
+ seatCache.set(key, seat)
579
+ return seat
580
+ })
581
+ )
582
+ const source: SeatSource = { seatFor }
583
+ yield* probeExecutors(roster, seatFor, options.workDir)
584
+
585
+ const judgmentConfig =
586
+ options.judgment ??
587
+ document.executors
588
+ .filter((spec) => rolesOf(spec).includes("judge"))
589
+ .flatMap((spec) => {
590
+ const configuration = executorConfig(spec, true, runEnvironment)
591
+ return configuration === undefined ? [] : [configuration]
592
+ })[0] ??
593
+ reasoning
594
+ const coderCapabilities = (workDir: string, held: HeldCoder) =>
595
+ Effect.gen(function* () {
596
+ const current = yield* held.lease
597
+ const first =
598
+ current ??
599
+ [...document.executors]
600
+ .filter((spec) => rolesOf(spec).includes("coder"))
601
+ .sort((left, right) => priorityOf(left, "coder") - priorityOf(right, "coder"))[0]
602
+ if (first === undefined) {
603
+ return ConnectorCapabilitiesClass.make({})
604
+ }
605
+ return yield* Effect.map(Effect.result(seatFor(first, "coder", workDir)), (result) =>
606
+ result._tag === "Success"
607
+ ? result.success.capabilities
608
+ : ConnectorCapabilitiesClass.make({})
609
+ )
610
+ })
611
+ const viewFor = (
612
+ held: HeldCoder,
613
+ workDir: string,
614
+ label: string,
615
+ laneEvents: FlowEventsShape
616
+ ): RosterView => ({
617
+ forRole: (role) =>
618
+ rosterSeat(roster, source, role, workDir, {
619
+ events: laneEvents,
620
+ avoid: Effect.map(held.executor, (id) => (id === undefined ? [] : [id])),
621
+ borrow: held.lease,
622
+ label
623
+ }),
624
+ available: roster.available,
625
+ slots: roster.slots,
626
+ executor: held.executor,
627
+ history: held.history
628
+ })
629
+ const contextIn = (
630
+ workDir: string,
631
+ held: HeldCoder,
632
+ label: string,
633
+ rebind: boolean,
634
+ laneEvents: FlowEventsShape
635
+ ): Effect.Effect<FlowContextShape, FlowError> =>
636
+ Effect.gen(function* () {
637
+ const view = viewFor(held, workDir, label, laneEvents)
638
+ const judgmentSeat =
639
+ options.judgment === undefined
640
+ ? view.forRole("judge")
641
+ : yield* resolveSeat(
642
+ prepareConnector(bridged(options.judgment), workDir, runEnvironment)
643
+ ).pipe(ScopeModule.provide(runScope))
644
+ const judgment = yield* judgmentFor(judgmentSeat, judgmentConfig, runEnvironment)
645
+ return FlowContext.of({
646
+ reasoning: view.forRole(rebind ? "planner" : "reviewer"),
647
+ coder: held.service,
648
+ judgment,
649
+ git: makeGitTool(dependencies.process, workDir, laneEvents),
650
+ hosting: makeGitHubTool(dependencies.process, workDir, laneEvents),
651
+ events: laneEvents,
652
+ reviewers: [view.forRole("reviewer")],
653
+ coderCapabilities: yield* coderCapabilities(workDir, held),
654
+ userPrompt: options.userPrompt,
655
+ workDir,
656
+ workspace: options.workspace,
657
+ roster: view,
658
+ ...(rebind
659
+ ? {
660
+ contextFor: (directory: string, contextOptions?: ContextOptions) =>
661
+ Effect.gen(function* () {
662
+ const label = contextOptions?.label ?? directory
663
+ // The lane's executor is read at every event, so a
664
+ // handover shows on the next line it prints.
665
+ const executorRef = yield* Ref.make<Effect.Effect<string | undefined>>(
666
+ Effect.succeed(undefined)
667
+ )
668
+ const laneEvents = withLane(events, {
669
+ lane: label,
670
+ executor: Effect.flatten(Ref.get(executorRef)),
671
+ workDir: directory
672
+ })
673
+ const storyCoder = yield* makeHeldCoder(roster, source, directory, {
674
+ events: laneEvents,
675
+ eager: true,
676
+ label,
677
+ ...(contextOptions?.prefer === undefined
678
+ ? {}
679
+ : { prefer: contextOptions.prefer })
680
+ })
681
+ yield* Ref.set(executorRef, storyCoder.executor)
682
+ return yield* contextIn(directory, storyCoder, label, false, laneEvents)
683
+ })
684
+ }
685
+ : {})
686
+ })
687
+ })
688
+ const rootCoder = yield* makeHeldCoder(roster, source, options.workDir, {
689
+ events,
690
+ eager: false,
691
+ label: "the run"
692
+ })
693
+ return yield* contextIn(options.workDir, rootCoder, "the run", true, events)
694
+ })
695
+
696
+ const rosterDocument =
697
+ options.roster === "none"
698
+ ? undefined
699
+ : (options.roster ??
700
+ (yield* loadRosterDocument({
701
+ files: dependencies.files,
702
+ environment: runEnvironment,
703
+ workDir: options.workDir
704
+ })))
705
+ if (rosterDocument !== undefined) {
706
+ const context = yield* rosterContext(rosterDocument)
707
+ return {
708
+ events,
709
+ tracker,
710
+ runId,
711
+ roster: rosterDocument,
712
+ ...(judgmentLog === undefined ? {} : { judgmentLog }),
713
+ context
714
+ }
715
+ }
433
716
  const seats = yield* seatsFor(options.workDir)
434
717
  return {
435
718
  events,
@@ -440,6 +723,10 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
440
723
  }
441
724
  })
442
725
 
726
+ /** `claude (coder·reviewer·judge ×3)` — how the run header names an executor. */
727
+ const describeExecutor = (spec: ExecutorSpec): string =>
728
+ `${spec.id} (${rolesOf(spec).join("·")} ×${spec.slots ?? 1})`
729
+
443
730
  export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle")(function* <
444
731
  A,
445
732
  E,
@@ -468,11 +755,16 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
468
755
  })
469
756
  if (verbosity !== "Quiet") {
470
757
  const reviewers = options.reviewers ?? []
471
- const seats = [
472
- `coder ${describeSeat(options.coder)}`,
473
- `reasoning ${describeSeat(defaultReasoningConfig(options.coder, options.reasoning))}`,
474
- ...(reviewers.length === 0 ? [] : [`reviewers ${reviewers.map(describeSeat).join(", ")}`])
475
- ].join(" · ")
758
+ const seats =
759
+ bundle.roster === undefined
760
+ ? [
761
+ `coder ${describeSeat(options.coder)}`,
762
+ `reasoning ${describeSeat(defaultReasoningConfig(options.coder, options.reasoning))}`,
763
+ ...(reviewers.length === 0
764
+ ? []
765
+ : [`reviewers ${reviewers.map(describeSeat).join(", ")}`])
766
+ ].join(" · ")
767
+ : `executors ${bundle.roster.executors.map(describeExecutor).join(", ")} (roster in force: the flow's own seat choices are not used)`
476
768
  yield* surface.log(palette.info(`${seats} · ${options.workDir}`))
477
769
  if (tracePath !== undefined) {
478
770
  yield* surface.log(