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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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 <task> --reason <r>`, 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:

Expand Down Expand Up @@ -288,7 +288,8 @@ The incumbent's checkout is not duplicated. When a claim supersedes an earlier c
| `quorumgit repo protect-path <name> <glob> [--remove]` | Require an approval for any push changing matching paths (operator only) |
| `quorumgit repo allow-ref <name> <prefix> [--remove]` | Allow pushes to a ref namespace such as `refs/tags/` (operator only) |
| `quorumgit task add --repo <name> --title <t> [--objective <o>]` | Create a task |
| `quorumgit task done <task> [--note <n>] [--remove-worktree]` | Close a task for good — live claim holder only; releases the claim |
| `quorumgit task done <task> [--note <n>] [--remove-worktree]` | Close a task — live claim holder only; releases the claim |
| `quorumgit task reopen <task> --reason <r>` | Return a done task to `open` (operator only) |
| `quorumgit claim <task> --branch <b> --scope <glob>… [--no-worktree] [--takeover] [--override-overlap] [--lease-hours <h>]` | Claim a task |
| `quorumgit renew <claim> [--lease-hours <h>]` | Extend a live, unexpired lease; expired claims must be acquired again |
| `quorumgit checkpoint <claim> [--commit <commit>] [--note <n>]` | Record verified progress |
Expand All @@ -305,7 +306,7 @@ The incumbent's checkout is not duplicated. When a claim supersedes an earlier c
| `quorumgit hook install --repo <name>` | Install the pre-receive and reference-transaction hooks (hub model) |
| `quorumgit audit [--entity <e>] [--entity-id <id>] [--limit <n>]` | Read the audit trail |

`repo list`, `agent list`, and `task list [--repo <name>]` 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 <name>`, 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 <name>]` 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 <name>`, 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:

Expand Down
13 changes: 13 additions & 0 deletions src/quorumgit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,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:
Expand Down Expand Up @@ -734,6 +742,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),
Expand Down
30 changes: 29 additions & 1 deletion src/quorumgit/work.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -864,6 +864,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,
Expand Down
83 changes: 81 additions & 2 deletions tests/test_task_done.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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


Expand Down Expand Up @@ -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"
Loading