@llm4ts/flow 2.41.0 → 2.42.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.
@@ -16,6 +16,11 @@ export interface ContextOptions {
16
16
  readonly prefer?: string
17
17
  /** Who the context is for, in the roster's events ("story conto-overview"). */
18
18
  readonly label?: string
19
+ /**
20
+ * Take a coder only if one is free now, else fail `RosterExhausted` at
21
+ * once: a task offered aside never waits for a slot (ADR 0034).
22
+ */
23
+ readonly ifFree?: boolean
19
24
  }
20
25
 
21
26
  export interface FlowContextShape {
package/src/FlowEvents.ts CHANGED
@@ -295,6 +295,10 @@ export class TaskStarted extends Schema.TaggedClass<TaskStarted>()("TaskStarted"
295
295
  count: Schema.Int,
296
296
  title: Schema.String,
297
297
  satisfies: Schema.optionalKey(Schema.Array(Schema.Int)),
298
+ /** The task runs beside the story's held coder, in its own worktree (ADR 0034). */
299
+ parallel: Schema.optionalKey(Schema.Boolean),
300
+ /** The clone of the extra coder a parallel task holds (ADR 0034). */
301
+ clone: Schema.optionalKey(Schema.Int),
298
302
  lane: Schema.optionalKey(Schema.String),
299
303
  executor: Schema.optionalKey(Schema.String)
300
304
  }) {}
@@ -513,7 +517,11 @@ const stamped = (
513
517
  count: event.count,
514
518
  title: event.title,
515
519
  ...(event.satisfies === undefined ? {} : { satisfies: event.satisfies }),
516
- ...tags
520
+ ...tags,
521
+ // A parallel task names its own coder, not the lane's (ADR 0034).
522
+ ...(event.parallel === undefined ? {} : { parallel: event.parallel }),
523
+ ...(event.executor === undefined ? {} : { executor: event.executor }),
524
+ ...(event.clone === undefined ? {} : { clone: event.clone })
517
525
  })
518
526
  case "TaskCompleted":
519
527
  return event.lane !== undefined
package/src/Perimeter.ts CHANGED
@@ -131,3 +131,39 @@ export const strayTasks = (
131
131
  })
132
132
  return [{ task, paths, foreign }]
133
133
  })
134
+
135
+ /** A task's `Owns:` entry as a path prefix: `src/session/*` and `src/routes/**` own their directory. */
136
+ const ownedPrefix = (entry: string): string => entry.replace(/\/?\*+$/u, "")
137
+
138
+ /**
139
+ * A task run aside (ADR 0034) as a gate over what it changed: every path
140
+ * outside its `Owns:` entries is one Critical issue handed back to its
141
+ * coder, so two tasks running at once keep to their own files. A task that
142
+ * names no paths is not checked; the story's perimeter still is.
143
+ */
144
+ export const taskOwnsGate = (
145
+ changedPaths: ReadonlyArray<string>,
146
+ owns: ReadonlyArray<string>
147
+ ): ReviewResult => {
148
+ if (owns.length === 0) {
149
+ return ReviewResult.make({ issues: [], summary: "" })
150
+ }
151
+ const prefixes = owns.map(ownedPrefix)
152
+ const stray = changedPaths.filter((path) => !prefixes.some((prefix) => pathWithin(path, prefix)))
153
+ return stray.length === 0
154
+ ? ReviewResult.make({ issues: [], summary: "task owns: clean" })
155
+ : ReviewResult.make({
156
+ issues: [
157
+ ReviewIssue.make({
158
+ severity: "Critical",
159
+ title: "task owns",
160
+ description: [
161
+ `This task owns ${owns.join(", ")} and changed paths outside them: ${stray.join(", ")}.`,
162
+ "Another task may be changing those right now. Revert them and keep this task's",
163
+ "work inside the paths it owns."
164
+ ].join("\n")
165
+ })
166
+ ],
167
+ summary: "task owns: violated"
168
+ })
169
+ }
package/src/Plan.ts CHANGED
@@ -120,3 +120,74 @@ export const parsePlan = Effect.fn("@llm4ts/flow/Plan.parse")(function* (
120
120
  ...(brief === undefined ? {} : { brief })
121
121
  })
