Skip to content
xiaoyuuuuuupeng
Go back

一次 Sandbox 下 Task Record 维护失败 PR 的排查与 Review 实录

本文记录的是审查当日的代码与结论;PR 状态和代码行号可能继续变化,请以文首 GitHub 链接为准。

更新:PR #2803 已于 2026-09-05 合并。 PRagentscope-ai/agentscope-java#2803
Issue#2743
标题:fix(harness): route task records around the per-call sandbox proxy
作者:birdie7761
Review:LGTM(包含整体意见与逐文件备注)
日期:2026-09-02


前言

这个 PR 动 5 个文件(约 +357 / -4),其中生产逻辑大约只有 49 行,其余是测试与 Javadoc。CI 全绿,合并准备度很高,却卡在「还没有人类 Review」。

它修的问题不那么「用户一眼能看出来」:不是主回复卡住,而是 sandbox 模式下,agent call 已经结束之后,后台任务台账读写全部失败——心跳刷不了、孤儿任务标不成 FAILED、迟到启动的异步任务甚至读不到自己的 record。

本文记录:我们如何理解 Task Record 与 Sandbox 的生命周期差异、bug 到底在哪个时间点发生、本地如何复现、PR 如何把 agents/.../tasks/*.json 从 sandbox 容器改写到 host workspace,以及 Review 时核对过的路由边界。


1. 先搞清上下文:两套生命周期叠在一起

要理解 #2743,先分清两样东西。

1.1 Task Record 是什么?

Task Record 是 后台任务的持久化台账,落在 workspace 相对路径:

agents/<agentId>/tasks/<sessionId>.json

JSON 里是 taskId → TaskRecord 的 map,字段包括状态(PENDING / RUNNING / COMPLETED / FAILED)、结果、错误信息、lastUpdatedAt 等。

它的作用:

用途说明
跨 call 查状态Agent 已回复用户,后台任务可能还在跑
跨节点可见配了 RemoteFilesystemSpec 时,别的节点也能读
孤儿检测sweeper 靠 lastUpdatedAt 判断「是不是没人管了」
取消 / 完成回调cancel 标志、terminal 状态都写在这里

内存里的 BackgroundTaskCompletableFuture)只是本机缓存;Task Record 才是权威真相来源

1.2 Sandbox 是什么?SandboxBackedFilesystem 又是什么?

Sandbox 是 每次 agent call 期间临时租用的隔离执行环境(常见是容器)。Agent 在里面读写代码、跑 shell——安全、隔离。

SandboxBackedFilesystem 可以理解成 沙箱文件系统的代理

Agent 想读写的文件


SandboxBackedFilesystem(进程内常驻代理)
        │  转发给

Sandbox(临时容器)


容器内 /workspace/... 文件

生命周期由 SandboxLifecycleMiddleware 管:

acquireForCall()  → 注入 live sandbox
  … agent call …
releaseForCall()  → sandbox = null(代理还在,会议室钥匙没了)

类比:

概念类比
Host workspace公司办公室文件柜(一直在)
Sandbox临时租的会议室(call 期间开,call 结束锁门)
SandboxBackedFilesystem会议室的钥匙(没会议室就打不开)

1.3 后台还有谁在读写 Task Record?

WorkspaceTaskRepository 启动时(生产环境)会开:

① 任务执行线程池(ws-task-*)
   └── 真正跑异步 LocalTask / RemoteTask

② 定时调度线程(ws-task-maint-*)
   ├── 每 30 秒:heartbeat → 刷新 RUNNING 的 lastUpdatedAt
   └── 每 5 分钟:orphan sweeper → 超时没心跳则标 FAILED

关键矛盾:Task Record 是 跨 call 的编排元数据;Sandbox 是 per-call 的。维护线程和迟到启动的任务,往往跑在 call 已经结束、sandbox 已经释放 之后。

时间 ──────────────────────────────────────────────────────────►

     │◄── Agent Call ──►│                    │
     │  sandbox 活着     │  sandbox = null    │  后台线程还在跑
     │                   │                    │
  acquireForCall    releaseForCall      heartbeat / sweeper / 任务线程

                    【问题从这里开始】

2. 旧代码有什么问题:三种失败模式

配置了 sandbox filesystem 时,WorkspaceManager 读写 Task Record 会走:

WorkspaceManager
  → SandboxBackedFilesystem.read / upload / glob
  → 要求 live sandbox
  → call 外:SandboxConfigurationException("No active sandbox …")

也就是说,旧代码把 task JSON 当成普通 agent 工作文件,错误地一起推进了 sandbox 容器。

2.1 Orphan sweeper(每 5 分钟)

listAllTaskRecords 里无保护地调用 filesystem.glob() → 抛异常 → 整轮 sweep abort。

结果:真正孤儿任务永远标不成 FAILED

2.2 Heartbeat(每 30 秒)

updateStatuspersistTaskMap 走 sandbox FS → 异常被 heartbeat()catch 打成 debug 吞掉。

结果:任务其实还在跑,但 lastUpdatedAt 永不刷新(看起来像静默失败)。

2.3 Async local task 迟到启动

上一个 call 提交了任务,call 结束释放 sandbox 后,线程池里的 supplier 才开始跑。第一步就要 readTaskRecord → 同样抛。

结果:任务根本启动不了(测试里的 task supplier never started)。

2.4 一个具体时间线

10:00  用户:「帮我跑个耗时任务」
10:00  Agent call 开始,sandbox 启动
10:01  putTask → Task Record 写成 RUNNING(此时可能成功)
10:02  Agent 回复「好的,已在后台运行」→ call 结束,sandbox 释放
10:02  任务还在跑……
10:02:30  heartbeat 想更新 lastUpdatedAt → ❌ 写失败
10:07  sweeper 想扫孤儿 → ❌ glob 失败
10:12  record 里 lastUpdatedAt 还是 10:01;sweeper 也读不到 → 状态卡在 RUNNING

2.5 这不是「进程挂了」

需要把两种场景分开:

场景sweeper 能跑吗旧代码能标 FAILED 吗
整个 JVM crash❌ 不能❌ 不能(另一类问题:要靠多节点 / 重启)
进程活着,call 结束,sandbox 释放✅ 能跑不能(#2743 / 本 PR)

PR 修的是第二种:进程还活着、维护线程也在跑,但因为 sandbox 已释放而读写失败


3. 如何排查:我们的 Review 步骤

3.1 第一步:读 Issue + PR 描述,对齐问题定义

#2743#2803 确认:

3.2 第二步:读 diff 核心几处

文件改动
WorkspaceManager.javataskRecordsRouteToLiveSandbox + 读/写/glob 三处分支(核心)
CompositeFilesystem.java新增 filesystemFor(path)
RoutedSandboxFilesystem.java新增 backendFor(path)
WorkspaceTaskRepository.javaJavadoc 补充 sandbox 行为
WorkspaceTaskRepositorySandboxMaintenanceTest.java3 个回归用例(+305)

判定逻辑:

private boolean taskRecordsRouteToLiveSandbox(String relPath) {
    AbstractFilesystem fs = this.filesystem;
    if (fs instanceof RoutedSandboxFilesystem routed) {
        fs = routed.backendFor(relPath);
    }
    return fs instanceof SandboxBackedFilesystem;
}

设计要点:

3.3 第三步:画出两条路径

agents/<agentId>/tasks/<sessionId>.json


    taskRecordsRouteToLiveSandbox(rel)?

       ┌────────┴────────┐
       │ YES             │ NO
       ▼                 ▼
  Host workspace    原有 filesystem 路径
  (readFileQuietly  (readWithOverride /
   / writeLocalFile) writeUtf8WorkspaceRelative)


              例:agents/ 有显式 persistent route
              → MapFs / RemoteFS 等仍服务该路径

逻辑路径字符串 不变,变的是 物理写到哪里

修复前修复后(sandbox proxy 命中时)
路径agents/.../tasks/sess.json同一个相对路径
落盘试图写进 sandbox 容器写进 host workspace 根目录下同名文件

「和没配 sandbox filesystem 时一样」:当 filesystem == null 时,本来就走 writeLocalFile;本 PR 让 sandbox 模式下的 Task Record 也走这条 host 直写路径

3.4 第四步:重点检查清单

对照 ranking 里的 20 分钟 Review 清单:

检查项结论
最长前缀 route 不被 host fallback 绕过backendFor + 第 3 个测试
读路径校验✅ host 读仍 requireSafeRelativePath
写路径校验writeLocalFilestartsWith(workspace)
锁语义✅ 仍经 pathLocks / readTaskMapLocked
HarnessAgent 实际组装形态✅ 裸 SandboxBackedFilesystemRoutedSandboxFilesystem,都能命中
RemoteFilesystemSpec✅ backend 不是 sandbox proxy,走原路径
文档 checklist 未勾⚠️ 内部路由修复,可接受

3.5 第五步:本地复现

cd agentscope-java
gh pr checkout 2803 --repo agentscope-ai/agentscope-java
# 分支:fix/2743-sandbox-task-record-maintenance

cd agentscope-harness
mvn test -Dtest=WorkspaceTaskRepositorySandboxMaintenanceTest

结果:3/3 通过

测试用 从未注入 sandbox 的 SandboxBackedFilesystem 模拟 call 外状态,并把 record seed 在 host @TempDir 上——这正是 releaseForCall 之后的现实。

测试验证点
orphanSweep_marksOrphanFailed_outsideCallContextsweeper 能把 stale RUNNING 标 FAILED
heartbeat_refreshesLastUpdatedAt_outsideCallContextasync supplier 能启动 + heartbeat 能刷新时间戳
routedPersistentBackend_stillServesTaskRecords显式 agents/ route 时不 silent 落到 host

PR 描述里的 TDD 证据:前两个用例在 main 上是红的(expected FAILED but was RUNNING / task supplier never started),修完变绿。


4. PR 如何修复:绕过 per-call sandbox proxy

4.1 三处 IO 入口统一分支

  1. readTaskMap:sandbox proxy → readFileQuietly(workspace.resolve(requireSafeRelativePath(rel)))
  2. persistTaskMap:sandbox proxy → writeLocalFile(rel, serialized)(atomic temp→rename)
  3. listAllTaskRecords:sandbox proxy → 跳过 filesystem.glob(),只靠 host Files.list 枚举(原有 union 的下半段)

配套暴露查询 API(零行为变更):

4.2 Call 内 vs Call 外

场景Agent 读写代码 / 跑脚本Task Record
Call 内仍走 sandbox 容器走 host(本 PR 后一致)
Call 外sandbox 不可用走 host,heartbeat / sweeper 正常

Call 内也把 Task Record 落到 host,是 有意为之:否则 call 内写进容器、call 外维护线程在 host 上读,会立刻出现「写了找不到」。跨 call 的元数据从一开始就不该进临时容器。

4.3 一张图总结

                    agents/test-agent/tasks/sess.json

              ┌───────────────┴───────────────┐
              │                               │
        Agent call 内                   Agent call 外
    (读写代码、跑脚本)              (heartbeat / sweeper)
              │                               │
              ▼                               ▼
      SandboxBackedFilesystem          PR 修复后:
      → sandbox 容器内文件              直接写 host workspace
      (临时、call 结束就没了)         (持久、进程活着就能读写)

5. Review 结论:已解决 vs 非阻塞备注

已解决(值得 Approve)

结论
call 外 orphan sweeper 失败✅ 跳过 sandbox glob + host 落盘
call 外 heartbeat 静默失败✅ host writeLocalFile
async task 起不来✅ host readTaskMap
显式 agents/ persistent routebackendFor + 契约测试
路径安全 / 锁✅ 读校验 + write 边界 + per-file lock
测试证据✅ TDD red→green,三场景锁定
CI✅ License / Module Sync / ubuntu / windows / codecov patch 全绿

非阻塞(不挡 merge)

A. persistTaskMap 写侧与读侧略不对称

读 host 分支显式 requireSafeRelativePath;写 host 分支直接 writeLocalFile(rel)
rel 来自 taskRecordPath,且 writeLocalFilestartsWith(workspace),风险很低——可记作 nit。

B. 文档 checklist 未勾

本次是内部路由修复,Javadoc 已补;公开文档未更新可接受。

C. 进程 crash 仍不在本 PR 范围

JVM 挂了 → ws-task-maint 也没了 → 本机 sweeper 跑不了。那要靠多节点共享存储上的 sweeper,或进程重启后的扫描。本 PR 正确收窄在「进程活着但 sandbox 已释放」。


6. 对话里澄清的几个概念

排查过程中反复对齐的问题,一并记入:

Q:是不是用户 call 以后会开一个线程专门维护 Task Record?

大方向对。 更精确地说是两类后台活动:

  1. 任务执行线程:跑 LocalTaskRunSpec 的 supplier,读写 Task Record 更新状态
  2. 维护定时线程:heartbeat(30s)+ orphan sweeper(5min)

两者都可能在 call 结束后继续碰 Task Record。

Q:Sandbox 情况下 call 结束、sandbox 释放后,维护线程就会写失败?

是(旧代码)。SandboxBackedFilesystem 就会抛 No active sandbox;heartbeat 被 debug 吞掉,sweeper 的 glob 直接 abort。

Q:改写到 host 的文件是不是 agents/<agentId>/tasks/<sessionId>.json

是。 相对路径不变;从「写进 sandbox 容器里的同名路径」改成「写进 host workspace 根下的同名路径」。

Q:SandboxBackedFilesystem 是干什么的?

把文件 / shell 操作转发给 临时 sandbox 的代理。适合 call 内的隔离执行;不适合跨 call 的编排元数据。

Q:orphan sweeper 每 5 分钟,但进程停了还是标不了 FAILED?

对。 进程没了就没人跑 sweeper。那是另一类可靠性问题;本 bug 是 进程还在、sweeper 也触发了,但 IO 失败


7. FAQ

Q:没开 sandbox 会中招吗?

不会。 filesystem == null 时本来就写 host;Remote / 其它非 SandboxBackedFilesystem backend 也不会命中 taskRecordsRouteToLiveSandbox

Q:Agent 在 call 里改代码还会进 sandbox 吗?

会。 本 PR 只特判 Task Record 三条路径;call 内普通文件 IO / execute 仍走 sandbox。

Q:如果配置了 agents/ 显式持久化路由呢?

Task Record 继续走那个持久 backend,不会 silent 落到 host。第三个测试专门锁这个契约。

Q:修完以后旧的「只在 sandbox 容器里」的 task JSON 怎么办?

旧路径在 call 之间本来就读不到(这也是 bug 本身)。修复后新写入统一在 host(或显式 route);没有复杂迁移逻辑,范围合理。

Q:生产改动真的只有几十行吗?

是。 核心在 WorkspaceManager 的判定 + 三处分支;filesystemFor / backendFor 是薄封装;大头是回归测试。


8. Review 时可粘贴的评论骨架

(完整英文稿见 2026-09-02-2803-review-comments-draft.md

整体 Approve 要点:根因匹配、三入口都改到、route 契约有测试、本地 + CI 绿、一个非阻塞 nit(写侧 requireSafeRelativePath)。

逐文件要点

文件评论角度
CompositeFilesystem零行为变更暴露路由查询
RoutedSandboxFilesystembackendFor 防止误判显式 mount
WorkspaceTaskRepositoryJavadoc 说明「不进 sandbox」例外
WorkspaceManager只匹配 SandboxBackedFilesystem;glob 守卫;读安全;写 nit
Testoutside-call 建模准;latch 双职责;routed contract 锁回归风险

9. 小结

阶段要点
理解上下文Task Record 跨 call;Sandbox per-call;维护线程在 call 外跑
根因Task JSON 错误走 SandboxBackedFilesystem,call 外抛 No active sandbox
三种失败sweeper abort / heartbeat 静默失败 / async supplier 起不来
修复命中 sandbox proxy 时 Task Record 改写 host(与 filesystem==null 同路径)
边界显式 agents/ route 仍优先;普通 agent 文件仍走 sandbox
复现WorkspaceTaskRepositorySandboxMaintenanceTest,3/3 绿
ReviewLGTM;非阻塞 nit 写侧 path normalize;文档 checklist 可接受

这是一个 「生命周期边界画清楚、改动面极小、测试把三种失败模式钉死」 的 PR。方向正确,适合作为 Harness committer 练习「先对齐概念再读 diff」的范例。


附录:关键文件索引

路径说明
agentscope-harness/.../workspace/WorkspaceManager.javaTask Record 路由判定与三处 IO 分支
agentscope-harness/.../filesystem/CompositeFilesystem.javafilesystemFor
agentscope-harness/.../filesystem/RoutedSandboxFilesystem.javabackendFor
agentscope-harness/.../filesystem/sandbox/SandboxBackedFilesystem.javaper-call proxy;requireSandbox 抛错处
agentscope-harness/.../middleware/SandboxLifecycleMiddleware.javaacquireForCall / releaseForCall
agentscope-harness/.../subagent/task/WorkspaceTaskRepository.javaheartbeat / sweeper / putTask
agentscope-harness/.../subagent/task/TaskRecord.java台账字段定义
agentscope-harness/.../subagent/task/WorkspaceTaskRepositorySandboxMaintenanceTest.javaPR 回归测试
agentscope-harness/.../HarnessAgent.java组装 SandboxBackedFilesystem / RoutedSandboxFilesystem
AS-java-pr/2026-09-01-harness-pr-ranking.md本 PR 的 20 分钟 Review 清单
Review 评论草稿未纳入本系列公开文章

Share this post:

Previous Post
一次 Harness 异步 Memory Flush PR 的排查与 Review 实录
Next Post
1.1 服务韧性的基本原理