@llm4ts/flow 2.35.3 → 2.36.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/src/Roster.ts CHANGED
@@ -13,6 +13,7 @@ import * as Schema from "effect/Schema"
13
13
  import type * as Scope from "effect/Scope"
14
14
  import * as Semaphore from "effect/Semaphore"
15
15
  import type { LlmError } from "@llm4ts/core/Errors"
16
+ import { Effort } from "@llm4ts/core/Models"
16
17
  import { RosterExhausted, type FlowError } from "./FlowError.ts"
17
18
  import {
18
19
  Began,
@@ -66,7 +67,14 @@ export class ExecutorSpec extends Schema.Class<ExecutorSpec>("ExecutorSpec")({
66
67
  /** In a repository roster: removes the user roster's entry with this id. */
67
68
  disabled: Schema.optionalKey(Schema.Boolean),
68
69
  /** The autonomy contract profile this executor's coder prompts carry (ADR 0027); default `full`. */
69
- contract: Schema.optionalKey(Schema.Literals(["full", "minimal", "off"]))
70
+ contract: Schema.optionalKey(Schema.Literals(["full", "minimal", "off"])),
71
+ /**
72
+ * Reasoning effort (ADR 0029): one for every role, or per role with a
73
+ * `default`, mapped to the harness's own flag; absent keeps its default.
74
+ */
75
+ effort: Schema.optionalKey(Schema.Union([Effort, Schema.Record(Schema.String, Effort)])),
76
+ /** Run the harness without the repository's own hooks, MCP servers, settings and instructions (ADR 0029). */
77
+ isolated: Schema.optionalKey(Schema.Boolean)
70
78
  }) {}
71
79
 
72
80
  export class RosterDocument extends Schema.Class<RosterDocument>("RosterDocument")({
@@ -117,6 +125,15 @@ export const coderSlotsOf = (spec: ExecutorSpec): number => {
117
125
  return hasRole(spec, "coder") && reasons && slots > 1 ? slots - 1 : slots
118
126
  }
119
127
 
128
+ /** The effort a role runs at on this executor (ADR 0029); absent is the harness's default. */
129
+ export const effortOf = (spec: ExecutorSpec, role: Role): Effort | undefined => {
130
+ const effort = spec.effort
131
+ if (effort === undefined || typeof effort === "string") {
132
+ return effort
133
+ }
134
+ return effort[role] ?? effort.default
135
+ }
136
+
120
137
  export const priorityOf = (spec: ExecutorSpec, role: Role): number => {
121
138
  const priority = spec.priority
122
139
  if (priority === undefined) {
@@ -207,6 +224,14 @@ export const rosterViolations = (document: RosterDocument): ReadonlyArray<string
207
224
  violations.push(`${where} has an unreadable ${name} cooldown '${text}'`)
208
225
  }
209
226
  }
227
+ if (spec.effort !== undefined && typeof spec.effort !== "string") {
228
+ const unknown = Object.keys(spec.effort).filter((key) => key !== "default" && !isRole(key))
229
+ if (unknown.length > 0) {
230
+ violations.push(
231
+ `${where} sets effort for unknown role(s) ${unknown.join(", ")} (roles: ${roles.join(", ")}, or default)`
232
+ )
233
+ }
234
+ }
210
235
  }
211
236
  return violations
212
237
  }
package/src/WorkQueue.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // feature with a DAG and a judge, a queue item is a file with a predicate.
6
6
  import * as Clock from "effect/Clock"
7
7
  import * as Effect from "effect/Effect"
8
+ import * as Ref from "effect/Ref"
8
9
  import * as Schema from "effect/Schema"
9
10
  import { Stalled, describeFlowError, type FlowError } from "./FlowError.ts"
10
11
  import {
@@ -20,6 +21,17 @@ export interface QueueItem {
20
21
  readonly id: string
21
22
  }
22
23
 
24
+ /**
25
+ * One checkout the queue may work in (ADR 0028, decided later): a git
26
+ * worktree of its own, so units that build or edit shared files do not
27
+ * trip over each other. The flow creates, merges and removes shards; the
28
+ * queue only hands each in-flight item one and never two items the same.
29
+ */
30
+ export interface Shard {
31
+ readonly id: string
32
+ readonly dir: string
33
+ }
34
+
23
35
  export const Confidence = Schema.Literals(["high", "medium", "low"])
24
36
 
25
37
  /** One line of the ledger: what happened to one item in one round. */
@@ -47,12 +59,18 @@ export interface WorkResult {
47
59
  export interface RunQueueOptions<Item extends QueueItem> {
48
60
  readonly label: string
49
61
  readonly items: ReadonlyArray<Item>
50
- /** The filesystem predicate: an item whose output exists is done, whatever the ledger says. */
51
- readonly done: (item: Item) => Effect.Effect<boolean, FlowError>
62
+ /**
63
+ * The filesystem predicate: an item whose output exists is done, whatever
64
+ * the ledger says. Right after `work` it is asked with the shard the work
65
+ * ran in, before the first round without one.
66
+ */
67
+ readonly done: (item: Item, shard?: Shard) => Effect.Effect<boolean, FlowError>
52
68
  /** One unit of work: implement, review, fix; the queue catches its failure. */
53
- readonly work: (item: Item, round: number) => Effect.Effect<WorkResult, FlowError>
69
+ readonly work: (item: Item, round: number, shard?: Shard) => Effect.Effect<WorkResult, FlowError>
54
70
  readonly events: FlowEventsShape
55
71
  readonly concurrency?: number
72
+ /** Checkouts to spread the work over; at most one in-flight item per shard, so they also cap concurrency. */
73
+ readonly shards?: ReadonlyArray<Shard>
56
74
  /** Rounds over the items that failed; default 3. */
57
75
  readonly maxRounds?: number
58
76
  /** Where outcomes are appended, one JSON line each; omit to keep none. */
@@ -95,8 +113,22 @@ export const readLedger = (
95
113
  export const runQueue = Effect.fn("@llm4ts/flow/WorkQueue.run")(function* <Item extends QueueItem>(
96
114
  options: RunQueueOptions<Item>
97
115
  ): Effect.fn.Return<QueueReport<Item>, FlowError> {
98
- const concurrency = Math.max(1, options.concurrency ?? 4)
116
+ const shards = options.shards ?? []
117
+ const concurrency = Math.max(
118
+ 1,
119
+ Math.min(
120
+ options.concurrency ?? 4,
121
+ shards.length === 0 ? Number.POSITIVE_INFINITY : shards.length
122
+ )
123
+ )
99
124
  const maxRounds = Math.max(1, options.maxRounds ?? 3)
125
+ // The free shards: an item takes one before its work and returns it after.
126
+ const free = yield* Ref.make<ReadonlyArray<Shard>>(shards)
127
+ const takeShard: Effect.Effect<Shard | undefined> = Ref.modify(free, (list) =>
128
+ list.length === 0 ? [undefined, list] : [list[0], list.slice(1)]
129
+ )
130
+ const giveShard = (shard: Shard | undefined): Effect.Effect<void> =>
131
+ shard === undefined ? Effect.void : Ref.update(free, (list) => [...list, shard])
100
132
  const outcomes: Array<QueueOutcome> = []
101
133
  const record = (outcome: QueueOutcome): Effect.Effect<void, FlowError> =>
102
134
  Effect.gen(function* () {
@@ -137,7 +169,8 @@ export const runQueue = Effect.fn("@llm4ts/flow/WorkQueue.run")(function* <Item
137
169
  const stage = `${options.label} ${item.id}`
138
170
  yield* options.events.publish(StageStarted.make({ stage, lane: item.id }))
139
171
  const started = yield* Clock.currentTimeMillis
140
- const result = yield* options.work(item, round).pipe(
172
+ const shard = yield* takeShard
173
+ const result = yield* options.work(item, round, shard).pipe(
141
174
  Effect.map((value) => ({ ok: true as const, value })),
142
175
  Effect.catch((error: FlowError) =>
143
176
  Effect.succeed({ ok: false as const, error: describeFlowError(error) })
@@ -145,7 +178,8 @@ export const runQueue = Effect.fn("@llm4ts/flow/WorkQueue.run")(function* <Item
145
178
  )
146
179
  const ended = yield* Clock.currentTimeMillis
147
180
  // The predicate decides, not the work's own account of itself.
148
- const finished = result.ok && (yield* options.done(item))
181
+ const finished = result.ok && (yield* options.done(item, shard))
182
+ yield* giveShard(shard)
149
183
  const outcome = QueueOutcome.make({
150
184
  id: item.id,
151
185
  round,