122
122
  })
123
+
124
+ // ---- The task graph (ADR 0034) ------------------------------------------------
125
+
126
+ /**
127
+ * The tasks a task waits for, from its description's `Depends on:` line
128
+ * (`1, 3`, or `none`). Without the line it waits for every earlier task, so
129
+ * a plan written before ADR 0034 runs in order. Only earlier tasks count: a
130
+ * forward or self reference is dropped, so the graph never has a cycle, and
131
+ * a line left naming none of them is read as no line.
132
+ */
133
+ export const dependsOnOf = (description: string, index: number): ReadonlyArray<number> => {
134
+ const earlier = Array.from({ length: Math.max(0, index - 1) }, (_, position) => position + 1)
135
+ const match = /\bDepends on:\s*([^\n.]*)/iu.exec(description)
136
+ if (match === null) {
137
+ return earlier
138
+ }
139
+ const text = match[1] ?? ""
140
+ if (/^\s*none\b/iu.test(text)) {
141
+ return []
142
+ }
143
+ const named = [
144
+ ...new Set(
145
+ text
146
+ .split(/[^\d]+/u)
147
+ .filter((part) => part.length > 0)
148
+ .map((part) => Number.parseInt(part, 10))
149
+ .filter((number) => Number.isInteger(number) && number >= 1 && number < index)
150
+ )
151
+ ]
152
+ return named.length === 0 ? earlier : named
153
+ }
154
+
155
+ /**
156
+ * The paths a task says it changes, from its `Owns:` line; empty when it
157
+ * says none. The line ends at a newline, the next label, or a period that
158
+ * ends the sentence (the planner writes one-line JSON descriptions).
159
+ */
160
+ export const ownsOf = (description: string): ReadonlyArray<string> => {
161
+ const match = /\bOwns:\s*(.*?)(?=\s+(?:Depends on|Satisfies):|\.\s|\.$|\n|$)/iu.exec(description)
162
+ return match === null
163
+ ? []
164
+ : (match[1] ?? "")
165
+ .split(/[,\s]+/u)
166
+ .map((path) =>
167
+ path
168
+ .trim()
169
+ .replace(/^`|`$/gu, "")
170
+ .replace(/[.;]+$/u, "")
171
+ )
172
+ .filter((path) => path.length > 0 && path.toLowerCase() !== "none")
173
+ }
174
+
175
+ /** One task of a plan as the scheduler sees it: its 1-based index and what it waits for. */
176
+ export interface TaskNode {
177
+ readonly index: number
178
+ readonly task: Task
179
+ readonly dependsOn: ReadonlyArray<number>
180
+ readonly owns: ReadonlyArray<string>
181
+ }
182
+
183
+ export const taskGraph = (plan: Plan): ReadonlyArray<TaskNode> =>
184
+ plan.tasks.map((task, position) => ({
185
+ index: position + 1,
186
+ task,
187
+ dependsOn: dependsOnOf(task.description, position + 1),
188
+ owns: ownsOf(task.description)
189
+ }))
190
+
191
+ /** Whether some task of the plan may start without the task right before it. */
192
+ export const hasParallelTasks = (plan: Plan): boolean =>
193
+ taskGraph(plan).some((node) => node.index > 1 && !node.dependsOn.includes(node.index - 1))
@@ -1,4 +1,8 @@
1
1
  import * as Effect from "effect/Effect"
