@llm4ts/runner 2.7.2 → 2.9.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.
@@ -0,0 +1,318 @@
1
+ // Loading the executor roster (ADR 0019): the user's file, the repository's
2
+ // override, the per-run choice (`LLM4TS_ROSTER`, `LLM4TS_EXECUTORS`), and the
3
+ // mapping from an executor to the connector config the registry resolves.
4
+ // Harness knowledge lives here, beside the connector presets; the roster
5
+ // itself (`@llm4ts/flow/Roster`) knows only ids, roles and slots.
6
+ import { homedir } from "node:os"
7
+ import { join } from "node:path"
8
+ import * as Clock from "effect/Clock"
9
+ import * as Duration from "effect/Duration"
10
+ import * as Effect from "effect/Effect"
11
+ import * as Schema from "effect/Schema"
12
+ import {
13
+ ApiConnectorConfig,
14
+ CliConnectorConfig,
15
+ type ConnectorConfig
16
+ } from "@llm4ts/core/ConnectorConfig"
17
+ import { RosterInvalid, type FlowError } from "@llm4ts/flow/FlowError"
18
+ import type { PlainFileStoreShape } from "@llm4ts/flow/Persistence"
19
+ import {
20
+ Exclusion,
21
+ RosterDocument,
22
+ coderSlotsOf,
23
+ describeExclusion,
24
+ hasRole,
25
+ makeRosterStateStore,
26
+ mergeRosterDocuments,
27
+ narrowRoster,
28
+ parseDuration,
29
+ rolesOf,
30
+ rosterViolations,
31
+ slotsOf,
32
+ type ExecutorSpec
33
+ } from "@llm4ts/flow/Roster"
34
+ import { apiPresetFor, asReadOnly, coderFor, withEnvironment, withModel } from "./Connectors.ts"
35
+
36
+ type Environment = Readonly<Record<string, string | undefined>>
37
+
38
+ const home = (environment: Environment): string => environment.HOME ?? homedir()
39
+
40
+ /** `${XDG_CONFIG_HOME:-~/.config}/llm4ts/roster.json`. */
41
+ export const userRosterPath = (environment: Environment): string =>
42
+ join(environment.XDG_CONFIG_HOME ?? join(home(environment), ".config"), "llm4ts", "roster.json")
43
+
44
+ /** `<repo>/.llm4ts/roster.json`: overrides the user's entries by id. */
45
+ export const repoRosterPath = (workDir: string): string => join(workDir, ".llm4ts", "roster.json")
46
+
47
+ /** `${XDG_STATE_HOME:-~/.local/state}/llm4ts/roster-state.json`: exclusions that outlive a run. */
48
+ export const rosterStatePath = (environment: Environment): string =>
49
+ join(
50
+ environment.XDG_STATE_HOME ?? join(home(environment), ".local", "state"),
51
+ "llm4ts",
52
+ "roster-state.json"
53
+ )
54
+
55
+ const reference = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g
56
+
57
+ /** `${VAR}` references resolved from the environment; the names of missing ones. */
58
+ export const substituteEnvironment = (
59
+ values: Readonly<Record<string, string>>,
60
+ environment: Environment
61
+ ): {
62
+ readonly values: Readonly<Record<string, string>>
63
+ readonly missing: ReadonlyArray<string>
64
+ } => {
65
+ const missing: Array<string> = []
66
+ const resolved: Record<string, string> = {}
67
+ for (const [key, value] of Object.entries(values)) {
68
+ resolved[key] = value.replace(reference, (_match, name: string) => {
69
+ const found = environment[name]
70
+ if (found === undefined) {
71
+ missing.push(name)
72
+ return ""
73
+ }
74
+ return found
75
+ })
76
+ }
77
+ return { values: resolved, missing: [...new Set(missing)] }
78
+ }
79
+
80
+ /** Whether a harness name is an API provider (reasoning roles only). */
81
+ export const isApiHarness = (harness: string): boolean =>
82
+ coderFor(harness) === undefined && apiPresetFor(harness) !== undefined
83
+
84
+ /**
85
+ * The connector config an executor runs with: its harness preset, model,
86
+ * flags, endpoint and environment, read-only for a reasoning role.
87
+ */
88
+ export const executorConfig = (
89
+ spec: ExecutorSpec,
90
+ readOnly: boolean,
91
+ environment: Environment
92
+ ): ConnectorConfig | undefined => {
93
+ const cli = coderFor(spec.harness)
94
+ if (cli !== undefined) {
95
+ const modelled = spec.model === undefined ? cli : withModel(cli, spec.model)
96
+ const flagged =
97
+ spec.flags === undefined
98
+ ? modelled
99
+ : CliConnectorConfig.make({ ...modelled, flags: { ...modelled.flags, ...spec.flags } })
100
+ const withEnv =
101
+ spec.env === undefined
102
+ ? flagged
103
+ : withEnvironment(flagged, substituteEnvironment(spec.env, environment).values)
104
+ return readOnly ? asReadOnly(withEnv) : withEnv
105
+ }
106
+ const api = apiPresetFor(spec.harness)
107
+ if (api === undefined) {
108
+ return undefined
109
+ }
110
+ const modelled = spec.model === undefined ? api : withModel(api, spec.model)
111
+ return spec.baseUrl === undefined
112
+ ? modelled
113
+ : ApiConnectorConfig.make({ ...modelled, baseUrl: spec.baseUrl })
114
+ }
115
+
116
+ /** The flow-level violations plus what needs the harness table and the environment. */
117
+ export const rosterLoadViolations = (
118
+ document: RosterDocument,
119
+ environment: Environment
120
+ ): ReadonlyArray<string> => {
121
+ const violations = [...rosterViolations(document)]
122
+ for (const spec of document.executors) {
123
+ const where = `executor '${spec.id}'`
124
+ if (coderFor(spec.harness) === undefined && apiPresetFor(spec.harness) === undefined) {
125
+ violations.push(`${where} has an unknown harness '${spec.harness}'`)
126
+ } else if (isApiHarness(spec.harness) && hasRole(spec, "coder")) {
127
+ violations.push(
128
+ `${where} is an API provider ('${spec.harness}'): it may reason, but coding needs a CLI harness`
129
+ )
130
+ }
131
+ const missing = substituteEnvironment(spec.env ?? {}, environment).missing
132
+ if (missing.length > 0) {
133
+ violations.push(`${where} references unset variable(s) ${missing.join(", ")}`)
134
+ }
135
+ }
136
+ return violations
137
+ }
138
+
139
+ const parseRoster = (path: string, text: string): Effect.Effect<RosterDocument, RosterInvalid> =>
140
+ Effect.try({
141
+ try: (): unknown => JSON.parse(text),
142
+ catch: (error) =>
143
+ RosterInvalid.make({
144
+ path,
145
+ violations: [`not JSON: ${error instanceof Error ? error.message : String(error)}`]
146
+ })
147
+ }).pipe(
148
+ Effect.flatMap((json) =>
149
+ Schema.decodeUnknownEffect(RosterDocument)(json).pipe(
150
+ Effect.mapError((error) => RosterInvalid.make({ path, violations: [error.message] }))
151
+ )
152
+ )
153
+ )
154
+
155
+ const readRoster = (
156
+ files: PlainFileStoreShape,
157
+ path: string
158
+ ): Effect.Effect<RosterDocument | undefined, FlowError> =>
159
+ Effect.flatMap(files.read(path), (text) =>
160
+ text === undefined ? Effect.succeed(undefined) : parseRoster(path, text)
161
+ )
162
+
163
+ export interface RosterSource {
164
+ readonly files: PlainFileStoreShape
165
+ readonly environment: Environment
166
+ readonly workDir: string
167
+ }
168
+
169
+ /**
170
+ * The roster in force for a run, or `undefined` for today's one executor per
171
+ * seat: `LLM4TS_ROSTER=none` ignores rosters, `LLM4TS_ROSTER=<path>` uses that
172
+ * file alone, otherwise the user's file with the repository's over it.
173
+ * `LLM4TS_EXECUTORS=a,b` narrows the result to those ids.
174
+ */
175
+ export const loadRosterDocument = Effect.fn("@llm4ts/runner/ExecutorRoster.load")(function* (
176
+ source: RosterSource
177
+ ): Effect.fn.Return<RosterDocument | undefined, FlowError> {
178
+ const chosen = source.environment.LLM4TS_ROSTER?.trim()
179
+ if (chosen === "none") {
180
+ return undefined
181
+ }
182
+ const loaded = yield* chosen !== undefined && chosen.length > 0
183
+ ? Effect.flatMap(readRoster(source.files, chosen), (document) =>
184
+ document === undefined
185
+ ? Effect.fail(RosterInvalid.make({ path: chosen, violations: ["no roster file there"] }))
186
+ : Effect.succeed({ document, where: chosen })
187
+ )
188
+ : Effect.gen(function* () {
189
+ const user = userRosterPath(source.environment)
190
+ const repo = repoRosterPath(source.workDir)
191
+ const userDocument = yield* readRoster(source.files, user)
192
+ const repoDocument = yield* readRoster(source.files, repo)
193
+ if (userDocument === undefined && repoDocument === undefined) {
194
+ return undefined
195
+ }
196
+ const where = [
197
+ userDocument === undefined ? undefined : user,
198
+ repoDocument === undefined ? undefined : repo
199
+ ]
200
+ .filter((path): path is string => path !== undefined)
201
+ .join(" + ")
202
+ return { document: mergeRosterDocuments(userDocument, repoDocument), where }
203
+ })
204
+ if (loaded === undefined) {
205
+ return undefined
206
+ }
207
+ const { document, where } = loaded
208
+ const ids = (source.environment.LLM4TS_EXECUTORS ?? "")
209
+ .split(",")
210
+ .map((id) => id.trim())
211
+ .filter((id) => id.length > 0)
212
+ const unknown = ids.filter((id) => !document.executors.some((spec) => spec.id === id))
213
+ const narrowed = narrowRoster(document, ids)
214
+ const violations = [
215
+ ...rosterLoadViolations(narrowed, source.environment),
216
+ ...unknown.map((id) => `LLM4TS_EXECUTORS names '${id}', which the roster does not have`),
217
+ ...(narrowed.executors.length === 0 ? ["no executor left to run with"] : [])
218
+ ]
219
+ if (violations.length > 0) {
220
+ return yield* RosterInvalid.make({ path: where, violations })
221
+ }
222
+ return narrowed
223
+ })
224
+
225
+ /** Whether `url` answers 2xx within five seconds: a roster executor's health check. */
226
+ export const httpProbe = (url: string): Effect.Effect<boolean> =>
227
+ Effect.tryPromise(() => fetch(url, { signal: AbortSignal.timeout(5_000) })).pipe(
228
+ Effect.map((response) => response.ok),
229
+ Effect.catch(() => Effect.succeed(false))
230
+ )
231
+
232
+ // ---- `llm4ts roster` -------------------------------------------------------------
233
+
234
+ export interface RosterReport {
235
+ readonly document: RosterDocument | undefined
236
+ readonly exclusions: ReadonlyArray<Exclusion>
237
+ readonly statePath: string
238
+ }
239
+
240
+ /** The roster in force here and the exclusions persisted for it. */
241
+ export const rosterReport = Effect.fn("@llm4ts/runner/ExecutorRoster.report")(function* (
242
+ source: RosterSource
243
+ ): Effect.fn.Return<RosterReport, FlowError> {
244
+ const document = yield* loadRosterDocument(source)
245
+ const statePath = rosterStatePath(source.environment)
246
+ const exclusions = yield* makeRosterStateStore(source.files, statePath).load
247
+ return { document, exclusions, statePath }
248
+ })
249
+
250
+ const priorityText = (spec: ExecutorSpec): string => {
251
+ const priority = spec.priority
252
+ if (priority === undefined) {
253
+ return "priority 1"
254
+ }
255
+ if (typeof priority === "number") {
256
+ return `priority ${priority}`
257
+ }
258
+ return `priority ${Object.entries(priority)
259
+ .map(([role, value]) => `${role} ${value}`)
260
+ .join(", ")}`
261
+ }
262
+
263
+ export const renderRosterReport = (report: RosterReport): string => {
264
+ if (report.document === undefined) {
265
+ return [
266
+ "no roster: every run uses one executor per seat (LLM4TS_CODER and the flow's defaults).",
267
+ "Write ~/.config/llm4ts/roster.json (or <repo>/.llm4ts/roster.json) to pool executors."
268
+ ].join("\n")
269
+ }
270
+ const lines = [`executors (${report.document.executors.length}):`]
271
+ for (const spec of report.document.executors) {
272
+ const exclusion = report.exclusions.find((candidate) => candidate.id === spec.id)
273
+ const slots = slotsOf(spec)
274
+ const coders = hasRole(spec, "coder") ? `, ${coderSlotsOf(spec)} for coding` : ""
275
+ lines.push(
276
+ `- ${spec.id}: ${spec.harness}${spec.model === undefined ? "" : ` ${spec.model}`} · ${rolesOf(spec).join(", ")} · ${slots} slot(s)${coders} · ${priorityText(spec)}`,
277
+ ` ${exclusion === undefined ? "in the round" : `out ${describeExclusion(exclusion)}`}`
278
+ )
279
+ }
280
+ lines.push(`state: ${report.statePath}`)
281
+ return lines.join("\n")
282
+ }
283
+
284
+ /** Takes an executor out of every run's pick-up round, for a while or until resumed. */
285
+ export const pauseExecutor = Effect.fn("@llm4ts/runner/ExecutorRoster.pause")(function* (
286
+ source: RosterSource,
287
+ id: string,
288
+ duration: string | undefined
289
+ ): Effect.fn.Return<Exclusion, FlowError> {
290
+ const document = yield* loadRosterDocument(source)
291
+ if (document === undefined || !document.executors.some((spec) => spec.id === id)) {
292
+ return yield* RosterInvalid.make({ violations: [`no executor '${id}' in the roster`] })
293
+ }
294
+ const span = duration === undefined ? undefined : parseDuration(duration)
295
+ if (duration !== undefined && span === undefined) {
296
+ return yield* RosterInvalid.make({ violations: [`cannot read the duration '${duration}'`] })
297
+ }
298
+ const now = yield* Clock.currentTimeMillis
299
+ const exclusion = Exclusion.make({
300
+ id,
301
+ kind: "manual",
302
+ reason: "paused by the operator",
303
+ ...(span === undefined ? {} : { until: now + Duration.toMillis(span) })
304
+ })
305
+ yield* makeRosterStateStore(source.files, rosterStatePath(source.environment)).save(
306
+ [id],
307
+ [exclusion]
308
+ )
309
+ return exclusion
310
+ })
311
+
312
+ /** Puts an executor back in the round, whatever took it out. */
313
+ export const resumeExecutor = Effect.fn("@llm4ts/runner/ExecutorRoster.resume")(function* (
314
+ source: RosterSource,
315
+ id: string
316
+ ): Effect.fn.Return<void, FlowError> {
317
+ yield* makeRosterStateStore(source.files, rosterStatePath(source.environment)).save([id], [])
318
+ })
package/src/FlowRunner.ts CHANGED
@@ -13,6 +13,7 @@ import { makeTypeSafeJudgment } from "@llm4ts/core/judgment/TypeSafeJudgment"
13
13
  import * as Redacted from "effect/Redacted"
