57 Commits
Author SHA1 Message Date
istosandClaude Fable 5 a5dd0ac565 Version 0.2-alpha
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:15:14 +02:00
istos f64afcfd92 board: 11 → done (istos) 2026-07-30 14:13:20 +02:00
Ronald AshriandGitHub 8d1e84819d Merge pull request #22 from 12vectors/task/11-failed-agent-runs-must-be-visible
11 — A failed agent run must leave a visible trace on the card
2026-07-30 14:13:11 +02:00
istosandClaude Fable 5 c2cf1e92c0 Card 26: forge adapters — core stops speaking GitHub
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:12:06 +02:00
istos 780cdf97f4 board: 11 PR opened (istos) 2026-07-30 13:11:03 +02:00
istos 55a5d2a781 board: 11 → review (istos) 2026-07-30 13:10:55 +02:00
istosandClaude Opus 5 d94b6ee423 A failed run is a state the card wears
An agent that exits non-zero was the least-handled outcome on the board:
one ticker line that scrolled away, a card that looked exactly as it did
before the launch, and the log's contents — usually the whole story — left
on disk. Three launches died in an API outage and the board said nothing a
person would notice.

So the outcome is recorded on the run: exit code, ended-at, and the
cleaned tail of its log as the excerpt (a launch that died before the
agent spoke says so rather than showing blank). From that 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 line stays, now naming what the log ended on.

Every headless kind lands in _finish, so work, act-pr, PR review and the
relevance check are all covered. The state is scoped to the run and the
stage: the next launch supersedes it, and the watcher drops it when the
card moves, so nothing follows a card into review/.

The way is cleared for the relaunch too: a failed run with no commits has
its worktree and empty branch removed — the reasoning a decline already
uses — so ▸ start work works without a hand `git worktree remove`. A
failed run with commits keeps its worktree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:10:26 +02:00
istos 3693b66574 board: 11 → in-progress (istos) 2026-07-30 12:54:59 +02:00
istos 829a764870 board: 21 → done (istos) 2026-07-30 12:51:35 +02:00
Ronald AshriandGitHub 64ad311267 Merge pull request #20 from 12vectors/task/21-shebang-means-executable
21 — A shebang means executable: fix the shipped modes and test the invariant
2026-07-30 12:51:27 +02:00
istos eb29af4977 board: 24 → done (istos) 2026-07-30 12:51:17 +02:00
Ronald AshriandGitHub 5eed46d963 Merge pull request #21 from 12vectors/task/24-model-chip-beside-the-agent-name
24 — A model chip beside every agent name
2026-07-30 12:51:12 +02:00
istos 663552f5db board: 24 PR opened (istos) 2026-07-30 12:05:02 +02:00
istos fa0be43fe6 board: 24 → review (istos) 2026-07-30 12:04:54 +02:00
istos 053e1d7d71 A model chip beside every agent name
Task 12 recorded which model each launch rode, but it surfaced only at
the tail of the session-detail metadata line, where nobody's eye lands.
With work and review models diverging per intent, "which brain did this"
is a routine review question, so the answer now sits beside the name that
did it.

One component, modelChip(), rendered wherever a name identifies a run:
the sessions list row, the session-detail header, the Focus header and
the working card's agent line. It borrows the session-id hash's register
— small, mono, dim — and takes no colour, because a model is not a state.
The vendor prefix a name repeats is dropped for the chip (claude-opus-4-8
→ opus-4-8, anthropic/x → x) with the whole string on hover; an
unfamiliar name is shown as recorded rather than guessed at.

A launch that never knew its model wears no chip at all — an inherited
default, or a session replayed from disk after a restart. The two
metadata lines that used to carry the model keep only what the chip
cannot say: "model inherited", and only when it genuinely is.

Tests lift the two functions out of the page and run them in node (skipped
where node is absent) for the shortening, escaping and silence; placement
and register stay source-level invariants, as board.html has no frontend
test runner.
2026-07-30 12:04:36 +02:00
istos 8794061db3 board: 21 PR opened (istos) 2026-07-30 12:00:29 +02:00
istos 5dd154cef7 board: 21 → review (istos) 2026-07-30 12:00:21 +02:00
istosandClaude Opus 5 eec69ba575 A shebang means executable: test the invariant, cut the patch
install.py shipped mode 644 in v0.1-alpha, so the README one-liner's
./install.py was permission-denied on every install. The repo modes and
update.sh's repair line were hotfixed already (all 14 shebang'd tracked
files are 100755; update.sh:165 names install.py) — this is the guard
that keeps them that way, and the patch release that heals the field.

