@llm4ts/flow 2.39.0 → 2.40.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
@@ -27,7 +27,7 @@ import {
27
27
  ensureBaseline as ensureStoredBaseline,
28
28
  failingLinesOf,
29
29
  storyGateLogDir,
30
- triageGates,
30
+ mergeGateLogDir,
31
31
  writeBaseline,
32
32
  type GateLogDir
33
33
  } from "./Gates.ts"
@@ -703,6 +703,13 @@ export interface StoriesOptions {
703
703
  * story's plan (coder, review, gates, commit). Default 2: one revision.
704
704
  */
705
705
  readonly judgeRounds?: number
706
+ /**
707
+ * When the epic gates are red after a story merges (the merge is undone),
708
+ * the story goes back to its worktree this many times: it catches up with
709
+ * the epic, gets a revision task with the failures, and merges again.
710
+ * Default 1; 0 fails the story at once, as before.
711
+ */
712
+ readonly mergeRevisions?: number
706
713
  /**
707
714
  * How much of the code a story starts from goes into its coder's system
708
715
  * prompt, in characters: the shared read-only files it uses, then its own.
@@ -871,6 +878,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
871
878
  const epicBranch = options.epicBranch ?? `epic/${plan.epicId}`
872
879
  const concurrency = Math.max(1, options.concurrency ?? 3)
873
880
  const judgeRounds = Math.max(1, options.judgeRounds ?? 2)
881
+ const mergeRevisions = Math.max(0, options.mergeRevisions ?? 1)
874
882
  const deferring = options.deferNonBlocking === true
875
883
  const statePath = (story: Story): string => join(options.stateDir, `stories/${story.id}.json`)
876
884
  const planPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.plan.md`)
@@ -1010,8 +1018,15 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1010
1018
 
1011
1019
  const mergeLock = yield* Semaphore.make(1)
1012
1020
 
1013
- /** Merge the story branch into the epic branch and re-gate the epic head, one story at a time. */
1014
- const integrate = (story: Story, branch: string): Effect.Effect<void, FlowError> =>
1021
+ /**
1022
+ * Merge the story branch into the epic branch and re-gate the epic head,
1023
+ * one story at a time. Red gates undo the merge and are returned (the
1024
+ * failures the merge added); `undefined` means merged.
1025
+ */
1026
+ const integrate = (
1027
+ story: Story,
1028
+ branch: string
1029
+ ): Effect.Effect<ReviewResult | undefined, FlowError> =>
1015
1030
  Effect.flatMap(Clock.currentTimeMillis, (requested) =>
1016
1031
  Effect.andThen(
1017
1032
  laneOf(story).publish(Began.make({ kind: "wait", label: "merge lock" })),
@@ -1032,22 +1047,36 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1032
1047
  Timed.make({ kind: "merge", label: "merge", ms, ...(failed ? { failed } : {}) }),
1033
1048
  () => Began.make({ kind: "merge", label: "merge" })
1034
1049
  )
1035
- const gate = yield* options.gates(context.workDir, lane)
1050
+ // The whole output is kept: the reason below names only the
1051
+ // failing lines, which is not enough to tell why.
1052
+ const gate = yield* options.gates(context.workDir, lane, {
1053
+ files,
1054
+ dir: mergeGateLogDir(options.stateDir, story.id)
1055
+ })
1036
1056
  // Charged only with what the merge added: the head before it had
1037
- // a baseline (run start or the previous merge).
1057
+ // a baseline (run start or the previous merge). A new line green
1058
+ // on one rerun of the test gate is flaky, as in the worktree.
1038
1059
  const before = yield* ensureBaseline(
1039
1060
  checkpoint,
1040
1061
  Effect.succeed(ReviewResult.make({ issues: [] })),
1041
1062
  context.workDir
1042
1063
  )
1043
- const verdict = triageGates(gate, before, rootsOf(context.workDir))
1044
- if (!verdict.blocking.isClean) {
1064
+ const blocking = yield* applyTriage(
1065
+ gate,
1066
+ {
1067
+ baseline: Effect.succeed(before),
1068
+ roots: rootsOf(context.workDir),
1069
+ ...(options.testGate === undefined
1070
+ ? {}
1071
+ : { rerunTest: options.testGate(context.workDir, lane) })
1072
+ },
1073
+ lane,
1074
+ yield* Ref.make<ReadonlySet<string>>(new Set())
1075
+ )
1076
+ if (!blocking.isClean) {
1045
1077
  // Never leave a red epic head for the next story to inherit.
1046
1078
  yield* context.git.rollback(checkpoint)
1047
- return yield* failed(
1048
- story,
1049
- `epic gates failed after merging; merge undone:\n${issueLines(verdict.blocking)}`
1050
- )
1079
+ return blocking
1051
1080
  }
1052
1081
  if (gateCommands !== undefined) {
1053
1082
  const mergedHead = yield* context.git.checkpoint
@@ -1061,6 +1090,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1061
1090
  yield* laneOf(story).publish(
1062
1091
  Info.make({ message: `story ${story.id}: merged into ${epicBranch}` })
1063
1092
  )
1093
+ return undefined
1064
1094
  })
1065
1095
  )
1066
1096
  )
