Files
multica/server/internal/daemon/wsrpc.go
Multica Eve c3dd9ec845 Machine-level batch task claim endpoint (MUL-4257) (#5193)
* feat(daemon-claim): machine-level batch task claim endpoint (MUL-4257)

Collapse the per-runtime /tasks/claim poll fan-out into a single machine-level
batch claim to cut /api/daemon claim request volume.

Server:
- agent.sql: = ANY(runtime_ids) batch variants of the claim queries
  (ListQueuedClaimCandidatesByRuntimes, PromoteDueDeferredTasksForRuntimes,
  ReclaimStaleDispatchedTasksForRuntimes); runtime.sql: GetAgentRuntimes(= ANY)
  so a whole machine's runtimes are resolved/promoted/reclaimed/listed in a
  constant number of queries instead of N.
- service.ClaimTasksForRuntimes: claim up to max_tasks across a runtime set,
  preserving per-(issue,agent) serialization, the concurrency cap, the
  empty-claim cache short-circuit, and every dispatch side effect. Batch
  promote replays the per-row side effects (task:queued + empty-cache Bump).
- handler.ClaimTasksByRuntime (canonical POST /api/daemon/tasks/claim, with a
  transitional /claim alias): validates daemon_id (required; must match the
  mdt_ token) and rejects runtimes bound to a different daemon (group-ownership
  check mirroring the WS path); resolves+authorizes each runtime_id; claims;
  and finalizes each task through the SAME FinalizeTaskClaim as the per-runtime
  endpoint (atomic token + delivered_comment_ids receipt), requeueing the exact
  claim and omitting it on failure. buildClaimedTaskResponse is extracted from
  the per-runtime handler and returns the delivered-comment ids plus a
  structured *claimBuildFailure so both paths share identical payload building
  and failure semantics (workspace-isolation, chat-input load/empty).
- max_tasks: negative -> 400, zero -> empty (never coerce to 1), positive
  capped at 32. runtime_ids parsed with non-panicking util.ParseUUID.

Daemon:
- Client.ClaimTasks posts daemon_id + runtime set + free-slot count to the
  canonical path under a short request-scoped timeout, bounding the
  head-of-line coupling the per-runtime pollers avoid (MUL-1744).

Tests: service batch drain / max_tasks cap / deferred-promote receipt /
finalize-failure rollback+requeue; handler routing + token, cross-workspace
skip, cross-daemon skip, daemon_id required, owner-missing cancel,
max_tasks=0/negative, invalid-uuid skip, comment delivery receipt, stale-reclaim
replacement receipt; client posts/parses (daemon_id + canonical path).

Follow-up: cut the daemon pollLoop over to a single batched poller (flips the
MUL-1744 isolation contract; needs its concurrency tests redesigned).

Co-authored-by: multica-agent <github@multica.ai>

* feat(daemon-ws): generic WS request/response transport for daemon RPC (MUL-4257)

Add a generic daemon->server request/response layer over the existing WS
control connection, the transport for WS-first claim (HTTP fallback):
- protocol: daemon:rpc_request / daemon:rpc_response envelopes with a
  correlation request_id + method + body, and an rpc-v1 capability gate.
- daemonws.Hub: SetRPCHandler + goroutine-dispatched handleRPCFrame (bounded
  by a per-connection in-flight cap) that echoes the request_id; missing
  handler / saturation return non-2xx so the daemon falls back to HTTP.
  Read limit raised to 64KB for rpc requests carrying a runtime set.
- hub tests: round-trip, handler-error->non-2xx, no-handler->503.

Co-authored-by: multica-agent <github@multica.ai>

* feat(daemon-ws): WS-first task claim over the generic RPC transport (MUL-4257)

Bind claim to the WS request/response layer, with HTTP fallback:
- server: handler.DaemonRPCHandler adapts a daemon:rpc_request (method
  tasks.claim) to the existing HTTP ClaimTasksByRuntime via a synthetic
  in-process request carrying the WS connection's identity (daemon_id +
  workspace + capabilities), so all auth / payload-building / finalization is
  reused unchanged. Wired via daemonHub.SetRPCHandler. ClientIdentity now
  captures X-Client-Capabilities so capability gating matches the HTTP path.
- daemon: wsRPCClient correlates responses by request_id over the shared WS
  connection; attached to the live connection's write channel (guarded so a
  Call racing teardown never sends on a closed channel) and detached on
  disconnect. rpc_response frames are routed in the read loop.
  Daemon.ClaimTasksWSFirst issues tasks.claim over WS and falls back to the
  HTTP claim endpoint on any transport failure (no conn / buffer full /
  timeout) — wired into the poller at the poller cutover.
