@llm4ts/flow 2.21.1 → 2.22.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/src/Stories.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // merges back only after its judge and perimeter checks pass, and the
5
5
  // target's gates run on the epic branch after every merge. Scheduling,
6
6
  // gating, resume and failure policy live here; seats come from `contextFor`.
7
+ import * as Clock from "effect/Clock"
7
8
  import * as Effect from "effect/Effect"
8
9
  import * as Exit from "effect/Exit"
9
10
  import * as Queue from "effect/Queue"
@@ -28,7 +29,15 @@ import {
28
29
  StoryFailed,
29
30
  type FlowError
30
31
  } from "./FlowError.ts"
31
- import { Info, JudgedDimension, StoryJudged, withLane, type FlowEventsShape } from "./FlowEvents.ts"
32
+ import {
33
+ Info,
34
+ JudgedDimension,
35
+ StoryJudged,
36
+ Timed,
37
+ withLane,
38
+ type FlowEventsShape
39
+ } from "./FlowEvents.ts"
40
+ import { timeEffect } from "./Timing.ts"
32
41
  import { statusPaths, type GitToolShape } from "./GitTool.ts"
33
42
  import {
34
43
  checkPerimeter,
@@ -44,10 +53,10 @@ import {
44
53
  saveVersioned,
45
54
  type PlainFileStoreShape
46
55
  } from "./Persistence.ts"
47
- import { Plan } from "./Plan.ts"
56
+ import { Plan, Task } from "./Plan.ts"
48
57
  import { stage } from "./PlanExecution.ts"
49
58
  import { planFrom } from "./Planner.ts"
50
- import { ReviewResult } from "./Review.ts"
59
+ import { ReviewIssue, ReviewResult } from "./Review.ts"
51
60
  import type { Reviewer } from "./Reviewer.ts"
52
61
  import {
53
62
  dependentsOf,
@@ -464,7 +473,7 @@ export interface StoriesOptions {
464
473
  * installed dependencies, so the gates cannot run there without this.
465
474
  * Runs on every start and resume; a failure fails the story.
466
475
  */
467
- readonly setup?: (workDir: string) => Effect.Effect<void, FlowError>
476
+ readonly setup?: (workDir: string, events?: FlowEventsShape) => Effect.Effect<void, FlowError>
468
477
  /**
469
478
  * When setup fails, give the story's coder ONE turn to make the worktree
470
479
  * ready (install, warm a cache, generate a client), then run setup again as
@@ -473,14 +482,30 @@ export interface StoriesOptions {
473
482
  */
474
483
  readonly setupAgent?: boolean
475
484
  /** The target's gates, run in a worktree per task and on the epic checkout after each merge. */
476
- readonly gates: (workDir: string) => Effect.Effect<ReviewResult, FlowError>
485
+ /**
486
+ * The target's gates in a directory. `events` is the story's lane, so a
487
+ * gate's timing names its story; a gate may ignore it.
488
+ */
489
+ readonly gates: (
490
+ workDir: string,
491
+ events?: FlowEventsShape
492
+ ) => Effect.Effect<ReviewResult, FlowError>
477
493
  /** Story-level judge over the branch's diff against the epic branch; omit to skip. */
478
494
  readonly judge?: (
479
495
  story: Story,
480
496
  diff: string,
481
- seats: StorySeats
497
+ seats: StorySeats,
498
+ /**
499
+ * `diff`: the branch's changes against the epic. `code`: the branch has
500
+ * none, so this is the current code of the story's owned paths, to judge
501
+ * whether the story is already in place.
502
+ */
503
+ subject?: "diff" | "code"
482
504
  ) => Effect.Effect<ReviewResult, FlowError>
483
- /** Judge attempts, each but the last followed by one coder feedback round. Default 2. */
505
+ /**
506
+ * Judge attempts, each but the last followed by a revision task on the
507
+ * story's plan (coder, review, gates, commit). Default 2: one revision.
508
+ */
484
509
  readonly judgeRounds?: number
485
510
  /** Extra system context per story (house rules, shared read-only excerpts). */
486
511
  readonly system?: (story: Story) => Effect.Effect<string, FlowError>
@@ -570,6 +595,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
570
595
  const judgeRounds = Math.max(1, options.judgeRounds ?? 2)
571
596
  const statePath = (story: Story): string => join(options.stateDir, `stories/${story.id}.json`)
572
597
  const planPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.plan.md`)
598
+ /** Git's empty tree: a diff against it is a path's whole current content. */
599
+ const emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
600
+ const revisionTitle = /^Revision \d+: /u
573
601
 
574
602
  /**
575
603
  * The epic checkout must hold nothing uncommitted: every merge and epic
@@ -624,24 +652,37 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
624
652
 
625
653
  /** Merge the story branch into the epic branch and re-gate the epic head, one story at a time. */
626
654
  const integrate = (story: Story, branch: string): Effect.Effect<void, FlowError> =>
627
- mergeLock.withPermit(
628
- Effect.gen(function* () {
629
- yield* epicCheckoutClean(story)
630
- const checkpoint = yield* context.git.checkpoint
631
- yield* context.git.merge(branch, `${plan.epicId}: merge story ${story.id}`)
632
- const gate = yield* options.gates(context.workDir)
633
- if (!gate.isClean) {
634
- // Never leave a red epic head for the next story to inherit.
635
- yield* context.git.rollback(checkpoint)
636
- return yield* failed(
637
- story,
638
- `epic gates failed after merging; merge undone:\n${issueLines(gate)}`
655
+ Effect.flatMap(Clock.currentTimeMillis, (requested) =>
656
+ mergeLock.withPermit(
657
+ Effect.gen(function* () {
658
+ const lane = laneOf(story)
659
+ // Merges are one at a time: how long this story queued for its turn.
660
+ const turn = yield* Clock.currentTimeMillis
661
+ yield* lane.publish(
662
+ Timed.make({ kind: "wait", label: "merge lock", ms: turn - requested })
639
663
  )
640
- }
641
- yield* laneOf(story).publish(
642
- Info.make({ message: `story ${story.id}: merged into ${epicBranch}` })
643
- )
644
- })
664
+ yield* epicCheckoutClean(story)
665
+ const checkpoint = yield* context.git.checkpoint
666
+ yield* timeEffect(
667
+ lane,
668
+ context.git.merge(branch, `${plan.epicId}: merge story ${story.id}`),
669
+ (ms, failed) =>
670
+ Timed.make({ kind: "merge", label: "merge", ms, ...(failed ? { failed } : {}) })
671
+ )
672
+ const gate = yield* options.gates(context.workDir, lane)
673
+ if (!gate.isClean) {
674
+ // Never leave a red epic head for the next story to inherit.
675
+ yield* context.git.rollback(checkpoint)
676
+ return yield* failed(
677
+ story,
678
+ `epic gates failed after merging; merge undone:\n${issueLines(gate)}`
679
+ )
680
+ }
681
+ yield* laneOf(story).publish(
682
+ Info.make({ message: `story ${story.id}: merged into ${epicBranch}` })
683
+ )
684
+ })
685
+ )
645
686
  )
646
687
 
647
688
  /** The worktree and branch for a story, honouring the hash-guarded resume rules. */
@@ -753,6 +794,8 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
753
794
  ): Effect.fn.Return<
754
795
  {
755
796
  readonly judge: string | undefined
797
+ /** Its branch had no changes and the judge found its code already on the epic. */
798
+ readonly inPlace: boolean
756
799
  readonly totals: TokenUsage | undefined
757
800
  readonly executor: string | undefined
758
801
  },
@@ -794,7 +837,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
794
837
  // Catch up before setup: the epic may have changed the manifest too.
795
838
  yield* catchUp(story, git)
796
839
  if (options.setup !== undefined) {
797
- const setup = options.setup(state.worktree)
840
+ const setup = options.setup(state.worktree, laneOf(story))
798
841
  yield* stage(
799
842
  laneOf(story),
800
843
  `story ${story.id}: setup`,
@@ -835,7 +878,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
835
878
  // The target's gates plus the perimeter: a stray path is a gate failure
836
879
  // the task review loop hands back to the coder before anything commits.
837
880
  const gates: Effect.Effect<ReviewResult, FlowError> = Effect.gen(function* () {
838
- const target = yield* options.gates(state.worktree)
881
+ const target = yield* options.gates(state.worktree, laneOf(story))
839
882
  const changed = yield* perimeterNow(story, git)
840
883
  return combined(target, perimeterGate([...changed.sharedReadOnly, ...changed.outside], story))
841
884
  })
@@ -980,17 +1023,62 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
980
1023
  yield* catchUp(story, git)
981
1024
  yield* repairPerimeter
982
1025
 
1026
+ // A judge finding becomes a revision task on the story's own plan, run by
1027
+ // the task loop like any other: coder, review, gates, commit.
1028
+ const addRevision = (verdict: ReviewResult, empty: boolean): Effect.Effect<void, FlowError> =>
1029
+ Effect.gen(function* () {
1030
+ const store = makePlanStore(files)
1031
+ const current = yield* store.load(planPath(story))
1032
+ if (current === undefined) {
1033
+ return
1034
+ }
1035
+ const number = current.tasks.filter((task) => revisionTitle.test(task.title)).length + 1
1036
+ yield* store.save(
1037
+ planPath(story),
1038
+ Plan.make({
1039
+ ...current,
1040
+ tasks: [
1041
+ ...current.tasks,
1042
+ Task.make({
1043
+ title: `Revision ${number}: close the judge's findings`,
1044
+ description: [
1045
+ empty
1046
+ ? `The story "${story.title}" has no changes yet, and what it must provide is not all in place on the epic branch.`
1047
+ : `The story "${story.title}" was judged short of done.`,
1048
+ "Close these gaps without weakening any test and without leaving your owned paths:",
1049
+ issueLines(verdict)
1050
+ ].join("\n")
1051
+ })
1052
+ ]
1053
+ })
1054
+ )
1055
+ })
1056
+
983
1057
  let judgeNote: string | undefined
