@llm4ts/shell 2.45.0 → 2.46.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.
Files changed (64) hide show
  1. package/dist/Cli.d.ts +3 -7
  2. package/dist/Cli.d.ts.map +1 -1
  3. package/dist/Cli.js +20 -4
  4. package/dist/Cli.js.map +1 -1
  5. package/dist/FlowLaunch.d.ts +2 -16
  6. package/dist/FlowLaunch.d.ts.map +1 -1
  7. package/dist/FlowLaunch.js +20 -66
  8. package/dist/FlowLaunch.js.map +1 -1
  9. package/dist/FlowProcess.d.ts +67 -0
  10. package/dist/FlowProcess.d.ts.map +1 -0
  11. package/dist/FlowProcess.js +175 -0
  12. package/dist/FlowProcess.js.map +1 -0
  13. package/dist/Menu.d.ts +1 -1
  14. package/dist/Menu.d.ts.map +1 -1
  15. package/dist/OperatorActTools.d.ts +170 -0
  16. package/dist/OperatorActTools.d.ts.map +1 -0
  17. package/dist/OperatorActTools.js +332 -0
  18. package/dist/OperatorActTools.js.map +1 -0
  19. package/dist/OperatorConfig.d.ts +44 -0
  20. package/dist/OperatorConfig.d.ts.map +1 -0
  21. package/dist/OperatorConfig.js +88 -0
  22. package/dist/OperatorConfig.js.map +1 -0
  23. package/dist/OperatorNode.d.ts +45 -0
  24. package/dist/OperatorNode.d.ts.map +1 -0
  25. package/dist/OperatorNode.js +103 -0
  26. package/dist/OperatorNode.js.map +1 -0
  27. package/dist/OperatorRuns.d.ts +76 -0
  28. package/dist/OperatorRuns.d.ts.map +1 -0
  29. package/dist/OperatorRuns.js +146 -0
  30. package/dist/OperatorRuns.js.map +1 -0
  31. package/dist/OperatorServer.d.ts +160 -0
  32. package/dist/OperatorServer.d.ts.map +1 -0
  33. package/dist/OperatorServer.js +123 -0
  34. package/dist/OperatorServer.js.map +1 -0
  35. package/dist/OperatorTools.d.ts +558 -0
  36. package/dist/OperatorTools.d.ts.map +1 -0
  37. package/dist/OperatorTools.js +398 -0
  38. package/dist/OperatorTools.js.map +1 -0
  39. package/dist/Refine.d.ts +2 -2
  40. package/dist/Refine.d.ts.map +1 -1
  41. package/dist/Refine.js +3 -5
  42. package/dist/Refine.js.map +1 -1
  43. package/dist/RunSupervisor.d.ts +51 -0
  44. package/dist/RunSupervisor.d.ts.map +1 -0
  45. package/dist/RunSupervisor.js +282 -0
  46. package/dist/RunSupervisor.js.map +1 -0
  47. package/dist/ShellErrors.d.ts +9 -0
  48. package/dist/ShellErrors.d.ts.map +1 -0
  49. package/dist/ShellErrors.js +7 -0
  50. package/dist/ShellErrors.js.map +1 -0
  51. package/flows/port-guide.js +4 -1
  52. package/package.json +13 -4
  53. package/src/Cli.ts +64 -3
  54. package/src/FlowLaunch.ts +20 -66
  55. package/src/FlowProcess.ts +239 -0
  56. package/src/OperatorActTools.ts +407 -0
  57. package/src/OperatorConfig.ts +148 -0
  58. package/src/OperatorNode.ts +147 -0
  59. package/src/OperatorRuns.ts +270 -0
  60. package/src/OperatorServer.ts +192 -0
  61. package/src/OperatorTools.ts +610 -0
  62. package/src/Refine.ts +4 -7
  63. package/src/RunSupervisor.ts +403 -0
  64. package/src/ShellErrors.ts +6 -0
