@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.
Files changed (75) hide show
  1. package/dist/AutonomyContract.d.ts +12 -0
  2. package/dist/AutonomyContract.d.ts.map +1 -0
  3. package/dist/AutonomyContract.js +51 -0
  4. package/dist/AutonomyContract.js.map +1 -0
  5. package/dist/CarriedNotes.d.ts +10 -0
  6. package/dist/CarriedNotes.d.ts.map +1 -1
  7. package/dist/CarriedNotes.js +27 -2
  8. package/dist/CarriedNotes.js.map +1 -1
  9. package/dist/Evidence.d.ts +12 -0
  10. package/dist/Evidence.d.ts.map +1 -0
  11. package/dist/Evidence.js +30 -0
  12. package/dist/Evidence.js.map +1 -0
  13. package/dist/Flow.d.ts +35 -3
  14. package/dist/Flow.d.ts.map +1 -1
  15. package/dist/Flow.js +58 -4
  16. package/dist/Flow.js.map +1 -1
  17. package/dist/FlowEvents.d.ts +20 -5
  18. package/dist/FlowEvents.d.ts.map +1 -1
  19. package/dist/FlowEvents.js +32 -9
  20. package/dist/FlowEvents.js.map +1 -1
  21. package/dist/GateTriage.d.ts +81 -0
  22. package/dist/GateTriage.d.ts.map +1 -0
  23. package/dist/GateTriage.js +161 -0
  24. package/dist/GateTriage.js.map +1 -0
  25. package/dist/Gates.d.ts +35 -0
  26. package/dist/Gates.d.ts.map +1 -0
  27. package/dist/Gates.js +63 -0
  28. package/dist/Gates.js.map +1 -0
  29. package/dist/OracleGuard.d.ts +53 -0
  30. package/dist/OracleGuard.d.ts.map +1 -0
  31. package/dist/OracleGuard.js +165 -0
  32. package/dist/OracleGuard.js.map +1 -0
  33. package/dist/Pack.d.ts +7 -0
  34. package/dist/Pack.d.ts.map +1 -1
  35. package/dist/Pack.js +9 -0
  36. package/dist/Pack.js.map +1 -1
  37. package/dist/Retro.d.ts.map +1 -1
  38. package/dist/Retro.js +5 -0
  39. package/dist/Retro.js.map +1 -1
  40. package/dist/Review.d.ts +68 -2
  41. package/dist/Review.d.ts.map +1 -1
  42. package/dist/Review.js +158 -27
  43. package/dist/Review.js.map +1 -1
  44. package/dist/Roster.d.ts +20 -9
  45. package/dist/Roster.d.ts.map +1 -1
  46. package/dist/Roster.js +54 -18
  47. package/dist/Roster.js.map +1 -1
  48. package/dist/RosterSeats.d.ts +6 -3
  49. package/dist/RosterSeats.d.ts.map +1 -1
  50. package/dist/RosterSeats.js +7 -4
  51. package/dist/RosterSeats.js.map +1 -1
  52. package/dist/Stories.d.ts +36 -2
  53. package/dist/Stories.d.ts.map +1 -1
  54. package/dist/Stories.js +159 -12
  55. package/dist/Stories.js.map +1 -1
  56. package/dist/StoryPlan.d.ts +6 -0
  57. package/dist/StoryPlan.d.ts.map +1 -1
  58. package/dist/StoryPlan.js +9 -2
  59. package/dist/StoryPlan.js.map +1 -1
  60. package/package.json +7 -2
  61. package/src/AutonomyContract.ts +61 -0
  62. package/src/CarriedNotes.ts +37 -2
  63. package/src/Evidence.ts +43 -0
  64. package/src/Flow.ts +109 -3
  65. package/src/FlowEvents.ts +33 -11
  66. package/src/GateTriage.ts +250 -0
  67. package/src/Gates.ts +104 -0
  68. package/src/OracleGuard.ts +205 -0
  69. package/src/Pack.ts +16 -0
  70. package/src/Retro.ts +7 -0
  71. package/src/Review.ts +261 -34
  72. package/src/Roster.ts +89 -40
  73. package/src/RosterSeats.ts +13 -6
  74. package/src/Stories.ts +289 -16
  75. 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 const fixPrompt = (result: ReviewResult): string =>
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
- ...result.issues.map((issue) => `- [${issue.severity}] ${issue.title}: ${issue.description}`)
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
- export const mergeReviewResults = (results: ReadonlyArray<ReviewResult>): ReviewResult =>
251
- ReviewResult.make({
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 ${command.join(" ")}`,
283
- { kind: "TOOL", attributes: { [attr.gateCommand]: command.join(" ") } },
284
- guarded(
285
- Capabilities.Exec(executable),
286
- `lint: ${command.join(" ")}`,
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
- ).pipe(Effect.tap((ran) => Effect.annotateCurrentSpan(attr.gateExit, ran.exitCode)))
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: command.join(" "),
491
+ label,
302
492
  ms: (yield* Clock.currentTimeMillis) - started,
303
- exitCode: result.exitCode,
304
- ...(result.exitCode === 0 ? {} : { failed: true })
493
+ ...(result === undefined ? {} : { exitCode: result.exitCode }),
494
+ ...(failed ? { failed: true } : {})
305
495
  })
306
496
  )
307
- if (result.exitCode === 0) {
308
- return ReviewResult.make({ issues: [], summary: "lint passed" })
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: ${command.join(" ")}`,
315
- description: processProblem(result.stdout, result.stderr, result.exitCode)
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 lint = yield* options.lint ?? Effect.succeed(ReviewResult.make({ issues: [] }))
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, used without a slot (its
407
- * coder is idle while the context makes this call) when independence
408
- * cannot be had: no executor outside `avoid` can ever take the role, or
409
- * every one in the round that could has all its slots held by coders. A
410
- * coder's slot frees only when its story ends, and that story may itself
411
- * be waiting on a call like this one — waiting for it would saturate the
412
- * roster. An executor that is out of the round is still borrowed in the
413
- * first case (the call fails on it, as it would on anyone), never in the
414
- * second (a story in the round will free a slot).
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
- yield* events.publish(ExecutorLeased.make({ executor: spec.id, role, ...labelled }))
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 coderHeld = (role: Role, avoid: ReadonlyArray<string> = []): Effect.Effect<boolean> =>
662
- lock.withPermit(
663
- Effect.sync(() => {
664
- const inRound = eligible(role, avoid)
665
- return (
666
- inRound.length > 0 &&
667
- inRound.every(
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
- /** Borrowing when the round is held by coders needs an executor in the round. */
773
- const borrowInRound = (): Effect.Effect<ExecutorSpec | undefined> =>
774
- lock.withPermit(
775
- Effect.sync(() =>
776
- borrow !== undefined && !exclusions.has(borrow.id) ? borrow : undefined
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
- if (borrow !== undefined) {
802
- yield* waited
803
- return yield* borrowedLease(borrow, role, leaseOptions.label, "nobody")
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
- if (yield* coderHeld(role, avoid)) {
821
- const own = yield* borrowInRound()
868
+ const blocked = yield* independenceBlocked(role, avoid)
869
+ if (blocked !== undefined) {
870
+ const own = yield* takeOwn(blocked)
822
871
  if (own !== undefined) {
823
- yield* waited
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,