Conversation
Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
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.
…rsistence (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
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 <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…#383) * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 -- <file>` 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 <noreply@anthropic.com>
…r-notify 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=<id-1>`. 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 <noreply@anthropic.com>
…scape 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
fix(admin): rebuild deps for system-python (rebase of #323 + integration tests)
…ls break (#400) * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
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 <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…age.api_error_status (#403) (#404) * 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 <noreply@anthropic.com> --------- Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
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 <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…#413) (#414) * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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:<peer> 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 <noreply@anthropic.com> --------- Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…418) (#423) 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 <oleg@Olegs-Mac-mini.local>
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 <oleg@Olegs-Mac-mini.local>
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.
…gents (#431) * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
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 <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Resolves the divergence between beta and main caused by hotfixes landing straight on main (#416 SDK floor, #411 discord backport, #407 Docker, #408/#398/#397 broker fixes, #406 release/auth-alerts). Beta had 25 commits not on main and main had 10 commits not on beta. Conflict resolution: - pyproject.toml: claude-agent-sdk >=0.1.80 (main's bump wins) - agent_registry.py: kept beta additions (peer_fleet_acl, mesh_outbound_allowlist, strict_effort_enforcement) layered onto main's structure - api.py: ~85 inline BaseModel class defs (beta legacy) removed in favor of main's api_models.py refactor. Added 7 beta-only classes to api_models.py: EffortDriftRequest, FederationPeerUpsertRequest, MeshAllowlistEntryRequest, MeshAllowlistSetRequest, MeshSendRequest, PeerFleetAclEntryRequest, PeerFleetAclSetRequest - api_models.py: RegisterAgentRequest + UpdateAgentRequest gained strict_effort_enforcement field (PR #429) - send_voice / send_gif / send_document handlers: took main's outreach-outcome-enum versions (rejected/error_upstream/error_internal buckets) over beta's simpler error handling - /admin/update: kept beta's #432 auto-deps drift detection (_check_installed_deps_drift) on top of main's deps install logic - tests/test_admin_update.py: kept beta's TestInstalledDepsDriftDetection Local test run: 1835 passed (pinky_federation skipped — pynacl not installed on dev machine). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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 <noreply@anthropic.com>
chore: sync beta with main (closes #449)
| # 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 "" |
| # 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) |
|
|
||
| AgentRegistry._sync_hooks_settings( | ||
| claude_dir / "settings.json", | ||
| working_path=working_path.resolve(), |
| AgentRegistry._sync_hooks_settings( | ||
| claude_dir / "settings.json", | ||
| working_path=working_path.resolve(), | ||
| idle_path=idle_path.resolve(), |
| claude_dir / "settings.json", | ||
| working_path=working_path.resolve(), | ||
| idle_path=idle_path.resolve(), | ||
| verify_effort_path=verify_effort_path.resolve(), |
| # 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()) |
| target_bucket.setdefault("hooks", []).append( | ||
| {"type": "command", "command": verify_cmd} | ||
| ) | ||
| settings_path.write_text(_json.dumps(data, indent=2) + "\n") |
| _log(f"codex[{self.agent_name}]: reconnect attempt {attempt_idx} failed: {e}") | ||
| try: | ||
| await self.disconnect() | ||
| except Exception: |
| # 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 |
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Rollup release bringing beta into main as part of Phase 1 of trunk-based migration (#450).
Highlights from this rollup:
After this merges, main will be the source of truth for the channel-logic cleanup PR + tag cut (26.05.072).
Test plan
🤖 Opened by Barsik