14
14
  import type { LlmServiceShape } from "@llm4ts/core/LlmService"
15
15
  import type { ConnectorCapabilities, TokenUsage } from "@llm4ts/core/Models"
16
+ import { ConnectorCapabilities as ConnectorCapabilitiesClass } from "@llm4ts/core/Models"
16
17
  import type { ProcessExecutorShape } from "@llm4ts/core/ProcessExecutor"
17
18
  import type { TemporaryFilesShape } from "@llm4ts/core/TemporaryFiles"
18
19
  import type { GeminiCliExecutorShape } from "@llm4ts/core/providers/GeminiCliProvider"
@@ -35,7 +36,12 @@ import {
35
36
  type CostBudget
36
37
  } from "@llm4ts/flow/CostLedger"
37
38
  import { estimatedUsageOptionsFromEnv, makeEstimatedUsageMeter } from "@llm4ts/flow/EstimatedUsage"
38
- import { FlowLlmError, describeFlowError, type FlowError } from "@llm4ts/flow/FlowError"
39
+ import {
40
+ FlowLlmError,
41
+ RosterInvalid,
42
+ describeFlowError,
43
+ type FlowError
44
+ } from "@llm4ts/flow/FlowError"
39
45
  import {
40
46
  defaultDrainTimeout,
41
47
  FlowEvents,
@@ -45,13 +51,35 @@ import {
45
51
  makeFlowEventHub,
46
52
  type FlowEventHub
47
53
  } from "@llm4ts/flow/FlowEvents"
48
- import { FlowContext, type FlowContextShape } from "@llm4ts/flow/FlowContext"
54
+ import { FlowContext, type ContextOptions, type FlowContextShape } from "@llm4ts/flow/FlowContext"
49
55
  import { makeFlowRecorder } from "@llm4ts/flow/FlowRecorder"
50
56
  import { makeJudgmentLog, type JudgmentLogShape } from "@llm4ts/flow/JudgmentLog"
51
57
  import { makeGitHubTool } from "@llm4ts/flow/GitHubTool"
52
58
  import { makeGitTool } from "@llm4ts/flow/GitTool"
53
59
  import type { PlainFileStoreShape } from "@llm4ts/flow/Persistence"
54
60
  import { makeTransientRetry } from "@llm4ts/flow/TransientRetry"
61
+ import {
62
+ Exclusion,
63
+ makeRoster,
64
+ makeRosterStateStore,
65
+ priorityOf,
66
+ rolesOf,
67
+ type ExecutorSpec,
68
+ type Role,
69
+ type RosterDocument,
70
+ type RosterShape
71
+ } from "@llm4ts/flow/Roster"
72
+ import {
73
+ makeHeldCoder,
74
+ rosterSeat,
75
+ type HeldCoder,
76
+ type RosterSeat,
77
+ type RosterView,
78
+ type SeatSource
79
+ } from "@llm4ts/flow/RosterSeats"
80
+ import * as Semaphore from "effect/Semaphore"
81
+ import * as ScopeModule from "effect/Scope"
82
+ import { executorConfig, httpProbe, loadRosterDocument, rosterStatePath } from "./ExecutorRoster.ts"
55
83
  import { nodeHttpClient } from "./NodeHttpClient.ts"
56
84
  import { nodeGeminiCliExecutor } from "./NodeGeminiCliExecutor.ts"
57
85
  import { nodePlainFileStore } from "./NodePlainFileStore.ts"
@@ -158,10 +186,19 @@ export interface FlowRunnerOptions {
158
186
  * nothing (see the connector capability matrix).
159
187
  */
160
188
  readonly budget?: CostBudget
189
+ /**
190
+ * The executor roster (ADR 0019): every seat is then served from it and
191
+ * the coder/reasoning/reviewer configs above are ignored. Omitted, the
192
+ * runner loads one (`LLM4TS_ROSTER`, the user's and the repository's
193
+ * roster files); `"none"` runs with one executor per seat.
194
+ */
195
+ readonly roster?: RosterDocument | "none"
161
196
  }
162
197
 
163
198
  export interface FlowRunnerBundle {
164
199
  readonly context: FlowContextShape
200
+ /** The executor roster in force (ADR 0019), when the run has one. */
201
+ readonly roster?: RosterDocument
165
202
  readonly events: FlowEventHub
166
203
  /** The run's cost tracker, already subscribed to `events`. */
167
204
  readonly tracker: CostTracker
@@ -430,6 +467,222 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
430
467
  }
431
468
  : {})
