@llm4ts/flow 2.41.0 → 2.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Stories.ts CHANGED
@@ -20,7 +20,7 @@ import type { LlmChunk, TokenUsage } from "@llm4ts/core/Models"
20
20
  import type { LlmError } from "@llm4ts/core/Errors"
21
21
  import { BoardItem, eventedBoard, type BoardSyncShape } from "./BoardSync.ts"
22
22
  import { makeChat } from "./Chat.ts"
23
- import { implementPlanFlow } from "./Flow.ts"
23
+ import { implementPlanFlow, type TaskOverrides } from "./Flow.ts"
24
24
  import {
25
25
  GateBaseline,
26
26
  baselineKey,
@@ -58,6 +58,7 @@ import {
58
58
  enforcePerimeter,
59
59
  isWithinPerimeter,
60
60
  perimeterGate,
61
+ taskOwnsGate,
61
62
  strayTasks,
62
63
  type PerimeterCheck
63
64
  } from "./Perimeter.ts"
@@ -68,7 +69,7 @@ import {
68
69
  type PlainFileStoreShape
69
70
  } from "./Persistence.ts"
70
71
  import { Plan, Task } from "./Plan.ts"
71
- import { stage } from "./PlanExecution.ts"
72
+ import { stage, type AsideOutcome, type AsideRequest } from "./PlanExecution.ts"
72
73
  import { planFrom } from "./Planner.ts"
73
74
  import {
74
75
  ReviewIssue,
@@ -459,6 +460,10 @@ export const storyTaskPlanInstructions = (story: Story): string =>
459
460
  "End every task's description with `Satisfies: <n>` naming the criteria it serves, and",
460
461
  "make sure every criterion is served by at least one task."
461
462
  ]),
463
+ "End every task's description with a `Depends on: <n, …>` line naming the earlier tasks it",
464
+ "needs done first (`Depends on: none` when it needs none; without the line it waits for the",
465
+ "task before it), and an `Owns: <paths>` line naming the files it changes. Tasks that need",
466
+ "nothing from each other and own different files may run at the same time.",
462
467
  "Never plan a task that creates or changes anything outside them — registering or wiring",
463
468
  "the story in elsewhere (the app's composition point, a shared kit file) is another story's job.",
464
469
  `Use exactly this epicId: "${story.id}".`,
@@ -727,6 +732,13 @@ export interface StoriesOptions {
727
732
  ) => Effect.Effect<Plan, FlowError>
728
733
  /** Stories implemented at once. Default 3. */
729
734
  readonly concurrency?: number
735
+ /**
736
+ * Coders one story may hold at once, its own included (ADR 0034): a
737
+ * ready task whose plan says it may runs beside the held coder, in its
738
+ * own worktree, on a coder that is free now. Default 2; 1 runs every task
739
+ * in order on the story's coder.
740
+ */
741
+ readonly taskConcurrency?: number
730
742
  /** Stop the epic at the first failed story instead of skipping its dependents. */
731
743
  readonly failFast?: boolean
732
744
  readonly reviewers?: ReadonlyArray<Reviewer>
@@ -881,6 +893,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
881
893
  const concurrency = Math.max(1, options.concurrency ?? 3)
882
894
  const judgeRounds = Math.max(1, options.judgeRounds ?? 2)
883
895
  const mergeRevisions = Math.max(0, options.mergeRevisions ?? 1)
896
+ const taskConcurrency = Math.max(1, options.taskConcurrency ?? 2)
884
897
  const deferring = options.deferNonBlocking === true
885
898
  const statePath = (story: Story): string => join(options.stateDir, `stories/${story.id}.json`)
