Fix interrupted run replay state

This commit is contained in:
武阳
2026-05-07 17:07:57 +08:00
parent 47d3678a18
commit 5b4f7c2c8d
3 changed files with 409 additions and 12 deletions
@@ -35,8 +35,13 @@ type ClawStoredMessage = {
name?: string;
tool_call_id?: string;
tool_calls?: ClawStoredToolCall[];
state?: string;
stop_reason?: string;
metadata?: {
elapsed_ms?: unknown;
placeholder?: unknown;
phase?: unknown;
status?: unknown;
};
};
@@ -48,11 +53,22 @@ type ClawStoredSession = {
export type ClawRunStatus = {
run_id?: string;
session_id?: string;
status?: "idle" | "queued" | "running" | "completed" | "failed" | "cancelled";
status?:
| "idle"
| "queued"
| "running"
| "completed"
| "failed"
| "cancelled"
| "interrupted";
current_stage?: string;
started_at?: number;
};
type ClawActiveRunStatus = ClawRunStatus & {
status: "queued" | "running";
};
type ClawStoredToolCall = {
id?: string;
name?: string;
@@ -364,12 +380,12 @@ export function toReplayRepository(
runStatus?: ClawRunStatus | null,
): ExportedMessageRepository {
const messages: ExportedMessageRepository["messages"] = [];
const toolResults = collectToolResults(session.messages ?? []);
const lastAssistantContentIndex = findLastAssistantContentIndex(
session.messages ?? [],
);
const replayMessages = getReplayMessages(session.messages ?? [], runStatus);
const toolResults = collectToolResults(replayMessages);
const lastAssistantContentIndex =
findLastAssistantContentIndex(replayMessages);
let previousId: string | null = null;
for (const [index, message] of (session.messages ?? []).entries()) {
for (const [index, message] of replayMessages.entries()) {
if (message.role === "tool") continue;
if (message.role !== "user" && message.role !== "assistant") continue;
const content = cleanStoredContent(message.content ?? "");
@@ -421,6 +437,37 @@ export function toReplayRepository(
messages.push({ message: threadMessage, parentId: previousId });
previousId = id;
}
if (shouldShowInterruptedNotice(session.messages ?? [], runStatus)) {
const id = `${fallbackSessionId}-interrupted`;
const text =
runStatus?.status === "cancelled"
? "上一次任务已取消,后台没有正在执行的进程。你可以继续回复,或重新发起任务。"
: "上一次任务已中断,后台没有正在执行的进程。你可以继续回复,或重新发起任务。";
const parts = [{ type: "text", text }] as UIMessage["parts"];
const uiMessage: UIMessage = {
id,
role: "assistant",
parts,
metadata: { sessionId: session.session_id ?? fallbackSessionId },
} as UIMessage;
const threadMessage = {
id,
role: "assistant",
createdAt: new Date(),
content: toThreadMessageContent(parts),
status: { type: "complete", reason: "stop" },
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: { sessionId: session.session_id ?? fallbackSessionId },
},
} as ThreadMessage;
bindExternalStoreMessage(threadMessage, uiMessage);
messages.push({ message: threadMessage, parentId: previousId });
previousId = id;
}
if (isActiveRunStatus(runStatus)) {
const id = `${fallbackSessionId}-run-${runStatus.run_id ?? "active"}`;
const text =
@@ -468,10 +515,65 @@ export function toReplayRepository(
function isActiveRunStatus(
runStatus?: ClawRunStatus | null,
): runStatus is ClawRunStatus {
): runStatus is ClawActiveRunStatus {
return runStatus?.status === "queued" || runStatus?.status === "running";
}
function getReplayMessages(
messages: readonly ClawStoredMessage[],
runStatus?: ClawRunStatus | null,
) {
if (isActiveRunStatus(runStatus)) return [...messages];
return trimIncompleteReplayTail(messages);
}
function shouldShowInterruptedNotice(
messages: readonly ClawStoredMessage[],
runStatus?: ClawRunStatus | null,
) {
if (isActiveRunStatus(runStatus)) return false;
return (
runStatus?.status === "interrupted" ||
runStatus?.status === "cancelled" ||
trimIncompleteReplayTail(messages).length !== messages.length
);
}
function trimIncompleteReplayTail(messages: readonly ClawStoredMessage[]) {
const trimmed = [...messages];
while (trimmed.length && isIncompleteReplayMessage(trimmed.at(-1))) {
trimmed.pop();
}
while (trimmed.length && hasTrailingAssistantToolCall(trimmed.at(-1))) {
trimmed.pop();
}
return trimmed;
}
function isIncompleteReplayMessage(message?: ClawStoredMessage) {
if (!message) return false;
if (message.state && message.state !== "final") return true;
if (
message.role === "tool" &&
(message.metadata?.phase === "starting" ||
message.metadata?.phase === "running")
) {
return true;
}
if (
message.role !== "user" &&
message.metadata?.placeholder === true &&
message.metadata?.status === "running"
) {
return true;
}
return false;
}
function hasTrailingAssistantToolCall(message?: ClawStoredMessage) {
return message?.role === "assistant" && Boolean(message.tool_calls?.length);
}
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);
@@ -499,7 +601,11 @@ function cleanStoredContent(content: string) {
function collectToolResults(messages: readonly ClawStoredMessage[]) {
const results = new Map<string, string>();
for (const message of messages) {
if (message.role === "tool" && message.tool_call_id) {
if (
message.role === "tool" &&
message.tool_call_id &&
!isIncompleteReplayMessage(message)
) {
results.set(message.tool_call_id, message.content ?? "");
}
}