diff --git a/docs/reference/spec-v02-cicd.md b/docs/reference/spec-v02-cicd.md index 5740311d..fae8ef8e 100644 --- a/docs/reference/spec-v02-cicd.md +++ b/docs/reference/spec-v02-cicd.md @@ -510,6 +510,7 @@ registry 与 git mirror/relay 分属不同基础设施边界。registry 保持 - no-op env-reuse fast lane 必须输出完整证据链:`g14-ci-plan` 全复用、`skipped-runtime-unchanged`、`gitops-commit`/`gitops-push` skipped、`runtime-ready` skipped。 不能只用 PipelineRun `Completed` 或总耗时证明没有退化。 - 真实 rollout 的 `runtime-ready` 必须输出 `started`、周期性 `progress` 和最终 `argo-refresh`/`argo-sync-health`/`workload-ready` 类事件;缺少这些事件时应按可见性回归处理。 +- Bun 语法检查和测试入口必须分开:`bun --check ` 只用于非测试 TypeScript 源文件语法/转译检查;`*.test.ts`、`*.test.mts` 或其他使用 `test(...)` 的文件必须用 `bun test ` 或 repo-owned package script 执行。不得再用 `bun --check` 直接跑 `.test.ts`,该命令会在测试运行器外求值并报 `Cannot use test outside of the test runner`,这不是业务回归证据。 GitOps branch 已更新、source branch render 通过、PipelineRun 名称存在或 `G14` DEV/PROD health 正常,都不能单独代表 `v0.2` CI/CD 通过。 diff --git a/internal/cloud/code-agent-session-registry.test.ts b/internal/cloud/code-agent-session-registry.test.ts index 43936393..73a4079c 100644 --- a/internal/cloud/code-agent-session-registry.test.ts +++ b/internal/cloud/code-agent-session-registry.test.ts @@ -1055,9 +1055,10 @@ async function prepareFakeCodexHome(root = null) { return codexHome; } -function createFakeAppServerClient({ calls, responses = ["first stdio reply", "second stdio reply"], failedTurn = null, retryingTurn = null } = {}) { +function createFakeAppServerClient({ calls, responses = ["first stdio reply", "second stdio reply"], failedTurn = null, retryingTurn = null, staleResumeThreads = [] } = {}) { let notificationHandler = null; let turn = 0; + const staleThreads = new Set(staleResumeThreads); return { async initialize() { calls?.push({ method: "initialize" }); @@ -1073,6 +1074,10 @@ function createFakeAppServerClient({ calls, responses = ["first stdio reply", "s }, async resumeThread(args) { calls?.push({ method: "thread/resume", args }); + if (staleThreads.has(args.threadId)) { + staleThreads.delete(args.threadId); + throw new Error(`no rollout found for thread id ${args.threadId}`); + } notificationHandler?.({ method: "thread/started", params: { thread: { id: args.threadId } } }); return { threadId: args.threadId }; }, @@ -1381,6 +1386,68 @@ test("Codex stdio uses a persisted thread id after manager restart", async () => } }); +test("Codex stdio starts a fresh thread when a restored browser thread is stale", async () => { + const calls = []; + const fakeCodex = await createFakeCodexCommand(); + const codexHome = await prepareFakeCodexHome(); + const manager = createCodexStdioSessionManager({ + idFactory: () => "ses_stale_browser_thread", + createRpcClient: async () => createFakeAppServerClient({ + calls, + responses: ["fresh thread response"], + staleResumeThreads: ["thread_stale_browser_restore"] + }) + }); + const env = { + PATH: process.env.PATH, + OPENAI_API_KEY: "test-openai-key-material", + CODEX_HOME: codexHome, + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, + HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", + HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", + HWLAB_CODE_AGENT_CODEX_WORKSPACE: process.cwd(), + HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access", + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:49280/responses" + }; + + try { + const payload = await handleCodeAgentChat( + { + conversationId: "cnv_stale_browser_thread", + sessionId: "ses_stale_browser_thread", + threadId: "thread_stale_browser_restore", + traceId: "trc_stale_browser_thread", + message: "刷新浏览器后继续发送" + }, + { + now: () => "2026-05-31T01:06:00.000Z", + env, + codexStdioManager: manager + } + ); + + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "completed"); + assert.equal(payload.error, undefined); + assert.equal(payload.session.sessionId, "ses_stale_browser_thread"); + assert.equal(payload.session.threadId, "thread_stdio_ready"); + assert.equal(payload.providerTrace.threadId, "thread_stdio_ready"); + assert.equal(payload.providerTrace.terminalStatus, "completed"); + assert.ok(payload.runnerTrace.events.some((event) => event.label === "thread:resume:stale")); + assert.deepEqual(calls.filter((call) => call.method !== "initialize").map((call) => call.method), [ + "thread/resume", + "thread/start", + "turn/start" + ]); + assert.equal(calls.find((call) => call.method === "turn/start")?.args.threadId, "thread_stdio_ready"); + } finally { + await rm(fakeCodex.root, { recursive: true, force: true }); + await rm(codexHome, { recursive: true, force: true }); + } +}); + test("Codex stdio manager reports concrete blockers without falling back to readonly", async () => { const manager = createCodexStdioSessionManager({ idFactory: () => "ses_stdio_blocked" diff --git a/internal/cloud/codex-stdio-session.ts b/internal/cloud/codex-stdio-session.ts index 626d9838..90cd0cc9 100644 --- a/internal/cloud/codex-stdio-session.ts +++ b/internal/cloud/codex-stdio-session.ts @@ -1606,55 +1606,64 @@ async function runAppServerTurn({ threadId, waitingFor: "thread/resume" }); - const resumed = await client.resumeThread({ - threadId, - model, - cwd: workspace, - sandbox, - approvalPolicy: "never" - }, effectiveMs); - threadId = optionalId(resumed?.threadId) ?? threadId; - state.setThreadId(threadId); - state.appendTrace({ - type: "thread", - status: "completed", - label: "thread:resume:completed", - sessionId: session.sessionId, - sessionStatus: session.status, - turn: session.turn, - threadId, - waitingFor: "turn/start" - }); + try { + const resumed = await client.resumeThread({ + threadId, + model, + cwd: workspace, + sandbox, + approvalPolicy: "never" + }, effectiveMs); + threadId = optionalId(resumed?.threadId) ?? threadId; + state.setThreadId(threadId); + state.appendTrace({ + type: "thread", + status: "completed", + label: "thread:resume:completed", + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + threadId, + waitingFor: "turn/start" + }); + } catch (error) { + if (!isStaleAppServerThreadError(error)) throw error; + state.appendTrace({ + type: "thread", + status: "degraded", + label: "thread:resume:stale", + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + threadId, + message: "Persisted app-server thread is no longer resumable; starting a fresh thread for this turn.", + waitingFor: "thread/start" + }); + session.threadId = null; + threadId = null; + state.setThreadId(null); + const started = await startFreshAppServerThread({ + client, + state, + session, + workspace, + sandbox, + model, + effectiveMs + }); + threadId = started.threadId; + } } else { - state.appendTrace({ - type: "thread", - status: "started", - label: "thread:start:started", - sessionId: session.sessionId, - sessionStatus: session.status, - turn: session.turn, - waitingFor: "thread/start" - }); - const started = await client.startThread({ - model, - cwd: workspace, + const started = await startFreshAppServerThread({ + client, + state, + session, + workspace, sandbox, - approvalPolicy: "never", - serviceName: "hwlab-cloud-api" - }, effectiveMs); - threadId = optionalId(started?.threadId); - if (!threadId) throw new Error("thread/start response did not include thread.id"); - state.setThreadId(threadId); - state.appendTrace({ - type: "thread", - status: "completed", - label: "thread:start:completed", - sessionId: session.sessionId, - sessionStatus: session.status, - turn: session.turn, - threadId, - waitingFor: "turn/start" + model, + effectiveMs }); + threadId = started.threadId; } state.appendTrace({ @@ -1698,3 +1707,42 @@ async function runAppServerTurn({ } } } + +async function startFreshAppServerThread({ client, state, session, workspace, sandbox, model, effectiveMs }) { + state.appendTrace({ + type: "thread", + status: "started", + label: "thread:start:started", + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + waitingFor: "thread/start" + }); + const started = await client.startThread({ + model, + cwd: workspace, + sandbox, + approvalPolicy: "never", + serviceName: "hwlab-cloud-api" + }, effectiveMs); + const threadId = optionalId(started?.threadId); + if (!threadId) throw new Error("thread/start response did not include thread.id"); + state.setThreadId(threadId); + state.appendTrace({ + type: "thread", + status: "completed", + label: "thread:start:completed", + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + threadId, + waitingFor: "turn/start" + }); + return { threadId }; +} + +function isStaleAppServerThreadError(error) { + const message = String(error?.message ?? ""); + return /\bno\s+rollout\s+found\s+for\s+thread\s+id\b/iu.test(message) || + /\bthread\b.*\b(?:not\s+found|missing|expired|gone|unknown)\b/iu.test(message); +}