Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions README.md

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -888,3 +888,24 @@ A new `agents` block in `loopstra/config.yaml` gives each step an agent to play,
So these sessions get main's setup, read from the step's commit of main (`git ls-tree` and `cat-file`) into a temporary folder of the session's own, removed when it ends: its settings merged into `--settings` (lists and maps joined, so both Loopstra's hook and main's run), its CLAUDE.md files and `.claude/rules` (which an added dir loads too, checked live) as a read-only `--add-dir`, its `.claude/skills`, `agents` and `commands` as the plugin `project`, and its `.mcp.json` servers less those its settings disable. A first version read them from the step's snapshot, a checkout: the review of this change found that a build can write the snapshot with any command it may run (only the edit tools are denied there), which is why agent files were already read from the commit, and it made every branch step check out all of main. The limits: main's subfolder CLAUDE.md files, and files a CLAUDE.md imports from elsewhere in the repository, do not load in these sessions; a hook or MCP server of main's that runs a script from the project folder runs the branch's copy; and main's settings can still turn hooks off or allow more, as they always could.
- **A session runs on the config its prompt was rendered from.** A phase read loopstra/config.yaml three times (prompt, model, agents), so a setting saved in between could give a session agents its prompt did not name. The step now remembers the config each recent prompt was rendered from, and the session that sends the prompt uses it, as do the keys that match a prompt to earlier work (the judge comparison, the plan written ahead, the early review); a retry renders and reads again.
- **Shared helpers, once.** `readText` is in `fsutil.ts`, frontmatter parsing (`splitFrontmatter`) in `agent-files.ts`, the agent and skill name rule (`ITEM_NAME`) in `agent-library.ts`, `TEAM_NAME` and `once` in `agents.ts` (which the page can import), and the empty-map cleanup is `ConfigDocument.dropEmptyMaps`.

## Changes running at the same time without getting in each other's way (2026-10-27)

