From ff647bdeaa74f1f74b688cbfd0517496b9bf2c94 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 14 Oct 2026 13:11:02 +0000 Subject: [PATCH 1/4] Handoff note: issue #242 --- .agent/handoff/242.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .agent/handoff/242.md diff --git a/.agent/handoff/242.md b/.agent/handoff/242.md new file mode 100644 index 0000000..98d7e12 --- /dev/null +++ b/.agent/handoff/242.md @@ -0,0 +1,22 @@ +# Handoff note: issue #242 + +- issue: 242 +- started_at: 2026-10-14T13:01:47.773506Z +- reason: token_soft_threshold +- last_work_commit: 11f6b9bf07d96aeea85e3e1c2207a4eaab2d8f29 + +## Summary + +Implemented the claim-to-finalisation telemetry slice: checkpoint now stores `issue_title` + `continuation_index` (old checkpoints load as unknown), `control.active_attempt` carries `issue_title`, `TelemetryReporter` gained `begin_attempt(seed)` / `finish_attempt()` with configured figures and unavailable live figures, lifecycle seeds telemetry at both claim paths and clears it in `_commit_finalization` (+ startup leftover delete), `__main__` wires configured limits. Ruff passes; `test_attempt_state`, `test_telemetry`, `test_telemetry_examples`, `test_lifecycle_control`, `test_auto_continuations`, `test_control`, `test_lifecycle`, `test_finalization`, `test_handoff_end_to_end` pass (115 + 50 + 51 green). + +## Remaining work + +1. Manual verification via python snippet was blocked by token threshold — rerun a quick `snapshot()` check (null → present with seeded values → null) and a lifecycle claim/finalise check. +2. Run the full completion gate in order: ruff check, pytest py3.12, pytest py3.13. +3. Invoke the `code-review` skill on the changes. +4. Commit the implementation work to branch `agent/issue-242` (currently uncommitted: `__main__.py`, `attempt_state.py`, `control.py`, `lifecycle.py`, `telemetry.py`). + +## Evidence + +- `git diff --stat`: 5 files changed, 473 insertions, 53 deletions (see status context). +- Partial test evidence: ruff "All checks passed"; pytest 50 passed (attempt_state/telemetry/examples); 115 passed (lifecycle_control/auto_continuations/control/lifecycle); 51 passed (finalization/handoff_e2e). Full gate + code-review not yet run. From 703dd705f4b7e98fff4e5aa9c803ae8e47af6063 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 14 Oct 2026 13:12:40 +0000 Subject: [PATCH 2/4] Seal attempt work for #242 (model_limit_reached) Attempt-Id: 2026-10-14T13:01:47.773506Z --- simple_coding_agent/__main__.py | 4 + simple_coding_agent/attempt_state.py | 75 +++--- simple_coding_agent/control.py | 2 + simple_coding_agent/lifecycle.py | 97 +++++++- simple_coding_agent/telemetry.py | 348 ++++++++++++++++++++++++++- 5 files changed, 473 insertions(+), 53 deletions(-) diff --git a/simple_coding_agent/__main__.py b/simple_coding_agent/__main__.py index 65e9c66..074ffb5 100644 --- a/simple_coding_agent/__main__.py +++ b/simple_coding_agent/__main__.py @@ -106,6 +106,10 @@ def _attempt_sink(issue_number: int) -> AttemptArchive | None: sdk_version=provenance.sdk_version, cli_version=provenance.cli_version, process_started_at=process_started_at, + hard_ceiling=config.max_budget_tokens, + turns_max=config.max_turns, + timeout_seconds=config.model_timeout, + continuation_max=config.max_auto_continuations, ) lifecycle = AgentLifecycle( tracker=tracker, diff --git a/simple_coding_agent/attempt_state.py b/simple_coding_agent/attempt_state.py index 19f684e..b13b779 100644 --- a/simple_coding_agent/attempt_state.py +++ b/simple_coding_agent/attempt_state.py @@ -47,6 +47,8 @@ class AttemptCheckpoint: updated_at: str auto_merge_opt_in: bool = False requeue_source: str = "human" + issue_title: str | None = None + continuation_index: int | None = None class AttemptStateStore: @@ -83,6 +85,8 @@ def start( branch: str, auto_merge_opt_in: bool = False, requeue_source: str = "human", + issue_title: str | None = None, + continuation_index: int | None = None, ) -> AttemptCheckpoint: """Record the initial claimed phase for a new implementation attempt. @@ -93,6 +97,9 @@ def start( 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. + ``issue_title`` and ``continuation_index`` are computed once at claim + so telemetry needs no tracker lookup for them; ``None`` means unknown + (including checkpoints written before this change). """ if self.read() is not None: @@ -105,6 +112,14 @@ def start( 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'") + if issue_title is not None and not isinstance(issue_title, str): + raise AttemptStateError("Issue title must be a string or None") + if continuation_index is not None and ( + isinstance(continuation_index, bool) + or not isinstance(continuation_index, int) + or continuation_index < 0 + ): + raise AttemptStateError("Continuation index must be a non-negative integer or None") timestamp = self._next_timestamp() checkpoint = AttemptCheckpoint( @@ -115,6 +130,8 @@ def start( updated_at=timestamp, auto_merge_opt_in=auto_merge_opt_in, requeue_source=requeue_source, + issue_title=issue_title, + continuation_index=continuation_index, ) self._write(checkpoint) return checkpoint @@ -139,6 +156,8 @@ def transition(self, phase: AttemptPhase) -> AttemptCheckpoint: updated_at=self._next_timestamp(), auto_merge_opt_in=checkpoint.auto_merge_opt_in, requeue_source=checkpoint.requeue_source, + issue_title=checkpoint.issue_title, + continuation_index=checkpoint.continuation_index, ) self._write(transitioned) return transitioned @@ -196,7 +215,7 @@ def _as_utc(value: datetime) -> datetime: return value.astimezone(UTC) -def _checkpoint_to_json(checkpoint: AttemptCheckpoint) -> dict[str, bool | int | str]: +def _checkpoint_to_json(checkpoint: AttemptCheckpoint) -> dict[str, bool | int | str | None]: return { "issue_number": checkpoint.issue_number, "branch": checkpoint.branch, @@ -205,44 +224,18 @@ def _checkpoint_to_json(checkpoint: AttemptCheckpoint) -> dict[str, bool | int | "updated_at": checkpoint.updated_at, "auto_merge_opt_in": checkpoint.auto_merge_opt_in, "requeue_source": checkpoint.requeue_source, + "issue_title": checkpoint.issue_title, + "continuation_index": checkpoint.continuation_index, } def _checkpoint_from_json(value: object) -> AttemptCheckpoint: - if not isinstance(value, dict) or set(value) not in ( - { - "issue_number", - "branch", - "phase", - "started_at", - "updated_at", - }, - { - "issue_number", - "branch", - "phase", - "started_at", - "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", - }, - ): + _required = {"issue_number", "branch", "phase", "started_at", "updated_at"} + _optional = {"auto_merge_opt_in", "requeue_source", "issue_title", "continuation_index"} + if not isinstance(value, dict): + raise ValueError("invalid checkpoint shape") + keys = set(value) + if not _required <= keys <= (_required | _optional): raise ValueError("invalid checkpoint shape") issue_number = value["issue_number"] @@ -252,6 +245,8 @@ def _checkpoint_from_json(value: object) -> AttemptCheckpoint: updated_at = value["updated_at"] auto_merge_opt_in = value.get("auto_merge_opt_in", False) requeue_source = value.get("requeue_source", "human") + issue_title = value.get("issue_title", None) + continuation_index = value.get("continuation_index", None) 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(): @@ -264,6 +259,14 @@ def _checkpoint_from_json(value: object) -> AttemptCheckpoint: raise ValueError("invalid auto-merge opt-in") if requeue_source not in ("human", "automatic"): raise ValueError("invalid requeue source") + if issue_title is not None and not isinstance(issue_title, str): + raise ValueError("invalid issue title") + if continuation_index is not None and ( + isinstance(continuation_index, bool) + or not isinstance(continuation_index, int) + or continuation_index < 0 + ): + raise ValueError("invalid continuation index") _parse_timestamp(started_at) _parse_timestamp(updated_at) return AttemptCheckpoint( @@ -274,6 +277,8 @@ def _checkpoint_from_json(value: object) -> AttemptCheckpoint: updated_at=updated_at, auto_merge_opt_in=auto_merge_opt_in, requeue_source=requeue_source, + issue_title=issue_title, + continuation_index=continuation_index, ) diff --git a/simple_coding_agent/control.py b/simple_coding_agent/control.py index e7f0745..511fb1c 100644 --- a/simple_coding_agent/control.py +++ b/simple_coding_agent/control.py @@ -113,6 +113,7 @@ class ActiveAttemptInfo: branch: str phase: str started_at: str + issue_title: str | None = None @dataclass(frozen=True) @@ -2097,6 +2098,7 @@ def build_status( "branch": active_attempt.branch, "phase": active_attempt.phase, "started_at": active_attempt.started_at, + "issue_title": active_attempt.issue_title, } if active_attempt is not None else None diff --git a/simple_coding_agent/lifecycle.py b/simple_coding_agent/lifecycle.py index be562da..bd2c40c 100644 --- a/simple_coding_agent/lifecycle.py +++ b/simple_coding_agent/lifecycle.py @@ -2718,6 +2718,67 @@ def _emit_auto_continuation_claim( issue_number=issue_number, ) + def _continuation_index_for_claim( + self, source: str, auto_count: int | None + ) -> int | None: + """The claim-time automatic-continuation index stored for telemetry. + + A human requeue starts a new row at zero; an automatic continuation + continues it at ``count + 1``. An unreadable count stays unknown + (``None``) so telemetry reports it as unavailable instead of guessing. + """ + + if source != REQUEUE_SOURCE_AUTOMATIC: + return 0 + if not isinstance(auto_count, int) or isinstance(auto_count, bool): + return None + return auto_count + 1 + + def _telemetry_begin_for_checkpoint(self, checkpoint: AttemptCheckpoint) -> None: + """Seed telemetry ``current_attempt`` at claim; never raises. + + A telemetry failure logs once and keeps the previous view so it can + never break the claim it observes. + """ + + reporter = self._telemetry + if reporter is None: + return + try: + reporter.begin_attempt( + { + "attempt_id": checkpoint.started_at, + "issue_number": checkpoint.issue_number, + "issue_title": getattr(checkpoint, "issue_title", None), + "continuation_index": getattr(checkpoint, "continuation_index", None), + "continuation_max": self._max_auto_continuations, + } + ) + except Exception as error: + self._event_log( + "telemetry_publish_failed", + f"telemetry begin_attempt failed: {error}; keeping previous view", + level="WARNING", + issue_number=checkpoint.issue_number, + ) + + def _telemetry_finish(self) -> None: + """Clear telemetry ``current_attempt`` at finalisation; never raises.""" + + reporter = self._telemetry + if reporter is None: + return + try: + finish = getattr(reporter, "finish_attempt", None) + if callable(finish): + finish() + except Exception as error: + self._event_log( + "telemetry_publish_failed", + f"telemetry finish_attempt failed: {error}; keeping previous view", + level="WARNING", + ) + def _handoff_publication_details( self, claim: Claim, @@ -2881,6 +2942,7 @@ def control_status(self, repository: str | None = None) -> dict: branch=checkpoint.branch, phase=checkpoint.phase.value, started_at=checkpoint.started_at, + issue_title=getattr(checkpoint, "issue_title", None), ) if checkpoint is not None else None @@ -2967,6 +3029,7 @@ def _build_telemetry_control_section(self) -> dict: branch=checkpoint.branch, phase=checkpoint.phase.value, started_at=checkpoint.started_at, + issue_title=getattr(checkpoint, "issue_title", None), ) if checkpoint is not None else None @@ -3555,18 +3618,25 @@ def _gated_claim(self) -> tuple[Claim | None, AttemptCheckpoint | None]: level="INFO", ) return (None, None) + source = requeue_source_for_labels(claim.issue.labels) + if source == REQUEUE_SOURCE_AUTOMATIC: + try: + auto_count = self._auto_continuation_count(claim.issue) + except Exception: + auto_count = None + else: + auto_count = None checkpoint = self._attempt_state.start( 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), + requeue_source=source, + issue_title=claim.issue.title, + continuation_index=self._continuation_index_for_claim(source, auto_count), ) 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._telemetry_begin_for_checkpoint(checkpoint) + if source == REQUEUE_SOURCE_AUTOMATIC: 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 @@ -3691,15 +3761,24 @@ def _claim_prioritized_locked( level="INFO", issue_number=claim.issue.number, ) - if requeue_source_for_labels(fresh.labels) == REQUEUE_SOURCE_AUTOMATIC: + source = requeue_source_for_labels(fresh.labels) + if source == REQUEUE_SOURCE_AUTOMATIC: self._emit_auto_continuation_claim(claim.issue.number, auto_count) + continuation_index: int | None = self._continuation_index_for_claim( + source, auto_count + ) + else: + continuation_index = 0 checkpoint = self._attempt_state.start( 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), + requeue_source=source, + issue_title=claim.issue.title, + continuation_index=continuation_index, ) self._attempt_processing = True + self._telemetry_begin_for_checkpoint(checkpoint) return (claim, checkpoint) def _wire_attempt_runner_handoff(self) -> None: @@ -4197,6 +4276,7 @@ def _commit_finalization( ) self._record_control_finalization(attempt_id, outcome) self._attempt_state.delete() + self._telemetry_finish() def _reconcile_startup(self) -> AttemptOutcome | None: """Finish one durable attempt without recreating its SDK execution context.""" @@ -4291,6 +4371,7 @@ def _reconcile_startup(self) -> AttemptOutcome | None: self._active_issue_number = None return None self._attempt_state.delete() + self._telemetry_finish() self._active_issue_number = None return None claim = self._tracker.recover_claim(checkpoint.issue_number) diff --git a/simple_coding_agent/telemetry.py b/simple_coding_agent/telemetry.py index d985e10..43961a4 100644 --- a/simple_coding_agent/telemetry.py +++ b/simple_coding_agent/telemetry.py @@ -8,17 +8,22 @@ ``docs/agents/telemetry-snapshot.md``; the transport decision (control socket, not HTTP) is ADR 0006. -Tracer scope: identity and control are real. ``current_attempt`` and -``last_finished`` are ``None`` and ``recent_events`` is empty until later -tickets fill them with the writer side (``begin_attempt``, -``publish_attempt``, ``finish_attempt``). +``current_attempt`` is present from claim (``begin_attempt``) until the +attempt is finalised (``finish_attempt`` or, equivalently, a snapshot whose +control section shows no active attempt). Its configured limits come from +the seed (claim-time values) falling back to the reporter construction +defaults; live figures stay ``unavailable`` until later tickets publish +them. Building the section never raises into the attempt: a failure logs +once and keeps the previous view. """ from __future__ import annotations from collections.abc import Callable from datetime import UTC, datetime +import copy import json +import logging import threading from typing import Protocol @@ -45,6 +50,9 @@ TELEMETRY_FAILURE_COOLDOWN_SECONDS = 60.0 +logger = logging.getLogger(__name__) + + class SnapshotFailedError(RuntimeError): """Raised when a telemetry snapshot cannot be built right now.""" @@ -64,14 +72,182 @@ def telemetry_control_snapshot(self) -> dict: ... +def _figure_configured(value: object) -> dict: + """A configured figure, or unavailable when no value is known.""" + + if value is None: + return {"value": None, "basis": "unavailable", "reason": "not configured"} + return {"value": value, "basis": "configured"} + + +def _figure_unavailable(reason: str = "not yet published") -> dict: + """An explicitly unavailable figure (never zero).""" + + return {"value": None, "basis": "unavailable", "reason": reason} + + +def _as_int(value: object) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + return None + + +def _seed_int(candidate: object) -> int | None: + """Extract an int from a bare value or a figure object.""" + + if isinstance(candidate, dict): + return _as_int(candidate.get("value")) + return _as_int(candidate) + + +def _seed_str(candidate: object) -> str | None: + if isinstance(candidate, dict): + inner = candidate.get("value") + return inner if isinstance(inner, str) else None + return candidate if isinstance(candidate, str) else None + + +def _normalize_seed(seed: object) -> dict: + """Normalize a ``begin_attempt`` seed to the stored claim-time view. + + Accepts flat keys (``attempt_id``, ``issue_number``, ``issue_title``, + ``continuation_index``, ``continuation_max``, ``hard_ceiling``, + ``turns_max``, ``timeout_seconds``) with common aliases + (``max_budget_tokens`` for the hard ceiling, ``max_turns`` for the turn + limit, ``model_timeout`` for the timeout, ``max_auto_continuations`` for + the continuation max), plus nested shapes (``continuation: {index, max}``, + ``budget: {hard_ceiling}``, ``turns: {max}``) where each leaf may be a + bare value or a figure object. Raises ``ValueError`` on a missing or + malformed attempt identity. + """ + + if not isinstance(seed, dict): + raise ValueError("telemetry seed must be a mapping") + source: dict = dict(seed) + + continuation = source.get("continuation") + budget = source.get("budget") + turns = source.get("turns") + if not isinstance(continuation, dict): + continuation = None + if not isinstance(budget, dict): + budget = None + if not isinstance(turns, dict): + turns = None + + def _first(*candidates: object) -> object: + for candidate in candidates: + if candidate is not None: + return candidate + return None + + attempt_id = _seed_str( + _first(source.get("attempt_id"), source.get("attemptId"), source.get("started_at")) + ) + issue_number_raw = _first( + source.get("issue_number"), source.get("issueNumber"), source.get("issue") + ) + if isinstance(issue_number_raw, dict): + issue_number_raw = issue_number_raw.get("value") + issue_title = _seed_str( + _first( + source.get("issue_title"), + source.get("issueTitle"), + source.get("title"), + (continuation or {}).get("issue_title") if continuation else None, + ) + ) + continuation_index_raw = _first( + source.get("continuation_index"), + source.get("continuationIndex"), + source.get("index"), + (continuation or {}).get("index") if continuation else None, + ) + continuation_max_raw = _first( + source.get("continuation_max"), + source.get("continuationMax"), + source.get("max_auto_continuations"), + source.get("max"), + (continuation or {}).get("max") if continuation else None, + ) + hard_ceiling_raw = _first( + source.get("hard_ceiling"), + source.get("hardCeiling"), + source.get("max_budget_tokens"), + source.get("maxBudgetTokens"), + (budget or {}).get("hard_ceiling") if budget else None, + ) + turns_max_raw = _first( + source.get("turns_max"), + source.get("turnsMax"), + source.get("max_turns"), + source.get("maxTurns"), + (turns or {}).get("max") if turns else None, + ) + timeout_raw = _first( + source.get("timeout_seconds"), + source.get("timeoutSeconds"), + source.get("model_timeout"), + source.get("timeout"), + ) + + if not isinstance(attempt_id, str) or not attempt_id: + raise ValueError("telemetry seed must carry a non-empty attempt_id") + if isinstance(issue_number_raw, bool) or not isinstance(issue_number_raw, int): + # Accept numeric strings for convenience, but never booleans. + try: + issue_number = int(str(issue_number_raw).strip(), 10) + except (TypeError, ValueError) as error: + raise ValueError("telemetry seed must carry an issue_number") from error + else: + issue_number = issue_number_raw + if issue_number <= 0: + raise ValueError("telemetry seed must carry a positive issue_number") + + continuation_index = _seed_int(continuation_index_raw) + if continuation_index_raw is not None and continuation_index is None: + raise ValueError("telemetry continuation index must be an integer or None") + if continuation_index is not None and continuation_index < 0: + raise ValueError("telemetry continuation index must be non-negative") + continuation_max = _seed_int(continuation_max_raw) + if continuation_max_raw is not None and continuation_max is None: + raise ValueError("telemetry continuation max must be an integer or None") + if continuation_max is not None and continuation_max < 0: + raise ValueError("telemetry continuation max must be non-negative") + hard_ceiling = _seed_int(hard_ceiling_raw) + if hard_ceiling_raw is not None and hard_ceiling is None: + raise ValueError("telemetry hard ceiling must be an integer or None") + turns_max = _seed_int(turns_max_raw) + if turns_max_raw is not None and turns_max is None: + raise ValueError("telemetry turns max must be an integer or None") + timeout_seconds = _seed_int(timeout_raw) + if timeout_raw is not None and timeout_seconds is None: + raise ValueError("telemetry timeout must be an integer or None") + + return { + "attempt_id": attempt_id, + "issue_number": issue_number, + "issue_title": issue_title, + "continuation_index": continuation_index, + "continuation_max": continuation_max, + "hard_ceiling": hard_ceiling, + "turns_max": turns_max, + "timeout_seconds": timeout_seconds, + } + + class TelemetryReporter: """Assemble schema-v1 snapshots behind the reader interface. Owns the process-local identity (recorded at startup), the process-local - sequence (reset with the process), and the size bound. The ``control`` - argument of :meth:`snapshot` supplies the live control section; later - tickets add the writer side that fills ``current_attempt``, - ``last_finished``, and ``recent_events``. + sequence (reset with the process), the size bound, and the claim-time + ``current_attempt`` view (set by :meth:`begin_attempt`, cleared by + :meth:`finish_attempt`). Configured limits passed at construction are + defaults; seed values from the claim override them per attempt. """ def __init__( @@ -83,6 +259,14 @@ def __init__( cli_version: str, process_started_at: str, clock: Callable[[], datetime] | None = None, + hard_ceiling: int | None = None, + turns_max: int | None = None, + timeout_seconds: int | None = None, + continuation_max: int | None = None, + max_budget_tokens: int | None = None, + max_turns: int | None = None, + model_timeout: int | None = None, + max_auto_continuations: int | None = None, ) -> None: self._identity = { "repository": repository, @@ -94,12 +278,56 @@ def __init__( self._clock = clock or (lambda: datetime.now(UTC)) self._sequence = 0 self._sequence_lock = threading.Lock() + self._attempt_lock = threading.Lock() + self._configured = { + "hard_ceiling": hard_ceiling if hard_ceiling is not None else max_budget_tokens, + "turns_max": turns_max if turns_max is not None else max_turns, + "timeout_seconds": timeout_seconds if timeout_seconds is not None else model_timeout, + "continuation_max": ( + continuation_max if continuation_max is not None else max_auto_continuations + ), + } + self._current_seed: dict | None = None + self._last_good_current: dict | None = None + self._build_failed_logged = False + + def begin_attempt(self, seed: dict) -> None: + """Seed the ``current_attempt`` view at claim; never raises. + + A malformed seed logs once and keeps the previous view so a + telemetry failure can never break the claim it observes. + """ + + try: + normalized = _normalize_seed(seed) + except Exception as error: + with self._attempt_lock: + if not self._build_failed_logged: + self._build_failed_logged = True + logger.warning("telemetry begin_attempt failed: %s; keeping previous view", error) + return + with self._attempt_lock: + self._current_seed = normalized + self._build_failed_logged = False + + def finish_attempt(self) -> None: + """Clear the ``current_attempt`` view at finalisation; never raises.""" + + try: + with self._attempt_lock: + self._current_seed = None + self._last_good_current = None + self._build_failed_logged = False + except Exception as error: # pragma: no cover - lock failure + logger.warning("telemetry finish_attempt failed: %s", error) def snapshot(self, control: TelemetryControl) -> dict: """Build one schema-v1 snapshot; raise when the control section is missing. ``SnapshotFailedError`` from the control reader propagates unchanged - so the socket handler can answer ``snapshot_failed``. + so the socket handler can answer ``snapshot_failed``. Building the + ``current_attempt`` section never raises: a failure logs once and + keeps the previous view. """ # The control section first: only a snapshot that is actually served @@ -108,19 +336,119 @@ def snapshot(self, control: TelemetryControl) -> dict: with self._sequence_lock: self._sequence += 1 sequence = self._sequence + current: dict | None + try: + current = self._build_current_attempt() + except Exception as error: + with self._attempt_lock: + if not self._build_failed_logged: + self._build_failed_logged = True + logger.warning( + "telemetry current_attempt build failed: %s; keeping previous view", + error, + ) + current = copy.deepcopy(self._last_good_current) if self._last_good_current is not None else None + else: + with self._attempt_lock: + self._build_failed_logged = False + self._last_good_current = copy.deepcopy(current) if current is not None else None snapshot = { "schema_version": SCHEMA_VERSION, "generated_at": _format_timestamp(self._clock()), "sequence": sequence, "identity": dict(self._identity), "control": section, - "current_attempt": None, + "current_attempt": current, "last_finished": None, "recent_events": [], "truncated": False, } return apply_size_cap(snapshot) + def _build_current_attempt(self) -> dict | None: + with self._attempt_lock: + seed = copy.deepcopy(self._current_seed) if self._current_seed is not None else None + configured = dict(self._configured) + if seed is None: + return None + continuation_index = seed.get("continuation_index") + continuation_max = seed.get("continuation_max") + if continuation_max is None: + continuation_max = configured.get("continuation_max") + hard_ceiling = seed.get("hard_ceiling") + if hard_ceiling is None: + hard_ceiling = configured.get("hard_ceiling") + turns_max = seed.get("turns_max") + if turns_max is None: + turns_max = configured.get("turns_max") + timeout_seconds = seed.get("timeout_seconds") + if timeout_seconds is None: + timeout_seconds = configured.get("timeout_seconds") + + if continuation_index is not None and ( + isinstance(continuation_index, bool) + or not isinstance(continuation_index, int) + or continuation_index < 0 + ): + raise ValueError("stored continuation index is invalid") + for name, value in ( + ("continuation_max", continuation_max), + ("hard_ceiling", hard_ceiling), + ("turns_max", turns_max), + ("timeout_seconds", timeout_seconds), + ): + if value is not None and ( + isinstance(value, bool) or not isinstance(value, int) or value < 0 + ): + raise ValueError(f"stored {name} is invalid") + + attempt: dict = { + "attempt_id": seed["attempt_id"], + "issue_number": seed["issue_number"], + "observed_at": _format_timestamp(self._clock()), + "continuation": { + "index": ( + _figure_configured(continuation_index) + if continuation_index is not None + else _figure_unavailable() + ), + "max": ( + _figure_configured(continuation_max) + if continuation_max is not None + else _figure_unavailable() + ), + }, + "budget": { + "used": _figure_unavailable(), + "hard_ceiling": ( + _figure_configured(hard_ceiling) + if hard_ceiling is not None + else _figure_unavailable() + ), + }, + "turns": { + "value": None, + "basis": "unavailable", + "reason": "not yet published", + "max": ( + _figure_configured(turns_max) + if turns_max is not None + else _figure_unavailable() + ), + }, + "timeout_seconds": ( + _figure_configured(timeout_seconds) + if timeout_seconds is not None + else _figure_unavailable() + ), + "elapsed_seconds": _figure_unavailable(), + "provider_rate_limit": _figure_unavailable("not exposed through the gateway"), + } + issue_title = seed.get("issue_title") + if issue_title is not None: + attempt["issue_title"] = issue_title + return attempt + def apply_size_cap(snapshot: dict) -> dict: """Trim an assembled snapshot to the serialised size bound, in place. From 320f1c426bced5fc4001d4e281bc983f9ddb6c2c Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 14 Oct 2026 13:25:21 +0000 Subject: [PATCH 3/4] Handoff note: issue #242 --- .agent/handoff/242.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/.agent/handoff/242.md b/.agent/handoff/242.md index 98d7e12..900fdf2 100644 --- a/.agent/handoff/242.md +++ b/.agent/handoff/242.md @@ -1,22 +1,24 @@ # Handoff note: issue #242 - issue: 242 -- started_at: 2026-10-14T13:01:47.773506Z +- started_at: 2026-10-14T13:13:03.054548Z - reason: token_soft_threshold -- last_work_commit: 11f6b9bf07d96aeea85e3e1c2207a4eaab2d8f29 +- last_work_commit: 703dd705f4b7e98fff4e5aa9c803ae8e47af6063 ## Summary -Implemented the claim-to-finalisation telemetry slice: checkpoint now stores `issue_title` + `continuation_index` (old checkpoints load as unknown), `control.active_attempt` carries `issue_title`, `TelemetryReporter` gained `begin_attempt(seed)` / `finish_attempt()` with configured figures and unavailable live figures, lifecycle seeds telemetry at both claim paths and clears it in `_commit_finalization` (+ startup leftover delete), `__main__` wires configured limits. Ruff passes; `test_attempt_state`, `test_telemetry`, `test_telemetry_examples`, `test_lifecycle_control`, `test_auto_continuations`, `test_control`, `test_lifecycle`, `test_finalization`, `test_handoff_end_to_end` pass (115 + 50 + 51 green). +Issue #242 implementation is complete in the working tree (uncommitted): claim-to-finalisation telemetry slice (checkpoint `issue_title` + `continuation_index` with legacy-unknown loading; `TelemetryReporter.begin_attempt`/`finish_attempt` with configured limits and unavailable live figures; `control.active_attempt.issue_title`; never-raises publishing) plus 13 new tests in `tests/test_telemetry.py` (reporter null→present→null, seed-override, unavailable-never-zero, malformed-seed keeps view + logs once, build-failure keeps last good, checkpoint store/legacy compat, nested-seed tolerance, claim→present→finalise→null integration, startup-resume re-seed). Code-review skill invoked with both axes; repairs applied: corrected the false "or equivalently" docstring clause, startup resume now re-seeds telemetry from the checkpoint, extracted `_seed_or_configured`, direct attribute access replacing defensive getattr. Rebuttals recorded in the conversation: `turns` shape and extra keys match the published contract example; `continuation.index` stays `configured` per AC3's explicit letter. Manual snapshot() verification confirmed null→present→null. ## Remaining work -1. Manual verification via python snippet was blocked by token threshold — rerun a quick `snapshot()` check (null → present with seeded values → null) and a lifecycle claim/finalise check. -2. Run the full completion gate in order: ruff check, pytest py3.12, pytest py3.13. -3. Invoke the `code-review` skill on the changes. -4. Commit the implementation work to branch `agent/issue-242` (currently uncommitted: `__main__.py`, `attempt_state.py`, `control.py`, `lifecycle.py`, `telemetry.py`). +1. Commit the working tree (`simple_coding_agent/lifecycle.py`, `simple_coding_agent/telemetry.py`, `tests/test_telemetry.py`) to branch `agent/issue-242` — sealing is expected to handle this. +2. Completion gate runs automatically after the model finishes (ruff check, pytest py3.12, pytest py3.13). Pre-verified locally: ruff clean; full suite green after repairs. ## Evidence -- `git diff --stat`: 5 files changed, 473 insertions, 53 deletions (see status context). -- Partial test evidence: ruff "All checks passed"; pytest 50 passed (attempt_state/telemetry/examples); 115 passed (lifecycle_control/auto_continuations/control/lifecycle); 51 passed (finalization/handoff_e2e). Full gate + code-review not yet run. +- `uv run --extra dev ruff check` (changed files) → All checks passed. +- `uv run --extra dev pytest tests/test_telemetry.py` → 38 passed. +- Adjacent suites (lifecycle, lifecycle_control, auto_continuations, control, finalization, recovery, handoff_e2e, telemetry_examples, requeue_source, next_issue) → 248 passed. +- Full suite (`uv run --extra dev pytest -q -p no:cacheprovider`) after repairs → exit code 0. +- Manual snippet: no attempt → None; claimed → seeded values with configured limits + unavailable live figures; finalised → None. +- Reviews performed: 2 (Standards + Spec sub-agents via code-review skill); repair notes in conversation. From 75ad9f9b52ef243f543878c053e9f7ae277521e1 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 14 Oct 2026 13:25:36 +0000 Subject: [PATCH 4/4] Seal attempt work for #242 (model_limit_reached) Attempt-Id: 2026-10-14T13:13:03.054548Z --- simple_coding_agent/lifecycle.py | 12 +- simple_coding_agent/telemetry.py | 40 +-- tests/test_telemetry.py | 440 ++++++++++++++++++++++++++++++- 3 files changed, 468 insertions(+), 24 deletions(-) diff --git a/simple_coding_agent/lifecycle.py b/simple_coding_agent/lifecycle.py index bd2c40c..174b843 100644 --- a/simple_coding_agent/lifecycle.py +++ b/simple_coding_agent/lifecycle.py @@ -2749,8 +2749,8 @@ def _telemetry_begin_for_checkpoint(self, checkpoint: AttemptCheckpoint) -> None { "attempt_id": checkpoint.started_at, "issue_number": checkpoint.issue_number, - "issue_title": getattr(checkpoint, "issue_title", None), - "continuation_index": getattr(checkpoint, "continuation_index", None), + "issue_title": checkpoint.issue_title, + "continuation_index": checkpoint.continuation_index, "continuation_max": self._max_auto_continuations, } ) @@ -2942,7 +2942,7 @@ def control_status(self, repository: str | None = None) -> dict: branch=checkpoint.branch, phase=checkpoint.phase.value, started_at=checkpoint.started_at, - issue_title=getattr(checkpoint, "issue_title", None), + issue_title=checkpoint.issue_title, ) if checkpoint is not None else None @@ -3029,7 +3029,7 @@ def _build_telemetry_control_section(self) -> dict: branch=checkpoint.branch, phase=checkpoint.phase.value, started_at=checkpoint.started_at, - issue_title=getattr(checkpoint, "issue_title", None), + issue_title=checkpoint.issue_title, ) if checkpoint is not None else None @@ -4382,6 +4382,10 @@ def _reconcile_startup(self) -> AttemptOutcome | None: # workspace repair that could progress it. raise RuntimeError("Interrupted attempt issue is unavailable for recovery") self._active_issue_number = claim.issue.number + # The attempt survived a restart: re-seed the claim-time view from + # the checkpoint (no tracker lookup) so telemetry covers the attempt + # from its original claim until its eventual finalisation. + self._telemetry_begin_for_checkpoint(checkpoint) with self._control_lock: self._attempt_processing = True if self._control_store is not None: diff --git a/simple_coding_agent/telemetry.py b/simple_coding_agent/telemetry.py index 43961a4..e1f0aec 100644 --- a/simple_coding_agent/telemetry.py +++ b/simple_coding_agent/telemetry.py @@ -9,12 +9,15 @@ socket, not HTTP) is ADR 0006. ``current_attempt`` is present from claim (``begin_attempt``) until the -attempt is finalised (``finish_attempt`` or, equivalently, a snapshot whose -control section shows no active attempt). Its configured limits come from -the seed (claim-time values) falling back to the reporter construction -defaults; live figures stay ``unavailable`` until later tickets publish -them. Building the section never raises into the attempt: a failure logs -once and keeps the previous view. +attempt is finalised (``finish_attempt``). The lifecycle calls ``begin`` at +every claim boundary (including resuming a retained attempt after a +restart) and ``finish`` at every finalization boundary (including the +startup-leftover path), so the stored seed alone decides the section: a +set seed means an active attempt. Its configured limits come from the seed +(claim-time values) falling back to the reporter construction defaults; +live figures stay ``unavailable`` until later tickets publish them. +Building the section never raises into the attempt: a failure logs once +per episode and keeps the previous view. """ from __future__ import annotations @@ -111,6 +114,15 @@ def _seed_str(candidate: object) -> str | None: return candidate if isinstance(candidate, str) else None +def _seed_or_configured(seed: dict, configured: dict, key: str) -> int | None: + """A claim-time seed value falling back to the construction default.""" + + value = seed.get(key) + if value is None: + value = configured.get(key) + return value # type: ignore[return-value] + + def _normalize_seed(seed: object) -> dict: """Normalize a ``begin_attempt`` seed to the stored claim-time view. @@ -372,18 +384,10 @@ def _build_current_attempt(self) -> dict | None: if seed is None: return None continuation_index = seed.get("continuation_index") - continuation_max = seed.get("continuation_max") - if continuation_max is None: - continuation_max = configured.get("continuation_max") - hard_ceiling = seed.get("hard_ceiling") - if hard_ceiling is None: - hard_ceiling = configured.get("hard_ceiling") - turns_max = seed.get("turns_max") - if turns_max is None: - turns_max = configured.get("turns_max") - timeout_seconds = seed.get("timeout_seconds") - if timeout_seconds is None: - timeout_seconds = configured.get("timeout_seconds") + continuation_max = _seed_or_configured(seed, configured, "continuation_max") + hard_ceiling = _seed_or_configured(seed, configured, "hard_ceiling") + turns_max = _seed_or_configured(seed, configured, "turns_max") + timeout_seconds = _seed_or_configured(seed, configured, "timeout_seconds") if continuation_index is not None and ( isinstance(continuation_index, bool) diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index dabae50..7fd3015 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -13,15 +13,27 @@ import socket import threading import time +from datetime import UTC, datetime, timedelta from pathlib import Path import pytest from simple_coding_agent.agentctl import main as agentctl_main -from simple_coding_agent.attempt_state import AttemptStateStore +from simple_coding_agent.attempt_state import ( + AttemptCheckpoint, + AttemptPhase, + AttemptStateError, + AttemptStateStore, +) +from simple_coding_agent.completion import ( + AttemptOutcome, + CompletionDecision, + PublicationPath, +) from simple_coding_agent.control import ControlStore from simple_coding_agent.control_server import ControlServer, send_request -from simple_coding_agent.lifecycle import AgentLifecycle +from simple_coding_agent.github_tracker import TrackerIssue +from simple_coding_agent.lifecycle import AgentLifecycle, AttemptEvidence from simple_coding_agent.telemetry import ( SNAPSHOT_SIZE_LIMIT_BYTES, SnapshotFailedError, @@ -786,3 +798,427 @@ def test_size_cap_drops_subagents_after_events_are_empty() -> None: assert remaining assert remaining[0]["id"] != "agent-000" assert remaining[-1]["id"] == "agent-059" + + +# --------------------------------------------------------------------------- +# claim-to-finalisation current_attempt (issue #242) +# --------------------------------------------------------------------------- + + +class _StubControl: + """A control reader with a fixed section for reporter-level tests.""" + + def __init__(self, section: dict | None = None) -> None: + self._section = dict( + section or {"control_observed_at": "2026-10-14T12:00:00Z"} + ) + + def telemetry_control_snapshot(self) -> dict: + return dict(self._section) + + +def make_attempt_reporter(**overrides: object) -> TelemetryReporter: + values: dict[str, object] = { + "repository": "owner/repo", + "agent_version": "0.0-test", + "sdk_version": "0.2.163-test", + "cli_version": "2.1.286-test", + "process_started_at": "2026-01-01T00:00:00Z", + "hard_ceiling": 1000, + "turns_max": 50, + "timeout_seconds": 600, + "continuation_max": 2, + } + values.update(overrides) + return TelemetryReporter(**values) # type: ignore[arg-type] + + +def claim_seed(**overrides: object) -> dict: + seed: dict[str, object] = { + "attempt_id": "2026-10-14T12:00:00.000001Z", + "issue_number": 7, + "issue_title": "Fix the widget", + "continuation_index": 0, + } + seed.update(overrides) + return seed + + +def without_observed_at(current: dict) -> dict: + rest = dict(current) + rest.pop("observed_at", None) + return rest + + +def test_snapshot_without_an_attempt_reports_null_current_attempt() -> None: + reporter = make_attempt_reporter() + + assert reporter.snapshot(_StubControl())["current_attempt"] is None + + +def test_begin_attempt_publishes_seeded_values_with_configured_limits() -> None: + reporter = make_attempt_reporter() + reporter.begin_attempt(claim_seed(continuation_index=1)) + + current = reporter.snapshot(_StubControl())["current_attempt"] + + assert current is not None + assert current["attempt_id"] == "2026-10-14T12:00:00.000001Z" + assert current["issue_number"] == 7 + assert current["issue_title"] == "Fix the widget" + assert isinstance(current["observed_at"], str) and current["observed_at"] + assert current["continuation"]["index"] == {"value": 1, "basis": "configured"} + assert current["continuation"]["max"] == {"value": 2, "basis": "configured"} + assert current["budget"]["hard_ceiling"] == {"value": 1000, "basis": "configured"} + assert current["turns"]["max"] == {"value": 50, "basis": "configured"} + assert current["timeout_seconds"] == {"value": 600, "basis": "configured"} + # Live figures stay unavailable until later tickets publish them. + assert current["budget"]["used"]["value"] is None + assert current["budget"]["used"]["basis"] == "unavailable" + assert current["turns"]["value"] is None + assert current["elapsed_seconds"]["value"] is None + assert current["elapsed_seconds"]["basis"] == "unavailable" + + +def test_seed_limits_override_the_reporter_defaults() -> None: + reporter = make_attempt_reporter() + reporter.begin_attempt( + claim_seed( + continuation_index=2, + continuation_max=5, + hard_ceiling=4000, + turns_max=25, + timeout_seconds=120, + ) + ) + + current = reporter.snapshot(_StubControl())["current_attempt"] + + assert current is not None + assert current["continuation"]["index"] == {"value": 2, "basis": "configured"} + assert current["continuation"]["max"] == {"value": 5, "basis": "configured"} + assert current["budget"]["hard_ceiling"]["value"] == 4000 + assert current["turns"]["max"]["value"] == 25 + assert current["timeout_seconds"]["value"] == 120 + + +def test_unconfigured_limits_report_unavailable_never_zero() -> None: + reporter = make_reporter() + reporter.begin_attempt( + { + "attempt_id": "2026-10-14T12:00:00.000001Z", + "issue_number": 7, + } + ) + + current = reporter.snapshot(_StubControl())["current_attempt"] + + assert current is not None + for figure in ( + current["continuation"]["index"], + current["continuation"]["max"], + current["budget"]["hard_ceiling"], + current["turns"]["max"], + current["timeout_seconds"], + current["budget"]["used"], + current["elapsed_seconds"], + ): + assert figure["value"] is None + assert figure["basis"] == "unavailable" + assert "issue_title" not in current + + +def test_begin_attempt_accepts_nested_and_figure_shaped_seeds() -> None: + reporter = make_attempt_reporter() + reporter.begin_attempt( + { + "attempt_id": "2026-10-14T12:00:00.000001Z", + "issue_number": "7", + "continuation": { + "index": {"value": 1, "basis": "estimated"}, + "max": {"value": 3, "basis": "configured"}, + }, + "budget": {"hard_ceiling": {"value": 2000, "basis": "configured"}}, + "turns": {"max": 40}, + # Integral floats are accepted as limits, not rejected. + "timeout_seconds": 300.0, + } + ) + + current = reporter.snapshot(_StubControl())["current_attempt"] + + assert current is not None + assert current["issue_number"] == 7 + assert current["continuation"]["index"] == {"value": 1, "basis": "configured"} + assert current["continuation"]["max"] == {"value": 3, "basis": "configured"} + assert current["budget"]["hard_ceiling"] == {"value": 2000, "basis": "configured"} + assert current["turns"]["max"] == {"value": 40, "basis": "configured"} + assert current["timeout_seconds"] == {"value": 300, "basis": "configured"} + + +def test_finish_attempt_clears_current_attempt() -> None: + reporter = make_attempt_reporter() + reporter.begin_attempt(claim_seed()) + + assert reporter.snapshot(_StubControl())["current_attempt"] is not None + + reporter.finish_attempt() + + assert reporter.snapshot(_StubControl())["current_attempt"] is None + + +def test_malformed_seed_keeps_the_previous_view_and_logs_once( + caplog: pytest.LogCaptureFixture, +) -> None: + reporter = make_attempt_reporter() + reporter.begin_attempt(claim_seed()) + before = without_observed_at( + reporter.snapshot(_StubControl())["current_attempt"] + ) + + with caplog.at_level("WARNING", logger="simple_coding_agent.telemetry"): + reporter.begin_attempt({}) + reporter.begin_attempt({"attempt_id": "", "issue_number": -1}) + + after = without_observed_at( + reporter.snapshot(_StubControl())["current_attempt"] + ) + assert after == before + kept = [ + record + for record in caplog.records + if "keeping previous view" in record.getMessage() + ] + assert len(kept) == 1 + + +def test_malformed_seed_on_a_fresh_reporter_stays_null() -> None: + reporter = make_attempt_reporter() + + reporter.begin_attempt({}) + + assert reporter.snapshot(_StubControl())["current_attempt"] is None + + +def test_current_attempt_build_failure_keeps_the_last_good_view( + caplog: pytest.LogCaptureFixture, +) -> None: + reporter = make_attempt_reporter() + reporter.begin_attempt(claim_seed()) + good = reporter.snapshot(_StubControl())["current_attempt"] + assert good is not None + # Corrupt the stored claim-time view behind the reporter's back: the + # next build must not raise into the attempt. + assert reporter._current_seed is not None + reporter._current_seed["continuation_index"] = -1 + + with caplog.at_level("WARNING", logger="simple_coding_agent.telemetry"): + snapshot = reporter.snapshot(_StubControl()) + + assert snapshot["current_attempt"] == good + kept = [ + record + for record in caplog.records + if "keeping previous view" in record.getMessage() + ] + assert len(kept) == 1 + + +def test_checkpoint_stores_the_claim_time_telemetry_fields(tmp_path: Path) -> None: + store = AttemptStateStore(tmp_path) + + checkpoint = store.start( + issue_number=7, + branch="agent/issue-7", + issue_title="Fix the widget", + continuation_index=2, + ) + + assert checkpoint.issue_title == "Fix the widget" + assert checkpoint.continuation_index == 2 + reread = store.read() + assert reread is not None + assert reread.issue_title == "Fix the widget" + assert reread.continuation_index == 2 + # The values ride along untouched through phase transitions. + assert store.transition(AttemptPhase.SETUP).continuation_index == 2 + + with pytest.raises(AttemptStateError): + AttemptStateStore(tmp_path / "other").start( + issue_number=9, branch="agent/issue-9", continuation_index=-1 + ) + + +def test_legacy_checkpoint_without_telemetry_fields_loads_as_unknown( + tmp_path: Path, +) -> None: + state_dir = tmp_path / "state" + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "attempt.json").write_text( + json.dumps( + { + "issue_number": 7, + "branch": "agent/issue-7", + "phase": "claimed", + "started_at": "2026-10-14T12:00:00Z", + "updated_at": "2026-10-14T12:00:00Z", + "auto_merge_opt_in": False, + "requeue_source": "human", + } + ) + ) + + checkpoint = AttemptStateStore(tmp_path).read() + + assert checkpoint is not None + assert checkpoint.issue_title is None + assert checkpoint.continuation_index is None + + +_CREATED_BASE = datetime(2026, 10, 14, 12, 0, tzinfo=UTC) + + +def tracked_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": frozenset({"ready-for-agent"}), + "assignee_logins": (), + "blocked_by": 0, + "author_login": "reporter", + } + base.update(overrides) + return TrackerIssue(**base) # type: ignore[arg-type] + + +def complete_attempt_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 test_claim_seeds_telemetry_and_finalization_clears_it(tmp_path: Path) -> None: + from tests.fakes import FakePublisher, FakeTracker, InMemoryWorkspace + + reporter = make_attempt_reporter() + tracker = FakeTracker({7: tracked_issue(7, title="Fix the widget")}, fifo=[7]) + state = AttemptStateStore(tmp_path) + lifecycle = AgentLifecycle( + tracker=tracker, + attempt_state=state, + workspace=InMemoryWorkspace(tmp_path / "repo"), + profile_loader=lambda _: type("Profile", (), {"base_branch": "main"})(), + publisher=FakePublisher(), + attempt_runner=lambda claim, profile, prepared: complete_attempt_evidence(), + control_store=ControlStore(tmp_path), + repository="owner/repo", + telemetry=reporter, + sleeper=lambda seconds: None, + max_auto_continuations=2, + ) + + assert lifecycle.telemetry_snapshot()["current_attempt"] is None + + mid_run: list[dict] = [] + mid_checkpoint: list[AttemptCheckpoint | None] = [] + + def capturing_runner( + claim: object, profile: object, prepared: object + ) -> AttemptEvidence: + mid_run.append(lifecycle.telemetry_snapshot()) + mid_checkpoint.append(state.read()) + return complete_attempt_evidence() + + lifecycle.set_attempt_runner(capturing_runner) + result = lifecycle.run_once() + + assert result.status.value == "attempted" + assert len(mid_run) == 1 + current = mid_run[0]["current_attempt"] + assert current is not None + assert current["issue_number"] == 7 + assert current["continuation"]["index"] == {"value": 0, "basis": "configured"} + assert current["continuation"]["max"] == {"value": 2, "basis": "configured"} + assert current["budget"]["hard_ceiling"] == {"value": 1000, "basis": "configured"} + assert current["turns"]["max"] == {"value": 50, "basis": "configured"} + assert current["timeout_seconds"] == {"value": 600, "basis": "configured"} + assert mid_run[0]["control"]["active_attempt"]["issue_number"] == 7 + assert mid_run[0]["control"]["active_attempt"]["issue_title"] == "Fix the widget" + # The checkpoint captured the claim-time values once, so telemetry + # needs no tracker lookup for them. + checkpoint = mid_checkpoint[0] + assert checkpoint is not None + assert checkpoint.issue_title == "Fix the widget" + assert checkpoint.continuation_index == 0 + # Finalisation clears the attempt view again. + assert lifecycle.telemetry_snapshot()["current_attempt"] is None + + +def test_startup_resume_reseeds_telemetry_from_the_checkpoint( + tmp_path: Path, +) -> None: + """A restart mid-attempt restores the claim-time view without a lookup. + + The checkpoint already carries the issue title and continuation index, + so resuming a retained attempt re-seeds telemetry from disk: the + attempt stays visible from its original claim until finalisation even + though this process never claimed it. + """ + + from simple_coding_agent.git_workspace import DirtyWorkspaceError + from tests.fakes import FakePublisher, FakeTracker + + class DirtyWorkspace: + def __init__(self, root: Path) -> None: + self.working_directory = root + + def prepare_for_profile_read(self) -> None: + raise DirtyWorkspaceError("tree holds unexplained work") + + reporter = make_attempt_reporter() + state = AttemptStateStore(tmp_path) + checkpoint = state.start( + issue_number=7, + branch="agent/issue-7", + issue_title="Fix the widget", + continuation_index=2, + ) + lifecycle = AgentLifecycle( + tracker=FakeTracker({7: tracked_issue(7, title="Fix the widget")}), + attempt_state=state, + workspace=DirtyWorkspace(tmp_path / "repo"), # type: ignore[arg-type] + profile_loader=lambda _: type("Profile", (), {"base_branch": "main"})(), + publisher=FakePublisher(), + control_store=ControlStore(tmp_path), + repository="owner/repo", + telemetry=reporter, + sleeper=lambda seconds: None, + max_auto_continuations=2, + ) + + assert lifecycle.telemetry_snapshot()["current_attempt"] is None + + # The dirty tree holds the resume for the operator, but telemetry + # already covers the retained attempt. + assert lifecycle.run_once().status.value == "attempted" + assert state.read() is not None + + snapshot = lifecycle.telemetry_snapshot() + current = snapshot["current_attempt"] + assert current is not None + assert current["attempt_id"] == checkpoint.started_at + assert current["issue_number"] == 7 + assert current["continuation"]["index"] == {"value": 2, "basis": "configured"} + assert current["continuation"]["max"] == {"value": 2, "basis": "configured"} + assert snapshot["control"]["active_attempt"]["issue_title"] == "Fix the widget"