Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -945,3 +945,23 @@ it natively. Each component lives in its own self-registering directory under
view, so a catalog/renderer mismatch never breaks the page. To add a component,
drop a folder there (frontend) and declare it in the agent's catalog (backend —
see `veadk.a2ui.BaseA2UICatalog`).

### 历史沙箱后台恢复

智能体列表及新会话选择器只展示已就绪的存活沙箱。启用
`autoResumeSnapshots=true` 的列表请求会在后台启动快照恢复,不等待恢复完成;
响应中的可选字段 `restoringSnapshots: true` 表示仍有任务。页面仅在有任务时,等每次
请求完成后再过 3 秒刷新,保留已有列表,并显示“部分历史智能体正在恢复,恢复后将自动显示。”。
恢复完成后通过 Session Metadata 展示正常卡片,不展示恢复中的快照卡片。

恢复仍沿用管理员权限范围,并使用配置的快照 Tool 查询历史快照。显式
`autoResumeSnapshots=false` 保留只查询 Session 和快照的接口行为。
后台任务在服务关闭时取消,同一服务内最多并发恢复 3 个快照,同一会话不会重复启动。
失败后分别冷却 5 分钟、30 分钟,后续列表请求可触发重试;累计失败 3 次后停止自动尝试。
失败详情仅记后端日志,冷却中的项目不保持页面轮询或恢复提示。
仍存在的最新可恢复快照累计失败 3 次后,管理员列表响应返回
`snapshotRecoveryPaused: true`,底部显示“部分历史智能体多次恢复失败,已暂停自动恢复。”。
暂停提示可与恢复中提示同时显示,所有恢复任务结束后仍保留,但不会触发持续轮询。
快照已消失、已有存活会话或被新快照替代时,下次列表查询会清除对应的暂停提示。
去重、成功记录和失败次数均为进程内状态,服务重启后重置,多副本之间不共享;
需要跨副本或跨重启保证时,应改用共享持久化任务状态。
9 changes: 7 additions & 2 deletions frontend/src/adk/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export interface SandboxRequestOptions {

export interface SandboxListOptions extends SandboxRequestOptions {
autoResumeSnapshots?: boolean;
onRecoveryStatus?: (restoring: boolean, paused: boolean) => void;
}

export interface SandboxStartOptions extends SandboxRequestOptions {
Expand Down Expand Up @@ -496,6 +497,8 @@ interface SessionResponse {
}

interface ListSessionsResponse {
restoringSnapshots?: boolean;
snapshotRecoveryPaused?: boolean;
sessions?: SessionResponse[];
snapshots?: SnapshotResponse[];
}
Expand Down Expand Up @@ -697,8 +700,8 @@ function parseSnapshot(
}

function sandboxListUrl(base: string, options?: SandboxListOptions): string {
if (!options?.autoResumeSnapshots) return base;
const params = new URLSearchParams({ autoResumeSnapshots: "true" });
if (options?.autoResumeSnapshots === undefined) return base;
const params = new URLSearchParams({ autoResumeSnapshots: String(options.autoResumeSnapshots) });
return `${base}?${params.toString()}`;
}

Expand Down Expand Up @@ -1217,6 +1220,7 @@ function createSandboxClient(
if (data.snapshots !== undefined && !Array.isArray(data.snapshots)) {
throw new Error(adkT("sandbox.invalidSnapshotList"));
}
options.onRecoveryStatus?.(data.restoringSnapshots === true, data.snapshotRecoveryPaused === true);
return [
...data.sessions.map((session) => parseSession(session)),
...(data.snapshots ?? []).map((snapshot) => parseSnapshot(snapshot)),
Expand Down Expand Up @@ -1273,6 +1277,7 @@ function createSandboxClient(
if (data.snapshots !== undefined && !Array.isArray(data.snapshots)) {
throw new Error(adkT("sandbox.invalidKindSnapshotList", { kind }));
}
options.onRecoveryStatus?.(data.restoringSnapshots === true, data.snapshotRecoveryPaused === true);
return [
...data.sessions.map((session) => parseSession(session, kind)),
...(data.snapshots ?? []).map((snapshot) => parseSnapshot(snapshot, kind)),
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/resources/en-US/newChat.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
"unavailable": "Unavailable"
},
"agentPicker": {
"recoveryPaused": "Some historical agents could not be restored after multiple attempts. Automatic recovery is paused.",
"restoringHistory": "Some historical agents are being restored and will appear automatically.",
"select": "Select Agent",
"typesLabel": "Agent types",
"listLabel": "{{type}} list",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/resources/en-US/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -1318,6 +1318,8 @@
}
},
"myAgents": {
"recoveryPaused": "Some historical agents could not be restored after multiple attempts. Automatic recovery is paused.",
"restoringHistory": "Some historical agents are being restored and will appear automatically.",
"agent": "Agents",
"agentTypes": {
"general": "General Agents",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/resources/zh-CN/newChat.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
"unavailable": "暂不可用"
},
"agentPicker": {
"recoveryPaused": "部分历史智能体多次恢复失败,已暂停自动恢复。",
"restoringHistory": "部分历史智能体正在恢复,恢复后将自动显示。",
"select": "选择智能体",
"typesLabel": "智能体类型",
"listLabel": "{{type}}列表",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/resources/zh-CN/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -1318,6 +1318,8 @@
}
},
"myAgents": {
"recoveryPaused": "部分历史智能体多次恢复失败,已暂停自动恢复。",
"restoringHistory": "部分历史智能体正在恢复,恢复后将自动显示。",
"agent": "智能体",
"agentTypes": {
"general": "通用智能体",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/ui/MyAgents.css
Original file line number Diff line number Diff line change
Expand Up @@ -374,3 +374,8 @@
}

}

.my-agent-recovery-notice {
flex-direction: column;
gap: 4px;
}
34 changes: 31 additions & 3 deletions frontend/src/ui/MyAgents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,9 @@ export function MyAgents({
const [sandboxAgents, setSandboxAgents] = useState<MyAgentCardData[]>([]);
const [loadingSandboxAgents, setLoadingSandboxAgents] = useState(false);
const [sandboxError, setSandboxError] = useState("");
const [restoringSandboxType, setRestoringSandboxType] = useState<AgentType | null>(null);
const [pausedSandboxType, setPausedSandboxType] = useState<AgentType | null>(null);
const [sandboxPollVersion, setSandboxPollVersion] = useState(0);
const [connectingAgentId, setConnectingAgentId] = useState("");
const [runtimeCompatibility, setRuntimeCompatibility] = useState<
Record<string, RuntimeCompatibility>
Expand Down Expand Up @@ -890,29 +893,39 @@ export function MyAgents({
runtimeCompatibilityAbortRef.current.clear();
}, []);

const fetchSandboxAgents = useCallback(async (type: Exclude<AgentType, "general">) => {
const fetchSandboxAgents = useCallback(async (type: Exclude<AgentType, "general">, background = false) => {
sandboxAbortRef.current?.abort();
const controller = new AbortController();
sandboxAbortRef.current = controller;
const requestId = ++sandboxRequestRef.current;
setLoadingSandboxAgents(true);
if (!background) setLoadingSandboxAgents(true);
setSandboxError("");
setSandboxAgents([]);
let restoring = false;
let paused = false;
const onRecoveryStatus = (isRestoring: boolean, isPaused: boolean) => {
restoring = isRestoring;
paused = isPaused;
};
try {
const sessions = type === "codex"
? await sandboxClient.listSessions({
signal: controller.signal,
autoResumeSnapshots: true,
onRecoveryStatus,
})
: await sandboxClient.listAgentSessions(type, {
signal: controller.signal,
autoResumeSnapshots: true,
onRecoveryStatus,
});
if (sandboxRequestRef.current !== requestId) return;
setRestoringSandboxType(restoring ? type : null);
setPausedSandboxType(paused ? type : null);
setSandboxAgents(sessions.map((session) => sandboxToAgent(session, t)));
} catch (cause) {
if ((cause as Error)?.name === "AbortError") return;
if (sandboxRequestRef.current !== requestId) return;
setRestoringSandboxType(null);
setSandboxError(formatRequestError(
cause,
t("myAgents.loadAgentType", { type: t(`myAgents.agentTypes.${type}`) }),
Expand All @@ -922,12 +935,21 @@ export function MyAgents({
if (sandboxAbortRef.current === controller) sandboxAbortRef.current = null;
if (sandboxRequestRef.current === requestId) {
setLoadingSandboxAgents(false);
setSandboxPollVersion((value) => value + 1);
}
}
}, [t]);

useEffect(() => {
if (activeType === "general" || restoringSandboxType !== activeType) return;
const timer = window.setTimeout(() => void fetchSandboxAgents(activeType, true), 3000);
return () => window.clearTimeout(timer);
}, [activeType, restoringSandboxType, sandboxPollVersion, fetchSandboxAgents]);

function selectAgentType(type: AgentType) {
if (type === activeType) return;
setRestoringSandboxType(null);
setPausedSandboxType(null);
if (type === "general") {
runtimeRequestRef.current += 1;
setRuntimeAgents([]);
Expand Down Expand Up @@ -1345,6 +1367,12 @@ export function MyAgents({
)}
</div>
)}
{activeType !== "general" && (restoringSandboxType === activeType || pausedSandboxType === activeType) ? (
<div className="my-agent-load-more my-agent-recovery-notice" role="status">
{restoringSandboxType === activeType ? <span>{t("myAgents.restoringHistory")}</span> : null}
{pausedSandboxType === activeType ? <span>{t("myAgents.recoveryPaused")}</span> : null}
</div>
) : null}
</ResourceResults>
{draftToDelete ? (
<StudioConfirmDialog
Expand Down
48 changes: 44 additions & 4 deletions frontend/src/ui/new-chat-modes/NewChatAgentPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ export function NewChatAgentPicker({
const [nextToken, setNextToken] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [restoringSandboxType, setRestoringSandboxType] = useState<AgentType | null>(null);
const [pausedSandboxType, setPausedSandboxType] = useState<AgentType | null>(null);
const [sandboxPollVersion, setSandboxPollVersion] = useState(0);
const [connectingRuntimeId, setConnectingRuntimeId] = useState("");
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
Expand All @@ -141,6 +144,12 @@ export function NewChatAgentPicker({
window.clearTimeout(hoverCloseTimerRef.current);
hoverCloseTimerRef.current = null;
}
requestIdRef.current += 1;
sandboxAbortRef.current?.abort();
setLoadedSandboxType(null);
setRestoringSandboxType(null);
setPausedSandboxType(null);
setLoading(false);
setOpen(false);
setActiveType(null);
setKeyboardPanel("types");
Expand Down Expand Up @@ -188,31 +197,47 @@ export function NewChatAgentPicker({

const loadSandboxSessions = useCallback(async (
type: Exclude<AgentType, "general">,
background = false,
) => {
sandboxAbortRef.current?.abort();
const controller = new AbortController();
sandboxAbortRef.current = controller;
const requestId = ++requestIdRef.current;
setLoading(true);
if (!background) {
setLoading(true);
setSandboxSessions([]);
setRestoringSandboxType(null);
setPausedSandboxType(null);
}
setError("");
setSandboxSessions([]);
let restoring = false;
let paused = false;
const onRecoveryStatus = (isRestoring: boolean, isPaused: boolean) => {
restoring = isRestoring;
paused = isPaused;
};
try {
const sessions = type === "codex"
? await sandboxClient.listSessions({
signal: controller.signal,
autoResumeSnapshots: true,
onRecoveryStatus,
})
: await sandboxClient.listAgentSessions(type, {
signal: controller.signal,
autoResumeSnapshots: true,
onRecoveryStatus,
});
if (requestIdRef.current !== requestId) return;
setSandboxSessions(sessions);
setRestoringSandboxType(restoring ? type : null);
setPausedSandboxType(paused ? type : null);
setLoadedSandboxType(type);
setActiveRuntimeIndex(0);
if (!background) setActiveRuntimeIndex(0);
} catch (cause) {
if ((cause as Error)?.name === "AbortError") return;
if (requestIdRef.current !== requestId) return;
setRestoringSandboxType(null);
const typeKey = AGENT_TYPES.find((item) => item.id === type)?.labelKey;
setError(formatRequestError(
cause,
Expand All @@ -222,10 +247,19 @@ export function NewChatAgentPicker({
setLoadedSandboxType(type);
} finally {
if (sandboxAbortRef.current === controller) sandboxAbortRef.current = null;
if (requestIdRef.current === requestId) setLoading(false);
if (requestIdRef.current === requestId) {
setLoading(false);
setSandboxPollVersion((value) => value + 1);
}
}
}, [t]);

useEffect(() => {
if (!open || !activeType || activeType === "general" || restoringSandboxType !== activeType) return;
const timer = window.setTimeout(() => void loadSandboxSessions(activeType, true), 3000);
return () => window.clearTimeout(timer);
}, [open, activeType, restoringSandboxType, sandboxPollVersion, loadSandboxSessions]);

useEffect(() => {
if (agentsSource === "local" || !open || activeType !== "general" || runtimes.length > 0 || loading || error) return;
void loadRuntimes("", true);
Expand Down Expand Up @@ -649,6 +683,12 @@ export function NewChatAgentPicker({
) : null}
</>
)}
{activeType !== "general" && (restoringSandboxType === activeType || pausedSandboxType === activeType) ? (
<div className="new-chat-agent-picker__recovery-hint" role="status">
{restoringSandboxType === activeType ? <div>{t("agentPicker.restoringHistory")}</div> : null}
{pausedSandboxType === activeType ? <div>{t("agentPicker.recoveryPaused")}</div> : null}
</div>
) : null}
</div>
) : null}
</div>
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/ui/new-chat-modes/new-chat-agent-picker.css
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,10 @@
.new-chat-agent-picker__trigger-chevron { transition: none; }
.new-chat-agent-picker__spinner { animation: none; }
}

.new-chat-agent-picker__recovery-hint {
padding: 8px;
color: hsl(var(--muted-foreground));
font-size: 12px;
line-height: 1.5;
}
13 changes: 12 additions & 1 deletion frontend/tests/myAgents.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ test("clears stale sandbox cards as soon as the Agent type changes", () => {
);
assert.match(
pageSource,
/const fetchSandboxAgents[\s\S]*?setLoadingSandboxAgents\(true\)[\s\S]*?setSandboxAgents\(\[\]\)[\s\S]*?await sandboxClient/,
/const fetchSandboxAgents[\s\S]*?if \(!background\) setLoadingSandboxAgents\(true\)[\s\S]*?await sandboxClient/,
);
assert.match(
pageSource,
Expand Down Expand Up @@ -825,3 +825,14 @@ test("authenticated users land on a new chat without a selected Agent", () => {
);
assert.doesNotMatch(appSource, /defaultViewAppliedRef/);
});

test("refreshes background restores without clearing cards and stops polling on navigation", () => {
const fetchBody = pageSource.slice(pageSource.indexOf("const fetchSandboxAgents"), pageSource.indexOf("function selectAgentType"));
assert.doesNotMatch(fetchBody, /setSandboxAgents\(\[\]\)/);
assert.match(fetchBody, /sandboxRequestRef\.current !== requestId/);
assert.match(fetchBody, /setRestoringSandboxType\(restoring \? type : null\)/);
assert.match(fetchBody, /restoringSandboxType !== activeType/);
assert.match(fetchBody, /fetchSandboxAgents\(activeType, true\)/);
assert.match(fetchBody, /clearTimeout\(timer\)/);
assert.match(pageSource, /role="status">[\s\S]*?\{t\("myAgents.restoringHistory"\)\}/);
});
9 changes: 9 additions & 0 deletions frontend/tests/newChatAgentPicker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,12 @@ test("shows request context and backend detail for every picker error", () => {
assert.match(pickerSource, /t\("agentPicker\.openType", \{ type: activeTypeLabel \}\)/);
assert.match(pickerStyles, /\.new-chat-agent-picker__error > span,[\s\S]*?white-space: pre-wrap/);
});

test("polls background restores only while their picker panel is open", () => {
assert.match(pickerSource, /if \(!open \|\| !activeType \|\| activeType === "general" \|\| restoringSandboxType !== activeType\) return/);
assert.match(pickerSource, /loadSandboxSessions\(activeType, true\)/);
assert.match(pickerSource, /clearTimeout\(timer\)/);
assert.match(pickerSource, /if \(!background\) \{\s*setLoading\(true\);\s*setSandboxSessions\(\[\]\)/);
assert.match(pickerSource, /if \(!background\) setActiveRuntimeIndex\(0\)/);
assert.match(pickerSource, /role="status">[\s\S]*?\{t\("agentPicker.restoringHistory"\)\}/);
});
Loading
Loading