- tests/test_release_artifact.py: the invariant, read from the tar
  header rather than the repo — every member whose content starts `#!`
  must carry the exec bit, failing by name. No exception list: there is
  no shipped file that legitimately may not be run, and gaining one
  means editing the test with a reason. Two tests keep it honest: the
  guard is proven to bite by repacking the real artifact with
  install.py's mode stripped, and an unpacked release must run
  ./install.py as a program, not via python3.
- 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
  again after any update. `cp` onto an existing file keeps the
  destination's mode, so the chmod line is the only thing healing it;
  removing that line fails this test.
- manager/core/VERSION → 0.1-alpha.1: cutting the patch is the honest
  move over a release note telling users to work around it.
- manager/core/release-manifest: the invariant, stated where the
  shipping list lives.

Verified: python3 -m unittest discover -s tests (267 tests, OK). Both
new assertions were watched failing first — a build-side `chmod -x` on
the staged install.py, and update.sh with install.py dropped from its
chmod list — then restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:00:01 +02:00
istos 39ab96d81b board: 24 → in-progress (istos) 2026-07-30 11:52:54 +02:00
istos 9e5e44d48f board: 21 → in-progress (istos) 2026-07-30 11:52:51 +02:00
istos de81ce653e board: 20 → done (istos) 2026-07-30 11:51:41 +02:00
istos 13af1d0a60 Merge branch 'task/20-replicas-render-actors-act' 2026-07-30 11:51:36 +02:00
istos f4e01377c5 board: 23 → done (istos) 2026-07-30 11:49:50 +02:00
istos dd00a77238 Merge branch 'task/23-updated-bench-logo-from-design' 2026-07-30 11:49:45 +02:00
istos f241fc5968 board: 20 → review (istos) 2026-07-30 11:19:57 +02:00
istosandClaude Opus 5 96ec71ff33 docs: state syncs, reactions don't
AGENTS.md gets the "State syncs; reactions don't" section under Syncing
boards — the actor-only rule, the file-carried backstops behind it, and
the two consequences you can see (a half-done side effect is nobody's to
finish automatically; ownership gates work launches). The Pull requests
section says which board opens one and how ↑ open PR asks afterwards, the
card-actions list gains ▸ take over and ↑ open PR, "Claiming a card" stops
saying the claim refuses nothing, and the merge & clean up paragraph
documents the origin-side merge — including the constraint the local path
hid: whoever clicks needs merge rights on the repo, not just push rights.

.env.example: BOARD_COMMIT_MOVES documents the claim as a launch gate,
BOARD_SYNC the actor-only reactions and the origin-side merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:18:24 +02:00
istosandClaude Opus 5 755d40dd33 etiquette: the actor's board acts, every other replica renders
Task 19 gave every board the same truth; this makes exactly one of them
react to it. State syncs; reactions don't.

- watch.py: attribution is now the trigger gate. _actor returns (who,
  remote), and a move a pull applied — the arrivals sync files — renders
  and narrates but opens no PR. A plain mv on this disk still acts: inert
  means "happened elsewhere", not "unattributed".