- tests: handler tasks.claim RPC end-to-end (claims + dispatches) + unknown
  method 404; daemon wsRPCClient round-trip / timeout / unavailable /
  server-error / detach-fails-pending (all under -race).

Co-authored-by: multica-agent <github@multica.ai>

* feat(daemon): cut claim poller over to machine-level ClaimTasksWSFirst (MUL-4257)

Replace the per-runtime HTTP poll loop with a single batch poller: each cycle
acquires all free execution slots (slot-before-claim) and issues ONE
ClaimTasksWSFirst across every runtime the daemon hosts (WS-first, HTTP
fallback), dispatching each returned task to its runtime. Wakeups (targeted /
catch-up / runtime-set change) collapse to one nudge. Removes runRuntimePoller
+ runtimePollOffset. The WS handshake now advertises the same capabilities as
HTTP (+ rpc-v1) so WS-built claim payloads keep skill-ref / coalesced-comment
gating.

Trades per-runtime isolation (MUL-1744) for one request, bounded by the short
per-request WS timeout / client timeout. Tests: batch poller claims across
runtimes + skips-at-capacity + pollLoop shutdown drain (replacing the
per-runtime poller tests); heartbeat isolation + runtime-set watcher kept.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon-ws): WS RPC disconnect-race panic + batch stale-comment-plan repair (MUL-4257)

Two PR #5193 review blockers:

1) WS RPC send-on-closed-channel race, both ends:
   - server: give each connection a cancelable ctx (cancelled on readPump
     teardown) and run the RPC handler under it, so a slow claim stops on
     disconnect; guard c.send with sendMu/sendClosed (trySend) so a late RPC
     response goroutine never writes to the closed channel. Heartbeat ack routed
     through the same guard.
   - daemon: wsRPCClient.deliver now sends under the mutex, serialized with
     attach(nil)'s close+delete, so a delivered response can't hit a channel
     the detach path just closed.
   - regressions (-race): daemon deliver-vs-detach; server
     disconnect-during-handler-response.

2) batch claim now runs the stale-comment-plan repair: extracted the
   per-runtime handler's repair (trigger deleted, only coalesced survive ->
   cancel + replay survivors) into shared repairStaleCommentPlanIfNeeded, called
   by both claim paths. Prevents the batch path (now the default poller) from
   finalizing+dispatching a task with no comment input and silently dropping the
   surviving user comment. Regression: batch omits the stale task, cancels it,
   and rebuilds the survivor into a new trigger plan.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon-ws): server-side RPC deadline + legacy claim fallback (MUL-4257)

Two review blockers:

1) WS RPC timeout/fallback (GPT-Boy): the daemon's WS wait didn't cancel
   server-side claim, so a slow WS claim could commit after the daemon fell
   back to HTTP, leaking dispatched tasks and breaking the free-slot bound.
   Fix: RPC envelope carries TimeoutMs; the server bounds the handler ctx by it
   (so ClaimTasksByRuntime's tx is cancelled/rolled back at the deadline), and
   the daemon waits budget + grace so a claim that committed before the deadline
   still reports back. A committed-then-unreported claim degrades to the same
   stale-reclaim safety net as HTTP, never a double effective claim. Regression:
   server-side TimeoutMs cancels the handler.

2) Backward compat (Terra-Boy): a new daemon against a server without the batch
   route (/api/daemon/tasks/claim 404) couldn't claim. Fix: ClaimTasksWSFirst
   falls back to the legacy per-runtime ClaimTask loop on a batch 404 and caches
   'batch unsupported' (reset on WS reconnect to re-probe after a server
   upgrade). Regression: server exposing only the legacy route.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon-ws): no double-claim on WS teardown/detach (MUL-4257)

Sol-Boy review blocker: on reconnect, teardown failed the pending RPC (→ HTTP
fallback) but then flushed the queued tasks.claim frame to the still-alive
socket, so the server committed the WS claim on top of the HTTP one — double
claim, WS batch orphaned to stale reclaim, breaking the free-slot bound.

- Teardown now closes the connection FIRST, so runWSWriter discards the queued
  RPC frame (write error path) instead of delivering it.
- A detach while a claim's frame is already in flight now returns a distinct
  errWSRPCUncertain; ClaimTasksWSFirst does NOT HTTP-fall-back on uncertain (the
  WS claim may have committed) — it skips the cycle and lets reclaim / the next
  poll recover. Genuine 'not sent' / timeout still fall back (safe: the
  server-side deadline guarantees no uncommitted claim by budget+grace).
