mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 09:56:47 +08:00
* perf(realtime): stop the task:message fanout from flooding every client (MUL-6396) `task:message` is broadcast to every client in the workspace for every run, and the client cached all of it: the handler's `(old = [])` default built a timeline entry on first sight, so each client accumulated the full transcript of runs its user would never open, held for the life of the run. Tool input is unbounded on the wire, so a single large Write shipped its whole body to every open client and was retained there. Three changes, each useful on its own: - Client drops frames for tasks no mounted view is rendering. Presence in the query cache is the signal, which registers when a view mounts and before its fetch resolves, so a frame landing mid-backfill is still kept. Rows are persisted before they are broadcast, so a dropped frame costs nothing — whoever opens the task next fetches it. - Server clips oversized `input` / `output` out of the broadcast copy and marks it `truncated`. Persisted rows and the REST list responses are untouched. A client seeing the flag repairs that task's timeline from the DB, once per flush window rather than once per frame. - Client coalesces surviving frames into one cache write per 100ms window, turning a burst of streamed messages into one merge and one render. The window is fixed, not a resetting debounce, so a continuous stream still flushes instead of being starved until it pauses. Mobile keeps its own per-session updater and was already scoped to mounted sessions, but shares the wire format, so it refetches on `truncated` too. The remaining half of the batching idea — one WS frame per reported batch instead of one per message — is deliberately not here. The client identifies itself on the upgrade URL but the server does not retain that version, so a shape change would silently break every older client. It belongs with the client scope-subscription work that `listeners.go` is already waiting on. Co-authored-by: multica-agent <[email protected]> * fix(realtime): keep broadcast clipping off the wire, and stop a late fetch dropping live seqs Addresses both must-fix findings from review. 1. Clipping the broadcast copy is not something an existing client can ignore: it changes what `input` / `output` mean, and every deployed client already consumes them. A build without the `truncated` reader caches the clipped copy under staleTime:Infinity and never refetches, so its execution log would stay incomplete until the window is reloaded — the PR even proposed shipping the server half first, which is exactly backwards. Clipping now sits behind MULTICA_CLIP_TASK_MESSAGE_BROADCAST, off by default, so the reader ships first and the flip is a later, revertible ops step. Capability routing would be the principled fix, but the hub does not retain the client_version it is handed at upgrade and frames cross nodes through the Redis relay as serialized bytes — the same protocol work per-task scope routing is already waiting on. 2. Not caching unopened tasks made first open of a live task fetch where it previously found a pre-seeded cache. That fetch replaced the array wholesale, so any frame written while it was in flight was dropped — and with staleTime:Infinity nothing would ever fetch it again. Every write to the timeline cache now folds through one rule, installed as the query's own structuralSharing: union by seq, server data winning on conflict, rows the response never mentioned kept rather than deleted. That subsumes the backfill's hand-rolled eviction, so the three write paths (fetch, backfill, realtime batch) can no longer disagree. Also documents why mobile's truncated branch is unreachable today: its task:message handler gates on chat_session_id, which the server's payload does not carry, so mobile appends nothing at all. Pre-existing, reported separately, and the branch stays so whichever side is fixed first is not left caching clipped rows. Co-authored-by: multica-agent <[email protected]> * fix(realtime): re-check the timeline is still held before writing a batch Holding was verified when a frame entered the batch, but the write happens up to a window later — and `setQueryData` does not postpone garbage collection. query-core arms that timer when the last observer leaves and never re-arms it on write, so closing a transcript while its run keeps streaming has the entry collected mid-window with a batch already in flight. Writing then rebuilt the entry holding only that batch. Under the app-wide `staleTime: Infinity` the next open reads the stub as fresh and never fetches, so everything before it is missing until the window is reloaded — silent, permanent, and not self-healing. Agent runs routinely outlive the 10 minute gcTime, so "opened a running task, closed it, it kept going" reaches this on an ordinary day rather than in a race. The flush now re-checks immediately before each write and drops the batch if the entry is gone, dropping its repair fetch with it. `backfillTaskMessages` guards its own post-await write for the same reason. Dropping costs nothing: the rows are persisted, so the next open fetches the whole timeline — which the regression test asserts rather than just checking the entry stays absent. Also corrects the doc comment on isTaskMessageTimelineHeld, which described the entry as a mounted view. It means "opened at some point and not yet collected", and the bounded tail is one gcTime. Co-authored-by: multica-agent <[email protected]> * docs(realtime): describe the task:message gate as it actually behaves The overview still said frames are dropped unless a mounted view holds the task, and that the old behaviour retained a transcript for the life of the run. Neither matches the code: the gate is timeline-entry presence (opened at some point, not yet collected), and writes do not postpone GC, so retention is bounded by gcTime rather than the run. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]>
267 lines
8.3 KiB
TypeScript
267 lines
8.3 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Loader2, ScrollText } from "lucide-react";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@multica/ui/components/ui/tooltip";
|
|
import { api } from "@multica/core/api";
|
|
import {
|
|
chatKeys,
|
|
isTaskMessageTaskId,
|
|
taskMessagesOptions,
|
|
} from "@multica/core/chat/queries";
|
|
import type { AgentTask } from "@multica/core/types/agent";
|
|
import type { TaskMessagePayload } from "@multica/core/types/events";
|
|
import { AgentTranscriptDialog } from "./agent-transcript-dialog";
|
|
import { buildTimeline, type TimelineItem } from "./build-timeline";
|
|
|
|
interface TranscriptButtonProps {
|
|
task: AgentTask;
|
|
agentName: string;
|
|
/**
|
|
* Pre-loaded timeline. When provided the button skips the fetch and opens
|
|
* the dialog immediately — used by surfaces that already own an accumulating
|
|
* timeline. Omit for terminal tasks; the button will fetch via
|
|
* `api.listTaskMessages` on the first click and cache the result. Omit for
|
|
* live tasks too: the button then subscribes to the shared task-messages
|
|
* cache so the dialog keeps growing as new events arrive.
|
|
*/
|
|
items?: TimelineItem[];
|
|
isLive?: boolean;
|
|
className?: string;
|
|
title?: string;
|
|
renderButton?: boolean;
|
|
open?: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
/**
|
|
* Optional content rendered above the transcript event list. Used to
|
|
* surface autopilot webhook payloads inline with the run history.
|
|
*/
|
|
headerSlot?: React.ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Compact icon-button that opens the full transcript dialog. Used on any
|
|
* surface that lists agent tasks (issue activity card, agent detail
|
|
* activity tab). Owns its own dialog state and lazy-load — the parent
|
|
* just drops it in.
|
|
*
|
|
* Three data modes:
|
|
* - Provided items: parent owns the timeline, we just render it.
|
|
* - Live cache: `isLive` with no provided items and a persisted task id —
|
|
* subscribe to the shared `["task-messages", taskId]` cache (seeded by the
|
|
* WS `task:message` stream) so the open dialog keeps growing in real time,
|
|
* and force a seq-merged backfill on open to heal any WS reconnect gap.
|
|
* - Lazy: terminal tasks fetch once on first click and cache locally.
|
|
*/
|
|
export function TranscriptButton({
|
|
task,
|
|
agentName,
|
|
items: providedItems,
|
|
isLive = false,
|
|
className,
|
|
title = "View transcript",
|
|
renderButton = true,
|
|
open: controlledOpen,
|
|
onOpenChange: controlledOnOpenChange,
|
|
headerSlot,
|
|
}: TranscriptButtonProps) {
|
|
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [loadedItems, setLoadedItems] = useState<TimelineItem[] | null>(null);
|
|
const open = controlledOpen ?? uncontrolledOpen;
|
|
const setOpen = controlledOnOpenChange ?? setUncontrolledOpen;
|
|
|
|
// Live cache mode: the running task feeds the shared task-messages cache, so
|
|
// we render straight off that cache instead of a one-shot local snapshot.
|
|
const liveCacheMode =
|
|
isLive && providedItems === undefined && isTaskMessageTaskId(task.id);
|
|
|
|
// Latch the live path for the duration of an open session. The parent flips
|
|
// `isLive` to false the moment the task finishes; without the latch the
|
|
// dialog would drop to empty `loadedItems` mid-view. Staying on the cache
|
|
// path keeps every delivered seq on screen and lets the dialog take a final
|
|
// authoritative backfill on the running→terminal transition.
|
|
const [liveSession, setLiveSession] = useState(false);
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setLiveSession(false);
|
|
return;
|
|
}
|
|
if (liveCacheMode) {
|
|
setLiveSession(true);
|
|
}
|
|
}, [liveCacheMode, open]);
|
|
|
|
// Live mode renders from the cache; lazy/provided modes from local state.
|
|
const items = providedItems ?? loadedItems ?? [];
|
|
|
|
const handleClick = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (liveCacheMode) {
|
|
setLiveSession(true);
|
|
setOpen(true);
|
|
return;
|
|
}
|
|
if (providedItems !== undefined || loadedItems !== null) {
|
|
setOpen(true);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
api
|
|
.listTaskMessages(task.id)
|
|
.then((msgs) => {
|
|
setLoadedItems(buildTimeline(msgs));
|
|
setOpen(true);
|
|
})
|
|
.catch((err) => {
|
|
console.error(err);
|
|
setLoadedItems([]);
|
|
setOpen(true);
|
|
})
|
|
.finally(() => setLoading(false));
|
|
},
|
|
[liveCacheMode, providedItems, loadedItems, setOpen, task.id],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
|
|
const handleGlobalNavigate = () => {
|
|
setOpen(false);
|
|
};
|
|
|
|
window.addEventListener("multica:navigate", handleGlobalNavigate);
|
|
return () => {
|
|
window.removeEventListener("multica:navigate", handleGlobalNavigate);
|
|
};
|
|
}, [open, setOpen]);
|
|
|
|
return (
|
|
<>
|
|
{renderButton ? (
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={<button type="button" />}
|
|
onClick={handleClick}
|
|
disabled={loading}
|
|
aria-label={title}
|
|
className={cn(
|
|
"flex items-center justify-center rounded p-1 text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors disabled:opacity-50",
|
|
className,
|
|
)}
|
|
>
|
|
{loading ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<ScrollText className="h-3.5 w-3.5" />
|
|
)}
|
|
</TooltipTrigger>
|
|
<TooltipContent>{title}</TooltipContent>
|
|
</Tooltip>
|
|
) : null}
|
|
|
|
{open &&
|
|
(liveSession ? (
|
|
<LiveTranscriptDialog
|
|
task={task}
|
|
agentName={agentName}
|
|
isLive={isLive}
|
|
onOpenChange={setOpen}
|
|
headerSlot={headerSlot}
|
|
/>
|
|
) : (
|
|
<AgentTranscriptDialog
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
task={task}
|
|
items={items}
|
|
agentName={agentName}
|
|
isLive={isLive}
|
|
headerSlot={headerSlot}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface LiveTranscriptDialogProps {
|
|
task: AgentTask;
|
|
agentName: string;
|
|
isLive: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
headerSlot?: React.ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Live transcript view backed by the shared task-messages cache. Mounted only
|
|
* while the dialog is open, so closed live rows hold no query subscription and
|
|
* don't widen the baseline request volume.
|
|
*
|
|
* The cache observer is read-only (`enabled: false`): the WS `task:message`
|
|
* handler is the live writer, and the backfill below is the only fetch here.
|
|
* Keeping React Query from issuing its own refetch is deliberate — its result
|
|
* would blind-replace the cache and could drop a seq that arrived mid-flight,
|
|
* whereas the backfill merges by seq.
|
|
*/
|
|
function LiveTranscriptDialog({
|
|
task,
|
|
agentName,
|
|
isLive,
|
|
onOpenChange,
|
|
headerSlot,
|
|
}: LiveTranscriptDialogProps) {
|
|
const queryClient = useQueryClient();
|
|
const { data } = useQuery({
|
|
...taskMessagesOptions(task.id),
|
|
enabled: false,
|
|
});
|
|
|
|
// Force a backfill on open, and again when the task reaches a terminal state.
|
|
// `taskMessagesOptions` is `staleTime: Infinity`, so a plain subscription
|
|
// never refetches — a WS reconnect gap (or the final tail of messages a
|
|
// completed issue task never re-broadcasts) would otherwise leave a hole.
|
|
// The query's `structuralSharing` folds this response into whatever the
|
|
// realtime stream has already written, so neither side loses a seq.
|
|
useEffect(() => {
|
|
if (!isTaskMessageTaskId(task.id)) return;
|
|
let cancelled = false;
|
|
api
|
|
.listTaskMessages(task.id)
|
|
.then((msgs) => {
|
|
if (cancelled) return;
|
|
queryClient.setQueryData<TaskMessagePayload[]>(
|
|
chatKeys.taskMessages(task.id),
|
|
msgs,
|
|
);
|
|
})
|
|
.catch((err) => {
|
|
console.error(err);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [task.id, isLive, queryClient]);
|
|
|
|
const items = useMemo(() => buildTimeline(data ?? []), [data]);
|
|
|
|
return (
|
|
<AgentTranscriptDialog
|
|
open
|
|
onOpenChange={onOpenChange}
|
|
task={task}
|
|
items={items}
|
|
agentName={agentName}
|
|
isLive={isLive}
|
|
headerSlot={headerSlot}
|
|
/>
|
|
);
|
|
}
|