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
46 changes: 46 additions & 0 deletions .agent/handoff/201.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Handoff note: issue #201

- issue: 201
- started_at: 2026-10-11T00:00:00Z
- reason: token_soft_threshold
- last_work_commit: b0f5044

## Summary

The requeue-source groundwork spec is fully implemented and committed as
b0f5044 on `agent/issue-201`. Every attempt-result comment now carries a
hidden `<!-- requeue-source: human|automatic -->` marker next to the
`<!-- agent-attempt: … -->` marker (normal publication for all outcomes
plus the recovery-release path); the source is decided once from the claim
snapshot and stored on the attempt checkpoint so recovery keeps the
claim-time decision; and `GitHubTracker.automatic_continuation_count()`
counts automatic-requeue results in a row from the agent's own comments,
treating legacy unmarked results as human requeues. `CONTEXT.md` defines
**Automatic continuation** and **Requeue source**. Full suite: 896 passed,
9 skipped; `ruff check` passes. An inline code review (Standards + Spec)
found no violations or scope creep; the parallel review sub-agents could
not be launched (token soft threshold).

## Remaining work

- None for this spec: all six acceptance criteria are implemented, tested,
reviewed inline, and committed. A continuation only needs to publish:
push the branch, open the PR, and post results per the driving process.
- Optional follow-up (not in spec): the `("human", "automatic")` literal is
repeated in `recovery.py` and `attempt_state.py` to keep `attempt_state`
dependency-free; a shared constant could be introduced if the team prefers.

## Evidence

- Commit b0f5044: 11 files, +543/−5 (`git show b0f5044 --stat`).
- New tests: `tests/test_requeue_source.py` (claim-snapshot semantics,
recovery-retry keeps claim-time source); count semantics in
`tests/test_github_tracker.py`; marker tests in `tests/test_publication.py`
and `tests/test_recovery.py`; checkpoint tests in
`tests/test_attempt_state.py`.
- Key files: `simple_coding_agent/recovery.py` (marker helpers + counting),
`simple_coding_agent/publication.py` (`PublicationRequest.requeue_source`),
`simple_coding_agent/attempt_state.py` (checkpoint field),
`simple_coding_agent/lifecycle.py` (thread-through at all 7 publish sites),
`simple_coding_agent/github_tracker.py`
(`automatic_continuation_count`), `CONTEXT.md` (definitions).
8 changes: 8 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ _Avoid_: Backlog, work queue, job queue
Reserving an eligible issue by assigning the agent's GitHub identity as the issue assignee, after verifying the assignee is still empty. A claimed issue is no longer eligible for other sessions.
_Avoid_: Lock, reservation

**Automatic continuation**:
An implementation attempt claimed from `round-finished` without a human requeue: the issue carried only `round-finished` (no `ready-for-agent`) at claim time, so the agent continues the previous round on its own. It is counted by the requeue source recorded in each attempt-result comment.
_Avoid_: Auto-retry, self requeue

**Requeue source**:
How one implementation attempt was requeued: **human** (the issue carried `ready-for-agent` at claim time) or **automatic** (it carried only `round-finished` at claim time). The source is decided once from the claim snapshot and recorded as a hidden `<!-- requeue-source: ... -->` marker in the attempt-result comment, next to the `<!-- agent-attempt: ... -->` marker. The automatic-continuation count is the number of automatic-requeue result comments in a row from the newest, up to the most recent human-requeue result; a human requeue (including a legacy unmarked result) resets it to zero.
_Avoid_: Claim reason, retry count

**Attempt outcome**:
The structured result of an implementation attempt. Exactly one of: `complete` (all acceptance criteria met, checks pass, review clear, PR created), `incomplete` (work performed but criteria not met), `infrastructure_error` (failure unrelated to implementation logic), `no_changes` (skill loop finished with zero commits), or `handoff` (work paused cooperatively near a resource limit, with progress committed and published as a Handoff note for human-approved continuation; a zero-commit handoff downgrades to `no_changes`).
_Avoid_: Status, result code, exit status
Expand Down
38 changes: 37 additions & 1 deletion simple_coding_agent/attempt_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class AttemptCheckpoint:
started_at: str
updated_at: str
auto_merge_opt_in: bool = False
requeue_source: str = "human"


class AttemptStateStore:
Expand Down Expand Up @@ -76,13 +77,22 @@ def read(self) -> AttemptCheckpoint | None:
raise AttemptStateError("Attempt checkpoint is malformed") from error

def start(
self, *, issue_number: int, branch: str, auto_merge_opt_in: bool = False
self,
*,
issue_number: int,
branch: str,
auto_merge_opt_in: bool = False,
requeue_source: str = "human",
) -> AttemptCheckpoint:
"""Record the initial claimed phase for a new implementation attempt.

``auto_merge_opt_in`` captures whether the claimed issue carried the
``agent-auto-merge`` label at claim time, so recovery keeps the
claim-time decision instead of re-reading the current labels.
``requeue_source`` captures how the attempt was requeued (``human``
when the issue carried ``ready-for-agent`` at claim time, otherwise
``automatic``), so every attempt-result comment records the actual
reason for the claim even across recovery.
"""

