Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@
"name": "work-system",
"source": "./plugins/work-system",
"description": "Generic task and worktree workflow system for Claude Code. Keep tasks as GitHub issues or local markdown files, run them in isolated git worktrees with a choice of worker agent (Claude, codex, grok, kimi, or a PATH-detected cc-harness agent), and track progress across the define/kickoff/continue/status/close lifecycle.",
"version": "1.28.2"
"version": "1.28.4"
},
{
"name": "pr-flow",
"source": "./plugins/pr-flow",
"description": "PR review feedback loop for Claude Code. Create PRs with readiness checks, commit + push + trigger @claude review, inspect status, and work through review issues interactively.",
"version": "1.8.1"
"version": "1.8.2"
},
{
"name": "swarm",
Expand Down
43 changes: 29 additions & 14 deletions .claude/knowledge/features/worker-autonomy-mandate.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,20 +239,35 @@ false, and `mandate.sh lane <branch>` is why: that resolver exists so a Manager
session running `/cycle` from the main repo can act on a *worker's* worktree. The
worker's own review loop and the Manager's can therefore book a round at the same
instant, both read the same count, and a budget of two funds an unbounded number
of reviews. The lock is eight lines of `mkdir`. Worth re-reading a standing
rejection whenever the surrounding design moves under it — the argument was sound
when first made and quietly stopped being true.

Its first version had a TOCTOU of its own (CI flake, rc=4, 1.28.1): after a
failed `mkdir` it tested `[ ! -d "$lock" ]` to tell "cannot create" from
contention — but the holder can release the lock in between, so contention
read as a read-only tree. Decide from `mkdir`'s own error (`LC_ALL=C`, "File
exists" = contention) instead of from the lock's state a moment later.
Reproduce such races by running many copies of the concurrent test at once
(16/72 runs failed); pin the fix with a PATH `mkdir` shim that fails once with
"File exists" and no lock left behind. The pid is written aside and renamed
in, so `lock_is_stale` never reads a partial pid; a young lock without one is
never stale, which covers the `mkdir`-to-pid gap.
of reviews. Worth re-reading a standing rejection whenever the surrounding
design moves under it — the argument was sound when first made and quietly
stopped being true.

The lock is a `flock(2)` on `.MANDATE.lock` (1.28.4), not the `mkdir` lock it
started as. A `mkdir` lock outlives `kill -9`, so it needs a "the owner is dead,
break it" rule, and that rule has no safe form: a breaker reads the dead owner's
pid, the lock is meanwhile released and re-taken by a live booker, and the
breaker deletes the live lock (ABA; a directory cannot be deleted
conditionally). Two earlier fixes (a TOCTOU after a failed `mkdir`, the pid
written aside and renamed in) each closed one window and left this one. The
kernel drops a flock when the last descriptor closes, so nothing ever judges a
lock abandoned. Mechanics worth keeping: bash holds the descriptor
(`exec 9<>file`) and a short `python3 -I` flocks the inherited fd 9 — flock
belongs to the open file description, so the lock outlives the python process
and lasts as long as the script (macOS has no `flock(1)`; `lockf`/`fcntl`
record locks would die with python). Every child inherits fd 9, so a daemon
spawned under the lock would hold it forever: `mandate.sh` wraps `git` to close
fd 9 (a PATH git shim that probes `( true >&9 )` pins it — not `: >&9`, whose
failed redirect on a special builtin exits dash; only `fixed`/`acknowledged`
run git under the lock, so `round` cannot test it). Never delete the lock file:
unlinking a flock file lets two processes lock two different inodes. Because it
stays, `close-request.sh` counts `.MANDATE.lock` as a lane file (else every
mandate lane loses the zero-loss auto-close) and every writer re-ensures the
`.MANDATE.*` exclude. Sessions on 1.28.2 and older cannot share a lane with
1.28.4: their mkdir refuses the lock file and advises `rm -f`, which would
unlink a held lock — reload them (`/sessions`) first.
Reproduce such races by running many concurrent bookings in a loop (16 at once,
1 of 40 runs lost a booking before, 0 of 160 after).

## Indentation is structure too

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ entries are grouped per plugin, newest first.

## work-system

### 1.28.4 — 2026-10-27
- `mandate.sh` locks the record writers (`round`, `reviewed`, `fixed`, `acknowledged`) with `flock(2)` on `.MANDATE.lock` instead of a `mkdir` lock directory. Breaking an "abandoned" `mkdir` lock raced: a breaker read the dead owner's pid, the lock was released and taken by a live booker meanwhile, and the breaker deleted the live lock, so two writers ran and a concurrent `round` booking was lost (CI flake in `test_mandate.py`; 1 of 40 runs of 16 concurrent bookings on 1.28.2, 0 of 160 now). The kernel frees a flock when its owner dies, so the staleness rule (pid check, 5-minute age bound) is gone. The lock file stays beside the record (covered by the `.MANDATE.*` exclude); git calls close the lock descriptor so no daemon can inherit it. Taking the lock needs python3 (macOS has no `flock(1)`); without it a write is refused with exit 4, never done unlocked. A leftover lock directory from 1.28.2 or older is refused with the `rm -rf` to run. Reload every session working a lane (`/sessions`) before it books again: a 1.28.2 session refuses the new lock file and advises an `rm -f` that would unlink a held lock. The lock file counts as a lane file for the Manager's zero-loss close, every writer re-ensures the `.MANDATE.*` exclude, and a filesystem that refuses flock is named as such instead of reported as contention.

### 1.28.2 — 2026-10-27
- `test_close_request.py` and `test_landing.py` build their shadow `PATH` skipping dangling source entries and checking presence with `os.path.lexists`, so a dangling link that `/usr/bin` and `/bin` both list (usrmerge) no longer fails the second `symlink_to` with `FileExistsError`, and a later source's real binary of that name still gets linked (Structure Checks red on main since #91). Tests only; no script behavior changes.

Expand Down Expand Up @@ -333,6 +336,9 @@ entries are grouped per plugin, newest first.

## pr-flow

### 1.8.2 — 2026-10-28
- `REVIEW-ROUTING.md` describes `mandate.sh round` exit 4 as work-system 1.28.4 reports it (another session writing the record, no python3 for the lock, a read-only tree); the advice to clear an abandoned lock is gone, since a flock is never abandoned. Docs only.

### 1.8.1 — 2026-10-22
- Trim the `/open` skill description to the word budget; the readiness-check list lives in the skill body.

Expand Down
2 changes: 1 addition & 1 deletion plugins/pr-flow/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "pr-flow",
"description": "PR review feedback loop for Claude Code. Create PRs with readiness checks, commit + push + trigger @claude review, inspect status, and work through review issues interactively.",
"version": "1.8.1",
"version": "1.8.2",
"author": {
"name": "gering"
},
Expand Down
6 changes: 3 additions & 3 deletions plugins/pr-flow/docs/REVIEW-ROUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,9 @@ Branch on **`round_authorized`**, not on the exhaustion flag:
ones" — finish, then stop.
- `round_authorized=no` → the budget is spent and **nothing was charged**. Stop
and ask; do not review.
- exit `4` → the round could not be persisted (read-only tree, or a lock another
session holds). Say so and do not review on an in-session count. The message
names the lock and the command to clear an abandoned one.
- exit `4` → the round could not be persisted (read-only tree, another session
writing the record right now, no python3 for the lock). Say so and do not
review on an in-session count; the message names the cause.

Book **once per review**, and only if your own stage does not already book — §3
says which consumer books where.
Expand Down
2 changes: 1 addition & 1 deletion plugins/work-system/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "work-system",
"description": "Generic task and worktree workflow system for Claude Code. Keep tasks as GitHub issues or local markdown files, run them in isolated git worktrees with a choice of worker agent (Claude, codex, grok, kimi, or a PATH-detected cc-harness agent), and track progress across the define/kickoff/continue/status/close lifecycle.",
"version": "1.28.2",
"version": "1.28.4",
"author": {
"name": "gering"
},
Expand Down
2 changes: 2 additions & 0 deletions plugins/work-system/scripts/close-request.sh
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,8 @@ out(("task", seen["task"]), ("worktree", rp(seen["worktree"])))' "$file" "$TASK_
while IFS= read -r line; do
case "$line" in
''|'?? TASK.md'|'?? MANDATE.md'|'?? .ws-kicker'|'!! TASK.md'|'!! MANDATE.md'|'!! .ws-kicker') ;;
# mandate.sh's flock file: lock state that stays beside the record.
'?? .MANDATE.lock'|'!! .MANDATE.lock') ;;
'!! '*) ignored=$((ignored + 1)) ;;
*) dirty=$((dirty + 1)) ;;
esac
Expand Down
Loading
Loading