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

- issue: 240
- started_at: 2026-10-14T12:03:00.200226Z
- reason: token_soft_threshold
- last_work_commit: 69b15f1d9bf87639d1942a27c594996f269f38e8

## Summary

Implemented the telemetry tracer bullet (agent-side `telemetry` over the control socket). All new tests pass (22/22 in `tests/test_telemetry.py`), full suite passes (1070 passed, 10 skipped), and `ruff check` is clean. The `code-review` skill was invoked in this run: Standards axis found 0 hard violations + 4 judgement-call smells; Spec axis found 0 missing requirements plus 2 partial gaps, 2 scope-creep notes, and 2 fragility findings (full report in the run transcript).

What was built:
- New `simple_coding_agent/telemetry.py`: `TelemetryReporter` (startup identity, process-local sequence, `snapshot(control)` reader interface, 256 KiB cap via `apply_size_cap` trimming oldest events then subagents), `SnapshotFailedError`, timeout/size constants.
- `lifecycle.py`: `AgentLifecycle(..., telemetry=...)`, `telemetry_snapshot()`, `telemetry_control_snapshot()` (≤1 s control-lock wait, reuse of last built section with `control_observed_at`, `SnapshotFailedError` when none built yet), plus network-free control-section/recovery builders (no workspace/tracker/publisher I/O; publication fields `None`).
- `control_server.py`: `{"op": "telemetry"}` → `{"ok": true, "telemetry": ...}`; `Unknown operation` for readers without telemetry; `snapshot_failed` + rate-limited `telemetry_failed` event (60 s); new `UnreadableReplyError`; `send_request(..., timeout=10.0)` default unchanged.
- `agentctl.py`: `telemetry --json` (required flag) printing the bare snapshot, exit 0; failures print `{"ok": false, "error": {"code", "message"}}` to stdout with `agent_unreachable`→2, `unsupported_operation`/`snapshot_failed`→1; 3 s timeout; no tracebacks.
- `__main__.py`: wires `TelemetryReporter` (provenance sdk/cli/agent versions, `process_started_at`) into `AgentLifecycle`.
- `tests/test_telemetry.py`: socket/shape/sequence, lock-busy cache reuse and empty-cache failure, exploding-fake no-I/O tests (idle + retained hold with null publication), all CLI error codes/exits, 3 s black-hole timeout, size-cap unit tests, rate-limit test.

## Remaining work

1. Decide whether to address code-review findings before finishing (most actionable: return a copy of the cached control section in `telemetry_control_snapshot`; move the final `print(json.dumps(snapshot))` inside the telemetry try in `agentctl.py`; consider dropping the `format_timestamp`/`identity` extra public surface). None was applied — implementation work is disabled at handoff.
2. Check whether the `PytestUnhandledThreadExceptionWarning` in the full-suite output is caused by the new tests (unverified; could be pre-existing).
3. Run the completion gate commands in order (ruff + pytest on both venvs).
4. Commit the work to branch `agent/issue-240` (nothing committed yet; sealing will commit uncommitted work).

## Evidence

- Modified: `simple_coding_agent/__main__.py`, `simple_coding_agent/agentctl.py`, `simple_coding_agent/control_server.py`, `simple_coding_agent/lifecycle.py` (460 insertions, 7 deletions per diff stat).
- New: `simple_coding_agent/telemetry.py`, `tests/test_telemetry.py`.
- Test results observed: `tests/test_telemetry.py` 22 passed; full suite 1070 passed, 10 skipped; `ruff check` clean.
- Code-review report: delivered in the run transcript under `## Standards` / `## Spec` headings.
11 changes: 11 additions & 0 deletions simple_coding_agent/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import sys
from datetime import UTC, datetime
from pathlib import Path

from simple_coding_agent.attempt_state import AttemptStateStore
Expand All @@ -24,13 +25,15 @@
from simple_coding_agent.operating import ConsecutiveErrorStore
from simple_coding_agent.publication import Publisher
from simple_coding_agent.provenance import ProvenanceError, ProvenanceVerifier
from simple_coding_agent.telemetry import TelemetryReporter, format_timestamp
from dotenv import load_dotenv


def main() -> None:
"""Run sequential attempts forever for the one operator-configured repository."""

