@llm4ts/flow 2.30.1 → 2.31.1
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/AutonomyContract.d.ts +12 -0
- package/dist/AutonomyContract.d.ts.map +1 -0
- package/dist/AutonomyContract.js +51 -0
- package/dist/AutonomyContract.js.map +1 -0
- package/dist/CarriedNotes.d.ts +10 -0
- package/dist/CarriedNotes.d.ts.map +1 -1
- package/dist/CarriedNotes.js +27 -2
- package/dist/CarriedNotes.js.map +1 -1
- package/dist/Evidence.d.ts +12 -0
- package/dist/Evidence.d.ts.map +1 -0
- package/dist/Evidence.js +30 -0
- package/dist/Evidence.js.map +1 -0
- package/dist/Flow.d.ts +35 -3
- package/dist/Flow.d.ts.map +1 -1
- package/dist/Flow.js +58 -4
- package/dist/Flow.js.map +1 -1
- package/dist/FlowEvents.d.ts +20 -5
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +32 -9
- package/dist/FlowEvents.js.map +1 -1
- package/dist/GateTriage.d.ts +81 -0
- package/dist/GateTriage.d.ts.map +1 -0
- package/dist/GateTriage.js +161 -0
- package/dist/GateTriage.js.map +1 -0
- package/dist/Gates.d.ts +35 -0
- package/dist/Gates.d.ts.map +1 -0
- package/dist/Gates.js +63 -0
- package/dist/Gates.js.map +1 -0
- package/dist/OracleGuard.d.ts +53 -0
- package/dist/OracleGuard.d.ts.map +1 -0
- package/dist/OracleGuard.js +165 -0
- package/dist/OracleGuard.js.map +1 -0
- package/dist/Pack.d.ts +7 -0
- package/dist/Pack.d.ts.map +1 -1
- package/dist/Pack.js +9 -0
- package/dist/Pack.js.map +1 -1
- package/dist/Retro.d.ts.map +1 -1
- package/dist/Retro.js +5 -0
- package/dist/Retro.js.map +1 -1
- package/dist/Review.d.ts +68 -2
- package/dist/Review.d.ts.map +1 -1
- package/dist/Review.js +158 -27
- package/dist/Review.js.map +1 -1
- package/dist/Roster.d.ts +20 -9
- package/dist/Roster.d.ts.map +1 -1
- package/dist/Roster.js +54 -18
- package/dist/Roster.js.map +1 -1
- package/dist/RosterSeats.d.ts +6 -3
- package/dist/RosterSeats.d.ts.map +1 -1
- package/dist/RosterSeats.js +7 -4
- package/dist/RosterSeats.js.map +1 -1
- package/dist/Stories.d.ts +36 -2
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +159 -12
- package/dist/Stories.js.map +1 -1
- package/dist/StoryPlan.d.ts +6 -0
- package/dist/StoryPlan.d.ts.map +1 -1
- package/dist/StoryPlan.js +9 -2
- package/dist/StoryPlan.js.map +1 -1
- package/package.json +7 -2
- package/src/AutonomyContract.ts +61 -0
- package/src/CarriedNotes.ts +37 -2
- package/src/Evidence.ts +43 -0
- package/src/Flow.ts +109 -3
- package/src/FlowEvents.ts +33 -11
- package/src/GateTriage.ts +250 -0
- package/src/Gates.ts +104 -0
- package/src/OracleGuard.ts +205 -0
- package/src/Pack.ts +16 -0
- package/src/Retro.ts +7 -0
- package/src/Review.ts +261 -34
- package/src/Roster.ts +89 -40
- package/src/RosterSeats.ts +13 -6
- package/src/Stories.ts +289 -16
- package/src/StoryPlan.ts +11 -1
package/src/Review.ts
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
import { join } from "node:path"
|
|
2
2
|
import * as Clock from "effect/Clock"
|
|
3
|
+
import * as Duration from "effect/Duration"
|
|
3
4
|
import * as Effect from "effect/Effect"
|
|
5
|
+
import * as Ref from "effect/Ref"
|
|
4
6
|
import * as Schema from "effect/Schema"
|
|
5
7
|
import type { LlmError } from "@llm4ts/core/Errors"
|
|
6
8
|
import type { LlmServiceShape } from "@llm4ts/core/LlmService"
|
|
7
9
|
import type { JsonSchema } from "@llm4ts/core/Models"
|
|
8
|
-
import type { ProcessExecutorShape } from "@llm4ts/core/ProcessExecutor"
|
|
10
|
+
import type { ProcessExecutorShape, ProcessResult } from "@llm4ts/core/ProcessExecutor"
|
|
9
11
|
import { Capabilities } from "@llm4ts/core/Capability"
|
|
10
12
|
import { guarded } from "./CapabilityGuard.ts"
|
|
11
13
|
import type { Chat } from "./Chat.ts"
|
|
12
14
|
import { FlowLlmError, ProcessError, describeFlowError, type FlowError } from "./FlowError.ts"
|
|
13
15
|
import type { PlainFileStoreShape } from "./Persistence.ts"
|
|
16
|
+
import { GateBaseline, isGateIssue, triageGates } from "./GateTriage.ts"
|
|
17
|
+
import {
|
|
18
|
+
type OracleRules,
|
|
19
|
+
checkOracle,
|
|
20
|
+
defaultOracleRules,
|
|
21
|
+
parseUnifiedDiff,
|
|
22
|
+
passedCountIn
|
|
23
|
+
} from "./OracleGuard.ts"
|
|
14
24
|
import { cachedReview, fingerprintOf } from "./ReviewCache.ts"
|
|
15
25
|
import {
|
|
16
26
|
Info,
|
|
@@ -51,7 +61,13 @@ export class ReviewIssue extends Schema.Class<ReviewIssue>("ReviewIssue")({
|
|
|
51
61
|
confidence: Schema.Number.pipe(
|
|
52
62
|
Schema.withConstructorDefault(Effect.succeed(1)),
|
|
53
63
|
Schema.withDecodingDefaultKey(Effect.succeed(1))
|
|
54
|
-
)
|
|
64
|
+
),
|
|
65
|
+
/** A gate failure's triage against the base (ADR 0027); absent for review findings. */
|
|
66
|
+
origin: Schema.optionalKey(Schema.Literals(["new", "base", "flaky"])),
|
|
67
|
+
/** How a gate ended: a red exit, killed at the timeout, or a signal exit. */
|
|
68
|
+
gateClass: Schema.optionalKey(Schema.Literals(["red", "hang", "crash"])),
|
|
69
|
+
/** Where the gate's full output was written, when the caller asked for a log. */
|
|
70
|
+
logPath: Schema.optionalKey(Schema.String)
|
|
55
71
|
}) {}
|
|
56
72
|
|
|
57
73
|
export class ReviewResult extends Schema.Class<ReviewResult>("ReviewResult")({
|
|
@@ -59,7 +75,9 @@ export class ReviewResult extends Schema.Class<ReviewResult>("ReviewResult")({
|
|
|
59
75
|
summary: Schema.String.pipe(
|
|
60
76
|
Schema.withConstructorDefault(Effect.succeed("")),
|
|
61
77
|
Schema.withDecodingDefaultKey(Effect.succeed(""))
|
|
62
|
-
)
|
|
78
|
+
),
|
|
79
|
+
/** A test gate's passed-test count, when its output had a summary the oracle guard reads (ADR 0027). */
|
|
80
|
+
passed: Schema.optionalKey(Schema.Int)
|
|
63
81
|
}) {
|
|
64
82
|
get isClean(): boolean {
|
|
65
83
|
return this.issues.length === 0
|
|
@@ -241,20 +259,165 @@ export const reviewPrompt = (task: string, diff: string): string =>
|
|
|
241
259
|
diff
|
|
242
260
|
].join("\n")
|
|
243
261
|
|
|
244
|
-
export
|
|
245
|
-
|
|
262
|
+
export interface FixPromptOptions {
|
|
263
|
+
/** Characters of a gate's output kept in the prompt, from the end. Default 4000. */
|
|
264
|
+
readonly tailChars?: number
|
|
265
|
+
/** Name the gate log's path (a CLI coder can open it; an API coder cannot). Default false. */
|
|
266
|
+
readonly showPaths?: boolean
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export const defaultGateTailChars = 4_000
|
|
270
|
+
|
|
271
|
+
const evidenceNote =
|
|
272
|
+
"The gate output above is your only runtime evidence. If you are guessing without it, say `confidence: low` in your Findings."
|
|
273
|
+
|
|
274
|
+
export const fixPrompt = (result: ReviewResult, options: FixPromptOptions = {}): string => {
|
|
275
|
+
const tailChars = options.tailChars ?? defaultGateTailChars
|
|
276
|
+
const lines = result.issues.map((issue) => {
|
|
277
|
+
const gate = isGateIssue(issue)
|
|
278
|
+
const description =
|
|
279
|
+
gate && issue.description.length > tailChars
|
|
280
|
+
? `…${issue.description.slice(-tailChars)}`
|
|
281
|
+
: issue.description
|
|
282
|
+
const path =
|
|
283
|
+
gate && options.showPaths === true && issue.logPath !== undefined
|
|
284
|
+
? ` (full output: ${issue.logPath})`
|
|
285
|
+
: ""
|
|
286
|
+
return `- [${issue.severity}] ${issue.title}: ${description}${path}`
|
|
287
|
+
})
|
|
288
|
+
const hasGate = result.issues.some(isGateIssue)
|
|
289
|
+
return [
|
|
246
290
|
"Address these review findings, then stop:",
|
|
247
|
-
...
|
|
291
|
+
...lines,
|
|
292
|
+
...(hasGate ? ["", evidenceNote] : [])
|
|
248
293
|
].join("\n")
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export interface GateTriageOptions {
|
|
297
|
+
/** The baseline for the code this change started from; `undefined` means no triage. */
|
|
298
|
+
readonly baseline: Effect.Effect<GateBaseline | undefined, FlowError>
|
|
299
|
+
/** Root prefixes stripped from gate output before comparing (the work dir, the app dir). */
|
|
300
|
+
readonly roots: ReadonlyArray<string>
|
|
301
|
+
/** Re-run the test gate alone to tell a flaky line from a new one; omit to never rerun. */
|
|
302
|
+
readonly rerunTest?: Effect.Effect<ReviewResult, FlowError>
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const listed = (lines: ReadonlyArray<string>): string => lines.map((line) => ` ${line}`).join("\n")
|
|
306
|
+
|
|
307
|
+
export interface OracleGateOptions {
|
|
308
|
+
/** The change under review as a unified diff (committed and uncommitted). */
|
|
309
|
+
readonly diff: Effect.Effect<string, FlowError>
|
|
310
|
+
readonly rules?: OracleRules
|
|
311
|
+
/** The plan entry says tests may change (`testsChange: true`); the guard then stays silent. */
|
|
312
|
+
readonly declared?: boolean
|
|
313
|
+
/** Tests passing on the base, from its baseline; omitted or `undefined` skips the count check. */
|
|
314
|
+
readonly baseCount?: Effect.Effect<number | undefined, FlowError>
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The oracle guard (ADR 0027 decision 4) joined to a lint result: deleted
|
|
319
|
+
* test files, added skip or focus markers and a passed-count drop become
|
|
320
|
+
* Critical issues the fix round can undo. When a count is unknown on either
|
|
321
|
+
* side the comparison is skipped and said once.
|
|
322
|
+
*/
|
|
323
|
+
export const withOracle = Effect.fn("@llm4ts/flow/Review.withOracle")(function* (
|
|
324
|
+
lint: ReviewResult,
|
|
325
|
+
oracle: OracleGateOptions | undefined,
|
|
326
|
+
events: FlowEventsShape,
|
|
327
|
+
noted: Ref.Ref<boolean>
|
|
328
|
+
): Effect.fn.Return<ReviewResult, FlowError> {
|
|
329
|
+
if (oracle === undefined || oracle.declared === true) {
|
|
330
|
+
return lint
|
|
331
|
+
}
|
|
332
|
+
const diff = yield* oracle.diff
|
|
333
|
+
const base = oracle.baseCount === undefined ? undefined : yield* oracle.baseCount
|
|
334
|
+
if ((base === undefined || lint.passed === undefined) && !(yield* Ref.get(noted))) {
|
|
335
|
+
yield* Ref.set(noted, true)
|
|
336
|
+
yield* events.publish(
|
|
337
|
+
Info.make({
|
|
338
|
+
message:
|
|
339
|
+
"oracle guard: no passed-test count on the base or the change (no summary line the guard reads), count comparison skipped"
|
|
340
|
+
})
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
const issues = checkOracle(
|
|
344
|
+
parseUnifiedDiff(diff),
|
|
345
|
+
{ base, current: lint.passed },
|
|
346
|
+
oracle.rules ?? defaultOracleRules,
|
|
347
|
+
false
|
|
348
|
+
)
|
|
349
|
+
return issues.length === 0
|
|
350
|
+
? lint
|
|
351
|
+
: ReviewResult.make({
|
|
352
|
+
issues: [...lint.issues, ...issues],
|
|
353
|
+
summary: [lint.summary, "oracle guard"].filter((part) => part.length > 0).join("; "),
|
|
354
|
+
...(lint.passed === undefined ? {} : { passed: lint.passed })
|
|
355
|
+
})
|
|
356
|
+
})
|
|
249
357
|
|
|
250
|
-
|
|
251
|
-
|
|
358
|
+
/**
|
|
359
|
+
* Charges a lint result only with what the change caused (ADR 0027).
|
|
360
|
+
* Inherited lines are published once per `reported` set as Info; with
|
|
361
|
+
* `rerunTest`, a new line that is green on one rerun of the test gate is
|
|
362
|
+
* flaky: published, not charged. Without options or a baseline the result
|
|
363
|
+
* is returned as is.
|
|
364
|
+
*/
|
|
365
|
+
export const applyTriage = Effect.fn("@llm4ts/flow/Review.applyTriage")(function* (
|
|
366
|
+
lint: ReviewResult,
|
|
367
|
+
triage: GateTriageOptions | undefined,
|
|
368
|
+
events: FlowEventsShape,
|
|
369
|
+
reported: Ref.Ref<ReadonlySet<string>>
|
|
370
|
+
): Effect.fn.Return<ReviewResult, FlowError> {
|
|
371
|
+
if (triage === undefined || lint.isClean) {
|
|
372
|
+
return lint
|
|
373
|
+
}
|
|
374
|
+
const baseline = yield* triage.baseline
|
|
375
|
+
if (baseline === undefined) {
|
|
376
|
+
return lint
|
|
377
|
+
}
|
|
378
|
+
let triaged = triageGates(lint, baseline, triage.roots)
|
|
379
|
+
const seen = yield* Ref.get(reported)
|
|
380
|
+
const unseen = triaged.inherited.filter((line) => !seen.has(line))
|
|
381
|
+
if (unseen.length > 0) {
|
|
382
|
+
yield* Ref.update(reported, (set) => new Set([...set, ...unseen]))
|
|
383
|
+
yield* events.publish(
|
|
384
|
+
Info.make({
|
|
385
|
+
message: `${unseen.length} gate failure(s) inherited from the base, not charged to this change:\n${listed(unseen)}`
|
|
386
|
+
})
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
if (!triaged.blocking.isClean && triaged.newLines.length > 0 && triage.rerunTest !== undefined) {
|
|
390
|
+
const again = yield* triage.rerunTest
|
|
391
|
+
const stillRed = new Set(triageGates(again, baseline, triage.roots).newLines)
|
|
392
|
+
const flaky = triaged.newLines.filter((line) => !stillRed.has(line))
|
|
393
|
+
if (flaky.length > 0) {
|
|
394
|
+
yield* events.publish(
|
|
395
|
+
Info.make({
|
|
396
|
+
message: `${flaky.length} gate failure(s) flaky (red once, green on rerun), not charged:\n${listed(flaky)}`
|
|
397
|
+
})
|
|
398
|
+
)
|
|
399
|
+
triaged = triageGates(
|
|
400
|
+
again,
|
|
401
|
+
GateBaseline.make({ ...baseline, failingLines: [...baseline.failingLines, ...flaky] }),
|
|
402
|
+
triage.roots
|
|
403
|
+
)
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return triaged.blocking
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
export const mergeReviewResults = (results: ReadonlyArray<ReviewResult>): ReviewResult => {
|
|
410
|
+
const counts = results.flatMap((result) => (result.passed === undefined ? [] : [result.passed]))
|
|
411
|
+
return ReviewResult.make({
|
|
252
412
|
issues: results.flatMap((result) => result.issues),
|
|
253
413
|
summary: results
|
|
254
414
|
.map((result) => result.summary)
|
|
255
415
|
.filter((summary) => summary.length > 0)
|
|
256
|
-
.join("; ")
|
|
416
|
+
.join("; "),
|
|
417
|
+
// Several gates may count tests (unit and e2e): the sum is what the change must keep.
|
|
418
|
+
...(counts.length === 0 ? {} : { passed: counts.reduce((sum, value) => sum + value, 0) })
|
|
257
419
|
})
|
|
420
|
+
}
|
|
258
421
|
|
|
259
422
|
const processProblem = (
|
|
260
423
|
stdout: ReadonlyArray<string>,
|
|
@@ -265,57 +428,107 @@ const processProblem = (
|
|
|
265
428
|
return detail.length === 0 ? `process exited with code ${exitCode}` : detail
|
|
266
429
|
}
|
|
267
430
|
|
|
431
|
+
export interface LintCommandOptions {
|
|
432
|
+
/** Kill the gate after this long; the issue is then `gateClass: "hang"`. Default: never. */
|
|
433
|
+
readonly timeout?: Duration.Duration
|
|
434
|
+
/** Write the gate's whole stdout and stderr here; the issue carries `logPath`. */
|
|
435
|
+
readonly log?: { readonly files: PlainFileStoreShape; readonly path: string }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const gateClassOf = (exitCode: number): "red" | "crash" => (exitCode >= 128 ? "crash" : "red")
|
|
439
|
+
|
|
268
440
|
export const lintCommand = Effect.fn("@llm4ts/flow/Review.lintCommand")(function* (
|
|
269
441
|
process: ProcessExecutorShape,
|
|
270
442
|
events: FlowEventsShape,
|
|
271
443
|
command: ReadonlyArray<string>,
|
|
272
|
-
workDir: string
|
|
444
|
+
workDir: string,
|
|
445
|
+
options: LintCommandOptions = {}
|
|
273
446
|
): Effect.fn.Return<ReviewResult, FlowError> {
|
|
274
447
|
const executable = command[0]
|
|
275
448
|
if (executable === undefined) {
|
|
276
449
|
return ReviewResult.make({ issues: [], summary: "" })
|
|
277
450
|
}
|
|
451
|
+
const label = command.join(" ")
|
|
278
452
|
const started = yield* Clock.currentTimeMillis
|
|
453
|
+
const run = guarded(
|
|
454
|
+
Capabilities.Exec(executable),
|
|
455
|
+
`lint: ${label}`,
|
|
456
|
+
events,
|
|
457
|
+
process.run(command, workDir, {}).pipe(
|
|
458
|
+
Effect.mapError((cause) =>
|
|
459
|
+
ProcessError.make({
|
|
460
|
+
message: label,
|
|
461
|
+
detail: cause.message
|
|
462
|
+
})
|
|
463
|
+
)
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
// Interrupting `run` interrupts the child through the executor's scope;
|
|
467
|
+
// `undefined` here means the gate never exited (ADR 0027: a hang is a
|
|
468
|
+
// gate failure that says so, never a stuck run).
|
|
469
|
+
const bounded: Effect.Effect<ProcessResult | undefined, FlowError> =
|
|
470
|
+
options.timeout === undefined
|
|
471
|
+
? run
|
|
472
|
+
: run.pipe(
|
|
473
|
+
Effect.timeoutOption(options.timeout),
|
|
474
|
+
Effect.map((option) => (option._tag === "Some" ? option.value : undefined))
|
|
475
|
+
)
|
|
279
476
|
// A TOOL span (ADR 0026) and a Timed event: the command as configured and
|
|
280
477
|
// its exit code, never its output.
|
|
281
478
|
const result = yield* withKindSpan(
|
|
282
|
-
`gate ${
|
|
283
|
-
{ kind: "TOOL", attributes: { [attr.gateCommand]:
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
events,
|
|
288
|
-
process.run(command, workDir, {}).pipe(
|
|
289
|
-
Effect.mapError((cause) =>
|
|
290
|
-
ProcessError.make({
|
|
291
|
-
message: command.join(" "),
|
|
292
|
-
detail: cause.message
|
|
293
|
-
})
|
|
294
|
-
)
|
|
479
|
+
`gate ${label}`,
|
|
480
|
+
{ kind: "TOOL", attributes: { [attr.gateCommand]: label } },
|
|
481
|
+
bounded.pipe(
|
|
482
|
+
Effect.tap((ran) =>
|
|
483
|
+
ran === undefined ? Effect.void : Effect.annotateCurrentSpan(attr.gateExit, ran.exitCode)
|
|
295
484
|
)
|
|
296
|
-
)
|
|
485
|
+
)
|
|
297
486
|
)
|
|
487
|
+
const failed = result === undefined || result.exitCode !== 0
|
|
298
488
|
yield* events.publish(
|
|
299
489
|
Timed.make({
|
|
300
490
|
kind: "gate",
|
|
301
|
-
label
|
|
491
|
+
label,
|
|
302
492
|
ms: (yield* Clock.currentTimeMillis) - started,
|
|
303
|
-
exitCode: result.exitCode,
|
|
304
|
-
...(
|
|
493
|
+
...(result === undefined ? {} : { exitCode: result.exitCode }),
|
|
494
|
+
...(failed ? { failed: true } : {})
|
|
305
495
|
})
|
|
306
496
|
)
|
|
307
|
-
|
|
308
|
-
|
|
497
|
+
let logPath: string | undefined
|
|
498
|
+
if (options.log !== undefined) {
|
|
499
|
+
const output =
|
|
500
|
+
result === undefined ? "" : [...result.stdout, ...result.stderr].join("\n").trim()
|
|
501
|
+
yield* options.log.files.writeAtomic(options.log.path, output)
|
|
502
|
+
logPath = options.log.path
|
|
309
503
|
}
|
|
504
|
+
const passed =
|
|
505
|
+
result === undefined
|
|
506
|
+
? undefined
|
|
507
|
+
: passedCountIn([...result.stdout, ...result.stderr].join("\n"))
|
|
508
|
+
if (result !== undefined && result.exitCode === 0) {
|
|
509
|
+
return ReviewResult.make({
|
|
510
|
+
issues: [],
|
|
511
|
+
summary: "lint passed",
|
|
512
|
+
...(passed === undefined ? {} : { passed })
|
|
513
|
+
})
|
|
514
|
+
}
|
|
515
|
+
const seconds =
|
|
516
|
+
options.timeout === undefined ? 0 : Math.round(Duration.toSeconds(options.timeout))
|
|
310
517
|
return ReviewResult.make({
|
|
311
518
|
issues: [
|
|
312
519
|
ReviewIssue.make({
|
|
313
520
|
severity: "Critical",
|
|
314
|
-
title: `lint failed: ${
|
|
315
|
-
description:
|
|
521
|
+
title: `lint failed: ${label}`,
|
|
522
|
+
description:
|
|
523
|
+
result === undefined
|
|
524
|
+
? `gate killed: no exit after ${seconds} seconds (LLM4TS_GATE_TIMEOUT)`
|
|
525
|
+
: processProblem(result.stdout, result.stderr, result.exitCode),
|
|
526
|
+
gateClass: result === undefined ? "hang" : gateClassOf(result.exitCode),
|
|
527
|
+
...(logPath === undefined ? {} : { logPath })
|
|
316
528
|
})
|
|
317
529
|
],
|
|
318
|
-
summary: "lint failed"
|
|
530
|
+
summary: "lint failed",
|
|
531
|
+
...(passed === undefined ? {} : { passed })
|
|
319
532
|
})
|
|
320
533
|
})
|
|
321
534
|
|
|
@@ -350,6 +563,12 @@ export interface ReviewAndFixOptions {
|
|
|
350
563
|
result: ReviewResult,
|
|
351
564
|
settled: boolean
|
|
352
565
|
) => Effect.Effect<void, FlowError>
|
|
566
|
+
/** Charge the lint gate only with failures the change caused (ADR 0027). */
|
|
567
|
+
readonly triage?: GateTriageOptions
|
|
568
|
+
/** How gate output reaches the coder in the fix prompt. */
|
|
569
|
+
readonly fix?: FixPromptOptions
|
|
570
|
+
/** Fail the round when the change deletes or skips tests (ADR 0027 decision 4). */
|
|
571
|
+
readonly oracle?: OracleGateOptions
|
|
353
572
|
}
|
|
354
573
|
|
|
355
574
|
export interface ReviewCacheLocation {
|
|
@@ -493,13 +712,21 @@ export const reviewAndFixLoop = Effect.fn("@llm4ts/flow/Review.reviewAndFixLoop"
|
|
|
493
712
|
)
|
|
494
713
|
)
|
|
495
714
|
const format = options.format ?? Effect.void
|
|
715
|
+
const reported = yield* Ref.make<ReadonlySet<string>>(new Set())
|
|
716
|
+
const oracleNoted = yield* Ref.make(false)
|
|
496
717
|
|
|
497
718
|
const reviewOnce = (
|
|
498
719
|
round: number,
|
|
499
720
|
previous: ReviewResult | undefined
|
|
500
721
|
): Effect.Effect<ReviewResult, FlowError> =>
|
|
501
722
|
Effect.gen(function* () {
|
|
502
|
-
const
|
|
723
|
+
const lintRaw = yield* options.lint ?? Effect.succeed(ReviewResult.make({ issues: [] }))
|
|
724
|
+
const lint = yield* withOracle(
|
|
725
|
+
yield* applyTriage(lintRaw, options.triage, options.events, reported),
|
|
726
|
+
options.oracle,
|
|
727
|
+
options.events,
|
|
728
|
+
oracleNoted
|
|
729
|
+
)
|
|
503
730
|
if (!lint.isClean) {
|
|
504
731
|
return lint
|
|
505
732
|
}
|
|
@@ -585,7 +812,7 @@ export const reviewAndFixLoop = Effect.fn("@llm4ts/flow/Review.reviewAndFixLoop"
|
|
|
585
812
|
if (settled) {
|
|
586
813
|
return result
|
|
587
814
|
}
|
|
588
|
-
yield* options.coder.ask(fixPrompt(result))
|
|
815
|
+
yield* options.coder.ask(fixPrompt(result, options.fix))
|
|
589
816
|
return yield* loop(round + 1, result)
|
|
590
817
|
})
|
|
591
818
|
|
package/src/Roster.ts
CHANGED
|
@@ -63,7 +63,9 @@ export class ExecutorSpec extends Schema.Class<ExecutorSpec>("ExecutorSpec")({
|
|
|
63
63
|
health: Schema.optionalKey(Schema.String),
|
|
64
64
|
cooldown: Schema.optionalKey(Cooldown),
|
|
65
65
|
/** In a repository roster: removes the user roster's entry with this id. */
|
|
66
|
-
disabled: Schema.optionalKey(Schema.Boolean)
|
|
66
|
+
disabled: Schema.optionalKey(Schema.Boolean),
|
|
67
|
+
/** The autonomy contract profile this executor's coder prompts carry (ADR 0027); default `full`. */
|
|
68
|
+
contract: Schema.optionalKey(Schema.Literals(["full", "minimal", "off"]))
|
|
67
69
|
}) {}
|
|
68
70
|
|
|
69
71
|
export class RosterDocument extends Schema.Class<RosterDocument>("RosterDocument")({
|
|
@@ -393,6 +395,8 @@ export interface Lease {
|
|
|
393
395
|
readonly release: Effect.Effect<void>
|
|
394
396
|
/** Took no slot: the context's own coder executor serves the call (see `LeaseOptions.borrow`). */
|
|
395
397
|
readonly borrowed: boolean
|
|
398
|
+
/** Set when the executor is the context's own coder (not independent), and why; see `ExecutorLeased.because`. */
|
|
399
|
+
readonly because?: "nobody" | "held" | "out"
|
|
396
400
|
}
|
|
397
401
|
|
|
398
402
|
export interface LeaseOptions {
|
|
@@ -403,15 +407,16 @@ export interface LeaseOptions {
|
|
|
403
407
|
/** Who is asking, for the events (a story's worktree). */
|
|
404
408
|
readonly label?: string
|
|
405
409
|
/**
|
|
406
|
-
* The executor holding this context's coder,
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
410
|
+
* The executor holding this context's coder, taken when independence
|
|
411
|
+
* cannot be had now: no executor outside `avoid` can ever take the role,
|
|
412
|
+
* every one that could is held by a coder, or every one is out of the
|
|
413
|
+
* round. A coder's slot frees only when its story ends (which may itself
|
|
414
|
+
* wait on a call like this one), and an exclusion may last hours; a
|
|
415
|
+
* reasoning call only ever waits for another call to end. The own
|
|
416
|
+
* executor is taken on a free slot of its own when it has one, else
|
|
417
|
+
* without a slot (its coder is idle while the context makes this call).
|
|
418
|
+
* Out of the round itself, it is still taken when nobody can ever serve
|
|
419
|
+
* (the call fails on it, as it would on anyone), and waited past otherwise.
|
|
415
420
|
*/
|
|
416
421
|
readonly borrow?: ExecutorSpec
|
|
417
422
|
}
|
|
@@ -443,6 +448,15 @@ export interface RosterShape {
|
|
|
443
448
|
* for `role` would then wait for a story to end, not for a call.
|
|
444
449
|
*/
|
|
445
450
|
readonly coderHeld: (role: Role, avoid?: ReadonlyArray<string>) => Effect.Effect<boolean>
|
|
451
|
+
/**
|
|
452
|
+
* Why a lease for `role` outside `avoid` cannot be served by a call
|
|
453
|
+
* ending: `held` (every executor in the round is held by coders), `out`
|
|
454
|
+
* (none is in the round), or `undefined` when one has a slot a call frees.
|
|
455
|
+
*/
|
|
456
|
+
readonly independenceBlocked: (
|
|
457
|
+
role: Role,
|
|
458
|
+
avoid?: ReadonlyArray<string>
|
|
459
|
+
) => Effect.Effect<"held" | "out" | undefined>
|
|
446
460
|
/** Free slots for `role` now, across executors in the round. */
|
|
447
461
|
readonly available: (role: Role) => Effect.Effect<number>
|
|
448
462
|
/** Configured slots for `role`. */
|
|
@@ -572,11 +586,13 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
572
586
|
const leaseOf = (
|
|
573
587
|
spec: ExecutorSpec,
|
|
574
588
|
role: Role,
|
|
575
|
-
label: string | undefined
|
|
589
|
+
label: string | undefined,
|
|
590
|
+
because?: "nobody" | "held" | "out"
|
|
576
591
|
): Effect.Effect<Lease, never, Scope.Scope> =>
|
|
577
592
|
Effect.gen(function* () {
|
|
578
593
|
const labelled = label === undefined ? {} : { label }
|
|
579
|
-
|
|
594
|
+
const own = because === undefined ? {} : { because }
|
|
595
|
+
yield* events.publish(ExecutorLeased.make({ executor: spec.id, role, ...labelled, ...own }))
|
|
580
596
|
const done = yield* Ref.make(false)
|
|
581
597
|
const free = Effect.flatMap(Ref.getAndSet(done, true), (was) =>
|
|
582
598
|
was
|
|
@@ -587,7 +603,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
587
603
|
)
|
|
588
604
|
)
|
|
589
605
|
yield* Effect.addFinalizer(() => free)
|
|
590
|
-
return { executor: spec, role, release: free, borrowed: false }
|
|
606
|
+
return { executor: spec, role, release: free, borrowed: false, ...own }
|
|
591
607
|
})
|
|
592
608
|
|
|
593
609
|
/** A lease over the context's own coder executor: no slot taken, none freed. */
|
|
@@ -595,7 +611,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
595
611
|
spec: ExecutorSpec,
|
|
596
612
|
role: Role,
|
|
597
613
|
label: string | undefined,
|
|
598
|
-
because: "nobody" | "held"
|
|
614
|
+
because: "nobody" | "held" | "out"
|
|
599
615
|
): Effect.Effect<Lease, never, Scope.Scope> =>
|
|
600
616
|
Effect.gen(function* () {
|
|
601
617
|
const labelled = label === undefined ? {} : { label }
|
|
@@ -609,7 +625,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
609
625
|
: events.publish(ExecutorReleased.make({ executor: spec.id, role, ...labelled }))
|
|
610
626
|
)
|
|
611
627
|
yield* Effect.addFinalizer(() => free)
|
|
612
|
-
return { executor: spec, role, release: free, borrowed: true }
|
|
628
|
+
return { executor: spec, role, release: free, borrowed: true, because }
|
|
613
629
|
})
|
|
614
630
|
|
|
615
631
|
/** One attempt: expire, pick, take the slot — all under the lock. */
|
|
@@ -658,18 +674,26 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
658
674
|
)
|
|
659
675
|
)
|
|
660
676
|
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
(spec) => (usage.get(spec.id) ?? { busy: 0, busyCoders: 0 }).busyCoders >= slotsOf(spec)
|
|
669
|
-
)
|
|
670
|
-
)
|
|
671
|
-
})
|
|
677
|
+
const blockedOutside = (role: Role, avoid: ReadonlyArray<string>): "held" | "out" | undefined => {
|
|
678
|
+
const inRound = eligible(role, avoid)
|
|
679
|
+
if (inRound.length === 0) {
|
|
680
|
+
return "out"
|
|
681
|
+
}
|
|
682
|
+
return inRound.every(
|
|
683
|
+
(spec) => (usage.get(spec.id) ?? { busy: 0, busyCoders: 0 }).busyCoders >= slotsOf(spec)
|
|
672
684
|
)
|
|
685
|
+
? "held"
|
|
686
|
+
: undefined
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const independenceBlocked = (
|
|
690
|
+
role: Role,
|
|
691
|
+
avoid: ReadonlyArray<string> = []
|
|
692
|
+
): Effect.Effect<"held" | "out" | undefined> =>
|
|
693
|
+
lock.withPermit(Effect.sync(() => blockedOutside(role, avoid)))
|
|
694
|
+
|
|
695
|
+
const coderHeld = (role: Role, avoid: ReadonlyArray<string> = []): Effect.Effect<boolean> =>
|
|
696
|
+
Effect.map(independenceBlocked(role, avoid), (blocked) => blocked === "held")
|
|
673
697
|
|
|
674
698
|
/** Probes every `health` exclusion whose next probe is due; a live one comes back. */
|
|
675
699
|
const probeDue: Effect.Effect<void> = Effect.gen(function* () {
|
|
@@ -769,13 +793,37 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
769
793
|
leaseOptions.borrow !== undefined && hasRole(leaseOptions.borrow, role)
|
|
770
794
|
? leaseOptions.borrow
|
|
771
795
|
: undefined
|
|
772
|
-
/**
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
796
|
+
/**
|
|
797
|
+
* Independence cannot be had now: the context's own executor, when in
|
|
798
|
+
* the round, takes the call — on a free slot of its own, else without
|
|
799
|
+
* one (its coder is idle while the context makes this call).
|
|
800
|
+
*/
|
|
801
|
+
const takeOwn = (
|
|
802
|
+
because: "nobody" | "held" | "out"
|
|
803
|
+
): Effect.Effect<Lease | undefined, never, Scope.Scope> =>
|
|
804
|
+
Effect.gen(function* () {
|
|
805
|
+
if (borrow === undefined) {
|
|
806
|
+
return undefined
|
|
807
|
+
}
|
|
808
|
+
// Out of the round itself, the own executor is still taken when
|
|
809
|
+
// nobody else can ever serve (the call fails on it, as on anyone).
|
|
810
|
+
const inRound = yield* lock.withPermit(Effect.sync(() => !exclusions.has(borrow.id)))
|
|
811
|
+
if (!inRound && because !== "nobody") {
|
|
812
|
+
return undefined
|
|
813
|
+
}
|
|
814
|
+
const own = yield* attempt(role, {
|
|
815
|
+
...leaseOptions,
|
|
816
|
+
avoid: avoid.filter((id) => id !== borrow.id),
|
|
817
|
+
prefer: borrow.id
|
|
818
|
+
})
|
|
819
|
+
yield* waited
|
|
820
|
+
return own !== undefined && own.id === borrow.id
|
|
821
|
+
? yield* leaseOf(own, role, leaseOptions.label, because)
|
|
822
|
+
: own !== undefined
|
|
823
|
+
? // Someone came back between the check and the pick: independent after all.
|
|
824
|
+
yield* leaseOf(own, role, leaseOptions.label)
|
|
825
|
+
: yield* borrowedLease(borrow, role, leaseOptions.label, because)
|
|
826
|
+
})
|
|
779
827
|
let announced = false
|
|
780
828
|
const asked = yield* Clock.currentTimeMillis
|
|
781
829
|
// A lease that had to wait says how long, for the story it is for.
|
|
@@ -798,9 +846,9 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
798
846
|
return yield* leaseOf(spec, role, leaseOptions.label)
|
|
799
847
|
}
|
|
800
848
|
if (!(yield* canEverServe(role, avoid))) {
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
return
|
|
849
|
+
const own = yield* takeOwn("nobody")
|
|
850
|
+
if (own !== undefined) {
|
|
851
|
+
return own
|
|
804
852
|
}
|
|
805
853
|
const reasons = yield* lock.withPermit(
|
|
806
854
|
Effect.sync(() =>
|
|
@@ -817,11 +865,11 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
817
865
|
reasons: reasons.length === 0 ? ["no executor in the roster takes this role"] : reasons
|
|
818
866
|
})
|
|
819
867
|
}
|
|
820
|
-
|
|
821
|
-
|
|
868
|
+
const blocked = yield* independenceBlocked(role, avoid)
|
|
869
|
+
if (blocked !== undefined) {
|
|
870
|
+
const own = yield* takeOwn(blocked)
|
|
822
871
|
if (own !== undefined) {
|
|
823
|
-
|
|
824
|
-
return yield* borrowedLease(own, role, leaseOptions.label, "held")
|
|
872
|
+
return own
|
|
825
873
|
}
|
|
826
874
|
}
|
|
827
875
|
if (!announced) {
|
|
@@ -954,6 +1002,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
954
1002
|
tryLease,
|
|
955
1003
|
canEverServe,
|
|
956
1004
|
coderHeld,
|
|
1005
|
+
independenceBlocked,
|
|
957
1006
|
available,
|
|
958
1007
|
slots,
|
|
959
1008
|
report,
|