fix: 释放孤儿 active trace
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
- `internal/cloud/access-control.ts` 也是账号 workspace authority:`account_workspaces` 记录同一账号的 Workbench 当前 workspace、selected conversation/session、active trace、provider profile 和 revision。
|
||||
- `internal/db/runtime-store.ts` 和 `internal/cloud/db-contract.ts` 负责 Postgres runtime store 与 readiness 分层。
|
||||
- `internal/cloud/code-agent-*.ts` 负责 Codex stdio session、trace store、result cache、provider profile 和取消/轮询。
|
||||
- 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。
|
||||
- 同 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 运行资源,不是独立用户入口。
|
||||
|
||||
|
||||
@@ -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 status clears completed AgentRun active trace on read", async () => {
|
||||
const agentRunCalls = [];
|
||||
const agentSessions = new Map();
|
||||
|
||||
@@ -210,7 +210,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 <= 8);
|
||||
assert.ok(body.transientEnv.length >= 7);
|
||||
return send({
|
||||
action: "create-kubernetes-job",
|
||||
runId: "run_hwlab_adapter",
|
||||
|
||||
@@ -906,16 +906,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) {
|
||||
sendJson(response, 409, {
|
||||
ok: false,
|
||||
@@ -964,6 +992,14 @@ function isActiveWorkbenchConversation(conversation = null) {
|
||||
return values.some((value) => ["running", "busy", "pending", "active", "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;
|
||||
|
||||
Reference in New Issue
Block a user