From ab9499e8d89cfc9ceeb9a4e8b05e5092b9a88bbb Mon Sep 17 00:00:00 2001 From: Brad Brok Date: Sat, 2 May 2026 23:54:15 +0400 Subject: [PATCH 01/24] Add OpencodeSession integration design spec (#356) Co-authored-by: Oleg --- specs/opencode-session-integration.md | 659 ++++++++++++++++++++++++++ 1 file changed, 659 insertions(+) create mode 100644 specs/opencode-session-integration.md diff --git a/specs/opencode-session-integration.md b/specs/opencode-session-integration.md new file mode 100644 index 00000000..9031427c --- /dev/null +++ b/specs/opencode-session-integration.md @@ -0,0 +1,659 @@ +# OpencodeSession Integration Design + +Status: draft spec for review +Scope: design only, no implementation in this branch +Primary goal: add `opencode` as a third PinkyBot agent runtime alongside Claude SDK `StreamingSession` and Codex CLI `CodexSession` + +## Context + +PinkyBot currently has two live agent runtimes: + +- `StreamingSession` in `src/pinky_daemon/streaming_session.py`, backed by the Claude Agent SDK. +- `CodexSession` in `src/pinky_daemon/codex_session.py`, backed by `codex exec --json`. + +Both are registered in the broker through `broker.register_streaming(agent_name, session, label=...)` and are expected to expose the same public surface: + +- `connect()` +- `send(prompt, platform="", chat_id="", message_id="", agent_hint="")` +- `disconnect()` +- `force_restart()` +- `idle_sleep()` +- `attempt_reconnect()` where practical +- `is_connected` +- `is_idle_sleeping` or equivalent +- `session_id` +- `id` +- `stats` +- `get_context_info()` + +The current runtime selection is overloaded onto provider config: `_start_streaming_session()` uses `provider_url == "codex_cli"` to select `CodexSession`, otherwise it uses `StreamingSession`. That was workable for one alternate runtime, but `opencode` needs provider selection inside the runtime, so continuing to encode runtime choice in `provider_url` would make configuration ambiguous. + +This spec uses the opencode architecture facts supplied by Barsik, plus a +verification pass against the official server/config docs and a short local +`opencode serve` smoke run. + +Verification notes: + +- `bunx opencode-ai --version` resolved v1.14.32 locally. +- `opencode serve` on `127.0.0.1:4196` with `OPENCODE_SERVER_PASSWORD` returned `/global/health` as `{"healthy":true,"version":"1.14.32"}`. +- `/global/event` emitted `server.connected` SSE events. +- The official server docs say `POST /session/:id/message` waits for completion and returns the response, while `POST /session/:id/message_async` queues a prompt and does not wait for completion. +- The generated SDK/OpenAPI types include `cost` and `tokens` on assistant message and step-finish shapes. +- The config docs support secret substitution from env/file values, and provider config supports `options.apiKey`. + +## Recommendation + +Add `OpencodeSession` as a polymorphic runtime backed by a shared `opencode serve` process. Add an explicit agent `runtime` field with values: + +- `claude_sdk` +- `codex_cli` +- `opencode` + +Keep provider/model selection separate from runtime selection. For opencode agents, PinkyBot stores the selected opencode model string, such as `deepseek/deepseek-v4-pro`, in existing model/provider fields or a normalized provider config, then renders that into generated opencode config. + +Do not auto-migrate existing agents. Opencode should coexist until it proves stable under real workloads. + +## 1. `OpencodeSession` Class Shape + +Create: + +```text +src/pinky_daemon/opencode_session.py +``` + +The class should mirror `StreamingSession` and `CodexSession` closely enough that `MessageBroker`, scheduler, status endpoints, restart endpoints, and web chat keep treating sessions polymorphically. + +Constructor: + +```python +class OpencodeSession: + def __init__( + self, + config: StreamingSessionConfig, + *, + response_callback=None, + conversation_store=None, + cost_callback=None, + stream_event_callback=None, + analytics_store=None, + registry=None, + server_manager=None, + ) -> None: + ... +``` + +Public fields/properties: + +- `agent_name` +- `session_id`: opencode session id persisted via the existing `_on_session_id` callback. +- `created_at` +- `last_active` +- `usage: SessionUsage` +- `account_info = {"apiProvider": "opencode"}` +- `is_connected` +- `is_idle_sleeping` +- `id`: keep `f"{agent_name}-{label}"`, matching existing session ids. +- `stats`: include `connected`, `idle_sleeping`, `processing`, `pending_messages`, `current_activity`, `activity_log`, `cost_usd`, `account`, `thinking_effort`. +- `get_context_info()`: best effort initially, same estimated-token fallback as `CodexSession` unless opencode exposes richer usage. + +Public methods: + +- `connect()`: ensure the shared opencode server is healthy, create or resume the opencode session, start queue worker, attach to the global event stream through the manager, then enqueue the wake prompt. +- `send(prompt, platform="", chat_id="", message_id="", agent_hint="")`: same semantics as `CodexSession.send()`. It must accept `agent_hint`, append it only to the runtime prompt, and store the raw prompt in conversation history. +- `disconnect()`: stop local worker/subscriptions. It should not stop the shared opencode server unless this is daemon shutdown and the manager owns the process. +- `attempt_reconnect()`: reconnect to the server and event stream with the existing bounded backoff shape from `StreamingSession`. +- `force_restart()`: apply the restart guard, clear `session_id`, clear persisted session id via `_on_session_id`, create a fresh opencode session, and enqueue wake context. +- `idle_sleep()`: ask the agent to persist state, disconnect this session, preserve `session_id`, and mark idle sleeping. + +Internal structure: + +- `_message_queue: asyncio.Queue[tuple[str, str, str, str]]` +- `_worker_task` +- `_event_subscription` +- `_processing` +- `_pending_chats` or a per-turn routing map if opencode can run overlapping turns. +- `context_estimator`, using a shared helper rather than copying `CodexSession`'s private `_internal_context_texts` implementation. + +The MVP should process prompts sequentially per PinkyBot session. That matches `CodexSession` and avoids ordering bugs while opencode event semantics are still new. + +Add a shared context helper before or with `OpencodeSession`: + +```python +class ContextTextEstimator: + def record_internal_text(self, text: str) -> None: ... + def estimated_tokens(self, session_id: str, conversation_store) -> int: ... + def context_info(self, session_id: str, conversation_store, max_tokens: int) -> dict: ... +``` + +Then refactor `CodexSession` to use the helper instead of maintaining its own +private implementation. `OpencodeSession` should not become a third copy of the +same estimation code. + +Send semantics are now resolved by docs: use `POST /session/:id/message` for +the sequential worker path because it waits for completion and returns the +response. Use `/global/event` for incremental UI activity and tool/status +streaming. Do not use `/message_async` for the MVP broker path unless we later +need fully detached turns. + +## 2. Process Lifecycle + +Recommendation: shared server manager, not one process per agent. + +However, make it a server pool keyed by opencode runtime root, not a single hard-coded global forever: + +```text +OpencodeServerManager + key: (working_dir/config_root, port/password) + value: running opencode serve process + HTTP client + SSE dispatcher +``` + +For the current PinkyBot deployment, this will normally be one `opencode serve` process shared by all opencode agents, with one opencode `session_id` per PinkyBot streaming session label. + +Why not one process per agent: + +- More memory and process churn. +- More port management. +- Harder daemon startup/shutdown. +- Duplicates opencode's own session persistence. + +Why not one unconditional global: + +- PinkyBot agents can have different `working_dir` values. +- opencode config and file access are likely scoped to the directory where `opencode serve` starts. +- A single global process could accidentally collapse separate project boundaries. + +MVP lifecycle: + +1. `create_api()` initializes `OpencodeServerManager`. +2. On first opencode agent start, manager checks `/global/health`. +3. If no server is reachable and PinkyBot is configured to own it, manager spawns: + + ```text + opencode serve --hostname 127.0.0.1 --port + ``` + +4. Manager waits for `/global/health`. +5. Manager lazily regenerates generated opencode config if the agent/config + fingerprint is dirty. +6. Sessions call `POST /session` or reuse a persisted session id. +7. On daemon shutdown, manager terminates owned processes. + +Operational mode should be configurable: + +- `managed`: PinkyBot starts/stops opencode. Default for local installs. +- `external`: PinkyBot only connects to an existing opencode server. + +Generated config rewrite policy: + +- Store a fingerprint alongside each generated config containing agent runtime, + model, prompt hash, permission mode, materialized MCP config hash, provider + secret refs, and opencode manager version. +- Rewrite lazily on next session start when the fingerprint differs. +- Force-regenerate before `force_restart()`. +- Mark configs dirty on registry writes that affect runtime, model, soul, + boundaries, directives, permission mode, working directory, provider refs, or + skill/materialized MCP state. +- Directive updates should mark dirty through the directive write path, even + though the rewrite still happens lazily unless the agent is explicitly + restarted. +- Permission changes are security-sensitive: for live opencode sessions, + `permission_mode` writes should force-regenerate and require an immediate + session restart to guarantee the new edit/bash policy applies. If the restart + guard blocks, mark the session `restart_required` and surface a warning rather + than silently continuing under stale permissions. + +## 3. Auth Model + +Use both local bind and auth. + +Default: + +- Bind opencode to `127.0.0.1`. +- In managed mode, generate a new random password on every PinkyBot daemon + restart. Do not persist it to disk or DB. +- Pass it to the child process via `OPENCODE_SERVER_PASSWORD`. +- Store it only in memory on `OpencodeServerManager`. +- Use HTTP Basic auth for all REST and SSE requests. +- Expose the current generated password through an auth-gated PinkyBot daemon + diagnostics endpoint for admin/debug curl use cases. It should follow the + same auth posture as existing local admin/status endpoints such as + `/agents/{name}/streaming/status`, and should never be available without + PinkyBot admin auth. + +Do not run unauthenticated by default. Local-only without auth is convenient but brittle: localhost is still reachable by other local processes, browser extensions, and misconfigured reverse proxies. The overhead of random Basic auth is small and avoids a class of avoidable local privilege mistakes. + +For external mode: + +- Read password from a PinkyBot setting or environment variable, such as `PINKY_OPENCODE_SERVER_PASSWORD`. +- Never persist generated passwords to the agent DB. + +Decision trail: Brad chose regenerate-on-restart for managed mode on 2026-05-02. + +## 4. Streaming Protocol Mapping + +opencode exposes `/global/event` as SSE. PinkyBot already exposes its own UI SSE endpoint at: + +```text +GET /agents/{agent_name}/streaming/events +``` + +The mapping should be: + +```text +opencode /global/event + -> OpencodeServerManager single SSE reader + -> dispatch by opencode session_id + -> OpencodeSession._handle_event() + -> stream_event_callback() + -> PinkyBot /agents/{name}/streaming/events subscribers +``` + +The manager should own one SSE connection per server process, not one SSE connection per PinkyBot agent. It should parse events, filter/route by `session_id`, and send heartbeat/liveness updates to registered `OpencodeSession` objects. + +Event categories to normalize: + +- assistant text delta or completed assistant text -> `assistant_delta` +- tool started/completed -> `tool_use` +- command/file activity -> current activity/status labels +- usage/cost if available -> `StreamingTurnResult.model_usage` +- errors -> `turn_error` or `turn_failed` +- session created/resumed -> `_on_session_id(agent_name, session_id)` + +For broker responses, `OpencodeSession` should produce the same final `StreamingTurnResult` used by the current response callback. If opencode's `POST /session/:id/message` returns the completed assistant message, use that as the authoritative final response and use SSE for incremental UI only. If the message endpoint is fire-and-stream, accumulate final text from events and resolve the queued turn on the matching completion event. + +Heartbeat/resurrection fit: + +- PR #339-style resurrection still fits if `OpencodeSession.is_connected` reflects both HTTP health and SSE reader health. +- `attempt_reconnect()` should reconnect the HTTP client/SSE subscription, then verify `/global/health`. +- The watchdog must not restart a deliberately idle-sleeping session, same as `StreamingSession.is_idle_sleeping`. +- If the server process is down and PinkyBot owns it, `attempt_reconnect()` should ask `OpencodeServerManager` to restart the server before reattaching sessions. + +Important edge case: + +- A healthy shared server can coexist with one wedged opencode session. Recovery should first recreate the affected opencode session, not restart the whole server. Restarting the server should be reserved for failed `/global/health` or broken global SSE. + +## 5. Agent Registry Changes + +Recommendation: add an explicit `runtime` column to `agents`. + +Schema: + +```sql +ALTER TABLE agents ADD COLUMN runtime TEXT NOT NULL DEFAULT 'claude_sdk'; +``` + +Allowed values: + +- `claude_sdk` +- `codex_cli` +- `opencode` + +Migration behavior: + +- Existing rows default to `claude_sdk`. +- Run a one-shot boot-time data backfill: if an existing row has + `provider_url = 'codex_cli'`, set `runtime = 'codex_cli'`. +- After that backfill, runtime selection reads only `agents.runtime`. +- Leave `provider_url` stored for compatibility/debugging during the rollout, + but do not continue using it as a runtime selector. +- Remove any legacy `provider_url == 'codex_cli'` runtime shim after the rollout + window, target two weeks after deployment if no rollback is active. + +Update: + +- `Agent` dataclass +- `Agent.to_dict()` +- `_AGENT_COLUMNS` +- `AgentRegistry.register()` +- API create/update payloads +- frontend settings if the UI manages runtime/provider selection + +After the one-shot backfill has run, runtime selection in +`_start_streaming_session()` becomes: + +```python +runtime = agent.runtime or "claude_sdk" +if runtime == "codex_cli": + SessionClass = CodexSession +elif runtime == "opencode": + SessionClass = OpencodeSession +else: + SessionClass = StreamingSession +``` + +During the short rollout window only, a compatibility shim can protect DBs that +have not yet run the one-shot backfill: + +```python +def runtime_from_legacy_provider(agent): + if agent.provider_url == "codex_cli": + return "codex_cli" + return "claude_sdk" +``` + +Do not introduce opencode as another sentinel `provider_url` value. +`provider_url` should mean provider endpoint, not runtime. + +Rollback path: + +- UI: expose a runtime selector that can switch an agent from `opencode` back to + `claude_sdk`, then restart the streaming session. +- API/SQL fallback: + + ```sql + UPDATE agents SET runtime='claude_sdk', updated_at=? WHERE name=?; + ``` + +- On rollback, disconnect the live opencode session, leave the opencode + `session_id` persisted only for audit/debugging if needed, and start a fresh + Claude SDK session with normal wake context. + +## 6. Config Injection + +Use PinkyBot's existing system prompt builder as the source of truth. + +For an opencode agent, generate an opencode config block from PinkyBot agent state: + +```json +{ + "agent": { + "barsik": { + "description": "Barsik - Personal AI Sidekick", + "mode": "primary", + "model": "deepseek/deepseek-v4-pro", + "prompt": "", + "permission": { + "edit": "allow", + "bash": "allow" + } + } + } +} +``` + +Mapping: + +- `soul`, boundaries, users, active directives, skill catalog, and generated messaging instructions still flow through `agents.build_system_prompt(agent_name, skill_store=skills)`. +- `wake_context` is not baked into opencode config. It should be sent as the first prompt on `connect()`, matching `StreamingSession` and `CodexSession`. +- Permission mapping should be conservative: + - Pinky `bypassPermissions` or `auto` -> opencode `allow` for edit/bash. + - Pinky `default`, `acceptEdits`, or unknown -> opencode `ask` initially. + - Pinky `plan` -> opencode `deny` for edit/bash. +- Runtime permission changes require config regeneration and session restart, as + described in the lifecycle section. Do not assume an in-place config reload is + sufficient for active opencode turns until verified in implementation. + +MCP config: + +- Prefer opencode native MCP support where possible. +- Generate MCP entries from the same `skills.materialize_for_agent()` result used today. +- In shared MCP mode, pass Pinky shared MCP HTTP endpoints with `X-Agent-Name`. +- Technical verification: generated opencode types for remote MCP config include + `headers`, so Pinky shared MCP identity headers can be represented in config. +- Even with header support, the first implementation should prefer generated + config roots over mutating a user's hand-written `opencode.json`. + +Config file ownership: + +- PinkyBot should generate runtime config into a Pinky-owned cache path, not mutate the user's hand-written `opencode.json` directly. +- Suggested path: + + ```text + data/opencode/{server_key}/opencode.json + ``` + +- Include a header/comment-equivalent in adjacent metadata that says it is generated. + +Config regeneration triggers are intentionally centralized in +`OpencodeServerManager`. Individual registry write paths should mark agent config +dirty; the manager decides whether to rewrite immediately or lazily. This avoids +duplicating opencode config rendering across agent CRUD, directive writes, skill +assignment, and session startup. + +## 7. Provider/Model Config Flow + +User-facing model selection should stay in PinkyBot. + +Recommended fields: + +- `agents.runtime = 'opencode'` +- `agents.model = 'deepseek/deepseek-v4-pro'` or a selected row from `models` +- `providers` table can still store display/provider presets, but for opencode the effective output is an opencode model id string. + +Do not make users edit generated `opencode.json`. + +Flow: + +1. User selects runtime: `opencode`. +2. UI lists opencode-compatible provider/model ids. This can initially be manually seeded in PinkyBot's `models` table, then later refreshed from opencode/models.dev if desired. +3. User selects `DeepSeek V4-Pro`. +4. Pinky stores the chosen model id in `agent.model` or `provider_model`. +5. `_start_streaming_session()` passes `effective_model` to `OpencodeSession`. +6. `OpencodeServerManager` regenerates config if agent prompt/model/permissions/MCP config changed. + +Secrets: + +- Phase 1 provider secrets must use scoped environment variables only. +- Technical verification: opencode config supports secret substitution from env + and file values, and provider config supports `options.apiKey`. +- Generated opencode config should reference environment variables rather than + embedding raw provider keys or file substitutions. +- The manager should launch opencode with a scoped environment containing only + provider keys required by opencode agents attached to that server root. +- opencode also exposes `/auth/{providerID}` for setting auth credentials; keep + that as a flagged future path, not a Phase 1 path. +- Do not write provider keys into generated config files in Phase 1. + +Decision trail: Brad chose scoped env vars for Phase 1 on 2026-05-02. + +## 8. Opencode Runtime Dependency + +Treat opencode as an optional runtime dependency, not a hard PinkyBot install +requirement for agents that do not use the opencode runtime. + +Install detection: + +- `opencode --version` +- `PINKY_OPENCODE_CMD` can override the executable path for dev/test. + +Mac Mini / production deploy path: + +- Install with npm, pinned to a deliberate version: + + ```text + npm install -g opencode-ai@ + ``` + +- Pin the version in PinkyBot deploy config alongside other runtime/SDK pins. +- Bump deliberately through `update_and_restart`/deploy changes, not silently. +- Expected result: `opencode` is on `PATH`. +- `PINKY_OPENCODE_CMD` remains available for development and test overrides. + +Linux/Pi/Mini: + +- Use the same npm-pinned install path where Node/npm is available. +- If npm install is unavailable on a target, keep opencode disabled with a clear + health error until Brad approves an alternate path: + + ```text + Opencode runtime unavailable: opencode executable not found. Install opencode or configure PINKY_OPENCODE_CMD. + ``` + +Non-Phase-1 alternatives: + +- Homebrew is not the Mac Mini path because the available formula was stale + during review. +- `bunx` is useful for local verification but not the production deploy path; + Brad does not want Bun as a platform dependency when Node is already present. +- curl-bash install paths are out for Phase 1 because they fight reproducibility + and version pinning. + +Configuration: + +- `PINKY_OPENCODE_CMD`: override executable path. +- `PINKY_OPENCODE_MODE=managed|external`. +- `PINKY_OPENCODE_URL`: external server URL. +- `PINKY_OPENCODE_SERVER_PASSWORD`: external password. + +Rollout should not break existing Claude/Codex installs when opencode is missing. + +Decision trail: Brad chose npm global install with pinned version for the Mac +Mini deployment path on 2026-05-02. + +## 9. Test Plan + +Unit tests: + +- REST client builds Basic auth headers. +- REST client handles `/global/health` healthy/unhealthy. +- Session creation persists returned `session_id` through `_on_session_id`. +- `send()` accepts `agent_hint` and appends it only to the runtime prompt. +- `send()` uses the wait-for-completion message endpoint for MVP. +- `send()` stores raw prompt in `ConversationStore`. +- SSE dispatcher routes events by opencode `session_id`. +- Event normalization maps assistant/tool/error/completion events into PinkyBot stream events. +- Cost/tokens are extracted from assistant message or step-finish data when present. +- `force_restart()` clears persisted session id and creates a new opencode session. +- `idle_sleep()` preserves session id and sets idle sleeping. +- `attempt_reconnect()` restarts/reconnects via manager when health/SSE fails. +- Runtime selection chooses `OpencodeSession` only when `agent.runtime == "opencode"`. +- Boot-time backfill maps legacy `provider_url == "codex_cli"` to `runtime = "codex_cli"`. + +Integration tests: + +- Fixture starts `opencode serve` on a random localhost port with a random password. +- Test creates a temporary agent config and session. +- Test sends a trivial prompt to `/session/:id/message`. +- Test consumes `/global/event` until assistant completion or timeout. +- Test verifies Pinky `StreamingTurnResult` callback receives final response. +- Test verifies `/agents/{name}/streaming/events` emits normalized events. + +Smoke test: + +```text +1. Start PinkyBot with PINKY_OPENCODE_MODE=managed. +2. Create agent `deepseek-test` with runtime=opencode and model=deepseek/deepseek-v4-pro. +3. Send "Reply with exactly pong." +4. Verify Telegram/web response is "pong" or provider-equivalent. +5. Kill opencode serve. +6. Verify watchdog recovery restarts/reconnects without losing the PinkyBot process. +``` + +CI gating: + +- Unit tests always run. +- Main CI should include an opencode integration job rather than silently + skipping forever. The job should install the pinned opencode version with npm + or use a Docker fixture that contains that same pinned version, then launch + `opencode serve` on a random localhost port. +- Local developer runs may skip integration tests if opencode or npm is missing, + but `main` should exercise the real server path. +- If the team chooses not to run opencode integration in CI, document that as an + accepted risk in the PR and keep a manual smoke checklist mandatory before + enabling `PINKY_ENABLE_OPENCODE` in production. + +## 10. Migration And Rollout + +Phase 0: spec and review. + +Phase 1: hidden backend runtime. + +- Add `runtime` column. +- Add `OpencodeServerManager`. +- Add REST client. +- Add `OpencodeSession`. +- Add runtime selection behind feature flag: + + ```text + PINKY_ENABLE_OPENCODE=1 + ``` + +- No UI by default. + +Phase 2: local dogfood. + +- Enable one test agent. +- Keep Claude/Codex agents unchanged. +- Track reliability: + - send latency + - turn completion rate + - SSE reconnects + - watchdog recoveries + - tool-call success + - reported cost/tokens coverage + +Circuit breaker: + +- Track opencode runtime errors per agent and globally: + - failed sends + - reconnect attempts + - `force_restart()` calls + - watchdog recoveries + - global SSE disconnects +- If an opencode agent exceeds 3 forced restarts in 1 hour, disable new sends to + that opencode session, mark it unhealthy, and alert Brad through the existing + owner notification path. +- If all opencode agents together exceed 10 forced restarts or watchdog + recoveries in 1 hour, automatically flip the process-level opencode feature + gate to disabled for new session starts until manual intervention. +- Circuit breaker rollback should not rewrite agent runtime fields. It should + stop starting new opencode sessions and tell the operator to switch affected + agents back to `claude_sdk` or `codex_cli`. + +Cost visibility: + +- Verified opencode types expose cost/tokens on assistant message and + step-finish data, so the implementation should wire those into + `StreamingTurnResult.total_cost_usd`, `model_usage`, and analytics. +- If a provider/event path does not return cost, do not silently report `$0`. + Mark cost as unknown in session stats/analytics and surface a warning in + diagnostics. `$0` is acceptable only when opencode explicitly reports zero. + +Phase 3: UI support. + +- Add runtime selector. +- Add opencode model selector. +- Show opencode server health in diagnostics. +- Make install/runtime errors actionable. + +Phase 4: optional migration. + +- Do not auto-migrate agents. +- Offer manual clone/migrate: + + ```text + Clone agent as opencode runtime + ``` + +- Keep `codex_session.py` and `streaming_session.py` indefinitely until real usage shows opencode can replace them. + +## Open Questions + +1. What is the exact generated config root strategy for multiple PinkyBot + `working_dir` values: one opencode server per root, or a shared server with + per-session directory metadata? The spec assumes a manager pool keyed by + runtime root until implementation verifies opencode's directory isolation. +2. Does opencode apply regenerated agent config to an existing session in place, + or is a fresh opencode session always required? The spec assumes restart for + permission changes until proven otherwise. +3. Which exact opencode event names should be treated as terminal for a + completed assistant turn? Generated types show `session.idle` and + message/part events, but implementation should confirm the most reliable + completion signal from `/doc` or a fixture trace. +4. How should opencode slash commands map to PinkyBot admin actions, if at all? Initial implementation should keep them internal to opencode. + +## Non-Goals + +- Replacing existing Claude SDK or Codex runtime in the first implementation. +- Reworking PinkyBot's memory model. +- Moving provider secrets into generated opencode config unless unavoidable. +- UI polish beyond basic runtime/model selection during initial backend integration. +- Any implementation in this spec branch. + +## References + +- Official server docs: +- Official config docs: +- Official agents docs: +- Generated SDK types checked during verification: + From 6a1bd7b9d8641c9b7cbb5df89cc51a9aa56a3f9d Mon Sep 17 00:00:00 2001 From: Brad Brok Date: Sun, 3 May 2026 00:37:40 +0400 Subject: [PATCH 02/24] Refactor Codex context estimation (#357) Co-authored-by: Oleg --- src/pinky_daemon/codex_session.py | 41 +++++---------- src/pinky_daemon/context_estimator.py | 70 ++++++++++++++++++++++++++ tests/test_context_estimator.py | 72 +++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 29 deletions(-) create mode 100644 src/pinky_daemon/context_estimator.py create mode 100644 tests/test_context_estimator.py diff --git a/src/pinky_daemon/codex_session.py b/src/pinky_daemon/codex_session.py index 1a0e84e6..f1b771c0 100644 --- a/src/pinky_daemon/codex_session.py +++ b/src/pinky_daemon/codex_session.py @@ -23,7 +23,8 @@ import time from dataclasses import dataclass, field -from pinky_daemon.sessions import CHARS_PER_TOKEN, MODEL_CONTEXT_SIZES, SessionUsage +from pinky_daemon.context_estimator import ContextTextEstimator +from pinky_daemon.sessions import MODEL_CONTEXT_SIZES, SessionUsage from pinky_daemon.streaming_session import ( StreamingSessionConfig, StreamingTurnResult, @@ -96,7 +97,7 @@ def __init__( self.account_info: dict = {"apiProvider": "codex_cli"} self._on_session_id = None # async fn(agent_name, session_id) self._pending_session_id_update = "" # Set by sync _handle_event, consumed by async worker - self._internal_context_texts: list[str] = [] + self._context_estimator = ContextTextEstimator() self._current_turn_seq = 0 self._last_user_message = "" # For analytics keyword classification @@ -877,21 +878,9 @@ def max_tokens(self) -> int: @property def estimated_tokens(self) -> int: """Estimate current context size from persisted chat plus internal prompts.""" - total = sum( - max(1, len(text) // CHARS_PER_TOKEN) - for text in self._internal_context_texts - if text - ) - if not self._conversation_store: - return total - try: - history = self._conversation_store.get_history(self.id, limit=1000) - except Exception: - return total - return total + sum( - max(1, len(msg.content) // CHARS_PER_TOKEN) - for msg in history - if msg.content + return self._context_estimator.estimated_tokens( + session_id=self.id, + conversation_store=self._conversation_store, ) @property @@ -902,16 +891,11 @@ def context_used_pct(self) -> float: def get_context_info(self) -> dict: """Best-effort context info for APIs that expect session context details.""" - total = self.estimated_tokens - max_tokens = self.max_tokens - pct = round(total / max_tokens * 100, 1) if max_tokens > 0 else 0.0 - return { - "total_tokens": total, - "max_tokens": max_tokens, - "percentage": pct, - "categories": [], - "mcp_tools": [], - } + return self._context_estimator.context_info( + session_id=self.id, + conversation_store=self._conversation_store, + max_tokens=self.max_tokens, + ) @property def stats(self) -> dict: @@ -934,8 +918,7 @@ def id(self) -> str: def _record_internal_context_text(self, text: str) -> None: """Track prompts/responses that do not appear in the conversation store.""" - if text: - self._internal_context_texts.append(text) + self._context_estimator.record_internal_text(text) def _analytics_session_started(self) -> None: if not self._analytics_store: diff --git a/src/pinky_daemon/context_estimator.py b/src/pinky_daemon/context_estimator.py new file mode 100644 index 00000000..62803000 --- /dev/null +++ b/src/pinky_daemon/context_estimator.py @@ -0,0 +1,70 @@ +"""Shared context-size estimation helpers for non-SDK runtimes.""" + +from __future__ import annotations + +from pinky_daemon.sessions import CHARS_PER_TOKEN + + +class ContextTextEstimator: + """Estimate context from internal prompts plus persisted conversation text. + + Runtimes that do not expose authoritative context usage can share this + best-effort estimator instead of duplicating token-counting fallback code. + """ + + def __init__(self, *, chars_per_token: int = CHARS_PER_TOKEN) -> None: + self._chars_per_token = max(1, chars_per_token) + self._internal_context_texts: list[str] = [] + + def record_internal_text(self, text: str) -> None: + """Track prompt/response text that is not in ConversationStore.""" + if text: + self._internal_context_texts.append(text) + + def estimated_tokens( + self, + *, + session_id: str, + conversation_store=None, + history_limit: int = 1000, + ) -> int: + """Estimate tokens from internal text and optional conversation history.""" + total = sum(self._estimate_text_tokens(text) for text in self._internal_context_texts) + if not conversation_store: + return total + try: + history = conversation_store.get_history(session_id, limit=history_limit) + except Exception: + return total + return total + sum( + self._estimate_text_tokens(getattr(msg, "content", "")) + for msg in history + ) + + def context_info( + self, + *, + session_id: str, + max_tokens: int, + conversation_store=None, + history_limit: int = 1000, + ) -> dict: + """Return the context-info shape expected by streaming status APIs.""" + total = self.estimated_tokens( + session_id=session_id, + conversation_store=conversation_store, + history_limit=history_limit, + ) + pct = round(total / max_tokens * 100, 1) if max_tokens > 0 else 0.0 + return { + "total_tokens": total, + "max_tokens": max_tokens, + "percentage": pct, + "categories": [], + "mcp_tools": [], + } + + def _estimate_text_tokens(self, text: str) -> int: + if not text: + return 0 + return max(1, len(text) // self._chars_per_token) diff --git a/tests/test_context_estimator.py b/tests/test_context_estimator.py new file mode 100644 index 00000000..8286d403 --- /dev/null +++ b/tests/test_context_estimator.py @@ -0,0 +1,72 @@ +"""Tests for shared context estimation helpers.""" + +from __future__ import annotations + +from types import SimpleNamespace + +from pinky_daemon.context_estimator import ContextTextEstimator + + +class _Store: + def __init__(self, *contents: str) -> None: + self.contents = contents + self.calls: list[tuple[str, int]] = [] + + def get_history(self, session_id: str, limit: int = 1000): + self.calls.append((session_id, limit)) + return [SimpleNamespace(content=content) for content in self.contents] + + +class _BrokenStore: + def get_history(self, session_id: str, limit: int = 1000): + raise RuntimeError("db unavailable") + + +def test_estimates_internal_texts(): + estimator = ContextTextEstimator(chars_per_token=4) + + estimator.record_internal_text("") + estimator.record_internal_text("abcd") + estimator.record_internal_text("abcdefghi") + + assert estimator.estimated_tokens(session_id="s") == 3 + + +def test_estimates_store_history_with_internal_texts(): + estimator = ContextTextEstimator(chars_per_token=4) + estimator.record_internal_text("abcdefgh") + store = _Store("abcd", "", "abcdefghijkl") + + total = estimator.estimated_tokens( + session_id="agent-main", + conversation_store=store, + history_limit=50, + ) + + assert total == 6 + assert store.calls == [("agent-main", 50)] + + +def test_returns_internal_estimate_when_store_read_fails(): + estimator = ContextTextEstimator(chars_per_token=4) + estimator.record_internal_text("abcdefgh") + + assert estimator.estimated_tokens( + session_id="agent-main", + conversation_store=_BrokenStore(), + ) == 2 + + +def test_context_info_shape_and_percentage(): + estimator = ContextTextEstimator(chars_per_token=4) + estimator.record_internal_text("abcdefgh") + + info = estimator.context_info(session_id="agent-main", max_tokens=10) + + assert info == { + "total_tokens": 2, + "max_tokens": 10, + "percentage": 20.0, + "categories": [], + "mcp_tools": [], + } From 075b28dc82804df2f4ef9168fe26ec769bda8503 Mon Sep 17 00:00:00 2001 From: Brad Brok Date: Sun, 3 May 2026 00:50:19 +0400 Subject: [PATCH 03/24] Add agent runtime column (#358) Co-authored-by: Oleg --- src/pinky_daemon/agent_registry.py | 62 +++++++++++++++++++----- src/pinky_daemon/api.py | 56 ++++++++++++++++++++-- src/pinky_daemon/scheduler.py | 3 ++ tests/test_agent_registry.py | 30 ++++++++++++ tests/test_api.py | 76 +++++++++++++++++++++++++++++- 5 files changed, 208 insertions(+), 19 deletions(-) diff --git a/src/pinky_daemon/agent_registry.py b/src/pinky_daemon/agent_registry.py index 95a44880..f2dbd006 100644 --- a/src/pinky_daemon/agent_registry.py +++ b/src/pinky_daemon/agent_registry.py @@ -185,6 +185,7 @@ class Agent: working_status: str = "idle" # idle, working, offline working_status_updated_at: float = 0.0 # When working_status last changed last_seen_at: float = 0.0 # Server-side presence: updated on delivery/turn completion + runtime: str = "claude_sdk" # Agent runtime: claude_sdk, codex_cli, opencode provider_url: str = "" # e.g. "http://localhost:11434" for Ollama, empty = Anthropic default provider_key: str = "" # API key override, empty = use ANTHROPIC_API_KEY env var provider_model: str = "" # model name override (e.g. "llama3.2"), empty = use agent.model @@ -243,6 +244,7 @@ def to_dict(self) -> dict: "working_status": self.working_status, "working_status_updated_at": self.working_status_updated_at, "last_seen_at": self.last_seen_at, + "runtime": self.runtime, "provider_url": self.provider_url, "provider_key": self.provider_key, "provider_model": self.provider_model, @@ -475,6 +477,7 @@ def _init_tables(self) -> None: heartbeat_interval INTEGER NOT NULL DEFAULT 0, plain_text_fallback INTEGER NOT NULL DEFAULT 0, role TEXT NOT NULL DEFAULT '', + runtime TEXT NOT NULL DEFAULT 'claude_sdk', created_at REAL NOT NULL, updated_at REAL NOT NULL ); @@ -729,11 +732,14 @@ def _migrate(self) -> None: ("thinking_effort", "TEXT NOT NULL DEFAULT 'medium'"), ("watchdog_config", "TEXT NOT NULL DEFAULT '{}'"), ("last_seen_at", "REAL NOT NULL DEFAULT 0"), + ("runtime", "TEXT NOT NULL DEFAULT 'claude_sdk'"), ] for col, typedef in migrations: if col not in existing: self._db.execute(f"ALTER TABLE agents ADD COLUMN {col} {typedef}") _log(f"agent_registry: migrated — added column {col}") + self._db.commit() + self._backfill_runtime_from_provider_url() # Migrate agent_schedules table sched_existing = { @@ -804,6 +810,25 @@ def _migrate(self) -> None: # Seed default models self._seed_models() + def _backfill_runtime_from_provider_url(self) -> None: + """One-shot migration from legacy provider_url runtime selection.""" + marker = "migration:agents_runtime_codex_cli_backfill" + if self.get_setting(marker) == "1": + return + + cursor = self._db.execute( + "UPDATE agents SET runtime='codex_cli' " + "WHERE provider_url='codex_cli' AND runtime='claude_sdk'" + ) + self._db.execute( + "INSERT INTO system_settings (key, value) VALUES (?, '1') " + "ON CONFLICT(key) DO UPDATE SET value='1'", + (marker,), + ) + self._db.commit() + if cursor.rowcount: + _log(f"agent_registry: backfilled runtime=codex_cli for {cursor.rowcount} agent(s)") + # ── Workspace Init ───────────────────────────────────── @staticmethod @@ -932,7 +957,7 @@ def register(self, name: str, **kwargs) -> Agent: "clock_aligned", "auto_sleep_hours", "plain_text_fallback", "voice_config", "role", "dream_enabled", "dream_schedule", "dream_timezone", "dream_model", "dream_notify", "librarian_enabled", "librarian_schedule", - "provider_url", "provider_key", "provider_model", "provider_ref", + "runtime", "provider_url", "provider_key", "provider_model", "provider_ref", "thinking_effort"): if key in kwargs: updates[key] = kwargs[key] @@ -1014,6 +1039,12 @@ def register(self, name: str, **kwargs) -> Agent: dream_notify=kwargs.get("dream_notify", True), librarian_enabled=kwargs.get("librarian_enabled", False), librarian_schedule=kwargs.get("librarian_schedule", "0 4 * * *"), + runtime=kwargs.get("runtime", "claude_sdk"), + provider_url=kwargs.get("provider_url", ""), + provider_key=kwargs.get("provider_key", ""), + provider_model=kwargs.get("provider_model", ""), + provider_ref=kwargs.get("provider_ref", ""), + thinking_effort=kwargs.get("thinking_effort", "medium"), watchdog_config=kwargs.get("watchdog_config", {}), created_at=now, updated_at=now, @@ -1027,9 +1058,11 @@ def register(self, name: str, **kwargs) -> Agent: max_sessions, enabled, auto_start, heartbeat_interval, plain_text_fallback, wake_interval, clock_aligned, auto_sleep_hours, voice_config, role, dream_enabled, dream_schedule, dream_timezone, dream_model, dream_notify, - librarian_enabled, librarian_schedule, watchdog_config, + librarian_enabled, librarian_schedule, + runtime, provider_url, provider_key, provider_model, provider_ref, + thinking_effort, watchdog_config, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (agent.name, agent.display_name, agent.model, agent.soul, agent.users, agent.boundaries, agent.system_prompt, agent.working_dir, agent.permission_mode, @@ -1042,7 +1075,9 @@ def register(self, name: str, **kwargs) -> Agent: json.dumps(agent.voice_config), agent.role, int(agent.dream_enabled), agent.dream_schedule, agent.dream_timezone, agent.dream_model, int(agent.dream_notify), int(agent.librarian_enabled), agent.librarian_schedule, - json.dumps(agent.watchdog_config), + agent.runtime, agent.provider_url, agent.provider_key, + agent.provider_model, agent.provider_ref, + agent.thinking_effort, json.dumps(agent.watchdog_config), agent.created_at, agent.updated_at), ) self._db.commit() @@ -1060,7 +1095,7 @@ def register(self, name: str, **kwargs) -> Agent: "dream_enabled, dream_schedule, dream_timezone, dream_model, dream_notify, " "librarian_enabled, librarian_schedule, " "working_status, working_status_updated_at, " - "provider_url, provider_key, provider_model, provider_ref, " + "runtime, provider_url, provider_key, provider_model, provider_ref, " "disallowed_tools, thinking_effort, watchdog_config, last_seen_at" ) @@ -2584,14 +2619,15 @@ def _row_to_agent(self, row: tuple) -> Agent: librarian_schedule=row[36] if len(row) > 36 and row[36] else "0 4 * * *", working_status=row[37] if len(row) > 37 and row[37] else "idle", working_status_updated_at=row[38] if len(row) > 38 else 0.0, - provider_url=row[39] if len(row) > 39 and row[39] else "", - provider_key=row[40] if len(row) > 40 and row[40] else "", - provider_model=row[41] if len(row) > 41 and row[41] else "", - provider_ref=row[42] if len(row) > 42 and row[42] else "", - disallowed_tools=json.loads(row[43]) if len(row) > 43 and row[43] else [], - thinking_effort=row[44] if len(row) > 44 and row[44] else "medium", - watchdog_config=json.loads(row[45]) if len(row) > 45 and row[45] else {}, - last_seen_at=row[46] if len(row) > 46 else 0.0, + runtime=row[39] if len(row) > 39 and row[39] else "claude_sdk", + provider_url=row[40] if len(row) > 40 and row[40] else "", + provider_key=row[41] if len(row) > 41 and row[41] else "", + provider_model=row[42] if len(row) > 42 and row[42] else "", + provider_ref=row[43] if len(row) > 43 and row[43] else "", + disallowed_tools=json.loads(row[44]) if len(row) > 44 and row[44] else [], + thinking_effort=row[45] if len(row) > 45 and row[45] else "medium", + watchdog_config=json.loads(row[46]) if len(row) > 46 and row[46] else {}, + last_seen_at=row[47] if len(row) > 47 else 0.0, ) # ── Cost Tracking ────────────────────────────────────── diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index d5535d8c..202d4a6e 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -447,6 +447,11 @@ class RegisterAgentRequest(BaseModel): auto_start: bool = False role: str = "" heartbeat_interval: int = 0 + runtime: str = "claude_sdk" + provider_url: str = "" # ANTHROPIC_BASE_URL override (e.g. Ollama endpoint) + provider_key: str = "" # ANTHROPIC_API_KEY override + provider_model: str = "" # Model name override for this provider + provider_ref: str = "" # ID of a global provider from the providers table thinking_effort: str = "medium" # low/medium/high/xhigh/max watchdog_config: dict | None = None # Per-agent watchdog overrides @@ -480,9 +485,11 @@ class UpdateAgentRequest(BaseModel): dream_timezone: str | None = None # IANA timezone for dream schedule dream_model: str | None = None # Model override for dream runs (empty = agent's model) dream_notify: bool | None = None # Inject dream summary into morning wake context + runtime: str | None = None # Runtime selector: claude_sdk, codex_cli, opencode provider_url: str | None = None # ANTHROPIC_BASE_URL override (e.g. Ollama endpoint) provider_key: str | None = None # ANTHROPIC_API_KEY override provider_model: str | None = None # Model name override for this provider + provider_ref: str | None = None # ID of a global provider from the providers table thinking_effort: str | None = None # low/medium/high/xhigh/max watchdog_config: dict | None = None # Per-agent watchdog overrides @@ -2447,12 +2454,47 @@ async def _start_streaming_session( # Deduplicate effective_disallowed = sorted(set(effective_disallowed)) - resolved_provider_url, resolved_provider_key, resolved_provider_model = _resolve_agent_provider(agent) + def runtime_from_legacy_provider(agent_config) -> str: + """Deprecated temporary shim for the runtime rollout. + + Runtime selection now belongs to agents.runtime. This fallback exists + only for legacy rows that have not passed the one-shot codex_cli + provider_url backfill yet, and should be removed after rollout. + """ + runtime = (getattr(agent_config, "runtime", "") or "").strip() + if runtime: + return runtime + if (getattr(agent_config, "provider_url", "") or "").strip() == "codex_cli": + return "codex_cli" + return "claude_sdk" + + runtime = runtime_from_legacy_provider(agent) + if runtime == "opencode": + if os.environ.get("PINKY_ENABLE_OPENCODE", "0") == "1": + msg = "opencode runtime is enabled but OpencodeSession is not implemented yet" + status = 501 + else: + msg = "opencode runtime is disabled; set PINKY_ENABLE_OPENCODE=1 after implementation lands" + status = 503 + _log(f"api: refusing to start {agent_name}: {msg}") + raise HTTPException(status, msg) + if runtime not in {"claude_sdk", "codex_cli"}: + msg = f"unknown runtime '{runtime}' for agent '{agent_name}'" + _log(f"api: {msg}") + raise HTTPException(400, msg) + + is_codex = runtime == "codex_cli" + if is_codex: + resolved_provider_url = "codex_cli" + resolved_provider_key = agent.provider_key or "" + resolved_provider_model = agent.provider_model or "" + else: + resolved_provider_url, resolved_provider_key, resolved_provider_model = _resolve_agent_provider(agent) effective_model = resolved_provider_model or agent.model # Build MCP server config for Codex agents (injected via -c flags) codex_mcp_servers = {} - if resolved_provider_url == "codex_cli" and SHARED_MCP_ENABLED: + if is_codex and SHARED_MCP_ENABLED: shared_base = f"http://{SHARED_MCP_HOST}:{SHARED_MCP_PORT}" agent_headers = {"X-Agent-Name": agent_name} for srv_name in ("self", "memory", "messaging"): @@ -2488,8 +2530,7 @@ async def _start_streaming_session( callback = await _make_streaming_response_callback() sid_callback = await _make_streaming_session_id_callback(agent_name, label) - # Select session class based on provider type - is_codex = resolved_provider_url == "codex_cli" + # Select session class based on the persisted runtime. SessionClass = CodexSession if is_codex else StreamingSession # noqa: N806 init_kwargs = { @@ -2514,7 +2555,7 @@ async def _start_streaming_session( session_id=ss.id, agent_name=agent_name, session_label=label, - provider=resolved_provider_url or "default", + provider=runtime if is_codex else (resolved_provider_url or "default"), model=effective_model or "", ) analytics.log_activity( @@ -5244,6 +5285,11 @@ async def register_agent(req: RegisterAgentRequest): auto_start=req.auto_start, role=req.role, heartbeat_interval=req.heartbeat_interval, + runtime=req.runtime, + provider_url=req.provider_url, + provider_key=req.provider_key, + provider_model=req.provider_model, + provider_ref=req.provider_ref, thinking_effort=req.thinking_effort, watchdog_config=req.watchdog_config or {}, ) diff --git a/src/pinky_daemon/scheduler.py b/src/pinky_daemon/scheduler.py index c9c0bff6..e5603fb1 100644 --- a/src/pinky_daemon/scheduler.py +++ b/src/pinky_daemon/scheduler.py @@ -35,6 +35,9 @@ def _log(msg: str) -> None: def _is_claude_code_agent(agent, registry: AgentRegistry) -> bool: """Return True if this agent runs on Claude Code (not Codex or other provider).""" + runtime = (getattr(agent, "runtime", "") or "").strip() + if runtime: + return runtime == "claude_sdk" try: from pinky_daemon.api import resolve_provider_config url, _, _ = resolve_provider_config( diff --git a/tests/test_agent_registry.py b/tests/test_agent_registry.py index 286d7f43..7cd62e4a 100644 --- a/tests/test_agent_registry.py +++ b/tests/test_agent_registry.py @@ -26,6 +26,7 @@ def test_register(self, registry): assert agent.name == "oleg" assert agent.display_name == "Oleg" assert agent.model == "opus" + assert agent.runtime == "claude_sdk" assert agent.enabled is True assert agent.created_at > 0 @@ -44,6 +45,7 @@ def test_register_with_full_config(self, registry, tmp_path): parent="oleg", groups=["butter-team"], max_sessions=3, + runtime="codex_cli", ) assert agent.model == "sonnet" assert agent.soul == "# Leo the Worker" @@ -51,6 +53,7 @@ def test_register_with_full_config(self, registry, tmp_path): assert agent.parent == "oleg" assert agent.groups == ["butter-team"] assert agent.max_sessions == 3 + assert agent.runtime == "codex_cli" def test_register_update(self, registry): registry.register("oleg", model="sonnet") @@ -126,8 +129,35 @@ def test_to_dict(self, registry): assert d["name"] == "test" assert d["display_name"] == "Test Agent" assert d["model"] == "opus" + assert d["runtime"] == "claude_sdk" assert d["enabled"] is True + def test_runtime_column_exists_with_default(self, registry): + columns = { + row[1]: row + for row in registry._db.execute("PRAGMA table_info(agents)").fetchall() + } + assert "runtime" in columns + assert columns["runtime"][4] == "'claude_sdk'" + + agent = registry.register("runtime-default") + assert agent.runtime == "claude_sdk" + + def test_runtime_codex_cli_backfill_is_one_shot_and_idempotent(self, registry): + registry.register("legacy-codex", provider_url="codex_cli", runtime="claude_sdk") + registry.register("explicit-claude", provider_url="codex_cli", runtime="claude_sdk") + + marker = "migration:agents_runtime_codex_cli_backfill" + registry.set_setting(marker, "") + registry._backfill_runtime_from_provider_url() + assert registry.get("legacy-codex").runtime == "codex_cli" + assert registry.get("explicit-claude").runtime == "codex_cli" + assert registry.get_setting(marker) == "1" + + registry.register("explicit-claude", runtime="claude_sdk") + registry._backfill_runtime_from_provider_url() + assert registry.get("explicit-claude").runtime == "claude_sdk" + def test_stamp_last_seen_updates_column(self, registry): registry.register("seen") agent = registry.get("seen") diff --git a/tests/test_api.py b/tests/test_api.py index e07e9051..3a4bcdc0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -600,6 +600,63 @@ async def fake_send(self, prompt: str, platform: str = "", chat_id: str = ""): assert app.state.broker._streaming["test-agent"]["main"].is_connected is True assert sent_prompts[-1][1] == "Wake up" + def test_wake_uses_streaming_session_for_claude_runtime(self): + async def fake_connect(self): + self._connected = True + + async def fake_send(self, prompt: str, platform: str = "", chat_id: str = ""): + del prompt, platform, chat_id + + with tempfile.TemporaryDirectory() as tmpdir, \ + patch("pinky_daemon.streaming_session.StreamingSession.connect", new=fake_connect), \ + patch("pinky_daemon.streaming_session.StreamingSession.send", new=fake_send): + db_path = os.path.join(tmpdir, "test.db") + app = self._make_app(db_path) + with TestClient(app) as client: + client.post("/agents", json={"name": "claude-agent", "model": "sonnet", "runtime": "claude_sdk"}) + + resp = client.post("/agents/claude-agent/wake?prompt=Wake") + assert resp.status_code == 200 + + session = app.state.broker._streaming["claude-agent"]["main"] + assert session.__class__.__name__ == "StreamingSession" + + def test_wake_uses_codex_session_for_codex_runtime(self): + async def fake_connect(self): + self._connected = True + self.session_id = self.session_id or f"{self.agent_name}-codex" + if self._on_session_id: + await self._on_session_id(self.agent_name, self.session_id) + + async def fake_send(self, prompt: str, platform: str = "", chat_id: str = "", message_id: str = "", agent_hint: str = ""): + del prompt, platform, chat_id, message_id, agent_hint + + with tempfile.TemporaryDirectory() as tmpdir, \ + patch("pinky_daemon.codex_session.CodexSession.connect", new=fake_connect), \ + patch("pinky_daemon.codex_session.CodexSession.send", new=fake_send): + db_path = os.path.join(tmpdir, "test.db") + app = self._make_app(db_path) + with TestClient(app) as client: + client.post("/agents", json={"name": "codex-agent", "model": "gpt-5-codex", "runtime": "codex_cli"}) + + resp = client.post("/agents/codex-agent/wake?prompt=Wake") + assert resp.status_code == 200 + + session = app.state.broker._streaming["codex-agent"]["main"] + assert session.__class__.__name__ == "CodexSession" + assert session._config.provider_url == "codex_cli" + + def test_wake_rejects_opencode_runtime_until_session_exists(self): + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + app = self._make_app(db_path) + with TestClient(app) as client: + client.post("/agents", json={"name": "opencode-agent", "model": "deepseek-v4", "runtime": "opencode"}) + + resp = client.post("/agents/opencode-agent/wake?prompt=Wake") + assert resp.status_code == 503 + assert "opencode runtime is disabled" in resp.text + def test_streaming_restart_requires_explicit_current_session_save(self): with tempfile.TemporaryDirectory() as tmpdir: db_path = os.path.join(tmpdir, "test.db") @@ -1382,11 +1439,28 @@ def _make_client(self): def test_register_agent(self): client = self._make_client() - resp = client.post("/agents", json={"name": "alice", "model": "sonnet"}) + resp = client.post("/agents", json={ + "name": "alice", + "model": "sonnet", + "runtime": "codex_cli", + "provider_url": "codex_cli", + "provider_model": "gpt-5-codex", + }) assert resp.status_code == 200 data = resp.json() assert data["name"] == "alice" assert data["model"] == "sonnet" + assert data["runtime"] == "codex_cli" + assert data["provider_url"] == "codex_cli" + assert data["provider_model"] == "gpt-5-codex" + + def test_update_agent_runtime(self): + client = self._make_client() + client.post("/agents", json={"name": "alice", "model": "sonnet"}) + + resp = client.put("/agents/alice", json={"runtime": "codex_cli"}) + assert resp.status_code == 200 + assert resp.json()["runtime"] == "codex_cli" def test_register_agent_with_soul(self): client = self._make_client() From 1dd6b1f92b89059b540a13f40f54aa62adf97535 Mon Sep 17 00:00:00 2001 From: Brad Brok Date: Sun, 3 May 2026 04:38:45 +0400 Subject: [PATCH 04/24] Fix Codex runtime provider resolution (#360) Restore _resolve_agent_provider() for all runtimes so provider_ref key/model resolution works for Codex agents. Override only resolved_provider_url to codex_cli sentinel after resolution. Extend backfill tests for WHERE-clause defense-in-depth. --- src/pinky_daemon/api.py | 5 +---- tests/test_agent_registry.py | 10 ++++++++++ tests/test_api.py | 26 +++++++++++++++++++++++++- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index 202d4a6e..05c5389d 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -2484,12 +2484,9 @@ def runtime_from_legacy_provider(agent_config) -> str: raise HTTPException(400, msg) is_codex = runtime == "codex_cli" + resolved_provider_url, resolved_provider_key, resolved_provider_model = _resolve_agent_provider(agent) if is_codex: resolved_provider_url = "codex_cli" - resolved_provider_key = agent.provider_key or "" - resolved_provider_model = agent.provider_model or "" - else: - resolved_provider_url, resolved_provider_key, resolved_provider_model = _resolve_agent_provider(agent) effective_model = resolved_provider_model or agent.model # Build MCP server config for Codex agents (injected via -c flags) diff --git a/tests/test_agent_registry.py b/tests/test_agent_registry.py index 7cd62e4a..ab50da65 100644 --- a/tests/test_agent_registry.py +++ b/tests/test_agent_registry.py @@ -146,14 +146,24 @@ def test_runtime_column_exists_with_default(self, registry): def test_runtime_codex_cli_backfill_is_one_shot_and_idempotent(self, registry): registry.register("legacy-codex", provider_url="codex_cli", runtime="claude_sdk") registry.register("explicit-claude", provider_url="codex_cli", runtime="claude_sdk") + registry.register("already-codex", provider_url="codex_cli", runtime="codex_cli") + registry.register("opencode-agent", provider_url="codex_cli", runtime="opencode") marker = "migration:agents_runtime_codex_cli_backfill" registry.set_setting(marker, "") registry._backfill_runtime_from_provider_url() assert registry.get("legacy-codex").runtime == "codex_cli" assert registry.get("explicit-claude").runtime == "codex_cli" + assert registry.get("already-codex").runtime == "codex_cli" + assert registry.get("opencode-agent").runtime == "opencode" assert registry.get_setting(marker) == "1" + registry.set_setting(marker, "") + registry._backfill_runtime_from_provider_url() + assert registry.get("legacy-codex").runtime == "codex_cli" + assert registry.get("already-codex").runtime == "codex_cli" + assert registry.get("opencode-agent").runtime == "opencode" + registry.register("explicit-claude", runtime="claude_sdk") registry._backfill_runtime_from_provider_url() assert registry.get("explicit-claude").runtime == "claude_sdk" diff --git a/tests/test_api.py b/tests/test_api.py index 3a4bcdc0..e60ffc73 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -637,7 +637,29 @@ async def fake_send(self, prompt: str, platform: str = "", chat_id: str = "", me db_path = os.path.join(tmpdir, "test.db") app = self._make_app(db_path) with TestClient(app) as client: - client.post("/agents", json={"name": "codex-agent", "model": "gpt-5-codex", "runtime": "codex_cli"}) + now = time.time() + app.state.agents._db.execute( + "INSERT INTO providers " + "(id, name, preset, provider_url, provider_key, provider_model, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + "codex-openai", + "Codex OpenAI", + "", + "https://api.openai.com/v1", + "global-openai-key", + "gpt-5-codex", + now, + now, + ), + ) + app.state.agents._db.commit() + client.post("/agents", json={ + "name": "codex-agent", + "model": "fallback-model", + "runtime": "codex_cli", + "provider_ref": "codex-openai", + }) resp = client.post("/agents/codex-agent/wake?prompt=Wake") assert resp.status_code == 200 @@ -645,6 +667,8 @@ async def fake_send(self, prompt: str, platform: str = "", chat_id: str = "", me session = app.state.broker._streaming["codex-agent"]["main"] assert session.__class__.__name__ == "CodexSession" assert session._config.provider_url == "codex_cli" + assert session._config.provider_key == "global-openai-key" + assert session._config.model == "gpt-5-codex" def test_wake_rejects_opencode_runtime_until_session_exists(self): with tempfile.TemporaryDirectory() as tmpdir: From 243e88894e0b91585258ec2248caf5eb3a82bd7f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 00:40:24 +0000 Subject: [PATCH 05/24] test(registry): regression test for first-register-with-all-kwargs persistence (closes #361) Adds a single-register-call test asserting that provider_url, provider_key, provider_model, provider_ref, thinking_effort, and runtime are persisted through both the returned Agent object and a subsequent get() DB round-trip. Covers the INSERT path that silently dropped these fields before PR #358. https://claude.ai/code/session_01EGnozrh9Fbrp6FMcjDZEnb --- tests/test_agent_registry.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_agent_registry.py b/tests/test_agent_registry.py index ab50da65..3250d45f 100644 --- a/tests/test_agent_registry.py +++ b/tests/test_agent_registry.py @@ -143,6 +143,35 @@ def test_runtime_column_exists_with_default(self, registry): agent = registry.register("runtime-default") assert agent.runtime == "claude_sdk" + def test_first_register_with_all_kwargs_persists_every_field(self, registry): + # Regression: pre-#358 INSERT omitted provider_url/key/model/ref, thinking_effort, runtime. + # A single register() call (no follow-up UPDATE) must persist all of them. + agent = registry.register( + "full-kwargs-agent", + provider_url="https://api.openai.com/v1", + provider_key="sk-test-key", + provider_model="gpt-5", + provider_ref="some-provider-id", + thinking_effort="high", + runtime="codex_cli", + ) + # Verify via the returned object (built from INSERT path) + assert agent.provider_url == "https://api.openai.com/v1" + assert agent.provider_key == "sk-test-key" + assert agent.provider_model == "gpt-5" + assert agent.provider_ref == "some-provider-id" + assert agent.thinking_effort == "high" + assert agent.runtime == "codex_cli" + + # Verify via a fresh get() to confirm DB round-trip, not just in-memory object + fetched = registry.get("full-kwargs-agent") + assert fetched.provider_url == "https://api.openai.com/v1" + assert fetched.provider_key == "sk-test-key" + assert fetched.provider_model == "gpt-5" + assert fetched.provider_ref == "some-provider-id" + assert fetched.thinking_effort == "high" + assert fetched.runtime == "codex_cli" + def test_runtime_codex_cli_backfill_is_one_shot_and_idempotent(self, registry): registry.register("legacy-codex", provider_url="codex_cli", runtime="claude_sdk") registry.register("explicit-claude", provider_url="codex_cli", runtime="claude_sdk") From 67a203cd88df842c282d7cd6f8d10f2236e01316 Mon Sep 17 00:00:00 2001 From: olegbrok Date: Tue, 5 May 2026 09:11:18 -0700 Subject: [PATCH 06/24] =?UTF-8?q?deps:=20bump=20claude-agent-sdk=200.1.72?= =?UTF-8?q?=E2=86=920.1.73,=20anthropic=200.96=E2=86=920.98=20(#378)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the upstream fixes summarized in #378: claude-agent-sdk 0.1.73 (claude-code v2.1.122–v2.1.128): - Sub-agent prompt cache fix (~3× cache_creation reduction) - MCP reconnection no longer floods conversation with full tool list - Parallel shell tool sibling-cancellation fix - MCP image-dropping fix - EnterWorktree branch-base fix - OAuth 401 retry-loop fix - Skill MCP-tools-on-fork fix anthropic 0.98.1: - Streaming stop_details propagation fix (#1725) - Managed Agents API improvements - Multipart file array field-name fix Verified: 1700 passed, 1 skipped. No code changes required — pure dep bump. Heads-up: claude-code v2.1.128 reserves "workspace" as an MCP server name. Relevant to #73 (Pinky Workspace API Server) — pick a different server name. Co-authored-by: Oleg Co-authored-by: Claude Opus 4.7 --- pyproject.toml | 4 ++-- uv.lock | 24 ++++++++++++------------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 478b6e14..48b7b6e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "httpx>=0.28", "fastapi>=0.135", "uvicorn>=0.44", - "claude-agent-sdk>=0.1.72", + "claude-agent-sdk>=0.1.73", "Pillow>=10.0", # Federation crypto (sealed_box_v1): X25519, Ed25519, HKDF, XChaCha20-Poly1305. "cryptography>=41.0", @@ -54,7 +54,7 @@ calendar = [ "cryptography>=41.0", ] web = ["camoufox>=0.4", "markdownify>=1.0", "beautifulsoup4>=4.12"] -voice = ["twilio>=9.0", "anthropic>=0.96"] +voice = ["twilio>=9.0", "anthropic>=0.98"] local-embeddings = ["sentence-transformers>=2.0"] all = [ "pinky-ai[telegram,discord,slack,google,calendar,voice]", diff --git a/uv.lock b/uv.lock index f20c6cda..fd90df95 100644 --- a/uv.lock +++ b/uv.lock @@ -163,7 +163,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.96.0" +version = "0.98.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -175,9 +175,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/7e/672f533dee813028d2c699bfd2a7f52c9118d7353680d9aa44b9e23f717f/anthropic-0.96.0.tar.gz", hash = "sha256:9de947b737f39452f68aa520f1c2239d44119c9b73b0fb6d4e6ca80f00279ee6", size = 658210, upload-time = "2026-04-16T14:28:02.846Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/60/a9e4426dfe594e5eec8a9757d48e3d8dcf529a0a35a4fc8aefa352bd95fe/anthropic-0.98.1.tar.gz", hash = "sha256:62205edec42f5877df63d58be8e9443843d3e032215836e228fba1f59514a433", size = 725085, upload-time = "2026-05-04T21:40:39.496Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/5a/72f33204064b6e87601a71a6baf8d855769f8a0c1eaae8d06a1094872371/anthropic-0.96.0-py3-none-any.whl", hash = "sha256:9a6e335a354602a521cd9e777e92bfd46ba6e115bf9bbfe6135311e8fb2015b2", size = 635930, upload-time = "2026-04-16T14:28:01.436Z" }, + { url = "https://files.pythonhosted.org/packages/9b/6f/7f7f80f714e6de0784518f1999f71fd632076aefd3e22fe0ccd27ca9571f/anthropic-0.98.1-py3-none-any.whl", hash = "sha256:107ebf954415382fdcea6a94f9cf334a53199ad64794403590dc55366cefcc28", size = 699604, upload-time = "2026-05-04T21:40:41.311Z" }, ] [[package]] @@ -508,20 +508,20 @@ wheels = [ [[package]] name = "claude-agent-sdk" -version = "0.1.72" +version = "0.1.73" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "mcp" }, { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/b7/ce35a79f4891797ef60b5cf437453bf22e3e420f5946007312c9e144f5e0/claude_agent_sdk-0.1.72.tar.gz", hash = "sha256:e737f919301d5fc65a3ebc6f438911b73e237b16fa9fa3061420e79727cfa307", size = 241286, upload-time = "2026-05-01T02:17:34.37Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/d7/5207b9428813918532d52fbcc8342aa105e04cb50afb94118228a322af39/claude_agent_sdk-0.1.73.tar.gz", hash = "sha256:7dbb1e885abac558e39374da632c52e87a931861e20c67e1f36c86e7e3be7f19", size = 242906, upload-time = "2026-05-04T23:14:12.366Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/de/098dd15dec8ce3c5ed9c9c2415a290fb5c24ad9cd1214cfc3e20c3be0342/claude_agent_sdk-0.1.72-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a16ee041db0734e8f82d1fca7bd7c122227c0d8c150a301b365ab3f0a83a27b", size = 63695454, upload-time = "2026-05-01T02:17:38.468Z" }, - { url = "https://files.pythonhosted.org/packages/55/de/bea82fdd65244bab9cf6aa3492c2b6cc5d97213836730febd89c0a342975/claude_agent_sdk-0.1.72-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:4b3c630b5b07cd4f3d57631d833539b77045937093ec4975f47ee29de6b9a9df", size = 65539313, upload-time = "2026-05-01T02:17:43.129Z" }, - { url = "https://files.pythonhosted.org/packages/1c/26/78e45a08c4acb262b8f99f6885fb5b96ccf32f27be008610403b86cc3da8/claude_agent_sdk-0.1.72-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:6fd7c6cbab95928ceb39fe82f413bd124e83dc5ed9bf63b6dffb9dc2b83f67bc", size = 76872182, upload-time = "2026-05-01T02:17:48.616Z" }, - { url = "https://files.pythonhosted.org/packages/58/6d/9641f9a3119adec03d33cb40be8a194801cde0c15b3c497f07e36b38dab1/claude_agent_sdk-0.1.72-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:e2cf7beb573b608832cf2c644b330e51b79cfdab85286f064cf92f8f63c66382", size = 77032401, upload-time = "2026-05-01T02:17:54.322Z" }, - { url = "https://files.pythonhosted.org/packages/88/a1/35eeda6bac78c5c236c0f3c36fb8634503514ff78ada4e46b77397922ed7/claude_agent_sdk-0.1.72-py3-none-win_amd64.whl", hash = "sha256:9159ac974b496bb50d05027f49b44b76a595f1b4df3bfdef1136b56c95fc956d", size = 78421027, upload-time = "2026-05-01T02:18:00.614Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9d/37bac90d86f50642ba1e9b70b558cdd643da8bb1109c584ce32ad0b1fc6f/claude_agent_sdk-0.1.73-py3-none-macosx_11_0_arm64.whl", hash = "sha256:62171e16840a8d00681207f6ea133736082b5df701a87548a84ec5ab00cdf5ca", size = 63885588, upload-time = "2026-05-04T23:14:16.457Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e2/81212e886a9bc34a14b8d3588aafd3460bb37328edc4793b10e7df205e05/claude_agent_sdk-0.1.73-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:a702aecfdd1d9ad7dd9fcf19aa9ebde41ffc426e28a4125b3ab2e88b49a89c47", size = 65744104, upload-time = "2026-05-04T23:14:20.345Z" }, + { url = "https://files.pythonhosted.org/packages/80/81/2ecea14eb376d2eaf7f121fdc6ee4b00071b9b1b859ff7251d84fe0e4686/claude_agent_sdk-0.1.73-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:6cb2c38fbfd3d6be7edfc41ce342635569403a8b41f77ded07c328f20b6138cf", size = 77049373, upload-time = "2026-05-04T23:14:24.595Z" }, + { url = "https://files.pythonhosted.org/packages/89/16/a02de4e05bd522beddf2aa02351fc670222929e9e25fff2e23add7937df6/claude_agent_sdk-0.1.73-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:938b2f1adf10e92d4e14c0b16d61f8e616171e98cdc7c3cbcd197dd857fdbc22", size = 77225319, upload-time = "2026-05-04T23:14:28.843Z" }, + { url = "https://files.pythonhosted.org/packages/d0/6a/cb0b8b9a9110ca46e5fb2de7c4e4224e0b18ca8fd50430ca0ea4118608cd/claude_agent_sdk-0.1.73-py3-none-win_amd64.whl", hash = "sha256:85454108785058bab796e9de2d654ce5570c2d9f8de9a9889e02a751d4a43158", size = 78636611, upload-time = "2026-05-04T23:14:33.165Z" }, ] [[package]] @@ -2254,11 +2254,11 @@ dev = [ [package.metadata] requires-dist = [ - { name = "anthropic", marker = "extra == 'voice'", specifier = ">=0.96" }, + { name = "anthropic", marker = "extra == 'voice'", specifier = ">=0.98" }, { name = "beautifulsoup4", marker = "extra == 'web'", specifier = ">=4.12" }, { name = "caldav", marker = "extra == 'calendar'", specifier = ">=1.3" }, { name = "camoufox", marker = "extra == 'web'", specifier = ">=0.4" }, - { name = "claude-agent-sdk", specifier = ">=0.1.72" }, + { name = "claude-agent-sdk", specifier = ">=0.1.73" }, { name = "cryptography", specifier = ">=41.0" }, { name = "cryptography", marker = "extra == 'calendar'", specifier = ">=41.0" }, { name = "discord-py", marker = "extra == 'discord'", specifier = ">=2.3" }, From 3f4e5217d460712e5f03eb65450c6abf9406ab45 Mon Sep 17 00:00:00 2001 From: olegbrok Date: Tue, 5 May 2026 12:57:43 -0700 Subject: [PATCH 07/24] feat(discord): REST-polling inbound poller for per-agent Discord bots (#383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(discord): REST-polling inbound poller for per-agent Discord bots Adds a `BrokerDiscordPoller` analogous to `BrokerTelegramPoller` so agents can receive Discord messages, not just send them. The Discord REST API has no equivalent to Telegram's getUpdates long-poll, so this is a periodic sweep over watched channels (default 1s). A future v0.2 may add a Gateway/WebSocket variant for true push delivery. Behavior: - Auto-discovers text + announcement channels across the bot's guilds at startup; can be overridden per-token via settings.watched_channels. - Primes last_message_id from the most recent message on first sight so history is not replayed. - Filters out the bot's own messages (no self-reply loop). - Honors 429 rate limits via DiscordRateLimitError.retry_after with a bounded retry, then bubbles up so the poll loop can sleep. - Re-discovers channels every 60s so newly-joined guilds become reachable without a daemon restart. Adapter changes: - DiscordAdapter now sends a Discord-style User-Agent (default urllib / httpx UA gets Cloudflare-403'd on /users/@me). - _request() catches 429 and retries up to max_429_retries (default 1) before raising DiscordRateLimitError. (DiscordRateLimited kept as alias.) - get_my_guilds() and discover_text_channels() helpers for the poller. Wiring: - api.py PUT /agents/{name}/tokens/discord starts/restarts the poller the same way the telegram path does, including reading poll_interval_sec and watched_channels from token settings. - DELETE /agents/{name}/tokens/discord stops the poller. - on_startup auto-spawns Discord pollers for every enabled agent that has a saved discord token. Tests: 11 new (35 total in test_discord*) — UA header, 429 retry/exhaustion, guild listing, channel-type filtering, broker delivery, bot-self skip, chronological ordering, empty-channel sentinel handling, discovery failure tolerance, rate-limit propagation. Live smoke-tested against bot user Barsik#2361 (id 1501305263351660674): auth works, poller boots, discovers 0 channels (bot not yet invited to any guilds), 2 poll sweeps complete cleanly, stop is graceful. 🤖 Opened by Barsik Co-Authored-By: Claude Opus 4.7 * review(discord): address Pushok's PR #383 feedback - Q2 (BLOCKING for promotion): hoist get_channel out of the per-message loop and add a poller-lifetime cache invalidated on each _refresh_channels (60s default). Eliminates the burst pathology where a chatty channel would fan out to N round-trips per poll. New tests cover single-channel-call-per-burst, cache reuse across polls, and cache invalidation on rediscovery. - Q1 (naming): rename `_refresh_channels(prime_last_ids=...)` to `_refresh_channels(verbose=...)` — the kwarg only gates logging, not priming, and the old name was misleading. - Q3 (docstring): document that DiscordAdapter is synchronous and that async callers must use run_in_executor; mention 429-induced time.sleep behavior. - Drop the DiscordRateLimited/DiscordRateLimitError alias — both were introduced in this same PR, no legacy callers, ossifying it now would be premature. Keeps DiscordRateLimited as canonical (poller + tests already use it) with a noqa N818 since the name reads naturally at call sites without the Error suffix. - Add `add_done_callback` to the broker-delivery `create_task` so unhandled exceptions surface in poller logs instead of silently warning at the event loop layer. New test asserts the failure log line is emitted. - `asyncio.get_event_loop()` → `asyncio.get_running_loop()` in coroutines (modern equivalent; surfaces non-running-loop bugs). Tests: 39 in test_discord*.py (4 new), still pass; full suite green. 🤖 Reviewed by Pushok, addressed by Barsik Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Oleg Co-authored-by: Claude Opus 4.7 --- src/pinky_daemon/api.py | 94 ++++++++- src/pinky_daemon/pollers.py | 316 +++++++++++++++++++++++++++++ src/pinky_outreach/discord.py | 122 +++++++++-- tests/test_discord.py | 118 ++++++++++- tests/test_discord_poller.py | 370 ++++++++++++++++++++++++++++++++++ 5 files changed, 1004 insertions(+), 16 deletions(-) create mode 100644 tests/test_discord_poller.py diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index 05c5389d..a079a3b1 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -5964,6 +5964,44 @@ async def set_agent_token(name: str, platform: str, req: SetAgentTokenRequest): except Exception as e: _log(f"api: failed to start telegram poller for {name}: {e}") + # Dynamically start/restart a broker poller for Discord tokens + if platform == "discord" and raw_token: + try: + from pinky_daemon.pollers import BrokerDiscordPoller + from pinky_outreach.discord import DiscordAdapter + + # Stop any existing poller for this agent + for p in list(_broker_pollers): + if ( + isinstance(p, BrokerDiscordPoller) + and p._agent_name == name + ): + p.stop() + _broker_pollers.remove(p) + _log(f"api: stopped old discord poller for {name}") + break + + # Optional per-token overrides come through token settings. + settings = req.settings or {} + poll_interval = float(settings.get("poll_interval_sec", 1.0)) + watched = settings.get("watched_channels") or None + + adapter = DiscordAdapter(raw_token) + poller = BrokerDiscordPoller( + adapter, name, broker, registry=agents, + poll_interval=poll_interval, + watched_channels=watched, + ) + _broker_pollers.append(poller) + asyncio.create_task(poller.start()) + _log( + f"api: started discord poller for {name} " + f"(poll_interval={poll_interval}s, " + f"watched={'auto' if not watched else len(watched)})" + ) + except Exception as e: + _log(f"api: failed to start discord poller for {name}: {e}") + return token.to_dict() @app.get("/agents/{name}/tokens") @@ -5993,6 +6031,19 @@ async def remove_agent_token(name: str, platform: str): _log(f"api: stopped telegram poller for {name}") break + # Stop broker poller if removing a Discord token + if platform == "discord": + from pinky_daemon.pollers import BrokerDiscordPoller + for p in list(_broker_pollers): + if ( + isinstance(p, BrokerDiscordPoller) + and p._agent_name == name + ): + p.stop() + _broker_pollers.remove(p) + _log(f"api: stopped discord poller for {name}") + break + return {"deleted": True, "agent": name, "platform": platform} # ── MCP Servers ──────────────────────────────────────── @@ -7896,7 +7947,12 @@ def _resolve_memory_db(agent_name: str) -> str: auto_start_agents = agents.list_auto_start_agents() # Start broker pollers and streaming sessions for all enabled agents. - from pinky_daemon.pollers import BrokeriMessagePoller, BrokerTelegramPoller + from pinky_daemon.pollers import ( + BrokerDiscordPoller, + BrokeriMessagePoller, + BrokerTelegramPoller, + ) + from pinky_outreach.discord import DiscordAdapter from pinky_outreach.telegram import TelegramAdapter all_agents = agents.list(enabled_only=True) streaming_count = 0 @@ -7928,6 +7984,42 @@ def _resolve_memory_db(agent_name: str) -> str: asyncio.create_task(poller.start()) _log(f"startup: broker poller started for {agent.name}") + # Discord poller — REST polling (Gateway/WebSocket is a future v0.2) + discord_token = agents.get_raw_token(agent.name, "discord") + if discord_token: + existing = any( + isinstance(p, BrokerDiscordPoller) and p._agent_name == agent.name + for p in _broker_pollers + ) + if existing: + _log(f"startup: discord poller already exists for {agent.name}, skipping") + else: + try: + # Read per-token settings (poll_interval_sec, watched_channels) + settings = {} + for tok in agents.list_tokens(agent.name): + if tok.platform == "discord": + settings = tok.settings or {} + break + poll_interval = float(settings.get("poll_interval_sec", 1.0)) + watched = settings.get("watched_channels") or None + + d_adapter = DiscordAdapter(discord_token) + d_poller = BrokerDiscordPoller( + d_adapter, agent.name, broker, registry=agents, + poll_interval=poll_interval, + watched_channels=watched, + ) + _broker_pollers.append(d_poller) + asyncio.create_task(d_poller.start()) + _log( + f"startup: discord poller started for {agent.name} " + f"(interval={poll_interval}s, " + f"channels={'auto' if not watched else len(watched)})" + ) + except Exception as e: + _log(f"startup: discord poller failed for {agent.name}: {e}") + # iMessage poller — check if agent has imessage enabled in DB if agents.get_raw_token(agent.name, "imessage"): try: diff --git a/src/pinky_daemon/pollers.py b/src/pinky_daemon/pollers.py index 222a63ca..d783c95d 100644 --- a/src/pinky_daemon/pollers.py +++ b/src/pinky_daemon/pollers.py @@ -9,10 +9,15 @@ import asyncio import sys +from typing import TYPE_CHECKING from pinky_daemon.message_handler import InboundMessage, MessageHandler +from pinky_outreach.discord import DiscordAdapter, DiscordError, DiscordRateLimited from pinky_outreach.telegram import TelegramAdapter, TelegramError +if TYPE_CHECKING: + from pinky_outreach.types import Chat + class TelegramPoller: """Polls Telegram Bot API for new messages. @@ -386,5 +391,316 @@ def is_running(self) -> bool: return self._running +class BrokerDiscordPoller: + """Polls Discord REST API for a specific agent's bot token, routes through MessageBroker. + + This is the Discord analogue of BrokerTelegramPoller, but using REST polling + rather than long-polling — the Discord REST API has no equivalent to + Telegram's getUpdates. A future version may add a Gateway/WebSocket poller + for true push delivery; this class provides a working inbound channel today. + + Behavior: + - Discovers watchable channels at startup (settings.watched_channels if set, + otherwise auto-discovered text channels across all the bot's guilds). + - Polls each channel every `poll_interval` seconds, fetching only messages + with id > last seen via the `?after=` parameter. + - On first sight of a channel, primes `last_id` from the most recent + message so we don't replay history. + - Skips messages authored by the bot itself (no self-reply loop). + - Honors 429 rate limits via DiscordRateLimited (sleeps retry_after). + - Re-discovers channels every `discovery_interval` seconds so newly-joined + guilds become reachable without a daemon restart. + """ + + def __init__( + self, + adapter: DiscordAdapter, + agent_name: str, + broker, # MessageBroker + registry=None, # AgentRegistry — reserved for future guild tracking + *, + poll_interval: float = 1.0, + discovery_interval: float = 60.0, + watched_channels: list[str] | None = None, + event_callback=None, + ) -> None: + from pinky_daemon.broker import BrokerMessage, MessageBroker + self._BrokerMessage = BrokerMessage + + self._adapter = adapter + self._agent_name = agent_name + self._broker: MessageBroker = broker + self._registry = registry + self._poll_interval = max(0.25, float(poll_interval)) + self._discovery_interval = max(10.0, float(discovery_interval)) + self._configured_channels = list(watched_channels or []) + self._event_callback = event_callback + self._running = False + self._poll_count = 0 + self._bot_user_id = "" + self._bot_username = "" + # Per-channel state + self._channels: list[str] = [] + self._last_id: dict[str, str] = {} + self._last_discovery: float = 0.0 + # Channel metadata cache — invalidated each discovery cycle (~60s by + # default). Avoids fanning out get_channel() calls across every message + # in a burst on the same channel. + self._channel_info_cache: dict[str, Chat] = {} + + @property + def agent_name(self) -> str: + return self._agent_name + + @property + def poll_count(self) -> int: + return self._poll_count + + @property + def is_running(self) -> bool: + return self._running + + @property + def watched_channels(self) -> list[str]: + return list(self._channels) + + async def start(self) -> None: + """Start the polling loop.""" + self._running = True + _log(f"discord-poller[{self._agent_name}]: starting") + + # Verify bot connection + try: + me = await asyncio.get_running_loop().run_in_executor( + None, self._adapter.get_me, + ) + self._bot_user_id = me.get("id", "") + self._bot_username = me.get("username", "?") + _log( + f"discord-poller[{self._agent_name}]: connected as " + f"{self._bot_username} (id={self._bot_user_id})" + ) + except DiscordError as e: + _log(f"discord-poller[{self._agent_name}]: failed to connect: {e}") + self._running = False + return + + # Initial channel discovery + last_id priming + await self._refresh_channels(verbose=True) + + while self._running: + try: + # Periodic re-discovery (cheap — one /users/@me/guilds + one + # /guilds/{id}/channels per guild per discovery_interval). + import time as _time + if _time.monotonic() - self._last_discovery >= self._discovery_interval: + await self._refresh_channels(verbose=False) + + await self._poll_once() + except DiscordRateLimited as e: + _log( + f"discord-poller[{self._agent_name}]: rate limited, " + f"sleeping {e.retry_after:.2f}s" + ) + await asyncio.sleep(min(e.retry_after, 30.0)) + except DiscordError as e: + _log(f"discord-poller[{self._agent_name}]: error: {e}") + await asyncio.sleep(5) + except Exception as e: + _log(f"discord-poller[{self._agent_name}]: unexpected error: {e}") + await asyncio.sleep(5) + + await asyncio.sleep(self._poll_interval) + + async def _refresh_channels(self, *, verbose: bool) -> None: + """Refresh the watched-channel set and prime last_id for newcomers. + + `verbose=True` produces a log line on every call (used at startup); + otherwise we only log on add/remove changes. + """ + import time as _time + loop = asyncio.get_running_loop() + + if self._configured_channels: + new_set = list(self._configured_channels) + else: + try: + new_set = await loop.run_in_executor( + None, self._adapter.discover_text_channels, + ) + except DiscordError as e: + _log( + f"discord-poller[{self._agent_name}]: " + f"channel discovery failed: {e}" + ) + # Keep existing set on transient discovery failure. + self._last_discovery = _time.monotonic() + return + + added = [c for c in new_set if c not in self._last_id] + removed = [c for c in self._channels if c not in new_set] + + # Prime last_id for newly-discovered channels: fetch the most recent + # message and treat its id as the baseline so we don't replay history. + for ch in added: + try: + recent = await loop.run_in_executor( + None, + lambda c=ch: self._adapter.get_messages(c, limit=1), + ) + except DiscordError: + # Channel might be unreadable; skip it for now, retry next discovery + continue + if recent: + # get_messages returns newest-first + self._last_id[ch] = recent[0].message_id + else: + # Empty channel — start from "0", which sorts before any real snowflake + self._last_id[ch] = "0" + + for ch in removed: + self._last_id.pop(ch, None) + + self._channels = new_set + self._last_discovery = _time.monotonic() + + # Drop cached channel metadata so renames / type changes get picked up + # on the next inbound. Cheap (≤ N discovery_interval-old entries). + self._channel_info_cache.clear() + + if added or removed or verbose: + _log( + f"discord-poller[{self._agent_name}]: watching " + f"{len(self._channels)} channels (+{len(added)} -{len(removed)})" + ) + + async def _resolve_channel_info(self, channel_id: str): + """Return cached channel metadata or fetch + cache it on miss.""" + cached = self._channel_info_cache.get(channel_id) + if cached is not None: + return cached + try: + chat_info = await asyncio.get_running_loop().run_in_executor( + None, lambda c=channel_id: self._adapter.get_channel(c), + ) + except DiscordError: + return None + self._channel_info_cache[channel_id] = chat_info + return chat_info + + def _attach_broker_failure_logger(self, task: "asyncio.Task") -> None: + """Surface unhandled broker delivery exceptions as poller log lines.""" + def _log_failure(t: "asyncio.Task") -> None: + if t.cancelled(): + return + exc = t.exception() + if exc is None: + return + _log( + f"discord-poller[{self._agent_name}]: " + f"broker delivery failed: {exc!r}" + ) + task.add_done_callback(_log_failure) + + async def _poll_once(self) -> None: + """Single sweep across all watched channels.""" + loop = asyncio.get_running_loop() + self._poll_count += 1 + + for channel_id in list(self._channels): + after = self._last_id.get(channel_id, "") + if after == "0": + # Empty-channel sentinel — drop the after filter so the first real + # message is picked up regardless of snowflake ordering. + after = "" + + try: + messages = await loop.run_in_executor( + None, + lambda c=channel_id, a=after: self._adapter.get_messages( + c, limit=50, after=a, + ), + ) + except DiscordRateLimited: + # Bubble up so the outer loop can sleep retry_after + raise + except DiscordError as e: + _log( + f"discord-poller[{self._agent_name}]: " + f"channel {channel_id} fetch failed: {e}" + ) + continue + + if not messages: + continue + + # Discord returns newest-first; replay in chronological order so + # the broker sees them as they happened. + messages = list(reversed(messages)) + + # Channel metadata is constant per channel per poll cycle (and + # essentially constant across the whole discovery_interval — + # renames are rare). Resolve once per channel per sweep instead + # of fanning out N get_channel calls in the per-message loop. + chat_info = await self._resolve_channel_info(channel_id) + chat_title = chat_info.title if chat_info and chat_info.title else "" + is_group = bool(chat_info and chat_info.chat_type != "dm") + + for msg in messages: + # Track high-water mark even for skipped messages so we don't + # re-fetch them next tick. + self._last_id[channel_id] = msg.message_id + + meta = msg.metadata or {} + if meta.get("is_bot"): + continue + if meta.get("author_id") and meta["author_id"] == self._bot_user_id: + continue + + broker_msg = self._BrokerMessage( + platform="discord", + chat_id=channel_id, + sender_name=msg.sender, + sender_id=meta.get("author_id", ""), + content=msg.content, + agent_name=self._agent_name, + message_id=msg.message_id, + chat_title=chat_title, + is_group=is_group, + reply_to=msg.reply_to, + metadata=meta, + attachments=meta.get("attachments", []), + ) + + _log( + f"discord-poller[{self._agent_name}]: message from " + f"{msg.sender} in {channel_id}: {msg.content[:50]}..." + ) + + delivery = asyncio.create_task( + self._broker.handle_inbound(broker_msg) + ) + self._attach_broker_failure_logger(delivery) + + if self._event_callback: + try: + await self._event_callback( + platform="discord", + chat_id=str(channel_id), + sender=msg.sender, + content=msg.content, + ) + except Exception as e: + _log( + f"discord-poller[{self._agent_name}]: " + f"event callback error: {e}" + ) + + def stop(self) -> None: + """Stop the polling loop.""" + self._running = False + _log(f"discord-poller[{self._agent_name}]: stopping") + + def _log(msg: str) -> None: print(msg, file=sys.stderr, flush=True) diff --git a/src/pinky_outreach/discord.py b/src/pinky_outreach/discord.py index 1d73d6df..36dbb4d0 100644 --- a/src/pinky_outreach/discord.py +++ b/src/pinky_outreach/discord.py @@ -11,12 +11,18 @@ from __future__ import annotations import os +import time from datetime import datetime import httpx from pinky_outreach.types import Chat, Message, Platform +# Discord requires a User-Agent that follows the convention; default urllib/httpx +# UAs trigger Cloudflare 403s on some endpoints. See +# https://discord.com/developers/docs/reference#user-agent +_DEFAULT_USER_AGENT = "DiscordBot (https://github.com/olegbrok/PinkyBot, 0.1.0)" + class DiscordError(Exception): """Discord API error.""" @@ -26,17 +32,41 @@ def __init__(self, message: str, status_code: int = 0): super().__init__(f"Discord API error {status_code}: {message}") +class DiscordRateLimited(DiscordError): # noqa: N818 — name reads naturally at call sites + """Discord 429 — caller may retry after `retry_after` seconds.""" + + def __init__(self, retry_after: float, message: str = "rate limited"): + self.retry_after = retry_after + super().__init__(message, status_code=429) + + class DiscordAdapter: - """Discord REST API adapter using httpx.""" + """Discord REST API adapter using httpx. + + Synchronous: uses `httpx.Client` and `time.sleep` for 429 backoff. Callers + in async contexts must wrap calls in `loop.run_in_executor` to avoid + blocking the event loop. The bundled `BrokerDiscordPoller` does this + correctly; ad-hoc async callers must too. A future v0.2 may swap to + `httpx.AsyncClient` + `asyncio.sleep` to remove the requirement. + """ BASE_URL = "https://discord.com/api/v10" - def __init__(self, bot_token: str, *, timeout: float = 30.0) -> None: + def __init__( + self, + bot_token: str, + *, + timeout: float = 30.0, + user_agent: str = _DEFAULT_USER_AGENT, + max_429_retries: int = 1, + ) -> None: self._token = bot_token + self._max_429_retries = max_429_retries self._client = httpx.Client( base_url=self.BASE_URL, headers={ "Authorization": f"Bot {bot_token}", + "User-Agent": user_agent, }, timeout=timeout, ) @@ -46,19 +76,51 @@ def close(self) -> None: self._client.close() def _request(self, method: str, path: str, **kwargs) -> dict | list: - """Make a Discord API request.""" - resp = self._client.request(method, path, **kwargs) - - if resp.status_code == 204: - return {} - - data = resp.json() + """Make a Discord API request, with bounded 429 retry. - if resp.status_code >= 400: - msg = data.get("message", str(data)) - raise DiscordError(msg, resp.status_code) - - return data + Synchronous: blocks via `time.sleep` on 429 backoff. Async callers + must wrap in `run_in_executor` (see class docstring). + """ + attempts = 0 + while True: + resp = self._client.request(method, path, **kwargs) + + if resp.status_code == 429: + # Discord returns retry_after either as a JSON body field (seconds) + # or in the X-RateLimit-Reset-After header. Prefer the body. + retry_after = 1.0 + try: + body = resp.json() + retry_after = float(body.get("retry_after", retry_after)) + except Exception: + header = resp.headers.get("X-RateLimit-Reset-After") + if header: + try: + retry_after = float(header) + except ValueError: + pass + + if attempts >= self._max_429_retries: + raise DiscordRateLimited(retry_after) + attempts += 1 + time.sleep(min(retry_after, 5.0)) + continue + + if resp.status_code == 204: + return {} + + try: + data = resp.json() + except Exception: + if resp.status_code >= 400: + raise DiscordError(resp.text[:200], resp.status_code) + return {} + + if resp.status_code >= 400: + msg = data.get("message", str(data)) if isinstance(data, dict) else str(data) + raise DiscordError(msg, resp.status_code) + + return data # ── Actions ────────────────────────────────────────────── @@ -249,6 +311,38 @@ def get_guild_channels(self, guild_id: str) -> list[Chat]: for ch in results ] + def get_my_guilds(self) -> list[dict]: + """List guilds (servers) the bot is currently a member of. + + Returns minimal guild dicts: {id, name, owner, permissions, ...}. + Used by the inbound poller to auto-discover channels to watch. + """ + result = self._request("GET", "/users/@me/guilds") + return result if isinstance(result, list) else [] + + def discover_text_channels(self) -> list[str]: + """Auto-discover all text channels (type 0) the bot can access across all guilds. + + Returns a list of channel IDs. Used as a default watch-set when no + explicit channel list is configured in the bot token settings. + """ + # Discord channel types: 0=GUILD_TEXT, 5=GUILD_ANNOUNCEMENT. + # We treat both as message-bearing for inbound polling purposes. + watchable_types = {0, 5} + channel_ids: list[str] = [] + for guild in self.get_my_guilds(): + try: + raw = self._request("GET", f"/guilds/{guild['id']}/channels") + except DiscordError: + # Skip guilds where listing fails (e.g., transient permission issues). + continue + if not isinstance(raw, list): + continue + for ch in raw: + if ch.get("type") in watchable_types: + channel_ids.append(ch["id"]) + return channel_ids + # ── Reactions ──────────────────────────────────────────── def add_reaction( diff --git a/tests/test_discord.py b/tests/test_discord.py index 4de93eb2..dc9c9f12 100644 --- a/tests/test_discord.py +++ b/tests/test_discord.py @@ -7,7 +7,7 @@ import httpx import pytest -from pinky_outreach.discord import DiscordAdapter, DiscordError +from pinky_outreach.discord import DiscordAdapter, DiscordError, DiscordRateLimited from pinky_outreach.types import Platform @@ -283,6 +283,122 @@ def test_get_guild_channels(self): assert channels[1].title == "voice" adapter.close() + def test_user_agent_header_sent(self): + """Discord 403s requests with the default httpx UA on some endpoints.""" + adapter = self._make_adapter() + ua = adapter._client.headers["User-Agent"] + # Must follow Discord's "DiscordBot (..., version)" convention + assert ua.startswith("DiscordBot (") + adapter.close() + + def test_429_retry_then_success(self, monkeypatch): + """A single 429 should sleep `retry_after` then retry transparently.""" + adapter = self._make_adapter() + slept: list[float] = [] + monkeypatch.setattr( + "pinky_outreach.discord.time.sleep", + lambda s: slept.append(s), + ) + + rate_limited = MagicMock() + rate_limited.status_code = 429 + rate_limited.json.return_value = {"retry_after": 0.5, "message": "rate limited"} + + success = MagicMock() + success.status_code = 200 + success.json.return_value = { + "id": "1234567890", + "channel_id": "99999", + "content": "ok", + "timestamp": "2026-03-27T12:00:00+00:00", + } + adapter._client.request = MagicMock(side_effect=[rate_limited, success]) + + msg = adapter.send_message("99999", "ok") + assert msg.message_id == "1234567890" + assert slept and slept[0] == pytest.approx(0.5) + adapter.close() + + def test_429_retry_exhausted_raises_rate_limited(self, monkeypatch): + """After `max_429_retries` attempts a 429 must surface as DiscordRateLimited.""" + adapter = DiscordAdapter("fake-discord-token", max_429_retries=1) + monkeypatch.setattr("pinky_outreach.discord.time.sleep", lambda s: None) + + rate_limited = MagicMock() + rate_limited.status_code = 429 + rate_limited.json.return_value = {"retry_after": 2.5} + + adapter._client.request = MagicMock(return_value=rate_limited) + with pytest.raises(DiscordRateLimited) as exc: + adapter.send_message("99999", "ok") + assert exc.value.retry_after == pytest.approx(2.5) + adapter.close() + + def test_get_my_guilds(self): + adapter = self._make_adapter() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + {"id": "g1", "name": "Server One"}, + {"id": "g2", "name": "Server Two"}, + ] + adapter._client.request = MagicMock(return_value=mock_response) + + guilds = adapter.get_my_guilds() + assert len(guilds) == 2 + assert guilds[0]["id"] == "g1" + adapter.close() + + def test_discover_text_channels_filters_voice_and_categories(self): + """Channel discovery should include text(0) + announcement(5), drop the rest.""" + adapter = self._make_adapter() + + guilds_resp = MagicMock() + guilds_resp.status_code = 200 + guilds_resp.json.return_value = [{"id": "g1", "name": "Server One"}] + + channels_resp = MagicMock() + channels_resp.status_code = 200 + channels_resp.json.return_value = [ + {"id": "ch-text", "name": "general", "type": 0}, + {"id": "ch-voice", "name": "Lounge", "type": 2}, + {"id": "ch-cat", "name": "Category", "type": 4}, + {"id": "ch-announce", "name": "news", "type": 5}, + ] + adapter._client.request = MagicMock(side_effect=[guilds_resp, channels_resp]) + + ids = adapter.discover_text_channels() + assert ids == ["ch-text", "ch-announce"] + adapter.close() + + def test_discover_text_channels_skips_unreadable_guilds(self): + """If listing one guild's channels fails, others should still be returned.""" + adapter = self._make_adapter() + + guilds_resp = MagicMock() + guilds_resp.status_code = 200 + guilds_resp.json.return_value = [ + {"id": "g1", "name": "Server One"}, + {"id": "g2", "name": "Server Two"}, + ] + + forbidden_resp = MagicMock() + forbidden_resp.status_code = 403 + forbidden_resp.json.return_value = {"message": "Missing Access"} + + ok_channels_resp = MagicMock() + ok_channels_resp.status_code = 200 + ok_channels_resp.json.return_value = [ + {"id": "ch-ok", "name": "general", "type": 0}, + ] + adapter._client.request = MagicMock( + side_effect=[guilds_resp, forbidden_resp, ok_channels_resp], + ) + + ids = adapter.discover_text_channels() + assert ids == ["ch-ok"] + adapter.close() + class TestOutreachServerDiscord: """Test the MCP server with Discord support.""" diff --git a/tests/test_discord_poller.py b/tests/test_discord_poller.py new file mode 100644 index 00000000..5a56da08 --- /dev/null +++ b/tests/test_discord_poller.py @@ -0,0 +1,370 @@ +"""Tests for BrokerDiscordPoller (REST-polling Discord inbound).""" + +from __future__ import annotations + +import asyncio +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from pinky_outreach.discord import DiscordError, DiscordRateLimited +from pinky_outreach.types import Chat, Message, Platform + + +@pytest.fixture +def mock_adapter(): + """A mock DiscordAdapter with sensible defaults.""" + adapter = MagicMock() + adapter.get_me.return_value = {"id": "bot-001", "username": "TestBot"} + adapter.discover_text_channels.return_value = ["chan-A"] + adapter.get_messages.return_value = [] + adapter.get_channel.return_value = Chat( + platform=Platform.discord, + chat_id="chan-A", + title="general", + chat_type="text", + ) + return adapter + + +@pytest.fixture +def mock_broker(): + broker = MagicMock() + broker.handle_inbound = AsyncMock() + return broker + + +def _msg( + *, + msg_id: str, + content: str, + author_id: str = "user-1", + sender: str = "brad", + is_bot: bool = False, + reply_to: str = "", +) -> Message: + return Message( + platform=Platform.discord, + chat_id="chan-A", + sender=sender, + content=content, + timestamp=datetime.fromisoformat("2026-05-05T12:00:00+00:00"), + message_id=msg_id, + reply_to=reply_to, + is_outbound=is_bot, + metadata={"author_id": author_id, "is_bot": is_bot}, + ) + + +class TestBrokerDiscordPoller: + """Mocked-adapter tests for the inbound Discord poller.""" + + def _make_poller(self, mock_adapter, mock_broker, **kwargs): + from pinky_daemon.pollers import BrokerDiscordPoller + return BrokerDiscordPoller( + mock_adapter, "barsik", mock_broker, registry=None, + **kwargs, + ) + + @pytest.mark.asyncio + async def test_priming_skips_history_replay(self, mock_adapter, mock_broker): + """First sweep should treat existing latest message as the baseline (no replay).""" + existing = _msg(msg_id="100", content="ancient") + # Adapter returns the most recent message during priming, then nothing on poll + mock_adapter.get_messages.side_effect = [ + [existing], # priming call (limit=1) + [], # _poll_once call + ] + + poller = self._make_poller(mock_adapter, mock_broker) + await poller._refresh_channels(verbose=True) + await poller._poll_once() + + assert poller.watched_channels == ["chan-A"] + assert poller._last_id["chan-A"] == "100" + mock_broker.handle_inbound.assert_not_called() + + @pytest.mark.asyncio + async def test_new_message_routed_through_broker(self, mock_adapter, mock_broker): + """Real inbound user message should produce a BrokerMessage with platform=discord.""" + baseline = _msg(msg_id="100", content="baseline") + new_msg = _msg(msg_id="101", content="hello", author_id="user-9", sender="ryan") + mock_adapter.get_messages.side_effect = [ + [baseline], # priming + [new_msg], # poll + ] + + poller = self._make_poller(mock_adapter, mock_broker) + await poller._refresh_channels(verbose=True) + await poller._poll_once() + + # Allow the fire-and-forget task to settle + await asyncio.sleep(0) + + assert mock_broker.handle_inbound.await_count == 1 + delivered = mock_broker.handle_inbound.await_args.args[0] + assert delivered.platform == "discord" + assert delivered.chat_id == "chan-A" + assert delivered.message_id == "101" + assert delivered.content == "hello" + assert delivered.sender_name == "ryan" + assert delivered.sender_id == "user-9" + assert delivered.agent_name == "barsik" + assert poller._last_id["chan-A"] == "101" + + @pytest.mark.asyncio + async def test_bot_messages_skipped(self, mock_adapter, mock_broker): + """is_bot=True or author_id matching the bot's own id must be filtered out.""" + baseline = _msg(msg_id="100", content="baseline") + bot_self = _msg( + msg_id="101", content="self echo", + author_id="bot-001", sender="TestBot", is_bot=False, + ) + bot_other = _msg( + msg_id="102", content="other bot", + author_id="bot-other", sender="OtherBot", is_bot=True, + ) + real_user = _msg( + msg_id="103", content="real", + author_id="user-9", sender="ryan", + ) + # Discord returns newest-first; poller reverses to chronological for delivery. + mock_adapter.get_messages.side_effect = [ + [baseline], + [real_user, bot_other, bot_self], + ] + + poller = self._make_poller(mock_adapter, mock_broker) + # Set bot id manually so we don't depend on start() + poller._bot_user_id = "bot-001" + await poller._refresh_channels(verbose=True) + await poller._poll_once() + + await asyncio.sleep(0) + + # Only the real-user message should be delivered + assert mock_broker.handle_inbound.await_count == 1 + delivered = mock_broker.handle_inbound.await_args.args[0] + assert delivered.message_id == "103" + # last_id advances past all of them so we don't refetch + assert poller._last_id["chan-A"] == "103" + + @pytest.mark.asyncio + async def test_explicit_watched_channels_override_discovery(self, mock_adapter, mock_broker): + """If settings.watched_channels is set, discover_text_channels must NOT be called.""" + # Empty channel = "0" sentinel + mock_adapter.get_messages.return_value = [] + + poller = self._make_poller( + mock_adapter, mock_broker, + watched_channels=["explicit-1", "explicit-2"], + ) + await poller._refresh_channels(verbose=True) + + assert sorted(poller.watched_channels) == ["explicit-1", "explicit-2"] + mock_adapter.discover_text_channels.assert_not_called() + + @pytest.mark.asyncio + async def test_empty_channel_primes_with_zero_sentinel(self, mock_adapter, mock_broker): + """A channel with no messages at priming time should record last_id='0'.""" + mock_adapter.get_messages.return_value = [] # priming finds nothing + + poller = self._make_poller(mock_adapter, mock_broker) + await poller._refresh_channels(verbose=True) + + assert poller._last_id["chan-A"] == "0" + + @pytest.mark.asyncio + async def test_poll_drops_after_filter_for_zero_sentinel(self, mock_adapter, mock_broker): + """When last_id='0' (empty channel), the poll must NOT pass after=0 to the adapter.""" + # Priming: empty + # Poll: a real first message arrives + first = _msg(msg_id="500", content="first ever", author_id="user-9") + mock_adapter.get_messages.side_effect = [ + [], # priming + [first], # poll + ] + + poller = self._make_poller(mock_adapter, mock_broker) + await poller._refresh_channels(verbose=True) + await poller._poll_once() + + await asyncio.sleep(0) + + # Inspect the second call (the actual poll) + poll_call = mock_adapter.get_messages.call_args_list[1] + kwargs = poll_call.kwargs + # `after` should be empty (sentinel dropped), not "0" + assert kwargs.get("after", "") == "" + # And the first message did get delivered + assert mock_broker.handle_inbound.await_count == 1 + + @pytest.mark.asyncio + async def test_messages_routed_in_chronological_order(self, mock_adapter, mock_broker): + """Discord returns newest-first; poller must reverse so broker sees chronological order.""" + baseline = _msg(msg_id="100", content="baseline") + # Discord returns newest-first + newer = _msg(msg_id="103", content="third") + middle = _msg(msg_id="102", content="second") + older = _msg(msg_id="101", content="first") + mock_adapter.get_messages.side_effect = [ + [baseline], + [newer, middle, older], + ] + + poller = self._make_poller(mock_adapter, mock_broker) + await poller._refresh_channels(verbose=True) + await poller._poll_once() + + await asyncio.sleep(0) + + delivered_ids = [ + call.args[0].message_id + for call in mock_broker.handle_inbound.await_args_list + ] + assert delivered_ids == ["101", "102", "103"] + + @pytest.mark.asyncio + async def test_discovery_failure_keeps_existing_set(self, mock_adapter, mock_broker): + """A transient discovery failure must not blank the watch list.""" + mock_adapter.get_messages.return_value = [] + + poller = self._make_poller(mock_adapter, mock_broker) + await poller._refresh_channels(verbose=True) + # Now simulate failure on next discovery + mock_adapter.discover_text_channels.side_effect = DiscordError( + "transient", status_code=502, + ) + + await poller._refresh_channels(verbose=False) + assert poller.watched_channels == ["chan-A"] # unchanged + + @pytest.mark.asyncio + async def test_rate_limit_propagates_for_outer_sleep(self, mock_adapter, mock_broker): + """A 429 mid-poll must propagate so the outer loop can sleep retry_after.""" + baseline = _msg(msg_id="100", content="baseline") + mock_adapter.get_messages.side_effect = [ + [baseline], + DiscordRateLimited(retry_after=0.5), + ] + + poller = self._make_poller(mock_adapter, mock_broker) + await poller._refresh_channels(verbose=True) + with pytest.raises(DiscordRateLimited): + await poller._poll_once() + + def test_poll_interval_floor(self, mock_adapter, mock_broker): + """Sub-quarter-second intervals must be clamped to keep us under budget.""" + poller = self._make_poller( + mock_adapter, mock_broker, poll_interval=0.05, + ) + assert poller._poll_interval == 0.25 + + def test_stop_clears_running_flag(self, mock_adapter, mock_broker): + poller = self._make_poller(mock_adapter, mock_broker) + poller._running = True + poller.stop() + assert poller.is_running is False + + @pytest.mark.asyncio + async def test_get_channel_cached_across_burst(self, mock_adapter, mock_broker): + """A burst of N messages on one channel must trigger only ONE get_channel call. + + Regression for the burst pathology Pushok flagged in PR #383: prior code + looked up channel metadata inside the per-message loop, fanning out to N + round-trips for a chatty channel. Now hoisted to once per channel per poll + sweep with a discovery-cycle cache. + """ + baseline = _msg(msg_id="100", content="baseline") + burst = [ + _msg(msg_id=f"{200 + i}", content=f"msg-{i}", author_id="user-9") + for i in range(5) + ] + # Discord returns newest-first + mock_adapter.get_messages.side_effect = [ + [baseline], # priming + list(reversed(burst)), # poll: newest-first + ] + + poller = self._make_poller(mock_adapter, mock_broker) + await poller._refresh_channels(verbose=True) + await poller._poll_once() + + await asyncio.sleep(0) + + assert mock_broker.handle_inbound.await_count == 5 + # Hoisted lookup: one call per channel per poll, not per message + assert mock_adapter.get_channel.call_count == 1 + + @pytest.mark.asyncio + async def test_get_channel_cache_reused_across_polls(self, mock_adapter, mock_broker): + """Cache survives across polls within a discovery cycle.""" + baseline = _msg(msg_id="100", content="baseline") + first = _msg(msg_id="101", content="first", author_id="user-9") + second = _msg(msg_id="102", content="second", author_id="user-9") + mock_adapter.get_messages.side_effect = [ + [baseline], # priming + [first], # poll 1 + [second], # poll 2 + ] + + poller = self._make_poller(mock_adapter, mock_broker) + await poller._refresh_channels(verbose=True) + await poller._poll_once() + await poller._poll_once() + + await asyncio.sleep(0) + + # Still one get_channel call total — cached after first miss + assert mock_adapter.get_channel.call_count == 1 + + @pytest.mark.asyncio + async def test_get_channel_cache_invalidated_on_rediscovery( + self, mock_adapter, mock_broker, + ): + """A discovery refresh drops the cache so renames/type-changes are picked up.""" + baseline = _msg(msg_id="100", content="baseline") + first = _msg(msg_id="101", content="first", author_id="user-9") + second = _msg(msg_id="102", content="second", author_id="user-9") + mock_adapter.get_messages.side_effect = [ + [baseline], # priming (during first refresh) + [first], # poll 1 + [second], # poll 2 (after rediscovery) + ] + + poller = self._make_poller(mock_adapter, mock_broker) + await poller._refresh_channels(verbose=True) + await poller._poll_once() + # Force a rediscovery — should invalidate the cache + await poller._refresh_channels(verbose=False) + await poller._poll_once() + + await asyncio.sleep(0) + + # First poll → 1 lookup, rediscovery clears cache, second poll → 1 more lookup + assert mock_adapter.get_channel.call_count == 2 + + @pytest.mark.asyncio + async def test_broker_delivery_failure_logged(self, mock_adapter, mock_broker, capsys): + """If broker.handle_inbound raises, the failure must be visible in poller logs.""" + baseline = _msg(msg_id="100", content="baseline") + new_msg = _msg(msg_id="101", content="boom", author_id="user-9") + mock_adapter.get_messages.side_effect = [[baseline], [new_msg]] + + async def _raise(*_args, **_kwargs): + raise RuntimeError("broker exploded") + + mock_broker.handle_inbound = AsyncMock(side_effect=_raise) + + poller = self._make_poller(mock_adapter, mock_broker) + await poller._refresh_channels(verbose=True) + await poller._poll_once() + + # Let the fire-and-forget task settle and the done_callback fire. + await asyncio.sleep(0) + await asyncio.sleep(0) + + captured = capsys.readouterr() + assert "broker delivery failed" in captured.err + assert "broker exploded" in captured.err From c38238fa18816443e3e8e31aa34880456a727692 Mon Sep 17 00:00:00 2001 From: Oleg Date: Tue, 5 May 2026 15:39:23 -0700 Subject: [PATCH 08/24] fix(broker): stop typing indicator immediately after outreach send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram clients clear the typing indicator when a bot message arrives, but the broker's 4s sendChatAction loop kept running until the agent's turn formally completed via route_response. The next loop iteration would fire mid-await — making "typing..." reappear *after* the message had already landed and lingering for the indicator's ~5s TTL. Cancel the typing loop the moment any outreach send succeeds: text, voice, photo, document, animation, gif. Reactions intentionally don't trigger the cancel (the agent may still be drafting a real reply). Also silence the no-op log line in _stop_typing so defensive calls after every send don't spam the logs. Co-Authored-By: Claude Opus 4.7 --- src/pinky_daemon/api.py | 13 ++++++- src/pinky_daemon/broker.py | 7 ++-- tests/test_broker.py | 70 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index a079a3b1..dea7302c 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -1623,6 +1623,10 @@ async def _broker_send( silent=silent, ), ) + # Once an outbound message lands, the typing indicator becomes noise — + # Telegram has no "stop typing" API, but cancelling our 4s sendChatAction + # loop prevents the indicator from popping back up after the message arrives. + broker._stop_typing(agent_name, chat_id) return { "sent": True, "agent": agent_name, @@ -1821,11 +1825,14 @@ def _generate_and_send(): try: loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, _generate_and_send) + result = await loop.run_in_executor(None, _generate_and_send) except HTTPException: raise except Exception as e: raise HTTPException(500, f"Voice note failed: {e}") + # Stop the typing loop now that the voice message has landed. + broker._stop_typing(agent_name, chat_id) + return result activity = ActivityStore(db_path=db_path.replace(".db", "_activity.db")) @@ -6566,6 +6573,7 @@ async def broker_send_photo(req: dict): loop = asyncio.get_running_loop() msg = await loop.run_in_executor(None, lambda: _send_file_message(agent_name, platform, chat_id, file_path, caption=caption, reply_to=reply_to, kind="photo")) result = {"sent": True, "message_id": msg.message_id, "platform": platform, "chat_id": chat_id} + broker._stop_typing(agent_name, chat_id) _record_outbound_message( agent_name, platform=platform, @@ -6597,6 +6605,7 @@ async def broker_send_document(req: dict): loop = asyncio.get_running_loop() msg = await loop.run_in_executor(None, lambda: _send_file_message(agent_name, platform, chat_id, file_path, caption=caption, reply_to=reply_to, kind="document")) result = {"sent": True, "message_id": msg.message_id, "platform": platform, "chat_id": chat_id} + broker._stop_typing(agent_name, chat_id) _record_outbound_message( agent_name, platform=platform, @@ -6695,6 +6704,7 @@ def _download_and_send(): try: msg = await loop.run_in_executor(None, _download_and_send) result = {"sent": True, "message_id": msg.message_id, "query": query, "platform": platform, "chat_id": chat_id} + broker._stop_typing(agent_name_req, chat_id) _record_outbound_message( agent_name_req, platform=platform, @@ -6726,6 +6736,7 @@ async def broker_send_animation(req: dict): loop = asyncio.get_running_loop() msg = await loop.run_in_executor(None, lambda: _send_file_message(agent_name, platform, chat_id, file_path, caption=caption, reply_to=reply_to, kind="animation")) result = {"sent": True, "message_id": msg.message_id, "platform": platform, "chat_id": chat_id} + broker._stop_typing(agent_name, chat_id) _record_outbound_message( agent_name, platform=platform, diff --git a/src/pinky_daemon/broker.py b/src/pinky_daemon/broker.py index 94b9ae5c..7480c9e4 100644 --- a/src/pinky_daemon/broker.py +++ b/src/pinky_daemon/broker.py @@ -237,12 +237,15 @@ async def _start_typing(self, agent_name: str, platform: str, chat_id: str, stre _log(f"broker: typing indicator started for {agent_name}/{chat_id}") def _stop_typing(self, agent_name: str, chat_id: str) -> None: - """Stop the native typing indicator loop.""" + """Stop the native typing indicator loop. + + Safe to call defensively — no-op (and silent) when no task is active. + """ key = (agent_name, chat_id) task = self._typing_tasks.pop(key, None) if task and not task.done(): task.cancel() - _log(f"broker: typing indicator stopped for {agent_name}/{chat_id}") + _log(f"broker: typing indicator stopped for {agent_name}/{chat_id}") def _stop_all_typing(self, agent_name: str) -> None: """Stop ALL typing indicator loops for an agent (used on disconnect/stop).""" diff --git a/tests/test_broker.py b/tests/test_broker.py index 910ce97c..277a86bb 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -126,6 +126,76 @@ async def test_inject_agent_message_does_not_stamp_when_not_connected(self): finally: tmpdir.cleanup() + @pytest.mark.asyncio + async def test_stop_typing_cancels_active_task(self): + """_stop_typing must cancel a running typing-loop task.""" + import asyncio + + tmpdir, _, broker, _, _ = self._make_broker() + try: + async def _fake_loop(): + await asyncio.sleep(60) + + task = asyncio.create_task(_fake_loop()) + # Yield once so the task actually starts before we cancel it. + await asyncio.sleep(0) + broker._typing_tasks[("barsik", "6770805286")] = task + + broker._stop_typing("barsik", "6770805286") + # Yield until cancellation has propagated to the task. + try: + await task + except asyncio.CancelledError: + pass + + assert ("barsik", "6770805286") not in broker._typing_tasks + assert task.cancelled() + finally: + tmpdir.cleanup() + + def test_stop_typing_is_silent_noop_when_no_task(self): + """Defensive _stop_typing calls (e.g. after every outreach send) must be no-op.""" + tmpdir, _, broker, _, _ = self._make_broker() + try: + # No task registered for this chat — should not raise, should not log. + broker._stop_typing("barsik", "never-typed-here") + assert ("barsik", "never-typed-here") not in broker._typing_tasks + finally: + tmpdir.cleanup() + + @pytest.mark.asyncio + async def test_stop_typing_scoped_per_chat(self): + """Stopping typing for one chat must not affect other chats for the same agent.""" + import asyncio + + tmpdir, _, broker, _, _ = self._make_broker() + try: + async def _fake_loop(): + await asyncio.sleep(60) + + task_a = asyncio.create_task(_fake_loop()) + task_b = asyncio.create_task(_fake_loop()) + broker._typing_tasks[("barsik", "chat-A")] = task_a + broker._typing_tasks[("barsik", "chat-B")] = task_b + + broker._stop_typing("barsik", "chat-A") + # Yield once so cancellation propagates. + await asyncio.sleep(0) + + assert ("barsik", "chat-A") not in broker._typing_tasks + assert ("barsik", "chat-B") in broker._typing_tasks + assert task_a.cancelled() or task_a.done() + assert not task_b.done() + + # Cleanup + task_b.cancel() + try: + await task_b + except asyncio.CancelledError: + pass + finally: + tmpdir.cleanup() + def test_remember_message_context_tracks_voice_and_reply_metadata(self): tmpdir, _, broker, _, _ = self._make_broker() try: From e7042a8c3ec7970a690e065f68544c0ce40329ab Mon Sep 17 00:00:00 2001 From: Oleg Date: Tue, 5 May 2026 18:06:00 -0700 Subject: [PATCH 09/24] feat(admin): add force=true to /admin/update to bypass dirty tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the working tree has local mods to tracked files (e.g. stale frontend-dist build artifacts from a prior build), git pull aborts. Recovery previously required out-of-band `git checkout -- ` access. force=True now runs `git checkout -- .` before the pull to discard local mods to TRACKED files only. Untracked files (.env, local notes) are preserved — no `git clean`. dry_run is unaffected. Plumbed through pinky_self update_and_restart MCP tool with a force kwarg; response surfaces forced_reset + forced_files for visibility. Closes pinkybot task #77. Co-Authored-By: Claude Opus 4.7 --- src/pinky_daemon/api.py | 32 ++++- src/pinky_self/server.py | 24 +++- tests/test_admin_update.py | 241 +++++++++++++++++++++++++++++++++ tests/test_pinky_self_tools.py | 40 ++++++ 4 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 tests/test_admin_update.py diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index dea7302c..bb222cea 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -8239,7 +8239,7 @@ async def set_release_channel(channel: str = "stable"): # ── Admin: Update & Restart ─────────────────────────── @app.post("/admin/update") - async def admin_update(branch: str = "", dry_run: bool = False): + async def admin_update(branch: str = "", dry_run: bool = False, force: bool = False): """Pull latest code, rebuild if needed, and restart the daemon. The process manager (launchctl/systemd) must be installed for @@ -8249,6 +8249,11 @@ async def admin_update(branch: str = "", dry_run: bool = False): Beta channel: pulls HEAD of the beta branch. Branch defaults to PINKYBOT_CHANNEL env var ("stable" -> release tags, "beta" -> beta branch), falling back to "stable" if unset. + + force=True discards local modifications to TRACKED files + (`git checkout -- .`) before pulling, to recover from a dirty working + tree (e.g. stale build artifacts blocking the update). Untracked files + are preserved — no `git clean`. Ignored when dry_run=True. """ import shutil import subprocess as sp @@ -8355,6 +8360,29 @@ async def admin_update(branch: str = "", dry_run: bool = False): except Exception as e: _log(f"admin: branch checkout warning: {e}") + # Force mode: discard local mods to TRACKED files before pulling. + # Untracked files (e.g. .env, local notes) are NOT touched. + forced_reset = False + forced_files: list[str] = [] + if force: + try: + dirty = sp.check_output( + ["git", "diff", "--name-only", "HEAD"], + cwd=repo_dir, stderr=sp.DEVNULL, timeout=10, + ).decode().strip() + if dirty: + forced_files = [f for f in dirty.splitlines() if f.strip()] + sp.check_output( + ["git", "checkout", "--", "."], + cwd=repo_dir, stderr=sp.STDOUT, timeout=30, + ) + forced_reset = True + _log(f"admin: force=True reset {len(forced_files)} tracked file(s): {forced_files[:10]}") + except sp.CalledProcessError as e: + return {"error": f"force reset failed: {e.output.decode()[:500]}"} + except Exception as e: + _log(f"admin: force reset warning: {e}") + try: sp.check_output( ["git", "pull", "origin", branch], @@ -8429,6 +8457,8 @@ async def admin_update(branch: str = "", dry_run: bool = False): "deps_rebuilt": deps_rebuilt, "frontend_rebuilt": frontend_rebuilt, "frontend_error": frontend_error or None, + "forced_reset": forced_reset, + "forced_files": forced_files, "restarting": before_hash != after_hash or deps_rebuilt, } diff --git a/src/pinky_self/server.py b/src/pinky_self/server.py index 216aa2e2..e34c8df8 100644 --- a/src/pinky_self/server.py +++ b/src/pinky_self/server.py @@ -1933,11 +1933,21 @@ def check_for_updates() -> str: return "\n".join(parts) @mcp.tool() - def update_and_restart(branch: str = "") -> str: - """Pull latest code, rebuild if needed, and restart the daemon.""" + def update_and_restart(branch: str = "", force: bool = False) -> str: + """Pull latest code, rebuild if needed, and restart the daemon. + + force=True discards local mods to TRACKED files before pulling. + Use to recover from a dirty working tree (e.g. stale build artifacts). + Untracked files are preserved. + """ url = "/admin/update" + params = [] if branch: - url += f"?branch={branch}" + params.append(f"branch={branch}") + if force: + params.append("force=true") + if params: + url += "?" + "&".join(params) result = _api("POST", url) if "error" in result: return f"Update failed: {result['error']}" @@ -1951,6 +1961,14 @@ def update_and_restart(branch: str = "") -> str: else: parts = [f"Update applied: {result.get('before_hash', '?')} -> {result.get('after_hash', '?')}"] + if result.get("forced_reset"): + forced_files = result.get("forced_files", []) + parts.append(f"\nForce reset: discarded local mods to {len(forced_files)} tracked file(s).") + for f in forced_files[:5]: + parts.append(f" - {f}") + if len(forced_files) > 5: + parts.append(f" ... and {len(forced_files) - 5} more") + commits = result.get("commits", []) if commits: parts.append(f"\nChanges ({len(commits)} commit(s)):") diff --git a/tests/test_admin_update.py b/tests/test_admin_update.py new file mode 100644 index 00000000..d1595bfd --- /dev/null +++ b/tests/test_admin_update.py @@ -0,0 +1,241 @@ +"""Tests for POST /admin/update — covers force flag behavior.""" + +from __future__ import annotations + +import os +import subprocess as sp +import tempfile +from unittest.mock import patch + +from fastapi.testclient import TestClient + + +def _make_client(): + from pinky_daemon.api import create_api + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + app = create_api(max_sessions=10, default_working_dir="/tmp", db_path=path) + return TestClient(app) + + +class _GitMock: + """Programmable subprocess.check_output side_effect for git commands. + + Records every call so tests can assert on order/presence. + """ + + def __init__( + self, + *, + dirty_files: list[str] | None = None, + before_hash: str = "abc1234", + after_hash: str = "def5678", + branch: str = "beta", + ): + self.calls: list[list[str]] = [] + self.dirty_files = dirty_files or [] + self.before_hash = before_hash + self.after_hash = after_hash + self.branch = branch + self._hash_call = 0 + + def __call__(self, cmd, **kwargs): + self.calls.append(list(cmd)) + + # git rev-parse --short HEAD — first call returns before, second returns after + if cmd[:3] == ["git", "rev-parse", "--short"]: + self._hash_call += 1 + val = self.before_hash if self._hash_call == 1 else self.after_hash + return (val + "\n").encode() + + # git describe --tags --exact-match HEAD — pretend untagged + if cmd[:2] == ["git", "describe"]: + raise sp.CalledProcessError(128, cmd, output=b"") + + # git fetch origin --tags + if cmd[:3] == ["git", "fetch", "origin"]: + return b"" + + # git tag --sort=... -l ... (only for main / use_release_tags) + if cmd[:2] == ["git", "tag"]: + return b"" + + # git rev-parse --is-shallow-repository + if cmd[:3] == ["git", "rev-parse", "--is-shallow-repository"]: + return b"false\n" + + # git rev-parse --abbrev-ref HEAD + if cmd[:3] == ["git", "rev-parse", "--abbrev-ref"]: + return (self.branch + "\n").encode() + + # git diff --name-only HEAD (used by force-mode dirty check) + if cmd[:4] == ["git", "diff", "--name-only", "HEAD"]: + joined = "\n".join(self.dirty_files) + if joined: + joined += "\n" + return joined.encode() + + # git checkout -- . (force reset of tracked files) + if cmd[:3] == ["git", "checkout", "--"]: + return b"" + + # git checkout (used when on detached HEAD / wrong branch) + if cmd[:2] == ["git", "checkout"]: + return b"" + + # git pull origin + if cmd[:3] == ["git", "pull", "origin"]: + return b"" + + # git log --oneline ... (commit summary or dry-run pending list) + if cmd[:3] == ["git", "log", "--oneline"]: + return b"abc1234 feat: example\n" + + # git diff --name-only -- pyproject.toml + if cmd[:3] == ["git", "diff", "--name-only"]: + return b"" + + return b"" + + def did_force_reset(self) -> bool: + return any(c[:4] == ["git", "checkout", "--", "."] for c in self.calls) + + def did_pull(self) -> bool: + return any(c[:3] == ["git", "pull", "origin"] for c in self.calls) + + def did_clean(self) -> bool: + return any(c[:2] == ["git", "clean"] for c in self.calls) + + +class TestAdminUpdateForce: + """Verify the force=True flag from task #77.""" + + def test_force_false_is_default(self): + """No force param → no `git checkout -- .` is invoked.""" + gm = _GitMock(dirty_files=["frontend-dist/index.html"]) + with ( + patch("subprocess.check_output", side_effect=gm), + patch("shutil.which", return_value=None), + patch("os.kill"), + ): + client = _make_client() + r = client.post("/admin/update?branch=beta") + assert r.status_code == 200 + body = r.json() + assert body.get("updated") is True + assert body.get("forced_reset") is False + assert body.get("forced_files") == [] + assert not gm.did_force_reset() + assert gm.did_pull() + + def test_force_true_resets_dirty_tracked_files(self): + """force=True with a dirty tree → checkout -- . runs before pull.""" + dirty = ["frontend-dist/index.html", "frontend-dist/assets/app.js"] + gm = _GitMock(dirty_files=dirty) + with ( + patch("subprocess.check_output", side_effect=gm), + patch("shutil.which", return_value=None), + patch("os.kill"), + ): + client = _make_client() + r = client.post("/admin/update?branch=beta&force=true") + assert r.status_code == 200 + body = r.json() + assert body.get("forced_reset") is True + assert body.get("forced_files") == dirty + assert gm.did_force_reset() + + # Critical ordering: reset must happen BEFORE the pull + checkout_idx = next( + i for i, c in enumerate(gm.calls) if c[:4] == ["git", "checkout", "--", "."] + ) + pull_idx = next( + i for i, c in enumerate(gm.calls) if c[:3] == ["git", "pull", "origin"] + ) + assert checkout_idx < pull_idx, "force reset must precede git pull" + + def test_force_true_clean_tree_no_reset(self): + """force=True but tree is clean → no checkout invoked, forced_reset=False.""" + gm = _GitMock(dirty_files=[]) + with ( + patch("subprocess.check_output", side_effect=gm), + patch("shutil.which", return_value=None), + patch("os.kill"), + ): + client = _make_client() + r = client.post("/admin/update?branch=beta&force=true") + assert r.status_code == 200 + body = r.json() + assert body.get("forced_reset") is False + assert body.get("forced_files") == [] + assert not gm.did_force_reset() + assert gm.did_pull() + + def test_force_never_runs_git_clean(self): + """Untracked files must be preserved — never invoke `git clean`.""" + gm = _GitMock(dirty_files=["some/file.py"]) + with ( + patch("subprocess.check_output", side_effect=gm), + patch("shutil.which", return_value=None), + patch("os.kill"), + ): + client = _make_client() + client.post("/admin/update?branch=beta&force=true") + assert not gm.did_clean(), "force must not delete untracked files" + + def test_force_ignored_in_dry_run(self): + """dry_run=True returns before the destructive force block runs.""" + gm = _GitMock(dirty_files=["frontend-dist/index.html"]) + with ( + patch("subprocess.check_output", side_effect=gm), + patch("shutil.which", return_value=None), + ): + client = _make_client() + r = client.post("/admin/update?branch=beta&force=true&dry_run=true") + assert r.status_code == 200 + body = r.json() + assert body.get("dry_run") is True + # No destructive ops in dry_run, regardless of force + assert not gm.did_force_reset() + assert not gm.did_pull() + + +class TestAdminUpdateBaseline: + """Sanity coverage for the existing endpoint paths.""" + + def test_dry_run_reports_pending_commits(self): + gm = _GitMock(dirty_files=[]) + with ( + patch("subprocess.check_output", side_effect=gm), + patch("shutil.which", return_value=None), + ): + client = _make_client() + r = client.post("/admin/update?branch=beta&dry_run=true") + assert r.status_code == 200 + body = r.json() + assert body.get("dry_run") is True + assert body.get("branch") == "beta" + # _GitMock returns one canned "feat: example" commit for git log + assert body.get("pending_commits") == 1 + assert body.get("up_to_date") is False + + def test_pull_failure_returns_error(self): + """If git pull errors, endpoint returns {'error': ...}.""" + + def fail_on_pull(cmd, **kwargs): + if cmd[:3] == ["git", "pull", "origin"]: + raise sp.CalledProcessError(1, cmd, output=b"merge conflict in foo") + # Delegate everything else to a clean mock + return _GitMock(dirty_files=[])(cmd, **kwargs) + + with ( + patch("subprocess.check_output", side_effect=fail_on_pull), + patch("shutil.which", return_value=None), + patch("os.kill"), + ): + client = _make_client() + r = client.post("/admin/update?branch=beta") + assert r.status_code == 200 + body = r.json() + assert "error" in body + assert "git pull failed" in body["error"] diff --git a/tests/test_pinky_self_tools.py b/tests/test_pinky_self_tools.py index 1d6cf86a..566b0875 100644 --- a/tests/test_pinky_self_tools.py +++ b/tests/test_pinky_self_tools.py @@ -1473,6 +1473,46 @@ def test_error(self, srv): result = _tools(srv)["update_and_restart"]() assert "failed" in result.lower() + def test_force_reset_in_output(self, srv): + """force=True → response shows discarded files in human output.""" + resp = { + "updated": True, + "before_hash": "abc123", + "after_hash": "def456", + "commits": [], + "deps_rebuilt": False, + "frontend_rebuilt": False, + "forced_reset": True, + "forced_files": ["frontend-dist/index.html", "frontend-dist/assets/app.js"], + "restarting": True, + } + with _ok(resp): + result = _tools(srv)["update_and_restart"](force=True) + assert "Force reset" in result + assert "2 tracked file" in result + assert "frontend-dist/index.html" in result + + def test_force_url_includes_param(self, srv): + """force=True → request URL includes force=true query param.""" + captured: dict = {} + + def _urlopen(req, timeout=30): + captured["url"] = req.full_url if hasattr(req, "full_url") else str(req) + body = json.dumps({"updated": True, "before_hash": "a", "after_hash": "b", + "commits": [], "deps_rebuilt": False, "frontend_rebuilt": False, + "restarting": False}).encode() + resp = MagicMock() + resp.read.return_value = body + resp.__enter__ = MagicMock(return_value=resp) + resp.__exit__ = MagicMock(return_value=False) + return resp + + with patch("urllib.request.urlopen", side_effect=_urlopen): + _tools(srv)["update_and_restart"](branch="beta", force=True) + + assert "force=true" in captured["url"] + assert "branch=beta" in captured["url"] + # ── restart_daemon ──────────────────────────────────────────────────────────── From bb956be1e97059102b4f510635610c7d68bd7298 Mon Sep 17 00:00:00 2001 From: Oleg Date: Tue, 5 May 2026 19:07:13 -0700 Subject: [PATCH 10/24] fix(discord): prime-on-discover delivers fresh test message + DM owner-notify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from Brad's first Discord live-verify (PR #386 deploy): 1. Fresh first-test message swallowed by replay-prevention floor. When a bot joined a brand-new server and a user sent a test message right before the next discovery sweep, that message became the floor and was silently skipped — bad first-contact UX. Fix in BrokerDiscordPoller._refresh_channels: if the most-recent message at prime time is <30s old AND from a non-bot, back the floor off by 1 snowflake (Discord IDs are monotonic) so the next poll picks it up via `?after=`. Bot messages and stale messages keep the replay-prevention behavior. Always log the priming with timestamp + age so users see exactly what happened. 2. Broker tried to DM owner about new Discord users by sending to the user_id as a channel_id → "Discord API error 404: Unknown Channel". Discord requires opening a DM channel via POST /users/@me/channels first. Fix in DiscordAdapter: new open_dm_channel(user_id) helper, plus send_message catches 404 Unknown Channel and transparently resolves user_id → DM channel + retries. Resolution is cached per recipient so subsequent sends skip the lookup. 7 new tests (4 in test_discord.py, 3 in test_discord_poller.py). Full suite: 1740 pass, 0 fail. Ruff clean. Closes pinkybot task #75. Co-Authored-By: Claude Opus 4.7 --- src/pinky_daemon/pollers.py | 57 ++++++++++++++++-- src/pinky_outreach/discord.py | 46 ++++++++++++++- tests/test_discord.py | 108 ++++++++++++++++++++++++++++++++++ tests/test_discord_poller.py | 99 +++++++++++++++++++++++++++++++ 4 files changed, 304 insertions(+), 6 deletions(-) diff --git a/src/pinky_daemon/pollers.py b/src/pinky_daemon/pollers.py index d783c95d..ee8de6ab 100644 --- a/src/pinky_daemon/pollers.py +++ b/src/pinky_daemon/pollers.py @@ -9,12 +9,19 @@ import asyncio import sys +from datetime import datetime, timezone from typing import TYPE_CHECKING from pinky_daemon.message_handler import InboundMessage, MessageHandler from pinky_outreach.discord import DiscordAdapter, DiscordError, DiscordRateLimited from pinky_outreach.telegram import TelegramAdapter, TelegramError +# Threshold below which a "most recent message" found during channel-priming +# is treated as a real first-test inbound rather than as a replay-prevention +# floor. Discord first-contact UX: if a user sends a test message within this +# window before discovery sweeps, we still want to deliver it. +_PRIME_FRESH_WINDOW_SECONDS = 30.0 + if TYPE_CHECKING: from pinky_outreach.types import Chat @@ -542,6 +549,13 @@ async def _refresh_channels(self, *, verbose: bool) -> None: # Prime last_id for newly-discovered channels: fetch the most recent # message and treat its id as the baseline so we don't replay history. + # Exception: if that message is a fresh (< _PRIME_FRESH_WINDOW_SECONDS) + # non-bot message, treat it as a real first-test inbound — back the + # floor up by 1 snowflake so the next poll picks it up and delivers it. + # This avoids the "user sends test, discovery silently swallows it" + # first-contact UX trap. (Discord IDs are monotonic snowflakes; + # `?after=` resolves to "messages with id > id-1", i.e. this + # message itself.) for ch in added: try: recent = await loop.run_in_executor( @@ -551,12 +565,47 @@ async def _refresh_channels(self, *, verbose: bool) -> None: except DiscordError: # Channel might be unreadable; skip it for now, retry next discovery continue - if recent: - # get_messages returns newest-first - self._last_id[ch] = recent[0].message_id - else: + if not recent: # Empty channel — start from "0", which sorts before any real snowflake self._last_id[ch] = "0" + _log( + f"discord-poller[{self._agent_name}]: primed channel {ch} " + f"(empty — first message will be delivered)" + ) + continue + + # get_messages returns newest-first + msg = recent[0] + now = datetime.now(timezone.utc) + try: + age_sec = (now - msg.timestamp).total_seconds() + except (TypeError, ValueError): + age_sec = float("inf") + is_bot = bool((msg.metadata or {}).get("is_bot", False)) + + if age_sec < _PRIME_FRESH_WINDOW_SECONDS and not is_bot: + # Fresh first-contact message — back the floor up by 1 so + # the next poll fetches and delivers it. + try: + backed_off = str(int(msg.message_id) - 1) + except (TypeError, ValueError): + # Non-numeric id (shouldn't happen on Discord) — fall back + # to using the message itself as the floor. + backed_off = msg.message_id + self._last_id[ch] = backed_off + _log( + f"discord-poller[{self._agent_name}]: primed channel {ch} " + f"with floor {backed_off} (fresh msg {msg.message_id} from " + f"{msg.sender}, age {age_sec:.0f}s — will deliver on next poll)" + ) + else: + self._last_id[ch] = msg.message_id + _log( + f"discord-poller[{self._agent_name}]: primed channel {ch} " + f"with floor {msg.message_id} (most-recent msg age " + f"{age_sec:.0f}s, is_bot={is_bot} — first message AFTER " + f"this will be delivered)" + ) for ch in removed: self._last_id.pop(ch, None) diff --git a/src/pinky_outreach/discord.py b/src/pinky_outreach/discord.py index 36dbb4d0..ff4cdb1e 100644 --- a/src/pinky_outreach/discord.py +++ b/src/pinky_outreach/discord.py @@ -71,6 +71,9 @@ def __init__( timeout=timeout, ) self._bot_user: dict | None = None + # Cache user_id → DM channel_id resolutions so we only call + # POST /users/@me/channels once per recipient. + self._dm_channel_cache: dict[str, str] = {} def close(self) -> None: self._client.close() @@ -130,6 +133,24 @@ def send_typing(self, channel_id: str) -> None: # ── Sending ────────────────────────────────────────────── + def open_dm_channel(self, user_id: str) -> str: + """Open (or look up cached) a DM channel with a user. Returns channel_id. + + Discord requires explicitly opening a DM via POST /users/@me/channels + with `{recipient_id: user_id}`. The result is a channel_id that can + then be used with send_message / etc. The mapping is stable per bot, + so we cache it. + """ + cached = self._dm_channel_cache.get(user_id) + if cached: + return cached + result = self._request( + "POST", "/users/@me/channels", json={"recipient_id": user_id}, + ) + channel_id = result["id"] + self._dm_channel_cache[user_id] = channel_id + return channel_id + def send_message( self, channel_id: str, @@ -137,12 +158,33 @@ def send_message( *, reply_to: str = "", ) -> Message: - """Send a message to a Discord channel.""" + """Send a message to a Discord channel. + + If `channel_id` looks like a user_id (Discord returns 404 Unknown + Channel), transparently opens a DM channel with that user and + retries — this lets callers pass user_ids for owner-notify flows + where they only know the recipient's user_id. + """ payload: dict = {"content": content} if reply_to: payload["message_reference"] = {"message_id": reply_to} - result = self._request("POST", f"/channels/{channel_id}/messages", json=payload) + try: + result = self._request( + "POST", f"/channels/{channel_id}/messages", json=payload, + ) + except DiscordError as e: + if e.status_code == 404 and "Unknown Channel" in str(e): + # Treat channel_id as a user_id and try DMing them. + # Bounded — open_dm_channel will surface its own error + # if user_id is also invalid; we don't loop again. + dm_channel_id = self.open_dm_channel(channel_id) + result = self._request( + "POST", f"/channels/{dm_channel_id}/messages", json=payload, + ) + channel_id = dm_channel_id + else: + raise return Message( platform=Platform.discord, diff --git a/tests/test_discord.py b/tests/test_discord.py index dc9c9f12..b78a7729 100644 --- a/tests/test_discord.py +++ b/tests/test_discord.py @@ -84,6 +84,114 @@ def test_send_message_error(self): assert exc.value.status_code == 403 adapter.close() + def test_open_dm_channel_caches(self): + """open_dm_channel calls /users/@me/channels once per recipient and caches.""" + adapter = self._make_adapter() + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = {"id": "dm-channel-abc", "type": 1} + adapter._client.request = MagicMock(return_value=resp) + + ch1 = adapter.open_dm_channel("user-754") + ch2 = adapter.open_dm_channel("user-754") + + assert ch1 == "dm-channel-abc" + assert ch2 == "dm-channel-abc" + # Only one HTTP call thanks to the cache + assert adapter._client.request.call_count == 1 + # Verify it hit the right endpoint with the right body + call = adapter._client.request.call_args + assert call.args[0] == "POST" + assert call.args[1] == "/users/@me/channels" + assert call.kwargs["json"] == {"recipient_id": "user-754"} + adapter.close() + + def test_send_message_falls_back_to_dm_on_404_unknown_channel(self): + """If channel_id is actually a user_id, send_message opens a DM and retries.""" + adapter = self._make_adapter() + + send_404 = MagicMock() + send_404.status_code = 404 + send_404.json.return_value = {"message": "Unknown Channel", "code": 10003} + + open_dm_ok = MagicMock() + open_dm_ok.status_code = 200 + open_dm_ok.json.return_value = {"id": "dm-channel-xyz", "type": 1} + + send_ok = MagicMock() + send_ok.status_code = 200 + send_ok.json.return_value = { + "id": "msg-9999", + "channel_id": "dm-channel-xyz", + "content": "hi owner", + "timestamp": "2026-05-05T19:00:00+00:00", + } + + adapter._client.request = MagicMock(side_effect=[send_404, open_dm_ok, send_ok]) + + msg = adapter.send_message("754027672526389310", "hi owner") + assert msg.message_id == "msg-9999" + # chat_id should reflect the resolved DM channel, not the user_id + assert msg.chat_id == "dm-channel-xyz" + + # Three calls: failed POST → open DM → retry POST + assert adapter._client.request.call_count == 3 + calls = adapter._client.request.call_args_list + assert calls[0].args == ("POST", "/channels/754027672526389310/messages") + assert calls[1].args == ("POST", "/users/@me/channels") + assert calls[1].kwargs["json"] == {"recipient_id": "754027672526389310"} + assert calls[2].args == ("POST", "/channels/dm-channel-xyz/messages") + + # Cache populated for next time + assert adapter._dm_channel_cache["754027672526389310"] == "dm-channel-xyz" + adapter.close() + + def test_send_message_404_other_message_does_not_fallback(self): + """404 with a different message (e.g. Unknown Message) should NOT trigger DM open.""" + adapter = self._make_adapter() + + resp = MagicMock() + resp.status_code = 404 + resp.json.return_value = {"message": "Unknown Message", "code": 10008} + adapter._client.request = MagicMock(return_value=resp) + + with pytest.raises(DiscordError) as exc: + adapter.send_message("99999", "hi", reply_to="missing-msg-id") + assert exc.value.status_code == 404 + assert "Unknown Message" in str(exc.value) + # No DM resolution attempted + assert adapter._client.request.call_count == 1 + adapter.close() + + def test_send_message_dm_fallback_uses_cached_channel(self): + """Second send to the same user_id reuses the cached DM channel — no /users/@me/channels call.""" + adapter = self._make_adapter() + # Pre-populate the cache as if a previous send had resolved the DM + adapter._dm_channel_cache["user-754"] = "dm-channel-abc" + + send_404 = MagicMock() + send_404.status_code = 404 + send_404.json.return_value = {"message": "Unknown Channel", "code": 10003} + + send_ok = MagicMock() + send_ok.status_code = 200 + send_ok.json.return_value = { + "id": "msg-2", + "channel_id": "dm-channel-abc", + "content": "second msg", + "timestamp": "2026-05-05T19:01:00+00:00", + } + + adapter._client.request = MagicMock(side_effect=[send_404, send_ok]) + + msg = adapter.send_message("user-754", "second msg") + assert msg.message_id == "msg-2" + # Only the failed initial POST + the retry — no /users/@me/channels lookup + assert adapter._client.request.call_count == 2 + calls = adapter._client.request.call_args_list + assert "/users/@me/channels" not in (calls[0].args[1], calls[1].args[1]) + adapter.close() + def test_send_file_success_uses_httpx_multipart_header(self, tmp_path): """Regression: passing Content-Type=None makes httpx raise before sending.""" adapter = self._make_adapter() diff --git a/tests/test_discord_poller.py b/tests/test_discord_poller.py index 5a56da08..723b4450 100644 --- a/tests/test_discord_poller.py +++ b/tests/test_discord_poller.py @@ -175,6 +175,105 @@ async def test_empty_channel_primes_with_zero_sentinel(self, mock_adapter, mock_ assert poller._last_id["chan-A"] == "0" + @pytest.mark.asyncio + async def test_priming_delivers_fresh_first_test_message( + self, mock_adapter, mock_broker, + ): + """Fresh (<30s) non-bot message at prime time → backed-off floor so it gets delivered. + + Scenario: bot joins server, user sends test message, then discovery + sweep runs ~seconds later. Without this fix, the test message becomes + the floor and is silently dropped. + """ + from datetime import datetime, timezone + fresh_msg = Message( + platform=Platform.discord, + chat_id="chan-A", + sender="brad", + content="hi bot", + # Brand new — well within the 30s freshness window + timestamp=datetime.now(timezone.utc), + message_id="200", + metadata={"author_id": "user-1", "is_bot": False}, + ) + mock_adapter.get_messages.side_effect = [ + [fresh_msg], # priming + [fresh_msg], # poll picks it up because floor was backed off to "199" + ] + + poller = self._make_poller(mock_adapter, mock_broker) + await poller._refresh_channels(verbose=True) + + # Floor must be id-1 so the next poll fetches this message + assert poller._last_id["chan-A"] == "199" + + await poller._poll_once() + await asyncio.sleep(0) + + # The fresh test message should have been delivered through the broker + assert mock_broker.handle_inbound.await_count == 1 + delivered = mock_broker.handle_inbound.await_args.args[0] + assert delivered.message_id == "200" + assert delivered.content == "hi bot" + + @pytest.mark.asyncio + async def test_priming_old_message_uses_message_as_floor( + self, mock_adapter, mock_broker, + ): + """Stale (>30s) most-recent message → use it as floor, NOT delivered.""" + from datetime import datetime, timezone + old_msg = Message( + platform=Platform.discord, + chat_id="chan-A", + sender="brad", + content="ancient", + timestamp=datetime(2020, 1, 1, tzinfo=timezone.utc), + message_id="100", + metadata={"author_id": "user-1", "is_bot": False}, + ) + mock_adapter.get_messages.side_effect = [ + [old_msg], # priming + [], # poll finds nothing new + ] + + poller = self._make_poller(mock_adapter, mock_broker) + await poller._refresh_channels(verbose=True) + await poller._poll_once() + await asyncio.sleep(0) + + # Floor is the message id itself — replay-prevention behavior preserved + assert poller._last_id["chan-A"] == "100" + mock_broker.handle_inbound.assert_not_called() + + @pytest.mark.asyncio + async def test_priming_fresh_bot_message_does_not_back_off( + self, mock_adapter, mock_broker, + ): + """Fresh-but-bot message → use as floor (don't loop on bot self-reply).""" + from datetime import datetime, timezone + fresh_bot = Message( + platform=Platform.discord, + chat_id="chan-A", + sender="OtherBot", + content="bot chatter", + timestamp=datetime.now(timezone.utc), + message_id="300", + metadata={"author_id": "bot-other", "is_bot": True}, + ) + mock_adapter.get_messages.side_effect = [ + [fresh_bot], # priming + [], # poll + ] + + poller = self._make_poller(mock_adapter, mock_broker) + await poller._refresh_channels(verbose=True) + await poller._poll_once() + await asyncio.sleep(0) + + # Bot message is the floor as-is — not backed off, not delivered + assert poller._last_id["chan-A"] == "300" + mock_broker.handle_inbound.assert_not_called() + @pytest.mark.asyncio async def test_poll_drops_after_filter_for_zero_sentinel(self, mock_adapter, mock_broker): """When last_id='0' (empty channel), the poll must NOT pass after=0 to the adapter.""" From c51b57893637b1b0635e1355ec17c10eac50d53a Mon Sep 17 00:00:00 2001 From: Oleg Date: Sat, 25 Apr 2026 03:47:32 -0700 Subject: [PATCH 11/24] fix(admin): rebuild deps for system-python deployments + force_deps escape hatch The admin_update endpoint only ran `pip install` if `.venv/bin/pip` existed, which silently skipped dependency rebuilds on system-python deployments (Homebrew/Debian). Result: pyproject pin bumps merged to main but the live daemon kept running stale package versions. Fix: - Fall back to `sys.executable -m pip install --break-system-packages` when no in-tree venv is present (PEP 668 externally-managed envs). - Trigger rebuild when pyproject.toml or uv.lock changed in the pulled diff, OR when the new `force_deps=true` query param is set. - Surface `deps_error` in the response so silent failures stop being silent. - Wire `force_deps` through the `update_and_restart` MCP tool. Tests: added 3 cases covering force_deps URL wiring, branch+force_deps combo, and deps_error surfacing. All 172 pinky_self tool tests pass. Co-Authored-By: Claude Opus 4 --- src/pinky_daemon/api.py | 66 +++++++++++++++++++++++++--------- src/pinky_self/server.py | 15 +++++++- tests/test_pinky_self_tools.py | 56 +++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 17 deletions(-) diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index bb222cea..765ae83e 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -8239,7 +8239,12 @@ async def set_release_channel(channel: str = "stable"): # ── Admin: Update & Restart ─────────────────────────── @app.post("/admin/update") - async def admin_update(branch: str = "", dry_run: bool = False, force: bool = False): + async def admin_update( + branch: str = "", + dry_run: bool = False, + force: bool = False, + force_deps: bool = False, + ): """Pull latest code, rebuild if needed, and restart the daemon. The process manager (launchctl/systemd) must be installed for @@ -8254,9 +8259,15 @@ async def admin_update(branch: str = "", dry_run: bool = False, force: bool = Fa (`git checkout -- .`) before pulling, to recover from a dirty working tree (e.g. stale build artifacts blocking the update). Untracked files are preserved — no `git clean`. Ignored when dry_run=True. + + force_deps=True: reinstall dependencies even when git HEAD didn't + change. Use this when installed package versions have drifted from + pyproject.toml (e.g., manual pip install bypassed, or the daemon was + seeded from a stale image). """ import shutil import subprocess as sp + import sys if not branch: channel = os.environ.get("PINKYBOT_CHANNEL", "stable") @@ -8408,23 +8419,45 @@ async def admin_update(branch: str = "", dry_run: bool = False, force: bool = Fa except Exception: summary = "" - # Detect dependency changes + # Detect dependency changes — rebuild whenever pyproject.toml or uv.lock + # changed in the pull, or when force_deps=True is passed (escape hatch + # for installed-vs-pinned drift that git diff can't see). deps_rebuilt = False + deps_error = "" try: - changed = sp.check_output( - ["git", "diff", "--name-only", before_hash, after_hash, "--", "pyproject.toml"], - cwd=repo_dir, stderr=sp.DEVNULL, timeout=10, - ).decode().strip() - if changed: - venv_pip = str(Path(repo_dir) / ".venv" / "bin" / "pip") - if Path(venv_pip).exists(): - sp.check_output( - [venv_pip, "install", "-e", ".[all]", "--quiet"], - cwd=repo_dir, stderr=sp.STDOUT, timeout=120, - ) - deps_rebuilt = True - except Exception: - pass + if before_hash != after_hash: + changed = sp.check_output( + ["git", "diff", "--name-only", before_hash, after_hash, "--", + "pyproject.toml", "uv.lock"], + cwd=repo_dir, stderr=sp.DEVNULL, timeout=10, + ).decode().strip() + else: + changed = "" + + if changed or force_deps: + # Prefer project venv pip if present, else use the running daemon's + # interpreter (sys.executable). This works for both venv and + # system-python deployments — the prior `.venv/bin/pip`-only path + # silently skipped rebuilds on system-python hosts. + venv_pip = Path(repo_dir) / ".venv" / "bin" / "pip" + if venv_pip.exists(): + pip_cmd = [str(venv_pip), "install", "-e", ".[all]", "--quiet"] + else: + # PEP 668: system pythons (Homebrew, Debian) mark themselves + # externally-managed. --break-system-packages lets us install + # into the same env the daemon imports from. + pip_cmd = [ + sys.executable, "-m", "pip", "install", + "-e", ".[all]", "--quiet", "--break-system-packages", + ] + sp.check_output(pip_cmd, cwd=repo_dir, stderr=sp.STDOUT, timeout=180) + deps_rebuilt = True + except sp.CalledProcessError as e: + deps_error = f"pip install failed: {e.output.decode()[:500] if e.output else e}" + _log(f"admin: {deps_error}") + except Exception as e: + deps_error = f"deps rebuild failed: {e}" + _log(f"admin: {deps_error}") # Always rebuild frontend on update to keep compiled assets fresh frontend_rebuilt = False @@ -8455,6 +8488,7 @@ async def admin_update(branch: str = "", dry_run: bool = False, force: bool = Fa "release": target_tag if use_release_tags else None, "commits": summary.splitlines() if summary else [], "deps_rebuilt": deps_rebuilt, + "deps_error": deps_error or None, "frontend_rebuilt": frontend_rebuilt, "frontend_error": frontend_error or None, "forced_reset": forced_reset, diff --git a/src/pinky_self/server.py b/src/pinky_self/server.py index e34c8df8..897465aa 100644 --- a/src/pinky_self/server.py +++ b/src/pinky_self/server.py @@ -1933,12 +1933,21 @@ def check_for_updates() -> str: return "\n".join(parts) @mcp.tool() - def update_and_restart(branch: str = "", force: bool = False) -> str: + def update_and_restart( + branch: str = "", + force: bool = False, + force_deps: bool = False, + ) -> str: """Pull latest code, rebuild if needed, and restart the daemon. force=True discards local mods to TRACKED files before pulling. Use to recover from a dirty working tree (e.g. stale build artifacts). Untracked files are preserved. + + force_deps=True reinstalls dependencies even when git HEAD didn't + move. Useful when installed package versions have drifted from + pyproject.toml (e.g., daemon seeded from a stale image, or a + previous deploy skipped the pip install step). """ url = "/admin/update" params = [] @@ -1946,6 +1955,8 @@ def update_and_restart(branch: str = "", force: bool = False) -> str: params.append(f"branch={branch}") if force: params.append("force=true") + if force_deps: + params.append("force_deps=true") if params: url += "?" + "&".join(params) result = _api("POST", url) @@ -1977,6 +1988,8 @@ def update_and_restart(branch: str = "", force: bool = False) -> str: if result.get("deps_rebuilt"): parts.append("\nDependencies rebuilt.") + if result.get("deps_error"): + parts.append(f"\nDeps rebuild error: {result['deps_error']}") if result.get("frontend_rebuilt"): parts.append("Frontend rebuilt.") diff --git a/tests/test_pinky_self_tools.py b/tests/test_pinky_self_tools.py index 566b0875..86d41c1d 100644 --- a/tests/test_pinky_self_tools.py +++ b/tests/test_pinky_self_tools.py @@ -1513,6 +1513,62 @@ def _urlopen(req, timeout=30): assert "force=true" in captured["url"] assert "branch=beta" in captured["url"] + def test_force_deps_in_url(self, srv): + """force_deps=True must reach /admin/update as a query param.""" + captured = {} + + def _urlopen(req, timeout=30): + captured["url"] = req.full_url if hasattr(req, "full_url") else str(req) + body = json.dumps({ + "updated": True, + "before_hash": "a", "after_hash": "a", + "deps_rebuilt": True, "restarting": True, + }).encode() + resp = MagicMock() + resp.read.return_value = body + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + with patch("urllib.request.urlopen", side_effect=_urlopen): + _tools(srv)["update_and_restart"](force_deps=True) + assert "force_deps=true" in captured["url"] + + def test_force_deps_with_branch(self, srv): + """force_deps and branch must combine cleanly in the URL.""" + captured = {} + + def _urlopen(req, timeout=30): + captured["url"] = req.full_url if hasattr(req, "full_url") else str(req) + body = json.dumps({ + "updated": True, + "before_hash": "a", "after_hash": "a", + "restarting": False, + }).encode() + resp = MagicMock() + resp.read.return_value = body + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + with patch("urllib.request.urlopen", side_effect=_urlopen): + _tools(srv)["update_and_restart"](branch="beta", force_deps=True) + assert "branch=beta" in captured["url"] + assert "force_deps=true" in captured["url"] + + def test_deps_error_surfaced(self, srv): + """Backend deps_error string is surfaced to the caller.""" + with _ok({ + "updated": True, + "before_hash": "a", "after_hash": "b", + "deps_rebuilt": False, + "deps_error": "pip install failed: boom", + "restarting": True, + }): + result = _tools(srv)["update_and_restart"]() + assert "deps rebuild error" in result.lower() + assert "boom" in result + # ── restart_daemon ──────────────────────────────────────────────────────────── From 65004cebfee664f1cbeda30d593aaf970404d4aa Mon Sep 17 00:00:00 2001 From: Oleg Date: Tue, 5 May 2026 20:08:14 -0700 Subject: [PATCH 12/24] test(admin): cover force_deps + force interaction at endpoint level Adds 4 tests in TestAdminUpdateForceDepsIntegration verifying: - force_deps=True triggers pip install on a clean (no-op) pull - force=True + force_deps=True combine correctly (reset + reinstall) - default behavior skips pip when pyproject.toml is unchanged - pip install failures surface as deps_error in the response Mirrors the matching MCP-tool-level coverage in test_pinky_self_tools.py. Co-Authored-By: Claude Opus 4.7 --- tests/test_admin_update.py | 108 +++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/tests/test_admin_update.py b/tests/test_admin_update.py index d1595bfd..f34362a0 100644 --- a/tests/test_admin_update.py +++ b/tests/test_admin_update.py @@ -239,3 +239,111 @@ def fail_on_pull(cmd, **kwargs): body = r.json() assert "error" in body assert "git pull failed" in body["error"] + + +class TestAdminUpdateForceDepsIntegration: + """force_deps (PR #323) and force (PR #390) are orthogonal — verify they cooperate.""" + + def _track_pip_calls(self, gm): + """Wrap _GitMock so we also log any pip-install invocation.""" + gm.pip_calls: list[list[str]] = [] + original_call = gm.__call__ + + def wrapped(cmd, **kwargs): + cmd_list = list(cmd) + # Detect pip install: either `/path/to/pip install ...` or + # `python -m pip install ...`. The first arg is the executable. + is_pip = ( + "install" in cmd_list + and ( + cmd_list[0].endswith("/pip") + or cmd_list[0] == "pip" + or "pip" in cmd_list # for `python -m pip ...` form + ) + ) + if is_pip: + gm.pip_calls.append(cmd_list) + return b"" + return original_call(cmd, **kwargs) + + return wrapped + + def test_force_deps_triggers_pip_install_even_on_clean_pull(self): + """force_deps=True → pip install runs even when nothing changed in git.""" + gm = _GitMock(dirty_files=[], before_hash="same1", after_hash="same1") + wrapped = self._track_pip_calls(gm) + with ( + patch("subprocess.check_output", side_effect=wrapped), + patch("shutil.which", return_value=None), + patch("os.kill"), + ): + client = _make_client() + r = client.post("/admin/update?branch=beta&force_deps=true") + assert r.status_code == 200 + body = r.json() + assert body.get("deps_rebuilt") is True + assert body.get("deps_error") is None + assert len(gm.pip_calls) == 1, f"expected exactly 1 pip install call, got {gm.pip_calls}" + # Should target the editable install + assert "-e" in gm.pip_calls[0] + assert ".[all]" in gm.pip_calls[0] + + def test_force_and_force_deps_combine(self): + """Both flags together: dirty tree gets reset AND deps get rebuilt.""" + gm = _GitMock(dirty_files=["frontend-dist/index.html"]) + wrapped = self._track_pip_calls(gm) + with ( + patch("subprocess.check_output", side_effect=wrapped), + patch("shutil.which", return_value=None), + patch("os.kill"), + ): + client = _make_client() + r = client.post("/admin/update?branch=beta&force=true&force_deps=true") + assert r.status_code == 200 + body = r.json() + assert body.get("forced_reset") is True + assert body.get("forced_files") == ["frontend-dist/index.html"] + assert body.get("deps_rebuilt") is True + assert gm.did_force_reset() + assert len(gm.pip_calls) == 1 + + def test_no_force_deps_skips_pip_when_pyproject_unchanged(self): + """Default behavior: don't reinstall when pyproject.toml didn't change.""" + gm = _GitMock(dirty_files=[]) + wrapped = self._track_pip_calls(gm) + with ( + patch("subprocess.check_output", side_effect=wrapped), + patch("shutil.which", return_value=None), + patch("os.kill"), + ): + client = _make_client() + r = client.post("/admin/update?branch=beta") + body = r.json() + assert body.get("deps_rebuilt") is False + assert gm.pip_calls == [] + + def test_deps_error_surfaced_when_pip_fails(self): + """If pip install errors out, deps_error is populated and surfaced.""" + gm = _GitMock(dirty_files=[], before_hash="same1", after_hash="same1") + + def fail_on_pip(cmd, **kwargs): + cmd_list = list(cmd) + is_pip = ( + "install" in cmd_list + and (cmd_list[0].endswith("/pip") or cmd_list[0] == "pip" or "pip" in cmd_list) + ) + if is_pip: + raise sp.CalledProcessError(1, cmd, output=b"ERROR: package not found") + return gm(cmd, **kwargs) + + with ( + patch("subprocess.check_output", side_effect=fail_on_pip), + patch("shutil.which", return_value=None), + patch("os.kill"), + ): + client = _make_client() + r = client.post("/admin/update?branch=beta&force_deps=true") + body = r.json() + assert body.get("deps_rebuilt") is False + assert body.get("deps_error") + assert "pip install failed" in body["deps_error"] From b919574d785e06c6bfff1df05461ef7b30e40e86 Mon Sep 17 00:00:00 2001 From: olegbrok Date: Wed, 6 May 2026 22:13:17 -0700 Subject: [PATCH 13/24] feat(auth-alerts): operator alert + auth_status when Claude credentials break (#400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(auth-alerts): notify operator + surface auth_status when Claude auth breaks When the Claude SDK returns error='authentication_failed' the streaming session has always suppressed the response (so raw API errors never reach end users). The downside: the operator who can re-auth has no signal until someone complains. This adds two channels for that signal. 1) AuthFailureTracker (auth_alerts.py): - Per-host sliding window of auth failures across all agents. - Fires an alert at threshold (3 fails in 5 min, OR 3 distinct agents failing at once = host-wide outage). - 30 min cooldown between alerts to prevent spam. - record_success() clears state on the next clean turn. 2) Streaming-session hooks (streaming_session.py): - New auth_alert_callback / auth_success_callback ctor params, called from the existing assistant-error and ResultMessage paths. - _is_auth_error() pattern-matches authentication_failed, invalid_api_key, unauthorized, auth_error, permission_error so future SDK renames keep alerting. 3) Operator delivery (api.py): - _on_auth_failure resolves operator chat (system_settings.operator_chat_id first; otherwise the chat_id appearing across the most approved_users rows) and DMs via the affected agent's own bot through _broker_send. - Codex sessions skip the hook — different auth lifecycle. 4) Health surface: - /admin/watchdog gains auth_status with status=ok|degraded|broken, window/threshold/cooldown config, outage age, alerts sent, and a per-agent failures list. External monitors can alert on broken. 20 new tests covering thresholds, cooldown, window eviction, host-wide detection, success-clears-state, operator resolution fallbacks, and a TestClient integration check on /admin/watchdog. Full suite: 1620 passed. Why now: hit a real outage on the Pi today — all 5 agents auth_failed for 12+ hours and nobody knew because the symptom was "agents stop responding." This makes the next one a 30-second telegram ping instead of a manual log grep. Co-Authored-By: Claude Opus 4.7 * fix(auth-alerts): preserve cooldown on broker delivery failure + plain-text alert PR #400 review (Pushok) caught the regression this PR was meant to prevent: record_failure() advanced _last_alert_at *before* awaiting _broker_send, so a network blip on the very first send-attempt silenced the alert system for 30 minutes — exactly the Sasha-down-9-12h shape we're designing against. Two-phase alerting: - record_failure() now decides only; never mutates cooldown. - New commit_alert() is invoked by _on_auth_failure ONLY after _broker_send succeeds. If delivery raises, cooldown is preserved so the next failure retries instead of being lost. Also fix the markdown parse_mode mismatch Pushok flagged: format_alert_message used `*agent*` and backticks, but _broker_send defaults to no parse_mode so operators would see literal `*sasha*`. Switched to plain text — safer than escaping for MarkdownV2 with arbitrary error strings. Tracker docstring: clarified concurrency contract (single event loop, no internal Lock needed) per Pushok's note that "not async-safe" overstated it. New tests: - test_failed_delivery_preserves_cooldown_and_retries_next_failure (regression) - test_commit_alert_advances_cooldown_and_counter - test_format_alert_uses_no_markdown_so_plain_text_renders_clean - two existing assertions extended to verify the decide/commit split Full suite: 1770 passed, 1 skipped. Ruff clean. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Oleg Co-authored-by: Claude Opus 4.7 --- src/pinky_daemon/api.py | 113 ++++++++- src/pinky_daemon/auth_alerts.py | 316 +++++++++++++++++++++++++ src/pinky_daemon/streaming_session.py | 51 ++++ tests/test_auth_alerts.py | 328 ++++++++++++++++++++++++++ 4 files changed, 806 insertions(+), 2 deletions(-) create mode 100644 src/pinky_daemon/auth_alerts.py create mode 100644 tests/test_auth_alerts.py diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index 765ae83e..56e11d0f 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -1310,6 +1310,17 @@ async def security_headers_middleware(request: Request, call_next): _comms_db = db_path.replace(".db", "_agent_comms.db") comms = AgentComms(db_path=_comms_db) + # Auth-failure tracker — counts SDK auth errors across all streaming + # sessions on this host and decides when to alert the operator. The + # callback wiring is created in _build_auth_alert_callbacks() below + # once _broker_send is available; the tracker itself is host-global. + from pinky_daemon.auth_alerts import ( + AuthFailureTracker, + format_alert_message, + resolve_operator_chat, + ) + auth_tracker = AuthFailureTracker() + # Message broker — routes platform messages through approval checks to agent sessions _platform_adapters: dict[tuple[str, str], object] = {} @@ -2287,6 +2298,83 @@ async def _on_stream_event(event: dict): return _on_stream_event + async def _on_auth_failure(agent_name: str, error: str) -> None: + """Streaming-session hook: record an auth failure and alert the operator. + + Called from StreamingSession's reader loop whenever the SDK reports + an authentication-class error (see auth_alerts._is_auth_error). The + AuthFailureTracker decides whether this failure crosses the alert + threshold and whether the cooldown has elapsed; if both, we DM the + operator via _broker_send using the affected agent's own bot. + """ + try: + decision = auth_tracker.record_failure(agent_name, error) + except Exception as e: + _log(f"auth_alerts: tracker.record_failure raised: {e}") + return + + if not decision.get("should_alert"): + return + + try: + chat_id, platform = resolve_operator_chat( + get_setting=agents.get_setting, + list_all_approved_users=agents.list_all_approved_users, + ) + except Exception as e: + _log(f"auth_alerts: resolve_operator_chat raised: {e}") + return + + if not chat_id: + _log( + "auth_alerts: auth failure detected but no operator_chat_id " + "configured and no approved_users to fall back on — " + "alert suppressed (set system_settings.operator_chat_id)" + ) + return + + try: + host_label = agents.get_setting("host_label", "") or "" + except Exception: + host_label = "" + + body = format_alert_message( + agent_name=agent_name, + decision=decision, + error=error, + host_label=host_label, + ) + + try: + await _broker_send(agent_name, platform, chat_id, body) + except Exception as e: + # Delivery failed — do NOT commit the alert. The next failure + # crossing threshold will retry instead of being silenced for + # the cooldown window. + _log( + f"auth_alerts: failed to send operator alert via " + f"{agent_name} bot to {platform}:{chat_id}: {e} " + f"(cooldown not advanced — will retry on next failure)" + ) + return + + try: + auth_tracker.commit_alert() + except Exception as e: + _log(f"auth_alerts: tracker.commit_alert raised: {e}") + _log( + f"auth_alerts: operator alert sent to {platform}:{chat_id} " + f"for {agent_name} (reason={decision.get('reason')}, " + f"agents_failing={decision.get('agents_failing')})" + ) + + def _on_auth_success(agent_name: str) -> None: + """Clear an agent's auth-failure record after a successful turn.""" + try: + auth_tracker.record_success(agent_name) + except Exception as e: + _log(f"auth_alerts: tracker.record_success raised: {e}") + async def _make_streaming_session_id_callback(agent_name: str, label: str): """Persist a streaming session ID when captured from the SDK. @@ -2544,6 +2632,13 @@ def runtime_from_legacy_provider(agent_config) -> str: "analytics_store": analytics, "registry": agents, } + # Auth-failure detection is currently only wired for the SDK-based + # StreamingSession. Codex sessions don't surface an equivalent + # "authentication_failed" signal — they rely on a separate provider + # token lifecycle — so we skip the hooks there. + if not is_codex: + init_kwargs["auth_alert_callback"] = _on_auth_failure + init_kwargs["auth_success_callback"] = _on_auth_success if is_codex: init_kwargs["stream_event_callback"] = await _make_streaming_event_callback(agent_name, label) @@ -8181,8 +8276,22 @@ async def on_shutdown(): @app.get("/admin/watchdog") async def get_watchdog_status(): - """Return session watchdog status and tracked agents.""" - return watchdog.status() + """Return session watchdog status, tracked agents, and Claude auth health. + + Three top-level keys for external monitors: + running / check_interval / agents — session-stuck watchdog state. + auth_status — host-wide Claude auth health (see auth_alerts). + + ``auth_status.status`` is one of ``ok | degraded | broken``; tying a + Prometheus alert to ``broken`` catches credential outages quickly. + """ + out = watchdog.status() + try: + out["auth_status"] = auth_tracker.status() + except Exception as e: + _log(f"admin/watchdog: auth_tracker.status raised: {e}") + out["auth_status"] = {"status": "unknown", "error": str(e)} + return out # ── Admin: Shared MCP Status ───────────────────────── diff --git a/src/pinky_daemon/auth_alerts.py b/src/pinky_daemon/auth_alerts.py new file mode 100644 index 00000000..10e27638 --- /dev/null +++ b/src/pinky_daemon/auth_alerts.py @@ -0,0 +1,316 @@ +"""Auth-failure tracking and operator alerts. + +When the Claude SDK returns ``error='authentication_failed'`` for an agent's +streaming session, the user-facing chat sees nothing — the daemon suppresses +the response so raw error JSON never reaches end users. The downside is the +operator (the person who can re-auth) has no idea Claude credentials are +broken until someone complains. + +This module provides: + +- ``AuthFailureTracker`` — in-memory counter that records auth failures across + all agents on a host, decides when an alert should fire, and enforces a + cooldown so we don't spam the operator. +- ``resolve_operator_chat()`` — best-effort lookup of the operator's + chat_id/platform. Reads ``operator_chat_id`` / ``operator_platform`` from + system_settings if set; otherwise picks the chat_id appearing across the + most ``approved_users`` rows (heuristic: the person with broadest access + is the operator). + +The streaming session calls into the tracker on each auth failure; when the +tracker says "alert", the daemon's alert wiring sends a Telegram DM via the +existing broker. Both are documented inline so the next Brad wedge debug is +faster. +""" +from __future__ import annotations + +import logging +import time +from collections import Counter, defaultdict +from dataclasses import dataclass, field +from typing import Iterable + +_log = logging.getLogger("pinky.auth_alerts").info +_warn = logging.getLogger("pinky.auth_alerts").warning + + +# ── Defaults ──────────────────────────────────────────────────────── + +DEFAULT_FAIL_WINDOW_SECONDS = 300 # 5 min +DEFAULT_FAIL_THRESHOLD = 3 # within window → alert +DEFAULT_ALERT_COOLDOWN_SECONDS = 1800 # 30 min between alerts + + +@dataclass +class _AgentFailures: + """Sliding-window record of recent auth failures for one agent.""" + + timestamps: list[float] = field(default_factory=list) + last_error: str = "" + + def record(self, now: float, error: str, window: float) -> int: + """Append a failure timestamp, prune older ones, return current count.""" + self.timestamps.append(now) + self.last_error = error or "" + cutoff = now - window + self.timestamps = [t for t in self.timestamps if t >= cutoff] + return len(self.timestamps) + + def reset(self) -> None: + self.timestamps = [] + self.last_error = "" + + +class AuthFailureTracker: + """Per-host auth failure detector with cooldown. + + Concurrency: callers must share a single event loop (the streaming-session + reader loop). No internal locking is needed — every method is synchronous + and mutation happens before return. Methods are O(N) over recent + timestamps which is fine — N is bounded by the threshold in practice. + + Two-phase alerting: ``record_failure()`` decides whether an alert *should* + fire but never advances the cooldown by itself. The caller must invoke + ``commit_alert()`` only after the operator notification was actually + delivered. If delivery raises (broker down, bot token revoked, etc.), the + cooldown is preserved so the next failure can retry — that's the whole + reason this PR exists, so a single failed-send doesn't silence the alert + system for 30 minutes. + """ + + def __init__( + self, + *, + fail_window_seconds: int = DEFAULT_FAIL_WINDOW_SECONDS, + fail_threshold: int = DEFAULT_FAIL_THRESHOLD, + alert_cooldown_seconds: int = DEFAULT_ALERT_COOLDOWN_SECONDS, + clock=time.time, + ) -> None: + self._window = fail_window_seconds + self._threshold = fail_threshold + self._cooldown = alert_cooldown_seconds + self._clock = clock + self._agents: dict[str, _AgentFailures] = defaultdict(_AgentFailures) + self._last_alert_at: float = 0.0 + self._first_failure_at: float = 0.0 # When current outage started + self._alert_count: int = 0 # Alerts fired since last clear + + def record_failure(self, agent_name: str, error: str = "") -> dict: + """Record one auth failure. Returns a decision dict. + + Returned keys: + should_alert (bool): True if this failure crossed the threshold + AND cooldown has elapsed. + reason (str): Short message explaining the decision. + count (int): Current failures-in-window for this agent. + agents_failing (int): Count of distinct agents currently failing. + """ + now = self._clock() + if self._first_failure_at == 0.0: + self._first_failure_at = now + + record = self._agents[agent_name] + count = record.record(now, error, self._window) + agents_failing = sum(1 for r in self._agents.values() if r.timestamps) + + # Multi-agent host outage: if ≥ threshold agents failing simultaneously, + # alert immediately on the very first qualifying failure (don't wait for + # any single agent to hit `threshold` failures alone). + threshold_hit = ( + count >= self._threshold or agents_failing >= self._threshold + ) + + if not threshold_hit: + return { + "should_alert": False, + "reason": "below_threshold", + "count": count, + "agents_failing": agents_failing, + } + + if self._last_alert_at and now - self._last_alert_at < self._cooldown: + return { + "should_alert": False, + "reason": "cooldown", + "count": count, + "agents_failing": agents_failing, + "cooldown_remaining": int( + self._cooldown - (now - self._last_alert_at) + ), + } + + # NOTE: do NOT advance _last_alert_at / _alert_count here. Caller must + # invoke commit_alert() after successful delivery; that way a broker + # failure preserves the cooldown so the alert can retry on the next + # failure instead of being silenced for the cooldown window. + return { + "should_alert": True, + "reason": ( + "host_wide" if agents_failing >= self._threshold else "agent_repeated" + ), + "count": count, + "agents_failing": agents_failing, + } + + def commit_alert(self) -> None: + """Mark an alert as delivered: advance cooldown and bump the counter. + + Call this only after the operator notification was actually sent. If + delivery fails, do NOT call this — the next record_failure() that + crosses threshold will return should_alert=True so the alert retries + instead of being lost for the cooldown window. + """ + self._last_alert_at = self._clock() + self._alert_count += 1 + + def record_success(self, agent_name: str) -> None: + """Clear failure tracking for an agent that successfully called Claude. + + If all agents are clear, reset the outage state so the next outage + emits a fresh "outage started" alert. + """ + if agent_name in self._agents: + self._agents[agent_name].reset() + if not any(r.timestamps for r in self._agents.values()): + self._first_failure_at = 0.0 + # Keep _last_alert_at in place — cooldown still respected even + # across a brief recovery → re-failure to avoid alert flapping. + + def status(self) -> dict: + """Snapshot for /admin/watchdog and diagnostics. + + Status semantics: + "ok" — no agent has failures in window. + "degraded" — some failures but below threshold. + "broken" — at or above threshold (alert is or will fire). + """ + now = self._clock() + agents_failing: list[dict] = [] + for name, rec in self._agents.items(): + # Prune stale timestamps lazily here too so /admin/watchdog + # doesn't show ghosts after the window has elapsed. + cutoff = now - self._window + rec.timestamps = [t for t in rec.timestamps if t >= cutoff] + if not rec.timestamps: + continue + agents_failing.append( + { + "agent": name, + "failures_in_window": len(rec.timestamps), + "last_failure_age_s": int(now - rec.timestamps[-1]), + "last_error": rec.last_error[:120], + } + ) + + if not agents_failing: + status = "ok" + elif any( + a["failures_in_window"] >= self._threshold for a in agents_failing + ) or len(agents_failing) >= self._threshold: + status = "broken" + else: + status = "degraded" + + return { + "status": status, + "fail_window_seconds": self._window, + "fail_threshold": self._threshold, + "alert_cooldown_seconds": self._cooldown, + "outage_started_at": self._first_failure_at or None, + "outage_age_seconds": ( + int(now - self._first_failure_at) + if self._first_failure_at + else None + ), + "alerts_sent": self._alert_count, + "last_alert_age_seconds": ( + int(now - self._last_alert_at) if self._last_alert_at else None + ), + "agents_failing": agents_failing, + } + + +# ── Operator chat resolution ──────────────────────────────────────── + + +def resolve_operator_chat( + *, + get_setting, + list_all_approved_users, +) -> tuple[str, str]: + """Pick the (chat_id, platform) that should receive operator alerts. + + Resolution order: + 1. ``operator_chat_id`` + ``operator_platform`` in system_settings. + 2. Fallback: the chat_id appearing across the most approved_users rows + (heuristic: the person with broadest access is the operator). + 3. Empty strings if nothing matches — caller must handle "no operator". + + Returns ("", "") when unresolved. Never raises. + """ + try: + chat_id = (get_setting("operator_chat_id", "") or "").strip() + platform = (get_setting("operator_platform", "") or "").strip() or "telegram" + if chat_id: + return chat_id, platform + except Exception as e: + _warn("auth_alerts: failed to read operator_chat_id setting: %s", e) + + try: + users: Iterable[dict] = list_all_approved_users() or [] + except Exception as e: + _warn("auth_alerts: failed to list approved users: %s", e) + return "", "" + + counter: Counter[str] = Counter() + for u in users: + cid = (u.get("chat_id") or "").strip() if isinstance(u, dict) else "" + if cid: + counter[cid] += 1 + if not counter: + return "", "" + + chat_id, _ = counter.most_common(1)[0] + return chat_id, "telegram" + + +# ── Alert formatting ─────────────────────────────────────────────── + + +def format_alert_message( + *, + agent_name: str, + decision: dict, + error: str, + host_label: str = "", +) -> str: + """Render the operator-alert message body. + + Plain text — _broker_send defaults to no parse_mode, so anything wrapped + in ``*`` or `` ` `` would render literally on Telegram. Keep it readable + without markdown affordances. + + Kept human-readable and short; includes enough detail for the operator + to know which host to log into and re-auth. + """ + reason = decision.get("reason", "") + agents_failing = decision.get("agents_failing", 0) + count = decision.get("count", 0) + + if reason == "host_wide": + headline = ( + f"🚨 Claude auth broken on {host_label or 'this host'}: " + f"{agents_failing} agent(s) failing" + ) + else: + headline = ( + f"🚨 Claude auth broken for {agent_name}: " + f"{count} failures in window" + ) + + detail = error.strip()[:200] or "no error detail" + instructions = ( + "Re-auth needed: SSH to the host, run 'claude' to log back in, " + "then 'sudo systemctl restart pinkybot' (or equivalent)." + ) + return f"{headline}\n\nLast error: {detail}\n\n{instructions}" diff --git a/src/pinky_daemon/streaming_session.py b/src/pinky_daemon/streaming_session.py index fc6bfabd..4c8ec484 100644 --- a/src/pinky_daemon/streaming_session.py +++ b/src/pinky_daemon/streaming_session.py @@ -112,6 +112,27 @@ def _is_outreach_tool(tool_name: str) -> bool: return _tool_basename(tool_name) in _OUTREACH_TOOL_NAMES +# Auth-failure heuristics. The Claude Agent SDK normalises most credential +# problems to error='authentication_failed'; older SDK versions used +# 'invalid_api_key' / 'permission_error'. Pattern-match instead of a strict +# equality check so a future SDK rename doesn't silently break the alerting. +_AUTH_ERROR_TOKENS = ( + "authentication_failed", + "invalid_api_key", + "unauthorized", + "auth_error", + "permission_error", +) + + +def _is_auth_error(err) -> bool: + """True if the SDK error string looks like a credential failure.""" + if not err: + return False + s = str(err).lower() + return any(token in s for token in _AUTH_ERROR_TOKENS) + + def _describe_tool_use(tool_name: str, tool_input: dict) -> str: """Build a human-readable description of a tool invocation.""" name = _tool_basename(tool_name) @@ -174,6 +195,8 @@ def __init__( cost_callback=None, # fn(agent_name, cost_usd, input_tokens, output_tokens, session_id) analytics_store=None, registry=None, # AgentRegistry — for server-side presence stamping + auth_alert_callback=None, # async fn(agent_name, error_str) — fires on auth_failed + auth_success_callback=None, # fn(agent_name) — fires on a successful turn (clears auth fail state) ) -> None: self._config = config self._response_callback = response_callback @@ -181,6 +204,11 @@ def __init__( self._conversation_store = conversation_store self._analytics_store = analytics_store self._registry = registry + # Auth-failure detection plumbing — called from the reader loop when + # the SDK reports an authentication error so the daemon can alert + # the operator. See pinky_daemon/auth_alerts.py for the tracker. + self._auth_alert_callback = auth_alert_callback + self._auth_success_callback = auth_success_callback self._client = None self._reader_task: asyncio.Task | None = None self._connected = False @@ -439,6 +467,20 @@ async def _reader_loop(self) -> None: f"streaming[{self.agent_name}]: assistant error={msg.error!r}" f" stop_reason={msg.stop_reason!r} — suppressing content" ) + # Detect auth failures and notify the operator. The SDK + # surfaces these as msg.error == 'authentication_failed' + # (and historically 'invalid_api_key' on some versions); + # match defensively so any future variants still alert. + if _is_auth_error(msg.error) and self._auth_alert_callback: + try: + await self._auth_alert_callback( + self.agent_name, str(msg.error) + ) + except Exception as exc: + _log( + f"streaming[{self.agent_name}]: " + f"auth_alert_callback raised: {exc}" + ) # Don't touch _last_response; fall through to usage/session_id capture. else: # Extract text and tool uses from content blocks @@ -596,6 +638,15 @@ async def _reader_loop(self) -> None: except Exception as e: _log(f"streaming[{self.agent_name}]: callback error: {e}") + # A successful (non-errored) turn proves Claude auth is + # working again — clear any auth-fail tracking for this + # agent so the next outage emits a fresh alert. + if self._auth_success_callback: + try: + self._auth_success_callback(self.agent_name) + except Exception: + pass + # Track usage from result if msg.total_cost_usd: self.usage.total_cost_usd += msg.total_cost_usd diff --git a/tests/test_auth_alerts.py b/tests/test_auth_alerts.py new file mode 100644 index 00000000..36b9695d --- /dev/null +++ b/tests/test_auth_alerts.py @@ -0,0 +1,328 @@ +"""Tests for the auth-failure tracker and operator-alert wiring.""" +from __future__ import annotations + +from pinky_daemon.auth_alerts import ( + AuthFailureTracker, + format_alert_message, + resolve_operator_chat, +) +from pinky_daemon.streaming_session import _is_auth_error + +# ── _is_auth_error ──────────────────────────────────────────────── + + +def test_is_auth_error_matches_known_tokens(): + assert _is_auth_error("authentication_failed") is True + assert _is_auth_error("invalid_api_key") is True + assert _is_auth_error("Unauthorized") is True # case-insensitive + assert _is_auth_error("auth_error: token expired") is True + assert _is_auth_error("permission_error") is True + + +def test_is_auth_error_skips_other_failures(): + assert _is_auth_error("rate_limit_exceeded") is False + assert _is_auth_error("content_filter") is False + assert _is_auth_error("") is False + assert _is_auth_error(None) is False + + +# ── AuthFailureTracker ──────────────────────────────────────────── + + +class _FakeClock: + def __init__(self, start: float = 1_000_000.0) -> None: + self.now = start + + def __call__(self) -> float: + return self.now + + def tick(self, seconds: float) -> None: + self.now += seconds + + +def _make_tracker(**kw): + clock = _FakeClock() + tracker = AuthFailureTracker( + fail_window_seconds=kw.get("window", 300), + fail_threshold=kw.get("threshold", 3), + alert_cooldown_seconds=kw.get("cooldown", 1800), + clock=clock, + ) + return tracker, clock + + +def test_below_threshold_does_not_alert(): + tracker, _ = _make_tracker() + d = tracker.record_failure("sasha", "authentication_failed") + assert d["should_alert"] is False + assert d["count"] == 1 + assert d["reason"] == "below_threshold" + + +def test_threshold_for_single_agent_fires_alert(): + tracker, clock = _make_tracker(threshold=3) + for _ in range(2): + clock.tick(10) + d = tracker.record_failure("sasha", "authentication_failed") + assert d["should_alert"] is False + clock.tick(10) + d = tracker.record_failure("sasha", "authentication_failed") + assert d["should_alert"] is True + assert d["reason"] == "agent_repeated" + assert d["count"] == 3 + # Decision alone must not commit cooldown — caller hasn't delivered yet. + assert tracker._last_alert_at == 0.0 + assert tracker._alert_count == 0 + + +def test_host_wide_outage_fires_immediately_at_threshold_agents(): + """If 3 different agents fail, alert on the 3rd — don't wait for any + single agent to hit the per-agent threshold.""" + tracker, _ = _make_tracker(threshold=3) + assert tracker.record_failure("sasha", "auth")["should_alert"] is False + assert tracker.record_failure("ivan", "auth")["should_alert"] is False + d = tracker.record_failure("kuzya", "auth") + assert d["should_alert"] is True + assert d["reason"] == "host_wide" + assert d["agents_failing"] == 3 + + +def test_cooldown_blocks_repeat_alerts(): + tracker, clock = _make_tracker(threshold=3, cooldown=600) + # Drive past threshold once and simulate successful delivery. + for _ in range(3): + tracker.record_failure("sasha", "auth") + tracker.commit_alert() + # Next failure within cooldown — must not realert. + clock.tick(60) + d = tracker.record_failure("sasha", "auth") + assert d["should_alert"] is False + assert d["reason"] == "cooldown" + assert d["cooldown_remaining"] > 0 + + +def test_cooldown_elapses_then_alert_fires_again(): + """During a continuous outage we want a re-alert each cooldown period. + + Use a window long enough that failures stay live across the cooldown gap + — that's the realistic outage shape (agent retries every minute or so). + """ + tracker, clock = _make_tracker(threshold=3, window=3600, cooldown=600) + for _ in range(3): + tracker.record_failure("sasha", "auth") # first alert decision here + tracker.commit_alert() # simulate first delivery succeeded + clock.tick(601) # past cooldown, still inside window + d = tracker.record_failure("sasha", "auth") + assert d["should_alert"] is True + assert d["count"] >= 3 + + +def test_failed_delivery_preserves_cooldown_and_retries_next_failure(): + """Regression: a broker_send raise must NOT advance the cooldown. + + Pushok review on PR #400: the original code mutated _last_alert_at + *before* the await, so a network blip on the very first send-attempt + silenced the alert system for 30 minutes — the exact regression this + feature exists to prevent (Sasha-down-9-12h). + """ + tracker, clock = _make_tracker(threshold=3, cooldown=600) + # First three failures cross threshold; caller "tries to deliver" but + # the broker raises, so commit_alert() is NEVER called. + for _ in range(3): + d = tracker.record_failure("sasha", "auth") + assert d["should_alert"] is True + # No commit_alert() — simulating broker_send failure. + assert tracker._last_alert_at == 0.0 # cooldown untouched + + # Next failure (1 second later) must still be should_alert: True so the + # caller can retry delivery. + clock.tick(1) + d = tracker.record_failure("sasha", "auth") + assert d["should_alert"] is True + assert d["reason"] == "agent_repeated" + + +def test_commit_alert_advances_cooldown_and_counter(): + tracker, clock = _make_tracker(threshold=3, cooldown=600) + for _ in range(3): + tracker.record_failure("sasha", "auth") + assert tracker._alert_count == 0 + tracker.commit_alert() + assert tracker._alert_count == 1 + assert tracker._last_alert_at == clock.now + + +def test_window_eviction_drops_old_failures(): + tracker, clock = _make_tracker(window=60, threshold=3) + tracker.record_failure("sasha", "auth") + clock.tick(61) # first entry now stale + tracker.record_failure("sasha", "auth") + clock.tick(1) + d = tracker.record_failure("sasha", "auth") + # Only 2 fresh entries within window — below threshold. + assert d["should_alert"] is False + assert d["count"] == 2 + + +def test_record_success_clears_agent_failures(): + tracker, _ = _make_tracker(threshold=3) + tracker.record_failure("sasha", "auth") + tracker.record_failure("sasha", "auth") + tracker.record_success("sasha") + d = tracker.record_failure("sasha", "auth") + assert d["count"] == 1 + assert d["should_alert"] is False + + +def test_status_reports_ok_when_no_failures(): + tracker, _ = _make_tracker() + s = tracker.status() + assert s["status"] == "ok" + assert s["agents_failing"] == [] + assert s["alerts_sent"] == 0 + + +def test_status_reports_broken_at_or_above_threshold(): + tracker, _ = _make_tracker(threshold=3) + for _ in range(3): + tracker.record_failure("sasha", "auth") + tracker.commit_alert() # simulate delivery succeeded + s = tracker.status() + assert s["status"] == "broken" + assert s["alerts_sent"] == 1 + assert any(a["agent"] == "sasha" for a in s["agents_failing"]) + + +def test_status_reports_degraded_below_threshold(): + tracker, _ = _make_tracker(threshold=3) + tracker.record_failure("sasha", "auth") + s = tracker.status() + assert s["status"] == "degraded" + + +# ── resolve_operator_chat ───────────────────────────────────────── + + +def test_resolve_operator_chat_prefers_setting(): + settings = {"operator_chat_id": "12345", "operator_platform": "telegram"} + chat_id, platform = resolve_operator_chat( + get_setting=lambda k, default="": settings.get(k, default), + list_all_approved_users=lambda: [], + ) + assert chat_id == "12345" + assert platform == "telegram" + + +def test_resolve_operator_chat_falls_back_to_most_common_approved_user(): + users = [ + {"chat_id": "111", "agent_name": "a"}, + {"chat_id": "222", "agent_name": "a"}, + {"chat_id": "111", "agent_name": "b"}, + {"chat_id": "111", "agent_name": "c"}, + ] + chat_id, platform = resolve_operator_chat( + get_setting=lambda k, default="": "", + list_all_approved_users=lambda: users, + ) + assert chat_id == "111" # appears 3× → operator + assert platform == "telegram" + + +def test_resolve_operator_chat_returns_empty_when_nothing_known(): + chat_id, platform = resolve_operator_chat( + get_setting=lambda k, default="": "", + list_all_approved_users=lambda: [], + ) + assert chat_id == "" + assert platform == "" + + +def test_resolve_operator_chat_tolerates_setting_errors(): + def bad_setting(*_a, **_kw): + raise RuntimeError("db down") + + chat_id, platform = resolve_operator_chat( + get_setting=bad_setting, + list_all_approved_users=lambda: [{"chat_id": "999"}], + ) + assert chat_id == "999" # fell through to fallback + assert platform == "telegram" + + +# ── format_alert_message ────────────────────────────────────────── + + +def test_format_alert_includes_agent_when_repeated(): + msg = format_alert_message( + agent_name="sasha", + decision={"reason": "agent_repeated", "count": 3, "agents_failing": 1}, + error="authentication_failed", + host_label="rpi5", + ) + assert "sasha" in msg + assert "authentication_failed" in msg + assert "Re-auth" in msg + + +def test_format_alert_says_host_wide_when_multiple_agents_fail(): + msg = format_alert_message( + agent_name="sasha", + decision={"reason": "host_wide", "count": 1, "agents_failing": 3}, + error="invalid_api_key", + host_label="rpi5", + ) + assert "rpi5" in msg + assert "3 agent" in msg + + +def test_format_alert_truncates_long_error_strings(): + long = "x" * 5000 + msg = format_alert_message( + agent_name="sasha", + decision={"reason": "agent_repeated", "count": 3, "agents_failing": 1}, + error=long, + host_label="", + ) + # Last error block is capped at 200 chars. + assert "x" * 5000 not in msg + + +def test_format_alert_uses_no_markdown_so_plain_text_renders_clean(): + """Pushok review: _broker_send defaults to no parse_mode, so any markdown + in the message renders literally on Telegram (operator sees `*sasha*`). + Keep the message plain text. + """ + msg = format_alert_message( + agent_name="sasha", + decision={"reason": "agent_repeated", "count": 3, "agents_failing": 1}, + error="authentication_failed", + host_label="rpi5", + ) + assert "*" not in msg + assert "`" not in msg + # Sanity: still actually informative. + assert "sasha" in msg + assert "authentication_failed" in msg + + +# ── /admin/watchdog integration ─────────────────────────────────── + + +def test_admin_watchdog_exposes_auth_status(tmp_path): + """End-to-end: /admin/watchdog includes auth_status with the right shape.""" + from fastapi.testclient import TestClient + + from pinky_daemon.api import create_api + + api = create_api(db_path=str(tmp_path / "memory.db")) + with TestClient(api) as client: + resp = client.get("/admin/watchdog") + assert resp.status_code == 200 + body = resp.json() + assert "auth_status" in body + auth = body["auth_status"] + assert auth["status"] == "ok" # no failures yet + assert auth["fail_threshold"] >= 1 + assert auth["agents_failing"] == [] + assert "fail_window_seconds" in auth + assert "alert_cooldown_seconds" in auth From a3fa99049f88be6783b3560b2ba49c3f1160ee9c Mon Sep 17 00:00:00 2001 From: olegbrok Date: Fri, 8 May 2026 08:36:34 -0700 Subject: [PATCH 14/24] =?UTF-8?q?chore(deps):=20bump=20claude-agent-sdk=20?= =?UTF-8?q?floor=200.1.73=20=E2=86=92=200.1.76=20(#402)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up SDK 0.1.74-0.1.76 fixes on the floor pin: hook event streaming + deferred decisions, strict MCP config validation, eager session-store flushing, permission-suggestions deserialization fix, and bundled CC 2.1.132 (MCP reconnect-flood fix, subprocess-cleanup-on-parent-exit, CLAUDE_CODE_SESSION_ID env in subprocesses). Closes the floor-bump action item from #401. The native API-error-status investigation, subprocess-cleanup verification, and PR #400 auth-alert re-validation remain as separate follow-ups in #401. Verification: - pip install -e . resolves to claude-agent-sdk 0.1.76 (bundled CC 2.1.132) - pytest: 1770 passed, 1 skipped (parity with pre-bump) Co-authored-by: Oleg Co-authored-by: Claude Opus 4.7 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 48b7b6e5..ccba046d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "httpx>=0.28", "fastapi>=0.135", "uvicorn>=0.44", - "claude-agent-sdk>=0.1.73", + "claude-agent-sdk>=0.1.76", "Pillow>=10.0", # Federation crypto (sealed_box_v1): X25519, Ed25519, HKDF, XChaCha20-Poly1305. "cryptography>=41.0", From f6177f91616ebaf1ea40ba2686d2a1c396d68132 Mon Sep 17 00:00:00 2001 From: olegbrok Date: Fri, 8 May 2026 09:04:19 -0700 Subject: [PATCH 15/24] refactor(auth-detect): structured AssistantMessage.error + ResultMessage.api_error_status (#403) (#404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(auth-detect): structured AssistantMessage.error + ResultMessage.api_error_status (#403) Replaces the legacy `_AUTH_ERROR_TOKENS` substring tuple in streaming_session.py with structured detection on both message paths, and bumps `claude-agent-sdk` floor 0.1.76 → 0.1.77. ## What changed **streaming_session.py** - Drop `_AUTH_ERROR_TOKENS` substring tuple and `_is_auth_error(str)`. - Add `_is_auth_error_assistant(msg)`: exact match against the `AssistantMessageError` Literal (`"authentication_failed"`). Other Literal values (billing_error, rate_limit, invalid_request, server_error, unknown) are NOT credential failures and must NOT trip the operator alert. - Add `_is_auth_error_result(result)`: detects `api_error_status` in {401, 403} on `ResultMessage` (added in SDK 0.1.76, emitted by CLI >= 2.1.110). Closes the gap where errors that only land at turn completion (not mid-turn on AssistantMessage) were silently swallowed by the existing error-result path. - Update both reader-loop callsites: AssistantMessage path now uses the assistant variant; ResultMessage `is_error` path now also fires the auth alert callback when status is 401/403. **api.py** - Update the `_on_auth_failure` docstring: the comment used to point at `auth_alerts._is_auth_error` (which never existed there); now references both the assistant and result detectors in `streaming_session`. **tests/test_auth_alerts.py** - Replace `_is_auth_error(str)` tests with structured tests for both detectors using `SimpleNamespace` stand-ins for the SDK message types. Coverage: - assistant: only `"authentication_failed"` is true; all 5 other Literal values are false; `None` and missing-attr are false. - result: 401/403 are true; 429 + 5xx are false; `None`/200/missing are false. **pyproject.toml** - `claude-agent-sdk` floor 0.1.76 → 0.1.77. Required for the `api_error_status` field on `ResultMessage` and gets the bundled Claude CLI 2.1.133 (subagent skill discovery fix, parallel-session refresh-token race fix, hook effort context). ## Why this matters Pre-refactor, the substring-tuple matcher carried entries (`invalid_api_key`, `unauthorized`, `auth_error`, `permission_error`) that the SDK Literal cannot produce — pure cargo from before the type became strict. Worse, the ResultMessage error path had no auth detection at all, so credential failures that landed only at turn completion were silently swallowed. After this refactor, both paths detect credential failures with exact-match semantics over typed values, the operator-alert wiring fires from either path, and we can no longer be silently broken by an AssistantMessageError variant rename — a mismatch becomes a test failure instead of a silent regression. ## Test plan - [x] `pytest tests/test_auth_alerts.py -x` — 27 passed - [x] Full suite: `pytest tests/ -x` — 1774 passed, 1 skipped - [x] `ruff check` on modified files — clean Refs: #401, #402, #403 🤖 Opened by Barsik * fix(auth-detect): per-turn dedupe + SDK Literal invariant guards Address Murzik's PR #404 review (P1-ish double-count) and Pushok's forward-looking concern about silent regression on SDK rename. ## Per-turn auth dedupe (Murzik) A single failed turn can surface auth errors on BOTH paths the reader_loop watches: - AssistantMessage with error="authentication_failed" (mid-turn) - ResultMessage with api_error_status=401/403 (turn completion) Without dedupe, AuthFailureTracker.record_failure() increments twice for one real failure — tripping the operator-alert threshold early (after ~2 actual failed turns instead of 3) and skewing the host-wide multi-agent baseline. Fix: a per-turn `auth_reported_this_turn` flag in the reader_loop. The AssistantMessage auth path sets it before invoking the callback (so a callback exception still dedupes). The ResultMessage auth path gates its callback on `not auth_reported_this_turn`. The flag resets at the end of each ResultMessage handling — turn boundary. ## SDK Literal invariant (Pushok) `_is_auth_error_assistant` does exact-match against `AssistantMessageError`. If a future SDK release renames the Literal value, exact-match silently stops detecting credential failures — re-creating the exact regression #400 was built to catch. Two-layer guard: - Import-time assertion in reader_loop checks `_AUTH_ASSISTANT_ERROR in AssistantMessageError.__args__`. Fails loud at session start on any local-dev SDK upgrade that drops the literal. - Unit test in test_auth_alerts.py asserts the same invariant. Fails loud at CI on any PR that bumps the SDK across a rename. ## Other changes - Enrich the ResultMessage auth-callback string with `msg.errors` when present — operators see actionable triage context in their DM, not just the raw status code (SDK 0.1.77 started returning meaningful error messages here). - Bump uv.lock claude-agent-sdk 0.1.73 → 0.1.77 to match pyproject.toml's floor (lock file was stale from PR #402). ## Tests - 4 new reader-loop regression tests in test_streaming_session.py: - both paths emit for one turn → callback fires once - result-only path still fires (dedupe doesn't break the gap-fill) - dedupe resets between turns (two real failures = two callbacks) - non-auth AssistantMessage error doesn't poison next-turn dedupe - 1 new SDK-Literal-invariant unit test in test_auth_alerts.py. - test_api.py fake_types stub gains AssistantMessageError so the import-time invariant check passes under that test's monkey-patch. Full suite: 1779 passed / 1 skipped, ruff clean. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Oleg Co-authored-by: Claude Opus 4.7 --- pyproject.toml | 2 +- src/pinky_daemon/api.py | 11 +- src/pinky_daemon/streaming_session.py | 128 +++++++++++++++---- tests/test_api.py | 11 ++ tests/test_auth_alerts.py | 104 +++++++++++++-- tests/test_streaming_session.py | 174 ++++++++++++++++++++++++++ uv.lock | 16 +-- 7 files changed, 397 insertions(+), 49 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ccba046d..84a7a5bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "httpx>=0.28", "fastapi>=0.135", "uvicorn>=0.44", - "claude-agent-sdk>=0.1.76", + "claude-agent-sdk>=0.1.77", "Pillow>=10.0", # Federation crypto (sealed_box_v1): X25519, Ed25519, HKDF, XChaCha20-Poly1305. "cryptography>=41.0", diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index 56e11d0f..f76d6ba9 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -2302,10 +2302,13 @@ async def _on_auth_failure(agent_name: str, error: str) -> None: """Streaming-session hook: record an auth failure and alert the operator. Called from StreamingSession's reader loop whenever the SDK reports - an authentication-class error (see auth_alerts._is_auth_error). The - AuthFailureTracker decides whether this failure crosses the alert - threshold and whether the cooldown has elapsed; if both, we DM the - operator via _broker_send using the affected agent's own bot. + an authentication-class error — either AssistantMessage.error == + "authentication_failed" (see streaming_session._is_auth_error_assistant) + or ResultMessage.api_error_status in {401, 403} (see + streaming_session._is_auth_error_result). The AuthFailureTracker + decides whether this failure crosses the alert threshold and whether + the cooldown has elapsed; if both, we DM the operator via + _broker_send using the affected agent's own bot. """ try: decision = auth_tracker.record_failure(agent_name, error) diff --git a/src/pinky_daemon/streaming_session.py b/src/pinky_daemon/streaming_session.py index 4c8ec484..e0d238ba 100644 --- a/src/pinky_daemon/streaming_session.py +++ b/src/pinky_daemon/streaming_session.py @@ -112,25 +112,38 @@ def _is_outreach_tool(tool_name: str) -> bool: return _tool_basename(tool_name) in _OUTREACH_TOOL_NAMES -# Auth-failure heuristics. The Claude Agent SDK normalises most credential -# problems to error='authentication_failed'; older SDK versions used -# 'invalid_api_key' / 'permission_error'. Pattern-match instead of a strict -# equality check so a future SDK rename doesn't silently break the alerting. -_AUTH_ERROR_TOKENS = ( - "authentication_failed", - "invalid_api_key", - "unauthorized", - "auth_error", - "permission_error", -) - - -def _is_auth_error(err) -> bool: - """True if the SDK error string looks like a credential failure.""" - if not err: - return False - s = str(err).lower() - return any(token in s for token in _AUTH_ERROR_TOKENS) +# Auth-failure detection — uses native SDK types (claude-agent-sdk >= 0.1.76). +# +# Two paths surface credential failures, and we check both: +# +# 1. ``AssistantMessage.error`` is an ``AssistantMessageError`` Literal whose +# only credential-failure value is "authentication_failed". The other +# Literal members (billing_error, rate_limit, invalid_request, +# server_error, unknown) are NOT auth issues and must NOT trip the +# operator alert — billing errors and rate limits in particular would +# spam the operator on perfectly authenticated sessions. +# +# 2. ``ResultMessage.api_error_status`` is the raw HTTP status of a failing +# API call (added in SDK 0.1.76, emitted by CLI >= 2.1.110). 401 and 403 +# are credential failures; 429 and 5xx are transient/operational and +# handled separately. +# +# This replaces an older substring-tuple match against ``msg.error`` that +# pre-dated the Literal type and over-matched (e.g. "permission_error" is +# never a value the SDK can produce). +_AUTH_ASSISTANT_ERROR = "authentication_failed" +_AUTH_HTTP_STATUSES = frozenset({401, 403}) + + +def _is_auth_error_assistant(msg) -> bool: + """True if an ``AssistantMessage`` indicates a credential failure.""" + return getattr(msg, "error", None) == _AUTH_ASSISTANT_ERROR + + +def _is_auth_error_result(result) -> bool: + """True if a ``ResultMessage``'s HTTP status indicates a credential failure.""" + status = getattr(result, "api_error_status", None) + return status in _AUTH_HTTP_STATUSES def _describe_tool_use(tool_name: str, tool_input: dict) -> str: @@ -440,6 +453,7 @@ async def _reader_loop(self) -> None: """Background loop that reads responses and fires callbacks.""" from claude_agent_sdk.types import ( AssistantMessage, + AssistantMessageError, ResultMessage, TextBlock, ThinkingBlock, @@ -447,9 +461,30 @@ async def _reader_loop(self) -> None: ToolUseBlock, ) + # Defensive invariant: ``_is_auth_error_assistant`` does an exact + # match against ``AssistantMessageError`` for "authentication_failed". + # If a future SDK release renames that Literal value, exact-match + # silently stops detecting credential failures — re-creating the + # exact regression mode #400 was built to catch. Fail loud at session + # start instead. (CI also asserts this in test_auth_alerts.py, so + # bumps caught in PR; this guard catches local-dev SDK upgrades.) + assert _AUTH_ASSISTANT_ERROR in AssistantMessageError.__args__, ( + f"claude-agent-sdk renamed AssistantMessageError Literal — " + f"_AUTH_ASSISTANT_ERROR={_AUTH_ASSISTANT_ERROR!r} no longer in " + f"{AssistantMessageError.__args__}. Update the constant and tests." + ) + _log(f"streaming[{self.agent_name}]: reader loop running") turn_tool_uses = [] # Track tool uses per turn turn_thinking: list[str] = [] # Track thinking blocks per turn + # Per-turn dedupe for auth-failure callbacks. A single failed turn can + # surface auth errors on BOTH paths: an AssistantMessage with + # error="authentication_failed", followed by the terminal ResultMessage + # with api_error_status=401. Without dedupe, AuthFailureTracker would + # increment twice for one real failure — tripping the operator-alert + # threshold early and skewing the multi-agent baseline. Reset at the + # end of ResultMessage handling (turn boundary). + auth_reported_this_turn = False try: async for msg in self._client.receive_messages(): @@ -468,10 +503,17 @@ async def _reader_loop(self) -> None: f" stop_reason={msg.stop_reason!r} — suppressing content" ) # Detect auth failures and notify the operator. The SDK - # surfaces these as msg.error == 'authentication_failed' - # (and historically 'invalid_api_key' on some versions); - # match defensively so any future variants still alert. - if _is_auth_error(msg.error) and self._auth_alert_callback: + # types ``msg.error`` as the AssistantMessageError + # Literal; only "authentication_failed" is a credential + # issue. Other Literal values (billing_error, rate_limit, + # invalid_request, server_error, unknown) are NOT auth + # failures and must not trip the operator alert. + if _is_auth_error_assistant(msg) and self._auth_alert_callback: + # Set the dedupe flag BEFORE invoking the callback + # so a callback exception can't cause the + # ResultMessage path to double-fire for the same + # turn. + auth_reported_this_turn = True try: await self._auth_alert_callback( self.agent_name, str(msg.error) @@ -576,8 +618,42 @@ async def _reader_loop(self) -> None: _log( f"streaming[{self.agent_name}]: error result" f" stop_reason={msg.stop_reason!r}" + f" api_error_status={getattr(msg, 'api_error_status', None)!r}" f" errors={msg.errors!r} — suppressing forwarded response" ) + # Detect credential failures on the result path too. The + # AssistantMessage path catches errors the SDK surfaces + # mid-turn; this path catches errors that only land at + # turn completion (api_error_status added in 0.1.76: + # 401/403 = bad creds; 429/5xx = transient, handled + # below as a generic error result). + # + # Skip if the AssistantMessage path already reported + # auth for this turn — both paths firing for one real + # failure double-counts in AuthFailureTracker. + if ( + _is_auth_error_result(msg) + and self._auth_alert_callback + and not auth_reported_this_turn + ): + auth_reported_this_turn = True + # Surface msg.errors into the callback string when + # present — operators get richer triage context + # than the raw status code alone (the SDK started + # returning actionable messages in 0.1.77). + err_detail = f"api_error_status={msg.api_error_status}" + if msg.errors: + err_detail += f" errors={msg.errors!r}" + try: + await self._auth_alert_callback( + self.agent_name, + err_detail, + ) + except Exception as exc: + _log( + f"streaming[{self.agent_name}]: " + f"auth_alert_callback (result) raised: {exc}" + ) # Analytics: still record errored turns _u = msg.usage or {} self._analytics_log_turn_usage( @@ -612,6 +688,8 @@ async def _reader_loop(self) -> None: turn_thinking = [] self._stats["turns"] += 1 self.last_active = time.time() + # Reset per-turn auth dedupe — turn boundary + auth_reported_this_turn = False continue # Turn complete — fire response callback @@ -763,6 +841,10 @@ async def _reader_loop(self) -> None: turn_thinking = [] # Reset for next turn self._stats["turns"] += 1 self.last_active = time.time() + # Reset per-turn auth dedupe — turn boundary. Successful + # turns rarely set this flag (no auth error fired), but + # reset unconditionally to keep the invariant simple. + auth_reported_this_turn = False _log(f"streaming[{self.agent_name}]: turn complete (total: {self._stats['turns']})") diff --git a/tests/test_api.py b/tests/test_api.py index e60ffc73..66c4ced7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -233,6 +233,17 @@ def __init__(self, thinking=""): fake_types.ToolResultBlock = ToolResultBlock fake_types.AssistantMessage = AssistantMessage fake_types.ResultMessage = ResultMessage + # Stub for the SDK Literal — reader_loop's import-time invariant + # check (PR #404) reads __args__ to defend against SDK rename. + from typing import Literal as _Literal + fake_types.AssistantMessageError = _Literal[ + "authentication_failed", + "billing_error", + "rate_limit", + "invalid_request", + "server_error", + "unknown", + ] old_sdk_types = sys.modules.get("claude_agent_sdk.types") sys.modules["claude_agent_sdk.types"] = fake_types diff --git a/tests/test_auth_alerts.py b/tests/test_auth_alerts.py index 36b9695d..4f999cc7 100644 --- a/tests/test_auth_alerts.py +++ b/tests/test_auth_alerts.py @@ -1,29 +1,107 @@ """Tests for the auth-failure tracker and operator-alert wiring.""" from __future__ import annotations +from types import SimpleNamespace + from pinky_daemon.auth_alerts import ( AuthFailureTracker, format_alert_message, resolve_operator_chat, ) -from pinky_daemon.streaming_session import _is_auth_error +from pinky_daemon.streaming_session import ( + _AUTH_ASSISTANT_ERROR, + _is_auth_error_assistant, + _is_auth_error_result, +) + +# ── SDK Literal invariant ──────────────────────────────────────── +# +# ``_is_auth_error_assistant`` does an exact string match against +# AssistantMessageError. If a future SDK rename of the Literal value +# happens unnoticed, exact-match silently stops detecting credential +# failures — re-creating the exact regression mode #400 was built to +# catch. This test fails loud at CI time on any SDK bump that drops +# or renames "authentication_failed". + + +def test_auth_assistant_error_literal_present_in_sdk_type(): + """``_AUTH_ASSISTANT_ERROR`` must remain a member of + ``AssistantMessageError.__args__``. If the SDK renames the Literal, + update both the constant and ``_is_auth_error_assistant``.""" + from claude_agent_sdk.types import AssistantMessageError + + assert _AUTH_ASSISTANT_ERROR in AssistantMessageError.__args__, ( + f"claude-agent-sdk renamed the AssistantMessageError Literal — " + f"{_AUTH_ASSISTANT_ERROR!r} no longer in {AssistantMessageError.__args__}. " + f"Update _AUTH_ASSISTANT_ERROR in streaming_session.py." + ) + +# ── _is_auth_error_assistant ────────────────────────────────────── +# +# AssistantMessage.error is the AssistantMessageError Literal: +# authentication_failed, billing_error, rate_limit, invalid_request, +# server_error, unknown +# Only "authentication_failed" should trip the operator alert. + + +def _msg(error=None): + """Lightweight AssistantMessage stand-in (only the .error attr matters).""" + return SimpleNamespace(error=error) + + +def test_is_auth_error_assistant_true_only_for_authentication_failed(): + assert _is_auth_error_assistant(_msg("authentication_failed")) is True + + +def test_is_auth_error_assistant_false_for_other_literal_values(): + # Other AssistantMessageError values are real errors but NOT credential + # failures. Alerting the operator on these would be noise (or actively + # wrong, in the case of billing/rate_limit issues that have nothing to + # do with credentials). + for not_auth in ( + "billing_error", + "rate_limit", + "invalid_request", + "server_error", + "unknown", + ): + assert _is_auth_error_assistant(_msg(not_auth)) is False, not_auth + + +def test_is_auth_error_assistant_false_for_none_and_missing(): + assert _is_auth_error_assistant(_msg(None)) is False + # Missing attribute (e.g. unrelated message type) must not raise. + assert _is_auth_error_assistant(SimpleNamespace()) is False + + +# ── _is_auth_error_result ───────────────────────────────────────── +# +# ResultMessage.api_error_status is int|None — raw HTTP status from a +# failing API call. 401/403 are credential failures; 429 and 5xx are +# transient/operational and must not trip the auth alert. + + +def _result(api_error_status=None): + """Lightweight ResultMessage stand-in.""" + return SimpleNamespace(api_error_status=api_error_status) + -# ── _is_auth_error ──────────────────────────────────────────────── +def test_is_auth_error_result_true_for_401_and_403(): + assert _is_auth_error_result(_result(401)) is True + assert _is_auth_error_result(_result(403)) is True -def test_is_auth_error_matches_known_tokens(): - assert _is_auth_error("authentication_failed") is True - assert _is_auth_error("invalid_api_key") is True - assert _is_auth_error("Unauthorized") is True # case-insensitive - assert _is_auth_error("auth_error: token expired") is True - assert _is_auth_error("permission_error") is True +def test_is_auth_error_result_false_for_transient_statuses(): + # Rate limit (429) and any 5xx must NOT alert as a credential failure. + for not_auth_status in (429, 500, 502, 503, 504, 529): + assert _is_auth_error_result(_result(not_auth_status)) is False, not_auth_status -def test_is_auth_error_skips_other_failures(): - assert _is_auth_error("rate_limit_exceeded") is False - assert _is_auth_error("content_filter") is False - assert _is_auth_error("") is False - assert _is_auth_error(None) is False +def test_is_auth_error_result_false_for_none_and_success(): + assert _is_auth_error_result(_result(None)) is False + assert _is_auth_error_result(_result(200)) is False + # Missing attribute (older SDK / non-result objects) must not raise. + assert _is_auth_error_result(SimpleNamespace()) is False # ── AuthFailureTracker ──────────────────────────────────────────── diff --git a/tests/test_streaming_session.py b/tests/test_streaming_session.py index a1f187a6..def9bed7 100644 --- a/tests/test_streaming_session.py +++ b/tests/test_streaming_session.py @@ -202,3 +202,177 @@ async def test_idle_sleep_returns_false_when_already_disconnected() -> None: assert result is False assert ss.is_idle_sleeping is False + + +# -- Auth-failure dedupe across AssistantMessage + ResultMessage paths -------- +# +# A single failed turn can surface auth errors on BOTH paths the reader_loop +# watches: an AssistantMessage with error="authentication_failed", followed +# by the terminal ResultMessage with api_error_status=401. Without dedupe, +# AuthFailureTracker.record_failure() would increment twice for one real +# failure — tripping the operator-alert threshold early and skewing the +# host-wide multi-agent baseline. Reader_loop must collapse the two-path +# emission into a single auth-alert-callback invocation per turn. +# +# Per Murzik's PR #404 review: "I verified locally with a synthetic reader +# stream: callback fired as [('agent', 'authentication_failed'), ('agent', +# 'api_error_status=401')]." This test pins down the fix. + + +def _make_assistant_message(*, error: str | None = None): + """Build an AssistantMessage with the minimum fields the reader_loop reads.""" + from claude_agent_sdk.types import AssistantMessage + + return AssistantMessage( + content=[], + model="claude-opus-4-7", + error=error, + usage={"input_tokens": 0, "output_tokens": 0}, + stop_reason="error" if error else "end_turn", + ) + + +def _make_result_message( + *, + is_error: bool = False, + api_error_status: int | None = None, + errors: list[str] | None = None, +): + """Build a ResultMessage with the minimum fields the reader_loop reads.""" + from claude_agent_sdk.types import ResultMessage + + return ResultMessage( + subtype="success" if not is_error else "error", + duration_ms=10, + duration_api_ms=5, + is_error=is_error, + num_turns=1, + session_id="test-session", + stop_reason="end_turn" if not is_error else "error", + api_error_status=api_error_status, + errors=errors, + ) + + +async def _run_reader_against_stream(ss: StreamingSession, messages: list) -> None: + """Wire up a fake client whose receive_messages() yields the given list, + then run the reader_loop until the iterator drains.""" + + async def _receive_messages(): + for msg in messages: + yield msg + + ss._client.receive_messages = _receive_messages + await ss._reader_loop() + + +@pytest.mark.asyncio +async def test_auth_callback_fires_once_when_both_paths_signal_for_same_turn() -> None: + """Regression for PR #404 / Murzik's review. + + SDK emits AssistantMessage(error="authentication_failed") followed by + ResultMessage(is_error=True, api_error_status=401) for a single failed + turn. The auth-alert callback must fire exactly once — not once per path.""" + ss = _make_session() + callback = AsyncMock() + ss._auth_alert_callback = callback + + await _run_reader_against_stream( + ss, + [ + _make_assistant_message(error="authentication_failed"), + _make_result_message( + is_error=True, + api_error_status=401, + errors=["Invalid API key"], + ), + ], + ) + + assert callback.await_count == 1, ( + f"Expected one callback per failed turn (dedupe across " + f"AssistantMessage + ResultMessage paths); got {callback.await_count}: " + f"{callback.await_args_list}" + ) + # The AssistantMessage path should win (it fires first in the stream). + args, _ = callback.await_args + assert args == (ss.agent_name, "authentication_failed") + + +@pytest.mark.asyncio +async def test_auth_callback_fires_on_result_path_when_assistant_path_silent() -> None: + """If the failed turn surfaces only at the ResultMessage (no + AssistantMessage error mid-turn), the result-path detection must still + fire the alert. Dedupe must not break this case.""" + ss = _make_session() + callback = AsyncMock() + ss._auth_alert_callback = callback + + await _run_reader_against_stream( + ss, + [ + _make_result_message( + is_error=True, + api_error_status=403, + errors=["Forbidden"], + ), + ], + ) + + assert callback.await_count == 1 + args, _ = callback.await_args + assert args[0] == ss.agent_name + assert "api_error_status=403" in args[1] + # Enriched detail string should include msg.errors for triage context. + assert "Forbidden" in args[1] + + +@pytest.mark.asyncio +async def test_auth_dedupe_resets_between_turns() -> None: + """Two separate failed turns in one session must produce two callback + invocations — dedupe is per-turn, not per-session.""" + ss = _make_session() + callback = AsyncMock() + ss._auth_alert_callback = callback + + await _run_reader_against_stream( + ss, + [ + # Turn 1: AssistantMessage auth + ResultMessage 401 — one alert + _make_assistant_message(error="authentication_failed"), + _make_result_message(is_error=True, api_error_status=401), + # Turn 2: ResultMessage 401 only — one more alert + _make_result_message(is_error=True, api_error_status=401), + ], + ) + + assert callback.await_count == 2, ( + f"Expected two callbacks (one per failed turn); got {callback.await_count}" + ) + + +@pytest.mark.asyncio +async def test_billing_error_assistant_does_not_block_subsequent_auth_alert() -> None: + """Edge case: AssistantMessage with error="billing_error" (NOT auth) on + one turn must not set the dedupe flag, so a real auth failure on the + NEXT turn still alerts.""" + ss = _make_session() + callback = AsyncMock() + ss._auth_alert_callback = callback + + await _run_reader_against_stream( + ss, + [ + # Turn 1: billing error — must not fire auth alert, must not + # set the dedupe flag (it's not an auth error). + _make_assistant_message(error="billing_error"), + _make_result_message(is_error=True, api_error_status=402), + # Turn 2: real auth failure — must alert. + _make_assistant_message(error="authentication_failed"), + _make_result_message(is_error=True, api_error_status=401), + ], + ) + + assert callback.await_count == 1 + args, _ = callback.await_args + assert args == (ss.agent_name, "authentication_failed") diff --git a/uv.lock b/uv.lock index fd90df95..990ce522 100644 --- a/uv.lock +++ b/uv.lock @@ -508,20 +508,20 @@ wheels = [ [[package]] name = "claude-agent-sdk" -version = "0.1.73" +version = "0.1.77" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "mcp" }, { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/d7/5207b9428813918532d52fbcc8342aa105e04cb50afb94118228a322af39/claude_agent_sdk-0.1.73.tar.gz", hash = "sha256:7dbb1e885abac558e39374da632c52e87a931861e20c67e1f36c86e7e3be7f19", size = 242906, upload-time = "2026-05-04T23:14:12.366Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/d4/7e3ca29d1b6f76639b4bd4becb796ef68a492be3a561c6cd19abe5fe903a/claude_agent_sdk-0.1.77.tar.gz", hash = "sha256:cb292268ecab294047f02365298ecbf8cc17146c4c86fda54b068a1d38e1ebbb", size = 250297, upload-time = "2026-05-08T00:03:03.199Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/9d/37bac90d86f50642ba1e9b70b558cdd643da8bb1109c584ce32ad0b1fc6f/claude_agent_sdk-0.1.73-py3-none-macosx_11_0_arm64.whl", hash = "sha256:62171e16840a8d00681207f6ea133736082b5df701a87548a84ec5ab00cdf5ca", size = 63885588, upload-time = "2026-05-04T23:14:16.457Z" }, - { url = "https://files.pythonhosted.org/packages/3e/e2/81212e886a9bc34a14b8d3588aafd3460bb37328edc4793b10e7df205e05/claude_agent_sdk-0.1.73-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:a702aecfdd1d9ad7dd9fcf19aa9ebde41ffc426e28a4125b3ab2e88b49a89c47", size = 65744104, upload-time = "2026-05-04T23:14:20.345Z" }, - { url = "https://files.pythonhosted.org/packages/80/81/2ecea14eb376d2eaf7f121fdc6ee4b00071b9b1b859ff7251d84fe0e4686/claude_agent_sdk-0.1.73-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:6cb2c38fbfd3d6be7edfc41ce342635569403a8b41f77ded07c328f20b6138cf", size = 77049373, upload-time = "2026-05-04T23:14:24.595Z" }, - { url = "https://files.pythonhosted.org/packages/89/16/a02de4e05bd522beddf2aa02351fc670222929e9e25fff2e23add7937df6/claude_agent_sdk-0.1.73-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:938b2f1adf10e92d4e14c0b16d61f8e616171e98cdc7c3cbcd197dd857fdbc22", size = 77225319, upload-time = "2026-05-04T23:14:28.843Z" }, - { url = "https://files.pythonhosted.org/packages/d0/6a/cb0b8b9a9110ca46e5fb2de7c4e4224e0b18ca8fd50430ca0ea4118608cd/claude_agent_sdk-0.1.73-py3-none-win_amd64.whl", hash = "sha256:85454108785058bab796e9de2d654ce5570c2d9f8de9a9889e02a751d4a43158", size = 78636611, upload-time = "2026-05-04T23:14:33.165Z" }, + { url = "https://files.pythonhosted.org/packages/80/fc/dbb90aff01fe7bdd7708077aaff4b36d5be48a38318fd4ca2216aa64ed87/claude_agent_sdk-0.1.77-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a5697c838cb3a0c78f73698933cd2eea98e8baea99be0740e314fc9e1f69fd4c", size = 60659903, upload-time = "2026-05-08T00:03:06.461Z" }, + { url = "https://files.pythonhosted.org/packages/37/6e/c984d60f4b3cbbf7d84157283338493d11a2b557a2f6389118b5a7f8adc2/claude_agent_sdk-0.1.77-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:a85e3a8cf5d521116d964d28939f25e367d18e5bdb2232cae260b8e8bd9d946f", size = 62721493, upload-time = "2026-05-08T00:03:09.808Z" }, + { url = "https://files.pythonhosted.org/packages/00/83/575e798ce53b58e14d775db104cdb2558706e7f8d22dc647752208fa853f/claude_agent_sdk-0.1.77-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:16864b91bf369e99590e3ffba82e2d711807caaa4329b11462212779224cd355", size = 70393564, upload-time = "2026-05-08T00:03:13.383Z" }, + { url = "https://files.pythonhosted.org/packages/00/67/50aed27534a9183e204cca33fbef36d0fe5f5145ebf4061c01f2edeaf6db/claude_agent_sdk-0.1.77-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:7db011c9c8ca4168249f1ce90d4372e13ebab2b122498167e6fb2a5c9c1bd427", size = 70582992, upload-time = "2026-05-08T00:03:16.997Z" }, + { url = "https://files.pythonhosted.org/packages/83/3f/a9d396c46e40d025d4209cf0f6f01a62c70c33b70efceb2d2e991e8ed08c/claude_agent_sdk-0.1.77-py3-none-win_amd64.whl", hash = "sha256:fe7dd006a15a85c1d9a2698d6fffd58e0ce689db1f0040f5b5e4c06d51ce2505", size = 71224295, upload-time = "2026-05-08T00:03:20.695Z" }, ] [[package]] @@ -2258,7 +2258,7 @@ requires-dist = [ { name = "beautifulsoup4", marker = "extra == 'web'", specifier = ">=4.12" }, { name = "caldav", marker = "extra == 'calendar'", specifier = ">=1.3" }, { name = "camoufox", marker = "extra == 'web'", specifier = ">=0.4" }, - { name = "claude-agent-sdk", specifier = ">=0.1.73" }, + { name = "claude-agent-sdk", specifier = ">=0.1.77" }, { name = "cryptography", specifier = ">=41.0" }, { name = "cryptography", marker = "extra == 'calendar'", specifier = ">=41.0" }, { name = "discord-py", marker = "extra == 'discord'", specifier = ">=2.3" }, From 455ae4d46aa23e9bbaa224ac1dbe840278f52a3f Mon Sep 17 00:00:00 2001 From: olegbrok Date: Sat, 9 May 2026 11:42:15 -0700 Subject: [PATCH 16/24] fix(discord-poller): allow peer agents/bots to reach us (#411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Discord poller silently dropped every message authored by any bot, not just our own. That made cross-fleet messaging impossible — peer agents like Pulse (Matt's) and Misha (also an agent) were filtered out before reaching the broker, even when they explicitly addressed us. The intent of the filter was self-reply-loop prevention; the existing author_id == self._bot_user_id check at the next line already does that load-bearing work. The is_bot check was overshooting. Changes - pollers.py: drop the blanket is_bot filter; keep self-skip via author_id; retain a defensive guard for the (theoretical) is_bot-without-author_id case so we never can't-tell-if-it's-us. - pollers.py priming: use is_self (author_id == bot_user_id) instead of is_bot when deciding whether to back off the floor for a fresh first- contact message. Peer-bot first-contact during the discovery race now delivers the same way a fresh human's first-test message does. - tests: encode the new contract — self-bot filtered, peer-bot delivered (both per-message and during priming); defensive case covered. Co-authored-by: Oleg Co-authored-by: Claude Opus 4.7 --- src/pinky_daemon/pollers.py | 22 +++++-- tests/test_discord_poller.py | 121 +++++++++++++++++++++++++++++------ 2 files changed, 118 insertions(+), 25 deletions(-) diff --git a/src/pinky_daemon/pollers.py b/src/pinky_daemon/pollers.py index ee8de6ab..b6e8e0a0 100644 --- a/src/pinky_daemon/pollers.py +++ b/src/pinky_daemon/pollers.py @@ -581,9 +581,13 @@ async def _refresh_channels(self, *, verbose: bool) -> None: age_sec = (now - msg.timestamp).total_seconds() except (TypeError, ValueError): age_sec = float("inf") - is_bot = bool((msg.metadata or {}).get("is_bot", False)) + # Discriminate self vs peer: only OUR bot's messages are skipped at + # priming. Peer agents/bots are first-class senders and their fresh + # messages should be delivered on next poll (cross-fleet support). + author_id = (msg.metadata or {}).get("author_id", "") + is_self = bool(self._bot_user_id) and author_id == self._bot_user_id - if age_sec < _PRIME_FRESH_WINDOW_SECONDS and not is_bot: + if age_sec < _PRIME_FRESH_WINDOW_SECONDS and not is_self: # Fresh first-contact message — back the floor up by 1 so # the next poll fetches and delivers it. try: @@ -603,7 +607,7 @@ async def _refresh_channels(self, *, verbose: bool) -> None: _log( f"discord-poller[{self._agent_name}]: primed channel {ch} " f"with floor {msg.message_id} (most-recent msg age " - f"{age_sec:.0f}s, is_bot={is_bot} — first message AFTER " + f"{age_sec:.0f}s, is_self={is_self} — first message AFTER " f"this will be delivered)" ) @@ -701,9 +705,17 @@ async def _poll_once(self) -> None: self._last_id[channel_id] = msg.message_id meta = msg.metadata or {} - if meta.get("is_bot"): + # Skip our own messages to avoid self-reply loops. We do NOT + # filter on is_bot — peer agents and other bots must reach us + # for cross-fleet messaging (Pulse, Misha, etc.). Each agent + # is responsible for its own conversational rate-limiting. + author_id = meta.get("author_id", "") + if author_id and author_id == self._bot_user_id: continue - if meta.get("author_id") and meta["author_id"] == self._bot_user_id: + # Defensive: a bot-authored message with no author_id can't be + # deduped against ourselves, so skip it. The Discord adapter + # always sets author_id, so this is a belt-and-suspenders guard. + if meta.get("is_bot") and not author_id: continue broker_msg = self._BrokerMessage( diff --git a/tests/test_discord_poller.py b/tests/test_discord_poller.py index 723b4450..f6cebe33 100644 --- a/tests/test_discord_poller.py +++ b/tests/test_discord_poller.py @@ -114,16 +114,21 @@ async def test_new_message_routed_through_broker(self, mock_adapter, mock_broker assert poller._last_id["chan-A"] == "101" @pytest.mark.asyncio - async def test_bot_messages_skipped(self, mock_adapter, mock_broker): - """is_bot=True or author_id matching the bot's own id must be filtered out.""" + async def test_self_bot_filtered_peer_bots_delivered(self, mock_adapter, mock_broker): + """Only OUR bot's own messages are filtered. Peer agents/bots reach us. + + Cross-fleet messaging requires that other agents (Pulse, Misha, etc.) + can talk to us. The only message we must drop is one we authored, to + avoid self-reply loops. is_bot=True alone is NOT a filter criterion. + """ baseline = _msg(msg_id="100", content="baseline") bot_self = _msg( msg_id="101", content="self echo", - author_id="bot-001", sender="TestBot", is_bot=False, + author_id="bot-001", sender="TestBot", is_bot=True, ) - bot_other = _msg( - msg_id="102", content="other bot", - author_id="bot-other", sender="OtherBot", is_bot=True, + bot_peer = _msg( + msg_id="102", content="hello from pulse", + author_id="bot-other", sender="Pulse", is_bot=True, ) real_user = _msg( msg_id="103", content="real", @@ -132,7 +137,7 @@ async def test_bot_messages_skipped(self, mock_adapter, mock_broker): # Discord returns newest-first; poller reverses to chronological for delivery. mock_adapter.get_messages.side_effect = [ [baseline], - [real_user, bot_other, bot_self], + [real_user, bot_peer, bot_self], ] poller = self._make_poller(mock_adapter, mock_broker) @@ -143,13 +148,42 @@ async def test_bot_messages_skipped(self, mock_adapter, mock_broker): await asyncio.sleep(0) - # Only the real-user message should be delivered - assert mock_broker.handle_inbound.await_count == 1 - delivered = mock_broker.handle_inbound.await_args.args[0] - assert delivered.message_id == "103" + # Both peer-bot and real-user should be delivered; only self-bot dropped. + assert mock_broker.handle_inbound.await_count == 2 + delivered_ids = { + call.args[0].message_id + for call in mock_broker.handle_inbound.await_args_list + } + assert delivered_ids == {"102", "103"} # last_id advances past all of them so we don't refetch assert poller._last_id["chan-A"] == "103" + @pytest.mark.asyncio + async def test_bot_message_without_author_id_is_skipped_defensively( + self, mock_adapter, mock_broker, + ): + """is_bot=True with empty author_id can't be deduped vs self → skip.""" + baseline = _msg(msg_id="100", content="baseline") + # Adapter normally always sets author_id, but if it's missing on a bot + # message we have no way to tell whether it's us or a peer — be safe. + no_author_bot = _msg( + msg_id="101", content="anon bot", + author_id="", sender="???", is_bot=True, + ) + mock_adapter.get_messages.side_effect = [ + [baseline], + [no_author_bot], + ] + + poller = self._make_poller(mock_adapter, mock_broker) + poller._bot_user_id = "bot-001" + await poller._refresh_channels(verbose=True) + await poller._poll_once() + await asyncio.sleep(0) + + mock_broker.handle_inbound.assert_not_called() + assert poller._last_id["chan-A"] == "101" + @pytest.mark.asyncio async def test_explicit_watched_channels_override_discovery(self, mock_adapter, mock_broker): """If settings.watched_channels is set, discover_text_channels must NOT be called.""" @@ -246,32 +280,79 @@ async def test_priming_old_message_uses_message_as_floor( mock_broker.handle_inbound.assert_not_called() @pytest.mark.asyncio - async def test_priming_fresh_bot_message_does_not_back_off( + async def test_priming_fresh_peer_bot_backs_off_and_delivers( self, mock_adapter, mock_broker, ): - """Fresh-but-bot message → use as floor (don't loop on bot self-reply).""" + """Fresh peer-bot message at prime time → backed-off floor → delivered. + + Peer agents/bots are first-class senders. If we discover a channel + right after a peer-bot posts (the cross-fleet first-contact race), + their message must be delivered, same as a fresh human's would be. + """ from datetime import datetime, timezone - fresh_bot = Message( + fresh_peer_bot = Message( platform=Platform.discord, chat_id="chan-A", - sender="OtherBot", - content="bot chatter", + sender="Pulse", + content="hi from pulse", timestamp=datetime.now(timezone.utc), message_id="300", metadata={"author_id": "bot-other", "is_bot": True}, ) mock_adapter.get_messages.side_effect = [ - [fresh_bot], # priming - [], # poll + [fresh_peer_bot], # priming + [fresh_peer_bot], # poll picks it up after floor backed off + ] + + poller = self._make_poller(mock_adapter, mock_broker) + # Our bot id is different from the peer's + poller._bot_user_id = "bot-self" + await poller._refresh_channels(verbose=True) + + # Floor must be backed off so the next poll fetches the peer-bot msg + assert poller._last_id["chan-A"] == "299" + + await poller._poll_once() + await asyncio.sleep(0) + + # Peer-bot message delivered through broker + assert mock_broker.handle_inbound.await_count == 1 + delivered = mock_broker.handle_inbound.await_args.args[0] + assert delivered.message_id == "300" + assert delivered.content == "hi from pulse" + + @pytest.mark.asyncio + async def test_priming_fresh_self_bot_uses_as_floor( + self, mock_adapter, mock_broker, + ): + """Fresh OWN-bot message at prime time → use as floor, not delivered. + + Loop prevention: even if we just posted, we must not re-deliver our + own message back to ourselves through the broker. + """ + from datetime import datetime, timezone + fresh_self = Message( + platform=Platform.discord, + chat_id="chan-A", + sender="Self", + content="i just said this", + timestamp=datetime.now(timezone.utc), + message_id="400", + metadata={"author_id": "bot-self", "is_bot": True}, + ) + mock_adapter.get_messages.side_effect = [ + [fresh_self], # priming + [], # nothing new on poll ] poller = self._make_poller(mock_adapter, mock_broker) + poller._bot_user_id = "bot-self" await poller._refresh_channels(verbose=True) await poller._poll_once() await asyncio.sleep(0) - # Bot message is the floor as-is — not backed off, not delivered - assert poller._last_id["chan-A"] == "300" + # Self message used as the floor as-is — not delivered + assert poller._last_id["chan-A"] == "400" mock_broker.handle_inbound.assert_not_called() @pytest.mark.asyncio From 58e79f04f98d8bad94106aededef90e93a27a432 Mon Sep 17 00:00:00 2001 From: olegbrok Date: Sun, 10 May 2026 14:45:44 -0700 Subject: [PATCH 17/24] feat(ferry): scaffold @ferry/host-pinky with substrate v0.1 ingestion (#413) (#414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ferry): scaffold @ferry/host-pinky with substrate v0.1 ingestion (#413) Add `pinky_daemon.ferry` package implementing the PinkyBot-side host-callback for ferry — receives ferry envelopes addressed to PinkyBot agents and translates them into either: - inbound platform messages (via broker.handle_inbound), or - substrate-spec memory imports (pinky-memory + pinky-self). This is the Python implementation of @ferry/host-pinky's TS placeholder package (choose27/ferry PR #1) and the design doc at packages/host-pinky/README.md. Wire to pinky-daemon's existing primitives: - BrokerMessage(platform="ferry", ...) injection through broker.handle_inbound (bypassing human-approval path; peer-fleet ACL enforced upstream) - Reflection storage via pinky_memory.MemoryStore.insert - Task creation via pinky_self task store Substrate v0.1 §12.5 unwrap steps implemented: 1. Verify ferry envelope (stub for v0.1; signature verification deferred) 2. Populate port_history from envelope.traversal (§6.4 canonicality) 3. Preserve source.by verbatim (§6.1 — never overwrite with immediate sender) 4. Type-map per §11 reference impl notes 5. Index rebuild handled by pinky-memory's normal insert path Type mapping (host-implementation-defined per §11): fact / event / reference → pinky-memory `fact` feedback / decision → pinky-memory `insight` pattern → pinky-memory `interaction_pattern` pending → pinky-self `create_task` (lifecycle states map to task states; cross-MCP integration) Trust → salience: `salience = 1 + round(trust * 4)` clamped [1, 5]. Documented as one valid mapping, not the mapping (per Misha + Pulse threads — substrate stays trust-opaque, hosts decide). Links preservation: pinky-memory has no native cross-entry edge surface, so substrate `links` are stored in `context` JSON sidecar. v0.2 §3.2.1 "no-link-surface pattern" reference recipe will formalize the recall-time fan-out helper. Peer-fleet ACL primitive (`AgentCardSelector`) — separate identity primitive from human-user approval. Default-deny. At-least-one-required semantics with wildcards via `agent_id="*"` and `fleet="*"`. Tests: 33 passing. Acceptance test uses substrate v0.1 §12 worked example (two entries, ported A → B) verbatim as fixtures: Entry 1: feedback, trust 0.95, scope user/brad — lands as insight, salience 5 Entry 2: pattern, trust 0.7, scope user/brad — lands as interaction_pattern, salience 4, with `links` preserved in context sidecar pointing at Entry 1's substrate id Out of scope for v0.1 (tracked in issue #413): - NATS leaf-link plumbing (Pulse owns) - Outbound from PinkyBot (mcp__pinky-self__send_to_peer_fleet) - Multi-agent target routing (broadcast) - Real signature verification (accept-all + log) - Twice-ported degradation (re_grounded: true reset) - Import-time read-quiesce protocol (write-race during demo) Refs: - PinkyBot #413 (this work's behavior contract + acceptance test plan) - choose27/ferry PROTOCOL.md v0.1 (envelope shape) - choose27/ferry specs/substrate/v0.1.md (entry schema + §12 worked example) - choose27/ferry packages/host-pinky/README.md (design doc) Co-Authored-By: Claude Opus 4.7 * feat(ferry): peer_fleet_acl persistence + REST API + 20 tests (#413) Adds the persistence layer + REST API surface that PR #414's host-pinky needs to enforce peer-fleet ACL. With this in place, the draft can be unmarked. agent_registry.py: - New `peer_fleet_acl` JSON column on `agents` (default '[]') - Migration entry in `_ensure_columns()` migrations list - `has_agent(name) -> bool` — efficient existence check - `get_peer_fleet_acl(name) -> list[dict]` — read selectors - `set_peer_fleet_acl(name, selectors)` — full replacement (drops empty/invalid) - `add_peer_fleet_acl(name, fleet=, agent_id=, pinky_type=)` — idempotent append - `remove_peer_fleet_acl(name, fleet=, agent_id=, pinky_type=)` — exact-match delete api.py — new endpoints under `/agents/{name}/peer-fleet-acl`: - GET — list current selectors (404 if agent unknown) - POST — add one selector (400 if all fields empty; idempotent on duplicates) - PUT — full replacement of the selector list - DELETE — remove by exact match query (returns count removed) Pydantic request models: - `PeerFleetAclEntryRequest` — single selector for POST/DELETE query - `PeerFleetAclSetRequest` — list of selectors for PUT replacement Identity primitive notes: - peer_fleet_acl is *separate* from approved_users. approved_users gates humans on Telegram/Discord; peer_fleet_acl gates *agents* arriving via ferry. Different identity primitive, different list, different default (deny-all for ferry vs auto-approve-primary-user for humans). - Empty/invalid selectors are silently dropped at the registry layer with a log line; the API layer rejects them with 400 so the caller sees the error rather than having a silent no-op. Tests (20 new, total 53 passing in tests/test_ferry_host_pinky.py): - 9 registry persistence tests (set/get roundtrip, replace-not-merge, add-idempotent, remove, empty-rejection, type-validation) - 2 end-to-end tests against real AgentRegistry (ACL allow/remove cycle, real has_agent for unknown-agent rejection) - 8 API endpoint tests (GET empty/404, POST adds/rejects-empty/idempotent, PUT replaces, DELETE removes/no-match) - All ruff-clean; no warnings beyond pre-existing FastAPI deprecations. Refs: - PinkyBot #413 (behavior contract) - choose27/ferry packages/host-pinky/README.md (ACL contract section, AgentCardSelector shape) - Substrate v0.1 §6 source.by preservation invariant relies on this ACL working — peer-fleet identity carries source.by claims. Co-Authored-By: Claude Opus 4.7 * fix(ferry): close broker-boundary identity leak; round-2 review fixes PR #414 round-1 wired host-pinky's message dispatch through broker.handle_inbound, which writes the peer-fleet sender_id into the human approved_users table and emits /approve_ferry: as a Telegram prompt to the owner. The load-bearing identity-primitive separation claim was broken at the broker boundary. Round 2 changes: Blocking fix: - Add broker.dispatch_pre_authorized(agent_name, message) — public primitive that routes a pre-authorized message straight to the agent's streaming session, bypassing handle_inbound's onboarding flow (get_user_status / add_pending_user / /approve_ prompt). Reusable for future pre-authorized channels (federation, MCP-host inbound). - Switch host_pinky._deliver_message to dispatch_pre_authorized; rewrite docstring to describe what actually happens at the broker boundary (no more vacuous "dedicated path" claim). - Add TestBrokerIntegrationFerryInbound regression guard: pipes a ferry envelope through real Broker + AgentRegistry and asserts the ferry sender_id stays out of approved_users, no /approve_ prompt fires, no message queues under the ferry identity. Other findings: - DeliveryResult gains transient_failure status. host_pinky raises _TransientACLLoadError on sqlite3.Error from the registry read and translates to transient_failure so the broker retries instead of treating an operator-side hiccup as a policy denial. - _insert_task ternary refactored — old form stringified the entire task object as a "task id" when .id was empty. - AgentRegistry add/remove_peer_fleet_acl now guarded by an RLock so concurrent admin-API requests can't lose updates on the read-modify-write of peer_fleet_acl. - PortHistoryEntry gains attested_by: "broker" | "receiver" so receiver-fabricated synthetic-hop timestamps don't conflate with broker-stamped traversal timestamps. - _load_peer_fleet_acl narrows broad except to (TypeError, ValueError, json.JSONDecodeError) for per-item parse skips; sqlite3.Error escapes for the transient path. - HostPinky gains optional reflection_factory injection for tests. - remove_peer_fleet_acl wildcard-vs-exact semantics documented in the docstring + new test pins the behavior. Tests: - 4 new test classes: TestTransientACLLoadFailure, TestACLRemoveWildcardSemantics, TestDeliverPendingSubstrate, TestBrokerIntegrationFerryInbound. - TestPortHistory now asserts attested_by distinction. - 58 ferry tests passing; ruff clean. 🤖 Round-2 by Barsik (author) addressing Misha's Class A review. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Oleg Co-authored-by: Claude Opus 4.7 --- src/pinky_daemon/agent_registry.py | 150 ++++ src/pinky_daemon/api.py | 107 +++ src/pinky_daemon/broker.py | 27 + src/pinky_daemon/ferry/__init__.py | 36 + src/pinky_daemon/ferry/host_pinky.py | 524 +++++++++++++ src/pinky_daemon/ferry/substrate.py | 270 +++++++ src/pinky_daemon/ferry/types.py | 214 ++++++ tests/test_ferry_host_pinky.py | 1063 ++++++++++++++++++++++++++ 8 files changed, 2391 insertions(+) create mode 100644 src/pinky_daemon/ferry/__init__.py create mode 100644 src/pinky_daemon/ferry/host_pinky.py create mode 100644 src/pinky_daemon/ferry/substrate.py create mode 100644 src/pinky_daemon/ferry/types.py create mode 100644 tests/test_ferry_host_pinky.py diff --git a/src/pinky_daemon/agent_registry.py b/src/pinky_daemon/agent_registry.py index f2dbd006..a34f636f 100644 --- a/src/pinky_daemon/agent_registry.py +++ b/src/pinky_daemon/agent_registry.py @@ -25,6 +25,7 @@ import json import sqlite3 import sys +import threading import time from dataclasses import dataclass, field from pathlib import Path @@ -452,6 +453,13 @@ def __init__(self, db_path: str = "data/agents.db") -> None: self._db = sqlite3.connect(db_path, check_same_thread=False) self._db.execute("PRAGMA journal_mode=WAL") self._db.execute("PRAGMA foreign_keys=ON") + # Guard read-modify-write sequences (e.g. peer_fleet_acl mutation) + # from concurrent admin-API requests. SQLite connection is shared + # across threads (check_same_thread=False) and Python's default + # isolation_level uses deferred BEGIN — without this lock, two + # admin requests can both read the same baseline ACL, append + # different entries, and one write loses. + self._rmw_lock = threading.RLock() self._init_tables() def _init_tables(self) -> None: @@ -733,6 +741,9 @@ def _migrate(self) -> None: ("watchdog_config", "TEXT NOT NULL DEFAULT '{}'"), ("last_seen_at", "REAL NOT NULL DEFAULT 0"), ("runtime", "TEXT NOT NULL DEFAULT 'claude_sdk'"), + # Ferry peer-fleet ACL — list of AgentCardSelector dicts + # (separate identity primitive from approved_users; default deny-all) + ("peer_fleet_acl", "TEXT NOT NULL DEFAULT '[]'"), ] for col, typedef in migrations: if col not in existing: @@ -2674,5 +2685,144 @@ def get_total_lifetime_cost(self) -> float: row = self._db.execute("SELECT SUM(cost_usd) FROM agent_costs").fetchone() return round(row[0] or 0, 6) + # ── Ferry peer-fleet ACL ─────────────────────────────── + # + # Separate identity primitive from approved_users (humans on Telegram / + # Discord / etc.). Ferry inbound is *agents* addressing an agent — + # different identity primitive, separate list. Default-deny. + # + # Stored as JSON array of AgentCardSelector dicts on the `agents` row's + # `peer_fleet_acl` column. See `pinky_daemon.ferry.types.AgentCardSelector` + # for the selector shape. + + def has_agent(self, agent_name: str) -> bool: + """Return True if an agent with this name is registered (any status).""" + row = self._db.execute( + "SELECT 1 FROM agents WHERE name = ? LIMIT 1", (agent_name,) + ).fetchone() + return row is not None + + def get_peer_fleet_acl(self, agent_name: str) -> list[dict]: + """Return the list of peer-fleet ACL selector dicts for an agent. + + Each dict has shape `{fleet, agent_id, pinky_type}` (any combination, + with at least one non-null field per AgentCardSelector contract). + Returns [] for unknown agents or empty/missing ACL. + """ + row = self._db.execute( + "SELECT peer_fleet_acl FROM agents WHERE name = ?", (agent_name,) + ).fetchone() + if not row or not row[0]: + return [] + try: + data = json.loads(row[0]) + except (TypeError, ValueError): + return [] + if not isinstance(data, list): + return [] + return [d for d in data if isinstance(d, dict)] + + def set_peer_fleet_acl( + self, + agent_name: str, + selectors: list[dict], + ) -> None: + """Replace the peer-fleet ACL for an agent (full replacement, not merge). + + Each selector must have at least one of fleet/agent_id/pinky_type + non-empty. Selectors that don't validate are silently dropped with + a log line. Empty list = deny all peer-fleet inbound. + """ + clean: list[dict] = [] + for raw in selectors or []: + if not isinstance(raw, dict): + _log(f"peer_fleet_acl: skipping non-dict selector for {agent_name}: {raw!r}") + continue + fleet = (raw.get("fleet") or "").strip() or None + agent_id = (raw.get("agent_id") or "").strip() or None + pinky_type = (raw.get("pinky_type") or "").strip() or None + if not (fleet or agent_id or pinky_type): + _log(f"peer_fleet_acl: skipping empty selector for {agent_name}") + continue + clean.append({ + "fleet": fleet, + "agent_id": agent_id, + "pinky_type": pinky_type, + }) + self._db.execute( + "UPDATE agents SET peer_fleet_acl = ? WHERE name = ?", + (json.dumps(clean), agent_name), + ) + self._db.commit() + + def add_peer_fleet_acl( + self, + agent_name: str, + *, + fleet: str | None = None, + agent_id: str | None = None, + pinky_type: str | None = None, + ) -> bool: + """Append one selector to an agent's peer_fleet_acl. + + Returns True if added, False if the selector was empty (dropped). + Idempotent: a selector matching an existing entry is skipped. + + Thread-safe: read-modify-write is guarded by ``_rmw_lock`` so + concurrent admin-API requests can't lose updates. + """ + entry = { + "fleet": (fleet or "").strip() or None, + "agent_id": (agent_id or "").strip() or None, + "pinky_type": (pinky_type or "").strip() or None, + } + if not (entry["fleet"] or entry["agent_id"] or entry["pinky_type"]): + return False + with self._rmw_lock: + existing = self.get_peer_fleet_acl(agent_name) + if entry in existing: + return True + existing.append(entry) + self.set_peer_fleet_acl(agent_name, existing) + return True + + def remove_peer_fleet_acl( + self, + agent_name: str, + *, + fleet: str | None = None, + agent_id: str | None = None, + pinky_type: str | None = None, + ) -> int: + """Remove all selectors matching the given criteria. Returns count removed. + + Matching is **exact** on every field. A stored selector matches + only when all three of ``fleet`` / ``agent_id`` / ``pinky_type`` + equal the corresponding argument (``None`` and empty string are + normalized to the same value, so omitting an argument is the + same as passing ``""``). + + Wildcard caveat: a stored selector with ``agent_id="*"`` is + removed only by passing ``agent_id="*"`` — calling + ``remove_peer_fleet_acl(agent_name, fleet="sigil")`` will **not** + remove a stored ``{fleet:"sigil", agent_id:"*"}`` and silently + returns 0. Pass the exact stored selector you want to delete. + + Thread-safe: read-modify-write is guarded by ``_rmw_lock`` so + concurrent admin-API requests can't lose updates. + """ + target = { + "fleet": (fleet or "").strip() or None, + "agent_id": (agent_id or "").strip() or None, + "pinky_type": (pinky_type or "").strip() or None, + } + with self._rmw_lock: + existing = self.get_peer_fleet_acl(agent_name) + kept = [s for s in existing if s != target] + removed = len(existing) - len(kept) + if removed: + self.set_peer_fleet_acl(agent_name, kept) + return removed + def close(self) -> None: self._db.close() diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index f76d6ba9..d1a6397b 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -615,6 +615,25 @@ class ApproveUserRequest(BaseModel): approved_by: str = "" +class PeerFleetAclEntryRequest(BaseModel): + """Add or remove one peer-fleet ACL selector for an agent. + + Selectors are shaped after `pinky_daemon.ferry.types.AgentCardSelector` — + at-least-one of fleet/agent_id/pinky_type must be non-empty. Wildcards via + agent_id="*" or fleet="*". + """ + + fleet: str = "" + agent_id: str = "" + pinky_type: str = "" + + +class PeerFleetAclSetRequest(BaseModel): + """Replace the full peer-fleet ACL for an agent (full replacement, not merge).""" + + selectors: list[PeerFleetAclEntryRequest] = [] + + class SpawnSessionRequest(BaseModel): """Spawn a new session from an agent's config.""" @@ -6340,6 +6359,94 @@ async def set_user_timezone(name: str, chat_id: str, timezone: str = ""): raise HTTPException(404, "User not found") return {"updated": True, "chat_id": chat_id, "timezone": timezone} + # ── Ferry Peer-Fleet ACL ─────────────────────────────── + # + # Separate identity primitive from approved_users. Ferry inbound is + # *agents* addressing an agent; approved_users is *humans*. Default-deny. + # See `pinky_daemon.ferry.types.AgentCardSelector` for selector shape. + + @app.get("/agents/{name}/peer-fleet-acl") + async def get_peer_fleet_acl(name: str): + """List the peer-fleet ACL selectors for an agent.""" + if not agents.has_agent(name): + raise HTTPException(404, f"Agent '{name}' not found") + return { + "agent": name, + "selectors": agents.get_peer_fleet_acl(name), + } + + @app.post("/agents/{name}/peer-fleet-acl") + async def add_peer_fleet_acl(name: str, req: PeerFleetAclEntryRequest): + """Add one selector to an agent's peer-fleet ACL. + + Empty selectors (no fleet/agent_id/pinky_type set) are rejected with 400. + Adds are idempotent — already-present selectors return success without + creating duplicates. + """ + if not agents.has_agent(name): + raise HTTPException(404, f"Agent '{name}' not found") + added = agents.add_peer_fleet_acl( + name, + fleet=req.fleet or None, + agent_id=req.agent_id or None, + pinky_type=req.pinky_type or None, + ) + if not added: + raise HTTPException( + 400, + "selector requires at least one of fleet, agent_id, or pinky_type", + ) + return { + "agent": name, + "added": True, + "selectors": agents.get_peer_fleet_acl(name), + } + + @app.put("/agents/{name}/peer-fleet-acl") + async def set_peer_fleet_acl(name: str, req: PeerFleetAclSetRequest): + """Replace the full peer-fleet ACL for an agent (full replacement).""" + if not agents.has_agent(name): + raise HTTPException(404, f"Agent '{name}' not found") + selector_dicts = [ + { + "fleet": s.fleet or None, + "agent_id": s.agent_id or None, + "pinky_type": s.pinky_type or None, + } + for s in req.selectors + ] + agents.set_peer_fleet_acl(name, selector_dicts) + return { + "agent": name, + "selectors": agents.get_peer_fleet_acl(name), + } + + @app.delete("/agents/{name}/peer-fleet-acl") + async def remove_peer_fleet_acl( + name: str, + fleet: str = "", + agent_id: str = "", + pinky_type: str = "", + ): + """Remove all selectors matching the given query. + + Match is exact across all three fields (None == empty == "don't filter"). + Returns the count removed. + """ + if not agents.has_agent(name): + raise HTTPException(404, f"Agent '{name}' not found") + removed = agents.remove_peer_fleet_acl( + name, + fleet=fleet or None, + agent_id=agent_id or None, + pinky_type=pinky_type or None, + ) + return { + "agent": name, + "removed": removed, + "selectors": agents.get_peer_fleet_acl(name), + } + # ── Pending Messages (Broker) ────────────────────────── @app.get("/agents/{name}/pending-messages") diff --git a/src/pinky_daemon/broker.py b/src/pinky_daemon/broker.py index 7480c9e4..accfc9b1 100644 --- a/src/pinky_daemon/broker.py +++ b/src/pinky_daemon/broker.py @@ -631,6 +631,33 @@ async def handle_inbound(self, message: BrokerMessage) -> None: except Exception: pass + async def dispatch_pre_authorized( + self, agent_name: str, message: BrokerMessage, + ) -> None: + """Dispatch a message whose sender is already authorized upstream. + + Bypasses the human-platform onboarding flow that ``handle_inbound`` + runs (``get_user_status`` → ``add_pending_user`` → ``/approve_…`` + Telegram prompt to the owner). Intended for callers that enforce + their own identity gating before invoking the broker — currently + the ferry host-callback (``host_pinky``), where peer-fleet ACL has + already been enforced. Future pre-authorized channels (federation, + MCP-host inbound) should land on this same primitive rather than + reusing ``handle_inbound``. + + Concretely: routes the message to the agent's streaming session + without consulting ``approved_users``. The agent will see the + message in its prompt feed exactly as if the broker had approved + it via the human-platform flow. + + Activity-log emission is intentionally left to the caller — ferry + inbound has its own observability (host-pinky's stats counters), + and the broker's ``message_received`` event is shaped for human + platforms (sender/preview formatting). If a future caller wants + broker-side activity logs, expose that as a separate flag. + """ + await self._route_streaming(agent_name, message) + def _format_prompt(self, message: BrokerMessage) -> str: """Format a single message as a platform-aware prompt line.""" from datetime import datetime diff --git a/src/pinky_daemon/ferry/__init__.py b/src/pinky_daemon/ferry/__init__.py new file mode 100644 index 00000000..bec49edd --- /dev/null +++ b/src/pinky_daemon/ferry/__init__.py @@ -0,0 +1,36 @@ +"""Ferry — federated agent messaging integration. + +`@ferry/host-pinky` Python implementation. Receives ferry envelopes addressed +to a PinkyBot agent and routes them either as inbound platform messages +(via broker.handle_inbound) or as substrate-spec memory imports (via +pinky-memory + pinky-self). + +References: +- ferry PROTOCOL.md v0.1 — envelope shape +- substrate v0.1 — memory interchange spec, §12 worked example +- packages/host-pinky/README.md — design doc + +See PinkyBot issue #413 for the behavior contract and acceptance test plan. +""" + +from pinky_daemon.ferry.types import ( + AgentCardSelector, + DeliveryResult, + FerryEnvelope, + PortHistoryEntry, + SubstrateEntry, + SubstrateLifecycle, + SubstrateScope, + SubstrateSource, +) + +__all__ = [ + "AgentCardSelector", + "DeliveryResult", + "FerryEnvelope", + "SubstrateEntry", + "SubstrateScope", + "SubstrateSource", + "SubstrateLifecycle", + "PortHistoryEntry", +] diff --git a/src/pinky_daemon/ferry/host_pinky.py b/src/pinky_daemon/ferry/host_pinky.py new file mode 100644 index 00000000..39592f25 --- /dev/null +++ b/src/pinky_daemon/ferry/host_pinky.py @@ -0,0 +1,524 @@ +"""@ferry/host-pinky — PinkyBot host-callback for ferry. + +Receives ferry envelopes addressed to a PinkyBot agent and routes them +either as inbound platform messages (via broker.handle_inbound) or as +substrate-spec memory imports (via pinky-memory + pinky-self). + +The behavior contract is documented in PinkyBot issue #413 and mirrored +in ferry repo's packages/host-pinky/README.md. + +This module is the **adapter** — it does no transport (NATS / leaf-link +plumbing is owned by ferry-core) and does no model invocation (broker + +streaming sessions handle that). host-pinky's job is to translate one +identity primitive (signed ferry agent cards) into another (PinkyBot +agent + chat_id), enforce the peer-fleet ACL on the way in, and keep +substrate's port_history canonicality invariant on inbound (§6.4). +""" + +from __future__ import annotations + +import json +import sqlite3 +import sys +from collections.abc import Callable +from dataclasses import asdict +from typing import Any + +from pinky_daemon.ferry.substrate import ( + classify_entry_destination, + populate_port_history, + to_reflection_input, + to_task_input, +) +from pinky_daemon.ferry.types import ( + AgentCardSelector, + DeliveryResult, + FerryEnvelope, + IngestResult, + SubstrateEntry, +) + + +class _TransientACLLoadError(Exception): + """Raised by ``_load_peer_fleet_acl`` when the registry read failed for + operator-side reasons (DB lock, schema mismatch during rolling deploy, + etc.). Distinct from "ACL is empty" so the broker can replay rather + than treat the message as policy-denied. Internal to host_pinky. + """ + + +def _log(msg: str) -> None: + print(f"host-pinky: {msg}", file=sys.stderr, flush=True) + + +# -- Address parsing ----------------------------------------------------------- + +_FERRY_PINKY_PREFIX = "ferry://pinkybot/" + + +def parse_pinkybot_address(addr: str) -> str | None: + """Extract the agent name from a ferry://pinkybot/ address. + + Returns the agent name on success, or None if the address is not + pointed at this fleet. + """ + if not addr: + return None + if not addr.startswith(_FERRY_PINKY_PREFIX): + return None + name = addr[len(_FERRY_PINKY_PREFIX):].strip() + return name or None + + +def parse_peer_card(addr: str) -> tuple[str, str]: + """Parse a peer agent's address into (fleet, agent_id). + + Examples: + pulse@studio@sigil → ("sigil", "pulse@studio@sigil") + ferry://sigil/pulse → ("sigil", "ferry://sigil/pulse") + misha@pinky.local → ("pinky.local", "misha@pinky.local") + """ + if addr.startswith("ferry://"): + rest = addr[len("ferry://"):] + parts = rest.split("/", 1) + fleet = parts[0] if parts else "" + return (fleet, addr) + # at-form: name@(scope@)?fleet — rightmost segment is the fleet + parts = addr.split("@") + if len(parts) >= 2: + return (parts[-1], addr) + return ("", addr) + + +# -- HostPinky ----------------------------------------------------------------- + + +class HostPinky: + """Per-daemon host-callback. Constructed once at daemon startup. + + Required collaborators: + registry — AgentRegistry (for agent lookup + peer_fleet_acl read) + broker — MessageBroker (for handle_inbound) + memory_store — pinky-memory MemoryStore (for substrate→reflection insert) + task_store — pinky-self TaskStore (for substrate→pending→create_task) + + The collaborators are passed by reference; HostPinky does not own them. + """ + + def __init__( + self, + *, + registry: Any, + broker: Any, + memory_store: Any | None = None, + task_store: Any | None = None, + verify_signatures: bool = False, + reflection_factory: Callable[[dict[str, Any]], Any] | None = None, + ) -> None: + """Construct a HostPinky. + + ``reflection_factory`` (optional): callable that builds a Reflection + from a kwargs dict. Defaults to ``pinky_memory.types.Reflection`` when + unset. Inject in tests to avoid the pinky_memory import cost and to + substitute a stand-in shape; production callers leave it as ``None``. + """ + self._registry = registry + self._broker = broker + self._memory_store = memory_store + self._task_store = task_store + self._verify_signatures = verify_signatures + self._reflection_factory = reflection_factory + self._stats: dict[str, int] = { + "delivered": 0, + "rejected": 0, + "queued": 0, + "transient_failures": 0, + "substrate_imports": 0, + "messages_routed": 0, + } + + @property + def stats(self) -> dict[str, int]: + return dict(self._stats) + + # -- Main entry point ------------------------------------------------------ + + async def deliver(self, envelope: FerryEnvelope) -> DeliveryResult: + """Deliver a ferry envelope to a PinkyBot agent. + + Implements the four-step contract from the host-pinky design doc: + 1. Verify ferry envelope signatures + 2. Resolve target agent + 3. Peer-fleet ACL check + 4. Dispatch by body.kind + """ + # 1. Signature verification + if self._verify_signatures: + verify_err = self._verify(envelope) + if verify_err: + return self._reject("auth_failed", verify_err) + + # 2. Resolve target agent + agent_name = parse_pinkybot_address(envelope.to) + if not agent_name: + return self._reject( + "unknown_agent", + f"address {envelope.to!r} is not a PinkyBot ferry address", + ) + if not self._agent_exists(agent_name): + return self._reject("unknown_agent", f"no agent named {agent_name!r}") + + # 3. Peer-fleet ACL check + try: + allowed = self._acl_allows(agent_name, envelope.from_) + except _TransientACLLoadError as e: + # Operator-side hiccup (DB lock, schema mismatch during rolling + # deploy). Not a policy denial — the broker should retry rather + # than terminally drop a message that an ACL would have allowed. + return self._transient( + "acl_load_failed", + f"could not load peer_fleet_acl for {agent_name}: {e}", + ) + if not allowed: + return self._reject( + "acl_denied", + f"peer {envelope.from_!r} not on {agent_name}'s peer_fleet_acl", + ) + + # 4. Dispatch by payload kind + kind = envelope.kind + if kind == "message": + return await self._deliver_message(agent_name, envelope) + if kind in ("substrate.entry", "substrate.batch"): + return await self._deliver_substrate(agent_name, envelope) + + return self._reject("unsupported_payload_kind", f"kind={kind!r}") + + # -- Signature verification (deferred for v0.1) --------------------------- + + def _verify(self, envelope: FerryEnvelope) -> str | None: + """Verify envelope.from_ + each traversal record's signature. + + Returns None on success, or a human-readable error string. + + v0.1: stub — accept all. Real implementation lands with the + signed-agent-card subsystem in v0.2/v0.3 (per Pulse §12.3 thread). + """ + # TODO(v0.2): real Ed25519 signature verification. + return None + + # -- Agent lookup --------------------------------------------------------- + + def _agent_exists(self, agent_name: str) -> bool: + try: + return self._registry.has_agent(agent_name) + except AttributeError: + # Fallback: most AgentRegistry impls expose either has_agent or list_agents. + try: + names = {a.get("name") for a in self._registry.list_agents()} + return agent_name in names + except Exception: + return False + + # -- Peer-fleet ACL ------------------------------------------------------- + + def _acl_allows(self, agent_name: str, peer_address: str) -> bool: + """Check peer_fleet_acl for the receiving agent. + + Default-deny: empty/missing ACL means no peer-fleet inbound allowed. + """ + selectors = self._load_peer_fleet_acl(agent_name) + if not selectors: + return False + peer_fleet, peer_agent_id = parse_peer_card(peer_address) + for sel in selectors: + if sel.matches(peer_fleet, peer_agent_id, ""): + return True + return False + + def _load_peer_fleet_acl(self, agent_name: str) -> list[AgentCardSelector]: + """Load the agent's peer_fleet_acl selectors. + + Returns [] when the ACL is unset (policy: empty list = deny all). + + Raises ``_TransientACLLoadError`` when the registry read fails for + operator-side reasons (DB lock, schema mismatch). The caller in + ``deliver()`` translates that into a ``transient_failure`` delivery + result so the broker retries instead of treating the message as + terminally rejected. + + Per-item parse errors (malformed selector dict, invalid field + combinations) are skipped silently — they don't taint the rest of + the ACL or trigger a transient. + """ + try: + raw = self._registry.get_peer_fleet_acl(agent_name) + except AttributeError: + # Registry implementation doesn't expose peer_fleet_acl — older + # registry shape. Treat as empty (deny). Not transient. + return [] + except sqlite3.Error as e: + raise _TransientACLLoadError(str(e)) from e + if not raw: + return [] + out: list[AgentCardSelector] = [] + for item in raw: + try: + if isinstance(item, AgentCardSelector): + out.append(item) + elif isinstance(item, dict): + out.append(AgentCardSelector(**item)) + except (TypeError, ValueError, json.JSONDecodeError) as e: + _log(f"skipping invalid ACL entry for {agent_name}: {item} ({e})") + return out + + # -- Dispatch: message → broker.handle_inbound ---------------------------- + + async def _deliver_message( + self, + agent_name: str, + envelope: FerryEnvelope, + ) -> DeliveryResult: + """Route a ferry message envelope to the agent's streaming session. + + Identity-primitive separation: peer-fleet ACL was enforced + upstream in this module's ``_acl_allows`` step. The peer-fleet + identity (``ferry:``) is **not** written into the + receiving agent's human-platform ``approved_users`` table — the + two identity primitives stay disjoint at the broker boundary. + + Concretely: we call ``broker.dispatch_pre_authorized``, which + routes the message straight to the agent's streaming session, + bypassing ``handle_inbound``'s human-onboarding flow + (``get_user_status`` → ``add_pending_user`` → ``/approve_…`` + Telegram prompt). That bypass is what makes the dedicated-path + claim load-bearing-true at the broker surface. + """ + from pinky_daemon.broker import BrokerMessage # local import to avoid cycles + + text = str(envelope.body.get("text") or envelope.body.get("content") or "").strip() + if not text: + return self._reject("empty_message_body", "body.text was empty") + + peer_address = envelope.from_ + broker_msg = BrokerMessage( + platform="ferry", + chat_id=peer_address, + sender_name=peer_address, + sender_id=f"ferry:{peer_address}", + content=text, + agent_name=agent_name, + timestamp=envelope.ts / 1000.0, + message_id=envelope.id, + chat_title=envelope.subject or "", + is_group=False, + metadata={ + "ferry": { + "envelope_id": envelope.id, + "from": envelope.from_, + "to": envelope.to, + "ts": envelope.ts, + "wake": envelope.wake, + "ack": envelope.ack, + "correlation_id": envelope.correlation_id, + "reply_to": envelope.reply_to, + "headers": dict(envelope.headers), + "traversal": [asdict(t) for t in envelope.traversal], + } + }, + ) + + try: + await self._broker.dispatch_pre_authorized(agent_name, broker_msg) + except Exception as e: + _log( + f"broker.dispatch_pre_authorized failed for ferry envelope " + f"{envelope.id}: {e}" + ) + return DeliveryResult(status="rejected", reason="broker_error", detail={"error": str(e)}) + + self._stats["delivered"] += 1 + self._stats["messages_routed"] += 1 + return DeliveryResult(status="delivered") + + # -- Dispatch: substrate.entry / substrate.batch -------------------------- + + async def _deliver_substrate( + self, + agent_name: str, + envelope: FerryEnvelope, + ) -> DeliveryResult: + """Import substrate entries into the receiving agent's stores.""" + entries = self._extract_substrate_entries(envelope) + if not entries: + return self._reject("empty_substrate_payload", "no entries in body") + + receiving_address = f"ferry://pinkybot/{agent_name}" + results: list[IngestResult] = [] + for entry in entries: + populated = populate_port_history(entry, envelope, receiving_address) + try: + result = self._ingest_one(agent_name, populated) + except Exception as e: + _log(f"ingest failed for substrate {entry.id}: {e}") + results.append( + IngestResult( + substrate_id=entry.id, + target_store="skipped", + note=f"error: {e}", + ) + ) + continue + results.append(result) + + self._stats["delivered"] += 1 + self._stats["substrate_imports"] += sum(1 for r in results if r.target_store != "skipped") + return DeliveryResult( + status="delivered", + detail={"ingested": [asdict(r) for r in results]}, + ) + + def _ingest_one(self, agent_name: str, entry: SubstrateEntry) -> IngestResult: + """Route a single entry to its destination store.""" + destination = classify_entry_destination(entry) + + if destination == "pinky-memory": + if self._memory_store is None: + return IngestResult( + substrate_id=entry.id, + target_store="skipped", + note="memory_store not configured", + ) + ref_kwargs = to_reflection_input(entry) + ref_kwargs["project"] = agent_name + inserted = self._insert_reflection(ref_kwargs) + return IngestResult( + substrate_id=entry.id, + target_store="pinky-memory", + target_id=str(inserted), + ) + + if destination == "pinky-self": + if self._task_store is None: + return IngestResult( + substrate_id=entry.id, + target_store="skipped", + note="task_store not configured", + ) + task_kwargs = to_task_input(entry) + task_kwargs["assigned_agent"] = agent_name + task_kwargs["created_by"] = entry.source.by + inserted = self._insert_task(task_kwargs) + return IngestResult( + substrate_id=entry.id, + target_store="pinky-self", + target_id=str(inserted), + ) + + return IngestResult( + substrate_id=entry.id, + target_store="skipped", + note=f"destination={destination}", + ) + + def _insert_reflection(self, ref_kwargs: dict[str, Any]) -> str: + """Insert into pinky-memory. Returns the inserted reflection id. + + Uses the configured ``reflection_factory`` if provided (test-only + injection point), otherwise falls back to ``pinky_memory.types.Reflection``. + """ + if self._reflection_factory is not None: + reflection = self._reflection_factory(ref_kwargs) + else: + # Local import: pinky_memory is an optional dep at call time so + # ferry tests don't have to install it. + from pinky_memory.types import Reflection + reflection = Reflection(**ref_kwargs) + result = self._memory_store.insert(reflection) + return getattr(result, "id", "") or getattr(reflection, "id", "") + + def _insert_task(self, task_kwargs: dict[str, Any]) -> str: + """Insert into pinky-self task store. Returns task id (as str). + + ``task_store.create_task`` may return either a dict (older signature) + or an object with ``.id`` (newer signature). Handle both explicitly + rather than via a ternary that becomes ambiguous when ``.id`` is + empty (the prior implementation would stringify the entire object + as a "task id" in that case — see PR #414 round 2 finding #2). + """ + result = self._task_store.create_task(**task_kwargs) + if isinstance(result, dict): + return str(result.get("id", "")) + return str(getattr(result, "id", "") or "") + + # -- substrate envelope unwrapping ---------------------------------------- + + def _extract_substrate_entries(self, envelope: FerryEnvelope) -> list[SubstrateEntry]: + """Pull SubstrateEntry objects out of envelope.body. + + Supports both kinds: + body.kind == "substrate.entry" → body.entry is one entry-shaped dict + body.kind == "substrate.batch" → body.entries is a list of entry-shaped dicts + """ + from pinky_daemon.ferry.types import ( + PortHistoryEntry, + SubstrateLifecycle, + SubstrateScope, + SubstrateSource, + ) + + kind = envelope.kind + raw_entries: list[dict[str, Any]] = [] + if kind == "substrate.entry": + entry = envelope.body.get("entry") + if isinstance(entry, dict): + raw_entries.append(entry) + elif kind == "substrate.batch": + entries = envelope.body.get("entries") + if isinstance(entries, list): + raw_entries = [e for e in entries if isinstance(e, dict)] + + out: list[SubstrateEntry] = [] + for raw in raw_entries: + try: + out.append( + SubstrateEntry( + id=str(raw.get("id", "")), + type=raw.get("type", "fact"), + scope=SubstrateScope(**raw["scope"]), + source=SubstrateSource(**raw["source"]), + created_at=str(raw.get("created_at", "")), + updated_at=str(raw.get("updated_at", "")), + content=str(raw.get("content", "")), + links=list(raw.get("links") or []), + lifecycle=SubstrateLifecycle(**(raw.get("lifecycle") or {})), + port_history=[ + PortHistoryEntry(**ph) if isinstance(ph, dict) else ph + for ph in (raw.get("port_history") or []) + ], + ) + ) + except Exception as e: + _log(f"skipping malformed substrate entry: {e}") + return out + + # -- Helpers -------------------------------------------------------------- + + def _reject(self, reason: str, detail_text: str) -> DeliveryResult: + self._stats["rejected"] += 1 + _log(f"reject reason={reason}: {detail_text}") + return DeliveryResult( + status="rejected", + reason=reason, + detail={"detail": detail_text}, + ) + + def _transient(self, reason: str, detail_text: str) -> DeliveryResult: + """Return a ``transient_failure`` result. Broker should retry.""" + self._stats["transient_failures"] += 1 + _log(f"transient_failure reason={reason}: {detail_text}") + return DeliveryResult( + status="transient_failure", + reason=reason, + detail={"detail": detail_text}, + ) diff --git a/src/pinky_daemon/ferry/substrate.py b/src/pinky_daemon/ferry/substrate.py new file mode 100644 index 00000000..323ca7ce --- /dev/null +++ b/src/pinky_daemon/ferry/substrate.py @@ -0,0 +1,270 @@ +"""substrate v0.1 → PinkyBot stores translation. + +Implements the five host-side unwrap steps from substrate v0.1 §12.5: + 1. Verify ferry envelope (deferred — see host_pinky.deliver()) + 2. Populate port_history from envelope.traversal (§6.4 canonicality) + 3. Preserve source.by verbatim (§6.1 — never overwrite with immediate sender) + 4. Type-map per §11 reference impl notes (host-implementation-defined) + 5. Index rebuild (handled by pinky-memory's normal insert path) + +Type mapping (host-pinky's choice, not the spec's): + fact / event / reference → pinky-memory `fact` + feedback / decision → pinky-memory `insight` + pattern → pinky-memory `interaction_pattern` + pending → pinky-self `create_task` + +Trust → salience: salience = 1 + round(trust * 4) clamped [1, 5] + (one valid mapping; not the mapping. Documented in host-pinky README.) + +Links: pinky-memory has no native cross-entry edge surface, so substrate +`links` are preserved in `context` JSON sidecar. v0.2 §3.2.1 "no-link-surface +pattern" reference recipe will formalize the recall-time fan-out helper. +""" + +from __future__ import annotations + +import json +import sys +from dataclasses import asdict +from datetime import datetime, timezone +from typing import Any + +from pinky_daemon.ferry.types import ( + FerryEnvelope, + PortHistoryEntry, + SubstrateEntry, +) + + +def _log(msg: str) -> None: + print(f"host-pinky/substrate: {msg}", file=sys.stderr, flush=True) + + +# -- §11 type mapping ---------------------------------------------------------- + +# substrate.type → pinky-memory ReflectionType.value +_PINKY_MEMORY_TYPE_MAP: dict[str, str] = { + "fact": "fact", + "event": "fact", + "reference": "fact", + "feedback": "insight", + "decision": "insight", + "pattern": "interaction_pattern", + # "pending" is special-cased — routes to pinky-self, not pinky-memory +} + + +def map_substrate_type_to_reflection(substrate_type: str) -> str | None: + """Return the pinky-memory ReflectionType.value for a substrate type, or None.""" + return _PINKY_MEMORY_TYPE_MAP.get(substrate_type) + + +def trust_to_salience(trust: float) -> int: + """Trust [0.0, 1.0] → pinky-memory salience [1, 5]. + + Formula: 1 + round(trust * 4), clamped [1, 5]. + + This is host-pinky's mapping, not substrate's. Substrate stays + trust-opaque per §6 — hosts decide how to discretize. + """ + if trust <= 0: + return 1 + if trust >= 1: + return 5 + return max(1, min(5, 1 + round(trust * 4))) + + +# -- port_history population (§6.4) ------------------------------------------- + + +def populate_port_history( + entry: SubstrateEntry, + envelope: FerryEnvelope, + receiving_agent_address: str, +) -> SubstrateEntry: + """Append one port_history entry per ferry traversal hop. + + Per substrate v0.1 §6.4: substrate's port_history is canonical; + ferry's traversal is transport metadata. The receiving host walks + the traversal array on inbound and writes one port_history record + per hop, preserving from/to/at/via. + + Mutates and returns the entry. + """ + if not envelope.traversal: + # First port — no traversal recorded. Emit a single hop from + # envelope.from_ → receiving agent so port_history reflects this + # crossing even when traversal was empty (single-broker delivery). + # `attested_by="receiver"` because no broker stamped this hop — + # the receiver's clock is the only witness on `at`. + entry.port_history.append( + PortHistoryEntry( + from_=envelope.from_, + to=receiving_agent_address, + at=_iso_now(), + via=envelope.id, + re_grounded=False, + attested_by="receiver", + ) + ) + return entry + + prior_address = envelope.from_ + for hop in envelope.traversal: + # Broker-attested: the broker stamped `at` when receiving the hop. + entry.port_history.append( + PortHistoryEntry( + from_=prior_address, + to=hop.broker, + at=_ms_to_iso(hop.at), + via=hop.via or envelope.id, + re_grounded=False, + attested_by="broker", + ) + ) + prior_address = hop.broker + # Final hop: last broker → receiving agent. Receiver-attested because + # no broker stamped this hop (last broker stamped its own arrival, + # not its handoff to this receiver). + entry.port_history.append( + PortHistoryEntry( + from_=prior_address, + to=receiving_agent_address, + at=_iso_now(), + via=envelope.id, + re_grounded=False, + attested_by="receiver", + ) + ) + return entry + + +# -- pinky-memory storage shape ----------------------------------------------- + + +def to_reflection_input(entry: SubstrateEntry) -> dict[str, Any]: + """Build a pinky-memory Reflection-shaped dict for a substrate entry. + + Returned dict is suitable for `MemoryStore.insert(Reflection(**d))`. + Caller is responsible for resolving the project (typically the agent name). + + Keys produced: + - type: pinky-memory ReflectionType.value + - content: substrate entry content (markdown allowed) + - context: JSON-encoded substrate sidecar (provenance, links, port_history) + - salience: derived from trust + - entities: [scope.ref] when scope.kind in {user, agent} + - source_session_id: derived from source.by (for traceability) + """ + reflection_type = map_substrate_type_to_reflection(entry.type) + if reflection_type is None: + raise ValueError( + f"Substrate type {entry.type!r} does not map to a pinky-memory " + "ReflectionType. (Did you mean to route 'pending' to pinky-self?)" + ) + + sidecar = { + "substrate_id": entry.id, + "substrate_type": entry.type, + "scope": asdict(entry.scope), + "source": asdict(entry.source), + "lifecycle": asdict(entry.lifecycle), + "links": list(entry.links), + "port_history": [asdict(p) for p in entry.port_history], + "created_at": entry.created_at, + "updated_at": entry.updated_at, + } + + entities: list[str] = [] + if entry.scope.kind in ("user", "agent") and entry.scope.ref: + entities.append(entry.scope.ref) + + return { + "type": reflection_type, + "content": entry.content, + "context": json.dumps(sidecar, ensure_ascii=False, sort_keys=True), + "salience": trust_to_salience(entry.source.trust), + "entities": entities, + "source_channel": "ferry", + "source_session_id": f"ferry:{entry.source.by}", + } + + +# -- pinky-self task shape (for `pending` entries) ---------------------------- + + +def to_task_input(entry: SubstrateEntry) -> dict[str, Any]: + """Build a pinky-self task-shaped dict for a substrate `pending` entry. + + Lifecycle mapping (substrate → pinky-self task): + pending + expected_resolution_by → pending (with deps if known) + pending + no resolution date → pending + pending + state=active(?) → in_progress (rare on import) + """ + if entry.type != "pending": + raise ValueError( + f"to_task_input called on non-pending entry (type={entry.type!r})" + ) + + title = entry.content.split("\n", 1)[0].strip()[:120] or "(unnamed substrate pending)" + description_parts = [ + entry.content, + "", + "---", + f"Imported from substrate (id: {entry.id})", + f"Author: {entry.source.by} (origin: {entry.source.origin}, trust: {entry.source.trust})", + ] + if entry.lifecycle.expected_resolution_by: + description_parts.append( + f"Expected resolution: {entry.lifecycle.expected_resolution_by}" + ) + if entry.source.evidence: + description_parts.append(f"Evidence: {entry.source.evidence}") + + return { + "title": title, + "description": "\n".join(description_parts), + "priority": _priority_from_trust(entry.source.trust), + "tags": ["substrate", "substrate-pending", f"trust:{entry.source.trust:.2f}"], + # Caller adds project / assigned_agent based on receiving agent. + } + + +def _priority_from_trust(trust: float) -> str: + """Map trust → task priority. Higher trust = higher priority.""" + if trust >= 0.85: + return "high" + if trust >= 0.5: + return "normal" + return "low" + + +# -- Top-level ingest dispatcher ---------------------------------------------- + + +def classify_entry_destination(entry: SubstrateEntry) -> str: + """Return one of: 'pinky-memory', 'pinky-self', 'skipped'. + + `pending` → pinky-self (cross-MCP integration, lifecycle-shaped). + `decayed` lifecycle → skipped (per §5.1, the decay event imports + as its own `event`-type entry, not via the decayed entry itself). + Everything else → pinky-memory. + """ + if entry.lifecycle.state == "decayed": + return "skipped" + if entry.type == "pending": + return "pinky-self" + if map_substrate_type_to_reflection(entry.type) is not None: + return "pinky-memory" + return "skipped" + + +# -- Time helpers -------------------------------------------------------------- + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _ms_to_iso(ms: int) -> str: + return datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc).isoformat() diff --git a/src/pinky_daemon/ferry/types.py b/src/pinky_daemon/ferry/types.py new file mode 100644 index 00000000..015e4597 --- /dev/null +++ b/src/pinky_daemon/ferry/types.py @@ -0,0 +1,214 @@ +"""Ferry envelope + substrate entry dataclasses. + +Mirrors: +- ferry PROTOCOL.md v0.1 §1 envelope schema +- substrate v0.1 §3 entry schema + +These are wire-shapes — they describe what crosses the boundary, not +how PinkyBot stores things internally. Storage shapes (Reflection in +pinky-memory, Task in pinky-self) are separate; the substrate.py module +translates between the two. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal + +# -- Ferry PROTOCOL.md v0.1 §1 ------------------------------------------------- + + +@dataclass +class TraversalRecord: + """One broker hop on a ferry envelope's traversal path. + + Per PROTOCOL.md §12.3, brokers append (never modify) traversal records + as the envelope crosses each broker. The receiving host-callback uses + the array to populate substrate's port_history on inbound (§6.4). + """ + + broker: str # broker identity (e.g. "ferry-broker:nova") + at: int # broker wall-clock millis when received + via: str = "" # link/transport identifier (optional) + signature: str = "" # broker signature over the prior envelope state + + +@dataclass +class FerryEnvelope: + """A ferry envelope, per PROTOCOL.md v0.1 §1. + + Top-level fields are spec-defined; `headers` is the only place + extension metadata may live. + """ + + v: str # protocol version, e.g. "0.1" + id: str # uuid-v7, transport-stable, broker-deduped within 24h + from_: str # immediate sender address + to: str # recipient address + ts: int # sender wall-clock millis + body: dict[str, Any] # application payload — must contain "kind" + + priority: Literal["low", "normal", "high", "urgent"] = "normal" + wake: Literal["none", "if-idle", "when-free", "interrupt"] = "if-idle" + ack: Literal["none", "delivery", "processed"] = "delivery" + + ttl_ms: int | None = None + correlation_id: str | None = None + reply_to: str | None = None + subject: str | None = None + traversal: list[TraversalRecord] = field(default_factory=list) + headers: dict[str, str] = field(default_factory=dict) + + @property + def kind(self) -> str: + """Payload kind — `body.kind` per PROTOCOL.md §1 + ferry overlay docs.""" + return str(self.body.get("kind", "")).strip() + + +# -- DeliveryResult — host-pinky's contract with the ferry broker -------------- + + +@dataclass +class DeliveryResult: + """Result of host-pinky's `deliver(envelope)` call. + + The ferry broker uses the status to decide ack semantics (PROTOCOL.md §6): + - `delivered`: at-least-once delivery satisfied, processing started + - `queued`: agent offline / asleep; envelope will be replayed on wake + - `rejected`: do not retry — auth, ACL, or unsupported payload (terminal) + - `transient_failure`: retry with backoff — operational failure (DB lock, + schema mismatch during rolling deploy, etc.) on the host side. Distinct + from `rejected` so the broker doesn't swallow a real ACL-allowed message + because of an operator-side hiccup. + """ + + status: Literal["delivered", "queued", "rejected", "transient_failure"] + reason: str | None = None + detail: dict[str, Any] = field(default_factory=dict) + + +# -- AgentCardSelector — peer-fleet ACL primitive ------------------------------ + + +@dataclass(frozen=True) +class AgentCardSelector: + """Selector for a peer-fleet agent card. + + At-least-one field must be set. Wildcards via `agent_id="*"` (matches + any agent on the fleet) and `fleet="*"` (matches any fleet — use sparingly). + + Examples: + AgentCardSelector(fleet="sigil", agent_id="pulse@studio@sigil") + AgentCardSelector(fleet="sigil", agent_id="*") # fleet-wide allow + AgentCardSelector(pinky_type="executor") # capability-based + """ + + fleet: str | None = None + agent_id: str | None = None + pinky_type: str | None = None + + def __post_init__(self) -> None: + if not (self.fleet or self.agent_id or self.pinky_type): + raise ValueError( + "AgentCardSelector requires at least one of fleet, agent_id, " + "or pinky_type — empty selectors are not permitted." + ) + + def matches(self, card_fleet: str, card_agent_id: str, card_pinky_type: str = "") -> bool: + """Test whether a peer's signed card matches this selector.""" + if self.fleet is not None and self.fleet != "*" and self.fleet != card_fleet: + return False + if self.agent_id is not None and self.agent_id != "*" and self.agent_id != card_agent_id: + return False + if self.pinky_type is not None and self.pinky_type != card_pinky_type: + return False + return True + + +# -- substrate v0.1 §3 entry schema -------------------------------------------- + + +@dataclass +class SubstrateScope: + """substrate v0.1 §3.3 scope — what the entry is *about*.""" + + kind: Literal["user", "agent", "project", "global"] + ref: str # e.g. "brad", "misha@pinky.local", "ferry-v0.1" + + +@dataclass +class SubstrateSource: + """substrate v0.1 §6 provenance — where the claim came from.""" + + origin: Literal["user-stated", "agent-inferred", "agent-observed", "external"] + by: str # original author identity (e.g. "misha@pinky.local") — §6.1 immutable on port + evidence: str = "" # human-readable trace; quotes, refs, observations + trust: float = 0.5 # [0.0, 1.0]; opaque to spec, host decides what it means + + +@dataclass +class SubstrateLifecycle: + """substrate v0.1 §5 lifecycle state.""" + + state: Literal["active", "pending", "decayed", "superseded"] = "active" + expected_resolution_by: str | None = None # ISO datetime + resolved_by: str | None = None # substrate id of resolver entry + + +@dataclass +class PortHistoryEntry: + """substrate v0.1 §6.4 — one hop in the entry's cross-fleet history. + + Populated on inbound from ferry's traversal array. Substrate's + port_history is canonical (§6.4); ferry's traversal is transport-only. + + Trust boundary on `at`: + - `attested_by="broker"` — `at` mirrors a broker-stamped traversal + record's wall-clock time. Witnessed by a transport intermediary. + - `attested_by="receiver"` — `at` was fabricated by the receiver + (synthetic hop, no broker traversal record). The receiver's clock + is the only witness. Don't conflate with broker-attested `at` + when reasoning about cross-fleet timing. + """ + + from_: str + to: str + at: str # ISO datetime + via: str = "" # ferry msg_id (transport-stable id) + re_grounded: bool = False # whether receiver re-verified the claim + attested_by: Literal["broker", "receiver"] = "broker" + + +@dataclass +class SubstrateEntry: + """A substrate v0.1 §3 entry — wire shape, pre-storage. + + On inbound to host-pinky, this is what we get out of `envelope.body` + (for `kind=substrate.entry`) or each item of `body.entries` (for + `kind=substrate.batch`). The substrate.py module translates this + into pinky-memory Reflection / pinky-self Task primitives. + """ + + id: str # substrate-stable id (uuid) — distinct from ferry transport id + type: Literal["fact", "feedback", "decision", "pattern", "event", "reference", "pending"] + scope: SubstrateScope + source: SubstrateSource + created_at: str # ISO datetime + updated_at: str # ISO datetime + content: str # free-text body (markdown allowed) + links: list[str] = field(default_factory=list) # substrate ids of linked entries + lifecycle: SubstrateLifecycle = field(default_factory=SubstrateLifecycle) + port_history: list[PortHistoryEntry] = field(default_factory=list) + + +# -- Ingest result ------------------------------------------------------------- + + +@dataclass +class IngestResult: + """Outcome of a single substrate-entry import into PinkyBot stores.""" + + substrate_id: str + target_store: Literal["pinky-memory", "pinky-self", "skipped"] + target_id: str = "" # reflection id / task id + note: str = "" diff --git a/tests/test_ferry_host_pinky.py b/tests/test_ferry_host_pinky.py new file mode 100644 index 00000000..2e5adb55 --- /dev/null +++ b/tests/test_ferry_host_pinky.py @@ -0,0 +1,1063 @@ +"""Tests for @ferry/host-pinky — substrate ↔ pinky-memory bridge. + +Acceptance test uses substrate v0.1 §12 worked example as fixtures: + Entry 1: feedback, trust 0.95, scope user/brad ("Brad wants terse responses…") + Entry 2: pattern, trust 0.7, scope user/brad ("Brad's tolerance for response length…") + links → Entry 1 + +See PinkyBot issue #413 and ferry packages/host-pinky/README.md. +""" + +from __future__ import annotations + +import json +import os +import tempfile +from dataclasses import asdict + +import pytest + +from pinky_daemon.agent_registry import AgentRegistry +from pinky_daemon.ferry.host_pinky import ( + HostPinky, + parse_peer_card, + parse_pinkybot_address, +) +from pinky_daemon.ferry.substrate import ( + classify_entry_destination, + map_substrate_type_to_reflection, + populate_port_history, + to_reflection_input, + trust_to_salience, +) +from pinky_daemon.ferry.types import ( + AgentCardSelector, + FerryEnvelope, + SubstrateEntry, + SubstrateLifecycle, + SubstrateScope, + SubstrateSource, + TraversalRecord, +) + +# -- §12 worked example fixtures (substrate v0.1) ------------------------------ + + +def _entry_1_feedback() -> SubstrateEntry: + """Entry 1 from substrate v0.1 §12.2 — user-stated feedback.""" + return SubstrateEntry( + id="0c7e3d12-9f8a-4f1e-b6e2-2d8a8c1f4f9a", + type="feedback", + scope=SubstrateScope(kind="user", ref="brad"), + source=SubstrateSource( + origin="user-stated", + by="misha@pinky.local", + evidence=( + "Brad in DM, 2026-04-12 16:23 PT: 'stop summarizing what you " + "just did at the end of every response, I can read the diff'" + ), + trust=0.95, + ), + created_at="2026-04-12T16:24:11-07:00", + updated_at="2026-04-12T16:24:11-07:00", + content=( + "Brad wants terse responses with no trailing summaries.\n" + "Why: he reads the diff himself; trailing summaries are noise.\n" + "How to apply: end responses at the last substantive sentence; " + "do not append a recap of what was changed." + ), + links=[], + lifecycle=SubstrateLifecycle(state="active"), + port_history=[], + ) + + +def _entry_2_pattern() -> SubstrateEntry: + """Entry 2 from substrate v0.1 §12.3 — agent-inferred pattern.""" + return SubstrateEntry( + id="4a9b2e57-3c1d-4e8f-9a0b-7e6c5d4f3e2a", + type="pattern", + scope=SubstrateScope(kind="user", ref="brad"), + source=SubstrateSource( + origin="agent-inferred", + by="misha@pinky.local", + evidence=( + "Refs to 12 conversation entries: [list of 12 ids elided]; " + "pattern: Brad responds with curtness or 'too long' when " + "responses exceed ~3 sentences without functional content." + ), + trust=0.7, + ), + created_at="2026-05-02T09:11:32-07:00", + updated_at="2026-05-02T09:11:32-07:00", + content=( + "Brad's tolerance for response length appears bounded around 3 " + "sentences of non-functional prose. Beyond that, he often " + "pushes back ('too long', curtness, or no reply).\n" + "This pattern holds even when he hasn't explicitly asked for brevity.\n" + "Implication for response calibration: default to terse; verbose " + "only when Brad explicitly asks for depth or the content is " + "irreducibly long." + ), + links=["0c7e3d12-9f8a-4f1e-b6e2-2d8a8c1f4f9a"], + lifecycle=SubstrateLifecycle(state="active"), + port_history=[], + ) + + +def _build_envelope_substrate_batch(entries: list[SubstrateEntry]) -> FerryEnvelope: + """Wrap §12 entries in a ferry envelope addressed to barsik.""" + return FerryEnvelope( + v="0.1", + id="01891e0e-7a00-7000-9000-000000000042", # uuid-v7 placeholder + from_="misha@pinky.local", + to="ferry://pinkybot/barsik", + ts=1746846000000, + body={ + "kind": "substrate.batch", + "entries": [_entry_to_dict(e) for e in entries], + }, + traversal=[ + TraversalRecord( + broker="ferry-broker:misha-local", + at=1746846000050, + via="leaf-link", + signature="", + ), + ], + ) + + +def _entry_to_dict(e: SubstrateEntry) -> dict: + """Serialize a SubstrateEntry to the wire-shape dict (handles from_ → from).""" + raw = { + "id": e.id, + "type": e.type, + "scope": asdict(e.scope), + "source": asdict(e.source), + "created_at": e.created_at, + "updated_at": e.updated_at, + "content": e.content, + "links": list(e.links), + "lifecycle": asdict(e.lifecycle), + "port_history": [asdict(p) for p in e.port_history], + } + return raw + + +# -- Fakes --------------------------------------------------------------------- + + +class FakeRegistry: + """Minimal AgentRegistry stand-in for host-pinky tests.""" + + def __init__(self, agents: dict[str, list[AgentCardSelector]]) -> None: + self._agents = agents + + def has_agent(self, name: str) -> bool: + return name in self._agents + + def list_agents(self) -> list[dict]: + return [{"name": n} for n in self._agents] + + def get_peer_fleet_acl(self, name: str) -> list[AgentCardSelector]: + return list(self._agents.get(name, [])) + + +class FakeBroker: + """Records every dispatch_pre_authorized call. + + Note: host-pinky calls ``broker.dispatch_pre_authorized(agent_name, + broker_msg)`` — NOT ``broker.handle_inbound``. The latter is the + human-platform onboarding path that ferry inbound MUST bypass to + keep peer-fleet identities out of the ``approved_users`` table. + See PR #414 round-2 — round-1 had this wired to ``handle_inbound``, + which leaked the peer-fleet identity into the human-approval flow. + """ + + def __init__(self) -> None: + self.received: list = [] + self.dispatched_for: list[str] = [] + + async def dispatch_pre_authorized(self, agent_name, broker_msg) -> None: # noqa: ANN001 + self.dispatched_for.append(agent_name) + self.received.append(broker_msg) + + +class FakeReflection: + """Reflection stand-in for FakeMemoryStore.insert.""" + + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + if not getattr(self, "id", ""): + self.id = f"refl-{abs(hash(self.content)) % 10**8}" + + +class FakeMemoryStore: + """Records every insert call.""" + + def __init__(self) -> None: + self.inserted: list = [] + + def insert(self, reflection): # noqa: ANN001 + # Accept both the real Reflection and our FakeReflection stand-in. + # Add an id if not present. + if not getattr(reflection, "id", ""): + reflection.id = f"refl-{len(self.inserted) + 1}" + self.inserted.append(reflection) + return reflection + + +class FakeTaskStore: + """Records every create_task call.""" + + def __init__(self) -> None: + self.created: list = [] + + def create_task(self, **kwargs): + kwargs["id"] = f"task-{len(self.created) + 1}" + self.created.append(kwargs) + return kwargs + + +# -- substrate.py unit tests --------------------------------------------------- + + +class TestTypeMapping: + def test_fact_event_reference_map_to_pinky_memory_fact(self): + for sub in ("fact", "event", "reference"): + assert map_substrate_type_to_reflection(sub) == "fact" + + def test_feedback_decision_map_to_insight(self): + for sub in ("feedback", "decision"): + assert map_substrate_type_to_reflection(sub) == "insight" + + def test_pattern_maps_to_interaction_pattern(self): + assert map_substrate_type_to_reflection("pattern") == "interaction_pattern" + + def test_pending_does_not_map_to_pinky_memory(self): + assert map_substrate_type_to_reflection("pending") is None + + def test_unknown_does_not_map(self): + assert map_substrate_type_to_reflection("nonsense") is None + + +class TestTrustToSalience: + @pytest.mark.parametrize( + "trust,expected", + [ + (0.0, 1), + (0.1, 1), # 1 + round(0.4) = 1 + 0 = 1 + (0.25, 2), # 1 + round(1.0) = 2 + (0.5, 3), + (0.7, 4), # Entry 2 from §12 — trust 0.7 → salience 4 + (0.95, 5), # Entry 1 from §12 — trust 0.95 → salience 5 + (1.0, 5), + (-0.1, 1), # clamped + (1.5, 5), # clamped + ], + ) + def test_formula(self, trust, expected): + assert trust_to_salience(trust) == expected + + +class TestPortHistory: + def test_single_hop_with_traversal_record(self): + entry = _entry_1_feedback() + envelope = _build_envelope_substrate_batch([entry]) + populate_port_history(entry, envelope, "ferry://pinkybot/barsik") + + # One traversal hop in fixture → broker hop + final delivery hop = 2 entries + assert len(entry.port_history) == 2 + assert entry.port_history[0].from_ == "misha@pinky.local" + assert entry.port_history[0].to == "ferry-broker:misha-local" + assert entry.port_history[-1].to == "ferry://pinkybot/barsik" + # All hops re_grounded=False on inbound (§6.4) + assert all(p.re_grounded is False for p in entry.port_history) + # attested_by distinction (round-2 finding #5): + # broker-stamped hop gets "broker", receiver-fabricated final hop "receiver" + assert entry.port_history[0].attested_by == "broker" + assert entry.port_history[-1].attested_by == "receiver" + + def test_no_traversal_writes_single_synthetic_hop(self): + entry = _entry_1_feedback() + envelope = FerryEnvelope( + v="0.1", + id="abc", + from_="misha@pinky.local", + to="ferry://pinkybot/barsik", + ts=1, + body={"kind": "substrate.entry", "entry": _entry_to_dict(entry)}, + ) + populate_port_history(entry, envelope, "ferry://pinkybot/barsik") + assert len(entry.port_history) == 1 + assert entry.port_history[0].from_ == "misha@pinky.local" + assert entry.port_history[0].to == "ferry://pinkybot/barsik" + # No traversal records → entire hop is receiver-attested + assert entry.port_history[0].attested_by == "receiver" + + +class TestToReflectionInput: + def test_entry_1_lands_as_insight_salience_5(self): + e = _entry_1_feedback() + ref = to_reflection_input(e) + + assert ref["type"] == "insight" + assert ref["salience"] == 5 + assert ref["entities"] == ["brad"] + assert ref["source_channel"] == "ferry" + assert "Brad wants terse responses" in ref["content"] + + # Sidecar preserves source.by per §6.1 + sidecar = json.loads(ref["context"]) + assert sidecar["substrate_id"] == e.id + assert sidecar["substrate_type"] == "feedback" + assert sidecar["source"]["by"] == "misha@pinky.local" + assert sidecar["source"]["origin"] == "user-stated" + assert sidecar["scope"] == {"kind": "user", "ref": "brad"} + + def test_entry_2_lands_as_interaction_pattern_salience_4_with_links(self): + e = _entry_2_pattern() + ref = to_reflection_input(e) + + assert ref["type"] == "interaction_pattern" + assert ref["salience"] == 4 + assert ref["entities"] == ["brad"] + + sidecar = json.loads(ref["context"]) + # links surface preserved (no native pinky-memory edge surface; v0.2 §3.2.1 recipe) + assert sidecar["links"] == ["0c7e3d12-9f8a-4f1e-b6e2-2d8a8c1f4f9a"] + + def test_pending_entry_raises(self): + e = _entry_1_feedback() + e.type = "pending" # type: ignore[assignment] + with pytest.raises(ValueError, match="pinky-self"): + to_reflection_input(e) + + +class TestClassifyEntryDestination: + def test_pending_routes_to_pinky_self(self): + e = _entry_1_feedback() + e.type = "pending" # type: ignore[assignment] + assert classify_entry_destination(e) == "pinky-self" + + def test_decayed_lifecycle_skips(self): + e = _entry_1_feedback() + e.lifecycle = SubstrateLifecycle(state="decayed") + assert classify_entry_destination(e) == "skipped" + + def test_active_feedback_routes_to_pinky_memory(self): + assert classify_entry_destination(_entry_1_feedback()) == "pinky-memory" + + +# -- HostPinky.deliver() integration tests ------------------------------------- + + +@pytest.fixture +def allow_misha_acl() -> dict[str, list[AgentCardSelector]]: + """Barsik has an ACL allowing the entire pinky.local fleet.""" + return { + "barsik": [AgentCardSelector(fleet="pinky.local", agent_id="*")], + } + + +@pytest.mark.asyncio +class TestDeliverMessage: + async def test_message_routes_to_broker_with_ferry_metadata(self, allow_misha_acl): + registry = FakeRegistry(allow_misha_acl) + broker = FakeBroker() + host = HostPinky(registry=registry, broker=broker) + + envelope = FerryEnvelope( + v="0.1", + id="msg-001", + from_="misha@pinky.local", + to="ferry://pinkybot/barsik", + ts=1746846000000, + body={"kind": "message", "text": "ping from misha"}, + ) + + result = await host.deliver(envelope) + + assert result.status == "delivered" + assert len(broker.received) == 1 + bm = broker.received[0] + assert bm.platform == "ferry" + assert bm.agent_name == "barsik" + assert bm.content == "ping from misha" + assert bm.message_id == "msg-001" + assert bm.metadata["ferry"]["from"] == "misha@pinky.local" + + async def test_unknown_agent_rejected(self, allow_misha_acl): + registry = FakeRegistry(allow_misha_acl) + broker = FakeBroker() + host = HostPinky(registry=registry, broker=broker) + + envelope = FerryEnvelope( + v="0.1", + id="msg-002", + from_="misha@pinky.local", + to="ferry://pinkybot/nonexistent", + ts=1, + body={"kind": "message", "text": "x"}, + ) + + result = await host.deliver(envelope) + assert result.status == "rejected" + assert result.reason == "unknown_agent" + assert broker.received == [] + + async def test_acl_denied_rejected(self): + # Empty ACL → deny all + registry = FakeRegistry({"barsik": []}) + broker = FakeBroker() + host = HostPinky(registry=registry, broker=broker) + + envelope = FerryEnvelope( + v="0.1", + id="msg-003", + from_="pulse@studio@sigil", + to="ferry://pinkybot/barsik", + ts=1, + body={"kind": "message", "text": "x"}, + ) + + result = await host.deliver(envelope) + assert result.status == "rejected" + assert result.reason == "acl_denied" + assert broker.received == [] + + +@pytest.mark.asyncio +class TestDeliverSubstrateBatch: + """Acceptance test: substrate v0.1 §12 worked example, ported A → B.""" + + async def test_two_entries_land_in_pinky_memory_with_correct_shape( + self, allow_misha_acl + ): + registry = FakeRegistry(allow_misha_acl) + broker = FakeBroker() + memory = FakeMemoryStore() + tasks = FakeTaskStore() + host = HostPinky( + registry=registry, + broker=broker, + memory_store=memory, + task_store=tasks, + ) + + envelope = _build_envelope_substrate_batch( + [_entry_1_feedback(), _entry_2_pattern()] + ) + + # Patch out the real Reflection import inside the host_pinky module so + # we can run the test without the pinky_memory dependency providing a + # concrete model. The test substitutes our FakeReflection. + import pinky_daemon.ferry.host_pinky as host_pinky_mod + + original_insert = host_pinky_mod.HostPinky._insert_reflection + + def _fake_insert(self, ref_kwargs): # noqa: ANN001 + r = FakeReflection(**ref_kwargs) + self._memory_store.insert(r) + return r.id + + host_pinky_mod.HostPinky._insert_reflection = _fake_insert # type: ignore[method-assign] + try: + result = await host.deliver(envelope) + finally: + host_pinky_mod.HostPinky._insert_reflection = original_insert # type: ignore[method-assign] + + assert result.status == "delivered" + assert len(memory.inserted) == 2 + assert len(tasks.created) == 0 # no `pending` entries in §12 example + + # Entry 1: feedback → insight, salience 5 + e1 = memory.inserted[0] + assert e1.type == "insight" + assert e1.salience == 5 + assert "Brad wants terse responses" in e1.content + sidecar1 = json.loads(e1.context) + assert sidecar1["source"]["by"] == "misha@pinky.local" # §6.1 preserved + assert sidecar1["substrate_type"] == "feedback" + assert len(sidecar1["port_history"]) >= 1 + assert sidecar1["port_history"][-1]["to"] == "ferry://pinkybot/barsik" + + # Entry 2: pattern → interaction_pattern, salience 4, links preserved + e2 = memory.inserted[1] + assert e2.type == "interaction_pattern" + assert e2.salience == 4 + sidecar2 = json.loads(e2.context) + assert sidecar2["links"] == ["0c7e3d12-9f8a-4f1e-b6e2-2d8a8c1f4f9a"] + + +# -- Address parsing ----------------------------------------------------------- + + +class TestAddressParsing: + def test_parse_pinkybot_address_extracts_agent(self): + assert parse_pinkybot_address("ferry://pinkybot/barsik") == "barsik" + + def test_parse_pinkybot_address_rejects_other_fleets(self): + assert parse_pinkybot_address("ferry://sigil/pulse") is None + assert parse_pinkybot_address("misha@pinky.local") is None + + def test_parse_peer_card_at_form(self): + assert parse_peer_card("pulse@studio@sigil") == ("sigil", "pulse@studio@sigil") + assert parse_peer_card("misha@pinky.local") == ("pinky.local", "misha@pinky.local") + + def test_parse_peer_card_ferry_form(self): + assert parse_peer_card("ferry://sigil/pulse") == ("sigil", "ferry://sigil/pulse") + + +class TestAgentCardSelector: + def test_empty_selector_rejected(self): + with pytest.raises(ValueError, match="at least one"): + AgentCardSelector() + + def test_fleet_wildcard_matches_any_agent(self): + sel = AgentCardSelector(fleet="sigil", agent_id="*") + assert sel.matches("sigil", "pulse@studio@sigil") is True + assert sel.matches("sigil", "kain@ces-mini@sigil") is True + assert sel.matches("pinky.local", "misha@pinky.local") is False + + def test_specific_match(self): + sel = AgentCardSelector(fleet="sigil", agent_id="pulse@studio@sigil") + assert sel.matches("sigil", "pulse@studio@sigil") is True + assert sel.matches("sigil", "kain@ces-mini@sigil") is False + + +# -- AgentRegistry peer_fleet_acl persistence --------------------------------- + + +@pytest.fixture +def real_registry(): + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + r = AgentRegistry(db_path=path) + r.register("barsik", model="opus") + yield r + r.close() + os.unlink(path) + + +class TestPeerFleetAclPersistence: + def test_unset_returns_empty_list(self, real_registry): + assert real_registry.get_peer_fleet_acl("barsik") == [] + + def test_unknown_agent_returns_empty_list(self, real_registry): + assert real_registry.get_peer_fleet_acl("nonexistent") == [] + + def test_has_agent(self, real_registry): + assert real_registry.has_agent("barsik") is True + assert real_registry.has_agent("nonexistent") is False + + def test_set_and_get_roundtrip(self, real_registry): + real_registry.set_peer_fleet_acl( + "barsik", + [ + {"fleet": "sigil", "agent_id": "pulse@studio@sigil", "pinky_type": None}, + {"fleet": "sigil", "agent_id": "kain@ces-mini@sigil", "pinky_type": None}, + ], + ) + loaded = real_registry.get_peer_fleet_acl("barsik") + assert len(loaded) == 2 + assert loaded[0]["fleet"] == "sigil" + assert loaded[0]["agent_id"] == "pulse@studio@sigil" + + def test_set_drops_empty_selectors(self, real_registry): + real_registry.set_peer_fleet_acl( + "barsik", + [ + {"fleet": "sigil", "agent_id": "pulse@studio@sigil"}, + {"fleet": "", "agent_id": "", "pinky_type": ""}, # empty — dropped + {}, # empty — dropped + "not-a-dict", # type-invalid — dropped + ], + ) + loaded = real_registry.get_peer_fleet_acl("barsik") + assert len(loaded) == 1 + assert loaded[0]["agent_id"] == "pulse@studio@sigil" + + def test_set_replaces_not_merges(self, real_registry): + real_registry.set_peer_fleet_acl( + "barsik", [{"fleet": "sigil", "agent_id": "*"}] + ) + real_registry.set_peer_fleet_acl( + "barsik", [{"fleet": "pinky.local", "agent_id": "*"}] + ) + loaded = real_registry.get_peer_fleet_acl("barsik") + assert len(loaded) == 1 + assert loaded[0]["fleet"] == "pinky.local" + + def test_add_idempotent(self, real_registry): + added1 = real_registry.add_peer_fleet_acl( + "barsik", fleet="sigil", agent_id="pulse@studio@sigil" + ) + added2 = real_registry.add_peer_fleet_acl( + "barsik", fleet="sigil", agent_id="pulse@studio@sigil" + ) + assert added1 is True + assert added2 is True # idempotent — already-present is success + assert len(real_registry.get_peer_fleet_acl("barsik")) == 1 + + def test_add_rejects_empty(self, real_registry): + added = real_registry.add_peer_fleet_acl("barsik") + assert added is False + assert real_registry.get_peer_fleet_acl("barsik") == [] + + def test_remove(self, real_registry): + real_registry.add_peer_fleet_acl("barsik", fleet="sigil", agent_id="pulse@studio@sigil") + real_registry.add_peer_fleet_acl("barsik", fleet="sigil", agent_id="kain@ces-mini@sigil") + removed = real_registry.remove_peer_fleet_acl( + "barsik", fleet="sigil", agent_id="pulse@studio@sigil" + ) + assert removed == 1 + loaded = real_registry.get_peer_fleet_acl("barsik") + assert len(loaded) == 1 + assert loaded[0]["agent_id"] == "kain@ces-mini@sigil" + + def test_remove_no_match_returns_zero(self, real_registry): + real_registry.add_peer_fleet_acl("barsik", fleet="sigil", agent_id="*") + removed = real_registry.remove_peer_fleet_acl( + "barsik", fleet="other", agent_id="x" + ) + assert removed == 0 + + +@pytest.mark.asyncio +class TestHostPinkyAgainstRealRegistry: + """End-to-end: real AgentRegistry + FakeBroker confirms ACL plumbing works.""" + + async def test_acl_allows_then_denies_after_remove(self, real_registry): + broker = FakeBroker() + host = HostPinky(registry=real_registry, broker=broker) + + envelope = FerryEnvelope( + v="0.1", + id="msg-allow-flow", + from_="pulse@studio@sigil", + to="ferry://pinkybot/barsik", + ts=1, + body={"kind": "message", "text": "hello"}, + ) + + # Default: deny-all + result = await host.deliver(envelope) + assert result.status == "rejected" + assert result.reason == "acl_denied" + + # Allow Pulse + real_registry.add_peer_fleet_acl( + "barsik", fleet="sigil", agent_id="pulse@studio@sigil" + ) + result = await host.deliver(envelope) + assert result.status == "delivered" + assert len(broker.received) == 1 + + # Remove Pulse — back to deny + real_registry.remove_peer_fleet_acl( + "barsik", fleet="sigil", agent_id="pulse@studio@sigil" + ) + result = await host.deliver(envelope) + assert result.status == "rejected" + assert result.reason == "acl_denied" + + async def test_unknown_agent_uses_real_has_agent(self, real_registry): + broker = FakeBroker() + host = HostPinky(registry=real_registry, broker=broker) + + envelope = FerryEnvelope( + v="0.1", + id="msg-unknown", + from_="misha@pinky.local", + to="ferry://pinkybot/nonexistent", + ts=1, + body={"kind": "message", "text": "hi"}, + ) + + result = await host.deliver(envelope) + assert result.status == "rejected" + assert result.reason == "unknown_agent" + + +# -- API: peer-fleet-acl endpoints -------------------------------------------- + + +@pytest.fixture +def api_client(): + """FastAPI TestClient with a fresh registry containing 'barsik'.""" + from fastapi.testclient import TestClient + + from pinky_daemon.api import create_api + + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + app = create_api(max_sessions=10, default_working_dir="/tmp", db_path=path) + client = TestClient(app) + # Register barsik via the API + r = client.post("/agents", json={"name": "barsik", "model": "opus"}) + assert r.status_code in (200, 201), f"agent register failed: {r.status_code} {r.text}" + yield client + client.close() + os.unlink(path) + + +class TestPeerFleetAclApi: + def test_get_empty_for_known_agent(self, api_client): + r = api_client.get("/agents/barsik/peer-fleet-acl") + assert r.status_code == 200 + assert r.json() == {"agent": "barsik", "selectors": []} + + def test_get_404_for_unknown_agent(self, api_client): + r = api_client.get("/agents/nonexistent/peer-fleet-acl") + assert r.status_code == 404 + + def test_post_adds_selector(self, api_client): + r = api_client.post( + "/agents/barsik/peer-fleet-acl", + json={"fleet": "sigil", "agent_id": "pulse@studio@sigil"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["added"] is True + assert len(body["selectors"]) == 1 + assert body["selectors"][0]["fleet"] == "sigil" + assert body["selectors"][0]["agent_id"] == "pulse@studio@sigil" + + def test_post_rejects_empty_selector(self, api_client): + r = api_client.post( + "/agents/barsik/peer-fleet-acl", + json={"fleet": "", "agent_id": "", "pinky_type": ""}, + ) + assert r.status_code == 400 + assert "at least one" in r.json()["detail"] + + def test_post_idempotent(self, api_client): + for _ in range(3): + r = api_client.post( + "/agents/barsik/peer-fleet-acl", + json={"fleet": "sigil", "agent_id": "pulse@studio@sigil"}, + ) + assert r.status_code == 200 + r = api_client.get("/agents/barsik/peer-fleet-acl") + assert len(r.json()["selectors"]) == 1 + + def test_put_replaces_full_acl(self, api_client): + # Add two + api_client.post( + "/agents/barsik/peer-fleet-acl", + json={"fleet": "sigil", "agent_id": "pulse@studio@sigil"}, + ) + api_client.post( + "/agents/barsik/peer-fleet-acl", + json={"fleet": "sigil", "agent_id": "kain@ces-mini@sigil"}, + ) + # PUT to replace with just one different selector + r = api_client.put( + "/agents/barsik/peer-fleet-acl", + json={"selectors": [{"fleet": "pinky.local", "agent_id": "*"}]}, + ) + assert r.status_code == 200 + body = r.json() + assert len(body["selectors"]) == 1 + assert body["selectors"][0]["fleet"] == "pinky.local" + + def test_delete_by_query_removes_matching(self, api_client): + api_client.post( + "/agents/barsik/peer-fleet-acl", + json={"fleet": "sigil", "agent_id": "pulse@studio@sigil"}, + ) + api_client.post( + "/agents/barsik/peer-fleet-acl", + json={"fleet": "sigil", "agent_id": "kain@ces-mini@sigil"}, + ) + r = api_client.delete( + "/agents/barsik/peer-fleet-acl", + params={"fleet": "sigil", "agent_id": "pulse@studio@sigil"}, + ) + assert r.status_code == 200 + assert r.json()["removed"] == 1 + remaining = r.json()["selectors"] + assert len(remaining) == 1 + assert remaining[0]["agent_id"] == "kain@ces-mini@sigil" + + def test_delete_no_match_returns_zero(self, api_client): + api_client.post( + "/agents/barsik/peer-fleet-acl", + json={"fleet": "sigil", "agent_id": "*"}, + ) + r = api_client.delete( + "/agents/barsik/peer-fleet-acl", + params={"fleet": "other", "agent_id": "pulse"}, + ) + assert r.status_code == 200 + assert r.json()["removed"] == 0 + + +# -- Round-2 additions: transient ACL, wildcard remove, pending substrate, broker integration -- + + +class _RaisingRegistry: + """Registry stub whose get_peer_fleet_acl raises a sqlite3.Error. + + Models the "DB locked / schema mismatch during rolling deploy" scenario + that Misha's PR #414 review #1 flagged. See round-2 finding #1. + """ + + def __init__(self) -> None: + import sqlite3 as _sq3 + self._exc = _sq3.OperationalError("database is locked") + + def has_agent(self, name: str) -> bool: + return name == "barsik" + + def get_peer_fleet_acl(self, name: str): # noqa: ANN001 + raise self._exc + + +@pytest.mark.asyncio +class TestTransientACLLoadFailure: + """A sqlite3 error during ACL load yields ``transient_failure``, + NOT ``rejected``/``acl_denied`` (round-2 finding #1). + + Without this distinction, a brief DB lock during a rolling deploy + permanently drops a real ACL-allowed message because the broker + can't tell "operator hiccup" from "policy denial." + """ + + async def test_sqlite_error_during_acl_load_yields_transient_failure(self): + registry = _RaisingRegistry() + broker = FakeBroker() + host = HostPinky(registry=registry, broker=broker) + + envelope = FerryEnvelope( + v="0.1", + id="msg-transient", + from_="misha@pinky.local", + to="ferry://pinkybot/barsik", + ts=1, + body={"kind": "message", "text": "x"}, + ) + + result = await host.deliver(envelope) + + assert result.status == "transient_failure" + assert result.reason == "acl_load_failed" + assert "database is locked" in result.detail.get("detail", "") + # Critically: did NOT degrade to rejected/acl_denied + assert result.status != "rejected" + # Broker not invoked (we never got to dispatch) + assert broker.received == [] + # Stats incremented on the right counter + assert host.stats["transient_failures"] == 1 + assert host.stats["rejected"] == 0 + + +class TestACLRemoveWildcardSemantics: + """The exact-match contract on ``remove_peer_fleet_acl`` is documented + on the method (round-2 nit). This test pins the documented behavior: + a stored ``agent_id="*"`` is removed only by passing ``agent_id="*"``. + """ + + def test_remove_without_wildcard_does_not_match_stored_wildcard(self, real_registry): + # Store a fleet-wide allow + real_registry.add_peer_fleet_acl("barsik", fleet="sigil", agent_id="*") + + # Forgetting the wildcard MUST silently zero-remove (don't surprise-delete) + removed = real_registry.remove_peer_fleet_acl("barsik", fleet="sigil") + assert removed == 0 + assert len(real_registry.get_peer_fleet_acl("barsik")) == 1 + + # Passing the exact stored selector removes it + removed = real_registry.remove_peer_fleet_acl( + "barsik", fleet="sigil", agent_id="*", + ) + assert removed == 1 + assert real_registry.get_peer_fleet_acl("barsik") == [] + + +@pytest.mark.asyncio +class TestDeliverPendingSubstrate: + """End-to-end test of the ``pending`` substrate path through ``deliver()``. + + Round-2 finding #2 / test gap: round-1 only exercised entries that + routed to pinky-memory; the pinky-self ``_insert_task`` path was + untested, which kept a latent ternary bug undetectable. This fixture + routes a ``pending`` entry through the full deliver() flow and + confirms it lands in the task store. + """ + + async def test_pending_entry_routes_to_task_store(self, allow_misha_acl): + registry = FakeRegistry(allow_misha_acl) + broker = FakeBroker() + memory = FakeMemoryStore() + tasks = FakeTaskStore() + host = HostPinky( + registry=registry, + broker=broker, + memory_store=memory, + task_store=tasks, + ) + + pending_entry = SubstrateEntry( + id="pending-001", + type="pending", # routes to pinky-self per classify_entry_destination + scope=SubstrateScope(kind="user", ref="brad"), + source=SubstrateSource( + origin="agent-inferred", + by="misha@pinky.local", + evidence="Brad mentioned needing to follow up with Matt re: NATS creds", + trust=0.85, + ), + created_at="2026-05-09T10:00:00-07:00", + updated_at="2026-05-09T10:00:00-07:00", + content="Follow up with Matt about NATS creds for ferry demo", + links=[], + lifecycle=SubstrateLifecycle(state="pending"), + port_history=[], + ) + + envelope = FerryEnvelope( + v="0.1", + id="msg-pending", + from_="misha@pinky.local", + to="ferry://pinkybot/barsik", + ts=1746846000000, + body={ + "kind": "substrate.entry", + "entry": _entry_to_dict(pending_entry), + }, + ) + + result = await host.deliver(envelope) + + assert result.status == "delivered" + # Pending → pinky-self, not pinky-memory + assert len(tasks.created) == 1 + assert len(memory.inserted) == 0 + # Confirm the task carries the right substrate provenance + task = tasks.created[0] + assert "Follow up with Matt" in task["title"] or "Follow up with Matt" in task.get("description", "") + assert task["assigned_agent"] == "barsik" + # IngestResult shape + results = result.detail["ingested"] + assert results[0]["target_store"] == "pinky-self" + assert results[0]["target_id"] # non-empty (round-2 finding #2 — ternary returned object str before) + + +@pytest.mark.asyncio +class TestBrokerIntegrationFerryInbound: + """Regression guard for round-2 blocking finding (broker boundary leak). + + Round-1 wired host-pinky's message dispatch through ``broker.handle_inbound`` + — which runs the human-platform onboarding flow (``get_user_status`` → + ``add_pending_user`` → ``/approve_…`` Telegram prompt to the owner) and + writes the ferry sender_id into the human ``approved_users`` table. That + defeated the load-bearing identity-primitive separation claim of the PR. + + Round-2 fixed it by routing through ``broker.dispatch_pre_authorized`` + (a public broker entry point that bypasses onboarding). This test pipes + a ferry envelope through real Broker + real AgentRegistry and confirms: + + 1. ``approved_users`` does NOT contain the ferry sender_id + 2. The owner does NOT receive an ``/approve_…`` Telegram prompt + 3. No pending message is queued under the ferry sender_id + + If a future refactor of ``handle_inbound`` re-introduces the leak, this + test fails. None of the FakeBroker tests above would have caught it. + """ + + async def test_ferry_inbound_does_not_touch_human_approval_path(self, real_registry): + # Real Broker, real Registry. send_callback records every outbound; + # we assert no /approve_ prompt was emitted. + from pinky_daemon.broker import MessageBroker + + sent: list[tuple[str, str, str, str]] = [] + + async def record_send(agent_name, platform, chat_id, content): # noqa: ANN001 + sent.append((agent_name, platform, chat_id, content)) + + broker = MessageBroker( + real_registry, + session_manager=None, # not exercised — _route_streaming finds no session + send_callback=record_send, + ) + + # Allow misha@pinky.local fleet-wide + real_registry.add_peer_fleet_acl( + "barsik", fleet="pinky.local", agent_id="*", + ) + + host = HostPinky(registry=real_registry, broker=broker) + envelope = FerryEnvelope( + v="0.1", + id="msg-ferry-integration", + from_="misha@pinky.local", + to="ferry://pinkybot/barsik", + ts=1746846000000, + body={"kind": "message", "text": "hello from misha across the fleet"}, + ) + + result = await host.deliver(envelope) + assert result.status == "delivered" + + # 1. ferry sender NOT in approved_users (the human-identity table) + ferry_user_id = "ferry:misha@pinky.local" + status = real_registry.get_user_status("barsik", ferry_user_id) + assert status is None, ( + f"ferry sender_id {ferry_user_id!r} leaked into approved_users " + f"with status={status!r} — round-1 bug regression" + ) + + # 2. no /approve_ prompt sent (would have gone to owner on round-1 bug) + for _, _, _, content in sent: + assert "/approve_" not in content, ( + f"owner received /approve_ prompt for ferry inbound: {content!r}" + ) + assert "wants to talk to" not in content, ( + f"owner received human-onboarding prompt for ferry inbound: {content!r}" + ) + + # 3. no pending message queued under ferry sender_id + pending = real_registry.get_pending_messages("barsik", ferry_user_id) + assert pending == [], ( + f"ferry message queued as pending under {ferry_user_id!r}: {pending}" + ) + + async def test_acl_denied_does_not_leak_either(self, real_registry): + """Same regression guard, denied path. ACL-deny must not fall back + to onboarding either — the agent should never see the message and + the owner should never see an /approve_ prompt.""" + from pinky_daemon.broker import MessageBroker + + sent: list[tuple[str, str, str, str]] = [] + + async def record_send(agent_name, platform, chat_id, content): # noqa: ANN001 + sent.append((agent_name, platform, chat_id, content)) + + broker = MessageBroker( + real_registry, session_manager=None, send_callback=record_send, + ) + # Empty ACL — default-deny + host = HostPinky(registry=real_registry, broker=broker) + envelope = FerryEnvelope( + v="0.1", + id="msg-ferry-deny", + from_="pulse@studio@sigil", # not on barsik's empty ACL + to="ferry://pinkybot/barsik", + ts=1, + body={"kind": "message", "text": "x"}, + ) + + result = await host.deliver(envelope) + assert result.status == "rejected" + assert result.reason == "acl_denied" + + # Owner gets nothing + assert sent == [] + # Registry untouched + assert real_registry.get_user_status( + "barsik", "ferry:pulse@studio@sigil", + ) is None From 94573a8855301bd2ee92c77385e9e128c6845f45 Mon Sep 17 00:00:00 2001 From: olegbrok Date: Sun, 10 May 2026 14:48:19 -0700 Subject: [PATCH 18/24] =?UTF-8?q?feat(ferry):=20outbound=20mesh=5Fremote?= =?UTF-8?q?=5Fsend=20tool=20=E2=80=94=20daemon-side=20publisher=20(#418)?= =?UTF-8?q?=20(#423)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to #414's host_pinky.py inbound. Adds the outbound side: build canonical ferry envelopes per PROTOCOL.md v0.1 and publish them to the mesh transport, exposed to agents as the `mesh_remote_send` MCP tool on pinky-self. Why shellout (not nats-py): host_pinky.py's docstring is explicit that the Python module is the *adapter* and "does no transport (NATS / leaf-link plumbing is owned by ferry-core)". To keep that boundary clean on outbound, we shell out to the `nats` CLI rather than introduce a new in-process transport. One-shot publishes match the CLI shape and we proved the wire end-to-end with this exact command earlier today (Barsik smoke 2026-05-10 12:14 PDT, sub-2s, FERRY_MESSAGES seq #3 confirmed by Pulse). If we later need a long-lived in-process publisher, MeshSender.send is the swap point — callers don't change. What lands: - ferry/outbound.py — parse_address (canonical ferry:// + at-form), derive_subject (NATS-token sanitization for both fleet + agent), allowlist_matches (exact / wildcard / default-deny), build_envelope (canonical FerryEnvelope), envelope_to_wire (key rename, optional-field elision), MeshSender (env-driven creds, diagnostics, send → SendResult). - agent_registry.py — `mesh_outbound_allowlist` column + accessors mirroring the peer_fleet_acl pattern (get/set/add/remove, _rmw_lock-guarded). - api.py — `MeshSendRequest` + `MeshAllowlist*Request` Pydantic models, `GET/POST/PUT/DELETE /agents/{name}/mesh/allowlist`, `GET /mesh/diagnostics`, `POST /agents/{name}/mesh/send`. Default-deny on send. - pinky_self/server.py — `mesh_remote_send(target, body, kind, …)` tool next to `send_to_agent`. Returns JSON `{sent, correlation_id, subject, ts, error}`. - tests/test_ferry_outbound.py — 44 tests across address parsing, subject derivation, allowlist matching, envelope building, wire serialization, and MeshSender (config / unparseable / happy / nats-failure / timeout / password-not-leaked). Cred handling: PINKYBOT_FLEET_USER / PINKYBOT_FLEET_PASSWORD / PINKYBOT_FLEET_NATS_URL read from env at MeshSender construction time. Password never appears in logs, diagnostics, or error messages (defense-in-depth test). PINKYBOT_FERRY_NATS_BIN allows pinning the CLI binary path; falls back to `shutil.which("nats")`. Permission model: Per-agent `mesh_outbound_allowlist` (list of `agent@fleet` patterns, wildcards supported). Empty list = no outbound. Stops a compromised agent from spamming the federation. Stacked PR: Branched off `feat/ferry-host-pinky` to share ferry/types.py + ferry/ package init. Once #414 merges, rebase to `beta` is a no-op (only the inbound files cascade in). Refs: - #418 issue body for full design - ferry PROTOCOL.md v0.1 §1 envelope schema - packages/host-pinky/README.md design doc - Smoke proof memory id 09fa81848419 Test plan: - 44/44 new ferry_outbound tests pass - 102/102 ferry_host_pinky + agent_registry adjacent tests pass - 418 tests across all touched modules pass - ruff clean on all touched files Co-authored-by: Oleg --- src/pinky_daemon/agent_registry.py | 82 +++++++ src/pinky_daemon/api.py | 153 ++++++++++++ src/pinky_daemon/ferry/outbound.py | 363 +++++++++++++++++++++++++++++ src/pinky_self/server.py | 44 ++++ tests/test_ferry_outbound.py | 357 ++++++++++++++++++++++++++++ 5 files changed, 999 insertions(+) create mode 100644 src/pinky_daemon/ferry/outbound.py create mode 100644 tests/test_ferry_outbound.py diff --git a/src/pinky_daemon/agent_registry.py b/src/pinky_daemon/agent_registry.py index a34f636f..e8e2fd3d 100644 --- a/src/pinky_daemon/agent_registry.py +++ b/src/pinky_daemon/agent_registry.py @@ -744,6 +744,10 @@ def _migrate(self) -> None: # Ferry peer-fleet ACL — list of AgentCardSelector dicts # (separate identity primitive from approved_users; default deny-all) ("peer_fleet_acl", "TEXT NOT NULL DEFAULT '[]'"), + # Ferry outbound mesh allowlist — list of "agent@fleet" patterns + # gating which targets this agent may publish to via + # mesh_remote_send. Default-deny (empty list = no outbound). + ("mesh_outbound_allowlist", "TEXT NOT NULL DEFAULT '[]'"), ] for col, typedef in migrations: if col not in existing: @@ -2824,5 +2828,83 @@ def remove_peer_fleet_acl( self.set_peer_fleet_acl(agent_name, kept) return removed + # ── Ferry outbound mesh allowlist ────────────────────── + # + # Per-agent allowlist gating which (fleet, agent) targets this agent + # may publish to via the mesh_remote_send tool. Stored as JSON list + # of "agent_slug@fleet" patterns on the `agents` row's + # `mesh_outbound_allowlist` column. Default-deny (empty list = no + # outbound). Patterns support wildcards per + # `pinky_daemon.ferry.outbound.allowlist_matches`. + + def get_mesh_outbound_allowlist(self, agent_name: str) -> list[str]: + """Return the agent's mesh outbound allowlist patterns. + + Returns [] for unknown agents or empty/missing allowlist. + """ + row = self._db.execute( + "SELECT mesh_outbound_allowlist FROM agents WHERE name = ?", + (agent_name,), + ).fetchone() + if not row or not row[0]: + return [] + try: + data = json.loads(row[0]) + except (TypeError, ValueError): + return [] + if not isinstance(data, list): + return [] + return [s for s in data if isinstance(s, str) and s.strip()] + + def set_mesh_outbound_allowlist( + self, + agent_name: str, + patterns: list[str], + ) -> None: + """Replace the mesh outbound allowlist for an agent (full replace). + + Empty/whitespace patterns are silently dropped. Empty list = deny + all outbound. + """ + clean = [p.strip() for p in (patterns or []) if isinstance(p, str) and p.strip()] + self._db.execute( + "UPDATE agents SET mesh_outbound_allowlist = ? WHERE name = ?", + (json.dumps(clean), agent_name), + ) + self._db.commit() + + def add_mesh_outbound_allowlist(self, agent_name: str, pattern: str) -> bool: + """Append one pattern to an agent's mesh outbound allowlist. + + Returns True if added (or already present), False if pattern is empty. + Idempotent. Thread-safe via ``_rmw_lock``. + """ + pat = (pattern or "").strip() + if not pat: + return False + with self._rmw_lock: + existing = self.get_mesh_outbound_allowlist(agent_name) + if pat in existing: + return True + existing.append(pat) + self.set_mesh_outbound_allowlist(agent_name, existing) + return True + + def remove_mesh_outbound_allowlist(self, agent_name: str, pattern: str) -> int: + """Remove all matching patterns. Returns count removed. + + Exact string match only; pass the stored pattern verbatim. + """ + pat = (pattern or "").strip() + if not pat: + return 0 + with self._rmw_lock: + existing = self.get_mesh_outbound_allowlist(agent_name) + kept = [s for s in existing if s != pat] + removed = len(existing) - len(kept) + if removed: + self.set_mesh_outbound_allowlist(agent_name, kept) + return removed + def close(self) -> None: self._db.close() diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index d1a6397b..2c9b87b8 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -634,6 +634,36 @@ class PeerFleetAclSetRequest(BaseModel): selectors: list[PeerFleetAclEntryRequest] = [] +class MeshSendRequest(BaseModel): + """Publish a ferry envelope to a remote agent via the daemon's mesh sender. + + The agent making this request must have ``target`` matching at least one + pattern in its ``mesh_outbound_allowlist``; default-deny otherwise. + + ``body`` is a free-form payload — caller decides shape; ``kind`` is + inserted into ``body.kind`` per ferry PROTOCOL.md v0.1 §1. + """ + + target: str # "agent_slug@fleet" or "ferry://fleet/agent_slug" + body: str | dict = "" + kind: str = "msg" + correlation_id: str = "" + reply_to: str = "" + priority: str = "normal" + + +class MeshAllowlistEntryRequest(BaseModel): + """Add or remove one pattern in an agent's mesh outbound allowlist.""" + + pattern: str # e.g. "pulse@pulse", "*@pulse", "pulse@*" + + +class MeshAllowlistSetRequest(BaseModel): + """Replace the full mesh outbound allowlist for an agent.""" + + patterns: list[str] = [] + + class SpawnSessionRequest(BaseModel): """Spawn a new session from an agent's config.""" @@ -6447,6 +6477,129 @@ async def remove_peer_fleet_acl( "selectors": agents.get_peer_fleet_acl(name), } + # ── Ferry Mesh Outbound (mesh_remote_send) ───────────── + # + # Agent-driven outbound: build a ferry envelope and publish to the + # mesh transport (NATS). Default-deny via per-agent allowlist; cred + # never leaves the daemon process. See pinky_daemon.ferry.outbound. + + @app.get("/agents/{name}/mesh/allowlist") + async def get_mesh_allowlist(name: str): + """List the outbound allowlist patterns for an agent.""" + if not agents.has_agent(name): + raise HTTPException(404, f"Agent '{name}' not found") + return { + "agent": name, + "patterns": agents.get_mesh_outbound_allowlist(name), + } + + @app.post("/agents/{name}/mesh/allowlist") + async def add_mesh_allowlist(name: str, req: MeshAllowlistEntryRequest): + """Add one pattern to an agent's outbound allowlist (idempotent).""" + if not agents.has_agent(name): + raise HTTPException(404, f"Agent '{name}' not found") + added = agents.add_mesh_outbound_allowlist(name, req.pattern) + if not added: + raise HTTPException(400, "pattern must be non-empty") + return { + "agent": name, + "added": True, + "patterns": agents.get_mesh_outbound_allowlist(name), + } + + @app.put("/agents/{name}/mesh/allowlist") + async def set_mesh_allowlist(name: str, req: MeshAllowlistSetRequest): + """Replace the full outbound allowlist for an agent.""" + if not agents.has_agent(name): + raise HTTPException(404, f"Agent '{name}' not found") + agents.set_mesh_outbound_allowlist(name, req.patterns) + return { + "agent": name, + "patterns": agents.get_mesh_outbound_allowlist(name), + } + + @app.delete("/agents/{name}/mesh/allowlist") + async def remove_mesh_allowlist(name: str, pattern: str = ""): + """Remove one pattern from an agent's outbound allowlist.""" + if not agents.has_agent(name): + raise HTTPException(404, f"Agent '{name}' not found") + removed = agents.remove_mesh_outbound_allowlist(name, pattern) + return { + "agent": name, + "removed": removed, + "patterns": agents.get_mesh_outbound_allowlist(name), + } + + @app.get("/mesh/diagnostics") + async def mesh_diagnostics(): + """Operator-facing health snapshot for the daemon's mesh sender. + + Reports whether creds + URL + nats CLI are wired without leaking + the password. + """ + from pinky_daemon.ferry.outbound import MeshSender + return MeshSender().diagnostics() + + @app.post("/agents/{name}/mesh/send") + async def mesh_send(name: str, req: MeshSendRequest): + """Publish a ferry envelope to ``req.target`` on behalf of ``name``. + + Permission model: ``req.target`` must match at least one pattern + in the agent's ``mesh_outbound_allowlist``. Default-deny. + + Returns ``{sent, correlation_id, subject, ts}`` on success; + on failure the error is surfaced in ``error``. + """ + from pinky_daemon.ferry.outbound import ( + MeshSender, + allowlist_matches, + build_envelope, + parse_address, + ) + + if not agents.has_agent(name): + raise HTTPException(404, f"Agent '{name}' not found") + + target = (req.target or "").strip() + if not target: + raise HTTPException(400, "target is required") + if parse_address(target) is None: + raise HTTPException(400, f"unparseable target address: {target!r}") + + allowlist = agents.get_mesh_outbound_allowlist(name) + if not allowlist_matches(target, allowlist): + raise HTTPException( + 403, + f"target {target!r} not in agent's mesh_outbound_allowlist", + ) + + # Body shape: callers may pass a string (wrapped as {"text": str}) + # or a dict. ``kind`` is always set from the request. + if isinstance(req.body, dict): + body_dict = dict(req.body) + else: + body_dict = {"text": str(req.body or "")} + body_dict["kind"] = req.kind or "msg" + + envelope = build_envelope( + from_=f"ferry://pinkybot/{name}", + to=target, + body=body_dict, + correlation_id=req.correlation_id or None, + reply_to=req.reply_to or None, + priority=req.priority or "normal", + ) + + sender = MeshSender() + result = sender.send(envelope) + return { + "sent": result.sent, + "correlation_id": result.correlation_id, + "subject": result.subject, + "ts": result.ts, + "error": result.error, + } + # ── Pending Messages (Broker) ────────────────────────── @app.get("/agents/{name}/pending-messages") diff --git a/src/pinky_daemon/ferry/outbound.py b/src/pinky_daemon/ferry/outbound.py new file mode 100644 index 00000000..5aaabb42 --- /dev/null +++ b/src/pinky_daemon/ferry/outbound.py @@ -0,0 +1,363 @@ +"""@ferry/host-pinky — outbound side: build + publish ferry envelopes. + +Companion to host_pinky.py (inbound). Where host_pinky.py receives envelopes +and routes them into PinkyBot, this module builds envelopes from PinkyBot +agents and publishes them onto the ferry transport. + +Design choice — shellout, not in-process NATS client: + host_pinky.py's docstring is explicit that the Python module is the + **adapter** and "does no transport (NATS / leaf-link plumbing is owned + by ferry-core)". To keep that boundary clean on the outbound side, we + shell out to the `nats` CLI (https://github.com/nats-io/natscli) rather + than introduce nats-py as a dep. One-shot publishes are the only outbound + pattern we need; the CLI matches that shape and we already proved it + works end-to-end (Barsik smoke 2026-05-10 12:14 PDT, sub-2s). + +If we later need a long-lived in-process publisher (e.g. for high-throughput +streaming), this module's surface (`MeshSender.send`) lets us swap the +shellout for an async nats-py client without touching callers. + +References: +- ferry PROTOCOL.md v0.1 §1 — envelope schema (mirrored by ferry/types.py) +- PinkyBot issue #418 — outbound mesh_remote_send tool +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import time +import uuid +from dataclasses import dataclass +from typing import Any + +from pinky_daemon.ferry.types import FerryEnvelope + +# -- Address parsing ----------------------------------------------------------- + +_FERRY_SCHEME = "ferry://" + + +def parse_address(addr: str) -> tuple[str, str] | None: + """Parse a ferry address into ``(fleet, agent_slug)``. + + Accepts two forms: + - ``ferry:///`` — preferred canonical form + - ``@`` — at-form for casual use + + Returns ``None`` for malformed input. ``agent_slug`` may itself contain + ``@`` (e.g. for scoped peer addresses like ``pulse@studio@sigil``); only + the rightmost ``@`` is the fleet boundary. + """ + if not addr or not isinstance(addr, str): + return None + s = addr.strip() + if not s: + return None + + if s.startswith(_FERRY_SCHEME): + rest = s[len(_FERRY_SCHEME):] + parts = rest.split("/", 1) + if len(parts) != 2: + return None + fleet, agent_slug = parts[0].strip(), parts[1].strip() + if not fleet or not agent_slug: + return None + return (fleet, agent_slug) + + # at-form: rightmost @ is the fleet boundary + if "@" not in s: + return None + head, _, fleet = s.rpartition("@") + head, fleet = head.strip(), fleet.strip() + if not head or not fleet: + return None + return (fleet, head) + + +def _sanitize_token(s: str) -> str: + """Make a string safe to use as a NATS subject token. + + NATS subjects are dot-separated; ``.`` and ``@`` are replaced with ``-`` + so each segment occupies exactly one token. Sanitization is reversible + at the application layer via the envelope's ``from`` / ``to`` fields, + which preserve the original address forms. + """ + return s.replace(".", "-").replace("@", "-") + + +def derive_subject(fleet: str, agent_slug: str) -> str: + """NATS subject for a ferry envelope addressed to ``agent_slug@fleet``. + + Convention: ``ferry...inbox``. Both ``fleet`` and + ``agent_slug`` are sanitized — see ``_sanitize_token``. + + The ``ferry.>`` prefix matches the ``pinkybot_fleet`` ACL granted to + PinkyBot on Nova (confirmed by Pulse 2026-05-10). + """ + return f"ferry.{_sanitize_token(fleet)}.{_sanitize_token(agent_slug)}.inbox" + + +# -- Allowlist matching -------------------------------------------------------- + + +def allowlist_matches(target: str, allowlist: list[str]) -> bool: + """True if ``target`` matches any pattern in ``allowlist``. + + Patterns: + - exact match (e.g. ``pulse@pulse``) + - wildcard agent: ``*@`` (any agent on the fleet) + - wildcard fleet: ``@*`` (any fleet for that agent slug) + - global: ``*@*`` or ``*`` (any target — use sparingly) + + Empty allowlist means default-deny. Wildcards do NOT match across + address forms — pass canonical form (at-form) only. + """ + if not allowlist: + return False + parsed_target = parse_address(target) + if parsed_target is None: + return False + t_fleet, t_agent = parsed_target + for pat in allowlist: + if not pat: + continue + p = pat.strip() + if p in ("*", "*@*"): + return True + parsed_pat = parse_address(p) + if parsed_pat is None: + continue + p_fleet, p_agent = parsed_pat + fleet_ok = p_fleet == "*" or p_fleet == t_fleet + agent_ok = p_agent == "*" or p_agent == t_agent + if fleet_ok and agent_ok: + return True + return False + + +# -- Envelope builder ---------------------------------------------------------- + + +def build_envelope( + *, + from_: str, + to: str, + body: dict[str, Any], + correlation_id: str | None = None, + reply_to: str | None = None, + priority: str = "normal", + headers: dict[str, str] | None = None, +) -> FerryEnvelope: + """Build a canonical ferry envelope per PROTOCOL.md v0.1 §1. + + ``body`` MUST contain a ``kind`` field (per spec). Caller is responsible + for setting it; this function does not infer it. + + ``id`` is a uuid (v4 for now; spec calls for uuid-v7 — followup once + Python stdlib lands it in 3.13+ or we add the ``uuid-utils`` dep). + """ + if not isinstance(body, dict) or "kind" not in body: + raise ValueError("envelope.body must be a dict containing a 'kind' field") + return FerryEnvelope( + v="0.1", + id=str(uuid.uuid4()), + from_=from_, + to=to, + ts=int(time.time() * 1000), + body=dict(body), + priority=priority if priority in ("low", "normal", "high", "urgent") else "normal", + correlation_id=correlation_id, + reply_to=reply_to, + headers=dict(headers or {}), + ) + + +def envelope_to_wire(env: FerryEnvelope) -> str: + """Serialize an envelope to its on-wire JSON form. + + Renames ``from_`` → ``from`` (Python keyword workaround), drops + default-valued optional fields to keep wire compact. + """ + payload: dict[str, Any] = { + "v": env.v, + "id": env.id, + "from": env.from_, + "to": env.to, + "ts": env.ts, + "body": env.body, + } + if env.priority != "normal": + payload["priority"] = env.priority + if env.wake != "if-idle": + payload["wake"] = env.wake + if env.ack != "delivery": + payload["ack"] = env.ack + if env.ttl_ms is not None: + payload["ttl_ms"] = env.ttl_ms + if env.correlation_id: + payload["correlation_id"] = env.correlation_id + if env.reply_to: + payload["reply_to"] = env.reply_to + if env.subject: + payload["subject"] = env.subject + if env.traversal: + # FerryEnvelope.traversal is List[TraversalRecord] dataclasses; + # serialize each via __dict__ (small, flat, no nested dataclasses). + payload["traversal"] = [t.__dict__ for t in env.traversal] + if env.headers: + payload["headers"] = env.headers + return json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + + +# -- Sender -------------------------------------------------------------------- + + +@dataclass +class SendResult: + """Outcome of a single ``MeshSender.send`` call.""" + + sent: bool + correlation_id: str | None + subject: str + ts: int + error: str | None = None + + +class MeshSender: + """Daemon-owned outbound publisher. + + One per-daemon instance, constructed at startup. Reads NATS credentials + and server URL from the environment; never accepts them as arguments + so they don't leak into agent context, request bodies, or logs. + + Environment variables: + PINKYBOT_FLEET_USER — NATS user (e.g. "pinkybot_fleet") + PINKYBOT_FLEET_PASSWORD — NATS password (treated as a secret) + PINKYBOT_FLEET_NATS_URL — server URL (default wss://fleet-nats.kostverse.com) + PINKYBOT_FERRY_NATS_BIN — path to nats CLI (default looks up "nats" on PATH) + """ + + DEFAULT_NATS_URL = "wss://fleet-nats.kostverse.com" + DEFAULT_TIMEOUT_S = 10.0 + + def __init__( + self, + *, + env: dict[str, str] | None = None, + bin_path: str | None = None, + timeout_s: float = DEFAULT_TIMEOUT_S, + ) -> None: + e = env if env is not None else dict(os.environ) + self._user = e.get("PINKYBOT_FLEET_USER", "").strip() + self._password = e.get("PINKYBOT_FLEET_PASSWORD", "").strip() + self._nats_url = e.get("PINKYBOT_FLEET_NATS_URL", self.DEFAULT_NATS_URL).strip() + self._bin_path = ( + bin_path + or e.get("PINKYBOT_FERRY_NATS_BIN", "").strip() + or shutil.which("nats") + or "" + ) + self._timeout_s = timeout_s + + @property + def configured(self) -> bool: + """True iff creds + URL + nats CLI binary are all available.""" + return bool(self._user and self._password and self._nats_url and self._bin_path) + + def diagnostics(self) -> dict[str, Any]: + """Operator-facing health snapshot. Never includes the password.""" + return { + "configured": self.configured, + "user_set": bool(self._user), + "password_set": bool(self._password), + "nats_url": self._nats_url, + "nats_bin": self._bin_path, + } + + def send(self, envelope: FerryEnvelope) -> SendResult: + """Publish an envelope to its derived subject. Synchronous. + + Failure modes are returned as ``SendResult(sent=False, error=...)`` + rather than raised — callers (the API endpoint) want a structured + response either way. + """ + ts = envelope.ts + cid = envelope.correlation_id + + if not self.configured: + return SendResult( + sent=False, + correlation_id=cid, + subject="", + ts=ts, + error="mesh sender not configured (missing creds, URL, or nats CLI)", + ) + + parsed = parse_address(envelope.to) + if parsed is None: + return SendResult( + sent=False, + correlation_id=cid, + subject="", + ts=ts, + error=f"unparseable target address: {envelope.to!r}", + ) + fleet, agent_slug = parsed + subject = derive_subject(fleet, agent_slug) + wire = envelope_to_wire(envelope) + + cmd = [ + self._bin_path, + "--user", self._user, + "--password", self._password, + "-s", self._nats_url, + "pub", subject, wire, + ] + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self._timeout_s, + check=False, + ) + except subprocess.TimeoutExpired: + return SendResult( + sent=False, + correlation_id=cid, + subject=subject, + ts=ts, + error=f"nats CLI timed out after {self._timeout_s}s", + ) + except OSError as exc: + return SendResult( + sent=False, + correlation_id=cid, + subject=subject, + ts=ts, + error=f"nats CLI invocation failed: {exc}", + ) + + if proc.returncode != 0: + # stderr may contain auth/ACL detail — include but do NOT + # include the original command (which has the password). + err_tail = (proc.stderr or proc.stdout or "").strip().splitlines() + err_msg = err_tail[-1] if err_tail else f"nats CLI exit {proc.returncode}" + return SendResult( + sent=False, + correlation_id=cid, + subject=subject, + ts=ts, + error=err_msg, + ) + + return SendResult( + sent=True, + correlation_id=cid, + subject=subject, + ts=ts, + error=None, + ) diff --git a/src/pinky_self/server.py b/src/pinky_self/server.py index 897465aa..809e07da 100644 --- a/src/pinky_self/server.py +++ b/src/pinky_self/server.py @@ -1408,6 +1408,50 @@ def send_to_agent( return f"Message queued for {to} (they're offline). They'll see it in their inbox when they wake up." return f"Message delivered to {to}." + @mcp.tool() + def mesh_remote_send( + target: str, + body: str = "", + kind: str = "msg", + correlation_id: str = "", + reply_to: str = "", + priority: str = "normal", + ) -> str: + """Send a ferry envelope to a remote agent on another fleet. + + Cross-fleet counterpart to ``send_to_agent``. Builds a canonical + ferry envelope (PROTOCOL.md v0.1) addressed to ``target`` and + publishes via the daemon's mesh sender. + + Args: + target: ``"agent_slug@fleet"`` (e.g. ``"pulse@pulse"``) or + ``"ferry://fleet/agent_slug"``. + body: Free-form payload string. Wrapped as ``{"text": body, "kind": kind}``. + Pass JSON if you need structured data. + kind: Envelope ``body.kind`` — ``"msg"`` (default), ``"smoke"``, + ``"ack"``, etc. Receivers route on this. + correlation_id: Optional correlation id for request/response pairing. + Auto-generated by the receiver if omitted. + reply_to: Optional ``correlation_id`` of the message being replied to. + priority: ``"low"`` | ``"normal"`` | ``"high"`` | ``"urgent"``. + + Permission: ``target`` must match a pattern in this agent's + ``mesh_outbound_allowlist`` (default-deny if empty). + + Returns a JSON string with ``{sent, correlation_id, subject, ts, error}``. + """ + result = _api("POST", f"/agents/{agent_name}/mesh/send", { + "target": target, + "body": body, + "kind": kind, + "correlation_id": correlation_id, + "reply_to": reply_to, + "priority": priority, + }) + if "error" in result and "sent" not in result: + return json.dumps({"sent": False, "error": result.get("error", "unknown")}) + return json.dumps(result) + @mcp.tool() def send_file_to_agent( to_agent: str, diff --git a/tests/test_ferry_outbound.py b/tests/test_ferry_outbound.py new file mode 100644 index 00000000..f2717d2f --- /dev/null +++ b/tests/test_ferry_outbound.py @@ -0,0 +1,357 @@ +"""Tests for @ferry/host-pinky outbound side — envelope build + publish. + +Companion to test_ferry_host_pinky.py (inbound). Covers: +- address parsing (canonical ferry:// + at-form, edge cases) +- subject derivation (token sanitization) +- allowlist matching (exact, wildcards, default-deny) +- envelope builder (kind enforcement, ts/id stamping) +- envelope-to-wire serialization (key rename, optional-field elision) +- MeshSender (configured/diagnostics/send happy + failure paths) + +The MeshSender tests mock subprocess.run rather than spawning the real +``nats`` CLI — the contract under test is "we shell out the right thing +and surface the right result", not "the nats CLI works" (separately +proven by the smoke fired 2026-05-10 12:14 PDT). + +PinkyBot issue: #418. +""" + +from __future__ import annotations + +import json +import subprocess +from unittest.mock import patch + +import pytest + +from pinky_daemon.ferry.outbound import ( + MeshSender, + SendResult, + allowlist_matches, + build_envelope, + derive_subject, + envelope_to_wire, + parse_address, +) + +# -- parse_address ------------------------------------------------------------- + + +class TestParseAddress: + def test_canonical_ferry_scheme(self): + assert parse_address("ferry://pulse/pulse") == ("pulse", "pulse") + + def test_at_form(self): + assert parse_address("pulse@pulse") == ("pulse", "pulse") + + def test_at_form_with_scope(self): + # Rightmost @ is the fleet boundary; scope segments stay in the slug. + assert parse_address("pulse@studio@sigil") == ("sigil", "pulse@studio") + + def test_canonical_with_complex_slug(self): + assert parse_address("ferry://sigil/pulse@studio") == ("sigil", "pulse@studio") + + @pytest.mark.parametrize("addr", [ + "", + " ", + "no-fleet", + "ferry://", + "ferry://onlyfleet", + "ferry://onlyfleet/", + "ferry:///onlyagent", + "@nofleet", + "noslug@", + None, + ]) + def test_invalid_returns_none(self, addr): + assert parse_address(addr) is None # type: ignore[arg-type] + + def test_strips_whitespace(self): + assert parse_address(" pulse@pulse ") == ("pulse", "pulse") + + +# -- derive_subject ------------------------------------------------------------ + + +class TestDeriveSubject: + def test_simple(self): + assert derive_subject("pulse", "pulse") == "ferry.pulse.pulse.inbox" + + def test_at_in_slug_sanitized(self): + assert derive_subject("sigil", "pulse@studio") == "ferry.sigil.pulse-studio.inbox" + + def test_dot_in_slug_sanitized(self): + # dots are NATS subject separators — must not appear in tokens + assert derive_subject("pinky.local", "misha") == "ferry.pinky-local.misha.inbox" + + +# -- allowlist_matches --------------------------------------------------------- + + +class TestAllowlistMatches: + def test_empty_allowlist_denies_all(self): + assert allowlist_matches("pulse@pulse", []) is False + + def test_exact_match(self): + assert allowlist_matches("pulse@pulse", ["pulse@pulse"]) is True + + def test_no_match(self): + assert allowlist_matches("pulse@pulse", ["misha@pulse"]) is False + + def test_wildcard_agent_for_fleet(self): + assert allowlist_matches("pulse@pulse", ["*@pulse"]) is True + assert allowlist_matches("misha@pulse", ["*@pulse"]) is True + assert allowlist_matches("pulse@sigil", ["*@pulse"]) is False + + def test_wildcard_fleet_for_agent(self): + assert allowlist_matches("pulse@pulse", ["pulse@*"]) is True + assert allowlist_matches("pulse@sigil", ["pulse@*"]) is True + assert allowlist_matches("misha@pulse", ["pulse@*"]) is False + + def test_global_wildcard(self): + assert allowlist_matches("pulse@pulse", ["*"]) is True + assert allowlist_matches("anything@anything", ["*@*"]) is True + + def test_unparseable_target_denied(self): + assert allowlist_matches("not-an-address", ["*"]) is False + + def test_invalid_pattern_skipped_not_matched(self): + # Bad pattern shouldn't crash; good pattern still matches. + assert allowlist_matches( + "pulse@pulse", + ["", "garbage", "pulse@pulse"], + ) is True + + +# -- build_envelope ------------------------------------------------------------ + + +class TestBuildEnvelope: + def _body(self, **extras): + return {"kind": "msg", "text": "hi", **extras} + + def test_basic(self): + env = build_envelope( + from_="ferry://pinkybot/barsik", + to="pulse@pulse", + body=self._body(), + ) + assert env.v == "0.1" + assert env.from_ == "ferry://pinkybot/barsik" + assert env.to == "pulse@pulse" + assert env.body == {"kind": "msg", "text": "hi"} + assert env.id # uuid stamped + assert env.ts > 0 + + def test_kind_enforced(self): + with pytest.raises(ValueError, match="must be a dict containing a 'kind' field"): + build_envelope(from_="x", to="y", body={"text": "no kind"}) + + def test_body_must_be_dict(self): + with pytest.raises(ValueError): + build_envelope(from_="x", to="y", body="not-a-dict") # type: ignore[arg-type] + + def test_correlation_id_passthrough(self): + env = build_envelope( + from_="x", to="y", body=self._body(), + correlation_id="cid-123", + ) + assert env.correlation_id == "cid-123" + + def test_priority_validated(self): + for p in ("low", "normal", "high", "urgent"): + env = build_envelope(from_="x", to="y", body=self._body(), priority=p) + assert env.priority == p + # Bogus priority falls back to "normal" (don't crash on caller typo). + env = build_envelope(from_="x", to="y", body=self._body(), priority="bogus") + assert env.priority == "normal" + + +# -- envelope_to_wire ---------------------------------------------------------- + + +class TestEnvelopeToWire: + def _env(self, **kw): + return build_envelope( + from_="ferry://pinkybot/barsik", + to="pulse@pulse", + body={"kind": "msg", "text": "hi"}, + **kw, + ) + + def test_renames_from(self): + wire = json.loads(envelope_to_wire(self._env())) + assert "from" in wire + assert "from_" not in wire + assert wire["from"] == "ferry://pinkybot/barsik" + + def test_required_fields(self): + wire = json.loads(envelope_to_wire(self._env())) + for field in ("v", "id", "from", "to", "ts", "body"): + assert field in wire + + def test_optional_fields_elided_at_default(self): + wire = json.loads(envelope_to_wire(self._env())) + # Defaults shouldn't bloat the wire. + assert "priority" not in wire # default "normal" + assert "wake" not in wire # default "if-idle" + assert "ack" not in wire # default "delivery" + assert "ttl_ms" not in wire + assert "correlation_id" not in wire + assert "reply_to" not in wire + assert "subject" not in wire + assert "traversal" not in wire + assert "headers" not in wire + + def test_optional_fields_emitted_when_set(self): + env = self._env(correlation_id="cid-1", reply_to="cid-0", priority="high") + wire = json.loads(envelope_to_wire(env)) + assert wire["correlation_id"] == "cid-1" + assert wire["reply_to"] == "cid-0" + assert wire["priority"] == "high" + + +# -- MeshSender ---------------------------------------------------------------- + + +class TestMeshSenderConfig: + def test_unconfigured_when_no_env(self): + s = MeshSender(env={}, bin_path="") + assert s.configured is False + diag = s.diagnostics() + assert diag["configured"] is False + assert diag["user_set"] is False + assert diag["password_set"] is False + # Password value never in diagnostics. + assert "password" not in diag or diag.get("password") in (None, "") + + def test_configured_with_full_env(self, tmp_path): + # Synthesize a fake nats binary that exists, so configured=True. + fake_bin = tmp_path / "nats" + fake_bin.write_text("#!/bin/sh\nexit 0\n") + fake_bin.chmod(0o755) + s = MeshSender( + env={ + "PINKYBOT_FLEET_USER": "pinkybot_fleet", + "PINKYBOT_FLEET_PASSWORD": "secret", + "PINKYBOT_FLEET_NATS_URL": "wss://example.test", + }, + bin_path=str(fake_bin), + ) + assert s.configured is True + diag = s.diagnostics() + assert diag["user_set"] is True + assert diag["password_set"] is True + assert diag["nats_url"] == "wss://example.test" + # Diagnostics MUST NOT leak the password value itself. + for v in diag.values(): + assert v != "secret" + + +class TestMeshSenderSend: + def _sender(self, tmp_path): + fake_bin = tmp_path / "nats" + fake_bin.write_text("#!/bin/sh\nexit 0\n") + fake_bin.chmod(0o755) + return MeshSender( + env={ + "PINKYBOT_FLEET_USER": "pinkybot_fleet", + "PINKYBOT_FLEET_PASSWORD": "secret", + "PINKYBOT_FLEET_NATS_URL": "wss://example.test", + }, + bin_path=str(fake_bin), + ) + + def _envelope(self, **kw): + return build_envelope( + from_="ferry://pinkybot/barsik", + to="pulse@pulse", + body={"kind": "smoke", "text": "hi"}, + **kw, + ) + + def test_unconfigured_returns_error(self): + s = MeshSender(env={}, bin_path="") + result = s.send(self._envelope(correlation_id="cid-x")) + assert result.sent is False + assert "not configured" in (result.error or "") + assert result.correlation_id == "cid-x" + + def test_unparseable_target(self, tmp_path): + env = self._envelope() + env.to = "not-an-address" + result = self._sender(tmp_path).send(env) + assert result.sent is False + assert "unparseable target" in (result.error or "") + + def test_happy_path(self, tmp_path): + sender = self._sender(tmp_path) + env = self._envelope(correlation_id="cid-happy") + + with patch("subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, stdout="Published 165 bytes\n", stderr="", + ) + result = sender.send(env) + + assert result.sent is True + assert result.error is None + assert result.correlation_id == "cid-happy" + assert result.subject == "ferry.pulse.pulse.inbox" + + # Verify the subprocess call had the right shape. + call = mock_run.call_args + cmd = call.args[0] + assert cmd[0].endswith("nats") + assert "--user" in cmd + assert "pinkybot_fleet" in cmd + assert "--password" in cmd + assert "secret" in cmd + assert "pub" in cmd + assert "ferry.pulse.pulse.inbox" in cmd + # Wire payload is the last arg; verify it's the envelope JSON. + payload = json.loads(cmd[-1]) + assert payload["from"] == "ferry://pinkybot/barsik" + assert payload["to"] == "pulse@pulse" + assert payload["correlation_id"] == "cid-happy" + + def test_nats_failure_surfaces_error(self, tmp_path): + sender = self._sender(tmp_path) + with patch("subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=1, stdout="", + stderr="nats: error: auth failed\n", + ) + result = sender.send(self._envelope()) + + assert result.sent is False + assert result.error and "auth failed" in result.error + + def test_timeout_surfaces_error(self, tmp_path): + sender = self._sender(tmp_path) + with patch("subprocess.run") as mock_run: + mock_run.side_effect = subprocess.TimeoutExpired(cmd="nats", timeout=10.0) + result = sender.send(self._envelope()) + + assert result.sent is False + assert "timed out" in (result.error or "") + + def test_password_not_in_error_message(self, tmp_path): + """Defense in depth — if a failure surfaces stderr, password must + not appear (we don't put it in stderr by default, but assert it).""" + sender = self._sender(tmp_path) + with patch("subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=1, stdout="", + stderr="nats: error: connection refused\n", + ) + result = sender.send(self._envelope()) + assert result.sent is False + assert "secret" not in (result.error or "") + + +class TestSendResultShape: + def test_smoke(self): + # Quick proof of the dataclass shape — used by API serializer. + r = SendResult(sent=True, correlation_id="cid", subject="s", ts=1, error=None) + assert r.sent and r.correlation_id == "cid" From 3bc5838bb1a84f5b8fac374e7f977b49ed19d730 Mon Sep 17 00:00:00 2001 From: olegbrok Date: Sun, 10 May 2026 14:49:07 -0700 Subject: [PATCH 19/24] feat(ferry): mesh persistence + federation peer registry (#419) (#422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on PR #421 (mesh_remote_send) which is stacked on PR #414 (host-pinky inbound). Adds the audit log + directory layer. Two new tables in their own SQLite file (data/pinky_mesh.db, sibling to the other *_store DBs): - mesh_messages — every cross-fleet envelope we send or receive, one row each. Direction-tagged, indexed on (local_agent, received_at) for the UI inbox view and on correlation_id for request/response pairing. - federation_peers — known remote agents, keyed by (fleet, agent). Tracks first/last seen + seed_source ("config" for pre-configured vs "observed" for auto-registered on first inbound). Wiring: - mesh_store.py — MeshStore class, MeshMessage + FederationPeer dataclasses. Two tables, three indices. Thread-safe writes via RLock. - api.py — MeshStore instantiated in create_app alongside the other *_store singletons. New endpoints: GET /agents/{name}/mesh/inbox (list w/ kind/sender/direction filters) GET /federation/peers POST /federation/peers (admin upsert) DELETE /federation/peers/{fleet}/{agent} Outbound logging wired into existing POST /agents/{name}/mesh/send. Persistence failures are caught (defensive) — they must not propagate into the API response. - ferry/host_pinky.py — new optional `mesh_store` kwarg on HostPinky.__init__. When set, every deliver() call logs one mesh_messages row with the final disposition (success or rejection reason). Logging is wrapped in try/except: persistence errors are logged but never break delivery. Auto-registration: every inbound + every successful outbound touches the peer's last_seen and registers as seed_source='observed' if new. Failed outbound does NOT touch the peer (a failed publish doesn't prove reachability). Config peers are stronger than observed: an upsert with seed_source='config' promotes an observed peer; subsequent observed traffic does NOT downgrade a config peer. Deferred from #419 issue body: - YAML config seeder (data/federation_peers.yaml at daemon start) — the POST /federation/peers admin path covers config-seeding for now; a YAML loader is a follow-up if Brad wants startup-time seeding. Test plan: - 20 new tests in test_mesh_store.py (schema, log_message inbound/ outbound success+failure, get_inbox filters/pagination, correlation-id lookup, peer upsert/list/get/remove, config-vs-observed promotion semantics, defensive deserialization) - 122 ferry-area tests pass (mesh_store + ferry_outbound + ferry_host_pinky) - 438 tests pass across all touched modules - ruff clean Refs: - #419 — issue with full design - #418 / PR #421 — outbound mesh_remote_send (parent of this stack) - #414 — host-pinky inbound (grandparent) - #420 — Svelte Mesh tab (next, consumes the new read endpoints) Co-authored-by: Oleg --- src/pinky_daemon/api.py | 108 +++++++ src/pinky_daemon/ferry/host_pinky.py | 46 +++ src/pinky_daemon/mesh_store.py | 416 +++++++++++++++++++++++++++ tests/test_mesh_store.py | 299 +++++++++++++++++++ 4 files changed, 869 insertions(+) create mode 100644 src/pinky_daemon/mesh_store.py create mode 100644 tests/test_mesh_store.py diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index 2c9b87b8..1d53ed9a 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -78,6 +78,7 @@ ) from pinky_daemon.kb_store import KBStore from pinky_daemon.librarian_runner import LibrarianRunner +from pinky_daemon.mesh_store import MeshStore from pinky_daemon.outreach_config import OutreachConfigStore from pinky_daemon.plugin_manager import PluginManager from pinky_daemon.presentation_store import PresentationStore @@ -664,6 +665,19 @@ class MeshAllowlistSetRequest(BaseModel): patterns: list[str] = [] +class FederationPeerUpsertRequest(BaseModel): + """Insert or update a federation peer (admin / config-seeded path). + + Composite key: (fleet, agent). ``seed_source='config'`` is the stronger + statement of intent and never gets downgraded by an observed update. + """ + + fleet: str + agent: str + display_name: str = "" + seed_source: str = "config" # "config" | "observed" + + class SpawnSessionRequest(BaseModel): """Spawn a new session from an agent's config.""" @@ -2812,6 +2826,7 @@ async def _disconnect_streaming_sessions(agent_name: str) -> int: presentations = PresentationStore(db_path=db_path.replace(".db", "_presentations.db")) app_store = AppStore(db_path=db_path.replace(".db", "_apps.db")) trigger_store = TriggerStore(db_path=db_path.replace(".db", "_triggers.db")) + mesh_store = MeshStore(db_path=db_path.replace(".db", "_mesh.db")) # Knowledge Base — project-level, all agents share _data_dir = Path(db_path).parent @@ -6592,6 +6607,26 @@ async def mesh_send(name: str, req: MeshSendRequest): sender = MeshSender() result = sender.send(envelope) + + # Audit log: every send attempt, success or failure. Persistence + # failure must not propagate into the API response (defensive try). + try: + target_fleet, target_agent = parse_address(target) or ("", "") + mesh_store.log_message( + direction="outbound", + local_agent=name, + remote_fleet=target_fleet, + remote_agent=target_agent, + correlation_id=envelope.correlation_id or "", + kind=envelope.body.get("kind", "msg"), + body=envelope.body, + envelope_ts=envelope.ts, + reply_to=envelope.reply_to, + error=result.error, + ) + except Exception as exc: # noqa: BLE001 + _log(f"mesh outbound log failed (non-fatal): {exc}") + return { "sent": result.sent, "correlation_id": result.correlation_id, @@ -6600,6 +6635,79 @@ async def mesh_send(name: str, req: MeshSendRequest): "error": result.error, } + @app.get("/agents/{name}/mesh/inbox") + async def get_mesh_inbox( + name: str, + limit: int = 50, + offset: int = 0, + kind: str = "", + sender: str = "", + direction: str = "", + ): + """List mesh messages for an agent, newest first. + + Filters: ``kind`` (exact), ``sender`` (``"agent@fleet"``), + ``direction`` (``"inbound"`` | ``"outbound"`` | ``""``). + """ + if not agents.has_agent(name): + raise HTTPException(404, f"Agent '{name}' not found") + messages = mesh_store.get_inbox( + name, + limit=limit, + offset=offset, + kind=kind, + sender=sender, + direction=direction, + ) + return { + "agent": name, + "count": len(messages), + "messages": [m.to_dict() for m in messages], + } + + @app.get("/federation/peers") + async def list_federation_peers( + fleet: str = "", + seed_source: str = "", + limit: int = 200, + ): + """List known federation peers, newest-seen first. + + Filters: ``fleet`` (exact), ``seed_source`` (``"config"`` | ``"observed"``). + """ + peers = mesh_store.list_peers( + fleet=fleet, seed_source=seed_source, limit=limit, + ) + return { + "count": len(peers), + "peers": [p.to_dict() for p in peers], + } + + @app.post("/federation/peers") + async def upsert_federation_peer(req: FederationPeerUpsertRequest): + """Insert or update a federation peer (admin / config-seeded path). + + ``seed_source='config'`` is the stronger statement of intent — once + set, an observed-update doesn't downgrade it. + """ + if not (req.fleet and req.agent): + raise HTTPException(400, "fleet and agent are required") + seed = req.seed_source if req.seed_source in ("config", "observed") else "config" + mesh_store.upsert_peer( + fleet=req.fleet, + agent=req.agent, + display_name=req.display_name, + seed_source=seed, + ) + peer = mesh_store.get_peer(req.fleet, req.agent) + return {"upserted": True, "peer": peer.to_dict() if peer else None} + + @app.delete("/federation/peers/{fleet}/{agent}") + async def delete_federation_peer(fleet: str, agent: str): + """Hard-delete a federation peer.""" + removed = mesh_store.remove_peer(fleet, agent) + return {"removed": removed, "fleet": fleet, "agent": agent} + # ── Pending Messages (Broker) ────────────────────────── @app.get("/agents/{name}/pending-messages") diff --git a/src/pinky_daemon/ferry/host_pinky.py b/src/pinky_daemon/ferry/host_pinky.py index 39592f25..16f01cc1 100644 --- a/src/pinky_daemon/ferry/host_pinky.py +++ b/src/pinky_daemon/ferry/host_pinky.py @@ -112,6 +112,7 @@ def __init__( broker: Any, memory_store: Any | None = None, task_store: Any | None = None, + mesh_store: Any | None = None, verify_signatures: bool = False, reflection_factory: Callable[[dict[str, Any]], Any] | None = None, ) -> None: @@ -121,11 +122,16 @@ def __init__( from a kwargs dict. Defaults to ``pinky_memory.types.Reflection`` when unset. Inject in tests to avoid the pinky_memory import cost and to substitute a stand-in shape; production callers leave it as ``None``. + + ``mesh_store`` (optional): a ``MeshStore`` for inbound envelope + audit + federation peer auto-registration (see #419). When ``None``, + delivery proceeds without persistence. """ self._registry = registry self._broker = broker self._memory_store = memory_store self._task_store = task_store + self._mesh_store = mesh_store self._verify_signatures = verify_signatures self._reflection_factory = reflection_factory self._stats: dict[str, int] = { @@ -151,7 +157,17 @@ async def deliver(self, envelope: FerryEnvelope) -> DeliveryResult: 2. Resolve target agent 3. Peer-fleet ACL check 4. Dispatch by body.kind + + Side effect: if a ``mesh_store`` was passed at construction time, + every call here logs one ``mesh_messages`` row with the final + disposition (success or rejection reason). """ + result = await self._deliver_core(envelope) + self._log_inbound_safely(envelope, result) + return result + + async def _deliver_core(self, envelope: FerryEnvelope) -> DeliveryResult: + """The main deliver pipeline. ``deliver()`` wraps this with logging.""" # 1. Signature verification if self._verify_signatures: verify_err = self._verify(envelope) @@ -194,6 +210,36 @@ async def deliver(self, envelope: FerryEnvelope) -> DeliveryResult: return self._reject("unsupported_payload_kind", f"kind={kind!r}") + def _log_inbound_safely( + self, + envelope: FerryEnvelope, + result: DeliveryResult, + ) -> None: + """Best-effort log + peer-touch. Never propagates persistence errors — + delivery semantics must not depend on whether mesh persistence is up.""" + if self._mesh_store is None: + return + try: + local_agent = parse_pinkybot_address(envelope.to) or "" + peer_fleet, peer_agent_id = parse_peer_card(envelope.from_) + error: str | None = None + if result.status in ("rejected", "transient_failure"): + error = f"{result.status}:{result.reason or ''}" + self._mesh_store.log_message( + direction="inbound", + local_agent=local_agent, + remote_fleet=peer_fleet, + remote_agent=peer_agent_id, + correlation_id=envelope.correlation_id or "", + kind=envelope.kind, + body=envelope.body if isinstance(envelope.body, dict) else {"_raw": envelope.body}, + envelope_ts=envelope.ts, + reply_to=envelope.reply_to, + error=error, + ) + except Exception as e: # noqa: BLE001 — defensive: persistence must not break delivery + _log(f"mesh_store inbound log failed (non-fatal): {e}") + # -- Signature verification (deferred for v0.1) --------------------------- def _verify(self, envelope: FerryEnvelope) -> str | None: diff --git a/src/pinky_daemon/mesh_store.py b/src/pinky_daemon/mesh_store.py new file mode 100644 index 00000000..b553c333 --- /dev/null +++ b/src/pinky_daemon/mesh_store.py @@ -0,0 +1,416 @@ +"""Mesh persistence — ferry envelope log + federation peer registry. + +Two tables in SQLite (its own DB file by default, sibling to the others +under ``data/``): + +- ``mesh_messages`` — every cross-fleet envelope we send or receive, + one row each. Direction-tagged. Indexed on (local_agent, received_at) + for the per-agent inbox view (#420 UI) and on correlation_id for + request/response pairing. +- ``federation_peers`` — known remote agents, keyed by (fleet, agent). + Tracks first/last seen + ``seed_source`` (``"config"`` for + pre-configured peers vs ``"observed"`` for those auto-registered + on first inbound). + +Default-deny still lives in the ``mesh_outbound_allowlist`` (per-agent, +in agent_registry); this store is the *audit log + directory*, not the +permission boundary. + +PinkyBot issue: #419. +""" + +from __future__ import annotations + +import json +import sqlite3 +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +# -- Records ------------------------------------------------------------------- + + +@dataclass +class MeshMessage: + """One row in ``mesh_messages``.""" + + id: int + correlation_id: str + direction: Literal["inbound", "outbound"] + local_agent: str + remote_fleet: str + remote_agent: str + kind: str + body: dict # JSON-decoded + envelope_ts: int # millis from envelope.ts + received_at: float # our wall clock (seconds since epoch) + reply_to: str | None = None + error: str | None = None + + def to_dict(self) -> dict: + return { + "id": self.id, + "correlation_id": self.correlation_id, + "direction": self.direction, + "local_agent": self.local_agent, + "remote_fleet": self.remote_fleet, + "remote_agent": self.remote_agent, + "kind": self.kind, + "body": self.body, + "envelope_ts": self.envelope_ts, + "received_at": self.received_at, + "reply_to": self.reply_to, + "error": self.error, + } + + +@dataclass +class FederationPeer: + """One row in ``federation_peers``.""" + + fleet: str + agent: str + display_name: str + first_seen: float + last_seen: float + seed_source: Literal["config", "observed"] + + def to_dict(self) -> dict: + return { + "fleet": self.fleet, + "agent": self.agent, + "display_name": self.display_name, + "first_seen": self.first_seen, + "last_seen": self.last_seen, + "seed_source": self.seed_source, + "address": f"{self.agent}@{self.fleet}", + } + + +# -- Store --------------------------------------------------------------------- + + +class MeshStore: + """SQLite-backed mesh log + federation peer registry. + + Thread-safe via a per-instance lock around writes; reads use SQLite's + own concurrency story. + """ + + def __init__(self, db_path: str = "data/mesh.db") -> None: + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + self._db = sqlite3.connect(db_path, check_same_thread=False) + self._db.execute("PRAGMA journal_mode=WAL") + self._db.execute("PRAGMA foreign_keys=ON") + self._lock = threading.RLock() + self._init_tables() + + def _init_tables(self) -> None: + self._db.executescript(""" + CREATE TABLE IF NOT EXISTS mesh_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + correlation_id TEXT NOT NULL DEFAULT '', + direction TEXT NOT NULL CHECK(direction IN ('inbound','outbound')), + local_agent TEXT NOT NULL, + remote_fleet TEXT NOT NULL, + remote_agent TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '{}', + envelope_ts INTEGER NOT NULL DEFAULT 0, + received_at REAL NOT NULL, + reply_to TEXT, + error TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_mesh_messages_local + ON mesh_messages(local_agent, received_at DESC); + CREATE INDEX IF NOT EXISTS idx_mesh_messages_corr + ON mesh_messages(correlation_id); + CREATE INDEX IF NOT EXISTS idx_mesh_messages_direction + ON mesh_messages(direction, received_at DESC); + + CREATE TABLE IF NOT EXISTS federation_peers ( + fleet TEXT NOT NULL, + agent TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + first_seen REAL NOT NULL, + last_seen REAL NOT NULL, + seed_source TEXT NOT NULL DEFAULT 'observed', + PRIMARY KEY (fleet, agent) + ); + + CREATE INDEX IF NOT EXISTS idx_federation_peers_seen + ON federation_peers(last_seen DESC); + """) + self._db.commit() + + # -- mesh_messages -------------------------------------------------------- + + def log_message( + self, + *, + direction: Literal["inbound", "outbound"], + local_agent: str, + remote_fleet: str, + remote_agent: str, + correlation_id: str = "", + kind: str = "", + body: dict | None = None, + envelope_ts: int = 0, + reply_to: str | None = None, + error: str | None = None, + ) -> int: + """Append a row to ``mesh_messages`` and return its id. + + Side effect: also touches ``federation_peers`` — bumps last_seen + if known, registers as ``seed_source='observed'`` if not. For + outbound rows, only updates peer state when ``error is None`` + (a failed publish doesn't prove the peer's reachable). + """ + body_json = json.dumps(body or {}, separators=(",", ":"), ensure_ascii=False) + now = time.time() + with self._lock: + cur = self._db.execute( + """INSERT INTO mesh_messages + (correlation_id, direction, local_agent, + remote_fleet, remote_agent, kind, body, + envelope_ts, received_at, reply_to, error) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (correlation_id, direction, local_agent, + remote_fleet, remote_agent, kind, body_json, + envelope_ts, now, reply_to, error), + ) + row_id = cur.lastrowid or 0 + # Touch peer for inbound-success and outbound-success only. + should_touch = direction == "inbound" or (direction == "outbound" and error is None) + if should_touch: + self._touch_peer_locked(remote_fleet, remote_agent, now) + self._db.commit() + return row_id + + def get_inbox( + self, + local_agent: str, + *, + limit: int = 50, + offset: int = 0, + kind: str = "", + sender: str = "", + direction: str = "", + ) -> list[MeshMessage]: + """Return mesh messages for ``local_agent``, newest first. + + Filters: ``kind`` exact match, ``sender`` matches against + ``"agent@fleet"``, ``direction`` ∈ ``{"", "inbound", "outbound"}``. + """ + clauses = ["local_agent = ?"] + params: list[Any] = [local_agent] + if kind: + clauses.append("kind = ?") + params.append(kind) + if direction in ("inbound", "outbound"): + clauses.append("direction = ?") + params.append(direction) + if sender: + # sender is "agent@fleet"; split on rightmost @ for matching + if "@" in sender: + agent, _, fleet = sender.rpartition("@") + if agent and fleet: + clauses.append("remote_agent = ? AND remote_fleet = ?") + params.extend([agent, fleet]) + where = " AND ".join(clauses) + params.extend([int(limit), int(offset)]) + rows = self._db.execute( + f"""SELECT id, correlation_id, direction, local_agent, + remote_fleet, remote_agent, kind, body, + envelope_ts, received_at, reply_to, error + FROM mesh_messages + WHERE {where} + ORDER BY received_at DESC + LIMIT ? OFFSET ?""", + params, + ).fetchall() + return [self._row_to_message(r) for r in rows] + + def get_message_by_correlation_id(self, correlation_id: str) -> MeshMessage | None: + """Fetch the most recent message with this correlation_id (any agent). + + Useful for request/response pairing. Returns None if not found. + """ + if not correlation_id: + return None + row = self._db.execute( + """SELECT id, correlation_id, direction, local_agent, + remote_fleet, remote_agent, kind, body, + envelope_ts, received_at, reply_to, error + FROM mesh_messages + WHERE correlation_id = ? + ORDER BY received_at DESC + LIMIT 1""", + (correlation_id,), + ).fetchone() + return self._row_to_message(row) if row else None + + def count_messages(self, local_agent: str = "") -> int: + """Count messages, optionally filtered to one agent.""" + if local_agent: + row = self._db.execute( + "SELECT COUNT(*) FROM mesh_messages WHERE local_agent = ?", + (local_agent,), + ).fetchone() + else: + row = self._db.execute("SELECT COUNT(*) FROM mesh_messages").fetchone() + return int(row[0]) if row else 0 + + def _row_to_message(self, row: tuple) -> MeshMessage: + try: + body = json.loads(row[7]) if row[7] else {} + if not isinstance(body, dict): + body = {"_raw": body} + except (TypeError, ValueError): + body = {"_raw": row[7]} + return MeshMessage( + id=row[0], + correlation_id=row[1], + direction=row[2], + local_agent=row[3], + remote_fleet=row[4], + remote_agent=row[5], + kind=row[6], + body=body, + envelope_ts=row[8], + received_at=row[9], + reply_to=row[10], + error=row[11], + ) + + # -- federation_peers ----------------------------------------------------- + + def _touch_peer_locked(self, fleet: str, agent: str, now: float) -> None: + """Insert-or-update peer's last_seen. Called under self._lock.""" + existing = self._db.execute( + "SELECT first_seen FROM federation_peers WHERE fleet = ? AND agent = ?", + (fleet, agent), + ).fetchone() + if existing: + self._db.execute( + "UPDATE federation_peers SET last_seen = ? WHERE fleet = ? AND agent = ?", + (now, fleet, agent), + ) + else: + self._db.execute( + """INSERT INTO federation_peers + (fleet, agent, display_name, first_seen, last_seen, seed_source) + VALUES (?, ?, '', ?, ?, 'observed')""", + (fleet, agent, now, now), + ) + + def upsert_peer( + self, + *, + fleet: str, + agent: str, + display_name: str = "", + seed_source: Literal["config", "observed"] = "config", + ) -> None: + """Insert or update a peer (admin / config-seeded path). + + ``seed_source='config'`` does NOT downgrade an already-observed + peer to "observed" — config-flagged peers stay flagged. Conversely, + an already-config peer that becomes observed keeps the 'config' + flag (config wins, since it's a stronger statement of intent). + """ + now = time.time() + with self._lock: + existing = self._db.execute( + """SELECT first_seen, seed_source FROM federation_peers + WHERE fleet = ? AND agent = ?""", + (fleet, agent), + ).fetchone() + if existing: + # Keep the stronger seed_source: config beats observed. + final_source = ( + "config" + if seed_source == "config" or existing[1] == "config" + else "observed" + ) + self._db.execute( + """UPDATE federation_peers + SET display_name = ?, last_seen = ?, seed_source = ? + WHERE fleet = ? AND agent = ?""", + (display_name, now, final_source, fleet, agent), + ) + else: + self._db.execute( + """INSERT INTO federation_peers + (fleet, agent, display_name, first_seen, last_seen, seed_source) + VALUES (?, ?, ?, ?, ?, ?)""", + (fleet, agent, display_name, now, now, seed_source), + ) + self._db.commit() + + def list_peers( + self, + *, + fleet: str = "", + seed_source: str = "", + limit: int = 200, + ) -> list[FederationPeer]: + """List peers, newest-seen first. Filter by fleet or seed_source.""" + clauses: list[str] = [] + params: list[Any] = [] + if fleet: + clauses.append("fleet = ?") + params.append(fleet) + if seed_source in ("config", "observed"): + clauses.append("seed_source = ?") + params.append(seed_source) + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + params.append(int(limit)) + rows = self._db.execute( + f"""SELECT fleet, agent, display_name, first_seen, last_seen, seed_source + FROM federation_peers + {where} + ORDER BY last_seen DESC + LIMIT ?""", + params, + ).fetchall() + return [ + FederationPeer( + fleet=r[0], agent=r[1], display_name=r[2], + first_seen=r[3], last_seen=r[4], seed_source=r[5], + ) + for r in rows + ] + + def get_peer(self, fleet: str, agent: str) -> FederationPeer | None: + """Look up one peer by composite key.""" + row = self._db.execute( + """SELECT fleet, agent, display_name, first_seen, last_seen, seed_source + FROM federation_peers + WHERE fleet = ? AND agent = ?""", + (fleet, agent), + ).fetchone() + if not row: + return None + return FederationPeer( + fleet=row[0], agent=row[1], display_name=row[2], + first_seen=row[3], last_seen=row[4], seed_source=row[5], + ) + + def remove_peer(self, fleet: str, agent: str) -> bool: + """Hard-delete a peer. Returns True if removed.""" + with self._lock: + cur = self._db.execute( + "DELETE FROM federation_peers WHERE fleet = ? AND agent = ?", + (fleet, agent), + ) + self._db.commit() + return (cur.rowcount or 0) > 0 + + # -- Lifecycle ------------------------------------------------------------ + + def close(self) -> None: + self._db.close() diff --git a/tests/test_mesh_store.py b/tests/test_mesh_store.py new file mode 100644 index 00000000..f90100fc --- /dev/null +++ b/tests/test_mesh_store.py @@ -0,0 +1,299 @@ +"""Tests for MeshStore — ferry envelope log + federation peer registry. + +Covers: +- schema migration / idempotent init +- log_message: inbound/outbound rows, side-effect peer touch, error semantics +- get_inbox: filters (kind, sender, direction), pagination, ordering +- get_message_by_correlation_id: most-recent on duplicate, missing returns None +- federation_peers: upsert (config wins), list filters, get/remove +- defensive: malformed body fields don't crash deserialization + +PinkyBot issue: #419. +""" + +from __future__ import annotations + +import time + +import pytest + +from pinky_daemon.mesh_store import MeshStore + + +@pytest.fixture +def store(tmp_path): + s = MeshStore(db_path=str(tmp_path / "mesh.db")) + yield s + s.close() + + +# -- Schema -------------------------------------------------------------------- + + +class TestSchema: + def test_init_idempotent(self, tmp_path): + path = str(tmp_path / "mesh.db") + s1 = MeshStore(db_path=path) + s1.close() + # Second open should not crash on existing tables. + s2 = MeshStore(db_path=path) + s2.close() + + +# -- log_message --------------------------------------------------------------- + + +class TestLogMessage: + def test_outbound_success_logs_and_touches_peer(self, store): + rid = store.log_message( + direction="outbound", + local_agent="barsik", + remote_fleet="pulse", + remote_agent="pulse", + correlation_id="cid-1", + kind="msg", + body={"text": "hi", "kind": "msg"}, + envelope_ts=1234567890, + ) + assert rid > 0 + peer = store.get_peer("pulse", "pulse") + assert peer is not None + assert peer.seed_source == "observed" + + def test_outbound_failure_does_not_touch_peer(self, store): + store.log_message( + direction="outbound", + local_agent="barsik", + remote_fleet="ghost", + remote_agent="ghost", + correlation_id="cid-fail", + kind="msg", + body={"text": "x", "kind": "msg"}, + error="nats: error: connection refused", + ) + # Failed sends shouldn't auto-register the unreachable peer. + assert store.get_peer("ghost", "ghost") is None + + def test_inbound_always_touches_peer(self, store): + store.log_message( + direction="inbound", + local_agent="barsik", + remote_fleet="pulse", + remote_agent="misha", + correlation_id="cid-in-1", + kind="message", + body={"text": "hello", "kind": "message"}, + error="rejected:acl_denied", # even rejected inbound counts as "we saw them" + ) + peer = store.get_peer("pulse", "misha") + assert peer is not None + assert peer.seed_source == "observed" + + def test_count_messages(self, store): + for i in range(5): + store.log_message( + direction="outbound", + local_agent="barsik", + remote_fleet="pulse", + remote_agent="pulse", + correlation_id=f"cid-{i}", + kind="msg", + body={"kind": "msg"}, + ) + for _ in range(3): + store.log_message( + direction="inbound", + local_agent="pushok", + remote_fleet="pulse", + remote_agent="misha", + kind="message", + body={"kind": "message"}, + ) + assert store.count_messages() == 8 + assert store.count_messages("barsik") == 5 + assert store.count_messages("pushok") == 3 + assert store.count_messages("nobody") == 0 + + +# -- get_inbox ----------------------------------------------------------------- + + +class TestGetInbox: + def _seed(self, store): + # Three messages spaced apart in time so ordering is stable. + for i, kind in enumerate(["msg", "smoke", "msg"]): + store.log_message( + direction="outbound" if i % 2 else "inbound", + local_agent="barsik", + remote_fleet="pulse", + remote_agent="pulse" if i < 2 else "misha", + correlation_id=f"cid-{i}", + kind=kind, + body={"kind": kind, "n": i}, + ) + time.sleep(0.005) + + def test_newest_first(self, store): + self._seed(store) + msgs = store.get_inbox("barsik") + assert len(msgs) == 3 + # received_at descends + assert msgs[0].received_at >= msgs[1].received_at >= msgs[2].received_at + + def test_kind_filter(self, store): + self._seed(store) + smokes = store.get_inbox("barsik", kind="smoke") + assert len(smokes) == 1 + assert smokes[0].kind == "smoke" + + def test_direction_filter(self, store): + self._seed(store) + out = store.get_inbox("barsik", direction="outbound") + assert all(m.direction == "outbound" for m in out) + + def test_sender_filter(self, store): + self._seed(store) + from_misha = store.get_inbox("barsik", sender="misha@pulse") + assert len(from_misha) == 1 + assert from_misha[0].remote_agent == "misha" + + def test_pagination(self, store): + self._seed(store) + page1 = store.get_inbox("barsik", limit=2, offset=0) + page2 = store.get_inbox("barsik", limit=2, offset=2) + assert len(page1) == 2 + assert len(page2) == 1 + # No overlap. + assert page1[1].id != page2[0].id + + def test_other_agent_isolated(self, store): + self._seed(store) + store.log_message( + direction="inbound", + local_agent="pushok", + remote_fleet="pulse", + remote_agent="pulse", + kind="msg", + body={"kind": "msg"}, + ) + assert len(store.get_inbox("barsik")) == 3 + assert len(store.get_inbox("pushok")) == 1 + + +# -- get_message_by_correlation_id -------------------------------------------- + + +class TestGetByCorrelationId: + def test_missing_returns_none(self, store): + assert store.get_message_by_correlation_id("nope") is None + assert store.get_message_by_correlation_id("") is None + + def test_returns_most_recent_on_duplicate(self, store): + store.log_message( + direction="outbound", local_agent="barsik", + remote_fleet="p", remote_agent="p", + correlation_id="cid-dup", kind="msg", body={"kind": "msg", "v": 1}, + ) + time.sleep(0.005) + store.log_message( + direction="inbound", local_agent="barsik", + remote_fleet="p", remote_agent="p", + correlation_id="cid-dup", kind="ack", body={"kind": "ack", "v": 2}, + ) + latest = store.get_message_by_correlation_id("cid-dup") + assert latest is not None + assert latest.kind == "ack" + assert latest.body == {"kind": "ack", "v": 2} + + +# -- federation_peers ---------------------------------------------------------- + + +class TestFederationPeers: + def test_upsert_inserts_then_updates(self, store): + store.upsert_peer(fleet="pulse", agent="pulse", display_name="Pulse") + p1 = store.get_peer("pulse", "pulse") + assert p1 is not None + assert p1.display_name == "Pulse" + assert p1.seed_source == "config" + + time.sleep(0.005) + store.upsert_peer(fleet="pulse", agent="pulse", display_name="Pulse v2") + p2 = store.get_peer("pulse", "pulse") + assert p2 is not None + assert p2.display_name == "Pulse v2" + # first_seen preserved across update + assert p2.first_seen == p1.first_seen + assert p2.last_seen >= p1.last_seen + + def test_config_beats_observed_on_promotion(self, store): + # Observed first. + store.log_message( + direction="inbound", local_agent="barsik", + remote_fleet="pulse", remote_agent="pulse", + kind="msg", body={"kind": "msg"}, + ) + assert store.get_peer("pulse", "pulse").seed_source == "observed" + # Then config-promoted. + store.upsert_peer(fleet="pulse", agent="pulse", display_name="Pulse") + assert store.get_peer("pulse", "pulse").seed_source == "config" + + def test_observed_does_not_downgrade_config(self, store): + store.upsert_peer(fleet="pulse", agent="pulse", display_name="Pulse") + # Subsequent observed traffic shouldn't downgrade seed_source. + store.log_message( + direction="inbound", local_agent="barsik", + remote_fleet="pulse", remote_agent="pulse", + kind="msg", body={"kind": "msg"}, + ) + assert store.get_peer("pulse", "pulse").seed_source == "config" + + def test_list_peers_filters(self, store): + store.upsert_peer(fleet="pulse", agent="pulse", seed_source="config") + store.upsert_peer(fleet="pulse", agent="misha", seed_source="config") + store.upsert_peer(fleet="sigil", agent="agent3", seed_source="observed") + + all_peers = store.list_peers() + assert len(all_peers) == 3 + + pulse_only = store.list_peers(fleet="pulse") + assert len(pulse_only) == 2 + assert {p.agent for p in pulse_only} == {"pulse", "misha"} + + config_only = store.list_peers(seed_source="config") + assert len(config_only) == 2 + + observed_only = store.list_peers(seed_source="observed") + assert len(observed_only) == 1 + assert observed_only[0].fleet == "sigil" + + def test_remove_peer(self, store): + store.upsert_peer(fleet="pulse", agent="pulse") + assert store.remove_peer("pulse", "pulse") is True + assert store.remove_peer("pulse", "pulse") is False # already gone + assert store.get_peer("pulse", "pulse") is None + + def test_address_field_in_to_dict(self, store): + store.upsert_peer(fleet="pulse", agent="misha") + peer = store.get_peer("pulse", "misha") + assert peer.to_dict()["address"] == "misha@pulse" + + +# -- Defensive deserialization ------------------------------------------------- + + +class TestDefensive: + def test_malformed_body_json_does_not_crash(self, store): + # Bypass the public API to write a row with broken JSON. + store._db.execute( + """INSERT INTO mesh_messages + (correlation_id, direction, local_agent, remote_fleet, + remote_agent, kind, body, envelope_ts, received_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ("cid-x", "inbound", "barsik", "p", "p", "msg", + "{not valid json", 0, time.time()), + ) + store._db.commit() + msgs = store.get_inbox("barsik") + assert len(msgs) == 1 + assert "_raw" in msgs[0].body # fell back gracefully From 79adb4e213144e1e75633ef1ad5e429d92ad52a2 Mon Sep 17 00:00:00 2001 From: olegbrok Date: Mon, 11 May 2026 09:24:38 -0700 Subject: [PATCH 20/24] feat(hooks): effort.level verification hook (#429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a PreToolUse CLI hook that compares the runtime \$CLAUDE_EFFORT (Claude Code v2.1.133+) against PINKY_EXPECTED_EFFORT (agent's configured thinking_effort, injected by the daemon at session start). On drift the hook fire-and-forget POSTs to /agents/{name}/effort-drift; in strict mode it also emits a decision: block so Claude Code refuses the tool call — except for the remediation tool (set_thinking_effort and MCP variants), which is allowlisted so the agent can self-correct. What's in: - agent_registry: strict_effort_enforcement column (default False); effort_drift_events table + record_effort_drift / get_effort_drift_events; hook_verify_effort.py source as a module-level function with strict-mode remediation allowlist; _setup_hooks rewrites the verify_effort script when on-disk content drifts from the daemon's current source so fixes reach existing agents on next session start; idempotent settings.json merge (preserves user hooks); ensure_workspace_hooks() public retrofit method. - sdk_runner + streaming_session: surface PINKY_AGENT_NAME, PINKY_EXPECTED_EFFORT, PINKY_STRICT_EFFORT through options.env. - api.py: POST /agents/{name}/effort-drift records the event + heartbeat note + activity entry; GET lists recent events. Streaming-start path calls ensure_workspace_hooks() for retrofit. RegisterAgentRequest + UpdateAgentRequest carry strict_effort_enforcement so per-agent opt-in is reachable from the public API. - pinky_self: set_thinking_effort MCP tool widened to accept xhigh (mirrors registry validator). Defaults: warn-only fleet-wide, strict opt-in per agent, no Telegram nag in v1. Murzik review (3 rounds, all real catches): (1) strict mode blocked the remediation tool — fixed by parsing tool_name from stdin BEFORE deciding to block, with substring allowlist; (2) strict_effort_enforcement wasn't plumbed through the public API request models — fixed; (3) xhigh remediation suggestion was unreachable because SET_EFFORT_ACCEPTED excluded xhigh — fixed, with honest "no self-remediation path" fallback for genuinely-unreachable levels. Tests: 42 in test_effort_verification.py, all pass. Pre-existing 4 failures around mesh_remote_send unexpectedly in core exist on beta independently of this change. Unlocks on the pending #416 daemon restart, which is where Claude Code v2.1.138 (\$CLAUDE_EFFORT) actually lives. Closes #429. --- src/pinky_daemon/agent_registry.py | 454 +++++++++++++++-- src/pinky_daemon/api.py | 88 ++++ src/pinky_daemon/sdk_runner.py | 14 + src/pinky_daemon/streaming_session.py | 17 +- src/pinky_self/server.py | 9 +- tests/test_effort_verification.py | 692 ++++++++++++++++++++++++++ 6 files changed, 1240 insertions(+), 34 deletions(-) create mode 100644 tests/test_effort_verification.py diff --git a/src/pinky_daemon/agent_registry.py b/src/pinky_daemon/agent_registry.py index e8e2fd3d..3d1514b2 100644 --- a/src/pinky_daemon/agent_registry.py +++ b/src/pinky_daemon/agent_registry.py @@ -35,6 +35,159 @@ def _log(msg: str) -> None: print(msg, file=sys.stderr, flush=True) +def _verify_effort_hook_source() -> str: + """Return the source for ``.claude/hook_verify_effort.py``. + + The hook compares the runtime ``$CLAUDE_EFFORT`` (Claude Code v2.1.133+) + against ``$PINKY_EXPECTED_EFFORT`` (injected by the daemon at session + start). On drift, it POSTs to ``/agents/{name}/effort-drift`` and, in + strict mode (``$PINKY_STRICT_EFFORT=1``), emits a block decision so + Claude Code refuses the tool call. + + No-ops silently when expected is empty/auto, when actual is unset (older + CLI), or when ``$PINKY_AGENT_NAME`` is missing. + """ + return '''\ +#!/usr/bin/env python3 +"""PinkyBot effort-drift verification hook (#429). + +Compares the runtime thinking effort surfaced by Claude Code (v2.1.133+) to +the expected value injected by the daemon. On mismatch, reports drift and +optionally blocks the tool call. + +Hooks must never crash the tool call — failures are swallowed. +""" +from __future__ import annotations + +import json +import os +import sys + +# Tools the hook MUST NOT block even in strict mode — these are how the +# agent self-remediates drift. Blocking set_thinking_effort would trap +# the agent: the only fix becomes unavailable. Match is substring against +# the tool name, so it covers raw `set_thinking_effort` and any MCP-qualified +# variant (e.g. `mcp__pinky-self__set_thinking_effort`). +REMEDIATION_TOOLS = ( + "set_thinking_effort", +) + +# Levels set_thinking_effort MCP tool accepts. Kept in sync with the tool's +# validator (pinky_self/server.py). If a future registry adds a level +# outside this set, suggesting `set_thinking_effort(expected)` would fail +# at the MCP layer — surface a clear "not self-remediable" reason instead +# of an unreachable suggestion. +SET_EFFORT_ACCEPTED = ("low", "medium", "high", "xhigh", "max", "auto") + + +def _post_drift(agent: str, expected: str, actual: str, tool_name: str, + strict: bool, daemon_url: str) -> None: + import urllib.request + + path = f"/agents/{agent}/effort-drift" + body = json.dumps({ + "expected": expected, + "actual": actual, + "tool_name": tool_name, + "strict": bool(strict), + }).encode() + req = urllib.request.Request( + f"{daemon_url}{path}", data=body, method="POST", + ) + req.add_header("Content-Type", "application/json") + req.add_header("x-pinky-agent", agent) + try: + urllib.request.urlopen(req, timeout=2) + except Exception: + pass + + +def _is_remediation_tool(tool_name: str) -> bool: + """True if tool_name is on the strict-mode allowlist.""" + if not tool_name: + return False + needle = tool_name.lower() + return any(t in needle for t in REMEDIATION_TOOLS) + + +def _remediation_suggestion(expected: str) -> str: + """Return a remediation call the agent can actually make. + + When expected is in the MCP tool's accepted set (the common case), + suggest the direct call. Otherwise be honest: tell the agent the + expected level isn't reachable from inside the session, so it knows + to escalate to the owner rather than spinning on a suggestion that + won't resolve the drift. + """ + if expected in SET_EFFORT_ACCEPTED: + return f"set_thinking_effort({expected!r})" + return ( + f"" + ) + + +def main() -> None: + actual = os.environ.get("CLAUDE_EFFORT", "").strip().lower() + expected = os.environ.get("PINKY_EXPECTED_EFFORT", "").strip().lower() + agent = os.environ.get("PINKY_AGENT_NAME", "").strip() + strict = os.environ.get("PINKY_STRICT_EFFORT", "").strip() == "1" + daemon_url = os.environ.get( + "PINKY_DAEMON_URL", "http://localhost:8888" + ).rstrip("/") + + # No-op cases — these are not drift events: + # - no expected configured + # - expected is "auto" (effort is intentionally adaptive) + # - actual is unset (older Claude Code without v2.1.133 effort.level) + # - no agent name (hook misconfigured) + if not expected or expected == "auto" or not actual or not agent: + return + + if actual == expected: + return # match — no action + + # Try to read the tool name from the JSON event piped to stdin, if any. + tool_name = "" + try: + stdin_data = sys.stdin.read() if not sys.stdin.isatty() else "" + if stdin_data: + payload = json.loads(stdin_data) + tool_name = ( + payload.get("tool_name") + or (payload.get("tool") or {}).get("name") + or "" + ) + except Exception: + pass + + # Always record the drift — even when we're about to let it through. + _post_drift(agent, expected, actual, tool_name, strict, daemon_url) + + # Strict path: emit a block decision EXCEPT when the tool being called + # is the very thing that can fix the drift. Blocking the remediation + # tool would trap the agent in an unbreakable strict-mode loop. + if strict and not _is_remediation_tool(tool_name): + print(json.dumps({ + "decision": "block", + "reason": ( + f"Effort drift detected: expected={expected} actual={actual}. " + f"Call {_remediation_suggestion(expected)} and retry the tool, " + "or contact your owner to relax strict_effort_enforcement." + ), + })) + + +if __name__ == "__main__": + try: + main() + except Exception: + # Hooks must never crash the tool call. + pass +''' + + def _cron_next_run(cron: str, timezone: str = "UTC") -> float | None: """Compute the next run timestamp for a cron expression using stdlib only. @@ -192,6 +345,10 @@ class Agent: provider_model: str = "" # model name override (e.g. "llama3.2"), empty = use agent.model provider_ref: str = "" # ID of a global provider from the providers table thinking_effort: str = "medium" # low, medium, high, xhigh, max — default thinking depth + # When True, the verify_effort CLI hook blocks tool calls if the runtime + # effort drifts from thinking_effort. Default False (warn-only): drift is + # surfaced to /agents/{name}/effort-drift + heartbeat but does not block. + strict_effort_enforcement: bool = False watchdog_config: dict = field(default_factory=dict) # Per-agent watchdog overrides (JSON blob) # watchdog_config schema: { # "enabled": true, # Enable/disable watchdog for this agent @@ -251,6 +408,7 @@ def to_dict(self) -> dict: "provider_model": self.provider_model, "provider_ref": self.provider_ref, "thinking_effort": self.thinking_effort, + "strict_effort_enforcement": self.strict_effort_enforcement, "watchdog_config": self.watchdog_config, "created_at": self.created_at, "updated_at": self.updated_at, @@ -681,6 +839,20 @@ def _init_tables(self) -> None: updated_at REAL NOT NULL DEFAULT 0 ); + CREATE TABLE IF NOT EXISTS effort_drift_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_name TEXT NOT NULL, + session_id TEXT NOT NULL DEFAULT '', + expected TEXT NOT NULL, + actual TEXT NOT NULL, + tool_name TEXT NOT NULL DEFAULT '', + strict INTEGER NOT NULL DEFAULT 0, + timestamp REAL NOT NULL, + FOREIGN KEY (agent_name) REFERENCES agents(name) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_effort_drift_agent ON effort_drift_events(agent_name, timestamp DESC); + CREATE TABLE IF NOT EXISTS models ( id TEXT PRIMARY KEY, provider TEXT NOT NULL DEFAULT 'anthropic', @@ -738,6 +910,9 @@ def _migrate(self) -> None: ("provider_ref", "TEXT NOT NULL DEFAULT ''"), ("disallowed_tools", "TEXT NOT NULL DEFAULT '[]'"), ("thinking_effort", "TEXT NOT NULL DEFAULT 'medium'"), + # When 1, verify_effort CLI hook blocks tool calls on effort drift + # (vs. warn-only default of 0). See #429. + ("strict_effort_enforcement", "INTEGER NOT NULL DEFAULT 0"), ("watchdog_config", "TEXT NOT NULL DEFAULT '{}'"), ("last_seen_at", "REAL NOT NULL DEFAULT 0"), ("runtime", "TEXT NOT NULL DEFAULT 'claude_sdk'"), @@ -846,6 +1021,24 @@ def _backfill_runtime_from_provider_url(self) -> None: # ── Workspace Init ───────────────────────────────────── + def ensure_workspace_hooks(self, agent_name: str) -> None: + """Re-run hook setup for an existing agent's workspace. + + Idempotent. Use to install new hooks (e.g. ``hook_verify_effort.py`` + from #429) on agents whose workspace pre-dates them, without nuking + any user customizations to existing scripts. + """ + agent = self.get(agent_name) + if not agent or not agent.working_dir: + return + work_dir = Path(agent.working_dir) + if not work_dir.exists(): + return + try: + self._setup_hooks(work_dir, agent_name) + except Exception as e: # pragma: no cover — defensive + _log(f"agent_registry: ensure_workspace_hooks({agent_name}) failed: {e}") + @staticmethod def _init_workspace(work_dir: Path, agent_name: str = "") -> None: """Create an agent workspace with default directory structure. @@ -872,10 +1065,13 @@ def _init_workspace(work_dir: Path, agent_name: str = "") -> None: @staticmethod def _setup_hooks(work_dir: Path, agent_name: str) -> None: - """Generate Claude Code hooks for agent working/idle status reporting. + """Generate Claude Code hooks for agent working/idle status reporting + and (since #429) effort-drift verification. - Creates .claude/ directory with hook_working.py, hook_idle.py, and - settings.json if they don't already exist. Won't overwrite custom configs. + Creates ``.claude/`` directory with hook scripts and settings.json. + Existing scripts are not overwritten; settings.json is idempotently + merged so the verify_effort hook can be added to agents whose + settings predate #429 without nuking their existing hooks. """ claude_dir = work_dir / ".claude" claude_dir.mkdir(exist_ok=True) @@ -911,6 +1107,7 @@ def _setup_hooks(work_dir: Path, agent_name: str) -> None: ''' working_path = claude_dir / "hook_working.py" idle_path = claude_dir / "hook_idle.py" + verify_effort_path = claude_dir / "hook_verify_effort.py" if not working_path.exists(): working_path.write_text( @@ -924,35 +1121,130 @@ def _setup_hooks(work_dir: Path, agent_name: str) -> None: ) _log(f"agent_registry: created hook_idle.py for {agent_name}") - settings_path = claude_dir / "settings.json" + # #429: verify_effort hook — compares $CLAUDE_EFFORT (v2.1.133+) to + # PINKY_EXPECTED_EFFORT (set by daemon at session start). On drift, + # posts to /agents/{name}/effort-drift; under strict mode also emits + # a block decision so Claude Code refuses the tool call. + # + # We ALWAYS rewrite this script (unlike hook_working / hook_idle + # which are left alone if present) — it's fully PinkyBot-managed + # and getting the latest semantics on disk matters: e.g. the + # remediation-tool allowlist fix shipped after the initial cut. + existing_source = ( + verify_effort_path.read_text() if verify_effort_path.exists() else "" + ) + new_source = _verify_effort_hook_source() + if existing_source != new_source: + verify_effort_path.write_text(new_source) + verb = "updated" if existing_source else "created" + _log(f"agent_registry: {verb} hook_verify_effort.py for {agent_name}") + + AgentRegistry._sync_hooks_settings( + claude_dir / "settings.json", + working_path=working_path.resolve(), + idle_path=idle_path.resolve(), + verify_effort_path=verify_effort_path.resolve(), + agent_name=agent_name, + ) + + @staticmethod + def _sync_hooks_settings( + settings_path: Path, + *, + working_path: Path, + idle_path: Path, + verify_effort_path: Path, + agent_name: str, + ) -> None: + """Idempotently ensure settings.json has all PinkyBot hooks wired up. + + - Creates settings.json with the full hook set if missing. + - If present, adds any missing PinkyBot-managed hook entries to + PreToolUse / Stop, preserving user-added entries. + - Identifies PinkyBot-managed entries by the absolute script path + appearing in the command string. + """ + import json as _json + + verify_cmd = ( + f"python3 {verify_effort_path}" + f' "$CLAUDE_PROJECT_DIR" 2>/dev/null || true' + ) + working_cmd = f"python3 {working_path} 2>/dev/null || true" + idle_cmd = f"python3 {idle_path} 2>/dev/null || true" + if not settings_path.exists(): - abs_working = str(working_path.resolve()) - abs_idle = str(idle_path.resolve()) settings = { "hooks": { - "PreToolUse": [{ - "matcher": ".*", - "hooks": [{ - "type": "command", - "command": ( - f"python3 {abs_working} 2>/dev/null || true" - ), - }], - }], - "Stop": [{ - "matcher": ".*", - "hooks": [{ - "type": "command", - "command": ( - f"python3 {abs_idle} 2>/dev/null || true" - ), - }], - }], + "PreToolUse": [ + { + "matcher": ".*", + "hooks": [ + {"type": "command", "command": working_cmd}, + {"type": "command", "command": verify_cmd}, + ], + } + ], + "Stop": [ + { + "matcher": ".*", + "hooks": [ + {"type": "command", "command": idle_cmd}, + ], + } + ], } } - import json as _json settings_path.write_text(_json.dumps(settings, indent=2) + "\n") _log(f"agent_registry: created settings.json for {agent_name}") + return + + # Merge path — only add the verify_effort hook if it's not already + # present. Match on the absolute script path so we don't dup if the + # user re-pathed it manually. + try: + data = _json.loads(settings_path.read_text()) + except Exception as e: + _log( + f"agent_registry: settings.json parse failed for {agent_name}: {e}; " + "skipping verify_effort merge" + ) + return + + hooks = data.setdefault("hooks", {}) + pre_tool_use = hooks.setdefault("PreToolUse", []) + + needle = str(verify_effort_path) + already_installed = False + for entry in pre_tool_use: + for h in entry.get("hooks", []): + if needle in (h.get("command") or ""): + already_installed = True + break + if already_installed: + break + + if already_installed: + return + + # Append to the first matcher=".*" bucket if one exists, else create. + target_bucket = None + for entry in pre_tool_use: + if entry.get("matcher") == ".*": + target_bucket = entry + break + if target_bucket is None: + target_bucket = {"matcher": ".*", "hooks": []} + pre_tool_use.append(target_bucket) + + target_bucket.setdefault("hooks", []).append( + {"type": "command", "command": verify_cmd} + ) + settings_path.write_text(_json.dumps(data, indent=2) + "\n") + _log( + f"agent_registry: merged hook_verify_effort into settings.json " + f"for {agent_name}" + ) # ── Agent CRUD ────────────────────────────────────────── @@ -973,7 +1265,7 @@ def register(self, name: str, **kwargs) -> Agent: "dream_enabled", "dream_schedule", "dream_timezone", "dream_model", "dream_notify", "librarian_enabled", "librarian_schedule", "runtime", "provider_url", "provider_key", "provider_model", "provider_ref", - "thinking_effort"): + "thinking_effort", "strict_effort_enforcement"): if key in kwargs: updates[key] = kwargs[key] @@ -1003,6 +1295,8 @@ def register(self, name: str, **kwargs) -> Agent: updates["dream_notify"] = int(updates["dream_notify"]) if "librarian_enabled" in updates: updates["librarian_enabled"] = int(updates["librarian_enabled"]) + if "strict_effort_enforcement" in updates: + updates["strict_effort_enforcement"] = int(updates["strict_effort_enforcement"]) if updates: updates["updated_at"] = now @@ -1060,6 +1354,7 @@ def register(self, name: str, **kwargs) -> Agent: provider_model=kwargs.get("provider_model", ""), provider_ref=kwargs.get("provider_ref", ""), thinking_effort=kwargs.get("thinking_effort", "medium"), + strict_effort_enforcement=kwargs.get("strict_effort_enforcement", False), watchdog_config=kwargs.get("watchdog_config", {}), created_at=now, updated_at=now, @@ -1075,9 +1370,9 @@ def register(self, name: str, **kwargs) -> Agent: dream_enabled, dream_schedule, dream_timezone, dream_model, dream_notify, librarian_enabled, librarian_schedule, runtime, provider_url, provider_key, provider_model, provider_ref, - thinking_effort, watchdog_config, + thinking_effort, strict_effort_enforcement, watchdog_config, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (agent.name, agent.display_name, agent.model, agent.soul, agent.users, agent.boundaries, agent.system_prompt, agent.working_dir, agent.permission_mode, @@ -1092,7 +1387,8 @@ def register(self, name: str, **kwargs) -> Agent: int(agent.librarian_enabled), agent.librarian_schedule, agent.runtime, agent.provider_url, agent.provider_key, agent.provider_model, agent.provider_ref, - agent.thinking_effort, json.dumps(agent.watchdog_config), + agent.thinking_effort, int(agent.strict_effort_enforcement), + json.dumps(agent.watchdog_config), agent.created_at, agent.updated_at), ) self._db.commit() @@ -1111,7 +1407,8 @@ def register(self, name: str, **kwargs) -> Agent: "librarian_enabled, librarian_schedule, " "working_status, working_status_updated_at, " "runtime, provider_url, provider_key, provider_model, provider_ref, " - "disallowed_tools, thinking_effort, watchdog_config, last_seen_at" + "disallowed_tools, thinking_effort, watchdog_config, last_seen_at, " + "strict_effort_enforcement" ) def get(self, name: str) -> Agent | None: @@ -1662,6 +1959,102 @@ def record_heartbeat( notes=notes, latency_ms=latency_ms, ) + # ── Effort Drift Events (#429) ─────────────────────── + + def record_effort_drift( + self, + agent_name: str, + *, + expected: str, + actual: str, + session_id: str = "", + tool_name: str = "", + strict: bool = False, + ) -> int: + """Record a thinking-effort drift event from the verify_effort CLI hook. + + Emitted when ``$CLAUDE_EFFORT`` (runtime) diverges from the agent's + configured ``thinking_effort``. See #429 / verify_effort hook. + + Also writes a structured note to the heartbeat stream so drift is + visible alongside normal liveness telemetry without a separate + query. + + Returns the inserted event row id. + """ + now = time.time() + cursor = self._db.execute( + """INSERT INTO effort_drift_events + (agent_name, session_id, expected, actual, tool_name, strict, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (agent_name, session_id, expected, actual, tool_name, + int(bool(strict)), now), + ) + self._db.commit() + + # Mirror to heartbeat notes so it shows up in normal observability. + # Don't let heartbeat failure break the drift recording. + try: + label = "blocked" if strict else "warn" + note = ( + f"[effort drift / {label}] expected={expected} actual={actual}" + ) + if tool_name: + note += f" tool={tool_name}" + self.record_heartbeat( + agent_name, + session_id=session_id, + status="alive", + notes=note, + ) + except Exception as e: # pragma: no cover — defensive + _log(f"agent_registry: effort-drift heartbeat note failed: {e}") + + return int(cursor.lastrowid or 0) + + def get_effort_drift_events( + self, + agent_name: str = "", + *, + limit: int = 50, + since: float = 0.0, + ) -> list[dict]: + """Query recent effort-drift events. + + Pass ``agent_name=""`` to get fleet-wide events. ``since`` is a unix + timestamp; only events after it are returned. + """ + conditions: list[str] = [] + params: list = [] + if agent_name: + conditions.append("agent_name=?") + params.append(agent_name) + if since: + conditions.append("timestamp>?") + params.append(since) + where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + params.append(limit) + rows = self._db.execute( + f"""SELECT id, agent_name, session_id, expected, actual, + tool_name, strict, timestamp + FROM effort_drift_events {where} + ORDER BY timestamp DESC LIMIT ?""", + params, + ).fetchall() + return [ + { + "id": r[0], + "agent_name": r[1], + "session_id": r[2], + "expected": r[3], + "actual": r[4], + "tool_name": r[5], + "strict": bool(r[6]), + "timestamp": r[7], + } + for r in rows + ] + def get_latest_heartbeat(self, agent_name: str) -> AgentHeartbeat | None: """Get the most recent heartbeat for an agent.""" row = self._db.execute( @@ -2643,6 +3036,7 @@ def _row_to_agent(self, row: tuple) -> Agent: thinking_effort=row[45] if len(row) > 45 and row[45] else "medium", watchdog_config=json.loads(row[46]) if len(row) > 46 and row[46] else {}, last_seen_at=row[47] if len(row) > 47 else 0.0, + strict_effort_enforcement=bool(row[48]) if len(row) > 48 else False, ) # ── Cost Tracking ────────────────────────────────────── diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index 1d53ed9a..af37abfc 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -454,6 +454,9 @@ class RegisterAgentRequest(BaseModel): provider_model: str = "" # Model name override for this provider provider_ref: str = "" # ID of a global provider from the providers table thinking_effort: str = "medium" # low/medium/high/xhigh/max + # When True, the verify_effort CLI hook blocks tool calls if the runtime + # effort drifts from thinking_effort. Default False (warn-only). See #429. + strict_effort_enforcement: bool = False watchdog_config: dict | None = None # Per-agent watchdog overrides @@ -492,6 +495,8 @@ class UpdateAgentRequest(BaseModel): provider_model: str | None = None # Model name override for this provider provider_ref: str | None = None # ID of a global provider from the providers table thinking_effort: str | None = None # low/medium/high/xhigh/max + # When True, verify_effort CLI hook blocks tool calls on effort drift. See #429. + strict_effort_enforcement: bool | None = None watchdog_config: dict | None = None # Per-agent watchdog overrides @@ -864,6 +869,20 @@ class AgentStatusRequest(BaseModel): detail: str = "" +class EffortDriftRequest(BaseModel): + """Effort-drift event — POSTed by hook_verify_effort.py (#429). + + Emitted when the runtime ``$CLAUDE_EFFORT`` (Claude Code v2.1.133+) + diverges from the daemon-injected ``PINKY_EXPECTED_EFFORT``. + """ + + expected: str + actual: str + tool_name: str = "" + strict: bool = False + session_id: str = "" + + # ── Research Pipeline Models ────────────────────────────────── @@ -2661,6 +2680,14 @@ def runtime_from_legacy_provider(agent_config) -> str: "headers": agent_headers, } + # #429: ensure verify_effort hook is installed in the agent's + # .claude/settings.json before we spin up the session. No-op for + # agents whose workspace already has it. + try: + agents.ensure_workspace_hooks(agent_name) + except Exception as e: + _log(f"streaming-start: ensure_workspace_hooks({agent_name}) — {e}") + config = StreamingSessionConfig( agent_name=agent_name, label=label, @@ -2683,6 +2710,9 @@ def runtime_from_legacy_provider(agent_config) -> str: provider_url=resolved_provider_url, provider_key=resolved_provider_key, thinking_effort=agent.thinking_effort or "medium", + strict_effort_enforcement=bool( + getattr(agent, "strict_effort_enforcement", False) + ), ) callback = await _make_streaming_response_callback() @@ -5457,6 +5487,7 @@ async def register_agent(req: RegisterAgentRequest): provider_model=req.provider_model, provider_ref=req.provider_ref, thinking_effort=req.thinking_effort, + strict_effort_enforcement=req.strict_effort_enforcement, watchdog_config=req.watchdog_config or {}, ) # Write .mcp.json so the agent gets default MCP servers (memory, self, messaging) @@ -5589,6 +5620,63 @@ async def set_agent_working_status(name: str, req: AgentStatusRequest): "ok": True, } + @app.post("/agents/{name}/effort-drift") + async def report_effort_drift(name: str, req: EffortDriftRequest): + """Record a thinking-effort drift event from hook_verify_effort.py (#429). + + Called when ``$CLAUDE_EFFORT`` (runtime) diverges from + ``$PINKY_EXPECTED_EFFORT`` (agent's configured ``thinking_effort``). + The hook is fire-and-forget — we return quickly and never fail the + tool call. + """ + agent = agents.get(name) + if not agent: + raise HTTPException(404, f"Agent '{name}' not found") + event_id = agents.record_effort_drift( + name, + expected=req.expected, + actual=req.actual, + session_id=req.session_id, + tool_name=req.tool_name, + strict=req.strict, + ) + activity.log( + agent_name=name, + event_type="effort_drift", + title=( + f"effort drift — expected={req.expected} actual={req.actual}" + + (" (blocked)" if req.strict else "") + ), + metadata={ + "agent": name, + "expected": req.expected, + "actual": req.actual, + "tool_name": req.tool_name, + "strict": req.strict, + }, + ) + return { + "ok": True, + "agent": name, + "event_id": event_id, + "expected": req.expected, + "actual": req.actual, + "strict": req.strict, + } + + @app.get("/agents/{name}/effort-drift") + async def list_effort_drift_events( + name: str, limit: int = 50, since: float = 0.0, + ): + """List recent effort-drift events for an agent (#429).""" + agent = agents.get(name) + if not agent: + raise HTTPException(404, f"Agent '{name}' not found") + events = agents.get_effort_drift_events( + agent_name=name, limit=limit, since=since, + ) + return {"agent": name, "events": events, "count": len(events)} + @app.get("/agents/{name}/status") async def get_agent_working_status(name: str): """Get an agent's current working status. diff --git a/src/pinky_daemon/sdk_runner.py b/src/pinky_daemon/sdk_runner.py index 0abc8218..18bb18f6 100644 --- a/src/pinky_daemon/sdk_runner.py +++ b/src/pinky_daemon/sdk_runner.py @@ -62,6 +62,10 @@ class SDKRunnerConfig: # Thinking effort: low, medium, high, max thinking_effort: str = "medium" + # When True, the verify_effort CLI hook blocks tool calls if the runtime + # effort drifts from thinking_effort. Default False (warn-only). See #429. + strict_effort_enforcement: bool = False + # Provider overrides — set these to use Ollama or other compatible endpoints provider_url: str = "" # ANTHROPIC_BASE_URL override provider_key: str = "" # ANTHROPIC_API_KEY override @@ -158,6 +162,16 @@ async def run( env_overrides["ANTHROPIC_BASE_URL"] = self._config.provider_url if self._config.provider_key: env_overrides["ANTHROPIC_API_KEY"] = self._config.provider_key + # #429: surface configured effort + agent identity to CLI hooks so + # verify_effort.py can detect drift from PINKY_EXPECTED_EFFORT vs + # $CLAUDE_EFFORT at PreToolUse time. The hook no-ops on "auto" / + # empty (intentionally adaptive). + if self._agent_name: + env_overrides["PINKY_AGENT_NAME"] = self._agent_name + if effort: + env_overrides["PINKY_EXPECTED_EFFORT"] = effort + if self._config.strict_effort_enforcement: + env_overrides["PINKY_STRICT_EFFORT"] = "1" options.env = env_overrides # Session management diff --git a/src/pinky_daemon/streaming_session.py b/src/pinky_daemon/streaming_session.py index e0d238ba..c4c5d8ef 100644 --- a/src/pinky_daemon/streaming_session.py +++ b/src/pinky_daemon/streaming_session.py @@ -66,6 +66,9 @@ class StreamingSessionConfig: provider_url: str = "" # ANTHROPIC_BASE_URL override (e.g. "http://localhost:11434" for Ollama) provider_key: str = "" # ANTHROPIC_API_KEY override (empty = use env var) thinking_effort: str = "medium" # low, medium, high, xhigh, max — default thinking depth + # When True, the verify_effort CLI hook blocks tool calls if the runtime + # effort drifts from thinking_effort. Default False (warn-only). See #429. + strict_effort_enforcement: bool = False restart_reason: str = "" # "context_restart", "auto_restart", etc. — cleared after wake prompt @@ -293,12 +296,24 @@ async def connect(self) -> None: options.effort = effort # Build provider env overrides (Ollama / custom compatible endpoints) - provider_env = {} + provider_env: dict[str, str] = {} if self._config.provider_url: provider_env["ANTHROPIC_BASE_URL"] = self._config.provider_url if self._config.provider_key: provider_env["ANTHROPIC_API_KEY"] = self._config.provider_key provider_env["ANTHROPIC_AUTH_TOKEN"] = self._config.provider_key + + # #429: surface configured effort + agent identity to CLI hooks so + # hook_verify_effort.py can detect drift from PINKY_EXPECTED_EFFORT + # vs $CLAUDE_EFFORT at PreToolUse time. The hook no-ops on "auto" / + # empty (intentionally adaptive). + if self.agent_name: + provider_env["PINKY_AGENT_NAME"] = self.agent_name + if effort: + provider_env["PINKY_EXPECTED_EFFORT"] = effort + if self._config.strict_effort_enforcement: + provider_env["PINKY_STRICT_EFFORT"] = "1" + if provider_env: # Generous timeout for slow local/third-party models (30 min) provider_env.setdefault("API_TIMEOUT_MS", "1800000") diff --git a/src/pinky_self/server.py b/src/pinky_self/server.py index 809e07da..f8f23fc7 100644 --- a/src/pinky_self/server.py +++ b/src/pinky_self/server.py @@ -1143,10 +1143,13 @@ def send_heartbeat(status: str = "ok", context_pct: float = 0.0, notes: str = "" @mcp.tool() def set_thinking_effort(level: str) -> str: """Set thinking effort for this session. Match depth to task complexity. - level: low | medium | high | max | auto (clears override). + level: low | medium | high | xhigh | max | auto (clears override). """ - if level not in ("low", "medium", "high", "max", "auto"): - return f"Invalid level: {level}. Use low, medium, high, max, or auto." + if level not in ("low", "medium", "high", "xhigh", "max", "auto"): + return ( + f"Invalid level: {level}. " + "Use low, medium, high, xhigh, max, or auto." + ) result = _api("POST", f"/agents/{agent_name}/sessions/main/effort", { "effort": level, }) diff --git a/tests/test_effort_verification.py b/tests/test_effort_verification.py new file mode 100644 index 00000000..090e6042 --- /dev/null +++ b/tests/test_effort_verification.py @@ -0,0 +1,692 @@ +"""Tests for the effort-drift verification hook (#429). + +Covers: +- ``Agent.strict_effort_enforcement`` column round-trip +- ``AgentRegistry.record_effort_drift`` + ``get_effort_drift_events`` +- ``ensure_workspace_hooks`` idempotently installs the verify_effort hook +- ``_setup_hooks`` writes valid settings.json with both working + verify hooks +- ``_setup_hooks`` merges verify_effort into pre-existing settings.json + without clobbering user-added hooks +- Subprocess-level behavior of ``hook_verify_effort.py``: + - match → silent no-op + - mismatch + warn mode → POSTs to ``/agents/{name}/effort-drift`` + - mismatch + strict mode → emits a block decision on stdout + - no expected / actual / agent → silent no-op + - ``expected=auto`` → silent no-op (intentionally adaptive) +- ``SDKRunnerConfig`` + ``StreamingSessionConfig`` carry the strict flag +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +import pytest + +from pinky_daemon.agent_registry import ( + AgentRegistry, + _verify_effort_hook_source, +) + + +@pytest.fixture +def registry(): + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + r = AgentRegistry(db_path=path) + yield r + r.close() + os.unlink(path) + + +@pytest.fixture +def hook_script(tmp_path): + """Write the hook source to a temp file we can invoke as a subprocess.""" + p = tmp_path / "hook_verify_effort.py" + p.write_text(_verify_effort_hook_source()) + return p + + +class _CaptureHandler(BaseHTTPRequestHandler): + """Tiny test HTTP server that records POST bodies for assertion.""" + + captured: list = [] + + def do_POST(self): # noqa: N802 — http.server interface + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length).decode() if length else "" + try: + data = json.loads(body) + except Exception: + data = {"raw": body} + type(self).captured.append({ + "path": self.path, + "body": data, + "headers": dict(self.headers), + }) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"ok": true}') + + def log_message(self, *_args, **_kwargs): # silence test noise + pass + + +@pytest.fixture +def fake_daemon(): + """Spin up an in-process HTTP server that captures hook POSTs.""" + + class _Handler(_CaptureHandler): + captured: list = [] + + server = HTTPServer(("127.0.0.1", 0), _Handler) + port = server.server_port + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield port, _Handler.captured + server.shutdown() + server.server_close() + + +def _run_hook( + hook_path: Path, + *, + expected: str = "", + actual: str = "", + agent: str = "", + strict: bool = False, + daemon_url: str = "", + stdin: str = "", +) -> subprocess.CompletedProcess: + env = {**os.environ} + # Wipe any inherited CLAUDE_EFFORT so test env is hermetic + env.pop("CLAUDE_EFFORT", None) + env.pop("PINKY_EXPECTED_EFFORT", None) + env.pop("PINKY_AGENT_NAME", None) + env.pop("PINKY_STRICT_EFFORT", None) + env.pop("PINKY_DAEMON_URL", None) + if actual: + env["CLAUDE_EFFORT"] = actual + if expected: + env["PINKY_EXPECTED_EFFORT"] = expected + if agent: + env["PINKY_AGENT_NAME"] = agent + if strict: + env["PINKY_STRICT_EFFORT"] = "1" + if daemon_url: + env["PINKY_DAEMON_URL"] = daemon_url + return subprocess.run( + [sys.executable, str(hook_path)], + env=env, + input=stdin, + capture_output=True, + text=True, + timeout=10, + ) + + +# ── Schema / dataclass ───────────────────────────────────── + + +class TestStrictEffortColumn: + def test_default_false(self, registry): + agent = registry.register("alpha", model="opus") + assert agent.strict_effort_enforcement is False + + def test_set_via_register(self, registry): + agent = registry.register("alpha", strict_effort_enforcement=True) + assert agent.strict_effort_enforcement is True + + def test_update_round_trip(self, registry): + registry.register("alpha") + registry.register("alpha", strict_effort_enforcement=True) + got = registry.get("alpha") + assert got is not None + assert got.strict_effort_enforcement is True + # And back to off + registry.register("alpha", strict_effort_enforcement=False) + got2 = registry.get("alpha") + assert got2 is not None + assert got2.strict_effort_enforcement is False + + def test_to_dict_includes_field(self, registry): + agent = registry.register("alpha", strict_effort_enforcement=True) + d = agent.to_dict() + assert d["strict_effort_enforcement"] is True + + +# ── record_effort_drift + get_effort_drift_events ────────── + + +class TestEffortDriftRecording: + def test_record_writes_row(self, registry): + registry.register("alpha") + event_id = registry.record_effort_drift( + "alpha", expected="high", actual="medium", + session_id="sess-1", tool_name="Bash", strict=False, + ) + assert event_id > 0 + events = registry.get_effort_drift_events("alpha") + assert len(events) == 1 + e = events[0] + assert e["expected"] == "high" + assert e["actual"] == "medium" + assert e["tool_name"] == "Bash" + assert e["strict"] is False + assert e["agent_name"] == "alpha" + + def test_record_writes_heartbeat_note(self, registry): + registry.register("alpha") + registry.record_effort_drift( + "alpha", expected="max", actual="low", strict=True, + ) + hb = registry.get_latest_heartbeat("alpha") + assert hb is not None + assert "effort drift" in hb.notes + assert "blocked" in hb.notes # strict label + assert "expected=max" in hb.notes + assert "actual=low" in hb.notes + + def test_record_warn_label_in_note(self, registry): + registry.register("alpha") + registry.record_effort_drift( + "alpha", expected="high", actual="medium", strict=False, + ) + hb = registry.get_latest_heartbeat("alpha") + assert hb is not None + assert "warn" in hb.notes + + def test_get_filters_by_agent(self, registry): + registry.register("alpha") + registry.register("beta") + registry.record_effort_drift("alpha", expected="high", actual="low") + registry.record_effort_drift("beta", expected="high", actual="medium") + a_events = registry.get_effort_drift_events("alpha") + b_events = registry.get_effort_drift_events("beta") + assert len(a_events) == 1 + assert len(b_events) == 1 + assert a_events[0]["actual"] == "low" + assert b_events[0]["actual"] == "medium" + + def test_get_filters_by_since(self, registry): + import time + + registry.register("alpha") + registry.record_effort_drift("alpha", expected="high", actual="low") + cutoff = time.time() + 0.01 + time.sleep(0.05) + registry.record_effort_drift("alpha", expected="high", actual="medium") + events = registry.get_effort_drift_events("alpha", since=cutoff) + # Only the second event should be returned + assert len(events) == 1 + assert events[0]["actual"] == "medium" + + def test_get_fleet_wide(self, registry): + registry.register("alpha") + registry.register("beta") + registry.record_effort_drift("alpha", expected="high", actual="low") + registry.record_effort_drift("beta", expected="high", actual="medium") + all_events = registry.get_effort_drift_events("") + assert len(all_events) == 2 + + +# ── settings.json installer ──────────────────────────────── + + +class TestHookInstaller: + def test_setup_creates_verify_effort_script(self, tmp_path): + AgentRegistry._setup_hooks(tmp_path, "alpha") + assert (tmp_path / ".claude" / "hook_verify_effort.py").exists() + + def test_setup_settings_has_verify_hook(self, tmp_path): + AgentRegistry._setup_hooks(tmp_path, "alpha") + settings = json.loads( + (tmp_path / ".claude" / "settings.json").read_text() + ) + commands = [ + h["command"] + for entry in settings["hooks"]["PreToolUse"] + for h in entry["hooks"] + ] + assert any("hook_verify_effort.py" in c for c in commands), commands + # And working/idle still present + assert any("hook_working.py" in c for c in commands), commands + + def test_setup_idempotent_double_call(self, tmp_path): + AgentRegistry._setup_hooks(tmp_path, "alpha") + first = (tmp_path / ".claude" / "settings.json").read_text() + AgentRegistry._setup_hooks(tmp_path, "alpha") + second = (tmp_path / ".claude" / "settings.json").read_text() + # The file should be unchanged on second call (no duplicate hooks) + # Count verify_effort references — must be exactly 1 + assert second.count("hook_verify_effort.py") == 1 + # And working count is also 1 + assert second.count("hook_working.py") == first.count("hook_working.py") + + def test_setup_merges_into_legacy_settings(self, tmp_path): + # Simulate an old agent: settings.json exists with only working hook. + claude_dir = tmp_path / ".claude" + claude_dir.mkdir() + legacy = { + "hooks": { + "PreToolUse": [{ + "matcher": ".*", + "hooks": [{ + "type": "command", + "command": "python3 /custom/user-hook.py || true", + }], + }], + } + } + (claude_dir / "settings.json").write_text(json.dumps(legacy)) + + AgentRegistry._setup_hooks(tmp_path, "alpha") + merged = json.loads((claude_dir / "settings.json").read_text()) + commands = [ + h["command"] + for entry in merged["hooks"]["PreToolUse"] + for h in entry["hooks"] + ] + # User's custom hook preserved + assert any("user-hook.py" in c for c in commands), commands + # Verify hook added + assert any("hook_verify_effort.py" in c for c in commands), commands + + def test_ensure_workspace_hooks_no_agent(self, registry): + # Calling on a non-existent agent must not raise. + registry.ensure_workspace_hooks("nonexistent") # should no-op + + def test_ensure_workspace_hooks_installs(self, registry, tmp_path): + registry.register( + "alpha", + working_dir=str(tmp_path / "agent"), + ) + # Initial register already runs _setup_hooks; remove the + # verify_effort entry to simulate pre-#429 state. + settings_path = tmp_path / "agent" / ".claude" / "settings.json" + settings = json.loads(settings_path.read_text()) + for entry in settings["hooks"]["PreToolUse"]: + entry["hooks"] = [ + h for h in entry["hooks"] + if "hook_verify_effort" not in (h.get("command") or "") + ] + settings_path.write_text(json.dumps(settings)) + assert "hook_verify_effort" not in settings_path.read_text() + + # Now re-ensure — hook should be re-added. + registry.ensure_workspace_hooks("alpha") + assert "hook_verify_effort" in settings_path.read_text() + + +# ── Hook subprocess behavior ─────────────────────────────── + + +class TestVerifyEffortHookBehavior: + def test_match_silent(self, hook_script): + proc = _run_hook( + hook_script, expected="high", actual="high", agent="alpha", + ) + assert proc.returncode == 0 + assert proc.stdout == "" + + def test_no_expected_silent(self, hook_script): + proc = _run_hook(hook_script, actual="high", agent="alpha") + assert proc.returncode == 0 + assert proc.stdout == "" + + def test_no_actual_silent(self, hook_script): + # Older Claude Code without v2.1.133 effort.level + proc = _run_hook(hook_script, expected="high", agent="alpha") + assert proc.returncode == 0 + assert proc.stdout == "" + + def test_auto_expected_silent(self, hook_script): + proc = _run_hook( + hook_script, expected="auto", actual="low", agent="alpha", + ) + assert proc.returncode == 0 + assert proc.stdout == "" + + def test_no_agent_silent(self, hook_script): + # Hook misconfigured — no agent name — must not crash + no output + proc = _run_hook( + hook_script, expected="high", actual="low", + ) + assert proc.returncode == 0 + assert proc.stdout == "" + + def test_mismatch_warn_posts(self, hook_script, fake_daemon): + port, captured = fake_daemon + proc = _run_hook( + hook_script, + expected="high", actual="medium", agent="alpha", + daemon_url=f"http://127.0.0.1:{port}", + ) + assert proc.returncode == 0 + # Warn mode → no block decision printed + assert proc.stdout == "" + assert len(captured) == 1, captured + msg = captured[0] + assert msg["path"] == "/agents/alpha/effort-drift" + assert msg["body"]["expected"] == "high" + assert msg["body"]["actual"] == "medium" + assert msg["body"]["strict"] is False + + def test_mismatch_strict_emits_block(self, hook_script, fake_daemon): + port, captured = fake_daemon + proc = _run_hook( + hook_script, + expected="high", actual="low", agent="alpha", strict=True, + daemon_url=f"http://127.0.0.1:{port}", + ) + assert proc.returncode == 0 + # Strict mode → block decision on stdout + decision = json.loads(proc.stdout) + assert decision["decision"] == "block" + assert "high" in decision["reason"] + assert "low" in decision["reason"] + # Plus the drift POST still happens + assert len(captured) == 1 + assert captured[0]["body"]["strict"] is True + + def test_mismatch_with_tool_name_from_stdin(self, hook_script, fake_daemon): + port, captured = fake_daemon + stdin_payload = json.dumps({"tool_name": "Bash", "session_id": "s1"}) + proc = _run_hook( + hook_script, + expected="high", actual="medium", agent="alpha", + daemon_url=f"http://127.0.0.1:{port}", + stdin=stdin_payload, + ) + assert proc.returncode == 0 + assert len(captured) == 1 + assert captured[0]["body"]["tool_name"] == "Bash" + + # ── Strict-mode remediation allowlist (Murzik review catch) ── + + @pytest.mark.parametrize("tool_name", [ + "set_thinking_effort", + "mcp__pinky-self__set_thinking_effort", + "SET_THINKING_EFFORT", # case-insensitive + ]) + def test_strict_allows_remediation_tool( + self, hook_script, fake_daemon, tool_name, + ): + """Strict mode must NOT block set_thinking_effort — that's the only + way the agent can self-correct drift. Otherwise strict is a trap. + """ + port, captured = fake_daemon + stdin_payload = json.dumps({"tool_name": tool_name}) + proc = _run_hook( + hook_script, + expected="high", actual="medium", agent="alpha", strict=True, + daemon_url=f"http://127.0.0.1:{port}", + stdin=stdin_payload, + ) + assert proc.returncode == 0 + # Drift still recorded + assert len(captured) == 1 + assert captured[0]["body"]["tool_name"] == tool_name + assert captured[0]["body"]["strict"] is True + # But NO block decision on stdout + assert proc.stdout == "", ( + f"Strict mode must not block remediation tool {tool_name!r}; " + f"got stdout: {proc.stdout!r}" + ) + + def test_strict_still_blocks_other_tools(self, hook_script, fake_daemon): + """Sanity: strict mode still blocks non-remediation tools.""" + port, captured = fake_daemon + stdin_payload = json.dumps({"tool_name": "Bash"}) + proc = _run_hook( + hook_script, + expected="high", actual="medium", agent="alpha", strict=True, + daemon_url=f"http://127.0.0.1:{port}", + stdin=stdin_payload, + ) + assert proc.returncode == 0 + decision = json.loads(proc.stdout) + assert decision["decision"] == "block" + + def test_strict_xhigh_remediation_suggests_xhigh( + self, hook_script, fake_daemon, + ): + """When expected=xhigh, the remediation suggestion must be + ``set_thinking_effort('xhigh')`` — not a closer-but-wrong level. + Anything else leaves the agent stuck: calling set_thinking_effort + with the wrong level just produces another drift event. + """ + port, _captured = fake_daemon + proc = _run_hook( + hook_script, + expected="xhigh", actual="medium", agent="alpha", strict=True, + daemon_url=f"http://127.0.0.1:{port}", + ) + assert proc.returncode == 0 + decision = json.loads(proc.stdout) + reason = decision["reason"] + # Must suggest set_thinking_effort('xhigh') — matches the tool's + # accepted levels after #429 widened it. + assert "set_thinking_effort('xhigh')" in reason, reason + # Must NOT suggest a wrong-level call. + assert "set_thinking_effort('high')" not in reason, reason + assert "set_thinking_effort('max')" not in reason, reason + + def test_strict_unreachable_level_says_so( + self, hook_script, fake_daemon, + ): + """If expected is somehow outside the MCP tool's accepted set + (configuration mismatch — shouldn't happen in normal use), the + reason must be honest: tell the agent there's no self-remediation + path so it escalates to the owner instead of spinning. + """ + port, _captured = fake_daemon + proc = _run_hook( + hook_script, + # Synthetic invalid level — hypothetical future registry value + expected="ultra", actual="medium", agent="alpha", strict=True, + daemon_url=f"http://127.0.0.1:{port}", + ) + assert proc.returncode == 0 + decision = json.loads(proc.stdout) + reason = decision["reason"] + assert "no self-remediation path" in reason + # And don't suggest a tool call that won't fix the drift. + assert "set_thinking_effort(" not in reason + + +class TestVerifyEffortScriptOnDisk: + """Ensure stale on-disk verify_effort scripts get refreshed.""" + + def test_setup_overwrites_stale_script(self, tmp_path): + AgentRegistry._setup_hooks(tmp_path, "alpha") + script_path = tmp_path / ".claude" / "hook_verify_effort.py" + # Simulate a stale older version on disk. + script_path.write_text("# stale version\nprint('old')\n") + # Second run should refresh. + AgentRegistry._setup_hooks(tmp_path, "alpha") + content = script_path.read_text() + assert "stale version" not in content + assert "PinkyBot effort-drift verification hook" in content + + def test_setup_keeps_fresh_script_unchanged(self, tmp_path): + AgentRegistry._setup_hooks(tmp_path, "alpha") + script_path = tmp_path / ".claude" / "hook_verify_effort.py" + mtime_first = script_path.stat().st_mtime + # Same content → should not be rewritten (mtime preserved). + import time + time.sleep(0.05) + AgentRegistry._setup_hooks(tmp_path, "alpha") + mtime_second = script_path.stat().st_mtime + assert mtime_first == mtime_second + + +# ── Config plumbing ──────────────────────────────────────── + + +class TestSDKRunnerConfigField: + def test_default(self): + from pinky_daemon.sdk_runner import SDKRunnerConfig + cfg = SDKRunnerConfig() + assert cfg.strict_effort_enforcement is False + + def test_set_true(self): + from pinky_daemon.sdk_runner import SDKRunnerConfig + cfg = SDKRunnerConfig(strict_effort_enforcement=True) + assert cfg.strict_effort_enforcement is True + + +class TestStreamingSessionConfigField: + def test_default(self): + from pinky_daemon.streaming_session import StreamingSessionConfig + cfg = StreamingSessionConfig(agent_name="x", working_dir="/tmp") + assert cfg.strict_effort_enforcement is False + + def test_set_true(self): + from pinky_daemon.streaming_session import StreamingSessionConfig + cfg = StreamingSessionConfig( + agent_name="x", working_dir="/tmp", + strict_effort_enforcement=True, + ) + assert cfg.strict_effort_enforcement is True + + +class TestAPIRoundTrip: + """strict_effort_enforcement must round-trip through the public agent API + (Murzik review catch #2). Without this, the per-agent strict opt-in + promised by the design is unreachable except via direct DB writes. + """ + + def _make_client(self): + from fastapi.testclient import TestClient + + from pinky_daemon.api import create_api + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + app = create_api( + max_sessions=10, default_working_dir="/tmp", db_path=path, + ) + return TestClient(app), path + + def test_register_with_strict_effort(self): + client, db_path = self._make_client() + try: + resp = client.post("/agents", json={ + "name": "alpha", + "model": "opus", + "strict_effort_enforcement": True, + }) + assert resp.status_code == 200, resp.text + assert resp.json()["strict_effort_enforcement"] is True + + # And it persists on GET. + got = client.get("/agents/alpha").json() + assert got["strict_effort_enforcement"] is True + finally: + client.close() + os.unlink(db_path) + + def test_register_default_strict_effort_false(self): + client, db_path = self._make_client() + try: + resp = client.post("/agents", json={ + "name": "alpha", + "model": "opus", + }) + assert resp.status_code == 200 + assert resp.json()["strict_effort_enforcement"] is False + finally: + client.close() + os.unlink(db_path) + + def test_update_toggles_strict_effort(self): + client, db_path = self._make_client() + try: + client.post("/agents", json={"name": "alpha", "model": "opus"}) + # Turn it on + resp = client.put("/agents/alpha", json={ + "strict_effort_enforcement": True, + }) + assert resp.status_code == 200, resp.text + assert resp.json()["strict_effort_enforcement"] is True + # Turn it back off + resp = client.put("/agents/alpha", json={ + "strict_effort_enforcement": False, + }) + assert resp.status_code == 200 + assert resp.json()["strict_effort_enforcement"] is False + finally: + client.close() + os.unlink(db_path) + + +class TestEffortDriftEndpoint: + """Smoke-test the POST/GET /agents/{name}/effort-drift endpoints.""" + + def _make_client(self): + from fastapi.testclient import TestClient + + from pinky_daemon.api import create_api + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + app = create_api( + max_sessions=10, default_working_dir="/tmp", db_path=path, + ) + return TestClient(app), path + + def test_post_records_event(self): + client, db_path = self._make_client() + try: + client.post("/agents", json={"name": "alpha", "model": "opus"}) + resp = client.post("/agents/alpha/effort-drift", json={ + "expected": "high", + "actual": "medium", + "tool_name": "Bash", + "strict": False, + }) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["ok"] is True + assert body["expected"] == "high" + assert body["actual"] == "medium" + assert body["event_id"] > 0 + finally: + client.close() + os.unlink(db_path) + + def test_get_lists_events(self): + client, db_path = self._make_client() + try: + client.post("/agents", json={"name": "alpha", "model": "opus"}) + client.post("/agents/alpha/effort-drift", json={ + "expected": "high", "actual": "low", + }) + client.post("/agents/alpha/effort-drift", json={ + "expected": "high", "actual": "medium", + }) + resp = client.get("/agents/alpha/effort-drift") + assert resp.status_code == 200 + body = resp.json() + assert body["count"] == 2 + assert len(body["events"]) == 2 + finally: + client.close() + os.unlink(db_path) + + def test_post_for_unknown_agent_404s(self): + client, db_path = self._make_client() + try: + resp = client.post("/agents/ghost/effort-drift", json={ + "expected": "high", "actual": "low", + }) + assert resp.status_code == 404 + finally: + client.close() + os.unlink(db_path) From 160a7d354adb8f9deecb389b50631807c8bf3718 Mon Sep 17 00:00:00 2001 From: olegbrok Date: Mon, 11 May 2026 11:29:23 -0700 Subject: [PATCH 21/24] fix(scheduler): stop infinite resurrection loop for connected codex agents (#431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(scheduler): stop infinite resurrection loop for connected codex agents Two-part fix for the murzik resurrection storm where the scheduler kept firing "resurrection attempt 5/5 for dead agent 'murzik'" against an agent that was demonstrably online with an active streaming session. Root cause: - scheduler._check_heartbeats decided death from latest heartbeat row only, ignoring agents.last_seen_at and connected streaming sessions. A month-old stale `dead` heartbeat triggered resurrection forever even when the agent was clearly alive via streaming presence. - _heartbeat_resurrect then called ss.is_idle_sleeping and ss.attempt_reconnect — Claude's StreamingSession has both; CodexSession had neither. AttributeError on every resurrection callback, retry, repeat. Fix: 1. codex_session.py: implement the StreamingSession watchdog contract — _idle_sleeping/is_idle_sleeping property, attempt_reconnect() with bounded backoff (2/8/30s), idempotent connect(), stats exposes idle_sleeping. 2. api._heartbeat_resurrect: use getattr fallbacks for is_connected / is_idle_sleeping / attempt_reconnect / disconnect / connect. Runtime- tolerant — protects future non-Claude runtimes too, not just Codex. 3. scheduler.py: _reconcile_server_liveness() runs before death-handling. If a connected streaming session exists or last_seen_at is fresher than the heartbeat row (within 2x heartbeat_interval grace), record a synthetic `alive` heartbeat with metadata.source=server_presence and clear that agent's resurrection attempt counter. Verification: - pytest tests/test_codex_session.py tests/test_scheduler.py -q → 76 passed - pytest tests/test_api.py -q → 272 passed (warnings only) - ruff check on all 5 files → clean Tests added: - test_codex_session.py: pin is_idle_sleeping defaults, idle_sleep sets it, connect clears it, disconnect alone does not, attempt_reconnect preserves codex_session_id and increments reconnects stat. - test_scheduler.py: connected streaming session + dead heartbeat → no resurrection callback, synthetic alive heartbeat recorded; fresh last_seen_at + dead heartbeat → same suppression via fresh_last_seen reason. Diagnosis and implementation by Murzik. Verified and pushed by Barsik. Co-Authored-By: Claude Opus 4.7 * test: bump tool-gate count for mesh_remote_send mesh_remote_send (PR #418/#423) was added as a core tool but the TestToolGates assertions in tests/test_pinky_self_tools.py were never updated, leaving CI red on every branch off beta. - Add "mesh_remote_send" to CORE_TOOLS set - Bump core count 23 → 24, all-gates total 68 → 69 - Rename the two count-named tests to match Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Oleg Co-authored-by: Claude Opus 4.7 --- src/pinky_daemon/api.py | 26 +++++++-- src/pinky_daemon/codex_session.py | 54 +++++++++++++++++- src/pinky_daemon/scheduler.py | 88 +++++++++++++++++++++++++++++ tests/test_codex_session.py | 93 +++++++++++++++++++++++++++++++ tests/test_pinky_self_tools.py | 8 +-- tests/test_scheduler.py | 71 +++++++++++++++++++++++ 6 files changed, 331 insertions(+), 9 deletions(-) diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index af37abfc..bde17232 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -8359,12 +8359,12 @@ async def _heartbeat_resurrect(agent_name: str, _session_id: str) -> None: f"session registered" ) return - if ss.is_connected: + if getattr(ss, "is_connected", False): # Race: the in-process retry recovered between heartbeat tick and # the callback running. Also the load-bearing guard for bypassing # the save_my_context restart guard — see docstring above. return - if ss.is_idle_sleeping: + if getattr(ss, "is_idle_sleeping", False): # Session was deliberately disconnected by idle_sleep(). Resurrecting # it here would fight the idle-sleep state and cause an immediate # reconnect/sleep churn cycle. The next genuine wake (scheduler or @@ -8378,8 +8378,26 @@ async def _heartbeat_resurrect(agent_name: str, _session_id: str) -> None: try: # TODO(#338-followup): wrap in asyncio.create_task so the scheduler # tick isn't blocked for up to ~40s during the internal backoff. - await ss.attempt_reconnect() - if ss.is_connected: + attempt_reconnect = getattr(ss, "attempt_reconnect", None) + if callable(attempt_reconnect): + await attempt_reconnect() + else: + # Runtime-tolerant fallback for future StreamingSession-compatible + # implementations that have connect/disconnect but not the richer + # watchdog reconnect helper yet. + try: + disconnect = getattr(ss, "disconnect", None) + if callable(disconnect): + await disconnect() + except Exception as e: + _log(f"api: resurrection pre-disconnect failed for {agent_name}: {e}") + connect = getattr(ss, "connect", None) + if not callable(connect): + raise AttributeError( + f"{ss.__class__.__name__} has no attempt_reconnect or connect" + ) + await connect() + if getattr(ss, "is_connected", False): activity.log( agent_name, "watchdog_resurrect", f"{agent_name} restored by heartbeat watchdog", diff --git a/src/pinky_daemon/codex_session.py b/src/pinky_daemon/codex_session.py index f1b771c0..91322932 100644 --- a/src/pinky_daemon/codex_session.py +++ b/src/pinky_daemon/codex_session.py @@ -73,6 +73,7 @@ def __init__( self._analytics_store = analytics_store self._registry = registry self._connected = False + self._idle_sleeping = False self._processing = False # True while a codex exec is running self._message_queue: asyncio.Queue[tuple[str, str, str, str]] = asyncio.Queue() self._worker_task: asyncio.Task | None = None @@ -114,11 +115,17 @@ def __init__( async def connect(self) -> None: """Start the session. Sends wake prompt via codex exec.""" + if self._connected: + self._idle_sleeping = False + return + self._connected = True + self._idle_sleeping = False self._analytics_session_started() # Start the message processing worker - self._worker_task = asyncio.create_task(self._message_worker()) + if not self._worker_task or self._worker_task.done(): + self._worker_task = asyncio.create_task(self._message_worker()) _log(f"codex[{self.agent_name}]: connected, worker started") @@ -833,10 +840,49 @@ async def idle_sleep(self) -> bool: _log(f"codex[{self.agent_name}]: memory save failed before idle sleep: {e}") await self.disconnect() + self._idle_sleeping = True self._stats["auto_restarts"] += 1 _log(f"codex[{self.agent_name}]: idle sleep complete") return True + # Reconnect backoff schedule (seconds). Kept in step with StreamingSession's + # watchdog contract so api._heartbeat_resurrect can treat runtimes uniformly. + _RECONNECT_BACKOFF = (2, 8, 30) + + async def attempt_reconnect(self) -> None: + """Attempt to reconnect after a failure with bounded retries.""" + try: + await self.disconnect() + except Exception as e: + _log(f"codex[{self.agent_name}]: pre-reconnect disconnect raised: {e}") + + last_error: Exception | None = None + for attempt_idx, delay in enumerate(self._RECONNECT_BACKOFF, start=1): + self._stats["reconnects"] += 1 + _log( + f"codex[{self.agent_name}]: reconnect attempt {attempt_idx}/" + f"{len(self._RECONNECT_BACKOFF)} (#{self._stats['reconnects']} total) " + f"after {delay}s backoff" + ) + await asyncio.sleep(delay) + try: + await self.connect() + _log(f"codex[{self.agent_name}]: reconnected successfully") + return + except Exception as e: + last_error = e + _log(f"codex[{self.agent_name}]: reconnect attempt {attempt_idx} failed: {e}") + try: + await self.disconnect() + except Exception: + pass + + self._connected = False + _log( + f"codex[{self.agent_name}]: all {len(self._RECONNECT_BACKOFF)} reconnect " + f"attempts failed (last error: {last_error}); session left disconnected" + ) + async def disconnect(self) -> None: """Disconnect — kill any running subprocess and cancel the worker.""" self._connected = False @@ -866,6 +912,11 @@ async def disconnect(self) -> None: def is_connected(self) -> bool: return self._connected + @property + def is_idle_sleeping(self) -> bool: + """True when disconnected deliberately by idle_sleep().""" + return self._idle_sleeping + @property def max_tokens(self) -> int: """Estimated max context tokens for this session's model.""" @@ -902,6 +953,7 @@ def stats(self) -> dict: return { **self._stats, "connected": self._connected, + "idle_sleeping": self._idle_sleeping, "processing": self._processing, "pending_messages": self._message_queue.qsize(), "current_activity": self._current_activity, diff --git a/src/pinky_daemon/scheduler.py b/src/pinky_daemon/scheduler.py index e5603fb1..889ebe48 100644 --- a/src/pinky_daemon/scheduler.py +++ b/src/pinky_daemon/scheduler.py @@ -333,12 +333,20 @@ async def _check_schedules(self, now: float) -> None: async def _check_heartbeats(self, now: float) -> None: """Check heartbeat health for all agents with heartbeat_interval > 0.""" agents = self._registry.list(enabled_only=True) + streaming_sessions = {} + if self._streaming_sessions_fn: + try: + streaming_sessions = self._streaming_sessions_fn() or {} + except Exception: + streaming_sessions = {} for agent in agents: if agent.heartbeat_interval <= 0: continue hb = self._registry.get_latest_heartbeat(agent.name) + if self._reconcile_server_liveness(agent, hb, now, streaming_sessions): + continue if not hb: # No heartbeat ever recorded — mark stale self._registry.record_heartbeat( @@ -410,6 +418,86 @@ async def _maybe_resurrect( except Exception as e: _log(f"scheduler: resurrection callback failed for {agent_name}: {e}") + def _reconcile_server_liveness( + self, + agent, + hb, + now: float, + streaming_sessions: dict, + ) -> bool: + """Return True when server-side liveness should suppress death handling. + + Heartbeat rows are agent-reported, but the daemon also has authoritative + transport evidence: connected streaming sessions and server-stamped + last_seen_at. Without reconciling those signals, a stale dead heartbeat + can make the scheduler resurrect an already-live runtime forever. + """ + sessions = streaming_sessions.get(agent.name, {}) or {} + connected_label = "" + connected_session = None + main_session = sessions.get("main") + if main_session is not None and getattr(main_session, "is_connected", False): + connected_label = "main" + connected_session = main_session + else: + for label, session in sessions.items(): + if getattr(session, "is_connected", False): + connected_label = label + connected_session = session + break + + hb_ts = hb.timestamp if hb else 0 + server_ts = getattr(agent, "last_seen_at", 0.0) or 0.0 + grace_seconds = agent.heartbeat_interval * 2 + fresh_server_seen = server_ts > hb_ts and (now - server_ts) <= grace_seconds + + if not connected_session and not fresh_server_seen: + return False + + should_record = ( + not hb + or hb.status != "alive" + or (now - hb.timestamp) >= agent.heartbeat_interval + or server_ts > hb_ts + ) + if should_record: + context_pct = 0.0 + message_count = 0 + session_id = f"{agent.name}-main" + metadata = {"source": "server_presence"} + if connected_session: + session_id = getattr(connected_session, "id", session_id) + metadata["reason"] = "connected_streaming_session" + metadata["label"] = connected_label + try: + context_pct = float(getattr(connected_session, "context_used_pct", 0.0) or 0.0) + except Exception: + context_pct = 0.0 + stats = getattr(connected_session, "stats", {}) or {} + if isinstance(stats, dict): + message_count = int(stats.get("messages_sent", 0) or 0) + int( + stats.get("turns", 0) or 0 + ) + else: + metadata["reason"] = "fresh_last_seen" + metadata["last_seen_at"] = server_ts + + self._registry.record_heartbeat( + agent.name, + session_id=session_id, + status="alive", + context_pct=context_pct, + message_count=message_count, + metadata=metadata, + ) + _log( + f"scheduler: reconciled heartbeat for '{agent.name}' from " + f"{metadata['reason']}" + ) + + self._resurrection_attempts.pop(agent.name, None) + return True + async def _check_idle_sessions(self, now: float) -> None: """Put idle streaming sessions to sleep to save resources.""" if not self._streaming_sessions_fn: diff --git a/tests/test_codex_session.py b/tests/test_codex_session.py index 8e4d9ee8..33b6878c 100644 --- a/tests/test_codex_session.py +++ b/tests/test_codex_session.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import os import tempfile @@ -43,8 +44,10 @@ def test_properties(self): assert s.agent_name == "test-agent" assert s.id == "test-agent-main" assert s.is_connected is False + assert s.is_idle_sleeping is False assert isinstance(s.stats, dict) assert s.stats["connected"] is False + assert s.stats["idle_sleeping"] is False assert s.stats["account"]["apiProvider"] == "codex_cli" def test_stats_shape(self): @@ -318,6 +321,96 @@ async def test_disconnect_idempotent(self): await s.disconnect() assert not s.is_connected + @pytest.mark.asyncio + async def test_disconnect_alone_does_not_set_idle_sleeping(self): + config = StreamingSessionConfig( + agent_name="test", + working_dir="/tmp", + provider_url="codex_cli", + ) + s = CodexSession(config) + + await s.disconnect() + + assert s.is_connected is False + assert s.is_idle_sleeping is False + + @pytest.mark.asyncio + async def test_idle_sleep_sets_idle_sleeping(self): + config = StreamingSessionConfig( + agent_name="test", + working_dir="/tmp", + provider_url="codex_cli", + ) + s = CodexSession(config) + s._connected = True + + async def fake_exec(prompt: str) -> CodexTurnResult: + return CodexTurnResult() + + s._exec_codex = fake_exec # type: ignore[assignment] + + slept = await s.idle_sleep() + + assert slept is True + assert s.is_connected is False + assert s.is_idle_sleeping is True + assert s.stats["idle_sleeping"] is True + + @pytest.mark.asyncio + async def test_connect_clears_idle_sleeping(self): + config = StreamingSessionConfig( + agent_name="test", + working_dir="/tmp", + provider_url="codex_cli", + ) + s = CodexSession(config) + s._idle_sleeping = True + + async def fake_worker() -> None: + await asyncio.sleep(60) + + s._message_worker = fake_worker # type: ignore[assignment] + + await s.connect() + + assert s.is_connected is True + assert s.is_idle_sleeping is False + await s.disconnect() + + @pytest.mark.asyncio + async def test_attempt_reconnect_uses_connect_and_preserves_codex_session_id(self): + config = StreamingSessionConfig( + agent_name="test", + working_dir="/tmp", + provider_url="codex_cli", + ) + s = CodexSession(config) + s.codex_session_id = "thread-123" + s.session_id = "thread-123" + s._RECONNECT_BACKOFF = (0,) + calls = [] + + async def fake_disconnect() -> None: + calls.append("disconnect") + s._connected = False + + async def fake_connect() -> None: + calls.append("connect") + s._connected = True + s._idle_sleeping = False + + s.disconnect = fake_disconnect # type: ignore[method-assign] + s.connect = fake_connect # type: ignore[method-assign] + + await s.attempt_reconnect() + + assert calls == ["disconnect", "connect"] + assert s.is_connected is True + assert s.codex_session_id == "thread-123" + assert s.session_id == "thread-123" + assert s.stats["reconnects"] == 1 + class TestCodexCommandConstruction: """Pin down `_build_codex_cmd()` against #351 regression: `--sandbox=...` diff --git a/tests/test_pinky_self_tools.py b/tests/test_pinky_self_tools.py index 86d41c1d..4d485eb6 100644 --- a/tests/test_pinky_self_tools.py +++ b/tests/test_pinky_self_tools.py @@ -1987,7 +1987,7 @@ def test_error_returns_fallback(self, srv): "claim_task", "complete_task", "context_restart", "context_status", "create_task", "get_next_task", "get_owner_profile", "list_agents", "list_my_skills", "load_my_context", - "load_skill", "request_sleep", "save_my_context", + "load_skill", "mesh_remote_send", "request_sleep", "save_my_context", "search_history", "send_file_to_agent", "send_heartbeat", "send_to_agent", "set_thinking_effort", "who_am_i", } @@ -2040,13 +2040,13 @@ def test_kb_raw_source_id_encodes_path_segment(self, srv): class TestToolGates: - def test_core_only_has_23_tools(self): + def test_core_only_has_24_tools(self): """No gates → only core tools registered.""" srv = create_server(agent_name="test", tool_gates=[]) tools = {t.name for t in srv._tool_manager.list_tools()} assert tools == CORE_TOOLS - def test_all_gates_has_68_tools(self): + def test_all_gates_has_69_tools(self): """All gates → full tool set.""" all_gates = [ "extras", "kb", "research", "presentations", "triggers", @@ -2054,7 +2054,7 @@ def test_all_gates_has_68_tools(self): ] srv = create_server(agent_name="test", tool_gates=all_gates) tools = srv._tool_manager.list_tools() - assert len(tools) == 68 + assert len(tools) == 69 def test_extras_gate_adds_extras_tools(self): """Enabling 'extras' gate adds get_attribution, render_pdf, etc.""" diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 011be662..46eccce8 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -436,6 +436,12 @@ class TestHeartbeatResurrection: rate-limited to RESURRECTION_MAX_ATTEMPTS per RESURRECTION_WINDOW_SECONDS. """ + class _FakeStreamingSession: + is_connected = True + id = "ivan-main" + context_used_pct = 12.5 + stats = {"messages_sent": 2, "turns": 3} + @pytest.mark.asyncio async def test_dead_agent_triggers_resurrection_callback(self, registry): registry.register("ivan", model="opus", heartbeat_interval=60) @@ -526,3 +532,68 @@ async def cb(agent_name, session_id): scheduler = AgentScheduler(registry, heartbeat_callback=cb) # Should swallow and log, not propagate await scheduler._check_heartbeats(time.time()) + + @pytest.mark.asyncio + async def test_connected_streaming_session_clears_dead_heartbeat(self, registry): + registry.register("ivan", model="opus", heartbeat_interval=60) + registry.record_heartbeat("ivan", session_id="old", status="dead") + registry._db.execute( + "UPDATE agent_heartbeats SET timestamp = ? WHERE agent_name = ?", + (time.time() - 600, "ivan"), + ) + registry._db.commit() + called = [] + + async def cb(agent_name, session_id): + called.append((agent_name, session_id)) + + scheduler = AgentScheduler( + registry, + heartbeat_callback=cb, + streaming_sessions_fn=lambda: { + "ivan": {"main": self._FakeStreamingSession()}, + }, + ) + await scheduler._check_heartbeats(time.time()) + + latest = registry.get_latest_heartbeat("ivan") + assert called == [] + assert latest is not None + assert latest.status == "alive" + assert latest.session_id == "ivan-main" + assert latest.context_pct == 12.5 + assert latest.message_count == 5 + assert latest.metadata["source"] == "server_presence" + assert latest.metadata["reason"] == "connected_streaming_session" + + @pytest.mark.asyncio + async def test_fresh_last_seen_suppresses_dead_heartbeat_resurrection(self, registry): + registry.register("ivan", model="opus", heartbeat_interval=60) + registry.record_heartbeat("ivan", session_id="old", status="dead") + old_ts = time.time() - 600 + registry._db.execute( + "UPDATE agent_heartbeats SET timestamp = ? WHERE agent_name = ?", + (old_ts, "ivan"), + ) + registry._db.commit() + now = time.time() + registry.stamp_last_seen("ivan", ts=now - 10) + called = [] + + async def cb(agent_name, session_id): + called.append((agent_name, session_id)) + + scheduler = AgentScheduler( + registry, + heartbeat_callback=cb, + streaming_sessions_fn=lambda: {}, + ) + await scheduler._check_heartbeats(now) + + latest = registry.get_latest_heartbeat("ivan") + assert called == [] + assert latest is not None + assert latest.status == "alive" + assert latest.metadata["source"] == "server_presence" + assert latest.metadata["reason"] == "fresh_last_seen" + assert latest.metadata["last_seen_at"] == pytest.approx(now - 10) From 77c36cd196fc0e825d577722ca54a6854a6cd71d Mon Sep 17 00:00:00 2001 From: olegbrok Date: Mon, 11 May 2026 11:29:27 -0700 Subject: [PATCH 22/24] feat(boot): main-agent-only boot + auto-deps drift detection (#432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(boot): main-agent-only boot + auto-deps drift detection Two related changes from today's restart cycle pain (see also PR #431): 1. **Boot policy**: only the main agent's streaming session and autonomy loop start at daemon boot. Sibling agents stay dormant until something triggers them — inbound message, agent-to-agent message, or a scheduled wake firing. Resources are spent on demand. The main agent decides when siblings are needed. 2. **Auto-deps drift detection**: admin_update now compares installed package versions to pyproject.toml pins independently of `git diff`, so the "pyproject bumped on an earlier pull whose routine restart skipped reinstall" case self-heals on the next routine restart. The manual `force_deps=True` escape hatch remains for cases drift can't see (corrupt install, etc.). Changes: - src/pinky_daemon/api.py * Startup loop: only the main agent gets a streaming session resumed and an autonomy loop started. Drop the `auto_start_agents` loop, `agent.heartbeat_interval > 0` trigger, and `has_persisted` branch for sibling sessions. Sibling sessions are created lazily via `_ensure_streaming_session` on first dispatch. * `admin_update`: added `_check_installed_deps_drift()` helper that parses pyproject.toml and reports packages whose installed version fails the pin (or are missing entirely). Drift entries become a third trigger for `pip install`, alongside `pyproject.toml` diff and `force_deps=True`. Response now includes `deps_drift` field. Drift-check failures (e.g. parse errors) are non-fatal — the other reinstall triggers still work. - src/pinky_daemon/daemon.py * Mirror the startup policy: only start the main agent's autonomy loop. Sibling agents' loops start lazily via `autonomy.push_event`. - src/pinky_daemon/autonomy.py * `push_event` wake gate now checks `agent.enabled` only, not `agent.auto_start`. Gating wake on `auto_start` would have stranded sibling agents unable to receive messages under the new boot policy. Tests: - tests/test_autonomy.py (new): pin the wake gate — enabled non-auto-start agents wake on event; disabled agents do not wake even with auto_start; unknown agents are no-ops; already-running loops are not double-started; events for blocked-wake agents are still queued (no message drops). - tests/test_admin_update.py: 9 new tests for `_check_installed_deps_drift` and its integration with `admin_update` — empty drift when pins satisfied, drift entry when installed < pin, `installed=None` when package missing, optional-dependencies inspected, environment markers respected, unparseable deps skipped silently, drift triggers reinstall, empty drift + clean diff means no reinstall, drift-check raise is non-fatal. - tests/test_api.py: updated `test_manual_streaming_session_persists_and_restores_labels` to mark its test agent as main, since boot-time session restore is now scoped to the main agent. Verification: - pytest tests/test_admin_update.py tests/test_autonomy.py -q → 25 passed - pytest tests/test_admin_update.py tests/test_autonomy.py tests/test_scheduler.py tests/test_api.py tests/test_codex_session.py -q → 367 passed - ruff check on all 6 files → clean Boot-policy rationale: today's force_deps incident exposed how much state gets restored on every restart cycle. Multiple agents resurrecting in parallel can interact in pathological ways (cf. murzik resurrection loop in PR #431). Brad's call: "only main agent on start; you decide the rest." Cleaner failure mode, less resource churn, simpler debugging. Co-Authored-By: Claude Opus 4.7 * test: bump tool-gate count for mesh_remote_send mesh_remote_send (PR #418/#423) was added as a core tool but the TestToolGates assertions in tests/test_pinky_self_tools.py were never updated, leaving CI red on every branch off beta. - Add "mesh_remote_send" to CORE_TOOLS set - Bump core count 23 → 24, all-gates total 68 → 69 - Rename the two count-named tests to match Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Oleg Co-authored-by: Claude Opus 4.7 --- src/pinky_daemon/api.py | 177 +++++++++++++++++++-------- src/pinky_daemon/autonomy.py | 15 ++- src/pinky_daemon/daemon.py | 20 ++- tests/test_admin_update.py | 229 ++++++++++++++++++++++++++++++++++- tests/test_api.py | 4 + tests/test_autonomy.py | 156 ++++++++++++++++++++++++ 6 files changed, 541 insertions(+), 60 deletions(-) create mode 100644 tests/test_autonomy.py diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index bde17232..c544f22f 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -1312,6 +1312,66 @@ def _write_mcp_json( mcp_json.write_text(json.dumps(mcp_config, indent=2)) +def _check_installed_deps_drift(repo_dir: str) -> list[dict]: + """Compare installed package versions to pyproject.toml pins. + + Returns a list of drift entries — one per dependency whose installed + version doesn't satisfy the pin (including missing packages). Each entry + is ``{"package": str, "specifier": str, "installed": str | None}``. + + Used by ``admin_update`` to decide whether ``pip install`` is needed even + when ``pyproject.toml`` didn't change in the current pull. Catches the + common failure mode where an earlier pull bumped a pin but its routine + restart skipped the reinstall step, leaving installed packages stale. + + Failures (missing pyproject, unparseable deps) are surfaced via raised + exceptions and treated as non-fatal by callers — drift can't be assessed + so reinstall stays gated on the other triggers. + """ + import tomllib + from importlib.metadata import PackageNotFoundError, version + + from packaging.requirements import Requirement + + pyproject_path = Path(repo_dir) / "pyproject.toml" + with open(pyproject_path, "rb") as f: + meta = tomllib.load(f) + + project = meta.get("project", {}) or {} + deps: list[str] = list(project.get("dependencies", []) or []) + optional = project.get("optional-dependencies", {}) or {} + for group_deps in optional.values(): + if group_deps: + deps.extend(group_deps) + + drifts: list[dict] = [] + for dep_str in deps: + try: + req = Requirement(dep_str) + except Exception: + # Unparseable entry — skip rather than fail the whole check. + continue + # Skip markers that don't apply to the current env (e.g. python_version<"3.10") + if req.marker is not None and not req.marker.evaluate(): + continue + try: + installed = version(req.name) + except PackageNotFoundError: + drifts.append({ + "package": req.name, + "specifier": str(req.specifier), + "installed": None, + }) + continue + if req.specifier and not req.specifier.contains(installed, prereleases=True): + drifts.append({ + "package": req.name, + "specifier": str(req.specifier), + "installed": installed, + }) + return drifts + + # ── API Server ─────────────────────────────────────────────── @@ -8527,7 +8587,16 @@ def _resolve_memory_db(agent_name: str) -> str: await shared_mcp_manager.start() _log(f"startup: shared MCP server started on {shared_mcp_manager.url}") - auto_start_agents = agents.list_auto_start_agents() + # Boot policy (2026-05-11): only the **main agent** auto-resumes at boot. + # Sibling agents stay dormant until something triggers them — an inbound + # message, an agent-to-agent message, or a scheduled wake firing. Their + # autonomy loop + streaming session are created lazily on first dispatch + # (see `_ensure_streaming_session` and `autonomy.push_event`). + # + # `auto_start_agents` is no longer used to gate boot startup. The + # `agent.auto_start` flag is preserved for compat (`/admin/auto-start` + # endpoint still reports it) but does not control which agents come up. + main_name_for_boot = agents.get_main_agent() # Start broker pollers and streaming sessions for all enabled agents. from pinky_daemon.pollers import ( @@ -8617,54 +8686,39 @@ def _resolve_memory_db(agent_name: str) -> str: except Exception as e: _log(f"startup: iMessage poller failed for {agent.name}: {e}") - # Decide which agents get streaming sessions on boot: - # - Agents with auto_start, heartbeat, or main agent: always start (create main if needed) - # - Agents with persisted sessions from before: restore those sessions - # - Other agents: skip (sessions created on-demand via _ensure_streaming_session) - should_auto_start = ( - agent.auto_start - or agent.heartbeat_interval > 0 - or agent.name == agents.get_main_agent() - ) - persisted = agents.list_streaming_session_ids(agent.name) - has_persisted = any(entry["session_id"] for entry in persisted) + # Boot policy: only the main agent auto-resumes its streaming session. + # Sibling sessions are created on-demand via `_ensure_streaming_session` + # when an inbound message / agent-to-agent message / scheduled wake + # routes through the autonomy event queue. Persisted session IDs are + # kept in the DB so resume-by-id still works when siblings later wake. + if agent.name != main_name_for_boot: + _log(f"startup: skipping streaming session for {agent.name} (on-demand only)") + continue # Validate working_dir exists — stale paths from a different machine # (e.g. migrating from Mac Mini to RPi) would cause Fatal errors. if work_dir and not work_dir.is_dir(): _log(f"startup: working_dir missing for {agent.name}: {work_dir} — skipping session") # Clear stale session IDs so we don't retry on next boot - for entry in persisted: + for entry in agents.list_streaming_session_ids(agent.name): if entry["session_id"]: agents.set_streaming_session_id(agent.name, "", label=entry["label"]) continue - if should_auto_start: - # Only start main session on boot — sub-sessions are on-demand - main_resume = agents.get_streaming_session_id(agent.name, label="main") - labels_to_start = {"main": main_resume} - elif has_persisted: - # Agent had sessions before — just restore main - main_resume = agents.get_streaming_session_id(agent.name, label="main") - labels_to_start = {"main": main_resume} - else: - _log(f"startup: skipping streaming session for {agent.name} (on-demand only)") - continue - - for label, resume_id in labels_to_start.items(): - try: - await _start_streaming_session(agent.name, label=label, resume_id=resume_id) - streaming_count += 1 - if resume_id: - _log(f"startup: streaming session resumed for {agent.name}/{label} (session {resume_id[:12]})") - else: - _log(f"startup: streaming session connected for {agent.name}/{label} (new)") - except Exception as e: - _log(f"startup: streaming session failed for {agent.name}/{label}: {e}") - # If resume failed, clear the stale session ID and try fresh on next boot - if resume_id: - _log(f"startup: clearing stale session ID for {agent.name}/{label}") - agents.set_streaming_session_id(agent.name, "", label=label) + main_resume = agents.get_streaming_session_id(agent.name, label="main") + try: + await _start_streaming_session(agent.name, label="main", resume_id=main_resume) + streaming_count += 1 + if main_resume: + _log(f"startup: streaming session resumed for {agent.name}/main (session {main_resume[:12]})") + else: + _log(f"startup: streaming session connected for {agent.name}/main (new)") + except Exception as e: + _log(f"startup: streaming session failed for {agent.name}/main: {e}") + # If resume failed, clear the stale session ID and try fresh on next boot + if main_resume: + _log(f"startup: clearing stale session ID for {agent.name}/main") + agents.set_streaming_session_id(agent.name, "", label="main") # Clean up legacy sessions for agents that now have streaming sessions. # These ghost sessions were restored by SessionManager._restore_sessions() @@ -8682,19 +8736,29 @@ def _resolve_memory_db(agent_name: str) -> str: await autonomy.start() await watchdog.start() - for agent in auto_start_agents: - await autonomy.start_agent_loop(agent.name) - - # Main agent always gets an autonomy loop, even without auto_start + # Boot policy: only the main agent's autonomy loop starts at boot. + # Sibling loops start lazily via `autonomy.push_event` when an event + # arrives for them (inbound message, agent-to-agent message, scheduled + # wake). See `autonomy.push_event` — it ungates the wake on `enabled` + # rather than `auto_start` so any enabled agent can be woken on demand. + main_started = False main_name = agents.get_main_agent() - auto_started_names = {a.name for a in auto_start_agents} - if main_name and main_name not in auto_started_names: + if main_name: main_agent = agents.get(main_name) if main_agent and main_agent.enabled: await autonomy.start_agent_loop(main_name) - _log(f"startup: main agent '{main_name}' auto-started") + main_started = True + _log(f"startup: main agent '{main_name}' autonomy loop started") + else: + _log(f"startup: warn — main agent '{main_name}' missing or disabled") + else: + _log("startup: warn — no main agent configured; no autonomy loop started") - _log(f"startup: scheduler + autonomy + watchdog running, {len(auto_start_agents)} agent(s) auto-started, {len(_broker_pollers)} broker poller(s), {streaming_count} streaming") + _log( + f"startup: scheduler + autonomy + watchdog running, " + f"main={'on' if main_started else 'off'}, " + f"{len(_broker_pollers)} broker poller(s), {streaming_count} streaming" + ) @app.on_event("shutdown") async def on_shutdown(): @@ -9006,10 +9070,12 @@ async def admin_update( summary = "" # Detect dependency changes — rebuild whenever pyproject.toml or uv.lock - # changed in the pull, or when force_deps=True is passed (escape hatch - # for installed-vs-pinned drift that git diff can't see). + # changed in the pull, when force_deps=True is passed, or when the + # installed package versions have drifted from the pyproject pins + # (e.g., pyproject was bumped on an earlier pull that skipped reinstall). deps_rebuilt = False deps_error = "" + deps_drift: list[dict] = [] try: if before_hash != after_hash: changed = sp.check_output( @@ -9020,7 +9086,17 @@ async def admin_update( else: changed = "" - if changed or force_deps: + # Auto-deps drift check: compare installed versions to pyproject pins + # independently of git diff. Catches the "pyproject bumped earlier, + # routine restart skipped reinstall" case that force_deps used to + # paper over manually. + try: + deps_drift = _check_installed_deps_drift(repo_dir) + except Exception as drift_exc: + _log(f"admin: deps drift check failed (non-fatal): {drift_exc}") + deps_drift = [] + + if changed or force_deps or deps_drift: # Prefer project venv pip if present, else use the running daemon's # interpreter (sys.executable). This works for both venv and # system-python deployments — the prior `.venv/bin/pip`-only path @@ -9075,6 +9151,7 @@ async def admin_update( "commits": summary.splitlines() if summary else [], "deps_rebuilt": deps_rebuilt, "deps_error": deps_error or None, + "deps_drift": deps_drift, "frontend_rebuilt": frontend_rebuilt, "frontend_error": frontend_error or None, "forced_reset": forced_reset, diff --git a/src/pinky_daemon/autonomy.py b/src/pinky_daemon/autonomy.py index 6ef7b5ca..e721d0db 100644 --- a/src/pinky_daemon/autonomy.py +++ b/src/pinky_daemon/autonomy.py @@ -272,14 +272,23 @@ async def stop_agent_loop(self, agent_name: str) -> None: _log(f"autonomy: stopped work loop for {agent_name}") async def push_event(self, event: AgentEvent) -> None: - """Push an event to an agent's queue. Starts loop if not running.""" + """Push an event to an agent's queue. Starts loop if not running. + + Boot policy (2026-05-11): only the main agent's autonomy loop is started + at daemon boot. Sibling agents start their loop here on demand, the + first time any event (inbound message, agent-to-agent message, or + scheduled wake) arrives. The gate is `enabled` only — the older + `agent.auto_start` flag is no longer consulted at wake time, since + gating wake on `auto_start` would strand siblings unable to receive + messages after the boot-policy change. + """ await self._event_queue.push(event) _log(f"autonomy: event {event.type.value} for {event.agent_name}") - # Auto-start loop if agent has auto_start and loop isn't running + # Wake the loop on first event for any enabled agent. if event.agent_name not in self._running_loops: agent = self._registry.get(event.agent_name) - if agent and agent.auto_start and agent.enabled: + if agent and agent.enabled: await self.start_agent_loop(event.agent_name) async def _agent_loop(self, agent_name: str) -> None: diff --git a/src/pinky_daemon/daemon.py b/src/pinky_daemon/daemon.py index 8c565e9f..6853d8c7 100644 --- a/src/pinky_daemon/daemon.py +++ b/src/pinky_daemon/daemon.py @@ -255,12 +255,20 @@ async def start(self) -> None: await self._autonomy.start() _log("daemon: autonomy engine started") - # Start work loops for auto-start agents - auto_start_agents = self._registry.list_auto_start_agents() - for agent in auto_start_agents: - await self._autonomy.start_agent_loop(agent.name) - if auto_start_agents: - _log(f"daemon: started autonomy loops for {len(auto_start_agents)} agent(s)") + # Boot policy (2026-05-11): only the main agent's autonomy loop starts + # at boot. Sibling loops start lazily via `autonomy.push_event` when an + # event arrives for them (inbound message, agent-to-agent message, or + # scheduled wake). + main_name = self._registry.get_main_agent() + if main_name: + main_agent = self._registry.get(main_name) + if main_agent and main_agent.enabled: + await self._autonomy.start_agent_loop(main_name) + _log(f"daemon: started main agent autonomy loop ({main_name})") + else: + _log(f"daemon: warn — main agent '{main_name}' missing or disabled") + else: + _log("daemon: warn — no main agent configured; no autonomy loop started") # Start platform pollers if self._config.telegram_token: diff --git a/tests/test_admin_update.py b/tests/test_admin_update.py index f34362a0..99e26a47 100644 --- a/tests/test_admin_update.py +++ b/tests/test_admin_update.py @@ -308,11 +308,18 @@ def test_force_and_force_deps_combine(self): assert len(gm.pip_calls) == 1 def test_no_force_deps_skips_pip_when_pyproject_unchanged(self): - """Default behavior: don't reinstall when pyproject.toml didn't change.""" + """Default behavior: don't reinstall when pyproject.toml didn't change. + + The drift detector is also a reinstall trigger (see + TestInstalledDepsDriftDetection) so we patch it to report no drift — + this test pins the pyproject-diff axis specifically. + """ gm = _GitMock(dirty_files=[]) wrapped = self._track_pip_calls(gm) with ( patch("subprocess.check_output", side_effect=wrapped), + patch("pinky_daemon.api._check_installed_deps_drift", + return_value=[]), patch("shutil.which", return_value=None), patch("os.kill"), ): @@ -347,3 +354,223 @@ def fail_on_pip(cmd, **kwargs): assert body.get("deps_rebuilt") is False assert body.get("deps_error") assert "pip install failed" in body["deps_error"] + + +class TestInstalledDepsDriftDetection: + """Direct unit tests for `_check_installed_deps_drift`. + + The helper compares installed package versions to pyproject.toml pins to + catch the "pyproject bumped earlier, routine restart skipped reinstall" + case that previously needed manual force_deps=True. + """ + + def _write_pyproject(self, tmp_dir: str, deps: list[str], + optional: dict | None = None) -> None: + from pathlib import Path + # TOML literal strings (single-quoted) — no escape processing, + # so embedded double quotes (env markers like `platform_system == "x"`) + # don't need escaping. + content = "[project]\nname = 'test'\nversion = '0.0.0'\ndependencies = [\n" + for d in deps: + content += f" '{d}',\n" + content += "]\n" + if optional: + content += "\n[project.optional-dependencies]\n" + for group, group_deps in optional.items(): + content += f"{group} = [\n" + for d in group_deps: + content += f" '{d}',\n" + content += "]\n" + (Path(tmp_dir) / "pyproject.toml").write_text(content) + + def test_empty_drift_when_all_pins_satisfied(self): + """A pyproject that depends only on packages whose installed versions + satisfy the pins returns an empty list.""" + from pinky_daemon.api import _check_installed_deps_drift + + # `pytest` must be installed (we're literally running under it), + # so any-version pin should be satisfied. + with tempfile.TemporaryDirectory() as tmp: + self._write_pyproject(tmp, ["pytest"]) + drifts = _check_installed_deps_drift(tmp) + assert drifts == [] + + def test_pin_higher_than_installed_yields_drift_entry(self): + """When pyproject pins a version above what's installed, that package + shows up in the drift list with the installed version recorded.""" + from importlib.metadata import version as _v + + from pinky_daemon.api import _check_installed_deps_drift + + installed = _v("pytest") + # Construct a pin that the installed version cannot satisfy. + # Bump major+1 to keep things robust against future pytest releases. + major = int(installed.split(".")[0]) + 1 + unsatisfiable_pin = f"pytest>={major}.0.0" + + with tempfile.TemporaryDirectory() as tmp: + self._write_pyproject(tmp, [unsatisfiable_pin]) + drifts = _check_installed_deps_drift(tmp) + + assert len(drifts) == 1 + entry = drifts[0] + assert entry["package"] == "pytest" + assert entry["installed"] == installed + assert str(major) in entry["specifier"] + + def test_missing_package_records_installed_none(self): + """A pyproject dep that isn't installed at all shows up with + installed=None — distinct from a version mismatch.""" + from pinky_daemon.api import _check_installed_deps_drift + + ghost_pkg = "definitely-not-a-real-package-9b3c" + with tempfile.TemporaryDirectory() as tmp: + self._write_pyproject(tmp, [ghost_pkg]) + drifts = _check_installed_deps_drift(tmp) + + assert len(drifts) == 1 + assert drifts[0]["package"] == ghost_pkg + assert drifts[0]["installed"] is None + + def test_optional_dependencies_are_inspected(self): + """Drift in `project.optional-dependencies` groups is reported just + like core dependencies — keeps `pip install -e .[all]` honest.""" + from pinky_daemon.api import _check_installed_deps_drift + + ghost_pkg = "definitely-not-a-real-package-opt-7a2d" + with tempfile.TemporaryDirectory() as tmp: + self._write_pyproject( + tmp, deps=["pytest"], optional={"extra": [ghost_pkg]} + ) + drifts = _check_installed_deps_drift(tmp) + + ghost_entries = [d for d in drifts if d["package"] == ghost_pkg] + assert len(ghost_entries) == 1 + assert ghost_entries[0]["installed"] is None + + def test_markers_excluding_current_env_are_skipped(self): + """Deps with environment markers that don't match the current + interpreter must be skipped — otherwise a Windows-only pin would + spuriously fire on macOS.""" + from pinky_daemon.api import _check_installed_deps_drift + + # platform_system="DefinitelyNotARealOS" never matches — package + # should be skipped, not reported as missing. + with tempfile.TemporaryDirectory() as tmp: + self._write_pyproject( + tmp, + ['ghost-pkg-marker-3f01 ; platform_system == "DefinitelyNotARealOS"'], + ) + drifts = _check_installed_deps_drift(tmp) + + assert drifts == [] + + def test_unparseable_dependency_is_skipped_silently(self): + """A malformed line in pyproject must not crash the whole check; + unparseable entries are silently skipped so well-formed deps still + get inspected.""" + from pinky_daemon.api import _check_installed_deps_drift + + with tempfile.TemporaryDirectory() as tmp: + # First line is junk, second is a real (satisfied) pin. + self._write_pyproject(tmp, ["===not a requirement===", "pytest"]) + drifts = _check_installed_deps_drift(tmp) + assert drifts == [] + + def test_drift_triggers_reinstall_in_admin_update(self): + """When the drift check reports any entry, admin_update kicks off + pip install even if pyproject.toml didn't change in this pull and + force_deps=False.""" + gm = _GitMock(dirty_files=[], before_hash="same1", after_hash="same1") + + fake_drift = [{ + "package": "claude-agent-sdk", + "specifier": ">=0.1.77", + "installed": "0.1.68", + }] + + pip_calls: list[list[str]] = [] + + def record_subprocess(cmd, **kwargs): + cmd_list = list(cmd) + is_pip = ( + "install" in cmd_list + and (cmd_list[0].endswith("/pip") or cmd_list[0] == "pip" + or "pip" in cmd_list) + ) + if is_pip: + pip_calls.append(cmd_list) + return b"" + return gm(cmd, **kwargs) + + with ( + patch("subprocess.check_output", side_effect=record_subprocess), + patch("pinky_daemon.api._check_installed_deps_drift", + return_value=fake_drift), + patch("shutil.which", return_value=None), + patch("os.kill"), + ): + client = _make_client() + r = client.post("/admin/update?branch=beta") + body = r.json() + assert body.get("deps_rebuilt") is True + assert body.get("deps_drift") == fake_drift + assert pip_calls, "drift should have triggered pip install" + + def test_no_drift_no_diff_no_force_means_no_reinstall(self): + """The drift signal is additive — when there's no drift AND no + pyproject change AND no force_deps, pip is not invoked.""" + gm = _GitMock(dirty_files=[], before_hash="same1", after_hash="same1") + + pip_calls: list[list[str]] = [] + + def record_subprocess(cmd, **kwargs): + cmd_list = list(cmd) + is_pip = ( + "install" in cmd_list + and (cmd_list[0].endswith("/pip") or cmd_list[0] == "pip" + or "pip" in cmd_list) + ) + if is_pip: + pip_calls.append(cmd_list) + return b"" + return gm(cmd, **kwargs) + + with ( + patch("subprocess.check_output", side_effect=record_subprocess), + patch("pinky_daemon.api._check_installed_deps_drift", + return_value=[]), + patch("shutil.which", return_value=None), + patch("os.kill"), + ): + client = _make_client() + r = client.post("/admin/update?branch=beta") + body = r.json() + assert body.get("deps_rebuilt") is False + assert body.get("deps_drift") == [] + assert pip_calls == [] + + def test_drift_check_failure_is_non_fatal(self): + """If the drift check itself raises (e.g. pyproject parse fails), + admin_update keeps going — drift just can't contribute to the + reinstall decision.""" + gm = _GitMock(dirty_files=[], before_hash="same1", after_hash="same1") + + def boom(_repo_dir): + raise RuntimeError("simulated drift-check failure") + + with ( + patch("subprocess.check_output", side_effect=gm), + patch("pinky_daemon.api._check_installed_deps_drift", + side_effect=boom), + patch("shutil.which", return_value=None), + patch("os.kill"), + ): + client = _make_client() + r = client.post("/admin/update?branch=beta") + assert r.status_code == 200 + body = r.json() + # No reinstall (no drift, no diff, no force), and the failure didn't + # propagate as a 500. + assert body.get("updated") is True + assert body.get("deps_drift") == [] diff --git a/tests/test_api.py b/tests/test_api.py index 66c4ced7..011ee2c5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -905,6 +905,10 @@ async def fake_connect(self): app1 = self._make_app(db_path) with TestClient(app1) as client1: client1.post("/agents", json={"name": "test-agent", "model": "sonnet"}) + # Boot policy (2026-05-11): only the main agent auto-resumes + # its streaming session on restart. Mark this agent as main so + # the cross-boot restore behavior under test still applies. + app1.state.agents.set_main_agent("test-agent") resp = client1.post("/agents/test-agent/streaming-sessions?label=worker") assert resp.status_code == 200 assert app1.state.agents.get_streaming_session_id("test-agent", label="worker") == "test-agent-sdk" diff --git a/tests/test_autonomy.py b/tests/test_autonomy.py new file mode 100644 index 00000000..c9c4bc2b --- /dev/null +++ b/tests/test_autonomy.py @@ -0,0 +1,156 @@ +"""Tests for AutonomyEngine push_event wake gating. + +Boot policy (2026-05-11): only the main agent's autonomy loop starts at boot. +Sibling agents wake on demand the first time `push_event` routes an event to +them. These tests pin the wake gate — it must depend only on `enabled`, not +the legacy `auto_start` flag (which used to gate wake too and would strand +siblings under the new boot policy). +""" + +from __future__ import annotations + +import asyncio +import os +import tempfile + +import pytest + +from pinky_daemon.agent_registry import AgentRegistry +from pinky_daemon.autonomy import AgentEvent, AutonomyEngine, EventType +from pinky_daemon.conversation_store import ConversationStore +from pinky_daemon.task_store import TaskStore + + +@pytest.fixture +def stores(): + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + reg = AgentRegistry(db_path=path) + tasks = TaskStore(db_path=path) + convos = ConversationStore(db_path=path) + yield reg, tasks, convos + reg.close() + os.unlink(path) + + +def _build_engine(registry, tasks, convos): + # session_sender is unused for push_event wake tests; the loop, once + # started, will block on its event queue waiting for more work. + return AutonomyEngine( + registry, + tasks, + convos, + session_sender=None, + idle_check_interval=3600, # don't fire idle exit during the test + ) + + +class TestPushEventWakeGate: + """The wake gate in `push_event` decides whether an inbound event should + spin up an agent's autonomy loop. Under the post-2026-05-11 boot policy + siblings start out without a loop, so this gate is the on-demand wake + path. It must remain permissive for enabled agents and refuse disabled ones. + """ + + @pytest.mark.asyncio + async def test_enabled_non_auto_start_agent_wakes_on_event(self, stores): + registry, tasks, convos = stores + # Critical: auto_start=False (sibling default under the new policy) + registry.register("murzik", model="opus", auto_start=False, enabled=True) + engine = _build_engine(registry, tasks, convos) + await engine.start() + try: + assert "murzik" not in engine._running_loops + + await engine.push_event(AgentEvent( + type=EventType.message_received, + agent_name="murzik", + )) + + # Loop should be started despite auto_start=False + assert "murzik" in engine._running_loops + finally: + await engine.stop() + + @pytest.mark.asyncio + async def test_disabled_agent_does_not_wake_on_event(self, stores): + registry, tasks, convos = stores + registry.register("retired", model="opus", auto_start=True, enabled=False) + engine = _build_engine(registry, tasks, convos) + await engine.start() + try: + await engine.push_event(AgentEvent( + type=EventType.manual_wake, + agent_name="retired", + )) + + # Disabled agent must NOT have its loop started, even with auto_start=True + assert "retired" not in engine._running_loops + finally: + await engine.stop() + + @pytest.mark.asyncio + async def test_event_for_unknown_agent_does_not_wake(self, stores): + registry, tasks, convos = stores + engine = _build_engine(registry, tasks, convos) + await engine.start() + try: + await engine.push_event(AgentEvent( + type=EventType.schedule_wake, + agent_name="ghost", + )) + + assert "ghost" not in engine._running_loops + finally: + await engine.stop() + + @pytest.mark.asyncio + async def test_running_loop_is_not_double_started(self, stores): + registry, tasks, convos = stores + registry.register("barsik", model="opus", auto_start=True, enabled=True) + engine = _build_engine(registry, tasks, convos) + await engine.start() + try: + await engine.start_agent_loop("barsik") + first_task = engine._running_loops["barsik"] + + await engine.push_event(AgentEvent( + type=EventType.message_received, + agent_name="barsik", + )) + + # Same task object — no replacement + assert engine._running_loops["barsik"] is first_task + finally: + await engine.stop() + + @pytest.mark.asyncio + async def test_event_is_queued_even_when_wake_blocked(self, stores): + """A disabled agent shouldn't have its loop started, but events still + land on the queue. If the agent is later re-enabled and its loop is + started manually, the queued event is available — push_event never + drops the message itself. + """ + registry, tasks, convos = stores + registry.register("retired", model="opus", auto_start=False, enabled=False) + engine = _build_engine(registry, tasks, convos) + await engine.start() + try: + ev = AgentEvent( + type=EventType.message_received, + agent_name="retired", + data={"text": "hello"}, + ) + await engine.push_event(ev) + + assert "retired" not in engine._running_loops + # Queue exists and has the event ready to pop + engine._event_queue.ensure_queue("retired") + popped = await asyncio.wait_for( + engine._event_queue.pop("retired", timeout=0.5), + timeout=1.0, + ) + assert popped is not None + assert popped.data == {"text": "hello"} + finally: + await engine.stop() From 30f2990b8b7d5cd7c4c57eda2ca4e8e7f326af0d Mon Sep 17 00:00:00 2001 From: olegbrok Date: Mon, 11 May 2026 14:01:40 -0700 Subject: [PATCH 23/24] fix(scheduler): skip resurrection eval for idle-sleeping agents (#446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session watchdog has been emitting "resurrection attempt 5/5 for dead agent X" every 30s for every idle-sleeping agent on the Pi fleet since PR #348/#349 landed. Those PRs added the idle_sleeping check, but only at the API callback site — by the time it fires, the scheduler has already consumed a rate-limit budget slot and logged the attempt. With 7 agents idle-sleeping most of the time, the journal fills with noise and Brad reports it "feels too eager." Root cause: _maybe_resurrect's only gate was the rate limit. The API callback would refuse the actual reconnect, but the scheduler had no way to know that ahead of time, so it kept burning budget + log slots. Fix: add is_resurrectable_fn precondition to AgentScheduler. Called before budget/log/callback. If it returns False, return silently — no budget consumed, no log emitted, no callback fired. api.py wires in _is_resurrectable which returns False when the streaming session is idle_sleeping (or missing entirely). Fails open on precondition exceptions so a buggy resolver can't silently disable resurrection for the whole fleet. Tests: - precondition False → callback never fires, budget stays at 0 - precondition True → original behavior preserved - precondition raises → falls through (fail-open) Pi was hot-patched in parallel (early-return in _maybe_resurrect) to quiet the noise immediately; this PR replaces that with the proper fix, so the next deploy cleanly overwrites the hot-patch. Co-authored-by: Oleg Co-authored-by: Claude Opus 4.7 --- src/pinky_daemon/api.py | 16 +++++++++ src/pinky_daemon/scheduler.py | 21 +++++++++++ tests/test_scheduler.py | 68 +++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index c544f22f..58ddd0ca 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -8465,10 +8465,26 @@ async def _heartbeat_resurrect(agent_name: str, _session_id: str) -> None: except Exception as e: _log(f"api: resurrection failed for {agent_name}: {e}") + def _is_resurrectable(agent_name: str) -> bool: + """Scheduler precondition: True iff resurrection should be attempted. + + Returns False for agents that don't currently want resurrection — most + commonly idle-sleeping sessions (which the API callback would refuse + anyway, but at the cost of a budget slot and a noisy log line every + scheduler tick). See #348/#349 for the original API-layer skip. + """ + ss = broker._get_streaming_session(agent_name) + if not ss: + return False # nothing to resurrect + if getattr(ss, "is_idle_sleeping", False): + return False # deliberately disconnected — leave it alone + return True + scheduler = AgentScheduler( agents, wake_callback=_wake_callback, heartbeat_callback=_heartbeat_resurrect, + is_resurrectable_fn=_is_resurrectable, direct_send_callback=broker.send_callback, dream_callback=_dream_callback, librarian_callback=_librarian_callback, diff --git a/src/pinky_daemon/scheduler.py b/src/pinky_daemon/scheduler.py index 889ebe48..f7b5d327 100644 --- a/src/pinky_daemon/scheduler.py +++ b/src/pinky_daemon/scheduler.py @@ -183,6 +183,7 @@ def __init__( dream_callback=None, librarian_callback=None, streaming_sessions_fn=None, + is_resurrectable_fn=None, comms_cleanup_fn=None, trigger_store=None, activity=None, @@ -197,6 +198,11 @@ def __init__( self._librarian_callback = librarian_callback # async fn(agent_name, agent_config) self._last_librarian_check: dict[str, tuple] = {} # dedup key self._streaming_sessions_fn = streaming_sessions_fn # fn() -> dict[name, StreamingSession] + # Precondition check for resurrection. If supplied, must return True iff + # the named agent is currently in a state where resurrection is desired. + # Used to skip eval for idle-sleeping agents (which the API callback + # would refuse anyway, but at the cost of a budget slot and a log line). + self._is_resurrectable_fn = is_resurrectable_fn # fn(agent_name) -> bool self._comms_cleanup_fn = comms_cleanup_fn # fn() -> int (expired inbox cleanup) self._trigger_store = trigger_store # TriggerStore | None self._activity = activity # ActivityStore | None @@ -399,6 +405,21 @@ async def _maybe_resurrect( if not self._heartbeat_callback: return + # Precondition: skip agents that don't want resurrection at all + # (e.g. idle-sleeping). This avoids consuming the rate-limit budget + # and emitting "attempt N/N" log spam every tick for sleeping agents + # the API callback would refuse anyway. See #348/#349 — that fix + # landed at the API layer; this is the matching scheduler-level skip. + if self._is_resurrectable_fn is not None: + try: + if not self._is_resurrectable_fn(agent_name): + return + except Exception as e: + # Fail-open: if the precondition check itself errors, fall + # through to the existing path so we don't silently disable + # resurrection for everyone. + _log(f"scheduler: is_resurrectable_fn raised for {agent_name}: {e}") + # Trim attempts outside the window window_start = now - self.RESURRECTION_WINDOW_SECONDS attempts = [t for t in self._resurrection_attempts.get(agent_name, []) if t >= window_start] diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 46eccce8..2597b3fe 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -501,6 +501,74 @@ async def cb(agent_name, session_id): await scheduler._maybe_resurrect("ivan", "sid", future) assert len(called) == scheduler.RESURRECTION_MAX_ATTEMPTS + 1 + @pytest.mark.asyncio + async def test_resurrection_skipped_when_precondition_false(self, registry): + """is_resurrectable_fn returning False short-circuits before budget/log. + + Regression for the "watchdog spams 5/5 every 30s on idle-sleeping + agents" bug: the API callback used to be the only place idle-sleep + was checked, so the scheduler still consumed a budget slot and + emitted a log line for each tick. With is_resurrectable_fn, the + scheduler skips entirely. + """ + called = [] + + async def cb(agent_name, session_id): + called.append(agent_name) + + scheduler = AgentScheduler( + registry, + heartbeat_callback=cb, + is_resurrectable_fn=lambda name: False, # never resurrectable + ) + now = time.time() + for _ in range(scheduler.RESURRECTION_MAX_ATTEMPTS + 3): + await scheduler._maybe_resurrect("ivan", "sid", now) + + # Callback never fired + assert called == [] + # And budget was never consumed — the attempt list stays empty so a + # later state-change (agent stops idle-sleeping) gets the full quota. + assert scheduler._resurrection_attempts.get("ivan", []) == [] + + @pytest.mark.asyncio + async def test_resurrection_runs_when_precondition_true(self, registry): + """is_resurrectable_fn returning True preserves old behavior.""" + called = [] + + async def cb(agent_name, session_id): + called.append(agent_name) + + scheduler = AgentScheduler( + registry, + heartbeat_callback=cb, + is_resurrectable_fn=lambda name: True, + ) + now = time.time() + await scheduler._maybe_resurrect("ivan", "sid", now) + assert called == ["ivan"] + + @pytest.mark.asyncio + async def test_resurrection_precondition_failure_fails_open(self, registry): + """If is_resurrectable_fn raises, fall through (don't silently disable).""" + called = [] + + async def cb(agent_name, session_id): + called.append(agent_name) + + def bad_precondition(name): + raise RuntimeError("oops") + + scheduler = AgentScheduler( + registry, + heartbeat_callback=cb, + is_resurrectable_fn=bad_precondition, + ) + now = time.time() + await scheduler._maybe_resurrect("ivan", "sid", now) + # Fail-open: resurrection proceeds despite precondition exception + assert called == ["ivan"] + @pytest.mark.asyncio async def test_no_callback_means_no_crash(self, registry): """Dead agents are still legal even when no resurrection wiring exists.""" From 0dfabae585b407b901a04808debfff3b1270577f Mon Sep 17 00:00:00 2001 From: Oleg Date: Mon, 11 May 2026 16:02:35 -0700 Subject: [PATCH 24/24] chore: drop orphan imports from api.py post-reconcile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ruff --fix dropped 40 unused imports from `from pinky_daemon.api_models`. These came from the over-inclusive import I added when merging — the api_models refactor only used ~40 of the 84 model classes; the rest were referenced inline (now removed). Pushok caught it on the failing CI lint. Co-Authored-By: Claude Opus 4.7 --- src/pinky_daemon/api.py | 40 ---------------------------------------- 1 file changed, 40 deletions(-) diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index 0f5e6f3e..fa6d3a91 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -48,45 +48,25 @@ from pinky_daemon.agent_registry import AgentRegistry from pinky_daemon.analytics_store import AnalyticsStore from pinky_daemon.api_models import ( - AddCommentRequest, AddDirectiveRequest, - AddLinkedAssetRequest, AddMcpServerRequest, - AddRelationshipRequest, AddScheduleRequest, - AddTeamMemberRequest, AgentMessageRequest, AgentStatusRequest, ApproveUserRequest, - AssignResearchRequest, AssignSkillRequest, AuthLoginRequest, AuthSetupRequest, CloneWorkerRequest, - ConfigurePlatformRequest, ContextResponse, ConversationListResponse, - CreateAppRequest, CreateGroupRequest, - CreateMilestoneRequest, - CreatePresentationRequest, - CreateProjectRequest, - CreateResearchRequest, CreateSessionRequest, - CreateSkillFromMdRequest, - CreateSprintRequest, - CreateTaskRequest, - CreateTemplateRequest, - CreateTriggerRequest, - DeployAppRequest, EffortDriftRequest, FederationPeerUpsertRequest, ForkSessionRequest, - GeneratePresentationRequest, HistoryResponse, - InstallSkillFromGitRequest, JoinGroupRequest, - KBIngestRequest, MeshAllowlistEntryRequest, MeshAllowlistSetRequest, MeshSendRequest, @@ -97,41 +77,21 @@ PushEventRequest, RecordHeartbeatRequest, RegisterAgentRequest, - RegisterSkillRequest, RestartResponse, - RestoreVersionRequest, SearchResponse, SendAgentMessageRequest, SendMessageRequest, SessionResponse, - SessionSkillRequest, SetAgentTokenRequest, - SetAppPasswordRequest, SetContextRequest, SetDefaultProviderRequest, SetMainAgentRequest, SetModelRequest, - SetPresentationPasswordRequest, - SetVisibilityRequest, SpawnSessionRequest, - SubmitBriefRequest, - SubmitReviewRequest, UpdateAgentRequest, - UpdateAppRequest, UpdateHeartbeatPromptRequest, UpdateMcpServerRequest, - UpdateMilestoneRequest, UpdatePasswordRequest, - UpdatePresentationRequest, - UpdateProfileEntry, - UpdateProjectRequest, - UpdateResearchRequest, - UpdateSkillRequest, - UpdateSprintRequest, - UpdateTaskRequest, - UpdateTriggerRequest, - UpsertProfileEntry, - WikiSaveRequest, ) from pinky_daemon.app_store import AppStore from pinky_daemon.auth import (