From 29f8af10fac0a7e44c654c3cad6654a0c2813cac Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 23:30:39 +0000 Subject: [PATCH] Add `task reopen` for operators to undo `task done` `done` was terminal with no way back, so a task closed by mistake (or reopened by a regression) needed a new task and lost its history. `quorumgit task reopen --reason ` returns a done task to `open`. Undoing a terminal state is an authority decision, so it uses the same require_operator check as policy and role changes; the reason is required and recorded in a `task.reopened` audit event. Only done tasks can be reopened. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SHGwY6PLGwr7kQY63qQGq6 --- README.md | 9 +++-- src/quorumgit/cli.py | 13 +++++++ src/quorumgit/work.py | 30 ++++++++++++++- tests/test_task_done.py | 83 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 128 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c2826b5..fc85039 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Eight concepts, in the order you meet them: |---|---| | **Repository** | A registered Git repo that QuorumGit governs. | | **Agent** | A registered identity with one role — `worker` (the default), `reviewer`, or `operator`. Set via `QUORUMGIT_AGENT` or `--agent`. | -| **Task** | A unit of work against one repository. Its holder closes it with `task done`; a done task can never be claimed again. | +| **Task** | A unit of work against one repository. Its holder closes it with `task done`; a done task cannot be claimed until an operator reopens it. | | **Claim** | An agent's exclusive lease on a task: names a branch, declares at least one write **scope** (path glob), and expires at a timestamp (default lease: 8 hours, set with `--lease-hours`). Expired leases make the task reclaimable and cannot be renewed — evaluated at read time, no timers. | | **Worktree** | An isolated `git worktree` created per claim. Agents never share a mutable checkout; Git itself refuses to check one branch out twice. A claim that supersedes an earlier claim on the same task and branch continues its retained checkout instead of creating a second one. | | **Handoff** | A structured continuation record (done / remaining / exact commit / blockers) that transfers work to a successor instead of abandoning it. | @@ -109,7 +109,7 @@ Releasing returns the task to `open`, so any agent can claim it again — releas quorumgit task done 1 --note "merged in #42" --remove-worktree ``` -`task done` releases the claim and moves the task to the terminal `done` status in one transaction; no one can claim it afterwards. Only the agent holding the task's live, unexpired claim can run it (accept an open handoff first). `--remove-worktree` uses the same non-forced removal as `release`, so a worktree with uncommitted changes refuses the whole command and nothing changes. +`task done` releases the claim and moves the task to the terminal `done` status in one transaction; no one can claim it afterwards unless an operator runs `quorumgit task reopen --reason `, which returns it to `open` and audits the reason. Only the agent holding the task's live, unexpired claim can run it (accept an open handoff first). `--remove-worktree` uses the same non-forced removal as `release`, so a worktree with uncommitted changes refuses the whole command and nothing changes. …or hand the work to someone else with everything they need to continue: @@ -286,7 +286,8 @@ The incumbent's checkout is not duplicated. When a claim supersedes an earlier c | `quorumgit repo protect-path [--remove]` | Require an approval for any push changing matching paths (operator only) | | `quorumgit repo allow-ref [--remove]` | Allow pushes to a ref namespace such as `refs/tags/` (operator only) | | `quorumgit task add --repo --title [--objective ]` | Create a task | -| `quorumgit task done [--note ] [--remove-worktree]` | Close a task for good — live claim holder only; releases the claim | +| `quorumgit task done [--note ] [--remove-worktree]` | Close a task — live claim holder only; releases the claim | +| `quorumgit task reopen --reason ` | Return a done task to `open` (operator only) | | `quorumgit claim --branch --scope … [--no-worktree] [--takeover] [--override-overlap] [--lease-hours ]` | Claim a task | | `quorumgit renew [--lease-hours ]` | Extend a live, unexpired lease; expired claims must be acquired again | | `quorumgit checkpoint [--commit ] [--note ]` | Record verified progress | @@ -303,7 +304,7 @@ The incumbent's checkout is not duplicated. When a claim supersedes an earlier c | `quorumgit hook install --repo ` | Install the pre-receive hook (hub model) | | `quorumgit audit [--entity ] [--entity-id ] [--limit ]` | Read the audit trail | -`repo list`, `agent list`, and `task list [--repo ]` enumerate what's registered. Commands that act as an agent (`claim`, `renew`, `release`, `task done`, `checkpoint`, `handoff create/accept/decline/cancel`, `approve request/vote`, `approve prepare`, and the operator actions `agent add`, `agent role`, `repo policy`, `repo protect-path`, `repo allow-ref`) also take `--agent `, which overrides `QUORUMGIT_AGENT`. At least one `--scope` is required to claim. Exit codes: `0` success, `1` refused/violation/error, `2` usage error. +`repo list`, `agent list`, and `task list [--repo ]` enumerate what's registered. Commands that act as an agent (`claim`, `renew`, `release`, `task done`, `checkpoint`, `handoff create/accept/decline/cancel`, `approve request/vote`, `approve prepare`, and the operator actions `task reopen`, `agent add`, `agent role`, `repo policy`, `repo protect-path`, `repo allow-ref`) also take `--agent `, which overrides `QUORUMGIT_AGENT`. At least one `--scope` is required to claim. Exit codes: `0` success, `1` refused/violation/error, `2` usage error. `quorumgit doctor` only checks worktree paths already recorded by QuorumGit. It reports: diff --git a/src/quorumgit/cli.py b/src/quorumgit/cli.py index 5ed33e5..d3eb6e3 100644 --- a/src/quorumgit/cli.py +++ b/src/quorumgit/cli.py @@ -260,6 +260,14 @@ def cmd_task_done(args, cfg) -> int: return 0 +def cmd_task_reopen(args, cfg) -> int: + agent = _agent(args, cfg) + with store.session(cfg) as conn: + work.reopen_task(conn, args.task_id, agent, reason=args.reason) + print(f"task {args.task_id} reopened.") + return 0 + + def cmd_claim(args, cfg) -> int: agent = _agent(args, cfg) with store.session(cfg) as conn: @@ -679,6 +687,11 @@ def add(name, fn, configure=None, parent=None): help="remove the claim's worktree; refused if it has " "uncommitted changes"), ), parent=task) + add("reopen", cmd_task_reopen, lambda sp: ( + sp.add_argument("task_id", type=int), + sp.add_argument("--agent", help="operator reopening the task"), + sp.add_argument("--reason", required=True), + ), parent=task) add("claim", cmd_claim, lambda sp: ( sp.add_argument("task_id", type=int), diff --git a/src/quorumgit/work.py b/src/quorumgit/work.py index b107870..477e003 100644 --- a/src/quorumgit/work.py +++ b/src/quorumgit/work.py @@ -14,7 +14,7 @@ from typing import Any from . import audit -from .registry import get_agent, get_repository +from .registry import get_agent, get_repository, require_operator from .store import Connection, begin_immediate, json_dumps DEFAULT_LEASE_HOURS = 8 @@ -854,6 +854,34 @@ def complete_task(conn: Connection, task_id: int, agent: str, note: str = "") -> return claim["id"] +def reopen_task(conn: Connection, task_id: int, agent: str, reason: str) -> None: + """Return a done task to `open` so it can be claimed again. Operator only. + + `done` is terminal for everyone else, so undoing it is an authority + decision like changing approval policy, and the reason is audited. + """ + lock_task(conn, task_id) + get_agent(conn, agent) + require_operator(conn, agent, "Reopening a done task") + if not reason.strip(): + raise WorkError("Reopening a task requires a --reason.") + task = get_task(conn, task_id) + if task["status"] != "done": + raise WorkError( + f"Task {task_id} is {task['status']}, not done; only done tasks " + "can be reopened." + ) + set_task_status(conn, task_id, "open") + audit.record( + conn, + "task.reopened", + "task", + task_id, + agent=agent, + detail={"reason": reason}, + ) + + def add_checkpoint( conn: Connection, claim_id: int, diff --git a/tests/test_task_done.py b/tests/test_task_done.py index f0386e8..fb04ddb 100644 --- a/tests/test_task_done.py +++ b/tests/test_task_done.py @@ -1,7 +1,8 @@ -"""`task done`: the claim holder closes a task for good. +"""`task done` and `task reopen`. Completing releases the live claim and moves the task to the terminal `done` -status in one transaction; nobody can claim a done task afterwards. +status in one transaction; nobody can claim a done task afterwards. Only an +operator can reopen one, with an audited reason. """ from __future__ import annotations @@ -14,6 +15,8 @@ import pytest from quorumgit import handoff, registry, trees, work +from quorumgit.registry import RegistryError +from tests.conftest import OPERATOR, ensure_agent from tests.test_cli_hub import _cli @@ -157,3 +160,79 @@ def test_cli_done_refuses_dirty_worktree_and_changes_nothing( assert done.returncode == 0, done.stderr assert "worktree removed" not in done.stdout assert (path / "uncommitted.txt").exists() + + +# ------------------------------------------------------------------ reopen + + +def test_operator_reopens_done_task(conn, git_repo): + task, a, b, suffix = _setup(conn, git_repo) + ensure_agent(conn, OPERATOR, "operator") + _claim(conn, task, a, suffix) + work.complete_task(conn, task, a) + + work.reopen_task(conn, task, OPERATOR, reason="regression found") + + assert work.get_task(conn, task)["status"] == "open" + detail = conn.execute( + "SELECT detail FROM audit_events WHERE event_type = 'task.reopened' " + "AND entity = 'task' AND entity_id = ?", + (task,), + ).fetchone() + assert detail is not None and '"reason":"regression found"' in detail[0] + # Reopened work is claimable again, by anyone. + claim, _, _ = work.claim_task( + conn, task, b, branch=f"fix/{suffix}", scope_globs=[f"src/{suffix}/**"] + ) + assert work.get_claim(conn, claim)["agent"] == b + + +def test_worker_cannot_reopen(conn, git_repo): + task, a, _, suffix = _setup(conn, git_repo) + ensure_agent(conn, OPERATOR, "operator") + _claim(conn, task, a, suffix) + work.complete_task(conn, task, a) + + # Not even the agent that completed it: done is final for workers. + with pytest.raises(RegistryError, match="requires an operator"): + work.reopen_task(conn, task, a, reason="changed my mind") + assert work.get_task(conn, task)["status"] == "done" + + +def test_only_done_tasks_reopen_and_reason_is_required(conn, git_repo): + task, a, _, suffix = _setup(conn, git_repo) + ensure_agent(conn, OPERATOR, "operator") + + with pytest.raises(work.WorkError, match="is open, not done"): + work.reopen_task(conn, task, OPERATOR, reason="why") + _claim(conn, task, a, suffix) + with pytest.raises(work.WorkError, match="is claimed, not done"): + work.reopen_task(conn, task, OPERATOR, reason="why") + + work.complete_task(conn, task, a) + with pytest.raises(work.WorkError, match="requires a --reason"): + work.reopen_task(conn, task, OPERATOR, reason=" ") + assert work.get_task(conn, task)["status"] == "done" + + +def test_cli_reopen(committed_conn, git_repo, cfg): + conn = committed_conn + task, a, _, suffix = _setup(conn, git_repo) + ensure_agent(conn, OPERATOR, "operator") + conn.commit() + _cli_claim(cfg, task, a, suffix) + assert _cli(cfg, "task", "done", str(task), agent=a).returncode == 0 + + refused = _cli(cfg, "task", "reopen", str(task), "--reason", "x", agent=a) + assert refused.returncode == 1 + assert "requires an operator" in refused.stderr + + missing_reason = _cli(cfg, "task", "reopen", str(task), agent=OPERATOR) + assert missing_reason.returncode == 2 + + reopened = _cli( + cfg, "task", "reopen", str(task), "--reason", "flaky fix", agent=OPERATOR + ) + assert reopened.returncode == 0, reopened.stderr + assert f"task {task} reopened." in reopened.stdout + assert work.get_task(conn, task)["status"] == "open"