1058
+ let inPlace = false
984
1059
  if (options.judge !== undefined) {
985
1060
  const judge = options.judge
986
1061
  for (let round = 1; round <= judgeRounds; round += 1) {
987
1062
  const diff = yield* git.diffVsBase(epicBranch)
988
- if (diff.trim().length === 0) {
989
- // Nothing to judge is a deterministic failure, not a model call:
990
- // a model asked to score an empty diff scores the prompt instead.
991
- return yield* failed(story, "the story branch has no changes against the epic branch")
992
- }
993
- const verdict = yield* judge(story, diff, watchedSeats)
1063
+ // No changes is not a failure by itself: the story's code may already
1064
+ // be on the epic (an earlier story or run put it there). Judge that
1065
+ // code instead: the owned paths against the empty tree.
1066
+ const empty = diff.trim().length === 0
1067
+ const subject = empty ? yield* git.diffVsBaseScoped(emptyTree, story.owned, false) : diff
1068
+ const verdict =
1069
+ empty && subject.trim().length === 0
1070
+ ? // Nothing to judge is decided here, not asked of a model.
1071
+ ReviewResult.make({
1072
+ issues: [
1073
+ ReviewIssue.make({
1074
+ severity: "Critical",
1075
+ title: `nothing exists yet under ${story.owned.join(", ")}`,
1076
+ description: "The story's owned paths are empty on the epic branch."
1077
+ })
1078
+ ],
1079
+ summary: `judge:${story.id}`
1080
+ })
1081
+ : yield* judge(story, subject, watchedSeats, empty ? "code" : "diff")
994
1082
  yield* laneOf(story).publish(
995
1083
  StoryJudged.make({
996
1084
  lane: story.id,
@@ -1001,27 +1089,25 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1001
1089
  })
1002
1090
  )
1003
1091
  if (verdict.isClean) {
1004
- judgeNote = `judge cleared (round ${round})`
1092
+ inPlace = empty
1093
+ judgeNote = empty
1094
+ ? `verified already in place (round ${round})`
1095
+ : `judge cleared (round ${round})`
1005
1096
  break
1006
1097
  }
1007
1098
  if (round >= judgeRounds) {
1099
+ const revisions = `${round - 1} revision${round === 2 ? "" : "s"}`
1008
1100
  return yield* failed(
1009
1101
  story,
1010
- `judge not cleared after ${judgeRounds} round(s):\n${issueLines(verdict)}`
1102
+ empty
1103
+ ? `the story's code is not in place after ${revisions}, and its branch has no changes:\n${issueLines(verdict)}`
1104
+ : `judge not cleared after ${revisions}:\n${issueLines(verdict)}`
1011
1105
  )
1012
1106
  }
1013
- yield* guarded(
1014
- coderTurn(
1015
- [
1016
- `The story "${story.title}" scored below the bar. Close these gaps without`,
1017
- "weakening any test and without leaving your owned paths, then stop:",
1018
- issueLines(verdict)
1019
- ].join("\n")
1020
- ),
1021
- Effect.void
1022
- )
1107
+ yield* addRevision(verdict, empty)
1108
+ yield* guarded(implementTasks, implementTasks)
1109
+ yield* catchUp(story, git)
1023
1110
  yield* repairPerimeter
1024
- yield* commitGreen("address judge feedback", "while addressing judge feedback")
1025
1111
  }
1026
1112
  }
