diff --git a/.agent/handoff/201.md b/.agent/handoff/201.md new file mode 100644 index 0000000..1ce81a6 --- /dev/null +++ b/.agent/handoff/201.md @@ -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 `` marker next to the +`` 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). diff --git a/CONTEXT.md b/CONTEXT.md index c0eea83..eb5cf6d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 `` marker in the attempt-result comment, next to the `` 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 diff --git a/simple_coding_agent/attempt_state.py b/simple_coding_agent/attempt_state.py index 5d3a1b0..19f684e 100644 --- a/simple_coding_agent/attempt_state.py +++ b/simple_coding_agent/attempt_state.py @@ -46,6 +46,7 @@ class AttemptCheckpoint: started_at: str updated_at: str auto_merge_opt_in: bool = False + requeue_source: str = "human" class AttemptStateStore: @@ -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: @@ -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( @@ -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 @@ -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 @@ -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, } @@ -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") @@ -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(): @@ -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( @@ -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, ) diff --git a/simple_coding_agent/github_tracker.py b/simple_coding_agent/github_tracker.py index cf70852..9ddc435 100644 --- a/simple_coding_agent/github_tracker.py +++ b/simple_coding_agent/github_tracker.py @@ -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" @@ -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.""" diff --git a/simple_coding_agent/lifecycle.py b/simple_coding_agent/lifecycle.py index 1e9f8d8..e45705a 100644 --- a/simple_coding_agent/lifecycle.py +++ b/simple_coding_agent/lifecycle.py @@ -68,6 +68,7 @@ from simple_coding_agent.observability import AttemptArchive, Redactor from simple_coding_agent.operating import ConsecutiveErrorStore from simple_coding_agent.publication import AUTO_MERGE_LABEL, PublicationRequest +from simple_coding_agent.recovery import requeue_source_for_labels class AttemptInterruptionHandler: @@ -1640,6 +1641,7 @@ def _retry_retained_attempt( base_branch=profile.base_branch, note_commit_sha=note_sha, auto_merge_opt_in=_claim_time_opt_in(claim, checkpoint), + requeue_source=_claim_time_requeue_source(claim, checkpoint), ) ) except Exception as error: @@ -1797,6 +1799,7 @@ def _release_retained_attempt( ) branch = prepared.branch marker = attempt_marker(attempt_id) + source = _claim_time_requeue_source(claim, checkpoint) comment_body = release_comment_body( issue_number=issue_number, attempt_id=attempt_id, @@ -1804,9 +1807,10 @@ def _release_retained_attempt( failure_reason=failure_reason, saved_at=saved_at, branch=branch, + requeue_source=source, ) confirmed = self._confirm_release_comment( - claim, marker, comment_body, attempt_id + claim, marker, comment_body, attempt_id, requeue_source=source ) if confirmed is None: return False, ( @@ -2021,7 +2025,7 @@ def _infer_release_outcome( ) def _confirm_release_comment( - self, claim: Claim, marker: str, body: str, attempt_id: str + self, claim: Claim, marker: str, body: str, attempt_id: str, *, requeue_source: str = "human" ) -> bool | None: """Confirm the deduplicated release comment; ``None`` means ambiguous. @@ -2070,6 +2074,7 @@ def _confirm_release_comment( review_findings="not run during recovery release", details=body, base_branch="main", + requeue_source=requeue_source, ) ) except Exception: @@ -2456,6 +2461,7 @@ def run_once(self) -> LifecycleResult: claim, checkpoint.started_at, prepared, profile, outcome, timeout=self._handoff_publish_budget(), auto_merge_opt_in=_claim_time_opt_in(claim, checkpoint), + requeue_source=_claim_time_requeue_source(claim, checkpoint), ) comment_posted = getattr(published, "comment_posted", True) else: @@ -2478,6 +2484,7 @@ def run_once(self) -> LifecycleResult: base_branch=profile.base_branch, note_commit_sha=evidence.note_commit_sha, auto_merge_opt_in=_claim_time_opt_in(claim, checkpoint), + requeue_source=_claim_time_requeue_source(claim, checkpoint), ) ) outcome = published.outcome @@ -2523,6 +2530,7 @@ def run_once(self) -> LifecycleResult: claim, checkpoint.started_at, prepared, profile, error, timeout=self._handoff_publish_budget(), auto_merge_opt_in=_claim_time_opt_in(claim, checkpoint), + requeue_source=_claim_time_requeue_source(claim, checkpoint), ) outcome = AttemptOutcome.INFRASTRUCTURE_ERROR comment_posted = getattr(published, "comment_posted", True) @@ -2535,6 +2543,7 @@ def run_once(self) -> LifecycleResult: claim, checkpoint.started_at, prepared, profile, outcome, timeout=self._handoff_publish_budget(), auto_merge_opt_in=_claim_time_opt_in(claim, checkpoint), + requeue_source=_claim_time_requeue_source(claim, checkpoint), ) comment_posted = getattr(published, "comment_posted", True) except BaseException as error: @@ -2551,6 +2560,7 @@ def run_once(self) -> LifecycleResult: claim, checkpoint.started_at, prepared, profile, outcome, timeout=self._handoff_publish_budget(), auto_merge_opt_in=_claim_time_opt_in(claim, checkpoint), + requeue_source=_claim_time_requeue_source(claim, checkpoint), ) comment_posted = getattr(published, "comment_posted", True) except Exception: @@ -2734,6 +2744,7 @@ def _gated_claim(self) -> tuple[Claim | None, AttemptCheckpoint | None]: issue_number=claim.issue.number, branch=f"agent/issue-{claim.issue.number}", auto_merge_opt_in=AUTO_MERGE_LABEL in claim.issue.labels, + requeue_source=requeue_source_for_labels(claim.issue.labels), ) self._attempt_processing = True if self._control_store is not None: @@ -2848,6 +2859,7 @@ def _claim_prioritized_locked( issue_number=claim.issue.number, branch=f"agent/issue-{claim.issue.number}", auto_merge_opt_in=AUTO_MERGE_LABEL in claim.issue.labels, + requeue_source=requeue_source_for_labels(claim.issue.labels), ) self._attempt_processing = True return (claim, checkpoint) @@ -3512,6 +3524,7 @@ def _reconcile_startup(self) -> AttemptOutcome | None: base_branch=profile.base_branch, note_commit_sha=note_commit_sha, auto_merge_opt_in=_claim_time_opt_in(claim, checkpoint), + requeue_source=_claim_time_requeue_source(claim, checkpoint), ) ) outcome = published.outcome @@ -3561,6 +3574,7 @@ def _reconcile_startup(self) -> AttemptOutcome | None: claim, checkpoint.started_at, prepared, profile, error, timeout=self._handoff_publish_budget(), auto_merge_opt_in=_claim_time_opt_in(claim, checkpoint), + requeue_source=_claim_time_requeue_source(claim, checkpoint), ) outcome = AttemptOutcome.INFRASTRUCTURE_ERROR comment_posted = getattr(published, "comment_posted", True) @@ -3573,6 +3587,7 @@ def _reconcile_startup(self) -> AttemptOutcome | None: claim, checkpoint.started_at, prepared, profile, AttemptOutcome.INFRASTRUCTURE_ERROR, timeout=self._handoff_publish_budget(), auto_merge_opt_in=_claim_time_opt_in(claim, checkpoint), + requeue_source=_claim_time_requeue_source(claim, checkpoint), ) outcome = AttemptOutcome.INFRASTRUCTURE_ERROR comment_posted = getattr(published, "comment_posted", True) @@ -3586,6 +3601,7 @@ def _reconcile_startup(self) -> AttemptOutcome | None: claim, checkpoint.started_at, prepared, profile, AttemptOutcome.INFRASTRUCTURE_ERROR, timeout=self._handoff_publish_budget(), auto_merge_opt_in=_claim_time_opt_in(claim, checkpoint), + requeue_source=_claim_time_requeue_source(claim, checkpoint), ) comment_posted = getattr(published, "comment_posted", True) except Exception: @@ -3690,6 +3706,7 @@ def _publish_terminal( details: str = "Repository profile could not be loaded or the attempt could not start.", timeout: float | None = None, auto_merge_opt_in: bool = False, + requeue_source: str = "human", ): branch = prepared.branch if prepared is not None else f"agent/issue-{claim.issue.number}" base_branch = profile.base_branch if profile is not None else "main" @@ -3706,6 +3723,7 @@ def _publish_terminal( details=details, base_branch=base_branch, auto_merge_opt_in=auto_merge_opt_in, + requeue_source=requeue_source, ) if timeout is None: return self._publisher.publish(request) @@ -3720,6 +3738,7 @@ def _publish_rebase_conflict( error: RebaseConflictError, timeout: float | None = None, auto_merge_opt_in: bool = False, + requeue_source: str = "human", ): """Report an unresolvable rebase conflict as the terminal infrastructure error. @@ -3750,6 +3769,7 @@ def _publish_rebase_conflict( ), timeout=timeout, auto_merge_opt_in=auto_merge_opt_in, + requeue_source=requeue_source, ) def _record_terminal_outcome( @@ -3840,6 +3860,22 @@ def _claim_time_opt_in(claim: Claim, checkpoint: AttemptCheckpoint | None) -> bo return AUTO_MERGE_LABEL in claim.issue.labels +def _claim_time_requeue_source(claim: Claim, checkpoint: AttemptCheckpoint | None) -> str: + """How the attempt was requeued, decided once from the claim snapshot. + + The checkpoint recorded the claim snapshot's labels, so a label change + while the attempt runs (or after it finishes) never rewrites that + attempt's result comment — including when a crashed attempt is + recovered, where the re-read claim carries the current labels instead. + """ + + if checkpoint is not None: + source = getattr(checkpoint, "requeue_source", None) + if source in ("human", "automatic"): + return source + return requeue_source_for_labels(claim.issue.labels) + + def _attempt_id(checkpoint: AttemptCheckpoint | None) -> str: """The stable attempt ID carried in seal trailers and handoff notes. diff --git a/simple_coding_agent/publication.py b/simple_coding_agent/publication.py index 9389ff0..137cb32 100644 --- a/simple_coding_agent/publication.py +++ b/simple_coding_agent/publication.py @@ -10,6 +10,7 @@ from simple_coding_agent.attempt_state import AttemptPhase, AttemptStateStore from simple_coding_agent.attempt_workspace import BranchPusher from simple_coding_agent.git_workspace import GitWorkspaceError +from simple_coding_agent.recovery import REQUEUE_SOURCE_HUMAN, requeue_source_marker _SUCCEEDED_BUT_UNPUBLISHED = "Implementation succeeded but publication failed." @@ -91,6 +92,7 @@ class PublicationRequest: note_commit_sha: str | None = None auto_merge_opt_in: bool = False reviews_performed: int = 0 + requeue_source: str = REQUEUE_SOURCE_HUMAN @dataclass(frozen=True) @@ -415,6 +417,7 @@ def _result_comment( ) merge_line = f"**Merge**: {merge_result}\n" if merge_result is not None else "" review_lines = "\n".join(_review_lines(request)) + source_marker = requeue_source_marker(request.requeue_source) return ( f"## Agent Attempt Result: {outcome.value}\n\n" f"**Branch**: {branch}\n" @@ -427,7 +430,8 @@ def _result_comment( f"- Review findings: {request.review_findings}\n\n" "### Details\n" f"{details}\n\n" - f"{marker}" + f"{marker}\n" + f"{source_marker}" ) diff --git a/simple_coding_agent/recovery.py b/simple_coding_agent/recovery.py index b205993..076758b 100644 --- a/simple_coding_agent/recovery.py +++ b/simple_coding_agent/recovery.py @@ -13,6 +13,8 @@ from __future__ import annotations +from collections.abc import Container + class RecoveryRejectedError(ValueError): """Raised when a recovery command is rejected without a control change. @@ -61,6 +63,63 @@ def attempt_marker(attempt_id: str) -> str: return f"" +#: The claim-time reason an attempt was requeued, recorded as a hidden HTML +#: marker in every attempt-result comment. ``human`` means the issue carried +#: ``ready-for-agent`` when it was claimed; ``automatic`` means it carried +#: only ``round-finished`` (an automatic continuation without a human requeue). +REQUEUE_SOURCE_HUMAN = "human" +REQUEUE_SOURCE_AUTOMATIC = "automatic" + + +def requeue_source_marker(source: str) -> str: + """The durable issue-comment marker recording one attempt's requeue source.""" + + if source not in (REQUEUE_SOURCE_HUMAN, REQUEUE_SOURCE_AUTOMATIC): + raise ValueError(f"Invalid requeue source {source!r}: must be 'human' or 'automatic'") + return f"" + + +def parse_requeue_source(body: str) -> str | None: + """Return the requeue source recorded in a comment body, if any. + + Comments written before the marker existed carry no marker and read as + ``None`` (a legacy result, counted as a human requeue). + """ + + if requeue_source_marker(REQUEUE_SOURCE_AUTOMATIC) in body: + return REQUEUE_SOURCE_AUTOMATIC + if requeue_source_marker(REQUEUE_SOURCE_HUMAN) in body: + return REQUEUE_SOURCE_HUMAN + return None + + +def requeue_source_for_labels(labels: Container[str]) -> str: + """Decide the requeue source from the claim snapshot's labels. + + ``ready-for-agent`` present means a human requeued the issue; otherwise + the claim was an automatic continuation from ``round-finished``. + """ + + # The literal matches READY_FOR_AGENT in github_tracker without importing + # it (this module must stay importable from the tracker and publication). + return REQUEUE_SOURCE_HUMAN if "ready-for-agent" in labels else REQUEUE_SOURCE_AUTOMATIC + + +def count_automatic_continuations(bodies_newest_first: tuple[str, ...] | list[str]) -> int: + """Count automatic-requeue result comments in a row from the newest. + + Stops at the most recent human-requeue result; a legacy unmarked comment + counts as a human requeue and also stops the count. + """ + + count = 0 + for body in bodies_newest_first: + if parse_requeue_source(body) != REQUEUE_SOURCE_AUTOMATIC: + break + count += 1 + return count + + def release_comment_body( *, issue_number: int, @@ -69,6 +128,7 @@ def release_comment_body( failure_reason: str, saved_at: str, branch: str, + requeue_source: str = REQUEUE_SOURCE_HUMAN, ) -> str: """Build the abandonment comment for ``recovery release``. @@ -78,6 +138,7 @@ def release_comment_body( """ marker = attempt_marker(attempt_id) + source_marker = requeue_source_marker(requeue_source) return ( f"## Agent Attempt Result: {actual_outcome}\n\n" f"**Branch**: `{branch}` (retained work secured by the operator; not published as a success)\n\n" @@ -90,7 +151,8 @@ def release_comment_body( f"Retained work was secured at: `{saved_at}`\n\n" "This release does not claim a successful handoff and does not" " requeue the issue.\n\n" - f"{marker}" + f"{marker}\n" + f"{source_marker}" ) diff --git a/tests/test_attempt_state.py b/tests/test_attempt_state.py index 56580b9..07b94d4 100644 --- a/tests/test_attempt_state.py +++ b/tests/test_attempt_state.py @@ -196,3 +196,55 @@ def test_checkpoint_with_a_non_boolean_opt_in_is_malformed(tmp_path: Path) -> No with pytest.raises(AttemptStateError, match="malformed"): AttemptStateStore(tmp_path).read() + + +def test_claim_time_requeue_source_defaults_to_human(tmp_path: Path) -> None: + store = AttemptStateStore(tmp_path) + + attempt = store.start(issue_number=42, branch="agent/issue-42") + + assert attempt.requeue_source == "human" + + +def test_claim_time_requeue_source_survives_transitions(tmp_path: Path) -> None: + started = datetime(2026, 9, 20, 13, 0, tzinfo=UTC) + store = AttemptStateStore(tmp_path, clock=SequenceClock(started, started)) + + store.start(issue_number=42, branch="agent/issue-42", requeue_source="automatic") + + transitioned = store.transition(AttemptPhase.SETUP) + + assert transitioned.requeue_source == "automatic" + assert store.read() is not None + assert store.read().requeue_source == "automatic" + + +def test_checkpoint_without_requeue_source_reads_as_human(tmp_path: Path) -> None: + path = tmp_path / "state" / "attempt.json" + path.parent.mkdir() + path.write_text( + '{"branch": "agent/issue-42", "issue_number": 42, "phase": "claimed",' + ' "started_at": "2026-09-20T13:00:00Z", "updated_at": "2026-09-20T13:00:00Z"}' + ) + + assert AttemptStateStore(tmp_path).read().requeue_source == "human" + + +def test_checkpoint_with_an_unknown_requeue_source_is_malformed(tmp_path: Path) -> None: + path = tmp_path / "state" / "attempt.json" + path.parent.mkdir() + path.write_text( + '{"branch": "agent/issue-42", "issue_number": 42, "phase": "claimed",' + ' "requeue_source": "sometimes", "started_at": "2026-09-20T13:00:00Z",' + ' "updated_at": "2026-09-20T13:00:00Z"}' + ) + + with pytest.raises(AttemptStateError, match="malformed"): + AttemptStateStore(tmp_path).read() + + +def test_start_rejects_an_unknown_requeue_source(tmp_path: Path) -> None: + store = AttemptStateStore(tmp_path) + + with pytest.raises(AttemptStateError, match="Requeue source"): + store.start(issue_number=42, branch="agent/issue-42", requeue_source="sometimes") diff --git a/tests/test_github_tracker.py b/tests/test_github_tracker.py index 4b12aa9..31d04d3 100644 --- a/tests/test_github_tracker.py +++ b/tests/test_github_tracker.py @@ -17,6 +17,7 @@ TrackerIssue, ) from simple_coding_agent.publication import PullRequest +from simple_coding_agent.recovery import requeue_source_marker def test_lists_only_eligible_open_issues() -> None: @@ -171,6 +172,85 @@ def test_trusted_comments_never_matches_a_deleted_account_by_empty_login() -> No assert trusted == () +def test_automatic_continuation_count_is_zero_with_no_history() -> None: + transport = FakeCommentTransport(comments=()) + + assert GitHubTracker(transport, "octo/example").automatic_continuation_count(issue(1)) == 0 + + +def test_automatic_continuation_count_treats_legacy_unmarked_results_as_human() -> None: + transport = FakeCommentTransport( + comments=( + IssueComment( + author_login="agent", + body="## Agent Attempt Result: complete\n", + ), + IssueComment( + author_login="agent", + body="## Agent Attempt Result: incomplete\n", + ), + ) + ) + + assert GitHubTracker(transport, "octo/example").automatic_continuation_count(issue(1)) == 0 + + +def test_automatic_continuation_count_counts_consecutive_automatic_results() -> None: + transport = FakeCommentTransport( + comments=( + IssueComment(author_login="agent", body=result_body("human", "first")), + IssueComment(author_login="agent", body=result_body("automatic", "second")), + IssueComment(author_login="agent", body=result_body("automatic", "third")), + ) + ) + + assert GitHubTracker(transport, "octo/example").automatic_continuation_count(issue(1)) == 2 + + +def test_automatic_continuation_count_resets_at_the_most_recent_human_result() -> None: + transport = FakeCommentTransport( + comments=( + IssueComment(author_login="agent", body=result_body("automatic", "first")), + IssueComment(author_login="agent", body=result_body("human", "second")), + ) + ) + + assert GitHubTracker(transport, "octo/example").automatic_continuation_count(issue(1)) == 0 + + +def test_automatic_continuation_count_ignores_markers_in_other_users_comments() -> None: + transport = FakeCommentTransport( + comments=( + IssueComment(author_login="agent", body=result_body("automatic", "first")), + IssueComment(author_login="random-passerby", body=result_body("human", "second")), + IssueComment(author_login="random-passerby", body=result_body("automatic", "third")), + IssueComment(author_login="agent", body=result_body("automatic", "fourth")), + ) + ) + + assert GitHubTracker(transport, "octo/example").automatic_continuation_count(issue(1)) == 2 + + +def test_automatic_continuation_count_ignores_a_deleted_account_by_empty_login() -> None: + transport = FakeCommentTransport( + comments=( + IssueComment( + author_login="", + body=result_body("automatic", "first"), + ), + ) + ) + + assert GitHubTracker(transport, "octo/example").automatic_continuation_count(issue(1)) == 0 + + +def result_body(source: str, attempt_id: str) -> str: + return ( + f"## Agent Attempt Result: complete\n\n" + f"{requeue_source_marker(source)}" + ) + + def test_lists_issue_comments_with_author_login_via_graphql() -> None: transport = RecordingGraphQLTransport( [ diff --git a/tests/test_publication.py b/tests/test_publication.py index e7a92c0..acedd70 100644 --- a/tests/test_publication.py +++ b/tests/test_publication.py @@ -739,3 +739,41 @@ def test_pull_request_body_reports_reviews_performed(tmp_path: Path) -> None: assert "Reviews performed: 0" in body assert "Review repair cycles: 0/2" in body assert "code-review was not run" in body + + +# --- Requeue source marker ----------------------------------------------------- + + +def test_result_comment_records_a_human_requeue_source_by_default(tmp_path: Path) -> None: + remote, _ = repository_with_main(tmp_path) + workspace = GitWorkspace(tmp_path / "clone", str(remote), token_provider=lambda: "token") + prepared = workspace.prepare_attempt(base_branch="main", issue_number=23) + write_and_commit(tmp_path / "clone", "feature.txt", "done", "Implement publication") + github = FakeGitHub() + + Publisher(workspace, github, "octo/example").publish( + complete_request(prepared.branch) + ) + + (comment,) = [body for _, body in github.comments] + assert "" in comment + assert "" in comment + assert "" not in comment + + +def test_result_comment_records_an_automatic_requeue_source_when_requested( + tmp_path: Path, +) -> None: + remote, _ = repository_with_main(tmp_path) + workspace = GitWorkspace(tmp_path / "clone", str(remote), token_provider=lambda: "token") + prepared = workspace.prepare_attempt(base_branch="main", issue_number=23) + write_and_commit(tmp_path / "clone", "feature.txt", "done", "Implement publication") + github = FakeGitHub() + + Publisher(workspace, github, "octo/example").publish( + complete_request(prepared.branch, requeue_source="automatic") + ) + + (comment,) = [body for _, body in github.comments] + assert "" in comment + assert "" not in comment diff --git a/tests/test_recovery.py b/tests/test_recovery.py index 3581569..954fdae 100644 --- a/tests/test_recovery.py +++ b/tests/test_recovery.py @@ -18,6 +18,7 @@ RecoveryRejectedError, parse_attempt_id, parse_saved_at, + release_comment_body, ) from tests.fakes import FakePublisher, FakeTracker, InMemoryWorkspace @@ -620,3 +621,37 @@ def test_status_format_shows_recovery_hold(tmp_path: Path) -> None: assert "hold_reason" in rendered assert "next_action" in rendered assert "agentctl recovery retry" in rendered + + +# --------------------------------------------------------------------------- +# acceptance: release comments carry the attempt and requeue-source markers +# --------------------------------------------------------------------------- + + +def release_body(**overrides: object) -> str: + fields: dict[str, object] = { + "issue_number": 24, + "attempt_id": "2026-09-20T13:00:00Z", + "actual_outcome": "incomplete", + "failure_reason": "The operator secured the retained work.", + "saved_at": "/tmp/secured", + "branch": "agent/issue-24", + } + fields.update(overrides) + return release_comment_body(**fields) # type: ignore[arg-type] + + +def test_release_comment_records_a_human_requeue_source_by_default() -> None: + body = release_body() + + assert "" in body + assert "" in body + assert "" not in body + + +def test_release_comment_records_an_automatic_requeue_source_when_requested() -> None: + body = release_body(requeue_source="automatic") + + assert "" in body + assert "" in body + assert "" not in body diff --git a/tests/test_requeue_source.py b/tests/test_requeue_source.py new file mode 100644 index 0000000..b09e3f1 --- /dev/null +++ b/tests/test_requeue_source.py @@ -0,0 +1,166 @@ +"""Claim-time requeue source for attempt-result comments. + +Every attempt-result comment records how its 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, so a label change while the attempt runs (or after +it finishes) never rewrites that attempt's comment — including when a +crashed attempt is recovered later. +""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import UTC, datetime +from pathlib import Path + +from simple_coding_agent.attempt_state import AttemptPhase, AttemptStateStore +from simple_coding_agent.completion import AttemptOutcome, CompletionDecision, PublicationPath +from simple_coding_agent.control import CommandAcknowledgement, ControlStore +from simple_coding_agent.finalization import AttemptCompletionStore +from simple_coding_agent.github_tracker import Assignment, Claim, TrackerIssue +from simple_coding_agent.lifecycle import AgentLifecycle, AttemptEvidence, LifecycleStatus +from tests.fakes import FakePublisher, FakeTracker, InMemoryWorkspace + + +def issue(number: int, labels: frozenset[str] = frozenset({"ready-for-agent"})) -> TrackerIssue: + return TrackerIssue( + id=f"issue-{number}", + number=number, + title="Implement the requeued feature", + body="Connect boundaries.", + created_at=datetime(2026, 9, 20, tzinfo=UTC), + state="OPEN", + labels=labels, + assignee_logins=(), + blocked_by=0, + author_login="reporter", + ) + + +def complete_runner() -> AttemptEvidence: + return AttemptEvidence( + decision=CompletionDecision(None, True, PublicationPath.COMPLETE, ("ready",)), + check_command="pytest", + check_exit_code=0, + review_cycles=1, + review_findings="all clear", + details="Implemented the requeued feature.", + ) + + +def run_complete_attempt(tmp_path: Path, tracker: FakeTracker, runner=None): + workspace = InMemoryWorkspace(tmp_path / "repo", issue_number=24) + publisher = FakePublisher() + lifecycle = AgentLifecycle( + tracker=tracker, + attempt_state=AttemptStateStore(tmp_path), + workspace=workspace, + profile_loader=lambda _: type("Profile", (), {"base_branch": "main"})(), + publisher=publisher, + attempt_runner=runner or (lambda claim, profile, prepared: complete_runner()), + ) + + result = lifecycle.run_once() + + assert result.status is LifecycleStatus.ATTEMPTED + assert result.outcome is AttemptOutcome.COMPLETE + return lifecycle, publisher + + +def test_claim_with_ready_for_agent_records_a_human_requeue_source(tmp_path: Path) -> None: + tracker = FakeTracker( + next_claim=Claim( + issue(24, frozenset({"ready-for-agent", "round-finished"})), + Assignment("issue-24", "agent-id"), + ) + ) + + _, publisher = run_complete_attempt(tmp_path, tracker) + + assert publisher.requests[0].requeue_source == "human" + + +def test_claim_without_ready_for_agent_records_an_automatic_requeue_source( + tmp_path: Path, +) -> None: + tracker = FakeTracker( + issues={24: issue(24, frozenset({"round-finished"}))}, + fifo=[24], + ) + + _, publisher = run_complete_attempt(tmp_path, tracker) + + assert publisher.requests[0].requeue_source == "automatic" + + +def test_label_removed_after_the_claim_keeps_the_human_source(tmp_path: Path) -> None: + tracker = FakeTracker( + next_claim=Claim(issue(24), Assignment("issue-24", "agent-id")) + ) + + def runner(claim: Claim, profile: object, prepared: object) -> AttemptEvidence: + current = tracker.issues.get(claim.issue.number) + if current is None: + current = claim.issue + tracker.issues[claim.issue.number] = replace(current, labels=frozenset()) + return complete_runner() + + _, publisher = run_complete_attempt(tmp_path, tracker, runner) + + assert publisher.requests[0].requeue_source == "human" + + +def start_checkpoint(tmp_path: Path, *, requeue_source: str, phase: AttemptPhase = AttemptPhase.PUSHING): + state = AttemptStateStore(tmp_path) + state.start( + issue_number=24, + branch="agent/issue-24", + auto_merge_opt_in=False, + requeue_source=requeue_source, + ) + order = ( + AttemptPhase.SETUP, + AttemptPhase.MODEL_RUNNING, + AttemptPhase.PUSHING, + AttemptPhase.PUBLISHING, + ) + for next_phase in order: + current = state.read() + assert current is not None + if current.phase is phase: + break + state.transition(next_phase) + return state.read() + + +def recovery_lifecycle(tmp_path: Path, tracker: FakeTracker): + publisher = FakePublisher() + lifecycle = AgentLifecycle( + tracker=tracker, + attempt_state=AttemptStateStore(tmp_path), + workspace=InMemoryWorkspace(tmp_path / "workspace", issue_number=24), + profile_loader=lambda _: type("Profile", (), {"base_branch": "main"})(), + publisher=publisher, + attempt_runner=lambda c, p, pr: None, + control_store=ControlStore(tmp_path), + completion_store=AttemptCompletionStore(tmp_path), + sleeper=lambda seconds: None, + repository="owner/repo", + ) + return lifecycle, publisher + + +def self_assigned_tracker() -> FakeTracker: + return FakeTracker(issues={24: replace(issue(24), assignee_logins=("agent",))}) + + +def test_recovery_retry_keeps_the_claim_time_requeue_source(tmp_path: Path) -> None: + checkpoint = start_checkpoint(tmp_path, requeue_source="automatic") + assert checkpoint is not None + lifecycle, publisher = recovery_lifecycle(tmp_path, self_assigned_tracker()) + + record = lifecycle.submit_recovery_retry("req-keep-source", checkpoint.started_at) + + assert record.acknowledgement is CommandAcknowledgement.COMPLETED + assert publisher.requests[0].requeue_source == "automatic"