@@ -1173,7 +1203,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1173
1203
  /** Everything that happens inside a story's worktree: plan, implement, judge, perimeter. */
1174
1204
  const implementStory = Effect.fn("@llm4ts/flow/Stories.implementStory")(function* (
1175
1205
  story: Story,
1176
- state: StoryState
1206
+ state: StoryState,
1207
+ /** The epic gates' failures after the story's last merge: run as a revision task. */
1208
+ mergeFailure?: ReviewResult
1177
1209
  ): Effect.fn.Return<
1178
1210
  {
1179
1211
  readonly judge: string | undefined
@@ -1497,6 +1529,34 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1497
1529
  yield* git.commitAll(`${story.id}: put paths outside the perimeter back`)
1498
1530
  })
1499
1531
 
1532
+ /** A revision task on the story's own plan, run by the task loop like any other. */
1533
+ const appendRevision = (subject: string, lead: string, verdict: ReviewResult) =>
1534
+ Effect.gen(function* () {
1535
+ const store = makePlanStore(files)
1536
+ const current = yield* store.load(planPath(story))
1537
+ if (current === undefined) {
1538
+ return
1539
+ }
1540
+ const number = current.tasks.filter((task) => revisionTitle.test(task.title)).length + 1
1541
+ yield* store.save(
1542
+ planPath(story),
1543
+ Plan.make({
1544
+ ...current,
1545
+ tasks: [
1546
+ ...current.tasks,
1547
+ Task.make({
1548
+ title: `Revision ${number}: ${subject}`,
1549
+ description: [
1550
+ lead,
1551
+ "Close these gaps without weakening any test and without leaving your owned paths:",
1552
+ issueLines(verdict)
1553
+ ].join("\n")
1554
+ })
1555
+ ]
1556
+ })
1557
+ )
1558
+ })
1559
+
1500
1560
  const planTasks = options.planTasks ?? defaultPlanTasks
1501
1561
  // Taken after catch-up and setup: the code as the first task finds it.
1502
1562
  const startingCode = yield* startingCodeOf(
@@ -1570,6 +1630,19 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1570
1630
  ...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds }),
1571
1631
  ...(deferring ? { settle: "blocking" as const } : {})
1572
1632
  })
1633
+ if (mergeFailure !== undefined) {
1634
+ // The branch already caught up with the epic above: the gates in the
1635
+ // worktree now see the combination that was red after the merge.
1636
+ yield* appendRevision(
1637
+ "make the epic gates green after merging",
1638
+ [
1639
+ `The story "${story.title}" was merged into ${epicBranch} and the epic's gates went red, so the merge was undone.`,
1640
+ `Your branch now includes everything merged into ${epicBranch} since you started: the failures`,
1641
+ "come from your change combined with that work. Fix them on your side."
1642
+ ].join("\n"),
1643
+ mergeFailure
1644
+ )
1645
+ }
1573
1646
  yield* guarded(implementTasks, implementTasks)
1574
1647
 
1575
1648
  // Other stories may have merged while this one ran: judge the branch
@@ -1580,33 +1653,13 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1580
1653
  // A judge finding becomes a revision task on the story's own plan, run by
1581
1654
  // the task loop like any other: coder, review, gates, commit.
1582
1655
  const addRevision = (verdict: ReviewResult, empty: boolean): Effect.Effect<void, FlowError> =>
