Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .agent/handoff/242.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Handoff note: issue #242

- issue: 242
- started_at: 2026-10-14T13:13:03.054548Z
- reason: token_soft_threshold
- last_work_commit: 703dd705f4b7e98fff4e5aa9c803ae8e47af6063

## Summary

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. 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

- `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.
4 changes: 4 additions & 0 deletions simple_coding_agent/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
75 changes: 40 additions & 35 deletions simple_coding_agent/attempt_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand All @@ -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:
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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"]
Expand All @@ -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():
Expand All @@ -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(
Expand All @@ -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,
)


Expand Down
2 changes: 2 additions & 0 deletions simple_coding_agent/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ class ActiveAttemptInfo:
branch: str
phase: str
started_at: str
issue_title: str | None = None


@dataclass(frozen=True)
Expand Down Expand Up @@ -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
Expand Down
101 changes: 93 additions & 8 deletions simple_coding_agent/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": checkpoint.issue_title,
"continuation_index": checkpoint.continuation_index,
"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,
Expand Down Expand Up @@ -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=checkpoint.issue_title,
)
if checkpoint is not None
else None
Expand Down Expand Up @@ -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=checkpoint.issue_title,
)
if checkpoint is not None
else None
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Expand All @@ -4301,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:
Expand Down
Loading
Loading