Skip to content

feat(presence): server-side last_seen stamping for non-CC agents - #280

Merged
olegbrok merged 2 commits into
betafrom
barsik/server-presence-tracking
Apr 19, 2026
Merged

feat(presence): server-side last_seen stamping for non-CC agents#280
olegbrok merged 2 commits into
betafrom
barsik/server-presence-tracking

Conversation

@olegbrok

Copy link
Copy Markdown
Collaborator

Summary

Fixes #279 — non-CC agents (Codex, GLM) appeared offline because they never call the send_heartbeat MCP tool. Presence is now stamped server-side on real activity (turn completion, inbound message delivery) so the registry reflects actual liveness regardless of whether the agent manually heartbeats.

Changes

  • agent_registry.py — new agents.last_seen_at REAL column via _ensure_columns migration; AgentRegistry.stamp_last_seen(name, ts=None) helper.
  • api.py_agent_presence now returns max(agent.last_seen_at, heartbeat.timestamp); registry plumbed into session init.
  • broker.py — stamp after successful inject_agent_message send and inbound _route_streaming delivery.
  • streaming_session.py — stamp once after turn lifecycle (both success and error paths).
  • codex_session.py — same pattern on turn.completed / turn.failed for Codex/GLM.
  • Stamp failures are logged but never block delivery or turn completion — presence is telemetry, not critical path.

Test plan

  • pytest tests/test_agent_registry.py tests/test_broker.py — 47 passed
  • Full suite: 1475 passed, 1 skipped (pinky_federation skipped — PyNaCl missing, pre-existing)
  • Ruff clean on touched files
  • Manual verify: Codex/GLM agent shows online in dashboard after a turn without calling send_heartbeat

Notes

  • register() INSERT untouched — DEFAULT 0 on the new column handles inserts automatically.
  • Turn stamp collapsed into a single post-branch call (vs duplicating at turn_completed and turn_error) since both prove the pipe is alive.

Closes #279

🤖 Opened by Barsik

Oleg and others added 2 commits April 19, 2026 05:36
Non-CC agents (Codex, GLM) never call the send_heartbeat MCP tool, so
their presence showed stale despite active streaming sessions. Stamp
agents.last_seen_at server-side on every evidence of a working pipe:
broker delivery (inbound + inter-agent), and turn completion/error in
both StreamingSession and CodexSession.

- Add last_seen_at column + Agent.last_seen_at field + stamp_last_seen()
- _agent_presence takes max(server_ts, heartbeat_ts) — heartbeat path
  preserved so CC agents keep working unchanged
- Plumb AgentRegistry into StreamingSession/CodexSession via new
  `registry` kwarg (mirrors analytics_store pattern)

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
Addresses murzik's PR #280 review findings:

1. _agent_presence() status branch previously returned "unknown" for any
   agent without a heartbeat — even when last_seen_at was recently stamped
   by server-side activity. Status now falls through to server_ts when no
   fresher heartbeat exists, using the same online/idle/offline age bands.
   Heartbeat logic still wins when hb_ts >= server_ts so dead/non-alive
   heartbeats aren't masked by an older server stamp.

2. codex_session "error" event path now stamps last_seen. Previously only
   turn.completed and turn.failed stamped, so transport/runtime errors
   surfaced via the generic error event missed a real liveness signal.

Tests:
- tests/test_api.py — 5 new presence tests (server-stamped online/idle/
  offline, no-stamp-no-hb unknown, heartbeat-wins-when-fresher)
- tests/test_codex_session.py — 3 new stamp tests (error event stamps,
  plus regression guards for turn.completed and turn.failed)

1637 passed, ruff clean.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
@olegbrok

Copy link
Copy Markdown
Collaborator Author

Review: LGTM ✅ (Pushok)

Clean, well-scoped change.

What it does: Server-side last_seen_at stamping for non-CC agents (Codex/murzik) so presence reflects actual activity instead of going stale. Three stamp sites cover the full surface: broker delivery, codex turn events, streaming turn events.

Key observations:

  • max(server_ts, hb_ts) in the presence endpoint is correct — CC agents with heartbeats aren't degraded, non-heartbeat agents get proper presence.
  • Double-stamp (broker delivery → turn completion) is intentional and fine — last-write-wins, second stamp is more accurate.
  • stamp_last_seen on missing agent is a silent no-op (zero-row UPDATE), confirmed by test.
  • Error vs success paths each stamp exactly once (error branch continues before the success stamp).

Tests: 10 new tests across 4 files covering online/idle/offline/unknown/heartbeat-wins, broker delivery positive+negative, and all three codex event types.

Ship it. 🚢

— Pushok

