Compare commits
57
Commits
v0.1-alpha
...
v0.2-alpha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5dd0ac565 | ||
|
|
f64afcfd92 | ||
|
|
8d1e84819d | ||
|
|
c2cf1e92c0 | ||
|
|
780cdf97f4 | ||
|
|
55a5d2a781 | ||
|
|
d94b6ee423 | ||
|
|
3693b66574 | ||
|
|
829a764870 | ||
|
|
64ad311267 | ||
|
|
eb29af4977 | ||
|
|
5eed46d963 | ||
|
|
663552f5db | ||
|
|
fa0be43fe6 | ||
|
|
053e1d7d71 | ||
|
|
8794061db3 | ||
|
|
5dd154cef7 | ||
|
|
eec69ba575 | ||
|
|
39ab96d81b | ||
|
|
9e5e44d48f | ||
|
|
de81ce653e | ||
|
|
13af1d0a60 | ||
|
|
f4e01377c5 | ||
|
|
dd00a77238 | ||
|
|
f241fc5968 | ||
|
|
96ec71ff33 | ||
|
|
755d40dd33 | ||
|
|
3d427670ef | ||
|
|
3efa0f6aef | ||
|
|
865b4eeb3b | ||
|
|
eaa2d3f781 | ||
|
|
0e37d01b64 | ||
|
|
c812314706 | ||
|
|
e80ff6adc2 | ||
|
|
3916b0942f | ||
|
|
0bd9279284 | ||
|
|
3e3612916c | ||
|
|
ee8469d009 | ||
|
|
9d98e2c55a | ||
|
|
ed5bf81a4d | ||
|
|
0af24ccbc4 | ||
|
|
e31de617b7 | ||
|
|
c6be5a6298 | ||
|
|
48a8d6aa9c | ||
|
|
2e84227407 | ||
|
|
de5cedb31f | ||
|
|
3629116afa | ||
|
|
22255f2842 | ||
|
|
a647b849e8 | ||
|
|
5bc297d859 | ||
|
|
6dff0d3f84 | ||
|
|
374003f5d7 | ||
|
|
de4ff8fe92 | ||
|
|
ead190ac03 | ||
|
|
e12d5b1a85 | ||
|
|
0aeba69b01 | ||
|
|
f9d55efa15 |
@@ -0,0 +1,28 @@
|
||||
name: tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Stdlib-only, no install: the suite must pass on the oldest
|
||||
# Python bench supports and the newest current one.
|
||||
python-version: ["3.11", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Configure git for the tests that build scratch repos
|
||||
run: |
|
||||
git config --global user.name ci
|
||||
git config --global user.email ci@bench.invalid
|
||||
git config --global init.defaultBranch main
|
||||
- run: python3 -m unittest discover -s tests -v
|
||||
@@ -44,7 +44,7 @@ Drivers know apps, adapters know vendors, `local/` knows this project.
|
||||
Module map for `manager/core/` (dependencies flow strictly left to right):
|
||||
|
||||
```
|
||||
config → state → taskfiles → events / github / drive → agents → watch / httpd → board.py
|
||||
config → state → taskfiles → events / github / drive / sync → agents → watch / httpd → board.py
|
||||
```
|
||||
|
||||
- `config.py` — paths, stages, settings, prompt/adapter/driver resolution
|
||||
@@ -53,8 +53,10 @@ config → state → taskfiles → events / github / drive → agents → watch
|
||||
- `events.py` — ingests NORMALIZED events (the adapter contract), session registry
|
||||
- `github.py` — PR opening, Copilot requests, review/CI polling
|
||||
- `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
|
||||
|
||||
@@ -166,10 +168,19 @@ Port **26071 is pinned** so the URL is always the same one to bookmark. Running
|
||||
the command again while it is already up just reopens that tab rather than
|
||||
failing on a port clash.
|
||||
|
||||
Since a second bench means a second tab, the tab title names its project:
|
||||
`<project> · bench` — the project first, because tab truncation eats the
|
||||
tail and the tail is the same in every bench tab. The project is the repo
|
||||
directory's name unless `BOARD_TITLE` in `local/.env` overrides it. The
|
||||
server renders it into the page, so it is right on first paint; switching
|
||||
view swaps only the tail (`<project> · sessions`).
|
||||
|
||||
All settings live in `manager/core/.env.example` with their defaults documented —
|
||||
the port, the binaries agents launch with, the commands agents may run,
|
||||
the worktrees directory, the watch interval and the in-memory caps. Copy it
|
||||
to `manager/local/.env` (gitignored) to override locally; real environment
|
||||
the worktrees directory, whether moves claim and commit themselves,
|
||||
whether boards sync through origin/main and how often, the
|
||||
watch interval and the in-memory caps. Copy it to `manager/local/.env`
|
||||
(gitignored) to override locally; real environment
|
||||
variables beat `.env`, which beats the defaults. The hook bridge reads the
|
||||
same `.env`, so changing `BOARD_PORT` moves the board, the agents and the
|
||||
hooks together.
|
||||
@@ -196,7 +207,11 @@ The board has three views (header switcher):
|
||||
- **Board** — the kanban, live. Active Claude Code sessions appear as chips in
|
||||
the header; a card an agent is working on carries a live activity line; the
|
||||
bottom ticker narrates the latest events and every move is attributed
|
||||
(`you` / `agent` / `disk`).
|
||||
(`you` / `agent` / `disk`, or the teammate's git name when a sync brought
|
||||
it). With `BOARD_SYNC` on, a second header chip appears — and only
|
||||
appears — when sync stops converging, saying whether it is behind
|
||||
(origin unreachable, self-healing) or stalled on something only a human
|
||||
can settle.
|
||||
- **Sessions** — a flight recorder per session: a chronological timeline of
|
||||
reads, edits, test runs, commits and card moves, with filters and expandable
|
||||
output. Sessions persist to `.sessions/*.jsonl`, so past ones can be replayed.
|
||||
@@ -225,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
|
||||
@@ -236,9 +253,20 @@ Basil, …) — picked per launch, never shared by two running agents, shown as
|
||||
are held in memory, so a restarted board falls back to plain "Agent" for
|
||||
sessions that predate it.
|
||||
|
||||
Beside that name, wherever it identifies a run — the sessions list, the
|
||||
session and Focus headers, the working card's agent line — sits the model
|
||||
the launch rode: a small mono chip in the id hash's dim register
|
||||
(`opus-4-8`, the vendor's whole string on hover). Which brain did the work
|
||||
is a review question, not a state, so the chip takes no colour. A launch
|
||||
that inherited the vendor default, and a session replayed from disk, wear
|
||||
no chip at all — the board says nothing rather than guessing.
|
||||
|
||||
**▸ 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/<task-stem>/` on a new
|
||||
branch `task/<task-stem>` from the newest main it can see: with an
|
||||
@@ -252,10 +280,35 @@ only then does work start — the server refuses launches from anywhere else.
|
||||
2. The agent works in the worktree: implements, tests, commits. Its hook
|
||||
events stream to the board like any session.
|
||||
3. On clean exit with commits on the branch the board moves the card to
|
||||
`review/`; on failure it stays in `in-progress/` and the exit is narrated
|
||||
in the ticker. A clean exit that committed *nothing* also stays in
|
||||
`review/`; on failure it stays in `in-progress/` and the card wears the
|
||||
failure (below). A clean exit that committed *nothing* also stays in
|
||||
`in-progress/` and is called out loudly — an empty branch reaching
|
||||
review/ is how a broken launch hides. Stdout is kept in `.agent/logs/`.
|
||||
review/ is how a broken launch hides. Stdout is kept in
|
||||
`local/state/agent/logs/`.
|
||||
|
||||
### A run that died
|
||||
|
||||
An agent that exits non-zero is the one outcome a person must not miss, so
|
||||
it is a **state the card wears**, not an event that scrolls past. The run's
|
||||
record keeps the exit code, when it ended, and the cleaned tail of its log
|
||||
— the excerpt, which for an API outage is the whole story ("API Error:
|
||||
500 …") and which a launch that died before the agent ever spoke still
|
||||
answers honestly. From that the board does three things: the card takes the
|
||||
`--alarm` border and a `run failed` pill, with the excerpt on hover and in
|
||||
full in the card sheet; a toast fires, because failures are rare and
|
||||
actionable; and the ticker keeps its line, now naming what the log ended on
|
||||
rather than pointing vaguely at a file. Every headless kind lands here —
|
||||
work, act-pr, PR review, relevance check — and a card that is not in
|
||||
in-progress wears it just the same.
|
||||
|
||||
The state is scoped to the run and the stage: the next launch supersedes it
|
||||
(the card reads its most recent run), and moving the card to another stage
|
||||
drops it, since the failure was about the work in the stage it died in.
|
||||
Nothing retries by itself — a dead run is a human decision point, and an
|
||||
outage would make auto-retry a thundering herd — but the way is cleared for
|
||||
the human: a failed run that committed nothing has its worktree and empty
|
||||
branch removed, exactly as a decline does, so **▸ start work** is one click
|
||||
again. A failed run *with* commits keeps its worktree; there is work in it.
|
||||
|
||||
## Pull requests
|
||||
|
||||
@@ -270,6 +323,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,
|
||||
@@ -337,6 +398,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.
|
||||
|
||||
@@ -397,6 +468,115 @@ Moves are not always forward. Going back a stage is normal and expected —
|
||||
verification failing, or an approach not surviving contact with the code, should
|
||||
move the task backwards rather than being worked around in place.
|
||||
|
||||
## Claiming a card
|
||||
|
||||
**Claiming is moving.** Taking a card out of `backlog/` or `to-do/` towards
|
||||
work is the commitment, so that is where ownership is recorded: the board
|
||||
writes an `**Assignee:** <name>` line into the header, taken from this
|
||||
checkout's `git config user.name` — the identity git history already shows,
|
||||
no new concept. The first claim sticks: a card that already names an
|
||||
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 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:
|
||||
|
||||
- **Hand-moves bypass the claim.** A plain `mv` between stage directories
|
||||
is still a first-class move (the watcher narrates it), but nothing writes
|
||||
the assignee — update the line yourself in the same edit as **Status**.
|
||||
- **Identity is git's, so it collides like git's.** Two machines both
|
||||
configured `user.name = ronald` are one person as far as the board is
|
||||
concerned. Teams that share a git history already share that assumption.
|
||||
|
||||
With `BOARD_COMMIT_MOVES` on, board-made moves also **commit themselves**:
|
||||
the move and the claim land in one commit touching only that task file,
|
||||
messaged `board: <number> → <stage> (<name>)`, staged by pathspec so
|
||||
unrelated staged work is neither committed nor unstaged (hooks are skipped —
|
||||
this is bookkeeping, not code). Pushing is not part of it: those commits sit
|
||||
on your local `main` until you push it (or until `BOARD_SYNC` pushes them —
|
||||
see below), which the PR guard above will tell you about if you forget. The
|
||||
setting is off by default: a single-player
|
||||
board neither writes nor clears the assignee and makes no commits, exactly
|
||||
as before, and `tasks/` is committed by hand. The gate governs only whether
|
||||
a *move* writes the line — an **Assignee:** added to a file by hand is still
|
||||
read and shown on the card whether the gate is on or off.
|
||||
|
||||
## Syncing boards
|
||||
|
||||
`BOARD_SYNC=1` makes `origin/main` the truth and every board a converging
|
||||
replica. It implies `BOARD_COMMIT_MOVES` — a move that never commits has
|
||||
nothing to publish — and off (the default) nothing below runs: no fetch,
|
||||
no push, no thread, no behaviour change at all.
|
||||
|
||||
- **Push is event-driven.** The commit a move makes is pushed as soon as
|
||||
it lands. A rejected push means another board got there first, so the
|
||||
board fetches, replays its own commits on top and pushes again.
|
||||
- **Pull is a beat.** Every `BOARD_SYNC_INTERVAL` (30s by default) and
|
||||
once at startup: fetch, then fast-forward. The watcher narrates what
|
||||
arrived, attributed to the commit's author rather than `disk`.
|
||||
- **Losing a race is a toast, not a mystery.** When replaying collides
|
||||
with a card someone else already moved, origin wins: the local move is
|
||||
dropped, the file reverts to origin's version and the board says
|
||||
`07 claimed by elena — your move was undone`.
|
||||
- **A human's unpushed commit is never published.** Before any auto-push
|
||||
every local-ahead commit on `main` must be `board: `-prefixed. One
|
||||
that isn't stops the push (and the replay), names itself in the ticker
|
||||
and holds the header's `sync stalled` chip until you push it yourself
|
||||
or move it off main.
|
||||
- **Offline is quiet.** An unreachable origin says so once, then works
|
||||
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
|
||||
lives in worktrees and PRs — that is what keeps the main checkout clean
|
||||
and fast-forwardable. Uncommitted changes to tracked files, a checkout
|
||||
sitting on another branch, or a divergence the guard won't replay all
|
||||
stall sync rather than risking your work; each one is narrated once and
|
||||
shown as a chip in the header until it clears.
|
||||
- **Sync never merges.** It fast-forwards, or rebases the board's own
|
||||
bookkeeping commits. Nothing here force-pushes, and nothing reacts to
|
||||
what it pulled beyond narrating it.
|
||||
|
||||
One board fetches twice a minute at the default interval; N boards make
|
||||
N times that. Against GitHub this is nothing, but on a rate-limited or
|
||||
metered remote raise `BOARD_SYNC_INTERVAL`.
|
||||
|
||||
## Task file format
|
||||
|
||||
Each task is a markdown file with a descriptive filename
|
||||
@@ -435,6 +615,17 @@ Type is orthogonal to status. A discovery task — research, scoping, spiking an
|
||||
approach — moves through the same five stages as everything else; "discovery"
|
||||
describes the work, not where it sits on the board.
|
||||
|
||||
An optional **Assignee** line records who holds the card:
|
||||
|
||||
```markdown
|
||||
**Assignee:** ronald
|
||||
```
|
||||
|
||||
The board writes it when a move claims the card (see "Claiming a card") and
|
||||
removes it when the card is walked back to `backlog/`; on `done/` cards it
|
||||
stays as history. Editing it by hand is fine — it is a plain header field,
|
||||
and a hand-move should update it alongside **Status**.
|
||||
|
||||
An optional **Depends on** line can name what must land first — task numbers
|
||||
or external preconditions — so sequencing lives in the header instead of
|
||||
prose asides:
|
||||
|
||||
@@ -4,7 +4,10 @@ A live kanban for coding-agent work: task files in stage directories are
|
||||
the only source of truth; a stdlib-only board narrates everything that
|
||||
happens to them — agents working in git worktrees, PRs opening on review,
|
||||
CI and Copilot state on the cards, drives of the app from a task's own
|
||||
branch, and an archive that is never a delete.
|
||||
branch, and an archive that is never a delete. Turn on team mode
|
||||
(`BOARD_SYNC=1`) and the truth is `origin/main`: moves commit and push
|
||||
themselves, every board pulls on a beat, and the person who claimed a
|
||||
card first keeps it.
|
||||
|
||||
## Install into a repo
|
||||
|
||||
|
||||
Regular → Executable
@@ -6,6 +6,12 @@
|
||||
# everywhere at once.
|
||||
BOARD_PORT=26071
|
||||
|
||||
# What the tab calls this project: the title is "<project> · bench", so two
|
||||
# benches side by side are told apart at tab-bar width. Empty = the repo
|
||||
# directory's name, which is the right answer unless every checkout on this
|
||||
# machine is called "app".
|
||||
BOARD_TITLE=
|
||||
|
||||
# Which agent adapter runs headless jobs (core/adapters/<name>, overridable
|
||||
# in local/adapters/<name>). Ships with: claude, opencode.
|
||||
BOARD_AGENT_ADAPTER=claude
|
||||
@@ -57,6 +63,54 @@ BOARD_PR_POLL_INTERVAL=60
|
||||
# means today's behaviour, never a blocked launch.
|
||||
BOARD_FETCH_TIMEOUT=10
|
||||
|
||||
# Claim on move, and commit it. On: a board-made move out of backlog/ or
|
||||
# to-do/ writes **Assignee:** <git user.name> into the task file (first
|
||||
# claim only — an existing assignee is preserved), walking a card back to
|
||||
# backlog/ clears it, and each move commits itself — the move and the claim
|
||||
# in one commit touching only that task file, messaged
|
||||
# `board: <number> → <stage> (<name>)`. Nothing is pushed. Off (the
|
||||
# default) means moves neither write nor clear the assignee and never
|
||||
# commit — today's behaviour exactly, tasks/ committed by hand. An
|
||||
# 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
|
||||
# by default. On, it implies BOARD_COMMIT_MOVES above (a move that never
|
||||
# commits has nothing to publish) and: each board commit is pushed as it
|
||||
# is made; a beat fetches and fast-forwards every BOARD_SYNC_INTERVAL
|
||||
# seconds and once at startup; moves that arrive are narrated with their
|
||||
# commit author's name; and a card someone else moved first wins the race
|
||||
# — the local move is dropped with a toast naming who took it.
|
||||
#
|
||||
# Two things it will never do. It never publishes a commit the board did
|
||||
# not make: one local-ahead commit on main without the `board: ` prefix
|
||||
# stops the push and says which. And it never merges past a divergence or
|
||||
# into a checkout with uncommitted changes — it says so and waits for a
|
||||
# human. Both assume the discipline bench already has: code work lives in
|
||||
# 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=
|
||||
BOARD_SYNC_INTERVAL=30
|
||||
|
||||
# Seconds between disk polls of the stage directories.
|
||||
BOARD_WATCH_INTERVAL=2
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1-alpha
|
||||
0.2-alpha
|
||||
|
||||
Regular → Executable
Regular → Executable
+129
-6
@@ -21,7 +21,7 @@ from pathlib import Path
|
||||
import config
|
||||
import events
|
||||
import state
|
||||
from taskfiles import find_stage_of, move_task, read_task
|
||||
from taskfiles import actor_name, find_stage_of, move_task, read_task, set_assignee
|
||||
|
||||
|
||||
def _clean_log(text: str, cap: int = 3000) -> str:
|
||||
@@ -84,6 +84,10 @@ def _agent_public(record: dict) -> dict:
|
||||
# The model the launch was actually given; None = inherited the
|
||||
# vendor's own default. Honesty for the Sessions/Focus views.
|
||||
public["model"] = record.get("model")
|
||||
public["ended"] = record.get("ended")
|
||||
# The outcome a failed run leaves behind, for the card to wear (see
|
||||
# _record_failure). None on every run that did not die.
|
||||
public["failure"] = record.get("failure")
|
||||
return public
|
||||
|
||||
|
||||
@@ -190,10 +194,44 @@ def _fresh_branch_point() -> tuple[str | None, str | None]:
|
||||
return "origin/main", None
|
||||
|
||||
|
||||
def start_agent(filename: str, stage: str) -> dict:
|
||||
def _claim_for_launch(filename: str, stage: str, takeover: bool) -> None:
|
||||
"""One agent per task is a board-memory rule; across machines the card
|
||||
file is the only thing every board can see, so the claim is what gates
|
||||
a launch here.
|
||||
|
||||
Someone else's card refuses — naming who holds it — unless this is the
|
||||
deliberate takeover, which reassigns the card to whoever asked. An
|
||||
unclaimed card claims itself on launch: starting work is as much a
|
||||
commitment as the move that usually writes the line.
|
||||
|
||||
Only in team mode. With `BOARD_COMMIT_MOVES` off nothing writes the
|
||||
assignee, so nothing may refuse on it either — the launch is exactly
|
||||
what it was before.
|
||||
"""
|
||||
if not config.COMMIT_MOVES:
|
||||
return
|
||||
path = config.TASKS / stage / filename
|
||||
holder = read_task(path, stage).get("assignee")
|
||||
me = actor_name()
|
||||
if holder and me and holder != me and not takeover:
|
||||
raise ValueError(f"{holder} holds {filename} — take it over deliberately "
|
||||
f"(the card's ▸ take over), or clear the Assignee line")
|
||||
if not me:
|
||||
return # no identity to write; git has no name here
|
||||
if holder == me:
|
||||
return
|
||||
set_assignee(filename, stage, me)
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": (f"{me} took {filename} over from {holder}" if holder
|
||||
else f"{me} claimed {filename} by starting work on it")})
|
||||
|
||||
|
||||
def start_agent(filename: str, stage: str, takeover: bool = False) -> dict:
|
||||
# Moving a card to in-progress is the commitment; only then does work start.
|
||||
_validate(filename, stage, {"in-progress"},
|
||||
"work starts from in-progress/ — move the card there first")
|
||||
_claim_for_launch(filename, stage, takeover)
|
||||
|
||||
stem = filename[:-3]
|
||||
branch = f"task/{stem}"
|
||||
@@ -334,6 +372,78 @@ def _discard_untouched_worktree(record: dict) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _failure_excerpt(log_path: str | None, lines: int = 6, cap: int = 600) -> str:
|
||||
"""The tail of a dead run's log, cleaned — usually the whole story
|
||||
("API Error: 500 …"). A launch that died before the agent ever spoke
|
||||
leaves a line or two, or nothing at all; say which rather than showing
|
||||
an empty card."""
|
||||
text = ""
|
||||
if log_path:
|
||||
try:
|
||||
text = Path(log_path).read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
text = ""
|
||||
kept = [line.rstrip() for line in _clean_log(text, cap=8000).splitlines()
|
||||
if line.strip()]
|
||||
if not kept:
|
||||
return "no output — the run died before the agent said anything"
|
||||
return "\n".join(kept[-lines:])[-cap:]
|
||||
|
||||
|
||||
def _headline(excerpt: str, cap: int = 120) -> str:
|
||||
"""One line of an excerpt for a ticker line or a toast: the last one,
|
||||
which is where a dying process says why."""
|
||||
lines = [line for line in (excerpt or "").splitlines() if line.strip()]
|
||||
return (lines[-1].strip()[:cap] if lines else "no output")
|
||||
|
||||
|
||||
def _why(record: dict) -> str:
|
||||
"""What a dead run's log ended on, for the ticker line that records it."""
|
||||
return _headline((record.get("failure") or {}).get("excerpt", ""))
|
||||
|
||||
|
||||
def _record_failure(record: dict, rc: int) -> dict:
|
||||
"""A dead run is a state its card wears, not an event that scrolls by.
|
||||
|
||||
Every headless kind lands here, launches that died before the agent
|
||||
spoke included: the outcome goes onto the run's record — exit code,
|
||||
when it ended, the log's cleaned tail, and the stage the card was in —
|
||||
so the board can show it, and a toast says it once to whoever is
|
||||
looking. The stage is part of the state because the state is about
|
||||
work in that stage: carried into review/ it would libel the next run.
|
||||
"""
|
||||
failure = {
|
||||
"rc": rc,
|
||||
"ended": record.get("ended") or time.time(),
|
||||
"excerpt": _failure_excerpt(record.get("log")),
|
||||
"stage": find_stage_of(record["task"]) or record.get("origin"),
|
||||
"log": record.get("log"),
|
||||
"mode": record.get("mode", "work"),
|
||||
}
|
||||
with state.LOCK:
|
||||
record["failure"] = failure
|
||||
name = record.get("name") or "the agent"
|
||||
state.broadcast({
|
||||
"type": "toast", "error": True,
|
||||
"message": f"{name} failed on {record['task']} (rc={rc}) — "
|
||||
f"{_headline(failure['excerpt'])}",
|
||||
})
|
||||
return failure
|
||||
|
||||
|
||||
def forget_failure(filename: str) -> bool:
|
||||
"""Drop a card's failed-run state. Called when the card moves stage:
|
||||
the failure belonged to the work in the stage it died in, and no card
|
||||
should arrive somewhere new already wearing an alarm. A relaunch needs
|
||||
no call — the newer run is what the card reads."""
|
||||
cleared = False
|
||||
with state.LOCK:
|
||||
for record in state.AGENTS.values():
|
||||
if record["task"] == filename and record.pop("failure", None):
|
||||
cleared = True
|
||||
return cleared
|
||||
|
||||
|
||||
def _finish(agent_id: str, proc: subprocess.Popen, log_file) -> tuple[dict, bool, int]:
|
||||
rc = proc.wait()
|
||||
log_file.close()
|
||||
@@ -342,6 +452,10 @@ def _finish(agent_id: str, proc: subprocess.Popen, log_file) -> tuple[dict, bool
|
||||
stopped = record["status"] == "stopped"
|
||||
record["status"] = "stopped" if stopped else ("done" if rc == 0 else "failed")
|
||||
record["rc"] = rc
|
||||
record["ended"] = time.time()
|
||||
failed = record["status"] == "failed"
|
||||
if failed:
|
||||
_record_failure(record, rc)
|
||||
return record, stopped, rc
|
||||
|
||||
|
||||
@@ -388,7 +502,14 @@ def _reap_agent(agent_id: str, proc: subprocess.Popen, log_file) -> None:
|
||||
elif stopped:
|
||||
summary = f"{name} was held on {filename} — nothing is lost"
|
||||
else:
|
||||
summary = f"{name} exited on {filename} rc={rc} — see its log"
|
||||
# The card now wears the failure; the ticker keeps the record of it
|
||||
# and names what the log's tail said. A run that committed nothing
|
||||
# also leaves nothing worth keeping, so the worktree goes and
|
||||
# ▸ start work is one click again — same reasoning as a decline.
|
||||
cleaned = _discard_untouched_worktree(record)
|
||||
summary = (f"{name} exited on {filename} rc={rc} — {_why(record)}"
|
||||
+ (" (worktree cleared — relaunch when you have read it)" if cleaned
|
||||
else f" (worktree {record['worktree']} kept: it has commits)"))
|
||||
state.record_board_event({"kind": "agent", "actor": "agent", "file": filename,
|
||||
"summary": summary})
|
||||
state.broadcast({"type": "agents"})
|
||||
@@ -500,7 +621,7 @@ def _reap_pr_fix(agent_id: str, proc: subprocess.Popen, log_file) -> None:
|
||||
elif stopped:
|
||||
summary = f"{name} was held while acting on {filename}'s PR"
|
||||
else:
|
||||
summary = f"{name} failed acting on {filename}'s PR (rc={rc}) — see its log"
|
||||
summary = f"{name} failed acting on {filename}'s PR (rc={rc}) — {_why(record)}"
|
||||
state.record_board_event({"kind": "agent", "actor": "agent", "file": filename,
|
||||
"summary": summary})
|
||||
state.broadcast({"type": "board"})
|
||||
@@ -527,7 +648,9 @@ def _reap_pr_review(agent_id: str, proc: subprocess.Popen, log_file) -> None:
|
||||
|
||||
if stopped:
|
||||
summary = f"{name}'s PR review of {filename} was held"
|
||||
elif rc != 0 or verdict is None:
|
||||
elif rc != 0:
|
||||
summary = f"{name}'s PR review of {filename} died (rc={rc}) — {_why(record)}"
|
||||
elif verdict is None:
|
||||
summary = f"{name}'s PR review of {filename} ended without a verdict — see its log"
|
||||
else:
|
||||
word = "approved it" if verdict == "APPROVE" else "asked for changes"
|
||||
@@ -558,7 +681,7 @@ def _reap_review(agent_id: str, proc: subprocess.Popen, log_file) -> None:
|
||||
if stopped:
|
||||
summary = f"{name}'s check of {filename} was held"
|
||||
elif rc != 0:
|
||||
summary = f"{name}'s check of {filename} exited rc={rc} — see its log"
|
||||
summary = f"{name}'s check of {filename} exited rc={rc} — {_why(record)}"
|
||||
else:
|
||||
summary = f"{name} on {filename}: {verdict[:140] if verdict else 'report appended to the task'}"
|
||||
state.record_board_event({"kind": "agent", "actor": "agent", "file": filename,
|
||||
|
||||
+223
-31
@@ -3,8 +3,13 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Bench — task board</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='4' fill='%230d6e8c'/><circle cx='8' cy='8' r='3' fill='%23e9f3f3'/></svg>">
|
||||
<!-- The server rewrites this to "<project> · bench"; the view switcher keeps
|
||||
it in step. Project first — tab truncation eats the tail. -->
|
||||
<title>bench</title>
|
||||
<!-- The icon is the wordmark's own b on an accent tile, as the design draws
|
||||
it at app-icon size. Its outline is the same string as #mark-b below —
|
||||
one letter, two places; tests/test_header_logo.py keeps them equal. -->
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill-rule='evenodd'><rect width='16' height='16' rx='4' fill='%230d6e8c'/><path fill='%23ffffff' transform='translate(4.81 3.57) scale(.011)' d='M15 0H178V210H330C450 210 565 305 565 420V530C565 645 450 740 330 740H15V648H60V92H15ZM178 314V636H305C385 636 447 585 447 530V420C447 365 385 314 305 314Z'/></svg>">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,400&display=swap">
|
||||
@@ -21,6 +26,8 @@
|
||||
--on-accent:#0a161b; --on-calm:#141a10;
|
||||
--shadow:0 10px 24px -18px rgba(0,0,0,.9);
|
||||
--pad:14px; --gap:10px; --radius:10px; --col:296px; --col-min:240px;
|
||||
/* the wordmark's b-height — the one number that sizes the logo */
|
||||
--logo-h:14px;
|
||||
--sans:'IBM Plex Sans',system-ui,sans-serif;
|
||||
--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
}
|
||||
@@ -72,8 +79,13 @@
|
||||
display:flex;align-items:center;gap:14px;padding:10px 18px;flex-wrap:wrap;
|
||||
background:var(--canvas);border-bottom:1px solid var(--border);
|
||||
}
|
||||
.brand{display:flex;align-items:baseline;gap:10px}
|
||||
.brand b{font-size:15px;font-weight:600;letter-spacing:-.01em}
|
||||
/* The word is the logo — "bench" set in Zilla Slab SemiBold, tracked
|
||||
-.015em. It ships as outlines, not text: this page may not fetch a font
|
||||
for it, and outlines can never fall back to something else. currentColor
|
||||
gives it the theme's own ink, so it is never a colour that means state,
|
||||
and --logo-h scales it in one place rather than per theme. */
|
||||
.brand{display:flex;align-items:baseline;gap:11px}
|
||||
.brand .mark{height:var(--logo-h);width:auto;fill:currentColor;flex:none}
|
||||
.brand .path{font-family:var(--mono);font-size:11.5px;color:var(--dim)}
|
||||
.views{display:flex;gap:2px;padding:3px;background:var(--sunken);border:1px solid var(--border-soft);border-radius:8px}
|
||||
.views button{padding:5px 12px;border:1px solid transparent;border-radius:6px;font-size:12.5px;font-weight:500;color:var(--muted)}
|
||||
@@ -84,8 +96,22 @@
|
||||
background:var(--surface);border:1px solid var(--border);border-radius:99px;font-size:12.5px;
|
||||
}
|
||||
.livechip .mono{font-size:11.5px;color:var(--dim)}
|
||||
/* an author display beats the UA's [hidden] — say it here or the sync
|
||||
chip is never hidden */
|
||||
.livechip[hidden]{display:none}
|
||||
.dot{width:7px;height:7px;border-radius:99px;background:var(--idle);flex:none}
|
||||
.dot.live{background:var(--accent);animation:breathe 2.4s ease-in-out infinite}
|
||||
/* the model chip: which brain did this, beside the name that did it.
|
||||
Machine-produced, so mono; a model is not a state, so it borrows no
|
||||
colour — it lives in the session-id hash's register wherever names
|
||||
appear, tracking each site's hash size. cursor:help because the
|
||||
unshortened string is on the title. */
|
||||
.mchip{font-family:var(--mono);font-size:10.5px;font-weight:400;color:var(--dim);white-space:nowrap;cursor:help}
|
||||
/* the card's row is the tight one: there the chip gives way like the
|
||||
mono fact beside it rather than pushing the row wider */
|
||||
.card .whorow .mchip{font-size:11px;overflow:hidden;text-overflow:ellipsis}
|
||||
.f-head .s-title .mchip{font-size:12px}
|
||||
.refline .mchip{font-size:11.5px}
|
||||
|
||||
/* ── kanban ── */
|
||||
main{flex:1;display:flex;min-height:0}
|
||||
@@ -132,6 +158,9 @@
|
||||
/* PR verdicts: pine when it settled, terracotta when it snagged */
|
||||
.card.verdict-good{border-color:color-mix(in oklab, var(--calm) 55%, var(--border))}
|
||||
.card.verdict-bad{border-color:color-mix(in oklab, var(--alarm) 55%, var(--border))}
|
||||
/* the last run on this card died: the same terracotta, worn until the
|
||||
next launch replaces it or the card moves stage */
|
||||
.card.run-failed{border-color:color-mix(in oklab, var(--alarm) 55%, var(--border))}
|
||||
/* tool chips: destinations, not statuses — they live in the card's footer,
|
||||
never squeezed into the author row */
|
||||
.chiprow{
|
||||
@@ -236,7 +265,7 @@
|
||||
}
|
||||
#loghead .xfile{cursor:pointer;padding:1px 0;white-space:nowrap}
|
||||
#loghead .xfile:hover{color:var(--accent)}
|
||||
#logbody{flex:1;min-height:0;overflow-y:auto;padding:6px 18px 12px;display:grid;gap:2px;align-content:start}
|
||||
#logbody{flex:none;max-height:60vh;overflow-y:auto;padding:6px 18px 12px;display:grid;gap:2px;align-content:start}
|
||||
.ev{display:grid;grid-template-columns:64px 18px 1fr;align-items:baseline;gap:10px;font-size:12px;min-width:0}
|
||||
.ev time{font-family:var(--mono);font-size:11px;color:var(--dim)}
|
||||
.ev .glyph{font-family:var(--mono);font-size:11.5px;color:var(--muted);text-align:center}
|
||||
@@ -411,6 +440,8 @@
|
||||
padding:20px 20px 20px 6px;
|
||||
}
|
||||
#drawer .dhead{display:flex;align-items:center;gap:10px}
|
||||
/* the dead run's excerpt, above the task itself: machine output, bounded */
|
||||
#drawer .well.bad pre{margin:6px 0 4px;white-space:pre-wrap;max-height:220px;overflow-y:auto;color:var(--text)}
|
||||
#drawer .dbody{font-size:13px;line-height:1.6}
|
||||
#drawer .dbody h1{font-size:19px;line-height:1.3;font-weight:600;letter-spacing:-.01em;margin:0 0 4px;text-wrap:pretty}
|
||||
#drawer .dbody h2{font-size:14px;margin:18px 0 6px}
|
||||
@@ -471,13 +502,27 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brand"><b>Bench</b><span class="path" id="root"></span></div>
|
||||
<!-- The wordmark, one glyph per path, on a 1000-unit em: baseline at y=740,
|
||||
x-height top at 210, ascender at 0; the box runs to 746 to hold the
|
||||
round letters' overshoot. Translations are the advance widths with the
|
||||
design's -.015em tracking already folded in. -->
|
||||
<div class="brand">
|
||||
<svg class="mark" viewBox="0 0 2674 746" fill-rule="evenodd" role="img" aria-label="bench">
|
||||
<path id="mark-b" d="M15 0H178V210H330C450 210 565 305 565 420V530C565 645 450 740 330 740H15V648H60V92H15ZM178 314V636H305C385 636 447 585 447 530V420C447 365 385 314 305 314Z"/>
|
||||
<path transform="translate(565)" d="M205 204H319C415 204 494 283 494 379V522H148V582C148 616 172 642 206 642H330C395 642 445 615 470 566V670C440 712 390 746 319 746H205C109 746 30 667 30 571V379C30 283 109 204 205 204ZM148 428V368C148 335 172 308 206 308H318C352 308 376 335 376 368V428Z"/>
|
||||
<path transform="translate(1074)" d="M15 210H330C450 210 520 300 520 400V648H565V740H357V648H402V400C402 345 375 314 320 314H178V648H223V740H15V648H60V302H15Z"/>
|
||||
<path transform="translate(1639)" d="M300 204C358 204 404 212 438 236L392 326C365 313 336 308 300 308C212 308 148 378 148 475C148 572 212 642 300 642C336 642 365 637 392 624L438 714C404 738 358 746 300 746C167 746 30 640 30 475C30 310 167 204 300 204Z"/>
|
||||
<path transform="translate(2094)" d="M15 0H178V210H330C450 210 520 300 520 400V648H565V740H357V648H402V400C402 345 375 314 320 314H178V648H223V740H15V648H60V92H15Z"/>
|
||||
</svg>
|
||||
<span class="path" id="root"></span>
|
||||
</div>
|
||||
<nav class="views" id="views">
|
||||
<button data-view="board" class="on">Board</button>
|
||||
<button data-view="flight">Sessions</button>
|
||||
<button data-view="focus">Focus</button>
|
||||
</nav>
|
||||
<span class="spacer"></span>
|
||||
<div class="livechip" id="syncchip" hidden style="cursor:default"></div>
|
||||
<div class="livechip" id="livechip" title="open Sessions"></div>
|
||||
<button id="themebtn">Daylight</button>
|
||||
<button id="refresh">Refresh</button>
|
||||
@@ -560,10 +605,10 @@ const STAGE_TINT = { backlog: 'var(--dim)', 'to-do': 'var(--muted)',
|
||||
const STAGE_NOTE = { 'to-do': 'next up', review: 'your move' };
|
||||
const GLYPHS = { session: '●', end: '○', idle: '…', edit: '✎', read: '◔', search: '⌕',
|
||||
command: '$', test: '▶', check: '☑', git: '⎇', plan: '≡', subagent: '⑂', web: '∿',
|
||||
move: '⇢', new: '+', agent: '⚑', report: '▣', other: '·' };
|
||||
move: '⇢', new: '+', agent: '⚑', report: '▣', sync: '⇅', other: '·' };
|
||||
const FILTERS = [
|
||||
['all', 'All', null],
|
||||
['moves', 'Moves', new Set(['move', 'new', 'agent'])],
|
||||
['moves', 'Moves', new Set(['move', 'new', 'agent', 'sync'])],
|
||||
['edits', 'Edits', new Set(['edit'])],
|
||||
['reads', 'Reads', new Set(['read', 'search'])],
|
||||
['tests', 'Tests', new Set(['test', 'check'])],
|
||||
@@ -661,6 +706,30 @@ function agentFor(sid) { return (S.state?.agents || []).find(a => a.session ===
|
||||
function agentOnTask(file) {
|
||||
return (S.state?.agents || []).find(a => a.task === file && a.status === 'running');
|
||||
}
|
||||
/* The most recent run on a card. Records outlive their processes, so the
|
||||
latest launch is a max-by-start question, not a find. */
|
||||
function lastRunOn(file) {
|
||||
return (S.state?.agents || []).reduce(
|
||||
(best, a) => (a.task === file && (!best || a.started > best.started) ? a : best), null);
|
||||
}
|
||||
/* A dead run is a state the card wears: alarm border, `run failed` pill and
|
||||
the log's tail, until the next launch replaces it (a newer run is the
|
||||
latest one) or the card moves stage (the server drops the state, and the
|
||||
stage stamp keeps the card honest in the seconds before the watcher
|
||||
notices). Every headless kind counts — work, act-pr, PR review, relevance. */
|
||||
function failedRun(task) {
|
||||
const last = lastRunOn(task.file);
|
||||
const failure = last && last.status === 'failed' ? last.failure : null;
|
||||
return failure && failure.stage === task.stage ? failure : null;
|
||||
}
|
||||
/* The line a run died on: the excerpt's last, which is where a dying
|
||||
process says why ("API Error: 500 …"). Bounded, so one enormous line of
|
||||
machine output cannot grow the card — the whole excerpt is a hover away. */
|
||||
function whyFailed(failure) {
|
||||
const lines = (failure.excerpt || '').split('\n').filter(l => l.trim());
|
||||
const why = lines.length ? lines[lines.length - 1].trim() : 'no output';
|
||||
return why.length > 160 ? why.slice(0, 160) + '…' : why;
|
||||
}
|
||||
function sessionMeta(sid) { return (S.state?.sessions || []).find(m => m.id === sid); }
|
||||
function isLiveSession(m) {
|
||||
const fresh = (Date.now() / 1000 - (m.last || m.started || 0)) < 900;
|
||||
@@ -668,6 +737,24 @@ function isLiveSession(m) {
|
||||
}
|
||||
function allTasks() { return (S.state?.board.stages || []).flatMap(s => s.tasks); }
|
||||
|
||||
/* Which model a run rode, shortened for the chip: the provider path an
|
||||
opencode-style id carries (anthropic/model-x) and the vendor word a
|
||||
claude one repeats (claude-opus-4-8) are both redundant beside a board
|
||||
that already knows its vendor. Nothing else is touched — an unknown
|
||||
name is shown as recorded rather than guessed at. */
|
||||
function shortModel(model) {
|
||||
return String(model).split('/').pop().replace(/^claude-/, '');
|
||||
}
|
||||
|
||||
/* One chip, every place a name identifies a run. A launch that never knew
|
||||
its model (it inherited the vendor default, or the session was replayed
|
||||
from disk after a restart) gets nothing at all: no chip is the honest
|
||||
answer, a placeholder would read as a model named "unknown". */
|
||||
function modelChip(agent) {
|
||||
if (!agent || !agent.model) return '';
|
||||
return `<span class="mchip" title="${esc(agent.model)}">${esc(shortModel(agent.model))}</span>`;
|
||||
}
|
||||
|
||||
function connectStream() {
|
||||
const es = new EventSource('/api/stream');
|
||||
let hadError = false;
|
||||
@@ -700,6 +787,10 @@ function connectStream() {
|
||||
scheduleRender();
|
||||
} else if (msg.type === 'board' || msg.type === 'agents') {
|
||||
loadState();
|
||||
} else if (msg.type === 'toast') {
|
||||
// the server needs to say something to the person, not just the
|
||||
// ticker — losing a card to another board is the case that matters
|
||||
toast(msg.message, !!msg.error);
|
||||
} else if (msg.type === 'board_event') {
|
||||
S.state?.boardEvents.push(msg.event);
|
||||
scheduleRender();
|
||||
@@ -739,6 +830,23 @@ function renderChip() {
|
||||
}
|
||||
}
|
||||
|
||||
/* Sync only shows itself when it has stopped converging: a stall that is
|
||||
not visible is two halves of a team quietly drifting apart. Offline is
|
||||
driftwood (degraded, self-healing); anything waiting on a human is
|
||||
terracotta. */
|
||||
function renderSync() {
|
||||
const s = S.state?.sync;
|
||||
const el = $('#syncchip');
|
||||
if (!s || !s.enabled || s.state === 'ok') { el.hidden = true; return; }
|
||||
const alarm = s.state === 'stalled';
|
||||
const detail = s.detail || '';
|
||||
el.hidden = false;
|
||||
el.title = detail;
|
||||
el.innerHTML = `<span class="dot" style="background:var(--${alarm ? 'alarm' : 'idle'})"></span>` +
|
||||
`<span style="color:var(--${alarm ? 'alarm' : 'muted'})">sync ${alarm ? 'stalled' : 'behind'}</span>` +
|
||||
`<span class="mono">${esc(detail.split(' — ')[0].replace(/^sync[^:]*:\s*/, ''))}</span>`;
|
||||
}
|
||||
|
||||
function setView(view) {
|
||||
S.view = view;
|
||||
document.querySelectorAll('#views button').forEach(b => b.classList.toggle('on', b.dataset.view === view));
|
||||
@@ -746,9 +854,21 @@ function setView(view) {
|
||||
render();
|
||||
}
|
||||
|
||||
/* The tab says which bench this is: the project first (tab truncation eats
|
||||
the tail, and the tail is the same in every bench tab), then the view.
|
||||
Without a project in state the server-rendered title stands. */
|
||||
const VIEW_TITLES = { board: 'bench', flight: 'sessions', focus: 'focus' };
|
||||
|
||||
function renderTitle() {
|
||||
if (!S.state?.project) return;
|
||||
document.title = S.state.project + ' · ' + (VIEW_TITLES[S.view] || 'bench');
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!S.state) return;
|
||||
renderTitle();
|
||||
renderChip();
|
||||
renderSync();
|
||||
if (S.view === 'board') renderBoard();
|
||||
else if (S.view === 'flight') renderFlight();
|
||||
else renderFocus();
|
||||
@@ -797,10 +917,12 @@ function cardFor(task) {
|
||||
const agent = agentOnTask(task.file);
|
||||
const working = agent && agent.mode !== 'review';
|
||||
const verdict = task.stage === 'review' ? prVerdict(task) : null;
|
||||
const failure = failedRun(task);
|
||||
el.className = 'card'
|
||||
+ (S.selected && S.selected.file === task.file ? ' selected' : '')
|
||||
+ (working ? ' running' : '')
|
||||
+ (verdict === 'green' ? ' verdict-good' : verdict === 'red' ? ' verdict-bad' : '')
|
||||
+ (failure ? ' run-failed' : '')
|
||||
+ (task.stage === 'done' ? ' done-dim' : '');
|
||||
el.draggable = true;
|
||||
|
||||
@@ -815,6 +937,13 @@ function cardFor(task) {
|
||||
pill = { text: 'changes asked', tint: 'var(--alarm)', bg: mix('var(--alarm)', 16) };
|
||||
tint = 'var(--alarm)';
|
||||
}
|
||||
if (failure) {
|
||||
// the newest thing that happened here, and the only actionable one:
|
||||
// it outranks a PR verdict from before the run died
|
||||
pill = { text: 'run failed', tint: 'var(--alarm)', bg: mix('var(--alarm)', 16),
|
||||
title: failure.excerpt };
|
||||
tint = 'var(--alarm)';
|
||||
}
|
||||
const high = (task.priority || '').toLowerCase() === 'high';
|
||||
const top = [
|
||||
`<span class="mark${working ? ' breathing' : ''}" style="background:${tint}"></span>`,
|
||||
@@ -822,7 +951,8 @@ function cardFor(task) {
|
||||
'<span class="spacer"></span>',
|
||||
high ? '<span class="high">HIGH</span>' : '',
|
||||
task.statusMismatch ? `<span class="pill drift" title="File says ${esc(task.declaredStatus)}">drift</span>` : '',
|
||||
`<span class="pill status" style="background:${pill.bg};color:${pill.tint}">${pill.text}</span>`,
|
||||
`<span class="pill status" style="background:${pill.bg};color:${pill.tint}"` +
|
||||
`${pill.title ? ` title="${esc(pill.title)}"` : ''}>${pill.text}</span>`,
|
||||
];
|
||||
|
||||
// two actions per state, max — whatever you'd actually do without opening the card
|
||||
@@ -853,9 +983,17 @@ function cardFor(task) {
|
||||
}
|
||||
} else {
|
||||
if (task.stage === 'in-progress') {
|
||||
actions.push({ glyph: '▸', label: 'start work', confirm: 'start it?', busy: 'starting…',
|
||||
title: 'A worktree, a branch, and a headless Claude on this task',
|
||||
run: () => fireAgent(task, '/api/agent/start') });
|
||||
// someone else's card is never started by accident: the action says
|
||||
// whose it is, and firing it is the deliberate takeover
|
||||
const held = task.assignee && S.state.me && task.assignee !== S.state.me
|
||||
? task.assignee : null;
|
||||
actions.push(held
|
||||
? { glyph: '▸', label: 'take over', confirm: `take from ${held}?`, busy: 'starting…',
|
||||
title: `${held} holds this card — starting work takes it over and reassigns it to you`,
|
||||
run: () => fireAgent(task, '/api/agent/start', { takeover: true }) }
|
||||
: { glyph: '▸', label: 'start work', confirm: 'start it?', busy: 'starting…',
|
||||
title: 'A worktree, a branch, and a headless Claude on this task',
|
||||
run: () => fireAgent(task, '/api/agent/start') });
|
||||
} else if (task.stage === 'review') {
|
||||
actions.push({ glyph: '↩', label: 'back', busy: 'moving…', title: 'Send it back for more work',
|
||||
run: () => move(task.file, 'review', 'in-progress') });
|
||||
@@ -863,7 +1001,17 @@ function cardFor(task) {
|
||||
actions.push({ glyph: '↺', label: 'reopen', busy: 'reopening…', title: 'Put it back in the queue',
|
||||
run: () => move(task.file, 'done', 'to-do') });
|
||||
}
|
||||
actions.push(stillTrue);
|
||||
// work in review with no PR: no board opens one behind your back, so
|
||||
// the card offers it instead of the relevance check
|
||||
if (task.stage === 'review' && !task.pr
|
||||
&& (S.state.branches || []).includes(task.file.replace(/\.md$/, ''))) {
|
||||
actions.push({ glyph: '↑', label: 'open PR', confirm: 'open it?', busy: 'opening…',
|
||||
title: 'Push the branch and open its PR — the board does this when a card '
|
||||
+ 'enters review, and this is how you ask for it afterwards',
|
||||
run: () => openPR(task) });
|
||||
} else {
|
||||
actions.push(stillTrue);
|
||||
}
|
||||
}
|
||||
if (actions.length) el.classList.add('has-acts');
|
||||
|
||||
@@ -875,7 +1023,10 @@ function cardFor(task) {
|
||||
meta = elapsed(agent.started) + (agent.branch ? ' · ' + agent.branch : '');
|
||||
} else {
|
||||
initial = '·';
|
||||
if (task.stage === 'backlog' || task.stage === 'to-do') who = 'nobody yet';
|
||||
// a claimed card names its owner in every stage — in done/ the line is
|
||||
// history: who did this. Unclaimed cards keep the old stage vocabulary.
|
||||
if (task.assignee) { who = task.assignee; initial = task.assignee.slice(0, 1); }
|
||||
else if (task.stage === 'backlog' || task.stage === 'to-do') who = 'nobody yet';
|
||||
else if (task.stage === 'in-progress') who = 'unattended';
|
||||
else if (task.stage === 'review') who = 'needs your eyes';
|
||||
else who = 'merged';
|
||||
@@ -898,6 +1049,12 @@ function cardFor(task) {
|
||||
}
|
||||
} else if (agent) {
|
||||
liveLine = `<div class="well"><span class="lead">·</span><span class="wbody">warming up<span class="caret">▌</span></span></div>`;
|
||||
} else if (failure) {
|
||||
// "API Error: 500" one hover away instead of buried in a log file: the
|
||||
// line the run died on here, the whole excerpt on hover and in the sheet
|
||||
liveLine = `<div class="well bad" title="${esc(failure.excerpt)}">` +
|
||||
`<span class="lead">·</span><span class="wbody">rc=${esc(failure.rc)} · ` +
|
||||
`${esc(whyFailed(failure))}</span></div>`;
|
||||
}
|
||||
|
||||
// tool chips: destinations, not statuses — they live in the card's footer
|
||||
@@ -976,7 +1133,8 @@ function cardFor(task) {
|
||||
el.innerHTML =
|
||||
`<div class="toprow">${top.join('')}</div>` +
|
||||
`<div class="title">${esc(task.title)}</div>` +
|
||||
`<div class="whorow"><span class="initial">${initial}</span><span class="who">${who}</span>` +
|
||||
`<div class="whorow"><span class="initial">${esc(initial)}</span><span class="who">${esc(who)}</span>` +
|
||||
modelChip(agent) +
|
||||
`<span class="meta">${esc(meta)}${extras.length ? ' · ' + extras.join(' · ') : ''}</span></div>` +
|
||||
chipRow + driveWell + liveLine;
|
||||
el.querySelectorAll('a.chip2').forEach(a =>
|
||||
@@ -1100,11 +1258,11 @@ async function fireAction(btn, key, act) {
|
||||
return ok;
|
||||
}
|
||||
|
||||
async function fireAgent(task, url) {
|
||||
async function fireAgent(task, url, extra) {
|
||||
toast(`starting on ${task.file}…`);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file: task.file, stage: task.stage }),
|
||||
body: JSON.stringify({ file: task.file, stage: task.stage, ...(extra || {}) }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { toast(data.error || 'that did not start', true); return false; }
|
||||
@@ -1150,6 +1308,17 @@ async function parkDrive() {
|
||||
loadState();
|
||||
}
|
||||
|
||||
async function openPR(task) {
|
||||
const res = await fetch('/api/pr/open', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file: task.file }),
|
||||
});
|
||||
const data = await res.json();
|
||||
toast(res.ok ? `PR opened for ${task.file}` : (data.error || 'the PR did not open'), !res.ok);
|
||||
await loadState();
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
async function askCopilot(task) {
|
||||
const res = await fetch('/api/pr/copilot', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1202,8 +1371,13 @@ function completeSheet(task, from) {
|
||||
`<div class="sbtns">` +
|
||||
`<button id="sh-keep">Keep it where it is<small>Nothing moves, nothing changes.</small></button>` +
|
||||
`<button id="sh-move">Just move the card<small>The branch${task.pr ? ', PR' : ''} and worktree stay as they are.</small></button>` +
|
||||
`<button id="sh-ship" class="shipit">Merge & clean up<small>${driving ? 'Park the drive, then m' : 'M'}erge the branch into main, push` +
|
||||
`${task.pr ? ' (marks the PR merged)' : ''}, remove the worktree and branches, move the card.</small></button>` +
|
||||
// team mode merges on origin: the board never makes a merge commit of
|
||||
// its own, so every replica's main keeps fast-forwarding
|
||||
`<button id="sh-ship" class="shipit">Merge & clean up<small>${driving ? 'Park the drive, then m' : 'M'}erge ` +
|
||||
((S.state.sync || {}).enabled
|
||||
? `the PR on GitHub, remove the worktree and branches, move the card — local main fast-forwards on the next sync beat.`
|
||||
: `the branch into main, push${task.pr ? ' (marks the PR merged)' : ''}, remove the worktree and branches, move the card.`) +
|
||||
`</small></button>` +
|
||||
`</div></div>`;
|
||||
wrap.classList.add('open');
|
||||
wrap.addEventListener('click', (e) => { if (e.target === wrap) closeSheet(); });
|
||||
@@ -1246,16 +1420,28 @@ function renderDrawer() {
|
||||
if (!S.selected) { panel.classList.remove('open'); body.innerHTML = ''; return; }
|
||||
const t = S.selected;
|
||||
const agent = agentOnTask(t.file);
|
||||
const pill = agent && agent.mode === 'review'
|
||||
const failure = failedRun(t);
|
||||
const pill = failure
|
||||
? { text: 'run failed', tint: 'var(--alarm)', bg: mix('var(--alarm)', 16) }
|
||||
: agent && agent.mode === 'review'
|
||||
? { text: 'reviewing', tint: 'var(--accent)', bg: mix('var(--accent)', 16) }
|
||||
: pillFor(t.stage, agent && agent.mode !== 'review');
|
||||
const when = new Date(t.mtime * 1000).toLocaleString();
|
||||
// the whole excerpt, not just the line the card shows — the sheet is
|
||||
// where you read what killed the run without opening files on disk
|
||||
const failBlock = failure
|
||||
? `<div class="well bad"><span class="lead">·</span><div class="wbody">` +
|
||||
`<b>run failed</b> · rc=${esc(failure.rc)} · ${esc(ago(failure.ended))} ago` +
|
||||
`<pre>${esc(failure.excerpt)}</pre>` +
|
||||
`<span style="color:var(--dim)">${esc(failure.log || '')}</span></div></div>`
|
||||
: '';
|
||||
body.innerHTML =
|
||||
`<div class="dhead">` +
|
||||
`<span class="mono" style="font-size:12px;color:var(--dim)">${t.number ? '#' + esc(t.number) : ''}</span>` +
|
||||
`<span class="pill" style="background:${pill.bg};color:${pill.tint}">${pill.text}</span>` +
|
||||
`<span class="spacer"></span>` +
|
||||
`<button id="closeDrawer">Close</button></div>` +
|
||||
failBlock +
|
||||
`<div class="dbody">${md(t.body)}</div>` +
|
||||
`<div class="dmeta">${esc(t.stage)}/${esc(t.file)} · ${t.words} words · edited ${esc(when)}</div>`;
|
||||
panel.classList.add('open');
|
||||
@@ -1375,7 +1561,7 @@ function renderFlight() {
|
||||
return `<div class="sess-row${m.id === sid ? ' sel' : ''}" data-sid="${esc(m.id)}" role="button" tabindex="0">` +
|
||||
`<span class="top"><span class="dot${active ? ' live' : ''}"></span>` +
|
||||
`<span>${esc((m.label || m.id.slice(0, 8)).split(' · ')[0])}</span>` +
|
||||
`<span class="sid">${esc(m.id.slice(0, 8))}</span></span>` +
|
||||
`<span class="sid">${esc(m.id.slice(0, 8))}</span>${modelChip(agentFor(m.id))}</span>` +
|
||||
`<span class="sub">${m.task ? 'on ' + esc(m.task) + ' · ' : ''}${m.count || 0} events · ${m.last ? ago(m.last) + ' ago' : ''}</span>` +
|
||||
(m.lastSummary ? `<span class="sub">${esc(m.lastSummary)}</span>` : '') +
|
||||
`</div>`;
|
||||
@@ -1399,12 +1585,14 @@ function renderFlight() {
|
||||
const stopBtn = agent && agent.status === 'running'
|
||||
? `<button id="stopagent" class="stopbtn" data-aid="${esc(agent.id)}">Hold</button>` : '';
|
||||
const branch = agent && agent.branch ? ` · <span class="mono">${esc(agent.branch)}</span>` : '';
|
||||
// Honesty about what the run actually rode: the configured model, or
|
||||
// the vendor default it inherited. Interactive sessions say nothing.
|
||||
const model = agent ? ` · <span class="mono">${agent.model ? esc(agent.model) : 'model inherited'}</span>` : '';
|
||||
// Honesty about what the run actually rode. A known model is the chip's
|
||||
// job now, beside the name; the line keeps only what the chip cannot
|
||||
// say — that this launch inherited the vendor default. Interactive
|
||||
// sessions say nothing.
|
||||
const model = agent && !agent.model ? ` · <span class="mono">model inherited</span>` : '';
|
||||
$('#fsession').innerHTML =
|
||||
`<div><div class="s-title">${esc((meta.label || sid).split(' · ')[0])}` +
|
||||
`<span class="sid">${esc(sid.slice(0, 8))}</span></div>` +
|
||||
`<span class="sid">${esc(sid.slice(0, 8))}</span>${modelChip(agent)}</div>` +
|
||||
`<div class="s-line">${meta.task ? 'on ' + esc(meta.task) + ' · ' : ''}` +
|
||||
`started ${fmtShort(meta.started)} · ${meta.count || 0} events · ` +
|
||||
`${files.size} files edited · ${checks} check runs${branch}${model}</div></div>` +
|
||||
@@ -1591,9 +1779,11 @@ function renderFocus() {
|
||||
const refBits = [];
|
||||
if (task) {
|
||||
refBits.push(task.number ? '#' + esc(task.number) : esc(task.file));
|
||||
refBits.push(`<span class="acc">${esc((meta.label || '').split(' · ')[0])}</span>`);
|
||||
refBits.push(`<span class="acc">${esc((meta.label || '').split(' · ')[0])}</span>` +
|
||||
modelChip(agent));
|
||||
if (agent && agent.branch) refBits.push('worktree ' + esc(agent.branch));
|
||||
if (agent) refBits.push(agent.model ? esc(agent.model) : 'model inherited');
|
||||
// the chip says which model; the line is left saying only what it can't
|
||||
if (agent && !agent.model) refBits.push('model inherited');
|
||||
refBits.push(esc(task.stage) + '/' + esc(task.file));
|
||||
} else {
|
||||
refBits.push(esc(sid.slice(0, 8)), 'no task attached');
|
||||
@@ -1804,8 +1994,10 @@ document.addEventListener('keydown', (e) => {
|
||||
/* the activity log: drag its grip to resize, scroll to read back */
|
||||
{
|
||||
const grip = $('#loggrip'), body = $('#logbody');
|
||||
body.style.height = (parseInt(localStorage.getItem('bench-log-h'), 10) ||
|
||||
Math.round(innerHeight * 0.30)) + 'px';
|
||||
const clamp = (px) => Math.min(Math.max(px, 80), Math.round(innerHeight * 0.6));
|
||||
let h = clamp(parseInt(localStorage.getItem('bench-log-h'), 10) ||
|
||||
Math.round(innerHeight * 0.30));
|
||||
body.style.height = h + 'px';
|
||||
let dragging = false, startY = 0, startH = 0;
|
||||
grip.addEventListener('mousedown', (e) => {
|
||||
dragging = true; startY = e.clientY; startH = body.offsetHeight;
|
||||
@@ -1813,13 +2005,13 @@ document.addEventListener('keydown', (e) => {
|
||||
});
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!dragging) return;
|
||||
const h = Math.min(Math.max(startH + (startY - e.clientY), 80), Math.round(innerHeight * 0.6));
|
||||
h = clamp(startH + (startY - e.clientY));
|
||||
body.style.height = h + 'px';
|
||||
});
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (!dragging) return;
|
||||
dragging = false; grip.classList.remove('dragging');
|
||||
localStorage.setItem('bench-log-h', body.offsetHeight);
|
||||
localStorage.setItem('bench-log-h', h);
|
||||
});
|
||||
body.addEventListener('scroll', () => {
|
||||
S.logStick = body.scrollTop + body.clientHeight >= body.scrollHeight - 8;
|
||||
|
||||
Regular → Executable
+7
@@ -12,6 +12,7 @@ task files, but the tasks work as a plain folder kanban without it. See
|
||||
state.py shared registries, event persistence, SSE fan-out
|
||||
taskfiles.py reading/moving task files (the only code touching tasks/)
|
||||
events.py hook payloads → displayable events, session registry
|
||||
sync.py origin/main as the shared board: push on move, pull on a beat
|
||||
agents.py headless work/review agents: launch, reap, stop, diff
|
||||
watch.py 2s disk poller narrating moves made outside the API
|
||||
httpd.py HTTP routes, SSE stream, the page itself
|
||||
@@ -34,6 +35,7 @@ import events
|
||||
import github
|
||||
import httpd
|
||||
import state
|
||||
import sync
|
||||
import watch
|
||||
|
||||
|
||||
@@ -64,6 +66,11 @@ def main() -> None:
|
||||
threading.Thread(target=watch.watcher, daemon=True).start()
|
||||
threading.Thread(target=github.poller, daemon=True).start()
|
||||
threading.Thread(target=github.reconcile, daemon=True).start()
|
||||
if config.SYNC:
|
||||
# Team mode: board commits publish themselves and a beat pulls what
|
||||
# the other boards published. Off, neither thread nor hook exists.
|
||||
sync.install()
|
||||
threading.Thread(target=sync.beat, daemon=True).start()
|
||||
drive.adopt()
|
||||
|
||||
print(f"Task board for {config.TASKS}\n {url}\n Ctrl-C to stop")
|
||||
|
||||
@@ -73,6 +73,11 @@ def setting(key: str, default: str) -> str:
|
||||
return _ENV.get(key, default)
|
||||
|
||||
|
||||
def flag(key: str, default: str = "") -> bool:
|
||||
"""A boolean setting. Anything but empty/0/false/no/off is on."""
|
||||
return setting(key, default).strip().lower() not in ("", "0", "false", "no", "off")
|
||||
|
||||
|
||||
def child_env() -> dict[str, str]:
|
||||
"""Environment for adapter/driver child processes: the real environment
|
||||
with local/.env settings folded in (process env still wins), so
|
||||
@@ -86,6 +91,11 @@ PORT = int(setting("BOARD_PORT", "26071"))
|
||||
# One isolated checkout per running work agent, relative to the repo root.
|
||||
WORKTREES = REPO / setting("BOARD_WORKTREES", ".worktrees")
|
||||
|
||||
# The project this board serves. Every board looks alike in a tab bar, so
|
||||
# the title leads with this name — the repo directory's, unless the setting
|
||||
# says otherwise (checkouts all called "app" need the override).
|
||||
PROJECT = setting("BOARD_TITLE", "").strip() or REPO.name
|
||||
|
||||
# Which agent adapter runs headless jobs. Resolution ladder: local wins.
|
||||
ADAPTER = setting("BOARD_AGENT_ADAPTER", "claude")
|
||||
|
||||
@@ -124,6 +134,20 @@ PR_POLL_INTERVAL = float(setting("BOARD_PR_POLL_INTERVAL", "60"))
|
||||
# network weather; this bounds the whole delay.
|
||||
FETCH_TIMEOUT = float(setting("BOARD_FETCH_TIMEOUT", "10"))
|
||||
|
||||
# Team mode's second half: origin/main is the shared truth and every board
|
||||
# a converging replica — board commits push as they are made, a beat pulls
|
||||
# what other boards published. Off by default; on, it implies COMMIT_MOVES
|
||||
# below, because a move that never commits has nothing to publish.
|
||||
SYNC = flag("BOARD_SYNC")
|
||||
SYNC_INTERVAL = float(setting("BOARD_SYNC_INTERVAL", "30"))
|
||||
|
||||
# Team mode's first half: a board-made move claims the card (writing
|
||||
# **Assignee:** from git's own user.name) and commits itself, so ownership
|
||||
# and stage travel with the file to every clone. Off by default — a
|
||||
# single-player board moves cards exactly as it always did, and committing
|
||||
# tasks/ stays a hand job.
|
||||
COMMIT_MOVES = flag("BOARD_COMMIT_MOVES") or SYNC
|
||||
|
||||
WATCH_INTERVAL = float(setting("BOARD_WATCH_INTERVAL", "2"))
|
||||
EVENTS_CAP = int(setting("BOARD_EVENTS_CAP", "800"))
|
||||
BOARD_EVENTS_CAP = int(setting("BOARD_HISTORY_CAP", "300"))
|
||||
|
||||
+163
-54
@@ -4,6 +4,13 @@ Copilot reviews, and polling PR state for cards sitting in review/.
|
||||
All of it is mechanical `git` + `gh` — no Claude involvement. The PR url is
|
||||
written into the task file (`**PR:** <url>`), keeping the file the single
|
||||
source of truth; only the volatile review/check state lives in memory.
|
||||
|
||||
With replicas watching one truth, *who* opens a PR matters: the trigger is
|
||||
the actor's board (watch.py refuses to fire on a move a pull applied) and
|
||||
the `**PR:**` line is the backstop behind it — carried by the file, so a
|
||||
second attempt from anywhere finds the PR already there, and a `gh pr
|
||||
create` that races anyway adopts the open PR instead of erroring. Polling
|
||||
is read-only and every board does it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,7 +26,7 @@ from pathlib import Path
|
||||
import config
|
||||
import drive as drive_mod
|
||||
import state
|
||||
from taskfiles import STATUS_RE, find_stage_of, move_task, read_task
|
||||
from taskfiles import STATUS_RE, commit_edit, find_stage_of, move_task, read_task
|
||||
|
||||
PR_STATE: dict[str, dict] = {} # filename -> {verdict, detail, url, ts}
|
||||
_OPENING: set[str] = set() # filenames with a PR-open in flight
|
||||
@@ -47,6 +54,10 @@ def _branch_exists(branch: str) -> bool:
|
||||
|
||||
|
||||
def _write_pr_line(filename: str, url: str) -> None:
|
||||
"""The url joins the header — and in team mode commits itself, so the
|
||||
gate that stops a second board opening a second PR travels to the other
|
||||
boards rather than sitting in one working tree (where it would also
|
||||
stall sync, which never runs over uncommitted changes)."""
|
||||
stage = find_stage_of(filename)
|
||||
if not stage:
|
||||
return
|
||||
@@ -59,59 +70,80 @@ def _write_pr_line(filename: str, url: str) -> None:
|
||||
else:
|
||||
text = f"**PR:** {url}\n\n" + text
|
||||
path.write_text(text, encoding="utf-8")
|
||||
commit_edit(filename, stage, "PR opened")
|
||||
|
||||
|
||||
class _Quiet(ValueError):
|
||||
"""A reason not worth the ticker: no branch, or a PR already open. The
|
||||
automatic path swallows these; the explicit action still shows them."""
|
||||
|
||||
|
||||
def maybe_open_pr(filename: str) -> None:
|
||||
"""Card entered review/ — open a PR for its branch if one can be opened.
|
||||
"""Card entered review/ on *this* board — open a PR for its branch if
|
||||
one can be opened.
|
||||
|
||||
Quiet when there is simply no branch (hand-written tasks); loud in the
|
||||
ticker when a PR *should* be possible but something stands in the way.
|
||||
Quiet when there is simply no branch (hand-written tasks) or a PR is
|
||||
already on the card; loud in the ticker when a PR *should* be possible
|
||||
but something stands in the way.
|
||||
"""
|
||||
if filename in _OPENING:
|
||||
return
|
||||
_OPENING.add(filename)
|
||||
try:
|
||||
_open_pr(filename)
|
||||
except _Quiet:
|
||||
pass
|
||||
except ValueError as exc:
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": str(exc)})
|
||||
finally:
|
||||
_OPENING.discard(filename)
|
||||
|
||||
|
||||
def _open_pr(filename: str) -> None:
|
||||
def open_pr_now(filename: str) -> str:
|
||||
"""The explicit action behind ↑ open PR: no board ever completes a
|
||||
half-done side effect on its own (the actor's board may have died
|
||||
between moving the card and opening the PR), so a person asks for it —
|
||||
and hears the reason when it cannot happen."""
|
||||
if filename in _OPENING:
|
||||
raise ValueError(f"a PR for {filename} is already being opened")
|
||||
_OPENING.add(filename)
|
||||
try:
|
||||
return _open_pr(filename)
|
||||
finally:
|
||||
_OPENING.discard(filename)
|
||||
|
||||
|
||||
def _open_pr(filename: str) -> str:
|
||||
branch = f"task/{filename[:-3]}"
|
||||
if not _branch_exists(branch):
|
||||
return # nothing to publish — a hand-moved card without agent work
|
||||
# nothing to publish — a hand-moved card without agent work
|
||||
raise _Quiet(f"{filename} has no {branch} branch — nothing to open a PR from")
|
||||
stage = find_stage_of(filename)
|
||||
if stage != "review":
|
||||
return
|
||||
raise _Quiet(f"{filename} is not in review/ — PRs open from there")
|
||||
task = read_task(config.TASKS / stage / filename, stage)
|
||||
if task.get("pr"):
|
||||
return # already open
|
||||
raise _Quiet(f"{filename} already has a PR: {task['pr']}")
|
||||
|
||||
rname = remote()
|
||||
if rname is None or not gh_available():
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"no PR for {filename}: " +
|
||||
("no git remote configured" if rname is None else "gh is not installed")})
|
||||
return
|
||||
raise ValueError(f"no PR for {filename}: " +
|
||||
("no git remote configured" if rname is None
|
||||
else "gh is not installed"))
|
||||
|
||||
# The PR's diff is computed against the remote main — refuse to open one
|
||||
# that would drag unpushed main commits along with it.
|
||||
_run(["git", "fetch", rname, "main"], timeout=120)
|
||||
ahead = _run(["git", "rev-list", "--count", f"{rname}/main..main"]).stdout.strip()
|
||||
if ahead.isdigit() and int(ahead) > 0:
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"won't open a PR for {filename}: main is {ahead} commits "
|
||||
f"ahead of {rname} — push main first, then move the card again"})
|
||||
return
|
||||
raise ValueError(f"won't open a PR for {filename}: main is {ahead} commits "
|
||||
f"ahead of {rname} — push main first, then move the card again")
|
||||
|
||||
push = _run(["git", "push", "-u", rname, branch], timeout=180)
|
||||
if push.returncode != 0:
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"push failed for {branch}: {push.stderr.strip()[:140]}"})
|
||||
return
|
||||
raise ValueError(f"push failed for {branch}: {push.stderr.strip()[:140]}")
|
||||
|
||||
body = (f"Task: `{filename}` — tracked in `.task-manager/tasks/review/`.\n\n"
|
||||
f"Opened by the board when the card moved to review.")
|
||||
@@ -121,10 +153,20 @@ def _open_pr(filename: str) -> None:
|
||||
result = _run([config.GH_BIN, "pr", "create", "--head", branch, "--base", "main",
|
||||
"--title", task["title"], "--body", body], timeout=120)
|
||||
if result.returncode != 0:
|
||||
# The rare double-fire: two attempts crossed and GitHub already has
|
||||
# the PR. Adopt it — one PR still exists, and the card learns its
|
||||
# url. Anything else is a real failure.
|
||||
adopted = _existing_pr(branch) if _already_exists(result) else ""
|
||||
if not adopted:
|
||||
raise ValueError(f"PR creation failed for {branch}: "
|
||||
f"{result.stderr.strip()[:140]}")
|
||||
_write_pr_line(filename, adopted)
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"PR creation failed for {branch}: {result.stderr.strip()[:140]}"})
|
||||
return
|
||||
"summary": f"{filename}'s PR was already open — adopted it: {adopted}"})
|
||||
state.broadcast({"type": "board"})
|
||||
_poll_pr(filename, adopted)
|
||||
return adopted
|
||||
url = next((l.strip() for l in result.stdout.splitlines() if "/pull/" in l), result.stdout.strip())
|
||||
_write_pr_line(filename, url)
|
||||
state.record_board_event({
|
||||
@@ -132,6 +174,20 @@ def _open_pr(filename: str) -> None:
|
||||
"summary": f"PR opened for {filename}: {url}"})
|
||||
state.broadcast({"type": "board"})
|
||||
_poll_pr(filename, url) # first CI/review snapshot without waiting a cycle
|
||||
return url
|
||||
|
||||
|
||||
def _already_exists(result: subprocess.CompletedProcess) -> bool:
|
||||
return "already exists" in (result.stderr + result.stdout).lower()
|
||||
|
||||
|
||||
def _existing_pr(branch: str) -> str:
|
||||
"""The url of the PR already open for this branch, if gh can name it."""
|
||||
found = _run([config.GH_BIN, "pr", "view", branch, "--json", "url",
|
||||
"--jq", ".url"], timeout=60)
|
||||
if found.returncode != 0:
|
||||
return ""
|
||||
return next((l.strip() for l in found.stdout.splitlines() if "/pull/" in l), "")
|
||||
|
||||
|
||||
def _agent_log_tail(filename: str, cap: int = 1500) -> str:
|
||||
@@ -326,9 +382,15 @@ def open_pr_async(filename: str) -> None:
|
||||
|
||||
def complete_task(filename: str, stage: str) -> dict:
|
||||
"""The user chose "merge & clean up" on a move to done: park the drive
|
||||
if it is this task's, merge the branch into main, push (which marks the
|
||||
PR merged), remove the worktree and branches, then move the card.
|
||||
Every step narrates; a conflict aborts cleanly and the card stays."""
|
||||
if it is this task's, merge the branch, remove the worktree and
|
||||
branches, then move the card. Every step narrates; a conflict aborts
|
||||
cleanly and the card stays.
|
||||
|
||||
Where the merge happens depends on team mode. Single-player merges into
|
||||
the local main and pushes it, exactly as it always did; with
|
||||
`BOARD_SYNC` on the merge is made on origin through `gh pr merge`, so
|
||||
local main only ever fast-forwards to it — the discipline the whole
|
||||
sync design rests on."""
|
||||
if stage not in config.STAGE_DIRS or stage == "done":
|
||||
raise ValueError("complete runs on a live-stage card")
|
||||
if not (config.TASKS / stage / filename).is_file():
|
||||
@@ -348,37 +410,18 @@ def complete_task(filename: str, stage: str) -> dict:
|
||||
|
||||
merged = False
|
||||
if _branch_exists(branch):
|
||||
current = _run(["git", "branch", "--show-current"]).stdout.strip()
|
||||
if current != "main":
|
||||
raise ValueError(f"the repo is on '{current}', not main — switch first")
|
||||
result = _run(["git", "merge", "--no-edit", branch], timeout=120)
|
||||
if result.returncode != 0:
|
||||
_run(["git", "merge", "--abort"])
|
||||
detail = (result.stdout.strip() or result.stderr.strip())[-160:]
|
||||
raise ValueError(f"merge conflict — resolve by hand ({detail})")
|
||||
if config.SYNC:
|
||||
_merge_on_origin(filename, stage, branch)
|
||||
else:
|
||||
_merge_locally(filename, branch)
|
||||
merged = True
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"merged {branch} into main"})
|
||||
|
||||
rname = remote()
|
||||
if rname:
|
||||
push = _run(["git", "push", rname, "main"], timeout=180)
|
||||
if push.returncode != 0:
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"merged locally but the push failed — push main "
|
||||
f"yourself ({push.stderr.strip()[:100]})"})
|
||||
else:
|
||||
_run(["git", "push", rname, "--delete", branch], timeout=60)
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"pushed main (PR marked merged) and deleted {branch} on {rname}"})
|
||||
|
||||
worktree = config.WORKTREES / stem
|
||||
if worktree.exists():
|
||||
_run(["git", "worktree", "remove", "--force", str(worktree)])
|
||||
_run(["git", "branch", "-d", branch])
|
||||
# -D under sync: main here has not merged the branch yet (origin
|
||||
# did), so the safe delete would refuse something already landed.
|
||||
_run(["git", "branch", "-D" if config.SYNC else "-d", branch])
|
||||
PR_STATE.pop(filename, None)
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
@@ -389,6 +432,65 @@ def complete_task(filename: str, stage: str) -> dict:
|
||||
return {"merged": merged}
|
||||
|
||||
|
||||
def _merge_locally(filename: str, branch: str) -> None:
|
||||
"""Single-player: merge into the checkout's own main and push it."""
|
||||
current = _run(["git", "branch", "--show-current"]).stdout.strip()
|
||||
if current != "main":
|
||||
raise ValueError(f"the repo is on '{current}', not main — switch first")
|
||||
result = _run(["git", "merge", "--no-edit", branch], timeout=120)
|
||||
if result.returncode != 0:
|
||||
_run(["git", "merge", "--abort"])
|
||||
detail = (result.stdout.strip() or result.stderr.strip())[-160:]
|
||||
raise ValueError(f"merge conflict — resolve by hand ({detail})")
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"merged {branch} into main"})
|
||||
|
||||
rname = remote()
|
||||
if rname:
|
||||
push = _run(["git", "push", rname, "main"], timeout=180)
|
||||
if push.returncode != 0:
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"merged locally but the push failed — push main "
|
||||
f"yourself ({push.stderr.strip()[:100]})"})
|
||||
else:
|
||||
_run(["git", "push", rname, "--delete", branch], timeout=60)
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"pushed main (PR marked merged) and deleted {branch} on {rname}"})
|
||||
|
||||
|
||||
def _merge_on_origin(filename: str, stage: str, branch: str) -> None:
|
||||
"""Team mode: the merge commit is made by GitHub, on origin.
|
||||
|
||||
Replicas keep converging only while local main advances by
|
||||
fast-forward, so the board never creates a merge commit of its own —
|
||||
it asks origin for one and lets the sync beat deliver it. Needs merge
|
||||
rights on the repo for whoever clicks, which the local path did not.
|
||||
"""
|
||||
task = read_task(config.TASKS / stage / filename, stage)
|
||||
url = task.get("pr")
|
||||
if not url:
|
||||
raise ValueError(
|
||||
f"{filename} has no PR, and with BOARD_SYNC on the merge is made on "
|
||||
f"origin — open a PR for {branch} first (↑ open PR on the card)")
|
||||
if not gh_available():
|
||||
raise ValueError("gh is not installed — with BOARD_SYNC on the merge runs on origin")
|
||||
number = url.rstrip("/").rsplit("/", 1)[-1]
|
||||
result = _run([config.GH_BIN, "pr", "merge", number, "--merge"], timeout=180)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr.strip() or result.stdout.strip())[-160:]
|
||||
raise ValueError(f"origin would not merge the PR — resolve it on GitHub ({detail})")
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"merged {filename}'s PR on origin — local main fast-forwards "
|
||||
f"on the next sync beat"})
|
||||
rname = remote()
|
||||
if rname:
|
||||
_run(["git", "push", rname, "--delete", branch], timeout=60)
|
||||
|
||||
|
||||
def task_branches() -> list[str]:
|
||||
"""Stems of all task/* branches — the UI uses this to say honestly
|
||||
whether a review card has work attached."""
|
||||
@@ -400,7 +502,14 @@ def task_branches() -> list[str]:
|
||||
def reconcile() -> None:
|
||||
"""Catch up on moves the watcher never saw (board was down): any card
|
||||
already sitting in review/ with a branch but no PR gets its PR opened
|
||||
now. Runs once at startup."""
|
||||
now. Runs once at startup.
|
||||
|
||||
Not in team mode. A replica cannot tell whose move it missed, so every
|
||||
board starting up would race to open the same PR — and the card that
|
||||
needs one wears the explicit ↑ open PR action instead, which is a
|
||||
person deciding rather than N boards guessing."""
|
||||
if config.SYNC:
|
||||
return
|
||||
time.sleep(3) # let the server settle first
|
||||
directory = config.TASKS / "review"
|
||||
if not directory.is_dir():
|
||||
|
||||
+28
-4
@@ -4,8 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from html import escape
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
@@ -16,6 +18,7 @@ import drive
|
||||
import events
|
||||
import github
|
||||
import state
|
||||
import sync
|
||||
import taskfiles
|
||||
|
||||
|
||||
@@ -27,6 +30,7 @@ def state_payload() -> dict:
|
||||
board_events = list(state.BOARD_EVENTS[-80:])
|
||||
return {
|
||||
"board": taskfiles.collect(),
|
||||
"project": config.PROJECT,
|
||||
"sessions": sessions,
|
||||
"agents": agents.list_public(),
|
||||
"prs": github.public_state(),
|
||||
@@ -36,12 +40,27 @@ def state_payload() -> dict:
|
||||
"commands": config.commands(),
|
||||
"commandRuns": commands.public(),
|
||||
"checks": config.checks(),
|
||||
# who this board is, so a card can tell "yours" from "someone
|
||||
# else's". Empty outside team mode: nothing claims anything there.
|
||||
"me": taskfiles.actor_name() if config.COMMIT_MOVES else "",
|
||||
"archivedCount": taskfiles.archived_count(),
|
||||
"sync": sync.status(),
|
||||
"boardEvents": board_events,
|
||||
"now": time.time(),
|
||||
}
|
||||
|
||||
|
||||
_TITLE = re.compile(rb"<title>.*?</title>", re.DOTALL)
|
||||
|
||||
|
||||
def page_bytes() -> bytes:
|
||||
"""board.html with the project's name rendered into its <title>, so the
|
||||
tab reads right on first paint rather than after the first state load."""
|
||||
html = (config.CORE / "board.html").read_bytes()
|
||||
title = escape(f"{config.PROJECT} · bench").encode("utf-8")
|
||||
return _TITLE.sub(lambda _: b"<title>" + title + b"</title>", html, count=1)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args): # quieter console
|
||||
pass
|
||||
@@ -61,11 +80,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
url = urlparse(self.path)
|
||||
path = url.path
|
||||
if path in ("/", "/index.html", "/board.html"):
|
||||
page = config.CORE / "board.html"
|
||||
if not page.is_file():
|
||||
if not (config.CORE / "board.html").is_file():
|
||||
self._send(500, b"board.html is missing", "text/plain")
|
||||
return
|
||||
self._send(200, page.read_bytes(), "text/html; charset=utf-8")
|
||||
self._send(200, page_bytes(), "text/html; charset=utf-8")
|
||||
elif path == "/api/tasks":
|
||||
self._json(200, taskfiles.collect())
|
||||
elif path == "/api/state":
|
||||
@@ -152,7 +170,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._json(200, {"ok": True})
|
||||
elif path == "/api/agent/start":
|
||||
payload = self._read_body()
|
||||
agent = agents.start_agent(payload["file"], payload["stage"])
|
||||
# takeover: the second, deliberate click on a card someone
|
||||
# else holds — never the default a stale card face sends
|
||||
agent = agents.start_agent(payload["file"], payload["stage"],
|
||||
bool(payload.get("takeover")))
|
||||
self._json(200, {"agent": agent})
|
||||
elif path == "/api/agent/review":
|
||||
payload = self._read_body()
|
||||
@@ -166,6 +187,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
payload = self._read_body()
|
||||
agent = agents.start_pr_fix(payload["file"], payload["stage"])
|
||||
self._json(200, {"agent": agent})
|
||||
elif path == "/api/pr/open":
|
||||
payload = self._read_body()
|
||||
self._json(200, {"url": github.open_pr_now(payload["file"])})
|
||||
elif path == "/api/pr/copilot":
|
||||
payload = self._read_body()
|
||||
url = github.request_copilot(payload["file"])
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
#
|
||||
# Anything not listed here does not ship: bench's own task cards, its
|
||||
# manager/local/ content, local/state/, .claude/, tests/, release.sh.
|
||||
#
|
||||
# Modes ship too, and one invariant is absolute: any shipped file whose
|
||||
# first two bytes are `#!` carries the executable bit in the tarball.
|
||||
# The artifact test enforces it with no exception list — a shebang is a
|
||||
# promise the file can be run.
|
||||
copy AGENTS.md
|
||||
copy CLAUDE.md
|
||||
copy README.md
|
||||
|
||||
@@ -21,6 +21,7 @@ EVENTS: dict[str, list[dict]] = {} # session_id -> slim events
|
||||
BOARD_EVENTS: list[dict] = [] # moves + agent lifecycle
|
||||
AGENTS: dict[str, dict] = {} # agent_id -> launch record
|
||||
EXPECTED_MOVES: dict[tuple[str, str], tuple[str, float]] = {} # (file, to) -> (actor, ts)
|
||||
COMMIT_HOOKS: list = [] # run after a board-made task commit
|
||||
|
||||
# The port actually being served; board.py sets it from --port at startup so
|
||||
# launched agents know where to report events.
|
||||
@@ -59,6 +60,19 @@ def record_board_event(event: dict) -> None:
|
||||
broadcast({"type": "board_event", "event": event})
|
||||
|
||||
|
||||
def task_committed(filename: str) -> None:
|
||||
"""A board-made move committed itself. Registered hooks turn that into
|
||||
whatever else should follow — sync.py's push, when the gate is on. The
|
||||
hook is a registry rather than an import so taskfiles stays to the left
|
||||
of everything that reacts to it; a hook that raises must never break a
|
||||
move that has already happened on disk."""
|
||||
for hook in list(COMMIT_HOOKS):
|
||||
try:
|
||||
hook(filename)
|
||||
except Exception: # noqa: BLE001 — the move is done; nothing may undo it
|
||||
pass
|
||||
|
||||
|
||||
def expect_move(filename: str, target: str, actor: str) -> None:
|
||||
"""Tell the watcher who is about to move a file so it can attribute it."""
|
||||
with LOCK:
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
"""Boards converge through origin/main: push what this board commits, pull
|
||||
what the other boards published.
|
||||
|
||||
Gated on `BOARD_SYNC` (which implies `BOARD_COMMIT_MOVES` — a move that
|
||||
never commits has nothing to publish). Off, nothing here runs: no fetch,
|
||||
no push, no thread.
|
||||
|
||||
The shape of it:
|
||||
|
||||
- **push** is event-driven. `taskfiles` fires `state.task_committed` after
|
||||
a board-made move commits; the hook installed here publishes it. A
|
||||
rejected push means another board got there first, so the whole converge
|
||||
runs and pushes again.
|
||||
- **pull** is a beat: fetch, then integrate. Purely behind → fast-forward.
|
||||
Diverged → the board's own commits are rebased on top, never merged
|
||||
past; a rebase that conflicts on a task file means the local move lost
|
||||
the race, and it is dropped with a toast naming who took the card.
|
||||
- **the piggyback guard** stands in front of every push: each local-ahead
|
||||
commit on main must be `board: `-prefixed. A human's unpushed work is
|
||||
never published as a side effect of a card moving.
|
||||
- **offline** is not an error. The first unreachable fetch says so once,
|
||||
the rest are silent, commits queue on local main and the next reachable
|
||||
fetch catches up.
|
||||
|
||||
Git is the lock server and main the linearizer — that is the whole
|
||||
concurrency control. Nothing here reacts to synced state beyond narrating
|
||||
it: replicas render, they do not act.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import config
|
||||
import state
|
||||
from taskfiles import NUMBER_RE
|
||||
|
||||
REMOTE = "origin" # one remote, one branch — by design
|
||||
BRANCH = "main"
|
||||
UPSTREAM = f"{REMOTE}/{BRANCH}"
|
||||
BOARD_COMMIT = "board: " # the prefix taskfiles messages its own commits with
|
||||
PUSH_TIMEOUT = 120
|
||||
REBASE_TIMEOUT = 120
|
||||
ARRIVED_TTL = 60.0 # the watcher polls every 2s; this is generous
|
||||
|
||||
_LOCK = threading.Lock() # one git operation on the checkout at a time
|
||||
_ARRIVED_LOCK = threading.Lock()
|
||||
ARRIVED: dict[str, tuple[str, float]] = {} # filename -> (author, ts) from the last pull
|
||||
_NOTES: dict[str, tuple[str, str]] = {} # key -> (summary, level) already narrated
|
||||
|
||||
|
||||
def _git(*args: str, timeout: float = 30) -> subprocess.CompletedProcess:
|
||||
"""Never raises: a timeout or a missing binary is just a failed run."""
|
||||
try:
|
||||
return subprocess.run(["git", "-C", str(config.REPO), *args],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return subprocess.CompletedProcess(args, 128, "", "timed out")
|
||||
except OSError as exc:
|
||||
return subprocess.CompletedProcess(args, 128, "", str(exc))
|
||||
|
||||
|
||||
# ── narration ──────────────────────────────────────────────────────────
|
||||
# Every condition here repeats on every beat, so each one is narrated once
|
||||
# and then held: the ticker says it, the header chip keeps saying it.
|
||||
|
||||
|
||||
def status() -> dict:
|
||||
"""What the header shows: ok while converging, otherwise the reason."""
|
||||
if not config.SYNC:
|
||||
return {"enabled": False, "state": "off", "detail": ""}
|
||||
for level in ("offline", "stalled"):
|
||||
for summary, note_level in _NOTES.values():
|
||||
if note_level == level:
|
||||
return {"enabled": True, "state": level, "detail": summary}
|
||||
return {"enabled": True, "state": "ok", "detail": ""}
|
||||
|
||||
|
||||
def _note(key: str, summary: str, level: str = "stalled") -> None:
|
||||
if _NOTES.get(key) == (summary, level):
|
||||
return # same condition as last time: said once is enough
|
||||
_NOTES[key] = (summary, level)
|
||||
state.record_board_event({"kind": "sync", "actor": "sync", "summary": summary})
|
||||
state.broadcast({"type": "board"})
|
||||
|
||||
|
||||
def _clear(key: str, recovery: str = "") -> None:
|
||||
if _NOTES.pop(key, None) is None:
|
||||
return
|
||||
if recovery:
|
||||
state.record_board_event({"kind": "sync", "actor": "sync", "summary": recovery})
|
||||
state.broadcast({"type": "board"})
|
||||
|
||||
|
||||
# ── the checkout ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _origin_present() -> bool:
|
||||
return REMOTE in _git("remote").stdout.split()
|
||||
|
||||
|
||||
def _head() -> str:
|
||||
return _git("rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
|
||||
def _on_main() -> bool:
|
||||
return _git("rev-parse", "--abbrev-ref", "HEAD").stdout.strip() == BRANCH
|
||||
|
||||
|
||||
def _clean() -> bool:
|
||||
"""Tracked files only: an untracked scratch file is nobody's business,
|
||||
but a modified one is what a fast-forward would run over."""
|
||||
return not _git("status", "--porcelain", "--untracked-files=no").stdout.strip()
|
||||
|
||||
|
||||
def _count(rng: str) -> int:
|
||||
out = _git("rev-list", "--count", rng).stdout.strip()
|
||||
return int(out) if out.isdigit() else 0
|
||||
|
||||
|
||||
def _tasks_prefix() -> str:
|
||||
try:
|
||||
return config.TASKS.resolve().relative_to(config.REPO.resolve()).as_posix() + "/"
|
||||
except ValueError:
|
||||
return "tasks/"
|
||||
|
||||
|
||||
def _fetch() -> bool:
|
||||
result = _git("fetch", REMOTE, BRANCH, timeout=config.FETCH_TIMEOUT)
|
||||
if result.returncode != 0:
|
||||
if "couldn't find remote ref" in (result.stderr or "").lower():
|
||||
_note("no-branch", f"sync stalled: {REMOTE} has no {BRANCH} branch — "
|
||||
f"sync rides {UPSTREAM} and nothing else")
|
||||
return False
|
||||
_note("offline",
|
||||
f"sync is behind: {REMOTE} is unreachable — this board keeps "
|
||||
f"working locally and catches up when it returns", "offline")
|
||||
return False
|
||||
_clear("no-branch")
|
||||
_clear("offline", f"sync caught up: {REMOTE} is reachable again")
|
||||
return True
|
||||
|
||||
|
||||
# ── publishing ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _ahead() -> list[str]:
|
||||
"""`<short sha> <subject>` for every commit local main has and
|
||||
origin/main does not — newest first."""
|
||||
out = _git("log", "--format=%h %s", f"{UPSTREAM}..{BRANCH}").stdout
|
||||
return [line for line in out.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _stray(commits: list[str]) -> str:
|
||||
"""The piggyback hazard: pushing publishes *every* local-ahead commit,
|
||||
so one that the board did not make is a human's private work and stops
|
||||
the push. Oldest first — that is the one to deal with."""
|
||||
for line in reversed(commits):
|
||||
subject = line.split(" ", 1)[1] if " " in line else ""
|
||||
if not subject.startswith(BOARD_COMMIT):
|
||||
return line
|
||||
return ""
|
||||
|
||||
|
||||
def _publish() -> str:
|
||||
"""Push local main if — and only if — everything on it is the board's.
|
||||
|
||||
ok | nothing | stray | not-on-main | retry | offline | stalled
|
||||
"""
|
||||
if not _on_main():
|
||||
branch = _git("rev-parse", "--abbrev-ref", "HEAD").stdout.strip() or "a detached HEAD"
|
||||
_note("branch", f"sync paused: this checkout is on '{branch}', not {BRANCH} — "
|
||||
f"board commits are not landing where sync publishes from")
|
||||
return "not-on-main"
|
||||
_clear("branch")
|
||||
if _git("rev-parse", "--verify", "--quiet", UPSTREAM).returncode != 0:
|
||||
return "retry" # never fetched: converge first, then publish
|
||||
commits = _ahead()
|
||||
stray = _stray(commits)
|
||||
if stray:
|
||||
_note("stray", f"not pushing: {stray} is not a board commit — sync will not "
|
||||
f"publish it for you. Push main yourself, or move that commit "
|
||||
f"off main, and sync resumes")
|
||||
return "stray"
|
||||
_clear("stray") # nothing stray left to refuse, however that happened
|
||||
if not commits:
|
||||
return "nothing"
|
||||
|
||||
result = _git("push", REMOTE, f"{BRANCH}:{BRANCH}", timeout=PUSH_TIMEOUT)
|
||||
if result.returncode == 0:
|
||||
_clear("push")
|
||||
_clear("offline", f"sync caught up: {REMOTE} is reachable again")
|
||||
state.record_board_event({
|
||||
"kind": "sync", "actor": "sync",
|
||||
"summary": f"pushed {len(commits)} board commit"
|
||||
f"{'s' if len(commits) > 1 else ''} to {UPSTREAM}"})
|
||||
return "ok"
|
||||
stderr = (result.stderr or result.stdout).strip()
|
||||
if _rejected(stderr):
|
||||
return "retry"
|
||||
if _unreachable(stderr):
|
||||
_note("offline",
|
||||
f"sync is behind: {REMOTE} is unreachable — this board keeps "
|
||||
f"working locally and catches up when it returns", "offline")
|
||||
return "offline"
|
||||
detail = stderr.splitlines()[-1][:140] if stderr else "git said nothing"
|
||||
_note("push", f"sync could not push to {UPSTREAM}: {detail}")
|
||||
return "stalled"
|
||||
|
||||
|
||||
def _rejected(stderr: str) -> bool:
|
||||
text = stderr.lower()
|
||||
return "non-fast-forward" in text or "fetch first" in text or "! [rejected]" in text
|
||||
|
||||
|
||||
def _unreachable(stderr: str) -> bool:
|
||||
text = stderr.lower()
|
||||
return any(mark in text for mark in (
|
||||
"could not read from remote", "could not resolve", "unable to access",
|
||||
"does not appear to be a git repository", "connection", "timed out",
|
||||
"no such file or directory", "permission denied"))
|
||||
|
||||
|
||||
# ── integrating what arrived ───────────────────────────────────────────
|
||||
|
||||
|
||||
def _conflicted() -> list[str]:
|
||||
out = _git("diff", "--name-only", "--diff-filter=U").stdout
|
||||
return [line.strip() for line in out.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _is_task_file(path: str) -> bool:
|
||||
return path.startswith(_tasks_prefix()) and path.endswith(".md")
|
||||
|
||||
|
||||
def _author_of(filename: str, rev: str) -> str:
|
||||
"""Who wrote the newest commit touching this card in `rev` — a range for
|
||||
what a pull brought, a ref for what origin already holds."""
|
||||
return _git("log", "-1", "--format=%an", rev, "--",
|
||||
f"{_tasks_prefix()}*/{filename}").stdout.strip()
|
||||
|
||||
|
||||
def _number(filename: str) -> str:
|
||||
match = NUMBER_RE.match(filename)
|
||||
return match.group(1) if match else filename[:-3] if filename.endswith(".md") else filename
|
||||
|
||||
|
||||
def _lost(filename: str) -> None:
|
||||
"""The local move lost the race. Say who took the card — the file itself
|
||||
reverts to origin's version when the rebase drops our commit."""
|
||||
who = _author_of(filename, UPSTREAM) or "someone else"
|
||||
message = f"{_number(filename)} claimed by {who} — your move was undone"
|
||||
state.record_board_event({"kind": "sync", "actor": "sync", "file": filename,
|
||||
"summary": message})
|
||||
state.broadcast({"type": "toast", "message": message, "error": True})
|
||||
state.broadcast({"type": "board"})
|
||||
|
||||
|
||||
def _replay() -> str:
|
||||
"""Rebase this board's commits onto origin/main. Conflicts on a task
|
||||
file are resolved by dropping our commit: origin is the linearizer, and
|
||||
a card someone else moved first is theirs. Anything conflicting outside
|
||||
tasks/ is a real collision — abort and wait for a human.
|
||||
|
||||
ok | dirty | stalled
|
||||
"""
|
||||
if not _clean():
|
||||
_note("dirty", "sync paused: main has uncommitted changes — commit or stash "
|
||||
"them and sync resumes (code work belongs in a worktree)")
|
||||
return "dirty"
|
||||
_clear("dirty")
|
||||
|
||||
result = _git("rebase", UPSTREAM, timeout=REBASE_TIMEOUT)
|
||||
for _ in range(50): # bounded: one round per replayed commit
|
||||
if result.returncode == 0:
|
||||
_clear("replay")
|
||||
return "ok"
|
||||
conflicted = _conflicted()
|
||||
if not conflicted or not all(_is_task_file(p) for p in conflicted):
|
||||
_git("rebase", "--abort")
|
||||
detail = ", ".join(conflicted[:3]) or (result.stderr or result.stdout).strip()[-140:]
|
||||
_note("replay", f"sync stalled: replaying this board's commits onto {UPSTREAM} "
|
||||
f"collides outside tasks/ ({detail}) — a human has to settle it")
|
||||
return "stalled"
|
||||
for name in dict.fromkeys(Path(p).name for p in conflicted):
|
||||
_lost(name)
|
||||
result = _git("rebase", "--skip", timeout=REBASE_TIMEOUT)
|
||||
_git("rebase", "--abort")
|
||||
_note("replay", f"sync stalled: replaying onto {UPSTREAM} did not settle — "
|
||||
f"a human has to settle it")
|
||||
return "stalled"
|
||||
|
||||
|
||||
def _integrate() -> str:
|
||||
"""Bring local main to origin/main without ever merging past a
|
||||
divergence.
|
||||
|
||||
up-to-date | pulled | not-on-main | dirty | diverged | stalled
|
||||
"""
|
||||
if _count(f"{BRANCH}..{UPSTREAM}") == 0:
|
||||
return "up-to-date"
|
||||
if not _on_main():
|
||||
branch = _git("rev-parse", "--abbrev-ref", "HEAD").stdout.strip() or "a detached HEAD"
|
||||
_note("branch", f"sync paused: this checkout is on '{branch}', not {BRANCH} — "
|
||||
f"switch back and the board catches up with {UPSTREAM}")
|
||||
return "not-on-main"
|
||||
_clear("branch")
|
||||
|
||||
# Diverged. The board's own bookkeeping can be replayed on top of what
|
||||
# arrived — that is how a lost race resolves. A human's commit cannot,
|
||||
# and the guard that refuses to push it refuses to rebase it too.
|
||||
commits = _ahead()
|
||||
stray = _stray(commits)
|
||||
if stray:
|
||||
_note("diverged", f"sync stalled: main and {UPSTREAM} have diverged and "
|
||||
f"{stray} is not a board commit — pull or rebase it by "
|
||||
f"hand, and this board starts converging again")
|
||||
return "diverged"
|
||||
_clear("diverged")
|
||||
if commits:
|
||||
outcome = _replay()
|
||||
return "pulled" if outcome == "ok" else outcome
|
||||
|
||||
if not _clean():
|
||||
_note("dirty", "sync paused: main has uncommitted changes — commit or stash "
|
||||
"them and sync resumes (code work belongs in a worktree)")
|
||||
return "dirty"
|
||||
_clear("dirty")
|
||||
result = _git("merge", "--ff-only", UPSTREAM, timeout=REBASE_TIMEOUT)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout).strip().splitlines()
|
||||
_note("merge", f"sync stalled: fast-forwarding to {UPSTREAM} failed "
|
||||
f"({detail[-1][:140] if detail else 'no detail'})")
|
||||
return "stalled"
|
||||
_clear("merge")
|
||||
return "pulled"
|
||||
|
||||
|
||||
def _record_arrivals(before: str) -> None:
|
||||
"""Attribute what the pull brought: each task file it touched is filed
|
||||
under the name of whoever committed it, for the watcher to use instead
|
||||
of "disk" when the move surfaces on the next poll."""
|
||||
head = _head()
|
||||
if not before or not head or head == before:
|
||||
return
|
||||
rng = f"{before}..{head}"
|
||||
changed = _git("diff", "--name-only", rng, "--", _tasks_prefix()).stdout.splitlines()
|
||||
names = sorted({Path(p).name for p in changed if p.strip().endswith(".md")})
|
||||
if not names:
|
||||
return
|
||||
now = time.time()
|
||||
authors = set()
|
||||
with _ARRIVED_LOCK:
|
||||
for name in names:
|
||||
who = _author_of(name, rng)
|
||||
if who:
|
||||
ARRIVED[name] = (who, now)
|
||||
authors.add(who)
|
||||
for name in [n for n, (_, ts) in ARRIVED.items() if now - ts > ARRIVED_TTL]:
|
||||
ARRIVED.pop(name, None)
|
||||
count = _count(rng)
|
||||
state.record_board_event({
|
||||
"kind": "sync", "actor": "sync",
|
||||
"summary": f"pulled {count} commit{'s' if count != 1 else ''} from {UPSTREAM}"
|
||||
+ (f" ({', '.join(sorted(authors))})" if authors else "")})
|
||||
state.broadcast({"type": "board"})
|
||||
|
||||
|
||||
def arrived_actor(filename: str) -> str:
|
||||
"""Who moved this card, if a pull just brought it. Consumed once — the
|
||||
watcher asks exactly when it notices the move."""
|
||||
with _ARRIVED_LOCK:
|
||||
entry = ARRIVED.pop(filename, None)
|
||||
if not entry:
|
||||
return ""
|
||||
who, ts = entry
|
||||
return who if time.time() - ts <= ARRIVED_TTL else ""
|
||||
|
||||
|
||||
# ── the two entry points ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def _converge() -> str:
|
||||
"""One full beat: fetch, integrate what arrived, publish what is ours."""
|
||||
if not _origin_present():
|
||||
return "no-origin"
|
||||
if not _fetch():
|
||||
return "offline"
|
||||
before = _head()
|
||||
outcome = _integrate()
|
||||
_record_arrivals(before)
|
||||
if outcome in ("up-to-date", "pulled"):
|
||||
published = _publish()
|
||||
if published in ("stray", "offline", "stalled", "not-on-main"):
|
||||
return published
|
||||
return outcome
|
||||
|
||||
|
||||
def push_now() -> str:
|
||||
"""A board commit just landed — publish it. The fast path skips the
|
||||
fetch; a rejection means another board pushed first, and then the full
|
||||
converge (fetch, replay, push) runs."""
|
||||
if not config.SYNC:
|
||||
return "off"
|
||||
with _LOCK:
|
||||
if not _origin_present():
|
||||
return "no-origin"
|
||||
outcome = _publish()
|
||||
if outcome != "retry":
|
||||
return outcome
|
||||
return _converge()
|
||||
|
||||
|
||||
def pull_now() -> str:
|
||||
"""The beat. Also the offline catch-up: a fetch that works again is
|
||||
followed by the push that could not happen while origin was gone."""
|
||||
if not config.SYNC:
|
||||
return "off"
|
||||
with _LOCK:
|
||||
return _converge()
|
||||
|
||||
|
||||
def on_commit(filename: str) -> None:
|
||||
"""The `state.task_committed` hook: publish off the caller's thread, so
|
||||
a card move never waits on the network."""
|
||||
if not config.SYNC:
|
||||
return
|
||||
threading.Thread(target=push_now, name="sync-push", daemon=True).start()
|
||||
|
||||
|
||||
def install() -> None:
|
||||
"""Wire the push hook. Called once at startup, only with the gate on."""
|
||||
if on_commit not in state.COMMIT_HOOKS:
|
||||
state.COMMIT_HOOKS.append(on_commit)
|
||||
|
||||
|
||||
def beat(interval: float | None = None) -> None:
|
||||
interval = config.SYNC_INTERVAL if interval is None else interval
|
||||
while True:
|
||||
try:
|
||||
pull_now()
|
||||
except Exception as exc: # noqa: BLE001 — the beat outlives a bad cycle
|
||||
state.record_board_event({"kind": "sync", "actor": "sync",
|
||||
"summary": f"sync cycle failed: {str(exc)[:140]}"})
|
||||
time.sleep(interval)
|
||||
+135
-2
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import config
|
||||
@@ -18,10 +19,15 @@ TITLE_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
|
||||
STATUS_RE = re.compile(r"^\*\*Status:\*\*\s*(.+?)\s*$", re.MULTILINE)
|
||||
PRIORITY_RE = re.compile(r"^\*\*Priority:\*\*\s*(.+?)\s*$", re.MULTILINE)
|
||||
TYPE_RE = re.compile(r"^\*\*Type:\*\*\s*(.+?)\s*$", re.MULTILINE)
|
||||
ASSIGNEE_RE = re.compile(r"^\*\*Assignee:\*\*\s*(.+?)\s*$", re.MULTILINE)
|
||||
ASSIGNEE_LINE_RE = re.compile(r"^\*\*Assignee:\*\*[^\n]*\n?", re.MULTILINE)
|
||||
PR_RE = re.compile(r"^\*\*PR:\*\*\s*(\S+)\s*$", re.MULTILINE)
|
||||
PR_VERDICT_RE = re.compile(r"^PR REVIEW:\s*(APPROVE|REQUEST CHANGES)", re.MULTILINE)
|
||||
NUMBER_RE = re.compile(r"^(\d+)[-_]")
|
||||
|
||||
STAGE_ORDER = {slug: index for index, (slug, _) in enumerate(config.STAGES)}
|
||||
CLAIM_FROM = {"backlog", "to-do"} # the unstarted stages: leaving one claims
|
||||
|
||||
|
||||
def _first(pattern: re.Pattern[str], text: str) -> str | None:
|
||||
match = pattern.search(text)
|
||||
@@ -45,6 +51,8 @@ def read_task(path: Path, stage: str) -> dict:
|
||||
verdicts = PR_VERDICT_RE.findall(text)
|
||||
return {
|
||||
"pr": _first(PR_RE, text),
|
||||
# who holds the card — written by the board when a move claims it
|
||||
"assignee": _first(ASSIGNEE_RE, text),
|
||||
"prVerdict": {"APPROVE": "green", "REQUEST CHANGES": "red"}.get(
|
||||
verdicts[-1] if verdicts else None),
|
||||
"file": path.name,
|
||||
@@ -142,8 +150,122 @@ def archived_count() -> int:
|
||||
return len(list(directory.glob("*.md"))) if directory.is_dir() else 0
|
||||
|
||||
|
||||
def _git(*args: str, timeout: int = 30) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["git", "-C", str(config.REPO), *args],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
|
||||
def actor_name() -> str:
|
||||
"""Who this checkout is: `git config user.name`, the identity git history
|
||||
already shows. Empty when git has no name — then nothing is claimed."""
|
||||
try:
|
||||
result = _git("config", "user.name", timeout=10)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return ""
|
||||
return result.stdout.strip() if result.returncode == 0 else ""
|
||||
|
||||
|
||||
def claims(source: str, target: str) -> bool:
|
||||
"""Claiming is moving: taking a card out of one of the unstarted stages
|
||||
towards work is the commitment, and the commitment names its owner."""
|
||||
return source in CLAIM_FROM and STAGE_ORDER[target] > STAGE_ORDER[source]
|
||||
|
||||
|
||||
def _set_assignee(text: str, name: str) -> str:
|
||||
"""First claim only — an existing assignee is never overwritten. The line
|
||||
joins the other header fields, right under Status."""
|
||||
if ASSIGNEE_RE.search(text):
|
||||
return text
|
||||
if STATUS_RE.search(text):
|
||||
return STATUS_RE.sub(lambda m: f"{m.group(0)}\n**Assignee:** {name}", text, count=1)
|
||||
return TITLE_RE.sub(lambda m: f"{m.group(0)}\n\n**Assignee:** {name}", text, count=1)
|
||||
|
||||
|
||||
def set_assignee(filename: str, stage: str, name: str) -> None:
|
||||
"""Write who holds a card where it stands, replacing whoever held it.
|
||||
|
||||
A move's claim never overwrites — the first claim sticks. This is the
|
||||
other door: a launch claiming an unheld card, or the deliberate
|
||||
takeover of someone else's. It commits like every other board edit, so
|
||||
the new owner travels to the other boards.
|
||||
"""
|
||||
path = config.TASKS / stage / filename
|
||||
text = path.read_text(encoding="utf-8")
|
||||
updated = (ASSIGNEE_RE.sub(f"**Assignee:** {name}", text, count=1)
|
||||
if ASSIGNEE_RE.search(text) else _set_assignee(text, name))
|
||||
if updated == text:
|
||||
return
|
||||
path.write_text(updated, encoding="utf-8")
|
||||
commit_edit(filename, stage, f"claimed by {name}")
|
||||
|
||||
|
||||
def _commit(filename: str, message: str, spec: list[str], failure: str) -> bool:
|
||||
"""One commit touching only this task file's paths.
|
||||
|
||||
Staging is scoped to those paths (`git add` then a pathspec commit), so
|
||||
a developer's unrelated staged changes are neither committed nor
|
||||
unstaged. Hooks are skipped: this is the board's bookkeeping, not a code
|
||||
change. Anything going wrong is narrated — what the commit records has
|
||||
already happened on disk, which is the source of truth.
|
||||
"""
|
||||
try:
|
||||
result = _git("add", "-A", "--", *spec)
|
||||
if result.returncode == 0:
|
||||
result = _git("commit", "--no-verify", "-m", message, "--", *spec, timeout=60)
|
||||
if result.returncode == 0:
|
||||
state.task_committed(filename) # sync (when on) publishes it
|
||||
return True
|
||||
lines = (result.stderr or result.stdout).strip().splitlines()
|
||||
detail = lines[-1] if lines else f"git exited {result.returncode}"
|
||||
except (subprocess.SubprocessError, OSError) as exc:
|
||||
detail = str(exc)
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"{failure}: {detail[:140]}"})
|
||||
return False
|
||||
|
||||
|
||||
def _commit_move(filename: str, target: str, src: Path, dst: Path, who: str,
|
||||
number: str | None) -> None:
|
||||
"""The move and the claim in one commit."""
|
||||
spec = [str(dst)]
|
||||
try:
|
||||
tracked = _git("ls-files", "--", str(src))
|
||||
if tracked.returncode == 0 and tracked.stdout.strip():
|
||||
spec.insert(0, str(src)) # git knew the old path: record its removal
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
pass
|
||||
_commit(filename, f"board: {number or filename[:-3]} → {target} ({who or 'board'})",
|
||||
spec, f"{filename} moved, but committing it failed")
|
||||
|
||||
|
||||
def commit_edit(filename: str, stage: str, what: str) -> bool:
|
||||
"""Commit a board-made edit to a card in place — the `**PR:**` line and
|
||||
anything else the board writes into a file it does not move.
|
||||
|
||||
Team mode's own bookkeeping, so it carries the `board: ` prefix sync's
|
||||
piggyback guard looks for, and it fires the same commit hook a move
|
||||
does. With `BOARD_COMMIT_MOVES` off it does nothing at all: the edit
|
||||
stays in the working tree for a human to commit, exactly as before.
|
||||
"""
|
||||
if not config.COMMIT_MOVES:
|
||||
return False
|
||||
path = config.TASKS / stage / filename
|
||||
number = NUMBER_RE.match(filename)
|
||||
return _commit(filename,
|
||||
f"board: {number.group(1) if number else filename[:-3]} "
|
||||
f"{what} ({actor_name() or 'board'})",
|
||||
[str(path)],
|
||||
f"{filename}: {what} recorded, but committing it failed")
|
||||
|
||||
|
||||
def move_task(filename: str, source: str, target: str, actor: str = "you") -> dict:
|
||||
"""Move a task file between stage directories and fix its Status line."""
|
||||
"""Move a task file between stage directories and fix its Status line.
|
||||
|
||||
With `BOARD_COMMIT_MOVES` on, the move also claims the card (an
|
||||
**Assignee:** line, this checkout's git name) or releases it when the
|
||||
card is walked back to backlog, and commits the whole change.
|
||||
"""
|
||||
if source not in config.STAGE_DIRS or target not in config.STAGE_DIRS:
|
||||
raise ValueError("unknown stage")
|
||||
if Path(filename).name != filename or not filename.endswith(".md"):
|
||||
@@ -163,8 +285,19 @@ def move_task(filename: str, source: str, target: str, actor: str = "you") -> di
|
||||
else: # no Status line to keep in step — insert one under the title
|
||||
text = TITLE_RE.sub(lambda m: f"{m.group(0)}\n\n**Status:** {label}", text, count=1)
|
||||
|
||||
name = ""
|
||||
if config.COMMIT_MOVES:
|
||||
name = actor_name()
|
||||
if target == "backlog": # walked all the way back: unclaimed again
|
||||
text = ASSIGNEE_LINE_RE.sub("", text, count=1)
|
||||
elif name and claims(source, target):
|
||||
text = _set_assignee(text, name)
|
||||
|
||||
state.expect_move(filename, target, actor)
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
src.write_text(text, encoding="utf-8")
|
||||
shutil.move(str(src), str(dst))
|
||||
return read_task(dst, target)
|
||||
task = read_task(dst, target)
|
||||
if config.COMMIT_MOVES:
|
||||
_commit_move(filename, target, src, dst, name, task["number"])
|
||||
return task
|
||||
|
||||
+58
-20
@@ -1,16 +1,25 @@
|
||||
"""Disk watcher: the directories are the source of truth, so poll and narrate.
|
||||
|
||||
Catches moves the HTTP API never saw — a file dragged by hand, an agent, or
|
||||
another tool — and attributes them via the expectations registered in state.
|
||||
Catches moves the HTTP API never saw — a file dragged by hand, an agent,
|
||||
another tool, or a pull from origin/main — and attributes them via the
|
||||
expectations registered in state and the arrivals registered by sync.
|
||||
|
||||
Attribution is also the trigger gate. **State syncs; reactions don't**: a
|
||||
move a pull applied is somebody else's action reaching this replica, so it
|
||||
renders and narrates and nothing else — the side effects (opening a PR)
|
||||
belong to the board whose user made the move. Every future automation hung
|
||||
off a stage transition asks the same question here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import agents
|
||||
import config
|
||||
import github
|
||||
import state
|
||||
import sync
|
||||
|
||||
|
||||
def _board_sig() -> dict[str, set[str]]:
|
||||
@@ -21,6 +30,52 @@ def _board_sig() -> dict[str, set[str]]:
|
||||
return sig
|
||||
|
||||
|
||||
def _actor(filename: str, stage: str) -> tuple[str, bool]:
|
||||
"""Who did this, and whether it happened somewhere else.
|
||||
|
||||
A move this board made is claimed from the expectations; one a pull
|
||||
brought carries its commit author's name and is *remote* — this board
|
||||
is only rendering it; a plain mv on this disk is nobody in particular,
|
||||
but it is still this board's own disk, so it acts.
|
||||
"""
|
||||
actor = state.claim_expected(filename, stage)
|
||||
if actor == "disk":
|
||||
who = sync.arrived_actor(filename)
|
||||
if who:
|
||||
return who, True
|
||||
return actor, False
|
||||
|
||||
|
||||
def narrate(prev: dict[str, set[str]], cur: dict[str, set[str]]) -> None:
|
||||
"""Two board signatures → the events between them."""
|
||||
prev_loc = {f: s for s, files in prev.items() for f in files}
|
||||
cur_loc = {f: s for s, files in cur.items() for f in files}
|
||||
for f, stage in sorted(cur_loc.items()):
|
||||
if f in prev_loc and prev_loc[f] != stage:
|
||||
actor, remote = _actor(f, stage)
|
||||
state.record_board_event({
|
||||
"kind": "move", "file": f, "from": prev_loc[f], "to": stage,
|
||||
"actor": actor, "remote": remote,
|
||||
"summary": f"{f} moved {prev_loc[f]} → {stage} ({actor})",
|
||||
})
|
||||
# a failed run is worn by the card in the stage it died in —
|
||||
# wherever the card goes next, it arrives without the alarm
|
||||
agents.forget_failure(f)
|
||||
if stage == "review" and not remote:
|
||||
# a card entering review with a work branch gets a PR — on
|
||||
# the actor's board only, or the team gets one PR attempt
|
||||
# per replica
|
||||
github.open_pr_async(f)
|
||||
elif f not in prev_loc:
|
||||
actor, remote = _actor(f, stage)
|
||||
state.record_board_event({
|
||||
"kind": "new", "file": f, "to": stage, "actor": actor,
|
||||
"remote": remote,
|
||||
"summary": f"{f} appeared in {stage}/"
|
||||
+ (f" ({actor})" if actor != "disk" else ""),
|
||||
})
|
||||
|
||||
|
||||
def watcher(interval: float | None = None) -> None:
|
||||
interval = config.WATCH_INTERVAL if interval is None else interval
|
||||
prev = _board_sig()
|
||||
@@ -32,23 +87,6 @@ def watcher(interval: float | None = None) -> None:
|
||||
continue
|
||||
if cur == prev:
|
||||
continue
|
||||
prev_loc = {f: s for s, files in prev.items() for f in files}
|
||||
cur_loc = {f: s for s, files in cur.items() for f in files}
|
||||
for f, stage in sorted(cur_loc.items()):
|
||||
if f in prev_loc and prev_loc[f] != stage:
|
||||
actor = state.claim_expected(f, stage)
|
||||
state.record_board_event({
|
||||
"kind": "move", "file": f, "from": prev_loc[f], "to": stage,
|
||||
"actor": actor,
|
||||
"summary": f"{f} moved {prev_loc[f]} → {stage} ({actor})",
|
||||
})
|
||||
if stage == "review":
|
||||
# a card entering review with a work branch gets a PR
|
||||
github.open_pr_async(f)
|
||||
elif f not in prev_loc:
|
||||
state.record_board_event({
|
||||
"kind": "new", "file": f, "to": stage, "actor": "disk",
|
||||
"summary": f"{f} appeared in {stage}/",
|
||||
})
|
||||
narrate(prev, cur)
|
||||
prev = cur
|
||||
state.broadcast({"type": "board"})
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# 25 — Cards order within a lane: a Rank line the board writes on drop
|
||||
|
||||
**Status:** Backlog
|
||||
**Priority:** Medium — a backlog that cannot say "this first" makes priority live in someone's head
|
||||
**Type:** Feature
|
||||
|
||||
Lanes render in card-number order — creation order — so the backlog
|
||||
cannot express "21 before 18" without a conversation. Add manual
|
||||
ordering: drag a card up or down within its lane and the board records
|
||||
the position in the card itself, because the directory-is-truth law
|
||||
and the multi-user sync arc both demand that order live in the files
|
||||
and merge one card at a time.
|
||||
|
||||
## Context
|
||||
|
||||
- Lane order today: filename sort, i.e. the immortal `NN-` creation
|
||||
number. The number is identity, not priority ("stays with the file
|
||||
for life") — reusing it for order is ruled out by the template's
|
||||
own contract, and index-file designs concentrate every reorder into
|
||||
one file, which under task 19's sync turns concurrent reorders into
|
||||
guaranteed conflicts. A header field in the card is the only shape
|
||||
that inherits everything the Status line already gets: watcher
|
||||
narration, `board:` commits, per-file merges.
|
||||
- `taskfiles.py` already rewrites the Status line on move — the Rank
|
||||
write is the same kind of surgical header edit.
|
||||
- The **Priority** field stays what it is: a label with a
|
||||
justification clause, not a sort key. Rank is where the human's
|
||||
actual sequence lives; the two answer different questions.
|
||||
|
||||
**Affected areas:** `taskfiles.py` (the Rank read/write),
|
||||
`board.html` (in-lane drag targets, sort), `watch.py` narration of
|
||||
rank changes, task-template comment (one line documenting the field
|
||||
as board-managed).
|
||||
|
||||
## What to build
|
||||
|
||||
- `**Rank:** <integer>` in the card header, optional. Lane sort:
|
||||
rank ascending, then card number; unranked cards (every existing
|
||||
card) sort after ranked ones by number — today's boards render
|
||||
identically until someone reorders.
|
||||
- In-lane drag: dropping between two cards writes the midpoint of
|
||||
their ranks to the ONE dragged card (sparse ranks — first ranks in
|
||||
a lane are 10, 20, 30…). When no integer gap remains between
|
||||
neighbours, renumber that lane's ranked cards in one `board:`
|
||||
commit and then place the card — rare, mechanical, visible in
|
||||
history.
|
||||
- Stage moves leave Rank untouched (it goes stale-but-harmless in the
|
||||
new lane; the next reorder there re-ranks it). Nothing else is
|
||||
rewritten at move time.
|
||||
- The ticker narrates reorders like moves ("18 ranked above 21 in
|
||||
backlog (you)"); under task 18's gate the write commits like any
|
||||
board edit.
|
||||
|
||||
**Out of scope** — tempting neighbours left alone:
|
||||
|
||||
- Auto-ordering by the Priority label, due dates, or any computed
|
||||
sort — rank is a human's hand, nothing else's.
|
||||
- Cross-lane global priority; rank means nothing outside its lane.
|
||||
- Reordering from the plain-folder view; `ls` keeps showing number
|
||||
order and that limitation is documented, not fixed.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Given an unranked lane, when a card is dragged above another,
|
||||
then exactly one file gains a Rank line, the lane renders in
|
||||
the new order, and a `board:` commit (when gated on) carries
|
||||
exactly that file.
|
||||
- [ ] Given repeated bisection until the gap closes, then the board
|
||||
renumbers that lane in one commit and ordering behaviour is
|
||||
seamless to the user.
|
||||
- [ ] A board with no Rank lines anywhere renders byte-identically to
|
||||
today; a card moved to a new stage keeps its Rank line and
|
||||
causes no misbehaviour there.
|
||||
- [ ] Edge case — two synced boards reorder different cards in the
|
||||
same lane concurrently: both commits merge (different files),
|
||||
and both boards converge to the same order.
|
||||
|
||||
## Open questions
|
||||
|
||||
- None.
|
||||
|
||||
## Notes
|
||||
|
||||
Shape chosen over an index-file per lane and over filename prefixes —
|
||||
reasoning preserved from the 2026-07-30 design discussion: the rank
|
||||
must ride in the card so every ordering write is a single-file merge,
|
||||
which is what task 19's git-as-lock-server model needs. Rank is
|
||||
lane-local and deliberately allowed to go stale across moves; the
|
||||
alternative (rewrite on every move) buys nothing but churn.
|
||||
|
||||
**Risks**
|
||||
|
||||
- Midpoint ranking with integers exhausts gaps after ~log2 insertions
|
||||
between the same pair — the renumber path is not an edge case to
|
||||
skimp on; test it deliberately, including its interaction with the
|
||||
single-file-per-commit expectation (the renumber is the one
|
||||
sanctioned multi-file board commit).
|
||||
- Humans hand-editing Rank into nonsense (duplicates, negatives) must
|
||||
degrade to stable sort by number, never a broken lane.
|
||||
@@ -0,0 +1,129 @@
|
||||
# 26 — Forge adapters: core stops speaking GitHub
|
||||
|
||||
**Status:** Backlog
|
||||
**Priority:** Medium — GitHub-only is fine today and wrong as a law; the seam should exist before a second forge is urgent
|
||||
**Type:** Refactor
|
||||
|
||||
The three-layer law says core knows tasks, worktrees, PRs and events —
|
||||
but "PRs" today means GitHub specifically: `gh` invocations, Copilot,
|
||||
checks API, github.com URLs. Do for the forge what adapters did for
|
||||
coding agents: core deals in abstract operations — open a change
|
||||
proposal, read its combined verdict, request an external review, post
|
||||
a review, merge — and a forge adapter translates them. GitHub is the
|
||||
first and, initially, only forge; the card's product is the seam, not
|
||||
a second forge.
|
||||
|
||||
## Context
|
||||
|
||||
The coupling is wider than `github.py` — grep for `gh |github.com`
|
||||
lands in eight files across all three layers:
|
||||
|
||||
- `manager/core/github.py` — the obvious one: open/poll/merge via
|
||||
`gh`, Copilot requests via GitHub's API, checks polling, the
|
||||
ahead-guard. Becomes the GitHub forge's implementation, behind a
|
||||
neutral interface.
|
||||
- `manager/core/httpd.py` — serves forge state to the UI.
|
||||
- `manager/core/prompts/act-pr.md`, `review-pr.md` — instruct agents
|
||||
to run `gh pr view`, `gh api …` *verbatim*. The forge leaks into
|
||||
what agents are told.
|
||||
- `manager/core/adapters/claude/hook_settings.py`,
|
||||
`opencode/permission_config.py` — allowlist `gh pr …` prefixes per
|
||||
intent. The forge leaks into what agents are *allowed*.
|
||||
- `manager/core/adapters/README.md` — documents those grants.
|
||||
|
||||
That last pair is the important discovery: a forge adapter is not just
|
||||
API calls — it must also supply the **agent-facing verbs**: the
|
||||
command vocabulary the prompts teach and the permission stances allow.
|
||||
Otherwise a GitLab board launches agents instructed to run `gh`.
|
||||
|
||||
Naming that becomes neutral: "change proposal" internally (PR/MR is
|
||||
forge vocabulary); the card chip's label comes from the forge
|
||||
("PR ↗" / "MR ↗"); "⚑ copilot" becomes **request external review**,
|
||||
with the forge saying what that means there (GitHub: Copilot; GitLab:
|
||||
whatever exists; none: absent). CI/checks likewise: the forge folds
|
||||
its native signals into the one normalized verdict core already
|
||||
computes (any-changes-requested-or-red beats any-approval — that fold
|
||||
stays in core; only the fetching moves).
|
||||
|
||||
**Affected areas:** `github.py` (split into interface + first forge),
|
||||
`httpd.py`, both prompts, both agent adapters' permission generation,
|
||||
`adapters/README.md`, `.env.example` (`BOARD_FORGE`), card-face chip
|
||||
labels in `board.html`.
|
||||
|
||||
## What to build
|
||||
|
||||
- A forge contract — `core/forges/<name>/`, selected by `BOARD_FORGE`
|
||||
(default `github`), overridable from `local/forges/` like every
|
||||
other adapter — covering: detect (am I applicable to this remote),
|
||||
open-change (branch, title, body → url), state (url → normalized
|
||||
{reviews, checks, mergeable}), request-external-review, post-review
|
||||
(verdict + body), merge-change, and **agent-verbs** (per launch
|
||||
intent: the command prefixes to allow and a prompt fragment
|
||||
teaching them). Python-module contract is fine here — unlike agent
|
||||
vendors, forges are called by core many times a minute; document
|
||||
why this differs from the exec-based agent-adapter contract.
|
||||
- Extract today's behaviour into `forges/github/` unchanged in
|
||||
outcome: every existing flow (open on review-entry with the
|
||||
ahead-guard, 60s polling, Copilot, act-on-PR, conflict chip, merge
|
||||
paths) byte-identical for GitHub users.
|
||||
- A `none` forge — the honest degenerate: no remote or an
|
||||
unrecognized one → chips absent, review flows quiet, agents get no
|
||||
forge verbs. This is the second implementation that proves the
|
||||
seam, and it replaces today's scattered "gh missing / no remote"
|
||||
special cases with one place.
|
||||
- Prompts templated: the forge's fragment fills a placeholder in
|
||||
act-pr/review-pr; permission stances take the forge's prefixes at
|
||||
launch time alongside `AGENT_COMMANDS`.
|
||||
|
||||
**Out of scope** — tempting neighbours left alone:
|
||||
|
||||
- Actually writing a GitLab/Gitea/Forgejo forge — the contract must
|
||||
make it a contribution, not a rewrite, but none ships here.
|
||||
- Releases and update.sh: bench's own distribution rides GitHub
|
||||
Releases by explicit choice (task 15); a forge-neutral distribution
|
||||
channel is its own future decision, not this card's.
|
||||
- Self-hosted forge auth handling beyond what the forge's own CLI
|
||||
provides.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] `grep -rn "gh \|github" manager/core` hits nothing outside
|
||||
`core/forges/github/` (docs' illustrative mentions excepted).
|
||||
- [ ] A GitHub-remote board behaves byte-identically: same chips,
|
||||
same ticker lines, same task-file `**PR:**` writes, same
|
||||
permission grants reaching launches (stub-binary tests prove
|
||||
the flags unchanged).
|
||||
- [ ] With no remote (or `BOARD_FORGE=none`), review-stage cards show
|
||||
no forge chips, launches carry no forge verbs, and nothing
|
||||
errors — the degenerate forge passes the same test suite shape
|
||||
the github one does.
|
||||
- [ ] An agent launched under the github forge still finds `gh pr
|
||||
view` both instructed and allowed; under `none`, the prompt
|
||||
fragment is absent and so are the grants.
|
||||
- [ ] Edge case — remote is GitHub but `gh` is missing: the github
|
||||
forge's detect says so once, loudly, in the ticker; core does
|
||||
not special-case it anywhere.
|
||||
|
||||
## Open questions
|
||||
|
||||
- None.
|
||||
|
||||
## Notes
|
||||
|
||||
Requested 2026-07-30: "in the same way we adapt to different coding
|
||||
agents I'd like to adapt to different git repos." The grep inventory
|
||||
is the argument for doing it soon: GitHub vocabulary has already
|
||||
leaked into prompts and permission stances (both added this week), so
|
||||
the coupling is *growing* — every forge-flavoured feature landed
|
||||
before the seam exists makes the eventual cut wider.
|
||||
|
||||
**Risks**
|
||||
|
||||
- The agent-verbs mechanism (forge fragment into prompts + grants)
|
||||
touches the same templates and stances four other in-flight
|
||||
concerns touch — coordinate with anything open against
|
||||
prompts/permissions to avoid a rebase pileup like PR #11's.
|
||||
- Normalized "state" must not flatten forge differences that matter
|
||||
(GitHub's mergeable UNKNOWN lag already needed special tolerance in
|
||||
task 16) — the contract should let a forge say "unknown, ask
|
||||
later", not force a boolean.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# 04 — Make the activity log's resize grip actually resize
|
||||
|
||||
**Status:** Review
|
||||
**Status:** Done
|
||||
**PR:** https://github.com/12vectors/bench/pull/14
|
||||
**Priority:** Medium — a visible, advertised control ("drag to resize") that silently does nothing
|
||||
**Type:** Bug
|
||||
@@ -0,0 +1,130 @@
|
||||
# 11 — A failed agent run must leave a visible trace on the card
|
||||
|
||||
**Status:** Done
|
||||
**PR:** https://github.com/12vectors/bench/pull/22
|
||||
**Assignee:** istos
|
||||
**Priority:** High — three agents died today and the board showed nothing a human would notice
|
||||
**Type:** Feature
|
||||
|
||||
When a headless agent exits non-zero, the board's entire feedback is
|
||||
one ticker line ("‹name› exited on ‹file› rc=1 — see its log") that
|
||||
scrolls away in seconds. The card keeps sitting in in-progress looking
|
||||
exactly as it did before launch; the log's contents (today: an API 500)
|
||||
never reach the UI. Three launches (06, 07, 08) died within a minute
|
||||
during an API outage and the owner's experience was "they just stopped,
|
||||
no feedback". Failure must be a state the card wears, not an event that
|
||||
evaporates.
|
||||
|
||||
## Context
|
||||
|
||||
- `manager/core/agents.py:331-334` — the failure arm of `_reap_agent`:
|
||||
compose one summary line, record one ticker event, done. Compare the
|
||||
richer arms above it: decline moves the card back with a reason;
|
||||
clean-exit-no-commits (card 05's guard) holds the card with a loud
|
||||
message. Failure is the least-handled outcome despite being the most
|
||||
urgent one.
|
||||
- The design system already has the vocabulary: `--alarm` terracotta
|
||||
means "blocked, failed or HIGH" (CLAUDE.md, Live view), and cards
|
||||
already wear state borders/pills for PR verdicts (approved / changes
|
||||
asked). There is simply no "last run failed" state.
|
||||
- The log exists on disk (`local/state/agent/logs/…`) and its tail is
|
||||
usually the whole story ("API Error: 500 …"), but nothing in the UI
|
||||
displays it — "see its log" points at a file path the ticker doesn't
|
||||
even name.
|
||||
- Affected areas: `agents.py` (record the outcome), `state.py`/API
|
||||
payload (expose it), `board.html` (wear it).
|
||||
|
||||
## What to build
|
||||
|
||||
- Record the outcome on the agent's session record: exit code, ended-at,
|
||||
and the cleaned last few lines of the log as the failure excerpt.
|
||||
- The card wears it: a card in in-progress whose most recent run failed
|
||||
gets the `--alarm` treatment — border plus a `run failed` pill in the
|
||||
status slot — until the next launch replaces the state or the card
|
||||
moves stage. Hovering (or the card sheet) shows the excerpt, so "API
|
||||
Error: 500" is one hover away instead of buried in
|
||||
`local/state/…/logs`.
|
||||
- The toast on failure, not just a ticker line: failures are rare and
|
||||
actionable, exactly what toasts are for.
|
||||
- Same treatment for all headless kinds — work, review, act-pr,
|
||||
relevance — including launches that die before the agent speaks
|
||||
(today's MultiEdit flag error produced a 91-byte log; the excerpt
|
||||
handles it fine).
|
||||
|
||||
- Clear the way for relaunch: a failed run with zero commits leaves its
|
||||
worktree behind, and start-work refuses while it exists — so today a
|
||||
failed card needs hand `git worktree remove` before ▸ start work
|
||||
functions again. Reuse `_discard_untouched_worktree` (already called
|
||||
on declines) in the failure arm: nothing of value is lost (zero
|
||||
commits), and relaunch becomes one click. A failed run *with* commits
|
||||
keeps its worktree, same as declines do.
|
||||
|
||||
Out of scope: retries/auto-relaunch (a failed run is a human decision
|
||||
point, and API-outage storms would make auto-retry a thundering herd);
|
||||
distinguishing failure *causes* beyond showing the excerpt.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Kill a launch artificially (stub binary exiting 1 with a line of
|
||||
output): the card immediately shows the alarm border + `run
|
||||
failed` pill, a toast fires, and the excerpt is readable from the
|
||||
card without opening files on disk.
|
||||
- [ ] The state clears on relaunch and on stage move, and does not leak
|
||||
onto other cards or survive into review/.
|
||||
- [ ] A failed review/relevance run (no worktree, card not in
|
||||
in-progress) surfaces the same way on its card.
|
||||
- [ ] Ticker line still appears (the permanent record in the event log
|
||||
is unchanged — this card adds surfaces, it does not move them).
|
||||
|
||||
## Open questions
|
||||
|
||||
- None.
|
||||
|
||||
## Notes
|
||||
|
||||
Born from the 2026-07-29 API outage: agents on 06, 07 and 08 died with
|
||||
500/529 within a minute, review-pr on 02 died on the MultiEdit fossil
|
||||
(card 10), and every one of them looked identical to "nothing
|
||||
happening". The empty-branch guard from card 05 fixed the silent
|
||||
*success* path; this is its sibling for the failure path.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Work report — 2026-07-30 13:10 (Milo)
|
||||
|
||||
nded` on every run and, when a run exits non-zero and was not held, calls the new `_record_failure`. That puts a `failure` dict on the run's record (exit code, ended-at, the cleaned tail of the log as the excerpt, the stage the card was in, the log path) and broadcasts a toast. Because it hangs off `_finish`, all four headless kinds get it — work, act-pr, PR review, relevance check. `_agent_public` exposes `failure` and `ended`, so `/api/state` carries them. The failure arm of `_reap_agent` now also calls `_discard_untouched_worktree`: a dead run with zero commits leaves no worktree behind, so **▸ start work** functions without a hand `git worktree remove`; a dead run *with* commits keeps its worktree. All four ticker lines now name what the log ended on instead of saying "see its log".
|
||||
- `manager/core/watch.py` — the move branch of `narrate` calls `agents.forget_failure(file)`, so a card that changes stage arrives without the alarm. (`agents` sits left of `watch` in the module map, so the import direction holds.)
|
||||
- `manager/core/board.html` — `lastRunOn` / `failedRun` / `whyFailed`; a `.card.run-failed` alarm border, a `run failed` pill carrying the excerpt as its tooltip, and an alarm well on the card showing `rc=N · <the line it died on>`. The card sheet shows the whole excerpt in a bounded `<pre>` plus the log path. The failure outranks a PR verdict, and is only worn on the stage the run died in.
|
||||
- `AGENTS.md` — a new "A run that died" subsection documents the state, its scoping and the worktree cleanup; the stale `.agent/logs/` path in the sentence above it is corrected to `local/state/agent/logs/`.
|
||||
- `tests/test_failed_run_visible.py` — real launches through a real stub adapter (scripts that print a line and exit 1, that commit then exit 1, and that exit 1 silently), covering the record, the toast, the surviving ticker line, the public payload, the worktree cleanup and the successful relaunch after it, a dead relevance check, excerpt/headline edge cases, `forget_failure` scoping, and source-level invariants for board.html.
|
||||
|
||||
**What a reviewer should look at first**
|
||||
|
||||
- `manager/core/agents.py:452` (`_finish`) — recording the failure for every kind in one place is the design decision the rest follows from.
|
||||
- `manager/core/board.html:720` (`failedRun`) — the two-part scoping (most recent run, and only in the stage it failed in) is what keeps the alarm off other cards and out of review/.
|
||||
|
||||
**One thing to know:** I verified the frontend logic by extracting `lastRunOn` / `failedRun` / `whyFailed` and running them under `node` against fabricated state (failure shows on its own card in its stage, disappears when the card is in review/, does not appear on another card, is superseded by a newer run, and the headline is the log's last line), plus the source-level invariants in the test file. I did not open the board in a browser to look at the rendered card, so the visual — border, pill and well together — is the one thing worth eyeballing before merge.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## PR review — 2026-07-30 14:12 (Cleo)
|
||||
|
||||
PR REVIEW: APPROVE
|
||||
|
||||
The work is complete, committed on `task/11-failed-agent-runs-must-be-visible`, mergeable (CLEAN), and CI-green on Python 3.11 and 3.13 — I could not run the suite locally (this sandbox blocks directory creation and worktrees), but the new `tests/test_failed_run_visible.py` passes on CI. A failed agent run now leaves a durable trace the card wears, and every acceptance criterion is met.
|
||||
|
||||
**What I checked and found good**
|
||||
- **Recording (`agents.py`).** `_finish` stamps `ended` and, on a non-zero non-stopped exit, calls `_record_failure`, which puts `{rc, ended, excerpt, stage, log, mode}` on the run record and fires one error toast. Because it hangs off `_finish`, all four headless kinds inherit it — work, act-pr, PR review, relevance. `_agent_public` exposes `failure`/`ended`, so `/api/state` carries it.
|
||||
- **The excerpt** (`_failure_excerpt` → `_clean_log`) is the cleaned log tail, handles a launch that died before speaking ("no output …"), and survives the 91-byte MultiEdit-fossil case.
|
||||
- **Card surfaces (`board.html`).** Alarm border, `run failed` pill with the excerpt as tooltip, an alarm well showing `rc=N · <last line>`, and the whole excerpt + log path in the card sheet.
|
||||
- **Scoping is double-guarded** — the one thing that keeps the alarm off other cards and out of review/: `failedRun` gates on the *newest* run (`lastRunOn` is a max-by-`started`) AND `failure.stage === task.stage`; separately `watch.narrate` calls `agents.forget_failure` on any stage move. Relaunch clears it because the newer running record wins.
|
||||
- **Worktree cleanup is safely scoped.** `_discard_untouched_worktree` is added only to `_reap_agent` and no-ops unless `base` is set with zero commits, so act-pr's persistent worktree/branch (base=None) is never deleted. A failed work run with commits keeps its worktree.
|
||||
- **Layering/DoD.** `watch`→`agents` respects the module-map direction with no import cycle; the ticker (permanent record) is preserved on every reaper; AGENTS.md documents the state and corrects the stale `.agent/logs/` path to `local/state/agent/logs/` (confirmed: `STATE=local/state`, `AGENT_DIR=state/agent`).
|
||||
|
||||
**To know (no action required to merge)**
|
||||
- The rendered card — border + pill + well together — was verified via source-level invariants and `node`, not eyeballed in a browser. Acceptance #1 is visual; a 10-second glance at a real failed card is worth it but not a blocker.
|
||||
|
||||
**One process note**
|
||||
- The formal GitHub approval could not be posted because the PR's author identity is the same `istos` this board acts as; the verdict is on the PR as [a comment](https://github.com/12vectors/bench/pull/22#issuecomment-5130649491). A human with a distinct account can click Approve if a green check is wanted before merge.
|
||||
@@ -0,0 +1,121 @@
|
||||
# 18 — Cards are claimed on move: assignee written, board commits the change
|
||||
|
||||
**Status:** Done
|
||||
**PR:** https://github.com/12vectors/bench/pull/15
|
||||
**Priority:** High — the ownership primitive every other multi-user piece builds on
|
||||
**Type:** Feature
|
||||
|
||||
Cards have no owner: the face shows "nobody yet" and nothing records
|
||||
who picked work up. For one person that's cosmetic; for a team it's
|
||||
the missing primitive. Moving a card out of backlog claims it — the
|
||||
board writes the assignee into the file and commits the move — so
|
||||
ownership travels with the card to every clone, and attribution stops
|
||||
being an in-memory courtesy.
|
||||
|
||||
## Context
|
||||
|
||||
- Task files carry Status/Priority/Type but no assignee; the card
|
||||
face's "nobody yet" slot is the UI waiting for this value.
|
||||
- Board moves today rename the file and rewrite Status
|
||||
(`taskfiles.py`) but git never hears about it — commits of tasks/
|
||||
happen by hand, so the shared history lags the board by hours.
|
||||
- Identity: `git config user.name` — already present on every machine
|
||||
that can commit, already what blame/history show, no new concept.
|
||||
- The 2s disk watcher narrates hand-moves; nothing in this card
|
||||
changes that — it gains attribution in task 19 when remote moves
|
||||
arrive via sync.
|
||||
|
||||
**Affected areas:** `taskfiles.py` (the assignee line, the commit),
|
||||
`board.html` (render assignee on the face), AGENTS.md (the convention).
|
||||
|
||||
## What to build
|
||||
|
||||
- Moving a card backlog → to-do or to-do → in-progress through the
|
||||
board writes `**Assignee:** <git user.name>` into the header (first
|
||||
claim only — an existing assignee is preserved, not overwritten).
|
||||
Walking a card back to backlog clears it.
|
||||
- Board-made task changes commit themselves: the move + claim in ONE
|
||||
commit touching only that task file, message prefixed `board: ` with
|
||||
the actor and transition (`board: 18 → in-progress (ronald)`).
|
||||
Commit only — pushing is task 19's job. Gate the auto-commit behind
|
||||
`BOARD_COMMIT_MOVES` (default off) so single-player behaviour is
|
||||
unchanged until opted in.
|
||||
- The card face replaces "nobody yet" with the assignee; done/archive
|
||||
keep it as history.
|
||||
- AGENTS.md documents the convention: claiming is moving; the assignee
|
||||
launches agents on the card; hand-moves should update the line too.
|
||||
|
||||
**Out of scope** — tempting neighbours left alone:
|
||||
|
||||
- Pushing, pulling, or any cross-machine behaviour (task 19).
|
||||
- Enforcing assignee-only launches (task 20).
|
||||
- Multiple assignees, @-mentions, or any identity beyond git's.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Given an unclaimed backlog card, when it is dragged to to-do on
|
||||
a board with `BOARD_COMMIT_MOVES=1`, then the file gains
|
||||
`**Assignee:**` with the mover's git name and exactly one
|
||||
`board: `-prefixed commit exists touching exactly that file.
|
||||
- [ ] Given an already-assigned card, when someone else moves it
|
||||
forward, then the assignee is unchanged (first claim sticks).
|
||||
- [ ] Given a claimed card walked back to backlog, then the assignee
|
||||
line is removed.
|
||||
- [ ] With the gate off (default), moves behave byte-identically to
|
||||
today: no commit, no assignee write unless configured.
|
||||
- [ ] Edge case — a dirty index: the board's commit stages only the
|
||||
task file's paths; a developer's unrelated staged changes are
|
||||
neither committed nor unstaged.
|
||||
|
||||
## Open questions
|
||||
|
||||
- None.
|
||||
|
||||
## Notes
|
||||
|
||||
First card of the multi-user arc (18 claim → 19 sync → 20 etiquette),
|
||||
from the 2026-07-30 design discussion: origin/main becomes the truth
|
||||
and every checkout a replica, with git as the lock server. The claim
|
||||
must be atomic with the move — same commit — because it is the
|
||||
optimistic lock task 19's push races resolve.
|
||||
|
||||
**Risks**
|
||||
|
||||
- Hand-moves (plain `mv`) bypass the claim; the watcher still narrates
|
||||
them but no assignee is written. Acceptable — AGENTS.md says to
|
||||
update the line — but the gap should be stated, not hidden.
|
||||
- `user.name` collisions ("ronald" on two machines) merge identities;
|
||||
fine for teams that also share a git history, worth one doc line.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Work report — 2026-07-30 09:06 (Otto)
|
||||
|
||||
onfig user.name` into the header (first claim only — an existing assignee is never overwritten), and clears the line when a card is walked back to `backlog/`. The same move then commits itself through `_commit_move`: one commit containing the move and the claim, staged by pathspec (`git add -A -- <old> <new>`, then a pathspec `git commit --no-verify`), messaged `board: 18 → to-do (ronald)`. A commit that fails narrates in the ticker rather than raising — the card has already moved, and disk is the source of truth.
|
||||
- **`manager/core/config.py`** — `BOARD_COMMIT_MOVES` (default off) plus a small `flag()` helper for boolean settings. The gate governs both the assignee write and the commit, so with it off a move is byte-identical to before.
|
||||
- **`manager/core/board.html`** — the card face shows the assignee instead of `nobody yet` in every stage (on `done/` cards it reads as history); the who row is now escaped, since its content comes from a file.
|
||||
- **`AGENTS.md` / `manager/core/.env.example`** — a "Claiming a card" section (claiming is moving, the assignee launches agents, hand-moves bypass the claim, git identities collide like git's), the `**Assignee:**` field in the task-file-format section, and the setting documented with its default.
|
||||
- **`tests/test_claim_on_move.py`** — 18 new tests. Each runs `move_task` against a throwaway git repo, so the commit behaviour is checked against real git: claim written and exactly one commit touching exactly the two task paths, first claim sticks when a second mover advances the card, walk-back clears the line, gate-off produces the old bytes and no commit, a dirty index survives (staged change still staged, absent from the commit), and a failing commit still moves the card and says so.
|
||||
|
||||
## What a reviewer should look at first
|
||||
|
||||
1. **Unpushed `main` when the gate is on.** `github.complete_task` merges and pushes `main`, *then* calls `move_task`, so the resulting `board: … → done` commit sits unpushed on local `main`. The next card entering `review/` will hit the existing "main is N commits ahead" guard and refuse to open a PR until you push. Pushing is explicitly task 19's scope, so I left it and documented the consequence in AGENTS.md — worth confirming that's the intended seam.
|
||||
2. **Which transitions claim.** The card names `backlog → to-do` and `to-do → in-progress`; I implemented it as "any forward move out of `backlog/` or `to-do/`", which also covers a two-column drag straight to `in-progress` or `review`. `taskfiles.claims()` is the whole rule and is tested directly.
|
||||
3. **`--no-verify` on the board's commit.** A project pre-commit hook could otherwise block or rewrite a card move; I judged the board's bookkeeping commit should skip hooks. It's a deliberate choice, noted in the code and in AGENTS.md.
|
||||
|
||||
One gap to know about, stated rather than hidden: `archive_task` / `unarchive_task` neither claim nor commit — archived cards simply keep whatever assignee they had. Only `move_task` participates in team mode.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## PR update — 2026-07-30 09:16 (Otto)
|
||||
|
||||
ADDRESSED: Reworded the "gate off" docs in AGENTS.md and manager/core/.env.example to say the gate governs only whether a *move* writes/clears the assignee — an assignee already in a file is still read and shown either way.
|
||||
|
||||
- **Copilot, `manager/core/.env.example:66` — "gate off … no assignee" is misleading.** Addressed. The line read "no assignee," implying assignees can't exist or display when the gate is off. In fact `read_task()` (`manager/core/taskfiles.py:55`) parses the `**Assignee:**` line unconditionally and the card face always renders it; only `move_task`'s write/clear/commit is gated (`manager/core/taskfiles.py:241`). Reworded to: gate off means moves neither write nor clear the assignee and never commit, but an assignee added by hand is still read and shown.
|
||||
- **Copilot, `AGENTS.md:432` — same "no assignee" implication in the "Claiming a card" section.** Addressed with the matching clarification: "The gate governs only whether a *move* writes the line — an **Assignee:** added to a file by hand is still read and shown on the card whether the gate is on or off."
|
||||
|
||||
Both are the same point raised against the two doc files; no code change was needed because the code already behaves as clarified. This is documentation-only, so no new tests; the full suite (`python3 -m unittest discover -s tests -v`, 171 tests) still passes, including `test_gate_off_leaves_a_claimed_card_claimed`, which already asserts an existing assignee survives a gate-off move. PR remains MERGEABLE.
|
||||
|
||||
- To know: no behavior changed and nothing else in the PR was touched. Nothing for the reader to run or decide beyond the normal merge call.
|
||||
@@ -0,0 +1,170 @@
|
||||
# 19 — Boards sync through origin/main: push on move, pull on a beat
|
||||
|
||||
**Status:** Done
|
||||
**PR:** https://github.com/12vectors/bench/pull/17
|
||||
**Assignee:** istos
|
||||
**Priority:** High — this is the multi-user feature; 18 without it is bookkeeping
|
||||
**Type:** Feature
|
||||
**Depends on:** 18 — the claim commit is what gets pushed, and its
|
||||
same-commit atomicity is what makes the races below resolve correctly
|
||||
|
||||
One machine's `tasks/` is the truth today; everyone else's is stale
|
||||
until someone remembers to push and pull. Make origin/main the truth
|
||||
and every board a converging replica: board-made moves push
|
||||
immediately, every board pulls on a short beat, remote moves appear in
|
||||
the ticker attributed to their author, and losing a same-card race is
|
||||
a toast, not a mystery.
|
||||
|
||||
## Context
|
||||
|
||||
- `watch.py` polls the stage directories every 2s — remote changes
|
||||
that arrive via pull are already noticed and narrated; today they
|
||||
would read "disk", this card upgrades them to the commit author.
|
||||
- Task 14 (landed) already points fresh agent branches at
|
||||
origin/main; this card gives the *board state* the same treatment.
|
||||
- The precondition that makes pulling safe is bench's own discipline:
|
||||
code work lives in worktrees and PRs, so the main checkout stays
|
||||
clean and fast-forwardable. Team mode assumes — and the docs must
|
||||
say — that local main advances only through the board and origin.
|
||||
- Push publishes every local-ahead commit, not just the board's —
|
||||
the piggyback hazard below is the sharpest edge in this card.
|
||||
|
||||
**Affected areas:** a new small `sync.py` (or a sibling thread beside
|
||||
`watch.py`), `config.py` (settings), `state.py`/ticker attribution,
|
||||
AGENTS.md (team-mode discipline).
|
||||
|
||||
## What to build
|
||||
|
||||
- **Gate**: `BOARD_SYNC=1` (default off; implies `BOARD_COMMIT_MOVES`).
|
||||
Off = today's behaviour exactly.
|
||||
- **Push, event-driven**: after each board-made task commit, push. On
|
||||
non-fast-forward: fetch, rebase the board commits, push again. If
|
||||
the rebase conflicts on a task file, the local move loses: revert
|
||||
it, re-read the remote version, and toast who took it
|
||||
("07 claimed by elena — your move was undone").
|
||||
- **Piggyback guard**: before any auto-push, every local-ahead commit
|
||||
on main must be `board: `-prefixed. Anything else → no push, one
|
||||
clear ticker warning naming the stray commit. Never publish a
|
||||
human's unpushed work as a side effect.
|
||||
- **Pull, periodic**: fetch + fast-forward-only merge every
|
||||
`BOARD_SYNC_INTERVAL` (default ~30s) and once at startup. Never
|
||||
pull into a non-clean tree or past a divergence — narrate and wait
|
||||
for a human instead. The watcher then narrates arrived moves with
|
||||
the commit author's name instead of "disk".
|
||||
- **Offline**: unreachable origin degrades to local-only silently
|
||||
sane — commits queue, a quiet ticker note says sync is behind,
|
||||
next successful fetch catches up. No errors every 30s.
|
||||
|
||||
**Out of scope** — tempting neighbours left alone:
|
||||
|
||||
- Reacting to synced state with side effects — replicas render only
|
||||
(task 20 owns the etiquette).
|
||||
- Syncing `local/state/` — liveness stays per-board by design.
|
||||
- Any transport other than git via origin; any branch other than main.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Given two clones with `BOARD_SYNC=1`, when A moves a card, then
|
||||
within one beat B's board shows the move and B's ticker
|
||||
attributes it to A's git name.
|
||||
- [ ] Given both move the same card in one window, then exactly one
|
||||
claim survives on origin, the loser's board reverts with the
|
||||
take-over toast, and both converge to the same file bytes.
|
||||
- [ ] Given a human's non-board commit sitting on local main, when
|
||||
the board would push, then it refuses with the named warning
|
||||
and the human's commit stays private.
|
||||
- [ ] Edge case — origin unreachable: boards keep working locally,
|
||||
one quiet ticker note, full convergence after connectivity
|
||||
returns; no error spam.
|
||||
- [ ] With the gate off, no fetch, no push, no behaviour change.
|
||||
|
||||
## Open questions
|
||||
|
||||
- None.
|
||||
|
||||
## Notes
|
||||
|
||||
Middle card of the multi-user arc (18 claim → 19 sync → 20 etiquette).
|
||||
Git is the lock server and main the linearizer: push races are the
|
||||
concurrency control, which is why the claim must ride in the move's
|
||||
own commit (18) and why replicas must not react (20).
|
||||
|
||||
**Risks**
|
||||
|
||||
- ff-only pulls plus the piggyback guard mean a divergent main stalls
|
||||
sync until a human resolves it — correct, but the stall must be
|
||||
loudly visible or two halves of a team quietly stop converging.
|
||||
- A 30s beat × N boards is N fetches/min against origin — fine for
|
||||
GitHub, worth a doc line for rate-limited setups.
|
||||
- Reverting a lost move while the user is mid-drag needs care in the
|
||||
UI — the board re-reads disk on every render, which should make
|
||||
this safe, but test the race deliberately.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Work report — 2026-07-30 09:48 (Juno)
|
||||
|
||||
sts, including 34 new ones. Nothing is pushed or merged.
|
||||
|
||||
### What changed
|
||||
|
||||
- **`manager/core/sync.py` (new)** — the whole feature, gated on `BOARD_SYNC`. Push is event-driven; pull is a beat (`BOARD_SYNC_INTERVAL`, default 30s, plus once at startup). A purely-behind board fast-forwards; a diverged one replays its own `board: ` commits on top and never merges past the divergence. A replay that conflicts on a task file drops the local move and toasts `07 claimed by elena — your move was undone`. The piggyback guard sits in front of every push *and* every replay. Unreachable origin, uncommitted changes, a checkout off main and a stray commit each narrate once and hold a header chip rather than repeating every beat.
|
||||
- **`manager/core/state.py`** — a `COMMIT_HOOKS` registry plus `task_committed()`; `taskfiles.py` fires it after a move's commit. This is how push stays event-driven without `taskfiles` importing anything to its right in the module map.
|
||||
- **`manager/core/watch.py`** — moves that arrived over origin are attributed to the commit's author instead of `disk`; the narration moved out of the polling loop into a testable `narrate()`.
|
||||
- **`manager/core/board.py` / `httpd.py` / `board.html`** — the beat and hook start only with the gate on; `sync.status()` rides the state payload; a header chip appears *only* when sync stops converging; the SSE stream gained a `toast` type so the server can address the person, not just the ticker.
|
||||
- **`AGENTS.md`, `README.md`, `manager/core/.env.example`** — a "Syncing boards" section covering the two disciplines team mode assumes (local main advances only through the board and origin; sync never merges), plus the fetch-rate note for metered remotes.
|
||||
|
||||
### What a reviewer should look at first
|
||||
|
||||
1. `manager/core/sync.py:168` (`_publish`) and `:262` (`_replay`) — the push guard and the race resolution are where this card either works or quietly loses someone's work.
|
||||
2. `tests/test_boards_sync.py` — every case runs two real clones of a real bare upstream against real git, including both race shapes (same target and different targets), the stale-drag race the card flagged as a risk, and the "gate off touches no network" case (proved with a remote that never answers).
|
||||
3. `manager/core/state.py:26` — the hook registry, if you disagree with that being the way to keep `taskfiles` left of `sync` in the module map.
|
||||
|
||||
### Two decisions I made that the card left open
|
||||
|
||||
- **A divergence is not always a stall.** The card says never to pull past a divergence. When *every* local-ahead commit is a board commit, the board rebases them instead of stalling — that replay *is* the race resolution, and the piggyback guard means a human's commit still stalls it loudly. A pure "always stall" reading would leave a board that queued moves while offline stuck until someone moved a card.
|
||||
- **"Non-clean tree" means modified tracked files.** Untracked files do not stall sync; a stray scratch file on someone's disk should not stop a team converging.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Relevance review — 2026-07-30 10:17 (Wren)
|
||||
|
||||
RELEVANCE REVIEW: Still relevant
|
||||
|
||||
The feature is fully built and committed on branch `task/19-boards-sync-through-origin-main` (two commits: `9d98e2c` sync, `ee8469d` docs), tested, but not merged to `main` and — notably — with **no PR opened yet**. The card is correctly parked in `review/`. Nothing in the task has gone stale; keep it as is and drive it through review/merge.
|
||||
|
||||
**What I verified**
|
||||
- `manager/core/sync.py` does **not** exist on `main`; it lives only on the task branch (448 lines), and its module docstring matches the work report point-for-point (event-driven push, beat pull, piggyback guard, divergence-replay, offline degradation). The branch diff also touches `state.py` (+14, the `task_committed` hook), `watch.py` (+59, author attribution), `board.py`/`httpd.py`/`board.html`, `config.py` (settings), `AGENTS.md`/`README.md`/`.env.example`, and adds `tests/test_boards_sync.py` (565 lines). This is the complete surface the card's "Affected areas" named.
|
||||
- Both dependencies the card rests on have landed on `main`: task 18 (`48a8d6a Merge branch 'task/18-cards-claimed-on-move'` — the claim-commit atomicity the races rely on) and task 14 (fresh branches off origin/main). No renamed modules or replaced approaches; `watch.py`'s 2s poller and the `board:`-prefixed commit convention the guard keys on are both still present exactly as the card assumes.
|
||||
- The two decisions Juno flagged as card-left-open (divergence-is-not-always-a-stall via board-commit replay; "non-clean tree" = modified *tracked* files) are design refinements within scope, not contradictions of the card.
|
||||
|
||||
**To know (one process oddity, not a task defect)**
|
||||
- The task file has **no `**PR:**` line** — the board normally opens a PR automatically when a card enters `review/`, yet none is recorded here. Worth a glance at why before merging (a common cause is local `main` being ahead of `origin/main`, which makes the board refuse the auto-PR; I couldn't confirm the ahead/behind count in this read-only session). This affects how the card gets reviewed, not whether the work is still wanted.
|
||||
|
||||
**To do**
|
||||
- Nothing on the task text. Proceed with review of the branch (open/repair the PR, run `tests/test_boards_sync.py`, then merge). Do not rewrite or drop the card.
|
||||
|
||||
**Recommendation:** Keep as is. The task is an accurate, current record of complete-but-unmerged work; it stays in `review/` until the branch is reviewed and merged.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## PR review — 2026-07-30 10:33 (Wren)
|
||||
|
||||
PR REVIEW: APPROVE
|
||||
|
||||
The work is complete, committed on `task/19-boards-sync-through-origin-main` (two commits), and not yet merged. Task 19 delivers exactly what the card asked: origin/main becomes the shared truth behind `BOARD_SYNC`, board moves push themselves, a beat pulls every 30s, remote moves are attributed to their author, and losing a same-card race is a toast that reverts cleanly. I reviewed the full diff, the surrounding modules, and all of `tests/test_boards_sync.py`. GitHub refused a formal approval because the PR is authored by this checkout's own git user (`istos`), so the verdict is posted as a PR comment instead.
|
||||
|
||||
**What I checked**
|
||||
- **Every acceptance criterion maps to code and a test.** Gate off touches no network (`test_the_gate_off_does_not_touch_the_network`, using an `ext::sleep 30` remote that would hang if touched); event-driven push via `state.task_committed` → `on_commit` off-thread; piggyback guard (`_stray`, sync.py:501) refuses any non-`board:`-prefixed local-ahead commit before both push and replay; race resolution (`_replay`, sync.py:606) drops the local move on a task-file conflict and toasts who took the card; offline degrades quietly and catches up. Both race shapes plus the mid-drag stale-drop race are tested and converge to identical file bytes.
|
||||
- **Layering (AGENTS.md).** Clean and deliberate: `sync.py` imports only modules to its left; `watch.py` (to its right) imports `sync`; `taskfiles` stays left of what reacts to it by firing a `state.COMMIT_HOOKS` registry rather than importing sync. Module map, README, `.env.example`, and the new "Syncing boards" section are all updated and accurate.
|
||||
|
||||
**To know (non-blocking, for whoever merges)**
|
||||
- **`_NOTES` is read without a lock** (sync.py:76 — `status()` iterates `.values()` on the httpd thread) while `_note`/`_clear` mutate it on the beat/push threads. Its sibling `ARRIVED` gets `_ARRIVED_LOCK`; `_NOTES` does not. A concurrent mutation during iteration can raise `RuntimeError: dictionary changed size during iteration`, failing one state payload. Rare and self-recovering, but the asymmetry reads as an oversight — a lock or a snapshot copy would close it.
|
||||
- **Merge-to-done window**: the "merge & clean up" flow creates a non-`board:` merge commit before pushing it; if the 30s beat fires in that gap it briefly flags `sync stalled` before the flow's own push lands and self-clears. Cosmetic, low-probability.
|
||||
|
||||
**To do**
|
||||
- Run `python3 -m pytest tests/test_boards_sync.py` before merging — the sandbox here blocked worktree/temp-dir creation so I could not execute the suite myself; it reads as correct and comprehensive, and the earlier relevance review reported it passing.
|
||||
- Optionally guard `_NOTES` with a lock (or copy before iterating) — a small follow-up, not a blocker.
|
||||
@@ -0,0 +1,153 @@
|
||||
# 20 — Replica etiquette: the actor's board acts, everyone else renders
|
||||
|
||||
**Status:** Done
|
||||
**PR:** https://github.com/12vectors/bench/pull/18
|
||||
**Assignee:** istos
|
||||
**Priority:** High — without it, task 19 turns every board action into N duplicate side effects
|
||||
**Type:** Feature
|
||||
**Depends on:** 18, 19 — etiquette for a fleet that exists only once
|
||||
claims and sync do
|
||||
|
||||
The board doesn't just render state, it reacts to it: a card entering
|
||||
review opens a PR; entering in-progress arms launches. With N synced
|
||||
boards watching one truth, a move must trigger its side effects on
|
||||
exactly one of them — the actor's — or the team gets N PR-open
|
||||
attempts, duplicate agents, and merge stampedes. And ownership must
|
||||
mean something: launching work on someone else's claimed card should
|
||||
be a deliberate act, not an accident.
|
||||
|
||||
## Context
|
||||
|
||||
- `github.py` opens a PR when a card enters review; today that fires
|
||||
on the board that observed the move. Under task 19, every board
|
||||
observes every move — the trigger must distinguish "I did this"
|
||||
from "this arrived".
|
||||
- The `**PR:**` line already gates re-opening — the idempotency
|
||||
pattern to generalize, as the backstop behind actor-only triggers.
|
||||
- One-agent-per-task lives in board memory (`state.py` registries) —
|
||||
invisible to other machines. The card file's assignee (task 18) is
|
||||
the cross-machine version.
|
||||
- Merge & clean-up merges locally and pushes — in a synced team that
|
||||
fights the "main advances only through origin" discipline task 19
|
||||
documents.
|
||||
|
||||
**Affected areas:** `agents.py` (launch guard), `github.py` (actor-only
|
||||
PR opening, merge via origin), `taskfiles.py`/`watch.py` (marking
|
||||
remote-arrived moves), AGENTS.md.
|
||||
|
||||
## What to build
|
||||
|
||||
- **Remote moves are inert.** A move that arrives via sync (commit
|
||||
author ≠ this board's identity, or applied by the pull rather than
|
||||
the UI) renders and narrates but triggers nothing: no PR opening,
|
||||
no launch arming, no worktree work. Side effects belong to the
|
||||
board whose user made the move.
|
||||
- **Idempotency as the backstop.** The actor-only rule prevents
|
||||
duplication; file-carried gates (`**PR:**` line before `gh pr
|
||||
create`; branch-exists checks before worktree creation) make the
|
||||
rare double harmless. Both layers, deliberately.
|
||||
- **Claims gate launches.** ▸ start work on a card assigned to
|
||||
someone else refuses with who owns it; an explicit second path
|
||||
(arm-style, or clearing the assignee first) allows deliberate
|
||||
takeover — never accidental. Unassigned cards in team mode claim on
|
||||
launch, reusing 18's write.
|
||||
- **Merges go through origin.** With `BOARD_SYNC=1`, merge & clean-up
|
||||
merges via `gh pr merge` (origin-side) instead of locally, then
|
||||
lets the sync beat deliver the result — local main never diverges
|
||||
by design. Single-player keeps the local merge path untouched.
|
||||
|
||||
**Out of scope** — tempting neighbours left alone:
|
||||
|
||||
- Shared liveness (a teammate's running agent stays a static
|
||||
"in-progress, assigned to X" on your board).
|
||||
- Cross-machine agent registries or any coordination service beyond
|
||||
the files and origin.
|
||||
- Permissions/roles — anyone can take over deliberately; git history
|
||||
is the audit.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Given A moves a card to review, when the move syncs to B, then
|
||||
exactly one PR exists and B's github poller adopts it read-only.
|
||||
- [ ] Given a card assigned to A, when B clicks ▸ start work, then
|
||||
the launch refuses naming A; the deliberate-takeover path works
|
||||
and reassigns via 18's write.
|
||||
- [ ] Given the rare double-fire anyway, then the file-carried gates
|
||||
make the second attempt a no-op, not an error stampede.
|
||||
- [ ] Given `BOARD_SYNC=1`, when merge & clean-up runs, then the
|
||||
merge commit originates on origin and local main only ever
|
||||
fast-forwards. With sync off, the local merge path is
|
||||
byte-identical to today.
|
||||
- [ ] Edge case — the actor's board dies mid-side-effect (moved the
|
||||
card, crashed before PR opened): any board can complete it
|
||||
explicitly (the review-stage card without a PR shows an "open
|
||||
PR" action), but none does so automatically.
|
||||
|
||||
## Open questions
|
||||
|
||||
- None.
|
||||
|
||||
## Notes
|
||||
|
||||
Closing card of the multi-user arc (18 claim → 19 sync → 20
|
||||
etiquette). The principle worth keeping even beyond this card:
|
||||
**state syncs; reactions don't.** Every future automation hung off a
|
||||
stage transition inherits the same question — "am I the actor?" —
|
||||
and should answer it the same way.
|
||||
|
||||
**Risks**
|
||||
|
||||
- Actor detection must survive restarts (the board that moved a card
|
||||
reboots before its PR opens) — hence the explicit-completion action
|
||||
in the last acceptance item rather than a fragile in-memory flag.
|
||||
- `gh pr merge` needs merge rights on origin for every team member —
|
||||
a real-world constraint the docs must state, since the local-merge
|
||||
path hid it.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Work report — 2026-07-30 11:19 (Otto)
|
||||
|
||||
mmitted on `task/20-replicas-render-actors-act` — two commits (`755d40d` implementation + tests, `96ec71f` docs), with `python3 -m unittest` green at 251 tests (33 of them new, in `tests/test_actor_acts.py`). Nothing is blocked and nothing was left out of scope.
|
||||
|
||||
**What changed**
|
||||
|
||||
- **Remote moves are inert.** `manager/core/watch.py` now returns `(actor, remote)` from `_actor`, and a move a pull applied narrates and renders but opens no PR. A plain `mv` on this disk still acts — inert means "happened elsewhere", not "unattributed".
|
||||
- **Idempotency as the backstop.** In `manager/core/github.py` the `**PR:**` line now commits itself in team mode through a new `taskfiles.commit_edit` (sharing the move's pathspec-scoped commit machinery), so the gate travels to the other boards instead of sitting in one working tree — where it would also have stalled sync, which never runs over uncommitted changes. A `gh pr create` that races anyway adopts the PR GitHub already holds rather than erroring.
|
||||
- **Claims gate launches.** `agents.start_agent` refuses a card someone else holds, naming them; the card offers **▸ take over** as the deliberate armed second path, which reassigns via `taskfiles.set_assignee`. An unheld card claims itself on launch. All of it only under `BOARD_COMMIT_MOVES`: with the gate off nothing writes an assignee, so nothing reads one as a lock.
|
||||
- **Merges go through origin.** With `BOARD_SYNC=1`, `complete_task` runs `gh pr merge` and lets the sync beat deliver the result; local `main` only ever fast-forwards. Sync off keeps the local merge path unchanged.
|
||||
- **The dead-actor edge case.** The startup `reconcile` stands down in team mode, and a review card with a branch and no PR carries an **↑ open PR** action (`POST /api/pr/open`) — asked for by a person, never completed automatically by another board.
|
||||
|
||||
**What a reviewer should look at first**
|
||||
|
||||
1. `manager/core/watch.py:32` — the one function that decides "am I the actor?". Every future stage-transition automation inherits it, so it is the load-bearing change.
|
||||
2. `manager/core/github.py:415` (`_merge_on_origin`) — the origin-side merge, and the two constraints it exposes that the local path hid.
|
||||
3. `tests/test_actor_acts.py` — two real clones of a real bare upstream plus a stub `gh`; the `elsewhere()` helper is what keeps one process's in-memory registries out of the other's evidence.
|
||||
|
||||
**Worth knowing (no action needed)**
|
||||
|
||||
- `gh pr merge` needs merge rights on the repo for every team member who clicks merge & clean up. That is a real-world constraint the local-merge path did not have; it is stated in AGENTS.md and `manager/core/.env.example`.
|
||||
- The launch guard covers **▸ start work** only, not the read-only reviews or ↻ act on PR — reading someone else's card is never a stampede, and the acceptance criterion named start work specifically.
|
||||
- On a review card that has a branch but no PR, **↑ open PR** takes the slot **◔ still true?** would have had, keeping to the two-actions-per-state rule.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## PR review — 2026-07-30 11:50 (Ada)
|
||||
|
||||
PR REVIEW: APPROVE
|
||||
|
||||
**State of the work:** The PR is complete, committed on `task/20-replicas-render-actors-act` (two commits), and by the author's report all 251 tests pass, 33 of them new. I read the full diff and all surrounding code; I could not execute the suite myself because this sandbox blocks the git/subprocess the tests spawn, so the green result is the author's report plus my read of a thorough, well-structured test file — not my own run. My approval rests on the code reading.
|
||||
|
||||
**What I checked and found good**
|
||||
- **The load-bearing change (`manager/core/watch.py:32`) is correct.** `_actor` now returns `(actor, remote)`; `remote` is true only when a move was absent from this board's expectations *and* sync attributes it to a commit author — i.e. it arrived over origin. The PR trigger in `narrate` fires only when `not remote`. UI move acts once (expectation consumed), pulled move is inert, plain `mv` on this disk still acts. All three paths are tested, including the race-loser's reverted file returning as an inert move.
|
||||
- **Idempotency backstop is real.** `_write_pr_line` commits the `**PR:**` line via `taskfiles.commit_edit` (team mode only) so the gate reaches other boards rather than stalling sync as an uncommitted change; a racing `gh pr create` adopts the existing PR (`_already_exists`/`_existing_pr`) instead of erroring; a genuine failure still raises.
|
||||
- **Claims gate launches** (`manager/core/agents.py`, `_claim_for_launch`) with an armed `▸ take over` path threaded through `httpd.py`; unassigned claims on launch, your own card is untouched, off-mode reads no lock.
|
||||
- **Merges via origin** under `BOARD_SYNC` (`github.py:_merge_on_origin`); the single-player path is the old code extracted verbatim as `_merge_locally`. Dead-actor edge handled: `reconcile` stands down in team mode, and the `↑ open PR` action (`/api/pr/open`) covers a half-done side effect.
|
||||
- **Layering is clean** — only `taskfiles.py` touches task files; the `_commit`/`commit_edit`/`set_assignee` refactor is tidy. The merge-rights-on-origin constraint is documented in AGENTS.md and `.env.example`.
|
||||
|
||||
**To know (not blocking)**
|
||||
- Under `BOARD_SYNC`, `complete_task` commits the move-to-done on local main while origin holds the new merge commit, so local main diverges until the next sync beat rebases the board's own commit onto origin's merge. This is task-19 sync behaviour, not introduced here — but the new tests assert only the pre-beat state and don't drive a beat to prove convergence. Worth a manual two-board smoke test before a team relies on merge & clean-up under sync.
|
||||
|
||||
**For the human deciding this card:** one action — either merge it yourself (a formal approving review can't come from the PR author's own identity), or have a second teammate click ◔ review PR so GitHub records an approval from a different account.
|
||||
@@ -0,0 +1,126 @@
|
||||
# 21 — A shebang means executable: fix the shipped modes and test the invariant
|
||||
|
||||
**Status:** Done
|
||||
**PR:** https://github.com/12vectors/bench/pull/20
|
||||
**Assignee:** istos
|
||||
**Priority:** High — first external-install bug report: ./install.py is permission-denied on every install of v0.1-alpha
|
||||
**Type:** Bug
|
||||
|
||||
`install.py` is committed mode 100644 — the one shebang'd top-level
|
||||
file that isn't executable. The artifact inherits repo modes, so every
|
||||
install ships a non-runnable `./install.py`; a downstream user hit
|
||||
permission-denied, chmod'd locally, and correctly reported that the
|
||||
next `update.sh` will clobber their fix back. A sweep found three more
|
||||
shebang'd-but-non-executable files (`board.py`,
|
||||
`claude/hook_settings.py`, `opencode/permission_config.py`) — harmless
|
||||
today because they are invoked via `python3 …`, but the same lie. The
|
||||
invariant to enforce: any shipped file whose first two bytes are `#!`
|
||||
carries the exec bit.
|
||||
|
||||
## Context
|
||||
|
||||
- Field report (2026-07-30, first external install): `./install.py` →
|
||||
permission denied; root-caused downstream to the repo mode, fixed
|
||||
locally, flagged that update.sh would revert it — so the fix
|
||||
belongs at the source.
|
||||
- `update.sh:165` already repairs modes after every update
|
||||
(`chmod +x start.sh stop.sh update.sh`) — `install.py` is missing
|
||||
from the list, so existing installs (which cp from artifacts that
|
||||
carried the bad mode) never heal.
|
||||
- `release.sh` tars straight from the repo; `tests/test_release_artifact.py`
|
||||
asserts contents against the manifest but says nothing about modes —
|
||||
the missing check that would have caught this before v0.1-alpha.
|
||||
- Precedent for the mode-loss failure class: release.sh itself lost
|
||||
its exec bit once already (restored during PR #11's resolution) and
|
||||
the opencode run/wire needed `git add --chmod=+x` because the
|
||||
building agent couldn't chmod. Modes are bench's recurring blind
|
||||
spot; the test is the cure, not vigilance.
|
||||
|
||||
**Affected areas:** repo file modes, `update.sh` (repair line),
|
||||
`tests/test_release_artifact.py`.
|
||||
|
||||
## What to build
|
||||
|
||||
- Repo modes: exec bit on all four shebang'd files (the two hotfix
|
||||
lines — repo chmod + update.sh repair list gaining `install.py` —
|
||||
may already be landed by the time this card is worked; verify
|
||||
rather than redo).
|
||||
- The invariant test, in the artifact suite: every member of the
|
||||
built tarball whose content starts `#!` has the executable bit in
|
||||
its tar header; fail naming the file. This covers all future
|
||||
scripts automatically — adapters' run/wire included.
|
||||
- A second assertion the field report implies: the update round-trip
|
||||
test verifies `install.py` is executable after an update applied to
|
||||
an install where it wasn't — proving the repair line heals existing
|
||||
victims, not only fresh installs.
|
||||
- Decide the patch-release question: v0.1-alpha ships the bad mode;
|
||||
either cut v0.1-alpha.1 from the fixed tree or note in the release
|
||||
that `./start.sh` (which invokes install.py via python3) is
|
||||
unaffected and the next release heals it. Cutting the patch is
|
||||
cheap and the honest move.
|
||||
|
||||
**Out of scope** — tempting neighbours left alone:
|
||||
|
||||
- A general lint pass over the repo; the invariant is scoped to what
|
||||
ships in the artifact.
|
||||
- Windows-style mode handling — tar + POSIX bits are the contract.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Given the v0.1-alpha artifact rebuilt from the fixed tree, when
|
||||
the README one-liner runs, then `./install.py` executes directly.
|
||||
- [ ] Given an existing install with non-executable install.py, when
|
||||
`update.sh` applies any release from the fixed tree, then
|
||||
install.py is executable afterwards.
|
||||
- [ ] The artifact test fails if any shipped `#!`-file lacks the exec
|
||||
bit — demonstrated by a deliberate local mode-strip before
|
||||
committing the test.
|
||||
- [ ] Edge case — a shebang'd file legitimately meant only for
|
||||
`python3 x.py` invocation: there are none; the invariant is
|
||||
absolute so the test needs no exception list, and gaining one
|
||||
in future requires editing the test with a reason.
|
||||
|
||||
## Open questions
|
||||
|
||||
- None.
|
||||
|
||||
## Notes
|
||||
|
||||
Reported from the field within hours of v0.1-alpha — the release's
|
||||
first bug is a fitting one: the artifact pipeline's whole promise is
|
||||
"correct by construction", and modes were the one property nothing
|
||||
constructed. The reporter's own diagnosis (repo bit, repair line,
|
||||
shebang test) survives here nearly verbatim; good bug reports deserve
|
||||
that.
|
||||
|
||||
**Risks**
|
||||
|
||||
- Git preserves only the exec bit, not full modes — the test must
|
||||
check the tar header's mode, not assume repo mode equals artifact
|
||||
mode forever (release.sh could gain umask surprises on other
|
||||
machines).
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Work report — 2026-07-30 12:00 (Hazel)
|
||||
|
||||
guard that keeps them landed, plus the patch-release bump.
|
||||
|
||||
**What I verified was already fixed.** All 14 shebang'd tracked files carry mode `100755` in git — including the four the card names (`install.py`, `manager/core/board.py`, `manager/core/adapters/claude/hook_settings.py`, `manager/core/adapters/opencode/permission_config.py`) — and `update.sh:165` already lists `install.py` in its `chmod +x` repair line. I changed neither.
|
||||
|
||||
**What I built.**
|
||||
|
||||
- `tests/test_release_artifact.py` — the invariant, factored into `shebang_files_missing_exec()` and asserted against the tar header rather than repo modes (git records only the exec bit, and `release.sh` stages through a copy where a umask could still lose it — the risk the card flagged). No exception list. A second test proves the guard bites by repacking the real artifact with `install.py` set to mode 644 and asserting the check names it; a third unpacks a release and runs `./install.py` as a program with no interpreter in front of it, which is acceptance criterion 1 end to end.
|
||||
- `tests/test_update_from_release.py` — an install whose `install.py` is mode 644 (the shape v0.1-alpha left in the field) is executable after any update from the fixed tree. This is a real test of the repair line: `cp` onto an existing file keeps the *destination's* mode, so nothing else heals it.
|
||||
- `manager/core/VERSION` → `0.1-alpha.1`. I took the card's recommendation and cut the patch rather than writing a release note that tells users to work around the bug.
|
||||
- `manager/core/release-manifest` — the invariant stated in the header comment, where the shipping list already lives.
|
||||
|
||||
**Actions for the reviewer:**
|
||||
|
||||
1. Publish the patch after merge: `./release.sh` from the merged tree tags and publishes `v0.1-alpha.1`. I could not do it here (no push, and it needs `gh`), so the card's fourth deliverable is decided and staged but not executed.
|
||||
2. Confirm you want the version bump on this branch — it is the one change here that is a release decision rather than a test.
|
||||
|
||||
**Worth knowing.** The acceptance criterion asked for a deliberate local mode-strip demonstrated before committing the test. `chmod` is not permitted to me in this headless context, so I demonstrated it two other ways and watched both new assertions fail before they passed: I temporarily added `chmod -x "$stage"/install.py` inside `release.sh`'s build (the real repo → stage → tar path), which made the invariant test fail with `AssertionError: [] != ['install.py']`; and I temporarily removed `install.py` from `update.sh`'s chmod list, which made the heal test fail. Both files were restored, and `git status` confirms no mode or content drift in either. The repacking test now carries that demonstration permanently, which is stronger than a one-off manual strip.
|
||||
|
||||
I deliberately did not add a build-time `chmod` sweep to `release.sh` for all shebang'd files. It would make the new test pass tautologically and hide exactly the repo-mode error this card exists to catch.
|
||||
@@ -0,0 +1,95 @@
|
||||
# 22 — The board's tab names its project
|
||||
|
||||
**Status:** Done
|
||||
**PR:** https://github.com/12vectors/bench/pull/16
|
||||
**Priority:** Low — one line of confusion, many times a day once two benches exist
|
||||
**Type:** Bug
|
||||
|
||||
Every board tab is titled "Bench — task board", so two or more benches
|
||||
(one per project, exactly the setup start.sh's port-shifting exists
|
||||
for) are indistinguishable in the tab bar, in cmd-tab window lists,
|
||||
and in browser history. The title should lead with the project, which
|
||||
the board already knows.
|
||||
|
||||
## Context
|
||||
|
||||
- `board.html:6` — `<title>Bench — task board</title>`, static,
|
||||
never updated.
|
||||
- The board knows its identity: the header already renders the tasks
|
||||
root path, `/api/state` carries `board.root` (stop.sh identifies
|
||||
the right process by it), and the repo directory name
|
||||
(`config.REPO.name`) is the natural human name for the project.
|
||||
- Multiple boards are a supported reality: 26071 is pinned for the
|
||||
first, start.sh walks to a free port for the next — the tab title
|
||||
is currently the only thing that *doesn't* follow.
|
||||
|
||||
**Affected areas:** `board.html` (title update from state; possibly
|
||||
the served page), `httpd.py`/`config.py` only if the project name
|
||||
isn't already in the state payload.
|
||||
|
||||
## What to build
|
||||
|
||||
- Title becomes `<project> · bench` (project first — tab truncation
|
||||
eats the tail, and the tail is the same in every bench tab).
|
||||
Project = the repo directory's name; a `BOARD_TITLE` setting in
|
||||
`.env` overrides it for people whose checkout dirs are all `app`.
|
||||
- Set it as early as the page can know it (server-render into the
|
||||
HTML if cheap, else first state load) and keep it stable — no
|
||||
flicker between generic and named on every refresh.
|
||||
- The view switcher may suffix (`<project> · sessions`), but only if
|
||||
it costs nothing; the project must stay the first word regardless.
|
||||
|
||||
**Out of scope** — tempting neighbours left alone:
|
||||
|
||||
- Per-project favicons or theme accents.
|
||||
- Renaming anything in the page body — the header's path line already
|
||||
does that job.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Given two projects' boards open side by side, then their tabs
|
||||
read `projectA · bench` and `projectB · bench` — distinguishable
|
||||
at tab-bar width.
|
||||
- [ ] Given `BOARD_TITLE=payments` in local/.env, then the tab reads
|
||||
`payments · bench`.
|
||||
- [ ] The title is right on first paint or within the first state
|
||||
load, and never reverts to the generic string afterwards.
|
||||
|
||||
## Open questions
|
||||
|
||||
- None.
|
||||
|
||||
## Notes
|
||||
|
||||
Requested 2026-07-30, the day multiple benches first existed —
|
||||
downstream installs started the same morning as the v0.1-alpha
|
||||
release, and the tab bar immediately stopped saying which bench was
|
||||
which.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Work report — 2026-07-30 09:12 (Milo)
|
||||
|
||||
Done and committed. Closing report:
|
||||
|
||||
---
|
||||
|
||||
The change is committed on `task/22-board-title-names-its-project` (`de5cedb`) and the full test suite passes — 167 tests, including 14 new ones. Board tabs now read `<project> · bench` instead of the generic `Bench — task board`.
|
||||
|
||||
**What changed**
|
||||
|
||||
- `manager/core/config.py` — new `PROJECT` setting: `BOARD_TITLE` from `manager/local/.env` if set (whitespace-only counts as unset), otherwise the repo directory's name.
|
||||
- `manager/core/httpd.py` — a `page_bytes()` helper rewrites board.html's `<title>` with the escaped project name before serving it, so the tab is correct on first paint with no generic-to-named flicker. `/api/state` also carries `project`, which the browser needs for view switching.
|
||||
- `manager/core/board.html` — the shipped fallback title is now just `bench`, and `renderTitle()` (called from `render()`, which runs on every state load and view switch) sets `document.title` to `<project> · <tail>`, where the tail is `bench` / `sessions` / `focus`. It bails out when state has no project, so the server-rendered title stands rather than being replaced by something worse. That is the only assignment to `document.title` in the page.
|
||||
- `manager/core/.env.example` and `AGENTS.md` — the `BOARD_TITLE` setting and the titling rule documented where the other settings and board behaviour live.
|
||||
- `tests/test_board_title.py` — new.
|
||||
|
||||
**What a reviewer should look at first**
|
||||
|
||||
- `manager/core/httpd.py:48` — the title rewrite. It is a single regex substitution over the page bytes on each request; the escaping (`html.escape`) is what keeps a directory name from splicing markup into the head, and one test asserts nothing outside the `<title>` element differs from the file on disk.
|
||||
- `manager/core/board.html:751` — `VIEW_TITLES` and `renderTitle()`. The design decision worth a second opinion is that the Sessions and Focus views suffix (`<project> · sessions`), which the task permitted but did not require.
|
||||
|
||||
**Verification** — `python3 -m unittest discover -s tests`, all 167 passing. The server half of the new tests runs `config`/`httpd` in fresh interpreters with `BOARD_TITLE` set, because config reads settings at import; the browser half is checked as source-level invariants on board.html, matching how the existing UI tests in this repo work (there is no frontend test runner). I did not start a live board from this worktree, so the tab title has not been eyeballed in a browser — the served bytes are asserted end-to-end instead.
|
||||
|
||||
**Note on scope** — `config.PROJECT` derives from `config.REPO`, which is the git toplevel. A board is always run from the main checkout, so this is the project directory; if someone ran a board from inside `.worktrees/<task>/`, the tab would name the worktree. That matches how every other `REPO`-derived path in the module already behaves, so I left it alone.
|
||||
@@ -0,0 +1,126 @@
|
||||
# 23 — Bring the updated bench logo in from the design project
|
||||
|
||||
**Status:** Done
|
||||
**PR:** https://github.com/12vectors/bench/pull/19
|
||||
**Assignee:** istos
|
||||
**Priority:** Low — visual identity, no behaviour
|
||||
**Type:** Chore
|
||||
|
||||
The bench logo was updated in the design project; the board still
|
||||
renders the old mark. Import the design and bring the header's logo in
|
||||
line with it.
|
||||
|
||||
## Context
|
||||
|
||||
- Design source of truth: the claude_design MCP
|
||||
(`https://api.anthropic.com/v1/design/mcp`, auth via `/design-login`)
|
||||
— import this project:
|
||||
https://claude.ai/design/p/43447958-7124-44aa-9ee5-4bd0a9f0bacf?file=Bench+Board.dc.html
|
||||
- Focus file (the whole project is readable): `Bench Board.dc.html`.
|
||||
Also read what the selection imports: `support.js`.
|
||||
- Today's mark: the plain "Bench" wordmark in the header of
|
||||
`manager/core/board.html` (single-file UI — any asset the new logo
|
||||
needs must be inlined: SVG in the markup or a data: URI, never an
|
||||
external file or font).
|
||||
- The design system's constraints still bind: night theme is the
|
||||
default, Daylight must look right too, and colour only ever means
|
||||
state — the logo must not introduce a colour that reads as one.
|
||||
|
||||
**Affected areas:** `manager/core/board.html` (header markup/CSS)
|
||||
only.
|
||||
|
||||
## What to build
|
||||
|
||||
- Import the design project via the MCP and read `Bench Board.dc.html`
|
||||
(plus `support.js`) for the updated logo: its mark, geometry,
|
||||
spacing against the tasks-root path line, and any theme variants.
|
||||
- Reproduce it in the board header, inlined, in both themes. If the
|
||||
design gives the browser tab an icon, carry it as an inline
|
||||
data:-URI favicon — coordinated with task 22, which is retitling
|
||||
the same tab.
|
||||
- Match the design's intent, not just its pixels: if the mark scales
|
||||
or recolours between themes in the design, mirror the mechanism
|
||||
(CSS custom properties) rather than baking two bitmaps.
|
||||
|
||||
**Out of scope** — tempting neighbours left alone:
|
||||
|
||||
- Any other visual change the design file may contain — this card is
|
||||
the logo only; further deltas become their own cards.
|
||||
- Rebranding text, README badges, or release-notes art.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] The board header shows the updated logo, matching the design in
|
||||
both Night and Daylight themes.
|
||||
- [ ] The page remains fully self-contained: no new network requests,
|
||||
fonts, or image files — verified by loading with the network
|
||||
tab open.
|
||||
- [ ] Nothing else on the page moved: header height, the path line,
|
||||
view switcher and session chips sit where they did.
|
||||
|
||||
## Open questions
|
||||
|
||||
- None.
|
||||
|
||||
## Notes
|
||||
|
||||
The design MCP needs authentication (`/design-login`), which is
|
||||
interactive — a headless work agent launched by the board may not
|
||||
inherit that session. If the import fails on auth, the honest moves
|
||||
are: do the import half interactively and hand the extracted mark to
|
||||
the card, or configure the MCP for the agent's environment first.
|
||||
`NOT READY` is wrong here (nothing is undecided); "blocked on tooling
|
||||
access, here is exactly what to run" is the right report.
|
||||
|
||||
**Risks**
|
||||
|
||||
- Design files describe more than the delta — the temptation to
|
||||
"align" neighbouring header details while in there is exactly how
|
||||
logo chores become layout regressions; out-of-scope list is the
|
||||
fence.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Work report — 2026-07-30 11:16 (Ada)
|
||||
|
||||
became an inline `<svg class="mark">`, one `<path>` per letter of b-e-n-c-h on a 1000-unit em (baseline y=740, x-height top y=210, ascender y=0), with the design's −0.015em tracking folded into the per-glyph translations.
|
||||
- `fill:currentColor` gives the mark `--text` in both Night and Daylight from one copy, so it introduces no colour and in particular no state colour. `--logo-h:14px` (the wordmark's b-height, = 19px × 0.74) is the single token that sizes it.
|
||||
- `.brand` gap 10px → 11px, per the design. This is the one place I moved a neighbour: the acceptance bullet says the path line sits where it did, but "What to build" asks for the design's spacing against that line, so I took the design. It is a 1px shift and the header's height is set by the view switcher, not the brand, so nothing reflows.
|
||||
- The tab icon's dot became the wordmark's own `b` on the accent tile it already had (the design's app-icon treatment: accent ground, white letter). Both copies of the `b` are the same outline string, and a test keeps them equal.
|
||||
- Still fully self-contained: no `@font-face`, no font file, no new request. A test pins the page's external URLs to exactly the three IBM Plex ones that were already there and would fail if a fourth appeared.
|
||||
|
||||
## To do before merging
|
||||
|
||||
1. **Look at the header in both themes.** Zilla Slab's own contours are not reachable from this environment — `curl` and Python are not permitted to a headless agent here, so I could not fetch or convert the font binary, and I have no browser to render into. The five outlines are therefore hand-authored in Zilla Slab's idiom (slab feet, 118-unit stems, 104-unit bars, top-left flags on `b`/`h`/`n`, vertically-sheared `c` and `e` terminals), not exported from the face. I verified each path is a closed, non-self-intersecting loop and that even-odd filling puts the counters in the right places by point-sampling every region, but that proves the letters are *well-formed*, not that they are *handsome*.
|
||||
2. **If they are not right, swapping in real outlines is mechanical**: replace the five `d` attributes in the `<svg class="mark">` block and the one in the `<link rel="icon">`, keeping the `#mark-b` and favicon strings identical — `test_header_logo.TheTabIcon.test_it_is_the_wordmark_s_own_b` fails if they diverge, and the em grid is documented in the comment above the SVG.
|
||||
|
||||
## To know
|
||||
|
||||
- The design's `--logo` / `--logo-weight` / `--logo-track` custom properties are the control that swaps between the eight candidate faces; they have no meaning once the chosen face is outlined, so they are not carried over. `--logo-h` is what replaces them as the mark's one token.
|
||||
- Task 22's tab retitling is already on `main` (`de5cedb`), so the icon change sits alongside a title that is already `<project> · bench`; nothing needed coordinating.
|
||||
- Out of scope and left alone: the README's `# Bench`, and everything else in the design files (the docs-site directions in `Bench Docs.dc.html` are a whole separate body of work).
|
||||
|
||||
|
||||
---
|
||||
|
||||
## PR review — 2026-07-30 11:48 (Fern)
|
||||
|
||||
PR REVIEW: REQUEST CHANGES
|
||||
|
||||
The engineering is production-quality and every mechanical invariant holds; the single open item is human visual verification, because the wordmark ships as **hand-authored approximations of Zilla Slab, not the design's real outlines**. The build is otherwise ready to merge.
|
||||
|
||||
**What is good (verified against the branch, not just the tests):**
|
||||
- Scope is exactly the card: only `manager/core/board.html` (favicon + `.brand`) and the new `tests/test_header_logo.py`. No neighbouring header details "aligned" while in there.
|
||||
- Self-contained (acceptance #2): no `@font-face`, no font file, no new request; the external-URL set is pinned to the three pre-existing IBM Plex ones.
|
||||
- Theme mechanism correct: `fill:currentColor` → `--text` in both themes from one copy, no baked colour, none of the state tokens; `--logo-h` is the one sizing token.
|
||||
- Favicon/wordmark `b` are byte-identical (I confirmed 2 occurrences of the same outline on-branch, `board.html:9` and `:495`), so they cannot drift; even-odd fill keeps the counters open.
|
||||
- Neighbours preserved (acceptance #3): header padding/alignment, the mono path line, and the header control order are invariant-tested. CI green on 3.11 and 3.13.
|
||||
|
||||
**To do before merge (human — an agent re-run hits the same tooling wall):**
|
||||
1. Eyeball the header in **Night and Daylight** against the design. The five glyphs are hand-drawn (design MCP needs interactive auth; the font binary was unreachable headless), so acceptance #1 ("matching the design") is unverified and cannot be closed from source. If the shapes are off, swapping in real outlines is mechanical — replace the five `d` strings in `<svg class="mark">` and the favicon's, keeping `#mark-b` and the icon identical (`test_header_logo.TheTabIcon` enforces it).
|
||||
2. While there, confirm the mono path line still sits right: `.brand` gap moved 10px → 11px (disclosed, "per the design"), which acceptance #3 nominally forbids — trivial, but it's the one intentional neighbour shift.
|
||||
|
||||
**To know:**
|
||||
- I requested changes purely to keep the card in your hands for that visual check; nothing in the code needs rework. Once the glyphs are confirmed against the design, this is an approve.
|
||||
- I could not run the suite locally (sandbox denied `python3 -m unittest`), but both CI test jobs passed, which covers it.
|
||||
@@ -0,0 +1,103 @@
|
||||
# 24 — A model chip beside every agent name
|
||||
|
||||
**Status:** Done
|
||||
**PR:** https://github.com/12vectors/bench/pull/21
|
||||
**Assignee:** istos
|
||||
**Priority:** Low — the data is already there; this is putting it where eyes are
|
||||
**Type:** Feature
|
||||
|
||||
Task 12 made the board record the model each launch used, but it
|
||||
surfaces only at the tail of the session-detail metadata line —
|
||||
"… · task/04-… · claude-opus-4-8" — where nobody's eye lands. With
|
||||
per-intent models configured, which brain did the work becomes a real
|
||||
question ("was that review sonnet or opus?"), and the answer should
|
||||
sit beside the name that did it: a small chip, everywhere an agent
|
||||
wears its name.
|
||||
|
||||
## Context
|
||||
|
||||
- Task 12 (landed) records the resolved model (or inherited) on the
|
||||
session record; the Sessions detail line proves the data flows.
|
||||
- Where agent names appear today: the sessions list entries
|
||||
("Olive · a83fae45"), the session-detail header, the Focus view's
|
||||
header, the card face's agent line while working ("Cleo · 7m"),
|
||||
and header session chips on the Board view.
|
||||
- Design system: machine-produced text is Plex Mono; the session-id
|
||||
hash beside the name ("a83fae45") is exactly the visual register
|
||||
this chip belongs in — dim, mono, small. Colour means state, and a
|
||||
model is not a state: the chip must stay neutral.
|
||||
|
||||
**Affected areas:** `board.html` only; possibly the state payload if
|
||||
any of the name-bearing views lacks the model field today.
|
||||
|
||||
## What to build
|
||||
|
||||
- One chip component: the model string in small mono on the dim
|
||||
neutral used by the id hash, rendered immediately after the agent's
|
||||
name wherever that name identifies a run — sessions list, session
|
||||
header, Focus header, the card's live agent line.
|
||||
- Display the resolved model as recorded, shortened for the chip by
|
||||
dropping the redundant vendor prefix (`claude-opus-4-8` → chip
|
||||
`opus-4-8`, full string on hover/title). Inherited runs wear the
|
||||
resolved name too if known; if the launch genuinely didn't know
|
||||
(pre-12 sessions replayed from disk), the chip is absent, not a
|
||||
guess.
|
||||
- Old persisted sessions without the field render exactly as today —
|
||||
no chip, no placeholder.
|
||||
|
||||
**Out of scope** — tempting neighbours left alone:
|
||||
|
||||
- Recording anything new (12 did that); no per-event model tracking.
|
||||
- The ticker line — one-line narration stays terse; the chip lives
|
||||
where names are headings, not prose.
|
||||
- Any colour-coding of models.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Sessions list, session-detail header, Focus header and the
|
||||
working card's agent line all show the chip beside the name for
|
||||
a fresh launch; hover reveals the full model string.
|
||||
- [ ] A pre-existing session file without the model field renders
|
||||
chip-less and un-broken.
|
||||
- [ ] The chip is visually the id-hash register (mono, dim), adds no
|
||||
new colour meaning, and doesn't shift the layouts it joins —
|
||||
names and timestamps stay aligned.
|
||||
|
||||
## Open questions
|
||||
|
||||
- None.
|
||||
|
||||
## Notes
|
||||
|
||||
Prompted (2026-07-30) by reading a session where the model sat at the
|
||||
end of the metadata line: with work/review models diverging per task
|
||||
12's intent split, "which model did this" is now a routine review
|
||||
question, and the answer was hiding.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Work report — 2026-07-30 12:04 (Olive)
|
||||
|
||||
The work is committed and the full suite passes.
|
||||
|
||||
---
|
||||
|
||||
Committed on `task/24-model-chip-beside-the-agent-name` as `053e1d7`; the whole suite (`python3 -m unittest`, 274 tests including 10 new ones) passes.
|
||||
|
||||
**What changed**
|
||||
|
||||
`manager/core/board.html` gains one chip component — `modelChip(agent)`, with a `shortModel()` helper — rendered wherever a name identifies a run: the sessions-list row, the session-detail header, the Focus header's reference line, and the working card's agent line. The chip is small mono text in `var(--dim)`, the same register as the session-id hash it sits next to, and takes no state colour. It shows the model shortened by dropping the redundant vendor prefix (`claude-opus-4-8` → `opus-4-8`, `anthropic/claude-opus-4-8` → `opus-4-8`) with the full recorded string on hover; a name the shortening rule doesn't recognise, such as `gpt-4o`, is shown exactly as recorded. A launch whose model was never known — an inherited vendor default, or a session replayed from disk after a board restart — renders no chip and no placeholder.
|
||||
|
||||
No server-side change was needed: the model already reaches the browser on the agent record, and the sessions list resolves it through the existing `agentFor()` lookup.
|
||||
|
||||
**One behaviour change beyond adding the chip, worth a reviewer's eye:** the session-detail metadata line and the Focus reference line used to end with the model string. Leaving that would have said the same thing twice in one header, so those two places now print only `model inherited`, and only when the model genuinely is unknown. Task 12's honesty about inheriting is preserved; the duplicated resolved name is gone.
|
||||
|
||||
`AGENTS.md` gains a paragraph describing the chip next to the agent-names paragraph.
|
||||
|
||||
**Nothing is required of the reader to accept this card**, but two things are worth a look:
|
||||
|
||||
- `manager/core/board.html:105-114` — the `.mchip` rules, including the per-site font sizes that track each context's id-hash size, and the card-only ellipsis bound so a long model name squeezes rather than widens the card's row.
|
||||
- `tests/test_model_chip.py` — the behaviour half lifts `esc`, `shortModel` and `modelChip` out of the page and runs them in node, skipping cleanly where node is absent (GitHub's `ubuntu-latest` runners have it). The placement half stays source-level, in the house style, since `board.html` has no frontend test runner.
|
||||
|
||||
I did not verify this in a running board — the change is presentation-only and the render sites are exercised as source invariants rather than in a browser. If you want a visual check before merging, the ⛭ drive chip on the review card launches this worktree's board.
|
||||
@@ -1,87 +0,0 @@
|
||||
# 11 — A failed agent run must leave a visible trace on the card
|
||||
|
||||
**Status:** To Do
|
||||
**Priority:** High — three agents died today and the board showed nothing a human would notice
|
||||
**Type:** Feature
|
||||
|
||||
When a headless agent exits non-zero, the board's entire feedback is
|
||||
one ticker line ("‹name› exited on ‹file› rc=1 — see its log") that
|
||||
scrolls away in seconds. The card keeps sitting in in-progress looking
|
||||
exactly as it did before launch; the log's contents (today: an API 500)
|
||||
never reach the UI. Three launches (06, 07, 08) died within a minute
|
||||
during an API outage and the owner's experience was "they just stopped,
|
||||
no feedback". Failure must be a state the card wears, not an event that
|
||||
evaporates.
|
||||
|
||||
## Context
|
||||
|
||||
- `manager/core/agents.py:331-334` — the failure arm of `_reap_agent`:
|
||||
compose one summary line, record one ticker event, done. Compare the
|
||||
richer arms above it: decline moves the card back with a reason;
|
||||
clean-exit-no-commits (card 05's guard) holds the card with a loud
|
||||
message. Failure is the least-handled outcome despite being the most
|
||||
urgent one.
|
||||
- The design system already has the vocabulary: `--alarm` terracotta
|
||||
means "blocked, failed or HIGH" (CLAUDE.md, Live view), and cards
|
||||
already wear state borders/pills for PR verdicts (approved / changes
|
||||
asked). There is simply no "last run failed" state.
|
||||
- The log exists on disk (`local/state/agent/logs/…`) and its tail is
|
||||
usually the whole story ("API Error: 500 …"), but nothing in the UI
|
||||
displays it — "see its log" points at a file path the ticker doesn't
|
||||
even name.
|
||||
- Affected areas: `agents.py` (record the outcome), `state.py`/API
|
||||
payload (expose it), `board.html` (wear it).
|
||||
|
||||
## What to build
|
||||
|
||||
- Record the outcome on the agent's session record: exit code, ended-at,
|
||||
and the cleaned last few lines of the log as the failure excerpt.
|
||||
- The card wears it: a card in in-progress whose most recent run failed
|
||||
gets the `--alarm` treatment — border plus a `run failed` pill in the
|
||||
status slot — until the next launch replaces the state or the card
|
||||
moves stage. Hovering (or the card sheet) shows the excerpt, so "API
|
||||
Error: 500" is one hover away instead of buried in
|
||||
`local/state/…/logs`.
|
||||
- The toast on failure, not just a ticker line: failures are rare and
|
||||
actionable, exactly what toasts are for.
|
||||
- Same treatment for all headless kinds — work, review, act-pr,
|
||||
relevance — including launches that die before the agent speaks
|
||||
(today's MultiEdit flag error produced a 91-byte log; the excerpt
|
||||
handles it fine).
|
||||
|
||||
- Clear the way for relaunch: a failed run with zero commits leaves its
|
||||
worktree behind, and start-work refuses while it exists — so today a
|
||||
failed card needs hand `git worktree remove` before ▸ start work
|
||||
functions again. Reuse `_discard_untouched_worktree` (already called
|
||||
on declines) in the failure arm: nothing of value is lost (zero
|
||||
commits), and relaunch becomes one click. A failed run *with* commits
|
||||
keeps its worktree, same as declines do.
|
||||
|
||||
Out of scope: retries/auto-relaunch (a failed run is a human decision
|
||||
point, and API-outage storms would make auto-retry a thundering herd);
|
||||
distinguishing failure *causes* beyond showing the excerpt.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Kill a launch artificially (stub binary exiting 1 with a line of
|
||||
output): the card immediately shows the alarm border + `run
|
||||
failed` pill, a toast fires, and the excerpt is readable from the
|
||||
card without opening files on disk.
|
||||
- [ ] The state clears on relaunch and on stage move, and does not leak
|
||||
onto other cards or survive into review/.
|
||||
- [ ] A failed review/relevance run (no worktree, card not in
|
||||
in-progress) surfaces the same way on its card.
|
||||
- [ ] Ticker line still appears (the permanent record in the event log
|
||||
is unchanged — this card adds surfaces, it does not move them).
|
||||
|
||||
## Open questions
|
||||
|
||||
- None.
|
||||
|
||||
## Notes
|
||||
|
||||
Born from the 2026-07-29 API outage: agents on 06, 07 and 08 died with
|
||||
500/529 within a minute, review-pr on 02 died on the MultiEdit fossil
|
||||
(card 10), and every one of them looked identical to "nothing
|
||||
happening". The empty-branch guard from card 05 fixed the silent
|
||||
*success* path; this is its sibling for the failure path.
|
||||
@@ -0,0 +1,616 @@
|
||||
"""Replica etiquette (task 20): the actor's board acts, everyone else renders.
|
||||
|
||||
State syncs; reactions don't. These cases run against real clones of a real
|
||||
bare upstream, with a stub `gh` standing in for GitHub — the point of the
|
||||
card is who does what to whom, so nothing is mocked except the network's
|
||||
far end and the SSE fan-out.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO / "manager" / "core"))
|
||||
|
||||
import agents # noqa: E402
|
||||
import config # noqa: E402
|
||||
import github # noqa: E402
|
||||
import state # noqa: E402
|
||||
import sync # noqa: E402
|
||||
import taskfiles # noqa: E402
|
||||
import watch # noqa: E402
|
||||
|
||||
FILENAME = "20-a-shared-card.md"
|
||||
STEM = FILENAME[:-3]
|
||||
BRANCH = f"task/{STEM}"
|
||||
PR_URL = "https://github.com/acme/widget/pull/7"
|
||||
|
||||
# gh, as far as these tests are concerned: it logs every invocation and
|
||||
# answers from GH_MODE. Real subprocess, real argv — only GitHub is fake.
|
||||
FAKE_GH = f'''#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
|
||||
args = sys.argv[1:]
|
||||
with open(os.environ["GH_LOG"], "a") as fh:
|
||||
fh.write(json.dumps(args) + "\\n")
|
||||
mode = os.environ.get("GH_MODE", "ok")
|
||||
|
||||
if args[:2] == ["pr", "create"]:
|
||||
if mode == "exists":
|
||||
sys.stderr.write('a pull request for branch "{BRANCH}" into branch '
|
||||
'"main" already exists: {PR_URL}\\n')
|
||||
sys.exit(1)
|
||||
if mode == "create-fails":
|
||||
sys.stderr.write("something else went wrong\\n")
|
||||
sys.exit(1)
|
||||
print("{PR_URL}")
|
||||
elif args[:2] == ["pr", "view"] and "--jq" in args:
|
||||
print("{PR_URL}")
|
||||
elif args[:2] == ["pr", "view"]:
|
||||
print(json.dumps({{"reviews": [], "reviewRequests": [],
|
||||
"statusCheckRollup": [], "state": "OPEN",
|
||||
"mergeable": "MERGEABLE"}}))
|
||||
elif args[:2] == ["pr", "merge"]:
|
||||
if mode == "unmergeable":
|
||||
sys.stderr.write("Pull request is not mergeable: the base branch "
|
||||
"policy prohibits the merge.\\n")
|
||||
sys.exit(1)
|
||||
print("Merged pull request #7")
|
||||
else:
|
||||
sys.exit(0)
|
||||
'''
|
||||
|
||||
|
||||
def git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["git", "-C", str(cwd), *args],
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
def card(status: str, assignee: str | None = None, pr: str | None = None) -> str:
|
||||
header = f"**Status:** {status}\n**Priority:** High\n"
|
||||
if assignee:
|
||||
header += f"**Assignee:** {assignee}\n"
|
||||
if pr:
|
||||
header += f"**PR:** {pr}\n"
|
||||
return ("# 20 — A card two boards can see\n\n" + header +
|
||||
"\nBody text long enough that git sees a rename rather than a\n"
|
||||
"delete and an add when the file moves between stage directories.\n")
|
||||
|
||||
|
||||
class Boards(unittest.TestCase):
|
||||
"""One bare upstream, two clones — 'ada' and 'elena'. config.REPO/TASKS
|
||||
point at whichever board is acting."""
|
||||
|
||||
SYNC = True
|
||||
|
||||
def setUp(self):
|
||||
# resolve(): macOS tempdirs sit behind the /var → /private/var
|
||||
# symlink and git reports the resolved path.
|
||||
self.tmp = Path(tempfile.mkdtemp(prefix="bench-actor-")).resolve()
|
||||
self.addCleanup(shutil.rmtree, self.tmp, True)
|
||||
self.origin = self.tmp / "origin.git"
|
||||
subprocess.run(["git", "init", "-q", "--bare", "-b", "main", str(self.origin)],
|
||||
check=True, capture_output=True)
|
||||
|
||||
self.ada = self._clone("ada")
|
||||
for slug in config.STAGE_DIRS:
|
||||
(self.ada / "tasks" / slug).mkdir(parents=True)
|
||||
(self.ada / "code.txt").write_text("shipped\n", encoding="utf-8")
|
||||
git(self.ada, "add", "-A")
|
||||
git(self.ada, "commit", "-q", "-m", "root")
|
||||
git(self.ada, "push", "-q", "origin", "main")
|
||||
self.elena = self._clone("elena")
|
||||
|
||||
self.gh_log = self.tmp / "gh.log"
|
||||
gh = self.tmp / "gh"
|
||||
gh.write_text(FAKE_GH, encoding="utf-8")
|
||||
gh.chmod(gh.stat().st_mode | stat.S_IEXEC)
|
||||
os.environ["GH_LOG"] = str(self.gh_log)
|
||||
self.addCleanup(os.environ.pop, "GH_LOG", None)
|
||||
self.addCleanup(os.environ.pop, "GH_MODE", None)
|
||||
|
||||
self.patch(SYNC=self.SYNC, COMMIT_MOVES=True, FETCH_TIMEOUT=10.0,
|
||||
SESSIONS_DIR=self.tmp / "sessions", GH_BIN=str(gh),
|
||||
WORKTREES=self.tmp / "worktrees",
|
||||
REPO=self.ada, TASKS=self.ada / "tasks")
|
||||
self.use(self.ada)
|
||||
|
||||
state.BOARD_EVENTS.clear()
|
||||
state.EXPECTED_MOVES.clear()
|
||||
state.COMMIT_HOOKS.clear()
|
||||
self.addCleanup(state.COMMIT_HOOKS.clear)
|
||||
sync.ARRIVED.clear()
|
||||
sync._NOTES.clear()
|
||||
self.addCleanup(sync._NOTES.clear)
|
||||
self.addCleanup(sync.ARRIVED.clear)
|
||||
github.PR_STATE.pop(FILENAME, None)
|
||||
self.addCleanup(github.PR_STATE.pop, FILENAME, None)
|
||||
|
||||
self.broadcasts: list[dict] = []
|
||||
self.addCleanup(setattr, state, "broadcast", state.broadcast)
|
||||
state.broadcast = self.broadcasts.append
|
||||
|
||||
def _clone(self, who: str) -> Path:
|
||||
path = self.tmp / who
|
||||
subprocess.run(["git", "clone", "-q", str(self.origin), str(path)],
|
||||
check=True, capture_output=True)
|
||||
git(path, "config", "user.name", who)
|
||||
git(path, "config", "user.email", f"{who}@example.com")
|
||||
return path
|
||||
|
||||
def patch(self, **values) -> None:
|
||||
for attr, value in values.items():
|
||||
self.addCleanup(setattr, config, attr, getattr(config, attr))
|
||||
setattr(config, attr, value)
|
||||
|
||||
def mode(self, value: str) -> None:
|
||||
os.environ["GH_MODE"] = value
|
||||
|
||||
# — acting as one board or the other —
|
||||
|
||||
def use(self, board: Path) -> None:
|
||||
config.REPO, config.TASKS = board, board / "tasks"
|
||||
|
||||
def place(self, board: Path, stage: str, text: str, *, commit: bool = True) -> None:
|
||||
(board / "tasks" / stage / FILENAME).write_text(text, encoding="utf-8")
|
||||
if commit:
|
||||
git(board, "add", "-A")
|
||||
git(board, "commit", "-q", "-m", f"board: 20 → {stage} (setup)")
|
||||
git(board, "push", "-q", "origin", "main")
|
||||
|
||||
def work_branch(self, board: Path) -> None:
|
||||
"""A worktree with a commit on it: what an agent leaves behind."""
|
||||
worktree = config.WORKTREES / STEM
|
||||
git(board, "worktree", "add", "-q", "-b", BRANCH, str(worktree))
|
||||
(worktree / "feature.txt").write_text("the work\n", encoding="utf-8")
|
||||
git(worktree, "add", "-A")
|
||||
git(worktree, "commit", "-q", "-m", "the work")
|
||||
|
||||
def stage_of(self, board: Path) -> str | None:
|
||||
for slug in config.STAGE_DIRS:
|
||||
if (board / "tasks" / slug / FILENAME).is_file():
|
||||
return slug
|
||||
return None
|
||||
|
||||
def text(self, board: Path) -> str:
|
||||
return (board / "tasks" / self.stage_of(board) / FILENAME).read_text(
|
||||
encoding="utf-8")
|
||||
|
||||
def sig(self, board: Path) -> dict[str, set[str]]:
|
||||
return {slug: {p.name for p in (board / "tasks" / slug).glob("*.md")}
|
||||
for slug in config.STAGE_DIRS
|
||||
if (board / "tasks" / slug).is_dir()}
|
||||
|
||||
def elsewhere(self) -> None:
|
||||
"""Switch processes, not just directories. Two boards are two
|
||||
programs: the expectations one holds in memory (I am about to move
|
||||
this file) the other never saw, and only the commit reaches it.
|
||||
The registries are module globals here, so say so explicitly."""
|
||||
state.EXPECTED_MOVES.clear()
|
||||
sync.ARRIVED.clear()
|
||||
|
||||
def gh_calls(self) -> list[list[str]]:
|
||||
if not self.gh_log.is_file():
|
||||
return []
|
||||
return [json.loads(line) for line in
|
||||
self.gh_log.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
|
||||
def gh_verbs(self) -> list[str]:
|
||||
return [" ".join(c[:2]) for c in self.gh_calls()]
|
||||
|
||||
def summaries(self) -> list[str]:
|
||||
return [e["summary"] for e in state.BOARD_EVENTS]
|
||||
|
||||
|
||||
class RemoteMovesAreInert(Boards):
|
||||
"""A move that arrived over origin renders and narrates. Nothing else."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.place(self.ada, "in-progress", card("In Progress", "ada"))
|
||||
self.use(self.elena)
|
||||
sync.pull_now()
|
||||
self.elsewhere() # the fixture's own pull is not evidence
|
||||
self.use(self.ada)
|
||||
self.opened: list[str] = []
|
||||
self.addCleanup(setattr, github, "open_pr_async", github.open_pr_async)
|
||||
github.open_pr_async = self.opened.append
|
||||
|
||||
def narrate(self, board: Path, action, *, fresh: bool = False) -> list[dict]:
|
||||
self.use(board)
|
||||
if fresh:
|
||||
self.elsewhere()
|
||||
before = self.sig(board)
|
||||
action()
|
||||
after = self.sig(board)
|
||||
state.BOARD_EVENTS.clear()
|
||||
watch.narrate(before, after)
|
||||
return [e for e in state.BOARD_EVENTS if e["kind"] == "move"]
|
||||
|
||||
def test_the_actors_board_opens_the_pr(self):
|
||||
moves = self.narrate(self.ada, lambda: taskfiles.move_task(
|
||||
FILENAME, "in-progress", "review"))
|
||||
|
||||
self.assertEqual(self.opened, [FILENAME])
|
||||
self.assertEqual(moves[0]["actor"], "you")
|
||||
self.assertFalse(moves[0]["remote"])
|
||||
|
||||
def test_the_same_move_arriving_at_a_replica_triggers_nothing(self):
|
||||
self.use(self.ada)
|
||||
taskfiles.move_task(FILENAME, "in-progress", "review")
|
||||
self.assertEqual(sync.push_now(), "ok")
|
||||
self.opened.clear()
|
||||
|
||||
moves = self.narrate(self.elena, sync.pull_now, fresh=True)
|
||||
|
||||
self.assertEqual(self.stage_of(self.elena), "review",
|
||||
"the replica renders the move")
|
||||
self.assertEqual(moves[0]["actor"], "ada")
|
||||
self.assertTrue(moves[0]["remote"])
|
||||
self.assertEqual(self.opened, [],
|
||||
"the side effect belongs to the board that acted")
|
||||
|
||||
def test_a_plain_hand_move_on_this_disk_still_acts(self):
|
||||
"""Inert means "arrived from elsewhere", not "unattributed": a mv in
|
||||
this checkout is still this board's user doing something."""
|
||||
moves = self.narrate(self.ada, lambda: shutil.move(
|
||||
str(self.ada / "tasks" / "in-progress" / FILENAME),
|
||||
str(self.ada / "tasks" / "review" / FILENAME)))
|
||||
|
||||
self.assertEqual(moves[0]["actor"], "disk")
|
||||
self.assertFalse(moves[0]["remote"])
|
||||
self.assertEqual(self.opened, [FILENAME])
|
||||
|
||||
def test_an_undone_move_coming_back_is_inert_too(self):
|
||||
"""The loser of a race has its file reverted by the rebase — which
|
||||
the watcher sees as a move. It arrived; it acts on nothing."""
|
||||
self.use(self.ada)
|
||||
taskfiles.move_task(FILENAME, "in-progress", "review")
|
||||
sync.push_now()
|
||||
self.use(self.elena)
|
||||
self.elsewhere()
|
||||
before = self.sig(self.elena)
|
||||
taskfiles.move_task(FILENAME, "in-progress", "backlog")
|
||||
self.assertEqual(sync.push_now(), "pulled")
|
||||
self.opened.clear()
|
||||
|
||||
state.BOARD_EVENTS.clear()
|
||||
watch.narrate(before, self.sig(self.elena))
|
||||
|
||||
self.assertEqual(self.stage_of(self.elena), "review")
|
||||
moves = [e for e in state.BOARD_EVENTS if e["kind"] == "move"]
|
||||
self.assertEqual((moves[0]["actor"], moves[0]["remote"]), ("ada", True))
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
|
||||
class ThePRLineTravels(Boards):
|
||||
"""The `**PR:**` line is the backstop behind the actor-only trigger, so
|
||||
it has to reach the other boards — which means committing it."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.place(self.ada, "review", card("Review", "ada"))
|
||||
self.work_branch(self.ada)
|
||||
self.use(self.elena)
|
||||
sync.pull_now()
|
||||
self.use(self.ada)
|
||||
|
||||
def test_opening_a_pr_writes_and_commits_the_url(self):
|
||||
github.maybe_open_pr(FILENAME)
|
||||
|
||||
self.assertIn(f"**PR:** {PR_URL}", self.text(self.ada))
|
||||
self.assertEqual(git(self.ada, "status", "--porcelain",
|
||||
"--untracked-files=no").stdout, "",
|
||||
"an uncommitted task file would stall sync outright")
|
||||
subject = git(self.ada, "log", "-1", "--pretty=%s").stdout.strip()
|
||||
self.assertEqual(subject, "board: 20 PR opened (ada)")
|
||||
self.assertTrue(subject.startswith(sync.BOARD_COMMIT),
|
||||
"sync's piggyback guard only publishes board commits")
|
||||
|
||||
def test_the_url_reaches_the_other_board(self):
|
||||
github.maybe_open_pr(FILENAME)
|
||||
self.assertEqual(sync.push_now(), "ok")
|
||||
|
||||
self.use(self.elena)
|
||||
self.assertEqual(sync.pull_now(), "pulled")
|
||||
|
||||
self.assertIn(f"**PR:** {PR_URL}", self.text(self.elena))
|
||||
self.assertEqual(
|
||||
taskfiles.read_task(self.elena / "tasks" / "review" / FILENAME,
|
||||
"review")["pr"], PR_URL,
|
||||
"the replica's poller adopts the PR from the file, read-only")
|
||||
|
||||
def test_a_second_attempt_is_a_no_op_not_a_second_pr(self):
|
||||
github.maybe_open_pr(FILENAME)
|
||||
state.BOARD_EVENTS.clear()
|
||||
|
||||
github.maybe_open_pr(FILENAME)
|
||||
|
||||
self.assertEqual(self.gh_verbs().count("pr create"), 1)
|
||||
self.assertEqual([s for s in self.summaries() if "failed" in s], [])
|
||||
|
||||
def test_a_double_that_crosses_on_github_adopts_the_open_pr(self):
|
||||
"""The rare double-fire: the file gate lost the race but GitHub
|
||||
holds the line — one PR still exists, and the card learns its url."""
|
||||
self.mode("exists")
|
||||
|
||||
github.maybe_open_pr(FILENAME)
|
||||
|
||||
self.assertIn(f"**PR:** {PR_URL}", self.text(self.ada))
|
||||
self.assertTrue(any("adopted it" in s for s in self.summaries()))
|
||||
self.assertEqual([s for s in self.summaries() if "failed" in s], [])
|
||||
|
||||
def test_a_real_failure_is_still_a_failure(self):
|
||||
self.mode("create-fails")
|
||||
|
||||
github.maybe_open_pr(FILENAME)
|
||||
|
||||
self.assertNotIn("**PR:**", self.text(self.ada))
|
||||
self.assertTrue(any("PR creation failed" in s for s in self.summaries()))
|
||||
|
||||
def test_with_the_gate_off_the_line_is_left_for_a_human(self):
|
||||
self.patch(SYNC=False, COMMIT_MOVES=False)
|
||||
head = git(self.ada, "rev-parse", "HEAD").stdout
|
||||
|
||||
github.maybe_open_pr(FILENAME)
|
||||
|
||||
self.assertIn(f"**PR:** {PR_URL}", self.text(self.ada))
|
||||
self.assertEqual(git(self.ada, "rev-parse", "HEAD").stdout, head,
|
||||
"single-player commits tasks/ by hand, as it always did")
|
||||
|
||||
|
||||
class TheExplicitOpenPR(Boards):
|
||||
"""Nobody finishes the actor's half-done side effect automatically —
|
||||
a person asks for it, and hears why when it cannot happen."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.place(self.ada, "review", card("Review", "ada"))
|
||||
self.use(self.ada)
|
||||
|
||||
def test_it_opens_the_pr_and_returns_the_url(self):
|
||||
self.work_branch(self.ada)
|
||||
|
||||
self.assertEqual(github.open_pr_now(FILENAME), PR_URL)
|
||||
self.assertIn(f"**PR:** {PR_URL}", self.text(self.ada))
|
||||
|
||||
def test_it_says_why_when_there_is_nothing_to_open(self):
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
github.open_pr_now(FILENAME)
|
||||
self.assertIn("no task/", str(caught.exception))
|
||||
|
||||
def test_it_says_so_when_the_card_already_has_one(self):
|
||||
self.work_branch(self.ada)
|
||||
github.open_pr_now(FILENAME)
|
||||
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
github.open_pr_now(FILENAME)
|
||||
self.assertIn("already has a PR", str(caught.exception))
|
||||
|
||||
def test_startup_reconcile_stands_down_in_team_mode(self):
|
||||
"""Every replica would race to open the same PR at startup."""
|
||||
self.work_branch(self.ada)
|
||||
|
||||
github.reconcile()
|
||||
|
||||
self.assertEqual(self.gh_verbs(), [])
|
||||
self.assertNotIn("**PR:**", self.text(self.ada))
|
||||
|
||||
|
||||
class ClaimsGateLaunches(Boards):
|
||||
"""Ownership means something: work does not start on someone else's
|
||||
card by accident."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.place(self.ada, "in-progress", card("In Progress", "ada"))
|
||||
self.use(self.elena)
|
||||
sync.pull_now()
|
||||
self.use(self.elena) # elena's board is the one clicking
|
||||
|
||||
def test_someone_elses_card_refuses_and_names_them(self):
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
agents.start_agent(FILENAME, "in-progress")
|
||||
|
||||
self.assertIn("ada holds", str(caught.exception))
|
||||
self.assertFalse((config.WORKTREES / STEM).exists(),
|
||||
"the refusal comes before any worktree is made")
|
||||
self.assertEqual(git(self.elena, "rev-parse", "--verify", "--quiet",
|
||||
BRANCH).returncode, 1)
|
||||
|
||||
def test_the_deliberate_takeover_reassigns_the_card(self):
|
||||
agents._claim_for_launch(FILENAME, "in-progress", True)
|
||||
|
||||
self.assertIn("**Assignee:** elena", self.text(self.elena))
|
||||
self.assertEqual(self.text(self.elena).count("**Assignee:**"), 1)
|
||||
self.assertEqual(git(self.elena, "log", "-1", "--pretty=%s").stdout.strip(),
|
||||
"board: 20 claimed by elena (elena)")
|
||||
self.assertTrue(any("took" in s and "over from ada" in s
|
||||
for s in self.summaries()))
|
||||
|
||||
def test_the_takeover_reaches_the_other_board(self):
|
||||
agents._claim_for_launch(FILENAME, "in-progress", True)
|
||||
self.assertEqual(sync.push_now(), "ok")
|
||||
|
||||
self.use(self.ada)
|
||||
self.assertEqual(sync.pull_now(), "pulled")
|
||||
self.assertIn("**Assignee:** elena", self.text(self.ada))
|
||||
|
||||
def test_an_unclaimed_card_claims_on_launch(self):
|
||||
self.place(self.elena, "in-progress", card("In Progress"), commit=False)
|
||||
|
||||
agents._claim_for_launch(FILENAME, "in-progress", False)
|
||||
|
||||
self.assertIn("**Assignee:** elena", self.text(self.elena))
|
||||
self.assertTrue(any("claimed" in s for s in self.summaries()))
|
||||
|
||||
def test_your_own_card_launches_untouched(self):
|
||||
self.place(self.elena, "in-progress", card("In Progress", "elena"),
|
||||
commit=False)
|
||||
head = git(self.elena, "rev-parse", "HEAD").stdout
|
||||
|
||||
agents._claim_for_launch(FILENAME, "in-progress", False)
|
||||
|
||||
self.assertEqual(git(self.elena, "rev-parse", "HEAD").stdout, head,
|
||||
"nothing to record: it was already yours")
|
||||
|
||||
def test_the_gate_off_refuses_nobody(self):
|
||||
"""Single-player never writes an assignee, so it never reads one as
|
||||
a lock — a hand-written line stays decoration."""
|
||||
self.patch(SYNC=False, COMMIT_MOVES=False)
|
||||
|
||||
agents._claim_for_launch(FILENAME, "in-progress", False)
|
||||
|
||||
self.assertIn("**Assignee:** ada", self.text(self.elena))
|
||||
|
||||
|
||||
class MergesGoThroughOrigin(Boards):
|
||||
"""With replicas, local main advances only by fast-forward — so the
|
||||
merge commit is made on origin, not here."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.place(self.ada, "review", card("Review", "ada", PR_URL))
|
||||
self.work_branch(self.ada)
|
||||
self.use(self.ada)
|
||||
git(self.ada, "push", "-q", "-u", "origin", BRANCH)
|
||||
|
||||
def merges_on_main(self, board: Path) -> int:
|
||||
out = git(board, "rev-list", "--count", "--merges", "main").stdout.strip()
|
||||
return int(out) if out.isdigit() else 0
|
||||
|
||||
def test_the_merge_is_made_on_origin(self):
|
||||
head = git(self.ada, "rev-parse", "main").stdout.strip()
|
||||
|
||||
result = github.complete_task(FILENAME, "review")
|
||||
|
||||
self.assertTrue(result["merged"])
|
||||
self.assertIn("pr merge", self.gh_verbs())
|
||||
self.assertEqual(self.merges_on_main(self.ada), 0,
|
||||
"the board never makes a merge commit of its own")
|
||||
self.assertEqual(
|
||||
git(self.ada, "rev-list", "--count", f"{head}..main").stdout.strip(),
|
||||
"1", "only the card's own move commit — main is otherwise untouched")
|
||||
self.assertEqual(self.stage_of(self.ada), "done")
|
||||
self.assertTrue(any("merged" in s and "on origin" in s
|
||||
for s in self.summaries()))
|
||||
|
||||
def test_the_worktree_and_local_branch_go(self):
|
||||
github.complete_task(FILENAME, "review")
|
||||
|
||||
self.assertFalse((config.WORKTREES / STEM).exists())
|
||||
self.assertEqual(git(self.ada, "rev-parse", "--verify", "--quiet",
|
||||
BRANCH).returncode, 1,
|
||||
"the branch is deleted even though local main never "
|
||||
"merged it — origin did")
|
||||
|
||||
def test_a_pr_origin_will_not_merge_leaves_the_card_alone(self):
|
||||
self.mode("unmergeable")
|
||||
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
github.complete_task(FILENAME, "review")
|
||||
|
||||
self.assertIn("origin would not merge", str(caught.exception))
|
||||
self.assertEqual(self.stage_of(self.ada), "review")
|
||||
self.assertEqual(git(self.ada, "rev-parse", "--verify", "--quiet",
|
||||
BRANCH).returncode, 0)
|
||||
|
||||
def test_no_pr_is_a_refusal_that_names_the_way_out(self):
|
||||
self.place(self.ada, "review", card("Review", "ada"), commit=False)
|
||||
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
github.complete_task(FILENAME, "review")
|
||||
|
||||
self.assertIn("open PR", str(caught.exception))
|
||||
self.assertEqual(self.stage_of(self.ada), "review")
|
||||
|
||||
|
||||
class TheLocalMergeIsUntouched(Boards):
|
||||
"""With sync off, merge & clean up is the path it always was."""
|
||||
|
||||
SYNC = False
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.patch(COMMIT_MOVES=False)
|
||||
self.place(self.ada, "review", card("Review", None, PR_URL))
|
||||
self.work_branch(self.ada)
|
||||
self.use(self.ada)
|
||||
|
||||
def test_it_merges_locally_and_pushes_main(self):
|
||||
github.complete_task(FILENAME, "review")
|
||||
|
||||
self.assertIn("the work", git(self.ada, "log", "--format=%s", "main").stdout)
|
||||
self.assertEqual(self.gh_verbs(), [], "gh is not part of this path")
|
||||
self.assertEqual(git(self.origin, "rev-parse", "main").stdout,
|
||||
git(self.ada, "rev-parse", "main").stdout)
|
||||
self.assertEqual(self.stage_of(self.ada), "done")
|
||||
self.assertTrue(any("merged" in s and "into main" in s
|
||||
for s in self.summaries()))
|
||||
|
||||
def test_startup_reconcile_still_catches_up(self):
|
||||
"""The single-player behaviour the team-mode stand-down replaces."""
|
||||
self.place(self.ada, "review", card("Review"), commit=False)
|
||||
|
||||
github.reconcile()
|
||||
|
||||
self.assertIn(f"**PR:** {PR_URL}", self.text(self.ada))
|
||||
|
||||
|
||||
class TheCardFace(unittest.TestCase):
|
||||
"""board.html is a single file with no frontend runner — these are the
|
||||
source-level invariants of the surface this card adds."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = (REPO / "manager" / "core" / "board.html").read_text(encoding="utf-8")
|
||||
cls.httpd = (REPO / "manager" / "core" / "httpd.py").read_text(encoding="utf-8")
|
||||
|
||||
def test_the_board_knows_who_it_is(self):
|
||||
self.assertIn('"me": taskfiles.actor_name() if config.COMMIT_MOVES else ""',
|
||||
self.httpd)
|
||||
|
||||
def test_someone_elses_card_offers_takeover_not_start_work(self):
|
||||
self.assertIn("const held = task.assignee && S.state.me && task.assignee !== S.state.me",
|
||||
self.html)
|
||||
self.assertIn("label: 'take over', confirm: `take from ${held}?`", self.html)
|
||||
self.assertIn("fireAgent(task, '/api/agent/start', { takeover: true })", self.html)
|
||||
|
||||
def test_the_takeover_is_armed_like_every_costly_action(self):
|
||||
"""confirm: … is what makes wireAction demand a second click."""
|
||||
index = self.html.index("label: 'take over'")
|
||||
self.assertIn("confirm:", self.html[index:index + 120])
|
||||
|
||||
def test_the_server_refuses_a_takeover_it_was_not_asked_for(self):
|
||||
self.assertIn("bool(payload.get(\"takeover\"))", self.httpd)
|
||||
|
||||
def test_a_review_card_without_a_pr_can_ask_for_one(self):
|
||||
self.assertIn("label: 'open PR', confirm: 'open it?', busy: 'opening…'", self.html)
|
||||
self.assertIn("run: () => openPR(task)", self.html)
|
||||
self.assertIn('/api/pr/open', self.html)
|
||||
self.assertIn('elif path == "/api/pr/open":', self.httpd)
|
||||
|
||||
def test_the_open_pr_action_needs_a_branch_and_no_pr(self):
|
||||
index = self.html.index("label: 'open PR'")
|
||||
guard = self.html[self.html.index("task.stage === 'review' && !task.pr"):index]
|
||||
self.assertIn("S.state.branches", guard)
|
||||
|
||||
def test_the_completion_sheet_tells_the_truth_in_team_mode(self):
|
||||
self.assertIn("(S.state.sync || {}).enabled", self.html)
|
||||
self.assertIn("the PR on GitHub", self.html)
|
||||
self.assertIn("local main fast-forwards on the next sync beat", self.html)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,168 @@
|
||||
"""The tab names its project (task 22): the title is "<project> · bench",
|
||||
project first, so two benches side by side are told apart at tab-bar width.
|
||||
|
||||
Two halves are tested here. The server half — config resolving the project
|
||||
name and httpd rendering it into the served page — runs in fresh
|
||||
interpreters, because config reads its settings at import and BOARD_TITLE
|
||||
is the thing under test. The browser half lives in board.html's inline JS
|
||||
with no frontend test runner, so it is checked as source-level invariants:
|
||||
the ones that, if broken, would let the tab drift back to a generic string
|
||||
or put the view name before the project.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
CORE = REPO / "manager" / "core"
|
||||
BOARD = CORE / "board.html"
|
||||
|
||||
# BOARD_TITLE unset in the process environment is "nothing configured":
|
||||
# process env beats local/.env, so this also neutralizes a developer's own
|
||||
# override leaking into the defaults test.
|
||||
UNSET = {"BOARD_TITLE": ""}
|
||||
|
||||
|
||||
def _probe(expression: str, settings: dict) -> object:
|
||||
"""Evaluate an expression against config/httpd in a fresh interpreter,
|
||||
with the given settings in the environment config reads at import."""
|
||||
env = dict(os.environ)
|
||||
env.update(UNSET)
|
||||
env.update(settings)
|
||||
out = subprocess.check_output(
|
||||
[sys.executable, "-c",
|
||||
"import sys, json; sys.path.insert(0, sys.argv[1]); "
|
||||
"import config, httpd; print(json.dumps(eval(sys.argv[2])))",
|
||||
str(CORE), expression],
|
||||
env=env, text=True)
|
||||
return json.loads(out)
|
||||
|
||||
|
||||
def _title_of(settings: dict) -> str:
|
||||
"""The <title> text of the page the board would serve."""
|
||||
page = _probe("httpd.page_bytes().decode('utf-8')", settings)
|
||||
match = re.search(r"<title>(.*?)</title>", page, re.DOTALL)
|
||||
assert match, "the served page lost its <title>"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
class ProjectName(unittest.TestCase):
|
||||
def test_defaults_to_the_repo_directory_name(self):
|
||||
"""The name nobody has to configure: what the checkout is called."""
|
||||
self.assertEqual(_probe("config.PROJECT", {}), REPO.name)
|
||||
|
||||
def test_board_title_overrides_it(self):
|
||||
"""For people whose checkout directories are all called "app"."""
|
||||
self.assertEqual(_probe("config.PROJECT", {"BOARD_TITLE": "payments"}),
|
||||
"payments")
|
||||
|
||||
def test_a_blank_setting_is_not_a_blank_title(self):
|
||||
"""An empty or whitespace-only value means "not configured", not
|
||||
"call this board nothing"."""
|
||||
self.assertEqual(_probe("config.PROJECT", {"BOARD_TITLE": " "}),
|
||||
REPO.name)
|
||||
|
||||
def test_the_state_payload_carries_it(self):
|
||||
"""The browser needs it too — the view switcher rewrites the title
|
||||
without refetching the page."""
|
||||
self.assertEqual(_probe("httpd.state_payload()['project']",
|
||||
{"BOARD_TITLE": "payments"}), "payments")
|
||||
|
||||
|
||||
class ServedTitle(unittest.TestCase):
|
||||
def test_the_title_is_rendered_into_the_page(self):
|
||||
"""Server-rendered, so the tab is right on first paint rather than
|
||||
flickering from generic to named on every refresh."""
|
||||
self.assertEqual(_title_of({"BOARD_TITLE": "payments"}),
|
||||
"payments · bench")
|
||||
|
||||
def test_two_projects_get_two_titles(self):
|
||||
"""The whole point: distinguishable in the tab bar, in cmd-tab and
|
||||
in history — and distinguishable by their *first* word."""
|
||||
a = _title_of({"BOARD_TITLE": "projectA"})
|
||||
b = _title_of({"BOARD_TITLE": "projectB"})
|
||||
self.assertNotEqual(a, b)
|
||||
self.assertTrue(a.startswith("projectA") and b.startswith("projectB"),
|
||||
f"the project must lead the title, got {a!r} / {b!r}")
|
||||
|
||||
def test_the_default_title_names_this_checkout(self):
|
||||
self.assertEqual(_title_of({}), f"{REPO.name} · bench")
|
||||
|
||||
def test_a_project_name_cannot_inject_markup(self):
|
||||
"""The name comes from a directory or a settings file, both of which
|
||||
can hold anything — it is escaped, not spliced."""
|
||||
title = _title_of({"BOARD_TITLE": "<script>x</script>"})
|
||||
self.assertNotIn("<script>", title)
|
||||
self.assertIn("<script>", title)
|
||||
|
||||
def test_nothing_else_in_the_page_is_disturbed(self):
|
||||
"""Only the title element is rewritten; the rest is the file."""
|
||||
page = _probe("httpd.page_bytes().decode('utf-8')",
|
||||
{"BOARD_TITLE": "payments"})
|
||||
source = BOARD.read_text(encoding="utf-8")
|
||||
strip = lambda s: re.sub(r"<title>.*?</title>", "", s, count=1,
|
||||
flags=re.DOTALL)
|
||||
self.assertEqual(strip(page), strip(source))
|
||||
|
||||
|
||||
class PageInvariants(unittest.TestCase):
|
||||
"""board.html's own half: the fallback title and the code that keeps it
|
||||
in step with the view switcher."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = BOARD.read_text(encoding="utf-8")
|
||||
|
||||
def test_the_shipped_title_is_not_the_old_generic_string(self):
|
||||
""""Bench — task board" in two tabs was the bug."""
|
||||
self.assertNotIn("task board", self.html.lower())
|
||||
|
||||
def test_the_title_is_only_ever_written_from_the_project(self):
|
||||
"""One writer, and it starts with the project — so no code path can
|
||||
put the view name first or revert to the generic string."""
|
||||
writes = re.findall(r"document\.title\s*=\s*([^\n;]+)", self.html)
|
||||
self.assertEqual(len(writes), 1,
|
||||
f"expected one document.title assignment, got {writes}")
|
||||
self.assertTrue(writes[0].startswith("S.state.project"),
|
||||
f"the project must lead the title, got {writes[0]!r}")
|
||||
|
||||
def test_a_stateless_page_keeps_the_served_title(self):
|
||||
"""Before the first state load there is nothing better to say than
|
||||
what the server already rendered."""
|
||||
body = re.search(r"function renderTitle\(\)\s*\{(.*?)\n\}",
|
||||
self.html, re.DOTALL)
|
||||
self.assertIsNotNone(body, "board.html lost renderTitle()")
|
||||
self.assertIn("if (!S.state?.project) return;", body.group(1),
|
||||
"renderTitle must bail out rather than write a "
|
||||
"project-less title")
|
||||
|
||||
def test_every_view_has_a_tail(self):
|
||||
"""The switcher may suffix, but the board view keeps the name people
|
||||
bookmarked: "<project> · bench"."""
|
||||
table = re.search(r"const VIEW_TITLES = \{(.*?)\};", self.html, re.DOTALL)
|
||||
self.assertIsNotNone(table, "board.html lost VIEW_TITLES")
|
||||
tails = dict(re.findall(r"(\w+): '([^']+)'", table.group(1)))
|
||||
views = set(re.findall(r'data-view="(\w+)"', self.html))
|
||||
self.assertEqual(set(tails), views,
|
||||
"every view in the switcher needs a title tail")
|
||||
self.assertEqual(tails["board"], "bench")
|
||||
|
||||
def test_the_title_follows_every_render(self):
|
||||
"""render() runs on state loads and on view switches alike, so
|
||||
hanging renderTitle off it covers both."""
|
||||
body = re.search(r"function render\(\)\s*\{(.*?)\n\}", self.html, re.DOTALL)
|
||||
self.assertIsNotNone(body, "board.html lost render()")
|
||||
self.assertIn("renderTitle();", body.group(1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,565 @@
|
||||
"""Boards sync through origin/main (task 19): a move pushes, a beat pulls,
|
||||
a lost race is a toast, and a human's unpushed commit is never published.
|
||||
|
||||
Everything runs against real clones of a real bare upstream — the whole
|
||||
point of the card is what git actually does under a race, so nothing here
|
||||
is mocked except the SSE fan-out (captured, to read what the board said).
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO / "manager" / "core"))
|
||||
|
||||
import config # noqa: E402
|
||||
import state # noqa: E402
|
||||
import sync # noqa: E402
|
||||
import taskfiles # noqa: E402
|
||||
import watch # noqa: E402
|
||||
|
||||
FILENAME = "07-shared-card.md"
|
||||
CARD = ("# 07 — A card two boards can reach\n\n"
|
||||
"**Status:** Backlog\n"
|
||||
"**Priority:** High\n"
|
||||
"**Type:** Feature\n\n"
|
||||
"Body text long enough that git sees a rename rather than a delete\n"
|
||||
"and an add when the file moves between two stage directories, which\n"
|
||||
"is what turns a same-card race into a conflict it can report.\n")
|
||||
|
||||
|
||||
def git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["git", "-C", str(cwd), *args],
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
class TwoBoards(unittest.TestCase):
|
||||
"""One bare upstream, two clones — 'ada' and 'elena', each with its own
|
||||
board. config.REPO/TASKS point at whichever board is acting."""
|
||||
|
||||
def setUp(self):
|
||||
# resolve(): macOS tempdirs sit behind the /var → /private/var
|
||||
# symlink and git reports the resolved path.
|
||||
self.tmp = Path(tempfile.mkdtemp(prefix="bench-sync-")).resolve()
|
||||
self.addCleanup(shutil.rmtree, self.tmp, True)
|
||||
self.origin = self.tmp / "origin.git"
|
||||
subprocess.run(["git", "init", "-q", "--bare", "-b", "main", str(self.origin)],
|
||||
check=True, capture_output=True)
|
||||
|
||||
self.ada = self._clone("ada")
|
||||
for slug in config.STAGE_DIRS:
|
||||
(self.ada / "tasks" / slug).mkdir(parents=True)
|
||||
(self.ada / "tasks" / "backlog" / FILENAME).write_text(CARD, encoding="utf-8")
|
||||
(self.ada / "code.txt").write_text("shipped\n", encoding="utf-8")
|
||||
git(self.ada, "add", "-A")
|
||||
git(self.ada, "commit", "-q", "-m", "root")
|
||||
git(self.ada, "push", "-q", "origin", "main")
|
||||
self.elena = self._clone("elena")
|
||||
|
||||
# REPO/TASKS are patched here (not just assigned by use()) so the
|
||||
# checkout under test is restored for every other test module.
|
||||
self.patch(SYNC=True, COMMIT_MOVES=True, FETCH_TIMEOUT=10.0,
|
||||
SESSIONS_DIR=self.tmp / "sessions",
|
||||
REPO=self.ada, TASKS=self.ada / "tasks")
|
||||
self.use(self.ada)
|
||||
|
||||
state.BOARD_EVENTS.clear()
|
||||
state.EXPECTED_MOVES.clear()
|
||||
state.COMMIT_HOOKS.clear()
|
||||
self.addCleanup(state.COMMIT_HOOKS.clear)
|
||||
sync.ARRIVED.clear()
|
||||
sync._NOTES.clear()
|
||||
self.addCleanup(sync._NOTES.clear)
|
||||
self.addCleanup(sync.ARRIVED.clear)
|
||||
|
||||
self.broadcasts: list[dict] = []
|
||||
self.addCleanup(setattr, state, "broadcast", state.broadcast)
|
||||
state.broadcast = self.broadcasts.append
|
||||
|
||||
def _clone(self, who: str) -> Path:
|
||||
path = self.tmp / who
|
||||
subprocess.run(["git", "clone", "-q", str(self.origin), str(path)],
|
||||
check=True, capture_output=True)
|
||||
git(path, "config", "user.name", who)
|
||||
git(path, "config", "user.email", f"{who}@example.com")
|
||||
return path
|
||||
|
||||
def patch(self, **values) -> None:
|
||||
for attr, value in values.items():
|
||||
self.addCleanup(setattr, config, attr, getattr(config, attr))
|
||||
setattr(config, attr, value)
|
||||
|
||||
# — acting as one board or the other —
|
||||
|
||||
def use(self, board: Path) -> None:
|
||||
config.REPO, config.TASKS = board, board / "tasks"
|
||||
|
||||
def move(self, board: Path, source: str, target: str) -> None:
|
||||
self.use(board)
|
||||
taskfiles.move_task(FILENAME, source, target)
|
||||
|
||||
def stage_of(self, board: Path) -> str | None:
|
||||
for slug in config.STAGE_DIRS:
|
||||
if (board / "tasks" / slug / FILENAME).is_file():
|
||||
return slug
|
||||
return None
|
||||
|
||||
def card(self, board: Path) -> str:
|
||||
stage = self.stage_of(board)
|
||||
return (board / "tasks" / stage / FILENAME).read_text(encoding="utf-8")
|
||||
|
||||
def head(self, board: Path) -> str:
|
||||
return git(board, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
def origin_head(self) -> str:
|
||||
return git(self.origin, "rev-parse", "main").stdout.strip()
|
||||
|
||||
def summaries(self) -> list[str]:
|
||||
return [e["summary"] for e in state.BOARD_EVENTS]
|
||||
|
||||
def toasts(self) -> list[str]:
|
||||
return [b["message"] for b in self.broadcasts if b.get("type") == "toast"]
|
||||
|
||||
def sig(self, board: Path) -> dict[str, set[str]]:
|
||||
self.use(board)
|
||||
return {slug: {p.name for p in (board / "tasks" / slug).glob("*.md")}
|
||||
for slug in config.STAGE_DIRS
|
||||
if (board / "tasks" / slug).is_dir()}
|
||||
|
||||
# — a move reaching the other board —
|
||||
|
||||
def test_a_move_pushes_and_the_other_board_pulls_it(self):
|
||||
self.move(self.ada, "backlog", "to-do")
|
||||
self.assertEqual(sync.push_now(), "ok")
|
||||
|
||||
self.use(self.elena)
|
||||
self.assertEqual(sync.pull_now(), "pulled")
|
||||
|
||||
self.assertEqual(self.stage_of(self.elena), "to-do")
|
||||
self.assertEqual(self.card(self.elena), self.card(self.ada),
|
||||
"both boards hold the same bytes")
|
||||
self.assertIn("**Assignee:** ada", self.card(self.elena))
|
||||
|
||||
def test_the_pull_attributes_the_move_to_its_author(self):
|
||||
self.move(self.ada, "backlog", "to-do")
|
||||
sync.push_now()
|
||||
|
||||
self.use(self.elena)
|
||||
before = self.sig(self.elena)
|
||||
sync.pull_now()
|
||||
after = self.sig(self.elena)
|
||||
|
||||
# Two boards are two processes: elena's has no memory of ada moving
|
||||
# anything, so nothing but the pull can attribute this.
|
||||
state.EXPECTED_MOVES.clear()
|
||||
state.BOARD_EVENTS.clear()
|
||||
watch.narrate(before, after)
|
||||
moves = [e for e in state.BOARD_EVENTS if e["kind"] == "move"]
|
||||
self.assertEqual(len(moves), 1)
|
||||
self.assertEqual(moves[0]["actor"], "ada",
|
||||
"a move that arrived over origin is not 'disk'")
|
||||
self.assertEqual((moves[0]["from"], moves[0]["to"]), ("backlog", "to-do"))
|
||||
|
||||
def test_attribution_is_consumed_once_and_expires(self):
|
||||
self.move(self.ada, "backlog", "to-do")
|
||||
sync.push_now()
|
||||
self.use(self.elena)
|
||||
sync.pull_now()
|
||||
|
||||
self.assertEqual(sync.arrived_actor(FILENAME), "ada")
|
||||
self.assertEqual(sync.arrived_actor(FILENAME), "",
|
||||
"the next hand-move on this disk is not ada's")
|
||||
|
||||
def test_a_pull_that_brings_nothing_says_nothing(self):
|
||||
self.use(self.elena)
|
||||
state.BOARD_EVENTS.clear()
|
||||
|
||||
self.assertEqual(sync.pull_now(), "up-to-date")
|
||||
self.assertEqual(self.summaries(), [])
|
||||
|
||||
def test_the_commit_hook_publishes_without_being_asked(self):
|
||||
sync.install()
|
||||
self.assertIn(sync.on_commit, state.COMMIT_HOOKS)
|
||||
|
||||
before = self.origin_head()
|
||||
self.move(self.ada, "backlog", "to-do")
|
||||
for _ in range(100): # the push runs off-thread
|
||||
if self.origin_head() != before:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
self.assertEqual(self.origin_head(), self.head(self.ada),
|
||||
"the move published itself")
|
||||
|
||||
# — the same-card race —
|
||||
|
||||
def test_losing_a_race_undoes_the_move_and_names_who_took_the_card(self):
|
||||
self.move(self.ada, "backlog", "to-do")
|
||||
self.assertEqual(sync.push_now(), "ok")
|
||||
|
||||
# elena never saw ada's move: her board still shows the card in
|
||||
# backlog/ and she moves it somewhere else entirely.
|
||||
self.move(self.elena, "backlog", "in-progress")
|
||||
self.assertEqual(sync.push_now(), "pulled")
|
||||
|
||||
self.assertEqual(self.stage_of(self.elena), "to-do",
|
||||
"the loser's board holds the winner's version")
|
||||
self.assertEqual(self.card(self.elena), self.card(self.ada))
|
||||
self.assertIn("**Assignee:** ada", self.card(self.elena))
|
||||
self.assertEqual(self.origin_head(), self.head(self.elena))
|
||||
self.assertIn("07 claimed by ada — your move was undone", self.toasts())
|
||||
self.assertIn("07 claimed by ada — your move was undone", self.summaries())
|
||||
|
||||
def test_exactly_one_claim_survives_when_both_claim_the_same_card(self):
|
||||
self.move(self.ada, "backlog", "to-do")
|
||||
sync.push_now()
|
||||
self.move(self.elena, "backlog", "to-do") # same target, other name
|
||||
sync.push_now()
|
||||
|
||||
self.use(self.ada)
|
||||
sync.pull_now()
|
||||
|
||||
self.assertEqual(self.card(self.ada), self.card(self.elena),
|
||||
"both boards converge on the same file bytes")
|
||||
self.assertEqual(self.card(self.elena).count("**Assignee:**"), 1)
|
||||
self.assertIn("**Assignee:** ada", self.card(self.elena))
|
||||
self.assertEqual(self.origin_head(), self.head(self.ada))
|
||||
self.assertEqual(self.origin_head(), self.head(self.elena))
|
||||
|
||||
def test_the_winner_keeps_moving_after_the_loser_gave_way(self):
|
||||
"""Convergence is not a dead end: the board that lost re-reads the
|
||||
card and can move it on, and that move publishes normally."""
|
||||
self.move(self.ada, "backlog", "to-do")
|
||||
sync.push_now()
|
||||
self.move(self.elena, "backlog", "in-progress")
|
||||
sync.push_now()
|
||||
|
||||
self.move(self.elena, "to-do", "in-progress")
|
||||
self.assertEqual(sync.push_now(), "ok")
|
||||
|
||||
self.use(self.ada)
|
||||
sync.pull_now()
|
||||
self.assertEqual(self.stage_of(self.ada), "in-progress")
|
||||
|
||||
def test_a_race_on_two_different_cards_keeps_both_moves(self):
|
||||
other = "08-another-card.md"
|
||||
(self.ada / "tasks" / "backlog" / other).write_text(
|
||||
CARD.replace("# 07", "# 08"), encoding="utf-8")
|
||||
git(self.ada, "add", "-A")
|
||||
git(self.ada, "commit", "-q", "-m", "board: 08 → backlog (ada)")
|
||||
git(self.ada, "push", "-q", "origin", "main")
|
||||
self.use(self.elena)
|
||||
sync.pull_now()
|
||||
|
||||
self.move(self.ada, "backlog", "to-do")
|
||||
sync.push_now()
|
||||
self.use(self.elena)
|
||||
taskfiles.move_task(other, "backlog", "to-do")
|
||||
|
||||
self.assertEqual(sync.push_now(), "pulled")
|
||||
self.assertTrue((self.elena / "tasks" / "to-do" / other).is_file(),
|
||||
"elena's own move survives a rebase it does not collide with")
|
||||
self.assertTrue((self.elena / "tasks" / "to-do" / FILENAME).is_file())
|
||||
self.assertEqual(self.toasts(), [], "nothing was undone")
|
||||
|
||||
def test_a_drag_started_before_the_card_moved_underneath_is_refused(self):
|
||||
"""The mid-drag race. The browser sends the stage it picked the card
|
||||
up from, so a move that arrived meanwhile makes the drop stale — and
|
||||
a stale drop must fail, not resurrect the card in two places."""
|
||||
self.move(self.ada, "backlog", "to-do")
|
||||
sync.push_now()
|
||||
self.use(self.elena)
|
||||
sync.pull_now()
|
||||
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
taskfiles.move_task(FILENAME, "backlog", "in-progress")
|
||||
|
||||
self.assertIn("no longer in backlog/", str(caught.exception))
|
||||
self.assertEqual(self.stage_of(self.elena), "to-do")
|
||||
self.assertFalse((self.elena / "tasks" / "in-progress" / FILENAME).exists())
|
||||
|
||||
# — the piggyback guard —
|
||||
|
||||
def test_a_human_commit_on_main_stops_the_push(self):
|
||||
self.use(self.ada)
|
||||
(self.ada / "code.txt").write_text("my unpushed experiment\n", encoding="utf-8")
|
||||
git(self.ada, "commit", "-qam", "wip: not ready for anyone else")
|
||||
before = self.origin_head()
|
||||
|
||||
self.move(self.ada, "backlog", "to-do")
|
||||
|
||||
self.assertEqual(sync.push_now(), "stray")
|
||||
self.assertEqual(self.origin_head(), before, "origin never saw it")
|
||||
self.assertNotIn("wip: not ready for anyone else",
|
||||
git(self.origin, "log", "--format=%s", "main").stdout)
|
||||
warnings = [s for s in self.summaries() if "not a board commit" in s]
|
||||
self.assertEqual(len(warnings), 1)
|
||||
self.assertIn("wip: not ready for anyone else", warnings[0])
|
||||
self.assertEqual(sync.status()["state"], "stalled")
|
||||
|
||||
def test_the_guard_warns_once_however_often_the_beat_runs(self):
|
||||
self.use(self.ada)
|
||||
git(self.ada, "commit", "-q", "--allow-empty", "-m", "wip: mine")
|
||||
self.move(self.ada, "backlog", "to-do")
|
||||
|
||||
for _ in range(4):
|
||||
sync.push_now()
|
||||
sync.pull_now()
|
||||
|
||||
self.assertEqual(len([s for s in self.summaries() if "not a board commit" in s]), 1)
|
||||
|
||||
def test_a_human_commit_also_blocks_the_rebase_and_says_so(self):
|
||||
self.move(self.ada, "backlog", "to-do")
|
||||
sync.push_now()
|
||||
|
||||
self.use(self.elena)
|
||||
(self.elena / "code.txt").write_text("elena's experiment\n", encoding="utf-8")
|
||||
git(self.elena, "commit", "-qam", "wip: elena's own work")
|
||||
before = self.head(self.elena)
|
||||
|
||||
self.assertEqual(sync.pull_now(), "diverged")
|
||||
self.assertEqual(self.head(self.elena), before,
|
||||
"nothing was rebased over the human's commit")
|
||||
self.assertEqual(self.stage_of(self.elena), "backlog")
|
||||
self.assertTrue(any("diverged" in s for s in self.summaries()))
|
||||
self.assertEqual(sync.status()["state"], "stalled")
|
||||
|
||||
def test_the_guard_stands_down_once_the_human_commit_is_gone(self):
|
||||
self.use(self.ada)
|
||||
git(self.ada, "commit", "-q", "--allow-empty", "-m", "wip: mine")
|
||||
self.move(self.ada, "backlog", "to-do")
|
||||
self.assertEqual(sync.push_now(), "stray")
|
||||
|
||||
git(self.ada, "push", "-q", "origin", "main") # the human pushes it themselves
|
||||
self.assertEqual(sync.push_now(), "nothing")
|
||||
self.assertEqual(sync.status()["state"], "ok")
|
||||
|
||||
# — offline —
|
||||
|
||||
def test_an_unreachable_origin_is_quiet_and_catches_up(self):
|
||||
self.use(self.ada)
|
||||
git(self.ada, "remote", "set-url", "origin", str(self.tmp / "gone.git"))
|
||||
self.move(self.ada, "backlog", "to-do")
|
||||
|
||||
self.assertEqual(sync.push_now(), "offline")
|
||||
for _ in range(3):
|
||||
self.assertEqual(sync.pull_now(), "offline")
|
||||
self.assertEqual(len([s for s in self.summaries() if "unreachable" in s]), 1,
|
||||
"one quiet note, not one per beat")
|
||||
self.assertEqual(sync.status()["state"], "offline")
|
||||
self.assertEqual(self.stage_of(self.ada), "to-do",
|
||||
"the board kept working while origin was gone")
|
||||
|
||||
git(self.ada, "remote", "set-url", "origin", str(self.origin))
|
||||
self.assertEqual(sync.pull_now(), "up-to-date")
|
||||
|
||||
self.assertEqual(self.origin_head(), self.head(self.ada),
|
||||
"the queued commit went out on the next reachable beat")
|
||||
self.assertTrue(any("caught up" in s for s in self.summaries()))
|
||||
self.assertEqual(sync.status()["state"], "ok")
|
||||
self.use(self.elena)
|
||||
sync.pull_now()
|
||||
self.assertEqual(self.stage_of(self.elena), "to-do")
|
||||
|
||||
def test_no_origin_at_all_is_simply_nothing_to_do(self):
|
||||
self.use(self.ada)
|
||||
git(self.ada, "remote", "remove", "origin")
|
||||
self.move(self.ada, "backlog", "to-do")
|
||||
|
||||
self.assertEqual(sync.push_now(), "no-origin")
|
||||
self.assertEqual(sync.pull_now(), "no-origin")
|
||||
self.assertEqual(self.summaries(), [])
|
||||
|
||||
# — never pulling into a checkout that is not ready —
|
||||
|
||||
def test_uncommitted_changes_stall_the_pull_loudly(self):
|
||||
self.move(self.ada, "backlog", "to-do")
|
||||
sync.push_now()
|
||||
|
||||
self.use(self.elena)
|
||||
(self.elena / "code.txt").write_text("half-finished\n", encoding="utf-8")
|
||||
before = self.head(self.elena)
|
||||
|
||||
self.assertEqual(sync.pull_now(), "dirty")
|
||||
self.assertEqual(self.head(self.elena), before)
|
||||
self.assertEqual((self.elena / "code.txt").read_text(encoding="utf-8"),
|
||||
"half-finished\n")
|
||||
self.assertTrue(any("uncommitted changes" in s for s in self.summaries()))
|
||||
self.assertEqual(sync.status()["state"], "stalled")
|
||||
|
||||
git(self.elena, "checkout", "--", "code.txt")
|
||||
self.assertEqual(sync.pull_now(), "pulled")
|
||||
self.assertEqual(sync.status()["state"], "ok")
|
||||
|
||||
def test_untracked_files_do_not_stall_anything(self):
|
||||
self.move(self.ada, "backlog", "to-do")
|
||||
sync.push_now()
|
||||
|
||||
self.use(self.elena)
|
||||
(self.elena / "scratch.txt").write_text("mine\n", encoding="utf-8")
|
||||
|
||||
self.assertEqual(sync.pull_now(), "pulled")
|
||||
|
||||
def test_a_checkout_off_main_pauses_sync(self):
|
||||
self.move(self.ada, "backlog", "to-do")
|
||||
sync.push_now()
|
||||
|
||||
self.use(self.elena)
|
||||
git(self.elena, "checkout", "-q", "-b", "side")
|
||||
before = self.head(self.elena)
|
||||
|
||||
self.assertEqual(sync.pull_now(), "not-on-main")
|
||||
self.assertEqual(self.head(self.elena), before)
|
||||
self.assertTrue(any("not main" in s for s in self.summaries()))
|
||||
|
||||
# — the gate —
|
||||
|
||||
def test_the_gate_off_does_not_touch_the_network(self):
|
||||
self.patch(SYNC=False)
|
||||
self.use(self.ada)
|
||||
# A remote that never answers: anything that fetched or pushed here
|
||||
# would hang instead of returning at once.
|
||||
git(self.ada, "config", "protocol.ext.allow", "always")
|
||||
git(self.ada, "remote", "set-url", "origin", "ext::sleep 30")
|
||||
|
||||
started = time.monotonic()
|
||||
self.assertEqual(sync.push_now(), "off")
|
||||
self.assertEqual(sync.pull_now(), "off")
|
||||
sync.on_commit(FILENAME)
|
||||
self.assertLess(time.monotonic() - started, 2)
|
||||
self.assertEqual(self.summaries(), [])
|
||||
self.assertEqual(sync.status(), {"enabled": False, "state": "off", "detail": ""})
|
||||
|
||||
def test_the_gate_off_leaves_moves_exactly_as_they_were(self):
|
||||
self.patch(SYNC=False, COMMIT_MOVES=False)
|
||||
before = self.origin_head()
|
||||
|
||||
self.move(self.ada, "backlog", "to-do")
|
||||
|
||||
self.assertEqual(self.stage_of(self.ada), "to-do")
|
||||
self.assertEqual(self.head(self.ada), before, "no commit, no push")
|
||||
self.assertEqual(self.origin_head(), before)
|
||||
self.assertNotIn("**Assignee:**", self.card(self.ada))
|
||||
|
||||
def test_a_registered_hook_is_inert_with_the_gate_off(self):
|
||||
sync.install()
|
||||
self.patch(SYNC=False)
|
||||
before = self.origin_head()
|
||||
|
||||
self.move(self.ada, "backlog", "to-do")
|
||||
time.sleep(0.2)
|
||||
|
||||
self.assertEqual(self.origin_head(), before)
|
||||
|
||||
def test_the_watcher_still_says_disk_for_a_plain_hand_move(self):
|
||||
self.use(self.ada)
|
||||
before = self.sig(self.ada)
|
||||
shutil.move(str(self.ada / "tasks" / "backlog" / FILENAME),
|
||||
str(self.ada / "tasks" / "to-do" / FILENAME))
|
||||
state.BOARD_EVENTS.clear()
|
||||
|
||||
watch.narrate(before, self.sig(self.ada))
|
||||
|
||||
self.assertEqual(state.BOARD_EVENTS[0]["actor"], "disk")
|
||||
|
||||
|
||||
class TheGateImpliesCommitMoves(unittest.TestCase):
|
||||
"""BOARD_SYNC=1 turns BOARD_COMMIT_MOVES on: there is nothing to publish
|
||||
until moves commit themselves."""
|
||||
|
||||
def reload(self, **env) -> None:
|
||||
saved = {k: os.environ.get(k) for k in ("BOARD_SYNC", "BOARD_COMMIT_MOVES")}
|
||||
|
||||
def restore():
|
||||
for key, value in saved.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
importlib.reload(config)
|
||||
|
||||
self.addCleanup(restore)
|
||||
for key in saved:
|
||||
os.environ.pop(key, None)
|
||||
os.environ.update(env)
|
||||
importlib.reload(config)
|
||||
|
||||
def test_sync_on_implies_commit_moves(self):
|
||||
self.reload(BOARD_SYNC="1")
|
||||
self.assertTrue(config.SYNC)
|
||||
self.assertTrue(config.COMMIT_MOVES)
|
||||
|
||||
def test_both_are_off_by_default(self):
|
||||
self.reload()
|
||||
self.assertFalse(config.SYNC)
|
||||
self.assertFalse(config.COMMIT_MOVES)
|
||||
self.assertEqual(config.SYNC_INTERVAL, 30.0)
|
||||
|
||||
def test_commit_moves_alone_stays_alone(self):
|
||||
self.reload(BOARD_COMMIT_MOVES="1")
|
||||
self.assertTrue(config.COMMIT_MOVES)
|
||||
self.assertFalse(config.SYNC)
|
||||
|
||||
|
||||
class TheStrayCommitTest(unittest.TestCase):
|
||||
"""The piggyback guard reads commit subjects — the one thing standing
|
||||
between a human's private work and origin."""
|
||||
|
||||
def test_board_commits_pass(self):
|
||||
self.assertEqual(sync._stray(["abc board: 07 → to-do (ada)",
|
||||
"def board: 08 → done (elena)"]), "")
|
||||
|
||||
def test_the_oldest_stray_is_the_one_named(self):
|
||||
self.assertEqual(
|
||||
sync._stray(["abc board: 07 → to-do (ada)", "def wip: older", "aaa wip: oldest"]),
|
||||
"aaa wip: oldest")
|
||||
|
||||
def test_a_commit_merely_mentioning_the_board_is_still_stray(self):
|
||||
self.assertEqual(sync._stray(["abc fix the board: really"]),
|
||||
"abc fix the board: really")
|
||||
|
||||
|
||||
class TheSyncChip(unittest.TestCase):
|
||||
"""board.html is a single file with no frontend runner — these are the
|
||||
source-level invariants of the surface this card adds."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = (REPO / "manager" / "core" / "board.html").read_text(encoding="utf-8")
|
||||
|
||||
def test_a_server_toast_reaches_the_person(self):
|
||||
self.assertIn("msg.type === 'toast'", self.html)
|
||||
self.assertIn("toast(msg.message, !!msg.error)", self.html)
|
||||
|
||||
def test_the_chip_hides_itself_while_sync_is_healthy(self):
|
||||
self.assertIn("if (!s || !s.enabled || s.state === 'ok') { el.hidden = true;", self.html)
|
||||
self.assertIn(".livechip[hidden]{display:none}", self.html,
|
||||
"the chip's own display:flex would beat the UA's [hidden]")
|
||||
|
||||
def test_a_refused_move_re_reads_the_board(self):
|
||||
"""What makes the mid-drag race safe in the browser: the drop sends
|
||||
the stage it started from, and a rejected move reloads disk state
|
||||
rather than leaving the stale card on screen."""
|
||||
self.assertIn("const { file, from } = JSON.parse(e.dataTransfer.getData("
|
||||
"'application/json'));", self.html)
|
||||
self.assertIn("if (!res.ok) { toast(data.error || 'move failed', true); "
|
||||
"await loadState(); return false; }", self.html)
|
||||
|
||||
def test_sync_events_have_a_glyph_and_a_filter(self):
|
||||
self.assertIn("sync: '⇅'", self.html)
|
||||
self.assertIn("new Set(['move', 'new', 'agent', 'sync'])", self.html)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Claiming is moving (task 18): a board-made move writes the assignee and
|
||||
commits itself — and with the gate off, changes nothing about today.
|
||||
|
||||
Every case runs against a throwaway git repo standing in for the project, so
|
||||
the commit behaviour is checked against real git rather than a mock.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO / "manager" / "core"))
|
||||
|
||||
import config # noqa: E402
|
||||
import state # noqa: E402
|
||||
import taskfiles # noqa: E402
|
||||
|
||||
CARD = ("# 18 — A card that gets claimed\n\n"
|
||||
"**Status:** Backlog\n"
|
||||
"**Priority:** High\n"
|
||||
"**Type:** Feature\n\n"
|
||||
"Body text nobody should touch.\n")
|
||||
FILENAME = "18-a-card.md"
|
||||
MOVER = "Mover One"
|
||||
|
||||
|
||||
def git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["git", "-C", str(cwd), *args],
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
class ClaimOnMove(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# resolve(): macOS tempdirs sit behind the /var → /private/var
|
||||
# symlink and git reports the resolved path, so absolute pathspecs
|
||||
# only match if we resolve too.
|
||||
tmp = Path(tempfile.mkdtemp(prefix="bench-claim-")).resolve()
|
||||
self.addCleanup(shutil.rmtree, tmp, True)
|
||||
self.repo = tmp / "repo"
|
||||
for slug in config.STAGE_DIRS:
|
||||
(self.repo / "tasks" / slug).mkdir(parents=True)
|
||||
subprocess.run(["git", "init", "-q", "-b", "main", str(self.repo)],
|
||||
check=True, capture_output=True)
|
||||
git(self.repo, "config", "user.name", MOVER)
|
||||
git(self.repo, "config", "user.email", "mover@example.com")
|
||||
|
||||
self.patch(TASKS=self.repo / "tasks", REPO=self.repo,
|
||||
SESSIONS_DIR=tmp / "sessions", COMMIT_MOVES=True)
|
||||
state.BOARD_EVENTS.clear()
|
||||
|
||||
(self.repo / "unrelated.txt").write_text("one\n", encoding="utf-8")
|
||||
self.write("backlog", CARD)
|
||||
git(self.repo, "add", "-A")
|
||||
git(self.repo, "commit", "-q", "-m", "root")
|
||||
self.baseline = self.commit_count()
|
||||
|
||||
def patch(self, **values) -> None:
|
||||
for attr, value in values.items():
|
||||
self.addCleanup(setattr, config, attr, getattr(config, attr))
|
||||
setattr(config, attr, value)
|
||||
|
||||
# — the repo under test —
|
||||
|
||||
def path(self, stage: str) -> Path:
|
||||
return self.repo / "tasks" / stage / FILENAME
|
||||
|
||||
def write(self, stage: str, text: str) -> None:
|
||||
self.path(stage).write_text(text, encoding="utf-8")
|
||||
|
||||
def read(self, stage: str) -> str:
|
||||
return self.path(stage).read_text(encoding="utf-8")
|
||||
|
||||
def commit_count(self) -> int:
|
||||
return int(git(self.repo, "rev-list", "--count", "HEAD").stdout.strip())
|
||||
|
||||
def head_message(self) -> str:
|
||||
return git(self.repo, "log", "-1", "--pretty=%s").stdout.strip()
|
||||
|
||||
def head_files(self) -> list[str]:
|
||||
# --no-renames: the point is which paths the commit touched, not how
|
||||
# git chooses to describe the pair.
|
||||
out = git(self.repo, "show", "--name-only", "--no-renames",
|
||||
"--pretty=format:", "HEAD").stdout
|
||||
return sorted(line for line in out.splitlines() if line.strip())
|
||||
|
||||
def hand_move(self, source: str, target: str, text: str) -> None:
|
||||
"""A teammate's plain mv, committed — the starting point for cases
|
||||
that need the card somewhere other than backlog/."""
|
||||
shutil.move(str(self.path(source)), str(self.path(target)))
|
||||
self.write(target, text)
|
||||
git(self.repo, "add", "-A")
|
||||
git(self.repo, "commit", "-q", "-m", f"hand move to {target}")
|
||||
self.baseline = self.commit_count()
|
||||
|
||||
def porcelain(self) -> str:
|
||||
return git(self.repo, "status", "--porcelain").stdout
|
||||
|
||||
# — claiming —
|
||||
|
||||
def test_a_forward_move_claims_the_card_and_commits_once(self):
|
||||
task = taskfiles.move_task(FILENAME, "backlog", "to-do")
|
||||
|
||||
self.assertEqual(task["assignee"], MOVER)
|
||||
self.assertIn(f"**Assignee:** {MOVER}", self.read("to-do"))
|
||||
self.assertEqual(self.commit_count(), self.baseline + 1)
|
||||
self.assertEqual(self.head_message(), f"board: 18 → to-do ({MOVER})")
|
||||
self.assertEqual(self.head_files(),
|
||||
["tasks/backlog/" + FILENAME, "tasks/to-do/" + FILENAME])
|
||||
self.assertEqual(self.porcelain(), "",
|
||||
"the move and the claim leave nothing behind uncommitted")
|
||||
|
||||
def test_the_claim_joins_the_header_and_leaves_the_rest_alone(self):
|
||||
taskfiles.move_task(FILENAME, "backlog", "to-do")
|
||||
|
||||
self.assertEqual(self.read("to-do"), CARD
|
||||
.replace("**Status:** Backlog", "**Status:** To Do")
|
||||
.replace("**Status:** To Do\n",
|
||||
f"**Status:** To Do\n**Assignee:** {MOVER}\n"))
|
||||
|
||||
def test_to_do_to_in_progress_claims_too(self):
|
||||
self.hand_move("backlog", "to-do", CARD.replace("Backlog", "To Do"))
|
||||
|
||||
task = taskfiles.move_task(FILENAME, "to-do", "in-progress")
|
||||
|
||||
self.assertEqual(task["assignee"], MOVER)
|
||||
self.assertEqual(self.head_message(), f"board: 18 → in-progress ({MOVER})")
|
||||
|
||||
def test_first_claim_sticks_when_someone_else_moves_it_on(self):
|
||||
self.hand_move("backlog", "to-do",
|
||||
CARD.replace("**Status:** Backlog",
|
||||
"**Status:** To Do\n**Assignee:** ada"))
|
||||
git(self.repo, "config", "user.name", "Mover Two")
|
||||
|
||||
task = taskfiles.move_task(FILENAME, "to-do", "in-progress")
|
||||
|
||||
self.assertEqual(task["assignee"], "ada", "the first claim owns the card")
|
||||
self.assertEqual(self.read("in-progress").count("**Assignee:**"), 1)
|
||||
self.assertEqual(self.head_message(), "board: 18 → in-progress (Mover Two)",
|
||||
"the commit names who acted, not who holds it")
|
||||
|
||||
def test_a_move_that_is_not_a_claim_writes_no_assignee(self):
|
||||
self.hand_move("backlog", "in-progress",
|
||||
CARD.replace("Backlog", "In Progress"))
|
||||
|
||||
task = taskfiles.move_task(FILENAME, "in-progress", "review")
|
||||
|
||||
self.assertIsNone(task["assignee"])
|
||||
self.assertNotIn("**Assignee:**", self.read("review"))
|
||||
self.assertEqual(self.commit_count(), self.baseline + 1,
|
||||
"the move itself still commits")
|
||||
|
||||
def test_walking_back_to_backlog_clears_the_claim(self):
|
||||
self.hand_move("backlog", "in-progress",
|
||||
CARD.replace("**Status:** Backlog",
|
||||
"**Status:** In Progress\n**Assignee:** ada"))
|
||||
|
||||
task = taskfiles.move_task(FILENAME, "in-progress", "backlog")
|
||||
|
||||
self.assertIsNone(task["assignee"])
|
||||
self.assertEqual(self.read("backlog"), CARD, "back to the unclaimed card")
|
||||
|
||||
def test_no_git_identity_claims_nothing(self):
|
||||
"""A checkout git cannot name claims nothing — there is no identity
|
||||
to write. (git itself then refuses the commit, which is narrated.)"""
|
||||
self.addCleanup(setattr, taskfiles, "actor_name", taskfiles.actor_name)
|
||||
taskfiles.actor_name = lambda: ""
|
||||
|
||||
task = taskfiles.move_task(FILENAME, "backlog", "to-do")
|
||||
|
||||
self.assertIsNone(task["assignee"])
|
||||
self.assertNotIn("**Assignee:**", self.read("to-do"))
|
||||
|
||||
# — the gate —
|
||||
|
||||
def test_gate_off_moves_exactly_as_before(self):
|
||||
self.patch(COMMIT_MOVES=False)
|
||||
|
||||
task = taskfiles.move_task(FILENAME, "backlog", "to-do")
|
||||
|
||||
self.assertIsNone(task["assignee"])
|
||||
self.assertEqual(self.read("to-do"),
|
||||
CARD.replace("**Status:** Backlog", "**Status:** To Do"))
|
||||
self.assertEqual(self.commit_count(), self.baseline)
|
||||
self.assertEqual(self.porcelain(),
|
||||
" D tasks/backlog/18-a-card.md\n?? tasks/to-do/\n",
|
||||
"the move stays for a human to commit, index untouched")
|
||||
|
||||
def test_gate_off_leaves_a_claimed_card_claimed(self):
|
||||
self.patch(COMMIT_MOVES=False)
|
||||
self.hand_move("backlog", "in-progress",
|
||||
CARD.replace("**Status:** Backlog",
|
||||
"**Status:** In Progress\n**Assignee:** ada"))
|
||||
|
||||
task = taskfiles.move_task(FILENAME, "in-progress", "backlog")
|
||||
|
||||
self.assertEqual(task["assignee"], "ada",
|
||||
"with the gate off the board rewrites Status and nothing else")
|
||||
|
||||
# — the commit —
|
||||
|
||||
def test_unrelated_staged_work_is_neither_committed_nor_unstaged(self):
|
||||
(self.repo / "unrelated.txt").write_text("two\n", encoding="utf-8")
|
||||
git(self.repo, "add", "--", "unrelated.txt")
|
||||
|
||||
taskfiles.move_task(FILENAME, "backlog", "to-do")
|
||||
|
||||
self.assertEqual(self.head_files(),
|
||||
["tasks/backlog/" + FILENAME, "tasks/to-do/" + FILENAME])
|
||||
self.assertEqual(self.porcelain(), "M unrelated.txt\n",
|
||||
"the developer's staged change is still staged")
|
||||
self.assertIn("+two", git(self.repo, "diff", "--cached", "HEAD",
|
||||
"--", "unrelated.txt").stdout)
|
||||
|
||||
def test_unrelated_unstaged_work_is_left_alone(self):
|
||||
(self.repo / "unrelated.txt").write_text("two\n", encoding="utf-8")
|
||||
|
||||
taskfiles.move_task(FILENAME, "backlog", "to-do")
|
||||
|
||||
self.assertEqual(self.porcelain(), " M unrelated.txt\n")
|
||||
|
||||
def test_a_failing_commit_still_moves_the_card_and_says_so(self):
|
||||
self.patch(REPO=self.repo.parent / "not-a-repo")
|
||||
(self.repo.parent / "not-a-repo").mkdir()
|
||||
|
||||
task = taskfiles.move_task(FILENAME, "backlog", "to-do")
|
||||
|
||||
self.assertEqual(task["stage"], "to-do")
|
||||
self.assertTrue(self.path("to-do").is_file())
|
||||
event = state.BOARD_EVENTS[-1]
|
||||
self.assertIn("committing it failed", event["summary"])
|
||||
self.assertNotEqual(event["kind"], "move",
|
||||
"a move-kind event is rendered from its from/to "
|
||||
"fields, which this one has none of")
|
||||
|
||||
|
||||
class ClaimPredicate(unittest.TestCase):
|
||||
"""Which transitions claim: leaving an unstarted stage, forwards only."""
|
||||
|
||||
def test_claiming_transitions(self):
|
||||
for source, target in (("backlog", "to-do"), ("backlog", "in-progress"),
|
||||
("to-do", "in-progress"), ("to-do", "review")):
|
||||
self.assertTrue(taskfiles.claims(source, target), f"{source} → {target}")
|
||||
|
||||
def test_non_claiming_transitions(self):
|
||||
for source, target in (("to-do", "backlog"), ("in-progress", "review"),
|
||||
("review", "done"), ("done", "to-do"),
|
||||
("in-progress", "backlog")):
|
||||
self.assertFalse(taskfiles.claims(source, target), f"{source} → {target}")
|
||||
|
||||
|
||||
class AssigneeParsing(unittest.TestCase):
|
||||
def test_read_task_exposes_the_assignee(self):
|
||||
tmp = Path(tempfile.mkdtemp(prefix="bench-claim-read-")).resolve()
|
||||
self.addCleanup(shutil.rmtree, tmp, True)
|
||||
path = tmp / FILENAME
|
||||
path.write_text(CARD.replace("**Status:** Backlog",
|
||||
"**Status:** Backlog\n**Assignee:** ada lovelace"),
|
||||
encoding="utf-8")
|
||||
|
||||
self.assertEqual(taskfiles.read_task(path, "backlog")["assignee"], "ada lovelace")
|
||||
|
||||
def test_an_unclaimed_card_has_no_assignee(self):
|
||||
tmp = Path(tempfile.mkdtemp(prefix="bench-claim-read-")).resolve()
|
||||
self.addCleanup(shutil.rmtree, tmp, True)
|
||||
path = tmp / FILENAME
|
||||
path.write_text(CARD, encoding="utf-8")
|
||||
|
||||
self.assertIsNone(taskfiles.read_task(path, "backlog")["assignee"])
|
||||
|
||||
|
||||
class CardFace(unittest.TestCase):
|
||||
"""board.html is a single file with no frontend runner — these are the
|
||||
source-level invariants of the face showing an owner."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = (REPO / "manager" / "core" / "board.html").read_text(encoding="utf-8")
|
||||
|
||||
def test_the_assignee_replaces_nobody_yet(self):
|
||||
self.assertIn("if (task.assignee) { who = task.assignee;", self.html)
|
||||
index = self.html.index("if (task.assignee) { who = task.assignee;")
|
||||
self.assertLess(index, self.html.index("who = 'nobody yet'"),
|
||||
"the claim must be checked before the stage fallbacks")
|
||||
|
||||
def test_the_who_row_escapes_what_the_file_said(self):
|
||||
self.assertIn('<span class="who">${esc(who)}</span>', self.html)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,391 @@
|
||||
"""A failed agent run leaves a visible trace (task 11).
|
||||
|
||||
Three agents died in an API outage and the board's whole answer was one
|
||||
ticker line that scrolled away. So: a dead run is recorded on its launch
|
||||
record (exit code, ended-at, the log's cleaned tail), the person is told
|
||||
once by toast, the card wears it until the next launch or the next stage,
|
||||
and the untouched worktree a dead run left behind is cleared so ▸ start
|
||||
work is one click again.
|
||||
|
||||
Real launches through a real (stub) adapter — the adapter contract is what
|
||||
a dying agent actually comes through, so nothing is mocked but the SSE
|
||||
fan-out and the adapter's own binary.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO / "manager" / "core"))
|
||||
|
||||
import agents # noqa: E402
|
||||
import config # noqa: E402
|
||||
import state # noqa: E402
|
||||
import watch # noqa: E402
|
||||
|
||||
BOARD = REPO / "manager" / "core" / "board.html"
|
||||
|
||||
FILENAME = "11-a-run-that-dies.md"
|
||||
STEM = FILENAME[:-3]
|
||||
BRANCH = f"task/{STEM}"
|
||||
|
||||
CARD = """# 11 — A run that dies
|
||||
|
||||
**Status:** In Progress
|
||||
**Priority:** High
|
||||
|
||||
Body text, so the prompt has something to carry.
|
||||
"""
|
||||
|
||||
# What the outage looked like from the board's side: a line of output on
|
||||
# stdout and a non-zero exit.
|
||||
DIES = """#!/usr/bin/env python3
|
||||
import sys
|
||||
print("thinking…")
|
||||
print("API Error: 500 {\\"type\\":\\"error\\",\\"error\\":{\\"type\\":\\"api_error\\"}}")
|
||||
sys.exit(1)
|
||||
"""
|
||||
|
||||
# Same death, but it committed first: there is work to keep.
|
||||
DIES_WITH_WORK = """#!/usr/bin/env python3
|
||||
import os, subprocess, sys
|
||||
cwd = os.environ["AGENT_CWD"]
|
||||
open(os.path.join(cwd, "half.txt"), "w").write("half a feature\\n")
|
||||
subprocess.run(["git", "-C", cwd, "add", "-A"], check=True)
|
||||
subprocess.run(["git", "-C", cwd, "-c", "user.email=a@b", "-c", "user.name=stub",
|
||||
"commit", "-q", "-m", "half"], check=True)
|
||||
print("API Error: 529 overloaded")
|
||||
sys.exit(1)
|
||||
"""
|
||||
|
||||
SILENT_DEATH = """#!/usr/bin/env python3
|
||||
import sys
|
||||
sys.exit(1)
|
||||
"""
|
||||
|
||||
LIVES = """#!/usr/bin/env python3
|
||||
print("all good")
|
||||
"""
|
||||
|
||||
|
||||
def git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["git", "-C", str(cwd), *args],
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
def wait_for(pred, timeout: float = 20.0) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if pred():
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
|
||||
class Launches(unittest.TestCase):
|
||||
"""One repo, one card, one adapter whose behaviour each test writes."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp(prefix="bench-failed-")).resolve()
|
||||
self.addCleanup(shutil.rmtree, self.tmp, True)
|
||||
self.repo = self.tmp / "repo"
|
||||
self.repo.mkdir()
|
||||
git(self.repo, "init", "-q", "-b", "main")
|
||||
git(self.repo, "config", "user.email", "t@t")
|
||||
git(self.repo, "config", "user.name", "tester")
|
||||
(self.repo / "code.txt").write_text("shipped\n", encoding="utf-8")
|
||||
git(self.repo, "add", "-A")
|
||||
git(self.repo, "commit", "-q", "-m", "root")
|
||||
|
||||
tasks = self.repo / "tasks"
|
||||
for slug in config.STAGE_DIRS:
|
||||
(tasks / slug).mkdir(parents=True)
|
||||
(tasks / "in-progress" / FILENAME).write_text(CARD, encoding="utf-8")
|
||||
|
||||
local = self.tmp / "local"
|
||||
(local / "adapters" / config.ADAPTER).mkdir(parents=True)
|
||||
self.adapter = local / "adapters" / config.ADAPTER / "run"
|
||||
|
||||
self.patch(REPO=self.repo, TASKS=tasks, LOCAL=local,
|
||||
WORKTREES=self.tmp / "worktrees",
|
||||
AGENT_DIR=self.tmp / "agent",
|
||||
SESSIONS_DIR=self.tmp / "sessions",
|
||||
COMMIT_MOVES=False, SYNC=False)
|
||||
|
||||
state.AGENTS.clear()
|
||||
state.BOARD_EVENTS.clear()
|
||||
state.EXPECTED_MOVES.clear()
|
||||
self.addCleanup(state.AGENTS.clear)
|
||||
self.addCleanup(state.BOARD_EVENTS.clear)
|
||||
|
||||
self.sent: list[dict] = []
|
||||
self.addCleanup(setattr, state, "broadcast", state.broadcast)
|
||||
state.broadcast = self.sent.append
|
||||
|
||||
def patch(self, **values) -> None:
|
||||
for attr, value in values.items():
|
||||
self.addCleanup(setattr, config, attr, getattr(config, attr))
|
||||
setattr(config, attr, value)
|
||||
|
||||
def adapter_is(self, script: str) -> None:
|
||||
self.adapter.write_text(script, encoding="utf-8")
|
||||
self.adapter.chmod(self.adapter.stat().st_mode | stat.S_IEXEC)
|
||||
|
||||
def run_agent(self, script: str, start=None) -> dict:
|
||||
"""Launch, wait for the reaper to be done with it, return the record.
|
||||
|
||||
Every reaper's last act is the agents broadcast, so counting those
|
||||
is the honest "it has finished" — the assertions then see a settled
|
||||
record rather than one mid-reap."""
|
||||
self.adapter_is(script)
|
||||
ended = self.sent.count({"type": "agents"})
|
||||
public = (start or (lambda: agents.start_agent(FILENAME, "in-progress")))()
|
||||
record = state.AGENTS[public["id"]]
|
||||
self.assertTrue(
|
||||
wait_for(lambda: self.sent.count({"type": "agents"}) > ended),
|
||||
f"the reaper never announced the ending (status {record['status']})")
|
||||
return record
|
||||
|
||||
def summaries(self) -> list[str]:
|
||||
return [e["summary"] for e in state.BOARD_EVENTS]
|
||||
|
||||
def toasts(self) -> list[dict]:
|
||||
return [m for m in self.sent if m.get("type") == "toast"]
|
||||
|
||||
def stage_of(self, filename: str = FILENAME) -> str | None:
|
||||
for slug in config.STAGE_DIRS:
|
||||
if (config.TASKS / slug / filename).is_file():
|
||||
return slug
|
||||
return None
|
||||
|
||||
|
||||
class TheOutcomeIsRecorded(Launches):
|
||||
def test_a_dead_work_run_lands_on_its_record(self):
|
||||
record = self.run_agent(DIES)
|
||||
self.assertEqual(record["status"], "failed")
|
||||
failure = record["failure"]
|
||||
self.assertEqual(failure["rc"], 1)
|
||||
self.assertIn("API Error: 500", failure["excerpt"])
|
||||
self.assertEqual(failure["stage"], "in-progress")
|
||||
self.assertGreaterEqual(failure["ended"], record["started"])
|
||||
self.assertTrue(Path(failure["log"]).is_file(),
|
||||
"the failure must name a log that exists")
|
||||
|
||||
def test_the_card_never_advances(self):
|
||||
self.run_agent(DIES)
|
||||
self.assertEqual(self.stage_of(), "in-progress")
|
||||
|
||||
def test_the_person_is_toasted_once(self):
|
||||
self.run_agent(DIES)
|
||||
toasts = self.toasts()
|
||||
self.assertEqual(len(toasts), 1, "a failure is one toast, not none or two")
|
||||
self.assertTrue(toasts[0]["error"], "a failure toast is an alarm")
|
||||
self.assertIn("API Error: 500", toasts[0]["message"])
|
||||
self.assertIn(FILENAME, toasts[0]["message"])
|
||||
|
||||
def test_the_ticker_line_still_records_it(self):
|
||||
"""This card adds surfaces; it does not move the permanent record."""
|
||||
self.run_agent(DIES)
|
||||
line = [s for s in self.summaries() if "rc=1" in s]
|
||||
self.assertTrue(line, "the event log lost the exit line")
|
||||
self.assertIn(FILENAME, line[0])
|
||||
self.assertIn("API Error: 500", line[0])
|
||||
|
||||
def test_the_public_payload_carries_it(self):
|
||||
"""The card reads the API, not the board's memory."""
|
||||
self.run_agent(DIES)
|
||||
public = agents.list_public()[0]
|
||||
self.assertEqual(public["status"], "failed")
|
||||
self.assertIn("API Error: 500", public["failure"]["excerpt"])
|
||||
self.assertIsNotNone(public["ended"])
|
||||
|
||||
def test_a_live_run_carries_no_failure(self):
|
||||
record = self.run_agent(LIVES)
|
||||
self.assertEqual(record["status"], "done")
|
||||
self.assertIsNone(agents.list_public()[0]["failure"])
|
||||
self.assertEqual(self.toasts(), [])
|
||||
|
||||
|
||||
class TheWayIsClearedForRelaunch(Launches):
|
||||
def test_an_untouched_worktree_goes(self):
|
||||
"""Nothing of value is lost — the run committed nothing — and
|
||||
▸ start work refuses while the worktree exists."""
|
||||
record = self.run_agent(DIES)
|
||||
self.assertFalse(Path(record["worktree"]).exists(),
|
||||
"a dead run with no commits must not block the relaunch")
|
||||
self.assertEqual(
|
||||
git(self.repo, "rev-parse", "--verify", "--quiet", BRANCH).returncode, 1,
|
||||
"the empty branch goes with the worktree")
|
||||
self.assertTrue(any("worktree cleared" in s for s in self.summaries()))
|
||||
|
||||
def test_the_relaunch_actually_works(self):
|
||||
self.run_agent(DIES)
|
||||
state.BOARD_EVENTS.clear()
|
||||
second = self.run_agent(LIVES)
|
||||
self.assertEqual(second["status"], "done")
|
||||
|
||||
def test_a_run_with_commits_keeps_its_worktree(self):
|
||||
record = self.run_agent(DIES_WITH_WORK)
|
||||
self.assertTrue(Path(record["worktree"]).exists(),
|
||||
"work that was committed is never thrown away")
|
||||
self.assertIn("kept", " ".join(self.summaries()))
|
||||
|
||||
|
||||
class EveryHeadlessKind(Launches):
|
||||
def test_a_dead_relevance_check_surfaces_the_same_way(self):
|
||||
"""No worktree, any stage — same state on the card."""
|
||||
record = self.run_agent(
|
||||
DIES, start=lambda: agents.start_review(FILENAME, "in-progress"))
|
||||
self.assertEqual(record["status"], "failed")
|
||||
self.assertIn("API Error: 500", record["failure"]["excerpt"])
|
||||
self.assertEqual(record["failure"]["stage"], "in-progress")
|
||||
self.assertEqual(len(self.toasts()), 1)
|
||||
|
||||
def test_a_death_before_the_agent_spoke_still_says_something(self):
|
||||
record = self.run_agent(SILENT_DEATH)
|
||||
self.assertIn("no output", record["failure"]["excerpt"])
|
||||
self.assertIn("no output", self.toasts()[0]["message"])
|
||||
|
||||
|
||||
class TheStateClears(Launches):
|
||||
def test_a_relaunch_replaces_it(self):
|
||||
"""Two records for one card, and the card reads the newest: the
|
||||
failure is superseded rather than cleared."""
|
||||
first = self.run_agent(DIES)
|
||||
time.sleep(1.1) # agent ids are stamped to the second
|
||||
second = self.run_agent(LIVES)
|
||||
self.assertNotEqual(first["id"], second["id"])
|
||||
latest = max(agents.list_public(), key=lambda a: a["started"])
|
||||
self.assertEqual(latest["id"], second["id"])
|
||||
self.assertIsNone(latest["failure"])
|
||||
self.assertIsNotNone(first["failure"],
|
||||
"the older run keeps its own history")
|
||||
|
||||
def test_a_stage_move_drops_it(self):
|
||||
record = self.run_agent(DIES)
|
||||
self.assertIsNotNone(record["failure"])
|
||||
watch.narrate({"in-progress": {FILENAME}, "to-do": set()},
|
||||
{"in-progress": set(), "to-do": {FILENAME}})
|
||||
self.assertIsNone(record.get("failure"),
|
||||
"a card arriving in a new stage wears no old alarm")
|
||||
|
||||
def test_forgetting_is_per_card(self):
|
||||
record = self.run_agent(DIES)
|
||||
self.assertFalse(agents.forget_failure("99-someone-else.md"))
|
||||
self.assertIsNotNone(record["failure"], "another card's move cleared this one")
|
||||
self.assertTrue(agents.forget_failure(FILENAME))
|
||||
self.assertFalse(agents.forget_failure(FILENAME), "clearing twice is a no-op")
|
||||
|
||||
|
||||
class ExcerptTests(unittest.TestCase):
|
||||
"""What the card shows, from the log alone."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp(prefix="bench-excerpt-")).resolve()
|
||||
self.addCleanup(shutil.rmtree, self.tmp, True)
|
||||
|
||||
def log(self, text: str) -> str:
|
||||
path = self.tmp / "run.log"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
def test_the_tail_is_what_you_get(self):
|
||||
excerpt = agents._failure_excerpt(
|
||||
self.log("\n".join(f"line {i}" for i in range(40))), lines=6)
|
||||
self.assertIn("line 39", excerpt)
|
||||
self.assertNotIn("line 20", excerpt)
|
||||
self.assertEqual(len(excerpt.splitlines()), 6)
|
||||
|
||||
def test_a_tiny_log_survives_whole(self):
|
||||
"""The MultiEdit flag error was 91 bytes; the excerpt handles it."""
|
||||
tiny = "error: unknown option '--allowedTools MultiEdit'\n"
|
||||
self.assertIn("MultiEdit", agents._failure_excerpt(self.log(tiny)))
|
||||
|
||||
def test_hook_noise_is_stripped(self):
|
||||
excerpt = agents._failure_excerpt(
|
||||
self.log("PostToolUse hook failed with status 1\nAPI Error: 500\n"))
|
||||
self.assertEqual(excerpt, "API Error: 500")
|
||||
|
||||
def test_nothing_at_all_says_so(self):
|
||||
for empty in (self.log(""), self.log(" \n\n"), str(self.tmp / "gone.log"), None):
|
||||
self.assertIn("no output", agents._failure_excerpt(empty))
|
||||
|
||||
def test_the_headline_is_the_last_line(self):
|
||||
"""A dying process says why last."""
|
||||
self.assertEqual(agents._headline("thinking…\nAPI Error: 500"), "API Error: 500")
|
||||
self.assertEqual(agents._headline(""), "no output")
|
||||
self.assertLessEqual(len(agents._headline("x" * 400)), 120)
|
||||
|
||||
|
||||
class TheCardWearsIt(unittest.TestCase):
|
||||
"""board.html is one file with inline JS and no frontend test runner, so
|
||||
these are source-level invariants — the ones that, if broken, put the
|
||||
failure back out of sight."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = BOARD.read_text(encoding="utf-8")
|
||||
|
||||
def rule(self, selector: str) -> str:
|
||||
m = re.search(re.escape(selector) + r"\{([^}]*)\}", self.html)
|
||||
self.assertIsNotNone(m, f"board.html lost its {selector} rule")
|
||||
return m.group(1)
|
||||
|
||||
def test_the_border_is_the_alarm_colour(self):
|
||||
self.assertIn("--alarm", self.rule(".card.run-failed"),
|
||||
"failed is terracotta — the design system's one word for it")
|
||||
|
||||
def test_the_pill_says_run_failed(self):
|
||||
m = re.search(r"\{ text: 'run failed'[^}]*\}", self.html)
|
||||
self.assertIsNotNone(m, "the status slot lost its `run failed` pill")
|
||||
self.assertIn("--alarm", m.group(0))
|
||||
|
||||
def test_the_failure_is_scoped_to_its_card_and_stage(self):
|
||||
"""The state belongs to the most recent run on THIS card, in the
|
||||
stage it died in: no leaking sideways, none into review/."""
|
||||
m = re.search(r"function failedRun\(task\) \{(.*?)\n\}", self.html, re.DOTALL)
|
||||
self.assertIsNotNone(m, "failedRun went missing")
|
||||
body = m.group(1)
|
||||
self.assertIn("lastRunOn(task.file)", body)
|
||||
self.assertIn("'failed'", body)
|
||||
self.assertIn("failure.stage === task.stage", body)
|
||||
|
||||
def test_the_latest_run_is_a_max_not_a_find(self):
|
||||
"""Records outlive their processes; picking the first match would
|
||||
pin a card to whichever run happens to be first in the list."""
|
||||
m = re.search(r"function lastRunOn\(file\) \{(.*?)\n\}", self.html, re.DOTALL)
|
||||
self.assertIsNotNone(m, "lastRunOn went missing")
|
||||
self.assertIn("started >", m.group(1))
|
||||
|
||||
def test_the_excerpt_is_one_hover_away(self):
|
||||
"""On the card: the alarm well, the line it died on, the whole
|
||||
excerpt in the tooltip."""
|
||||
m = re.search(r'<div class="well bad" title="\$\{esc\(failure\.excerpt\)\}"',
|
||||
self.html)
|
||||
self.assertIsNotNone(m, "the card's failure well lost its excerpt tooltip")
|
||||
self.assertIn("whyFailed(failure)", self.html)
|
||||
|
||||
def test_the_card_sheet_shows_the_whole_excerpt(self):
|
||||
self.assertRegex(self.html, r"<pre>\$\{esc\(failure\.excerpt\)\}</pre>",
|
||||
"the drawer must show the excerpt without opening files")
|
||||
self.assertRegex(self.html, r"#drawer \.well\.bad pre\{[^}]*max-height",
|
||||
"a long excerpt needs a scroll bound in the sheet")
|
||||
|
||||
def test_the_server_can_toast(self):
|
||||
"""The failure toast rides the generic server-toast channel."""
|
||||
self.assertIn("msg.type === 'toast'", self.html)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,169 @@
|
||||
"""The header wears the design's wordmark (task 23).
|
||||
|
||||
The design's answer to "what is the bench logo" is that there isn't a drawn
|
||||
one: the word *is* the logo — "bench", lowercase, set in Zilla Slab SemiBold
|
||||
and tracked -.015em, with the b lifted out of the same face as the icon. The
|
||||
board cannot fetch that face (the page adds no network requests for a logo),
|
||||
so the mark ships as outlines instead, and the b's outline is reused verbatim
|
||||
as the tab icon.
|
||||
|
||||
board.html has no frontend test runner, so this is checked the way task 22's
|
||||
browser half is: as source-level invariants — the ones that, if broken, would
|
||||
put the mark back on a font, bake a colour into it, let the two copies of the
|
||||
b drift apart, or shove the header's neighbours around.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
BOARD = REPO / "manager" / "core" / "board.html"
|
||||
|
||||
# What the page was already allowed to fetch before the logo landed. The list
|
||||
# is the point: a logo that needed a font would have to grow it.
|
||||
KNOWN_EXTERNALS = {
|
||||
"https://fonts.googleapis.com",
|
||||
"https://fonts.gstatic.com",
|
||||
"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500"
|
||||
"&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,400&display=swap",
|
||||
}
|
||||
|
||||
# Colour only ever means state, so none of these may appear in the mark.
|
||||
STATE_TOKENS = ("var(--accent)", "var(--calm)", "var(--alarm)", "var(--idle)")
|
||||
|
||||
|
||||
class Fixture(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = BOARD.read_text(encoding="utf-8")
|
||||
|
||||
def mark(self) -> str:
|
||||
"""The inline wordmark, markup and all."""
|
||||
found = re.search(r'<svg class="mark".*?</svg>', self.html, re.DOTALL)
|
||||
self.assertIsNotNone(found, "board.html lost the inline wordmark")
|
||||
return found.group(0)
|
||||
|
||||
def icon_href(self) -> str:
|
||||
found = re.search(r'<link rel="icon" href="([^"]*)"', self.html)
|
||||
self.assertIsNotNone(found, "board.html lost its tab icon")
|
||||
return found.group(1)
|
||||
|
||||
|
||||
class TheWordmark(Fixture):
|
||||
def test_the_header_carries_it_as_outlines(self):
|
||||
"""Outlines, not text: a font-set wordmark on this page would either
|
||||
need a fetch or silently fall back to whatever the OS has."""
|
||||
brand = re.search(r'<div class="brand">.*?</div>', self.html, re.DOTALL)
|
||||
self.assertIsNotNone(brand, "board.html lost the .brand block")
|
||||
self.assertIn('<svg class="mark"', brand.group(0))
|
||||
self.assertNotIn("<b>Bench</b>", self.html,
|
||||
"the old font-set wordmark is still in the header")
|
||||
|
||||
def test_the_word_is_the_lowercase_one(self):
|
||||
""""bench", not "Bench" — the design sets it lowercase everywhere, and
|
||||
the accessible name is the only place the word survives as text."""
|
||||
self.assertIn('aria-label="bench"', self.mark())
|
||||
|
||||
def test_it_spells_five_letters(self):
|
||||
"""One path per letter of b-e-n-c-h. A missing path is a missing
|
||||
letter, and nothing else in this file would notice."""
|
||||
self.assertEqual(len(re.findall(r"<path\b", self.mark())), 5)
|
||||
|
||||
def test_nothing_is_fetched_for_it(self):
|
||||
"""The whole reason it is outlines. If this fails, the page grew a
|
||||
request (or an @font-face) that the acceptance forbids."""
|
||||
externals = set(re.findall(r'(?:href|src)="(https?://[^"]+)"', self.html))
|
||||
self.assertEqual(externals, KNOWN_EXTERNALS,
|
||||
"board.html gained an external resource")
|
||||
for forbidden in ("@font-face", ".woff", ".ttf", "family=Zilla"):
|
||||
self.assertNotIn(forbidden, self.html,
|
||||
f"the logo may not bring in {forbidden}")
|
||||
# Naming the source face in a comment is documentation; setting text
|
||||
# in it would be a font the page hasn't got.
|
||||
self.assertIsNone(re.search(r"font-family:[^;}\n]*Zilla", self.html),
|
||||
"the mark is outlines — nothing is set in Zilla Slab")
|
||||
|
||||
def test_it_takes_the_theme_s_own_ink(self):
|
||||
"""currentColor is the theme mechanism: Night and Daylight both get
|
||||
--text without a second copy of the mark."""
|
||||
rule = re.search(r"\.brand \.mark\{([^}]*)\}", self.html)
|
||||
self.assertIsNotNone(rule, "board.html lost the .brand .mark rule")
|
||||
self.assertIn("fill:currentColor", rule.group(1))
|
||||
self.assertNotRegex(self.mark(), r"#[0-9a-fA-F]{3,6}\b",
|
||||
"the mark must not bake a colour in")
|
||||
|
||||
def test_it_is_not_coloured_like_a_state(self):
|
||||
"""--accent means an agent is alive. A logo wearing it would be
|
||||
lying about the board twice a second."""
|
||||
for token in STATE_TOKENS:
|
||||
self.assertNotIn(token, self.mark())
|
||||
|
||||
def test_one_token_sizes_it(self):
|
||||
"""Scaling is a custom property, per the design — not two hard-coded
|
||||
sizes to keep in step."""
|
||||
self.assertRegex(self.html, r":root\{[^}]*--logo-h:", )
|
||||
rule = re.search(r"\.brand \.mark\{([^}]*)\}", self.html)
|
||||
self.assertIn("height:var(--logo-h)", rule.group(1))
|
||||
|
||||
|
||||
class TheTabIcon(Fixture):
|
||||
def test_it_never_leaves_the_page(self):
|
||||
"""Inline data: URI, so the tab is right on first paint and offline."""
|
||||
self.assertTrue(self.icon_href().startswith("data:image/svg+xml,"),
|
||||
"the tab icon must stay inline")
|
||||
|
||||
def test_it_is_the_wordmark_s_own_b(self):
|
||||
"""The design's point about this mark is that one face does both jobs,
|
||||
so they can never drift apart. Here that means one outline: the icon's
|
||||
path and the wordmark's b are the same string, character for
|
||||
character."""
|
||||
icon = re.search(r"d='([^']+)'", self.icon_href())
|
||||
self.assertIsNotNone(icon, "the tab icon lost its b")
|
||||
word = re.search(r'<path id="mark-b" d="([^"]+)"', self.html)
|
||||
self.assertIsNotNone(word, "the wordmark lost its b")
|
||||
self.assertEqual(icon.group(1), word.group(1),
|
||||
"the icon's b and the wordmark's b have drifted apart")
|
||||
|
||||
def test_the_holes_in_the_b_are_holes(self):
|
||||
"""The b's bowl is a counter, drawn as a second subpath. Without
|
||||
even-odd filling it fills solid and the letter becomes a blob."""
|
||||
self.assertIn("fill-rule='evenodd'", self.icon_href())
|
||||
self.assertIn('fill-rule="evenodd"', self.mark())
|
||||
|
||||
|
||||
class TheNeighbours(Fixture):
|
||||
"""The card is the logo only: everything beside it stays put."""
|
||||
|
||||
def test_the_path_line_still_hangs_off_the_baseline(self):
|
||||
"""Baseline alignment against the tasks-root path is the design's own
|
||||
arrangement, and it is what keeps the mono line optically seated."""
|
||||
rule = re.search(r"\.brand\{([^}]*)\}", self.html)
|
||||
self.assertIsNotNone(rule, "board.html lost the .brand rule")
|
||||
self.assertIn("align-items:baseline", rule.group(1))
|
||||
|
||||
def test_the_path_line_is_untouched(self):
|
||||
self.assertIn(".brand .path{font-family:var(--mono);font-size:11.5px;"
|
||||
"color:var(--dim)}", self.html)
|
||||
|
||||
def test_the_header_row_is_untouched(self):
|
||||
"""Same padding, same alignment, same order — the mark replaced a
|
||||
word, it did not relayout the header."""
|
||||
rule = re.search(r"\n header\{([^}]*)\}", self.html)
|
||||
self.assertIsNotNone(rule, "board.html lost the header rule")
|
||||
self.assertIn("align-items:center", rule.group(1))
|
||||
self.assertIn("padding:10px 18px", rule.group(1))
|
||||
header = re.search(r"<header>.*?</header>", self.html, re.DOTALL)
|
||||
self.assertIsNotNone(header, "board.html lost its header")
|
||||
order = re.findall(r'id="(views|syncchip|livechip|themebtn|refresh)"',
|
||||
header.group(0))
|
||||
self.assertEqual(order, ["views", "syncchip", "livechip",
|
||||
"themebtn", "refresh"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""The activity log's resize grip actually resizes (task 04).
|
||||
|
||||
The grip's drag handlers wrote #logbody's inline height while the CSS said
|
||||
flex:1 — so the flex algorithm sized the element and the write was dead.
|
||||
Worse, mouseup persisted a re-read offsetHeight (the flex-computed value),
|
||||
silently overwriting the remembered size with the status quo.
|
||||
|
||||
board.html is a single file with inline JS and no frontend test runner, so
|
||||
these are source-level invariants over the rule and the handler block: the
|
||||
ones that, if broken, would decouple the written height from the laid-out
|
||||
height again, or bring back the offsetHeight re-read that corrupted the
|
||||
stored size.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
BOARD = Path(__file__).resolve().parents[1] / "manager" / "core" / "board.html"
|
||||
|
||||
|
||||
class LogResizeTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = BOARD.read_text(encoding="utf-8")
|
||||
|
||||
m = re.search(r"#logbody\{([^}]*)\}", cls.html)
|
||||
assert m, "board.html lost its #logbody rule"
|
||||
cls.logbody = m.group(1).replace(" ", "")
|
||||
|
||||
# the whole activity-log drag block, from its banner comment to the
|
||||
# drawer of the next top-level comment
|
||||
m = re.search(
|
||||
r"/\* the activity log:.*?\n\{(.*?)\n\}", cls.html, re.S)
|
||||
assert m, "board.html lost the activity-log resize block"
|
||||
cls.logjs = m.group(1)
|
||||
|
||||
m = re.search(r"/\* the drawer:.*?\n\{(.*?)\n\}", cls.html, re.S)
|
||||
assert m, "board.html lost the drawer resize block"
|
||||
cls.drawerjs = m.group(1)
|
||||
|
||||
# ── the height the handler writes is the height the layout uses ──
|
||||
|
||||
def test_logbody_height_is_authoritative(self):
|
||||
"""flex:none (0 0 auto): the inline height the drag handlers write
|
||||
sizes the element. flex:1 (basis 0% + grow) is what made the write
|
||||
dead — the flex algorithm never consulted the height property."""
|
||||
self.assertIn("flex:none", self.logbody,
|
||||
"#logbody must not be flex-sized; the grip writes "
|
||||
"style.height and the layout must honour it")
|
||||
self.assertNotIn("flex:1", self.logbody)
|
||||
|
||||
def test_logbody_growth_is_guarded(self):
|
||||
"""With height authoritative again, a huge saved value or a shrunk
|
||||
window must not overflow: CSS max-height mirrors the drag clamp's
|
||||
60vh ceiling."""
|
||||
self.assertIn("max-height:60vh", self.logbody)
|
||||
|
||||
def test_drag_clamp_bounds_survive(self):
|
||||
"""The advertised 80px–60vh drag bounds live in the handler block."""
|
||||
self.assertRegex(self.logjs, r"Math\.max\(px,\s*80\)")
|
||||
self.assertRegex(self.logjs, r"innerHeight\s*\*\s*0\.6\b")
|
||||
|
||||
# ── the saved size round-trips instead of being overwritten ──
|
||||
|
||||
def test_mouseup_persists_the_computed_height(self):
|
||||
"""mouseup must save the value the drag computed. Re-reading
|
||||
offsetHeight is the old bug: while the write was dead it persisted
|
||||
the flex-computed height, corrupting the stored size (856 → 156)."""
|
||||
save = re.search(r"setItem\('bench-log-h',\s*([^)]*)\)", self.logjs)
|
||||
self.assertIsNotNone(save, "the log block must persist bench-log-h")
|
||||
self.assertNotIn("offsetHeight", save.group(1),
|
||||
"persist the drag-computed value, not a re-read")
|
||||
|
||||
def test_restore_uses_the_drag_channel_and_clamp(self):
|
||||
"""The load-time restore writes the same property the drag writes,
|
||||
through the same clamp — a saved value from a taller window must
|
||||
not restore beyond today's 60vh."""
|
||||
self.assertRegex(
|
||||
self.logjs, r"clamp\([^)]*getItem\('bench-log-h'",
|
||||
"the restore must clamp the saved height")
|
||||
writes = re.findall(r"body\.style\.(\w+)\s*=", self.logjs)
|
||||
self.assertTrue(writes, "the block must size #logbody")
|
||||
self.assertEqual(set(writes), {"height"},
|
||||
"restore and drag must write the same property")
|
||||
|
||||
# ── the drawer grip: same interaction family, must not regress ──
|
||||
|
||||
def test_drawer_width_is_still_authoritative(self):
|
||||
"""The drawer handlers write #drawer's own width; #drawer is
|
||||
position:fixed, so no flex sizing competes with the write."""
|
||||
drawer = re.search(r"#drawer\{([^}]*)\}", self.html).group(1)
|
||||
self.assertIn("position:fixed", drawer.replace(" ", ""))
|
||||
self.assertIn("panel.style.width", self.drawerjs)
|
||||
self.assertIn("setItem('bench-drawer-w'", self.drawerjs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""The model chip beside every agent name (task 24).
|
||||
|
||||
Task 12 recorded which model each launch rode; this puts it where eyes
|
||||
land — one chip, in the session-id hash's register, beside the name that
|
||||
identifies a run.
|
||||
|
||||
Two halves. The chip's behaviour (shortening, escaping, and the silence
|
||||
that means "this launch never knew") is exercised for real: the two
|
||||
functions are lifted out of board.html and run in node, skipped where
|
||||
node is absent. The placement — which four render sites wear it, and
|
||||
that they all wear the same one — is a source-level invariant, board.html
|
||||
being a single file with inline JS and no frontend test runner.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
BOARD = Path(__file__).resolve().parents[1] / "manager" / "core" / "board.html"
|
||||
|
||||
# The pieces the chip is made of, lifted from the page as written.
|
||||
PARTS = (
|
||||
r"const esc = \(s\) =>.*?\}\[c\]\)\);",
|
||||
r"function shortModel\(model\) \{.*?\n\}",
|
||||
r"function modelChip\(agent\) \{.*?\n\}",
|
||||
)
|
||||
|
||||
|
||||
def _harness() -> str:
|
||||
html = BOARD.read_text(encoding="utf-8")
|
||||
out = []
|
||||
for pattern in PARTS:
|
||||
m = re.search(pattern, html, re.S)
|
||||
if m is None:
|
||||
raise AssertionError(f"board.html no longer defines {pattern!r}")
|
||||
out.append(m.group(0))
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
class ChipBehaviour(unittest.TestCase):
|
||||
"""What the chip actually renders, run as the browser would run it."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.node = shutil.which("node")
|
||||
if not cls.node:
|
||||
raise unittest.SkipTest("node not available — chip behaviour unrun")
|
||||
cls.src = _harness()
|
||||
|
||||
def chips(self, agents: list) -> list:
|
||||
"""modelChip(a) for each given agent record."""
|
||||
script = (self.src + "\nconsole.log(JSON.stringify("
|
||||
+ json.dumps(agents) + ".map(modelChip)));")
|
||||
out = subprocess.run([self.node, "-e", script],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(out.returncode, 0, out.stderr)
|
||||
return json.loads(out.stdout)
|
||||
|
||||
def test_a_launch_that_never_knew_its_model_shows_nothing(self):
|
||||
"""No chip is the honest answer for an inherited launch, a session
|
||||
with no agent record (yours, or one replayed from disk after a
|
||||
restart), and a pre-task-12 record: a placeholder would read as a
|
||||
model actually named that."""
|
||||
self.assertEqual(
|
||||
self.chips([None, {}, {"model": None}, {"model": ""},
|
||||
{"name": "Wren"}]),
|
||||
["", "", "", "", ""])
|
||||
|
||||
def test_the_vendor_prefix_is_dropped_and_kept_on_hover(self):
|
||||
"""claude-opus-4-8 → opus-4-8 on the face, whole on the title."""
|
||||
chip, = self.chips([{"model": "claude-opus-4-8"}])
|
||||
self.assertIn(">opus-4-8</span>", chip)
|
||||
self.assertIn('title="claude-opus-4-8"', chip)
|
||||
self.assertIn('class="mchip"', chip)
|
||||
|
||||
def test_a_provider_path_is_dropped_the_same_way(self):
|
||||
"""The opencode adapter's ids are provider/model."""
|
||||
chip, = self.chips([{"model": "anthropic/claude-opus-4-8"}])
|
||||
self.assertIn(">opus-4-8</span>", chip)
|
||||
self.assertIn('title="anthropic/claude-opus-4-8"', chip)
|
||||
|
||||
def test_an_unfamiliar_name_is_shown_as_recorded_not_guessed_at(self):
|
||||
"""Shortening only removes what this board knows is redundant —
|
||||
it never eats the first word of a name it does not recognise."""
|
||||
for model in ("gpt-4o", "some-model", "opus-4-8"):
|
||||
chip, = self.chips([{"model": model}])
|
||||
self.assertIn(f">{model}</span>", chip)
|
||||
|
||||
def test_the_model_string_is_escaped_on_both_face_and_title(self):
|
||||
chip, = self.chips([{"model": 'a"<b'}])
|
||||
self.assertNotIn('"<b', chip)
|
||||
self.assertIn(""<b", chip)
|
||||
|
||||
|
||||
class ChipPlacement(unittest.TestCase):
|
||||
"""Where it goes: beside the name, in all four places, once."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = BOARD.read_text(encoding="utf-8")
|
||||
|
||||
# anchor → what the name is in that view. The chip must follow the
|
||||
# anchor within a few characters: beside the name, not further down.
|
||||
SITES = {
|
||||
"the sessions list row":
|
||||
'<span class="sid">${esc(m.id.slice(0, 8))}</span>',
|
||||
"the session-detail header":
|
||||
'<span class="sid">${esc(sid.slice(0, 8))}</span>',
|
||||
"the Focus header":
|
||||
'<span class="acc">${esc((meta.label || \'\').split(\' · \')[0])}</span>',
|
||||
"the working card's agent line":
|
||||
'<span class="who">${esc(who)}</span>',
|
||||
}
|
||||
|
||||
def test_every_name_that_identifies_a_run_wears_the_chip(self):
|
||||
for where, anchor in self.SITES.items():
|
||||
pos = self.html.find(anchor)
|
||||
self.assertNotEqual(pos, -1, f"{where} no longer renders as expected")
|
||||
window = self.html[pos + len(anchor):pos + len(anchor) + 40]
|
||||
self.assertIn("modelChip(", window,
|
||||
f"{where} lost the model chip beside its name")
|
||||
|
||||
def test_there_is_one_chip_component_not_four(self):
|
||||
"""One component means one register: a second inline copy is how
|
||||
the four drift apart."""
|
||||
self.assertEqual(self.html.count('class="mchip"'), 1,
|
||||
"the chip's markup must live only in modelChip()")
|
||||
self.assertEqual(self.html.count("function modelChip("), 1)
|
||||
|
||||
def test_the_chip_is_the_id_hashs_register_and_means_no_state(self):
|
||||
"""Mono because a model name is machine-produced, dim because it
|
||||
is a footnote to the name — and no state colour, because a model
|
||||
is not a state."""
|
||||
rule = re.search(r"\n \.mchip\{([^}]*)\}", self.html)
|
||||
self.assertIsNotNone(rule, "the .mchip rule is gone")
|
||||
self.assertIn("font-family:var(--mono)", rule.group(1))
|
||||
self.assertIn("color:var(--dim)", rule.group(1))
|
||||
for state_colour in ("--accent", "--calm", "--alarm"):
|
||||
self.assertNotIn(state_colour, rule.group(1),
|
||||
f"the chip took on {state_colour}: colour "
|
||||
"would start meaning 'model' as well as state")
|
||||
|
||||
def test_the_chip_never_wraps_a_line_it_joins(self):
|
||||
"""It joins flex rows carrying names and timestamps; a wrapping
|
||||
chip would move them."""
|
||||
rule = re.search(r"\n \.mchip\{([^}]*)\}", self.html)
|
||||
self.assertIn("white-space:nowrap", rule.group(1))
|
||||
|
||||
def test_the_metadata_lines_no_longer_repeat_a_known_model(self):
|
||||
"""The chip says which model; the two lines that used to carry it
|
||||
keep only what the chip cannot say — that a launch inherited the
|
||||
vendor default — and say it nowhere else."""
|
||||
for m in re.finditer(r"model inherited", self.html):
|
||||
window = self.html[max(0, m.start() - 200):m.start()]
|
||||
self.assertIn("!agent.model", window,
|
||||
"'model inherited' must be reached only when the "
|
||||
"model is genuinely unknown")
|
||||
self.assertEqual(self.html.count("model inherited"), 2,
|
||||
"the session-detail line and the Focus refline are "
|
||||
"the two places that say it")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -55,6 +55,32 @@ def expected_files() -> set:
|
||||
return files
|
||||
|
||||
|
||||
def shebang_members(tarball: Path) -> dict:
|
||||
"""{member name: tar-header mode} for every file in the artifact whose
|
||||
content starts `#!`.
|
||||
|
||||
The tar header is the truth here, not the repo: git records only the
|
||||
exec bit, and release.sh stages through a copy where a umask could
|
||||
still lose it (task 21's risk)."""
|
||||
modes = {}
|
||||
with tarfile.open(tarball) as tar:
|
||||
for member in tar.getmembers():
|
||||
if not member.isfile():
|
||||
continue
|
||||
stream = tar.extractfile(member)
|
||||
if stream is None or stream.read(2) != b"#!":
|
||||
continue
|
||||
modes[member.name.removeprefix("./")] = member.mode
|
||||
return modes
|
||||
|
||||
|
||||
def shebang_files_missing_exec(tarball: Path) -> list:
|
||||
"""The invariant, in one place: a shipped file that starts `#!` and
|
||||
cannot be run. Anything this names is a bug."""
|
||||
return sorted(name for name, mode in shebang_members(tarball).items()
|
||||
if not mode & 0o100)
|
||||
|
||||
|
||||
def build_artifact(out: Path) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["bash", str(REPO / "release.sh"), "--tarball", str(out),
|
||||
@@ -136,12 +162,39 @@ class ArtifactContents(unittest.TestCase):
|
||||
self.assertIn('BENCH_SOURCE_DEFAULT=""',
|
||||
(REPO / "update.sh").read_text("utf-8"))
|
||||
|
||||
def test_scripts_are_executable_in_the_tarball(self):
|
||||
for name in ("start.sh", "stop.sh", "update.sh",
|
||||
def test_every_shipped_shebang_file_is_executable(self):
|
||||
"""A shebang is a promise the file can be run. v0.1-alpha shipped
|
||||
install.py mode 644, so the README one-liner's `./install.py` was
|
||||
permission-denied on every install. The invariant is absolute — no
|
||||
exception list: a file that may not be run must not claim it can,
|
||||
and adding an exception here means editing this test with a reason.
|
||||
"""
|
||||
self.assertEqual([], shebang_files_missing_exec(self.tarball))
|
||||
# The sweep must actually have reached the scripts — an artifact
|
||||
# whose members read as empty would pass vacuously.
|
||||
seen = shebang_members(self.tarball)
|
||||
for name in ("install.py", "start.sh", "stop.sh", "update.sh",
|
||||
"manager/core/board.py",
|
||||
"manager/core/adapters/claude/run",
|
||||
"manager/core/adapters/claude/wire"):
|
||||
mode = self.members[name].mode
|
||||
self.assertTrue(mode & 0o100, f"{name} lost its executable bit")
|
||||
self.assertIn(name, seen,
|
||||
f"{name} was not seen as a shebang file")
|
||||
|
||||
def test_the_executable_invariant_catches_a_stripped_mode(self):
|
||||
"""The guard itself, proven to bite: repack the real artifact with
|
||||
install.py's mode stripped — exactly the v0.1-alpha shape — and the
|
||||
check must name it. Without this, a sweep that silently stopped
|
||||
finding shebangs would read as a clean tarball forever."""
|
||||
stripped = self.scratch / "mode-stripped.tar.gz"
|
||||
with tarfile.open(self.tarball) as src, \
|
||||
tarfile.open(stripped, "w:gz") as out:
|
||||
for member in src.getmembers():
|
||||
if member.name.removeprefix("./") == "install.py":
|
||||
member.mode = 0o644
|
||||
out.addfile(member, src.extractfile(member)
|
||||
if member.isfile() else None)
|
||||
|
||||
self.assertEqual(["install.py"], shebang_files_missing_exec(stripped))
|
||||
|
||||
|
||||
class ReleaseRefusals(unittest.TestCase):
|
||||
@@ -222,6 +275,19 @@ class ArtifactInstalls(unittest.TestCase):
|
||||
self.assertTrue((tm / "tasks" / "task-template.md").is_file())
|
||||
self.assertTrue((tm / "manager" / "local" / "state").is_dir())
|
||||
|
||||
def test_install_py_runs_directly_from_an_unpacked_release(self):
|
||||
"""The README's next step after unpacking is `./install.py`.
|
||||
v0.1-alpha shipped it mode 644, so that step was permission-denied
|
||||
on every install. Run as a program — no interpreter in front of it
|
||||
— so the exec bit is what is under test, unpacked and in place."""
|
||||
tm = self.make_install("runnable")
|
||||
env = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("BOARD_", "BENCH_"))}
|
||||
result = subprocess.run([str(tm / "install.py")],
|
||||
capture_output=True, text=True,
|
||||
cwd=tm.parent, env=env)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_board_serves_from_an_unpacked_artifact(self):
|
||||
tm = self.make_install("serving")
|
||||
|
||||
|
||||
@@ -140,6 +140,22 @@ class UpdateFromRelease(unittest.TestCase):
|
||||
self.assertEqual((tm / path).read_bytes(), content,
|
||||
f"{path} must survive byte-identical")
|
||||
|
||||
def test_update_heals_a_non_executable_install_py(self):
|
||||
# The v0.1-alpha field report: installs unpacked from an artifact
|
||||
# that carried mode 644 stay broken by themselves, because `cp`
|
||||
# onto an existing file keeps the destination's mode. update.sh's
|
||||
# chmod line is what heals them — existing victims, not only fresh
|
||||
# installs.
|
||||
tm = self.make_install()
|
||||
(tm / "install.py").chmod(0o644)
|
||||
|
||||
result = self.run_update(tm)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertTrue(
|
||||
os.access(tm / "install.py", os.X_OK),
|
||||
"update.sh must restore install.py's executable bit")
|
||||
|
||||
def test_agents_brief_replaces_an_old_vendor_named_copy(self):
|
||||
# Ported from the retired test_update_round_trip.py (whose harness
|
||||
# targeted the removed git-clone mechanism): an install from the
|
||||
|
||||
@@ -162,7 +162,7 @@ while read -r kind path _; do
|
||||
mkdir -p "$TM/$(dirname "$path")"
|
||||
cp "$dist/$path" "$TM/$path"
|
||||
done < "$manifest"
|
||||
chmod +x "$TM"/start.sh "$TM"/stop.sh "$TM"/update.sh 2>/dev/null || true
|
||||
chmod +x "$TM"/start.sh "$TM"/stop.sh "$TM"/update.sh "$TM"/install.py 2>/dev/null || true
|
||||
find "$TM/manager/core/adapters" -name run -o -name wire | xargs chmod +x 2>/dev/null || true
|
||||
|
||||
after="$(cat "$TM/manager/core/VERSION" 2>/dev/null || echo '?')"
|
||||
|
||||
Reference in New Issue
Block a user