432
469
  })
470
+ /**
471
+ * One look at every executor before the first lease: a health URL that
472
+ * does not answer waits in the round for its engine; a harness that cannot
473
+ * start (not installed, not signed in) sits the run out.
474
+ */
475
+ const probeExecutors = (
476
+ roster: RosterShape,
477
+ seatFor: (
478
+ executor: ExecutorSpec,
479
+ role: Role,
480
+ workDir: string
481
+ ) => Effect.Effect<RosterSeat, FlowError>,
482
+ workDir: string
483
+ ): Effect.Effect<void> =>
484
+ Effect.forEach(
485
+ roster.executors,
486
+ (executor) =>
487
+ Effect.gen(function* () {
488
+ if (executor.health !== undefined) {
489
+ if (!(yield* httpProbe(executor.health))) {
490
+ yield* roster.exclude(
491
+ Exclusion.make({
492
+ id: executor.id,
493
+ kind: "health",
494
+ until: yield* Clock.currentTimeMillis,
495
+ reason: `its health check ${executor.health} does not answer`
496
+ })
497
+ )
498
+ }
499
+ return
500
+ }
501
+ const role = rolesOf(executor)[0] ?? "coder"
502
+ const seat = yield* Effect.result(seatFor(executor, role, workDir))
503
+ if (seat._tag === "Failure") {
504
+ yield* roster.exclude(
505
+ Exclusion.make({
506
+ id: executor.id,
507
+ kind: "run",
508
+ reason: `could not start: ${describeFlowError(seat.failure)}`
509
+ })
510
+ )
511
+ return
512
+ }
513
+ if (!(yield* seat.success.isAvailable)) {
514
+ yield* roster.exclude(
515
+ Exclusion.make({
516
+ id: executor.id,
517
+ kind: "run",
518
+ reason: `its harness '${executor.harness}' is not available (not installed, or not signed in)`
519
+ })
520
+ )
521
+ }
522
+ }),
523
+ { discard: true, concurrency: "unbounded" }
524
+ )
525
+
526
+ /**
527
+ * Every seat from the roster (ADR 0019): a lazily held coder at the root,
528
+ * an eagerly held one per `contextFor` (a story waits there for a slot),
529
+ * and reasoning seats leased per call, independent of the coder.
530
+ */
531
+ const rosterContext = Effect.fn("@llm4ts/runner/FlowRunner.rosterContext")(function* (
532
+ document: RosterDocument
533
+ ): Effect.fn.Return<FlowContextShape, FlowError, Scope.Scope> {
534
+ const runScope = yield* Effect.scope
535
+ const roster = yield* makeRoster({
536
+ executors: document.executors,
537
+ events,
538
+ state: makeRosterStateStore(dependencies.files, rosterStatePath(runEnvironment)),
539
+ probe: httpProbe
540
+ })
541
+ const seatCache = new Map<string, RosterSeat>()
542
+ const cacheLock = yield* Semaphore.make(1)
543
+ const seatFor = (
544
+ executor: ExecutorSpec,
545
+ role: Role,
546
+ workDir: string
547
+ ): Effect.Effect<RosterSeat, FlowError> =>
548
+ cacheLock.withPermit(
549
+ Effect.gen(function* () {
550
+ const readOnly = role !== "coder"
551
+ const key = `${executor.id}\u0000${readOnly ? "ro" : "rw"}\u0000${workDir}`
552
+ const cached = seatCache.get(key)
553
+ if (cached !== undefined) {
554
+ return cached
555
+ }
556
+ const configuration = executorConfig(executor, readOnly, runEnvironment)
557
+ if (configuration === undefined) {
558
+ return yield* RosterInvalid.make({
559
+ violations: [`executor '${executor.id}' has an unknown harness '${executor.harness}'`]
560
+ })
561
+ }
562
+ const seat: RosterSeat = yield* resolveSeat(
563
+ prepareConnector(bridged(configuration), workDir, runEnvironment)
564
+ ).pipe(ScopeModule.provide(runScope))
565
+ seatCache.set(key, seat)
566
+ return seat
567
+ })
568
+ )
569
+ const source: SeatSource = { seatFor }
570
+ yield* probeExecutors(roster, seatFor, options.workDir)
571
+
572
+ const judgmentConfig =
573
+ options.judgment ??
574
+ document.executors
575
+ .filter((spec) => rolesOf(spec).includes("judge"))
576
+ .flatMap((spec) => {
577
+ const configuration = executorConfig(spec, true, runEnvironment)
578
+ return configuration === undefined ? [] : [configuration]
579
+ })[0] ??
580
+ reasoning
581
+ const coderCapabilities = (workDir: string, held: HeldCoder) =>
582
+ Effect.gen(function* () {
583
+ const current = yield* held.lease
584
+ const first =
585
+ current ??
586
+ [...document.executors]
587
+ .filter((spec) => rolesOf(spec).includes("coder"))
588
+ .sort((left, right) => priorityOf(left, "coder") - priorityOf(right, "coder"))[0]
589
+ if (first === undefined) {
590
+ return ConnectorCapabilitiesClass.make({})
591
+ }
592
+ return yield* Effect.map(Effect.result(seatFor(first, "coder", workDir)), (result) =>
593
+ result._tag === "Success"
594
+ ? result.success.capabilities
595
+ : ConnectorCapabilitiesClass.make({})
596
+ )
597
+ })
598
+ const viewFor = (held: HeldCoder, workDir: string, label: string): RosterView => ({
599
+ forRole: (role) =>
600
+ rosterSeat(roster, source, role, workDir, {
601
+ events,
602
+ avoid: Effect.map(held.executor, (id) => (id === undefined ? [] : [id])),
603
+ borrow: held.lease,
604
+ label
605
+ }),
606
+ available: roster.available,
607
+ slots: roster.slots,
608
+ executor: held.executor,
609
+ history: held.history
610
+ })
611
+ const contextIn = (
612
+ workDir: string,
613
+ held: HeldCoder,
614
+ label: string,
615
+ rebind: boolean
616
+ ): Effect.Effect<FlowContextShape, FlowError> =>
617
+ Effect.gen(function* () {
618
+ const view = viewFor(held, workDir, label)
619
+ const judgmentSeat =
620
+ options.judgment === undefined
621
+ ? view.forRole("judge")
622
+ : yield* resolveSeat(
623
+ prepareConnector(bridged(options.judgment), workDir, runEnvironment)
624
+ ).pipe(ScopeModule.provide(runScope))
625
+ const judgment = yield* judgmentFor(judgmentSeat, judgmentConfig, runEnvironment)
626
+ return FlowContext.of({
627
+ reasoning: view.forRole(rebind ? "planner" : "reviewer"),
628
+ coder: held.service,
629
+ judgment,
630
+ git: makeGitTool(dependencies.process, workDir, events),
631
+ hosting: makeGitHubTool(dependencies.process, workDir, events),
632
+ events,
633
+ reviewers: [view.forRole("reviewer")],
634
+ coderCapabilities: yield* coderCapabilities(workDir, held),
635
+ userPrompt: options.userPrompt,
636
+ workDir,
637
+ workspace: options.workspace,
638
+ roster: view,
639
+ ...(rebind
640
+ ? {
641
+ contextFor: (directory: string, contextOptions?: ContextOptions) =>
642
+ Effect.gen(function* () {
643
+ const label = contextOptions?.label ?? directory
644
+ const storyCoder = yield* makeHeldCoder(roster, source, directory, {
645
+ events,
646
+ eager: true,
647
+ label,
648
+ ...(contextOptions?.prefer === undefined
649
+ ? {}
650
+ : { prefer: contextOptions.prefer })
651
+ })
652
+ return yield* contextIn(directory, storyCoder, label, false)
653
+ })
654
+ }
655
+ : {})
656
+ })
657
+ })
658
+ const rootCoder = yield* makeHeldCoder(roster, source, options.workDir, {
659
+ events,
660
+ eager: false,
661
+ label: "the run"
662
+ })
663
+ return yield* contextIn(options.workDir, rootCoder, "the run", true)
664
+ })
665
+
666
+ const rosterDocument =
667
+ options.roster === "none"
668
+ ? undefined
669
+ : (options.roster ??
670
+ (yield* loadRosterDocument({
671
+ files: dependencies.files,
672
+ environment: runEnvironment,
673
+ workDir: options.workDir
674
+ })))
675
+ if (rosterDocument !== undefined) {
676
+ const context = yield* rosterContext(rosterDocument)
677
+ return {
678
+ events,
679
+ tracker,
680
+ runId,
681
+ roster: rosterDocument,
682
+ ...(judgmentLog === undefined ? {} : { judgmentLog }),
683
+ context
684
+ }
685
+ }
433
686
  const seats = yield* seatsFor(options.workDir)