@olegbrok
olegbrok merged commit 2c53816 into beta Apr 19, 2026
8 checks passed
@olegbrok
olegbrok deleted the barsik/server-presence-tracking branch April 19, 2026 13:33
olegbrok added a commit that referenced this pull request Apr 22, 2026
…me, voice, outreach) (#313)

* chore(deps): bump actions/checkout from 4 to 6 (#275)

Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](actions/checkout@v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump actions/setup-node from 4 to 6 (#276)

Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](actions/setup-node@v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump actions/setup-python from 5 to 6 (#277)

Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](actions/setup-python@v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the npm-minor-patch group (#278)

Bumps the npm-minor-patch group in /frontend-svelte with 3 updates: [svelte-spa-router](https://github.com/ItalyPaleAle/svelte-spa-router), [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `svelte-spa-router` from 5.0.0 to 5.0.1
- [Release notes](https://github.com/ItalyPaleAle/svelte-spa-router/releases)
- [Changelog](https://github.com/ItalyPaleAle/svelte-spa-router/blob/main/CHANGELOG.md)
- [Commits](ItalyPaleAle/svelte-spa-router@v5.0.0...v5.0.1)

Updates `svelte` from 5.55.0 to 5.55.4
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.55.4/packages/svelte)

Updates `vite` from 8.0.3 to 8.0.8
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.8/packages/vite)

---
updated-dependencies:
- dependency-name: svelte-spa-router
  dependency-version: 5.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: svelte
  dependency-version: 5.55.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: vite
  dependency-version: 8.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(presence): server-side last_seen stamping for non-CC agents (#280)

* feat(presence): server-side last_seen stamping for non-CC agents (#279)

Non-CC agents (Codex, GLM) never call the send_heartbeat MCP tool, so
their presence showed stale despite active streaming sessions. Stamp
agents.last_seen_at server-side on every evidence of a working pipe:
broker delivery (inbound + inter-agent), and turn completion/error in
both StreamingSession and CodexSession.

- Add last_seen_at column + Agent.last_seen_at field + stamp_last_seen()
- _agent_presence takes max(server_ts, heartbeat_ts) — heartbeat path
  preserved so CC agents keep working unchanged
- Plumb AgentRegistry into StreamingSession/CodexSession via new
  `registry` kwarg (mirrors analytics_store pattern)

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

* fix(presence): honor server stamp in status + stamp on codex error event

Addresses murzik's PR #280 review findings:

1. _agent_presence() status branch previously returned "unknown" for any
   agent without a heartbeat — even when last_seen_at was recently stamped
   by server-side activity. Status now falls through to server_ts when no
   fresher heartbeat exists, using the same online/idle/offline age bands.
   Heartbeat logic still wins when hb_ts >= server_ts so dead/non-alive
   heartbeats aren't masked by an older server stamp.

2. codex_session "error" event path now stamps last_seen. Previously only
   turn.completed and turn.failed stamped, so transport/runtime errors
   surfaced via the generic error event missed a real liveness signal.

Tests:
- tests/test_api.py — 5 new presence tests (server-stamped online/idle/
  offline, no-stamp-no-hb unknown, heartbeat-wins-when-fresher)
- tests/test_codex_session.py — 3 new stamp tests (error event stamps,
  plus regression guards for turn.completed and turn.failed)

1637 passed, ruff clean.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

---------

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>

* fix(memory): distinguish zero-norm query embedding from empty results (#285) (#297)

search_by_embedding silently returned [] when the query embedding had
zero norm, making it impossible for callers to distinguish a broken
embedding from a legitimate 'no matches found' result.

- Add InvalidQueryEmbeddingError(ValueError) in pinky_memory.store.
- search_by_embedding_scored raises on empty or zero-norm queries.
- Inner _search_by_numpy keeps a defense-in-depth raise on zero-norm.
- MCP recall() in pinky_memory.server catches and logs the error before
  falling back to keyword search, so broken embeddings surface in logs
  instead of vanishing.
- Add regression tests covering zero-norm and empty query embeddings.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>

* fix(outreach): let httpx set Discord multipart content type (#283) (#298)

* fix(outreach): let httpx set Discord multipart content type (#283)

Discord file uploads crashed because the adapter set
`Content-Type: application/json` as a Client-level default, then tried to
override it per-request with `headers={"Content-Type": None}`. httpx
rejects `None` as a header value and raises before sending, so
DiscordAdapter.send_file() never worked.

- Drop the Client-level Content-Type default; httpx now sets the correct
  content type per request (application/json for json= bodies,
  multipart/form-data with boundary for files= bodies).
- Remove the invalid `Content-Type: None` override in send_file().
- Add a regression test that exercises the real send_file() path via
  httpx.MockTransport and asserts a proper multipart request is emitted.

Co-Authored-By: Murzik <noreply@pinkybot.ai>
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

* style: ruff isort fix for test_discord.py

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

---------

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Murzik <noreply@pinkybot.ai>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>

* fix(memory): validate and log embed() failures on reflect/recall (#291) (#299)

* fix(memory): validate and log embed() failures on reflect/recall (#291)

embed() failures (empty returns or raised exceptions) previously stored
empty embeddings silently. Downstream semantic search quietly missed
those memories with no signal that anything was wrong.

- Add _safe_embed() helper in pinky_memory.server that:
  - Skips the call entirely for NoOpEmbeddingClient (expected downgrade)
  - Catches exceptions from real embedders and logs with type + message
  - Logs a "degraded" warning when a non-NoOp embedder returns []
- reflect() now surfaces `embedded: bool` in its JSON response so callers
  can see when a memory was stored without a usable vector.
- recall() routes through the same helper — failures no longer masquerade
  as "no matches"; keyword fallback proceeds with a visible log entry.
- Add regression tests for: NoOp embedder, real embedder returning value,
  real embedder returning [] (degraded log), and embedder raising.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

* fix(memory): treat zero-norm and non-finite embeddings as degraded (#291)

Murzik review: a real embedder returning a non-empty but zero-norm (e.g.,
[0.0, ..., 0.0]) or non-finite (NaN/inf) vector passed the previous
`if not embedding` check, so reflect() reported embedded=True and stored
an unusable vector. Downstream search then silently skipped those rows
via the existing zero-norm row guard in _search_by_numpy — reproducing
the original silent-degrade bug with different inputs.

- Extend _safe_embed() to validate non-empty vectors: zero-norm or any
  non-finite component → log "degenerate" and return [] so reflect()
  reports embedded=False and stores no vector.
- Add regression tests for [0.0]*8 and NaN-containing embeddings.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
Co-Authored-By: Murzik <noreply@pinkybot.ai>

---------

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
Co-authored-by: Murzik <noreply@pinkybot.ai>

* fix(memory): log sqlite-vec query failures before numpy fallback (#290) (#300)

sqlite-vec query exceptions were swallowed and _search_by_numpy was
invoked silently. Users got slower, possibly differently ordered search
with no signal that vec search was broken.

- Log a WARNING via pinky_memory.store logger with exception type and
  message before falling back to numpy.
- Track a per-instance `_vec_fallback_count` bumped on every fallback.
- Surface `vec_available` and `vec_fallback_count` in introspect() so
  health checks / dashboards can detect a degraded vec backend.
- Regression test simulates a vec failure via a connection wrapper and
  asserts the warning is logged, counter incremented, introspect reports
  it, and numpy fallback still returns results.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>

* fix(outreach): reject failed WhatsApp media downloads (#284) (#302)

WhatsAppAdapter.download_file() wrote the raw response body to disk
without checking the second-stage media GET status. 401/404 responses
(HTML or Graph API error JSON) were persisted as wa_<media_id>.<ext>
files, silently corrupting the media cache.

- Raise WhatsAppError on any status_code >= 400 from the media download.
- Extract Graph API error message/code when the response is JSON; fall
  back to the raw HTTP status otherwise.
- Existing happy-path test now asserts status_code=200 explicitly so the
  new guard is exercised.
- New regression asserts a 401 Expired-token response raises with the
  correct error_code/message and leaves no wa_<id>.* file behind.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Murzik <noreply@pinkybot.ai>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>

* fix(outreach): omit null Telegram media reply ids (#288) (#303)

Telegram media senders (send_photo, send_document, send_animation) now
filter None-valued fields from multipart form data. Passing
reply_to_message_id=None was being serialized as the literal string
"None" in the multipart body, which Telegram's API rejected or treated
as an invalid message id.

Patch authored by Murzik; applied + committed with attribution.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Murzik <noreply@anthropic.com>

* fix(self): encode KB tool URL parameters (#292) (#304)

KB self-tools now URL-encode search query params and path variables:
- `kb_search` builds its querystring via urllib.parse.urlencode so that
  queries containing `&`, `#`, `?`, or spaces round-trip correctly
  instead of silently injecting into the request URI.
- `kb_get_wiki` / `kb_save_wiki` / `kb_delete_wiki` quote the slug with
  `safe="/"` — nested slugs like `topics/llm-knowledge-bases` still
  work, but spaces / specials inside a segment are escaped.
- `kb_delete_raw` / `kb_update_raw` quote the raw source id as a single
  path segment (`safe=""`), so ids containing `/` or other specials
  no longer break routing.

Added regressions for query injection, wiki slug specials, and raw
source IDs containing `/` + space.

Patch authored by Murzik; applied + committed with attribution.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Murzik <noreply@anthropic.com>

* fix(calendar): return tool error for invalid event dates (#296) (#305)

`get_events()` now parses start_date / end_date inside its error
boundary and returns JSON `{error: ...}` for invalid ISO dates
instead of letting `ValueError` escape the MCP tool (which surfaces
as an unhandled exception to the caller).

Added a new `tests/test_calendar_server.py` with a regression for
invalid `start_date`.

Patch authored by Murzik; applied + committed with attribution.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Murzik <noreply@anthropic.com>

* fix(voice): guard finalize_call against None session under expiry races (#286) (#307)

`voice_routes.voice_websocket` was handing `_voice_store.get_session(...)`
directly to `finalize_call()` in the WS disconnect path. `get_session()`
can legitimately return None under session-expiry races (session was
cleaned up, or was never committed after an earlier failure), and
`finalize_call()`'s first line accesses `session.call_sid` — boom,
`AttributeError: 'NoneType' object has no attribute 'call_sid'`, and the
whole finalize path (Opus review + artifact + owner notification) breaks.

Two changes, belt + suspenders:

1. Call site (`voice_routes.py`): look up the session before scheduling
   `finalize_call`, skip + log if it's gone. This prevents the task from
   being scheduled in the first place.

2. Callee (`voice_engine.py::finalize_call`): accept None defensively and
   early-return with a log line. Any future caller that forgets the guard
   gets a clean no-op instead of a crash.

Added tests/test_voice_engine.py covering:
- `finalize_call(None, ...)` no-ops and does not touch voice_store / agents / broker
- `finalize_call(real_session, ...)` still proceeds past the guard

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>

* fix(datetime): replace deprecated utcnow() + naive now() with tz-aware (#294) (#308)

`datetime.utcnow()` is deprecated as of Python 3.12 (will emit
DeprecationWarning and eventually be removed). Naive `datetime.now()`
silently picks up local tz, which breaks any cross-tz serialization
or comparison and — in the case of streaming_session.py:300 — labelled
a local-time string as "UTC".

Five call sites swept:

- `pinky_daemon/api.py:2946` — version string now uses `now(timezone.utc)`
  so the built version is deterministic regardless of host tz.
- `pinky_daemon/api.py:6966` — web/DM timestamp fallback uses `now(utc)`.
- `pinky_daemon/api.py:7026` — broker-message timestamp fallback uses `now(utc)`.
- `pinky_daemon/streaming_session.py:300` — fallback labelled UTC now
  actually uses UTC (was implicit local-tz bug).
- `pinky_memory/kg_extractor.py:449` — hoisted out of the per-triple loop
  into a single cached `_now_ymd` value per `_resolve_functional_conflict`
  call. Already tz-aware; this is just the "cache once per call" cleanup
  flagged in the issue.

Verified: `grep -n 'datetime\\.utcnow\\|datetime\\.now()'` returns only
comments. Full test suite for touched areas (test_api, test_kg_extractor,
test_streaming_session) — 305 passed.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>

* fix(memory): handle corrupt embedding JSON in dim detection + backfill (#289) (#301)

_detect_embedding_dimensions() and _backfill_vec() both called json.loads()
on stored embedding blobs with no guard. A single corrupted row crashed
dimension detection (blocking startup) or logged nothing in backfill.

- _detect_embedding_dimensions() now scans up to 50 newest-first rows,
  skips malformed JSON with a logger.warning carrying the reflection id,
  and returns the first valid embedding's length.
- _backfill_vec() catches ValueError/TypeError on json.loads, logs the
  reflection id, counts corrupt rows, and reports them in the completion
  message. The bare `except Exception: pass` on the insert step is
  replaced with a targeted sqlite3.Error catch + debug log.
- Regression test corrupts one row's embedding, makes it the newest,
  asserts _detect_embedding_dimensions still returns 8 from the good
  row and that the bad row id appears in the warning log.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>

* fix(memory): replace bare excepts with typed exceptions in store.py (#295) (#309)

Closes #295. Replaces 10 `except:` / `except Exception:` sites with
specific exception classes (mostly `sqlite3.OperationalError`) plus
logger calls at appropriate severity levels. Preserves fallback
semantics — callers still get numpy/LIKE fallbacks, migrations stay
idempotent. The one site calling user-supplied `llm_classify` keeps
a broad catch with a noqa BLE001 + justification since the callable
can raise anything.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Murzik <noreply@anthropic.com>

* fix(oauth): validate state nonce on legacy Google Calendar callback (#287) (#306)

* fix(oauth): validate state nonce on legacy Google Calendar callback (#287)

**Security fix (high).** The legacy `/calendar/google/callback` endpoint
accepted `state` but never compared it against any stored value before
exchanging the authorization code and saving tokens. A crafted callback
hitting the owner's browser with an attacker-controlled code could bind
the wrong Google account.

Changes:
- New `/calendar/google/direct-auth-url` endpoint that calls the existing
  `pinky_calendar.oauth.get_auth_url()` *and* persists the returned state
  nonce with an ISO timestamp under `GOOGLE_OAUTH_STATE_{nonce}` (TTL 10m).
- `/calendar/google/callback` now requires + validates + single-use-consumes
  the state nonce before any client-credential lookup or code exchange.
  Missing / unknown / replayed / expired / corrupt / future-dated states
  are all hard-rejected with HTTP 400 + a readable HTML error page.
- State is deleted immediately on consumption (even on exchange failure
  or replay attempt), preventing TOCTOU reuse.
- `pinky_calendar.oauth.exchange_code`: updated the misleading
  "validated by caller" marker to a real SECURITY note that spells out
  the caller's obligation.

Tests (`TestGoogleOAuthStateValidation`): missing state, unknown/forged
state, expired state (purged afterward), replay rejection, state
consumption on exchange failure, direct-auth-url state persistence,
direct-auth-url credential gate.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

* fix(oauth): fail closed on state consume + reject naive timestamps (#287)

Addresses Murzik's review on PR #306:

**High — consume must fail closed.** `_consume_google_oauth_state()`
was ignoring both the return value of `delete_setting()` and any
exception it raised. That meant:
- If a concurrent consumer won the race (e.g. legitimate callback +
  attacker replay fired in parallel), *both* could proceed past the
  validation check.
- If deletion itself failed (DB locked, disk full, any OperationalError),
  the request still proceeded to token exchange.

DELETE is now the authoritative consume step:
- Returns False (row not deleted) → "unknown or replayed state"
- Raises → "could not consume state"
Either way, `exchange_code()` is never invoked.

**Medium — naive timestamps.** `datetime.fromisoformat()` accepts ISO
strings without a tz suffix and returns a naive datetime. Subtracting a
naive dt from an aware `now()` raises TypeError → the endpoint 500s
instead of returning the promised 400. We only ever *issue* aware UTC
timestamps, so naive records now hard-reject as "corrupt state record".

Regressions added:
- `test_consume_fails_closed_when_delete_returns_false` — simulates
  concurrent consumer, asserts callback 400 + `exchange_code` not called.
- `test_consume_fails_closed_when_delete_raises` — simulates DB failure,
  asserts callback 400 + `exchange_code` not called.
- `test_callback_rejects_naive_timestamp_state` — seeds a naive ISO
  string, asserts 400 + row purged.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

---------

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
Co-authored-by: Murzik <noreply@pinkybot.ai>
olegbrok added a commit that referenced this pull request Apr 25, 2026
* chore(deps): bump actions/checkout from 4 to 6 (#275)

Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](actions/checkout@v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump actions/setup-node from 4 to 6 (#276)

Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](actions/setup-node@v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump actions/setup-python from 5 to 6 (#277)

Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](actions/setup-python@v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the npm-minor-patch group (#278)

Bumps the npm-minor-patch group in /frontend-svelte with 3 updates: [svelte-spa-router](https://github.com/ItalyPaleAle/svelte-spa-router), [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `svelte-spa-router` from 5.0.0 to 5.0.1
- [Release notes](https://github.com/ItalyPaleAle/svelte-spa-router/releases)
- [Changelog](https://github.com/ItalyPaleAle/svelte-spa-router/blob/main/CHANGELOG.md)
- [Commits](ItalyPaleAle/svelte-spa-router@v5.0.0...v5.0.1)

Updates `svelte` from 5.55.0 to 5.55.4
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.55.4/packages/svelte)

Updates `vite` from 8.0.3 to 8.0.8
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.8/packages/vite)

---
updated-dependencies:
- dependency-name: svelte-spa-router
  dependency-version: 5.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: svelte
  dependency-version: 5.55.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: vite
  dependency-version: 8.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(presence): server-side last_seen stamping for non-CC agents (#280)

* feat(presence): server-side last_seen stamping for non-CC agents (#279)

Non-CC agents (Codex, GLM) never call the send_heartbeat MCP tool, so
their presence showed stale despite active streaming sessions. Stamp
agents.last_seen_at server-side on every evidence of a working pipe:
broker delivery (inbound + inter-agent), and turn completion/error in
both StreamingSession and CodexSession.

- Add last_seen_at column + Agent.last_seen_at field + stamp_last_seen()
- _agent_presence takes max(server_ts, heartbeat_ts) — heartbeat path
  preserved so CC agents keep working unchanged
- Plumb AgentRegistry into StreamingSession/CodexSession via new
  `registry` kwarg (mirrors analytics_store pattern)

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

* fix(presence): honor server stamp in status + stamp on codex error event

Addresses murzik's PR #280 review findings:

1. _agent_presence() status branch previously returned "unknown" for any
   agent without a heartbeat — even when last_seen_at was recently stamped
   by server-side activity. Status now falls through to server_ts when no
   fresher heartbeat exists, using the same online/idle/offline age bands.
   Heartbeat logic still wins when hb_ts >= server_ts so dead/non-alive
   heartbeats aren't masked by an older server stamp.

2. codex_session "error" event path now stamps last_seen. Previously only
   turn.completed and turn.failed stamped, so transport/runtime errors
   surfaced via the generic error event missed a real liveness signal.

Tests:
- tests/test_api.py — 5 new presence tests (server-stamped online/idle/
  offline, no-stamp-no-hb unknown, heartbeat-wins-when-fresher)
- tests/test_codex_session.py — 3 new stamp tests (error event stamps,
  plus regression guards for turn.completed and turn.failed)

1637 passed, ruff clean.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

---------

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>

* fix(memory): distinguish zero-norm query embedding from empty results (#285) (#297)

search_by_embedding silently returned [] when the query embedding had
zero norm, making it impossible for callers to distinguish a broken
embedding from a legitimate 'no matches found' result.

- Add InvalidQueryEmbeddingError(ValueError) in pinky_memory.store.
- search_by_embedding_scored raises on empty or zero-norm queries.
- Inner _search_by_numpy keeps a defense-in-depth raise on zero-norm.
- MCP recall() in pinky_memory.server catches and logs the error before
  falling back to keyword search, so broken embeddings surface in logs
  instead of vanishing.
- Add regression tests covering zero-norm and empty query embeddings.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>

* fix(outreach): let httpx set Discord multipart content type (#283) (#298)

* fix(outreach): let httpx set Discord multipart content type (#283)

Discord file uploads crashed because the adapter set
`Content-Type: application/json` as a Client-level default, then tried to
override it per-request with `headers={"Content-Type": None}`. httpx
rejects `None` as a header value and raises before sending, so
DiscordAdapter.send_file() never worked.

- Drop the Client-level Content-Type default; httpx now sets the correct
  content type per request (application/json for json= bodies,
  multipart/form-data with boundary for files= bodies).
- Remove the invalid `Content-Type: None` override in send_file().
- Add a regression test that exercises the real send_file() path via
  httpx.MockTransport and asserts a proper multipart request is emitted.

Co-Authored-By: Murzik <noreply@pinkybot.ai>
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

* style: ruff isort fix for test_discord.py

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

---------

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Murzik <noreply@pinkybot.ai>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>

* fix(memory): validate and log embed() failures on reflect/recall (#291) (#299)

* fix(memory): validate and log embed() failures on reflect/recall (#291)

embed() failures (empty returns or raised exceptions) previously stored
empty embeddings silently. Downstream semantic search quietly missed
those memories with no signal that anything was wrong.

- Add _safe_embed() helper in pinky_memory.server that:
  - Skips the call entirely for NoOpEmbeddingClient (expected downgrade)
  - Catches exceptions from real embedders and logs with type + message
  - Logs a "degraded" warning when a non-NoOp embedder returns []
- reflect() now surfaces `embedded: bool` in its JSON response so callers
  can see when a memory was stored without a usable vector.
- recall() routes through the same helper — failures no longer masquerade
  as "no matches"; keyword fallback proceeds with a visible log entry.
- Add regression tests for: NoOp embedder, real embedder returning value,
  real embedder returning [] (degraded log), and embedder raising.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

* fix(memory): treat zero-norm and non-finite embeddings as degraded (#291)

Murzik review: a real embedder returning a non-empty but zero-norm (e.g.,
[0.0, ..., 0.0]) or non-finite (NaN/inf) vector passed the previous
`if not embedding` check, so reflect() reported embedded=True and stored
an unusable vector. Downstream search then silently skipped those rows
via the existing zero-norm row guard in _search_by_numpy — reproducing
the original silent-degrade bug with different inputs.

- Extend _safe_embed() to validate non-empty vectors: zero-norm or any
  non-finite component → log "degenerate" and return [] so reflect()
  reports embedded=False and stores no vector.
- Add regression tests for [0.0]*8 and NaN-containing embeddings.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
Co-Authored-By: Murzik <noreply@pinkybot.ai>

---------

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
Co-authored-by: Murzik <noreply@pinkybot.ai>

* fix(memory): log sqlite-vec query failures before numpy fallback (#290) (#300)

sqlite-vec query exceptions were swallowed and _search_by_numpy was
invoked silently. Users got slower, possibly differently ordered search
with no signal that vec search was broken.

- Log a WARNING via pinky_memory.store logger with exception type and
  message before falling back to numpy.
- Track a per-instance `_vec_fallback_count` bumped on every fallback.
- Surface `vec_available` and `vec_fallback_count` in introspect() so
  health checks / dashboards can detect a degraded vec backend.
- Regression test simulates a vec failure via a connection wrapper and
  asserts the warning is logged, counter incremented, introspect reports
  it, and numpy fallback still returns results.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>

* fix(outreach): reject failed WhatsApp media downloads (#284) (#302)

WhatsAppAdapter.download_file() wrote the raw response body to disk
without checking the second-stage media GET status. 401/404 responses
(HTML or Graph API error JSON) were persisted as wa_<media_id>.<ext>
files, silently corrupting the media cache.

- Raise WhatsAppError on any status_code >= 400 from the media download.
- Extract Graph API error message/code when the response is JSON; fall
  back to the raw HTTP status otherwise.
- Existing happy-path test now asserts status_code=200 explicitly so the
  new guard is exercised.
- New regression asserts a 401 Expired-token response raises with the
  correct error_code/message and leaves no wa_<id>.* file behind.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Murzik <noreply@pinkybot.ai>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>

* fix(outreach): omit null Telegram media reply ids (#288) (#303)

Telegram media senders (send_photo, send_document, send_animation) now
filter None-valued fields from multipart form data. Passing
reply_to_message_id=None was being serialized as the literal string
"None" in the multipart body, which Telegram's API rejected or treated
as an invalid message id.

Patch authored by Murzik; applied + committed with attribution.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Murzik <noreply@anthropic.com>

* fix(self): encode KB tool URL parameters (#292) (#304)

KB self-tools now URL-encode search query params and path variables:
- `kb_search` builds its querystring via urllib.parse.urlencode so that
  queries containing `&`, `#`, `?`, or spaces round-trip correctly
  instead of silently injecting into the request URI.
- `kb_get_wiki` / `kb_save_wiki` / `kb_delete_wiki` quote the slug with
  `safe="/"` — nested slugs like `topics/llm-knowledge-bases` still
  work, but spaces / specials inside a segment are escaped.
- `kb_delete_raw` / `kb_update_raw` quote the raw source id as a single
  path segment (`safe=""`), so ids containing `/` or other specials
  no longer break routing.

Added regressions for query injection, wiki slug specials, and raw
source IDs containing `/` + space.

Patch authored by Murzik; applied + committed with attribution.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Murzik <noreply@anthropic.com>

* fix(calendar): return tool error for invalid event dates (#296) (#305)

`get_events()` now parses start_date / end_date inside its error
boundary and returns JSON `{error: ...}` for invalid ISO dates
instead of letting `ValueError` escape the MCP tool (which surfaces
as an unhandled exception to the caller).

Added a new `tests/test_calendar_server.py` with a regression for
invalid `start_date`.

Patch authored by Murzik; applied + committed with attribution.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Murzik <noreply@anthropic.com>

* fix(voice): guard finalize_call against None session under expiry races (#286) (#307)

`voice_routes.voice_websocket` was handing `_voice_store.get_session(...)`
directly to `finalize_call()` in the WS disconnect path. `get_session()`
can legitimately return None under session-expiry races (session was
cleaned up, or was never committed after an earlier failure), and
`finalize_call()`'s first line accesses `session.call_sid` — boom,
`AttributeError: 'NoneType' object has no attribute 'call_sid'`, and the
whole finalize path (Opus review + artifact + owner notification) breaks.

Two changes, belt + suspenders:

1. Call site (`voice_routes.py`): look up the session before scheduling
   `finalize_call`, skip + log if it's gone. This prevents the task from
   being scheduled in the first place.

2. Callee (`voice_engine.py::finalize_call`): accept None defensively and
   early-return with a log line. Any future caller that forgets the guard
   gets a clean no-op instead of a crash.

Added tests/test_voice_engine.py covering:
- `finalize_call(None, ...)` no-ops and does not touch voice_store / agents / broker
- `finalize_call(real_session, ...)` still proceeds past the guard

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>

* fix(datetime): replace deprecated utcnow() + naive now() with tz-aware (#294) (#308)

`datetime.utcnow()` is deprecated as of Python 3.12 (will emit
DeprecationWarning and eventually be removed). Naive `datetime.now()`
silently picks up local tz, which breaks any cross-tz serialization
or comparison and — in the case of streaming_session.py:300 — labelled
a local-time string as "UTC".

Five call sites swept:

- `pinky_daemon/api.py:2946` — version string now uses `now(timezone.utc)`
  so the built version is deterministic regardless of host tz.
- `pinky_daemon/api.py:6966` — web/DM timestamp fallback uses `now(utc)`.
- `pinky_daemon/api.py:7026` — broker-message timestamp fallback uses `now(utc)`.
- `pinky_daemon/streaming_session.py:300` — fallback labelled UTC now
  actually uses UTC (was implicit local-tz bug).
- `pinky_memory/kg_extractor.py:449` — hoisted out of the per-triple loop
  into a single cached `_now_ymd` value per `_resolve_functional_conflict`
  call. Already tz-aware; this is just the "cache once per call" cleanup
  flagged in the issue.

Verified: `grep -n 'datetime\\.utcnow\\|datetime\\.now()'` returns only
comments. Full test suite for touched areas (test_api, test_kg_extractor,
test_streaming_session) — 305 passed.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>

* fix(memory): handle corrupt embedding JSON in dim detection + backfill (#289) (#301)

_detect_embedding_dimensions() and _backfill_vec() both called json.loads()
on stored embedding blobs with no guard. A single corrupted row crashed
dimension detection (blocking startup) or logged nothing in backfill.

- _detect_embedding_dimensions() now scans up to 50 newest-first rows,
  skips malformed JSON with a logger.warning carrying the reflection id,
  and returns the first valid embedding's length.
- _backfill_vec() catches ValueError/TypeError on json.loads, logs the
  reflection id, counts corrupt rows, and reports them in the completion
  message. The bare `except Exception: pass` on the insert step is
  replaced with a targeted sqlite3.Error catch + debug log.
- Regression test corrupts one row's embedding, makes it the newest,
  asserts _detect_embedding_dimensions still returns 8 from the good
  row and that the bad row id appears in the warning log.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>

* fix(memory): replace bare excepts with typed exceptions in store.py (#295) (#309)

Closes #295. Replaces 10 `except:` / `except Exception:` sites with
specific exception classes (mostly `sqlite3.OperationalError`) plus
logger calls at appropriate severity levels. Preserves fallback
semantics — callers still get numpy/LIKE fallbacks, migrations stay
idempotent. The one site calling user-supplied `llm_classify` keeps
a broad catch with a noqa BLE001 + justification since the callable
can raise anything.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Murzik <noreply@anthropic.com>

* fix(oauth): validate state nonce on legacy Google Calendar callback (#287) (#306)

* fix(oauth): validate state nonce on legacy Google Calendar callback (#287)

**Security fix (high).** The legacy `/calendar/google/callback` endpoint
accepted `state` but never compared it against any stored value before
exchanging the authorization code and saving tokens. A crafted callback
hitting the owner's browser with an attacker-controlled code could bind
the wrong Google account.

Changes:
- New `/calendar/google/direct-auth-url` endpoint that calls the existing
  `pinky_calendar.oauth.get_auth_url()` *and* persists the returned state
  nonce with an ISO timestamp under `GOOGLE_OAUTH_STATE_{nonce}` (TTL 10m).
- `/calendar/google/callback` now requires + validates + single-use-consumes
  the state nonce before any client-credential lookup or code exchange.
  Missing / unknown / replayed / expired / corrupt / future-dated states
  are all hard-rejected with HTTP 400 + a readable HTML error page.
- State is deleted immediately on consumption (even on exchange failure
  or replay attempt), preventing TOCTOU reuse.
- `pinky_calendar.oauth.exchange_code`: updated the misleading
  "validated by caller" marker to a real SECURITY note that spells out
  the caller's obligation.

Tests (`TestGoogleOAuthStateValidation`): missing state, unknown/forged
state, expired state (purged afterward), replay rejection, state
consumption on exchange failure, direct-auth-url state persistence,
direct-auth-url credential gate.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

* fix(oauth): fail closed on state consume + reject naive timestamps (#287)

Addresses Murzik's review on PR #306:

**High — consume must fail closed.** `_consume_google_oauth_state()`
was ignoring both the return value of `delete_setting()` and any
exception it raised. That meant:
- If a concurrent consumer won the race (e.g. legitimate callback +
  attacker replay fired in parallel), *both* could proceed past the
  validation check.
- If deletion itself failed (DB locked, disk full, any OperationalError),
  the request still proceeded to token exchange.

DELETE is now the authoritative consume step:
- Returns False (row not deleted) → "unknown or replayed state"
- Raises → "could not consume state"
Either way, `exchange_code()` is never invoked.

**Medium — naive timestamps.** `datetime.fromisoformat()` accepts ISO
strings without a tz suffix and returns a naive datetime. Subtracting a
naive dt from an aware `now()` raises TypeError → the endpoint 500s
instead of returning the promised 400. We only ever *issue* aware UTC
timestamps, so naive records now hard-reject as "corrupt state record".

Regressions added:
- `test_consume_fails_closed_when_delete_returns_false` — simulates
  concurrent consumer, asserts callback 400 + `exchange_code` not called.
- `test_consume_fails_closed_when_delete_raises` — simulates DB failure,
  asserts callback 400 + `exchange_code` not called.
- `test_callback_rejects_naive_timestamp_state` — seeds a naive ISO
  string, asserts 400 + row purged.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

---------

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>

* docs: update Deploy section in CLAUDE.md (#314)

sync.sh/status.sh are no longer present in the repo. Replace with the
current deploy path: agents call update_and_restart() from pinky-self,
which honors PINKYBOT_CHANNEL to pick main vs beta. Also refresh the
Mac Mini LAN IP (.32 → .209) after the DHCP lease change on 2026-04-17.

Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>

* registry: add gpt-5.5 to OpenAI/Codex seed models

OpenAI shipped GPT-5.5 on 2026-04-23, available in Codex CLI via ChatGPT
sign-in auth today (API access coming "very soon"). Murzik was already
bumped to gpt-5.5 live via the API; this mirrors the change in the seed
list so fresh installs inherit it.

supports_thinking=0 to match existing OpenAI entries (reasoning effort
is driven by the agent-level thinking_effort field, which Codex maps to
model_reasoning_effort at spawn).

🤖 Opened by Barsik

* chore: bump claude-agent-sdk to >=0.1.68

Picks up:
- v0.1.68 (CLI v2.1.119)
- v0.1.67: trio compatibility fix (RuntimeError: no running event loop
  regression from v0.1.51 — uses sniffio-based dispatch). Adds explicit
  sniffio runtime dep.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
Co-authored-by: Murzik <noreply@pinkybot.ai>
@olegbrok

olegbrok commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

Fresh evidence 2026-07-02 ~04:03-04:13 UTC: Murzik received at least 4 replayed/out-of-order deliveries of my earlier inter-agent messages during tonight's review cycle (his acks explicitly flag them as 'seen as replayed context' / 'older stand-down note'). No functional harm — he re-derived current state each time — but it burns his turns and invites state confusion. Reminder: fix PR #831 is still OPEN/HELD (needs review + a daemon bounce to land; tonight's 26.07.001 bounce would have carried it). Worth queueing for the next release.

🤖 Opened by Barsik

olegbrok added a commit that referenced this pull request Jul 10, 2026
…280)

requester's inbox. But agents also reply explicitly via send_to_agent
(comms.send -> inbox directly), so a turn that did both delivered the
reply TWICE. The dup hadn't surfaced yet only because no agent-injected
turn had completed since #279 deployed; the next Murzik review verdict
would have doubled.

Make auto-route a fallback: suppress it when the responder already sent
an explicit (non-auto_routed) message to the requester since the turn
began. The turn boundary is captured at inject_agent_message time
((responder, requester) -> start ts), consumed by route_agent_reply.

- Backend-agnostic: no reliance on tool_uses (codex turns omit them).
- Never content-matches, so it can't drop a genuinely new reply.
- Missing marker (e.g. lost across a restart) or a probe error falls
  through to delivery — a stray dup is acceptable, a dropped verdict is
  not.
- Orphan markers swept by TTL on insert (bounded memory).
- Kill-switch PINKY_AGENT_REPLY_DEDUP (default on) restores #279.

New AgentComms.has_message_since() backs the probe. Counted via the
existing broker `deduped` stat. Tests cover suppress-on-explicit-send,
deliver-on-no-send, deliver-on-missing-marker (safety), killswitch, the
inject marker, and the comms probe (incl. legacy/empty metadata).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
olegbrok added a commit that referenced this pull request Jul 10, 2026
A failed per-call handoff produces no route-back turn, but the #280
dedup marker stamped before the send survived it. The orphan stayed
live for up to the marker TTL and any unrelated explicit
responder->requester message would then suppress a DIFFERENT
injection's distinct route-back — a dropped verdict, not a stray dup.

The unmark is identity-guarded (timestamp token) so an older failed
call can never pop a newer concurrent injection's marker, and dedup
still fails open to delivery when no marker is present. Regressions:
failed handoff leaves no marker and cannot suppress a distinct later
route-back; raising sends unmark and propagate; True-handoff behavior
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant