Skip to content

feat(projects): Beads bridge — A2A coordination + JSONL snapshot#267

Merged
jaylfc merged 20 commits intomasterfrom
feat/projects-beads-bridge
Apr 27, 2026
Merged

feat(projects): Beads bridge — A2A coordination + JSONL snapshot#267
jaylfc merged 20 commits intomasterfrom
feat/projects-beads-bridge

Conversation

@jaylfc
Copy link
Copy Markdown
Owner

@jaylfc jaylfc commented Apr 27, 2026

Summary

  • Pass B item Local assistant LLM (Setup Agent) #4 of the Projects feature: emulate the Beads workflow over the existing A2A channel + ProjectTaskStore.
  • New BeadsBridge service: 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, /close verbs and tsk-<id> mentions (attached as comments with FIFO dedupe).
  • New data/projects/<slug>/.beads/tasks.jsonl snapshot, atomically rewritten on every mutation (write-tmp + os.replace).
  • Failure-isolated end-to-end: bridge cannot break routes, chat send paths, or boot. Mirrors the pattern used by `tinyagentos/projects/a2a.py`.

Architecture

  • `tinyagentos/projects/beads_format.py` — pure helpers (no IO, no taOS imports): `task_to_jsonl_dict`, `compute_ready`, `format_*` system messages, `parse_verbs`, `scan_task_ids`.
  • `tinyagentos/projects/beads_bridge.py` — `BeadsBridge` class with dirty-set writer loop, broker subscriber loop per project, FIFO dedupe (deque maxlen=1024 + companion set, lockstep eviction), `task.ready` synthesis when closing unblocks dependents.
  • 7 route hooks in `routes/projects.py` (create/update/claim/release/close task + add_relationship) + new `POST /api/projects/{id}/beads/export` endpoint.
  • 2 chat send hooks in `routes/chat.py` (WS + REST) — schedules bridge via `asyncio.create_task` so chat path never blocks. A2A channels bypass the slash-command @mention guardrail.
  • Lifespan wiring in `app.py`: bridge constructed after a2a backfill, started, backfill_active runs to subscribe active projects.

Test plan

  • `pytest tests/projects/` — 132/132 green (29 format + 44 bridge unit + 7 integration + pre-existing)
  • `pytest tests/test_agent_chat_router.py tests/projects/test_a2a.py` — 26/26 (no regressions)
  • `pytest tests/test_app_orchestrator.py tests/test_app_startup_migration.py` — 7/7
  • Manual: create a project, post a task, confirm `data/projects//.beads/tasks.jsonl` appears within ~200ms
  • Manual: send `/claim tsk-` to the project's A2A channel via `POST /api/chat/messages`, confirm task transitions to claimed
  • Manual: hit `POST /api/projects/{id}/beads/export` and confirm 200 + file present

Forward concerns (non-blocking)

  • Writer-loop retry on render failure has no cap or backoff; pathological always-failing render would log every debounce tick. Acceptable for now (test path covers one-shot failure); track for future hardening.
  • Empty `message_id` would dedupe two distinct messages mentioning the same task. Routes always pass real ids so non-issue today; sentinel comment would help future readers.

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

    • Optional Beads bridge started/stopped with the app that syncs project/task state to A2A channels, posts system messages (including “ready” notifications), and debounces background exports.
    • Chat verbs (/claim, /release, /close) with optional notes, plus mention-driven comment attachments.
    • Route hooks and a sync export endpoint to produce tasks.jsonl and trigger immediate exports.
  • Tests

    • Extensive unit and integration tests covering bridge lifecycle, export/formatting, commands, event routing, message handling, and resilience.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

📝 Walkthrough

Walkthrough

Adds 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

Cohort / File(s) Summary
Core Beads Implementation
tinyagentos/projects/beads_bridge.py, tinyagentos/projects/beads_format.py
New BeadsBridge class (start/stop, background writer loop, dirty tracking, backfill, broker subscriptions, event→A2A system messages, verb dispatch, mention→comment attachment with dedupe). beads_format adds JSONL conversion, readiness logic, message formatting, verb parsing, and task-id scanning.
App Lifespan Integration
tinyagentos/app.py
App lifespan now initializes app.state.beads_bridge (default None), attempts to construct/start BeadsBridge after A2A backfill, runs backfill_active(), and stops the bridge on shutdown with error handling.
Chat & Route Hooks
tinyagentos/routes/chat.py, tinyagentos/routes/projects.py
Chat endpoints schedule non-blocking handoff to beads_bridge.on_chat_message for A2A channels; slash-command logic exempts A2A channels from explicit-agent requirement. Project routes call _beads_mark_dirty after task mutations and add POST /api/projects/{project_id}/beads/export to trigger export_now.
Integration & Unit Tests
tests/projects/test_beads_bridge.py, tests/projects/test_beads_format.py, tests/projects/test_routes_beads.py
New extensive tests covering bridge lifecycle/idempotency, debounce/drain semantics, render atomicity/JSONL schema and readiness, backfill/export, event→message flows (claimed/released/closed/ready), verb dispatch, mention handling/dedupe, and route resilience.

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
Loading
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)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐇 I stitch the beads across the thread,
Tasks hum tidy, statuses spread,
I mark the dirty, render lines,
Post "ready" sparks when blockade declines,
Hopping joy — the project’s fed!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: introducing the BeadsBridge service for A2A coordination and JSONL task snapshots, which is the core feature across all modified files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/projects-beads-bridge

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
tinyagentos/routes/chat.py (1)

354-360: Minor: duplicate channel lookup.

channel_id is 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 raise StopIteration.

If no A2A channel exists (test setup issue), line 109 raises StopIteration with an unclear error. Consider adding a default or using assert for 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_task clears claimed_by before on_event runs, and the release event payload doesn't include releaser_id, the actor will always fall back to "agent". The comment acknowledges this limitation. If accurate attribution is needed, the task.released event payload would need to include the releaser.

Consider a follow-up to pass releaser_id in the task.released event payload from task_store.release_task, similar to how task.claimed includes claimed_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

📥 Commits

Reviewing files that changed from the base of the PR and between d0b2a5b and 83944ca.

📒 Files selected for processing (8)
  • tests/projects/test_beads_bridge.py
  • tests/projects/test_beads_format.py
  • tests/projects/test_routes_beads.py
  • tinyagentos/app.py
  • tinyagentos/projects/beads_bridge.py
  • tinyagentos/projects/beads_format.py
  • tinyagentos/routes/chat.py
  • tinyagentos/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.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 83944ca and 583c30c.

📒 Files selected for processing (2)
  • tests/projects/test_routes_beads.py
  • tinyagentos/routes/chat.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/projects/test_routes_beads.py

Comment thread tinyagentos/routes/chat.py Outdated
Comment thread tinyagentos/routes/chat.py
Comment thread tinyagentos/routes/chat.py Outdated
- 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.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

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 | 🟠 Major

Apply the slash-command guardrail in the WebSocket path too.

This validation still only runs in post_message. The WebSocket message branch 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

📥 Commits

Reviewing files that changed from the base of the PR and between 583c30c and cb6e6b4.

📒 Files selected for processing (1)
  • tinyagentos/routes/chat.py

@jaylfc jaylfc merged commit 603b496 into master Apr 27, 2026
8 checks passed
@jaylfc jaylfc deleted the feat/projects-beads-bridge branch April 27, 2026 21:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant