Add the abandon command
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m18s
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m18s
A4 already names abandon as one of the ways out of a halt (continue, re-plan, or abandon) and 0.2 closes a work item as completed or abandoned, but no command did it: from a gate or a question the only way to drop a task was to edit the state database. - 'abandon' ends the task from any waiting state and returns to IDLE. The move is made durable first; then the plan issue and any open milestone PR are closed with a note (branches kept), and any unresolved intent is aborted so boot reconciliation does not gate on work dropped on purpose. A Gitea failure is reported, not fatal. - Like 'status' it is a command in every state, never read as an answer or feedback. While work runs, 'stop' comes first, as before. - The restart / stranded question and the PR_OPEN hints mention it. - Spec: B1 gains the abandon edge and B2 the command. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
1 parent
4e8ebea26a
commit
d68e3b3a83
7 files changed
+307
-7
No files matched your search
@@ -74,6 +74,12 @@ class PlanBoard:
|
||||
except GiteaError:
|
||||
pass
|
||||
|
||||
def close(self, issue: int, note: str) -> None:
|
||||
"""Close the plan issue with a note saying why (e.g. abandoned).
|
||||
Unlike echo, failures raise: the caller reports them."""
|
||||
self._client.post_comment(self._repo, issue, note)
|
||||
self._client.close_issue(self._repo, issue)
|
||||
|
||||
def comments_since(self, issue: int, after_id: int) -> list[Comment]:
|
||||
return [
|
||||
c for c in self._client.list_comments(self._repo, issue) if c.id > after_id
|
||||
|
||||
@@ -55,6 +55,10 @@ TRANSITIONS = {
|
||||
# than passing through a separate FINISHED state.
|
||||
(PR_OPEN, MILESTONE_PLANNING),
|
||||
(PR_OPEN, IDLE),
|
||||
# abandon (A4, B2): the operator may end the task from any waiting state.
|
||||
# (AWAIT_INPUT may go anywhere already.)
|
||||
(AWAIT_ARCH_SIGNOFF, IDLE),
|
||||
(AWAIT_MILESTONE_SIGNOFF, IDLE),
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +69,7 @@ _WORKING = (PLANNING_ARCH, MILESTONE_PLANNING, EXECUTING)
|
||||
|
||||
_BOOT_CHOICES = (
|
||||
"reply 'continue' to pick up where the restart cut in, "
|
||||
"or 're-plan' to plan the current step again"
|
||||
"'re-plan' to plan the current step again, or 'abandon' to drop the task"
|
||||
)
|
||||
|
||||
|
||||
@@ -117,6 +121,8 @@ class MilestonePublisher(Protocol):
|
||||
|
||||
def comment(self, index: int, body: str) -> None: ...
|
||||
|
||||
def close_pull_request(self, index: int, note: str) -> None: ...
|
||||
|
||||
|
||||
class PlanBoard(Protocol):
|
||||
"""The Gitea side of plan gating; the concrete one is in
|
||||
@@ -128,6 +134,8 @@ class PlanBoard(Protocol):
|
||||
|
||||
def echo(self, issue: int, text: str) -> None: ...
|
||||
|
||||
def close(self, issue: int, note: str) -> None: ...
|
||||
|
||||
|
||||
class PlanningAgent(Protocol):
|
||||
def plan(
|
||||
@@ -428,6 +436,13 @@ class Orchestrator:
|
||||
text = msg.text.strip()
|
||||
if text == "status":
|
||||
self._send_status(current)
|
||||
elif text == "abandon":
|
||||
# Like status, a command in every waiting state: it takes
|
||||
# precedence over reading the word as an answer or feedback.
|
||||
if current == IDLE:
|
||||
self._transport.send_message("no active task; nothing to abandon")
|
||||
else:
|
||||
self._abandon(msg.sender)
|
||||
elif current == IDLE:
|
||||
if text.startswith("use "):
|
||||
self._begin_project(text[4:].strip())
|
||||
@@ -821,7 +836,7 @@ class Orchestrator:
|
||||
self._transition(PR_OPEN, cause=f"[stub] milestone {milestone} done")
|
||||
self._transport.send_message(
|
||||
f"[stub] milestone {milestone} done; no publisher, so no PR — "
|
||||
"say 'next milestone' or 'project complete'"
|
||||
"say 'next milestone', 'project complete' or 'abandon'"
|
||||
)
|
||||
return
|
||||
gate = self._data.get("gate") or {}
|
||||
@@ -869,13 +884,49 @@ class Orchestrator:
|
||||
"'project complete'"
|
||||
)
|
||||
|
||||
def _abandon(self, sender: str) -> None:
|
||||
"""End the task on the operator's word (A4: continue, re-plan, or
|
||||
abandon), closing its Gitea records. Only reached from a waiting
|
||||
state: while work runs the reply is "busy; say 'stop'" instead.
|
||||
|
||||
The move to IDLE is made durable first, so a failure while tidying
|
||||
Gitea can never leave a live task behind a closed plan issue."""
|
||||
note = f"abandoned by {sender}"
|
||||
board_state = self._data.get("board") or {}
|
||||
pr = self._data.get("pr") or {}
|
||||
project = self._project
|
||||
self.stop_watching()
|
||||
self._data = {**_fresh_data()}
|
||||
self._project = None
|
||||
self._store.oplog.task_id = None
|
||||
self._transition(IDLE, cause=f"operator: abandon ({sender})")
|
||||
|
||||
# Dropped on purpose: boot reconciliation must not gate on it.
|
||||
for intent in self._store.pending_intents():
|
||||
self._store.abort_intent(intent.id, cause=note)
|
||||
problems: list[str] = []
|
||||
if self._board is not None and board_state.get("issue"):
|
||||
try:
|
||||
self._board.close(int(board_state["issue"]), note)
|
||||
except GiteaError as exc:
|
||||
problems.append(f"plan issue #{board_state['issue']}: {exc}")
|
||||
if self._publisher is not None and pr.get("number"):
|
||||
try:
|
||||
self._publisher.close_pull_request(int(pr["number"]), note)
|
||||
except GiteaError as exc:
|
||||
problems.append(f"PR #{pr['number']}: {exc}")
|
||||
closed = "; could not close " + ", ".join(problems) if problems else ""
|
||||
self._transport.send_message(
|
||||
f"task on {project} abandoned{closed}. Say 'use <project>' to start another"
|
||||
)
|
||||
|
||||
def _return_to_pr_open(self, cause: str) -> None:
|
||||
self._transition(PR_OPEN, cause=cause)
|
||||
pr = self._data.get("pr") or {}
|
||||
self._watch_pr(pr)
|
||||
self._transport.send_message(
|
||||
f"PR #{pr.get('number')} is open and watched for review comments; "
|
||||
"say 'next milestone' or 'project complete'"
|
||||
"say 'next milestone', 'project complete' or 'abandon'"
|
||||
)
|
||||
|
||||
def _watch_pr(self, pr: dict[str, Any]) -> None:
|
||||
@@ -909,8 +960,8 @@ class Orchestrator:
|
||||
else:
|
||||
pr = self._data.get("pr") or {}
|
||||
self._transport.send_message(
|
||||
f"PR #{pr.get('number')} is open; say 'next milestone' or "
|
||||
"'project complete'"
|
||||
f"PR #{pr.get('number')} is open; say 'next milestone', "
|
||||
"'project complete' or 'abandon'"
|
||||
)
|
||||
|
||||
def _watch(
|
||||
|
||||
@@ -150,6 +150,11 @@ class GiteaClient:
|
||||
def close_pull_request(self, repo: str, index: int) -> None:
|
||||
self._request("PATCH", f"/repos/{repo}/pulls/{index}", json={"state": "closed"})
|
||||
|
||||
def close_issue(self, repo: str, index: int) -> None:
|
||||
self._request(
|
||||
"PATCH", f"/repos/{repo}/issues/{index}", json={"state": "closed"}
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._http.close()
|
||||
|
||||
|
||||
@@ -102,6 +102,12 @@ class MilestonePublisher:
|
||||
except GiteaError:
|
||||
pass
|
||||
|
||||
def close_pull_request(self, index: int, note: str) -> None:
|
||||
"""Close a milestone PR with a note saying why. The branch is kept,
|
||||
so the work can be reopened in Gitea."""
|
||||
self._client.post_comment(self._repo, index, note)
|
||||
self._client.close_pull_request(self._repo, index)
|
||||
|
||||
def push_followup(
|
||||
self, *, task_id: str, milestone: int, message: str
|
||||
) -> str | None:
|
||||
|
||||
Reference in new issue
Block a user