load_dotenv(override=False)
process_started_at = format_timestamp(datetime.now(UTC))
config = load_runtime_config()
attempt_state = AttemptStateStore(config.data_dir)
archive_factory = lambda issue_number, started_at: AttemptArchive(
Expand Down Expand Up @@ -97,6 +100,13 @@ def _attempt_sink(issue_number: int) -> AttemptArchive | None:
event, phase="setup", detail=detail, level=level, issue_number=issue_number
),
)
telemetry = TelemetryReporter(
repository=config.target_repo,
agent_version=provenance.agent_version,
sdk_version=provenance.sdk_version,
cli_version=provenance.cli_version,
process_started_at=process_started_at,
)
lifecycle = AgentLifecycle(
tracker=tracker,
attempt_state=attempt_state,
Expand Down Expand Up @@ -129,6 +139,7 @@ def _attempt_sink(issue_number: int) -> AttemptArchive | None:
completion_store=AttemptCompletionStore(config.data_dir),
control_store=ControlStore(config.data_dir),
repository=config.target_repo,
telemetry=telemetry,
)
interruption_handler = AttemptInterruptionHandler(
event_log=lambda event, detail="", level="INFO", issue_number=None: logger.emit(
Expand Down
105 changes: 105 additions & 0 deletions simple_coding_agent/agentctl.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from __future__ import annotations

import argparse
import json
import sys
import uuid
from collections.abc import Callable
Expand All @@ -24,6 +25,10 @@
send_request,
)
from simple_coding_agent.control import StopAfterRejectedError, parse_stop_after
from simple_coding_agent.telemetry import (
SNAPSHOT_SIZE_LIMIT_BYTES,
TELEMETRY_TIMEOUT_SECONDS,
)


def default_socket_path() -> Path:
Expand Down Expand Up @@ -119,6 +124,75 @@ def run_status(socket_path: Path) -> dict:
return reply["status"]


class TelemetryError(Exception):
"""A machine-readable ``agentctl telemetry`` failure with its error code."""

def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message


def run_telemetry(socket_path: Path) -> dict:
"""Read one telemetry snapshot; raise a coded error when it cannot be served.

Uses the short telemetry timeout so the call always answers inside the
dashboard's per-instance budget. ``agent_unreachable`` covers a missing,
refused, or timed-out socket; ``unsupported_operation`` covers an agent
that answers ``Unknown operation``; ``snapshot_failed`` covers an agent
that could not build the snapshot and replies that are unparsable,
oversized, or malformed.
"""

from simple_coding_agent.control_server import UnreadableReplyError

try:
reply = send_request(
socket_path, {"op": "telemetry"}, timeout=TELEMETRY_TIMEOUT_SECONDS
)
except UnreadableReplyError as error:
raise TelemetryError(
"snapshot_failed",
f"agent answered but the reply was unparsable or oversized: {error}",
) from error
except ControlUnavailableError as error:
raise TelemetryError(
"agent_unreachable",
f"cannot connect to the agent process: {error}",
) from error
if not reply.get("ok"):
message = str(reply.get("error", "unknown error"))
if "Unknown operation" in message:
raise TelemetryError(
"unsupported_operation",
'agent answered "Unknown operation":'
" this agent version does not serve telemetry",
)
raise TelemetryError(
"snapshot_failed",
"agent answered but the telemetry snapshot could not be built:"
f" {message}",
)
snapshot = reply.get("telemetry")
if not isinstance(snapshot, dict):
raise TelemetryError(
"snapshot_failed",
"agent answered but the telemetry snapshot was missing or malformed",
)
if len(json.dumps(snapshot).encode("utf-8")) > SNAPSHOT_SIZE_LIMIT_BYTES:
raise TelemetryError(
"snapshot_failed",
"agent answered but the telemetry snapshot exceeded the 256 KiB size bound",
)
return snapshot


def format_telemetry_error(error: TelemetryError) -> str:
"""Render a telemetry failure as the machine-readable stdout envelope."""

return json.dumps({"ok": False, "error": {"code": error.code, "message": error.message}})


def run_command_lookup(socket_path: Path, request_id: str) -> dict | None:
"""Look up one command's current durable acknowledgement.

Expand Down Expand Up @@ -382,6 +456,16 @@ def build_parser() -> argparse.ArgumentParser:
)

subparsers.add_parser("status", help="Show one consistent live status snapshot.")
telemetry_parser = subparsers.add_parser(
"telemetry", help="Print the agent telemetry snapshot as JSON."
)
telemetry_parser.add_argument(
"--json",
dest="json",
action="store_true",
required=True,
help="Print the bare telemetry snapshot object (the only v1 format).",
)
command_parser = subparsers.add_parser(
"command", help="Show one command's current durable acknowledgement."
)
Expand Down Expand Up @@ -493,6 +577,27 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "status":
print(format_status(run_status(socket_path)))
return 0
if args.command == "telemetry":
try:
snapshot = run_telemetry(socket_path)
print(json.dumps(snapshot, sort_keys=True))
except TelemetryError as error:
print(format_telemetry_error(error))
return 2 if error.code == "agent_unreachable" else 1
except Exception as error:
# No traceback ever escapes: an unexpected failure is still a
# machine-readable snapshot failure on stdout.
print(
format_telemetry_error(
TelemetryError(
"snapshot_failed",
"agent telemetry could not be read:"
f" {type(error).__name__}: {error}",
)
)
)
return 1
return 0
if args.command == "command":
record = run_command_lookup(socket_path, args.request_id)
if record is None:
Expand Down
Loading
Loading