2
+ import * as Exit from "effect/Exit"
3
+ import * as Fiber from "effect/Fiber"
4
+ import * as Queue from "effect/Queue"
5
+ import * as Semaphore from "effect/Semaphore"
2
6
  import {
3
7
  StageCompleted,
4
8
  StageFailed,
@@ -9,8 +13,8 @@ import {
9
13
  type FlowEventsShape
10
14
  } from "./FlowEvents.ts"
11
15
  import { withKindSpan, type SpanOptions } from "./Spans.ts"
12
- import { describeFlowError, type FlowError } from "./FlowError.ts"
13
- import type { Plan, Task } from "./Plan.ts"
16
+ import { FlowAborted, describeFlowError, type FlowError } from "./FlowError.ts"
17
+ import { taskGraph, type Plan, type Task, type TaskNode } from "./Plan.ts"
14
18
  import type { PlanStoreShape } from "./Persistence.ts"
15
19
 
16
20
  const errorMessage = describeFlowError
@@ -97,3 +101,185 @@ export const implementTaskLoop = Effect.fn("@llm4ts/flow/PlanExecution.implement
97
101
  return current
98
102
  }
99
103
  )
104
+
105
+ // ---- Tasks beside the held coder (ADR 0034) -----------------------------------
106
+
107
+ /**
108
+ * How a task tried beside the held coder ended: `merged` into the story,
109
+ * `declined` (no free coder, or it could not finish there) or `conflict`
110
+ * (its merge back conflicted). The last two go back to the held coder; a
111
+ * conflicting task runs after the others, on the merged state.
112
+ */
113
+ export type AsideOutcome = "merged" | "declined" | "conflict"
114
+
115
+ export interface AsideRequest {
116
+ readonly node: TaskNode
117
+ /** The plan's size, for `task n/m`. */
118
+ readonly count: number
119
+ readonly planSoFar: Plan
120
+ /** Runs `effect` while no task runs at home: the merge back into the story. */
121
+ readonly exclusive: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
122
+ /** Says the task started, on the coder it got; call it once that coder is held. */
123
+ readonly announce: (coder: {
124
+ readonly executor?: string
125
+ readonly clone?: number
126
+ }) => Effect.Effect<void>
127
+ }
128
+
129
+ export interface ParallelTasks<E, R> {
130
+ /** Coders one story may hold at once, its own included; 1 runs every task at home. */
131
+ readonly concurrency: number
132
+ readonly aside: (request: AsideRequest) => Effect.Effect<AsideOutcome, E, R>
133
+ }
134
+
135
+ /** A task's run that ended: at home (`undefined`) or aside (its outcome). */
136
+ interface Finished<E> {
137
+ readonly kind: "home" | "aside"
138
+ readonly node: TaskNode
139
+ readonly exit: Exit.Exit<AsideOutcome | undefined, E>
140
+ }
141
+
142
+ /**
143
+ * The task loop over the plan's graph (ADR 0034): the held coder runs one
144
+ * ready task at a time, in plan order, as `implementTaskLoop` does; up to
145
+ * `concurrency - 1` other ready tasks are offered to `aside`, which runs
146
+ * each on another coder and merges it back under `exclusive`. A task is
147
+ * ready when the tasks it depends on are complete; a plan whose every task
148
+ * depends on the one before runs exactly as `implementTaskLoop`.
149
+ */
150
+ export const implementTaskGraph = Effect.fn("@llm4ts/flow/PlanExecution.implementTaskGraph")(
151
+ function* <E, R, E2, R2>(
152
+ store: PlanStoreShape,
153
+ events: FlowEventsShape,
154
+ planPath: string,
155
+ plan: Plan,
156
+ perTask: (task: Task, planSoFar: Plan) => Effect.Effect<void, E, R>,
157
+ parallel: ParallelTasks<E2, R2>
158
+ ): Effect.fn.Return<Plan, E | E2 | FlowError, R | R2> {
159
+ const graph = taskGraph(plan)
160
+ const count = graph.length
161
+ yield* events.publish(
162
+ TasksPlanned.make({
163
+ tasks: plan.tasks.map((task) => ({
164
+ title: task.title,
165
+ completed: task.completed,
166
+ ...withSatisfies(task.description)
167
+ }))
168
+ })
169
+ )
170
+ let current = plan
171
+ const done = new Set(graph.filter((node) => node.task.completed).map((node) => node.index))
172
+ /** Tasks that go to the held coder only, and those that wait for every other one. */
173
+ const homeOnly = new Set<number>()
174
+ const last = new Set<number>()
175
+ const running = new Map<number, "home" | "aside">()
176
+ const fibers: Array<Fiber.Fiber<unknown, unknown>> = []
177
+ const finished = yield* Queue.unbounded<Finished<E | E2>>()
178
+ const home = yield* Semaphore.make(1)
179
+ const exclusive = <A, EX, RX>(effect: Effect.Effect<A, EX, RX>) => home.withPermit(effect)
180
+
181
+ const started = (
182
+ node: TaskNode,
183
+ extra: Partial<Pick<TaskStarted, "parallel" | "executor" | "clone">> = {}
184
+ ) =>
185
+ events.publish(
186
+ TaskStarted.make({
187
+ index: node.index,
188
+ count,
189
+ title: node.task.title,
190
+ ...withSatisfies(node.task.description),
191
+ ...extra
192
+ })
193
+ )
194
+ const complete = (node: TaskNode) =>
195
+ Effect.gen(function* () {
196
+ current = current.complete(node.task.title)
197
+ yield* store.save(planPath, current)
198
+ done.add(node.index)
199
+ yield* events.publish(
200
+ TaskCompleted.make({ index: node.index, count, title: node.task.title })
201
+ )
202
+ })
203
+
204
+ while (done.size < count) {
205
+ const ready = graph.filter(
206
+ (node) =>
207
+ !done.has(node.index) &&
208
+ !running.has(node.index) &&
209
+ node.dependsOn.every((index) => done.has(index))
210
+ )
211
+ if (![...running.values()].includes("home")) {
212
+ const pick = ready.find((node) => !last.has(node.index)) ?? ready[0]
213
+ if (pick !== undefined) {
214
+ running.set(pick.index, "home")
215
+ const snapshot = current
216
+ fibers.push(
217
+ yield* Effect.forkChild(
218
+ exclusive(
219
+ Effect.andThen(
220
+ started(pick),
221
+ stage(events, pick.task.title, perTask(pick.task, snapshot))
222
+ )
223
+ ).pipe(
224
+ Effect.as(undefined),
225
+ Effect.exit,
226
+ Effect.flatMap((exit) => Queue.offer(finished, { kind: "home", node: pick, exit }))
227
+ )
228
+ )
229
+ )
230
+ }
231
+ }
232
+ for (const node of ready) {
233
+ if (running.size >= parallel.concurrency) {
234
+ break
235
+ }
236
+ if (running.has(node.index) || homeOnly.has(node.index)) {
237
+ continue
238
+ }
239
+ running.set(node.index, "aside")
240
+ const request: AsideRequest = {
241
+ node,
242
+ count,
243
+ planSoFar: current,
244
+ exclusive,
245
+ announce: (coder) =>
246
+ started(node, {
247
+ parallel: true,
248
+ ...(coder.executor === undefined ? {} : { executor: coder.executor }),
249
+ ...(coder.clone === undefined ? {} : { clone: coder.clone })
250
+ })
251
+ }
252
+ fibers.push(
253
+ yield* Effect.forkChild(
254
+ parallel.aside(request).pipe(
255
+ Effect.exit,
256
+ Effect.flatMap((exit) => Queue.offer(finished, { kind: "aside", node, exit }))
257
+ )
258
+ )
259
+ )
260
+ }
261
+ if (running.size === 0) {
262
+ return yield* FlowAborted.make({
263
+ message: `plan ${plan.epicId}: no task can start, though ${count - done.size} are not done`
264
+ })
265
+ }
266
+ const next = yield* Queue.take(finished)
267
+ running.delete(next.node.index)
268
+ if (Exit.isFailure(next.exit)) {
269
+ yield* Fiber.interruptAll(fibers)
270
+ return yield* Effect.failCause(next.exit.cause)
271
+ }
272
+ if (next.kind === "home") {
273
+ yield* complete(next.node)
274
+ } else if (next.exit.value === "merged") {
275
+ yield* complete(next.node)
276
+ } else {
277
+ homeOnly.add(next.node.index)
278
+ if (next.exit.value === "conflict") {
279
+ last.add(next.node.index)
280
+ }
281
+ }
282
+ }
283
+ return current
284
+ }
285
+ )
@@ -11,7 +11,7 @@ import * as Stream from "effect/Stream"
11
11
  import { ConfigError, type LlmError } from "@llm4ts/core/Errors"
