From 96ec71ff337ee9870e7609650c90500994372669 Mon Sep 17 00:00:00 2001 From: istos Date: Thu, 30 Jul 2026 11:18:24 +0200 Subject: [PATCH] docs: state syncs, reactions don't MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md gets the "State syncs; reactions don't" section under Syncing boards — the actor-only rule, the file-carried backstops behind it, and the two consequences you can see (a half-done side effect is nobody's to finish automatically; ownership gates work launches). The Pull requests section says which board opens one and how ↑ open PR asks afterwards, the card-actions list gains ▸ take over and ↑ open PR, "Claiming a card" stops saying the claim refuses nothing, and the merge & clean up paragraph documents the origin-side merge — including the constraint the local path hid: whoever clicks needs merge rights on the repo, not just push rights. .env.example: BOARD_COMMIT_MOVES documents the claim as a launch gate, BOARD_SYNC the actor-only reactions and the origin-side merge. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 69 +++++++++++++++++++++++++++++++++++---- manager/core/.env.example | 14 ++++++++ 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4dabc10..98b2230 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,8 @@ config → state → taskfiles → events / github / drive / sync → agents → - `drive.py` — runs the project driver, tracks the one live drive - `sync.py` — origin/main as the shared board: push on move, pull on a beat - `agents.py` — headless work/review jobs, launched through the adapter -- `watch.py` — 2s disk poller narrating moves made outside the API +- `watch.py` — 2s disk poller narrating moves made outside the API, and + the gate that keeps a move a pull applied from triggering anything - `httpd.py` — HTTP routes, the SSE stream, serving the page - `board.py` — argparse + startup wiring only @@ -239,10 +240,12 @@ the board. Card actions appear on hover, taking over the status pill's slot (never stacking on top of it) — at most two per state, only things you'd actually -do without opening the card: **▸ start work** on in-progress cards, -**‖ hold** while an agent runs, **↩ back** on cards waiting on you, -**↺ reopen** on done cards, and **◔ still true?** everywhere. Actions that -cost tokens or stop work arm on first click and fire on the second. +do without opening the card: **▸ start work** on in-progress cards (**▸ +take over** when someone else holds them), **‖ hold** while an agent runs, +**↩ back** on cards waiting on you, **↑ open PR** on review cards whose +branch has none, **↺ reopen** on done cards, and **◔ still true?** +everywhere. Actions that cost tokens or stop work arm on first click and +fire on the second. Each launched agent wears a short name for its lifetime (Wren, Juno, Basil, …) — picked per launch, never shared by two running agents, shown as @@ -253,6 +256,9 @@ sessions that predate it. **▸ start work** launches a headless `claude -p` on the task. It exists only on `in-progress/` cards: moving a card to in-progress is the commitment, and only then does work start — the server refuses launches from anywhere else. +In team mode it also refuses a card someone else holds, naming them; the +action reads **▸ take over** there, and firing it is the deliberate +reassignment. An unclaimed card claims itself on launch. 1. The board creates a git worktree at `.worktrees//` on a new branch `task/` from the newest main it can see: with an @@ -284,6 +290,14 @@ would drag those commits into its diff, so the board refuses and tells you to push main first (then move the card out and back, or wait for the next entry into review/). +The board that opens it is the one whose user moved the card (see "State +syncs; reactions don't"), and the `**PR:**` line is the backstop behind +that: it is checked before every `gh pr create`, in team mode it commits +itself so it reaches the other boards, and a create that races anyway +adopts the PR GitHub already has rather than failing. A review card that +has a branch but no PR carries an **↑ open PR** action — the way to ask +for one after the fact, since no board opens it behind your back. + Review-stage cards with a PR carry two actions: - **◔ review PR** — a read-only agent reads the full diff in context, @@ -351,6 +365,16 @@ branch, remove the worktree and local branch, then move the card. Every step narrates in the ticker; a merge conflict aborts cleanly and the card stays put. Cards without work move silently, and hand-moves on disk are never intercepted — the board only asks when you act through it. + +With `BOARD_SYNC` on the merge is made **on origin** instead: the board +runs `gh pr merge` on the card's PR, cleans up and moves the card, and +local `main` fast-forwards to the result on the next beat. Replicas +converge only while main advances by fast-forward, so no board makes a +merge commit of its own. Two consequences the local path hid: whoever +clicks needs merge rights on the repo, not just push rights, and a branch +without a PR is refused with a pointer to **↑ open PR** — there is nothing +for origin to merge otherwise. Single-player merges locally, exactly as +above. One agent per task at a time; a work agent's worktree must not already exist when starting. @@ -422,8 +446,10 @@ assignee keeps it when someone else moves it on. Walking a card all the way back to `backlog/` clears the line — nobody holds it again. The assignee is who launches agents on the card and whose judgment the -review waits for. It is a convention, not a lock: the board does not (yet) -refuse anyone else's actions. +review waits for. It gates exactly one thing — starting work, which +another board refuses until you take the card over deliberately (see +"State syncs; reactions don't"). Everything else is convention: reading, +reviewing and moving are open to anyone, and git history is the audit. Two consequences worth knowing: @@ -473,6 +499,35 @@ no push, no thread, no behaviour change at all. locally; commits queue on `main` and go out on the next reachable fetch. +### State syncs; reactions don't + +The board does not only render state, it reacts to it: a card entering +review opens a PR. With N replicas watching one truth, a reaction must +fire on exactly one of them, so **only the board whose user made the move +acts on it**. A move a pull applied renders and narrates — attributed to +its author — and triggers nothing. `watch.py` answers the question, since +that is where the attribution already lives, and every future automation +hung off a stage transition inherits it: am I the actor? + +The file-carried gates stay in place behind that rule, so the rare double +is harmless rather than loud: the `**PR:**` line before `gh pr create` +(and a create that races anyway adopts the open PR), an existing branch +and worktree before a work launch. Both layers, deliberately — the +actor-only rule prevents the duplication, idempotency survives it. + +Two consequences you can see: + +- **A half-done side effect is nobody's to finish automatically.** The + actor's board can die between moving a card and opening its PR; no + other board picks that up, and in team mode the startup catch-up stands + down for the same reason. The card wears **↑ open PR** instead — a + person decides. +- **Ownership gates work launches.** A card someone else holds refuses + **▸ start work**, naming them, and offers **▸ take over** as the + deliberate second path. Shared liveness is not part of this: a + teammate's running agent is a static "in-progress, assigned to them" on + your board, because agent registries stay in each board's own memory. + Two disciplines make this safe, and team mode assumes both: - **Local `main` advances only through the board and origin.** Code work diff --git a/manager/core/.env.example b/manager/core/.env.example index d6bfaa8..d6ccc07 100644 --- a/manager/core/.env.example +++ b/manager/core/.env.example @@ -74,6 +74,12 @@ BOARD_FETCH_TIMEOUT=10 # assignee added to a file by hand is still read and shown either way; # the gate only governs whether a move writes it. Anything but # empty/0/false/no/off turns it on. +# +# It is also what makes the claim mean something: on, ▸ start work refuses +# a card someone else holds (▸ take over is the deliberate way in) and +# claims an unheld one, and board-made edits to a card in place — the +# **PR:** line — commit themselves too. Off, nothing writes an assignee, +# so nothing reads one as a lock. BOARD_COMMIT_MOVES= # Sync boards through origin/main: the second half of team mode, and off @@ -92,6 +98,14 @@ BOARD_COMMIT_MOVES= # worktrees and PRs, so local main advances only through the board and # origin. # +# State syncs; reactions don't. A move that arrives over origin renders +# and narrates on every board but triggers nothing — opening a PR belongs +# to the board whose user made the move, and a review card left without +# one carries an explicit ↑ open PR action instead of N boards guessing. +# The same discipline moves merges to origin: merge & clean up runs +# `gh pr merge` and lets the beat deliver the result, so local main only +# ever fast-forwards. Whoever clicks needs merge rights on the repo. +# # One board fetches twice a minute at the default interval; raise it on a # rate-limited or metered remote. Sync rides `origin` and `main` only. BOARD_SYNC=