@@ -0,0 +1,403 @@
1
+ import { homedir } from "node:os"
2
+ import { join } from "node:path"
3
+ import * as Clock from "effect/Clock"
4
+ import * as Deferred from "effect/Deferred"
5
+ import * as Effect from "effect/Effect"
6
+ import * as Exit from "effect/Exit"
7
+ import * as Ref from "effect/Ref"
8
+ import * as Scope from "effect/Scope"
9
+ import * as Semaphore from "effect/Semaphore"
10
+ import type { PersistenceError } from "@llm4ts/flow/FlowError"
11
+ import {
12
+ encodeOperatorRunLine,
13
+ latestRuns,
14
+ OperatorRun,
15
+ RunInProgress,
16
+ UnknownRun
17
+ } from "@llm4ts/flow/OperatorSchemas"
18
+ import type { OperatorKnobs } from "@llm4ts/flow/OperatorSchemas"
19
+ import type { PlainFileStoreShape } from "@llm4ts/flow/Persistence"
20
+ import type { FlowLaunchError, FlowProcessHandle, FlowProcessShape } from "./FlowProcess.ts"
21
+
22
+ /**
23
+ * Where the operator keeps one repository's registry and run logs:
24
+ * `${XDG_STATE_HOME:-~/.local/state}/llm4ts/mcp/<repoId>`. An empty
25
+ * `XDG_STATE_HOME` counts as unset; never under `<repo>/.llm4ts/`.
26
+ */
27
+ export const operatorStateDir = (
28
+ environment: Readonly<Record<string, string | undefined>>,
29
+ repoId: string
30
+ ): string => {
31
+ const home =
32
+ environment.HOME !== undefined && environment.HOME.length > 0 ? environment.HOME : homedir()
33
+ const stateHome =
34
+ environment.XDG_STATE_HOME !== undefined && environment.XDG_STATE_HOME.length > 0
35
+ ? environment.XDG_STATE_HOME
36
+ : join(home, ".local", "state")
37
+ return join(stateHome, "llm4ts", "mcp", repoId)
38
+ }
39
+
40
+ export interface SupervisorRepo {
41
+ readonly id: string
42
+ readonly path: string
43
+ }
44
+
45
+ export interface StartRun {
46
+ readonly repo: SupervisorRepo
47
+ readonly flow: { readonly name: string; readonly path: string }
48
+ readonly knobs: OperatorKnobs
49
+ readonly baseEnvironment: Readonly<Record<string, string | undefined>>
50
+ }
51
+
52
+ export interface RunSupervisorShape {
53
+ readonly start: (
54
+ request: StartRun
55
+ ) => Effect.Effect<OperatorRun, FlowLaunchError | RunInProgress | PersistenceError>
56
+ readonly stop: (runId: string) => Effect.Effect<OperatorRun, UnknownRun>
57
+ readonly get: (runId: string) => Effect.Effect<OperatorRun | undefined>
58
+ readonly list: (repoId?: string) => Effect.Effect<ReadonlyArray<OperatorRun>>
59
+ }
60
+
61
+ export interface SupervisorOptions {
62
+ readonly repos: ReadonlyArray<SupervisorRepo>
63
+ readonly environment: Readonly<Record<string, string | undefined>>
64
+ readonly files: PlainFileStoreShape
65
+ readonly process: FlowProcessShape
66
+ readonly parallelRuns: number
67
+ /** How long `stop` waits after SIGINT before SIGTERM; default 30 s. */
68
+ readonly graceMs?: number
69
+ /** Flows that may run beside a busy repository; default `epic-retro`. */
70
+ readonly readOnlyFlows?: ReadonlySet<string>
71
+ /** The 4-hex run id suffix; injectable so tests are deterministic. */
72
+ readonly random?: () => string
73
+ }
74
+
75
+ /** Knobs that become the flow's argv, in a fixed order; `flags` come last. */
76
+ export const knobsToArgs = (_flow: string, knobs: OperatorKnobs): ReadonlyArray<string> => [
77
+ ...(knobs.task === undefined ? [] : [knobs.task]),
78
+ ...(knobs.epic === undefined ? [] : ["--epic", knobs.epic]),
79
+ ...(knobs.land === undefined ? [] : [`--land=${knobs.land}`]),
80
+ ...(knobs.judgeRounds === undefined ? [] : ["--judge-rounds", String(knobs.judgeRounds)]),
81
+ ...(knobs.mergeRevisions === undefined
82
+ ? []
83
+ : ["--merge-revisions", String(knobs.mergeRevisions)]),
84
+ ...(knobs.flags ?? [])
85
+ ]
86
+
87
+ /** The only knobs that reach the flow's environment. */
88
+ export const knobsToEnvironment = (knobs: OperatorKnobs): Record<string, string> => ({
89
+ ...(knobs.executors === undefined ? {} : { LLM4TS_EXECUTORS: knobs.executors.join(",") }),
90
+ ...(knobs.pack === undefined ? {} : { LLM4TS_PACK: knobs.pack }),
91
+ ...(knobs.coder === undefined ? {} : { LLM4TS_CODER: knobs.coder })
92
+ })
93
+
94
+ const randomHex = (): string =>
95
+ Math.floor(Math.random() * 0x10000)
96
+ .toString(16)
97
+ .padStart(4, "0")
98
+
99
+ interface LiveRun {
100
+ readonly handle: FlowProcessHandle
101
+ readonly stopping: Ref.Ref<boolean>
102
+ readonly final: Deferred.Deferred<OperatorRun>
103
+ }
104
+
105
+ const isActive = (run: OperatorRun): boolean => run.state === "starting" || run.state === "running"
106
+
107
+ /** The server's own bearer token never reaches a child flow. */
108
+ const childEnvironment = (
109
+ base: Readonly<Record<string, string | undefined>>
110
+ ): Record<string, string | undefined> =>
111
+ Object.fromEntries(Object.entries(base).filter(([name]) => name !== "LLM4TS_MCP_TOKEN"))
112
+
113
+ export const makeRunSupervisor = (
114
+ options: SupervisorOptions
115
+ ): Effect.Effect<RunSupervisorShape, PersistenceError, Scope.Scope> =>
116
+ Effect.gen(function* () {
117
+ const graceMs = options.graceMs ?? 30_000
118
+ const readOnlyFlows = options.readOnlyFlows ?? new Set(["epic-retro"])
119
+ const random = options.random ?? randomHex
120
+ const stateDir = (repoId: string) => operatorStateDir(options.environment, repoId)
121
+ const registryPath = (repoId: string) => join(stateDir(repoId), "runs.jsonl")
122
+
123
+ const readRepo = (repoId: string) =>
124
+ options.files
125
+ .read(registryPath(repoId))
126
+ .pipe(Effect.map((contents) => latestRuns((contents ?? "").split("\n"))))
127
+ const record = (run: OperatorRun) =>
128
+ options.files.append(registryPath(run.repo), `${encodeOperatorRunLine(run)}\n`)
129
+ const withState = (run: OperatorRun, fields: Partial<OperatorRun>): OperatorRun =>
130
+ new OperatorRun({ ...run, ...fields })
131
+
132
+ // Runs whose child died while no supervisor watched them.
133
+ for (const repo of options.repos) {
134
+ for (const run of yield* readRepo(repo.id)) {
135
+ if (isActive(run) && (run.pid === undefined || !options.process.isAlive(run.pid))) {
136
+ const endedAt = yield* Clock.currentTimeMillis
137
+ yield* record(withState(run, { state: "lost", endedAt }))
138
+ }
139
+ }
140
+ }
141
+
142
+ const live = new Map<string, LiveRun>()
143
+ // Runs between their `starting` record and their `live` entry.
144
+ const launching = new Set<string>()
145
+ // Orphan runs (alive, left by an earlier server) a `stop` already
146
+ // signalled, with the outcome of their escalation.
147
+ const orphansStopping = new Map<string, Deferred.Deferred<OperatorRun>>()
148
+ const lock = yield* Semaphore.make(1)
149
+ const watchers = yield* Scope.make()
150
+
151
+ const isOrphan = (run: OperatorRun): boolean =>
152
+ isActive(run) && !live.has(run.runId) && !launching.has(run.runId)
153
+
154
+ // An active record nobody here watches: a run from an earlier server.
155
+ // Its child is re-checked lazily; once gone the record becomes `lost`.
156
+ const reconcile = (run: OperatorRun): Effect.Effect<OperatorRun, PersistenceError> =>
157
+ Effect.gen(function* () {
158
+ if (!isOrphan(run) || (run.pid !== undefined && options.process.isAlive(run.pid))) {
159
+ return run
160
+ }
161
+ const endedAt = yield* Clock.currentTimeMillis
162
+ const lost = withState(run, { state: "lost", endedAt })
163
+ yield* record(lost)
164
+ return lost
165
+ })
166
+
167
+ const readRepoLive = (repoId: string) =>
168
+ readRepo(repoId).pipe(Effect.flatMap((runs) => Effect.forEach(runs, reconcile)))
169
+
170
+ const allRuns = (repoId?: string) =>
171
+ Effect.forEach(
172
+ options.repos.filter((repo) => repoId === undefined || repo.id === repoId),
173
+ (repo) => readRepoLive(repo.id)
174
+ ).pipe(
175
+ Effect.map((perRepo) => perRepo.flat().sort((a, b) => b.startedAt - a.startedAt)),
176
+ Effect.orDie
177
+ )
178
+
179
+ const get = (runId: string) =>
180
+ allRuns().pipe(Effect.map((runs) => runs.find((run) => run.runId === runId)))
181
+
182
+ const watch = (running: OperatorRun, entry: LiveRun) =>
183
+ Effect.gen(function* () {
184
+ const exit = yield* entry.handle.exited
185
+ const stopped = yield* Ref.get(entry.stopping)
186
+ const endedAt = yield* Clock.currentTimeMillis
187
+ const final = withState(running, {
188
+ state: stopped ? "interrupted" : "exited",
189
+ endedAt,
190
+ ...(exit.code === undefined ? {} : { exitCode: exit.code }),
191
+ ...(exit.signal === undefined ? {} : { signal: exit.signal })
192
+ })
193
+ // A failed append leaves the record active; with the child gone, the
194
+ // next read reconciles it to `lost`. `stop` must not wait on it.
195
+ yield* record(final).pipe(
196
+ Effect.ignore,
197
+ Effect.ensuring(
198
+ Effect.suspend(() => {
199
+ live.delete(running.runId)
200
+ return Deferred.succeed(entry.final, final)
201
+ })
202
+ )
203
+ )
204
+ })
205
+
206
+ const start = (request: StartRun) =>
207
+ lock.withPermits(1)(
208
+ Effect.gen(function* () {
209
+ const existing = yield* readRepoLive(request.repo.id)
210
+ const requestId = request.knobs.requestId
211
+ if (requestId !== undefined) {
212
+ const repeated = existing.find((run) => run.requestId === requestId)
213
+ if (repeated !== undefined) return repeated
214
+ }
215
+ if (!readOnlyFlows.has(request.flow.name)) {
216
+ const busy = existing.filter((run) => isActive(run) && !readOnlyFlows.has(run.flow))
217
+ const [newest] = busy
218
+ if (newest !== undefined && busy.length >= options.parallelRuns) {
219
+ return yield* new RunInProgress({
220
+ message: `${request.repo.id} already runs ${newest.flow} (${newest.runId})`,
221
+ runId: newest.runId
222
+ })
223
+ }
224
+ }
225
+ const startedAt = yield* Clock.currentTimeMillis
226
+ const taken = (id: string) => live.has(id) || existing.some((run) => run.runId === id)
227
+ let runId = `op-${startedAt}-${random()}`
228
+ for (let attempt = 0; attempt < 16 && taken(runId); attempt++) {
229
+ runId = `op-${startedAt}-${random()}`
230
+ }
231
+ const tracePath = join(request.repo.path, ".llm4ts", `trace-${startedAt}.jsonl`)
232
+ const logDir = join(stateDir(request.repo.id), "runs", runId)
233
+ const args = knobsToArgs(request.flow.name, request.knobs)
234
+ const starting = new OperatorRun({
235
+ runId,
236
+ repo: request.repo.id,
237
+ flow: request.flow.name,
238
+ args,
239
+ knobs: request.knobs,
240
+ startedAt,
241
+ tracePath,
242
+ logDir,
243
+ state: "starting",
244
+ ...(requestId === undefined ? {} : { requestId })
245
+ })
246
+ launching.add(runId)
247
+ return yield* Effect.gen(function* () {
248
+ yield* record(starting)
249
+ const handle = yield* options.process
250
+ .start({
251
+ flowPath: request.flow.path,
252
+ args,
253
+ environment: {
254
+ ...childEnvironment(request.baseEnvironment),
255
+ ...knobsToEnvironment(request.knobs),
256
+ LLM4TS_RUN_ID: runId,
257
+ LLM4TS_TRACE_PATH: tracePath,
258
+ LLM4TS_FLOW: request.flow.name
259
+ },
260
+ cwd: request.repo.path,
261
+ stdio: { stdout: join(logDir, "stdout.log"), stderr: join(logDir, "stderr.log") },
262
+ detached: true
263
+ })
264
+ .pipe(
265
+ Effect.tapError(() =>
266
+ Effect.gen(function* () {
267
+ const endedAt = yield* Clock.currentTimeMillis
268
+ yield* record(withState(starting, { state: "exited", endedAt }))
269
+ })
270
+ )
271
+ )
272
+ const running = withState(starting, { state: "running", pid: handle.pid })
273
+ yield* record(running)
274
+ const entry: LiveRun = {
275
+ handle,
276
+ stopping: yield* Ref.make(false),
277
+ final: yield* Deferred.make<OperatorRun>()
278
+ }
279
+ live.set(runId, entry)
280
+ yield* Effect.forkIn(watch(running, entry), watchers)
281
+ return running
282
+ }).pipe(Effect.ensuring(Effect.sync(() => launching.delete(runId))))
283
+ })
284
+ )
285
+
286
+ // Polls until the pid is gone or `ms` elapses; true when it is gone.
287
+ const awaitGone = (pid: number, ms: number): Effect.Effect<boolean> =>
288
+ Effect.gen(function* () {
289
+ const step = Math.max(1, Math.min(250, ms))
290
+ for (let waited = 0; waited < ms; waited += step) {
291
+ if (!options.process.isAlive(pid)) return true
292
+ yield* Effect.sleep(step)
293
+ }
294
+ return !options.process.isAlive(pid)
295
+ })
296
+
297
+ // A live run's signal-and-escalate sequence. It runs in `watchers`, not
298
+ // in the caller's fiber, so a cancelled `stop` still sends SIGTERM.
299
+ const escalate = (entry: LiveRun): Effect.Effect<void> =>
300
+ Effect.gen(function* () {
301
+ if (yield* Deferred.isDone(entry.final)) return
302
+ yield* entry.handle.signal("SIGINT")
303
+ const graceful = yield* Deferred.await(entry.final).pipe(Effect.timeoutOption(graceMs))
304
+ if (graceful._tag === "None") yield* entry.handle.signal("SIGTERM")
305
+ })
306
+
307
+ // The same for an orphan, by pid. It records `interrupted` once the pid
308
+ // is gone and always completes `outcome`, even when interrupted.
309
+ const escalateOrphan = (
310
+ run: OperatorRun,
311
+ pid: number,
312
+ outcome: Deferred.Deferred<OperatorRun>
313
+ ): Effect.Effect<void> =>
314
+ Effect.gen(function* () {
315
+ yield* options.process.signal(pid, "SIGINT")
316
+ let gone = yield* awaitGone(pid, graceMs)
317
+ if (!gone) {
318
+ yield* options.process.signal(pid, "SIGTERM")
319
+ gone = yield* awaitGone(pid, graceMs)
320
+ }
321
+ if (!gone) return
322
+ const endedAt = yield* Clock.currentTimeMillis
323
+ const interrupted = withState(run, { state: "interrupted", endedAt })
324
+ yield* record(interrupted).pipe(Effect.ignore)
325
+ yield* Deferred.succeed(outcome, interrupted)
326
+ }).pipe(Effect.ensuring(Deferred.succeed(outcome, run)))
327
+
328
+ const stopOrphan = (run: OperatorRun, pid: number): Effect.Effect<OperatorRun> =>
329
+ Effect.gen(function* () {
330
+ const candidate = yield* Deferred.make<OperatorRun>()
331
+ // Check-and-set and fork with no interruption point between them:
332
+ // only the first stop signals, and its escalation always starts.
333
+ const outcome = yield* Effect.uninterruptible(
334
+ Effect.gen(function* () {
335
+ const existing = orphansStopping.get(run.runId)
336
+ if (existing !== undefined) return existing
337
+ orphansStopping.set(run.runId, candidate)
338
+ yield* Effect.forkIn(escalateOrphan(run, pid, candidate), watchers)
339
+ return candidate
340
+ })
341
+ )
342
+ const result = yield* Deferred.await(outcome)
343
+ if (!isActive(result)) return result
344
+ // The child ignored SIGTERM too: report where the registry stands.
345
+ return (yield* get(run.runId)) ?? result
346
+ })
347
+
348
+ const stop = (runId: string): Effect.Effect<OperatorRun, UnknownRun> =>
349
+ Effect.gen(function* () {
350
+ const entry = live.get(runId)
351
+ if (entry === undefined) {
352
+ const known = yield* get(runId)
353
+ if (known === undefined) {
354
+ return yield* new UnknownRun({ message: `no run ${runId}` })
355
+ }
356
+ if (isOrphan(known) && known.pid !== undefined) {
357
+ return yield* stopOrphan(known, known.pid)
358
+ }
359
+ return known
360
+ }
361
+ // Only the first stop signals: a second SIGINT kills a flow at once.
362
+ // The winner forks the escalation into `watchers`; every caller,
363
+ // the winner included, only awaits the outcome.
364
+ yield* Effect.uninterruptible(
365
+ Effect.gen(function* () {
366
+ const already = yield* Ref.getAndSet(entry.stopping, true)
367
+ if (!already) yield* Effect.forkIn(escalate(entry), watchers)
368
+ })
369
+ )
370
+ const graceful = yield* Deferred.await(entry.final).pipe(Effect.timeoutOption(graceMs))
371
+ if (graceful._tag === "Some") return graceful.value
372
+ const forced = yield* Deferred.await(entry.final).pipe(Effect.timeoutOption(graceMs))
373
+ if (forced._tag === "Some") return forced.value
374
+ // The child ignored SIGTERM too: report where the registry stands.
375
+ const latest = yield* get(runId)
376
+ return latest ?? (yield* new UnknownRun({ message: `no run ${runId}` }))
377
+ })
378
+
379
+ // Live runs are stopped (their escalations finish within the stop's
380
+ // wait) and pending orphan escalations awaited before `watchers` closes.
381
+ yield* Effect.addFinalizer(() =>
382
+ Effect.all(
383
+ [
384
+ Effect.forEach([...live.keys()], (runId) => Effect.ignore(stop(runId)), {
385
+ concurrency: "unbounded",
386
+ discard: true
387
+ }),
388
+ Effect.forEach([...orphansStopping.values()], Deferred.await, {
389
+ concurrency: "unbounded",
390
+ discard: true
391
+ })
392
+ ],
393
+ { concurrency: "unbounded", discard: true }
394
+ ).pipe(Effect.andThen(Scope.close(watchers, Exit.void)))
395
+ )
396
+
397
+ return {
398
+ start,
399
+ stop,
400
+ get,
401
+ list: allRuns
402
+ } satisfies RunSupervisorShape
403
+ })
@@ -0,0 +1,6 @@
1
+ import * as Schema from "effect/Schema"
2
+
3
+ /** Shared so modules that `Cli.ts` imports can fail with it without an import cycle. */
4
+ export class ShellUsageError extends Schema.TaggedError<ShellUsageError>()("ShellUsage", {
5
+ message: Schema.String
6
+ }) {}