12
12
  import type { LlmServiceShape } from "@llm4ts/core/LlmService"
13
13
  import { Message, type ConnectorCapabilities, type LlmChunk } from "@llm4ts/core/Models"
14
- import { describeFlowError, type FlowError } from "./FlowError.ts"
14
+ import { describeFlowError, type FlowError, RosterExhausted } from "./FlowError.ts"
15
15
  import { ExecutorHandedOver, type FlowEventsShape } from "./FlowEvents.ts"
16
16
  import { callPurpose } from "./Timing.ts"
17
17
  import {
@@ -48,6 +48,8 @@ export interface RosterView {
48
48
  readonly capacityChanged: (role: Role, from: number) => Effect.Effect<number>
49
49
  /** The executor holding this context's coder, once leased. */
50
50
  readonly executor: Effect.Effect<string | undefined>
51
+ /** Which clone of it (ADR 0033), once leased. */
52
+ readonly clone?: Effect.Effect<number | undefined>
51
53
  /** Every executor that held this context's coder, in order (handovers). */
52
54
  readonly history: Effect.Effect<ReadonlyArray<string>>
53
55
  /** The autonomy contract profile an executor's roster entry asks for (ADR 0027), when it does. */
@@ -253,6 +255,11 @@ export interface HeldCoderOptions {
253
255
  /** Handovers before a failure surfaces. Default 2. */
254
256
  readonly maxHandovers?: number
255
257
  readonly label?: string
258
+ /**
259
+ * Lease only a slot that is free now; none fails `RosterExhausted` at
260
+ * once instead of waiting (a task run aside, ADR 0034).
261
+ */
262
+ readonly ifFree?: boolean
256
263
  }
257
264
 
258
265
  export interface HeldCoder {
@@ -305,12 +312,26 @@ export const makeHeldCoder = Effect.fn("@llm4ts/flow/RosterSeats.heldCoder")(fun
305
312
  return held
306
313
  }
307
314
  const preferred = yield* Ref.get(prefer)
308
- const lease = yield* roster
309
- .lease("coder", {
310
- ...(preferred === undefined ? {} : { prefer: preferred }),
311
- ...(options.label === undefined ? {} : { label: options.label })
312
- })
313
- .pipe(Scope.provide(scope))
315
+ const leaseOptions = {
316
+ ...(preferred === undefined ? {} : { prefer: preferred }),
317
+ ...(options.label === undefined ? {} : { label: options.label })
318
+ }
319
+ const lease =
320
+ options.ifFree === true
321
+ ? yield* roster.tryLease("coder", leaseOptions).pipe(
322
+ Scope.provide(scope),
323
+ Effect.flatMap((free) =>
324
+ free === undefined
325
+ ? Effect.fail(
326
+ RosterExhausted.make({
327
+ role: "coder",
328
+ reasons: ["no coder slot is free now"]
329
+ })
330
+ )
331
+ : Effect.succeed(free)
332
+ )
333
+ )
334
+ : yield* roster.lease("coder", leaseOptions).pipe(Scope.provide(scope))
314
335
  const seat = yield* source.seatFor(lease.executor, "coder", workDir)
315
336
  const next = { lease, seat }
316
337
  yield* Ref.set(current, next)