@llm4ts/flow 2.32.0 → 2.34.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,194 @@
1
+ // A mechanical work queue (ADR 0028): many small units with a filesystem
2
+ // "done" predicate, worked in rounds under a concurrency cap, each unit's
3
+ // failure becoming the next round's item, progress persisted as a ledger so
4
+ // a rerun resumes where the run stopped. The Bun port's shape; a story is a
5
+ // feature with a DAG and a judge, a queue item is a file with a predicate.
6
+ import * as Clock from "effect/Clock"
7
+ import * as Effect from "effect/Effect"
8
+ import * as Schema from "effect/Schema"
9
+ import { Stalled, describeFlowError, type FlowError } from "./FlowError.ts"
10
+ import {
11
+ Info,
12
+ StageCompleted,
13
+ StageFailed,
14
+ StageStarted,
15
+ type FlowEventsShape
16
+ } from "./FlowEvents.ts"
17
+ import type { PlainFileStoreShape } from "./Persistence.ts"
18
+
19
+ export interface QueueItem {
20
+ readonly id: string
21
+ }
22
+
23
+ export const Confidence = Schema.Literals(["high", "medium", "low"])
24
+
25
+ /** One line of the ledger: what happened to one item in one round. */
26
+ export class QueueOutcome extends Schema.Class<QueueOutcome>("QueueOutcome")({
27
+ id: Schema.String,
28
+ round: Schema.Int,
29
+ status: Schema.Literals(["done", "failed"]),
30
+ ms: Schema.Number,
31
+ note: Schema.optionalKey(Schema.String),
32
+ confidence: Schema.optionalKey(Confidence),
33
+ todos: Schema.optionalKey(Schema.Int),
34
+ at: Schema.Number
35
+ }) {}
36
+
37
+ const encodeOutcome = Schema.encodeSync(Schema.fromJsonString(QueueOutcome))
38
+ const decodeOutcome = Schema.decodeUnknownOption(Schema.fromJsonString(QueueOutcome))
39
+
40
+ /** What a unit of work reports back; the queue records it. */
41
+ export interface WorkResult {
42
+ readonly note?: string
43
+ readonly confidence?: "high" | "medium" | "low"
44
+ readonly todos?: number
45
+ }
46
+
47
+ export interface RunQueueOptions<Item extends QueueItem> {
48
+ readonly label: string
49
+ 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>
52
+ /** One unit of work: implement, review, fix; the queue catches its failure. */
53
+ readonly work: (item: Item, round: number) => Effect.Effect<WorkResult, FlowError>
54
+ readonly events: FlowEventsShape
55
+ readonly concurrency?: number
56
+ /** Rounds over the items that failed; default 3. */
57
+ readonly maxRounds?: number
58
+ /** Where outcomes are appended, one JSON line each; omit to keep none. */
59
+ readonly ledger?: { readonly files: PlainFileStoreShape; readonly path: string }
60
+ /** After every round: the done items of that round, for a commit. */
61
+ readonly afterRound?: (
62
+ round: number,
63
+ done: ReadonlyArray<Item>,
64
+ failed: ReadonlyArray<Item>
65
+ ) => Effect.Effect<void, FlowError>
66
+ }
67
+
68
+ export interface QueueReport<Item extends QueueItem> {
69
+ readonly outcomes: ReadonlyArray<QueueOutcome>
70
+ readonly rounds: number
71
+ /** Items still not done when the queue stopped. */
72
+ readonly pending: ReadonlyArray<Item>
73
+ /** Items that were already done before the first round (a resume). */
74
+ readonly skipped: ReadonlyArray<Item>
75
+ }
76
+
77
+ export const readLedger = (
78
+ files: PlainFileStoreShape,
79
+ path: string
80
+ ): Effect.Effect<ReadonlyArray<QueueOutcome>, FlowError> =>
81
+ Effect.map(files.read(path), (text) =>
82
+ (text ?? "")
83
+ .split("\n")
84
+ .filter((line) => line.trim().length > 0)
85
+ .flatMap((line) => {
86
+ const decoded = decodeOutcome(line)
87
+ return decoded._tag === "Some" ? [decoded.value] : []
88
+ })
89
+ )
90
+
91
+ /**
92
+ * Works the items that are not done, round after round, until every item is
93
+ * done, the rounds run out, or a round makes no progress (typed `Stalled`).
94
+ */
95
+ export const runQueue = Effect.fn("@llm4ts/flow/WorkQueue.run")(function* <Item extends QueueItem>(
96
+ options: RunQueueOptions<Item>
97
+ ): Effect.fn.Return<QueueReport<Item>, FlowError> {
98
+ const concurrency = Math.max(1, options.concurrency ?? 4)
99
+ const maxRounds = Math.max(1, options.maxRounds ?? 3)
100
+ const outcomes: Array<QueueOutcome> = []
101
+ const record = (outcome: QueueOutcome): Effect.Effect<void, FlowError> =>
102
+ Effect.gen(function* () {
103
+ outcomes.push(outcome)
104
+ if (options.ledger !== undefined) {
105
+ yield* options.ledger.files.append(options.ledger.path, `${encodeOutcome(outcome)}\n`)
106
+ }
107
+ })
108
+
109
+ const skipped: Array<Item> = []
110
+ let pending: Array<Item> = []
111
+ for (const item of options.items) {
112
+ if (yield* options.done(item)) {
113
+ skipped.push(item)
114
+ } else {
115
+ pending.push(item)
116
+ }
117
+ }
118
+ if (skipped.length > 0) {
119
+ yield* options.events.publish(
120
+ Info.make({
121
+ message: `${options.label}: ${skipped.length} of ${options.items.length} item(s) already done; ${pending.length} to go`
122
+ })
123
+ )
124
+ }
125
+
126
+ let rounds = 0
127
+ while (pending.length > 0 && rounds < maxRounds) {
128
+ rounds += 1
129
+ const round = rounds
130
+ yield* options.events.publish(
131
+ Info.make({ message: `${options.label}: round ${round}, ${pending.length} item(s)` })
132
+ )
133
+ const results = yield* Effect.forEach(
134
+ pending,
135
+ (item) =>
136
+ Effect.gen(function* () {
137
+ const stage = `${options.label} ${item.id}`
138
+ yield* options.events.publish(StageStarted.make({ stage, lane: item.id }))
139
+ const started = yield* Clock.currentTimeMillis
140
+ const result = yield* options.work(item, round).pipe(
141
+ Effect.map((value) => ({ ok: true as const, value })),
142
+ Effect.catch((error: FlowError) =>
143
+ Effect.succeed({ ok: false as const, error: describeFlowError(error) })
144
+ )
145
+ )
146
+ const ended = yield* Clock.currentTimeMillis
147
+ // The predicate decides, not the work's own account of itself.
148
+ const finished = result.ok && (yield* options.done(item))
149
+ const outcome = QueueOutcome.make({
150
+ id: item.id,
151
+ round,
152
+ status: finished ? "done" : "failed",
153
+ ms: ended - started,
154
+ at: ended,
155
+ ...(result.ok
156
+ ? {
157
+ ...(finished
158
+ ? {}
159
+ : { note: result.value.note ?? "the work ended but left no output" }),
160
+ ...(finished && result.value.note !== undefined
161
+ ? { note: result.value.note }
162
+ : {}),
163
+ ...(result.value.confidence === undefined
164
+ ? {}
165
+ : { confidence: result.value.confidence }),
166
+ ...(result.value.todos === undefined ? {} : { todos: result.value.todos })
167
+ }
168
+ : { note: result.error })
169
+ })
170
+ yield* record(outcome)
171
+ yield* options.events.publish(
172
+ finished
173
+ ? StageCompleted.make({ stage, lane: item.id })
174
+ : StageFailed.make({ stage, lane: item.id, message: outcome.note ?? "failed" })
175
+ )
176
+ return { item, finished }
177
+ }),
178
+ { concurrency }
179
+ )
180
+ const done = results.filter((entry) => entry.finished).map((entry) => entry.item)
181
+ const failed = results.filter((entry) => !entry.finished).map((entry) => entry.item)
182
+ if (options.afterRound !== undefined) {
183
+ yield* options.afterRound(round, done, failed)
184
+ }
185
+ if (done.length === 0 && failed.length > 0) {
186
+ return yield* Stalled.make({
187
+ signal: "no-progress",
188
+ detail: `${options.label}: round ${round} finished none of its ${failed.length} item(s)`
189
+ })
190
+ }
191
+ pending = failed
192
+ }
193
+ return { outcomes, rounds, pending, skipped }
194
+ })