- github.py: the file-carried gates behind that rule, so the rare double
  is harmless rather than loud. The **PR:** line commits itself in team
  mode (taskfiles.commit_edit, sharing the move's pathspec-scoped commit),
  so it reaches the other boards instead of sitting in one working tree —
  where it would also stall sync outright; and a `gh pr create` that races
  anyway adopts the PR GitHub already holds.
- No board finishes the actor's half-done side effect on its own: the
  startup reconcile stands down in team mode and a review card with a
  branch and no PR carries ↑ open PR (POST /api/pr/open), which is a
  person deciding rather than N boards guessing. _open_pr raises its
  reasons now, so the automatic path narrates them and the explicit one
  toasts them.
- agents.py: the claim gates work launches. A card someone else holds
  refuses, naming them; ▸ take over is the deliberate second path (armed
  like everything that costs tokens) and reassigns via taskfiles'
  set_assignee; an unheld card claims itself on launch. Only in team mode
  — with BOARD_COMMIT_MOVES off nothing writes an assignee, so nothing
  reads one as a lock.
- github.complete_task: with BOARD_SYNC on, merge & clean up runs
  `gh pr merge` and lets the beat deliver the result, so local main only
  ever fast-forwards and no board makes a merge commit of its own. A
  branch without a PR is refused with a pointer to ↑ open PR. Sync off
  keeps the local merge path exactly as it was.

Verified with tests/test_actor_acts.py: two real clones of a real bare
upstream and a stub gh — the replica that only renders, the PR line that
travels, the double that adopts, the takeover that reassigns, and both
merge paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:18:17 +02:00
istos 3d427670ef board: 23 → review (istos) 2026-07-30 11:16:52 +02:00
istosandClaude Opus 5 3efa0f6aef The header wears the design's wordmark
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 still showed the old "Bench" in Plex Sans and a dot in the tab.

Bring both in line. The board may not fetch a font for a logo, so the
wordmark ships as outlines: one path per letter on a 1000-unit em, filled
with currentColor so Night and Daylight each get their own ink and the mark
never wears a colour that means state. --logo-h is the single number that
sizes it. The tab icon is the wordmark's own b on the accent tile it
already had, reusing the b's outline string character for character.

Nothing beside it moves: the mono path line still sits on the wordmark's
baseline, at the design's 11px, and the header's padding, switcher and chips
are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:16:25 +02:00
istos 865b4eeb3b board: 23 → in-progress (istos) 2026-07-30 10:59:35 +02:00
istos eaa2d3f781 board: 20 → in-progress (istos) 2026-07-30 10:56:57 +02:00
istos 0e37d01b64 board: 20 → to-do (istos) 2026-07-30 10:56:35 +02:00
istos c812314706 board: 20 → backlog (istos) 2026-07-30 10:52:36 +02:00
istos e80ff6adc2 board: 20 → to-do (istos) 2026-07-30 10:52:30 +02:00
istos 3916b0942f board: 19 → done (istos) 2026-07-30 10:49:16 +02:00
istos 0bd9279284 Merge branch 'task/19-boards-sync-through-origin-main' 2026-07-30 10:49:11 +02:00
istos 3e3612916c board: 19 → review (istos) 2026-07-30 09:48:46 +02:00
istos ee8469d009 docs: team mode through origin/main
AGENTS.md gets the "Syncing boards" section — what pushes, what pulls,
what a lost race looks like, and the two disciplines that make it safe:
local main advances only through the board and origin, and sync never
merges. The module map gains sync.py, the settings paragraph and the
Board view's description gain the sync chip and the author attribution,
and .env.example documents BOARD_SYNC / BOARD_SYNC_INTERVAL, including
the fetch-rate line for rate-limited remotes.
2026-07-30 09:47:49 +02:00
istos 9d98e2c55a sync: origin/main as the shared board, behind BOARD_SYNC
Team mode's second half. A board-made move already commits itself (18);
now that commit publishes, every board pulls on a beat, and a card two
boards move at once resolves the way git resolves everything else — the
push race is the concurrency control.

- core/sync.py: push is event-driven (a new state.COMMIT_HOOKS registry
  fires it from taskfiles, so taskfiles stays left of everything that
  reacts to it); pull is a beat that fast-forwards, or replays this
  board's own commits on top when the two diverged. A replay that
  conflicts on a task file drops the local move — origin is the
  linearizer — and toasts who took the card.
- The piggyback guard stands in front of every push and every replay:
  each local-ahead commit on main must be `board: `-prefixed, so a
  human's unpushed work is never published as a side effect of a card
  moving. Uncommitted changes, a checkout off main and an unreachable
  origin all stall or degrade rather than risk anything, each narrated
  once instead of once per beat.
- watch.py names the commit author instead of "disk" for moves a pull
  brought, via the arrivals sync files for it; its narration moved out
  of the loop into narrate() so it can be tested directly.
- The board grows a sync chip that appears only when sync stops
  converging, and the SSE stream grows a toast type so the server can
  say something to the person, not just to the ticker.

Gate off (the default) means no fetch, no push, no thread, no change.

Verified with tests/test_boards_sync.py: two real clones of a real bare
upstream race each other through every case above.
2026-07-30 09:47:38 +02:00
istos ed5bf81a4d board: 19 → in-progress (istos) — complete the half-committed move
The claim commit e31de61 was hand-made after the board (gate still off)
had already moved the file on disk: it added the in-progress copy but
missed staging the backlog deletion. Nothing was lost — same content,
both paths, now one.
2026-07-30 09:24:44 +02:00
istos 0af24ccbc4 board: 20 → in-progress (istos) 2026-07-30 09:23:14 +02:00
istos e31de617b7 board: 19 → in-progress (istos) 2026-07-30 09:22:13 +02:00
istosandClaude Fable 5 c6be5a6298 Board state: 04, 18, 22 done; 24 queued to to-do
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 09:18:44 +02:00
istos 48a8d6aa9c Merge branch 'task/18-cards-claimed-on-move' 2026-07-30 09:17:19 +02:00
istosandClaude Opus 4.8 2e84227407 Clarify gate-off docs: an existing assignee is still read and shown
Copilot review on PR #15: the 'gate off' wording in AGENTS.md and
.env.example read as 'no assignee', but read_task() always parses the
Assignee line and the card face always renders it. The gate governs only
whether a move writes/clears the line and commits. Reword both to say so.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 09:16:06 +02:00
istosandClaude Opus 5 de5cedb31f Card 22: the tab names its project
Every board tab read "Bench — task board", so the moment a second bench
existed the tab bar stopped saying which was which. The title now leads
with the project — "<project> · bench" — because tab truncation eats the
tail and the tail is the same in every bench tab.

The project is config.PROJECT: the repo directory's name, or BOARD_TITLE
from local/.env for people whose checkouts are all called "app". The
server renders it into the served page's <title>, so the tab is right on
first paint with no flicker from generic to named; /api/state carries it
too, and renderTitle() keeps it in step when the view switcher swaps the
tail (sessions, focus). The project stays the first word regardless, and
nothing else writes document.title.

Tests: tests/test_board_title.py covers the server half in fresh
interpreters (BOARD_TITLE resolution, the rendered title, escaping, the
rest of the page untouched) and the browser half as source invariants,
the same way the other board.html tests work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 09:11:58 +02:00
istosandClaude Opus 5 3629116afa Document the claim: a "Claiming a card" section and the Assignee field
AGENTS.md gains the convention — claiming is moving, the assignee is who
launches agents on the card, hand-moves bypass the claim and should update
the line by hand, and git identities collide the way git's do — plus the
**Assignee:** header field beside Status/Priority/Type and the
BOARD_COMMIT_MOVES gate with its unpushed-main consequence.
.env.example documents the setting with its default (off).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 09:06:00 +02:00
istosandClaude Opus 5 22255f2842 Cards are claimed on move: the assignee written, the move committed
Moving a card out of backlog/ or to-do/ now claims it. taskfiles.move_task
writes an **Assignee:** line from `git config user.name` — first claim only,
an existing assignee is never overwritten — and clears it when a card is
walked all the way back to backlog/. The same move then commits itself: one
commit, the move and the claim together, staged by pathspec so a developer's
unrelated staged work is neither committed nor unstaged, messaged
`board: <number> → <stage> (<name>)`. Hooks are skipped (bookkeeping, not
code) and nothing is pushed — that is task 19's job. A commit that fails is
narrated in the ticker; the card has already moved, and disk is the truth.

All of it sits behind BOARD_COMMIT_MOVES, off by default, so a
single-player board moves cards byte-identically to before.

The card face shows the owner instead of "nobody yet" in every stage — on
done/ cards the line reads as history — and the who row now escapes what
the file said.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 09:05:48 +02:00
istosandClaude Fable 5 a647b849e8 Card 25: in-lane ordering via a board-managed Rank line
Rank rides in the card header so every reorder is a single-file write
that merges one card at a time — the shape the directory-is-truth law
and task 19's sync model both demand. Sparse integer ranks, midpoint
drops, one sanctioned multi-file renumber when gaps exhaust.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 09:03:25 +02:00
istosandClaude Fable 5 5bc297d859 Card 24: a model chip beside every agent name
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 08:59:26 +02:00
istosandClaude Fable 5 6dff0d3f84 Card 23: bring the updated bench logo in from the design project
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 08:58:24 +02:00
istos 374003f5d7 Merge branch 'task/04-activity-log-resize-grip-does-nothing' 2026-07-30 08:51:54 +02:00
istosandClaude Fable 5 de4ff8fe92 Card 22: the board's tab names its project
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 08:50:51 +02:00
istosandClaude Fable 5 ead190ac03 Shebang'd files ship executable; update.sh heals existing installs (task 21 hotfix)
First field bug of v0.1-alpha: install.py was committed 100644, so
every install shipped it permission-denied for direct ./install.py use
(start.sh's python3 invocation masked it). Exec bit set on all four
shebang'd files (install.py, board.py, hook_settings.py,
permission_config.py) and install.py added to update.sh's post-update
chmod line so already-broken installs heal on their next update. The
artifact-side invariant test — every shipped #! file executable — is
card 21.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 08:50:08 +02:00
istosandClaude Fable 5 e12d5b1a85 Cards 18-20: the multi-user arc — claim on move, sync through origin, replica etiquette
From the 2026-07-30 design discussion: origin/main becomes the truth
and every checkout a replica. 18 makes moving a card claim it (assignee
written and committed atomically); 19 pushes board moves and pulls on a
beat, with git as the lock server and take-over toasts for lost races;
20 keeps side effects on the actor's board only, gates launches on
claims, and routes team-mode merges through origin. All gated off by
default; single-player bench is untouched until opted in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 08:45:58 +02:00
istosandClaude Fable 5 0aeba69b01 CI: run the test suite on every PR and push to main
Ubuntu runner, Python 3.11 and 3.13 (stdlib-only project: oldest
supported and newest current). Git identity and init.defaultBranch=main
configured for the tests that build scratch repos; everything else the
suite needs (bash, tar, rsync, stub binaries) is on the runner already.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 08:30:55 +02:00
istosandClaude Fable 5 f9d55efa15 Make the activity log's resize grip actually resize
The grip's handlers write #logbody's inline height, but the CSS said
flex:1 — inside the column-flex #logpanel the flex algorithm sized the
element from basis 0% + grow and never consulted the height property,
so dragging did nothing. Worse, mouseup persisted a re-read
offsetHeight (the flex-computed value), overwriting the remembered
size with the status quo on every attempt.

Re-couple the two: #logbody becomes flex:none so the written height is
authoritative again, with max-height:60vh guarding overflow. The
handlers track the clamped height they compute and mouseup persists
that value; the load-time restore runs through the same clamp and
writes the same property.

The drawer grip writes width on the position:fixed #drawer itself, so
no flex competes there — covered by a non-regression test alongside
the source-level invariants in tests/test_log_resize.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 08:22:36 +02:00
42 changed files with 5345 additions and 224 deletions
+28
View File
@@ -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
+203 -12
View File
@@ -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 -1
View File
@@ -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
View File
+54
View File
@@ -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
View File
@@ -1 +1 @@
0.1-alpha
0.2-alpha
View File
View File
+129 -6
View File
@@ -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
View File
@@ -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 &amp; 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 &amp; 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
View File
@@ -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")
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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"])
+5
View 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
+14
View File
@@ -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:
+448
View File
@@ -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
View File
@@ -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
View File
@@ -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"})
+99
View File
@@ -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.
+129
View File
@@ -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,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.
+121
View File
@@ -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.
+153
View File
@@ -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.
+126
View File
@@ -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.
+616
View File
@@ -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()
+168
View File
@@ -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("&lt;script&gt;", 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()
+565
View File
@@ -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()
+298
View File
@@ -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()
+391
View File
@@ -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()
+169
View File
@@ -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()
+103
View File
@@ -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 80px60vh 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()
+171
View File
@@ -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("&quot;&lt;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()
+70 -4
View File
@@ -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")
+16
View File
@@ -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
+1 -1
View File
@@ -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 '?')"