886
899
  const planPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.plan.md`)
@@ -1362,12 +1375,15 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1362
1375
  )
1363
1376
  // The oracle guard over the story's whole change against the epic:
1364
1377
  // committed on the branch and uncommitted in the worktree (ADR 0027).
1365
- const oracleGate = (target: ReviewResult): Effect.Effect<ReviewResult, FlowError> =>
1378
+ const oracleGate = (
1379
+ target: ReviewResult,
1380
+ where: GitToolShape = git
1381
+ ): Effect.Effect<ReviewResult, FlowError> =>
1366
1382
  story.testsChange
1367
1383
  ? Effect.succeed(ReviewResult.make({ issues: [] }))
1368
1384
  : Effect.gen(function* () {
1369
- const committed = yield* git.diffVsBase(epicBranch)
1370
- const uncommitted = yield* git.diffAll
1385
+ const committed = yield* where.diffVsBase(epicBranch)
1386
+ const uncommitted = yield* where.diffAll
1371
1387
  const base =
1372
1388
  gateCommands === undefined ? undefined : (yield* storyBaseline)?.passedCount
1373
1389
  const issues = checkOracle(
@@ -1378,17 +1394,41 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1378
1394
  )
1379
1395
  return ReviewResult.make({ issues, summary: issues.length === 0 ? "" : "oracle guard" })
1380
1396
  })
1381
- const gates: Effect.Effect<ReviewResult, FlowError> = Effect.gen(function* () {
1382
- const target = yield* options.gates(state.worktree, laneOf(story), {
1383
- files,
1384
- dir: storyGateLogDir(options.stateDir, story.id)
1397
+ /**
1398
+ * The gates in a worktree of the story: the target's, the story's
1399
+ * perimeter, the oracle guard and — for a task run aside — the paths the
1400
+ * task said it owns, against the story branch it started from.
1401
+ */
1402
+ const gatesAt = (
1403
+ workDir: string,
1404
+ where: GitToolShape,
1405
+ logDir: string,
1406
+ task?: { readonly owns: ReadonlyArray<string>; readonly from: string }
1407
+ ): Effect.Effect<ReviewResult, FlowError> =>
1408
+ Effect.gen(function* () {
1409
+ const target = yield* options.gates(workDir, laneOf(story), { files, dir: logDir })
1410
+ const changed = yield* perimeterNow(story, where)
1411
+ const owned =
1412
+ task === undefined || task.owns.length === 0
1413
+ ? ReviewResult.make({ issues: [] })
1414
+ : taskOwnsGate(
1415
+ [
1416
+ ...new Set([
1417
+ ...(yield* where.changedFilesVsBase(task.from)),
1418
+ ...(yield* where.uncommittedFiles)
1419
+ ])
1420
+ ],
1421
+ task.owns
1422
+ )
1423
+ return combined(
1424
+ combined(
1425
+ combined(target, perimeterGate([...changed.sharedReadOnly, ...changed.outside], story)),
1426
+ yield* oracleGate(target, where)
1427
+ ),
1428
+ owned
1429
+ )
1385
1430
  })
1386
- const changed = yield* perimeterNow(story, git)
1387
- return combined(
1388
- combined(target, perimeterGate([...changed.sharedReadOnly, ...changed.outside], story)),
1389
- yield* oracleGate(target)
1390
- )
1391
- })
1431
+ const gates = gatesAt(state.worktree, git, storyGateLogDir(options.stateDir, story.id))
1392
1432
  const triage: GateTriageOptions = {
1393
1433
  baseline: storyBaseline,
1394
1434
  roots: rootsOf(state.worktree),
@@ -1571,6 +1611,147 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1571
1611
  const taskSystem = [system, startingCode]
1572
1612
  .filter((part): part is string => part !== undefined)
1573
1613
  .join("\n\n")
1614
+ /** Tasks merged in from beside the held coder this run: the story's gates rerun after them. */
1615
+ let mergedAside = 0
1616
+ /**
1617
+ * A ready task beside the held coder (ADR 0034): a coder free now, a
1618
+ * worktree on its own branch from the story branch, the task's whole
1619
+ * body there — coder, review, gates with its `Owns:` — then a merge
1620
+ * into the story branch while no task runs at home. Anything short of
1621
+ * a clean merge hands the task back to the held coder; a conflict
1622
+ * hands it back to run after the others.
1623
+ */
1624
+ const runAside = (
1625
+ request: AsideRequest,
1626
+ run: (at: FlowContextShape, overrides: TaskOverrides) => Effect.Effect<void, FlowError>
1627
+ ): Effect.Effect<AsideOutcome, FlowError> => {
1628
+ const { node } = request
1629
+ const where = join(options.worktreeRoot, `${story.id}.task-${node.index}`)
1630
+ const branch = `${state.branch}--task-${node.index}`
1631
+ const lane = laneOf(story)
1632
+ const said = (message: string) =>
1633
+ lane.publish(Info.make({ message: `story ${story.id}: task ${node.index} ${message}` }))
1634
+ const cleanUp = Effect.gen(function* () {
1635
+ yield* context.git.removeWorktree(where, true).pipe(Effect.ignore)
1636
+ if (yield* context.git.branchExists(branch).pipe(Effect.orElseSucceed(() => false))) {
1637
+ yield* context.git.deleteBranch(branch).pipe(Effect.ignore)
1638
+ }
1639
+ })
1640
+ const attempt = Effect.gen(function* () {
1641
+ // No disk work while no coder is free: the held coder takes the task.
1642
+ if (roster !== undefined && (yield* roster.available("coder")) === 0) {
1643
+ return "declined" as const
1644
+ }
1645
+ // A previous run's leftovers go first.
1646
+ yield* cleanUp
1647
+ yield* context.git.addWorktreeNewBranch(where, branch, state.branch)
1648
+ // The coder is held for the work only: its slot is free again before
1649
+ // the merge waits for the story's coder to finish its own task.
1650
+ const blockedOn = yield* Effect.scoped(
1651
+ Effect.gen(function* () {
1652
+ const asideSeats = yield* options.contextFor(where, {
1653
+ label: `${story.id}#t${node.index}`,
1654
+ ifFree: true
1655
+ })
1656
+ const asideRoster = asideSeats.context.roster
1657
+ yield* request.announce({
1658
+ ...(asideRoster === undefined
1659
+ ? {}
1660
+ : {
1661
+ ...(yield* Effect.map(asideRoster.executor, (executor) =>
1662
+ executor === undefined ? {} : { executor }
1663
+ )),
1664
+ ...(asideRoster.clone === undefined
1665
+ ? {}
1666
+ : yield* Effect.map(asideRoster.clone, (clone) =>
1667
+ clone === undefined ? {} : { clone }
1668
+ ))
1669
+ })
1670
+ })
1671
+ if (options.setup !== undefined) {
1672
+ yield* options.setup(where, lane)
1673
+ }
1674
+ const asideBlocked = yield* Ref.make<string | undefined>(undefined)
1675
+ const asideContext: FlowContextShape = {
1676
+ ...asideSeats.context,
1677
+ coder: watchForBlockedOn(asideSeats.context.coder, asideBlocked)
1678
+ }
1679
+ const asideGit = asideContext.git
1680
+ const asideSystem = [
1681
+ withContract(
1682
+ [
1683
+ perimeterRules(story, { plan, worktree: where, epicCheckout: context.workDir }),
1684
+ gateRules,
1685
+ extra,
1686
+ node.owns.length === 0
1687
+ ? undefined
1688
+ : `This task owns ${node.owns.join(", ")}: another task of this story may be changing other files right now, so change nothing else.`
1689
+ ]
1690
+ .filter((part): part is string => part !== undefined && part.trim().length > 0)
1691
+ .join("\n\n"),
1692
+ contractProfile
1693
+ ),
1694
+ startingCode
1695
+ ]
1696
+ .filter((part): part is string => part !== undefined)
1697
+ .join("\n\n")
1698
+ yield* run(asideContext, {
1699
+ lint: gatesAt(
1700
+ where,
1701
+ asideGit,
1702
+ join(storyGateLogDir(options.stateDir, story.id), `task-${node.index}`),
1703
+ { owns: node.owns, from: state.branch }
1704
+ ),
1705
+ triage: {
1706
+ baseline: storyBaseline,
1707
+ roots: rootsOf(where),
1708
+ ...(options.testGate === undefined
1709
+ ? {}
1710
+ : { rerunTest: options.testGate(where, lane) })
1711
+ },
1712
+ // Its tool calls are on its own lane's transcript, not the story's.
1713
+ onTaskReply: undefined,
1714
+ system: asideSystem
1715
+ })
1716
+ return yield* Ref.get(asideBlocked)
1717
+ })
1718
+ )
1719
+ if (blockedOn !== undefined) {
1720
+ yield* said("stopped on BLOCKED_ON aside; the story's coder takes it")
1721
+ return "declined" as const
1722
+ }
1723
+ return yield* request.exclusive(
1724
+ Effect.gen(function* () {
1725
+ yield* git.merge(branch, `${story.id}: merge task ${node.index} (${node.task.title})`)
1726
+ mergedAside += 1
1727
+ yield* said(`merged from beside the story's coder`)
1728
+ return "merged" as const
1729
+ })
1730
+ )
1731
+ })
1732
+ return Effect.scoped(
1733
+ Effect.gen(function* () {
1734
+ yield* Effect.addFinalizer(() => cleanUp)
1735
+ return yield* attempt
1736
+ })
1737
+ ).pipe(
1738
+ Effect.catch((error) =>
1739
+ error._tag === "MergeConflict"
1740
+ ? Effect.as(
1741
+ said("conflicted merging back; the story's coder runs it last"),
1742
+ "conflict" as const
1743
+ )
1744
+ : error._tag === "RosterExhausted"
1745
+ ? Effect.succeed("declined" as const)
1746
+ : Effect.as(
1747
+ said(
1748
+ `could not finish aside (${error.message.split("\n")[0] ?? ""}); the story's coder takes it`
1749
+ ),
1750
+ "declined" as const
1751
+ )
1752
+ )
1753
+ )
1754
+ }
1574
1755
  const implementTasks = implementPlanFlow(storyContext, {
1575
1756
  store: makePlanStore(files),
1576
1757
  planPath: planPath(story),
@@ -1588,6 +1769,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1588
1769
  ),
1589
1770
  system: taskSystem,
1590
1771
  chatPerTask: true,
1772
+ ...(taskConcurrency > 1
1773
+ ? { parallel: { concurrency: taskConcurrency, aside: runAside } }
1774
+ : {}),
1591
1775
  carry: {
1592
1776
  read: files.read(notesPath(story)),
1593
1777
  write: (notes) => files.writeAtomic(notesPath(story), notes)
@@ -1646,6 +1830,33 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1646
1830
  )
1647
1831
  }
