Merge pull request #680 from pikasTech/fix/v02-workbench-orphan-active-trace

fix: 释放孤儿 active trace
This commit is contained in:
Lyon
2026-06-02 12:16:26 +08:00
committed by GitHub
4 changed files with 106 additions and 3 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
- `internal/cloud/code-agent-*.ts` 负责 Codex stdio session、trace store、result cache、provider profile 和取消/轮询。
- AgentRun v0.1 接入只使用标准 `threadId` 路径:`POST /v1/agent/chat` 收到的 `conversationId/sessionId/threadId` 必须写入 AgentRun command `payload.threadId``SessionRef.threadId`;协议字段、trace、result 和 conversation facts 都以该字段为唯一 thread identity。
- AgentRun run 级 events 写回 HWLAB trace 时必须按当前 `commandId` 归属过滤;同一 run 的旧 command 尾部事件不能混入后续 command trace。取消、失败或 blocked 轮次如果已有 assistant/tool 可读上下文,必须以脱敏、限长的 partial context 写入 conversation facts,供后续 `inspect`/`--from-trace` 和同一 thread 的下一轮使用。
- 传给 AgentRun runner Job 的 `transientEnv` 是短期运行上下文,数量不设固定 8 项上限新增项必须仍满足最小必要、脱敏和 Secret 值不打印原则
- Cloud API 通过 AgentRun v0.1 `runner-jobs.transientEnv` 传递本次 Code Agent turn 的短期上下文,例如 `HWLAB_RUNTIME_*``HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME` 和 device-pod session token。`transientEnv` 不设固定 8 项上限新增短期上下文时必须按 name 去重、只传本次 Job 需要的 value,并继续禁止承载 GitHub token、provider key、长期 SSH key 或其他可复用 credential;文档、日志和 trace 只允许保留脱敏后的 name、来源或摘要,不打印 Secret 值。
- 同 Pod sidecar `hwlab-codex-api-forwarder` 监听 `127.0.0.1:49280/responses`,用于 `codex-api` profile 直连 hyueapi,并保持 hyueapi 在 `NO_PROXY` 中。
- `hwlab-code-agent-workspace` PVC 挂载到 `/workspace/hwlab`,用于长会话 workspace;它是 cloud-api 运行资源,不是独立用户入口。
+66
View File
@@ -4,6 +4,7 @@ import { createServer } from "node:http";
import { test } from "bun:test";
import { createAccessController } from "./access-control.ts";
import { createCodeAgentTraceStore } from "./code-agent-trace-store.ts";
import { createCloudApiServer } from "./server.ts";
const INTERNAL_TOKEN = "test-internal-token";
@@ -401,6 +402,71 @@ test("workbench workspace ignores stale active trace when selected conversation
}
});
test("workbench workspace releases orphan cancel-blocked active trace on new turn", async () => {
const traceStore = createCodeAgentTraceStore();
traceStore.append("trc_issue664_orphan_active", {
type: "cancel",
stage: "cancel",
status: "blocked",
label: "cancel:not_cancelable",
message: "Cancel request did not include a bound Codex stdio sessionId.",
errorCode: "cancel_session_missing",
waitingFor: "session-binding"
});
const server = createCloudApiServer({
env: {
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01",
AGENTRUN_MGR_URL: "http://127.0.0.1:9",
HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1",
HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek"
},
traceStore,
now: () => "2026-06-01T00:00:00.000Z"
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-orphan", password: "alice-pass" }, adminLogin.cookie);
assert.equal(alice.status, 201);
const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-orphan", password: "alice-pass" });
const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie);
assert.equal(workspace.status, 200);
const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, {
expectedRevision: 1,
activeTraceId: "trc_issue664_orphan_active",
providerProfile: "deepseek",
sessionStatus: "running",
updatedByClient: "test-suite"
}, aliceLogin.cookie);
assert.equal(update.status, 200);
assert.equal(update.body.workspace.activeTraceId, "trc_issue664_orphan_active");
const next = await postJson(port, "/v1/agent/chat", {
message: "new turn should replace orphan active trace",
traceId: "trc_issue664_after_orphan",
conversationId: "cnv_issue664_after_orphan",
workspaceId: workspace.body.workspace.workspaceId,
expectedWorkspaceRevision: 2,
shortConnection: true
}, aliceLogin.cookie, { prefer: "respond-async", "x-trace-id": "trc_issue664_after_orphan" });
assert.equal(next.status, 202);
assert.equal(next.body.status, "running");
assert.equal(next.body.traceId, "trc_issue664_after_orphan");
const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie);
assert.equal(restored.body.workspace.activeTraceId, "trc_issue664_after_orphan");
assert.equal(restored.body.workspace.workspace.sessionStatus, "running");
} finally {
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("workbench workspace permits continuation when stale active trace has idle active conversation", async () => {
const workspaceDir = await mkdtemp("/tmp/hwlab-stale-active-idle-");
const server = createCloudApiServer({
+1
View File
@@ -224,6 +224,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
assert.equal(transientEnv.HWLAB_RUNTIME_ENDPOINT_LOCKED, "1");
assert.equal(transientEnv.HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME, "1");
assert.equal(transientEnv.HWLAB_DEVICE_POD_SESSION_TOKEN, "test-device-pod-session-token");
assert.ok(body.transientEnv.length >= 7);
assert.equal(Object.hasOwn(transientEnv, "HWLAB_DEVICE_POD_API_URL"), false);
assert.equal(Object.hasOwn(transientEnv, "HWLAB_CODE_AGENT_DEVICE_POD_API_URL"), false);
return send({
+38 -2
View File
@@ -1064,16 +1064,44 @@ async function claimWorkbenchWorkspaceTurn({ params = {}, options = {}, traceId,
}
const activeTraceId = safeTraceId(workspace.activeTraceId);
const activeResult = activeTraceId ? await resolveWorkbenchActiveTraceResult(activeTraceId, options) : null;
const activeTraceSnapshot = activeTraceId ? (options.traceStore ?? defaultCodeAgentTraceStore).snapshot(activeTraceId) : null;
const activeTraceOrphaned = Boolean(activeTraceId && !activeResult && isWorkbenchOrphanActiveTrace(activeTraceSnapshot));
const selectedConversationId = safeConversationId(workspace.selectedConversationId);
const shouldInspectSelectedConversation = Boolean(
activeTraceId && !activeResult && selectedConversationId && options.accessController?.visibleConversationForActor
activeTraceId && !activeResult && !activeTraceOrphaned && selectedConversationId && options.accessController?.visibleConversationForActor
);
const selectedConversation = shouldInspectSelectedConversation
? await options.accessController.visibleConversationForActor(options.actor, selectedConversationId, workspace.projectId)
: null;
const activeTraceBlocksTurn = Boolean(
activeTraceId && (activeResult?.status === "running" || (!activeResult && isActiveWorkbenchConversation(selectedConversation)))
activeTraceId && !activeTraceOrphaned && (activeResult?.status === "running" || (!activeResult && isActiveWorkbenchConversation(selectedConversation)))
);
if (activeTraceOrphaned) {
await store.updateWorkspace?.({
workspaceId,
ownerUserId: options.actor.id,
actorRole: options.actor.role,
selectedConversationId: workspace.selectedConversationId,
selectedAgentSessionId: workspace.selectedAgentSessionId,
selectedDevicePodId: workspace.selectedDevicePodId,
activeTraceId: null,
providerProfile: workspace.providerProfile,
patch: {
...(workspace.workspace && typeof workspace.workspace === "object" ? workspace.workspace : {}),
activeTraceId: null,
staleActiveTraceId: activeTraceId,
staleActiveTraceReason: "orphan-cancel-session-missing",
sessionStatus: "failed",
updatedAt: new Date().toISOString(),
source: "code-agent-submit-active-trace-repair",
valuesRedacted: true,
secretMaterialStored: false
},
updatedBySessionId: options.authSession?.id ?? null,
updatedByClient: "code-agent-submit-active-trace-repair",
now: new Date().toISOString()
});
}
if (activeTraceBlocksTurn) {
const conversationId = safeConversationId(params.conversationId) || selectedConversationId || activeResult?.conversationId || null;
const sessionId = safeSessionId(params.sessionId) || safeSessionId(workspace.selectedAgentSessionId) || activeResult?.sessionId || null;
@@ -1129,6 +1157,14 @@ function isActiveWorkbenchConversation(conversation = null) {
return values.some((value) => ["running", "busy", "pending", "queued"].includes(textValue(value).toLowerCase()));
}
function isWorkbenchOrphanActiveTrace(snapshot) {
if (!snapshot || typeof snapshot !== "object") return false;
const last = snapshot.lastEvent && typeof snapshot.lastEvent === "object" ? snapshot.lastEvent : null;
if (!last) return false;
if (last.errorCode !== "cancel_session_missing" && last.label !== "cancel:not_cancelable") return false;
return !snapshot.sessionId && !snapshot.runId && !snapshot.commandId && !snapshot.sessionStatus && !snapshot.turn;
}
async function resolveWorkbenchActiveTraceResult(traceId, options = {}) {
if (!safeTraceId(traceId)) return null;
const cached = options.codeAgentChatResults?.get?.(traceId) ?? null;