1027
1113
 
@@ -1031,6 +1117,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1031
1117
  const history = roster === undefined ? [] : yield* roster.history
1032
1118
  return {
1033
1119
  judge: judgeNote,
1120
+ inPlace,
1034
1121
  totals,
1035
1122
  executor: history.length === 0 ? undefined : history.join(" → ")
1036
1123
  }
@@ -1054,7 +1141,14 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1054
1141
  })
1055
1142
  }
1056
1143
  const result = yield* Effect.scoped(implementStory(story, state))
1057
- yield* integrate(story, state.branch)
1144
+ if (result.inPlace) {
1145
+ // Verified already on the epic: there is nothing to merge or re-gate.
1146
+ yield* laneOf(story).publish(
1147
+ Info.make({ message: `story ${story.id}: verified already in place; nothing to merge` })
1148
+ )
1149
+ } else {
1150
+ yield* integrate(story, state.branch)
1151
+ }
1058
1152
  const last = result.executor?.split(" → ").at(-1)
1059
1153
  yield* saveVersioned(
1060
1154
  files,
package/src/Timing.ts ADDED
@@ -0,0 +1,112 @@
1
+ import * as Clock from "effect/Clock"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Exit from "effect/Exit"
4
+ import * as Ref from "effect/Ref"
5
+ import * as Stream from "effect/Stream"
6
+ import type { LlmServiceShape } from "@llm4ts/core/LlmService"
7
+ import type { LlmChunk } from "@llm4ts/core/Models"
8
+ import { Timed, type FlowEventsShape } from "./FlowEvents.ts"
9
+
10
+ /**
11
+ * Where a run's time goes. `timedSeat` decorates a seat so every
12
+ * call publishes a `Timed{kind:"model"}` when it ends — its wall time, when
13
+ * its first output came, and the API and tool time the backend reported, if
14
+ * it did. The events carry the seat's role, never the prompt or the reply.
15
+ */
16
+
17
+ const reportedMs = (chunk: LlmChunk, key: string): number | undefined => {
18
+ const raw = chunk.metadata[key]
19
+ const value = raw === undefined ? Number.NaN : Number(raw)
20
+ return Number.isFinite(value) ? value : undefined
21
+ }
22
+
23
+ /** Times `effect`, publishing what `timed` makes of its duration whether it succeeds or fails. */
24
+ export const timeEffect = <A, E, R>(
25
+ events: FlowEventsShape,
26
+ effect: Effect.Effect<A, E, R>,
27
+ timed: (ms: number, failed: boolean) => Timed
28
+ ): Effect.Effect<A, E, R> =>
29
+ Effect.flatMap(Clock.currentTimeMillis, (start) =>
30
+ Effect.onExit(effect, (exit) =>
31
+ Effect.flatMap(Clock.currentTimeMillis, (end) =>
32
+ events.publish(timed(end - start, Exit.isFailure(exit)))
33
+ )
34
+ )
35
+ )
36
+
37
+ const timedStream = <E>(
38
+ events: FlowEventsShape,
39
+ label: string,
40
+ stream: Stream.Stream<LlmChunk, E>
41
+ ): Stream.Stream<LlmChunk, E> =>
42
+ Stream.unwrap(
43
+ Effect.gen(function* () {
44
+ const start = yield* Clock.currentTimeMillis
45
+ const first = yield* Ref.make<number | undefined>(undefined)
46
+ const reported = yield* Ref.make<{ apiMs?: number; toolMs?: number }>({})
47
+ const failed = yield* Ref.make(false)
48
+ return stream.pipe(
49
+ Stream.tap((chunk) =>
50
+ Effect.gen(function* () {
51
+ const now = yield* Clock.currentTimeMillis
52
+ yield* Ref.update(first, (at) => at ?? now - start)
53
+ const apiMs = reportedMs(chunk, "api_ms")
54
+ const toolMs = reportedMs(chunk, "tools_ms")
55
+ if (apiMs !== undefined || toolMs !== undefined) {
56
+ yield* Ref.update(reported, (current) => ({
57
+ ...current,
58
+ ...(apiMs === undefined ? {} : { apiMs }),
59
+ ...(toolMs === undefined ? {} : { toolMs })
60
+ }))
61
+ }
62
+ })
63
+ ),
64
+ Stream.tapError(() => Ref.set(failed, true)),
65
+ Stream.ensuring(
66
+ Effect.gen(function* () {
67
+ const end = yield* Clock.currentTimeMillis
68
+ const firstMs = yield* Ref.get(first)
69
+ yield* events.publish(
70
+ Timed.make({
71
+ kind: "model",
72
+ label,
73
+ ms: end - start,
74
+ ...(firstMs === undefined ? {} : { firstMs }),
75
+ ...(yield* Ref.get(reported)),
76
+ ...((yield* Ref.get(failed)) ? { failed: true } : {})
77
+ })
78
+ )
79
+ })
80
+ )
81
+ )
82
+ })
83
+ )
84
+
85
+ export const timedSeat = (
86
+ service: LlmServiceShape,
87
+ events: FlowEventsShape,
88
+ label: string
89
+ ): LlmServiceShape => {
90
+ const model = (ms: number, failed: boolean): Timed =>
91
+ Timed.make({ kind: "model", label, ms, ...(failed ? { failed: true } : {}) })
92
+ const batched = service.scoreLabelSequence
93
+ return {
94
+ executeStream: (prompt) => timedStream(events, label, service.executeStream(prompt)),
95
+ executeStreamWithHistory: (messages) =>
96
+ timedStream(events, label, service.executeStreamWithHistory(messages)),
97
+ executeWithTools: (prompt, tools) =>
98
+ timeEffect(events, service.executeWithTools(prompt, tools), model),
99
+ executeStructured: (prompt, schema, jsonSchema) =>
100
+ timeEffect(events, service.executeStructured(prompt, schema, jsonSchema), model),
101
+ executeStructuredWithUsage: (prompt, schema, jsonSchema) =>
102
+ timeEffect(events, service.executeStructuredWithUsage(prompt, schema, jsonSchema), model),
103
+ scoreLabels: (prompt, labels) => timeEffect(events, service.scoreLabels(prompt, labels), model),
104
+ ...(batched === undefined
105
+ ? {}
106
+ : {
107
+ scoreLabelSequence: (prompt: string, labelSets: ReadonlyArray<ReadonlyArray<string>>) =>
108
+ timeEffect(events, batched(prompt, labelSets), model)
109
+ }),
110
+ isAvailable: service.isAvailable
111
+ }
112
+ }