Files
Multica Eveandmultica-agent f2a6398d6e MUL-7257: refactor(server): remove unused compatibility queries and capabilities (#9671)
* refactor(comments): drop redundant RFC3339 fallback parse

RFC3339Nano parsing already accepts timestamps without fractional
seconds, so the second time.Parse(time.RFC3339) attempt can never
succeed where the first failed. Keep the existing 400 responses and add
a test pinning second-precision since and the invalid since/before cases.

Co-authored-by: multica-agent <[email protected]>

* refactor(daemon): stop advertising unread plugin capabilities

execution-manifest-v1, agent-skill-v1 and remote-mcp-v1 have had no
server-side reader since the Plugin V1 gates were removed in #7144.
Drop the constants and the daemon advertisement.

Co-authored-by: multica-agent <[email protected]>

* refactor(autopilot): remove legacy no-reservation terminal queries

UpdateAutopilotRunCompleted, UpdateAutopilotRunFailed and
UpdateAutopilotRunSkippedWithResult have no production callers; terminal
transitions go through UpdateAutopilotRunTerminalWithQuota. Their
generated Params structs were still used as service argument types, so
replace them with service-local structs. The reconciler test now seeds
its crash window with an explicit fixture UPDATE.

Co-authored-by: multica-agent <[email protected]>

* refactor(tasks): remove unused StartAgentTask query

Production start paths use StartAgentTaskWithSupplement. Move the seven
test fixtures to the same query with both capability flags off, which
performs the same running transition. The empty-body start handler
branch is unchanged.

Co-authored-by: multica-agent <[email protected]>

* refactor(channels): remove unused pending_fresh queries

MarkChannelChatSessionPendingFresh, LockChannelChatSessionPendingFresh
and ClearChannelChatSessionPendingFresh have no callers. The context
generation path and ClearChannelChatSessionPendingFreshForRevision
remain.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-10-29 16:49:57 +08:00

478 lines
24 KiB
Go

package protocol
import "encoding/json"
const (
DaemonCapabilitySkillBundlesV1 = "skill-bundles-v1"
DaemonCapabilityCoalescedCommentsV1 = "coalesced-comments-v1"
// DaemonCapabilityLocalWorktreeV1 advertises that the daemon implements
// worktree mode for local_directory resources (execution_mode=worktree).
//
// This is a CAPABILITY rather than a version check on purpose. The failure
// mode of getting it wrong is not a missing field — a daemon without the
// implementation json-skips execution_mode and runs the task IN PLACE,
// editing the working copy the user asked to isolate. Version strings
// cannot answer that reliably: a git-describe dev build ("v0.4.21-24-g…")
// is deliberately exempted from the version floor so `make daemon` stays
// unblocked, which let exactly such a daemon through (MUL-5707). A daemon
// that implements the mode says so; one that does not, cannot.
DaemonCapabilityLocalWorktreeV1 = "local-worktree-v1"
// DaemonCapabilitySourceContextQuickCreateV1 advertises support for the
// two-section quick-create prompt that keeps a new instruction separate
// from immutable historical source context.
DaemonCapabilitySourceContextQuickCreateV1 = "source_context_quick_create_v1"
// DaemonCapabilityRPCV1 advertises that the daemon can carry
// request/response RPCs over the WebSocket control connection (MUL-4257).
// Gated so only daemons+servers that both support it route claim over WS;
// everyone else keeps using the HTTP claim endpoint.
DaemonCapabilityRPCV1 = "rpc-v1"
// DaemonCapabilityClaimPollHintsV1 advertises that the daemon understands
// the batch-claim response's safety-poll metadata. The server only performs
// the extra deferred-task lookup for clients that opt in, and an older
// server's missing fields make a newer daemon retain its short fallback.
DaemonCapabilityClaimPollHintsV1 = "claim-poll-hints-v1"
// DaemonCapabilityPlatformSkillV1 advertises that the daemon's runtime
// brief names the merged `multica-platform` skill instead of the
// per-domain built-ins it replaced (MUL-6986).
//
// The brief is assembled by the daemon, so a backend upgrade does not
// rewrite it: a daemon released before that merge still tells the agent to
// "read the `multica-working-on-issues` skill", a name this server no
// longer ships. Without this gate the pointer dangles and the agent is left
// hunting for a skill that is not installed. When it is absent the server
// ships a redirect stub under the old name; when it is present it ships
// nothing extra, so the stub retires itself as daemons update.
DaemonCapabilityPlatformSkillV1 = "platform-skill-v1"
// DaemonCapabilityCheckoutKeepsWorkV1 advertises that the daemon's
// `multica repo checkout` keeps an existing checkout that holds work
// (uncommitted changes, untracked files, unpushed commits) instead of
// resetting it (MUL-7284).
//
// The server hands an automatic retry that must start a fresh session its
// parent's workdir only when this is present (MUL-7034). That session has no
// memory of the work in the directory and will fetch its repositories again.
// An older daemon's checkout resets an existing checkout and deletes that
// work, so such a daemon keeps getting a fresh directory and the parent's
// stays untouched on disk.
DaemonCapabilityCheckoutKeepsWorkV1 = "checkout-keeps-work-v1"
// DaemonCapabilityJoinedWakeupsV1 advertises that the daemon renders the
// claim's wakeup_joined field into the prompt. Only such a claim takes
// along the inputs of wakeup rules that waited for this run; for any other
// daemon those rules keep their inputs and start a run of their own.
DaemonCapabilityJoinedWakeupsV1 = "joined-wakeups-v1"
// DaemonCapabilityTaskSupplementV1 advertises that this provider run can accept
// an additional text instruction without cancelling or starting a task.
// It is persisted when this exact task enters running; absence always means
// unsupported so mixed server/daemon versions fail closed.
DaemonCapabilityTaskSupplementV1 = "task-supplement-v1"
// DaemonCapabilityPiProcessCleanupV1 promises that this Pi/omp run joins
// descendant cleanup and explicitly confirms its cancellation outcome.
// Persist only at start; old daemons must retain their cancellation contract.
DaemonCapabilityPiProcessCleanupV1 = "pi-process-cleanup-v1"
// DaemonCapabilityWorkflowStatusRulesV1 advertises that the daemon's
// runtime brief drops its built-in in_progress / in_review rules for an
// issue whose claim carries a project workflow (MUL-8145). Only then may
// the server leave out the workflow brief's lines that override those
// rules; an older daemon still states them, so it keeps getting them.
DaemonCapabilityWorkflowStatusRulesV1 = "workflow-status-rules-v1"
// DaemonCapabilityTaskSupplementAuthorV1 is sent when claiming a
// supplement: the daemon renders the claim's author_type, so an agent's
// message is presented as an agent's. Without it the server marks an
// agent's message in its content instead.
DaemonCapabilityTaskSupplementAuthorV1 = "task-supplement-author-v1"
// DaemonCapabilityCLICommentSteerV1 advertises that the multica CLI this
// daemon puts on the agent's PATH has `issue comment add --steer`. The
// server tells a squad leader about steering only then.
DaemonCapabilityCLICommentSteerV1 = "cli-comment-steer-v1"
TaskSupplementFailureTurnNotStarted = "turn_not_started"
TaskSupplementFailureProviderRejected = "provider_rejected"
TaskSupplementFailureTimeout = "timeout"
TaskSupplementFailureTurnEnded = "turn_ended"
// AppCapabilityChatDraftRestoreV1 is advertised (X-Client-Capabilities) by
// app clients that understand the durable draft-restore recovery path:
// chat:cancel_finalized as an invalidation hint plus the draft-restores
// endpoint. Cancelling a started-but-empty chat task defers the
// empty/non-empty judgment (#5219), so its cancel response carries no
// synchronous restore — a client without this capability would silently
// drop the user's prompt, and keeps the legacy synchronous restore instead.
AppCapabilityChatDraftRestoreV1 = "chat-draft-restore-v1"
)
// ChatQuickAction is a server-validated follow-up attached to one assistant
// reply. Label is the concise chip text; Prompt is the full next user turn.
type ChatQuickAction struct {
Label string `json:"label"`
Prompt string `json:"prompt"`
Primary bool `json:"primary,omitempty"`
}
// RPCRequestPayload is the generic daemon→server request envelope carried in a
// protocol.Message of type EventDaemonRPCRequest. RequestID correlates the
// response; Method selects the server-side handler (e.g. "tasks.claim"); Body
// is the method-specific request JSON.
type RPCRequestPayload struct {
RequestID string `json:"request_id"`
Method string `json:"method"`
Body json.RawMessage `json:"body,omitempty"`
// TimeoutMs is the server-side execution budget in milliseconds. The server
// bounds the handler's context by it so a slow RPC is cancelled (its work
// rolled back) rather than committing after the daemon has already timed
// out waiting and fallen back to HTTP (MUL-4257). 0 means no server-side
// bound (connection-lifetime only).
TimeoutMs int64 `json:"timeout_ms,omitempty"`
}
// RPCResponsePayload is the server→daemon reply, carried in a
// protocol.Message of type EventDaemonRPCResponse. RequestID echoes the
// request. Status mirrors an HTTP status so the daemon can treat WS and HTTP
// outcomes uniformly. Exactly one of Body / Error is meaningful: Body on
// success (2xx), Error on failure.
type RPCResponsePayload struct {
RequestID string `json:"request_id"`
Status int `json:"status"`
Body json.RawMessage `json:"body,omitempty"`
Error string `json:"error,omitempty"`
}
// Message is the envelope for all WebSocket messages.
type Message struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
// TaskDispatchPayload is sent from server to daemon when a task is assigned.
type TaskDispatchPayload struct {
TaskID string `json:"task_id"`
IssueID string `json:"issue_id"`
Title string `json:"title"`
Description string `json:"description"`
}
// TaskAvailablePayload carries content-free task and supplement wakeup hints.
// Supplement hints require an exact task ID; the daemon claims durable input
// through the corresponding HTTP endpoint.
type TaskAvailablePayload struct {
RuntimeID string `json:"runtime_id"`
TaskID string `json:"task_id,omitempty"`
}
// RuntimeProfilesChangedPayload is sent from server to daemon as a wakeup hint
// when a workspace custom runtime profile is created, edited, disabled, or
// deleted. The daemon still fetches profiles and registers runtimes through the
// existing HTTP endpoints.
type RuntimeProfilesChangedPayload struct {
WorkspaceID string `json:"workspace_id"`
RuntimeProfileID string `json:"runtime_profile_id,omitempty"`
}
// WorkspacesChangedPayload is an account-scoped hint that asks a daemon to
// reconcile its workspace membership set. The server remains authoritative;
// no workspace data is embedded in the event.
type WorkspacesChangedPayload struct{}
// PendingWorkKind values carried by PendingWorkPayload.Kind. The kind is
// advisory only — the daemon reacts identically to every kind (one immediate
// heartbeat, which claims whatever is queued) — so an unknown value from a
// newer server stays safe on an older daemon.
const (
PendingWorkKindModelList = "model_list"
PendingWorkKindLocalSkills = "local_skills"
PendingWorkKindLocalSkillImport = "local_skill_import"
)
// PendingWorkPayload is sent from server to daemon as a wakeup hint when a
// heartbeat-carried request is enqueued for a runtime. The daemon responds by
// sending one immediate heartbeat for RuntimeID instead of waiting for its next
// scheduled tick; the request itself is still claimed through the normal
// heartbeat path, so this event carries no work and is safe to lose, duplicate,
// or ignore (MUL-5444).
type PendingWorkPayload struct {
RuntimeID string `json:"runtime_id"`
Kind string `json:"kind,omitempty"`
}
// TaskProgressPayload is sent from daemon to server during task execution.
type TaskProgressPayload struct {
TaskID string `json:"task_id"`
Summary string `json:"summary"`
Step int `json:"step,omitempty"`
Total int `json:"total,omitempty"`
}
// TaskCompletedPayload is sent from daemon to server when a task finishes.
type TaskCompletedPayload struct {
TaskID string `json:"task_id"`
PRURL string `json:"pr_url,omitempty"`
Output string `json:"output,omitempty"`
}
// ChatQuickActionsPayload supplements one completed chat turn with the
// sanitized follow-up actions from the daemon's suggestion pass. An empty
// QuickActions list is a meaningful terminal state — it resolves the
// pending skeleton with "no suggestions this turn".
type ChatQuickActionsPayload struct {
ChatSessionID string `json:"chat_session_id"`
TaskID string `json:"task_id"`
MessageID string `json:"message_id"`
QuickActions []ChatQuickAction `json:"quick_actions"`
// Failed marks a supplement that resolves the client's refresh spinner
// because the regeneration FAILED (the provider pass or its delivery), not
// because it produced new suggestions. QuickActions then carries the turn's
// unchanged pills; the client shows a "couldn't refresh" notice instead of
// treating unchanged content as a silent success (MUL-5149). Omitted (false)
// on the normal success path and for the automatic best-effort pass.
Failed bool `json:"failed,omitempty"`
}
// TaskMessagePayload represents a single agent execution message (tool call, text, etc.)
type TaskMessagePayload struct {
// CallID is an opaque tool-call identity scoped to one backend execution.
CallID string `json:"call_id,omitempty"`
TaskID string `json:"task_id"`
IssueID string `json:"issue_id,omitempty"`
Seq int `json:"seq"`
Type string `json:"type"` // "text", "tool_use", "tool_result", "error"
Tool string `json:"tool,omitempty"` // tool name for tool_use/tool_result
Content string `json:"content,omitempty"` // text content
Input map[string]any `json:"input,omitempty"` // tool input (tool_use only)
Output string `json:"output,omitempty"` // tool output (tool_result only)
// OutputTruncated reports whether Output is the whole tool output that ran
// (tool_result only). Tri-state: omitted means no daemon ever measured this
// record — historical rows and older installed daemons — which clients must
// present as unknown rather than as complete.
OutputTruncated *bool `json:"output_truncated,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
// DaemonRegisterPayload is sent from daemon to server on connection.
type DaemonRegisterPayload struct {
DaemonID string `json:"daemon_id"`
AgentID string `json:"agent_id"`
Runtimes []RuntimeInfo `json:"runtimes"`
}
// RuntimeInfo describes an available agent runtime on the daemon's machine.
type RuntimeInfo struct {
Type string `json:"type"`
Version string `json:"version"`
Status string `json:"status"`
}
// ChatMessagePayload is broadcast when a new chat message is created.
type ChatMessagePayload struct {
ChatSessionID string `json:"chat_session_id"`
MessageID string `json:"message_id"`
Role string `json:"role"`
Content string `json:"content"`
TaskID string `json:"task_id,omitempty"`
CreatedAt string `json:"created_at"`
}
// Chat message kinds (chat_message.message_kind). Additive: unknown values
// degrade to ChatMessageKindMessage on older readers.
const (
// ChatMessageKindMessage is an ordinary user/assistant message.
ChatMessageKindMessage = "message"
// ChatMessageKindNoResponse marks a direct-chat turn the agent completed
// without any text reply — a visible, deliberate terminal outcome rather
// than a silently-dropped turn (MUL-4351).
ChatMessageKindNoResponse = "no_response"
// ChatMessageKindOnboardingKickoff is the server-authored, hidden first
// turn used to start Mika's onboarding conversation. It is persisted so
// the runtime receives a normal immutable chat input batch. User-facing
// APIs filter it out; clients also ignore the kind defensively.
ChatMessageKindOnboardingKickoff = "onboarding_kickoff"
// ChatMessageKindOnboardingOpening marks the assistant reply produced by
// the onboarding kickoff. The kickoff row itself never reaches clients, so
// the opening self-describes: chat renders the starter cards under this
// kind instead of quick-action chips (MUL-5765).
ChatMessageKindOnboardingOpening = "onboarding_opening"
)
// ChatDonePayload is broadcast when an agent finishes responding to a chat
// message. Carries the freshly-persisted assistant ChatMessage so the client
// can write it into the messages cache inline — avoids a refetch round-trip
// during the live-timeline → AssistantMessage handoff that previously caused
// a visible flicker (#2123).
//
// MessageKind is additive (MUL-4351): older clients ignore it and fall back to
// the non-empty Content the server always sends, so a no_response turn still
// renders a real bubble instead of an empty one. Because direct-chat completion
// now always writes exactly one assistant row (message or no_response),
// MessageID/Content/CreatedAt/ElapsedMs are always populated for direct chat —
// the omitempty tags only elide fields for the legacy paths that broadcast
// without a row.
type ChatDonePayload struct {
ChatSessionID string `json:"chat_session_id"`
TaskID string `json:"task_id"`
MessageID string `json:"message_id,omitempty"`
Content string `json:"content,omitempty"`
ElapsedMs int64 `json:"elapsed_ms,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
MessageKind string `json:"message_kind,omitempty"`
QuickActions []ChatQuickAction `json:"quick_actions,omitempty"`
// QuickActionsPending tells clients a chat:quick_actions supplement will
// follow for this turn (render a placeholder). Never true when
// QuickActions is already populated.
QuickActionsPending bool `json:"quick_actions_pending,omitempty"`
}
// Outcome values carried by ChatCancelFinalizedPayload.
const (
// ChatCancelOutcomeStopped: the transcript turned out non-empty, so a
// "Stopped." assistant message was persisted.
ChatCancelOutcomeStopped = "stopped"
// ChatCancelOutcomeRestored: the transcript stayed empty, so the
// triggering user message was deleted and its content should be
// restored into the composer as a draft.
ChatCancelOutcomeRestored = "restored"
)
// ChatCancelFinalizedPayload is broadcast when a cancelled chat task's
// deferred finalization settles (#5219). The cancel HTTP response cannot
// carry this outcome — it is only known after the daemon's transcript flush —
// so clients react to this event instead: outcome "stopped" inserts the
// assistant message (MessageID/Content/... describe the new row, shaped like
// ChatDonePayload), outcome "restored" removes the deleted user message from
// caches and prompts the initiator's client to fetch the durable draft
// restore from the creator-authorized endpoint. The restored prompt's content
// and attachments deliberately never ride this workspace-wide broadcast.
type ChatCancelFinalizedPayload struct {
Outcome string `json:"outcome"`
ChatSessionID string `json:"chat_session_id"`
TaskID string `json:"task_id"`
// InitiatorUserID is the human who triggered the cancelled task. Only
// this user's client needs to fetch the draft restore (the endpoint is
// creator-authorized regardless); clients treat a missing value as
// "not me".
InitiatorUserID string `json:"initiator_user_id,omitempty"`
MessageID string `json:"message_id,omitempty"`
// Content/MessageKind/CreatedAt/ElapsedMs describe the persisted
// "Stopped." assistant row and are set only for outcome "stopped" —
// the same exposure surface as chat:done.
Content string `json:"content,omitempty"`
MessageKind string `json:"message_kind,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
ElapsedMs int64 `json:"elapsed_ms,omitempty"`
}
// ChatSessionReadPayload is broadcast when the creator marks a session as read.
// Fires to other devices so their unread counts stay in sync.
type ChatSessionReadPayload struct {
ChatSessionID string `json:"chat_session_id"`
}
type ChatSessionCreatedPayload struct {
WorkspaceID string `json:"workspace_id"`
ChatSessionID string `json:"chat_session_id"`
AgentID string `json:"agent_id"`
CreatorID string `json:"creator_id"`
Title string `json:"title"`
ChannelSource ChatSessionChannelSource `json:"channel_source"`
IsCurrentChannelRoute bool `json:"is_current_channel_route"`
}
type ChatSessionChannelSource struct {
ChannelType string `json:"channel_type"`
InstallationID string `json:"installation_id"`
RouteRevision int64 `json:"route_revision"`
}
// ChatSessionDeletedPayload is broadcast when a chat session is hard-deleted
// so other tabs/devices drop it from their session lists and reset the active
// pointer if it referenced the deleted session.
type ChatSessionDeletedPayload struct {
ChatSessionID string `json:"chat_session_id"`
}
// ChatSessionUpdatedPayload is broadcast when a user-editable field on a
// chat session changes (today: title via inline rename). Other tabs/devices
// patch the session row in their cached list so the dropdown stays in sync
// without a full refetch.
type ChatSessionUpdatedPayload struct {
ChatSessionID string `json:"chat_session_id"`
Title string `json:"title"`
// ProjectID is set only by the project-context update path. The double
// pointer distinguishes an omitted field from an explicit JSON null.
ProjectID **string `json:"project_id,omitempty"`
// Pinned is set only by the pin/unpin path; nil on a plain rename so a
// receiver leaves the existing pin state untouched.
Pinned *bool `json:"pinned,omitempty"`
// Status is set only by the archive/unarchive path ("active"/"archived");
// nil on rename/pin so a receiver leaves the existing status untouched.
Status *string `json:"status,omitempty"`
UpdatedAt string `json:"updated_at"`
}
// DaemonHeartbeatRequestPayload is sent from daemon to server over WebSocket
// to update last_seen_at and pull pending actions for a single runtime.
// Mirrors the body of POST /api/daemon/heartbeat so both transports share
// identical semantics.
type DaemonHeartbeatRequestPayload struct {
RuntimeID string `json:"runtime_id"`
SupportsBatchImport bool `json:"supports_batch_import,omitempty"`
}
// DaemonHeartbeatAckPayload is the server's reply to DaemonHeartbeatRequestPayload.
// JSON shape mirrors the HTTP heartbeat response so daemon code can decode either.
// ServerCapabilities is explicit server-to-daemon protocol negotiation. A
// daemon must not infer support from its own advertised client capabilities.
//
// RuntimeGone is the WebSocket replacement for the HTTP 404 "runtime not found"
// response. When the server discovers the runtime row was deleted (UI delete,
// 7-day offline GC), it sends back an ack with Status=HeartbeatStatusRuntimeGone
// and RuntimeGone=true rather than tearing down the connection with an error.
// The daemon reads this signal, prunes the stale runtime from its local state
// and re-registers; without it the dead UUID would keep heartbeating until the
// daemon process restarts.
type DaemonHeartbeatAckPayload struct {
RuntimeID string `json:"runtime_id"`
Status string `json:"status"`
ServerCapabilities []string `json:"server_capabilities,omitempty"`
RuntimeGone bool `json:"runtime_gone,omitempty"`
PendingUpdate *DaemonHeartbeatPendingUpdate `json:"pending_update,omitempty"`
PendingModelList *DaemonHeartbeatPendingModelList `json:"pending_model_list,omitempty"`
PendingLocalSkills *DaemonHeartbeatPendingLocalSkills `json:"pending_local_skills,omitempty"`
PendingLocalSkillImport *DaemonHeartbeatPendingLocalSkillImport `json:"pending_local_skill_import,omitempty"`
// PendingLocalSkillImports carries multiple import requests in a single
// heartbeat so the daemon can process them concurrently. Old daemons
// that don't know this field silently ignore it (standard JSON behavior)
// and fall back to the singular PendingLocalSkillImport above.
PendingLocalSkillImports []DaemonHeartbeatPendingLocalSkillImport `json:"pending_local_skill_imports,omitempty"`
}
// HeartbeatStatusRuntimeGone is the ack Status used when the runtime row no
// longer exists server-side. Companion to DaemonHeartbeatAckPayload.RuntimeGone.
const HeartbeatStatusRuntimeGone = "runtime_gone"
// DaemonHeartbeatPendingUpdate describes a CLI-update action the daemon
// should run for the runtime.
type DaemonHeartbeatPendingUpdate struct {
ID string `json:"id"`
TargetVersion string `json:"target_version"`
}
// DaemonHeartbeatPendingModelList describes a request for the daemon to
// enumerate the runtime's supported models.
type DaemonHeartbeatPendingModelList struct {
ID string `json:"id"`
}
// DaemonHeartbeatPendingLocalSkills describes a request for the runtime's
// local-skill inventory.
type DaemonHeartbeatPendingLocalSkills struct {
ID string `json:"id"`
}
// DaemonHeartbeatPendingLocalSkillImport describes a request to import a
// specific runtime local skill.
type DaemonHeartbeatPendingLocalSkillImport struct {
ID string `json:"id"`
SkillKey string `json:"skill_key"`
}