Browse Source

fix(bridge): 保持重试请求的原会话与研究版本

会话收到 started 后会指向本轮运行,直接重试会意外引用自己或覆盖后续版本。现在先持久化提交上下文,让同一请求重试和进程重启后的重试仍指向原任务。

- 为消息生成稳定幂等键,原子保存父运行及会话引用,不保存凭证或任务正文
- 拒绝同一显式键对应不同请求,重试旧运行时不回退当前研究版本
- 中断提示携带原运行标识;科研门面明确转发题号和反馈类型,研究修订保留旧版成果
- 增加真实回环 HTTP 与独立进程重试测试,隔离测试状态目录
- 31 项 Bridge 测试及包类型检查通过,配套 PaaS 本地 HTTP 研究闭环通过
邵勃源 5 ngày trước cách đây
mục cha
commit
61902ee459

+ 11 - 7
.opencode/README.md

@@ -37,7 +37,7 @@ Research67 统一工作流为:
 bun run --cwd packages/opencode dev -- --agent research67
 bun run --cwd packages/opencode dev -- --agent research67
 ```
 ```
 
 
-之后直接在 OpenCode 中输入科学问题、125 Questions 题号或继续追问即可。同一 OpenCode session 会保留 AgentPaaS thread 和上一轮运行引用。对于 LocoStack 远程 Agent,继续研究会在新工作区中复制上一轮选定成果,并保留父子版本关系。
+之后直接在 OpenCode 中输入科学问题、125 Questions 题号或继续追问即可。同一 OpenCode session 会保留 AgentPaaS thread 和上一轮运行引用。配套版本的本地 PaaS 执行服务和 LocoStack 远程 Agent 均支持研究修订:在新工作区中复制上一轮选定成果,并保留父子版本关系。
 
 
 ## 推荐入口
 ## 推荐入口
 
 
@@ -81,12 +81,16 @@ export AGENTPAAS_API_KEY=你的_AgentPaaS_API_Key
 
 
 `agentpaas_run` 支持 `ag_...` 和 `locostack:<namespace>:<name>` 两种 Agent 身份。新增可选参数:
 `agentpaas_run` 支持 `ag_...` 和 `locostack:<namespace>:<name>` 两种 Agent 身份。新增可选参数:
 
 
-| 参数                 | 含义                                                     |
-| -------------------- | -------------------------------------------------------- |
-| `question_id`        | 1–125 的题号                                             |
-| `feedback_kind`      | `human`、`program_check`、`execution_result`、`evidence` |
-| `input_artifact_ids` | 从上一轮已登记成果中选择输入;省略时由 PaaS 选择全部     |
-| `idempotency_key`    | 同一提交重试时保持不变;修改请求内容应使用新键           |
+| 参数                 | 含义                                                             |
+| -------------------- | ---------------------------------------------------------------- |
+| `question_id`        | 1–125 的题号                                                     |
+| `feedback_kind`      | `human`、`program_check`、`execution_result`、`evidence`         |
+| `input_artifact_ids` | 从上一轮已登记成果中选择输入;省略时由 PaaS 选择全部             |
+| `idempotency_key`    | 可选;默认从 OpenCode 消息及请求内容生成稳定键。显式重试保持原键 |
+
+Bridge 在提交前保存原会话和父运行引用,因此重试不会因会话已推进而变成下一版研究。保存位置为 `~/.agentpaas/opencode-conversations/requests/`,可用 `AGENTPAAS_BRIDGE_STATE_DIR` 指定隔离目录。记录不含 API Key 或任务正文;写入失败会停止提交。任务中断时输出原 run ID 和提交键,先查询原状态再决定后续动作。
+
+只运行 OpenCode 与本机 PaaS 时,需要同时启动 PaaS API 和它的 Local Agent Service 执行进程,并配置 `AGENTPAAS_LOCAL_SERVICE_URL` 及两端一致的服务 token。无需连接 LocoStack。配套 PaaS 的 `scripts/smoke_research_integration.py --backend local` 提供隔离的一键验收,详见其 `docs/opencode-local-integration.md`。
 
 
 使用 `conversation: "continue"` 关联上一轮运行。服务端会检查用户、租户、Agent、session 和成果归属。单次调用出错仍不自动提交另一次研究;已有 run 可以通过 PaaS 快照和事件接口恢复读取。
 使用 `conversation: "continue"` 关联上一轮运行。服务端会检查用户、租户、Agent、session 和成果归属。单次调用出错仍不自动提交另一次研究;已有 run 可以通过 PaaS 快照和事件接口恢复读取。
 
 

