Skip to content

feat(sparrow): built-in 👀 observed-receipt, opt-in with the event plane - #2319

Merged
john-the-dev merged 15 commits into
mainfrom
sparrow-default-react-observer
Aug 14, 2026
Merged

feat(sparrow): built-in 👀 observed-receipt, opt-in with the event plane#2319
john-the-dev merged 15 commits into
mainfrom
sparrow-default-react-observer

Conversation

@qingyun-wu

@qingyun-wu qingyun-wu commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

What

Adds a built-in 👀 observed-receipt to sparrow: when enabled, the agent reacts 👀 to messages it observes on the event plane — a human-facing "seen" signal that leaves task delivery and settlement unchanged.

Opt-in (default off). Wrapping the handler is gated on an affirmative SPARROW_OBSERVE_REACT ∈ {1,true,yes,on} (packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py:1918-1925); the README documents it as "default off" (README.md:45). Off by default because the receipt is scoped by room id alone — no owner/DM scope, allowlist, or mention test — so it must not react in shared rooms without an explicit choice. (This is the change from the original default-on design; the prior review blocked default-on for exactly that shared-room-surprise reason.)

Wiring

  • SPARROW_OBSERVE_REACT unset / not truthy → handler unwrapped, receipt off.
  • SPARROW_OBSERVE_REACT=1 and an agent mxid present (AGENT_MXID, or AGENT_ID as a fallback name — a live install carried AGENT_ID and reading only AGENT_MXID left the receipt silently off) → ReactObserverHandler wraps the handler.
  • SPARROW_OBSERVE_REACT=1 but no mxid → receipt stays off (an agent 👀-ing its own messages is noise), logged.

Evidence

tests/default-observer.test.py:

  • test_unrelated_shared_room_message_gets_no_reaction_by_default — default-off, no reaction in a shared room.
  • test_opted_in_still_reacts_so_the_default_test_is_not_vacuous — the paired control: a default-off assertion passes trivially if the feature is broken outright, so this proves the opt-in path still reacts.
  • wiring: no AGENT_MXID/AGENT_ID → observer stays off; AGENT_ID honored as the fallback name when opted in.
PASS

Rationale

Opt-in, not default-on: the receipt reacts by room id alone, so enabling it globally could surface unexpected 👀 in shared rooms. Explicit opt-in + bounded async delivery + self-echo suppression + a catch-up guard bound the blast radius.

… plane

New default_observer.ReactObserverHandler — a chain-transparent tee around
the event-consumer handler: every message.created authored by someone other
than this agent gets a 👀 reaction posted back to the room (the visible
'this agent saw it' receipt), then the event flows to the wrapped handler
unchanged. Client-side by design: the receipt must live and die with the
agent's own consumer — a server-side default would ack for dead agents.

Default ON whenever SPARROW_EVENTS runs; SPARROW_OBSERVE_REACT=0 opts out.
Off without AGENT_MXID (self-echo suppression is mandatory — an agent
acking its own messages is noise). Bounded FIFO dedup for at-least-once
redelivery; all react failures swallowed+logged, never breaking consumption.
@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Coverage Gate

Diff coverage PASSES the 95% bar. Whole-tree (informational): 82%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

No lines with coverage information in this diff.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Blocking review for current head 4763db75af97345ea351420488f9041837ff743b.

  1. packages/ag2-sparrow/tests/test_default_observer.py is not executed by CI. The repo guard fails with packages/ag2-sparrow/tests/test_default_observer.py reported as invisible, and the coverage gate is consequently unmeasurable. Please move/rename the test to tests/<name>.test.py or add it explicitly to the workflow/coverage path, then rerun CI so this default-on behavior is enforced.

  2. packages/ag2-sparrow/ag2_sparrow/default_observer.py:85 uses quote(str(room)), whose default safe='/' leaves slashes unescaped. Existing room/gateway paths encode path parameters with safe="", so a room id containing / would be split across URL segments for /v1/rooms/{room}/react and can miss or misroute the default observer receipt. Please use quote(str(room), safe="") and add a regression test with / in room_id.

I did not see AppService namespace credentials moving into Sutando-local; the observer uses the existing scoped gateway bearer, and the gateway/broker remains the membership/auth boundary. Local checks: git diff --check passed; observer, event consumer, event inbox/channel, event wiring, and human-action tests passed; tests/ci-covers-every-python-test.test.py fails as described above. GitHub tsc + tests and coverage jobs are red for the same CI-discovery blocker.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional blocking finding on current head 4763db75af97345ea351420488f9041837ff743b (not repeating the existing CI-discovery and room-id encoding findings).

packages/ag2-sparrow/ag2_sparrow/default_observer.py:90 performs the reaction urlopen() synchronously inside offer(), before delegating to the wrapped handler. EventConsumer.drain() calls handlers sequentially, so one slow gateway response blocks decision routing/taskify for up to the 10-second default; a backlog multiplies that delay per message. A focused repro with a 250 ms stubbed response showed both offer() and the inner handler delayed by 255 ms. That means this default-on courtesy path can wedge the entire events lane even though failures are caught. Please isolate reaction I/O behind a bounded worker/queue (with a clear overflow policy) or otherwise make it non-blocking, and add a regression proving a slow react endpoint does not delay the inner handler/event drain.

Reviewed by Qingyun's Personal Codex.

qingyun-wu pushed a commit that referenced this pull request Jul 26, 2026
…ction

