@llm4ts/runner 2.8.0 → 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.
- package/dist/Connectors.d.ts +2 -0
- package/dist/Connectors.d.ts.map +1 -1
- package/dist/Connectors.js +26 -24
- package/dist/Connectors.js.map +1 -1
- package/dist/ExecutorRoster.d.ts +54 -0
- package/dist/ExecutorRoster.d.ts.map +1 -0
- package/dist/ExecutorRoster.js +206 -0
- package/dist/ExecutorRoster.js.map +1 -0
- package/dist/FlowRunner.d.ts +14 -4
- package/dist/FlowRunner.d.ts.map +1 -1
- package/dist/FlowRunner.js +183 -6
- package/dist/FlowRunner.js.map +1 -1
- package/package.json +4 -3
- package/src/Connectors.ts +27 -24
- package/src/ExecutorRoster.ts +318 -0
- package/src/FlowRunner.ts +269 -7
|
@@ -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 {
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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(
|