1648
1832
  yield* guarded(implementTasks, implementTasks)
1833
+ // Tasks merged in from beside the held coder were each green alone: the
1834
+ // story's gates now run on them together, and red goes back to the
1835
+ // story's coder as a revision, as a red epic merge does (ADR 0034).
1836
+ for (let revision = 0; mergedAside > 0; revision += 1) {
1837
+ mergedAside = 0
1838
+ const reported = yield* Ref.make<ReadonlySet<string>>(new Set())
1839
+ const together = yield* stage(
1840
+ laneOf(story),
1841
+ `story ${story.id}: gates after parallel tasks`,
1842
+ Effect.flatMap(gates, (result) => applyTriage(result, triage, laneOf(story), reported))
1843
+ )
1844
+ if (together.isClean) {
1845
+ break
1846
+ }
1847
+ if (revision >= mergeRevisions) {
1848
+ return yield* failed(
1849
+ story,
1850
+ `its gates are red with its parallel tasks merged together:\n${issueLines(together)}`
1851
+ )
1852
+ }
1853
+ yield* appendRevision(
1854
+ "make the story gates green after merging its parallel tasks",
1855
+ `Tasks of the story "${story.title}" ran at the same time and were merged together; the gates are red on the result.`,
1856
+ together
1857
+ )
1858
+ yield* guarded(implementTasks, implementTasks)
1859
+ }
1649
1860
 
1650
1861
  // Other stories may have merged while this one ran: judge the branch
1651
1862
  // against the epic as it is now.