Merge branch 'wsh_dev' into 'main'

model-iteration: runDic 单一可信源 + label-master 逐条调用

See merge request wuyang6/zk-data-agent!1
This commit is contained in:
武阳
2026-05-25 12:01:34 +08:00
52 changed files with 18958 additions and 8938 deletions
+1
View File
@@ -21,6 +21,7 @@ archive/
scratchpad/ scratchpad/
tasks/ tasks/
router_session_parquet/ router_session_parquet/
.logs/
# Environment files # Environment files
.env .env
+14
View File
@@ -0,0 +1,14 @@
INFO: Started server process [2637646]
INFO: Waiting for application startup.
INFO: Application startup complete.
ERROR: [Errno 98] error while attempting to bind on address ('127.0.0.1', 8975): address already in use
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
Claw Code GUI listening on http://127.0.0.1:8975
cwd : /home/mi/zk-data-agent-wsh
model : xiaomi/mimo-v2-flash
base-url : http://model.mify.ai.srv/v1
timeout : 3600s
sessions : /home/mi/zk-data-agent-wsh/.port_sessions/agent
shell : on
write : on
+3737 -17
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
import { loginByEmail } from "@/lib/claw-auth";
export async function POST(req: Request) {
try {
const payload = (await req.json()) as {
email?: string;
password?: string;
};
const account = await loginByEmail(
payload.email ?? "",
payload.password ?? "",
);
return Response.json({ account });
} catch (err) {
return Response.json(
{ error: err instanceof Error ? err.message : "登录失败" },
{ status: 400 },
);
}
}
@@ -0,0 +1,20 @@
import { registerByEmail } from "@/lib/claw-auth";
export async function POST(req: Request) {
try {
const payload = (await req.json()) as {
email?: string;
password?: string;
};
const account = await registerByEmail(
payload.email ?? "",
payload.password ?? "",
);
return Response.json({ account });
} catch (err) {
return Response.json(
{ error: err instanceof Error ? err.message : "注册失败" },
{ status: 400 },
);
}
}
@@ -0,0 +1,49 @@
import { getCurrentAccount } from "@/lib/claw-auth";
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
type BindBody = {
session_id?: string;
};
export async function POST(
req: Request,
{ params }: { params: Promise<{ workspaceId: string }> },
) {
const account = await getCurrentAccount();
if (!account)
return Response.json({ error: "请先登录账号" }, { status: 401 });
const { workspaceId } = await params;
const payload = (await req.json()) as BindBody;
try {
const target = `${CLAW_API_URL}/api/jupyter/workspaces/${encodeURIComponent(workspaceId)}/bind`;
const response = await fetch(target, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
session_id: payload.session_id,
account_id: account.id,
}),
cache: "no-store",
});
const body = await response.text();
return new Response(body, {
status: response.status,
headers: {
"content-type":
response.headers.get("content-type") ?? "application/json",
},
});
} catch (err) {
return Response.json(
{
error:
err instanceof Error
? `Unable to reach Claw backend: ${err.message}`
: "Unable to reach Claw backend",
},
{ status: 502 },
);
}
}
@@ -0,0 +1,42 @@
import { getCurrentAccount } from "@/lib/claw-auth";
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ workspaceId: string }> },
) {
const account = await getCurrentAccount();
if (!account)
return Response.json({ error: "请先登录账号" }, { status: 401 });
const { workspaceId } = await params;
try {
const target = new URL(
`${CLAW_API_URL}/api/jupyter/workspaces/${encodeURIComponent(workspaceId)}`,
);
target.searchParams.set("account_id", account.id);
const response = await fetch(target, {
method: "DELETE",
cache: "no-store",
});
const body = await response.text();
return new Response(body, {
status: response.status,
headers: {
"content-type":
response.headers.get("content-type") ?? "application/json",
},
});
} catch (err) {
return Response.json(
{
error:
err instanceof Error
? `Unable to reach Claw backend: ${err.message}`
: "Unable to reach Claw backend",
},
{ status: 502 },
);
}
}
@@ -0,0 +1,33 @@
import { getCurrentAccount } from "@/lib/claw-auth";
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
export async function GET() {
const account = await getCurrentAccount();
if (!account)
return Response.json({ error: "请先登录账号" }, { status: 401 });
try {
const target = new URL(`${CLAW_API_URL}/api/jupyter/workspaces`);
target.searchParams.set("account_id", account.id);
const response = await fetch(target, { cache: "no-store" });
const body = await response.text();
return new Response(body, {
status: response.status,
headers: {
"content-type":
response.headers.get("content-type") ?? "application/json",
},
});
} catch (err) {
return Response.json(
{
error:
err instanceof Error
? `Unable to reach Claw backend: ${err.message}`
: "Unable to reach Claw backend",
},
{ status: 502 },
);
}
}
@@ -0,0 +1,26 @@
import { getCurrentAccount } from "@/lib/claw-auth";
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
export async function GET(request: Request) {
const account = await getCurrentAccount();
if (!account) return Response.json({ error: "unauthorized" }, { status: 401 });
const incoming = new URL(request.url);
const sessionId = incoming.searchParams.get("session_id") ?? "";
const url = new URL(`${CLAW_API_URL}/api/training/pipeline`);
url.searchParams.set("account_id", account.id);
if (sessionId) url.searchParams.set("session_id", sessionId);
const response = await fetch(url, { cache: "no-store" });
const payload = await response.text();
return new Response(payload, {
status: response.status,
headers: {
"content-type":
response.headers.get("content-type") ?? "application/json",
"cache-control": "no-store",
},
});
}
@@ -0,0 +1,38 @@
import { getCurrentAccount } from "@/lib/claw-auth";
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const account = await getCurrentAccount();
if (!account) {
return new Response("unauthorized", { status: 401 });
}
const incoming = new URL(request.url);
const sessionId = incoming.searchParams.get("session_id") ?? "";
const url = new URL(`${CLAW_API_URL}/api/training/pipeline/stream`);
url.searchParams.set("account_id", account.id);
if (sessionId) url.searchParams.set("session_id", sessionId);
const upstream = await fetch(url, {
cache: "no-store",
signal: request.signal,
});
if (!upstream.body) {
return new Response("upstream has no body", { status: 502 });
}
return new Response(upstream.body, {
status: upstream.status,
headers: {
"content-type": "text/event-stream",
"cache-control": "no-store",
connection: "keep-alive",
"x-accel-buffering": "no",
},
});
}
@@ -0,0 +1,36 @@
import { getCurrentAccount } from "@/lib/claw-auth";
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
export async function GET(request: Request) {
const account = await getCurrentAccount();
if (!account) return Response.json({ error: "unauthorized" }, { status: 401 });
const incoming = new URL(request.url);
const sessionId = incoming.searchParams.get("session_id") ?? "";
const step = incoming.searchParams.get("step") ?? "";
const runId = incoming.searchParams.get("run_id");
if (!sessionId || !step) {
return Response.json(
{ error: "session_id and step are required" },
{ status: 400 },
);
}
const url = new URL(`${CLAW_API_URL}/api/training/step-detail`);
url.searchParams.set("account_id", account.id);
url.searchParams.set("session_id", sessionId);
url.searchParams.set("step", step);
if (runId) url.searchParams.set("run_id", runId);
const response = await fetch(url, { cache: "no-store" });
const payload = await response.text();
return new Response(payload, {
status: response.status,
headers: {
"content-type":
response.headers.get("content-type") ?? "application/json",
"cache-control": "no-store",
},
});
}
+376 -8
View File
@@ -1,16 +1,22 @@
"use client"; "use client";
import type { ExportedMessageRepository } from "@assistant-ui/core"; import type { ExportedMessageRepository } from "@assistant-ui/core";
import { AssistantRuntimeProvider } from "@assistant-ui/react"; import {
AssistantRuntimeProvider,
useAui,
useAuiState,
} from "@assistant-ui/react";
import { import {
AssistantChatTransport, AssistantChatTransport,
useChatRuntime, useChatRuntime,
} from "@assistant-ui/react-ai-sdk"; } from "@assistant-ui/react-ai-sdk";
import type { UIMessage } from "ai"; import type { UIMessage } from "ai";
import { useCallback, useEffect, useMemo, useRef } from "react"; import { PanelRightOpenIcon, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
ActivityPanel, ActivityPanel,
ActivityProvider, ActivityProvider,
useActivityPanel,
} from "@/components/assistant-ui/activity-panel"; } from "@/components/assistant-ui/activity-panel";
import { Thread } from "@/components/assistant-ui/thread"; import { Thread } from "@/components/assistant-ui/thread";
import { import {
@@ -18,18 +24,27 @@ import {
toReplayRepository, toReplayRepository,
} from "@/components/assistant-ui/thread-list"; } from "@/components/assistant-ui/thread-list";
import { ThreadListSidebar } from "@/components/assistant-ui/threadlist-sidebar"; import { ThreadListSidebar } from "@/components/assistant-ui/threadlist-sidebar";
import { TrainingPipelinePanel } from "@/components/training/training-pipeline-panel";
import { Button } from "@/components/ui/button";
import { import {
SidebarInset, SidebarInset,
SidebarProvider, SidebarProvider,
SidebarTrigger, SidebarTrigger,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { import {
ACTIVE_SESSION_CHANGED_EVENT,
consumeFreshLocalId,
readActiveSessionId, readActiveSessionId,
readPendingWorkspaceSessionId, readPendingWorkspaceSessionId,
writeActiveSessionId, writeActiveSessionId,
} from "@/lib/claw-active-session"; } from "@/lib/claw-active-session";
import { ClawSessionReplayProvider } from "@/lib/claw-session-replay"; import { ClawSessionReplayProvider } from "@/lib/claw-session-replay";
import { pushSessionUrl } from "@/lib/claw-session-url"; import { pushSessionUrl } from "@/lib/claw-session-url";
import { cn } from "@/lib/utils";
import {
useAppliedTraining,
useModelIterationEnabled,
} from "@/lib/use-training-mode";
import { ClawAccountGate, useClawAccount } from "./claw-account-gate"; import { ClawAccountGate, useClawAccount } from "./claw-account-gate";
type AssistantProps = { type AssistantProps = {
@@ -81,6 +96,7 @@ export const Assistant = ({ initialSessionId }: AssistantProps = {}) => {
}); });
const replaySession = useCallback( const replaySession = useCallback(
(sessionId: string, repository: ExportedMessageRepository) => { (sessionId: string, repository: ExportedMessageRepository) => {
console.log("[replay-session] called", { sessionId });
writeActiveSessionId(sessionId); writeActiveSessionId(sessionId);
pushSessionUrl(sessionId); pushSessionUrl(sessionId);
// 后端已经落盘/结束后,用回放结果接管当前会话。 // 后端已经落盘/结束后,用回放结果接管当前会话。
@@ -153,12 +169,7 @@ export const Assistant = ({ initialSessionId }: AssistantProps = {}) => {
onLogout={() => setAccount(null)} onLogout={() => setAccount(null)}
/> />
<SidebarInset> <SidebarInset>
<div className="flex min-h-0 flex-1 overflow-hidden"> <AssistantWorkspace />
<div className="min-w-0 flex-1 overflow-hidden">
<Thread />
</div>
<ActivityPanel />
</div>
</SidebarInset> </SidebarInset>
</div> </div>
</SidebarProvider> </SidebarProvider>
@@ -168,6 +179,363 @@ export const Assistant = ({ initialSessionId }: AssistantProps = {}) => {
); );
}; };
// 共享 composer 草稿缓存里 newTask(无 sessionId)使用的 key。
const NEWTASK_KEY = "__newtask__";
// AssistantWorkspace cache effect 通知 ImeComposerInput 直接更新 textarea
// 内容(绕过 store→local 间接路径,避免 switchToNewThread 时序问题)。
export const COMPOSER_RESTORE_EVENT = "claw-composer-restore-draft";
// __LOCALID_xxx 是侧栏在 newTask 状态下点工作区时临时分配的 placeholder
// session id(让 jupyter bind 有 id 可传)。每个 LOCALID 拥有独立的 draft
// key,避免多个 LOCALID 会话在 cache 里互相覆盖。newTask → 首次生成 LOCALID
// 这一瞬的 textarea 闪空,由下面 save/restore effect 里的一次性继承处理。
function getDraftKey(activeSessionId: string | null): string {
if (!activeSessionId) return NEWTASK_KEY;
return activeSessionId;
}
// 用户在对话里表达「我要开始/进行模型训练(这一轮迭代)」时命中的关键短语。
// 要求动词或专属触发词必须存在;避免 "什么是模型训练" 之类无意义匹配。
const APPLY_INTENT_PATTERNS: RegExp[] = [
// 触发短语:开始,xxx / 开始, xxx
/(?:^|\s|[,。.!?])开始\s*[,]\s*\S+/,
// 单纯训练动作:进行/开始/启动/开启/执行/发起/做 + (模型) + 训练
/(?:进行|开始|启动|开启|执行|发起|做)\s*(?:模型)?\s*训练/i,
// 训练 + 目标集合
/训练[\s\S]{0,60}?(?:目标集合|需求集合|specific[\s_-]?test|集合)/i,
// 「(模型)训练,目标是 X」「训练,基模 X」「训练 ... csv」之类自然表达
/(?:^|[\s,。.!?])(?:模型)?训练[\s,][\s\S]{0,80}?(?:目标|基模|specific[\s_-]?test|\.csv)/i,
// 跟着 program.md 的旧表达保留
/(?:应用|加载|载入|启用|装载|使用|跑|按照?|根据|用)\s*\S*program\.?md/i,
/program\.?md[\s\S]{0,120}?(?:训练|应用|启用|跑起来|跑一下)/i,
/apply\s+program(?:\.md)?/i,
/load\s+program(?:\.md)?/i,
/(?:start|begin|run)\s+(?:the\s+)?training/i,
];
function detectApplyIntent(text: string): boolean {
return APPLY_INTENT_PATTERNS.some((re) => re.test(text));
}
function AssistantWorkspace() {
const aui = useAui();
const [activeSessionId, setActiveSessionId] = useState<string | null>(() =>
readActiveSessionId(),
);
const skill = useModelIterationEnabled();
const { applied, setApplied } = useAppliedTraining(activeSessionId);
const { close: closeActivityPanel } = useActivityPanel();
const showPipeline = skill.enabled && applied;
const messages = useAuiState((s) => s.thread.messages);
const lastProcessedUserMsgId = useRef<string | null>(null);
const pipelineContainerRef = useRef<HTMLDivElement>(null);
const lastPipelineYRef = useRef<number | null>(null);
const [exitButtonVisible, setExitButtonVisible] = useState(false);
// Composer 是 per-thread 的,但本应用所有 backend session 都共用同一个
// assistant-ui thread(从不调 switchToThread),因此 composer.text 跨 session
// 共享。在 activeSessionId 切换时手动 save+restore,给每个 session 一份
// 独立的 draft。null sessionIdnewTask)用 NEWTASK_KEY 落盘。
const composerCacheRef = useRef<Map<string, string>>(new Map());
const prevSessionKeyRef = useRef<string>(getDraftKey(activeSessionId));
const handlePipelineMouseMove = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
const node = pipelineContainerRef.current;
if (!node) return;
const rect = node.getBoundingClientRect();
const relativeY = event.clientY - rect.top;
const inTopThird = relativeY >= 0 && relativeY <= rect.height / 3;
const lastY = lastPipelineYRef.current;
const movingUp = lastY !== null && event.clientY < lastY;
const movingDown = lastY !== null && event.clientY > lastY;
lastPipelineYRef.current = event.clientY;
setExitButtonVisible((prev) => {
if (!inTopThird) return false;
if (movingUp) return true;
if (movingDown) return false;
return prev;
});
},
[],
);
const handlePipelineMouseLeave = useCallback(() => {
lastPipelineYRef.current = null;
setExitButtonVisible(false);
}, []);
useEffect(() => {
if (!showPipeline) {
lastPipelineYRef.current = null;
setExitButtonVisible(false);
}
}, [showPipeline]);
useEffect(() => {
const update = () => {
const next = readActiveSessionId();
console.log("[active-session] focus-update", { next });
setActiveSessionId(next);
};
const handleChanged = (event: Event) => {
const detail = (event as CustomEvent<{ sessionId?: string | null }>)
.detail;
const next = detail?.sessionId ?? readActiveSessionId();
console.log("[active-session] event-changed", {
detailSessionId: detail?.sessionId,
next,
});
setActiveSessionId(next);
};
window.addEventListener(ACTIVE_SESSION_CHANGED_EVENT, handleChanged);
window.addEventListener("focus", update);
update();
return () => {
window.removeEventListener(ACTIVE_SESSION_CHANGED_EVENT, handleChanged);
window.removeEventListener("focus", update);
};
}, []);
// 切 session 时保存当前 composer 草稿到旧 key、恢复新 key 的草稿。
useEffect(() => {
const cache = composerCacheRef.current;
const nextKey = getDraftKey(activeSessionId);
const prevKey = prevSessionKeyRef.current;
console.log("[draft-cache] effect-fire", {
prevKey,
nextKey,
activeSessionId,
willSkip: prevKey === nextKey,
});
if (prevKey === nextKey) return;
// 不能信任 aui store 的 composer.text——assistant-ui 内部 reducer 在
// 切换 / isEditing 翻转时会让 store 与 textarea 脱钩(典型现象:textarea
// 显示着 "hello" 但 store 已经是 ""),后果是这里把空串存进 cache、
// 用户的草稿被静默吞掉。直接读 DOM 拿 textarea 当前真实值。
// EditComposer 用的是 aui-edit-composer-input,不会撞 selector。
const composerEl = document.querySelector<HTMLTextAreaElement>(
"textarea.aui-composer-input",
);
const composerState = aui.composer().getState();
const savingText =
composerEl?.value ?? composerState.text ?? "";
cache.set(prevKey, savingText);
// newTask 里点工作区会**新生成**一个 LOCALID 并切过去,用户视角里
// 这是同一份草稿的延续——把 newTask 的文本继承到 LOCALID 名下,
// 避免 textarea 闪空。consumeFreshLocalId 只对前端刚生成的 LOCALID
// 命中,点击侧栏老 LOCALID 不命中,确保历史会话之间互不串。
const inheritFromNewTask =
prevKey === NEWTASK_KEY && consumeFreshLocalId(activeSessionId);
if (inheritFromNewTask) {
cache.set(nextKey, savingText);
}
const restored = cache.get(nextKey) ?? "";
console.log("[draft-cache] do-switch", {
prevKey,
nextKey,
savingText,
savingTextSource: composerEl ? "dom" : "store",
storeText: composerState.text,
restored,
inheritFromNewTask,
});
aui.composer().setText(restored);
window.dispatchEvent(
new CustomEvent(COMPOSER_RESTORE_EVENT, {
detail: { text: restored },
}),
);
console.log("[draft-cache] dispatched", { restored });
prevSessionKeyRef.current = nextKey;
}, [activeSessionId, aui]);
useEffect(() => {
closeActivityPanel();
}, [showPipeline, closeActivityPanel]);
// Skill 关掉时撤销 applied,避免重新启用就立刻弹开。
useEffect(() => {
if (!skill.enabled && applied) {
setApplied(false);
}
}, [skill.enabled, applied, setApplied]);
// 自动应用:skill 启用 + 当前 session 后端的 pipeline 已经有进展(任何
// 卡 running 或 complete)→ applied=true。这样关 tab 重开 / 刷新 / 切回
// 已在跑的会话都能自动恢复 monitor 面板,不依赖触发词。
// 30 秒轮询一次,覆盖「页面打开时 pipeline 还没起、之后才被外部脚本启动」
// 这类情形——一次性 fetch 容易错过。
useEffect(() => {
if (!skill.enabled) return;
if (applied) return;
if (!activeSessionId) return;
let cancelled = false;
const pipelineUrl = `/api/claw/training/pipeline?session_id=${encodeURIComponent(activeSessionId)}`;
const runUrl = `/api/claw/runs/latest?session_id=${encodeURIComponent(activeSessionId)}`;
const tick = async () => {
try {
const res = await fetch(pipelineUrl, { cache: "no-store" });
if (res.ok) {
const payload = await res.json();
if (cancelled) return;
if (payload) {
const items = Array.isArray(payload.items) ? payload.items : [];
const hasItemProgress = items.some(
(it: {
type?: string;
status?: string;
cards?: Array<{ status?: string }>;
}) => {
if (it.type === "gate") {
return it.status === "running" || it.status === "complete";
}
return (
Array.isArray(it.cards) &&
it.cards.some(
(c) =>
c.status === "running" || c.status === "complete",
)
);
},
);
// items[] 走 phase-complete-only 过滤;R0 只有一张卡在跑时
// items 是空的,进展信号在 in_flight 字段里——也算进展。
const hasInFlight = Boolean(payload.in_flight);
if (hasItemProgress || hasInFlight) {
setApplied(true);
return;
}
}
}
} catch {
/* ignore, fall through to runs/latest fallback */
}
// Pipeline 还没写出 items/in_flightagent 处于 prep 阶段,比如在 watcher
// 里 sleep 等远端文件)时,pipeline 端点会返回 state=pending、items=[]
// 但后端 run 其实已经在跑。直接看 run 状态兜底,免得用户起了训练但
// monitor 面板等到第一个 phase 才弹。
try {
const res = await fetch(runUrl, { cache: "no-store" });
if (!res.ok) return;
const payload = await res.json();
if (cancelled || !payload) return;
if (payload.status === "running" || payload.status === "queued") {
setApplied(true);
}
} catch {
/* ignore */
}
};
tick();
const id = window.setInterval(tick, 30000);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [skill.enabled, applied, activeSessionId, setApplied]);
// 监听用户消息:skill 启用 + 检测到训练触发短语 → applied = true。
useEffect(() => {
if (!skill.enabled) return;
if (applied) return;
if (!Array.isArray(messages) || messages.length === 0) return;
// 找最新一条 user 消息
let latestUser: (typeof messages)[number] | null = null;
for (let i = messages.length - 1; i >= 0; i -= 1) {
const m = messages[i];
if (m.role === "user") {
latestUser = m;
break;
}
}
if (!latestUser) return;
if (lastProcessedUserMsgId.current === latestUser.id) return;
lastProcessedUserMsgId.current = latestUser.id;
const text = collectUserMessageText(latestUser);
if (text && detectApplyIntent(text)) {
setApplied(true);
}
}, [messages, skill.enabled, applied, setApplied]);
// 注意:用同一棵树 + 条件渲染,保证 <Thread /> 和 <ActivityPanel /> 在
// pipeline 切换前后都保持挂载,否则它们会 remount,
// useJupyterWorkspaceStatus 的内部 state 会被清空,UI 上 Jupyter 工作区
// indicator 会闪一下变成"切换工作区"。
return (
<div className="flex min-h-0 flex-1 overflow-hidden">
{showPipeline ? (
<div
ref={pipelineContainerRef}
onMouseMove={handlePipelineMouseMove}
onMouseLeave={handlePipelineMouseLeave}
className="relative flex min-w-0 flex-1 flex-col"
>
<button
type="button"
onClick={() => setApplied(false)}
title="退出训练模式"
aria-label="退出训练模式"
className={cn(
"-translate-x-1/2 absolute top-0 left-1/2 z-20 inline-flex items-center gap-1 rounded-b-md border-x border-b border-border bg-background px-3 py-1 text-muted-foreground text-xs shadow-md ring-offset-background transition-transform duration-200 hover:bg-muted hover:text-foreground focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
exitButtonVisible ? "translate-y-0" : "-translate-y-full",
)}
>
<XIcon className="size-3.5" />
<span>退</span>
</button>
<TrainingPipelinePanel sessionId={activeSessionId} />
</div>
) : null}
<div
className={cn(
"flex flex-col overflow-hidden",
showPipeline ? "w-[28rem] shrink-0 border-l" : "min-w-0 flex-1",
)}
>
<div className="min-h-0 flex-1 overflow-hidden">
<Thread />
</div>
</div>
<ActivityPanel asDrawer={showPipeline} />
{showPipeline ? <ActivityDrawerTrigger /> : null}
</div>
);
}
function collectUserMessageText(message: { content?: unknown }): string {
const content = message.content;
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const part of content) {
if (typeof part === "string") {
parts.push(part);
} else if (part && typeof part === "object") {
const text = (part as { text?: unknown }).text;
if (typeof text === "string") parts.push(text);
}
}
return parts.join("\n");
}
function ActivityDrawerTrigger() {
const { open, openActivity } = useActivityPanel();
if (open) return null;
return (
<Button
type="button"
variant="outline"
size="icon"
onClick={openActivity}
className="fixed top-3 right-3 z-30 size-9 rounded-lg bg-background/90 shadow-sm backdrop-blur"
aria-label="打开活动面板"
>
<PanelRightOpenIcon className="size-4" />
</Button>
);
}
function getLastSessionId(messages: UIMessage[]) { function getLastSessionId(messages: UIMessage[]) {
for (const message of [...messages].reverse()) { for (const message of [...messages].reverse()) {
if (message.role !== "assistant") continue; if (message.role !== "assistant") continue;
+37 -11
View File
@@ -36,36 +36,59 @@ export function useClawAccount() {
return { account, isLoading, refresh, setAccount }; return { account, isLoading, refresh, setAccount };
} }
const XIAOMI_EMAIL_RE = /^[\w.-]+@xiaomi\.com$/i;
export function ClawAccountGate({ export function ClawAccountGate({
onAccount, onAccount,
}: { }: {
onAccount: (account: ClawAccount) => void; onAccount: (account: ClawAccount) => void;
}) { }) {
const [mode, setMode] = useState<"login" | "register">("login"); const [mode, setMode] = useState<"login" | "register">("login");
const [username, setUsername] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
async function submit() { async function submit() {
setError(""); setError("");
const trimmed = email.trim();
if (!XIAOMI_EMAIL_RE.test(trimmed)) {
setError("请使用小米邮箱(@xiaomi.com)登录");
return;
}
if (password.length < 6) {
setError("密码至少 6 位");
return;
}
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const response = await fetch(`/api/claw/auth/${mode}`, { const endpoint =
mode === "register"
? "/api/claw/auth/email-register"
: "/api/claw/auth/email-login";
const response = await fetch(endpoint, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ username, password }), body: JSON.stringify({ email: trimmed, password }),
}); });
const payload = (await response.json()) as { const payload = (await response.json()) as {
account?: ClawAccount; account?: ClawAccount;
error?: string; error?: string;
}; };
if (!response.ok || !payload.account) { if (!response.ok || !payload.account) {
throw new Error(payload.error ?? "账号操作失败"); throw new Error(
payload.error ?? (mode === "register" ? "注册失败" : "登录失败"),
);
} }
onAccount(payload.account); onAccount(payload.account);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "账号操作失败"); setError(
err instanceof Error
? err.message
: mode === "register"
? "注册失败"
: "登录失败",
);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -77,22 +100,25 @@ export function ClawAccountGate({
<div className="mb-6"> <div className="mb-6">
<h1 className="font-semibold text-xl">ZK Data Agent</h1> <h1 className="font-semibold text-xl">ZK Data Agent</h1>
<p className="mt-1 text-muted-foreground text-sm"> <p className="mt-1 text-muted-foreground text-sm">
{mode === "register"
? "创建账号:使用小米邮箱注册,前缀作为 autoresearch 工作根目录。"
: "使用小米邮箱登录。会话/上传/产物按邮箱前缀和 chat 二级隔离。"}
</p> </p>
</div> </div>
<div className="grid gap-3"> <div className="grid gap-3">
<Input <Input
autoFocus autoFocus
placeholder="账号" type="email"
value={username} placeholder="your_name@xiaomi.com"
onChange={(event) => setUsername(event.target.value)} value={email}
onChange={(event) => setEmail(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter") submit(); if (event.key === "Enter") submit();
}} }}
/> />
<Input <Input
type="password" type="password"
placeholder="密码" placeholder={mode === "register" ? "设置密码(≥6 位)" : "密码"}
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
@@ -101,7 +127,7 @@ export function ClawAccountGate({
/> />
{error ? <p className="text-destructive text-sm">{error}</p> : null} {error ? <p className="text-destructive text-sm">{error}</p> : null}
<Button onClick={submit} disabled={isSubmitting}> <Button onClick={submit} disabled={isSubmitting}>
{isSubmitting ? "处理中..." : mode === "login" ? "登录" : "注册"} {isSubmitting ? "处理中..." : mode === "register" ? "注册" : "登录"}
</Button> </Button>
<button <button
type="button" type="button"
+81
View File
@@ -1,5 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@@ -51,6 +52,86 @@
background-position: -100% 0; background-position: -100% 0;
} }
} }
--animate-pipeline-running: pipeline-running-glow 1800ms ease-in-out infinite;
@keyframes pipeline-running-glow {
0%,
100% {
box-shadow:
0 0 0 1px rgb(52 211 153 / 0.45),
0 0 0 0 rgb(52 211 153 / 0);
border-color: rgb(52 211 153 / 0.6);
}
50% {
box-shadow:
0 0 0 1px rgb(52 211 153 / 0.95),
0 0 18px 2px rgb(52 211 153 / 0.4);
border-color: rgb(52 211 153 / 1);
}
}
--animate-pipeline-running-blue: pipeline-running-glow-blue 1800ms ease-in-out
infinite;
@keyframes pipeline-running-glow-blue {
0%,
100% {
box-shadow:
0 0 0 1px rgb(56 189 248 / 0.45),
0 0 0 0 rgb(56 189 248 / 0);
border-color: rgb(56 189 248 / 0.7);
}
50% {
box-shadow:
0 0 0 1px rgb(56 189 248 / 0.95),
0 0 18px 2px rgb(56 189 248 / 0.45);
border-color: rgb(56 189 248 / 1);
}
}
--animate-pipeline-progress-shimmer: pipeline-progress-shimmer 1400ms linear
infinite;
@keyframes pipeline-progress-shimmer {
from {
transform: translateX(-100%);
}
to {
transform: translateX(250%);
}
}
--animate-pipeline-pill-glow: pipeline-pill-glow 1800ms ease-in-out infinite;
@keyframes pipeline-pill-glow {
0%,
100% {
box-shadow:
0 0 0 1px rgb(56 189 248 / 0.3),
0 0 0 0 rgb(56 189 248 / 0);
border-color: rgb(56 189 248 / 0.45);
}
50% {
box-shadow:
0 0 0 1px rgb(56 189 248 / 0.7),
0 0 10px 2px rgb(56 189 248 / 0.35);
border-color: rgb(56 189 248 / 0.85);
}
}
--animate-pipeline-dot-ping: pipeline-dot-ping 1400ms ease-out infinite;
@keyframes pipeline-dot-ping {
0% {
transform: scale(0.6);
opacity: 0.9;
}
80%,
100% {
transform: scale(2.2);
opacity: 0;
}
}
--animate-icon-spin: icon-spin 3200ms linear infinite;
@keyframes icon-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
} }
.project-doc-page { .project-doc-page {
@@ -41,6 +41,7 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { import {
ACTIVE_SESSION_CHANGED_EVENT, ACTIVE_SESSION_CHANGED_EVENT,
readActiveSessionId, readActiveSessionId,
@@ -140,7 +141,7 @@ type ActivityItem = {
type LiveRunEvent = NonNullable<ClawRunStatus["events"]>[number]; type LiveRunEvent = NonNullable<ClawRunStatus["events"]>[number];
export function ActivityPanel() { export function ActivityPanel({ asDrawer = false }: { asDrawer?: boolean } = {}) {
const { const {
open, open,
mode, mode,
@@ -211,6 +212,7 @@ export function ActivityPanel() {
useEffect(() => { useEffect(() => {
const latestId = items.at(-1)?.id ?? null; const latestId = items.at(-1)?.id ?? null;
if ( if (
!asDrawer &&
mode === "activity" && mode === "activity" &&
!selectedId && !selectedId &&
latestId && latestId &&
@@ -219,22 +221,14 @@ export function ActivityPanel() {
openActivity(); openActivity();
} }
prevLatestIdRef.current = latestId; prevLatestIdRef.current = latestId;
}, [items, mode, openActivity, selectedId]); }, [asDrawer, items, mode, openActivity, selectedId]);
if (!open) return null; if (!asDrawer && !open) return null;
if (mode === "files") { const isFilesMode = mode === "files";
return (
<SessionFilesPanel
key={`${sessionId ?? "active"}:${filesRefreshToken}`}
sessionId={sessionId}
onClose={close}
/>
);
}
return ( const activityBody = (
<aside className="fixed inset-y-0 right-0 z-30 flex w-[min(23rem,calc(100vw-1rem))] flex-col border-l bg-background shadow-xl lg:static lg:z-auto lg:h-full lg:w-92 lg:shrink-0 lg:shadow-none"> <>
<header className="flex h-16 shrink-0 items-center justify-between border-b px-4"> <header className="flex h-16 shrink-0 items-center justify-between border-b px-4">
<div className="min-w-0"> <div className="min-w-0">
<h2 className="font-semibold text-base"></h2> <h2 className="font-semibold text-base"></h2>
@@ -244,16 +238,18 @@ export function ActivityPanel() {
: "暂无链路"} : "暂无链路"}
</p> </p>
</div> </div>
<Button {!asDrawer ? (
type="button" <Button
variant="ghost" type="button"
size="icon" variant="ghost"
className="size-8" size="icon"
onClick={close} className="size-8"
aria-label="关闭活动面板" onClick={close}
> aria-label="关闭活动面板"
<PanelRightCloseIcon className="size-4" /> >
</Button> <PanelRightCloseIcon className="size-4" />
</Button>
) : null}
</header> </header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4"> <div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{visibleItems.length === 0 ? ( {visibleItems.length === 0 ? (
@@ -274,12 +270,59 @@ export function ActivityPanel() {
open={selectedId === item.id} open={selectedId === item.id}
onOpenChange={(nextOpen) => { onOpenChange={(nextOpen) => {
if (nextOpen) openItem(item.id); if (nextOpen) openItem(item.id);
else if (selectedId === item.id) openActivity();
}} }}
/> />
))} ))}
</div> </div>
)} )}
</div> </div>
</>
);
if (asDrawer) {
return (
<Sheet
open={open}
onOpenChange={(next) => {
if (!next) close();
}}
>
<SheetContent
side="right"
className="flex w-[min(25rem,calc(100vw-1rem))] flex-col gap-0 p-0 sm:max-w-md"
>
<SheetTitle className="sr-only">
{isFilesMode ? "聊天中的文件" : "活动"}
</SheetTitle>
{isFilesMode ? (
<SessionFilesPanel
key={`drawer:${sessionId ?? "active"}:${filesRefreshToken}`}
sessionId={sessionId}
onClose={close}
embedded
/>
) : (
activityBody
)}
</SheetContent>
</Sheet>
);
}
if (isFilesMode) {
return (
<SessionFilesPanel
key={`${sessionId ?? "active"}:${filesRefreshToken}`}
sessionId={sessionId}
onClose={close}
/>
);
}
return (
<aside className="fixed inset-y-0 right-0 z-30 flex w-[min(23rem,calc(100vw-1rem))] flex-col border-l bg-background shadow-xl lg:static lg:z-auto lg:h-full lg:w-92 lg:shrink-0 lg:shadow-none">
{activityBody}
</aside> </aside>
); );
} }
@@ -308,9 +351,11 @@ type SessionFilesPayload = {
function SessionFilesPanel({ function SessionFilesPanel({
sessionId, sessionId,
onClose, onClose,
embedded = false,
}: { }: {
sessionId: string | null; sessionId: string | null;
onClose: () => void; onClose: () => void;
embedded?: boolean;
}) { }) {
const [payload, setPayload] = useState<SessionFilesPayload | null>(null); const [payload, setPayload] = useState<SessionFilesPayload | null>(null);
const [status, setStatus] = useState("正在读取聊天中的文件..."); const [status, setStatus] = useState("正在读取聊天中的文件...");
@@ -377,8 +422,8 @@ function SessionFilesPanel({
const outputFiles = payload?.output ?? []; const outputFiles = payload?.output ?? [];
const total = inputFiles.length + outputFiles.length; const total = inputFiles.length + outputFiles.length;
return ( const body = (
<aside className="fixed inset-y-0 right-0 z-30 flex w-[min(25rem,calc(100vw-1rem))] flex-col border-l bg-background shadow-xl lg:static lg:z-auto lg:h-full lg:w-96 lg:shrink-0 lg:shadow-none"> <>
<header className="flex h-16 shrink-0 items-center justify-between border-b px-4"> <header className="flex h-16 shrink-0 items-center justify-between border-b px-4">
<div className="min-w-0"> <div className="min-w-0">
<h2 className="font-semibold text-base"></h2> <h2 className="font-semibold text-base"></h2>
@@ -386,16 +431,18 @@ function SessionFilesPanel({
{status || `${total} 个文件`} {status || `${total} 个文件`}
</p> </p>
</div> </div>
<Button {!embedded ? (
type="button" <Button
variant="ghost" type="button"
size="icon" variant="ghost"
className="size-8" size="icon"
onClick={onClose} className="size-8"
aria-label="关闭文件面板" onClick={onClose}
> aria-label="关闭文件面板"
<PanelRightCloseIcon className="size-4" /> >
</Button> <PanelRightCloseIcon className="size-4" />
</Button>
) : null}
</header> </header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4"> <div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{!status && total === 0 ? ( {!status && total === 0 ? (
@@ -406,6 +453,16 @@ function SessionFilesPanel({
<FileSection title="输入" files={inputFiles} /> <FileSection title="输入" files={inputFiles} />
<FileSection title="输出" files={outputFiles} /> <FileSection title="输出" files={outputFiles} />
</div> </div>
</>
);
if (embedded) {
return <div className="flex min-h-0 flex-1 flex-col">{body}</div>;
}
return (
<aside className="fixed inset-y-0 right-0 z-30 flex w-[min(25rem,calc(100vw-1rem))] flex-col border-l bg-background shadow-xl lg:static lg:z-auto lg:h-full lg:w-96 lg:shrink-0 lg:shadow-none">
{body}
</aside> </aside>
); );
} }
@@ -6,10 +6,13 @@ import {
import { ThreadListPrimitive } from "@assistant-ui/react"; import { ThreadListPrimitive } from "@assistant-ui/react";
import type { UIMessage } from "ai"; import type { UIMessage } from "ai";
import { import {
Loader2Icon,
MoreHorizontalIcon, MoreHorizontalIcon,
PencilIcon, PencilIcon,
PlusIcon, PlusIcon,
ServerIcon,
Trash2Icon, Trash2Icon,
XIcon,
} from "lucide-react"; } from "lucide-react";
import { import {
type FC, type FC,
@@ -21,8 +24,10 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
clearActiveSessionId, clearActiveSessionId,
markFreshLocalId,
readActiveSessionId, readActiveSessionId,
writeActiveSessionId, writeActiveSessionId,
writePendingWorkspaceSessionId,
} from "@/lib/claw-active-session"; } from "@/lib/claw-active-session";
import { useClawSessionReplay } from "@/lib/claw-session-replay"; import { useClawSessionReplay } from "@/lib/claw-session-replay";
import { pushHomeUrl, pushSessionUrl } from "@/lib/claw-session-url"; import { pushHomeUrl, pushSessionUrl } from "@/lib/claw-session-url";
@@ -112,6 +117,7 @@ export const ThreadList: FC = () => {
return ( return (
<ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex min-h-0 flex-1 flex-col gap-1"> <ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex min-h-0 flex-1 flex-col gap-1">
<ThreadListNew /> <ThreadListNew />
<JupyterWorkspaceList />
<ClawSessionList /> <ClawSessionList />
</ThreadListPrimitive.Root> </ThreadListPrimitive.Root>
); );
@@ -139,6 +145,178 @@ const ThreadListNew: FC = () => {
); );
}; };
type JupyterWorkspaceEntry = {
id: string;
label?: string;
base_url?: string;
workspace_root?: string;
created_at?: number;
last_used_at?: number;
};
function ensureSidebarWorkspaceSessionId(): {
sessionId: string;
created: boolean;
} {
const existing = readActiveSessionId();
if (existing) return { sessionId: existing, created: false };
const generated =
typeof crypto !== "undefined" && "randomUUID" in crypto
? `__LOCALID_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
: `__LOCALID_${Math.random().toString(36).slice(2, 10)}`;
markFreshLocalId(generated);
writeActiveSessionId(generated);
return { sessionId: generated, created: true };
}
function workspaceDisplayLabel(entry: JupyterWorkspaceEntry): string {
if (entry.label && entry.label.trim()) return entry.label.trim();
if (entry.base_url) {
try {
return new URL(entry.base_url).host;
} catch {
return entry.base_url;
}
}
return entry.id;
}
const JupyterWorkspaceList: FC = () => {
const [entries, setEntries] = useState<JupyterWorkspaceEntry[]>([]);
const [loaded, setLoaded] = useState(false);
const [bindingId, setBindingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const refresh = () => {
fetch("/api/claw/jupyter/workspaces", { cache: "no-store" })
.then((res) => (res.ok ? res.json() : []))
.then((payload) => {
if (cancelled) return;
if (Array.isArray(payload)) {
setEntries(payload as JupyterWorkspaceEntry[]);
} else {
setEntries([]);
}
setLoaded(true);
})
.catch(() => {
if (cancelled) return;
setEntries([]);
setLoaded(true);
});
};
refresh();
const onChanged = () => refresh();
window.addEventListener("claw-workspaces-changed", onChanged);
window.addEventListener("focus", refresh);
return () => {
cancelled = true;
window.removeEventListener("claw-workspaces-changed", onChanged);
window.removeEventListener("focus", refresh);
};
}, []);
if (!loaded || entries.length === 0) return null;
const bind = async (entry: JupyterWorkspaceEntry) => {
setError(null);
setBindingId(entry.id);
const { sessionId, created } = ensureSidebarWorkspaceSessionId();
try {
const response = await fetch(
`/api/claw/jupyter/workspaces/${encodeURIComponent(entry.id)}/bind`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ session_id: sessionId }),
},
);
if (!response.ok) {
const payload = (await response.json().catch(() => ({}))) as {
detail?: string;
error?: string;
};
setError(payload.detail ?? payload.error ?? "切换工作区失败");
return;
}
if (created) writePendingWorkspaceSessionId(sessionId);
window.dispatchEvent(new Event("claw-sessions-changed"));
window.dispatchEvent(new Event("claw-workspaces-changed"));
} catch (err) {
setError(
err instanceof Error ? err.message : "切换工作区时网络异常",
);
} finally {
setBindingId(null);
}
};
const remove = async (entry: JupyterWorkspaceEntry) => {
if (!window.confirm(`移除工作区「${workspaceDisplayLabel(entry)}」?`)) return;
try {
const response = await fetch(
`/api/claw/jupyter/workspaces/${encodeURIComponent(entry.id)}`,
{ method: "DELETE" },
);
if (!response.ok) return;
setEntries((current) => current.filter((e) => e.id !== entry.id));
} catch {
// best effort; next refresh will reconcile
}
};
return (
<div className="mt-1 flex flex-col gap-1 border-y border-dashed py-2">
<div className="flex items-center gap-1.5 px-2 text-muted-foreground text-xs uppercase tracking-wide">
<ServerIcon className="size-3" />
<span></span>
</div>
<div className="flex flex-col gap-0.5">
{entries.map((entry) => {
const label = workspaceDisplayLabel(entry);
const subtitle = entry.workspace_root ?? "";
const isBinding = bindingId === entry.id;
return (
<div
key={entry.id}
className="group relative flex h-9 items-center gap-1 rounded-lg transition-colors hover:bg-muted"
>
<button
type="button"
disabled={isBinding}
onClick={() => void bind(entry)}
className="flex h-full min-w-0 flex-1 items-center gap-2 rounded-lg px-3 text-left text-sm disabled:cursor-wait"
title={`${entry.base_url ?? ""}${subtitle ? ` · ${subtitle}` : ""}`}
>
{isBinding ? (
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
) : (
<ServerIcon className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate">{label}</span>
</button>
<button
type="button"
aria-label="移除工作区"
title="移除工作区"
onClick={() => void remove(entry)}
className="mr-1 hidden size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-background hover:text-foreground group-hover:flex"
>
<XIcon className="size-3.5" />
</button>
</div>
);
})}
</div>
{error ? (
<div className="px-2 text-destructive text-xs">{error}</div>
) : null}
</div>
);
};
const ClawSessionList: FC = () => { const ClawSessionList: FC = () => {
const { replaySession } = useClawSessionReplay(); const { replaySession } = useClawSessionReplay();
const [sessions, setSessions] = useState<ClawSession[]>([]); const [sessions, setSessions] = useState<ClawSession[]>([]);
@@ -262,6 +440,10 @@ const ClawSessionList: FC = () => {
type="button" type="button"
className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center truncate px-3 text-start text-sm" className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center truncate px-3 text-start text-sm"
onClick={async () => { onClick={async () => {
console.log("[session-list] click", {
sessionId: session.session_id,
preview: session.preview,
});
setOpenMenuSessionId(null); setOpenMenuSessionId(null);
writeActiveSessionId(session.session_id); writeActiveSessionId(session.session_id);
pushSessionUrl(session.session_id); pushSessionUrl(session.session_id);
+71 -30
View File
@@ -84,6 +84,7 @@ import {
ACTIVE_SESSION_CHANGED_EVENT, ACTIVE_SESSION_CHANGED_EVENT,
clearActiveSessionId, clearActiveSessionId,
clearPendingWorkspaceSessionId, clearPendingWorkspaceSessionId,
markFreshLocalId,
PENDING_WORKSPACE_SESSION_CHANGED_EVENT, PENDING_WORKSPACE_SESSION_CHANGED_EVENT,
readActiveSessionId, readActiveSessionId,
readPendingWorkspaceSessionId, readPendingWorkspaceSessionId,
@@ -261,8 +262,14 @@ function useRefreshCurrentRun() {
includePending: true, includePending: true,
includeActive: true, includeActive: true,
}); });
const activeRunRef = useRef<string | null>(null); // Per-session bookkeeping: switching sessions (sidebar history) and coming
const appliedTerminalRef = useRef<string | null>(null); // back must NOT re-trigger replaySession on a session that has already been
// applied — replaySession rebuilds the thread tree, which causes a layout
// shift / scroll jitter in the chat UI. Single-value refs would get
// overwritten when visiting another session and forget that this session
// was already settled.
const activeRunRefMap = useRef<Map<string, string>>(new Map());
const appliedTerminalRefMap = useRef<Map<string, string>>(new Map());
useEffect(() => { useEffect(() => {
if (!sessionId) return; if (!sessionId) return;
@@ -273,17 +280,36 @@ function useRefreshCurrentRun() {
const runStatus = await fetchLatestRunStatus(sessionId); const runStatus = await fetchLatestRunStatus(sessionId);
if (cancelled) return; if (cancelled) return;
if (runStatus?.status === "queued" || runStatus?.status === "running") { if (runStatus?.status === "queued" || runStatus?.status === "running") {
activeRunRef.current = runStatus.run_id ?? "active"; const runKey = runStatus.run_id ?? "active";
const previousRunKey = activeRunRefMap.current.get(sessionId) ?? null;
activeRunRefMap.current.set(sessionId, runKey);
// Watcher auto-resume: backend started a new run via _maybe_auto_resume
// without a local SSE stream driving the UI. We need to replay once so
// the active-run placeholder (pending prompt + 思考中 bubble) appears.
// useRefreshReplayedRun will take over polling once replayedRun.active
// becomes true.
if (!runtimeRunning && previousRunKey !== runKey) {
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
cache: "no-store",
});
if (!response.ok || cancelled) return;
const payload = await response.json();
replaySession(
sessionId,
toReplayRepository(payload, sessionId, runStatus),
);
}
return; return;
} }
if (!runtimeRunning && !activeRunRef.current) return; const sessionActiveRun = activeRunRefMap.current.get(sessionId) ?? null;
if (!runtimeRunning && !sessionActiveRun) return;
const terminalKey = [ const terminalKey = [
runStatus?.run_id ?? activeRunRef.current ?? "unknown", runStatus?.run_id ?? sessionActiveRun ?? "unknown",
runStatus?.status ?? "idle", runStatus?.status ?? "idle",
runStatus?.finished_at ?? "", runStatus?.finished_at ?? "",
runStatus?.elapsed_ms ?? "", runStatus?.elapsed_ms ?? "",
].join(":"); ].join(":");
if (appliedTerminalRef.current === terminalKey) return; if (appliedTerminalRefMap.current.get(sessionId) === terminalKey) return;
const response = await fetch(`/api/claw/sessions/${sessionId}`, { const response = await fetch(`/api/claw/sessions/${sessionId}`, {
cache: "no-store", cache: "no-store",
}); });
@@ -293,8 +319,8 @@ function useRefreshCurrentRun() {
sessionId, sessionId,
toReplayRepository(payload, sessionId, runStatus), toReplayRepository(payload, sessionId, runStatus),
); );
activeRunRef.current = null; activeRunRefMap.current.delete(sessionId);
appliedTerminalRef.current = terminalKey; appliedTerminalRefMap.current.set(sessionId, terminalKey);
scheduleSessionListRefresh(); scheduleSessionListRefresh();
} }
@@ -425,17 +451,16 @@ async function cancelLatestRun(sessionId: string, runId?: string | null) {
const ChatTopActions: FC = () => { const ChatTopActions: FC = () => {
const { openFiles } = useActivityPanel(); const { openFiles } = useActivityPanel();
const [workspaceOpen, setWorkspaceOpen] = useState(false); const [workspaceOpen, setWorkspaceOpen] = useState(false);
const actionSessionId = useCurrentThreadSessionId({ // "切换工作区" dialog 与 indicator 必须用同一个 sessionId,否则会出现
includePending: true, // "绑给 A、查的是 B" 的错位(agent 的 bash 路由也是用 active session id
// 工作区切换必须绑定当前可见会话;空白新会话不能回退到上一个 // 错位时表面 indicator 显示已连接,实际 agent 跑在本地)。
// activeSessionId,否则会把 Jupyter 工作区切到旧会话上。 // 新建空会话场景由 thread-list.tsx 的 clearActiveSessionId() 兜底,
includeActive: false, // 此处不再需要刻意排除 activeSessionId。
}); const sessionId = useCurrentThreadSessionId({
const displaySessionId = useCurrentThreadSessionId({
includePending: true, includePending: true,
includeActive: true, includeActive: true,
}); });
const workspaceStatus = useJupyterWorkspaceStatus(displaySessionId); const workspaceStatus = useJupyterWorkspaceStatus(sessionId);
return ( return (
<div className="pointer-events-none sticky top-3 z-20 mr-4 flex justify-end"> <div className="pointer-events-none sticky top-3 z-20 mr-4 flex justify-end">
@@ -462,7 +487,7 @@ const ChatTopActions: FC = () => {
type="button" type="button"
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-muted" className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-muted"
onClick={() => { onClick={() => {
openFiles(displaySessionId); openFiles(sessionId);
}} }}
> >
<FileTextIcon className="size-4 text-muted-foreground" /> <FileTextIcon className="size-4 text-muted-foreground" />
@@ -482,7 +507,7 @@ const ChatTopActions: FC = () => {
<WorkspaceSwitchDialog <WorkspaceSwitchDialog
open={workspaceOpen} open={workspaceOpen}
onOpenChange={setWorkspaceOpen} onOpenChange={setWorkspaceOpen}
sessionId={actionSessionId} sessionId={sessionId}
/> />
</div> </div>
); );
@@ -633,6 +658,7 @@ function WorkspaceSwitchDialog({
if (needsFirstMessageSession) { if (needsFirstMessageSession) {
writePendingWorkspaceSessionId(sessionIdForRequest); writePendingWorkspaceSessionId(sessionIdForRequest);
} }
window.dispatchEvent(new Event("claw-workspaces-changed"));
setStatus(payload as JupyterWorkspaceStatus); setStatus(payload as JupyterWorkspaceStatus);
setProgress({ percent: 100, label: "切换完成" }); setProgress({ percent: 100, label: "切换完成" });
setMessage("工作区切换成功,后续工具会在远端 Jupyter 工作区执行。"); setMessage("工作区切换成功,后续工具会在远端 Jupyter 工作区执行。");
@@ -757,6 +783,7 @@ function ensureWorkspaceSessionId(current: string | null) {
typeof crypto !== "undefined" && "randomUUID" in crypto typeof crypto !== "undefined" && "randomUUID" in crypto
? `__LOCALID_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}` ? `__LOCALID_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
: `__LOCALID_${Math.random().toString(36).slice(2, 10)}`; : `__LOCALID_${Math.random().toString(36).slice(2, 10)}`;
markFreshLocalId(generated);
writeActiveSessionId(generated); writeActiveSessionId(generated);
scheduleSessionListRefresh(); scheduleSessionListRefresh();
return generated; return generated;
@@ -1187,6 +1214,7 @@ function ModelPickerButton({
}; };
}) { }) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
const filteredModels = useMemo(() => { const filteredModels = useMemo(() => {
if (!normalizedQuery) return status.models; if (!normalizedQuery) return status.models;
@@ -1200,7 +1228,7 @@ function ModelPickerButton({
}, [normalizedQuery, status.models]); }, [normalizedQuery, status.models]);
return ( return (
<PopoverPrimitive.Root> <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Trigger asChild> <PopoverPrimitive.Trigger asChild>
<button <button
type="button" type="button"
@@ -1241,7 +1269,10 @@ function ModelPickerButton({
key={model.id} key={model.id}
type="button" type="button"
className="flex h-8 w-full items-center justify-between gap-2 rounded-lg px-2 text-left text-sm hover:bg-muted" className="flex h-8 w-full items-center justify-between gap-2 rounded-lg px-2 text-left text-sm hover:bg-muted"
onClick={() => status.setModel(model.id)} onClick={() => {
setOpen(false);
void status.setModel(model.id);
}}
> >
<span className="min-w-0 flex-1 truncate">{model.id}</span> <span className="min-w-0 flex-1 truncate">{model.id}</span>
<span className="shrink-0 text-muted-foreground text-xs"> <span className="shrink-0 text-muted-foreground text-xs">
@@ -2371,6 +2402,11 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
const justSubmittedRef = useRef(false); const justSubmittedRef = useRef(false);
const isDisabled = runtimeDisabled || disabled; const isDisabled = runtimeDisabled || disabled;
// Pull store→local on external store changes (session switch, paste-from-attachment,
// submit-clear, etc). The reverse direction (local→store) is pushed eagerly by
// onChange/handleInsert/handleKeyDown/onCompositionEnd via syncComposerText, so we
// don't need a symmetric effect — and adding one creates a feedback loop that
// re-renders the composer at ~1ms intervals (visible as shake).
useEffect(() => { useEffect(() => {
if (!isComposingRef.current) { if (!isComposingRef.current) {
if (justSubmittedRef.current && storeText) return; if (justSubmittedRef.current && storeText) return;
@@ -2379,16 +2415,6 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
} }
}, [storeText]); }, [storeText]);
useEffect(() => {
if (
!isComposingRef.current &&
localText !== storeText &&
aui.composer().getState().isEditing
) {
aui.composer().setText(localText);
}
}, [aui, localText, storeText]);
useEffect(() => { useEffect(() => {
if (!autoFocus || isDisabled) return; if (!autoFocus || isDisabled) return;
const textarea = textareaRef.current; const textarea = textareaRef.current;
@@ -2411,6 +2437,21 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
}; };
}, [aui]); }, [aui]);
// AssistantWorkspace 切 session 时通过事件通知我们恢复 draft——直接
// 控制 localText,绕过 store→local 路径上的 justSubmittedRef 屏蔽和
// switchToNewThread 引起的异步覆盖。
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ text?: string }>).detail;
const text = detail?.text ?? "";
justSubmittedRef.current = false;
setLocalText(text);
};
window.addEventListener("claw-composer-restore-draft", handler);
return () =>
window.removeEventListener("claw-composer-restore-draft", handler);
}, []);
const syncComposerText = useCallback( const syncComposerText = useCallback(
(value: string) => { (value: string) => {
if (!aui.composer().getState().isEditing) return; if (!aui.composer().getState().isEditing) return;
@@ -72,6 +72,7 @@ type ClawSessionSummary = {
preview?: string; preview?: string;
model?: string; model?: string;
usage?: UsageSummary; usage?: UsageSummary;
is_training?: boolean;
}; };
type UsageSummary = { type UsageSummary = {
@@ -365,8 +366,15 @@ function SessionResultButton({
className="flex w-full flex-col rounded-lg px-2 py-2 text-left transition-colors hover:bg-muted" className="flex w-full flex-col rounded-lg px-2 py-2 text-left transition-colors hover:bg-muted"
onClick={onClick} onClick={onClick}
> >
<span className="truncate font-medium text-sm"> <span className="flex items-center gap-1.5">
{loading ? "回放中..." : session.preview || session.session_id} <span className="truncate font-medium text-sm">
{loading ? "回放中..." : session.preview || session.session_id}
</span>
{session.is_training ? (
<span className="shrink-0 rounded bg-amber-100 px-1.5 text-[10px] text-amber-800">
</span>
) : null}
</span> </span>
<span className="mt-0.5 text-muted-foreground text-xs"> <span className="mt-0.5 text-muted-foreground text-xs">
{session.turns ?? 0} · {session.tool_calls ?? 0} {session.turns ?? 0} · {session.tool_calls ?? 0}
@@ -382,17 +390,23 @@ function useSidebarSessions(open: boolean) {
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
let cancelled = false; let cancelled = false;
fetch("/api/claw/sessions", { cache: "no-store" }) const reload = () => {
.then((res) => (res.ok ? res.json() : [])) fetch("/api/claw/sessions", { cache: "no-store" })
.then((payload) => { .then((res) => (res.ok ? res.json() : []))
if (cancelled || !Array.isArray(payload)) return; .then((payload) => {
setSessions(dedupeSidebarSessions(payload)); if (cancelled || !Array.isArray(payload)) return;
}) setSessions(dedupeSidebarSessions(payload));
.catch(() => { })
if (!cancelled) setSessions([]); .catch(() => {
}); if (!cancelled) setSessions([]);
});
};
reload();
const handleUpdate = () => reload();
window.addEventListener("claw-session-updated", handleUpdate);
return () => { return () => {
cancelled = true; cancelled = true;
window.removeEventListener("claw-session-updated", handleUpdate);
}; };
}, [open]); }, [open]);
@@ -0,0 +1,214 @@
"use client";
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { cn } from "@/lib/utils";
export type Round = {
iteration: number;
runDic: number;
label: string;
timestamp: string;
values: Record<string, number | null>;
};
export type MetricsHistory = {
metrics: string[];
rounds: Round[];
};
const METRIC_LABELS: Record<string, string> = {
req_set_car: "需求集合(车载)",
dapan_car: "大盘车载",
specific_test: "Specific Test",
triage_err_rate: "Triage 错误率",
bvt_nav: "导航BVT",
talkable_controllable: "可聊可控",
target_subset_count: "目标子集数量",
target_subset_wrong: "目标子集错误数",
target_subset_correct: "目标子集正确数",
target_subset_acc: "目标子集准确率",
icl_test_overall: "ICL 测试整体",
specific_test_overall: "专项测试整体",
overrall: "大盘集",
overrall_che_zai: "大盘集车载",
overrall_shou_ji: "大盘集手机",
overrall_dian_shi: "大盘集电视",
overrall_yan_jing: "大盘集眼镜",
overrall_you_ping: "大盘集有屏",
overrall_wu_ping: "大盘集无屏",
};
const LINE_COLORS = [
"#34d399", // emerald
"#38bdf8", // sky
"#a78bfa", // violet
"#fbbf24", // amber
"#f472b6", // rose
"#22d3ee", // cyan
"#a3e635", // lime
"#fb923c", // orange
];
function labelOf(key: string): string {
return METRIC_LABELS[key] ?? key;
}
function formatValue(v: number | null | undefined): string {
if (v === null || v === undefined || Number.isNaN(v)) return "—";
return v.toFixed(4);
}
function formatDelta(curr: number | null | undefined, base: number | null | undefined): {
text: string;
tone: "up" | "down" | "flat";
} {
if (
curr === null ||
curr === undefined ||
base === null ||
base === undefined ||
Number.isNaN(curr) ||
Number.isNaN(base)
) {
return { text: "—", tone: "flat" };
}
const d = curr - base;
if (Math.abs(d) < 1e-6) return { text: "±0", tone: "flat" };
const sign = d > 0 ? "+" : "";
return { text: `${sign}${d.toFixed(4)}`, tone: d > 0 ? "up" : "down" };
}
function chartRowsFor(history: MetricsHistory, key: string): Array<Record<string, unknown>> {
return history.rounds.map((r) => {
const v = r.values[key];
return {
label: r.label,
[key]: v === undefined || v === null ? null : v,
};
});
}
export function MetricsChartPanel({ history }: { history: MetricsHistory | null }) {
if (!history || history.rounds.length === 0) {
return (
<div className="flex h-full items-center justify-center text-zinc-500 text-xs">
</div>
);
}
const latest = history.rounds[history.rounds.length - 1];
const baseline = history.rounds[0];
const prev =
history.rounds.length >= 2 ? history.rounds[history.rounds.length - 2] : baseline;
const colorFor = (key: string): string => {
const idx = history.metrics.indexOf(key);
return LINE_COLORS[idx >= 0 ? idx % LINE_COLORS.length : 0];
};
return (
<div className="h-full min-h-0 overflow-y-auto pr-1">
<div className="grid gap-2 [grid-template-columns:repeat(auto-fit,minmax(280px,1fr))]">
{history.metrics.map((k) => {
const curr = latest.values[k];
const dBase = formatDelta(curr, baseline.values[k]);
const dPrev = formatDelta(curr, prev.values[k]);
const color = colorFor(k);
const rows = chartRowsFor(history, k);
return (
<div
key={k}
className="flex flex-col rounded-md border border-zinc-800 bg-zinc-900/40 p-2"
>
<div className="flex shrink-0 items-baseline justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<span
className="size-1.5 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="truncate font-medium text-[11px] text-zinc-200">
{labelOf(k)}
</span>
</div>
<span className="font-mono font-semibold text-[14px] text-zinc-50 leading-none">
{formatValue(curr)}
</span>
</div>
<div className="mt-0.5 flex shrink-0 gap-2 font-mono text-[10px]">
<span className={deltaClass(dBase.tone)}>
Δbase {dBase.text}
</span>
<span className={deltaClass(dPrev.tone)}>
Δprev {dPrev.text}
</span>
</div>
<div className="mt-1.5 h-32">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={rows}
margin={{ top: 4, right: 8, left: -12, bottom: 0 }}
>
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
<XAxis
dataKey="label"
stroke="#a1a1aa"
tick={{ fontSize: 10 }}
/>
<YAxis
stroke="#a1a1aa"
tick={{ fontSize: 10 }}
tickFormatter={(v: number) =>
typeof v === "number" ? v.toFixed(4) : v
}
domain={["auto", "auto"]}
width={60}
/>
<Tooltip
contentStyle={{
background: "#18181b",
border: "1px solid #3f3f46",
borderRadius: 6,
fontSize: 11,
}}
labelStyle={{ color: "#e4e4e7" }}
itemStyle={{ color: "#e4e4e7" }}
formatter={(value: number, name: string) => [
typeof value === "number" ? value.toFixed(4) : value,
labelOf(name),
]}
/>
<Line
type="monotone"
dataKey={k}
stroke={color}
strokeWidth={1.5}
dot={{ r: 2.5 }}
activeDot={{ r: 4 }}
connectNulls={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
})}
</div>
</div>
);
}
function deltaClass(tone: "up" | "down" | "flat"): string {
if (tone === "up") return "text-emerald-400";
if (tone === "down") return "text-rose-400";
return "text-zinc-500";
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
"use client";
import { GitBranchIcon } from "lucide-react";
import { cn } from "@/lib/utils";
type Props = {
sessionId: string | null;
isTraining: boolean;
onChange: (next: boolean) => void;
disabled?: boolean;
lockedReason?: string;
};
export function TrainingModeToggle({
sessionId,
isTraining,
onChange,
disabled,
lockedReason,
}: Props) {
const isDisabled = disabled || !sessionId;
const tooltip = !sessionId
? "发送首条消息后开启"
: lockedReason
? lockedReason
: "切换训练模式";
return (
<button
type="button"
disabled={isDisabled}
onClick={() => onChange(!isTraining)}
title={tooltip}
aria-pressed={isTraining}
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition-colors",
"disabled:cursor-not-allowed disabled:opacity-50",
isTraining
? "border-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100"
: "border-border bg-background text-foreground hover:bg-muted",
)}
>
<GitBranchIcon className="size-3.5" />
<span></span>
<span
className={cn(
"ml-1 inline-flex h-3.5 w-7 shrink-0 items-center rounded-full transition-colors",
isTraining ? "bg-amber-400" : "bg-muted",
)}
>
<span
className={cn(
"inline-block size-3 rounded-full bg-background shadow-sm transition-transform",
isTraining ? "translate-x-3.5" : "translate-x-0.5",
)}
/>
</span>
</button>
);
}
@@ -0,0 +1,965 @@
"use client";
import {
AlertTriangleIcon,
BarChart3Icon,
CheckIcon,
ChevronRightIcon,
ClipboardListIcon,
ClockIcon,
DnaIcon,
FileTextIcon,
FlaskConicalIcon,
GraduationCapIcon,
HourglassIcon,
Loader2Icon,
type LucideIcon,
Maximize2Icon,
MicroscopeIcon,
Minimize2Icon,
RefreshCwIcon,
ShieldCheckIcon,
TrendingUpIcon,
UserCheckIcon,
} from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import {
MetricsChartPanel,
type MetricsHistory,
} from "@/components/training/metrics-chart-panel";
import { StepDetailSheet } from "@/components/training/step-detail-sheet";
import { cn } from "@/lib/utils";
type CardStatus =
| "pending"
| "running"
| "waiting"
| "complete"
| "failed"
| "cancelled";
type SectionType = "baseline" | "train" | "analysis";
type GateKind = "human-check" | "human-review";
type PipelineCard = {
key: string;
step: string;
icon?: string;
title: string;
subtitle: string;
status: CardStatus;
progress?: number;
};
type RoundItem = {
type: "round";
index: number;
run_id: string;
run_index: number;
section_type: SectionType;
label: string;
description: string;
status: CardStatus;
cards: PipelineCard[];
};
type GateItem = {
type: "gate";
index: number;
key: string;
run_id: string;
run_index: number;
gate_kind: GateKind;
title: string;
description: string;
status: CardStatus;
reason?: string | null;
summary?: string | null;
proposal?: string | null;
ask?: string | null;
};
type PipelineItem = RoundItem | GateItem;
type InFlight = {
run_id: string;
section_type: SectionType;
section_label: string;
cards: PipelineCard[];
};
type Kpi = {
label: string;
value: string;
icon?: string;
};
type LogLine = {
ts: string;
iter: string | number;
text: string;
};
type PipelinePayload = {
session_id: string;
generated_at_ms: number;
available?: boolean;
status: { mode: string; state: CardStatus };
kpis: Kpi[];
items: PipelineItem[];
in_flight?: InFlight | null;
logs: LogLine[];
metrics_history?: MetricsHistory | null;
};
const ICON_MAP: Record<string, LucideIcon> = {
"alert-triangle": AlertTriangleIcon,
"bar-chart": BarChart3Icon,
microscope: MicroscopeIcon,
"trending-up": TrendingUpIcon,
"file-text": FileTextIcon,
dna: DnaIcon,
flask: FlaskConicalIcon,
shield: ShieldCheckIcon,
"graduation-cap": GraduationCapIcon,
"clipboard-list": ClipboardListIcon,
"refresh-cw": RefreshCwIcon,
clock: ClockIcon,
hourglass: HourglassIcon,
"user-check": UserCheckIcon,
};
const SECTION_PHASE_TONE: Record<SectionType, string> = {
baseline: "from-violet-500/15 to-sky-500/10 border-violet-400/40",
train: "from-emerald-500/15 to-amber-500/10 border-emerald-400/40",
analysis: "from-sky-500/15 to-violet-500/10 border-sky-400/40",
};
const SECTION_BADGE_TONE: Record<SectionType, string> = {
baseline: "bg-violet-500/10 text-violet-700 dark:text-violet-300",
train: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
analysis: "bg-sky-500/10 text-sky-700 dark:text-sky-300",
};
export function TrainingPipelinePanel({
sessionId,
}: {
sessionId: string | null;
}) {
const [data, setData] = useState<PipelinePayload | null>(null);
const [loading, setLoading] = useState(false);
const [openCard, setOpenCard] = useState<{
key: string;
title: string;
status: string;
runId?: string;
} | null>(null);
const [bottomTab, setBottomTab] = useState<"log" | "metrics">("log");
useEffect(() => {
setLoading(true);
const url = sessionId
? `/api/claw/training/pipeline/stream?session_id=${encodeURIComponent(sessionId)}`
: "/api/claw/training/pipeline/stream";
const es = new EventSource(url);
es.onmessage = (ev) => {
try {
const payload = JSON.parse(ev.data);
if (payload && payload.available === false) {
setData(null);
} else {
setData(payload);
}
setLoading(false);
} catch {
/* ignore malformed events */
}
};
es.onerror = () => {
setLoading(false);
};
return () => {
es.close();
};
}, [sessionId]);
const stripNamespace = (key: string): string => {
// "R1:cml" → "cml" — backend uses round-namespaced keys, but the
// step-detail endpoint is keyed only by step name.
const idx = key.indexOf(":");
return idx >= 0 ? key.slice(idx + 1) : key;
};
return (
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden border-r bg-muted/20">
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<HeaderRow status={data?.status ?? null} loading={loading} />
<KpiRow kpis={data?.kpis ?? []} />
<div className="mt-5 space-y-3">
{(() => {
if (!data) {
return (
<div className="rounded-md border border-dashed p-8 text-center text-muted-foreground text-xs">
pipeline
</div>
);
}
const items = data.items ?? [];
if (items.length === 0) {
const isRunning = data.status?.state === "running";
return (
<div className="rounded-md border border-dashed p-8 text-center text-muted-foreground text-xs">
{isRunning
? "首个阶段进行中,卡片即将出现……"
: "等待 agent 进入第一个步骤……"}
</div>
);
}
return (
<>
{items.map((item, idx) => {
const showConnector = idx < items.length - 1;
if (item.type === "round") {
return (
<div
key={`round-${item.run_id}-${item.section_type}`}
>
<RoundSection
item={item}
onCardClick={(card) =>
setOpenCard({
key: stripNamespace(card.key),
title: card.title,
status: card.status,
runId: item.run_id,
})
}
/>
{showConnector ? <ItemConnector /> : null}
</div>
);
}
return (
<div key={`gate-${item.key}`}>
<GateCard
item={item}
onClick={() =>
setOpenCard({
key: stripNamespace(item.key),
title: item.title,
status: item.status,
runId: item.run_id,
})
}
/>
{showConnector ? <ItemConnector /> : null}
</div>
);
})}
</>
);
})()}
</div>
</div>
<BottomPanel
logs={data?.logs ?? []}
history={data?.metrics_history ?? null}
tab={bottomTab}
onTabChange={setBottomTab}
/>
<StepDetailSheet
sessionId={sessionId}
stepKey={openCard?.key ?? null}
runId={openCard?.runId ?? null}
stepTitle={openCard?.title}
stepStatus={openCard?.status}
open={openCard !== null}
onOpenChange={(next) => {
if (!next) setOpenCard(null);
}}
/>
</div>
);
}
function HeaderRow({
status,
loading,
}: {
status: PipelinePayload["status"] | null;
loading: boolean;
}) {
const statusLabel = loading
? "加载中"
: status
? `${status.mode} · ${statusText(status.state)}`
: "暂无运行";
const isRunning = status?.state === "running";
const isWaiting = status?.state === "waiting";
const isFailed = status?.state === "failed";
const isCancelled = status?.state === "cancelled";
const isErrored = isFailed || isCancelled;
return (
<header className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<MicroscopeIcon className="size-5 text-muted-foreground" />
<h2 className="font-semibold text-sm uppercase tracking-[0.2em] text-foreground/90">
Training Pipeline Monitor
</h2>
</div>
<div
className={cn(
"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs",
isErrored
? "border-rose-500/50 bg-rose-500/10 text-rose-700 dark:text-rose-300"
: isWaiting
? "border-amber-500/50 bg-amber-500/10 text-amber-700 dark:text-amber-300"
: isRunning
? "animate-pipeline-pill-glow border-sky-400 bg-sky-500/10 text-sky-700 dark:text-sky-300"
: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
)}
>
<span className="relative inline-flex size-2 items-center justify-center">
<span
className={cn(
"size-2 rounded-full",
isErrored
? "bg-rose-500"
: isWaiting
? "bg-amber-500"
: isRunning
? "bg-sky-500"
: status?.state === "complete"
? "bg-emerald-500"
: "bg-muted-foreground",
)}
/>
{isRunning && !isErrored ? (
<span className="pointer-events-none absolute inset-0 animate-pipeline-dot-ping rounded-full bg-sky-400" />
) : null}
</span>
<span className="font-medium">{statusLabel}</span>
</div>
</header>
);
}
function KpiRow({ kpis }: { kpis: Kpi[] }) {
if (!kpis.length) {
return null;
}
return (
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-7">
{kpis.map((kpi) => {
const Icon = kpi.icon ? ICON_MAP[kpi.icon] : null;
return (
<div
key={kpi.label}
className="rounded-lg border bg-background px-3 py-2 min-h-[58px]"
>
<div className="text-[10px] font-medium tracking-wider text-muted-foreground truncate">
{kpi.label}
</div>
<div className="mt-0.5 flex items-start gap-1.5 font-mono text-sm leading-tight text-foreground">
<span className="break-all line-clamp-2">{kpi.value}</span>
{Icon ? (
<Icon className="size-3.5 shrink-0 text-muted-foreground mt-0.5" />
) : null}
</div>
</div>
);
})}
</div>
);
}
function ItemConnector() {
return (
<div className="flex justify-center py-1">
<div className="h-5 w-0.5 bg-gradient-to-b from-border to-muted-foreground/30" />
</div>
);
}
function StatusPill({ status }: { status: CardStatus }) {
const map: Record<
CardStatus,
{ label: string; cls: string; icon: LucideIcon | null }
> = {
complete: {
label: "COMPLETED",
cls: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
icon: CheckIcon,
},
running: {
label: "IN PROGRESS",
cls: "bg-sky-500/15 text-sky-700 dark:text-sky-300 border-sky-500/30",
icon: Loader2Icon,
},
waiting: {
label: "WAITING FOR YOU",
cls: "bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/40",
icon: HourglassIcon,
},
pending: {
label: "PENDING",
cls: "bg-muted text-muted-foreground border-border",
icon: null,
},
failed: {
label: "FAILED",
cls: "bg-rose-500/15 text-rose-700 dark:text-rose-300 border-rose-500/30",
icon: AlertTriangleIcon,
},
cancelled: {
label: "CANCELLED",
cls: "bg-muted text-muted-foreground border-border",
icon: null,
},
};
const { label, cls, icon: Icon } = map[status];
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-[10px] font-semibold tracking-wider",
cls,
)}
>
{Icon ? (
<Icon
className={cn(
"size-3",
status === "running" ? "animate-spin" : "",
)}
/>
) : null}
{label}
</span>
);
}
function RoundSection({
item,
onCardClick,
}: {
item: RoundItem;
onCardClick?: (card: PipelineCard) => void;
}) {
const numLabel = item.index.toString().padStart(2, "0");
const phaseTone = SECTION_PHASE_TONE[item.section_type];
const badgeTone = SECTION_BADGE_TONE[item.section_type];
return (
<section
className={cn(
"rounded-2xl border bg-gradient-to-br p-4 shadow-sm",
phaseTone,
"bg-background/80",
)}
>
<div className="mb-3 flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<span
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-full font-mono text-sm font-semibold",
badgeTone,
)}
>
{numLabel}
</span>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-foreground text-sm">
{item.label}
</span>
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground">
{item.description}
</p>
</div>
</div>
<StatusPill status={item.status} />
</div>
{item.cards.length > 0 ? (
<div className="flex flex-wrap items-stretch justify-center gap-2">
{item.cards.map((card, idx) => (
<div key={card.key} className="flex items-stretch gap-2">
<PipelineCardView
card={card}
onClick={onCardClick ? () => onCardClick(card) : undefined}
/>
{idx < item.cards.length - 1 ? <CardArrow /> : null}
</div>
))}
</div>
) : null}
</section>
);
}
function CardArrow() {
return (
<div className="flex items-center px-1 text-muted-foreground/50">
<ChevronRightIcon className="size-5" />
</div>
);
}
function parseGateReason(reason: string | null | undefined): {
summary: string | null;
proposal: string | null;
ask: string | null;
} {
const text = (reason ?? "").trim();
if (!text) return { summary: null, proposal: null, ask: null };
// Find the ask: greedy from any "是否/要不要/能否/还是/请..." question phrase
// to end. Fallback: the trailing sentence containing ?/.
const askPatterns = [
/(是否[^。]*$)/,
/(要不要[^。]*$)/,
/(能否[^。]*$)/,
/(请[^。]{0,40}[?][^。]*$)/,
];
let ask: string | null = null;
let body = text;
for (const pat of askPatterns) {
const m = text.match(pat);
if (m && m.index !== undefined) {
ask = m[0].trim().replace(/^[。;,\s]+/, "");
body = text.slice(0, m.index).trim().replace(/[。;,\s]+$/, "");
break;
}
}
if (!ask) {
// Fallback: split off trailing sentence(s) containing ? or
const qIdx = Math.max(text.lastIndexOf("?"), text.lastIndexOf(""));
if (qIdx >= 0) {
// expand back to previous 。 or start of string
const prevDot = Math.max(
text.lastIndexOf("。", qIdx - 1),
text.lastIndexOf(";", qIdx - 1),
text.lastIndexOf("", qIdx - 1),
);
ask = text.slice(prevDot + 1).trim().replace(/^[。;,\s]+/, "");
body = text.slice(0, prevDot + 1).trim().replace(/[。;,\s]+$/, "");
}
}
const sentences = body
.split(/。/)
.map((s) => s.trim())
.filter(Boolean);
if (sentences.length === 0) {
return { summary: null, proposal: null, ask };
}
if (sentences.length === 1) {
return { summary: sentences[0], proposal: null, ask };
}
const proposalKw =
/(H\d|H[1-3]\b|拟|要做|计划|提议|方案|增强|仿写|修改|修\s*\d|fix|patch|候选|逐条审|relabel|sft|train)/i;
let splitIdx = sentences.findIndex((s) => proposalKw.test(s));
if (splitIdx <= 0) splitIdx = Math.max(1, Math.floor(sentences.length / 2));
const summary = sentences.slice(0, splitIdx).join("。 ");
const proposal = sentences.slice(splitIdx).join("。 ");
return { summary, proposal, ask };
}
function GateBodyRow({
label,
text,
tone,
}: {
label: string;
text: string;
tone: "status" | "proposal" | "ask";
}) {
const labelTone =
tone === "ask"
? "bg-amber-500/25 text-amber-800 dark:bg-amber-400/30 dark:text-amber-100"
: tone === "proposal"
? "bg-sky-500/15 text-sky-700 dark:bg-sky-400/20 dark:text-sky-200"
: "bg-zinc-500/15 text-zinc-700 dark:bg-zinc-400/20 dark:text-zinc-200";
const textTone = tone === "ask" ? "text-foreground font-medium" : "text-foreground/85";
return (
<div className="flex items-start gap-2">
<span
className={cn(
"shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px] font-semibold tracking-wider",
labelTone,
)}
>
{label}
</span>
<p className={cn("text-sm leading-relaxed", textTone)}>{text}</p>
</div>
);
}
function GateCard({
item,
onClick,
}: {
item: GateItem;
onClick?: () => void;
}) {
const numLabel = item.index.toString().padStart(2, "0");
const isWaiting = item.status === "waiting";
const isComplete = item.status === "complete";
const Tag = onClick ? "button" : "div";
const hint =
item.gate_kind === "human-review"
? "在右侧对话里给出 OK / 调整后的指令,agent 才会继续。"
: "在右侧对话里回 OK / 修改建议,确认后进入下一轮训练。";
// Prefer structured fields; fall back to heuristic parsing of `reason`.
const parsed = parseGateReason(item.reason);
const summary = item.summary ?? parsed.summary;
const proposal = item.proposal ?? parsed.proposal;
const ask = item.ask ?? parsed.ask;
const hasStructured = Boolean(summary || proposal || ask);
return (
<Tag
type={onClick ? "button" : undefined}
onClick={onClick}
className={cn(
"flex w-full items-center justify-between gap-4 rounded-2xl border-2 border-dashed bg-amber-500/5 p-5 text-left transition-colors",
isWaiting
? "border-amber-400 bg-amber-500/10 ring-2 ring-amber-300/40 dark:ring-amber-500/30 animate-pipeline-pill-glow"
: isComplete
? "border-emerald-400/60 bg-emerald-500/5"
: "border-amber-400/40",
onClick &&
"cursor-pointer hover:bg-amber-500/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400",
)}
>
<div className="flex items-center gap-3 min-w-0">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-amber-500/15 font-mono text-amber-700 text-base font-semibold dark:text-amber-300">
{numLabel}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<UserCheckIcon className="size-5 shrink-0 text-amber-700 dark:text-amber-300" />
<span className="font-semibold text-foreground text-base">
{item.title}
</span>
<span className="rounded bg-amber-500/15 px-1.5 py-0.5 font-mono text-xs text-amber-700 dark:text-amber-300">
{item.run_id}
</span>
</div>
{hasStructured ? (
<div className="mt-2 space-y-1.5">
{summary ? (
<GateBodyRow label="现状" text={summary} tone="status" />
) : null}
{proposal ? (
<GateBodyRow label="提议" text={proposal} tone="proposal" />
) : null}
{ask ? (
<GateBodyRow label="请选" text={ask} tone="ask" />
) : null}
</div>
) : item.reason ? (
<p className="mt-1.5 text-sm text-foreground/85 leading-relaxed">
{item.reason}
</p>
) : (
<p className="mt-1.5 text-sm text-muted-foreground leading-relaxed">
{item.description}
</p>
)}
{isWaiting ? (
<p className="mt-2 text-xs tracking-wide text-amber-700/90 dark:text-amber-400/90">
{hint}
</p>
) : null}
</div>
</div>
<StatusPill status={item.status} />
</Tag>
);
}
function PipelineCardView({
card,
onClick,
}: {
card: PipelineCard;
onClick?: () => void;
}) {
const isRunning = card.status === "running";
const isComplete = card.status === "complete";
const Icon = card.icon ? ICON_MAP[card.icon] : null;
const tone = isComplete
? "border-emerald-500/40 bg-emerald-50/40 dark:bg-emerald-950/20"
: isRunning
? "border-sky-400 bg-sky-50/60 dark:bg-sky-950/30 ring-2 ring-sky-300/50 dark:ring-sky-500/30 animate-pipeline-running-blue"
: card.status === "failed"
? "border-rose-500/40 bg-rose-50/40 dark:bg-rose-950/20"
: "border-border bg-background";
const Tag = onClick ? "button" : "div";
return (
<Tag
type={onClick ? "button" : undefined}
onClick={onClick}
className={cn(
"relative flex w-48 flex-col rounded-xl border px-3 pt-3 pb-2.5 text-left transition-colors",
tone,
onClick &&
"cursor-pointer hover:brightness-105 hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400",
)}
>
<div className="mb-2 flex items-start">
{isComplete ? (
<div className="flex size-5 items-center justify-center rounded-full bg-emerald-500 text-white">
<CheckIcon className="size-3" strokeWidth={3} />
</div>
) : (
<MiniStatus status={card.status} />
)}
</div>
<div className="flex items-center gap-1.5">
{Icon ? (
<Icon
className={cn(
"size-4 shrink-0",
isComplete
? "text-emerald-600 dark:text-emerald-400"
: isRunning
? "text-sky-600 dark:text-sky-400"
: "text-muted-foreground",
)}
/>
) : null}
<span
className={cn(
"font-medium text-sm leading-tight",
isRunning ? "text-foreground" : "",
card.status === "pending" ? "text-muted-foreground" : "",
)}
>
{card.title}
</span>
</div>
<span className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">
{card.subtitle}
</span>
</Tag>
);
}
function MiniStatus({ status }: { status: CardStatus }) {
const map: Record<CardStatus, { label: string; cls: string }> = {
complete: {
label: "DONE",
cls: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
},
running: {
label: "RUNNING",
cls: "bg-sky-500/20 text-sky-700 dark:text-sky-200",
},
waiting: {
label: "WAITING",
cls: "bg-amber-500/20 text-amber-700 dark:text-amber-200",
},
pending: {
label: "PENDING",
cls: "bg-muted text-muted-foreground",
},
failed: {
label: "FAILED",
cls: "bg-rose-500/15 text-rose-700 dark:text-rose-300",
},
cancelled: {
label: "CANCELLED",
cls: "bg-muted text-muted-foreground",
},
};
const { label, cls } = map[status];
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-semibold tracking-wider",
cls,
)}
>
{status === "running" ? (
<Loader2Icon className="size-2.5 animate-spin" />
) : null}
{label}
</span>
);
}
const BOTTOM_PANEL_DEFAULT_HEIGHT = 288;
const BOTTOM_PANEL_MIN_HEIGHT = 120;
function BottomPanel({
logs,
history,
tab,
onTabChange,
}: {
logs: LogLine[];
history: MetricsHistory | null;
tab: "log" | "metrics";
onTabChange: (next: "log" | "metrics") => void;
}) {
const [height, setHeight] = useState(BOTTOM_PANEL_DEFAULT_HEIGHT);
const [isFullscreen, setIsFullscreen] = useState(false);
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
useEffect(() => {
if (!isFullscreen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsFullscreen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [isFullscreen]);
const onHandlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (isFullscreen) return;
e.currentTarget.setPointerCapture(e.pointerId);
dragRef.current = { startY: e.clientY, startH: height };
};
const onHandlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return;
const dy = dragRef.current.startY - e.clientY;
const max =
typeof window !== "undefined"
? Math.max(BOTTOM_PANEL_MIN_HEIGHT, window.innerHeight - 100)
: 800;
setHeight(
Math.max(
BOTTOM_PANEL_MIN_HEIGHT,
Math.min(max, dragRef.current.startH + dy),
),
);
};
const onHandlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return;
dragRef.current = null;
e.currentTarget.releasePointerCapture(e.pointerId);
};
return (
<div
className={cn(
"flex flex-col bg-zinc-950",
isFullscreen
? "absolute inset-0 z-50 border-t"
: "shrink-0 border-t",
)}
style={isFullscreen ? undefined : { height: `${height}px` }}
>
{!isFullscreen && (
<div
onPointerDown={onHandlePointerDown}
onPointerMove={onHandlePointerMove}
onPointerUp={onHandlePointerUp}
onPointerCancel={onHandlePointerUp}
className="group h-1.5 shrink-0 cursor-ns-resize bg-zinc-900 hover:bg-sky-500/40 active:bg-sky-500/60"
title="拖动调整高度"
>
<div className="mx-auto mt-[2px] h-[2px] w-10 rounded-full bg-zinc-700 group-hover:bg-sky-400" />
</div>
)}
<div className="flex min-h-0 flex-1 flex-col px-5 py-3">
<div className="mb-2 flex shrink-0 items-center justify-between">
<div className="inline-flex rounded-full bg-zinc-900/80 p-0.5">
<PillTab active={tab === "log"} onClick={() => onTabChange("log")}>
Live Log
</PillTab>
<PillTab
active={tab === "metrics"}
onClick={() => onTabChange("metrics")}
>
Metrics
</PillTab>
</div>
<button
type="button"
onClick={() => setIsFullscreen((v) => !v)}
className="inline-flex items-center gap-1 text-[11px] text-zinc-400 transition-colors hover:text-zinc-200"
title={isFullscreen ? "退出 (Esc)" : "查看全部"}
>
{isFullscreen ? (
<Minimize2Icon className="size-3" />
) : (
<Maximize2Icon className="size-3" />
)}
</button>
</div>
<div className="min-h-0 flex-1">
{tab === "log" ? (
<LogStreamBody logs={logs} />
) : (
<MetricsChartPanel history={history} />
)}
</div>
</div>
</div>
);
}
function PillTab({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"rounded-full px-3 py-1 font-semibold text-[11px] uppercase tracking-[0.12em] transition-colors",
active
? "bg-zinc-700 text-zinc-50"
: "text-zinc-400 hover:text-zinc-200",
)}
>
{children}
</button>
);
}
function LogStreamBody({ logs }: { logs: LogLine[] }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
}, [logs]);
return (
<div
ref={ref}
className="h-full min-h-0 overflow-y-auto font-mono text-[12px] text-zinc-100 leading-6"
>
{logs.length === 0 ? (
<div className="text-zinc-500"></div>
) : (
logs.map((line, idx) => (
<div key={idx} className="flex gap-3">
<span className="text-zinc-500">{line.ts}</span>
<span className="text-emerald-400">[iter={line.iter}]</span>
<span>{line.text}</span>
</div>
))
)}
</div>
);
}
function statusText(state: CardStatus) {
const map: Record<CardStatus, string> = {
running: "Running",
waiting: "等待人工确认",
complete: "Complete",
failed: "Failed",
pending: "Idle",
cancelled: "Cancelled",
};
return map[state];
}
+8 -4
View File
@@ -48,9 +48,11 @@ function SheetContent({
className, className,
children, children,
side = "right", side = "right",
hideClose = false,
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"; side?: "top" | "right" | "bottom" | "left";
hideClose?: boolean;
}) { }) {
return ( return (
<SheetPortal> <SheetPortal>
@@ -72,10 +74,12 @@ function SheetContent({
{...props} {...props}
> >
{children} {children}
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> {hideClose ? null : (
<XIcon className="size-4" /> <SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<span className="sr-only">Close</span> <XIcon className="size-4" />
</SheetPrimitive.Close> <span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
); );
+16
View File
@@ -87,3 +87,19 @@ function normalizeSessionId(value: unknown) {
const trimmed = value.trim(); const trimmed = value.trim();
return trimmed || null; return trimmed || null;
} }
// 记录"前端刚生成的 LOCALID"。AssistantWorkspace 的 draft cache effect 用它
// 区分:从 newTask 切到一个**新生成**的 LOCALID(草稿应继承)vs 切到一个
// **侧栏点击**的老 LOCALID(草稿不应继承)。
const freshLocalIds = new Set<string>();
export function markFreshLocalId(sessionId: string) {
freshLocalIds.add(sessionId);
}
export function consumeFreshLocalId(sessionId: string | null): boolean {
if (!sessionId) return false;
if (!freshLocalIds.has(sessionId)) return false;
freshLocalIds.delete(sessionId);
return true;
}
+54
View File
@@ -73,6 +73,60 @@ export async function loginAccount(username: string, password: string) {
return createSession(account); return createSession(account);
} }
const XIAOMI_EMAIL_RE = /^([\w.-]+)@xiaomi\.com$/i;
export function accountIdFromEmail(email: string): string {
const match = XIAOMI_EMAIL_RE.exec((email ?? "").trim());
if (!match) {
throw new Error("请使用小米邮箱(@xiaomi.com)登录");
}
const prefix = match[1].toLowerCase();
if (!/^[a-z0-9._-]{2,40}$/.test(prefix)) {
throw new Error(
"邮箱前缀只能包含小写字母、数字、点、下划线或中划线,长度 2-40",
);
}
return prefix;
}
export async function registerByEmail(email: string, password: string) {
const accountId = accountIdFromEmail(email);
validatePassword(password);
const usersFile = await readUsers();
if (usersFile.users.some((user) => user.id === accountId)) {
throw new Error("该邮箱已注册,请直接登录");
}
const account: UserRecord = {
id: accountId,
username: accountId,
passwordHash: hashPassword(password),
createdAt: new Date().toISOString(),
};
usersFile.users.push(account);
await ensureLinuxAccount(account.id, password);
await writeUsers(usersFile);
await createAccountDirectories(account.id);
return createSession(account);
}
export async function loginByEmail(email: string, password: string) {
const accountId = accountIdFromEmail(email);
const usersFile = await readUsers();
const account = usersFile.users.find((user) => user.id === accountId);
if (!account) {
throw new Error("账号不存在,请先注册");
}
if (!account.passwordHash) {
throw new Error("该账号尚未设置密码,请联系管理员重置");
}
if (!verifyPassword(password, account.passwordHash)) {
throw new Error("邮箱或密码错误");
}
await ensureLinuxAccount(account.id, password);
await createAccountDirectories(account.id);
return createSession(account);
}
export async function logoutAccount() { export async function logoutAccount() {
const cookieStore = await cookies(); const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value; const token = cookieStore.get(COOKIE_NAME)?.value;
+228
View File
@@ -0,0 +1,228 @@
"use client";
import { useCallback, useEffect, useState } from "react";
export const SESSION_UPDATED_EVENT = "claw-session-updated";
const APPLIED_STORAGE_PREFIX = "claw.training.applied:";
const MODEL_ITERATION_SKILL_NAMES = new Set(["model-iteration"]);
const SKILL_POLL_MS = 8000;
export function useModelIterationEnabled(): {
enabled: boolean;
loading: boolean;
} {
const [enabled, setEnabled] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
const tick = async () => {
try {
const res = await fetch("/api/claw/skills", { cache: "no-store" });
if (!res.ok) {
if (!cancelled) {
setEnabled(false);
setLoading(false);
}
return;
}
const skills = await res.json();
if (!Array.isArray(skills)) {
if (!cancelled) {
setEnabled(false);
setLoading(false);
}
return;
}
const isOn = skills.some(
(s: { name?: string; enabled?: boolean }) =>
MODEL_ITERATION_SKILL_NAMES.has(String(s?.name ?? "")) &&
s?.enabled !== false,
);
if (!cancelled) {
setEnabled(isOn);
setLoading(false);
}
} catch {
if (!cancelled) {
setEnabled(false);
setLoading(false);
}
}
};
tick();
const id = window.setInterval(tick, SKILL_POLL_MS);
const onFocus = () => tick();
window.addEventListener("focus", onFocus);
return () => {
cancelled = true;
window.clearInterval(id);
window.removeEventListener("focus", onFocus);
};
}, []);
return { enabled, loading };
}
// applied 状态用 localStorage(按 session id)持久化:
// - 关 tab / 浏览器重启都不丢
// - 刷新页面也保持
// 不走后端 PATCH 是为了避开 __LOCALID_* 临时 session id 导致 404
export function useAppliedTraining(sessionId: string | null) {
const [applied, setApplied] = useState(false);
useEffect(() => {
if (!sessionId || typeof window === "undefined") {
setApplied(false);
return;
}
const stored =
window.localStorage.getItem(APPLIED_STORAGE_PREFIX + sessionId) === "1";
setApplied(stored);
}, [sessionId]);
const setAppliedPersist = useCallback(
(next: boolean) => {
setApplied(next);
if (typeof window === "undefined") return;
if (!sessionId) return;
const key = APPLIED_STORAGE_PREFIX + sessionId;
if (next) {
window.localStorage.setItem(key, "1");
} else {
window.localStorage.removeItem(key);
}
},
[sessionId],
);
return { applied, setApplied: setAppliedPersist };
}
export type ProgramStatus = {
available: boolean;
path?: string;
last_modified_ms?: number | null;
error?: string;
};
const PROGRAM_STATUS_POLL_MS = 5000;
export function useProgramStatus(): ProgramStatus & { loading: boolean } {
const [status, setStatus] = useState<ProgramStatus>({ available: false });
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
const tick = async () => {
try {
const res = await fetch("/api/claw/training/program-status", {
cache: "no-store",
});
if (!res.ok) {
if (!cancelled) {
setStatus({ available: false });
setLoading(false);
}
return;
}
const payload = (await res.json()) as ProgramStatus;
if (!cancelled) {
setStatus(payload);
setLoading(false);
}
} catch {
if (!cancelled) {
setStatus({ available: false });
setLoading(false);
}
}
};
tick();
const id = window.setInterval(tick, PROGRAM_STATUS_POLL_MS);
const onFocus = () => tick();
window.addEventListener("focus", onFocus);
return () => {
cancelled = true;
window.clearInterval(id);
window.removeEventListener("focus", onFocus);
};
}, []);
return { ...status, loading };
}
type SessionUpdatedDetail = {
sessionId: string;
is_training?: boolean;
};
export function dispatchSessionUpdated(detail: SessionUpdatedDetail) {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent<SessionUpdatedDetail>(SESSION_UPDATED_EVENT, { detail }),
);
}
export function useTrainingMode(sessionId: string | null) {
const [isTraining, setIsTraining] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
setLoaded(false);
if (!sessionId) {
setIsTraining(false);
return;
}
let cancelled = false;
fetch(`/api/claw/sessions/${encodeURIComponent(sessionId)}`, {
cache: "no-store",
})
.then(async (res) => {
if (!res.ok) return null;
return res.json();
})
.then((payload) => {
if (cancelled) return;
const next = Boolean(payload?.is_training);
setIsTraining(next);
setLoaded(true);
})
.catch(() => {
if (!cancelled) setLoaded(true);
});
return () => {
cancelled = true;
};
}, [sessionId]);
const setTraining = useCallback(
async (next: boolean) => {
if (!sessionId) return;
const previous = isTraining;
setIsTraining(next);
try {
const res = await fetch(
`/api/claw/sessions/${encodeURIComponent(sessionId)}`,
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ is_training: next }),
},
);
if (!res.ok) {
setIsTraining(previous);
return;
}
dispatchSessionUpdated({ sessionId, is_training: next });
} catch {
setIsTraining(previous);
}
},
[sessionId, isTraining],
);
return { isTraining, setTraining, loaded };
}
+7741 -7320
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -30,6 +30,8 @@
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
@@ -39,6 +41,7 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.13", "@biomejs/biome": "^2.4.13",
"@tailwindcss/postcss": "^4.2.4", "@tailwindcss/postcss": "^4.2.4",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
-66
View File
@@ -1,66 +0,0 @@
# Skill: model-iteration
本目录是小爱中控模型迭代 skill,用于评测、错误分析、数据增强、SFT 训练和 CML workflow 追踪。
## 目录结构
```
model-iteration/
├── config.yaml # 评估阈值 + CML 工作流配置
├── knowledge/ # 分流知识库
│ ├── navigation_routing_rules.md # 地图导航 Agent/ComplexTask 分流
│ ├── travel_routing_rules.md # 旅游 Agent/ComplexTask 分流
│ └── multi_command_rules.md # 多指令 vs ComplexTask 判定
├── scripts/
│ ├── prepare_and_train_sft.py # 数据组装 + 本地训练
│ ├── submit_sft_via_cml.sh # CML 云端训练提交(R29 起统一用此)
│ └── sft_train_job.yaml.tpl # CML 训练 job YAML 模板
├── SKILL.md # 完整迭代协议(触发规则、Step 0-6、数据增强/清洗规范)
└── README.md # 本文件
```
## 快速上手
### 1. 评测当前模型
```bash
cml workflow run \
--workflow_id f-20260408161444-wu3pz --version v28 \
--global_inputs runDic=<N> \
--global_inputs model_path_new=<新模型路径> \
--global_inputs model_path_old=<基线模型路径>
```
### 2. SFT 训练(CML 云端)
```bash
AUTORESEARCH_ROOT=/mnt/wangsenhao/autoresearch-zk \
./scripts/submit_sft_via_cml.sh <RUNDIC> [PREV_RUNDIC]
```
训练完成后自动起评测 workflow。
### 3. SFT 训练(本地)
```bash
AUTORESEARCH_ROOT=/mnt/wangsenhao/autoresearch-zk \
python scripts/prepare_and_train_sft.py prepare --output_dir ./sft_data
AUTORESEARCH_ROOT=/mnt/wangsenhao/autoresearch-zk \
python scripts/prepare_and_train_sft.py train \
--data_dir ./sft_data \
--model_path /mnt/wangsenhao/verl_zk/Qwen3-4B-Instruct-2507 \
--model_type qwen3 \
--train_output ./sft_output \
--epochs 3 --lr 1e-5
```
## 关键规则
- **达标标准**:需求集合 ≥ 95%,大盘车载降幅 ≤ 0.3%specific test 降幅 ≤ 1%
- **数据清洗**:case 驱动、禁止批量、必须备份、只改 output 不动 instruction
- **训练环境**FSDP2 + torch 2.6 需要 accelerate==1.7.0,不要改训练代码去适配
## 完整协议
详见 [SKILL.md](SKILL.md)
File diff suppressed because it is too large Load Diff
@@ -9,5 +9,5 @@ thresholds:
# CML 评测工作流配置 # CML 评测工作流配置
cml_eval: cml_eval:
workflow_id: f-20260408161444-wu3pz workflow_id: f-20260408161444-wu3pz
version: v28 version: v32
@@ -0,0 +1,57 @@
# 多轮对话连贯性 —— ComplexTask 继承规则
## 核心判定
| 场景 | 输出 | 示例 |
|------|------|------|
| 上轮是 ComplexTask + 当前 query 和上轮**相关**(延续/细化/替换/挑选/追加) | 当前仍 `ComplexTask(tag="...")` | 上轮 CT 导航多目的地,当前"第二家导航过去" → CT |
| 上轮是 Agent + 当前 query 是独立新任务 | 按当前 query 独立分类 | 上轮 Agent 导航,当前"播放周杰伦" → Agent(音乐播放) |
| 上轮是 ComplexTask + 当前 query 是**无关**新任务 | 按当前独立分类 | 上轮 CT 旅游,当前"今天天气怎么样" → QA/WeatherQA |
## 判定要点
- **"相关"的定义**:当前 query 是对上轮任务的**延续、细化、替换、挑选、追加**中的任一类:
- 延续:用户在上轮的同一任务线上继续操作("继续导航"/"换一条不堵的"
- 细化:对上轮结果加筛选条件("人少一点的"/"评分最高的"/"最近的那个"
- 替换:换掉上轮的某个要素,同领域保持("不要 A 换成 B"
- 挑选:从上轮推荐的候选中选择("第一个"/"第二家"/"刚给的那个"
- 追加:在上轮任务基础上加步骤("路上再加个加油站"/"顺便找个 ATM"
- **prev 侧"隐式 CT 信号"R17777 补充)**:prev 即使只是一轮用户独白,只要含以下任一信号也视作 CT 场景:
- **单轮内自我修正/替换**:"导航到 A 哦不对 B"、"去 X 啊不 Y"、"到 A 嗯就是 B" —— 用户在说的过程中换目的地,系统需处理替换逻辑
- **候选选择犹豫**"是 A 还是 B"、"哪个近就哪个"、"算了不去 A 了"
- **多 POI 枚举/对比**"A 和 B 哪个近"、"先说 A 再说 B"
> 注:单 POI + 多重形容词筛选("最便宜的有车位的充电站")**不算** CT 信号,按 R1 归 Agent。
- **继承的是 `complex` 维度,不是 tag 内容**:tag 仍可能是 Agent 标签,但 complex=true,因为复合任务语境未结束
## 反例(不适用继承)
- 当前 query 明显切换意图:上轮 CT 导航 → 当前"打开空调"(tag=车载控制,无关)→ 独立分类
- 当前 query 是独立的闲聊 / 时间 / 天气问答:上轮 CT → 当前"现在几点" → 独立分类
- 上轮是 CT 但失败/取消("算了不要了"):后续 query 不再继承
## 为什么需要这条规则(经验来源)
R17775 迭代中,我们修改了训练集 47 条"纯路线偏好"样本 complex=true → false,对齐 0511 专项 gold60% → 76.67%)。但这产生副作用:
- `复杂导航线上真实_rag` -3.30pp11 条 gold=true 被过度修正为 false
- 如 "我要重新选一个路线"、"嗯经过凤雏路去江北虹悦城"
- `0511 专项` 新引入 6 条错全是**多轮延续 POI/挑选**类
- 如 "评分最高的那个导航过去"、"第二家导航过去"、"人少一点的"
根因:单看 query 这些表达像"纯偏好",但在多轮上下文中是**对上轮 ComplexTask 的挑选/细化**,应继承 CT。SFT 模型在只看当前 query 时判错。
## 应用场景
- **数据增强**:生成带 prev_session 的训练样本时,如果 prev 是 `ComplexTask(...)`,当前 query 与之相关 → ground_truth 必须是 ComplexTask(不能矛盾)
- **训练集清洗**:不要把"多轮延续同任务"类样本的 complex=true 改为 falseR17774 的 47 条改动里,L10752 / L10943 / L10954 等 multi_turn_deixis 已保留未改,正确)
- **Badcase 分析**:错例 query 如果语境是对上轮 CT 的延续,SFT 模型判错不能靠修改训练集同 pattern 样本的 label 来解,需要补"带 prev_session 的延续 CT"仿写增强模型学到上下文信号
- **Reward 函数**:对违反此规则的预测(prev=CT + 相关继续 但 pred complex=false)应被惩罚
## 反模式
- ❌ 只看当前 query 文本判 complex,忽略 prev_session
- ❌ 训练集批改 complex=true→false 时未检查 prev_session,导致多轮延续样本被误改
- ❌ 生成仿写时当前 query 与 prev_session 矛盾(prev CT 但 current 标 complex=false
@@ -4,8 +4,8 @@
| 场景 | 输出 | 示例 | | 场景 | 输出 | 示例 |
|------|------|------| |------|------|------|
| 规划路线,无筛选条件 | `Agent(tag="旅游出行")` | 规划一个从北京到上海的路线 | | 规划路线,无筛选条件 | `Agent(tag="旅游")` | 规划一个从北京到上海的路线 |
| 规划路线,有筛选条件 | `ComplexTask(tag="旅游出行")` | 规划一个从北京到上海的路线,人少风景好、三天两夜、3000预算 | | 规划路线,有筛选条件 | `ComplexTask(tag="旅游")` | 规划一个从北京到上海的路线,人少风景好、三天两夜、3000预算 |
## 判定要点 ## 判定要点
File diff suppressed because it is too large Load Diff
@@ -2,18 +2,16 @@
""" """
从 ai-planning 组装 SFT 训练/验证集,并启动 zk_trainer SFT 训练。 从 ai-planning 组装 SFT 训练/验证集,并启动 zk_trainer SFT 训练。
用法: 用法(先 cd "$AUTORESEARCH_CHAT_ROOT",所有产物落 chat 工作区):
# 仅组装数据 # 仅组装数据
AUTORESEARCH_ROOT=/mnt/wangsenhao/autoresearch-zk \ python scripts/prepare_and_train_sft.py prepare --output_dir "$AUTORESEARCH_CHAT_ROOT/sft_data"
python prepare_and_train_sft.py prepare --output_dir /mnt/wangsenhao/autoresearch-zk/sft_data
# 启动训练(需先 prepare) # 启动训练(需先 prepare)
AUTORESEARCH_ROOT=/mnt/wangsenhao/autoresearch-zk \ python scripts/prepare_and_train_sft.py train \
python prepare_and_train_sft.py train \ --data_dir "$AUTORESEARCH_CHAT_ROOT/sft_data" \
--data_dir /mnt/wangsenhao/autoresearch-zk/sft_data \
--model_path /mnt/wangsenhao/verl_zk/Qwen3-4B-Instruct-2507 \ --model_path /mnt/wangsenhao/verl_zk/Qwen3-4B-Instruct-2507 \
--model_type qwen3 \ --model_type qwen3 \
--train_output /mnt/wangsenhao/autoresearch-zk/sft_output --train_output "$AUTORESEARCH_CHAT_ROOT/sft_output"
""" """
import argparse import argparse
@@ -28,14 +26,8 @@ from pathlib import Path
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
CHAT_ROOT = Path(__file__).resolve().parent.parent
def _path_from_env(name: str, fallback: Path) -> Path: AI_PLANNING = CHAT_ROOT / "ai-planning"
return Path(os.environ.get(name, str(fallback))).expanduser().resolve()
AUTORESEARCH_ROOT = _path_from_env("AUTORESEARCH_ROOT", Path("/mnt/wangsenhao/autoresearch-zk"))
AI_PLANNING = _path_from_env("AI_PLANNING_DIR", AUTORESEARCH_ROOT / "ai-planning")
ZK_TRAINER_DIR = _path_from_env("ZK_TRAINER_DIR", AUTORESEARCH_ROOT / "zk_trainer")
TRAIN_SET_DIR = AI_PLANNING / "data" / "train_set" TRAIN_SET_DIR = AI_PLANNING / "data" / "train_set"
DATA_TRAIN_DIR = AI_PLANNING / "data_train" DATA_TRAIN_DIR = AI_PLANNING / "data_train"
GENERATE_TRAIN_SCRIPT = DATA_TRAIN_DIR / "generate_train.py" GENERATE_TRAIN_SCRIPT = DATA_TRAIN_DIR / "generate_train.py"
@@ -157,7 +149,7 @@ def run_sft_training(data_dir: str, model_path: str, model_type: str, train_outp
epochs: int = 3, lr: float = 1e-5, max_seq_length: int = 1024, epochs: int = 3, lr: float = 1e-5, max_seq_length: int = 1024,
per_device_batch: int = 1, grad_accum: int = 1): per_device_batch: int = 1, grad_accum: int = 1):
"""基于 zk_trainer 的 accelerate launch 方式启动 SFT 训练。""" """基于 zk_trainer 的 accelerate launch 方式启动 SFT 训练。"""
zk_trainer_dir = str(ZK_TRAINER_DIR) zk_trainer_dir = str(CHAT_ROOT / "zk_trainer")
if not os.path.isdir(zk_trainer_dir): if not os.path.isdir(zk_trainer_dir):
log.error(f"zk_trainer 目录不存在: {zk_trainer_dir}") log.error(f"zk_trainer 目录不存在: {zk_trainer_dir}")
return None return None
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# resolve_run_ids.sh —— SFT/EVAL runDic 单一可信来源
#
# 规则(与 program.md §746 + §5.2 对齐):
# SFT_RUNDIC = workflow5/ 下「已落盘」(含 metric_diff/lark_template.json)的最大 runDic
# 即 R{n-1}.runDic —— augment 落盘、SFT yaml、modified_samples 归档全用这个值
# EVAL_RUNDIC = SFT_RUNDIC + 1
# 仅 cml workflow run 提交本轮评测时使用
#
# 用法:
# eval "$(./scripts/resolve_run_ids.sh)"
# echo "$SFT_RUNDIC $EVAL_RUNDIC"
#
# 失败行为:找不到任何已落盘 workflow 时打 ERR 到 stderr 并 exit 1,调用方 set -e 时直接终止。
set -euo pipefail
WF_ROOT=${WF_ROOT:-/mnt/xiaoai-zk-model-train-tj5/workflow5}
if [ ! -d "$WF_ROOT" ]; then
echo "ERR: WF_ROOT=$WF_ROOT 不存在或不可读" >&2
exit 1
fi
# 只挑「已落盘」的 workflow:必须存在 metric_diff/lark_template.json
# 这样可以避免 CML 创建了空目录就被算成最大值
MAX=$(
for d in "$WF_ROOT"/workflow*; do
[ -d "$d" ] || continue
[ -f "$d/metric_diff/lark_template.json" ] || continue
name=$(basename "$d")
echo "${name#workflow}"
done | sort -n | tail -1
)
if [ -z "$MAX" ]; then
echo "ERR: $WF_ROOT 下没有任何已落盘的 workflow(含 metric_diff/lark_template.json" >&2
exit 1
fi
echo "SFT_RUNDIC=$MAX"
echo "EVAL_RUNDIC=$((MAX+1))"
+39 -19
View File
@@ -1,17 +1,35 @@
#!/bin/bash #!/bin/bash
# 用 cml custom_train submit 提交 SFT 训练,训练完成后自动起 cml workflow run 评测 # 用 cml custom_train submit 提交 SFT 训练,训练完成后自动起 cml workflow run 评测
# 用法: ./submit_sft_via_cml.sh <RUNDIC> #
# 例: ./submit_sft_via_cml.sh 17756 # 用法:
# ./submit_sft_via_cml.sh <SFT_RUNDIC> <EVAL_RUNDIC> [PREV_RUNDIC]
#
# 推荐调用方式(runDic 由 resolve_run_ids.sh 决定,不要手敲):
# eval "$(./scripts/resolve_run_ids.sh)"
# ./scripts/submit_sft_via_cml.sh "$SFT_RUNDIC" "$EVAL_RUNDIC"
#
# 语义(与 program.md §746 + §5.2 对齐):
# SFT_RUNDIC = R{n-1}.runDicaugment / yaml / 归档全用这个,不 +1)
# EVAL_RUNDIC = R{n-1}.runDic + 1(仅本步起 eval 时使用,仅在这一处 +1)
# PREV_RUNDIC = SFT_RUNDIC - 1(默认;只用作上一轮 sft_output 改名)
set -euo pipefail set -euo pipefail
RUNDIC=${1:?usage: $0 <RUNDIC>} SFT_RUNDIC=${1:?usage: $0 <SFT_RUNDIC> <EVAL_RUNDIC> [PREV_RUNDIC]}
PREV_RUNDIC=${2:-$((RUNDIC-1))} EVAL_RUNDIC=${2:?usage: $0 <SFT_RUNDIC> <EVAL_RUNDIC> [PREV_RUNDIC]}
PREV_RUNDIC=${3:-$((SFT_RUNDIC-1))}
# 防御:EVAL_RUNDIC 必须严格大于 SFT_RUNDIC,否则一定是调用方算错
if [ "$EVAL_RUNDIC" -le "$SFT_RUNDIC" ]; then
echo "[cml-sft] ❌ EVAL_RUNDIC ($EVAL_RUNDIC) 必须 > SFT_RUNDIC ($SFT_RUNDIC)" >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT=${AUTORESEARCH_ROOT:-/mnt/wangsenhao/autoresearch-zk} ROOT=${AUTORESEARCH_ROOT:-/mnt/wangsenhao/autoresearch-zk}
MODEL_OLD=${MODEL_OLD:-/mnt/wangsenhao/verl_zk/qwen4b_cispo_wokl_add_bvt_2/global_step_5/actor/huggingface} MODEL_OLD=${MODEL_OLD:-/mnt/wangsenhao/verl_zk/qwen4b_cispo_wokl_add_bvt_2/global_step_5/actor/huggingface}
TPL=${SFT_TRAIN_JOB_TEMPLATE:-$SCRIPT_DIR/sft_train_job.yaml.tpl} TPL=${SFT_TRAIN_JOB_TEMPLATE:-$SCRIPT_DIR/sft_train_job.yaml.tpl}
YAML=/tmp/sft_train_job_r${RUNDIC}.yaml YAML=/tmp/sft_train_job_r${SFT_RUNDIC}.yaml
if [ -f ~/.cloudml-cli/.profile ]; then if [ -f ~/.cloudml-cli/.profile ]; then
source ~/.cloudml-cli/.profile source ~/.cloudml-cli/.profile
@@ -20,13 +38,13 @@ else
exit 1 exit 1
fi fi
# 1. 渲染 yaml 模板 # 1. 渲染 yaml 模板(用 SFT_RUNDIC,不是 EVAL_RUNDIC
sed \ sed \
-e "s|{RUNDIC}|${RUNDIC}|g" \ -e "s|{RUNDIC}|${SFT_RUNDIC}|g" \
-e "s|{PREV_RUNDIC}|${PREV_RUNDIC}|g" \ -e "s|{PREV_RUNDIC}|${PREV_RUNDIC}|g" \
-e "s|{AUTORESEARCH_ROOT}|${ROOT}|g" \ -e "s|{AUTORESEARCH_ROOT}|${ROOT}|g" \
"$TPL" > "$YAML" "$TPL" > "$YAML"
echo "[cml-sft] yaml: $YAML" echo "[cml-sft] yaml: $YAML (SFT_RUNDIC=$SFT_RUNDIC EVAL_RUNDIC=$EVAL_RUNDIC PREV_RUNDIC=$PREV_RUNDIC)"
# 2. 提交训练任务 # 2. 提交训练任务
SUBMIT_OUT=$(cml custom_train submit --filename "$YAML" 2>&1) SUBMIT_OUT=$(cml custom_train submit --filename "$YAML" 2>&1)
@@ -38,13 +56,14 @@ if [ -z "$JOB_ID" ]; then
fi fi
echo "[cml-sft] ✅ JobID: $JOB_ID" echo "[cml-sft] ✅ JobID: $JOB_ID"
# 3. 后台 watcher:等训练成功 → 自动起评测 # 3. 后台 watcher:等训练成功 → 自动起评测(用 EVAL_RUNDIC
WATCHER_LOG=/tmp/r${RUNDIC}_logs/cml_watcher.log WATCHER_LOG=/tmp/r${SFT_RUNDIC}_logs/cml_watcher.log
mkdir -p "$(dirname "$WATCHER_LOG")" mkdir -p "$(dirname "$WATCHER_LOG")"
nohup bash -c ' nohup bash -c '
JOB_ID='"$JOB_ID"' JOB_ID='"$JOB_ID"'
RUNDIC='"$RUNDIC"' SFT_RUNDIC='"$SFT_RUNDIC"'
EVAL_RUNDIC='"$EVAL_RUNDIC"'
LOG='"$WATCHER_LOG"' LOG='"$WATCHER_LOG"'
ROOT='"$ROOT"' ROOT='"$ROOT"'
MODEL_NEW="$ROOT/sft_output" MODEL_NEW="$ROOT/sft_output"
@@ -74,15 +93,15 @@ done
echo "[$(date)] ❌ 产出缺失" >> "$LOG"; exit 1 echo "[$(date)] ❌ 产出缺失" >> "$LOG"; exit 1
} }
# 起评测 # 起评测(这里且仅这里用 EVAL_RUNDIC
echo "[$(date)] 启动 CML 评测 runDic=$RUNDIC" >> "$LOG" echo "[$(date)] 启动 CML 评测 EVAL_RUNDIC=$EVAL_RUNDIC" >> "$LOG"
cml workflow run \ cml workflow run \
--workflow_id $EVAL_WORKFLOW_ID --version $EVAL_VERSION \ --workflow_id $EVAL_WORKFLOW_ID --version $EVAL_VERSION \
--global_inputs runDic=$RUNDIC \ --global_inputs runDic=$EVAL_RUNDIC \
--global_inputs model_path_new=$MODEL_NEW \ --global_inputs model_path_new=$MODEL_NEW \
--global_inputs model_path_old=$MODEL_OLD >> "$LOG" 2>&1 --global_inputs model_path_old=$MODEL_OLD >> "$LOG" 2>&1
echo "[$(date)] cml workflow run 提交完成" >> "$LOG" echo "[$(date)] cml workflow run 提交完成 (workflow$EVAL_RUNDIC)" >> "$LOG"
' > /tmp/r${RUNDIC}_logs/watcher_runner.log 2>&1 & ' > /tmp/r${SFT_RUNDIC}_logs/watcher_runner.log 2>&1 &
WATCHER_PID=$! WATCHER_PID=$!
echo "[cml-sft] watcher PID: $WATCHER_PIDlog: $WATCHER_LOG" echo "[cml-sft] watcher PID: $WATCHER_PIDlog: $WATCHER_LOG"
@@ -97,8 +116,9 @@ cat <<EOF
watcher log: tail -f $WATCHER_LOG watcher log: tail -f $WATCHER_LOG
训练完成后,evaluation workflow 会自动启动;评测产物在 训练完成后,evaluation workflow 会自动启动;评测产物在
/mnt/xiaoai-zk-model-train-tj5/workflow5/workflow${RUNDIC}/ /mnt/xiaoai-zk-model-train-tj5/workflow5/workflow${EVAL_RUNDIC}/
JobID: $JOB_ID JobID: $JOB_ID
RUNDIC: $RUNDIC SFT_RUNDIC: $SFT_RUNDIC (yaml / sft_output 命名)
EVAL_RUNDIC: $EVAL_RUNDIC (workflow 评测目录)
EOF EOF
+5 -2
View File
@@ -162,8 +162,8 @@ def get_doing_tasks_section() -> str:
'不要为了单次操作创建 helper 或抽象。优先使用能完整解决问题的最简单实现。', '不要为了单次操作创建 helper 或抽象。优先使用能完整解决问题的最简单实现。',
'除非确实需要新文件,否则优先编辑现有文件。', '除非确实需要新文件,否则优先编辑现有文件。',
'对于文件解析、表格转换、JSON 或 JSONL 处理、日志分析、批量校验、数据抽样等任务,如果小型 Python 脚本比手工文本处理更可靠,可以编写小型 Python 脚本。', '对于文件解析、表格转换、JSON 或 JSONL 处理、日志分析、批量校验、数据抽样等任务,如果小型 Python 脚本比手工文本处理更可靠,可以编写小型 Python 脚本。',
'小脚本应短小、可读,并明确输入和输出。需要执行 Python 代码或 Python 脚本时,使用 python_exec;不要通过 bash 执行 python、python3 或 .venv/bin/python', '小脚本应短小、可读,并明确输入和输出。需要执行 Python 时,把代码写到 session/scratchpad 下的 .py 文件,再用 bash 跑 `python3 -u <script> 2>&1 | tee <log>``-u` 关 stdout 缓冲、`tee` 让进度行实时落盘,超时被 kill 时仍能 tail 日志看到死在哪。不要把多行 Python 代码以 `-c` 字符串方式塞进 bash command',
'一次性 Python 分析优先直接传给 python_exec.code,不要为了临时分析创建项目文件', '一次性 Python 分析也走脚本文件 + bash,不要为了临时分析在项目根目录创建脚本,scratchpad 是默认临时区',
'如果确实需要临时脚本、缓存或中间产物,必须写入当前 session/scratchpad;交付产物必须优先写入当前 session/output。', '如果确实需要临时脚本、缓存或中间产物,必须写入当前 session/scratchpad;交付产物必须优先写入当前 session/output。',
'只有用户明确要求长期复用或该脚本属于产品代码时,才把 Python 脚本加入项目源码目录。', '只有用户明确要求长期复用或该脚本属于产品代码时,才把 Python 脚本加入项目源码目录。',
'当事情失败时,先诊断原因再改变方向。不要对同一个失败动作反复循环。', '当事情失败时,先诊断原因再改变方向。不要对同一个失败动作反复循环。',
@@ -249,6 +249,9 @@ def get_using_your_tools_section(enabled_tool_names: set[str]) -> str:
items.append( items.append(
'需要可靠保存文件时,可以在 python_exec.code 中使用 pathlib.Path(...).write_text(...)、json.dump/json.dumps、csv.writer 或流式逐行写入;这比把大段内容塞进 write_file 参数更稳定。' '需要可靠保存文件时,可以在 python_exec.code 中使用 pathlib.Path(...).write_text(...)、json.dump/json.dumps、csv.writer 或流式逐行写入;这比把大段内容塞进 write_file 参数更稳定。'
) )
items.append(
'python_exec 默认 timeout=30s。读 CSV/JSONL 大文件、pandas 处理、批量校验、训练评测脚本等可能 >30s 的任务,调用时显式传 timeout_seconds(常规 120-300、训练/长跑 600+),不要靠默认值。被 timeout(1) kill 后会以 timed_out=true 报错并提示同样的内容,看到就重跑并调大。'
)
if 'python_package' in enabled_tool_names: if 'python_package' in enabled_tool_names:
items.append( items.append(
'当 python_exec 因缺少 pandas、pyarrow、openpyxl 等 Python 包失败时,使用 python_package 在当前用户独立 venv 中安装缺失包,然后重试;不要安装到系统 Python 或项目 .venv。' '当 python_exec 因缺少 pandas、pyarrow、openpyxl 等 Python 包失败时,使用 python_package 在当前用户独立 venv 中安装缺失包,然后重试;不要安装到系统 Python 或项目 .venv。'
+3 -3
View File
@@ -1620,9 +1620,9 @@ class LocalCodingAgent:
'请回复“继续”,我会要求模型重新生成合法 JSON 参数;' '请回复“继续”,我会要求模型重新生成合法 JSON 参数;'
'如果是 write_file 写短文本/Markdown,应改用 content_lines' '如果是 write_file 写短文本/Markdown,应改用 content_lines'
'如果是很小的 JSON/JSONL/CSV,可用 json_content、jsonl_records 或 csv_rows' '如果是很小的 JSON/JSONL/CSV,可用 json_content、jsonl_records 或 csv_rows'
'如果是脚本、长文本、大 JSON、JSONL、CSV 或生成数据,应直接改用 python_exec 写文件' '如果是脚本、长文本、大 JSON、JSONL、CSV 或生成数据,应改用 bash 配合 quoted heredoc 写文件'
'如果是生成/转换结构化数据,应优先调用对应 skill 脚本或 python_exec' '或先 write_file 落地一个小 .py 再用 bash 跑;'
'不要把大段内容直接塞进工具参数bash 仅作为最后兜底' '不要把大段内容直接塞进工具参数。'
) from exc ) from exc
if not isinstance(arguments, dict): if not isinstance(arguments, dict):
raise OpenAICompatError( raise OpenAICompatError(
+7
View File
@@ -10,6 +10,7 @@ from .agent_types import AgentPermissions, AgentRuntimeConfig, ToolExecutionResu
if TYPE_CHECKING: if TYPE_CHECKING:
from .account_runtime import AccountRuntime from .account_runtime import AccountRuntime
from .ask_user_runtime import AskUserRuntime from .ask_user_runtime import AskUserRuntime
from .bash_bg_store import BgTaskSpec, BgTaskStatus
from .config_runtime import ConfigRuntime from .config_runtime import ConfigRuntime
from .lsp_runtime import LSPRuntime from .lsp_runtime import LSPRuntime
from .mcp_runtime import MCPRuntime from .mcp_runtime import MCPRuntime
@@ -59,6 +60,12 @@ class ToolExecutionContext:
team_runtime: 'TeamRuntime | None' = None team_runtime: 'TeamRuntime | None' = None
workflow_runtime: 'WorkflowRuntime | None' = None workflow_runtime: 'WorkflowRuntime | None' = None
worktree_runtime: 'WorktreeRuntime | None' = None worktree_runtime: 'WorktreeRuntime | None' = None
bg_register: Callable[['BgTaskSpec'], None] | None = None
bg_status_query: Callable[[str], 'BgTaskStatus | None'] | None = None
bg_kill_request: Callable[[str], bool] | None = None
account_id: str | None = None
session_id: str | None = None
run_id: str | None = None
ToolHandler = Callable[ ToolHandler = Callable[
+75 -49
View File
@@ -10,51 +10,6 @@ def build_execution_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool
"""构建本地命令、Python 执行与短等待工具声明。""" """构建本地命令、Python 执行与短等待工具声明。"""
return [ return [
AgentTool(
name='python_exec',
description=(
'优先用本工具执行小型 Python 代码或项目内 Python 脚本,用于结构化文件分析、'
'JSON/JSONL 处理、批量校验、数据抽样和快速计算。默认使用当前用户独立 Python venv'
'不要用 bash 运行 python/python3/.venv/bin/python。不要用本工具安装依赖。'
'缺包时应向用户确认后再处理依赖。一次性分析优先传 code;项目或 skill 内已有脚本优先传 script_path'
'不要在 code 里用 subprocess 二次调用 Python 脚本。如需写临时文件,'
'必须写入环境变量 PYTHON_EXEC_SCRATCHPAD 指向的会话隔离目录,'
'不要在项目根目录创建临时 .py 脚本。'
),
parameters={
'type': 'object',
'properties': {
'code': {
'type': 'string',
'description': '要通过 python -c 执行的 Python 代码。code 和 script_path 必须二选一;一次性分析优先使用 code。',
},
'script_path': {
'type': 'string',
'description': '工作区内已有、需要长期复用的 Python 脚本路径。code 和 script_path 必须二选一;不要为一次性分析在项目根目录新建脚本。',
},
'args': {
'type': 'array',
'items': {'type': 'string'},
'description': '传给脚本的命令行参数。仅在 script_path 模式下使用。',
},
'stdin': {
'type': 'string',
'description': '可选标准输入内容。',
},
'timeout_seconds': {
'type': 'number',
'minimum': 1,
'description': '可选超时时间,默认使用当前会话命令超时。',
},
'max_output_chars': {
'type': 'integer',
'minimum': 100,
'description': '可选输出截断长度,默认使用当前会话输出限制。',
},
},
},
handler=resolve_handler(handlers, 'python_exec', 'execution'),
),
AgentTool( AgentTool(
name='python_package', name='python_package',
description=( description=(
@@ -94,20 +49,91 @@ def build_execution_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool
AgentTool( AgentTool(
name='bash', name='bash',
description=( description=(
'运行真实 shell 命令,例如系统命令、进程控制、git 只读检查用户已确认的依赖安装' '运行真实 shell 命令,包括系统命令、进程控制、git 只读检查用户已确认的依赖安装'
'不要用 bash 执行 python、python3、pip 或 .venv/bin/python;结构化文件分析、' '以及所有 Python 脚本执行。'
'JSON/JSONL 处理、批量校验、数据抽样和快速计算必须优先使用 python_exec。' '执行 Python 时,把代码先写到 scratchpad 下的 .py 文件,'
'Python 包安装必须优先使用 python_package。' '再用本工具跑 `python3 -u <script> 2>&1 | tee <log>`'
'`-u` 关掉 stdout 缓冲,`tee` 让进度行实时落盘,超时被 kill 时仍能 `tail` 日志看到死在哪个 phase。'
'不要把多行 Python 代码以 `-c` 字符串方式塞进 command。'
'Python 包安装必须优先使用 python_package;不要通过 bash 执行 pip。'
'需要等待远端长任务(>30s 的训练、文件落盘、外部 API 回调)时,'
'设置 run_in_background=true 让命令在后台 detach 跑、立刻返回 task_id'
'默认 wait_for_completion=true 时进程结束后会自动起新一轮把结果送回。'
'后台任务输出落在 <remote_workspace>/.bg_tasks/<task_id>/output。'
), ),
parameters={ parameters={
'type': 'object', 'type': 'object',
'properties': { 'properties': {
'command': {'type': 'string'}, 'command': {'type': 'string'},
'run_in_background': {
'type': 'boolean',
'description': (
'后台 detach 跑。立刻返回 task_id,不等命令结束。'
'适合 >30s 的远端任务、需要等异步事件的场景。'
),
},
'wait_for_completion': {
'type': 'boolean',
'description': (
'仅在 run_in_background=true 时生效。'
'true(默认):进程结束后由后端自动起新一轮把结果作为 system 消息送回;'
'false:不自动续,agent 需自行调 bash_status 取结果。'
),
},
}, },
'required': ['command'], 'required': ['command'],
}, },
handler=resolve_handler(handlers, 'bash', 'execution'), handler=resolve_handler(handlers, 'bash', 'execution'),
), ),
AgentTool(
name='bash_status',
description=(
'查询通过 bash(run_in_background=true) 启动的后台任务状态。'
'block=true 会阻塞到任务终止或 timeout_seconds 到期再返回;'
'block=false(默认)立刻返回当前快照。'
'返回 status (running/completed/failed/cancelled)、exit_code 和最近输出预览。'
),
parameters={
'type': 'object',
'properties': {
'task_id': {
'type': 'string',
'description': 'bash run_in_background 启动时返回的 task_id。',
},
'block': {
'type': 'boolean',
'description': '是否等到任务终止再返回;默认 false。',
},
'timeout_seconds': {
'type': 'number',
'minimum': 0,
'maximum': 600,
'description': 'block=true 时最长等待秒数,默认 30,上限 600。',
},
},
'required': ['task_id'],
},
handler=resolve_handler(handlers, 'bash_status', 'execution'),
),
AgentTool(
name='bash_kill',
description=(
'主动终止通过 bash(run_in_background=true) 启动的后台任务。'
'注意:用户在前端按 stop 取消当前 run 不会自动杀后台进程(detach 的本意),'
'需要明确调用本工具才能终止远端进程。'
),
parameters={
'type': 'object',
'properties': {
'task_id': {
'type': 'string',
'description': 'bash run_in_background 启动时返回的 task_id。',
},
},
'required': ['task_id'],
},
handler=resolve_handler(handlers, 'bash_kill', 'execution'),
),
AgentTool( AgentTool(
name='sleep', name='sleep',
description='Pause execution briefly for bounded local wait flows.', description='Pause execution briefly for bounded local wait flows.',
+5 -5
View File
@@ -41,8 +41,8 @@ def build_file_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]:
description=( description=(
'Write or append a SMALL UTF-8 file inside the workspace. Creates parent directories when needed. ' 'Write or append a SMALL UTF-8 file inside the workspace. Creates parent directories when needed. '
'Use this for short notes, lightweight markdown, and small config files. ' 'Use this for short notes, lightweight markdown, and small config files. '
'For Python scripts, JSONL, large JSON/CSV, generated datasets, or quote-heavy/multi-line content, prefer python_exec ' 'For Python scripts, JSONL, large JSON/CSV, generated datasets, or quote-heavy/multi-line content, prefer bash with a quoted heredoc '
'to write the file with pathlib/json/csv; do not pack large content into tool arguments.' "(e.g. `cat > scratchpad/foo.py <<'EOF' ... EOF`); do not pack large content into tool arguments."
), ),
parameters={ parameters={
'type': 'object', 'type': 'object',
@@ -51,7 +51,7 @@ def build_file_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]:
'content_lines': { 'content_lines': {
'type': 'array', 'type': 'array',
'items': {'type': 'string'}, 'items': {'type': 'string'},
'description': 'Lines to join with "\\n"; use only for short text/markdown or small scripts. For long scripts use python_exec to create the file.', 'description': 'Lines to join with "\\n"; use only for short text/markdown or small scripts. For long scripts use bash with a quoted heredoc to create the file.',
}, },
'content': { 'content': {
'type': 'string', 'type': 'string',
@@ -63,12 +63,12 @@ def build_file_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]:
}, },
'json_content': { 'json_content': {
'type': 'object', 'type': 'object',
'description': 'Small JSON value to serialize with ensure_ascii=false and indent=2. For large JSON use python_exec streaming write.', 'description': 'Small JSON value to serialize with ensure_ascii=false and indent=2. For large JSON write a small Python script to scratchpad and run it via bash.',
}, },
'jsonl_records': { 'jsonl_records': {
'type': 'array', 'type': 'array',
'items': {'type': 'object'}, 'items': {'type': 'object'},
'description': 'Small list of objects to serialize as compact JSONL. For more than a few dozen records or generated datasets use python_exec.', 'description': 'Small list of objects to serialize as compact JSONL. For more than a few dozen records or generated datasets, write a Python script to scratchpad and run it via bash.',
}, },
'csv_headers': { 'csv_headers': {
'type': 'array', 'type': 'array',
+321 -40
View File
@@ -8,7 +8,9 @@ import json
import os import os
import pwd import pwd
import re import re
import secrets
import selectors import selectors
import shlex
import shutil import shutil
import signal import signal
import subprocess import subprocess
@@ -33,6 +35,7 @@ from .agent_tool_specs.data_agent import build_data_agent_tools
from .agent_tool_specs.execution import build_execution_tools from .agent_tool_specs.execution import build_execution_tools
from .agent_tool_specs.files import build_file_tools from .agent_tool_specs.files import build_file_tools
from .agent_types import DEFAULT_MAX_TURNS, ToolExecutionResult from .agent_types import DEFAULT_MAX_TURNS, ToolExecutionResult
from .bash_bg_store import BgTaskSpec, BgTaskStatus
from .data_agent_records import ( from .data_agent_records import (
DataRecordError, DataRecordError,
confirm_generation_goal, confirm_generation_goal,
@@ -115,9 +118,10 @@ def default_tool_registry() -> dict[str, AgentTool]:
'grep_search': _grep_search, 'grep_search': _grep_search,
}), }),
*build_execution_tools({ *build_execution_tools({
'python_exec': _run_python_exec,
'python_package': _run_python_package, 'python_package': _run_python_package,
'bash': _run_bash, 'bash': _run_bash,
'bash_status': _run_bash_status,
'bash_kill': _run_bash_kill,
'sleep': _sleep, 'sleep': _sleep,
}), }),
AgentTool( AgentTool(
@@ -1763,46 +1767,29 @@ def _ensure_shell_allowed(command: str, context: ToolExecutionContext) -> None:
'Shell command appears to write platform code. ' 'Shell command appears to write platform code. '
'Platform code directories are read-only in data-agent sessions.' 'Platform code directories are read-only in data-agent sessions.'
) )
if context.permissions.allow_destructive_shell_commands: return
return
destructive_patterns = [
r'(^|[;&|])\s*rm\s',
r'(^|[;&|])\s*mv\s',
r'(^|[;&|])\s*dd\s',
r'(^|[;&|])\s*shutdown\s',
r'(^|[;&|])\s*reboot\s',
r'(^|[;&|])\s*mkfs',
r'(^|[;&|])\s*chmod\s+-r\s+777',
r'(^|[;&|])\s*chown\s+-r',
r'(^|[;&|])\s*git\s+reset\s+--hard',
r'(^|[;&|])\s*git\s+clean\s+-fd',
r'(^|[;&|])\s*:\s*>\s*',
]
lowered = command.lower()
if any(re.search(pattern, lowered) for pattern in destructive_patterns):
raise ToolPermissionError(
'Potentially destructive shell command blocked. Re-run with --unsafe to allow it.'
)
def _looks_like_platform_code_shell_write( def _looks_like_platform_code_shell_write(
command: str, command: str,
context: ToolExecutionContext, context: ToolExecutionContext,
) -> bool: ) -> bool:
if context.jupyter_runtime is not None:
return False
if not _is_platform_app_root(context.root): if not _is_platform_app_root(context.root):
return False return False
lowered = command.lower() lowered = command.lower()
write_marker = re.search( write_marker = re.search(
r'>|(^|[\s;&|])(tee|touch|python|python3|sed\s+-i|perl\s+-pi)\b', r'(?<![0-9&])>(?!&)|(^|[\s;&|])(tee|touch|python|python3|sed\s+-i|perl\s+-pi)\b',
lowered, lowered,
) )
if write_marker is None: if write_marker is None:
return False return False
root_prefix = context.root.as_posix().lower()
platform_fragments = [ platform_fragments = [
f'{name}/' for name in _PLATFORM_READONLY_DIRS f'{root_prefix}/{name}/' for name in _PLATFORM_READONLY_DIRS
] + [ ] + [
f'{context.root.as_posix().lower()}/{name}/' f'./{name}/' for name in _PLATFORM_READONLY_DIRS
for name in _PLATFORM_READONLY_DIRS
] ]
return any(fragment in lowered for fragment in platform_fragments) return any(fragment in lowered for fragment in platform_fragments)
@@ -2355,6 +2342,26 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
] ]
if result.timed_out: if result.timed_out:
payload.insert(0, f'timed_out=true\ntimeout_seconds={timeout_seconds:g}') payload.insert(0, f'timed_out=true\ntimeout_seconds={timeout_seconds:g}')
# 被 timeout(1) 砍掉时 ok=True 会让 agent 误以为脚本跑完了;翻成 ok=False,
# 错误消息里直接给出建议(调大 timeout、拆分操作、流式读 CSV 等)。
raise ToolExecutionError(
_truncate_output(
'\n'.join(
[
*payload,
(
f'[hint] python_exec 在 {timeout_seconds:g}s 被 timeout(1) kill,没有跑完。\n'
'首选做法:把 timeout_seconds 调大(数据分析类任务起步给 300,处理大 CSV/JSONL 给 600+)。\n'
'不要把脚本拆成多个更小的 python_exec 调用——每次调用都要重新 import、重读数据、'
'重跑一遍带完整对话上下文的远端 round-trip,拆得越碎总耗时越长。'
'把多步分析(解析、聚合、统计、写出)合并到一个脚本里才是对的。\n'
'只有当数据本身远超内存或单次跑要 1h+ 时,才考虑 pandas chunksize / 流式过滤先把数据降量。'
),
]
).strip(),
max_output_chars,
)
)
return ( return (
_truncate_output('\n'.join(payload).strip(), max_output_chars), _truncate_output('\n'.join(payload).strip(), max_output_chars),
{ {
@@ -2426,21 +2433,17 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
stdout.rstrip(), stdout.rstrip(),
'[stderr]', '[stderr]',
stderr.rstrip(), stderr.rstrip(),
(
f'[hint] python_exec 在 {timeout_seconds:g}s 被 kill,没有跑完。\n'
'首选做法:把 timeout_seconds 调大(数据分析类任务起步给 300,处理大 CSV/JSONL 给 600+)。\n'
'不要把脚本拆成多个更小的 python_exec 调用——每次调用都要重新 import、重读数据、'
'重跑一遍带完整对话上下文的远端 round-trip,拆得越碎总耗时越长。'
'把多步分析(解析、聚合、统计、写出)合并到一个脚本里才是对的。\n'
'只有当数据本身远超内存或单次跑要 1h+ 时,才考虑 pandas chunksize / 流式过滤先把数据降量。'
),
] ]
return ( raise ToolExecutionError(
_truncate_output('\n'.join(payload).strip(), max_output_chars), _truncate_output('\n'.join(payload).strip(), max_output_chars)
{
'action': 'python_exec',
'mode': mode,
'interpreter': interpreter,
'scratchpad_directory': str(context.scratchpad_directory) if context.scratchpad_directory else '',
'python_env_dir': str(context.python_env_dir) if context.python_env_dir else '',
'timed_out': True,
'timeout_seconds': timeout_seconds,
'stdout_preview': _snapshot_text(stdout),
'stderr_preview': _snapshot_text(stderr),
'output_preview': _snapshot_text('\n'.join(payload).strip()),
},
) )
except ToolExecutionError: except ToolExecutionError:
raise raise
@@ -2742,7 +2745,15 @@ def _resolve_python_interpreter(context: ToolExecutionContext) -> str:
def _run_bash(arguments: dict[str, Any], context: ToolExecutionContext) -> str: def _run_bash(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
command = _require_string(arguments, 'command') if arguments.get('run_in_background'):
return _run_bash_background(arguments, context)
raw_command = arguments.get('command')
if not isinstance(raw_command, str) or not raw_command.strip():
return (
'(no-op: empty bash command — pass a non-empty `command` string '
'to actually execute something)'
)
command = raw_command
_ensure_shell_allowed(command, context) _ensure_shell_allowed(command, context)
if context.jupyter_runtime is not None: if context.jupyter_runtime is not None:
result = context.jupyter_runtime.run_command( result = context.jupyter_runtime.run_command(
@@ -2805,6 +2816,276 @@ def _run_bash(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
) )
def _new_bg_task_id() -> str:
return f'bg_{secrets.token_hex(4)}'
def _bg_extract_pid(stdout: str) -> int | None:
match = re.search(r'BG_TASK_PID=(\d+)', stdout)
if match is None:
return None
try:
return int(match.group(1))
except ValueError:
return None
def _bg_remote_launcher(
*,
task_dir: str,
output_path: str,
pid_path: str,
exit_code_path: str,
user_command: str,
) -> str:
inner = (
f'( {user_command} ) > {shlex.quote(output_path)} 2>&1'
f' ; echo $? > {shlex.quote(exit_code_path)}'
)
return (
f'mkdir -p {shlex.quote(task_dir)}\n'
f'nohup bash -c {shlex.quote(inner)} </dev/null '
'>/dev/null 2>&1 &\n'
'PID=$!\n'
f'echo $PID > {shlex.quote(pid_path)}\n'
'echo "BG_TASK_PID=$PID"\n'
)
def _run_bash_background(
arguments: dict[str, Any],
context: ToolExecutionContext,
) -> tuple[str, dict[str, Any]]:
raw_command = arguments.get('command')
if not isinstance(raw_command, str) or not raw_command.strip():
return (
'(no-op: empty bash command — pass a non-empty `command` string '
'to actually execute something)',
{'action': 'bash_background_noop'},
)
command = raw_command
_ensure_shell_allowed(command, context)
wait_for_completion = bool(arguments.get('wait_for_completion', True))
task_id = _new_bg_task_id()
if context.jupyter_runtime is not None:
workspace = context.jupyter_runtime.binding.workspace_cwd
task_dir = f'{workspace}/.bg_tasks/{task_id}'
output_path = f'{task_dir}/output'
pid_path = f'{task_dir}/pid'
exit_code_path = f'{task_dir}/exit_code'
launcher = _bg_remote_launcher(
task_dir=task_dir,
output_path=output_path,
pid_path=pid_path,
exit_code_path=exit_code_path,
user_command=command,
)
result = context.jupyter_runtime.run_command(
launcher,
timeout_seconds=min(context.command_timeout_seconds, 15.0),
max_output_chars=4096,
cancel_event=context.cancel_event,
)
if result.exit_code != 0:
raise ToolExecutionError(
f'后台 bash 启动失败 exit_code={result.exit_code}\n'
f'stderr={result.stderr.strip()}'
)
pid = _bg_extract_pid(result.stdout)
if pid is None:
raise ToolExecutionError(
f'后台 bash 启动后未拿到 pid\nstdout={result.stdout.strip()}'
)
spec = BgTaskSpec(
task_id=task_id,
account_key=context.account_id or '',
session_id=context.session_id or '',
run_id=context.run_id or '',
pid=pid,
task_dir=task_dir,
output_path=output_path,
pid_path=pid_path,
exit_code_path=exit_code_path,
command=command,
started_at=time.time(),
wait_for_completion=wait_for_completion,
)
if context.bg_register is not None:
context.bg_register(spec)
content = (
f'已在远端后台启动\n'
f'task_id={task_id}\n'
f'pid={pid}\n'
f'task_dir={task_dir}\n'
f'output={output_path}\n'
f'wait_for_completion={wait_for_completion}\n'
'完成后将自动起新一轮把结果送回;'
'中途可用 bash_status(task_id) 查看,bash_kill(task_id) 终止。'
)
return (
content,
{
'action': 'remote_bash_bg',
'task_id': task_id,
'pid': pid,
'task_dir': task_dir,
'output_path': output_path,
'command': command,
'wait_for_completion': wait_for_completion,
},
)
# Local fallback: spawn detached subprocess with file redirects.
base_dir = (context.scratchpad_directory or context.root) / '.bg_tasks' / task_id
base_dir.mkdir(parents=True, exist_ok=True)
output_path = base_dir / 'output'
pid_path = base_dir / 'pid'
exit_code_path = base_dir / 'exit_code'
inner = (
f'( {command} ) > {shlex.quote(str(output_path))} 2>&1'
f' ; echo $? > {shlex.quote(str(exit_code_path))}'
)
process = subprocess.Popen(
['bash', '-c', inner],
cwd=_execution_cwd(context),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=_build_subprocess_env(context),
start_new_session=True,
)
pid_path.write_text(f'{process.pid}\n', encoding='utf-8')
spec = BgTaskSpec(
task_id=task_id,
account_key=context.account_id or '',
session_id=context.session_id or '',
run_id=context.run_id or '',
pid=process.pid,
task_dir=str(base_dir),
output_path=str(output_path),
pid_path=str(pid_path),
exit_code_path=str(exit_code_path),
command=command,
started_at=time.time(),
wait_for_completion=wait_for_completion,
)
if context.bg_register is not None:
context.bg_register(spec)
content = (
f'已在本地后台启动\n'
f'task_id={task_id}\n'
f'pid={process.pid}\n'
f'output={output_path}\n'
)
return (
content,
{
'action': 'bash_bg',
'task_id': task_id,
'pid': process.pid,
'task_dir': str(base_dir),
'output_path': str(output_path),
'command': command,
'wait_for_completion': wait_for_completion,
},
)
def _format_bash_status(status: BgTaskStatus, max_output_chars: int) -> str:
finished = (
time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(status.finished_at))
if status.finished_at is not None
else '-'
)
started = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(status.started_at))
body = [
f'task_id={status.task_id}',
f'status={status.status}',
f'pid={status.pid}',
f'started_at={started}',
f'finished_at={finished}',
f'exit_code={status.exit_code if status.exit_code is not None else "-"}',
f'auto_resumed={status.auto_resumed}',
'[output_preview]',
status.output_preview or '(empty)',
]
return _truncate_output('\n'.join(body), max_output_chars)
def _run_bash_status(
arguments: dict[str, Any],
context: ToolExecutionContext,
) -> tuple[str, dict[str, Any]]:
task_id = _require_string(arguments, 'task_id')
if context.bg_status_query is None:
raise ToolExecutionError('bg_status_query 未注入;当前运行环境不支持 bash 后台任务')
block = bool(arguments.get('block', False))
timeout_seconds = float(arguments.get('timeout_seconds', 30))
timeout_seconds = max(0.0, min(timeout_seconds, 600.0))
deadline = time.monotonic() + timeout_seconds if block else None
while True:
status = context.bg_status_query(task_id)
if status is None:
raise ToolExecutionError(f'未找到 task_id={task_id}')
if not block or status.status != 'running':
return (
_format_bash_status(status, context.max_output_chars),
{
'action': 'bash_status',
'task_id': task_id,
'status': status.status,
'exit_code': status.exit_code,
'auto_resumed': status.auto_resumed,
},
)
if deadline is None or time.monotonic() >= deadline:
return (
_format_bash_status(status, context.max_output_chars),
{
'action': 'bash_status',
'task_id': task_id,
'status': status.status,
'timed_out_block': True,
},
)
if context.cancel_event is not None and context.cancel_event.is_set():
return (
_format_bash_status(status, context.max_output_chars),
{
'action': 'bash_status',
'task_id': task_id,
'status': status.status,
'cancelled': True,
},
)
time.sleep(min(2.0, max(0.5, deadline - time.monotonic())))
def _run_bash_kill(
arguments: dict[str, Any],
context: ToolExecutionContext,
) -> tuple[str, dict[str, Any]]:
task_id = _require_string(arguments, 'task_id')
if context.bg_kill_request is None:
raise ToolExecutionError('bg_kill_request 未注入;当前运行环境不支持 bash 后台任务')
killed = context.bg_kill_request(task_id)
content = (
f'已终止 task_id={task_id}'
if killed
else f'未能终止 task_id={task_id}(可能已结束或不存在)'
)
return (
content,
{
'action': 'bash_kill',
'task_id': task_id,
'killed': killed,
},
)
def _web_fetch(arguments: dict[str, Any], context: ToolExecutionContext) -> str: def _web_fetch(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
raw_url = _require_string(arguments, 'url') raw_url = _require_string(arguments, 'url')
max_chars = _coerce_int(arguments, 'max_chars', context.max_output_chars) max_chars = _coerce_int(arguments, 'max_chars', context.max_output_chars)
+2 -2
View File
@@ -157,8 +157,8 @@ DEFAULT_MAX_TURNS = 50
class AgentRuntimeConfig: class AgentRuntimeConfig:
cwd: Path cwd: Path
max_turns: int = DEFAULT_MAX_TURNS max_turns: int = DEFAULT_MAX_TURNS
command_timeout_seconds: float = 30.0 command_timeout_seconds: float = 300.0
max_output_chars: int = 12000 max_output_chars: int = 50000 # ≈ 20k token (Chinese/code mix ≈ 2.5 chars/token)
stream_model_responses: bool = False stream_model_responses: bool = False
auto_snip_threshold_tokens: int | None = None auto_snip_threshold_tokens: int | None = None
auto_compact_threshold_tokens: int | None = None auto_compact_threshold_tokens: int | None = None
+211
View File
@@ -0,0 +1,211 @@
"""Persist background bash task records.
Sibling to run_state_store.py: a SQLite table tracking bg shell tasks the
agent spawns via bash(run_in_background=true). The store survives backend
restarts so polling can be resumed; the actual remote process is owned by
nohup on the Jupyter terminal and is independent of this store.
"""
from __future__ import annotations
import sqlite3
import time
from dataclasses import dataclass
from pathlib import Path
from threading import RLock
from typing import Any
ACTIVE_BG_STATUSES = {'running'}
TERMINAL_BG_STATUSES = {'completed', 'failed', 'cancelled'}
@dataclass(frozen=True)
class BgTaskSpec:
"""Payload an agent-side handler hands to the backend on registration."""
task_id: str
account_key: str
session_id: str
run_id: str
pid: int
task_dir: str
output_path: str
pid_path: str
exit_code_path: str
command: str
started_at: float
wait_for_completion: bool
@dataclass(frozen=True)
class BgTaskStatus:
"""Snapshot the agent reads back via bash_status."""
task_id: str
status: str
pid: int
started_at: float
finished_at: float | None
exit_code: int | None
output_path: str
output_preview: str
auto_resumed: bool
class BashBgStore:
def __init__(self, db_path: Path) -> None:
self.db_path = db_path
self._lock = RLock()
def record_start(self, spec: BgTaskSpec) -> None:
now = time.time()
with self._connect() as conn:
conn.execute(
"""
insert into bash_bg_tasks (
task_id, account_key, session_id, run_id, pid,
task_dir, output_path, pid_path, exit_code_path,
command, status, started_at, updated_at,
wait_for_completion, auto_resumed
)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, 0)
on conflict(task_id) do nothing
""",
(
spec.task_id,
spec.account_key,
spec.session_id,
spec.run_id,
spec.pid,
spec.task_dir,
spec.output_path,
spec.pid_path,
spec.exit_code_path,
spec.command,
spec.started_at,
now,
1 if spec.wait_for_completion else 0,
),
)
def mark_completed(
self,
task_id: str,
*,
exit_code: int,
finished_at: float | None = None,
) -> None:
finished = finished_at if finished_at is not None else time.time()
status = 'completed' if exit_code == 0 else 'failed'
with self._connect() as conn:
conn.execute(
"""
update bash_bg_tasks
set status = ?, exit_code = ?, finished_at = ?, updated_at = ?
where task_id = ? and status = 'running'
""",
(status, exit_code, finished, finished, task_id),
)
def mark_killed(self, task_id: str) -> None:
now = time.time()
with self._connect() as conn:
conn.execute(
"""
update bash_bg_tasks
set status = 'cancelled', finished_at = ?, updated_at = ?
where task_id = ? and status = 'running'
""",
(now, now, task_id),
)
def mark_auto_resumed(self, task_id: str) -> bool:
"""Set auto_resumed=1 atomically; return True if we won the race."""
with self._connect() as conn:
cursor = conn.execute(
"""
update bash_bg_tasks
set auto_resumed = 1, updated_at = ?
where task_id = ? and auto_resumed = 0
""",
(time.time(), task_id),
)
return cursor.rowcount > 0
def list_active(self) -> list[dict[str, Any]]:
with self._connect() as conn:
rows = conn.execute(
"select * from bash_bg_tasks where status = 'running'"
).fetchall()
return [dict(row) for row in rows]
def list_for_session(
self,
account_key: str,
session_id: str,
) -> list[dict[str, Any]]:
with self._connect() as conn:
rows = conn.execute(
"""
select * from bash_bg_tasks
where account_key = ? and session_id = ?
order by started_at desc
""",
(account_key, session_id),
).fetchall()
return [dict(row) for row in rows]
def get(self, task_id: str) -> dict[str, Any] | None:
with self._connect() as conn:
row = conn.execute(
"select * from bash_bg_tasks where task_id = ?",
(task_id,),
).fetchone()
return dict(row) if row is not None else None
def _connect(self) -> sqlite3.Connection:
with self._lock:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(self.db_path, timeout=10)
conn.row_factory = sqlite3.Row
self._init_schema(conn)
return conn
def _init_schema(self, conn: sqlite3.Connection) -> None:
conn.execute('pragma journal_mode=wal')
conn.execute(
"""
create table if not exists bash_bg_tasks (
task_id text primary key,
account_key text not null,
session_id text not null,
run_id text not null default '',
pid integer not null,
task_dir text not null,
output_path text not null,
pid_path text not null,
exit_code_path text not null,
command text not null,
status text not null,
started_at real not null,
finished_at real,
exit_code integer,
updated_at real not null,
wait_for_completion integer not null default 1,
auto_resumed integer not null default 0
)
"""
)
conn.execute(
"""
create index if not exists idx_bash_bg_session
on bash_bg_tasks(account_key, session_id, started_at)
"""
)
conn.execute(
"""
create index if not exists idx_bash_bg_active
on bash_bg_tasks(status)
"""
)
-7
View File
@@ -1251,11 +1251,4 @@ def check_shell_security(
if result.is_misparsing: if result.is_misparsing:
return (False, f'Security check: {result.message}') return (False, f'Security check: {result.message}')
# Check destructive patterns if not allowed
if not allow_destructive:
warning = get_destructive_command_warning(command)
if warning:
return (False, f'Potentially destructive command blocked: {warning}. '
'Re-run with --unsafe to allow it.')
return (True, '') return (True, '')
+95 -1
View File
@@ -233,12 +233,41 @@ class JupyterRuntimeSession:
'persisted_at': time.time(), 'persisted_at': time.time(),
} }
@property
def chat_workspace_root(self) -> str:
"""Per-user-per-chat root on shared NFS so the SFT training pod
(which doesn't mount the jupyter pod's private workspace) can read
and write the same artifacts. Layout:
/mnt/wangsenhao/autoresearch-zk-users/<account_id>/<session_id>/"""
account_part = sanitize_remote_path_part(self.binding.account_id)
session_part = sanitize_remote_path_part(self.binding.session_id)
return (
f'/mnt/wangsenhao/autoresearch-zk-users/'
f'{account_part}/{session_part}'
)
def bootstrap_workspace( def bootstrap_workspace(
self, self,
*, *,
timeout_seconds: float = 20.0, timeout_seconds: float = 20.0,
project_root: Path | None = None, project_root: Path | None = None,
) -> None: ) -> None:
chat_root = self.chat_workspace_root
nfs_users_root = '/mnt/wangsenhao/autoresearch-zk-users'
probe = self.run_command(
(
f'mkdir -p {shlex.quote(nfs_users_root)} && '
f'touch {shlex.quote(nfs_users_root)}/.write_probe && '
f'rm -f {shlex.quote(nfs_users_root)}/.write_probe'
),
timeout_seconds=timeout_seconds,
max_output_chars=2000,
)
if probe.exit_code != 0:
raise JupyterRuntimeError(
'远端 jupyter pod 未挂载 /mnt/wangsenhao 或不可写:'
+ (probe.stdout.strip() or probe.stderr.strip() or 'unknown error')
)
result = self.run_command( result = self.run_command(
( (
'mkdir -p ' 'mkdir -p '
@@ -247,7 +276,10 @@ class JupyterRuntimeSession:
f'{shlex.quote(self.binding.workspace_cwd)}/scratchpad ' f'{shlex.quote(self.binding.workspace_cwd)}/scratchpad '
f'{shlex.quote(self.binding.workspace_root)}/.runtime/uploads ' f'{shlex.quote(self.binding.workspace_root)}/.runtime/uploads '
f'{shlex.quote(self.binding.workspace_root)}/runtime_uploads ' f'{shlex.quote(self.binding.workspace_root)}/runtime_uploads '
f'{shlex.quote(self.account_runtime_root)}/python' f'{shlex.quote(self.account_runtime_root)}/python '
f'{shlex.quote(chat_root)}/results '
f'{shlex.quote(chat_root)}/sft_output '
f'{shlex.quote(chat_root)}/scripts'
), ),
timeout_seconds=timeout_seconds, timeout_seconds=timeout_seconds,
max_output_chars=4000, max_output_chars=4000,
@@ -259,6 +291,27 @@ class JupyterRuntimeSession:
self.ensure_python_environment(timeout_seconds=max(timeout_seconds, 300.0)) self.ensure_python_environment(timeout_seconds=max(timeout_seconds, 300.0))
if project_root is not None: if project_root is not None:
self.sync_runtime_bundle(project_root, timeout_seconds=max(timeout_seconds, 180.0)) self.sync_runtime_bundle(project_root, timeout_seconds=max(timeout_seconds, 180.0))
self._sync_chat_workspace_scripts(timeout_seconds=timeout_seconds)
def _sync_chat_workspace_scripts(self, *, timeout_seconds: float = 20.0) -> None:
if not self.skills_root:
return
chat_root = self.chat_workspace_root
src = f'{self.skills_root}/model-iteration/scripts/prepare_and_train_sft.py'
dst = f'{chat_root}/scripts/prepare_and_train_sft.py'
result = self.run_command(
(
f'if [ -f {shlex.quote(src)} ]; then '
f'cp {shlex.quote(src)} {shlex.quote(dst)}; '
f'fi'
),
timeout_seconds=timeout_seconds,
max_output_chars=2000,
)
if result.exit_code != 0:
raise JupyterRuntimeError(
result.stdout.strip() or 'Unable to sync chat workspace SFT script.'
)
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
payload = self.binding.to_dict() payload = self.binding.to_dict()
@@ -774,6 +827,20 @@ print(f"{'appended' if MODE == 'a' else 'wrote'} {target} ({len(content)} chars)
def resolve_workspace_path(self, raw_path: str) -> str: def resolve_workspace_path(self, raw_path: str) -> str:
value = raw_path.strip() or '.' value = raw_path.strip() or '.'
# 系统提示里给的"当前会话目录"通常是本地后端的绝对/相对路径,例如
# `.port_sessions/accounts/<acc>/sessions/<sid>/input` 或
# `/home/mi/.../.port_sessions/accounts/<acc>/sessions/<sid>/output/foo.csv`。
# 远端 Jupyter 没这套目录结构。识别这种 pattern 后,把 input/output/
# scratchpad bucket 映射到远端 workspace 的同名目录。
bucket_match = self._match_local_session_bucket(value)
if bucket_match is not None:
bucket, rest = bucket_match
tail = f'/{rest}' if rest else ''
return normalize_posix_path(
f'{self.binding.workspace_cwd}/{bucket}{tail}'
)
if value.startswith('/'): if value.startswith('/'):
return normalize_posix_path(value) return normalize_posix_path(value)
path = PurePosixPath(value) path = PurePosixPath(value)
@@ -787,14 +854,41 @@ print(f"{'appended' if MODE == 'a' else 'wrote'} {target} ({len(content)} chars)
if path.parts and path.parts[0] in {'scratchpad', 'scratch'}: if path.parts and path.parts[0] in {'scratchpad', 'scratch'}:
tail = PurePosixPath(*path.parts[1:]) if len(path.parts) > 1 else PurePosixPath() tail = PurePosixPath(*path.parts[1:]) if len(path.parts) > 1 else PurePosixPath()
return normalize_posix_path(f'{self.binding.workspace_cwd}/scratchpad/{tail}') return normalize_posix_path(f'{self.binding.workspace_cwd}/scratchpad/{tail}')
if path.parts and path.parts[0] in {'input', 'inputs'}:
tail = PurePosixPath(*path.parts[1:]) if len(path.parts) > 1 else PurePosixPath()
return normalize_posix_path(f'{self.binding.workspace_cwd}/input/{tail}')
return normalize_posix_path(f'{self.binding.workspace_cwd}/{value}') return normalize_posix_path(f'{self.binding.workspace_cwd}/{value}')
_LOCAL_SESSION_BUCKET_RE = re.compile(
r'(?:^|/)\.port_sessions/accounts/[^/]+/sessions/[^/]+/'
r'(?P<bucket>input|inputs|output|outputs|scratchpad|scratch)'
r'(?:/(?P<rest>.*))?$'
)
@classmethod
def _match_local_session_bucket(cls, raw: str) -> tuple[str, str] | None:
m = cls._LOCAL_SESSION_BUCKET_RE.search(raw)
if not m:
return None
bucket = m.group('bucket')
normalized = {
'input': 'input',
'inputs': 'input',
'output': 'output',
'outputs': 'output',
'scratchpad': 'scratchpad',
'scratch': 'scratchpad',
}[bucket]
rest = m.group('rest') or ''
return normalized, rest
def _remote_env_prefix(self) -> str: def _remote_env_prefix(self) -> str:
exports = { exports = {
'ZK_AGENT_WORKSPACE': self.binding.workspace_cwd, 'ZK_AGENT_WORKSPACE': self.binding.workspace_cwd,
'ZK_AGENT_OUTPUT': f'{self.binding.workspace_cwd}/output', 'ZK_AGENT_OUTPUT': f'{self.binding.workspace_cwd}/output',
'ZK_AGENT_SCRATCHPAD': f'{self.binding.workspace_cwd}/scratchpad', 'ZK_AGENT_SCRATCHPAD': f'{self.binding.workspace_cwd}/scratchpad',
'ZK_AGENT_PYTHON_ENV': self.python_env_path, 'ZK_AGENT_PYTHON_ENV': self.python_env_path,
'AUTORESEARCH_CHAT_ROOT': self.chat_workspace_root,
} }
if self.platform_root: if self.platform_root:
exports['ZK_AGENT_PLATFORM_ROOT'] = self.platform_root exports['ZK_AGENT_PLATFORM_ROOT'] = self.platform_root
+104 -14
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
import os import os
import sys
from typing import Any, Iterator from typing import Any, Iterator
from urllib import error, request from urllib import error, request
@@ -31,7 +32,7 @@ ANTHROPIC_MESSAGES_MODEL_PREFIXES = (
) )
ANTHROPIC_VERSION = '2023-06-01' ANTHROPIC_VERSION = '2023-06-01'
ANTHROPIC_MAX_TOKENS = 4096 ANTHROPIC_MAX_TOKENS = 16384
DEFAULT_MODEL_IDLE_TIMEOUT_SECONDS = 60.0 DEFAULT_MODEL_IDLE_TIMEOUT_SECONDS = 60.0
@@ -218,6 +219,57 @@ def _uses_anthropic_messages_api(model: str, base_url: str) -> bool:
) )
_UNKNOWN_EVENT_LOG_LIMIT = 8
_unknown_event_seen: dict[str, int] = {}
def _log_unknown_anthropic_event(kind: str, payload: dict[str, Any]) -> None:
"""Emit a one-line stderr warning the first few times we see an unknown event.
The streaming handler used to silently drop any block/delta type it didn't
recognise. When the upstream proxy started emitting frames we didn't decode,
the model's output budget was burned with no observable effect. This log
surfaces the situation without flooding logs on every turn.
"""
seen = _unknown_event_seen.get(kind, 0)
if seen >= _UNKNOWN_EVENT_LOG_LIMIT:
return
_unknown_event_seen[kind] = seen + 1
try:
snippet = json.dumps(payload, ensure_ascii=False)[:400]
except Exception: # pragma: no cover - defensive
snippet = repr(payload)[:400]
print(
f'[openai_compat] unknown anthropic stream event {kind!r}: {snippet}',
file=sys.stderr,
flush=True,
)
def _mark_last_message_cache_breakpoint(messages: list[dict[str, Any]]) -> None:
"""Attach cache_control to the last content block of the last message.
Anthropic prompt cache requires the breakpoint at the *end* of the cacheable
prefix. The next turn appends a new message → the previous last becomes the
second-to-last with its breakpoint still in the prefix, so everything up to
that point is reused on the next call.
"""
if not messages:
return
last = messages[-1]
content = last.get('content')
if not isinstance(content, list) or not content:
return
last_block = content[-1]
if not isinstance(last_block, dict):
return
new_block = dict(last_block)
new_block['cache_control'] = {'type': 'ephemeral'}
new_content = list(content)
new_content[-1] = new_block
last['content'] = new_content
def _anthropic_base_url(base_url: str) -> str: def _anthropic_base_url(base_url: str) -> str:
base = base_url.rstrip('/') base = base_url.rstrip('/')
if base.lower().endswith('/anthropic'): if base.lower().endswith('/anthropic'):
@@ -486,25 +538,46 @@ class OpenAICompatClient:
if blocks: if blocks:
self._append_anthropic_message(anthropic_messages, 'user', blocks) self._append_anthropic_message(anthropic_messages, 'user', blocks)
system_text = '\n\n'.join(system_parts) if system_parts else ''
if output_schema is not None:
schema_hint = (
f'请严格输出符合 JSON Schema `{output_schema.name}` 的 JSON'
'不要输出额外解释。'
)
system_text = f'{system_text}\n\n{schema_hint}'.strip() if system_text else schema_hint
cache_enabled = (
os.environ.get('CLAW_DISABLE_PROMPT_CACHE', '').strip().lower()
not in {'1', 'true', 'yes', 'on'}
)
payload: dict[str, Any] = { payload: dict[str, Any] = {
'model': self.config.model, 'model': self.config.model,
'messages': anthropic_messages, 'messages': anthropic_messages,
'max_tokens': ANTHROPIC_MAX_TOKENS, 'max_tokens': ANTHROPIC_MAX_TOKENS,
'stream': stream, 'stream': stream,
} }
if system_parts: if system_text:
payload['system'] = '\n\n'.join(system_parts) if cache_enabled:
payload['system'] = [
{
'type': 'text',
'text': system_text,
'cache_control': {'type': 'ephemeral'},
}
]
else:
payload['system'] = system_text
converted_tools = self._anthropic_tools(tools) converted_tools = self._anthropic_tools(tools)
if converted_tools: if converted_tools:
if cache_enabled:
converted_tools = list(converted_tools)
last_tool = dict(converted_tools[-1])
last_tool['cache_control'] = {'type': 'ephemeral'}
converted_tools[-1] = last_tool
payload['tools'] = converted_tools payload['tools'] = converted_tools
if output_schema is not None: if cache_enabled and anthropic_messages:
schema_hint = ( _mark_last_message_cache_breakpoint(anthropic_messages)
f'请严格输出符合 JSON Schema `{output_schema.name}` 的 JSON'
'不要输出额外解释。'
)
payload['system'] = (
f'{payload.get("system", "")}\n\n{schema_hint}'.strip()
)
return payload return payload
def _anthropic_headers(self) -> dict[str, str]: def _anthropic_headers(self) -> dict[str, str]:
@@ -604,7 +677,12 @@ class OpenAICompatClient:
content_block = event_payload.get('content_block') content_block = event_payload.get('content_block')
if not isinstance(block_index, int) or not isinstance(content_block, dict): if not isinstance(block_index, int) or not isinstance(content_block, dict):
continue continue
if content_block.get('type') != 'tool_use': block_type = content_block.get('type')
if block_type != 'tool_use':
if block_type not in ('text', 'thinking', 'redacted_thinking'):
_log_unknown_anthropic_event(
'content_block_start', event_payload
)
continue continue
tool_index = next_tool_index tool_index = next_tool_index
next_tool_index += 1 next_tool_index += 1
@@ -636,7 +714,8 @@ class OpenAICompatClient:
delta = event_payload.get('delta') delta = event_payload.get('delta')
if not isinstance(delta, dict): if not isinstance(delta, dict):
continue continue
if delta.get('type') == 'text_delta': delta_type = delta.get('type')
if delta_type == 'text_delta':
text = delta.get('text') text = delta.get('text')
if isinstance(text, str) and text: if isinstance(text, str) and text:
yield StreamEvent( yield StreamEvent(
@@ -645,7 +724,7 @@ class OpenAICompatClient:
raw_event=event_payload, raw_event=event_payload,
) )
continue continue
if delta.get('type') == 'input_json_delta': if delta_type == 'input_json_delta':
block_index = event_payload.get('index') block_index = event_payload.get('index')
partial_json = delta.get('partial_json') partial_json = delta.get('partial_json')
if ( if (
@@ -661,6 +740,17 @@ class OpenAICompatClient:
raw_event=event_payload, raw_event=event_payload,
) )
continue continue
if delta_type in ('thinking_delta', 'signature_delta'):
# Extended-thinking blocks: we don't surface them as
# content (the runtime has nowhere to put them), but
# they previously vanished silently and burned the
# whole 4k output budget before tool_use could start.
# Logging once is enough to confirm they're flowing.
continue
_log_unknown_anthropic_event(
f'content_block_delta:{delta_type}', event_payload
)
continue
if event_type == 'message_delta': if event_type == 'message_delta':
delta = event_payload.get('delta') delta = event_payload.get('delta')
if isinstance(delta, dict) and isinstance(delta.get('stop_reason'), str): if isinstance(delta, dict) and isinstance(delta.get('stop_reason'), str):
+24 -22
View File
@@ -15,28 +15,28 @@ from .agent_types import (
OutputSchemaConfig, OutputSchemaConfig,
UsageStats, UsageStats,
) )
@dataclass(frozen=True) @dataclass(frozen=True)
class StoredSession: class StoredSession:
session_id: str session_id: str
messages: tuple[str, ...] messages: tuple[str, ...]
input_tokens: int input_tokens: int
output_tokens: int output_tokens: int
DEFAULT_SESSION_DIR = Path('.port_sessions') DEFAULT_SESSION_DIR = Path('.port_sessions')
DEFAULT_AGENT_SESSION_DIR = DEFAULT_SESSION_DIR / 'agent' DEFAULT_AGENT_SESSION_DIR = DEFAULT_SESSION_DIR / 'agent'
def save_session(session: StoredSession, directory: Path | None = None) -> Path: def save_session(session: StoredSession, directory: Path | None = None) -> Path:
target_dir = directory or DEFAULT_SESSION_DIR target_dir = directory or DEFAULT_SESSION_DIR
target_dir.mkdir(parents=True, exist_ok=True) target_dir.mkdir(parents=True, exist_ok=True)
path = target_dir / f'{session.session_id}.json' path = target_dir / f'{session.session_id}.json'
path.write_text(json.dumps(asdict(session), indent=2)) path.write_text(json.dumps(asdict(session), indent=2))
return path return path
def load_session(session_id: str, directory: Path | None = None) -> StoredSession: def load_session(session_id: str, directory: Path | None = None) -> StoredSession:
target_dir = directory or DEFAULT_SESSION_DIR target_dir = directory or DEFAULT_SESSION_DIR
data = json.loads((target_dir / f'{session_id}.json').read_text()) data = json.loads((target_dir / f'{session_id}.json').read_text())
@@ -68,6 +68,7 @@ class StoredAgentSession:
budget_state: JSONDict budget_state: JSONDict
plugin_state: JSONDict plugin_state: JSONDict
scratchpad_directory: str | None = None scratchpad_directory: str | None = None
is_training: bool = False
def save_agent_session(session: StoredAgentSession, directory: Path | None = None) -> Path: def save_agent_session(session: StoredAgentSession, directory: Path | None = None) -> Path:
@@ -118,6 +119,7 @@ def load_agent_session(session_id: str, directory: Path | None = None) -> Stored
if isinstance(data.get('scratchpad_directory'), str) if isinstance(data.get('scratchpad_directory'), str)
else None else None
), ),
is_training=bool(data.get('is_training', False)),
) )
@@ -222,8 +224,8 @@ def deserialize_runtime_config(payload: JSONDict) -> AgentRuntimeConfig:
return AgentRuntimeConfig( return AgentRuntimeConfig(
cwd=Path(str(payload['cwd'])).resolve(), cwd=Path(str(payload['cwd'])).resolve(),
max_turns=int(payload.get('max_turns', DEFAULT_MAX_TURNS)), max_turns=int(payload.get('max_turns', DEFAULT_MAX_TURNS)),
command_timeout_seconds=float(payload.get('command_timeout_seconds', 30.0)), command_timeout_seconds=float(payload.get('command_timeout_seconds', 300.0)),
max_output_chars=int(payload.get('max_output_chars', 12000)), max_output_chars=int(payload.get('max_output_chars', 50000)),
stream_model_responses=bool(payload.get('stream_model_responses', False)), stream_model_responses=bool(payload.get('stream_model_responses', False)),
auto_snip_threshold_tokens=_optional_int(payload.get('auto_snip_threshold_tokens')), auto_snip_threshold_tokens=_optional_int(payload.get('auto_snip_threshold_tokens')),
auto_compact_threshold_tokens=_optional_int(payload.get('auto_compact_threshold_tokens')), auto_compact_threshold_tokens=_optional_int(payload.get('auto_compact_threshold_tokens')),
+324
View File
@@ -0,0 +1,324 @@
"""Tests for the bash run_in_background path (local fallback only).
Covers:
- BashBgStore: record_start / mark_completed / mark_auto_resumed race semantics.
- _run_bash_background local fallback: spawns a detached process with file
redirects, registers it via the bg_register callback, output/pid/exit_code
files materialize.
- _run_bash_status / _run_bash_kill: route through bg_status_query / bg_kill_request
callbacks and format their result.
Remote (jupyter_runtime) path is intentionally not exercised here — that
requires a live wsh account and is covered by the e2e checklist in the plan.
"""
from __future__ import annotations
import time
from pathlib import Path
import pytest
from src.agent_tool_core import ToolExecutionContext
from src.agent_tools import (
_run_bash_background,
_run_bash_kill,
_run_bash_status,
)
from src.agent_types import AgentPermissions
from src.bash_bg_store import BashBgStore, BgTaskSpec, BgTaskStatus
def _make_context(
tmp_path: Path,
*,
bg_register=None,
bg_status_query=None,
bg_kill_request=None,
) -> ToolExecutionContext:
return ToolExecutionContext(
root=tmp_path,
command_timeout_seconds=10.0,
max_output_chars=4096,
permissions=AgentPermissions(allow_shell_commands=True),
scratchpad_directory=tmp_path,
bg_register=bg_register,
bg_status_query=bg_status_query,
bg_kill_request=bg_kill_request,
account_id='acct-test',
session_id='sess-test',
run_id='run-test',
)
# ----- BashBgStore --------------------------------------------------------
def test_store_record_and_complete(tmp_path: Path) -> None:
store = BashBgStore(tmp_path / 'bg.db')
spec = BgTaskSpec(
task_id='bg_abc',
account_key='acct',
session_id='sess',
run_id='run',
pid=12345,
task_dir=str(tmp_path / 'bg_abc'),
output_path=str(tmp_path / 'bg_abc' / 'output'),
pid_path=str(tmp_path / 'bg_abc' / 'pid'),
exit_code_path=str(tmp_path / 'bg_abc' / 'exit_code'),
command='echo hi',
started_at=time.time(),
wait_for_completion=True,
)
store.record_start(spec)
row = store.get('bg_abc')
assert row is not None
assert row['status'] == 'running'
assert row['exit_code'] is None
store.mark_completed('bg_abc', exit_code=0)
row = store.get('bg_abc')
assert row['status'] == 'completed'
assert row['exit_code'] == 0
assert row['finished_at'] is not None
def test_store_mark_auto_resumed_is_race_safe(tmp_path: Path) -> None:
store = BashBgStore(tmp_path / 'bg.db')
spec = BgTaskSpec(
task_id='bg_race',
account_key='acct',
session_id='sess',
run_id='run',
pid=1,
task_dir='/tmp/x',
output_path='/tmp/x/output',
pid_path='/tmp/x/pid',
exit_code_path='/tmp/x/exit_code',
command='true',
started_at=time.time(),
wait_for_completion=True,
)
store.record_start(spec)
store.mark_completed('bg_race', exit_code=0)
# First caller wins; subsequent callers always see False.
assert store.mark_auto_resumed('bg_race') is True
assert store.mark_auto_resumed('bg_race') is False
assert store.mark_auto_resumed('bg_race') is False
def test_store_mark_killed_only_running(tmp_path: Path) -> None:
store = BashBgStore(tmp_path / 'bg.db')
spec = BgTaskSpec(
task_id='bg_kill',
account_key='acct',
session_id='sess',
run_id='run',
pid=2,
task_dir='/tmp/y',
output_path='/tmp/y/output',
pid_path='/tmp/y/pid',
exit_code_path='/tmp/y/exit_code',
command='sleep 100',
started_at=time.time(),
wait_for_completion=False,
)
store.record_start(spec)
store.mark_killed('bg_kill')
row = store.get('bg_kill')
assert row['status'] == 'cancelled'
# Re-killing a non-running task is a no-op (status stays cancelled).
store.mark_killed('bg_kill')
row = store.get('bg_kill')
assert row['status'] == 'cancelled'
# ----- _run_bash_background local fallback --------------------------------
def test_run_bash_background_local_spawns_detached(tmp_path: Path) -> None:
captured: list[BgTaskSpec] = []
ctx = _make_context(tmp_path, bg_register=captured.append)
content, metadata = _run_bash_background(
{
'command': 'echo hello-from-bg',
'wait_for_completion': True,
},
ctx,
)
assert metadata['action'] == 'bash_bg'
task_id = metadata['task_id']
assert task_id.startswith('bg_')
assert metadata['wait_for_completion'] is True
assert 'pid=' in content
# bg_register received exactly one spec, with the agent's session/account.
assert len(captured) == 1
spec = captured[0]
assert spec.task_id == task_id
assert spec.account_key == 'acct-test'
assert spec.session_id == 'sess-test'
assert spec.run_id == 'run-test'
assert spec.command == 'echo hello-from-bg'
assert spec.wait_for_completion is True
# Wait for the detached subprocess to finish — bounded by exit_code file.
deadline = time.monotonic() + 5.0
exit_code_path = Path(spec.exit_code_path)
while time.monotonic() < deadline and not exit_code_path.exists():
time.sleep(0.05)
assert exit_code_path.exists(), 'exit_code file did not appear in 5s'
assert exit_code_path.read_text(encoding='utf-8').strip() == '0'
output_text = Path(spec.output_path).read_text(encoding='utf-8')
assert 'hello-from-bg' in output_text
def test_run_bash_background_failed_command_records_nonzero_exit(
tmp_path: Path,
) -> None:
captured: list[BgTaskSpec] = []
ctx = _make_context(tmp_path, bg_register=captured.append)
_content, metadata = _run_bash_background(
{'command': 'exit 7'},
ctx,
)
spec = captured[0]
assert metadata['action'] == 'bash_bg'
deadline = time.monotonic() + 5.0
exit_code_path = Path(spec.exit_code_path)
while time.monotonic() < deadline and not exit_code_path.exists():
time.sleep(0.05)
assert exit_code_path.exists()
assert exit_code_path.read_text(encoding='utf-8').strip() == '7'
def test_run_bash_background_requires_shell_permission(tmp_path: Path) -> None:
from src.agent_tool_core import ToolPermissionError
ctx = ToolExecutionContext(
root=tmp_path,
command_timeout_seconds=10.0,
max_output_chars=4096,
permissions=AgentPermissions(allow_shell_commands=False),
scratchpad_directory=tmp_path,
)
with pytest.raises(ToolPermissionError):
_run_bash_background({'command': 'echo nope'}, ctx)
# ----- bash_status / bash_kill via callbacks ------------------------------
def test_bash_status_routes_through_callback(tmp_path: Path) -> None:
fake_status = BgTaskStatus(
task_id='bg_xyz',
status='completed',
pid=999,
started_at=time.time() - 5.0,
finished_at=time.time(),
exit_code=0,
output_path='/tmp/x/output',
output_preview='all good\n',
auto_resumed=False,
)
queries: list[str] = []
def query(task_id: str) -> BgTaskStatus | None:
queries.append(task_id)
return fake_status
ctx = _make_context(tmp_path, bg_status_query=query)
content, metadata = _run_bash_status({'task_id': 'bg_xyz'}, ctx)
assert queries == ['bg_xyz']
assert metadata['action'] == 'bash_status'
assert metadata['status'] == 'completed'
assert metadata['exit_code'] == 0
assert 'all good' in content
assert 'task_id=bg_xyz' in content
def test_bash_status_unknown_task_raises(tmp_path: Path) -> None:
from src.agent_tool_core import ToolExecutionError
def query(_task_id: str):
return None
ctx = _make_context(tmp_path, bg_status_query=query)
with pytest.raises(ToolExecutionError):
_run_bash_status({'task_id': 'bg_missing'}, ctx)
def test_bash_status_without_callback_raises(tmp_path: Path) -> None:
from src.agent_tool_core import ToolExecutionError
ctx = _make_context(tmp_path)
with pytest.raises(ToolExecutionError):
_run_bash_status({'task_id': 'bg_anything'}, ctx)
def test_bash_kill_routes_through_callback(tmp_path: Path) -> None:
killed: list[str] = []
def kill(task_id: str) -> bool:
killed.append(task_id)
return True
ctx = _make_context(tmp_path, bg_kill_request=kill)
content, metadata = _run_bash_kill({'task_id': 'bg_kk'}, ctx)
assert killed == ['bg_kk']
assert metadata['killed'] is True
assert 'bg_kk' in content
def test_bash_kill_failure_reports(tmp_path: Path) -> None:
ctx = _make_context(tmp_path, bg_kill_request=lambda _t: False)
content, metadata = _run_bash_kill({'task_id': 'bg_dead'}, ctx)
assert metadata['killed'] is False
assert '未能终止' in content
# ----- end-to-end: spawn + register + sync via real BashBgStore -----------
def test_local_bg_lifecycle_with_real_store(tmp_path: Path) -> None:
"""Plug a real BashBgStore into bg_register and check the row appears."""
store = BashBgStore(tmp_path / 'bg.db')
def register(spec: BgTaskSpec) -> None:
store.record_start(spec)
ctx = _make_context(tmp_path, bg_register=register)
_content, metadata = _run_bash_background(
{'command': 'echo lifecycle && exit 0'}, ctx,
)
task_id = metadata['task_id']
row = store.get(task_id)
assert row is not None
assert row['status'] == 'running'
# Wait for child to write exit_code (more reliable than kill -0, since
# the unreaped child becomes a zombie and kill -0 still reports it alive).
deadline = time.monotonic() + 5.0
exit_code_path = Path(row['exit_code_path'])
while time.monotonic() < deadline and not exit_code_path.exists():
time.sleep(0.05)
if not exit_code_path.exists():
pytest.fail('background process did not exit within 5 seconds')
# Simulate manager finalize step.
exit_code = int(
exit_code_path.read_text(encoding='utf-8').strip()
)
store.mark_completed(task_id, exit_code=exit_code)
row = store.get(task_id)
assert row['status'] == 'completed'
assert row['exit_code'] == 0