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:
+36
-2
@@ -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
@@ -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 & clean up<small>${driving ? 'Park the drive, then m' : 'M'}erge the branch into main, push` +
|
||||
`${task.pr ? ' (marks the PR merged)' : ''}, remove the worktree and branches, move the card.</small></button>` +
|
||||
// team mode merges on origin: the board never makes a merge commit of
|
||||
// its own, so every replica's main keeps fast-forwarding
|
||||
`<button id="sh-ship" class="shipit">Merge & clean up<small>${driving ? 'Park the drive, then m' : 'M'}erge ` +
|
||||
((S.state.sync || {}).enabled
|
||||
? `the PR on GitHub, remove the worktree and branches, move the card — local main fast-forwards on the next sync beat.`
|
||||
: `the branch into main, push${task.pr ? ' (marks the PR merged)' : ''}, remove the worktree and branches, move the card.`) +
|
||||
`</small></button>` +
|
||||
`</div></div>`;
|
||||
wrap.classList.add('open');
|
||||
wrap.addEventListener('click', (e) => { if (e.target === wrap) closeSheet(); });
|
||||
|
||||
+163
-54
@@ -4,6 +4,13 @@ Copilot reviews, and polling PR state for cards sitting in review/.
|
||||
All of it is mechanical `git` + `gh` — no Claude involvement. The PR url is
|
||||
written into the task file (`**PR:** <url>`), keeping the file the single
|
||||
source of truth; only the volatile review/check state lives in memory.
|
||||
|
||||
With replicas watching one truth, *who* opens a PR matters: the trigger is
|
||||
the actor's board (watch.py refuses to fire on a move a pull applied) and
|
||||
the `**PR:**` line is the backstop behind it — carried by the file, so a
|
||||
second attempt from anywhere finds the PR already there, and a `gh pr
|
||||
create` that races anyway adopts the open PR instead of erroring. Polling
|
||||
is read-only and every board does it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,7 +26,7 @@ from pathlib import Path
|
||||
import config
|
||||
import drive as drive_mod
|
||||
import state
|
||||
from taskfiles import STATUS_RE, find_stage_of, move_task, read_task
|
||||
from taskfiles import STATUS_RE, commit_edit, find_stage_of, move_task, read_task
|
||||
|
||||
PR_STATE: dict[str, dict] = {} # filename -> {verdict, detail, url, ts}
|
||||
_OPENING: set[str] = set() # filenames with a PR-open in flight
|
||||
@@ -47,6 +54,10 @@ def _branch_exists(branch: str) -> bool:
|
||||
|
||||
|
||||
def _write_pr_line(filename: str, url: str) -> None:
|
||||
"""The url joins the header — and in team mode commits itself, so the
|
||||
gate that stops a second board opening a second PR travels to the other
|
||||
boards rather than sitting in one working tree (where it would also
|
||||
stall sync, which never runs over uncommitted changes)."""
|
||||
stage = find_stage_of(filename)
|
||||
if not stage:
|
||||
return
|
||||
@@ -59,59 +70,80 @@ def _write_pr_line(filename: str, url: str) -> None:
|
||||
else:
|
||||
text = f"**PR:** {url}\n\n" + text
|
||||
path.write_text(text, encoding="utf-8")
|
||||
commit_edit(filename, stage, "PR opened")
|
||||
|
||||
|
||||
class _Quiet(ValueError):
|
||||
"""A reason not worth the ticker: no branch, or a PR already open. The
|
||||
automatic path swallows these; the explicit action still shows them."""
|
||||
|
||||
|
||||
def maybe_open_pr(filename: str) -> None:
|
||||
"""Card entered review/ — open a PR for its branch if one can be opened.
|
||||
"""Card entered review/ on *this* board — open a PR for its branch if
|
||||
one can be opened.
|
||||
|
||||
Quiet when there is simply no branch (hand-written tasks); loud in the
|
||||
ticker when a PR *should* be possible but something stands in the way.
|
||||
Quiet when there is simply no branch (hand-written tasks) or a PR is
|
||||
already on the card; loud in the ticker when a PR *should* be possible
|
||||
but something stands in the way.
|
||||
"""
|
||||
if filename in _OPENING:
|
||||
return
|
||||
_OPENING.add(filename)
|
||||
try:
|
||||
_open_pr(filename)
|
||||
except _Quiet:
|
||||
pass
|
||||
except ValueError as exc:
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": str(exc)})
|
||||
finally:
|
||||
_OPENING.discard(filename)
|
||||
|
||||
|
||||
def _open_pr(filename: str) -> None:
|
||||
def open_pr_now(filename: str) -> str:
|
||||
"""The explicit action behind ↑ open PR: no board ever completes a
|
||||
half-done side effect on its own (the actor's board may have died
|
||||
between moving the card and opening the PR), so a person asks for it —
|
||||
and hears the reason when it cannot happen."""
|
||||
if filename in _OPENING:
|
||||
raise ValueError(f"a PR for {filename} is already being opened")
|
||||
_OPENING.add(filename)
|
||||
try:
|
||||
return _open_pr(filename)
|
||||
finally:
|
||||
_OPENING.discard(filename)
|
||||
|
||||
|
||||
def _open_pr(filename: str) -> str:
|
||||
branch = f"task/{filename[:-3]}"
|
||||
if not _branch_exists(branch):
|
||||
return # nothing to publish — a hand-moved card without agent work
|
||||
# nothing to publish — a hand-moved card without agent work
|
||||
raise _Quiet(f"{filename} has no {branch} branch — nothing to open a PR from")
|
||||
stage = find_stage_of(filename)
|
||||
if stage != "review":
|
||||
return
|
||||
raise _Quiet(f"{filename} is not in review/ — PRs open from there")
|
||||
task = read_task(config.TASKS / stage / filename, stage)
|
||||
if task.get("pr"):
|
||||
return # already open
|
||||
raise _Quiet(f"{filename} already has a PR: {task['pr']}")
|
||||
|
||||
rname = remote()
|
||||
if rname is None or not gh_available():
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"no PR for {filename}: " +
|
||||
("no git remote configured" if rname is None else "gh is not installed")})
|
||||
return
|
||||
raise ValueError(f"no PR for {filename}: " +
|
||||
("no git remote configured" if rname is None
|
||||
else "gh is not installed"))
|
||||
|
||||
# The PR's diff is computed against the remote main — refuse to open one
|
||||
# that would drag unpushed main commits along with it.
|
||||
_run(["git", "fetch", rname, "main"], timeout=120)
|
||||
ahead = _run(["git", "rev-list", "--count", f"{rname}/main..main"]).stdout.strip()
|
||||
if ahead.isdigit() and int(ahead) > 0:
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"won't open a PR for {filename}: main is {ahead} commits "
|
||||
f"ahead of {rname} — push main first, then move the card again"})
|
||||
return
|
||||
raise ValueError(f"won't open a PR for {filename}: main is {ahead} commits "
|
||||
f"ahead of {rname} — push main first, then move the card again")
|
||||
|
||||
push = _run(["git", "push", "-u", rname, branch], timeout=180)
|
||||
if push.returncode != 0:
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"push failed for {branch}: {push.stderr.strip()[:140]}"})
|
||||
return
|
||||
raise ValueError(f"push failed for {branch}: {push.stderr.strip()[:140]}")
|
||||
|
||||
body = (f"Task: `{filename}` — tracked in `.task-manager/tasks/review/`.\n\n"
|
||||
f"Opened by the board when the card moved to review.")
|
||||
@@ -121,10 +153,20 @@ def _open_pr(filename: str) -> None:
|
||||
result = _run([config.GH_BIN, "pr", "create", "--head", branch, "--base", "main",
|
||||
"--title", task["title"], "--body", body], timeout=120)
|
||||
if result.returncode != 0:
|
||||
# The rare double-fire: two attempts crossed and GitHub already has
|
||||
# the PR. Adopt it — one PR still exists, and the card learns its
|
||||
# url. Anything else is a real failure.
|
||||
adopted = _existing_pr(branch) if _already_exists(result) else ""
|
||||
if not adopted:
|
||||
raise ValueError(f"PR creation failed for {branch}: "
|
||||
f"{result.stderr.strip()[:140]}")
|
||||
_write_pr_line(filename, adopted)
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"PR creation failed for {branch}: {result.stderr.strip()[:140]}"})
|
||||
return
|
||||
"summary": f"{filename}'s PR was already open — adopted it: {adopted}"})
|
||||
state.broadcast({"type": "board"})
|
||||
_poll_pr(filename, adopted)
|
||||
return adopted
|
||||
url = next((l.strip() for l in result.stdout.splitlines() if "/pull/" in l), result.stdout.strip())
|
||||
_write_pr_line(filename, url)
|
||||
state.record_board_event({
|
||||
@@ -132,6 +174,20 @@ def _open_pr(filename: str) -> None:
|
||||
"summary": f"PR opened for {filename}: {url}"})
|
||||
state.broadcast({"type": "board"})
|
||||
_poll_pr(filename, url) # first CI/review snapshot without waiting a cycle
|
||||
return url
|
||||
|
||||
|
||||
def _already_exists(result: subprocess.CompletedProcess) -> bool:
|
||||
return "already exists" in (result.stderr + result.stdout).lower()
|
||||
|
||||
|
||||
def _existing_pr(branch: str) -> str:
|
||||
"""The url of the PR already open for this branch, if gh can name it."""
|
||||
found = _run([config.GH_BIN, "pr", "view", branch, "--json", "url",
|
||||
"--jq", ".url"], timeout=60)
|
||||
if found.returncode != 0:
|
||||
return ""
|
||||
return next((l.strip() for l in found.stdout.splitlines() if "/pull/" in l), "")
|
||||
|
||||
|
||||
def _agent_log_tail(filename: str, cap: int = 1500) -> str:
|
||||
@@ -326,9 +382,15 @@ def open_pr_async(filename: str) -> None:
|
||||
|
||||
def complete_task(filename: str, stage: str) -> dict:
|
||||
"""The user chose "merge & clean up" on a move to done: park the drive
|
||||
if it is this task's, merge the branch into main, push (which marks the
|
||||
PR merged), remove the worktree and branches, then move the card.
|
||||
Every step narrates; a conflict aborts cleanly and the card stays."""
|
||||
if it is this task's, merge the branch, remove the worktree and
|
||||
branches, then move the card. Every step narrates; a conflict aborts
|
||||
cleanly and the card stays.
|
||||
|
||||
Where the merge happens depends on team mode. Single-player merges into
|
||||
the local main and pushes it, exactly as it always did; with
|
||||
`BOARD_SYNC` on the merge is made on origin through `gh pr merge`, so
|
||||
local main only ever fast-forwards to it — the discipline the whole
|
||||
sync design rests on."""
|
||||
if stage not in config.STAGE_DIRS or stage == "done":
|
||||
raise ValueError("complete runs on a live-stage card")
|
||||
if not (config.TASKS / stage / filename).is_file():
|
||||
@@ -348,37 +410,18 @@ def complete_task(filename: str, stage: str) -> dict:
|
||||
|
||||
merged = False
|
||||
if _branch_exists(branch):
|
||||
current = _run(["git", "branch", "--show-current"]).stdout.strip()
|
||||
if current != "main":
|
||||
raise ValueError(f"the repo is on '{current}', not main — switch first")
|
||||
result = _run(["git", "merge", "--no-edit", branch], timeout=120)
|
||||
if result.returncode != 0:
|
||||
_run(["git", "merge", "--abort"])
|
||||
detail = (result.stdout.strip() or result.stderr.strip())[-160:]
|
||||
raise ValueError(f"merge conflict — resolve by hand ({detail})")
|
||||
if config.SYNC:
|
||||
_merge_on_origin(filename, stage, branch)
|
||||
else:
|
||||
_merge_locally(filename, branch)
|
||||
merged = True
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"merged {branch} into main"})
|
||||
|
||||
rname = remote()
|
||||
if rname:
|
||||
push = _run(["git", "push", rname, "main"], timeout=180)
|
||||
if push.returncode != 0:
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"merged locally but the push failed — push main "
|
||||
f"yourself ({push.stderr.strip()[:100]})"})
|
||||
else:
|
||||
_run(["git", "push", rname, "--delete", branch], timeout=60)
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"pushed main (PR marked merged) and deleted {branch} on {rname}"})
|
||||
|
||||
worktree = config.WORKTREES / stem
|
||||
if worktree.exists():
|
||||
_run(["git", "worktree", "remove", "--force", str(worktree)])
|
||||
_run(["git", "branch", "-d", branch])
|
||||
# -D under sync: main here has not merged the branch yet (origin
|
||||
# did), so the safe delete would refuse something already landed.
|
||||
_run(["git", "branch", "-D" if config.SYNC else "-d", branch])
|
||||
PR_STATE.pop(filename, None)
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
@@ -389,6 +432,65 @@ def complete_task(filename: str, stage: str) -> dict:
|
||||
return {"merged": merged}
|
||||
|
||||
|
||||
def _merge_locally(filename: str, branch: str) -> None:
|
||||
"""Single-player: merge into the checkout's own main and push it."""
|
||||
current = _run(["git", "branch", "--show-current"]).stdout.strip()
|
||||
if current != "main":
|
||||
raise ValueError(f"the repo is on '{current}', not main — switch first")
|
||||
result = _run(["git", "merge", "--no-edit", branch], timeout=120)
|
||||
if result.returncode != 0:
|
||||
_run(["git", "merge", "--abort"])
|
||||
detail = (result.stdout.strip() or result.stderr.strip())[-160:]
|
||||
raise ValueError(f"merge conflict — resolve by hand ({detail})")
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"merged {branch} into main"})
|
||||
|
||||
rname = remote()
|
||||
if rname:
|
||||
push = _run(["git", "push", rname, "main"], timeout=180)
|
||||
if push.returncode != 0:
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"merged locally but the push failed — push main "
|
||||
f"yourself ({push.stderr.strip()[:100]})"})
|
||||
else:
|
||||
_run(["git", "push", rname, "--delete", branch], timeout=60)
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"pushed main (PR marked merged) and deleted {branch} on {rname}"})
|
||||
|
||||
|
||||
def _merge_on_origin(filename: str, stage: str, branch: str) -> None:
|
||||
"""Team mode: the merge commit is made by GitHub, on origin.
|
||||
|
||||
Replicas keep converging only while local main advances by
|
||||
fast-forward, so the board never creates a merge commit of its own —
|
||||
it asks origin for one and lets the sync beat deliver it. Needs merge
|
||||
rights on the repo for whoever clicks, which the local path did not.
|
||||
"""
|
||||
task = read_task(config.TASKS / stage / filename, stage)
|
||||
url = task.get("pr")
|
||||
if not url:
|
||||
raise ValueError(
|
||||
f"{filename} has no PR, and with BOARD_SYNC on the merge is made on "
|
||||
f"origin — open a PR for {branch} first (↑ open PR on the card)")
|
||||
if not gh_available():
|
||||
raise ValueError("gh is not installed — with BOARD_SYNC on the merge runs on origin")
|
||||
number = url.rstrip("/").rsplit("/", 1)[-1]
|
||||
result = _run([config.GH_BIN, "pr", "merge", number, "--merge"], timeout=180)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr.strip() or result.stdout.strip())[-160:]
|
||||
raise ValueError(f"origin would not merge the PR — resolve it on GitHub ({detail})")
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"merged {filename}'s PR on origin — local main fast-forwards "
|
||||
f"on the next sync beat"})
|
||||
rname = remote()
|
||||
if rname:
|
||||
_run(["git", "push", rname, "--delete", branch], timeout=60)
|
||||
|
||||
|
||||
def task_branches() -> list[str]:
|
||||
"""Stems of all task/* branches — the UI uses this to say honestly
|
||||
whether a review card has work attached."""
|
||||
@@ -400,7 +502,14 @@ def task_branches() -> list[str]:
|
||||
def reconcile() -> None:
|
||||
"""Catch up on moves the watcher never saw (board was down): any card
|
||||
already sitting in review/ with a branch but no PR gets its PR opened
|
||||
now. Runs once at startup."""
|
||||
now. Runs once at startup.
|
||||
|
||||
Not in team mode. A replica cannot tell whose move it missed, so every
|
||||
board starting up would race to open the same PR — and the card that
|
||||
needs one wears the explicit ↑ open PR action instead, which is a
|
||||
person deciding rather than N boards guessing."""
|
||||
if config.SYNC:
|
||||
return
|
||||
time.sleep(3) # let the server settle first
|
||||
directory = config.TASKS / "review"
|
||||
if not directory.is_dir():
|
||||
|
||||
+10
-1
@@ -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
@@ -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
@@ -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 ""),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user