diff --git a/.agent/handoff/202.md b/.agent/handoff/202.md new file mode 100644 index 0000000..7d0ecc0 --- /dev/null +++ b/.agent/handoff/202.md @@ -0,0 +1,61 @@ +# Handoff note: issue #202 + +- issue: 202 +- started_at: 2026-10-11T10:30:00Z +- reason: token_soft_threshold +- last_work_commit: 43b5f51 + +## Summary + +Implemented the `MAX_AUTO_CONTINUATIONS` core path (commit `43b5f51` on +`agent/issue-202`, on top of #201's merged HEAD `8d95b44`). `RuntimeConfig` +gains `max_auto_continuations` parsed from `.env` via a new +`_non_negative_integer` helper (default 0; invalid values raise +`ConfigurationError`); the repository profile still rejects it as an unknown +key. `GitHubTracker` accepts the budget, exposes +`is_auto_continuation_eligible` (zero budget short-circuits before any +comment reads), orders continuations before ordinary `ready-for-agent` +issues in `runnable_queue` (FIFO within groups; human requeues stay +ordinary), and revalidates at claim time in `claim_next` with a budget-aware +`ineligibility_reason` (`"automatic continuation limit reached (k/N)"` when +exhausted). `AgentLifecycle` threads the budget through `submit_next_issue` +and `_claim_prioritized_locked`, emits `auto_continuation_claimed` on FIFO +and priority automatic claims, and appends a standing line (`k/N`, plus a +human-requeue notice when the budget is exhausted) to handoff details once +at release — the publisher is untouched. `FakeTracker` gains `auto_counts` +plus `automatic_continuation_count`; `__main__.py` wires the setting +end-to-end. Lint (`ruff check`) is clean and the existing config/tracker +suites pass (104 tests). + +## Remaining work + +1. Write `tests/test_auto_continuations.py`: config defaults/validation + + profile refusal; queue ordering (continuations first, FIFO within groups, + human-requeued stays ordinary); `claim_next` picks in-budget / skips + over-budget (incl. claim-time relabel and count-change-between-reads); + `next issue` accept within budget + reject over budget with `(k/N)`; + handoff comment lines (progress + exhausted) and silence at N=0; + `auto_continuation_claimed` on FIFO + priority claims; N=2 e2e (two auto + continuations then wait; human requeue grants two more). Key harnesses: + `make_lifecycle`/`issue()` in `tests/test_next_issue.py`, + `FakeTransport`/`FakeCommentTransport`/`issue()` in + `tests/test_github_tracker.py`, marker helpers in + `simple_coding_agent/recovery.py` (`requeue-source: human|automatic`), + `_claim_prioritized_locked` semantics at `lifecycle.py:2869`. +2. Docs: new `docs/adr/0005-*.md`; update + `docs/agents/operator-onboarding.md`, `README.md`, + `docs/agents/triage-labels.md`, `.env.example`. +3. Run targeted test files, then the full suite once, then `/code-review`, + address findings, and commit (with `Co-Authored-By: Claude Code + ` trailer) on `agent/issue-202`. + +## Evidence + +- Commit `43b5f51` (5 files, +267/-24): `simple_coding_agent/config.py`, + `github_tracker.py`, `lifecycle.py`, `__main__.py`, `tests/fakes.py`. +- Full prior-context transcript: + `/home/agent/.claude/projects/-data-repo/ce15b452-2e76-4739-b989-575fbcb9c956.jsonl` +- Verified: `ruff check simple_coding_agent/ tests/fakes.py` clean; + `pytest tests/test_config.py tests/test_github_tracker.py` → 104 passed. +- One fix applied during the attempt: removed an unused `READY_FOR_AGENT` + import in `lifecycle.py` (ruff F401). diff --git a/.env.example b/.env.example index 0450d9f..1344b19 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,17 @@ TARGET_REPO=owner/repository # MAX_RETRIES=3 # MAX_CONSECUTIVE_ERRORS=3 +# Automatic continuation of handoff releases. With the default 0, continuing +# a `round-finished` issue always needs a human decision (re-applying +# `ready-for-agent`). With MAX_AUTO_CONTINUATIONS=N, the agent picks up a +# `round-finished` issue on its own, ahead of new `ready-for-agent` work, up +# to N automatic continuations in a row; a human requeue resets the count and +# grants another N rounds, and removing `round-finished` stops the sequence +# at any time. Operator-only: the repository profile refuses this key, so an +# agent-written PR can never raise its own round limit. See +# docs/adr/0005-automatic-continuation-budget.md. +# MAX_AUTO_CONTINUATIONS=0 + # Repository profile commands (setup/check) run with a deliberately minimal # PATH (os.defpath) that excludes the orchestrator's own environment. If a # profile command needs a tool installed outside that default PATH (e.g. `uv` diff --git a/README.md b/README.md index 8a35e5c..7ab5336 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ The tracker selects only OPEN issues in `TARGET_REPO` that are labelled GitHub blockers. It orders eligible issues FIFO by creation date and rechecks eligibility immediately before assigning the authenticated GitHub identity. That check-then-set claim assumes one process; it is not a distributed lock. +A handoff releases its issue labelled `round-finished`: with the operator +setting `MAX_AUTO_CONTINUATIONS=N` (default `0`), a `round-finished` issue +within budget is claimed automatically ahead of new work, up to N +continuations in a row before a human requeue is needed again. Applying `ready-for-agent` is the human trust boundary: only repository collaborators with write access may apply it. The issue body is passed to the @@ -24,7 +28,9 @@ Copy `.env.example` and set `GITHUB_TOKEN`, `META_API_KEY`, and `TARGET_REPO` (`owner/repo`). `DATA_DIR` defaults to `~/.simple-coding-agent/`; its default clone location is `$DATA_DIR/repo/`. Other defaults are `POLL_INTERVAL=60`, `LOG_LEVEL=INFO`, `MODEL_TIMEOUT=3600`, `PUBLISH_TIMEOUT=120`, -`MAX_RETRIES=3`, and `MAX_CONSECUTIVE_ERRORS=3`. +`MAX_RETRIES=3`, `MAX_CONSECUTIVE_ERRORS=3`, and `MAX_AUTO_CONTINUATIONS=0` +(no automatic continuation of `round-finished` issues without a human +requeue). Use a fine-grained GitHub PAT limited to the configured repository with only `contents:write`, `pull-requests:write`, and `issues:write`. Configure provider diff --git a/docs/adr/0005-automatic-continuation-budget.md b/docs/adr/0005-automatic-continuation-budget.md new file mode 100644 index 0000000..71e6f1e --- /dev/null +++ b/docs/adr/0005-automatic-continuation-budget.md @@ -0,0 +1,61 @@ +# Let the agent continue a handoff on its own within an operator-set budget + +A `handoff` outcome releases the issue labelled `round-finished`: until now, +continuing always required a human decision — review the handoff note and +re-apply `ready-for-agent` to requeue, or leave the issue labelled to end the +sequence. We decided the operator may instead grant the agent up to N +automatic continuations in a row (`MAX_AUTO_CONTINUATIONS`, default `0`), +so a started branch does not wait behind new work while a human requeues +every round. A human requeue resets the count and grants another N rounds; +the default `0` keeps today's behaviour exactly. + +Automatic continuation reuses the existing claim path unchanged: the issue +must be open and unassigned, have a non-empty body and no open native +blockers (the same rules as the runnable queue), plus carry only +`round-finished` (never alongside `ready-for-agent`, which stays an ordinary +human requeue) with a count below the budget. Claiming assigns, removes +`round-finished`, and restores the branch with the handoff note in the +starting prompt. Eligible continuations are claimed before ordinary +`ready-for-agent` issues, FIFO by creation date within each group. The +operator `next issue` command accepts a `round-finished` target within +budget and rejects an over-budget one naming the limit (`k/N`); every +handoff result comment reports where the issue stands (`k/N`, and whether a +human requeue is now needed). + +The brakes that already exist stay in place, which is why a bounded loop is +safe to run unattended. A handoff with zero commits becomes `no_changes` +and never re-adds `round-finished`, so an issue that produces nothing stops +instead of looping. A human can stop automatic continuation at any time by +removing `round-finished`. The count itself is derived from the +requeue-source markers in the agent's own result comments (see #201), so a +human-stamped result always resets it — the agent cannot drift past the +budget by relabelling, only a human requeue grants more rounds. + +## Considered Options + +- **Keep requiring a human requeue after every handoff.** Rejected: each + round-trip waits on a human for a decision that is usually "keep going", + and a started branch with a restored context queues behind fresh work. + The default `0` preserves this behaviour for operators who want it. +- **Continue handoffs without any limit once enabled.** Rejected: an issue + whose work never converges would hand off forever with no human looking + at the notes. The budget bounds the unattended stretch; the handoff that + uses it up says a human requeue is needed. +- **Allow the limit in the repository profile.** Rejected for the same + reason as ADR 0004: the profile lives in the target repository, so an + agent-written PR could raise its own round limit. `MAX_AUTO_CONTINUATIONS` + is read only from the operator's `.env`, and the profile keeps rejecting + unknown keys. A non-negative integer is required; anything else fails at + startup with a configuration error. + +## Consequences + +- Operators opt in with `MAX_AUTO_CONTINUATIONS=N`; `0` (the default) + disables the path with no extra GitHub calls and byte-identical comments. +- With `N > 0`, `round-finished` issues within budget join the runnable + queue ahead of new work, each claim emits an `auto_continuation_claimed` + event, and each handoff comment carries the `k/N` standing line. +- A human requeue (re-applying `ready-for-agent`) resets the count; removing + `round-finished` stops the sequence at any time. +- No new token scope is needed: eligibility reads reuse the issue and + comment reads the tracker already performs. diff --git a/docs/agents/operator-onboarding.md b/docs/agents/operator-onboarding.md index 813c222..7671e69 100644 --- a/docs/agents/operator-onboarding.md +++ b/docs/agents/operator-onboarding.md @@ -201,10 +201,22 @@ without a cooperative handoff note likewise ends in an emergency handoff A `handoff` outcome pushes the attempt branch (no pull request), posts the handoff note as an issue comment, removes `ready-for-agent`, and adds -`round-finished`. **Continuation requires a human decision**: review the -note and comment, then re-apply `ready-for-agent` to requeue the issue (this -also clears `round-finished`), or leave it labelled `round-finished` to end -the attempt sequence. +`round-finished`. **By default, continuation requires a human decision**: +review the note and comment, then re-apply `ready-for-agent` to requeue the +issue (this also clears `round-finished`), or leave it labelled +`round-finished` to end the attempt sequence. + +`MAX_AUTO_CONTINUATIONS` (default `0`) optionally lets the agent continue a +`round-finished` issue on its own, up to N automatic continuations in a row, +claimed ahead of new `ready-for-agent` work. A human requeue resets the +count and grants another N rounds; removing `round-finished` stops the +sequence at any time. Every handoff result comment then reports where the +issue stands (`k/N used`, and whether a human requeue is now needed), and +each automatic claim emits an `auto_continuation_claimed` event. The setting +is operator-only on purpose: it lives in `.env`, never in the repository +profile (an unknown profile key is refused), so an agent-written PR cannot +raise its own round limit. See +[ADR 0005](../adr/0005-automatic-continuation-budget.md). Independent of the handoff path, any dirty or untracked change left in the working tree once the model reports success is also committed automatically diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md index 556f40e..ad36b1f 100644 --- a/docs/agents/triage-labels.md +++ b/docs/agents/triage-labels.md @@ -12,6 +12,18 @@ The skills speak in terms of five canonical triage roles. This file maps those r When a skill mentions a role, use the corresponding label string from this table. +## `round-finished` continuation + +`round-finished` is not a triage role: the agent applies it when a handoff +releases the issue (removing `ready-for-agent` and the assignment). With the +default `MAX_AUTO_CONTINUATIONS=0`, the issue waits there until a human +re-applies `ready-for-agent` to requeue it. With `MAX_AUTO_CONTINUATIONS=N`, +the agent claims a `round-finished` issue on its own — ahead of new +`ready-for-agent` work — up to N automatic continuations in a row; the +handoff that uses up the budget says a human requeue is needed. Re-applying +`ready-for-agent` resets the count and grants another N rounds, while +removing `round-finished` stops automatic continuation at any time. + ## `agent-auto-merge` opt-in The `agent-auto-merge` label opts one implementation issue into pull-request diff --git a/simple_coding_agent/__main__.py b/simple_coding_agent/__main__.py index 128963b..440bb6f 100644 --- a/simple_coding_agent/__main__.py +++ b/simple_coding_agent/__main__.py @@ -66,7 +66,9 @@ def _attempt_sink(issue_number: int) -> AttemptArchive | None: f"https://github.com/{config.target_repo}.git", token_provider=lambda: config.github_token, ) - tracker = GitHubTracker(github, config.target_repo) + tracker = GitHubTracker( + github, config.target_repo, max_auto_continuations=config.max_auto_continuations + ) runner = ModelAttemptRunner( attempt_state=attempt_state, workspace=workspace, @@ -115,6 +117,7 @@ def _attempt_sink(issue_number: int) -> AttemptArchive | None: poll_interval=config.poll_interval, error_store=ConsecutiveErrorStore(config.data_dir), max_consecutive_errors=config.max_consecutive_errors, + max_auto_continuations=config.max_auto_continuations, event_log=lambda event, detail="", level="INFO", issue_number=None: logger.emit( event, phase="polling", detail=detail, level=level, issue_number=issue_number ), diff --git a/simple_coding_agent/config.py b/simple_coding_agent/config.py index c5a053d..0b4dd96 100644 --- a/simple_coding_agent/config.py +++ b/simple_coding_agent/config.py @@ -67,6 +67,7 @@ class RuntimeConfig: model_stream_idle_timeout_ms: int = _DEFAULT_MODEL_STREAM_IDLE_TIMEOUT_MS pr_auto_merge: str = "false" pr_merge_method: str = "merge" + max_auto_continuations: int = 0 @property def soft_threshold_tokens(self) -> int: @@ -160,6 +161,7 @@ def load_runtime_config(environ: Mapping[str, str] | None = None) -> RuntimeConf soft_threshold_percentage=_fraction(values, "SOFT_THRESHOLD_PERCENTAGE", 0.2), pr_auto_merge=_auto_merge_mode(values), pr_merge_method=_merge_method(values), + max_auto_continuations=_non_negative_integer(values, "MAX_AUTO_CONTINUATIONS", 0), ) _warn_when_claude_code_version_is_set(values) _warn_when_usd_backstop_is_tighter(config) @@ -279,6 +281,17 @@ def _positive_integer(values: Mapping[str, str], name: str, default: int) -> int return number +def _non_negative_integer(values: Mapping[str, str], name: str, default: int) -> int: + value = values.get(name, str(default)) + try: + number = int(str(value).strip()) + except ValueError as error: + raise ConfigurationError(f"{name} must be a non-negative integer") from error + if number < 0: + raise ConfigurationError(f"{name} must be a non-negative integer") + return number + + def _fraction(values: Mapping[str, str], name: str, default: float) -> float: value = values.get(name, str(default)) try: diff --git a/simple_coding_agent/github_tracker.py b/simple_coding_agent/github_tracker.py index c783243..295f16c 100644 --- a/simple_coding_agent/github_tracker.py +++ b/simple_coding_agent/github_tracker.py @@ -18,13 +18,23 @@ ROUND_FINISHED = "round-finished" -def ineligibility_reason(issue: TrackerIssue | None, number: int) -> str | None: +def ineligibility_reason( + issue: TrackerIssue | None, + number: int, + *, + max_auto_continuations: int = 0, + auto_continuation_count: int = 0, +) -> str | None: """Explain why a ``next issue`` target cannot be accepted or claimed. Returns ``None`` when the issue is currently eligible under the ordinary queue rules (open, unassigned, labelled ``ready-for-agent``, with a - non-empty body and no open blocker); otherwise returns a visible reason - naming the observed state. + non-empty body and no open blocker) or as an automatic continuation (open, + unassigned, labelled ``round-finished`` without ``ready-for-agent``, with + a non-empty body and no open blocker, whose automatic-continuation count + is below ``max_auto_continuations``); otherwise returns a visible reason + naming the observed state. An over-budget continuation names the limit as + ``(k/N)``. """ if issue is None: @@ -32,7 +42,18 @@ def ineligibility_reason(issue: TrackerIssue | None, number: int) -> str | None: if issue.state != "OPEN": return f"issue #{number} is not open (state {issue.state})" if READY_FOR_AGENT not in issue.labels: - return f"issue #{number} is not labelled `{READY_FOR_AGENT}`" + if max_auto_continuations > 0 and ROUND_FINISHED in issue.labels: + if issue.meets_base_queue_rules: + if auto_continuation_count < max_auto_continuations: + return None + return ( + f"issue #{number}: automatic continuation limit reached" + f" ({auto_continuation_count}/{max_auto_continuations})" + ) + # A ``round-finished`` issue that fails the base queue rules is + # reported through the same checks as an ordinary issue below. + else: + return f"issue #{number} is not labelled `{READY_FOR_AGENT}`" if issue.assignee_logins: holders = ", ".join(issue.assignee_logins) return f"issue #{number} is already assigned to {holders}" @@ -75,6 +96,31 @@ def is_eligible(self) -> bool: and self.blocked_by == 0 ) + @property + def meets_base_queue_rules(self) -> bool: + """The runnable-queue rules shared by ordinary and continuation issues. + + Open, unassigned, with a non-empty body and no open native blockers; + the queue label itself is checked separately. + """ + + return ( + self.state == "OPEN" + and not self.assignee_logins + and bool(self.body.strip()) + and self.blocked_by == 0 + ) + + @property + def is_continuation_signal(self) -> bool: + """Whether the issue carries a handoff continuation signal only. + + ``round-finished`` without ``ready-for-agent``: a human requeue (which + re-applies ``ready-for-agent``) stays an ordinary queue issue. + """ + + return ROUND_FINISHED in self.labels and READY_FOR_AGENT not in self.labels + @dataclass(frozen=True) class IssuePage: @@ -139,22 +185,65 @@ def list_issue_comments(self, repository: str, issue_number: int) -> tuple[Issue class GitHubTracker: """Select and claim one eligible issue within exactly one repository.""" - def __init__(self, transport: GitHubTransport, target_repo: str) -> None: + def __init__( + self, transport: GitHubTransport, target_repo: str, *, max_auto_continuations: int = 0 + ) -> None: + if ( + isinstance(max_auto_continuations, bool) + or not isinstance(max_auto_continuations, int) + or max_auto_continuations < 0 + ): + raise GitHubTrackerError("MAX_AUTO_CONTINUATIONS must be a non-negative integer") self._transport = transport self._target_repo = target_repo + self._max_auto_continuations = max_auto_continuations + + @property + def max_auto_continuations(self) -> int: + """The operator's automatic-continuation budget (0 disables the path).""" + + return self._max_auto_continuations + + def is_auto_continuation_eligible(self, issue: TrackerIssue) -> bool: + """Whether a ``round-finished`` issue may continue without a human requeue. + + The issue must carry only the continuation signal (never alongside + ``ready-for-agent``, which stays ordinary), meet the base queue rules, + and have an automatic-continuation count below the operator budget. + With a zero budget this is always False and reads no comments. + """ + + if self._max_auto_continuations <= 0: + return False + if not issue.is_continuation_signal or not issue.meets_base_queue_rules: + return False + return self.automatic_continuation_count(issue) < self._max_auto_continuations def runnable_queue(self) -> tuple[TrackerIssue, ...]: - """Return all eligible issues in FIFO creation order.""" + """Return all eligible issues, continuations before ordinary issues. + + Issues eligible for automatic continuation are claimed before ordinary + ``ready-for-agent`` issues so a started branch does not wait behind + new work; within each group the order is FIFO by creation date. A + ``ready-for-agent`` issue that is also a continuation (a human requeue + after a handoff) stays in the ordinary group. + """ - issues: list[TrackerIssue] = [] + continuations: list[TrackerIssue] = [] + ordinary: list[TrackerIssue] = [] cursor: str | None = None while True: page = self._transport.list_issues(self._target_repo, cursor) - issues.extend(issue for issue in page.issues if issue.is_eligible) + for issue in page.issues: + if issue.is_eligible: + ordinary.append(issue) + elif self.is_auto_continuation_eligible(issue): + continuations.append(issue) if page.next_cursor is None: break cursor = page.next_cursor - return tuple(sorted(issues, key=lambda issue: (issue.created_at, issue.number))) + by_fifo = lambda issue: (issue.created_at, issue.number) + return tuple(sorted(continuations, key=by_fifo) + sorted(ordinary, key=by_fifo)) def fetch_issue(self, number: int) -> TrackerIssue | None: """Re-read one issue's current state for ``next issue`` validation.""" @@ -207,7 +296,12 @@ def claim_next(self) -> Claim | None: identity = self._transport.viewer() for candidate in queue: current = self._transport.get_issue(self._target_repo, candidate.number) - if current is None or not current.is_eligible: + # Revalidated at claim time under the same rule as the queue + # read: an issue whose budget ran out, or that a human + # relabelled, between the two reads is skipped. + if current is None or ( + not current.is_eligible and not self.is_auto_continuation_eligible(current) + ): continue assignment = self._transport.assign_issue(current.id, identity.id) if ROUND_FINISHED in current.labels: diff --git a/simple_coding_agent/lifecycle.py b/simple_coding_agent/lifecycle.py index c3ea643..acf7461 100644 --- a/simple_coding_agent/lifecycle.py +++ b/simple_coding_agent/lifecycle.py @@ -68,7 +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 +from simple_coding_agent.recovery import REQUEUE_SOURCE_AUTOMATIC, requeue_source_for_labels class AttemptInterruptionHandler: @@ -248,6 +248,8 @@ def is_self_assigned(self, issue: TrackerIssue) -> bool: ... def claim_verified(self, issue: TrackerIssue) -> Claim: ... + def automatic_continuation_count(self, issue: TrackerIssue) -> int: ... + def recover_claim(self, issue_number: int) -> Claim | None: ... def release_attempt(self, issue_number: int, label: str, assignee_id: str) -> None: ... @@ -1018,6 +1020,7 @@ def __init__( sleeper: Callable[[float], None] = time.sleep, error_store: ConsecutiveErrorStore | None = None, max_consecutive_errors: int = 3, + max_auto_continuations: int = 0, event_log: Callable[..., None] = ( lambda event, detail="", level="INFO", issue_number=None: None ), @@ -1037,6 +1040,7 @@ def __init__( self._sleeper = sleeper self._error_store = error_store self._max_consecutive_errors = max_consecutive_errors + self._max_auto_continuations = max_auto_continuations self._event_log = event_log self._attempt_archive_factory = attempt_archive_factory self._completion_store = completion_store @@ -1174,13 +1178,15 @@ def submit_next_issue(self, request_id: str, issue: object) -> CommandRecord: The target is fetched and checked against the ordinary eligibility rules (open, unassigned, labelled ``ready-for-agent``, with a - non-empty body and no open blocker) before anything is recorded; an - invalid, ineligible, or unverifiable target is rejected without - changing the intake state or an existing priority. A newer accepted - priority supersedes only the earlier priority, never a stop plan, - and never resumes intake. Retrying the same request ID with the - identical payload returns the stored acknowledgement without - re-fetching GitHub or applying the command twice. + non-empty body and no open blocker) or, within the operator's + automatic-continuation budget, the ``round-finished`` continuation + rule before anything is recorded; an invalid, ineligible, or + unverifiable target is rejected without changing the intake state or + an existing priority. A newer accepted priority supersedes only the + earlier priority, never a stop plan, and never resumes intake. + Retrying the same request ID with the identical payload returns the + stored acknowledgement without re-fetching GitHub or applying the + command twice. """ if self._control_store is None: @@ -1210,7 +1216,31 @@ def submit_next_issue(self, request_id: str, issue: object) -> CommandRecord: f"issue #{number} could not be verified: {error}." " No control change was accepted." ) from error - reason = ineligibility_reason(fresh, number) + # The continuation count needs a comment read, so it is only + # consulted when it can matter: a positive budget and a target + # carrying only the continuation signal. Anything else follows + # the ordinary rule with the count at zero, exactly as before. + auto_count = 0 + if ( + fresh is not None + and self._max_auto_continuations > 0 + and fresh.is_continuation_signal + ): + try: + auto_count = self._auto_continuation_count(fresh) + except NextIssueRejectedError: + raise + except Exception as error: + raise NextIssueRejectedError( + f"issue #{number} could not be verified: {error}." + " No control change was accepted." + ) from error + reason = ineligibility_reason( + fresh, + number, + max_auto_continuations=self._max_auto_continuations, + auto_continuation_count=auto_count, + ) if reason is not None: raise NextIssueRejectedError( f"{reason}. No control change was accepted." @@ -1639,7 +1669,9 @@ def _retry_retained_attempt( check_exit_code=None, review_cycles=0, review_findings="not rerun during recovery retry", - details=details, + details=self._handoff_publication_details( + claim, checkpoint, details, decision.outcome + ), base_branch=profile.base_branch, note_commit_sha=note_sha, auto_merge_opt_in=_claim_time_opt_in(claim, checkpoint), @@ -2256,6 +2288,87 @@ def _is_self_assignment(self, found: TrackerIssue) -> bool: return bool(self._tracker.is_self_assigned(found)) + def _auto_continuation_count(self, issue: TrackerIssue) -> int: + """The issue's automatic-continuation count since the last human requeue. + + Trackers without the seam (older test doubles) read as zero, which + keeps the continuation path disabled-safe: with a zero budget the + count is never consulted for eligibility. + """ + + counter = getattr(self._tracker, "automatic_continuation_count", None) + if not callable(counter): + return 0 + return int(counter(issue)) + + def _emit_auto_continuation_claim( + self, issue_number: int, auto_count: int | None + ) -> None: + """Emit the observability event for an automatic-continuation claim.""" + + budget = self._max_auto_continuations + used = "unknown" if auto_count is None else f"{auto_count}/{budget}" + self._event_log( + "auto_continuation_claimed", + f"issue #{issue_number}; automatic continuation count {used}", + level="INFO", + issue_number=issue_number, + ) + + def _handoff_publication_details( + self, + claim: Claim, + checkpoint: AttemptCheckpoint | None, + details: str, + outcome: AttemptOutcome, + ) -> str: + """Append the automatic-continuation standing to a handoff's details. + + Written once, when the handoff is released: with a positive operator + budget the result comment reports how much of the budget the issue + has used (including this handoff's own requeue source) and whether + the agent continues automatically or needs a human requeue. Any other + outcome, or a zero budget, passes the details through unchanged. + """ + + if outcome is not AttemptOutcome.HANDOFF: + return details + line = self._auto_continuation_status_line(claim, checkpoint) + if line is None: + return details + return f"{details}\n\n{line}" if details else line + + def _auto_continuation_status_line( + self, claim: Claim, checkpoint: AttemptCheckpoint | None + ) -> str | None: + """The handoff comment's budget-standing line, or ``None`` when silent.""" + + budget = self._max_auto_continuations + if budget <= 0: + return None + source = _claim_time_requeue_source(claim, checkpoint) + try: + count_before = self._auto_continuation_count(claim.issue) + except Exception: + return None + # The line reports where the issue stands once this handoff's own + # result comment lands: a human-stamped comment resets the count to + # zero (even after earlier automatic rounds), while an + # automatic-stamped one extends the current row by one. + used = count_before + 1 if source == REQUEUE_SOURCE_AUTOMATIC else 0 + shown = min(used, budget) + if used >= budget: + return ( + f"Automatic continuation {shown}/{budget} used; the automatic" + " continuation budget is exhausted and the issue now needs a" + " human requeue (re-apply `ready-for-agent`) before the agent" + " can continue it." + ) + return ( + f"Automatic continuation {shown}/{budget} used; the agent will" + " continue automatically." + ) + def get_command(self, request_id: str) -> CommandRecord | None: """Return one command's current durable acknowledgement.""" @@ -2482,7 +2595,12 @@ def run_once(self) -> LifecycleResult: review_cycles=evidence.review_cycles, reviews_performed=evidence.reviews_performed, review_findings=evidence.review_findings, - details=evidence.details, + details=self._handoff_publication_details( + claim, + checkpoint, + evidence.details, + evidence.decision.outcome, + ), base_branch=profile.base_branch, note_commit_sha=evidence.note_commit_sha, auto_merge_opt_in=_claim_time_opt_in(claim, checkpoint), @@ -2749,6 +2867,12 @@ def _gated_claim(self) -> tuple[Claim | None, AttemptCheckpoint | None]: requeue_source=requeue_source_for_labels(claim.issue.labels), ) self._attempt_processing = True + if requeue_source_for_labels(claim.issue.labels) == REQUEUE_SOURCE_AUTOMATIC: + try: + auto_count = self._auto_continuation_count(claim.issue) + except Exception: + auto_count = None + self._emit_auto_continuation_claim(claim.issue.number, auto_count) if self._control_store is not None: # The final counted attempt of a ``stop --after`` plan enters # ``stopping`` at claim; earlier claims leave intake running @@ -2779,6 +2903,16 @@ def _claim_prioritized_locked( target_number = int(snapshot["issue_number"]) try: fresh = self._tracker.fetch_issue(target_number) + # As in ``submit_next_issue``: the comment read behind the count + # only runs when it can matter, so a zero budget (or an ordinary + # target) revalidates exactly as before. + auto_count = 0 + if ( + fresh is not None + and self._max_auto_continuations > 0 + and fresh.is_continuation_signal + ): + auto_count = self._auto_continuation_count(fresh) except Exception as error: self._event_log( "next_issue_claim_ambiguous", @@ -2793,7 +2927,12 @@ def _claim_prioritized_locked( raise ControlStoreError( "The tracker returned a different issue than the pending priority" ) - reason = ineligibility_reason(fresh, target_number) + reason = ineligibility_reason( + fresh, + target_number, + max_auto_continuations=self._max_auto_continuations, + auto_continuation_count=auto_count, + ) if reason is not None: if fresh is not None and self._is_self_assignment(fresh): # Our own assignment is an ambiguous prior claim (the @@ -2857,6 +2996,8 @@ def _claim_prioritized_locked( level="INFO", issue_number=claim.issue.number, ) + if requeue_source_for_labels(fresh.labels) == REQUEUE_SOURCE_AUTOMATIC: + self._emit_auto_continuation_claim(claim.issue.number, auto_count) checkpoint = self._attempt_state.start( issue_number=claim.issue.number, branch=f"agent/issue-{claim.issue.number}", @@ -3522,7 +3663,9 @@ def _reconcile_startup(self) -> AttemptOutcome | None: check_exit_code=None, review_cycles=0, review_findings="not rerun during startup recovery", - details=details, + details=self._handoff_publication_details( + claim, checkpoint, details, decision.outcome + ), base_branch=profile.base_branch, note_commit_sha=note_commit_sha, auto_merge_opt_in=_claim_time_opt_in(claim, checkpoint), diff --git a/tests/fakes.py b/tests/fakes.py index a2d58dd..744eb2c 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -336,10 +336,12 @@ def __init__( *, fifo: list[int] | None = None, next_claim: Claim | None = None, + auto_counts: dict[int, int] | None = None, ) -> None: self.issues = dict(issues or {}) self.fifo: list[int] = list(fifo or []) self.next_claim = next_claim + self.auto_counts = dict(auto_counts or {}) if next_claim is not None: self.issues.setdefault(next_claim.issue.number, next_claim.issue) self.fetch_errors: dict[int, Exception] = {} @@ -373,6 +375,11 @@ def fetch_issue(self, number: int) -> TrackerIssue | None: def is_self_assigned(self, issue: TrackerIssue) -> bool: return "agent" in issue.assignee_logins + def automatic_continuation_count(self, issue: TrackerIssue) -> int: + """The recorded automatic-continuation count for ``issue`` (default 0).""" + + return self.auto_counts.get(issue.number, 0) + def claim_verified(self, issue: TrackerIssue) -> Claim: if issue.number in self.assign_errors: raise self.assign_errors[issue.number] diff --git a/tests/test_auto_continuations.py b/tests/test_auto_continuations.py new file mode 100644 index 0000000..28827a1 --- /dev/null +++ b/tests/test_auto_continuations.py @@ -0,0 +1,809 @@ +"""Automatic continuation of ``round-finished`` issues (issue #202). + +``MAX_AUTO_CONTINUATIONS`` (operator ``.env`` only, default ``0``) lets the +agent pick up ``round-finished`` issues on its own, up to N automatic +continuations in a row. A human requeue (re-applying ``ready-for-agent``) +resets the count and grants another N rounds. +""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest + +from simple_coding_agent.attempt_state import AttemptStateStore +from simple_coding_agent.completion import ( + AttemptOutcome, + CompletionDecision, + PublicationPath, +) +from simple_coding_agent.config import ( + ConfigurationError, + ProfileError, + load_repository_profile, + load_runtime_config, +) +from simple_coding_agent.control import ( + CommandAcknowledgement, + ControlStore, + NextIssueRejectedError, +) +from simple_coding_agent.github_tracker import ( + Assignment, + GitHubIdentity, + GitHubTracker, + IssueComment, + IssuePage, + TrackerIssue, + ineligibility_reason, +) +from simple_coding_agent.lifecycle import AgentLifecycle, AttemptEvidence +from simple_coding_agent.recovery import ( + REQUEUE_SOURCE_AUTOMATIC, + REQUEUE_SOURCE_HUMAN, + requeue_source_marker, +) +from tests.fakes import FakePublisher, FakeTracker, InMemoryWorkspace + +READY = frozenset({"ready-for-agent"}) +ROUND = frozenset({"round-finished"}) +BOTH = frozenset({"ready-for-agent", "round-finished"}) + +_CREATED_BASE = datetime(2026, 9, 20, 13, 0, tzinfo=UTC) + + +def issue(number: int, **overrides: object) -> TrackerIssue: + base: dict[str, object] = { + "id": f"issue-{number}", + "number": number, + "title": f"Issue {number}", + "body": f"Implement {number}.", + "created_at": _CREATED_BASE + timedelta(seconds=number), + "state": "OPEN", + "labels": READY, + "assignee_logins": (), + "blocked_by": 0, + "author_login": "reporter", + } + base.update(overrides) + return TrackerIssue(**base) # type: ignore[arg-type] + + +def result_body(source: str) -> str: + """An own attempt-result comment body stamped with a requeue source.""" + + return ( + "## Agent Attempt Result: handoff\n\nHalf done.\n\n" + "\n" + f"{requeue_source_marker(source)}" + ) + + +def comment(body: str, author: str = "agent") -> IssueComment: + return IssueComment(author_login=author, body=body) + + +def complete_evidence() -> AttemptEvidence: + return AttemptEvidence( + decision=CompletionDecision( + AttemptOutcome.COMPLETE, True, PublicationPath.COMPLETE, ("ready",) + ), + check_command="pytest", + check_exit_code=0, + review_cycles=1, + review_findings="all clear", + details="Implemented.", + ) + + +def handoff_evidence() -> AttemptEvidence: + return AttemptEvidence( + decision=CompletionDecision( + AttemptOutcome.HANDOFF, False, PublicationPath.PARTIAL, ("handoff note",) + ), + check_command="not run", + check_exit_code=None, + review_cycles=0, + review_findings="not run", + details="handoff note", + note_commit_sha="note-rev", + ) + + +class ContinuationTransport: + """Mutable transport double with per-issue comments for continuation tests. + + ``listed`` is served by ``list_issues`` as a single page; ``get_issue`` + serves ``refreshed`` overrides when present, otherwise the live state, so + tests can change what the claim-time re-read observes. ``comments`` maps + issue numbers to standing comment lists; ``comment_scripts`` maps issue + numbers to successive comment lists, one per ``list_issue_comments`` + call, to simulate the count changing between the queue read and the + claim. Label and assignee mutations apply to the live state, like the + production transport's writes. + """ + + def __init__( + self, + issues: list[TrackerIssue], + *, + comments: dict[int, list[IssueComment]] | None = None, + comment_scripts: dict[int, list[list[IssueComment]]] | None = None, + refreshed: dict[int, TrackerIssue | None] | None = None, + ) -> None: + self.state = {item.number: item for item in issues} + self.listed = [item.number for item in issues] + self.comments = {number: list(bodies) for number, bodies in (comments or {}).items()} + self.comment_scripts = { + number: [list(script) for script in scripts] + for number, scripts in (comment_scripts or {}).items() + } + self.refreshed = dict(refreshed or {}) + self.assignments: list[tuple[str, str]] = [] + self.removed_labels: list[tuple[str, str]] = [] + self.added_labels: list[tuple[str, str]] = [] + self.removed_assignees: list[tuple[str, str]] = [] + self.comment_reads: list[int] = [] + + def list_issues(self, repository: str, cursor: str | None) -> IssuePage: + assert repository == "octo/example" + assert cursor is None + return IssuePage( + issues=tuple(self.state[number] for number in self.listed if number in self.state), + next_cursor=None, + ) + + def get_issue(self, repository: str, number: int) -> TrackerIssue | None: + assert repository == "octo/example" + if number in self.refreshed: + return self.refreshed[number] + return self.state.get(number) + + def viewer(self) -> GitHubIdentity: + return GitHubIdentity(id="viewer-id", login="agent") + + def _find(self, issue_id: str) -> TrackerIssue: + for item in self.state.values(): + if item.id == issue_id: + return item + raise AssertionError(f"unknown issue id {issue_id!r}") + + def assign_issue(self, issue_id: str, assignee_id: str) -> Assignment: + self.assignments.append((issue_id, assignee_id)) + found = self._find(issue_id) + self.state[found.number] = replace( + found, assignee_logins=found.assignee_logins + ("agent",) + ) + return Assignment(issue_id=issue_id, assignee_id=assignee_id) + + def remove_assignee(self, issue_id: str, assignee_id: str) -> None: + self.removed_assignees.append((issue_id, assignee_id)) + found = self._find(issue_id) + self.state[found.number] = replace( + found, + assignee_logins=tuple(login for login in found.assignee_logins if login != "agent"), + ) + + def remove_label(self, issue_id: str, label: str) -> None: + self.removed_labels.append((issue_id, label)) + found = self._find(issue_id) + self.state[found.number] = replace(found, labels=found.labels - {label}) + + def add_label(self, issue_id: str, label: str) -> None: + self.added_labels.append((issue_id, label)) + found = self._find(issue_id) + self.state[found.number] = replace(found, labels=found.labels | {label}) + + def list_issue_comments(self, repository: str, issue_number: int) -> tuple[IssueComment, ...]: + assert repository == "octo/example" + self.comment_reads.append(issue_number) + script = self.comment_scripts.get(issue_number) + if script: + if len(script) > 1: + return tuple(script.pop(0)) + return tuple(script[0]) + return tuple(self.comments.get(issue_number, ())) + + +def make_auto_lifecycle( + tmp_path: Path, + issues: dict[int, TrackerIssue], + *, + fifo: list[int] | None = None, + auto_counts: dict[int, int] | None = None, + budget: int = 2, + runner=None, + with_control: bool = True, +): + """A lifecycle wired for continuation tests, recording observability events.""" + + tracker = FakeTracker( + issues, fifo=fifo if fifo is not None else [], auto_counts=auto_counts + ) + publisher = FakePublisher() + events: list[tuple[str, str, str, int | None]] = [] + + def event_log( + event: str, + detail: str = "", + level: str = "INFO", + issue_number: int | None = None, + ) -> None: + events.append((event, detail, level, issue_number)) + + store = ControlStore(tmp_path) if with_control else None + lifecycle = AgentLifecycle( + tracker=tracker, + attempt_state=AttemptStateStore(tmp_path), + workspace=InMemoryWorkspace(tmp_path / "repo"), + profile_loader=lambda _: type("Profile", (), {"base_branch": "main"})(), + publisher=publisher, + attempt_runner=runner or (lambda claim, profile, prepared: complete_evidence()), + control_store=store, + sleeper=lambda seconds: None, + event_log=event_log, + max_auto_continuations=budget, + ) + return lifecycle, tracker, publisher, events, store + + +def auto_events(events: list[tuple[str, str, str, int | None]]) -> list[tuple[str, str, str, int | None]]: + return [event for event in events if event[0] == "auto_continuation_claimed"] + + +# --------------------------------------------------------------------------- +# operator setting: load, validate, refuse in the repository profile +# --------------------------------------------------------------------------- + + +def _operator_env(tmp_path: Path, **overrides: str) -> dict[str, str]: + return { + "GITHUB_TOKEN": "github-secret", + "META_API_KEY": "meta-secret", + "TARGET_REPO": "octo/example", + "DATA_DIR": str(tmp_path), + **overrides, + } + + +def test_max_auto_continuations_defaults_to_zero(tmp_path: Path) -> None: + config = load_runtime_config(_operator_env(tmp_path)) + + assert config.max_auto_continuations == 0 + + +@pytest.mark.parametrize(("value", "expected"), [("0", 0), ("1", 1), ("2", 2), (" 3 ", 3)]) +def test_max_auto_continuations_parses_non_negative_integers( + tmp_path: Path, value: str, expected: int +) -> None: + config = load_runtime_config(_operator_env(tmp_path, MAX_AUTO_CONTINUATIONS=value)) + + assert config.max_auto_continuations == expected + + +@pytest.mark.parametrize("value", ["-1", "-10", "many", "2.5", "", " ", "2x"]) +def test_max_auto_continuations_rejects_invalid_values(tmp_path: Path, value: str) -> None: + with pytest.raises(ConfigurationError): + load_runtime_config(_operator_env(tmp_path, MAX_AUTO_CONTINUATIONS=value)) + + +def test_repository_profile_refuses_max_auto_continuations(tmp_path: Path) -> None: + profile_path = tmp_path / "docs" / "agents" / "simple-coding-agent-profile.yml" + profile_path.parent.mkdir(parents=True) + profile_path.write_text("setup: pytest\ncheck: pytest\nmax_auto_continuations: 2\n") + + with pytest.raises(ProfileError): + load_repository_profile(tmp_path) + + +# --------------------------------------------------------------------------- +# eligibility: signal, budget, and the visible over-budget reason +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("labels", "expected"), + [ + (READY, False), + (ROUND, True), + (BOTH, False), + (frozenset(), False), + (frozenset({"round-finished", "agent-auto-merge"}), True), + ], +) +def test_continuation_signal_is_round_finished_without_ready( + labels: frozenset[str], expected: bool +) -> None: + assert issue(7, labels=labels).is_continuation_signal is expected + + +def test_zero_budget_is_never_eligible_and_reads_no_comments() -> None: + transport = ContinuationTransport([issue(7, labels=ROUND)]) + tracker = GitHubTracker(transport, "octo/example", max_auto_continuations=0) + + assert tracker.is_auto_continuation_eligible(issue(7, labels=ROUND)) is False + assert tracker.runnable_queue() == () + assert transport.comment_reads == [] + + +def test_within_budget_continuation_is_eligible() -> None: + assert ( + ineligibility_reason( + issue(7, labels=ROUND), + 7, + max_auto_continuations=2, + auto_continuation_count=1, + ) + is None + ) + + +def test_over_budget_continuation_names_the_limit() -> None: + assert ineligibility_reason( + issue(7, labels=ROUND), + 7, + max_auto_continuations=2, + auto_continuation_count=2, + ) == "issue #7: automatic continuation limit reached (2/2)" + + +def test_human_requeue_stays_ordinary_regardless_of_count() -> None: + # A human requeue carries ``ready-for-agent`` again, so the ordinary rule + # applies even when the automatic count has reached the budget. + assert ( + ineligibility_reason( + issue(7, labels=BOTH), + 7, + max_auto_continuations=2, + auto_continuation_count=2, + ) + is None + ) + + +def test_tracker_counts_automatic_comments_since_the_last_human_requeue() -> None: + transport = ContinuationTransport( + [issue(7, labels=ROUND)], + comments={ + 7: [ + comment(result_body(REQUEUE_SOURCE_HUMAN)), + comment(result_body(REQUEUE_SOURCE_AUTOMATIC)), + comment(result_body(REQUEUE_SOURCE_AUTOMATIC)), + ] + }, + ) + tracker = GitHubTracker(transport, "octo/example", max_auto_continuations=3) + + assert tracker.automatic_continuation_count(issue(7, labels=ROUND)) == 2 + + +# --------------------------------------------------------------------------- +# runnable queue: continuations first, FIFO within each group +# --------------------------------------------------------------------------- + + +def test_runnable_queue_orders_continuations_before_ready_fifo() -> None: + # #1 ready and oldest, #3 continuation older than #2, #4 ready newest. + ready_old = issue(1) + continuation_newer = issue(2, labels=ROUND) + continuation_older = issue( + 3, labels=ROUND, created_at=_CREATED_BASE - timedelta(days=1) + ) + ready_new = issue(4) + transport = ContinuationTransport( + [ready_old, continuation_newer, continuation_older, ready_new] + ) + tracker = GitHubTracker(transport, "octo/example", max_auto_continuations=2) + + assert [item.number for item in tracker.runnable_queue()] == [3, 2, 1, 4] + + +def test_runnable_queue_keeps_human_requeues_in_the_ordinary_group() -> None: + # #5 is a human requeue after a handoff (both labels) created before the + # continuation #6: it must not jump the queue as a continuation. + human_requeue = issue(5, labels=BOTH) + continuation = issue(6, labels=ROUND) + transport = ContinuationTransport([human_requeue, continuation]) + tracker = GitHubTracker(transport, "octo/example", max_auto_continuations=2) + + assert [item.number for item in tracker.runnable_queue()] == [6, 5] + + +def test_over_budget_continuation_stays_queued_out_with_its_label() -> None: + exhausted = issue(7, labels=ROUND) + ordinary = issue(8) + transport = ContinuationTransport( + [exhausted, ordinary], + comments={7: [comment(result_body(REQUEUE_SOURCE_AUTOMATIC))] * 2}, + ) + tracker = GitHubTracker(transport, "octo/example", max_auto_continuations=2) + + assert [item.number for item in tracker.runnable_queue()] == [8] + # The queue read changes nothing: the issue keeps waiting labelled. + assert transport.state[7].labels == ROUND + assert transport.assignments == [] + + +def test_zero_budget_queue_behaves_as_before() -> None: + transport = ContinuationTransport([issue(7, labels=ROUND), issue(8)]) + tracker = GitHubTracker(transport, "octo/example", max_auto_continuations=0) + + assert [item.number for item in tracker.runnable_queue()] == [8] + assert transport.comment_reads == [] + + +# --------------------------------------------------------------------------- +# claim_next: picks in-budget continuations, skips the rest at claim time +# --------------------------------------------------------------------------- + + +def test_claim_next_picks_an_in_budget_continuation() -> None: + transport = ContinuationTransport([issue(7, labels=ROUND)]) + tracker = GitHubTracker(transport, "octo/example", max_auto_continuations=2) + + claim = tracker.claim_next() + + assert claim is not None + assert claim.issue.number == 7 + assert claim.assignment == Assignment(issue_id="issue-7", assignee_id="viewer-id") + # Claiming removes ``round-finished`` but the claim keeps the snapshot. + assert transport.removed_labels == [("issue-7", "round-finished")] + assert claim.issue.labels == ROUND + + +def test_claim_next_skips_over_budget_and_claims_the_next_issue() -> None: + transport = ContinuationTransport( + [issue(7, labels=ROUND), issue(8)], + comments={7: [comment(result_body(REQUEUE_SOURCE_AUTOMATIC))] * 2}, + ) + tracker = GitHubTracker(transport, "octo/example", max_auto_continuations=2) + + claim = tracker.claim_next() + + assert claim is not None + assert claim.issue.number == 8 + assert transport.assignments == [("issue-8", "viewer-id")] + assert ("issue-7", "round-finished") not in transport.removed_labels + + +def test_claim_next_skips_a_human_relabel_between_queue_read_and_claim() -> None: + listed = issue(7, labels=ROUND) + transport = ContinuationTransport( + [listed], refreshed={7: replace(listed, labels=frozenset())} + ) + tracker = GitHubTracker(transport, "octo/example", max_auto_continuations=2) + + assert tracker.claim_next() is None + assert transport.assignments == [] + + +def test_claim_next_skips_when_the_budget_runs_out_between_reads() -> None: + transport = ContinuationTransport( + [issue(7, labels=ROUND)], + comment_scripts={ + 7: [ + [comment(result_body(REQUEUE_SOURCE_AUTOMATIC))], + [ + comment(result_body(REQUEUE_SOURCE_AUTOMATIC)), + comment(result_body(REQUEUE_SOURCE_AUTOMATIC)), + ], + ] + }, + ) + tracker = GitHubTracker(transport, "octo/example", max_auto_continuations=2) + + assert tracker.claim_next() is None + assert transport.assignments == [] + # One comment read for the queue scan, one for the claim revalidation. + assert transport.comment_reads == [7, 7] + + +# --------------------------------------------------------------------------- +# next issue: accepts within budget, rejects past it with the limit named +# --------------------------------------------------------------------------- + + +def test_next_issue_accepts_a_continuation_within_budget(tmp_path: Path) -> None: + lifecycle, _, _, _, store = make_auto_lifecycle( + tmp_path, {7: issue(7, labels=ROUND)}, auto_counts={7: 1}, budget=2 + ) + + record = lifecycle.submit_next_issue("req-next", 7) + + assert record.acknowledgement is CommandAcknowledgement.ACCEPTED + assert store.next_issue_snapshot() == {"request_id": "req-next", "issue_number": 7} + + +def test_next_issue_rejects_a_continuation_past_budget_naming_the_limit( + tmp_path: Path, +) -> None: + lifecycle, _, _, _, store = make_auto_lifecycle( + tmp_path, {7: issue(7, labels=ROUND)}, auto_counts={7: 2}, budget=2 + ) + + with pytest.raises( + NextIssueRejectedError, match=r"automatic continuation limit reached \(2/2\)" + ): + lifecycle.submit_next_issue("req-next", 7) + + assert store.next_issue_snapshot() is None + assert store.get_command("req-next") is None + + +def test_next_issue_rejects_a_continuation_at_zero_budget(tmp_path: Path) -> None: + lifecycle, _, _, _, store = make_auto_lifecycle( + tmp_path, {7: issue(7, labels=ROUND)}, auto_counts={7: 0}, budget=0 + ) + + with pytest.raises(NextIssueRejectedError, match="not labelled `ready-for-agent`"): + lifecycle.submit_next_issue("req-next", 7) + + assert store.next_issue_snapshot() is None + + +def test_priority_claim_revalidates_the_budget_before_assigning( + tmp_path: Path, +) -> None: + lifecycle, tracker, _, _, store = make_auto_lifecycle( + tmp_path, {7: issue(7, labels=ROUND)}, auto_counts={7: 1}, budget=2 + ) + lifecycle.submit_next_issue("req-next", 7) + # The budget runs out after acceptance, before the claim boundary. + tracker.auto_counts[7] = 2 + + assert lifecycle.run_once().status.value == "idle" + assert tracker.claimed_verified == [] + + failed = store.get_command("req-next") + assert failed is not None + assert failed.acknowledgement is CommandAcknowledgement.NOT_FULFILLED + assert "automatic continuation limit reached (2/2)" in failed.detail + + +def test_priority_claim_revalidates_a_human_relabel_before_assigning( + tmp_path: Path, +) -> None: + lifecycle, tracker, _, _, store = make_auto_lifecycle( + tmp_path, {7: issue(7, labels=ROUND)}, auto_counts={7: 0}, budget=2 + ) + lifecycle.submit_next_issue("req-next", 7) + # A human removes ``round-finished`` after acceptance: no signal remains. + tracker.issues[7] = issue(7, labels=frozenset()) + + assert lifecycle.run_once().status.value == "idle" + assert tracker.claimed_verified == [] + + failed = store.get_command("req-next") + assert failed is not None + assert failed.acknowledgement is CommandAcknowledgement.NOT_FULFILLED + + +def test_priority_automatic_claim_emits_the_event(tmp_path: Path) -> None: + lifecycle, tracker, _, events, store = make_auto_lifecycle( + tmp_path, {7: issue(7, labels=ROUND)}, auto_counts={7: 1}, budget=2 + ) + lifecycle.submit_next_issue("req-next", 7) + + assert lifecycle.run_once().status.value == "attempted" + + assert tracker.claimed_verified == [7] + assert store.get_command("req-next").acknowledgement is CommandAcknowledgement.COMPLETED # type: ignore[union-attr] + claimed = auto_events(events) + assert len(claimed) == 1 + assert claimed[0][3] == 7 + assert "1/2" in claimed[0][1] + + +# --------------------------------------------------------------------------- +# FIFO claims: automatic continuations emit the observability event +# --------------------------------------------------------------------------- + + +def test_fifo_automatic_claim_emits_the_event(tmp_path: Path) -> None: + lifecycle, tracker, _, events, _ = make_auto_lifecycle( + tmp_path, {7: issue(7, labels=ROUND)}, fifo=[7], auto_counts={7: 0}, budget=2 + ) + + assert lifecycle.run_once().status.value == "attempted" + + claimed = auto_events(events) + assert len(claimed) == 1 + assert claimed[0][3] == 7 + assert "0/2" in claimed[0][1] + + +def test_fifo_ordinary_claim_emits_no_event(tmp_path: Path) -> None: + lifecycle, _, _, events, _ = make_auto_lifecycle( + tmp_path, {7: issue(7)}, fifo=[7], budget=2 + ) + + assert lifecycle.run_once().status.value == "attempted" + + assert auto_events(events) == [] + + +# --------------------------------------------------------------------------- +# handoff result comments: standing lines only with a positive budget +# --------------------------------------------------------------------------- + + +def test_handoff_reports_zero_used_after_a_human_round(tmp_path: Path) -> None: + lifecycle, _, publisher, _, _ = make_auto_lifecycle( + tmp_path, + {24: issue(24)}, + fifo=[24], + budget=2, + runner=lambda claim, profile, prepared: handoff_evidence(), + ) + + assert lifecycle.run_once().status.value == "attempted" + + assert publisher.requests[0].details == ( + "handoff note\n\nAutomatic continuation 0/2 used; the agent will continue automatically." + ) + + +def test_handoff_reports_progress_after_an_automatic_round(tmp_path: Path) -> None: + lifecycle, _, publisher, _, _ = make_auto_lifecycle( + tmp_path, + {24: issue(24, labels=ROUND)}, + fifo=[24], + auto_counts={24: 0}, + budget=2, + runner=lambda claim, profile, prepared: handoff_evidence(), + ) + + assert lifecycle.run_once().status.value == "attempted" + + assert publisher.requests[0].details == ( + "handoff note\n\nAutomatic continuation 1/2 used; the agent will continue automatically." + ) + + +def test_handoff_using_up_the_budget_asks_for_a_human_requeue(tmp_path: Path) -> None: + lifecycle, _, publisher, _, _ = make_auto_lifecycle( + tmp_path, + {24: issue(24, labels=ROUND)}, + fifo=[24], + auto_counts={24: 1}, + budget=2, + runner=lambda claim, profile, prepared: handoff_evidence(), + ) + + assert lifecycle.run_once().status.value == "attempted" + + assert publisher.requests[0].details == ( + "handoff note\n\nAutomatic continuation 2/2 used; the automatic continuation" + " budget is exhausted and the issue now needs a human requeue (re-apply" + " `ready-for-agent`) before the agent can continue it." + ) + + +def test_handoff_after_a_human_requeue_reports_a_reset_count(tmp_path: Path) -> None: + # The human requeue (both labels at claim time) stamps this handoff + # ``human``, so the count resets even though two automatic rounds ran + # before: the comment must not claim the budget is exhausted. + lifecycle, _, publisher, _, _ = make_auto_lifecycle( + tmp_path, + {24: issue(24, labels=BOTH)}, + fifo=[24], + auto_counts={24: 2}, + budget=2, + runner=lambda claim, profile, prepared: handoff_evidence(), + ) + + assert lifecycle.run_once().status.value == "attempted" + + assert publisher.requests[0].details == ( + "handoff note\n\nAutomatic continuation 0/2 used; the agent will continue automatically." + ) + + +def test_handoff_is_silent_at_zero_budget(tmp_path: Path) -> None: + lifecycle, _, publisher, _, _ = make_auto_lifecycle( + tmp_path, + {24: issue(24, labels=ROUND)}, + fifo=[24], + budget=0, + runner=lambda claim, profile, prepared: handoff_evidence(), + ) + + assert lifecycle.run_once().status.value == "attempted" + + assert publisher.requests[0].details == "handoff note" + + +def test_complete_outcome_gets_no_standing_line(tmp_path: Path) -> None: + lifecycle, _, publisher, _, _ = make_auto_lifecycle( + tmp_path, {24: issue(24)}, fifo=[24], budget=2 + ) + + assert lifecycle.run_once().status.value == "attempted" + + assert publisher.requests[0].details == "Implemented." + + +# --------------------------------------------------------------------------- +# end to end with N=2 through the real tracker +# --------------------------------------------------------------------------- + + +def test_n2_hands_off_twice_then_waits_and_resumes_after_human_requeue( + tmp_path: Path, +) -> None: + """Two automatic continuations, then waiting, then two more after a requeue.""" + + transport = ContinuationTransport([issue(24)]) + tracker = GitHubTracker(transport, "octo/example", max_auto_continuations=2) + publisher = FakePublisher() + events: list[tuple[str, str, str, int | None]] = [] + + lifecycle = AgentLifecycle( + tracker=tracker, + attempt_state=AttemptStateStore(tmp_path), + workspace=InMemoryWorkspace(tmp_path / "repo"), + profile_loader=lambda _: type("Profile", (), {"base_branch": "main"})(), + publisher=publisher, + attempt_runner=lambda claim, profile, prepared: handoff_evidence(), + sleeper=lambda seconds: None, + event_log=lambda event, detail="", level="INFO", issue_number=None: events.append( + (event, detail, level, issue_number) + ), + max_auto_continuations=2, + ) + + progress = ( + "Automatic continuation 0/2 used; the agent will continue automatically.", + "Automatic continuation 1/2 used; the agent will continue automatically.", + ) + exhausted = ( + "Automatic continuation 2/2 used; the automatic continuation budget is" + " exhausted and the issue now needs a human requeue (re-apply" + " `ready-for-agent`) before the agent can continue it." + ) + + def run_handoff_round(expected_line: str, expected_source: str) -> None: + from simple_coding_agent.lifecycle import LifecycleStatus + + result = lifecycle.run_once() + assert result.status is LifecycleStatus.ATTEMPTED + assert result.outcome is AttemptOutcome.HANDOFF + assert publisher.requests[-1].details == f"handoff note\n\n{expected_line}" + assert publisher.requests[-1].requeue_source == expected_source + # Simulate the posted result comment; the release already moved the + # labels to ``round-finished``-only through the transport. + transport.comments.setdefault(24, []).append( + comment(result_body(expected_source)) + ) + assert transport.state[24].labels == ROUND + + # Round 1: ordinary claim, then the first automatic continuation. + run_handoff_round(progress[0], REQUEUE_SOURCE_HUMAN) + assert auto_events(events) == [] + run_handoff_round(progress[1], REQUEUE_SOURCE_AUTOMATIC) + # Round 3 uses up the budget; round 4 finds nothing to claim. + run_handoff_round(exhausted, REQUEUE_SOURCE_AUTOMATIC) + assert [event[1] for event in auto_events(events)] != [] + assert [event[3] for event in auto_events(events)] == [24, 24] + + idle_assignments = list(transport.assignments) + from simple_coding_agent.lifecycle import LifecycleStatus + + assert lifecycle.run_once().status is LifecycleStatus.IDLE + assert len(publisher.requests) == 3 + assert transport.assignments == idle_assignments + assert transport.state[24].labels == ROUND + assert tracker.claim_next() is None + + # A human requeue resets the count and grants two more automatic rounds. + transport.state[24] = replace(transport.state[24], labels=BOTH) + run_handoff_round(progress[0], REQUEUE_SOURCE_HUMAN) + run_handoff_round(progress[1], REQUEUE_SOURCE_AUTOMATIC) + run_handoff_round(exhausted, REQUEUE_SOURCE_AUTOMATIC) + assert lifecycle.run_once().status is LifecycleStatus.IDLE + assert len(publisher.requests) == 6 + assert transport.state[24].labels == ROUND + assert [event[3] for event in auto_events(events)] == [24, 24, 24, 24]