if self.read() is not None:
Expand All @@ -93,6 +103,8 @@ def start(
raise AttemptStateError("Branch must be a non-empty string")
if not isinstance(auto_merge_opt_in, bool):
raise AttemptStateError("Auto-merge opt-in must be a boolean")
if requeue_source not in ("human", "automatic"):
raise AttemptStateError("Requeue source must be 'human' or 'automatic'")

timestamp = self._next_timestamp()
checkpoint = AttemptCheckpoint(
Expand All @@ -102,6 +114,7 @@ def start(
started_at=timestamp,
updated_at=timestamp,
auto_merge_opt_in=auto_merge_opt_in,
requeue_source=requeue_source,
)
self._write(checkpoint)
return checkpoint
Expand All @@ -125,6 +138,7 @@ def transition(self, phase: AttemptPhase) -> AttemptCheckpoint:
started_at=checkpoint.started_at,
updated_at=self._next_timestamp(),
auto_merge_opt_in=checkpoint.auto_merge_opt_in,
requeue_source=checkpoint.requeue_source,
)
self._write(transitioned)
return transitioned
Expand Down Expand Up @@ -190,6 +204,7 @@ def _checkpoint_to_json(checkpoint: AttemptCheckpoint) -> dict[str, bool | int |
"started_at": checkpoint.started_at,
"updated_at": checkpoint.updated_at,
"auto_merge_opt_in": checkpoint.auto_merge_opt_in,
"requeue_source": checkpoint.requeue_source,
}


Expand All @@ -210,6 +225,23 @@ def _checkpoint_from_json(value: object) -> AttemptCheckpoint:
"updated_at",
"auto_merge_opt_in",
},
{
"issue_number",
"branch",
"phase",
"started_at",
"updated_at",
"requeue_source",
},
{
"issue_number",
"branch",
"phase",
"started_at",
"updated_at",
"auto_merge_opt_in",
"requeue_source",
},
):
raise ValueError("invalid checkpoint shape")

Expand All @@ -219,6 +251,7 @@ def _checkpoint_from_json(value: object) -> AttemptCheckpoint:
started_at = value["started_at"]
updated_at = value["updated_at"]
auto_merge_opt_in = value.get("auto_merge_opt_in", False)
requeue_source = value.get("requeue_source", "human")
if isinstance(issue_number, bool) or not isinstance(issue_number, int) or issue_number <= 0:
raise ValueError("invalid issue number")
if not isinstance(branch, str) or not branch.strip():
Expand All @@ -229,6 +262,8 @@ def _checkpoint_from_json(value: object) -> AttemptCheckpoint:
raise ValueError("invalid timestamps")
if not isinstance(auto_merge_opt_in, bool):
raise ValueError("invalid auto-merge opt-in")
if requeue_source not in ("human", "automatic"):
raise ValueError("invalid requeue source")
_parse_timestamp(started_at)
_parse_timestamp(updated_at)
return AttemptCheckpoint(
Expand All @@ -238,6 +273,7 @@ def _checkpoint_from_json(value: object) -> AttemptCheckpoint:
started_at=started_at,
updated_at=updated_at,
auto_merge_opt_in=auto_merge_opt_in,
requeue_source=requeue_source,
)


Expand Down
21 changes: 21 additions & 0 deletions simple_coding_agent/github_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from urllib.request import Request, urlopen

from simple_coding_agent.publication import PullRequest
from simple_coding_agent.recovery import count_automatic_continuations


READY_FOR_AGENT = "ready-for-agent"
Expand Down Expand Up @@ -288,6 +289,26 @@ def trusted_comments(self, issue: TrackerIssue) -> tuple[str, ...]:
if comment.author_login and comment.author_login in (issue.author_login, viewer.login)
)

def automatic_continuation_count(self, issue: TrackerIssue) -> int:
"""Count automatic continuations in a row since the last human requeue.

Only comments written by this agent's own GitHub identity are
examined; any other comment is ignored even if it contains the
marker. A human-requeue result (or a legacy unmarked result from
before the marker existed) resets the count to zero.
"""

viewer = self._transport.viewer()
comments = self._transport.list_issue_comments(self._target_repo, issue.number)
own_bodies_newest_first = tuple(
comment.body
for comment in reversed(comments)
# An empty login means a deleted account; never count one by
# incidentally matching another deleted account.
if comment.author_login and comment.author_login == viewer.login
)
return count_automatic_continuations(own_bodies_newest_first)


class GitHubGraphQLTransport:
"""GitHub GraphQL transport used by the tracker in production."""
Expand Down
Loading
Loading