+ 3 - 1
.opencode/agent/research67.md

@@ -33,12 +33,14 @@ Research67 不是普通问答助手,也不是固定 3 Agent 或固定 9 Agent
 - agent_name: research67
 - agent_name: research67
 - mode: 问候、澄清、概念讨论和产物追问用 `chat`;用户明确要求检索、分析、实验、写作、生成文件、运行 125 Questions 或推进科研闭环时用 `iterate`
 - mode: 问候、澄清、概念讨论和产物追问用 `chat`;用户明确要求检索、分析、实验、写作、生成文件、运行 125 Questions 或推进科研闭环时用 `iterate`
 - conversation: 默认 `continue`;只有用户明确切换到不相关新课题或要求新会话时用 `new`
 - conversation: 默认 `continue`;只有用户明确切换到不相关新课题或要求新会话时用 `new`
+- question_id: 用户明确给出的 1–125 题号;未给出时不猜编号
+- feedback_kind: 对已有研究提出修改意见时传 `human`;只有反馈明确来自程序检查、执行结果或补充证据时,分别使用 `program_check`、`execution_result` 或 `evidence`
 - input: 用户任务原文;可以补充用户明确给出的时间、成本、授权和交付物边界,但不得改变研究问题
 - input: 用户任务原文;可以补充用户明确给出的时间、成本、授权和交付物边界,但不得改变研究问题
 - parameters: 需要科研执行、125 Questions、科学假设、研究计划、实验规划、论文写作、核验评审或闭环迭代时传 `{ "workflow": "RESEARCH67_UNIFIED_LOOP_V1" }`;纯问候和概念闲聊可留空
 - parameters: 需要科研执行、125 Questions、科学假设、研究计划、实验规划、论文写作、核验评审或闭环迭代时传 `{ "workflow": "RESEARCH67_UNIFIED_LOOP_V1" }`;纯问候和概念闲聊可留空
 
 
 OpenCode 侧只做桥接,不做科研推理。不得在 OpenCode 本地伪造研究产物、不得自行改写远端结论、不得在同一回合重复调用 `agentpaas_run`,也不得失败后擅自改调其他 Agent 补做。
 OpenCode 侧只做桥接,不做科研推理。不得在 OpenCode 本地伪造研究产物、不得自行改写远端结论、不得在同一回合重复调用 `agentpaas_run`,也不得失败后擅自改调其他 Agent 补做。
 
 
-同一 OpenCode session 会自动复用 AgentPaaS thread 和上一轮 workspace。用户追问“产物在哪”“继续下一轮”“补做实验规划”“写论文”“核验数据”等,都应继续转发到同一个远端会话,使 Research67 能沿用已有本体、Action DAG、receipt 和 artifact。
+同一 OpenCode session 会自动复用 AgentPaaS thread 和上一轮运行引用。研究修订会使用独立 workspace,复制上一轮已登记的成果,保留旧版。用户追问“产物在哪”“继续下一轮”“补做实验规划”“写论文”“核验数据”等,都应继续转发到同一个远端会话,使 Research67 能沿用已有本体、Action DAG、receipt 和 artifact。研究修订不传 `work_dir`,也不自行编造 `input_artifact_ids`。
 
 
 若用户引用 125 Questions 中的问题,保留编号、原问题文本和学科语境;背景摘要只能作为待核验线索,不得当作可靠证据。用户只给编号而未给正文时,原样转发编号并启用统一工作流,让远端 Research67 从已部署题库读取;若远端明确报告题库不可用,再向用户索要正文,OpenCode 不得猜题。
 若用户引用 125 Questions 中的问题,保留编号、原问题文本和学科语境;背景摘要只能作为待核验线索,不得当作可靠证据。用户只给编号而未给正文时,原样转发编号并启用统一工作流,让远端 Research67 从已部署题库读取;若远端明确报告题库不可用,再向用户索要正文,OpenCode 不得猜题。
 
 

+ 68 - 10
.opencode/tool/agentpaas_run.ts

@@ -1,6 +1,6 @@
 /// <reference path="../env.d.ts" />
 /// <reference path="../env.d.ts" />
 import { tool } from "@opencode-ai/plugin"
 import { tool } from "@opencode-ai/plugin"