- Regression: detach during an in-flight WS claim asserts zero HTTP claims
  (at most one path claims); plus the existing detach/deliver-race and
  server-timeout tests.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon-ws): cancelable RPC frames close the backpressure double-claim (MUL-4257)

Sol-Boy review blocker: the client's response budget starts at enqueue, but
the socket write is async (10s write deadline). A backpressured writer could
hold a tasks.claim in the local queue past the client timeout — the daemon
HTTP-fell-back, then the writer woke and delivered the stale WS frame, so the
server committed it too: same free slots claimed twice. No detach occurs, so
the prior errWSRPCUncertain fix did not cover it.

- WS frames are now cancelable (wsOutbound{sent,canceled} under a mutex). The
  writer calls beginWrite() before WriteMessage and skips cancelled frames.
- On give-up (timeout / detach / ctx), Call cancels the queued frame: if it was
  still pending the cancel wins and the frame is guaranteed never delivered
  (errWSRPCUnavailable → safe HTTP fallback); if the writer already began
  sending it the cancel loses and the outcome is errWSRPCUncertain (no
  fallback). The decision is atomic, so at most one transport claims.

Tests: wsOutbound cancel-before-write vs write-before-cancel; Call timeout
cancels an unsent frame (writer then drops it) vs uncertain when already sent;
plus the updated detach and existing timeout/race tests.

Co-authored-by: multica-agent <github@multica.ai>

* fix(batch-claim): return partial success instead of dropping committed claims (MUL-4257)

Sol-Boy review blocker: ClaimTasksForRuntimes reclaims (step 2) and claims per
agent (step 6) in independent transactions, but a step-4 candidate-SELECT error
or a mid-loop ClaimTask error did 'return nil, err' — discarding tasks already
committed as dispatched. The handler 500s; the daemon sees a definite (non-
uncertain) 500 and HTTP-falls-back, claiming a SECOND batch into the same free
slots while the first batch waits for stale reclaim — the double-claim this PR
removes.

- Both error paths now prefer partial success: if any task has already
  committed (claimed non-empty), return it (nil error) so the handler finalizes
  and returns 200; the errored candidates stay queued for the next poll. The
  remaining error is logged. Only a genuinely empty result still returns the
  error (safe: no committed claim to lose, HTTP fallback just re-fails).

Regression (internal/service, DB-backed, fault-injected):
- PartialSuccessOnSecondAgentClaimFailure: fail the 2nd ClaimTask's Begin →
  the first agent's committed task is returned, not dropped.
- PartialSuccessOnCandidateQueryFailureAfterReclaim: a stale dispatched task is
  reclaimed, then the candidate SELECT fails → the reclaimed task is returned.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-14 11:53:42 +08:00

307 lines
10 KiB
Go

