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>
This commit is contained in:
istos
2026-07-30 11:18:17 +02:00
co-authored by Claude Opus 5
parent eaa2d3f781
commit 755d40dd33
7 changed files with 955 additions and 91 deletions
+36 -2
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:
@@ -190,10 +190,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}"
+42 -8
View File
@@ -892,9 +892,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') });
@@ -902,7 +910,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');
@@ -1142,11 +1160,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; }
@@ -1192,6 +1210,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' },
@@ -1244,8 +1273,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(); });
+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():
+10 -1
View File
@@ -40,6 +40,9 @@ 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,
@@ -167,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()
@@ -181,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"])
+62 -15
View File
@@ -181,35 +181,82 @@ def _set_assignee(text: str, name: str) -> str:
return TITLE_RE.sub(lambda m: f"{m.group(0)}\n\n**Assignee:** {name}", text, count=1)
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 touching only this task file.
def set_assignee(filename: str, stage: str, name: str) -> None:
"""Write who holds a card where it stands, replacing whoever held it.
Staging is scoped to the file's own 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 the card has already
moved on disk, which is the source of truth.
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.
"""
message = f"board: {number or filename[:-3]}{target} ({who or 'board'})"
try:
spec = [str(dst)]
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
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
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"{filename} moved, but committing it failed: {detail[:140]}"})
"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:
+26 -11
View File
@@ -3,6 +3,12 @@
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
@@ -23,14 +29,20 @@ def _board_sig() -> dict[str, set[str]]:
return sig
def _actor(filename: str, stage: str) -> str:
"""Who did this. A move this board made is claimed from the
expectations; one a pull brought carries its commit author's name; a
plain mv on this disk is nobody in particular."""
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":
return sync.arrived_actor(filename) or "disk"
return actor
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:
@@ -39,19 +51,22 @@ def narrate(prev: dict[str, set[str]], cur: dict[str, set[str]]) -> None:
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 = _actor(f, stage)
actor, remote = _actor(f, stage)
state.record_board_event({
"kind": "move", "file": f, "from": prev_loc[f], "to": stage,
"actor": actor,
"actor": actor, "remote": remote,
"summary": f"{f} moved {prev_loc[f]}{stage} ({actor})",
})
if stage == "review":
# a card entering review with a work branch gets a PR
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 = _actor(f, stage)
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 ""),
})