434
687
  return {
435
688
  events,
@@ -440,6 +693,10 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
440
693
  }
441
694
  })
442
695
 
696
+ /** `claude (coder·reviewer·judge ×3)` — how the run header names an executor. */
697
+ const describeExecutor = (spec: ExecutorSpec): string =>
698
+ `${spec.id} (${rolesOf(spec).join("·")} ×${spec.slots ?? 1})`
699
+
443
700
  export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle")(function* <
444
701
  A,
445
702
  E,
@@ -468,11 +725,16 @@ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle"
468
725
  })
469
726
  if (verbosity !== "Quiet") {
470
727
  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(" · ")
728
+ const seats =
729
+ bundle.roster === undefined
730
+ ? [
731
+ `coder ${describeSeat(options.coder)}`,
732
+ `reasoning ${describeSeat(defaultReasoningConfig(options.coder, options.reasoning))}`,
733
+ ...(reviewers.length === 0
734
+ ? []
735
+ : [`reviewers ${reviewers.map(describeSeat).join(", ")}`])
736
+ ].join(" · ")
737
+ : `executors ${bundle.roster.executors.map(describeExecutor).join(", ")} (roster in force: the flow's own seat choices are not used)`
476
738
  yield* surface.log(palette.info(`${seats} · ${options.workDir}`))
477
739
  if (tracePath !== undefined) {
478
740
  yield* surface.log(