fix: MCP child sessions can be stranded in *_sending permanently #457

Open
opened 2026-07-25 23:33:34 +02:00 by dries · 0 comments
Owner

Code-quality audit (P2). internal/mcp/tools_comm.go:152,163.

A child session can end up permanently stuck in *_sending with no way out.

if err := t.sendMessage(ctx, childID, delivered); err != nil {
	_ = t.stateDB.RestoreChildFollowup(childID, *cs, sendingDelivery)   // :152 — error discarded
	...
}
...
claimed, err = t.stateDB.CompleteChildFollowup(childID, sendingDelivery, delivery)
if err != nil {
	...
	return mcplib.NewToolResultError(...)   // :163 — row left in *_sending
}

Two paths leave the row in ChildResultAsyncSending / ChildResultWaitSending:

  1. Send fails and the restore also fails — the failure is _-discarded, so the caller sees only "sending message to child" and has no idea the row is now wedged.
  2. Send succeeds but CompleteChildFollowup errors — no restore is attempted at all.

The guard at tools_comm.go:119 only accepts delivered / detached:

if cs.ResultDelivery != "delivered" && cs.ResultDelivery != "detached" {
	return mcplib.NewToolResultError("child session previous result is still pending delivery"), nil
}

so no subsequent request can ever claim that child again. The only recovery is editing state.db by hand.

internal/state/childsessions.go:74,87,103ClaimChildFollowup / CompleteChildFollowup / RestoreChildFollowup are the compare-and-swap primitives AGENTS.md calls "atomic" and race-critical, and all three are at 0.0% coverage. RestoreChildFollowup additionally discards RowsAffected:

func (d *DB) RestoreChildFollowup(id string, cs ChildSession, sendingDelivery string) error {
	_, err := d.db.Exec(`UPDATE child_sessions SET ... WHERE id = ? AND status = 'sending' AND result_delivery = ?`, ...)
	if err != nil { return fmt.Errorf("restoring child follow-up: %w", err) }
	return nil
}

A missed CAS (0 rows updated) is indistinguishable from success — which is exactly the case the caller above needs to detect.

Suggested fix

  • RestoreChildFollowup returns (bool, error) like its siblings; a missed CAS is reported.
  • Surface the restore failure in the tool error text so the caller knows the child is wedged.
  • Attempt a restore on the CompleteChildFollowup error path too.
  • Add a reaper (or a timestamp-based timeout on claim) that returns stale *_sending rows to delivered.
  • Direct unit tests for all three CAS primitives, including the contended/miss cases.

Acceptance criteria

  • No path leaves a child in *_sending without either a successful restore or a surfaced error.
  • Stale *_sending rows are reclaimed automatically.
  • All three CAS primitives have tests covering hit and miss.

Effort: M.

Code-quality audit (P2). `internal/mcp/tools_comm.go:152,163`. A child session can end up permanently stuck in `*_sending` with no way out. ```go if err := t.sendMessage(ctx, childID, delivered); err != nil { _ = t.stateDB.RestoreChildFollowup(childID, *cs, sendingDelivery) // :152 — error discarded ... } ... claimed, err = t.stateDB.CompleteChildFollowup(childID, sendingDelivery, delivery) if err != nil { ... return mcplib.NewToolResultError(...) // :163 — row left in *_sending } ``` Two paths leave the row in `ChildResultAsyncSending` / `ChildResultWaitSending`: 1. Send fails **and** the restore also fails — the failure is `_`-discarded, so the caller sees only "sending message to child" and has no idea the row is now wedged. 2. Send succeeds but `CompleteChildFollowup` errors — no restore is attempted at all. The guard at `tools_comm.go:119` only accepts `delivered` / `detached`: ```go if cs.ResultDelivery != "delivered" && cs.ResultDelivery != "detached" { return mcplib.NewToolResultError("child session previous result is still pending delivery"), nil } ``` so **no subsequent request can ever claim that child again**. The only recovery is editing `state.db` by hand. ## Related gap: the CAS primitives are untested `internal/state/childsessions.go:74,87,103` — `ClaimChildFollowup` / `CompleteChildFollowup` / `RestoreChildFollowup` are the compare-and-swap primitives AGENTS.md calls "atomic" and race-critical, and all three are at **0.0% coverage**. `RestoreChildFollowup` additionally discards `RowsAffected`: ```go func (d *DB) RestoreChildFollowup(id string, cs ChildSession, sendingDelivery string) error { _, err := d.db.Exec(`UPDATE child_sessions SET ... WHERE id = ? AND status = 'sending' AND result_delivery = ?`, ...) if err != nil { return fmt.Errorf("restoring child follow-up: %w", err) } return nil } ``` A missed CAS (0 rows updated) is indistinguishable from success — which is exactly the case the caller above needs to detect. ## Suggested fix - `RestoreChildFollowup` returns `(bool, error)` like its siblings; a missed CAS is reported. - Surface the restore failure in the tool error text so the caller knows the child is wedged. - Attempt a restore on the `CompleteChildFollowup` error path too. - Add a reaper (or a timestamp-based timeout on claim) that returns stale `*_sending` rows to `delivered`. - Direct unit tests for all three CAS primitives, including the contended/miss cases. ## Acceptance criteria - [ ] No path leaves a child in `*_sending` without either a successful restore or a surfaced error. - [ ] Stale `*_sending` rows are reclaimed automatically. - [ ] All three CAS primitives have tests covering hit and miss. Effort: M.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
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
dries/ocman#457
No description provided.