-import { mkdir } from "node:fs/promises"
+import { link, mkdir, rm } from "node:fs/promises"
 import { homedir } from "node:os"
 import { homedir } from "node:os"
 import path from "node:path"
 import path from "node:path"
 
 
@@ -66,7 +66,7 @@ export default tool({
     }
     }
     const agentID = await resolveAgentID(config, args.agent_name, context.abort)
     const agentID = await resolveAgentID(config, args.agent_name, context.abort)
     const conversation = args.conversation ?? "continue"
     const conversation = args.conversation ?? "continue"
-    const conversationKey = `${config.url}\n${agentID}`
+    const conversationKey = `${config.url}\n${agentID}\n${digest(config.apiKey)}`
     const savedConversation =
     const savedConversation =
       conversation === "new" ? undefined : await loadConversationState(context.sessionID, conversationKey)
       conversation === "new" ? undefined : await loadConversationState(context.sessionID, conversationKey)
     const threadID = args.thread_id?.trim() || savedConversation?.threadID || ""
     const threadID = args.thread_id?.trim() || savedConversation?.threadID || ""
@@ -74,6 +74,13 @@ export default tool({
       conversation === "continue" && (!args.thread_id?.trim() || args.thread_id.trim() === savedConversation?.threadID)
       conversation === "continue" && (!args.thread_id?.trim() || args.thread_id.trim() === savedConversation?.threadID)
         ? savedConversation?.runID || ""
         ? savedConversation?.runID || ""
         : ""
         : ""
+    const intent = digest(canonicalJSON({ ...args, idempotency_key: undefined, conversation }))
+    const idempotencyKey =
+      args.idempotency_key?.trim() ||
+      `opencode-${digest(canonicalJSON([context.sessionID, context.messageID, agentID, intent]))}`
+    // Freeze the parent before submitting. Once `started` advances the session,
+    // retrying this same request must not turn it into a new research revision.
+    const admission = await requestContext(config, agentID, idempotencyKey, { intent, threadID, runID: continueRunID })
     // OpenCode 的宿主机目录不会自动挂载进 AgentPaaS Docker。只有调用方
     // OpenCode 的宿主机目录不会自动挂载进 AgentPaaS Docker。只有调用方
     // 明确给出服务端可见路径时才覆盖,否则让 PaaS 创建并隔离 run workspace。
     // 明确给出服务端可见路径时才覆盖,否则让 PaaS 创建并隔离 run workspace。
     const workDir = args.work_dir
     const workDir = args.work_dir
@@ -86,17 +93,17 @@ export default tool({
           Authorization: `Bearer ${config.apiKey}`,
           Authorization: `Bearer ${config.apiKey}`,
           "Content-Type": "application/json",
           "Content-Type": "application/json",
           Accept: "text/event-stream",
           Accept: "text/event-stream",
-          ...(args.idempotency_key ? { "Idempotency-Key": args.idempotency_key } : {}),
+          "Idempotency-Key": idempotencyKey,
         },
         },
         body: JSON.stringify({
         body: JSON.stringify({
           input: args.input,
           input: args.input,
           mode: args.mode,
           mode: args.mode,
           target_subagent: args.target_subagent ?? "",
           target_subagent: args.target_subagent ?? "",
-          thread_id: threadID,
+          thread_id: admission.threadID,
           parameters: args.parameters ?? {},
           parameters: args.parameters ?? {},
           context: {
           context: {
             ...(workDir ? { work_dir: workDir } : {}),
             ...(workDir ? { work_dir: workDir } : {}),
-            ...(continueRunID ? { run_id: continueRunID } : {}),
+            ...(admission.runID ? { run_id: admission.runID } : {}),
             ...(args.question_id ? { question_id: args.question_id } : {}),
             ...(args.question_id ? { question_id: args.question_id } : {}),
             ...(args.feedback_kind ? { feedback_kind: args.feedback_kind } : {}),
             ...(args.feedback_kind ? { feedback_kind: args.feedback_kind } : {}),
             ...(args.input_artifact_ids ? { input_artifact_ids: args.input_artifact_ids } : {}),
             ...(args.input_artifact_ids ? { input_artifact_ids: args.input_artifact_ids } : {}),
@@ -117,6 +124,7 @@ export default tool({
     const liveMetadata: Record<string, unknown> = {
     const liveMetadata: Record<string, unknown> = {
       agent_name: args.agent_name,
       agent_name: args.agent_name,
       agent_id: agentID,
       agent_id: agentID,
+      idempotency_key: idempotencyKey,
       status: "running",
       status: "running",
     }
     }
     let done: Record<string, unknown> | null = null
     let done: Record<string, unknown> | null = null
@@ -145,10 +153,13 @@ export default tool({
           startedRunID = getString(data?.run_id)
           startedRunID = getString(data?.run_id)
           const remoteThreadID = getString(data?.thread_id) || getString(data?.session_id)
           const remoteThreadID = getString(data?.thread_id) || getString(data?.session_id)
           if (remoteThreadID && startedRunID) {
           if (remoteThreadID && startedRunID) {
-            await saveConversationState(context.sessionID, conversationKey, {
-              threadID: remoteThreadID,
-              runID: startedRunID,
-            })
+            const latest = await loadConversationState(context.sessionID, conversationKey)
+            if (admission.created || !latest || latest.runID === admission.runID || latest.runID === startedRunID) {
+              await saveConversationState(context.sessionID, conversationKey, {
+                threadID: remoteThreadID,
+                runID: startedRunID,
+              })
+            }
           }
           }
           if (context.abort.aborted) cancelOnAbort()
           if (context.abort.aborted) cancelOnAbort()
           updateMetadata("Research67 正在运行", {
           updateMetadata("Research67 正在运行", {
@@ -304,7 +315,9 @@ export default tool({
     }
     }
     if (!done) {
     if (!done) {
       const hint = sawCancelled ? "(收到 cancelled 事件)" : "(未收到 done 事件,连接可能中断)"
       const hint = sawCancelled ? "(收到 cancelled 事件)" : "(未收到 done 事件,连接可能中断)"
-      throw new Error(`AgentPaaS 任务未正常结束${hint}${errors.length ? ":" + errors.join("; ") : ""}`)
+      throw new Error(
+        `AgentPaaS 任务未正常结束${hint}${errors.length ? ":" + errors.join("; ") : ""}。run_id=${startedRunID || "尚未收到"};idempotency_key=${idempotencyKey}。请先查询原运行状态,不要换键重复执行。`,
+      )
     }
     }
     const status = typeof done.status === "string" ? done.status : "unknown"
     const status = typeof done.status === "string" ? done.status : "unknown"
     if (status === "completed" || status === "failed") progress.stopUnverified()
     if (status === "completed" || status === "failed") progress.stopUnverified()
@@ -314,6 +327,7 @@ export default tool({
     const metadata = {
     const metadata = {
       agent_name: args.agent_name,
       agent_name: args.agent_name,
       agent_id: agentID,
       agent_id: agentID,
+      idempotency_key: idempotencyKey,
       run_id: done.run_id ?? liveMetadata.run_id,
       run_id: done.run_id ?? liveMetadata.run_id,
       thread_id: done.thread_id ?? done.session_id ?? liveMetadata.thread_id,
       thread_id: done.thread_id ?? done.session_id ?? liveMetadata.thread_id,
       status,
       status,
@@ -429,6 +443,50 @@ function isSupportedAgentID(id: string): boolean {
 
 
 const conversationCache = new Map<string, ConversationState>()
 const conversationCache = new Map<string, ConversationState>()
 
 
+function digest(value: string) {
+  return new Bun.CryptoHasher("sha256").update(value).digest("hex")
+}
+
+function canonicalJSON(value: unknown): string {
+  if (Array.isArray(value)) return `[${value.map(canonicalJSON).join(",")}]`
+  const record = asRecord(value)
+  if (record)
+    return `{${Object.keys(record)
+      .sort()
+      .filter((key) => record[key] !== undefined)
+      .map((key) => `${JSON.stringify(key)}:${canonicalJSON(record[key])}`)
+      .join(",")}}`
+  return JSON.stringify(value) ?? "null"
+}
+
+async function requestContext(
+  config: { url: string; apiKey: string },
+  agentID: string,
+  key: string,
+  current: ConversationState & { intent: string },
+) {
+  const directory = path.join(agentPaaSConversationDir(), "requests")
+  const filePath = path.join(directory, `${digest(canonicalJSON([config.url, config.apiKey, agentID, key]))}.json`)
+  await mkdir(directory, { recursive: true, mode: 0o700 })
+  const temporary = `${filePath}.${crypto.randomUUID()}.tmp`
+  await Bun.write(temporary, JSON.stringify(current), { mode: 0o600 })
+  // link is atomic and never replaces the first submission's saved context.
+  const created = await link(temporary, filePath)
+    .then(
+      () => true,
+      (error: NodeJS.ErrnoException) => {
+        if (error.code === "EEXIST") return false
+        throw error
+      },
+    )
+    .finally(() => rm(temporary, { force: true }))
+  const saved = asRecord(await Bun.file(filePath).json())
+  if (!saved || typeof saved.threadID !== "string" || typeof saved.runID !== "string")
+    throw new Error("AgentPaaS 提交记录损坏,停止提交;请先检查原运行状态。")
+  if (saved.intent !== current.intent) throw new Error("AgentPaaS 幂等键已用于不同请求;修改后的任务必须使用新键。")
+  return { threadID: saved.threadID, runID: saved.runID, created }
+}
+
 async function loadConversationState(sessionID: string, key: string): Promise<ConversationState | undefined> {
 async function loadConversationState(sessionID: string, key: string): Promise<ConversationState | undefined> {
   const cacheKey = `${sessionID}\n${key}`
   const cacheKey = `${sessionID}\n${key}`
   const cached = conversationCache.get(cacheKey)
   const cached = conversationCache.get(cacheKey)

+ 91 - 0
packages/opencode/test/tool/agentpaas-admission.test.ts

@@ -0,0 +1,91 @@
+import { expect, test } from "bun:test"
+import { mkdtemp, rm } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import path from "node:path"
+import bridge from "../../../../.opencode/tool/agentpaas_run"
+
+test("retries retain their original parent across session advancement and process restart", async () => {
+  const directory = await mkdtemp(path.join(tmpdir(), "bridge-admission-"))
+  const saved = {
+    url: process.env.AGENTPAAS_URL,
+    key: process.env.AGENTPAAS_API_KEY,
+    state: process.env.AGENTPAAS_BRIDGE_STATE_DIR,
+  }
+  const requests: Array<{ key: string; body: Record<string, unknown> }> = []
+  const runs = new Map<string, string>()
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    async fetch(request) {
+      if (new URL(request.url).pathname === "/api/v1/agents")
+        return Response.json({ agents: [{ id: "ag_local", name: "local" }] })
+      const body = (await request.json()) as Record<string, unknown>
+      const key = request.headers.get("Idempotency-Key")!
+      requests.push({ key, body })
+      if (!runs.has(key)) runs.set(key, `run_${runs.size + 1}`)
+      const runID = runs.get(key)
+      return new Response(
+        `event: started\ndata: ${JSON.stringify({ run_id: runID, thread_id: "session_first" })}\n\n` +
+          `event: done\ndata: ${JSON.stringify({ run_id: runID, session_id: "session_first", status: "completed", output: "ok" })}\n\n`,
+        { headers: { "Content-Type": "text/event-stream" } },
+      )
+    },
+  })
+  process.env.AGENTPAAS_URL = server.url.origin
+  process.env.AGENTPAAS_API_KEY = "synthetic-admission-key"
+  process.env.AGENTPAAS_BRIDGE_STATE_DIR = directory
+  const context = {
+    sessionID: `test-${crypto.randomUUID()}`,
+    messageID: "first-message",
+    agent: "local",
+    directory,
+    worktree: directory,
+    abort: new AbortController().signal,
+    metadata() {},
+    async ask() {},
+  }
+  const args = { agent_name: "local", input: "first", mode: "iterate" as const, conversation: "continue" as const }
+  try {
+    await bridge.execute(args, context)
+    await bridge.execute(args, context)
+    expect(requests[0].key).toMatch(/^opencode-/)
+    expect(requests[1]).toEqual(requests[0])
+    expect(requests[0].body.thread_id).toBe("")
+    expect(requests[0].body.context).toEqual({})
+
+    const source = `import bridge from ${JSON.stringify(path.resolve(import.meta.dir, "../../../../.opencode/tool/agentpaas_run.ts"))};
+      await bridge.execute(${JSON.stringify(args)}, {...${JSON.stringify(context)},
+        abort: new AbortController().signal, metadata() {}, async ask() {}});`
+    const child = Bun.spawn([process.execPath, "--eval", source], { env: process.env, stdout: "pipe", stderr: "pipe" })
+    const error = await new Response(child.stderr).text()
+    expect(await child.exited, error).toBe(0)
+    expect(requests[2]).toEqual(requests[0])
+
+    await bridge.execute({ ...args, input: "next" }, { ...context, messageID: "second-message" })
+    expect(requests[3].key).not.toBe(requests[0].key)
+    expect(requests[3].body.thread_id).toBe("session_first")
+    expect(requests[3].body.context).toEqual({ run_id: "run_1" })
+
+    // Re-reading the first run must not rewind the latest research version.
+    await bridge.execute(args, context)
+    expect(requests[4]).toEqual(requests[0])
+
+    await bridge.execute({ ...args, idempotency_key: "explicit" }, context)
+    expect(requests[5].body.context).toEqual({ run_id: "run_2" })
+    await expect(bridge.execute({ ...args, input: "changed", idempotency_key: "explicit" }, context)).rejects.toThrow(
+      "不同请求",
+    )
+    expect(requests).toHaveLength(6)
+  } finally {
+    await server.stop(true)
+    for (const [key, value] of Object.entries({
+      AGENTPAAS_URL: saved.url,
+      AGENTPAAS_API_KEY: saved.key,
+      AGENTPAAS_BRIDGE_STATE_DIR: saved.state,
+    })) {
+      if (value === undefined) delete process.env[key]
+      else process.env[key] = value
+    }
+    await rm(directory, { recursive: true, force: true })
+  }
+})

+ 9 - 0
packages/opencode/test/tool/agentpaas-protocol.test.ts

@@ -1,11 +1,17 @@
 // Real loopback HTTP/SSE contract tests; no model or external service calls.
 // Real loopback HTTP/SSE contract tests; no model or external service calls.
 import { expect, test } from "bun:test"
 import { expect, test } from "bun:test"
+import { mkdtemp, rm } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import path from "node:path"
 import bridge from "../../../../.opencode/tool/agentpaas_run"
 import bridge from "../../../../.opencode/tool/agentpaas_run"
 type Event = [string, Record<string, unknown>]
 type Event = [string, Record<string, unknown>]
 
 
 async function execute(id: string, events: Event[], approved = true, confirmationStatus = 200) {
 async function execute(id: string, events: Event[], approved = true, confirmationStatus = 200) {
   const previousURL = process.env.AGENTPAAS_URL
   const previousURL = process.env.AGENTPAAS_URL
   const previousKey = process.env.AGENTPAAS_API_KEY
   const previousKey = process.env.AGENTPAAS_API_KEY
+  const previousState = process.env.AGENTPAAS_BRIDGE_STATE_DIR
+  const state = await mkdtemp(path.join(tmpdir(), "bridge-protocol-"))
+  process.env.AGENTPAAS_BRIDGE_STATE_DIR = state
   const requests: Array<{ path: string; body?: Record<string, unknown> }> = []
   const requests: Array<{ path: string; body?: Record<string, unknown> }> = []
   const updates: Array<{ title?: string; metadata?: Record<string, unknown> }> = []
   const updates: Array<{ title?: string; metadata?: Record<string, unknown> }> = []
   process.env.AGENTPAAS_API_KEY = "synthetic-key"
   process.env.AGENTPAAS_API_KEY = "synthetic-key"
@@ -55,6 +61,9 @@ async function execute(id: string, events: Event[], approved = true, confirmatio
     else process.env.AGENTPAAS_URL = previousURL
     else process.env.AGENTPAAS_URL = previousURL
     if (previousKey === undefined) delete process.env.AGENTPAAS_API_KEY
     if (previousKey === undefined) delete process.env.AGENTPAAS_API_KEY
     else process.env.AGENTPAAS_API_KEY = previousKey
     else process.env.AGENTPAAS_API_KEY = previousKey
+    if (previousState === undefined) delete process.env.AGENTPAAS_BRIDGE_STATE_DIR
+    else process.env.AGENTPAAS_BRIDGE_STATE_DIR = previousState
+    await rm(state, { recursive: true, force: true })
   }
   }
 }
 }
 const done = (status = "completed"): Event => ["done", { run_id: "run_probe", status, output: "ok" }]
 const done = (status = "completed"): Event => ["done", { run_id: "run_probe", status, output: "ok" }]