mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 19:20:07 +02:00
* perf(agents): make runtime model discovery fast on runtime switch (MUL-5444) Switching runtime in the agent creation form left the model picker spinning for ~8-20s. Two costs stacked up: - the list-models request sat in the store until the daemon's next scheduled heartbeat (0-15s, avg 7.5s of pure dead wait), and - the daemon then enumerated the catalog locally (static for claude, but a CLI/ACP round trip up to ~15s for everyone else). Both are addressed with the two standard techniques for a slow, low-frequency, read-only operation: push instead of poll, and stale-while-revalidate. Push (removes the heartbeat wait): - new additive `daemon:pending_work` hint, runtime-scoped, delivered through the existing daemon WS hub and the Redis relay so the API node holding the socket does the delivery. - the daemon answers a hint with ONE immediate heartbeat and dispatches what it claimed. The hint deliberately carries no work, so nothing has to be un-claimed when delivery fails and a duplicate hint cannot duplicate work - PopPending stays the atomic claim. - per-runtime coalescing plus a 1s floor keeps a caller-triggered hint from becoming a heartbeat amplifier. Cache (removes the discovery wait on repeat opens): - server-side per-runtime catalog cache (in-memory single-node, Redis multi-node) written on every successful report. - a snapshot younger than 15min answers the POST immediately as an already-completed request; older than 60s it also enqueues a background refresh that only warms the cache. - only supported, non-empty catalogs are cached; a completed-but-empty report invalidates instead, while a failed report keeps serving the last known good list. Frontend: staleTime 60s -> 5min and gcTime 30min, so a runtime revisited in the same session renders from cache and revalidates in the background instead of showing the spinner again. Compatibility: every wire change is additive. Old daemons ignore the unknown hint type and keep using the scheduled heartbeat; new daemons against an old server simply never receive one. The cached response is shaped exactly like a completed live discovery apart from the optional `cached` / `cached_at` markers. Co-authored-by: multica-agent <github@multica.ai> * fix(agents): address review on model discovery SWR (MUL-5444) Sol-Boy's review on #6098 found the client cache could outlive the server's own staleness promise, and that the two changed endpoints were still cast rather than validated. Must-fix 1 — client freshness now derives from the served answer. `staleTime` was a flat 5min, so a 14-minute-old snapshot (which the server returns while queueing its own refresh) was held as fresh for another 5min: observable staleness became server window + client window, and the refreshed catalog never reached the tab that triggered the refresh. `staleTime` is now a function of the query data: a `cached` answer is stale on arrival (bound stays the server's window alone, and the next mount/focus picks up the refreshed snapshot), while a live discovery — which just measured the truth — is trusted for the full 5min so a cold runtime is never re-enumerated inside one form session. `gcTime` stays 30min, so a revisited runtime still renders from cache and revalidates in the background; the pickers gate their spinner on `isLoading`, which stays false throughout. Must-fix 2 — both model-discovery responses go through a zod schema. `POST /api/runtimes/{id}/models` and its poll companion were casting network JSON to `RuntimeModelListRequest`, which the root CLAUDE.md API-compatibility rules forbid. Added a lenient schema (`status` stays `z.string()`, `supported` defaults to true, `.loose()` keeps unknown fields) plus a fallback record whose `status` is `failed`: a malformed body now surfaces "discovery failed" with manual entry still usable instead of a fabricated empty catalog or an endless spinner. `resolveRuntimeModels` was tightened to match — only an explicit `completed` is a catalog, so an unrecognised status is an error rather than a silent empty list, and `supported` can no longer be `undefined`. Nit — the in-memory catalog cache now deep-copies each entry's `Thinking` (and its level slice) and `ServiceTiers`, so it delivers the independent value its comment promises and matches the Redis backend's JSON round-trip semantics. Tests: staleTime policy for cached/live/no-data; a QueryObserver test proving the refreshed catalog reaches the same client with no blank loading state; unknown-status and omitted-`supported` handling; schema tests for live, cached, old-backend and nine malformed shapes; client tests that both endpoints degrade to an explicit failure; nested-field mutation isolation for the cache. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
179 lines
7.8 KiB
Go
179 lines
7.8 KiB
Go
package protocol
|
|
|
|
// Event types for WebSocket communication between server, web clients, and daemon.
|
|
const (
|
|
// Issue events
|
|
EventIssueCreated = "issue:created"
|
|
EventIssueUpdated = "issue:updated"
|
|
EventIssueDeleted = "issue:deleted"
|
|
EventIssueMetadataChanged = "issue_metadata:changed"
|
|
|
|
// Comment events
|
|
EventCommentCreated = "comment:created"
|
|
EventCommentUpdated = "comment:updated"
|
|
EventCommentDeleted = "comment:deleted"
|
|
EventCommentResolved = "comment:resolved"
|
|
EventCommentUnresolved = "comment:unresolved"
|
|
EventReactionAdded = "reaction:added"
|
|
EventReactionRemoved = "reaction:removed"
|
|
EventIssueReactionAdded = "issue_reaction:added"
|
|
EventIssueReactionRemoved = "issue_reaction:removed"
|
|
|
|
// Agent events
|
|
EventAgentStatus = "agent:status"
|
|
EventAgentCreated = "agent:created"
|
|
EventAgentArchived = "agent:archived"
|
|
EventAgentRestored = "agent:restored"
|
|
|
|
// Task events (server <-> daemon).
|
|
// Each event maps to a status transition on agent_task_queue. Front-end
|
|
// subscribes by `task:` prefix and invalidates the workspace task
|
|
// snapshot, so the granularity here is "what does the user want to see
|
|
// change" — not "every internal status flip".
|
|
EventTaskQueued = "task:queued" // ∅ → queued (enqueue / retry create)
|
|
EventTaskDispatch = "task:dispatch" // queued → dispatched (daemon claim)
|
|
EventTaskRunning = "task:running" // dispatched → running (daemon started)
|
|
EventTaskWaitingLocalDirectory = "task:waiting_local_directory" // dispatched → waiting_local_directory (daemon parked on a busy local_directory path)
|
|
EventTaskProgress = "task:progress"
|
|
EventTaskCompleted = "task:completed" // running → completed
|
|
EventTaskFailed = "task:failed" // running → failed
|
|
EventTaskMessage = "task:message"
|
|
EventTaskCancelled = "task:cancelled" // * → cancelled
|
|
|
|
// Inbox events
|
|
EventInboxNew = "inbox:new"
|
|
EventInboxRead = "inbox:read"
|
|
EventInboxArchived = "inbox:archived"
|
|
EventInboxUnarchived = "inbox:unarchived"
|
|
EventInboxBatchRead = "inbox:batch-read"
|
|
EventInboxBatchArchived = "inbox:batch-archived"
|
|
|
|
// Workspace events
|
|
EventWorkspaceUpdated = "workspace:updated"
|
|
EventWorkspaceDeleted = "workspace:deleted"
|
|
|
|
// Member events
|
|
EventMemberAdded = "member:added"
|
|
EventMemberUpdated = "member:updated"
|
|
EventMemberRemoved = "member:removed"
|
|
|
|
// Subscriber events
|
|
EventSubscriberAdded = "subscriber:added"
|
|
EventSubscriberRemoved = "subscriber:removed"
|
|
|
|
// Activity events
|
|
EventActivityCreated = "activity:created"
|
|
|
|
// Skill events
|
|
EventSkillCreated = "skill:created"
|
|
EventSkillUpdated = "skill:updated"
|
|
EventSkillDeleted = "skill:deleted"
|
|
|
|
// Chat events
|
|
EventChatMessage = "chat:message"
|
|
EventChatDone = "chat:done"
|
|
// EventChatCancelFinalized carries the deferred outcome of a cancelled
|
|
// chat task once the daemon has flushed its transcript (or the sweeper
|
|
// grace period expired): either a late "Stopped." assistant message or a
|
|
// draft restore (#5219). Channel outbounds (Slack/Lark) deliberately do
|
|
// not subscribe to it — cancellation stays silent on external channels.
|
|
EventChatCancelFinalized = "chat:cancel_finalized"
|
|
EventChatSessionRead = "chat:session_read"
|
|
EventChatSessionDeleted = "chat:session_deleted"
|
|
EventChatSessionUpdated = "chat:session_updated"
|
|
|
|
// Project events
|
|
EventProjectCreated = "project:created"
|
|
EventProjectUpdated = "project:updated"
|
|
EventProjectDeleted = "project:deleted"
|
|
EventProjectResourceCreated = "project_resource:created"
|
|
EventProjectResourceUpdated = "project_resource:updated"
|
|
EventProjectResourceDeleted = "project_resource:deleted"
|
|
|
|
// Label events
|
|
EventLabelCreated = "label:created"
|
|
EventLabelUpdated = "label:updated"
|
|
EventLabelDeleted = "label:deleted"
|
|
EventIssueLabelsChanged = "issue_labels:changed"
|
|
|
|
// Custom property events. Definitions are archived, never deleted, so
|
|
// there is no property:deleted — archive arrives as property:updated.
|
|
EventPropertyCreated = "property:created"
|
|
EventPropertyUpdated = "property:updated"
|
|
EventIssuePropertiesChanged = "issue_properties:changed"
|
|
|
|
// Pin events
|
|
EventPinCreated = "pin:created"
|
|
EventPinDeleted = "pin:deleted"
|
|
EventPinReordered = "pin:reordered"
|
|
|
|
// Invitation events
|
|
EventInvitationCreated = "invitation:created"
|
|
EventInvitationAccepted = "invitation:accepted"
|
|
EventInvitationDeclined = "invitation:declined"
|
|
EventInvitationRevoked = "invitation:revoked"
|
|
|
|
// Autopilot events
|
|
EventAutopilotCreated = "autopilot:created"
|
|
EventAutopilotUpdated = "autopilot:updated"
|
|
EventAutopilotDeleted = "autopilot:deleted"
|
|
EventAutopilotRunStart = "autopilot:run_start"
|
|
EventAutopilotRunDone = "autopilot:run_done"
|
|
|
|
// Squad events
|
|
EventSquadCreated = "squad:created"
|
|
EventSquadUpdated = "squad:updated"
|
|
EventSquadDeleted = "squad:deleted"
|
|
|
|
// Daemon events
|
|
EventDaemonHeartbeat = "daemon:heartbeat"
|
|
EventDaemonHeartbeatAck = "daemon:heartbeat_ack"
|
|
EventDaemonRegister = "daemon:register"
|
|
EventDaemonTaskAvailable = "daemon:task_available"
|
|
EventDaemonRuntimeProfilesChanged = "daemon:runtime_profiles_changed"
|
|
EventDaemonWorkspacesChanged = "daemon:workspaces_changed"
|
|
// EventDaemonPendingWork is a runtime-scoped hint that a heartbeat-carried
|
|
// request (today: model-list discovery) is queued for that runtime. Without
|
|
// it the daemon only learns about the request on its next scheduled
|
|
// heartbeat, which adds up to one HeartbeatInterval (15s by default) of
|
|
// dead wait to an interactive UI flow (MUL-5444). The hint carries no work
|
|
// itself: the daemon still pulls the request through the normal heartbeat
|
|
// claim, so a lost or duplicated hint is harmless.
|
|
EventDaemonPendingWork = "daemon:pending_work"
|
|
// Generic daemon→server request/response over the WebSocket control
|
|
// connection (MUL-4257). The daemon sends EventDaemonRPCRequest with a
|
|
// correlation id + method + body; the server replies EventDaemonRPCResponse
|
|
// with the same request id. This is the transport for WS-first claim (with
|
|
// HTTP fallback) and any future daemon→server RPC.
|
|
EventDaemonRPCRequest = "daemon:rpc_request"
|
|
EventDaemonRPCResponse = "daemon:rpc_response"
|
|
|
|
// GitHub integration events
|
|
EventGitHubInstallationCreated = "github_installation:created"
|
|
EventGitHubInstallationDeleted = "github_installation:deleted"
|
|
EventPullRequestLinked = "pull_request:linked"
|
|
EventPullRequestUpdated = "pull_request:updated"
|
|
EventPullRequestUnlinked = "pull_request:unlinked"
|
|
|
|
// VCS integration events (Forgejo / Gitea / GitLab)
|
|
EventVCSConnectionCreated = "vcs_connection:created"
|
|
EventVCSConnectionDeleted = "vcs_connection:deleted"
|
|
|
|
// Lark integration events. `created` covers both first-install
|
|
// (UNIQUE on (workspace_id, agent_id) means at most one row per
|
|
// agent) and re-install via UpsertLarkInstallation — front-ends
|
|
// treat both as a single "installation appeared / refreshed"
|
|
// notification. `revoked` flips status to 'revoked' without
|
|
// deleting the row; the audit trail is preserved.
|
|
EventLarkInstallationCreated = "lark_installation:created"
|
|
EventLarkInstallationRevoked = "lark_installation:revoked"
|
|
|
|
// Slack installation lifecycle (MUL-3666). Same semantics as the Lark
|
|
// events: `created` covers both first install and OAuth re-install (the
|
|
// UNIQUE on (workspace_id, agent_id, channel_type) means at most one row
|
|
// per agent), `revoked` flips status without deleting the row. Front-ends
|
|
// invalidate the Slack installations query on either.
|
|
EventSlackInstallationCreated = "slack_installation:created"
|
|
EventSlackInstallationRevoked = "slack_installation:revoked"
|
|
)
|