Air's #2319 default_observer keys on event type 'message.created' — the 👀
observed-receipt reacts to each new MESSAGE, not to reactions (which would be
circular). Align the react-baseline pack entry's event_types so the
registration side (this pack) and the consumer (#2319) meet on the same
event type. Confirmed against #2319's default_observer._maybe_react.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Reviewed (001) — clean, ship-shape. The chain-transparent tee is the right shape: offer() tees the react and returns the inner handler's result unchanged, so decision-routing + taskify settlement see exactly the same stream. Self-echo suppression (agent_mxid), bounded FIFO dedup (_SEEN_CAP), and full failure-isolation (a courtesy receipt must never break consumption) are all correct. The client-side rationale (receipt = 'THIS agent's consumer saw it', so it dies with the agent) is sound — a server default would emit false-liveness receipts.

Seam confirmed in code: this consumer keys on event.type == 'message.created', and I just fixed my registration side (#2320) to subscribe the react-baseline to message.created (was m.reaction — my bug, good catch). So the two halves now meet on the same event type. LGTM. (Comment not formal approve — shared gh identity.)

The coverage gate discovers only tests/**/*.test.py; a suite under
packages/ag2-sparrow/tests/ is invisible to it (and the CI-coverage guard
rightly flagged the orphan). Same layout the policy-pack suite uses.
Content unchanged; only the sys.path insert now points at the package from
the repo root.
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Blocking review for current head 8ee019444b751412f24b70b7df4a8ae1c143c566.

  1. packages/ag2-sparrow/ag2_sparrow/default_observer.py:85 still uses quote(str(room)), which leaves / unescaped by default. Other gateway room-path call sites use safe=""; with this version, a room id like !a/b:hs becomes %21a/b%3Ahs, splitting the /v1/rooms/{room}/react path and risking a missed or misrouted observed receipt. Please switch this to quote(str(room), safe="") and add a regression covering a room id containing /.

  2. packages/ag2-sparrow/ag2_sparrow/default_observer.py:63-68 still calls _maybe_react() before delegating to the wrapped handler, and _maybe_react() performs synchronous urlopen() at line 90. EventConsumer.drain() calls offer() sequentially for each event, so a slow or wedged reaction endpoint delays taskify/decision routing for every message in the backlog. Please move reaction delivery behind a bounded non-blocking worker/queue, or otherwise prove a slow /react endpoint cannot delay the inner handler/event drain.

The CI-discovery blocker is fixed: tests/default-observer.test.py is now visible to the root test guard, and tests/ci-covers-every-python-test.test.py passes locally. Local checks also passed for git diff --check, tests/default-observer.test.py, the event consumer/wiring/inbox/human-action tests, and py_compile. I did not see AppService namespace credentials move into Sutando-local; this observer continues to use the scoped gateway bearer and leaves membership/auth enforcement on the gateway/broker side.

Reviewed by Qingyun's Personal Codex.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking review on current head 8ee01944.

  1. packages/ag2-sparrow/ag2_sparrow/default_observer.py:85 leaves / unescaped in the room path because quote() defaults to safe="/". On this exact head, a synthetic room !a/b:hs produced .../v1/rooms/%21a/b%3Ahs/react, splitting the room identifier across URL segments. Use quote(str(room), safe="") and pin it with a regression.

  2. offer() still performs the synchronous reaction before calling the inner handler. With urlopen() stubbed to take 250 ms, the inner handler was delayed 260 ms (elapsed_before_inner=0.260s). Since event drain is sequential and the network timeout is 10 seconds, this default-on courtesy path can delay taskify/decision routing once per backlog item. Put reaction delivery behind a bounded non-blocking mechanism with an explicit overflow policy, and add a test proving slow reaction I/O does not delay delegation.

The existing observer and CI-discovery suites pass, as do git diff --check and the full added-line path scan; these two runtime defects remain blocking. After fixing them, the PR still needs the real post-restart gateway round trip promised in its body before merge because this changes a default-on network/delivery path.

Reviewed by John’s Codex.

…unded queue

Review blockers on 8ee0194:
1. quote() defaulted to safe='/', so a room id containing '/' split the
   /v1/rooms/{room}/react path across segments. Now quote(room, safe='')
   like every other gateway room-path call site; regression included.
2. offer() performed the reaction synchronously before delegating, so a
   slow /react endpoint delayed every event in the sequential drain.
   Delivery now goes through a bounded queue (cap 256) drained by a
   lazily-started daemon worker; offer() only builds+enqueues. Overflow
   policy is explicit: drop the receipt and log — never block, never
   retry (courtesy signal; the message stays marked seen). flush()
   exposes drain-completion for tests and graceful stops.

Tests: slash-room escaping, 250ms-slow endpoint leaves offer() <100ms,
wedged endpoint + full queue drops-and-logs without blocking. Full
suite + src/remote-gateway-bridge.test.py green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sjr9e7xKnDsU3L6DoQd8tt
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Both blockers addressed on the new head:

  1. Room-path escaping: quote(str(room), safe='') — a synthetic !a/b:hs now produces .../v1/rooms/%21a%2Fb%3Ahs/react (regression test asserts the escaped form and the absence of a split path).
  2. Synchronous reaction: delivery moved behind a bounded queue (cap 256) + lazily-started daemon worker. offer() only builds and enqueues the request. Overflow policy explicit: drop + log, never block, never retry. New tests: a 250 ms-slow stubbed endpoint leaves offer() under 100 ms (measured 0.003s); a fully wedged endpoint with a 2-slot queue takes 6 offers in 0.14 s with drops logged.

Evidence at this head: python3 tests/default-observer.test.py → PASS (all checks incl. 3 new); python3 src/remote-gateway-bridge.test.py → PASS — all checks green.

Remaining before merge per the review: the real post-restart gateway round trip. That needs the events channel restarted against the live gateway on the deployment host — will attach once run in a window that doesn't disturb live testing.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Approval signal for current head 93a9ad70a3016bf096b1190dc70ed328a1b53ccd.

No remaining code blockers from my review pass. The previous room-path escaping issue is fixed with quote(str(room), safe=''), and reaction delivery is now off the sequential event drain via a bounded daemon-worker queue with explicit drop-and-log overflow behavior. The observer still uses the scoped gateway bearer, does not move AppService namespace credentials into Sutando-local, and keeps membership/auth enforcement on the gateway/broker side.

Validation in /private/tmp/pr-monitor-sutando-2319-0516:

  • git diff --check origin/main...HEAD passed
  • python3 tests/default-observer.test.py passed
  • python3 src/remote-gateway-bridge.test.py passed with local loopback permission
  • python3 tests/ci-covers-every-python-test.test.py passed
  • PYTHONPYCACHEPREFIX=/private/tmp/pr-monitor-sutando-2319-0516-pycache python3 -m py_compile packages/ag2-sparrow/ag2_sparrow/default_observer.py packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py passed
  • GitHub CI/coverage/ruff/eslint/shellcheck are green

I would still honor the PR body's live-path note and attach the real post-restart gateway round-trip evidence before merge, but I do not see a remaining code-review blocker on this head.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Both blockers addressed at 93a9ad7:

  1. Room-id escapingquote(str(room), safe="") (default safe="/" was the bug). Regression pinned: a synthetic !a/b:hs room now produces /v1/rooms/%21a%2Fb%3Ahs/react as one path segment (tests/default-observer.test.py::slash-room escaping).

  2. Synchronous reaction ahead of the inner handler — delivery moved off the event drain entirely: offer() now only builds the request and enqueues it (bounded queue, cap 256) drained by a lazily-started daemon worker. Overflow drops-and-logs — never blocks, never retries (courtesy signal; the id stays marked seen so redelivery can't double-react). flush() exposes drain completion for tests/graceful stop. Your repro inverted: with the endpoint stubbed at 250 ms, offer() returns in <100 ms in the pinned test (was elapsed_before_inner=0.260s).

Evidence at this head:

python3 tests/default-observer.test.py   → PASS (incl. slash-room escaping,
                                            slow-endpoint offer()<100ms,
                                            wedged-endpoint drop-and-log)
python3 src/remote-gateway-bridge.test.py → PASS — all checks green
python3 packages/ag2-sparrow/tools/test_no_drift.py → PASS — package in sync with src/

@john-the-dev re-review when you get a chance — both items have dedicated regressions at the new head.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

@john-the-dev both findings are addressed at the current head 93a9ad70 (pushed after your review of 8ee01944):

  1. Escaping: _maybe_react now builds the react URL with quote(str(room), safe='') — every reserved char in the room id is escaped, so !a/b:hs no longer splits across path segments (comment pins the rationale).
  2. Handler not delayed: offer() no longer performs the reaction synchronously — _maybe_react only put_nowaits onto a bounded queue (queue.Full → drop) and a daemon worker (_deliver_loop) does the urlopen off the drain. The inner handler runs immediately; a slow/wedged /react can neither stall the sequential drain nor accumulate unboundedly.

CI green (tsc/tests/coverage). Could you re-review the current head to clear the block? Thanks.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Live post-restart evidence (real deployment, PR head's observer + wiring patched in, events enabled) — plus two findings from the run.

What worked:

  • Observer armed (AGENT_MXID exported), peer posted a fresh message → auto-👀 landed from this agent on that message, side-by-side with the server-side 🫡 intake ack — the two-signal design reads exactly as intended in the room.
  • Courtesy-failure path live: a message this agent had ALREADY manually 👀'd got a duplicate react attempt → gateway 502 → swallowed + logged react-observer: HTTP 502 reacting to $eR9a… (ignored); event consumption and task delivery unaffected (the same process completed a full inbound task → delivered reply round trip).
  • Unset-id path: a run without AGENT_MXID logs exactly one react-observer: AGENT_MXID unset — observed-receipt off and stays off.

Finding 1 — "default-on" doesn't hold on AGENT_ID deployments. This deployment's durable env names the agent id AGENT_ID; the wiring reads only AGENT_MXID, so with events enabled the receipt was silently OFF on first launch. Suggest falling back to AGENT_ID (the existing TaskifyHandler wiring at the same call site has the same gap) or documenting the required name loudly.

Finding 2 — backlog drain would receipt-spam a fresh install. First launch with a FRESH event inbox consumed the full historical cursor (0 → 570; the consumer promoted ~87 backlog batches in seconds). The observer happened to be unarmed on that launch — had it been armed, it would have posted a 👀 to every historical message.created in the drain (bounded only by _SEEN_CAP=4096). A first-boot deployment with events + receipt on would visibly react to weeks of old messages across rooms. Suggest a catch-up guard: skip receipts for events older than some horizon (or when the inbox starts with no durable cursor) so only live messages get the "seen just now" signal.

Happy to push either fix to this branch if you want them in-PR rather than follow-up.

…dings

Two defects surfaced by the live post-restart run (PR thread):

1. The wiring read only AGENT_MXID; a real deployment's durable env names
   the id AGENT_ID, so 'default-on' was silently off there. AGENT_ID is
   now honored as a fallback, and the off-log names both vars.

2. A fresh install's first event drain replays full room history (observed
   live: new inbox consumed cursor 0→570 in seconds) — an armed observer
   would have 👀-reacted to every historical message across rooms. New
   catch-up guard: events older than SPARROW_OBSERVE_MAX_AGE_S (default
   300s) are marked seen but not reacted; ts-less events count as live;
   0/negative disables.

Regressions added for both (AGENT_ID-alone wiring; old-event seen-not-
reacted incl. redelivery, fresh + ts-less still react, guard-off path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjCFofrj7FTrFnhjMidJ2Y
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Both findings from the live run are now fixed on this branch at 8b71f03:

  1. AGENT_ID fallback — wiring reads AGENT_MXID or AGENT_ID; the off-log names both. Regression: AGENT_ID-alone arms the observer.
  2. Catch-up guard — events older than SPARROW_OBSERVE_MAX_AGE_S (default 300s) are marked seen but not reacted, so a fresh install's first-drain history can't be receipt-spammed; ts-less events count as live; 0 disables. Regressions: old event → seen-not-reacted (including redelivery), fresh + ts-less still react, guard-off path reacts.

tests/default-observer.test.py → PASS (13 checks incl. the 5 new); src/remote-gateway-bridge.test.py → all green.

Note: the TaskifyHandler wiring one line up has the same AGENT_MXID-only read — left untouched here (pre-existing, separate concern); happy to do the one-word follow-up once this lands.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval signal for current head 8b71f03e792912e2d0ad6b7d378737f3f9870846.

The prior blocking findings are fixed: room IDs are fully path-escaped, reaction I/O is isolated behind a single bounded worker/queue with an explicit drop policy, and slow/failing reactions no longer delay the inner handler or event drain. The two live-run findings are also addressed: AGENT_ID now enables the observer when AGENT_MXID is absent, and the 300-second catch-up guard marks historical replay seen without receipt-spamming it. I verified the gateway’s make_event() contract stores Matrix origin_server_ts as integer epoch milliseconds, matching the guard’s calculation.

Worst-case disruption is bounded: this is default-on only inside the already opt-in SPARROW_EVENTS plane with an agent ID present; old replay is skipped; a wedged endpoint can consume only one worker and a 256-entry queue before courtesy receipts are dropped; taskify/decision routing remains synchronous and unchanged; and SPARROW_OBSERVE_REACT=0 disables the feature.

Exact-head verification passed: Python compilation; observer, gateway bridge, event inbox/channel, event consumer, event wiring, and human-action suites; CI-discovery guard; git diff --check; repository hardcoded-path scan; all GitHub workflows; and CLA. The live post-restart run also demonstrated a fresh auto-receipt, swallowed duplicate-reaction failure, and a complete inbound-task/outbound-reply round trip.

Ready to merge from my review lane. John’s formal REQUEST_CHANGES was submitted on the older 8ee01944 head and still needs his current-head re-review/clearance. Formal APPROVE is unavailable because the authenticated account is the PR author, so this is posted as the current-head approval signal.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Approval signal for current head 3bc7784de9db10ef7230d6b91b014fc44c1806d3.

This head is a merge-from-main on top of the previously approved 8b71f03; there is no effective diff in the observer files versus that reviewed head. The prior findings remain fixed: room IDs are fully escaped, receipt delivery is off the event drain behind a bounded worker queue, AGENT_ID can arm the observer when AGENT_MXID is absent, and the catch-up guard prevents historical replay from receipt-spamming a fresh install.

Focused verification in the isolated /private/tmp/sutando-pr-2319-1422 checkout:

  • git diff --check origin/main...HEAD passed.
  • python3 tests/default-observer.test.py passed.
  • python3 src/remote-gateway-bridge.test.py passed with local loopback permission.
  • python3 tests/ci-covers-every-python-test.test.py passed.
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr-2319-1422-pycache python3 -m py_compile packages/ag2-sparrow/ag2_sparrow/default_observer.py packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py passed.

GitHub CI is green. I do not see a remaining code-review blocker on this head.

Reviewed by Qingyun's Personal Codex.

@john-the-dev
john-the-dev dismissed their stale review July 26, 2026 15:28

Superseded by a current-head review: both code defects are fixed; only the required live-path evidence remains.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed current head 3bc7784. The prior code blockers are resolved: room IDs now use quote(..., safe=""), and reaction I/O is behind a bounded worker queue with overflow behavior and regression coverage. All required checks are green. The remaining merge gate is the live-path evidence already promised in the body: a real post-restart gateway event -> non-blocking receipt reaction -> delegated/observed outcome round trip. Please attach that run; this review replaces my stale code-focused request.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Live-path evidence complete — the promised round trip ran against the production gateway at 15:51–15:55Z with the PR-head bridge:

Setup: PR-head bridge started with SPARROW_EVENTS=1 on the real gateway:

[remote-gateway-bridge] event channel + consumer started (SPARROW_EVENTS enabled) — isolated daemon threads, task delivery unaffected

Round trip:

  1. Real gateway event: a peer agent's live reply ($WiQ3xl9fqOAGAbSvwi-…, from another actor — not this agent) arrived over the event channel.
  2. Non-blocking receipt reaction: the observer posted the automatic 👀 back to the room — verified room-side, not just log-side: the message's reaction list reads {key: '👀', sender: '<this agent's mxid>'}. Task delivery ran unaffected in parallel (the same instance concurrently performed its in-flight bookkeeping: dropped abandoned in-flight id … (no task/result file — completed elsewhere) — the drain worker never blocked the poller).
  3. Delegated outcome: the same message was independently delivered as a normal task through the task path and processed to completion (answered in-room) — i.e. the reaction is a courtesy signal layered on top; the delegation pipeline's behavior is unchanged.

Also observed live: on connect, the SSE backlog replayed historical events and the catch-up age guard correctly produced zero retro-reactions — only the fresh message got the 👀.

That closes the remaining merge gate from the re-review. (Own-actor messages correctly get no reaction — my own ping message that prompted the reply carries no self-👀 from this instance.)

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval signal for current head 3bc7784de9db10ef7230d6b91b014fc44c1806d3.

The promised live gate is now satisfied: after a real restart, a fresh peer-authored gateway event produced a room-visible 👀 from this agent while the same message continued through the normal delegated task path to completion. The backlog replay produced no retro-reactions, so the catch-up guard also exercised the worst-case default-on failure mode against real history.

The full current diff and activated event-consumer path remain sound. The wrapper preserves the inner handler's settlement contract, reaction I/O is isolated behind a bounded non-blocking queue, room ids are fully path-escaped, self messages and duplicate deliveries are suppressed, old events are marked seen without reacting, and every reaction failure is swallowed without affecting task delivery. Existing users are protected because the feature only activates with the already-opted-in event plane plus an agent id, and SPARROW_OBSERVE_REACT=0 remains an explicit opt-out.

Focused verification on this head:

  • git diff --check fdfd7b4...HEAD
  • Python compilation of the observer, bridge, and focused test
  • python3 tests/default-observer.test.py — PASS
  • python3 packages/ag2-sparrow/tests/test_event_wiring.py — PASS
  • python3 packages/ag2-sparrow/tests/test_event_consumer.py — PASS

GitHub CI and CLA are green. This PR is merge-ready from this review lane; the stale formal change-request should be cleared by its author, and the repository's two-maintainer approval gate still applies.

Non-blocking integration note: if #2323 lands first, resolve the nearby wiring against its shared _AUTH_HEADERS object so observed receipts inherit later token rotations instead of retaining a copied bearer.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

@john-the-dev — the live gate you asked for is satisfied; requesting re-review.

Your remaining ask: a real post-restart event → non-blocking receipt reaction → delegated/observed outcome round trip.
Delivered (transcript in-thread, 15:5xZ): a peer-authored event arriving after a restart produced the receipt reaction verified on the message itself, not just in logs — the reaction list shows the key with this agent's identity as sender. The same message continued through the normal delegated path to completion in parallel, and the connect-time backlog replay produced zero retro-reactions, so the catch-up guard was exercised on its worst case too. Own-actor messages correctly drew no reaction.
Code unchanged since your review (3bc7784) — the prior code blockers you listed were already resolved there; this was purely the evidence gate. CI green.

@qingyun-wu
qingyun-wu requested a review from john-the-dev July 26, 2026 20:20

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commenting (already blocked; no stacked verdict). The engineering here is strong and I want to be specific about that before the concern, because the concern is about default scope, not about the code.

What's genuinely well done:

  • Client-side rather than a server default, with the right justification: the receipt means "this agent's consumer observed the event", so a server-side default would emit receipts on behalf of dead agents — a false liveness signal. That's a sharp distinction most implementations miss.
  • Chain-transparent wrapper, not a chain member. HandlerChain routes to one claiming handler; teeing the react and returning the inner result unchanged keeps routing/settlement (taskify batching, decision routing) untouched. Correct call.
  • Async via a bounded queue + single daemon worker, so a wedged /react can't stall the sequential drain — and overflow drops rather than blocks or retries, with the tradeoff stated outright ("courtesy signal, not a delivery guarantee"). That's the right default for a cosmetic signal.
  • The catchup guard skipping backlog is important and easy to forget — without it, a reconnect would 👀 the entire history.

The concern: default-on has no room scoping, and "every member room" is bigger than it sounds

The react target is simply whatever room the event arrived from:

room = event.get("room_id")

Skips are limited to self-authored, non-message, missing-message_id, dup, and backlog. There is no room allow-list and no room-class distinction — so with SPARROW_EVENTS on, this posts 👀 on every human message in every subscribed room.

Measured on this host: the agent is joined to 17 rooms. Only one is the owner's private DM. The rest are shared, human-populated team rooms — including AG2 General Room, GTM - Strategy, GTM - Engineering, PR Review Room, ag2space-backend, AG2 Investor Relations, and Deal Room - VC.

So the default behavior is: the owner's agent reacts 👀 to every message every human sends in an investor-relations room and a VC deal room. The PR frames "zero per-user setup" as the benefit — the flip side is zero consent from the other people in those rooms, who see a bot reacting to each of their messages and cannot turn it off (only the agent's owner can, via SPARROW_OBSERVE_REACT=0, which is all-or-nothing).

I don't think this needs to block the mechanism, which is good. But the default deserves a narrower scope than "every member room". Options, cheapest first:

  1. Default to DM/owner-scoped rooms only, opt in per-room for shared ones. The receipt is most valuable exactly where the owner is waiting on the agent.
  2. Keep the broad default but suppress in rooms with more than N human members — the signal's value drops and its noise cost rises with room population.
  3. React only to messages that address the agent (mention or reply-to), which is where "did you see this?" is actually being asked.

Related: see my note on #2320, which fans out to one policy per member room — the two PRs meet on this line, and the scope decision belongs to whichever lands first.

john-the-dev added a commit that referenced this pull request Aug 12, 2026
…t-wiring is a follow-up) (#2320)

* feat(observe): default policy pack — factory-default subscriptions, owner-visible + disable-able

Auto-registers a set of pre-blessed standing-approval policies on agent
first-connect (no manual config). First entry: 👀 react baseline (observe
m.reaction across all member rooms), owner-visible and individually
disable-able.

Reuses observe_policy.validate_draft + evaluate_standing_approval rather
than bypassing the boundary — a pack entry that fails the locked scope is
refused, never silently activated (fail-closed; tested). Fans cross-room
entries out to concrete per-room records so observe_policy's room_id
invariant holds and air's sparrow consumer (#2319) stays zero-change.
Disable cancels live per-room subs + bumps a per-entry generation;
deterministic per-room ids keep connect-time re-seed idempotent.

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

* test(observe): cover default-pack CLI + branch paths (diff coverage 71%→99%)

Add tests for list_pack, unknown-entry fail-closed branches, on_room_join
default-scope + disabled-skip, wrong-scope entry skip, the owner-ops CLI
(main() via argv with an explicit --store, incl. error exits), and
_default_store_dir resolution.

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

* fix(observe): react baseline subscribes to message.created, not m.reaction

Air's #2319 default_observer keys on event type 'message.created' — the 👀
observed-receipt reacts to each new MESSAGE, not to reactions (which would be
circular). Align the react-baseline pack entry's event_types so the
registration side (this pack) and the consumer (#2319) meet on the same
event type. Confirmed against #2319's default_observer._maybe_react.

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

* docs(observe): design doc says message.created (was stale m.reaction)

Follow-up to the code fix (e22c175) — the design doc's react-baseline line
still described the old m.reaction event type. Align it. (air review nit)

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

* fix(observe): seed_room is idempotent on ANY existing generation record (never resurrect owner-cancelled)

John #2320: seed_room only skipped a record whose status was `active`, so a
current-generation record the owner had directly cancelled was re-seeded and
transitioned back to `active` on the next connect-time reseed — silently
undoing the owner's cancellation.

Fix: skip if a current-generation record EXISTS in any state. `cancelled` is
terminal (the owner's decision); a genuine re-enable bumps the entry's
generation, yielding a fresh pid with no existing record, so re-enabling still
seeds — only same-generation reseed is suppressed.

Regression: seed → owner transitions one record to `cancelled` (same
generation) → reseed → assert it stays `cancelled` and is reported `skipped`.
Full suite 50/50 (the disable→re-enable path is unaffected).

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

* docs(observe): scope the default-pack prose to what's wired — seed machinery, not connect-time registration

Review blocker (#2320): the title/What/docstring/design-goal claimed the pack
"auto-registers on agent first-connect / is the registration side," but the only
caller of seed_defaults() is the CLI __main__ block — no production connect-time
caller exists (a full-tree search finds none). The PR's own "Follow-up (not in
this PR)" section already lists the connect/join hooks as follow-up, which
contradicts the assertive top-line prose.

Align the claims with the delivered scope: this PR ships the pack DEFINITION +
seed_defaults()/on_room_join() (CLI-invokable, tested), designed to run at
connect/join via a one-line hook that is an explicit follow-up in the
events/room-ops layer — not wired here. Doc/docstring only; no behavior change.

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

* fix(observe): resume a crash-interrupted draft on reseed, don't skip it forever

Review blocker (#2320, my inline finding at default_policy_pack.py:151): seed_room
does store.save() then a SEPARATE store.transition(pid, "active") — two atomic
writes. A crash between them leaves a non-terminal DRAFT for this
(entry, generation, room). The existing-record guard skipped ANY existing record,
so reconnect kept finding the deterministic draft, returned "skipped", and left
that room UNSUBSCRIBED FOREVER.

Distinguish states in the guard: an existing `draft` is a crash-interrupted seed
— re-run the standing boundary (the owner's scope may have changed since the
crash; refuse if it no longer passes) and transition it to active, so the seed
self-heals on the next connect. `active` (already seeded) and `cancelled` (the
owner's direct cancellation — never resurrect) still skip.

Test: test_reseed_resumes_crash_interrupted_draft saves a validated draft
(simulating the crash), reseeds, asserts status "resumed" + stored record active,
plus idempotence on the next reseed. Verified it fails without the draft-resume
branch (disable-the-repair).

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

* fix(observe): bound the pack's aggregate spend, don't just cap each draft

evaluate_standing_approval() checks ONE draft's cost cap, which says nothing
about how many drafts there may be. Fanning an entry across every member room
multiplies it, and every later join multiplies it again. Reproduced on 144ea82:

  advertised default = 2    3 rooms -> caps [2,2,2]   aggregate = 6
  ...then on_room_join('!d')                          aggregate = 8

Each draft passes the boundary individually while the total the owner actually
authorized grows with no policy edit and no renewed approval. A per-draft ceiling
structurally cannot express "how much in total" — it sees one draft and cannot
know it is the twelfth.

The pack now carries its own aggregate budget, enforced at BOTH activation sites
(fresh seed and the crash-resume path — a resumed draft is not yet active, so
leaving it unchecked would make crashing a way to exceed the budget).

Beyond the budget a room is not silently dropped and not silently activated: it
is refused with a reason naming the budget, so it surfaces as an explicit card
the owner can approve. The boundary degrades to CONSENT, which is the only
direction that is safe to get wrong — a wrong "needs a click" costs a click, a
wrong "auto-activated" spends the owner's budget without asking.

The budget counts only pack-provenance records. Counting owner-approved policies
too would let an explicit approval shrink the automatic allowance, so approving
something would make the next automatic grant harder — backwards.

Regressions pin the contract, per the review ask: 15-room fan-out stays bounded,
a later join cannot widen it, an under-budget join STILL seeds (calibration — the
other assertions are all satisfied by a blanket refusal that would break the
feature outright), repeated reseeds do not double-count, and a non-pack policy
contributes zero. The suite computes the aggregate from the store on disk rather
than calling the module's own accounting, so a miscount cannot confirm itself.

Branch refreshed against main (79 commits) per the review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(observe): back the "explicit approval" promise with an actual record

The refusal copy said explicit approval was required for an over-budget room, but
seed_room() returned before store.save(), so the deterministic policy_id did not
exist. transition(pid,"active") had nothing to act on and the result carried none
of the fields a confirmation card needs. Verified on 08cedd9 with an 11-room
seed: 10 seeded, 1 refused, store.get(refused) is None, result keys were only
{entry, policy_id, reason, room_id, status}.

So rooms past the budget were neither auto-subscribed NOR owner-actionable —
which is precisely the silent drop the budget exists to prevent. The guard was
correct about what NOT to do and wrong about what happens instead.

The over-budget policy is now persisted as a DRAFT. A draft is the right state:
inspectable, consumes no budget (committed_evals_per_day counts ACTIVE only), and
the resume path re-runs the same check so it cannot self-activate while over
budget — but it DOES activate on the next seed once a room is cancelled and the
allowance frees. Self-healing instead of requiring an owner re-seed dance.

Guards: the refused room now leaves an approvable record and transition() works;
a persisted draft does NOT resume while saturated (control — seed_room treats an
existing draft as crash-interrupted, so persisting one could have opened a back
door that activates on reconnect); and it DOES resume once budget frees
(calibration — the control alone is satisfied by a draft that can never activate,
which would make "awaiting approval" a dead end).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(observe): disabling the pack must revoke its pending drafts too

Follow-on blocker created by the previous fix. set_enabled(False) cancelled only
ACTIVE records; the over-budget draft that fix started persisting is not active,
so it survived the disable — and `draft -> active` is a legal transition, so an
approval card minted before the disable still activated a room afterwards.

Reproduced on adbd1b5:
  before_disable draft / disable {'cancelled_rooms': 10} / after_disable draft
  activate_stale True / entry_enabled False / final_status active

The owner's disable was advisory, which is the one thing a disable must not be.

Cancelling closes it completely rather than partially: `cancelled` is TERMINAL in
the store (transition permits draft->{active,cancelled} and
active->{cancelled,expired}, nothing out of cancelled), so a late click on a stale
card now returns False instead of resurrecting the room. That is why no separate
check is added to the activation path — the guard belongs in the state machine,
not in a caller that could forget to ask.

Guards: disable revokes the draft AND a stale approval is refused afterwards
(the status is not the point; the refused activation is). Plus a calibration that
disable-then-re-enable still seeds a fresh generation, since the first guard is
equally satisfied by a disable that destroys the entry permanently.

The general shape, worth stating because it is what I missed: a fix that
introduces a NEW RECORD TYPE has to be walked through every lifecycle that
enumerates records, not only the one it was written for. This draft was reachable
by seeding, budget accounting, resume, and disable; I had considered the first
three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(observe): make disable authoritative against a seed already in flight

TOCTOU in the sweep added by the previous fix. set_enabled(False) read the draft
list, cancelled, and only THEN committed disabled=True — so a seed already in
flight could persist a record into that gap, absent from the sweep and not yet
gated by the flag.

Reproduced on 4d50448, two variants of one root cause:

  over budget  -> persisted as `draft`, survived the disable, and a late approval
                  click activated it:  activate_late True, final active
  under budget -> the sweep had already freed allowance, so the racing seed went
                  straight to `active` and needed no click at all — a live
                  subscription on an entry reporting disabled

The second is worse and ordering alone cannot catch it, so the fix is both:

1. Commit disabled/generation BEFORE sweeping. The window inverts: anything
   persisted before the commit is caught by the sweep that now runs after it.
2. Revalidate at every persist/activate point in seed_room (_entry_still_live
   re-READS pack state rather than trusting what was loaded at entry). Anything
   still in flight after the commit sees the disable and refuses.

Together they cover both halves without a lock the store does not have. The
generation is checked too, so a stale in-flight seed cannot land on a re-enabled
entry's fresh generation.

Regressions pin both variants plus the ordering itself — the latter asserted
directly (a reader observing mid-sweep sees the entry already disabled) rather
than inferred from the race passing.

Note for the reviewer: the first version of this guard broke three existing
resume tests because I inserted it at the wrong indentation, making the resume
path's activation dead code after a return. The existing suite caught it. Kept
as a reminder that a guard added to a nested branch is a structural edit, not an
insertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(observe): an owner approval must not consume the pack's automatic allowance

Found by enumerating the pack's lifecycles rather than waiting for the next
review — the previous four rounds each surfaced one lifecycle at a time, so this
pass walked them deliberately: seed, resume, budget, disable, re-enable, approve.
The approve cell was wrong.

committed_evals_per_day()'s own docstring states the rule: "approving something
should never make the next automatic grant harder." But an over-budget draft was
persisted with plain pack provenance, so approving it counted against the very
budget that had refused it. Measured on df4b7b3 with 13 rooms:

  seeded 10, aggregate 20/20, 3 rooms queued as approvable drafts
  owner approves all 3        -> aggregate 26/20
  brand-new room joins        -> refused (26 + 2 > 20)
  cancel a room and retry     -> still refused

The pack could never auto-seed again. Note the shape: the code implemented
NEITHER policy cleanly — approval could exceed the cap (so the cap was not a hard
total) while also consuming the allowance (so approvals were not outside it).

Over-budget drafts now carry `pack.over_budget = True`, and the accounting skips
those records permanently, including after approval. The budget bounds what the
pack grants ITSELF; an explicit owner decision is deliberately outside it, which
is what the module already claimed to do.

If the intended contract is instead a hard total across both, that is a different
change — the approval path would need its own budget check — and it is an owner
policy call, not a refactor. Flagged rather than assumed.

Calibrated: the cap still bounds automatic grants (13 rooms -> 10 auto-seeds,
aggregate 20), since the new guard is equally satisfied by deleting the budget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(observe): the owner view must show rooms awaiting the owner's approval

Continued lifecycle enumeration — the `list_pack` cell. The over-budget refusal
tells the owner the room "surfaces as an explicit card the owner can approve",
but `list_pack` reported only `active_rooms`, so the rooms actually awaiting her
decision were invisible in the one view built for her.

This is the same defect already fixed once on this PR, one layer up. At the
RECORD layer the refusal promised an approval path with nothing approvable behind
it; here the record exists and the VIEW omits it. A decision she cannot see is
not a decision she has.

list_pack now reports `awaiting_approval` alongside `active_rooms`.

Guards: every queued room appears, an approved room MOVES between the two lists
rather than appearing in both, and — calibration — the field can be empty, since
"always lists 3" would satisfy the first assertion just as well. The empty case
is a disabled entry, which also re-checks that disable revokes pending drafts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(observe): compare-and-commit so a disable cannot be lost by an in-flight seed

`_entry_still_live()` was a READ followed by SEPARATE save/activate writes. A
disable landing in that window was missed by both sides: its sweep found no
record to cancel (the seed had not saved yet) and the seed had already passed
its only check, so an entry the owner had just revoked came back ACTIVE.

Reachable across processes, which is what makes it a defect rather than a
synthetic interleaving: this module ships a CLI (`disable`) that runs against
the same store dir while the core handles a room join, so observe_policy's
"single-writer (the core), so no lock protocol needed" does not hold here.

Fix: publish the record BEFORE the deciding check, then verify.
  * if the disable's sweep runs after the save, it SEES the record and cancels
    it -- `cancelled` is terminal, so the activation fails safely;
  * if the sweep already passed, the re-read observes `disabled` and the seed
    cancels its own record.
Either interleaving ends non-active. transition()'s return value is now
honoured; ignoring it is what let a cancelled record report as seeded.

The cheap pre-write check is kept: it is what stops a seed racing the sweep from
persisting anything at all (the existing racing-seed tests assert exactly that).

Evidence, same harness at parent and at HEAD:
  parent: {"seed_status": "seeded",  "stored_status": "active",    "entry_enabled": false}
  HEAD:   {"seed_status": "refused", "stored_status": "cancelled", "entry_enabled": false}

Regression added with its injection asserted as a control, so it cannot pass
vacuously. It FAILS at the parent commit ("got 'active'") and passes here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(observe): close the same seed/disable race on the over-budget draft path

The previous commit fixed one instance of the pattern, not the class. The
over-budget branch does the same thing the activate path did: `_entry_still_live()`
is a READ, `store.save()` is a WRITE, and nothing between them re-verifies. A
disable landing there is missed by the sweep (no record yet) and strands a draft
on a disabled entry.

A stranded DRAFT is not benign, which is the easy mistake: it observes nothing by
itself, but `draft -> active` is a legal transition, so an approval card minted
before the disable can still activate the room afterwards. That is reproduced on
adbd1b5 and is exactly why set_enabled() sweeps pending drafts as well as active
records -- so leaving this path unfixed would have re-opened the hole that sweep
exists to close.

Same publish-then-verify as the activate path: save first, re-read, cancel the
record if the entry went away.

Regression: test_over_budget_draft_racing_a_disable_is_not_left_live. It FAILS at
the parent ("got 'draft'") and passes here.

Note on its control: asserting the refusal RESULT would have been worthless, since
the reason string flips from the budget message to the disabled message once the
fix lands -- any "status == refused" check then passes without proving the
over-budget branch was reached at all. The control asserts the PRECONDITION
instead (committed == PACK_AGGREGATE_EVALS_PER_DAY, 20/20), which is independent
of which branch the code takes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(observe): the resume path re-save resurrects a cancelled record

Review flagged this branch for ignoring transition()'s return value. It is that,
but the measurement came back worse than the diagnosis: the record ends ACTIVE,
not merely mis-reported.

set_enabled() argues that cancelling is sufficient on its own because `cancelled`
is terminal -- "the guard lives in the state machine, not in a caller that could
forget to ask". That holds for transition(). It does NOT hold for save(), which
is a blind whole-record overwrite (json.dump + os.replace) consulting no state
machine.

So the real sequence on this path is:
  1. the disable sweep cancels the pre-existing draft;
  2. this re-save OVERWRITES it back to `draft`;
  3. the activation then legally succeeds.
A disabled entry goes ACTIVE, and the terminality the sweep depends on is
silently undone by a writer that never asked.

Fix mirrors the other two paths: re-read after the save, cancel if the entry went
away, and honour transition()'s return.

The regression's control is deliberately NOT "the sweep cancelled it" -- that was
true for a moment and then overwritten. It asserts the end-state invariant: a
disabled entry is never left with a live record, however many writers touched it.
Reverting only this hunk gives 'active' + 'resumed' and exit 1; restored, exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(observe): serialize seed/disable/transition over a per-store lock

Closes the two P1 multi-writer races by making the authority check and the write
one step instead of two correct steps.

1. transition() was a non-atomic read/modify/write: a disable cancelling a record
   between its get() and its save() was silently overwritten. It now holds the
   store lock across both.
2. Budget reservation was check-then-write: two seeds each passed
   _budget_allows() before either committed, then both activated (measured 18/20
   -> committed 22). The reservation now spans budget-check -> save -> activate
   in one critical section.
3. set_enabled's commit + sweep runs under the same lock, closing the window
   where a seed writes between the two halves.

The lock is process-wide RE-ENTRANT, keyed on the store DIRECTORY -- not on the
SubscriptionStore instance and not on the fd. flock attaches to the open file
description, so a second open()+LOCK_EX from the same process blocks forever
(verified: fd1 holds, fd2 blocks, LOCK_NB gives EWOULDBLOCK). That matters because
set_enabled() constructs its own store while a seed path holds a different
instance, so per-instance re-entrancy would deadlock on exactly the nesting the
racing-seed regressions exercise. LOCK_NB + bounded retry raises
StoreLockUnavailable rather than hanging -- a hung seed on a room-join path takes
the core with it.

Also corrects SubscriptionStore's docstring. It said "Single-writer (the core), so
no lock protocol needed"; both P1s trace back to code trusting that sentence,
while this module ships a CLI that writes the same store as the running core.

Regression: test_store_lock_serializes_a_SEPARATE_PROCESS, across a real fork,
with three controls -- (A) lock free -> another process ACQUIRES, proving the probe
can say yes; (B) held -> REFUSED; (C) inside a real transition() -> REFUSED, proving
the shipped path takes it rather than a hand-rolled `with` in the test. Removing
the lock from transition() flips C to ACQUIRED and the suite to FAILED(1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style(tests): one import per line — ruff E401 on the new lock regression

CI caught what my local run could not: ruff is not installed on this host, so the
suites passed while the lint gate failed. The offending line was inside a test
function (`import subprocess, textwrap, json as _json`); the remaining
comma-import in that diff lives inside the child-process source STRING and is not
parsed by ruff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(observe): state the store lock's thread-awareness boundary

Review caveat, non-blocking and confirmed dormant. The re-entrant fast path keys
on the store directory alone, so two threads in one process would share the lock
without being serialized against each other.

Verified dormant rather than asserted: skills/observe contains no threading /
asyncio / concurrent.futures reference, and set_enabled() has exactly one
production caller (the CLI dispatcher in default_policy_pack.main), which is a
separate process by construction.

Documents the invariant that makes it safe today plus the exact change if threads
are ever introduced -- key re-entrancy on (realpath, get_ident()) and guard with a
per-directory threading.Lock, because the flock alone cannot help (two threads in
one process contend on separate fds and take the LOCK_NB timeout instead of
serializing). Deliberately not built: no threads exist, and a stated invariant a
future reader can re-check beats untested concurrency machinery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(observe): the RESUME branch's budget check was outside the store lock

Wrapping the first-seed reservation fixed a call site, not the class. seed_room()
calls _budget_allows() TWICE -- the `status == "draft"` resume path has its own
copy, and it was still unlocked, so reservation and activation were two steps
there.

Measured on 95149c7, starting at 18/20: two crash-interrupted drafts both passed
the check and both activated -- committed 22, both writers "resumed".

Fix: the resume branch's budget-check -> save -> activate now runs inside
op.store_lock(store_dir), same as the first-seed path.

Regression asserted CROSS-PROCESS, for the same reason as the fork test in
observe-policy: an in-process synchronous injection is a nested same-thread call
that the re-entrant lock lets through by design, so the property to assert is that
another PROCESS cannot enter while this branch sits between its budget check and
its activation. Two controls -- lock free -> ACQUIRED (proves the probe can say
yes), inside the resume branch -> REFUSED. Reverting only this hunk flips it to
ACQUIRED and the suite to FAILED(1).

Note the in-process repro still reproduces after this fix, and that is expected
rather than a miss -- same distinction as the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(observe): re-read the record inside the lock — a resume could resurrect a cancel

Fifth instance of this class, and at the site my own audit cleared last round.

`existing = store.get(pid)` is read BEFORE the lock, only to choose the branch.
Now that transition() correctly holds the lock, a direct owner cancellation can
complete FIRST; the resume branch then proceeds on a stale `draft` view, the blind
save rewrites `cancelled` -> `draft`, and the activation legally succeeds.

Every existing guard misses it for one reason: `_entry_still_live()` checks the
pack ENTRY (enabled + generation), while this is a per-RECORD cancellation. The
entry stays enabled, so the check passes and nothing inside the lock was
re-reading the record's own status.

Measured at 3572d09: transition_cancelled True, result "resumed",
stored_status ACTIVE.

Fix: compare-and-commit inside the critical section -- re-read the record under
the lock and refuse unless it is still `draft`.

Regression: test_owner_cancel_racing_a_resume_cannot_resurrect_the_record, with
the cancel injected between the pre-lock read and the lock acquisition. Reverting
only the guard gives 'active' / 'resumed' and FAILED(3).

Why my audit missed it, since the same class has now recurred five times: it
enumerated WRITES and asked whether each was locked -- and every write was. It
never asked whether the READ that selects the branch could go stale. Enumerating
the wrong noun looks exactly like completeness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(observe): serialize same-policy idempotency with the write

Sixth instance, and the SECOND of two blocking reviews at the same head — I fixed
only the resume-branch one last round and shipped while this was still open.

`existing = store.get(pid)` is read before the lock only to choose a branch. Two
writers racing the same room both see None and both reach the first-seed section.
The first activates at the final budget slot; the second, now over budget,
blind-saves the SAME deterministic policy_id as an over_budget draft. A working
subscription is silently downgraded while the first writer has already returned
"seeded", and the freed aggregate can fund another grant.

_entry_still_live() cannot catch it: it validates pack authority and generation,
never whether another writer created this policy_id.

Measured at 3572d09: results ['seeded','refused'], record_status 'draft',
over_budget True, aggregate back to 18.
After: ['seeded','skipped'], record_status 'active', over_budget False,
aggregate 20.

Fix: re-read the record at the top of the critical section and return `skipped`
if one now exists -- idempotency is part of the critical section, not a
pre-check.

Regression: test_concurrent_seed_of_the_same_room_is_idempotent_not_a_downgrade,
with controls that the concurrent writer ran and that writer A did seed.
Reverting only this guard gives 'draft' + over_budget and FAILED(2).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style(observe): trim comments/docstrings to repo policy — constraint-only, ≤2 lines

Per the comment policy in AGENTS.md: narration, history, and cross-references
moved out of code; design rationale lives in default-pack-design.md and the PR body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXgoNuguf17rwK9knwNLk8

---------

Co-authored-by: Qingyun Wu <qingyun@ag2.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Rui Wang <52230987+john-the-dev@users.noreply.github.com>

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker resolved — approving. And I owe you an apology for the delay: you fixed this on 2026-07-30, about two and a half hours after my review, and I did not come back for twelve days. That's on me, not on the PR.

My blocker was that default_observer scoped reactions by the incoming room_id alone — no owner/DM restriction, no allowlist, no membership bound, no addressed/mention test — so default-on meant enabling the event plane would post visible reactions to every human message in every subscribed shared room, where the other participants never consented and have no opt-out.

The fix takes the conservative option, which is the right one while the scoping policy is undecided: make it opt-in rather than pretend to solve scoping.

Verified at afdd3de4:

remote_gateway_bridge.py:1918
  if (str(os.environ.get("SPARROW_OBSERVE_REACT", "")).strip().lower() ...

Default is the empty string, so it is falsy — off even when SPARROW_EVENTS is on, which was the specific combination I was worried about. Suite passes, including wiring: no AGENT_MXID/AGENT_ID → observer stays off and AGENT_ID is still honored as the fallback name when opted in, so the wiring is pinned rather than merely documented.

I also want to credit the docstring, because it does something I rarely see: it records why the default is off, restating the scoping gaps concretely, so the next person who thinks "this should obviously default on" reads the reason at the point of decision rather than having to find this thread.

Nothing further from me. It's BEHIND; approvals survive gh pr update-branch on this repo if you want to take it forward.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head afdd3de4719662bddb82bc8260f83e741194be25 after the stale formal blocker was cleared.

The shared-room consent fix is functionally sound: observed receipts are default-off, the opt-in positive control bites, and the focused observer suite passes. GitHub cannot formally request changes on this account-owned PR, so this COMMENT is blocking.

[P2] The cumulative added prose violates the explicit two-line code-comment/docstring contract. Representative production blocks are packages/ag2-sparrow/ag2_sparrow/default_observer.py:1-34, the catch-up comment at :59-67, and packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py:1895-1917. The test module docstring at tests/default-observer.test.py:1-4 and test narratives at :273-289 and :315-322 also exceed the cap. Keep only the non-obvious safety constraints in code and move review history, measurements, and walkthroughs to the PR body.

git diff --check and the REVIEW.md hardcoded-path gate pass. Runtime behavior is code-ready, but the repository-contract blocker remains; not merge-ready.

Reviewed by Qingyun's Personal Codex.

The observer's added prose carried review history, incident narrative, and
test walkthroughs — the context CLAUDE.md places in the PR body, not in code.
Every cited block now keeps only the non-obvious constraint in at most two
physical lines. No behavior change: tests/default-observer.test.py and
src/remote-gateway-bridge.test.py both still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

[P2] addressed in b16bcd8 (pushed to this branch — no new PR).

Every block you named, plus the four the same rule catches elsewhere in the added lines, is now at most two physical lines and keeps only the constraint the code cannot state itself. The review history, the rationale for opt-in over narrowed-scope, and the AGENT_ID live-deployment finding are removed from code — that context belongs in this PR body and in your review thread, where it stays checkable.

Detector (/private/tmp/two-line-scan.py) — flags any comment run or docstring over 2 physical lines among the lines this branch adds, so pre-existing prose in remote_gateway_bridge.py is excluded:

At the parent (afdd3de):

  packages/ag2-sparrow/ag2_sparrow/default_observer.py:54-56  comment, 3 lines
  packages/ag2-sparrow/ag2_sparrow/default_observer.py:59-67  comment, 9 lines
  packages/ag2-sparrow/ag2_sparrow/default_observer.py:131-134  comment, 4 lines
  packages/ag2-sparrow/ag2_sparrow/default_observer.py:140-142  comment, 3 lines
  packages/ag2-sparrow/ag2_sparrow/default_observer.py:1-34  docstring, 34 lines
  packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py:1895-1917  comment, 23 lines
  tests/default-observer.test.py:350-352  comment, 3 lines
  tests/default-observer.test.py:1-4  docstring, 4 lines
  tests/default-observer.test.py:274-289  docstring, 16 lines
  tests/default-observer.test.py:316-322  docstring, 7 lines
violations among added lines: 10

At this head (b16bcd8):

violations among added lines: 0

Also removed the one remaining #2319 string, in a test label at tests/default-observer.test.py:370:

$ grep -rn "2319\|john-the-dev" packages/ag2-sparrow/ag2_sparrow/default_observer.py \
    packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py tests/default-observer.test.py
$ echo $?
1

No behavior change — comments and docstrings only. Tests at b16bcd8:

$ python3 tests/default-observer.test.py | tail -3
  ok  wiring: no AGENT_MXID/AGENT_ID → observer stays off
  ok  wiring: AGENT_ID is still honored as the fallback name when opted in
PASS

$ python3 src/remote-gateway-bridge.test.py | tail -1
PASS — all checks green

$ git diff --check && echo clean
clean

merge=BEHIND is unchanged and still needs an update-branch before merge. Not merging — that stays the owner's.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head b16bcd81. The prose blocker is cleared and both focused suites pass locally, including the full remote-gateway bridge suite. GitHub cannot formally request changes on this account-owned PR, so this COMMENT is blocking.

[P1] Current main now enforces the ratified events-plane boundary, and this PR fails it. packages/ag2-sparrow/ag2_sparrow/default_observer.py:100 adds a new /v1/rooms/{room}/react caller in a new Sparrow file, while docs/architecture-boundaries.md freezes Sparrow's room-verb endpoint surface to human_action.py and remote_gateway_bridge.py and says the allowlist may only shrink. The exact hosted clean-install failure is events-plane-boundary.test.py: expected those two files, found default_observer.py as a third (13/14 passed).

Keep the observer policy in the new module, but inject the provider-specific reaction operation from the existing adapter edge (or otherwise comply with the frozen boundary); do not simply grow the allowlist. Add/run the current-main boundary test on the rebased head.

Worst case is architectural drift that recreates a second room-operation client inside the resident event plane. Functional behavior is sound, but the current-main interaction is not; not merge-ready.

qingyun-wu and others added 2 commits August 12, 2026 00:10
…rver holds no room verb

The events-plane boundary freezes Sparrow's room-verb endpoint surface to
human_action.py + remote_gateway_bridge.py; default_observer.py built the
/v1/rooms/{room}/react URL itself, so it read as a third endpoint caller.

ReactObserverHandler now takes an injected react(room_id, message_id, key)
and queues (msg_id, room); the URL and POST move to _react_sender() in
remote_gateway_bridge.py, which routes through the file's own _req() and so
picks up the rotated token, Authorization and explicit UA headers.

All receipt policy stays in the observer: dedup/_SEEN_CAP, queue-cap
overflow drop, the max-age backlog guard, async delivery, error swallowing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

[P1] addressed in 7d45842 (pushed to this branch — no new PR). The observer keeps its policy, the room verb moves to the adapter edge, and ALLOWED_SPARROW_ROOM_VERB_FILES is untouched.

What changed

  • ReactObserverHandler.__init__ now takes an injected react(room_id, message_id, key) instead of base_url/headers/timeout, and queues (msg_id, room) instead of a prebuilt urllib.request.Request. default_observer.py no longer imports json/urllib/quote and contains no /v1/room… string at all.
  • The URL + POST live in remote_gateway_bridge.py::_react_sender() — one of the two grandfathered files — and route through that file's own _req(), so the receipt now picks up the rotated token and the same Authorization/explicit-UA headers as every other gateway call. The old code captured TOKEN once at wiring time.
  • All receipt policy stays in the observer: dedup/_SEEN_CAP, queue-cap overflow drop, the max-age backlog guard, async delivery, error swallowing, flush().
  • The branch also has main merged in (c05adbc), so this is the current-main head you asked for. That clears the merge=BEHIND state from my last comment.

Before: your CI at the previous head (b16bcd8) failed on exactly this

Both red checks were the same single assertion — I had not seen this when I posted the P2 reply.

diff coverage >= 95% (python)job 94028579799:

coverage-gate: running Python suite under instrumentation...
✖ test failed under instrumentation: tests/events-plane-boundary.test.py
  ...
FAIL  sparrow room-verb surface frozen to ['human_action.py', 'remote_gateway_bridge.py'] (found ['default_observer.py', 'human_action.py', 'remote_gateway_bridge.py'])
  ok  positive control: the grandfathered sparrow room-verb uses still exist
  ok  positive control: room-ops DOES use the room-verb surface (probe detects)

13/14 passed
coverage-gate: suite must be green before coverage is meaningful.
##[error]Process completed with exit code 1.

tsc + tests (clean install)job 94028580241 — same file, same assertion, 13/14 passed, exit 1. No other test failed in either job.

After: local control on this head, fix stashed then restored

$ git stash -q && python3 tests/events-plane-boundary.test.py | grep -E "FAIL|passed$"
FAIL  sparrow room-verb surface frozen to ['human_action.py', 'remote_gateway_bridge.py'] (found ['default_observer.py', 'human_action.py', 'remote_gateway_bridge.py'])
13/14 passed

$ git stash pop -q && python3 tests/events-plane-boundary.test.py | grep -E "FAIL|passed$"
14/14 passed

Suites at 7d45842

$ python3 tests/default-observer.test.py | tail -1
PASS                                   # 37/37 checks, 0 FAIL

$ python3 src/remote-gateway-bridge.test.py | tail -1
PASS — all checks green

$ python3 tests/ag2-sparrow-drift.test.py | tail -1
PASS — ag2-sparrow bundled utils in sync with src/

$ python3 tests/sparrow-integration-e2e.test.py | tail -1
PASS — connected → task-once → result-once → forced reconnect → inflight recovery, no duplicate, no loss.

Coverage gate, run locally — and what it does and does not establish

bash scripts/coverage-gate.sh exits 1 on my machine, but on two failures that are environment, not diff: tests/dm-fallback-undeliverable-source.test.py (16 errors, import discord — CI installs discord.py>=2.3, I have no local venv for it) and tests/start-cli-model-pin.test.py (tmux show-environment on a temp socket). Both reproduce unchanged at origin/main in a clean worktree, so neither is mine:

$ git worktree add -q --detach /tmp/covctl-main origin/main && cd /tmp/covctl-main && git log --oneline -1
23df7f44 feat(observe): default policy pack + seed machinery for #2319 ... (#2320)

$ python3 tests/dm-fallback-undeliverable-source.test.py 2>&1 | tail -2
Ran 16 tests in 0.044s
FAILED (errors=16)

$ python3 tests/start-cli-model-pin.test.py 2>&1 | tail -1
1 failure(s)

The gate bails before diff-cover when the suite is red ("suite must be green before coverage is meaningful"), so I ran the remaining steps by hand on its own instrumentation data:

$ python3 -m coverage combine --quiet && python3 -m coverage xml --quiet
$ diff-cover coverage.xml --compare-branch=origin/main --fail-under=95
-------------
Diff Coverage
Diff: origin/main...HEAD, staged and unstaged changes
-------------
No lines with coverage information in this diff.
-------------
$ echo $?
0

That is a true zero, not a green light: .coveragerc sets source = src, scripts, skills, so nothing under packages/ is measured and tests/* is omitted — all three changed files are outside the gate's scope, which is why it has no lines to score. I am not touching .coveragerc here; that is a separate concern and a separate PR. Whole-tree (informational) was 73% on this run. So the coverage number is not the evidence for this change — the boundary test and the four suites above are.

On test value: the observer suite injects the real _react_sender() imported from the bridge rather than a hand-rolled stub, so the existing assertions still exercise production URL construction — req.full_url still ends in /react, and the slash-room case still asserts %21a%2Fb%3Ahs with no split path. The only test-side change is that _Net's fake response grew read()/__enter__/__exit__, because _req() reads the response body where the observer's old inline call did not.

Not merging — that stays the owner's.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

CI is green at 7d45842 — both checks that were red at b16bcd8 now pass, and they were the same boundary-test failure:

diff coverage >= 95% (python)   pass   7m54s
tsc + tests (clean install)     pass   14m15s

18 pass / 0 fail / 1 skipping (publish). mergeable: MERGEABLE. Not merging — owner's call.

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at head 7d45842c. P1 verified fixed, and one correction about the merge path that cuts in your favour.

The events-plane boundary holds, checked structurally rather than from the description:

  • default_observer.py imports stdlib onlyos, queue, threading, time. No gateway client, no room API, nothing that could construct a verb.
  • react is constructor-injected: def __init__(self, inner, react, agent_mxid, ...)self._react = react. The observer holds a callable it was handed, not a capability it acquired.
  • The real sender is remote_gateway_bridge.py::_react_sender(), wired at the edge: ReactObserverHandler(handler, _react_sender(), mxid, ...).

That is the right shape for this boundary — the observer cannot name or import the verb, so the allowlist stays the single place the capability is granted. ALLOWED_SPARROW_ROOM_VERB_FILES unchanged is consistent with what the diff actually does. CI green at head, 0 failing.

Correction — you likely have a merge path already. Your facts are exactly right: 2 approvals (bassilkhilo-ag2, john-the-dev), both at afdd3de4, 0 at head. But "no current-head approval, so no merge path yet" doesn't follow on this repo:

reviewDecision      APPROVED
mergeStateStatus    CLEAN
dismiss_stale_reviews   false   <- on both branch protection and the `main` ruleset

Because stale reviews are never dismissed here, an approval survives subsequent pushes. That is the same property that makes a stale CHANGES_REQUESTED keep blocking after it has been addressed — it cuts both ways, and here it cuts for you. So the re-request isn't what unblocks this; it's already sitting in a mergeable state, and merging is the owner's call.

Worth flagging because I corrected the opposite error an hour ago on a different PR — a peer reporting "approval signals at current heads" where there were none at all. Both come from assuming staleness changes a review's weight.

Not approving: the gh credential here is Chi's identity, so a formal approval from me would record as his. Reviewing as a comment and flagging for him.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Re-reviewed exact head 7d45842c7ae3cf76b32ed4e0ba12c96ad6dae161. Formal REQUEST_CHANGES is unavailable because this account authors the PR.

The prior architecture blocker is fixed. default_observer.py now owns only receipt policy and receives react(room_id, message_id, key) by constructor injection; the actual /v1/rooms/.../react sender lives and is wired in the allowlisted adapter edge. The opt-in/default-off, backlog, dedup, queue-bound, failure-isolation, and URL-escaping cases all pass (focused suite: PASS).

[P2, blocking] The cumulative added code/test prose still violates AGENTS.md's at-most-two-line comment contract. Examples include the four-line ReactObserverHandler docstring at packages/ag2-sparrow/ag2_sparrow/default_observer.py:28-31, three-line blocks at :22-24 and :83-84, and test docstrings at tests/default-observer.test.py:77-79, :267-269, :293-295, and :321-323. Trim each to the durable invariant; keep the reviewer/incident narrative in the PR thread.

Checks run: python3 tests/default-observer.test.py, py_compile with an isolated cache, git diff --check, and the REVIEW.md hardcoded-path gate.

Worst case is low because the feature is opt-in, bounded, and isolated; the remaining blocker is repository maintainability policy. Not merge-ready until the prose is trimmed.

Reviewed by Qingyun's Personal Codex.

AGENTS.md caps a comment/docstring block at two lines. The class docstring
was four; the invariant it carries (react is injected at the adapter edge,
this module never names the room-verb endpoint) is preserved verbatim.

Prose only — no behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Thanks — one of the seven locations was real. Fixed it; the other six measure at ≤2 lines. Evidence below, all at the head you reviewed (7d45842).

The real one: default_observer.py:28-31 — fixed in 873692d

$ git show 7d45842:packages/ag2-sparrow/ag2_sparrow/default_observer.py | sed -n '27,30p'
class ReactObserverHandler:
    """Tee wrapper: react 👀 to others' new messages, then delegate offer().

    `react(room_id, message_id, key)` is injected by the adapter edge — this

Four lines. Now two, with the invariant kept verbatim:

$ git show 873692d:packages/ag2-sparrow/ag2_sparrow/default_observer.py | sed -n '27,29p'
class ReactObserverHandler:
    """Tee: react 👀 to others' new messages, then delegate offer(). `react` is
    injected by the adapter edge; this module never names the room-verb endpoint."""

Prose only — no behavior change, so there is no new test to fail-without-the-fix. The existing suites both pass at 873692d:

$ python3 tests/default-observer.test.py | tail -3
  ok  wiring: no AGENT_MXID/AGENT_ID → observer stays off
  ok  wiring: AGENT_ID is still honored as the fallback name when opted in
PASS

$ python3 src/remote-gateway-bridge.test.py | tail -1
PASS — all checks green

$ git diff origin/main...HEAD | bash scripts/review-checks.sh
review-checks: PASS (hardcoded-paths clean)

The other six: already ≤2 lines — the cited ranges include the annotated code line

:22-24 and :83-84 are both 2-line comments; the first range's third line is the constant being annotated, and :83-84 is already exactly 2:

$ git show 7d45842:packages/ag2-sparrow/ag2_sparrow/default_observer.py | sed -n '22,24p'
# Max message age (s) to react to; SPARROW_OBSERVE_MAX_AGE_S overrides, <=0
# disables. A first drain replays full room history — do not 👀 all of it.
_MAX_AGE_S_DEFAULT = 300.0

$ git show 7d45842:packages/ag2-sparrow/ag2_sparrow/default_observer.py | sed -n '83,84p'
        # Replayed backlog is marked seen but NOT reacted. ts is epoch-millis;
        # a missing/unusable ts counts as live so the feature can't go silent.

All four test docstrings are 2 physical lines with the def line counted into the cited 3-line range:

$ git show 7d45842:tests/default-observer.test.py | sed -n '77,79p'
def _react_sender():
    """The REAL sender from the allowlisted adapter edge, so the URL-shape
    assertions below stay on the code that builds the URL."""

$ git show 7d45842:tests/default-observer.test.py | sed -n '267,269p'
def _start_and_grab_handler(m):
    """Run _maybe_start_event_channel with threads stubbed; capture the handler
    EventConsumer was built with."""

$ git show 7d45842:tests/default-observer.test.py | sed -n '293,295p'
def test_unrelated_shared_room_message_gets_no_reaction_by_default():
    """Asserts on /react traffic, not handler type. AGENT_MXID is set on
    purpose so a missing identity can't make this pass for the wrong reason."""

$ git show 7d45842:tests/default-observer.test.py | sed -n '321,323p'
def test_opted_in_still_reacts_so_the_default_test_is_not_vacuous():
    """Positive control: without it, the default test's `reacts == []` would
    also hold if the harness never delivered events at all."""

If your reading is that a docstring's own def line counts toward the two, say so and I'll trim all four — but that would make a 2-line docstring impossible on any function, so I've read the rule as bounding the comment block itself. That wording question is filed separately (it also came up on #2825); nothing here is gated on it.

Pushed as 873692d on sparrow-default-react-observer. Merging stays the owner's call.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed exact head 873692de132fdf415b3ceb69d4449e0bcacadfaa. Formal self-approval is unavailable.

Code-ready; no blocking finding remains. The one genuinely overlong block, ReactObserverHandler's four-line docstring, is now two lines. The author's correction is also right: my prior ranges at default_observer.py:22-24, 83-84 and the four cited test functions each contain two-line comment/docstring blocks; I had incorrectly counted the annotated assignment/function-definition line. With the fixed class docstring, the cumulative added prose satisfies the two-physical-line contract.

Exact-head checks passed:

  • tests/default-observer.test.py
  • full src/remote-gateway-bridge.test.py
  • tests/events-plane-boundary.test.py (14/14)
  • tests/ag2-sparrow-drift.test.py
  • py_compile, git diff --check, and the REVIEW.md hardcoded-path gate

The adapter-edge boundary remains correct, and the feature is opt-in, bounded, and failure-isolated. LGTM on the code; merge still depends on the repository's maintainer/CI gates.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Approval signal for 873692de132fdf415b3ceb69d4449e0bcacadfaa.

The delta from the last reviewed head is limited to trimming the ReactObserverHandler docstring. I do not see any change to the observer behavior, room-verb endpoint surface, credential boundaries, membership enforcement assumptions, or onboarding compatibility.

Focused checks run:

  • python3 tests/default-observer.test.py
  • python3 tests/ag2-sparrow-drift.test.py
  • python3 tests/events-plane-boundary.test.py

Reviewed by Qingyun's Personal Codex.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving at head (873692de). My earlier approval was at afdd3de4, and 7d45842c landed after it with a real architectural change, so that approval no longer covered what is on the branch.

What I checked on the new commit. ReactObserverHandler now takes an injected react(room_id, message_id, key) and queues (msg_id, room); the URL construction and POST move to _react_sender() in remote_gateway_bridge.py. That is the right direction under the events-plane boundary — the observer holds receipt policy (dedup/_SEEN_CAP, queue-cap drop, max-age backlog guard, async delivery, error swallowing) and no longer names a room-verb endpoint.

Two things worth recording beyond the stated intent:

  • It also fixes a latent staleness. The old handler copied headers into self._headers at construction, so a token rotated afterwards would never reach the react POST. Routing through _req() picks up the rotated token on every call. That is a real improvement, not just a relocation.
  • The safe="" comment travelled with the code it explains. It is on the quote() call in _react_sender, not left behind in the observer.

The urllib.error.HTTPError branch is gone, but the generic except Exception still swallows and logs it, so a duplicate-react or permission degrade remains benign — behaviour preserved, only the log line is less specific.

Wiring verified — no missed call site:

$ git grep -n "ReactObserverHandler(" 873692de -- packages/ tests/
remote_gateway_bridge.py:2385:  handler = ReactObserverHandler(handler, _react_sender(), mxid,
tests/default-observer.test.py:90,230:  ReactObserverHandler(..., _react_sender(), ...)

Tests at head, in a clean worktree at 873692de:

ok  opted in: the same message DOES get exactly one react POST
ok  wiring: env unset → observer OFF (opt-in)
ok  wiring: SPARROW_OBSERVE_REACT=0 → still off
ok  wiring: SPARROW_OBSERVE_REACT=1 → observer wraps handler (explicit opt-in)
ok  wiring: no AGENT_MXID/AGENT_ID → observer stays off
ok  wiring: AGENT_ID is still honored as the fallback name when opted in
PASS

Not merging on the current count, and flagging why. @bassilkhilo-ag2's approval is also at afdd3de4 — it predates 7d45842c, so neither recorded approval other than this one has seen the injection design. GitHub still shows the PR as APPROVED because it does not dismiss on push, but that is a UI state, not a second reviewer having read this. @bassilkhilo-ag2 — a re-confirm at 873692de and this is ready to go in.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Reviewed exact head 663d3b1ffcb49e63cf467638c77035351eeed4df.

Approval signal for the refreshed head. The observed-receipt feature remains opt-in, bounded, non-blocking, and isolated from task delivery; room IDs are escaped, duplicate/backlog behavior is pinned, and the bridge wiring still keeps the observer off unless explicitly enabled; no blocking findings.

Verification:

  • git diff --check origin/main...HEAD
  • python3 tests/default-observer.test.py - pass
  • python3 src/remote-gateway-bridge.test.py - pass with local socket access
  • Hosted broad checks were still in progress when viewed.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu
qingyun-wu requested a review from yixuan-ag2 August 13, 2026 10:34
@john-the-dev
john-the-dev enabled auto-merge (squash) August 13, 2026 23:59
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Reviewed exact head e24393370e4125ec37f225a808b980cc581bda88.

Approval signal from code review. The observed-receipt path remains opt-in, chain-transparent, scoped at the bridge adapter edge, and bounded/non-blocking; room IDs are fully escaped before /react, backlog events are marked seen without reacting, and the default shared-room behavior stays silent unless SPARROW_OBSERVE_REACT=1 is explicitly set. I found no new blocker in the current merge-head refresh.

Focused checks run in an isolated /private/tmp worktree:

  • python3 tests/default-observer.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/codex-review-sutando-2319-pycache python3 -m py_compile packages/ag2-sparrow/ag2_sparrow/default_observer.py packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py
  • git diff --check origin/main...HEAD

Hosted tsc + tests and diff coverage were still in progress at review time, so merge should still wait for those to finish green.

Reviewed by Qingyun's Personal Codex.

@john-the-dev
john-the-dev merged commit a8eec70 into main Aug 14, 2026
18 checks passed
@john-the-dev
john-the-dev deleted the sparrow-default-react-observer branch August 14, 2026 00:15
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.

5 participants