- **Per-change temp folder, always, with a short path.** Every claimed change's commands and command-running sessions get `TMPDIR`/`TMP`/`TEMP` set to `<system temp>/loopstra-<8 hex of the repository's path>/s<slot>`, removed when the step releases the change. Not under the repository: tools make Unix sockets under `TMPDIR`, and a socket path may be at most about 104 bytes, which a deep repository path and a long slug would pass. Named by the slot, which no other running change has. A crashed step's folder is swept at the next tick's start. Claude Code itself runs with it too, which does no harm.
- **Ports handed out and checked free, 10 by default.** A change gets the lowest block (`ports_from + i × ports_per_change`) that no other claimed change holds and whose ports all bind on 127.0.0.1 at that moment. At most 50 blocks are tried; with none free the change gets no port variables and runs as before. Kept for the step, like the slot. The check is only a snapshot: something else can take a port after it, which the project's own retry or the fix loop meets as it would today.
- **Caches per slot, not per change, and opt-in.** Per-change caches would make every change fill its own install cache. A slot belongs to one running change at a time, so per-slot caches never collide and stay warm for the next change on that slot. Off by default, because sharing a cache is usually fine and isolating it costs disk.
- **`lanes.ts` still imports nothing from the rest of Loopstra.** It gained `onRelease` hooks and `isClaimed`; `src/resources.ts` registers its own clean-up there.
- **Conflicts are resolved, not blocked.** A rebase conflict used to block the change for an engineer. Now `catchUp` (src/stages/catch-up.ts) resolves it: at review start (only when main gained code, so a branch behind by records keeps the early review), in the merge checks' `up-to-date` check, and while a change waits for a person at the merge gate.
- **Squash, then replay, only on a conflict.** A clean rebase keeps the branch's commits. On a conflict the commits are squashed into one on top of main (`merge --squash` with diff3 markers), so the agent meets every conflict once against the final code, not once per commit with the same lines conflicting again; a conflict that only existed in an intermediate commit goes away. This applies with `gates.merge.method: merge` too: the branch's commits are mostly the loop's own build, fix and revise commits, and the squashed commit lists their subjects. Authors other than the runtime keep a `Co-authored-by` trailer.
- **The resolve step runs on `strong`, fresh, in the build tool set.** It does not resume the build session (that runs on the build model, and a resolution needs both sides' intent, which its prompt carries: this change's spec and plan, the conflicts with both sides, and the main commits involved with the specs of local merges named by their `<slug>: <title>` message). It must not run git that changes history or the index; the runtime stages and commits. A resolution passes only when the session says `resolved` and no conflicted file has a `<<<<<<<`, `|||||||` or `>>>>>>>` line (a bare `=======` is also a Markdown heading underline, so it is not checked).
- **Every resolution is tested, then reviewed.** At review start and while waiting: the test loop (`resolve-test-N`) runs before the review. In the merge checks the tests check runs after the resolution as usual, and the gate reports `changed`, which sends it back for a review round. The reviewer gets `{{resolution}}`: the conflicts as they were and the agent's summary. It is cleared when a review passes.
- **Resolution reviews do not use up `max_rounds`.** `roundsAllowed` is `max_rounds` plus the resolutions tried in this build; `stages.review.max_resolutions` (3) caps them, so a change cannot chase a busy main forever.
- **An interrupted resolution is undone, and does not count.** The old head is written to `catching-up` before the squash. A stop, an outage, the day's budget or a crash part-way puts the branch back (in a `finally`, and the next time the worktree is opened, or a build starts). The count goes up only once the resolve session has answered, whatever it said.
- **Failures write `conflict.md` beside `review.md`**, with the files, the conflicts with both sides, the commits involved and the reason, and leave the branch exactly as it was.
- **Waiting changes are kept mergeable only when they conflict.** A change at `merge-review` with a person on the gate (or a pull request waiting for approval) is checked with `git merge-tree --write-tree` (git 2.38+); a branch that merges cleanly is left alone even when behind, since the merge checks bring it up to date anyway. With an older git the check says nothing and resolution waits for the merge checks. Without a remote the tick makes such a change runnable (it is not otherwise); with one the watcher does it, within `max_parallel_changes` as `mergeWhenUpToDate` does.
- **An approval from before a resolution reached GitHub does not count.** GitHub keeps an approval through a push unless the repository dismisses stale ones. With `merge.human: pr`, `resolved-at` is `pending` from the resolution until the push that carries it, then that push's time; only an approval submitted after it (`latestReviews[].submittedAt` of someone with a say) merges the pull request, and `reviewDecision: APPROVED` alone is not enough then. Comparing with the resolution's own time would let an approval of the old head, given while the resolution was being reviewed, count. The wait says why (`PR_WAIT`), so the pull request does not look stuck.
- **A waiting pull request is caught up from GitHub's copy.** Before resolving, the watcher syncs main and takes commits a person added to the pull request (as `mergeWhenUpToDate` does, under the merge lock), so the resolution includes them and its push goes over them rather than blocking as "both changed".
- **Binary conflicts go to a person.** git leaves main's copy of a binary file in place with no markers, so a resolution that did not touch it would keep main's version unseen. A conflicted file with a NUL byte blocks the resolution before the session runs.
- **New files the resolve session leaves are removed** before the resolution is saved (`git clean -fd`, not `-x`), as after the test loop's runs: the change's own new files are already in the index, and the prompt says not to add files. Commits are allowed empty: a resolution may keep main's side of everything.
- **The waiting check is cached by commit.** `conflictsWhileWaiting` keeps its last answer per branch with main's and the branch's commits, so ticks and watcher looks that find neither moved run no `merge-tree`.
- **A resolution in the local merge checks holds the merge lock.** Without a remote, the merge checks run under it, so other changes' merges wait while a resolution and its tests run. Fix sessions in the merge checks already did this; moving the catch-up before the lock would leave a window where main moves again. The catch-up rebase inside `mergeIntoMain` still blocks on a conflict: under the lock, main can only have gained records there.
Loading
Loading