bug(workspace-server): MCP delegate_task bypasses delegation lifecycle — canvas shows no bubble for peer-agent work #49

Open
opened 2026-05-07 21:25:56 +00:00 by claude-ceo-assistant · 1 comment

Summary

The MCP delegate_task / delegate_task_async tool calls bypass the entire delegation lifecycle that the canvas-driven POST /workspaces/:id/delegate endpoint 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):

  1. Canvas user → MCP delegate_task to a peer (e.g. mac laptop, reno mac) registered as delivery_mode=poll.
  2. Caller adapter receives synthetic envelope {"status":"queued","delivery_mode":"poll","method":"message/send"} from the proxy short-circuit and reports back as unexpected response shape (no result, no error).
  3. Canvas chat: no waiting-bubble, no → To <peer> row, no dots, no eventual ✓ completed flip when the peer drains.

Root cause

There are two delegation entry points in workspace-server:

Path Lifecycle
POST /workspaces/:id/delegateDelegationHandler.Delegate (workspace-server/internal/handlers/delegation.go:109) INSERT activity_logs with status='pending'DELEGATION_SENTexecuteDelegation goroutine → DELEGATION_STATUS=dispatched → on response: DELEGATION_STATUS=queued (target busy) OR DELEGATION_COMPLETE OR DELEGATION_FAILEDpushDelegationResultToInbox. Full.
MCP delegate_task tool → MCPHandler.toolDelegateTask (workspace-server/internal/handlers/mcp_tools.go:143) and toolDelegateTaskAsync (mcp_tools.go:212) Resolves URL → isSafeURL → constructs A2A body → http.DefaultClient.Do to /a2a → returns extractA2AText(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 with logA2AReceiveQueued (a2a_proxy_helpers.go:506), which writes a generic a2a_receive row with Status: "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 four DELEGATION_* events and synthesises ActivityEntry rows with request_body.task = task_preview for live rendering. The WaitingBubbles component (line 630) animates dots whenever an outbound message tail has status === "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.gotoolDelegateTask (sync), toolDelegateTaskAsync (fire-and-forget). Both bypass lifecycle.
  • internal/handlers/delegation.goDelegate, executeDelegation, updateDelegationStatus, Record, UpdateStatus. The reference implementation to converge on.
  • internal/handlers/a2a_proxy_helpers.go:497-527logA2AReceiveQueued writes Status: "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 writes DELEGATION_COMPLETE only triggers when extractDelegationIDFromBody finds params.message.metadata.delegation_id. The MCP path does not embed delegation_id in metadata (mcp_tools.go:170), so even if the lifecycle were partially fixed, the eventual reply wouldn't stitch.
  • internal/handlers/external_connection.go — references delegate_task for external runtimes (codex, third-party); their path inherits whatever shape the MCP path provides.

Frontend (canvas/):

  • canvas/src/components/tabs/chat/AgentCommsPanel.tsx — Already consumes DELEGATION_*. No frontend change required for Layer 1.
  • canvas/src/lib/ws-events.ts — Parity gate against events/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 new mcp_delegate_lifecycle_test.go) — Add coverage that toolDelegateTask emits the same lifecycle as Delegate.
  • 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_logs schema — no migration needed. Status enum already accepts pending|dispatched|queued|completed|failed.
  • delegations ledger (gated by DELEGATION_LEDGER_WRITE per RFC #2829 #318) — recordLedgerInsert / recordLedgerStatus should 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 both Delegate (HTTP) and toolDelegateTask / toolDelegateTaskAsync (MCP). This mirrors the RFC #2945 PR-A AgentMessageWriter consolidation — same problem shape (HTTP + MCP duplicating chat-message persistence) solved the same way (one writer, two callers).

Skeleton:

// internal/handlers/delegation_writer.go
type DelegationWriter struct {
    db          *sql.DB
    broadcaster *events.Broadcaster
    proxy       A2AProxy // injected; same surface as workspace.proxyA2ARequest
}

// Begin records the row + emits DELEGATION_SENT and returns the
// delegation_id. Idempotent on (sourceID, idempotencyKey).
func (w *DelegationWriter) Begin(ctx context.Context, sourceID, targetID, task, idempotencyKey string) (delegationID string, err error)

// Dispatch fires DELEGATION_STATUS=dispatched, runs proxyA2ARequest,
// classifies the response into queued / complete / failed, emits
// the corresponding event, writes the result row, and (when applicable)
// pushes the result to the caller's inbox. Embeds delegation_id in
// the A2A metadata so a2a_queue stitch works for poll-mode targets.
func (w *DelegationWriter) Dispatch(ctx context.Context, sourceID, targetID, delegationID, task string) (response string, err error)

Both call sites become:

// HTTP — delegation.go:Delegate
id, err := w.Begin(ctx, sourceID, body.TargetID, body.Task, body.IdempotencyKey)
go w.Dispatch(detachCtx(ctx), sourceID, body.TargetID, id, body.Task)

// MCP sync — mcp_tools.go:toolDelegateTask
id, err := w.Begin(ctx, callerID, targetID, task, /* no idempotency */ "")
return w.Dispatch(ctx, callerID, targetID, id, task)

// MCP async — mcp_tools.go:toolDelegateTaskAsync
id, _ := w.Begin(ctx, callerID, targetID, task, "")
go w.Dispatch(detachCtx(ctx), callerID, targetID, id, task)
return id, nil

logA2AReceiveQueued is left in place for non-delegation a2a_receive traffic (peer-direct A2A that isn't a delegation), but the Dispatch path embeds delegation_id in params.message.metadata, so when the target is poll-mode the proxy short-circuit's queued response is classified by Dispatch as DELEGATION_STATUS=queued and the eventual drain stitch (a2a_queue.go:stitchDrainResponseToDelegation) finds the row and fires DELEGATION_COMPLETE.

Alternatives considered and rejected

  1. Add lifecycle events directly inside toolDelegateTask — duplicates the entire executeDelegation body (~120 lines: status update, error classification, retry, ledger writes, inbox push, broadcast). Violates SSOT and creates two places that must stay in sync. Rejected.
  2. Have toolDelegateTask POST to its own /workspaces/:id/delegate endpoint internally — clean SSOT but adds an HTTP round-trip for what should be an in-process call, complicates auth (the MCP path's X-Workspace-ID header convention vs. the HTTP path's bearer auth), and makes errors round-trip through HTTP-status translation twice. Rejected.
  3. Write 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 emit DELEGATION_* 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

  • RFC #2945 PR-A — AgentMessageWriter SSOT (d99b3f2a refactor(handlers): consolidate Notify + MCP send_message_to_user through AgentMessageWriter). Same problem shape, same fix shape. Adopt the constructor injection + interface boundary so DelegationWriter is unit-testable without a live broadcaster, mirroring how AgentMessageWriter tests substitute the writer.
  • RFC #2829 PR-2 result-push to caller inbox (ae79b9e9). Already wired in executeDelegation via pushDelegationResultToInbox; SSOT extraction preserves it for free.
  • #36 a2a-proxy SSOT proactive container check (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 the Dispatch health-check pattern.

Prior art rejected

  • GitHub Actions in-progress check UI — relies on a polling client and per-step persisted state. Our wire is push (WebSocket), not pull, so we don't need the polling layer. Adopting the polling shape would add latency we already eliminated with RecordAndBroadcast.
  • Slack-style typing indicator (ephemeral, no persistence) — we want persistence (the bubble survives reload + activity-log GET on mount). Rejected the ephemerality.

Security-aware design check (per Phase 2 SOP)

  • Untrusted input: task text is bounded by LLM message-size limits and already passes through textutil.TruncateBytes(_, 100) before broadcast. No new input surface.
  • Auth/sessions/permissions: toolDelegateTask already enforces registry.CanCommunicate(callerID, targetID). The HTTP path (Delegate) is gated by WorkspaceAuth. Extracting the writer does not change either gate; both callers continue to enforce their existing checks before invoking Begin. No expansion of who-can-delegate-what.
  • Data collection/logging: existing task text is already logged in activity_logs.request_body. The new broadcast carries task_preview (already truncated). No new data class, no secret-bearing fields. Continue to never log Authorization or X-Workspace-ID token values.
  • Who can access what: the DELEGATION_* events are scoped to the source workspace's WebSocket subscribers — same scope as ACTIVITY_LOGGED today. No cross-workspace leakage.
  • SSRF: Dispatch reuses proxyA2ARequest (already has SSRF defence + private-network rejection). MCP path's pre-existing isSafeURL(agentURL) check is preserved.

No expansion of attack surface. Compliance posture unchanged.

Backwards compatibility / versioning

  • Wire contract (WS events): DELEGATION_SENT/STATUS/COMPLETE/FAILED constants already exist in events/types.go and the canvas already consumes them. No new event types, no canvas-side migration. Producers add a fourth call site, consumers see no schema change.
  • HTTP contract: POST /workspaces/:id/delegate body and response unchanged.
  • MCP tool contract: delegate_task / delegate_task_async argument schema unchanged. Return value of delegate_task (sync) is the same extractA2AText(body) as today; lifecycle events are an additive side effect.
  • Database: activity_logs schema unchanged (the pending|queued|completed|failed enum already accommodates this). delegations ledger writes are gated by DELEGATION_LEDGER_WRITE — default off, so MCP-path additions are a no-op until the ledger is enabled.
  • Deprecation: nothing deprecated. The current MCP path becomes the writer's caller; no public symbol is removed.

Definition of done (Phase 4 verification plan)

  • Unit tests on DelegationWriter covering: happy path, idempotency hit, target busy (queued), target completes immediately, target fails (transient + permanent), CanCommunicate=false, isSafeURL=false.
  • Integration test (real Postgres) asserting that toolDelegateTask and Delegate produce identical activity_logs rows + identical broadcast event sequences for the same input. This is the SSOT regression gate. Caught the kind of drift we're fixing today.
  • E2E (canvas + workspace-server + a peer poll-mode workspace running molecule-mcp-claude-channel): canvas sends MCP delegate_task, asserts WaitingBubbles renders during the in-flight window, asserts the bubble flips to a completed message when the peer's reply lands. Local Postgres + real binaries per feedback_mandatory_local_e2e_before_ship.
  • Hostile self-review identifying the three weakest spots before merge.
  • Branch-count test coverage check before claiming 100% per feedback_branch_count_before_approving.
  • Logs verified sufficient for from-logs-alone debugging of: stuck-pending, queued-never-stitched, double-emit, and lost-broadcast.
  • Post-merge observation through one full cycle: at least one canvas-MCP delegation to a poll-mode peer with bubble-render verified by screenshot/network-trace.

Layer 2 (out of scope for this issue, separate RFC)

Streaming tool/step progress during the peer's work — i.e. DELEGATION_STATUS payload extended with current_step, tools_used, eta_hint, fired mid-flight by the peer adapter (template-claude-code, hermes, codex, etc.) calling a new POST /delegations/:id/status endpoint. 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

  1. Should DelegationWriter live in internal/handlers/ (alongside delegation.go) or in a new internal/delegation/ package? PR-A put AgentMessageWriter in internal/messaging/; symmetry would put this in internal/delegation/.
  2. Should the MCP sync toolDelegateTask continue to wait for the Dispatch to complete (preserving today's extractA2AText(body) return value) or return immediately and let the caller poll check_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 to mcpCallTimeout. Recommend: preserve sync semantics but emit DELEGATION_SENT before the wait so the bubble appears immediately.
  3. Whose persona should land this PR? The work touches workspace-server core; per feedback_per_agent_gitea_identity_default, this should be a Dev Lead persona, not a founder PAT.
## Summary The MCP `delegate_task` / `delegate_task_async` tool calls bypass the entire delegation lifecycle that the canvas-driven `POST /workspaces/:id/delegate` endpoint 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): 1. Canvas user → MCP `delegate_task` to a peer (e.g. `mac laptop`, `reno mac`) registered as `delivery_mode=poll`. 2. Caller adapter receives synthetic envelope `{"status":"queued","delivery_mode":"poll","method":"message/send"}` from the proxy short-circuit and reports back as `unexpected response shape (no result, no error)`. 3. Canvas chat: no waiting-bubble, no `→ To <peer>` row, no dots, no eventual `✓ completed` flip when the peer drains. ## Root cause There are two delegation entry points in `workspace-server`: | Path | Lifecycle | |---|---| | `POST /workspaces/:id/delegate` → `DelegationHandler.Delegate` (`workspace-server/internal/handlers/delegation.go:109`) | INSERT `activity_logs` with `status='pending'` → `DELEGATION_SENT` → `executeDelegation` goroutine → `DELEGATION_STATUS=dispatched` → on response: `DELEGATION_STATUS=queued` (target busy) OR `DELEGATION_COMPLETE` OR `DELEGATION_FAILED` → `pushDelegationResultToInbox`. **Full**. | | MCP `delegate_task` tool → `MCPHandler.toolDelegateTask` (`workspace-server/internal/handlers/mcp_tools.go:143`) and `toolDelegateTaskAsync` (`mcp_tools.go:212`) | Resolves URL → `isSafeURL` → constructs A2A body → `http.DefaultClient.Do` to `/a2a` → returns `extractA2AText(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 with `logA2AReceiveQueued` (`a2a_proxy_helpers.go:506`), which writes a generic `a2a_receive` row with `Status: "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 four `DELEGATION_*` events and synthesises `ActivityEntry` rows with `request_body.task = task_preview` for live rendering. The `WaitingBubbles` component (line 630) animates dots whenever an outbound message tail has `status === "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` — `logA2AReceiveQueued` writes `Status: "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 writes `DELEGATION_COMPLETE` only triggers when `extractDelegationIDFromBody` finds `params.message.metadata.delegation_id`. The MCP path does not embed `delegation_id` in metadata (`mcp_tools.go:170`), so even if the lifecycle were partially fixed, the eventual reply wouldn't stitch. - `internal/handlers/external_connection.go` — references `delegate_task` for external runtimes (codex, third-party); their path inherits whatever shape the MCP path provides. **Frontend (`canvas/`):** - `canvas/src/components/tabs/chat/AgentCommsPanel.tsx` — Already consumes `DELEGATION_*`. **No frontend change required for Layer 1.** - `canvas/src/lib/ws-events.ts` — Parity gate against `events/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 new `mcp_delegate_lifecycle_test.go`) — Add coverage that `toolDelegateTask` emits the same lifecycle as `Delegate`. - `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_logs` schema — no migration needed. Status enum already accepts `pending|dispatched|queued|completed|failed`. - `delegations` ledger (gated by `DELEGATION_LEDGER_WRITE` per RFC #2829 #318) — `recordLedgerInsert` / `recordLedgerStatus` should 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 both `Delegate` (HTTP) and `toolDelegateTask` / `toolDelegateTaskAsync` (MCP). This mirrors the RFC #2945 PR-A `AgentMessageWriter` consolidation — same problem shape (HTTP + MCP duplicating chat-message persistence) solved the same way (one writer, two callers). Skeleton: ```go // internal/handlers/delegation_writer.go type DelegationWriter struct { db *sql.DB broadcaster *events.Broadcaster proxy A2AProxy // injected; same surface as workspace.proxyA2ARequest } // Begin records the row + emits DELEGATION_SENT and returns the // delegation_id. Idempotent on (sourceID, idempotencyKey). func (w *DelegationWriter) Begin(ctx context.Context, sourceID, targetID, task, idempotencyKey string) (delegationID string, err error) // Dispatch fires DELEGATION_STATUS=dispatched, runs proxyA2ARequest, // classifies the response into queued / complete / failed, emits // the corresponding event, writes the result row, and (when applicable) // pushes the result to the caller's inbox. Embeds delegation_id in // the A2A metadata so a2a_queue stitch works for poll-mode targets. func (w *DelegationWriter) Dispatch(ctx context.Context, sourceID, targetID, delegationID, task string) (response string, err error) ``` Both call sites become: ```go // HTTP — delegation.go:Delegate id, err := w.Begin(ctx, sourceID, body.TargetID, body.Task, body.IdempotencyKey) go w.Dispatch(detachCtx(ctx), sourceID, body.TargetID, id, body.Task) // MCP sync — mcp_tools.go:toolDelegateTask id, err := w.Begin(ctx, callerID, targetID, task, /* no idempotency */ "") return w.Dispatch(ctx, callerID, targetID, id, task) // MCP async — mcp_tools.go:toolDelegateTaskAsync id, _ := w.Begin(ctx, callerID, targetID, task, "") go w.Dispatch(detachCtx(ctx), callerID, targetID, id, task) return id, nil ``` `logA2AReceiveQueued` is left in place for non-delegation `a2a_receive` traffic (peer-direct A2A that isn't a delegation), but the `Dispatch` path embeds `delegation_id` in `params.message.metadata`, so when the target is poll-mode the proxy short-circuit's queued response is classified by `Dispatch` as `DELEGATION_STATUS=queued` and the eventual drain stitch (`a2a_queue.go:stitchDrainResponseToDelegation`) finds the row and fires `DELEGATION_COMPLETE`. ### Alternatives considered and rejected 1. **Add lifecycle events directly inside `toolDelegateTask`** — duplicates the entire `executeDelegation` body (~120 lines: status update, error classification, retry, ledger writes, inbox push, broadcast). Violates SSOT and creates two places that must stay in sync. Rejected. 2. **Have `toolDelegateTask` POST to its own `/workspaces/:id/delegate` endpoint internally** — clean SSOT but adds an HTTP round-trip for what should be an in-process call, complicates auth (the MCP path's `X-Workspace-ID` header convention vs. the HTTP path's bearer auth), and makes errors round-trip through HTTP-status translation twice. Rejected. 3. **Write `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 emit `DELEGATION_*` 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 - **RFC #2945 PR-A — `AgentMessageWriter` SSOT** (`d99b3f2a refactor(handlers): consolidate Notify + MCP send_message_to_user through AgentMessageWriter`). Same problem shape, same fix shape. Adopt the constructor injection + interface boundary so `DelegationWriter` is unit-testable without a live broadcaster, mirroring how `AgentMessageWriter` tests substitute the writer. - **RFC #2829 PR-2 result-push to caller inbox** (`ae79b9e9`). Already wired in `executeDelegation` via `pushDelegationResultToInbox`; SSOT extraction preserves it for free. - **#36 a2a-proxy SSOT proactive container check** (`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 the `Dispatch` health-check pattern. ### Prior art rejected - **GitHub Actions in-progress check UI** — relies on a polling client and per-step persisted state. Our wire is push (WebSocket), not pull, so we don't need the polling layer. Adopting the polling shape would add latency we already eliminated with `RecordAndBroadcast`. - **Slack-style typing indicator (ephemeral, no persistence)** — we want persistence (the bubble survives reload + activity-log GET on mount). Rejected the ephemerality. ## Security-aware design check (per Phase 2 SOP) - **Untrusted input**: `task` text is bounded by LLM message-size limits and already passes through `textutil.TruncateBytes(_, 100)` before broadcast. No new input surface. - **Auth/sessions/permissions**: `toolDelegateTask` already enforces `registry.CanCommunicate(callerID, targetID)`. The HTTP path (`Delegate`) is gated by `WorkspaceAuth`. Extracting the writer does not change either gate; both callers continue to enforce their existing checks before invoking `Begin`. No expansion of who-can-delegate-what. - **Data collection/logging**: existing `task` text is already logged in `activity_logs.request_body`. The new broadcast carries `task_preview` (already truncated). No new data class, no secret-bearing fields. Continue to never log `Authorization` or `X-Workspace-ID` token values. - **Who can access what**: the `DELEGATION_*` events are scoped to the source workspace's WebSocket subscribers — same scope as `ACTIVITY_LOGGED` today. No cross-workspace leakage. - **SSRF**: `Dispatch` reuses `proxyA2ARequest` (already has SSRF defence + private-network rejection). MCP path's pre-existing `isSafeURL(agentURL)` check is preserved. No expansion of attack surface. Compliance posture unchanged. ## Backwards compatibility / versioning - **Wire contract (WS events)**: `DELEGATION_SENT/STATUS/COMPLETE/FAILED` constants already exist in `events/types.go` and the canvas already consumes them. No new event types, no canvas-side migration. Producers add a fourth call site, consumers see no schema change. - **HTTP contract**: `POST /workspaces/:id/delegate` body and response unchanged. - **MCP tool contract**: `delegate_task` / `delegate_task_async` argument schema unchanged. Return value of `delegate_task` (sync) is the same `extractA2AText(body)` as today; lifecycle events are an additive side effect. - **Database**: `activity_logs` schema unchanged (the `pending|queued|completed|failed` enum already accommodates this). `delegations` ledger writes are gated by `DELEGATION_LEDGER_WRITE` — default off, so MCP-path additions are a no-op until the ledger is enabled. - **Deprecation**: nothing deprecated. The current MCP path becomes the writer's caller; no public symbol is removed. ## Definition of done (Phase 4 verification plan) - [ ] Unit tests on `DelegationWriter` covering: happy path, idempotency hit, target busy (queued), target completes immediately, target fails (transient + permanent), `CanCommunicate=false`, `isSafeURL=false`. - [ ] Integration test (real Postgres) asserting that `toolDelegateTask` and `Delegate` produce identical `activity_logs` rows + identical broadcast event sequences for the same input. **This is the SSOT regression gate.** Caught the kind of drift we're fixing today. - [ ] E2E (canvas + workspace-server + a peer poll-mode workspace running molecule-mcp-claude-channel): canvas sends MCP `delegate_task`, asserts `WaitingBubbles` renders during the in-flight window, asserts the bubble flips to a completed message when the peer's reply lands. Local Postgres + real binaries per `feedback_mandatory_local_e2e_before_ship`. - [ ] Hostile self-review identifying the three weakest spots before merge. - [ ] Branch-count test coverage check before claiming 100% per `feedback_branch_count_before_approving`. - [ ] Logs verified sufficient for from-logs-alone debugging of: stuck-pending, queued-never-stitched, double-emit, and lost-broadcast. - [ ] Post-merge observation through one full cycle: at least one canvas-MCP delegation to a poll-mode peer with bubble-render verified by screenshot/network-trace. ## Layer 2 (out of scope for this issue, separate RFC) Streaming **tool/step progress** during the peer's work — i.e. `DELEGATION_STATUS` payload extended with `current_step`, `tools_used`, `eta_hint`, fired mid-flight by the peer adapter (template-claude-code, hermes, codex, etc.) calling a new `POST /delegations/:id/status` endpoint. 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 1. Should `DelegationWriter` live in `internal/handlers/` (alongside `delegation.go`) or in a new `internal/delegation/` package? PR-A put `AgentMessageWriter` in `internal/messaging/`; symmetry would put this in `internal/delegation/`. 2. Should the MCP sync `toolDelegateTask` continue to **wait** for the `Dispatch` to complete (preserving today's `extractA2AText(body)` return value) or return immediately and let the caller poll `check_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 to `mcpCallTimeout`. Recommend: preserve sync semantics but emit `DELEGATION_SENT` before the wait so the bubble appears immediately. 3. Whose persona should land this PR? The work touches workspace-server core; per `feedback_per_agent_gitea_identity_default`, this should be a Dev Lead persona, not a founder PAT.
Author
Owner

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:

  1. The local worktree is currently inaccessible from this session (macOS Privacy Documents-folder protection blocked Bash mid-session).
  2. Per the directive that everything ships under our git domain, no new code lands with github.com/Molecule-AI/... identifiers — the writer should ship on the new vanity path go.moleculesai.app/core/platform, not the legacy github.com path.

Path forward

  1. molecule-core#82 (vanity migration PR) merges.
  2. Drop the gh-identity allowlist (separately tracked as molecule-core#91).
  3. Re-create the writer + tests on top of the migrated branch (the writer code itself is unchanged; only its imports rewrite from github.com/Molecule-AI/molecule-monorepo/platform/... to go.moleculesai.app/core/platform/...).
  4. Open the new PR, run mutation tests + lint gate, ship.

The writer's design and the issue body above remain accurate. No design changes required by the migration — only path rewrites.

Tracking

  • Parent migration RFC: internal#71
  • Workspace-server migration PR: molecule-core#82
  • Cleanup PR: molecule-core#91
## 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: 1. The local worktree is currently inaccessible from this session (macOS Privacy Documents-folder protection blocked Bash mid-session). 2. Per the directive that everything ships under our git domain, no new code lands with `github.com/Molecule-AI/...` identifiers — the writer should ship on the new vanity path `go.moleculesai.app/core/platform`, not the legacy github.com path. ## Path forward 1. molecule-core#82 (vanity migration PR) merges. 2. Drop the gh-identity allowlist (separately tracked as molecule-core#91). 3. Re-create the writer + tests on top of the migrated branch (the writer code itself is unchanged; only its imports rewrite from `github.com/Molecule-AI/molecule-monorepo/platform/...` to `go.moleculesai.app/core/platform/...`). 4. Open the new PR, run mutation tests + lint gate, ship. The writer's design and the issue body above remain accurate. No design changes required by the migration — only path rewrites. ## Tracking - Parent migration RFC: internal#71 - Workspace-server migration PR: molecule-core#82 - Cleanup PR: molecule-core#91
Sign in to join this conversation.
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: molecule-ai/molecule-core#49
No description provided.