mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-22 17:49:48 +02:00
* 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>
247 lines
8.1 KiB
Go
247 lines
8.1 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
// TestWSRPCClient_CallRoundTrip: a request is framed and sent, and a matching
|
|
// response (by request_id) is decoded into respBody.
|
|
func TestWSRPCClient_CallRoundTrip(t *testing.T) {
|
|
c := newWSRPCClient(time.Second)
|
|
|
|
// Fake transport: capture the frame, and reply asynchronously with a 200.
|
|
c.attach(func(frame []byte) (*wsOutbound, error) {
|
|
var msg protocol.Message
|
|
if err := json.Unmarshal(frame, &msg); err != nil {
|
|
return nil, err
|
|
}
|
|
var req protocol.RPCRequestPayload
|
|
if err := json.Unmarshal(msg.Payload, &req); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.Method != "tasks.claim" {
|
|
t.Errorf("method = %q, want tasks.claim", req.Method)
|
|
}
|
|
go c.deliver(protocol.RPCResponsePayload{
|
|
RequestID: req.RequestID,
|
|
Status: 200,
|
|
Body: json.RawMessage(`{"tasks":[{"id":"t1"}]}`),
|
|
})
|
|
return &wsOutbound{data: frame}, nil
|
|
})
|
|
|
|
var resp struct {
|
|
Tasks []struct {
|
|
ID string `json:"id"`
|
|
} `json:"tasks"`
|
|
}
|
|
status, err := c.Call(context.Background(), "tasks.claim", 0, map[string]any{"max_tasks": 3}, &resp)
|
|
if err != nil || status != 200 {
|
|
t.Fatalf("Call: status=%d err=%v", status, err)
|
|
}
|
|
if len(resp.Tasks) != 1 || resp.Tasks[0].ID != "t1" {
|
|
t.Fatalf("resp = %+v, want one task t1", resp)
|
|
}
|
|
}
|
|
|
|
// TestWSRPCClient_Unavailable: with no connection attached, Call fails fast so
|
|
// the caller falls back to HTTP.
|
|
func TestWSRPCClient_Unavailable(t *testing.T) {
|
|
c := newWSRPCClient(time.Second)
|
|
if _, err := c.Call(context.Background(), "tasks.claim", 0, nil, nil); !errors.Is(err, errWSRPCUnavailable) {
|
|
t.Fatalf("err = %v, want errWSRPCUnavailable", err)
|
|
}
|
|
}
|
|
|
|
// TestWSRPCClient_Timeout: no response arrives within the per-request timeout.
|
|
func TestWSRPCClient_Timeout(t *testing.T) {
|
|
c := newWSRPCClient(50 * time.Millisecond)
|
|
c.attach(func(frame []byte) (*wsOutbound, error) { return &wsOutbound{data: frame}, nil }) // send succeeds, never replies
|
|
status, err := c.Call(context.Background(), "tasks.claim", 0, nil, nil)
|
|
if err == nil || status != 0 {
|
|
t.Fatalf("status=%d err=%v, want timeout (status 0, err)", status, err)
|
|
}
|
|
}
|
|
|
|
// TestWSRPCClient_ServerError: a non-2xx response surfaces as an error with the
|
|
// server-provided message, and a non-zero status so the caller can classify.
|
|
func TestWSRPCClient_ServerError(t *testing.T) {
|
|
c := newWSRPCClient(time.Second)
|
|
c.attach(func(frame []byte) (*wsOutbound, error) {
|
|
var msg protocol.Message
|
|
json.Unmarshal(frame, &msg)
|
|
var req protocol.RPCRequestPayload
|
|
json.Unmarshal(msg.Payload, &req)
|
|
go c.deliver(protocol.RPCResponsePayload{RequestID: req.RequestID, Status: 400, Error: "bad daemon_id"})
|
|
return &wsOutbound{data: frame}, nil
|
|
})
|
|
status, err := c.Call(context.Background(), "tasks.claim", 0, nil, nil)
|
|
if status != 400 || err == nil {
|
|
t.Fatalf("status=%d err=%v, want 400 + error", status, err)
|
|
}
|
|
}
|
|
|
|
// TestWSRPCClient_DetachFailsPending: detaching (disconnect) unblocks an
|
|
// in-flight Call whose frame was already sent with errWSRPCUncertain — the
|
|
// caller must not blindly re-claim over HTTP (MUL-4257).
|
|
func TestWSRPCClient_DetachFailsPending(t *testing.T) {
|
|
c := newWSRPCClient(2 * time.Second)
|
|
var mu sync.Mutex
|
|
var item *wsOutbound
|
|
c.attach(func(frame []byte) (*wsOutbound, error) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
item = &wsOutbound{data: frame}
|
|
return item, nil
|
|
})
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := c.Call(context.Background(), "tasks.claim", 0, nil, nil)
|
|
done <- err
|
|
}()
|
|
time.Sleep(30 * time.Millisecond)
|
|
// Simulate the writer having put the frame on the wire before the
|
|
// disconnect, so the server may have processed it → outcome is uncertain.
|
|
mu.Lock()
|
|
item.beginWrite()
|
|
mu.Unlock()
|
|
c.attach(nil) // detach
|
|
select {
|
|
case err := <-done:
|
|
if !errors.Is(err, errWSRPCUncertain) {
|
|
t.Fatalf("err = %v, want errWSRPCUncertain", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Call did not return after detach")
|
|
}
|
|
}
|
|
|
|
// TestWSRPCClient_DeliverDetachRaceNoPanic hammers deliver racing with
|
|
// attach(nil) (disconnect). Before the fix, deliver could send on a channel
|
|
// attach(nil) had just closed → "send on closed channel" panic. Run under
|
|
// -race; passing means the two are serialized under the mutex.
|
|
func TestWSRPCClient_DeliverDetachRaceNoPanic(t *testing.T) {
|
|
for iter := 0; iter < 300; iter++ {
|
|
c := newWSRPCClient(time.Second)
|
|
c.attach(func(frame []byte) (*wsOutbound, error) { return &wsOutbound{data: frame}, nil })
|
|
id := "req"
|
|
ch := make(chan protocol.RPCResponsePayload, 1)
|
|
c.mu.Lock()
|
|
c.pending[id] = ch
|
|
c.mu.Unlock()
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
c.deliver(protocol.RPCResponsePayload{RequestID: id, Status: 200})
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
c.attach(nil) // closes + deletes pending under the same mutex
|
|
}()
|
|
wg.Wait()
|
|
}
|
|
}
|
|
|
|
// TestWSOutbound_CancelBeforeWriteDropsFrame: a caller that gives up before the
|
|
// writer sends the frame cancels it, and the writer then skips it (never
|
|
// delivered) — the core guarantee that a delayed frame cannot double-claim
|
|
// after an HTTP fallback (MUL-4257).
|
|
func TestWSOutbound_CancelBeforeWriteDropsFrame(t *testing.T) {
|
|
o := &wsOutbound{data: []byte("x")}
|
|
if !o.cancel() {
|
|
t.Fatal("cancel of a pending frame should succeed")
|
|
}
|
|
if o.beginWrite() {
|
|
t.Fatal("writer must skip a cancelled frame")
|
|
}
|
|
}
|
|
|
|
// TestWSOutbound_WriteBeforeCancelDelivers: once the writer has begun sending a
|
|
// frame it can no longer be cancelled, so the caller must treat the outcome as
|
|
// uncertain rather than falling back.
|
|
func TestWSOutbound_WriteBeforeCancelDelivers(t *testing.T) {
|
|
o := &wsOutbound{data: []byte("x")}
|
|
if !o.beginWrite() {
|
|
t.Fatal("writer should send a pending frame")
|
|
}
|
|
if o.cancel() {
|
|
t.Fatal("cancel must fail once the frame has been sent")
|
|
}
|
|
}
|
|
|
|
// TestWSRPCClient_TimeoutCancelsUnsentFrame reproduces the Sol-Boy backpressure
|
|
// blocker: the frame is enqueued but the writer is stalled, so the client times
|
|
// out before it is sent. The timeout must cancel the queued frame (so the
|
|
// stalled writer later DROPS it) and report a not-sent outcome that is safe to
|
|
// HTTP-fall-back — never delivering the stale claim on top of the fallback.
|
|
func TestWSRPCClient_TimeoutCancelsUnsentFrame(t *testing.T) {
|
|
c := newWSRPCClient(20 * time.Millisecond)
|
|
var mu sync.Mutex
|
|
var item *wsOutbound
|
|
c.attach(func(frame []byte) (*wsOutbound, error) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
item = &wsOutbound{data: frame}
|
|
return item, nil // enqueued; no writer ever drains it
|
|
})
|
|
status, err := c.Call(context.Background(), "tasks.claim", 30*time.Millisecond, nil, nil)
|
|
if status != 0 {
|
|
t.Fatalf("status = %d, want 0", status)
|
|
}
|
|
if !errors.Is(err, errWSRPCUnavailable) {
|
|
t.Fatalf("err = %v, want errWSRPCUnavailable (not-sent → safe fallback)", err)
|
|
}
|
|
if errors.Is(err, errWSRPCUncertain) {
|
|
t.Fatal("unsent frame must not be reported uncertain")
|
|
}
|
|
// The stalled writer now wakes up: the frame must have been cancelled so it
|
|
// is dropped, not delivered after the fallback.
|
|
mu.Lock()
|
|
sent := item.beginWrite()
|
|
mu.Unlock()
|
|
if sent {
|
|
t.Fatal("timed-out frame must be dropped by the writer to avoid double-claim")
|
|
}
|
|
}
|
|
|
|
// TestWSRPCClient_TimeoutUncertainWhenAlreadySent: if the writer already put the
|
|
// frame on the wire, a subsequent client timeout is uncertain (the server may
|
|
// have it) and must NOT fall back.
|
|
func TestWSRPCClient_TimeoutUncertainWhenAlreadySent(t *testing.T) {
|
|
c := newWSRPCClient(30 * time.Millisecond)
|
|
var mu sync.Mutex
|
|
var item *wsOutbound
|
|
c.attach(func(frame []byte) (*wsOutbound, error) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
item = &wsOutbound{data: frame}
|
|
return item, nil
|
|
})
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := c.Call(context.Background(), "tasks.claim", 40*time.Millisecond, nil, nil)
|
|
done <- err
|
|
}()
|
|
time.Sleep(10 * time.Millisecond)
|
|
mu.Lock()
|
|
item.beginWrite() // writer sends it before the timeout fires
|
|
mu.Unlock()
|
|
select {
|
|
case err := <-done:
|
|
if !errors.Is(err, errWSRPCUncertain) {
|
|
t.Fatalf("err = %v, want errWSRPCUncertain", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Call did not return")
|
|
}
|
|
}
|