package daemon
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"github.com/google/uuid"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// errWSRPCUnavailable is returned by wsRPCClient.Call when there is no live WS
// connection to carry the request. Callers treat it as the signal to fall back
// to HTTP.
var errWSRPCUnavailable = errors.New("ws rpc: no active connection")
// errWSRPCUncertain is returned when a request's frame WAS sent but the
// connection dropped before a definitive response. The outcome is unknown (the
// server may have committed), so the caller must NOT fall back to another
// transport for the same work — that risks a double claim (MUL-4257).
var errWSRPCUncertain = errors.New("ws rpc: sent but outcome unknown (connection lost)")
// wsRPCResponseGrace is how much longer the daemon waits for an RPC response
// beyond the server-side execution budget it requested, so a claim that
// committed just before the server deadline still reports back before the
// daemon gives up (MUL-4257).
const wsRPCResponseGrace = 2 * time.Second
// errWSRPCWriteBufferFull is returned when the connection's write buffer is
// saturated; the caller falls back to HTTP rather than blocking the socket.
var errWSRPCWriteBufferFull = errors.New("ws rpc: write buffer full")
// wsRPCClient is the daemon-side half of the generic WS request/response
// transport (MUL-4257). It correlates responses to requests by request_id over
// the shared, multiplexed WS control connection so multiple RPCs can be in
// flight concurrently. Sending is delegated to an injected sendFrame func
// (which pushes onto the active connection's write channel); when no connection
// is attached, Call fails fast with errWSRPCUnavailable and the caller uses
// HTTP.
// wsOutbound is a frame queued for the WS writer. It is cancelable so an RPC
// caller that gives up (timeout/detach) before the frame has hit the socket can
// prevent it from being delivered later — otherwise a backpressured writer
// could deliver a stale tasks.claim after the daemon already HTTP-fell-back,
// double-claiming (MUL-4257, Sol-Boy review). sent/cancel race under mu so the
// decision is atomic: whoever wins determines whether the frame is delivered.
type wsOutbound struct {
data []byte
mu sync.Mutex
sent bool
canceled bool
}
// beginWrite is called by the writer immediately before WriteMessage. It
// returns false when the frame was already cancelled (skip it); otherwise it
// marks the frame sent so a concurrent cancel() can no longer un-send it.
func (o *wsOutbound) beginWrite() bool {
o.mu.Lock()
defer o.mu.Unlock()
if o.canceled {
return false
}
o.sent = true
return true
}
// cancel is called by an RPC caller giving up. Returns true if the frame was
// still pending (now cancelled — the writer will skip it, so it is guaranteed
// NOT delivered); false if the writer already began sending it.
func (o *wsOutbound) cancel() bool {
o.mu.Lock()
defer o.mu.Unlock()
if o.sent {
return false
}
o.canceled = true
return true
}
type wsRPCClient struct {
mu sync.Mutex
pending map[string]chan protocol.RPCResponsePayload
sendFrame func([]byte) (*wsOutbound, error)
// grace is added to a call's server-side timeout budget to get how long the
// daemon waits for the response, so a claim that committed just before the
// server deadline still reports back before the daemon gives up (MUL-4257).
grace time.Duration
}
func newWSRPCClient(grace time.Duration) *wsRPCClient {
return &wsRPCClient{
pending: make(map[string]chan protocol.RPCResponsePayload),
grace: grace,
}
}
// attach binds a live connection's frame writer. Passing nil detaches (on
// disconnect), after which Call fails fast until the next attach. Any pending
// requests are failed so their callers fall back to HTTP immediately.
func (c *wsRPCClient) attach(sendFrame func([]byte) (*wsOutbound, error)) {
c.mu.Lock()
c.sendFrame = sendFrame
if sendFrame == nil {
for id, ch := range c.pending {
close(ch)
delete(c.pending, id)
}
}
c.mu.Unlock()
}
// connected reports whether a live connection is attached.
func (c *wsRPCClient) connected() bool {
if c == nil {
return false
}
c.mu.Lock()
defer c.mu.Unlock()
return c.sendFrame != nil
}
// Call issues an RPC and blocks until the response, the per-request timeout, or
// ctx cancellation. reqBody is marshaled into the request envelope; on a 2xx
// response respBody (if non-nil) is unmarshaled from the response body. It
// returns the response status (0 when the call never reached the server) so the
// caller can distinguish transport failure (→ HTTP fallback) from a server-side
// error.
func (c *wsRPCClient) Call(ctx context.Context, method string, serverTimeout time.Duration, reqBody, respBody any) (int, error) {
if c == nil {
return 0, errWSRPCUnavailable
}
var rawReq json.RawMessage
if reqBody != nil {
b, err := json.Marshal(reqBody)
if err != nil {
return 0, fmt.Errorf("ws rpc: marshal request: %w", err)
}
rawReq = b
}
id := uuid.NewString()
frame, err := json.Marshal(protocol.Message{
Type: protocol.EventDaemonRPCRequest,
Payload: marshalRaw(protocol.RPCRequestPayload{
RequestID: id,
Method: method,
Body: rawReq,
TimeoutMs: serverTimeout.Milliseconds(),
}),
})
if err != nil {
return 0, fmt.Errorf("ws rpc: marshal frame: %w", err)
}
ch := make(chan protocol.RPCResponsePayload, 1)
c.mu.Lock()
if c.sendFrame == nil {
c.mu.Unlock()
return 0, errWSRPCUnavailable
}
send := c.sendFrame
c.pending[id] = ch
c.mu.Unlock()
defer func() {
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
}()
item, err := send(frame)
if err != nil {
return 0, fmt.Errorf("ws rpc: send: %w", err)
}
// giveUp resolves an abandoned request. If the frame is still queued we
// cancel it so the writer never delivers it — a definitively-not-sent
// outcome that is safe to HTTP-fall-back. If the writer already began
// sending it, it may reach the server, so the outcome is uncertain and the
// caller must NOT fall back (that would double-claim, MUL-4257).
giveUp := func() error {
if item.cancel() {
return errWSRPCUnavailable
}
return errWSRPCUncertain
}
// Wait the server-side budget PLUS a grace margin: a claim that committed
// just before the server deadline must still report back before the daemon
// gives up and falls back to HTTP, or we would double-claim (MUL-4257).
timeout := serverTimeout + c.grace
if timeout <= 0 {
timeout = 5 * time.Second
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case resp, ok := <-ch:
if !ok {
// The connection detached. Whether the server saw this request
// depends on whether the frame had already left the writer, so let
// giveUp() decide (not-sent → safe fallback; sent → uncertain).
return 0, giveUp()
}
if resp.Status >= 200 && resp.Status < 300 {
if respBody != nil && len(resp.Body) > 0 {
if err := json.Unmarshal(resp.Body, respBody); err != nil {
return resp.Status, fmt.Errorf("ws rpc: decode response: %w", err)
}
}
return resp.Status, nil
}
msg := resp.Error
if msg == "" {
msg = fmt.Sprintf("ws rpc status %d", resp.Status)
}
return resp.Status, errors.New(msg)
case <-timer.C:
// The budget elapsed. If the frame is still queued behind a
// backpressured writer, cancel it so it is never delivered after we
// fall back (giveUp → not-sent). If it already left the writer, the
// outcome is uncertain and we must not fall back.
if err := giveUp(); errors.Is(err, errWSRPCUncertain) {
return 0, err
}
return 0, fmt.Errorf("ws rpc: timeout after %s: %w", timeout, errWSRPCUnavailable)
case <-ctx.Done():
item.cancel()
return 0, ctx.Err()
}
}
// deliver routes an inbound rpc_response frame to the waiting Call. The send
// happens under the mutex so it is serialized with attach(nil)'s close+delete:
// a channel present in pending is guaranteed not yet closed, so this never
// sends on a closed channel. Unknown request ids (already timed out / detached)
// are dropped.
func (c *wsRPCClient) deliver(resp protocol.RPCResponsePayload) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
ch, ok := c.pending[resp.RequestID]
if !ok {
return
}
select {
case ch <- resp:
default:
}
}
// ClaimTasksWSFirst is the WS-first claim policy (MUL-4257): it issues the
// tasks.claim RPC over the WS control connection when one is attached, and
// falls back to the HTTP claim endpoint on any transport failure (no
// connection, write-buffer full, timeout) or server error. The request/response
// bodies are identical to the HTTP endpoint so both transports are
// interchangeable. Wired into the claim poller as part of the poller cutover.
func (d *Daemon) ClaimTasksWSFirst(ctx context.Context, daemonID string, runtimeIDs []string, maxTasks int) ([]*Task, error) {
// Un-upgraded server without the batch route: a prior poll already learned
// this (via a 404), so go straight to the legacy per-runtime claim and skip
// the WS + batch attempts each cycle.
if d.batchClaimUnsupported.Load() {
return d.client.claimTasksLegacy(ctx, runtimeIDs, maxTasks)
}
if d.wsRPC.connected() {
var resp struct {
Tasks []*Task `json:"tasks"`
}
// batchClaimRequestTimeout is the server-side execution budget; the
// daemon waits that plus the client's grace margin for the response.
_, err := d.wsRPC.Call(ctx, "tasks.claim", batchClaimRequestTimeout, map[string]any{
"daemon_id": daemonID,
"runtime_ids": runtimeIDs,
"max_tasks": maxTasks,
}, &resp)
if err == nil {
return resp.Tasks, nil
}
if errors.Is(err, errWSRPCUncertain) {
// The WS claim may have committed server-side; claiming the same
// free slots again over HTTP would double-claim. Skip this cycle —
// an orphaned server-side claim is recovered by stale reclaim and
// the next poll picks up anything still queued.
d.logger.Debug("ws claim outcome uncertain after disconnect; skipping fallback this cycle")
return nil, nil
}
d.logger.Debug("ws claim failed; falling back to http", "error", err)
}
tasks, err := d.client.ClaimTasks(ctx, daemonID, runtimeIDs, maxTasks)
if err == nil {
return tasks, nil
}
// Server has no batch route (404): freeze the old API contract by falling
// back to the legacy per-runtime claim loop, and remember it so we don't
// re-probe every cycle.
if isBatchClaimUnsupported(err) {
d.batchClaimUnsupported.Store(true)
d.logger.Info("batch claim route unsupported by server; using legacy per-runtime claim")
return d.client.claimTasksLegacy(ctx, runtimeIDs, maxTasks)
}
return nil, err
}