1583
- Effect.gen(function* () {
1584
- const store = makePlanStore(files)
1585
- const current = yield* store.load(planPath(story))
1586
- if (current === undefined) {
1587
- return
1588
- }
1589
- const number = current.tasks.filter((task) => revisionTitle.test(task.title)).length + 1
1590
- yield* store.save(
1591
- planPath(story),
1592
- Plan.make({
1593
- ...current,
1594
- tasks: [
1595
- ...current.tasks,
1596
- Task.make({
1597
- title: `Revision ${number}: close the judge's findings`,
1598
- description: [
1599
- empty
1600
- ? `The story "${story.title}" has no changes yet, and what it must provide is not all in place on the epic branch.`
1601
- : `The story "${story.title}" was judged short of done.`,
1602
- "Close these gaps without weakening any test and without leaving your owned paths:",
1603
- issueLines(verdict)
1604
- ].join("\n")
1605
- })
1606
- ]
1607
- })
1608
- )
1609
- })
1656
+ appendRevision(
1657
+ "close the judge's findings",
1658
+ empty
1659
+ ? `The story "${story.title}" has no changes yet, and what it must provide is not all in place on the epic branch.`
1660
+ : `The story "${story.title}" was judged short of done.`,
1661
+ verdict
1662
+ )
1610
1663
 
1611
1664
  let judgeNote: string | undefined
1612
1665
  let inPlace = false
@@ -1756,11 +1809,16 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1756
1809
  const changed = yield* git.changedFilesVsBase(epicBranch)
1757
1810
  yield* enforcePerimeter(changed, story)
1758
1811
  if (deferring) {
1812
+ // A merge revision adds to what the story's first run deferred.
1813
+ if (mergeFailure !== undefined) {
1814
+ const earlier = yield* files.read(deferredPath(options.stateDir, story.id))
1815
+ deferred.unshift(...(earlier ?? "").split("\n").filter((line) => line.startsWith("- [")))
1816
+ }
1759
1817
  yield* deferred.length === 0
1760
1818
  ? files.remove(deferredPath(options.stateDir, story.id))
1761
1819
  : files.writeAtomic(
1762
1820
  deferredPath(options.stateDir, story.id),
1763
- [`# Deferred from ${story.id}: ${story.title}`, "", ...deferred, ""].join("\n")
1821
+ [`# Deferred from ${story.id}: ${story.title}`, "", ...new Set(deferred), ""].join("\n")
1764
1822
  )
1765
1823
  }
1766
1824
  const totals = seats.totals === undefined ? undefined : yield* seats.totals
@@ -1790,14 +1848,38 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1790
1848
  judge: "merged on a previous run"
1791
1849
  })
1792
1850
  }
1793
- const result = yield* Effect.scoped(implementStory(story, state))
1794
- if (result.inPlace) {
1795
- // Verified already on the epic: there is nothing to merge or re-gate.
1851
+ let result = yield* Effect.scoped(implementStory(story, state))
1852
+ for (let revision = 0; ; revision += 1) {
1853
+ if (result.inPlace) {
1854
+ // Verified already on the epic: there is nothing to merge or re-gate.
1855
+ yield* laneOf(story).publish(
1856
+ Info.make({ message: `story ${story.id}: verified already in place; nothing to merge` })
1857
+ )
1858
+ break
1859
+ }
1860
+ const red = yield* integrate(story, state.branch)
1861
+ if (red === undefined) {
1862
+ break
1863
+ }
1864
+ const logs = red.issues.flatMap((issue) =>
1865
+ issue.logPath === undefined ? [] : [issue.logPath]
1866
+ )
1867
+ const why = `${issueLines(red)}${logs.length === 0 ? "" : `\nfull output: ${logs.join(", ")}`}`
1868
+ if (revision >= mergeRevisions) {
1869
+ return yield* failed(
1870
+ story,
1871
+ `epic gates failed after merging${
1872
+ revision === 0 ? "" : ` (after ${revision} merge revision${revision === 1 ? "" : "s"})`
1873
+ }; merge undone:\n${why}`
1874
+ )
1875
+ }
1876
+ yield* appendFindings(story, `epic gates after merging — red, merge undone; revising`, red)
1796
1877
  yield* laneOf(story).publish(
1797
- Info.make({ message: `story ${story.id}: verified already in place; nothing to merge` })
1878
+ Info.make({
1879
+ message: `story ${story.id}: epic gates red after merging; merge undone, sending it back to catch up and fix (merge revision ${revision + 1} of ${mergeRevisions})`
1880
+ })
1798
1881
  )
1799
- } else {
1800
- yield* integrate(story, state.branch)
1882
+ result = yield* Effect.scoped(implementStory(story, state, red))
1801
1883
  }
1802
1884
  const last = result.executor?.split(" → ").at(-1)
1803
1885
  yield* saveVersioned(