Preserve running session replay state
This commit is contained in:
+28
-2
@@ -276,8 +276,7 @@ class RunManager:
|
|||||||
|
|
||||||
def snapshot_latest(self, account_key: str, session_id: str) -> dict[str, Any] | None:
|
def snapshot_latest(self, account_key: str, session_id: str) -> dict[str, Any] | None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
run_id = self._latest_by_session.get((account_key, session_id))
|
record = self._select_session_record(account_key, session_id)
|
||||||
record = self._runs.get(run_id or '')
|
|
||||||
if record is None:
|
if record is None:
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
@@ -290,6 +289,33 @@ class RunManager:
|
|||||||
'error': record.error,
|
'error': record.error,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _select_session_record(
|
||||||
|
self,
|
||||||
|
account_key: str,
|
||||||
|
session_id: str,
|
||||||
|
) -> RunRecord | None:
|
||||||
|
records = [
|
||||||
|
record
|
||||||
|
for record in self._runs.values()
|
||||||
|
if record.account_key == account_key and record.session_id == session_id
|
||||||
|
]
|
||||||
|
running = [
|
||||||
|
record
|
||||||
|
for record in records
|
||||||
|
if record.status == 'running'
|
||||||
|
]
|
||||||
|
if running:
|
||||||
|
return max(running, key=lambda item: item.updated_at)
|
||||||
|
queued = [
|
||||||
|
record
|
||||||
|
for record in records
|
||||||
|
if record.status == 'queued'
|
||||||
|
]
|
||||||
|
if queued:
|
||||||
|
return max(queued, key=lambda item: item.started_at)
|
||||||
|
run_id = self._latest_by_session.get((account_key, session_id))
|
||||||
|
return self._runs.get(run_id or '')
|
||||||
|
|
||||||
|
|
||||||
class AgentState:
|
class AgentState:
|
||||||
"""Holds account-scoped agent instances, config, and execution locks."""
|
"""Holds account-scoped agent instances, config, and execution locks."""
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { getCurrentAccount } from "@/lib/claw-auth";
|
||||||
|
|
||||||
|
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const account = await getCurrentAccount();
|
||||||
|
if (!account)
|
||||||
|
return Response.json({ error: "请先登录账号" }, { status: 401 });
|
||||||
|
|
||||||
|
const body = (await req.json().catch(() => ({}))) as {
|
||||||
|
session_id?: unknown;
|
||||||
|
run_id?: unknown;
|
||||||
|
};
|
||||||
|
const sessionId =
|
||||||
|
typeof body.session_id === "string" ? body.session_id.trim() : "";
|
||||||
|
if (!sessionId)
|
||||||
|
return Response.json({ error: "Missing session_id" }, { status: 400 });
|
||||||
|
|
||||||
|
const runId = typeof body.run_id === "string" ? body.run_id.trim() : "";
|
||||||
|
const response = await fetch(`${CLAW_API_URL}/api/runs/cancel`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
account_id: account.id,
|
||||||
|
session_id: sessionId,
|
||||||
|
...(runId ? { run_id: runId } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const payload = await response.text();
|
||||||
|
return new Response(payload, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
"content-type":
|
||||||
|
response.headers.get("content-type") ?? "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ export type ClawRunStatus = {
|
|||||||
session_id?: string;
|
session_id?: string;
|
||||||
status?: "idle" | "queued" | "running" | "completed" | "failed" | "cancelled";
|
status?: "idle" | "queued" | "running" | "completed" | "failed" | "cancelled";
|
||||||
current_stage?: string;
|
current_stage?: string;
|
||||||
|
started_at?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClawStoredToolCall = {
|
type ClawStoredToolCall = {
|
||||||
@@ -431,7 +432,11 @@ export function toReplayRepository(
|
|||||||
id,
|
id,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
parts,
|
parts,
|
||||||
metadata: { sessionId: session.session_id ?? fallbackSessionId },
|
metadata: {
|
||||||
|
sessionId: session.session_id ?? fallbackSessionId,
|
||||||
|
runId: runStatus.run_id,
|
||||||
|
runStartedAtMs: normalizeRunTimestampMs(runStatus.started_at),
|
||||||
|
},
|
||||||
} as UIMessage;
|
} as UIMessage;
|
||||||
const threadMessage = {
|
const threadMessage = {
|
||||||
id,
|
id,
|
||||||
@@ -444,7 +449,11 @@ export function toReplayRepository(
|
|||||||
unstable_annotations: [],
|
unstable_annotations: [],
|
||||||
unstable_data: [],
|
unstable_data: [],
|
||||||
steps: [],
|
steps: [],
|
||||||
custom: { sessionId: session.session_id ?? fallbackSessionId },
|
custom: {
|
||||||
|
sessionId: session.session_id ?? fallbackSessionId,
|
||||||
|
runId: runStatus.run_id,
|
||||||
|
runStartedAtMs: normalizeRunTimestampMs(runStatus.started_at),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as ThreadMessage;
|
} as ThreadMessage;
|
||||||
bindExternalStoreMessage(threadMessage, uiMessage);
|
bindExternalStoreMessage(threadMessage, uiMessage);
|
||||||
@@ -463,6 +472,11 @@ function isActiveRunStatus(
|
|||||||
return runStatus?.status === "queued" || runStatus?.status === "running";
|
return runStatus?.status === "queued" || runStatus?.status === "running";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRunTimestampMs(value: unknown) {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
||||||
|
return value < 10_000_000_000 ? Math.round(value * 1000) : Math.round(value);
|
||||||
|
}
|
||||||
|
|
||||||
function findLastAssistantContentIndex(messages: readonly ClawStoredMessage[]) {
|
function findLastAssistantContentIndex(messages: readonly ClawStoredMessage[]) {
|
||||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
const message = messages[index];
|
const message = messages[index];
|
||||||
|
|||||||
@@ -157,21 +157,11 @@ export const Thread: FC = () => {
|
|||||||
|
|
||||||
function useRefreshReplayedRun() {
|
function useRefreshReplayedRun() {
|
||||||
const { replaySession } = useClawSessionReplay();
|
const { replaySession } = useClawSessionReplay();
|
||||||
const isRunning = useAuiState((s) => s.thread.isRunning);
|
const replayedRun = useReplayedRunState();
|
||||||
const hasReplayedRunMessage = useAuiState((s) =>
|
|
||||||
s.thread.messages.some((message) => message.id.includes("-run-")),
|
|
||||||
);
|
|
||||||
const latestSessionId = useAuiState((s) =>
|
|
||||||
findLatestMessageMetadataValue(s.thread.messages, "sessionId"),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isRunning || !hasReplayedRunMessage) return;
|
if (!replayedRun.active || !replayedRun.sessionId) return;
|
||||||
const sessionId = normalizeSessionId(
|
const activeSessionId = replayedRun.sessionId;
|
||||||
latestSessionId ?? window.localStorage.getItem("claw.activeSessionId"),
|
|
||||||
);
|
|
||||||
if (!sessionId) return;
|
|
||||||
const activeSessionId = sessionId;
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function refreshIfFinished() {
|
async function refreshIfFinished() {
|
||||||
@@ -198,7 +188,41 @@ function useRefreshReplayedRun() {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearInterval(interval);
|
window.clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, [hasReplayedRunMessage, isRunning, latestSessionId, replaySession]);
|
}, [replayedRun.active, replayedRun.sessionId, replaySession]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useReplayedRunState() {
|
||||||
|
return useAuiState((s) => {
|
||||||
|
const replayRunMessage = s.thread.messages.find((message) =>
|
||||||
|
isReplayRunMessageId(message.id),
|
||||||
|
);
|
||||||
|
const sessionId = normalizeSessionId(
|
||||||
|
readMetadataValue(replayRunMessage?.metadata, "sessionId") ??
|
||||||
|
findLatestMessageMetadataValue(s.thread.messages, "sessionId") ??
|
||||||
|
(typeof window !== "undefined"
|
||||||
|
? window.localStorage.getItem("claw.activeSessionId")
|
||||||
|
: null),
|
||||||
|
);
|
||||||
|
const runId = normalizeSessionId(
|
||||||
|
readMetadataValue(replayRunMessage?.metadata, "runId"),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
active: Boolean(replayRunMessage),
|
||||||
|
sessionId,
|
||||||
|
runId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelLatestRun(sessionId: string, runId?: string | null) {
|
||||||
|
await fetch("/api/claw/runs/cancel", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: sessionId,
|
||||||
|
...(runId ? { run_id: runId } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatTopActions: FC = () => {
|
const ChatTopActions: FC = () => {
|
||||||
@@ -315,6 +339,7 @@ const ThreadSuggestionItem: FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Composer: FC = () => {
|
const Composer: FC = () => {
|
||||||
|
const replayedRun = useReplayedRunState();
|
||||||
return (
|
return (
|
||||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
|
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
|
||||||
<ComposerPrimitive.AttachmentDropzone asChild>
|
<ComposerPrimitive.AttachmentDropzone asChild>
|
||||||
@@ -329,6 +354,7 @@ const Composer: FC = () => {
|
|||||||
rows={1}
|
rows={1}
|
||||||
autoFocus
|
autoFocus
|
||||||
aria-label="Message input"
|
aria-label="Message input"
|
||||||
|
disabled={replayedRun.active}
|
||||||
/>
|
/>
|
||||||
<ComposerAction />
|
<ComposerAction />
|
||||||
</div>
|
</div>
|
||||||
@@ -338,6 +364,8 @@ const Composer: FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ComposerAction: FC = () => {
|
const ComposerAction: FC = () => {
|
||||||
|
const runtimeRunning = useAuiState((s) => s.thread.isRunning);
|
||||||
|
const replayedRun = useReplayedRunState();
|
||||||
return (
|
return (
|
||||||
<div className="aui-composer-action-wrapper relative flex items-center justify-between">
|
<div className="aui-composer-action-wrapper relative flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -345,7 +373,7 @@ const ComposerAction: FC = () => {
|
|||||||
<ComposerAssistButtons />
|
<ComposerAssistButtons />
|
||||||
</div>
|
</div>
|
||||||
<ComposerContextStatus />
|
<ComposerContextStatus />
|
||||||
<AuiIf condition={(s) => !s.thread.isRunning}>
|
{!runtimeRunning && !replayedRun.active ? (
|
||||||
<ComposerPrimitive.Send asChild>
|
<ComposerPrimitive.Send asChild>
|
||||||
<TooltipIconButton
|
<TooltipIconButton
|
||||||
tooltip="Send message"
|
tooltip="Send message"
|
||||||
@@ -359,8 +387,8 @@ const ComposerAction: FC = () => {
|
|||||||
<ArrowUpIcon className="aui-composer-send-icon size-4" />
|
<ArrowUpIcon className="aui-composer-send-icon size-4" />
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
</ComposerPrimitive.Send>
|
</ComposerPrimitive.Send>
|
||||||
</AuiIf>
|
) : null}
|
||||||
<AuiIf condition={(s) => s.thread.isRunning}>
|
{runtimeRunning ? (
|
||||||
<ComposerPrimitive.Cancel asChild>
|
<ComposerPrimitive.Cancel asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -372,11 +400,58 @@ const ComposerAction: FC = () => {
|
|||||||
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
|
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
|
||||||
</Button>
|
</Button>
|
||||||
</ComposerPrimitive.Cancel>
|
</ComposerPrimitive.Cancel>
|
||||||
</AuiIf>
|
) : null}
|
||||||
|
{!runtimeRunning && replayedRun.active ? (
|
||||||
|
<ReplayedRunCancelButton
|
||||||
|
sessionId={replayedRun.sessionId}
|
||||||
|
runId={replayedRun.runId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ReplayedRunCancelButton: FC<{
|
||||||
|
sessionId: string | null;
|
||||||
|
runId: string | null;
|
||||||
|
}> = ({ sessionId, runId }) => {
|
||||||
|
const { replaySession } = useClawSessionReplay();
|
||||||
|
const [cancelling, setCancelling] = useState(false);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="icon"
|
||||||
|
className="aui-composer-cancel size-8 rounded-full"
|
||||||
|
aria-label="Stop generating"
|
||||||
|
disabled={!sessionId || cancelling}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
setCancelling(true);
|
||||||
|
try {
|
||||||
|
await cancelLatestRun(sessionId, runId);
|
||||||
|
const runStatus = await fetchLatestRunStatus(sessionId);
|
||||||
|
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const payload = await response.json();
|
||||||
|
replaySession(
|
||||||
|
sessionId,
|
||||||
|
toReplayRepository(payload, sessionId, runStatus),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new Event("claw-sessions-changed"));
|
||||||
|
} finally {
|
||||||
|
setCancelling(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const ComposerContextStatus: FC = () => {
|
const ComposerContextStatus: FC = () => {
|
||||||
const status = useComposerContextStatus();
|
const status = useComposerContextStatus();
|
||||||
const totalTokens = status.contextBudget?.projected_input_tokens ?? 0;
|
const totalTokens = status.contextBudget?.projected_input_tokens ?? 0;
|
||||||
@@ -1130,27 +1205,34 @@ const ActivityChainSummary: FC<{
|
|||||||
const { openItem } = useActivityPanel();
|
const { openItem } = useActivityPanel();
|
||||||
const startedAtRef = useRef(Date.now());
|
const startedAtRef = useRef(Date.now());
|
||||||
const [elapsedMs, setElapsedMs] = useState(0);
|
const [elapsedMs, setElapsedMs] = useState(0);
|
||||||
|
const runStartedAtMs = useAuiState((s) => {
|
||||||
|
const value = readMetadataValue(s.message.metadata, "runStartedAtMs");
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
|
});
|
||||||
const label = useAuiState((s) => {
|
const label = useAuiState((s) => {
|
||||||
const message = s.message;
|
const message = s.message;
|
||||||
return summarizeActivityChainLabel(
|
const status =
|
||||||
message.content,
|
message.status?.type === "running" || isReplayRunMessageId(messageId)
|
||||||
message.status?.type,
|
? "running"
|
||||||
elapsedMs,
|
: message.status?.type;
|
||||||
);
|
return summarizeActivityChainLabel(message.content, status, elapsedMs);
|
||||||
});
|
});
|
||||||
const active = useAuiState((s) => {
|
const active = useAuiState((s) => {
|
||||||
const message = s.message;
|
const message = s.message;
|
||||||
return message.status?.type === "running";
|
return (
|
||||||
|
message.status?.type === "running" || isReplayRunMessageId(messageId)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setElapsedMs(Date.now() - startedAtRef.current);
|
const startedAt = runStartedAtMs ?? startedAtRef.current;
|
||||||
|
setElapsedMs(Math.max(0, Date.now() - startedAt));
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
setElapsedMs(Date.now() - startedAtRef.current);
|
setElapsedMs(Math.max(0, Date.now() - startedAt));
|
||||||
}, 1000);
|
}, 1000);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [active]);
|
}, [active, runStartedAtMs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -1167,6 +1249,10 @@ const ActivityChainSummary: FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isReplayRunMessageId(messageId: string) {
|
||||||
|
return messageId.includes("-run-");
|
||||||
|
}
|
||||||
|
|
||||||
type ActivitySummaryPart = {
|
type ActivitySummaryPart = {
|
||||||
type: string;
|
type: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
|||||||
@@ -238,6 +238,25 @@ class GuiServerTests(unittest.TestCase):
|
|||||||
self.assertEqual(latest.status_code, 200)
|
self.assertEqual(latest.status_code, 200)
|
||||||
self.assertEqual(latest.json()['status'], 'cancelled')
|
self.assertEqual(latest.json()['status'], 'cancelled')
|
||||||
|
|
||||||
|
def test_latest_run_prefers_running_over_queued_for_session(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
client, state = _build_client(Path(d))
|
||||||
|
first = state.run_manager.start('alice', 'thread-1')
|
||||||
|
state.run_manager.update(first.run_id, status='running', stage='first stage')
|
||||||
|
second = state.run_manager.start('alice', 'thread-1')
|
||||||
|
self.assertEqual(second.status, 'queued')
|
||||||
|
|
||||||
|
latest = client.get(
|
||||||
|
'/api/runs/latest',
|
||||||
|
params={'account_id': 'alice', 'session_id': 'thread-1'},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(latest.status_code, 200)
|
||||||
|
payload = latest.json()
|
||||||
|
self.assertEqual(payload['run_id'], first.run_id)
|
||||||
|
self.assertEqual(payload['status'], 'running')
|
||||||
|
self.assertEqual(payload['current_stage'], 'first stage')
|
||||||
|
|
||||||
def test_chat_uses_account_scoped_model_config(self) -> None:
|
def test_chat_uses_account_scoped_model_config(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
client, _ = _build_client(Path(d))
|
client, _ = _build_client(Path(d))
|
||||||
|
|||||||
Reference in New Issue
Block a user