feat(projects): Beads bridge — A2A coordination + JSONL snapshot#267
feat(projects): Beads bridge — A2A coordination + JSONL snapshot#267
Conversation
📝 WalkthroughWalkthroughAdds a new BeadsBridge and beads_format module to sync project task graphs with per-project A2A chat channels, integrates bridge lifecycle into app startup/shutdown, wires chat and project routes to the bridge (mark-dirty, export, chat handoff), and adds comprehensive unit/integration tests for lifecycle, rendering, verbs, mentions, and events. Changes
Sequence Diagram(s)sequenceDiagram
participant App as FastAPI App
participant Bridge as BeadsBridge
participant Broker as ProjectEventBroker
participant Store as Project/Task Store
participant A2A as A2A Channel
App->>Bridge: start() on lifespan start
activate Bridge
Bridge->>Store: backfill_active() → list active projects
Bridge->>Broker: subscribe(projects...)
deactivate Bridge
Broker->>Bridge: task.claimed event
activate Bridge
Bridge->>Store: resolve project → A2A channel
Bridge->>A2A: post system message (claimed)
deactivate Bridge
Broker->>Bridge: task.closed event
activate Bridge
Bridge->>Store: compute ready dependents (blocker statuses)
Bridge->>A2A: post closed + ready messages
deactivate Bridge
App->>Bridge: stop() on shutdown
activate Bridge
Bridge->>Broker: cancel subscribers, drain timeout
deactivate Bridge
sequenceDiagram
participant Client as Chat Client
participant Route as /ws or /api/chat/messages
participant Bridge as BeadsBridge
participant Store as Task Store
participant A2A as A2A Channel
Client->>Route: send chat message (may contain /claim or task ids)
Route->>Bridge: schedule on_chat_message(project_id, channel_id, message)
activate Bridge
Bridge->>Bridge: parse_verbs(message) -> [(verb, id, note)...]
Bridge->>Store: dispatch verbs in order (claim/release/close)
Bridge->>Bridge: scan_task_ids(message) -> [ids...]
Bridge->>Store: attach comments for mentioned ids (dedupe)
Bridge->>A2A: post system messages for verbs/ready notifications
deactivate Bridge
Route->>Client: returns 200 (non-blocking)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (3)
tinyagentos/routes/chat.py (1)
354-360: Minor: duplicate channel lookup.
channel_idis already fetched at line 292 as_http_channel. Consider reusing it here to avoid an extra DB call.♻️ Suggested fix
router_svc = getattr(request.app.state, "agent_chat_router", None) - channel = await ch_store.get_channel(channel_id) + channel = _http_channel if channel is not None: if router_svc is not None: router_svc.dispatch(message, channel)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tinyagentos/routes/chat.py` around lines 354 - 360, The code does a redundant lookup with ch_store.get_channel(channel_id) even though the channel was already fetched as _http_channel earlier; replace the second lookup by reusing _http_channel (check if _http_channel is not None) and then call router_svc.dispatch(message, _http_channel) and asyncio.create_task(_beads_on_chat_message(request.app, _http_channel, message)); remove the extra ch_store.get_channel(channel_id) call and its variable to avoid the duplicate DB hit.tests/projects/test_routes_beads.py (1)
109-109: Minor:next()without default may raiseStopIteration.If no A2A channel exists (test setup issue), line 109 raises
StopIterationwith an unclear error. Consider adding a default or usingassertfor clearer test failures.🧪 Suggested fix for clearer test failures
- a2a = next(c2 for c2 in channels if (c2.get("settings") or {}).get("kind") == "a2a") + a2a_list = [c2 for c2 in channels if (c2.get("settings") or {}).get("kind") == "a2a"] + assert a2a_list, "Expected A2A channel to be created for project" + a2a = a2a_list[0]This pattern also appears on lines 151 and other places. The current code works but would produce confusing errors if the A2A channel creation failed silently.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/projects/test_routes_beads.py` at line 109, The test uses next(...) to find the A2A channel (a2a = next(c2 for c2 in channels if (c2.get("settings") or {}).get("kind") == "a2a")) which will raise StopIteration with an unclear error if no A2A channel exists; change this to either provide a default (e.g., next(..., None)) and assert the result is not None, or explicitly assert any((c.get("settings") or {}).get("kind") == "a2a" for c in channels) before calling next, and apply the same change to the other occurrences (e.g., the similar lookup at line ~151) so test failures report a clear assertion message instead of StopIteration.tinyagentos/projects/beads_bridge.py (1)
287-293: Note: Release actor always shows "agent".Since
release_taskclearsclaimed_bybeforeon_eventruns, and the release event payload doesn't includereleaser_id, the actor will always fall back to"agent". The comment acknowledges this limitation. If accurate attribution is needed, thetask.releasedevent payload would need to include the releaser.Consider a follow-up to pass
releaser_idin thetask.releasedevent payload fromtask_store.release_task, similar to howtask.claimedincludesclaimed_by.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tinyagentos/projects/beads_bridge.py` around lines 287 - 293, The release actor always falls back to "agent" because release_task clears claimed_by before the task.released event is handled; to fix this, have task_store.release_task include the releaser id in the event payload (e.g. add releaser_id when emitting the "task.released" event), and update the handler in beads_bridge.py (the task.released branch in on_event) to prefer task.get("releaser_id") (then task.get("claimed_by") then "agent") when building actor for format_released so accurate attribution is preserved.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@tests/projects/test_routes_beads.py`:
- Line 109: The test uses next(...) to find the A2A channel (a2a = next(c2 for
c2 in channels if (c2.get("settings") or {}).get("kind") == "a2a")) which will
raise StopIteration with an unclear error if no A2A channel exists; change this
to either provide a default (e.g., next(..., None)) and assert the result is not
None, or explicitly assert any((c.get("settings") or {}).get("kind") == "a2a"
for c in channels) before calling next, and apply the same change to the other
occurrences (e.g., the similar lookup at line ~151) so test failures report a
clear assertion message instead of StopIteration.
In `@tinyagentos/projects/beads_bridge.py`:
- Around line 287-293: The release actor always falls back to "agent" because
release_task clears claimed_by before the task.released event is handled; to fix
this, have task_store.release_task include the releaser id in the event payload
(e.g. add releaser_id when emitting the "task.released" event), and update the
handler in beads_bridge.py (the task.released branch in on_event) to prefer
task.get("releaser_id") (then task.get("claimed_by") then "agent") when building
actor for format_released so accurate attribution is preserved.
In `@tinyagentos/routes/chat.py`:
- Around line 354-360: The code does a redundant lookup with
ch_store.get_channel(channel_id) even though the channel was already fetched as
_http_channel earlier; replace the second lookup by reusing _http_channel (check
if _http_channel is not None) and then call router_svc.dispatch(message,
_http_channel) and asyncio.create_task(_beads_on_chat_message(request.app,
_http_channel, message)); remove the extra ch_store.get_channel(channel_id) call
and its variable to avoid the duplicate DB hit.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: a335b8ea-f9b0-4af8-9702-76926ad217c5
📒 Files selected for processing (8)
tests/projects/test_beads_bridge.pytests/projects/test_beads_format.pytests/projects/test_routes_beads.pytinyagentos/app.pytinyagentos/projects/beads_bridge.pytinyagentos/projects/beads_format.pytinyagentos/routes/chat.pytinyagentos/routes/projects.py
- chat.py: reuse _http_channel from line 292 instead of a second ch_store.get_channel(channel_id) call - test_routes_beads.py: replace bare next(...) with list+assert so a missing A2A channel produces a clear test failure rather than StopIteration (lines 109 and 151) Release-actor attribution (third nit) deferred — needs a payload change in task_store.release_task to surface releaser_id.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@tinyagentos/routes/chat.py`:
- Around line 357-359: The call to asyncio.create_task in the HTTP chat path can
get garbage-collected before completion; change the call in the block that
invokes asyncio.create_task(_beads_on_chat_message(request.app, _http_channel,
message)) so you capture the returned Task into a variable and persist it (e.g.,
append to the same app-scoped task list used in the WebSocket path) so the Task
is retained until finished; locate the create_task call and store its result on
request.app (or the same container used for WebSocket tasks) and ensure
cleanup/removal when the task completes.
- Around line 121-123: The fire-and-forget task created with
asyncio.create_task(_beads_on_chat_message(websocket.app, channel, message)) is
not stored and may be garbage-collected prematurely; fix it by keeping a
reference to the returned Task (e.g., assign it to a local variable or add it to
a module-level set like _background_tasks), and ensure you attach a done
callback to remove it from the set
(task.add_done_callback(_background_tasks.discard)) so the task reference is
retained until completion; locate the asyncio.create_task call in the chat
handler and update it to store the task reference accordingly.
- Around line 354-359: The HTTP path currently launches _beads_on_chat_message
whenever _http_channel is present even if router_svc is None, but the WebSocket
path only launches beads when router_svc exists; make the behavior consistent by
decoupling beads from the router: in the WebSocket handler where router_svc and
_ws_channel are handled, always call
asyncio.create_task(_beads_on_chat_message(request.app, _ws_channel, message))
when the channel exists (mirror the HTTP path) rather than gating it on
router_svc, and ensure you store the created task reference in both paths (e.g.,
assign the create_task result to the same task variable) so task lifecycle
tracking is uniform across _beads_on_chat_message, _http_channel, and router_svc
usage.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 5c6487b9-de77-4d0e-b28d-774ccdad0405
📒 Files selected for processing (2)
tests/projects/test_routes_beads.pytinyagentos/routes/chat.py
🚧 Files skipped from review as they are similar to previous changes (1)
- tests/projects/test_routes_beads.py
- Decouple beads handoff from agent_chat_router gating in WS path so WS and HTTP behave consistently. Now: if channel exists, dispatch to router (when present) AND spawn beads — independent systems. - Reuse _ws_channel from line 93 (drop duplicate get_channel call). - Add module-level _background_tasks set and _spawn_background helper so fire-and-forget tasks retain a reference until completion (RUF006). Used in both WS and HTTP beads hand-offs.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
tinyagentos/routes/chat.py (1)
264-276:⚠️ Potential issue | 🟠 MajorApply the slash-command guardrail in the WebSocket path too.
This validation still only runs in
post_message. The WebSocketmessagebranch starting at Line 101 will still accept and dispatch an unaddressed slash command in a non-DM channel, so the same input is rejected over HTTP but accepted over WS. That makes the safety behavior transport-dependent and reopens the multi-agent fan-out the guard is meant to block. Please move this check into a shared helper and call it from both ingress paths.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tinyagentos/routes/chat.py` around lines 264 - 276, The slash-command guardrail is implemented only in the post_message path (the block using stripped = content.lstrip(), ch_store.get_channel, parse_mentions and returning a JSONResponse) but missing from the WebSocket "message" branch; extract that logic into a shared helper (e.g., validate_slash_command_target or ensure_slash_addressed) that takes (content, channel_id, ch_store) and returns/raises a uniform error when a slash command in a non-DM, non-a2a channel is not addressed to an agent (use the same parse_mentions logic and channel.kind/type checks), then call this helper from both post_message and the WebSocket message handler so both ingress paths enforce the same guardrail.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In `@tinyagentos/routes/chat.py`:
- Around line 264-276: The slash-command guardrail is implemented only in the
post_message path (the block using stripped = content.lstrip(),
ch_store.get_channel, parse_mentions and returning a JSONResponse) but missing
from the WebSocket "message" branch; extract that logic into a shared helper
(e.g., validate_slash_command_target or ensure_slash_addressed) that takes
(content, channel_id, ch_store) and returns/raises a uniform error when a slash
command in a non-DM, non-a2a channel is not addressed to an agent (use the same
parse_mentions logic and channel.kind/type checks), then call this helper from
both post_message and the WebSocket message handler so both ingress paths
enforce the same guardrail.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 3a600090-7f5d-4d16-a072-86a3ef79793d
📒 Files selected for processing (1)
tinyagentos/routes/chat.py
Summary
BeadsBridgeservice: dirty-set + per-project asyncio.Lock + writer task; subscribes to the project event broker and posts task lifecycle messages into the A2A channel; parses inbound/claim,/release,/closeverbs andtsk-<id>mentions (attached as comments with FIFO dedupe).data/projects/<slug>/.beads/tasks.jsonlsnapshot, atomically rewritten on every mutation (write-tmp + os.replace).Architecture
Test plan
Forward concerns (non-blocking)
Spec: `docs/superpowers/specs/2026-04-27-projects-beads-bridge-design.md`
Plan: `docs/superpowers/plans/2026-04-27-projects-beads-bridge-plan.md` (gitignored, local-only)
Summary by CodeRabbit
New Features
Tests