bug(workspace-server): MCP delegate_task bypasses delegation lifecycle — canvas shows no bubble for peer-agent work #49
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
The MCP
delegate_task/delegate_task_asynctool calls bypass the entire delegation lifecycle that the canvas-drivenPOST /workspaces/:id/delegateendpoint emits. As a result, when a peer agent or canvas-via-MCP delegates work to a poll-mode workspace, the canvas chat shows no in-flight bubble, no task preview, no tool/step progress, and no completion signal until — at best — the GET-on-mount activity hydration eventually surfaces a row.User-visible repro (today):
delegate_taskto a peer (e.g.mac laptop,reno mac) registered asdelivery_mode=poll.{"status":"queued","delivery_mode":"poll","method":"message/send"}from the proxy short-circuit and reports back asunexpected response shape (no result, no error).→ To <peer>row, no dots, no eventual✓ completedflip when the peer drains.Root cause
There are two delegation entry points in
workspace-server:POST /workspaces/:id/delegate→DelegationHandler.Delegate(workspace-server/internal/handlers/delegation.go:109)activity_logswithstatus='pending'→DELEGATION_SENT→executeDelegationgoroutine →DELEGATION_STATUS=dispatched→ on response:DELEGATION_STATUS=queued(target busy) ORDELEGATION_COMPLETEORDELEGATION_FAILED→pushDelegationResultToInbox. Full.delegate_tasktool →MCPHandler.toolDelegateTask(workspace-server/internal/handlers/mcp_tools.go:143) andtoolDelegateTaskAsync(mcp_tools.go:212)isSafeURL→ constructs A2A body →http.DefaultClient.Doto/a2a→ returnsextractA2AText(body). No insert. No DELEGATION_SENT. No status updates. No DELEGATION_COMPLETE.When the target is
delivery_mode=poll, the receiving end (a2a_proxy.go:398) short-circuits withlogA2AReceiveQueued(a2a_proxy_helpers.go:506), which writes a generica2a_receiverow withStatus: "ok"(line 524) — a terminal status from the canvas's perspective.The canvas (
canvas/src/components/tabs/chat/AgentCommsPanel.tsx:262-365) already subscribes to all fourDELEGATION_*events and synthesisesActivityEntryrows withrequest_body.task = task_previewfor live rendering. TheWaitingBubblescomponent (line 630) animates dots whenever an outbound message tail hasstatus === "pending" || "queued". The canvas is fully wired; the MCP-side platform code never fires the events.Affected surfaces
Backend (
workspace-server/):internal/handlers/mcp_tools.go—toolDelegateTask(sync),toolDelegateTaskAsync(fire-and-forget). Both bypass lifecycle.internal/handlers/delegation.go—Delegate,executeDelegation,updateDelegationStatus,Record,UpdateStatus. The reference implementation to converge on.internal/handlers/a2a_proxy_helpers.go:497-527—logA2AReceiveQueuedwritesStatus: "ok"on receive; needs to either flip to"pending"or be replaced by an event-emitting writer when the inbound is a delegation.internal/handlers/a2a_queue.go:340-447— Queue drain stitch that writesDELEGATION_COMPLETEonly triggers whenextractDelegationIDFromBodyfindsparams.message.metadata.delegation_id. The MCP path does not embeddelegation_idin metadata (mcp_tools.go:170), so even if the lifecycle were partially fixed, the eventual reply wouldn't stitch.internal/handlers/external_connection.go— referencesdelegate_taskfor external runtimes (codex, third-party); their path inherits whatever shape the MCP path provides.Frontend (
canvas/):canvas/src/components/tabs/chat/AgentCommsPanel.tsx— Already consumesDELEGATION_*. No frontend change required for Layer 1.canvas/src/lib/ws-events.ts— Parity gate againstevents/types.go. Already includes the four DELEGATION constants.Tests:
internal/handlers/delegation_test.go— Pattern for asserting the four broadcasts. Reuse for MCP path.internal/handlers/mcp_tools_test.go(or newmcp_delegate_lifecycle_test.go) — Add coverage thattoolDelegateTaskemits the same lifecycle asDelegate.internal/handlers/a2a_queue_drain_test.go— Drain stitch must work regardless of entry point. Add a poll-mode-stitch test for the MCP path.Storage:
activity_logsschema — no migration needed. Status enum already acceptspending|dispatched|queued|completed|failed.delegationsledger (gated byDELEGATION_LEDGER_WRITEper RFC #2829 #318) —recordLedgerInsert/recordLedgerStatusshould fire from the SSOT path so MCP-initiated rows land in the ledger too.Proposed approach (Layer 1 — bubble + lifecycle parity)
Single-source-of-truth decision: extract a
DelegationWriter(working name) that owns the lifecycle, then call it from bothDelegate(HTTP) andtoolDelegateTask/toolDelegateTaskAsync(MCP). This mirrors the RFC #2945 PR-AAgentMessageWriterconsolidation — same problem shape (HTTP + MCP duplicating chat-message persistence) solved the same way (one writer, two callers).Skeleton:
Both call sites become:
logA2AReceiveQueuedis left in place for non-delegationa2a_receivetraffic (peer-direct A2A that isn't a delegation), but theDispatchpath embedsdelegation_idinparams.message.metadata, so when the target is poll-mode the proxy short-circuit's queued response is classified byDispatchasDELEGATION_STATUS=queuedand the eventual drain stitch (a2a_queue.go:stitchDrainResponseToDelegation) finds the row and firesDELEGATION_COMPLETE.Alternatives considered and rejected
toolDelegateTask— duplicates the entireexecuteDelegationbody (~120 lines: status update, error classification, retry, ledger writes, inbox push, broadcast). Violates SSOT and creates two places that must stay in sync. Rejected.toolDelegateTaskPOST to its own/workspaces/:id/delegateendpoint internally — clean SSOT but adds an HTTP round-trip for what should be an in-process call, complicates auth (the MCP path'sX-Workspace-IDheader convention vs. the HTTP path's bearer auth), and makes errors round-trip through HTTP-status translation twice. Rejected.status='pending'on the receive side (logA2AReceiveQueued) and let the canvas render that — fixes the bubble for inbound rows but not for the caller's outbound bubble; doesn't carry task text; doesn't emitDELEGATION_*so the existing canvas listeners stay dark; and conflates "I queued a delegation" with "someone queued an A2A receive at me." Rejected.Prior art adopted
AgentMessageWriterSSOT (d99b3f2a refactor(handlers): consolidate Notify + MCP send_message_to_user through AgentMessageWriter). Same problem shape, same fix shape. Adopt the constructor injection + interface boundary soDelegationWriteris unit-testable without a live broadcaster, mirroring howAgentMessageWritertests substitute the writer.ae79b9e9). Already wired inexecuteDelegationviapushDelegationResultToInbox; SSOT extraction preserves it for free.be5fbb5a). Demonstrates that adding an SSOT pre-check to the proxy path doesn't regress reactive-discovery callers as long as the pre-check is additive. Inform theDispatchhealth-check pattern.Prior art rejected
RecordAndBroadcast.Security-aware design check (per Phase 2 SOP)
tasktext is bounded by LLM message-size limits and already passes throughtextutil.TruncateBytes(_, 100)before broadcast. No new input surface.toolDelegateTaskalready enforcesregistry.CanCommunicate(callerID, targetID). The HTTP path (Delegate) is gated byWorkspaceAuth. Extracting the writer does not change either gate; both callers continue to enforce their existing checks before invokingBegin. No expansion of who-can-delegate-what.tasktext is already logged inactivity_logs.request_body. The new broadcast carriestask_preview(already truncated). No new data class, no secret-bearing fields. Continue to never logAuthorizationorX-Workspace-IDtoken values.DELEGATION_*events are scoped to the source workspace's WebSocket subscribers — same scope asACTIVITY_LOGGEDtoday. No cross-workspace leakage.DispatchreusesproxyA2ARequest(already has SSRF defence + private-network rejection). MCP path's pre-existingisSafeURL(agentURL)check is preserved.No expansion of attack surface. Compliance posture unchanged.
Backwards compatibility / versioning
DELEGATION_SENT/STATUS/COMPLETE/FAILEDconstants already exist inevents/types.goand the canvas already consumes them. No new event types, no canvas-side migration. Producers add a fourth call site, consumers see no schema change.POST /workspaces/:id/delegatebody and response unchanged.delegate_task/delegate_task_asyncargument schema unchanged. Return value ofdelegate_task(sync) is the sameextractA2AText(body)as today; lifecycle events are an additive side effect.activity_logsschema unchanged (thepending|queued|completed|failedenum already accommodates this).delegationsledger writes are gated byDELEGATION_LEDGER_WRITE— default off, so MCP-path additions are a no-op until the ledger is enabled.Definition of done (Phase 4 verification plan)
DelegationWritercovering: happy path, idempotency hit, target busy (queued), target completes immediately, target fails (transient + permanent),CanCommunicate=false,isSafeURL=false.toolDelegateTaskandDelegateproduce identicalactivity_logsrows + identical broadcast event sequences for the same input. This is the SSOT regression gate. Caught the kind of drift we're fixing today.delegate_task, assertsWaitingBubblesrenders during the in-flight window, asserts the bubble flips to a completed message when the peer's reply lands. Local Postgres + real binaries perfeedback_mandatory_local_e2e_before_ship.feedback_branch_count_before_approving.Layer 2 (out of scope for this issue, separate RFC)
Streaming tool/step progress during the peer's work — i.e.
DELEGATION_STATUSpayload extended withcurrent_step,tools_used,eta_hint, fired mid-flight by the peer adapter (template-claude-code, hermes, codex, etc.) calling a newPOST /delegations/:id/statusendpoint. Touches every adapter; warrants its own RFC + design review. Layer 1 above gives an immediate bubble; Layer 2 makes it live.Open questions for reviewer
DelegationWriterlive ininternal/handlers/(alongsidedelegation.go) or in a newinternal/delegation/package? PR-A putAgentMessageWriterininternal/messaging/; symmetry would put this ininternal/delegation/.toolDelegateTaskcontinue to wait for theDispatchto complete (preserving today'sextractA2AText(body)return value) or return immediately and let the caller pollcheck_task_status? Today's behaviour is sync; preserving it is safer but means lifecycle-emitting MCP delegations to slow peers will hold the MCP request open up tomcpCallTimeout. Recommend: preserve sync semantics but emitDELEGATION_SENTbefore the wait so the bubble appears immediately.feedback_per_agent_gitea_identity_default, this should be a Dev Lead persona, not a founder PAT.Status update — sequenced after internal#71 migration
This issue is parked behind the Go-module vanity-import migration (parent RFC: internal#71). Code work is complete in a local worktree (DelegationWriter SSOT + 13 contract tests + dead-code cleanup) but not yet pushed, because:
github.com/Molecule-AI/...identifiers — the writer should ship on the new vanity pathgo.moleculesai.app/core/platform, not the legacy github.com path.Path forward
github.com/Molecule-AI/molecule-monorepo/platform/...togo.moleculesai.app/core/platform/...).The writer's design and the issue body above remain accurate. No design changes required by the migration — only path rewrites.
Tracking