第 25 章:深入 Harbor 的 Job 与 Trial 执行链
一次包含八个 Trial 的故障诊断 Job 被 SIGTERM 中断。值班工程师看到 jobs/system-diagnosis-nightly/result.json 已经存在,便判断“Job 已完成”;另一位工程师发现某个 Trial 只有 config.json,没有 result.json,于是手工删除整个 Job 目录并重跑。前者把实时状态快照误当成终态,后者又丢掉了已经完成的六个 Trial。
要安全处理这类事故,不能只会运行 harbor run。你需要知道 CLI 在什么时候构造 JobConfig,Dataset 如何展开成 Task,Task、Agent 和 attempt 如何形成 TrialConfig,取消与重试如何改变目录,Job 级 result.json 与 Trial 级 result.json 又分别回答什么问题。本章沿 Harbor Framework v0.18.0、提交 527d50deb63a5d279e8c20593c18a2cbc7f61f9e 的真实调用链追踪一次运行;示例不会启动 Docker,也不会伪造完整评测结果。
读完本章,你应该能够:
- 从 CLI 参数追踪到最终
JobConfig,解释配置文件与显式参数的覆盖顺序; - 计算并核验 Dataset、Task、Agent 与 attempt 展开后的 Trial 数;
- 按 Environment、Agent、Artifact、Verifier 和 Result 的顺序定位故障;
- 区分全 Trial 并发上限与仅 Agent phase 生效的并发上限;
- 判断 timeout、取消、重试和恢复分别保留或删除哪些证据;
- 用落盘文件和 Viewer 检查运行中、失败后和恢复后的状态。
25.1 先划清四层接口
Harbor 把 Job 定义为一组 Trial 的入口,把 Trial 定义为 Agent 对一个 Task 的一次 attempt。官方概念文档也明确说 Job 会在内部生成一批 TrialConfig 并并行运行。1 但“内部生成”不代表每个下划线方法都是稳定扩展点。本章把观察面分为四层:
| 层 | 例子 | 本章怎样使用 |
|---|---|---|
| 公开 CLI | harbor run、harbor job resume、harbor view | 运维入口;命令行为可依赖 |
| 公开 Python API | Job、JobConfig、TrialQueue、TrialEvent | 可编程入口;v0.18.0 从 harbor 顶层导出这些符号2 |
| 内部实现 | Job._init_trial_configs()、_remaining_trial_configs、_write_job_result() | 用于解释和测试探针;升级时必须重审 |
| 工程推论 | “先保存 Job 目录再恢复,比整批重跑损失小” | 由源码行为推导,不是 Harbor 的兼容承诺 |
本章会引用内部符号,是为了建立可证伪的心智模型,而不是建议业务代码长期调用它们。生产侧优先调用 CLI 或 await Job.create(config)、await job.run();针对内部方法的测试应被视作版本锁的一部分。
一次单步 Job 的主干可以压缩成下面的调用链:
harbor run / harbor job start
└─ cli.jobs.start()
├─ 读取 YAML/JSON → JobConfig.model_validate()
├─ 用显式 CLI 参数修改 JobConfig
├─ EnvironmentFactory.run_preflight()
└─ Job.create(config)
├─ DatasetConfig → TaskConfig[]
├─ 解析 Metric、缓存 Task
└─ Job.__init__() → TrialConfig[]
└─ Job.run()
├─ 写 Job config.json / lock.json / result.json
└─ TrialQueue + asyncio.TaskGroup
└─ Trial.create(config)
├─ 加载 Task;选择 SingleStepTrial 或 MultiStepTrial
└─ Trial.run()
├─ Environment.start + healthcheck
├─ Agent.setup
├─ Agent.run
├─ 收集 Artifact
├─ Verifier.verify
├─ Environment.stop
└─ 写 Trial result.json,发出 END hook
└─ 聚合 Reward、Metric、Pass@k、token/cost 与异常统计
这张图是对源码的结构化转述。CLI 在同一个异步入口中创建并运行 Job;Job.create() 解析 Task、资源策略和 Metric,随后构造 Job;Job.run() 才写初始状态并把剩余 Trial 交给队列。34
25.2 从 CLI 参数进入 JobConfig
harbor run 是 harbor job start 的别名。--config 只接受 YAML 或 JSON;读取后先执行 Pydantic 校验,再按“传了才覆盖”的方式应用 --job-name、--jobs-dir、-k/--n-attempts、timeout、并发和 retry 等显式参数。Agent、Environment、Verifier 和 Dataset/Task 参数也会继续修改同一个对象;--print-config 在创建 Job 之前打印解析后的配置并退出。5
因此,审查一次昂贵运行时,第一步不是启动,而是保存解析后的配置:
cd /path/to/system-diagnosis-benchmark
harbor run \
--config configs/system-diagnosis-v1.json \
--job-name system-diagnosis-chain-audit \
-k 2 \
-n 2 \
--print-config \
> records/system-diagnosis-chain-audit.resolved.json
python3 -m json.tool \
records/system-diagnosis-chain-audit.resolved.json \
>/dev/null
这条命令不创建 Job,也不启动 Environment。它适合检查“配置文件写的是 4 个并发,为什么本次变成 2 个”之类的覆盖问题。不要把保存的 JSON 当成已经解析到具体 Task digest 的 lock.json:JobConfig 记录请求,Job/Trial lock 记录解析后的可重放输入,两者职责不同。
Dataset、Task、Agent 与 attempt 的笛卡尔积
Job.create() 先复制 config.tasks,再把每个 Dataset 展开得到的 Task 追加进去;二者都为空才拒绝运行。随后 _init_trial_configs() 的三层循环依次遍历 attempt、Task 和 Agent,为每个组合构造一个 TrialConfig。6 因而在没有过滤和重复输入干扰时:
Trial 总数 = 解析后的 Task 数 × AgentConfig 数 × n_attempts
这里的 AgentConfig 数不是 CLI 中 --agent 的字符串个数。若为同一 Agent 传入两个 --model,CLI 会构造两个 AgentConfig;它们各自参与展开。每个 TrialConfig 默认用 Task 短名的前 32 个字符加 7 位随机 ShortUUID 形成 trial_name,所以 attempt 编号不直接出现在目录名中;TrialConfig 的相等比较又特意忽略 trial_name 与 job_id,以便恢复时把新规划的组合与旧结果对齐。7
注意:随机目录名只是一次执行的身份,不是实验主键。关联跨次运行时应使用 Job 配置、Task digest、Agent/Model 和 attempt 设计,而不是比较
trial_name字符串。
解析 Task 不等于开始执行
Dataset 的展开仍发生在宿主侧。对于本地 Dataset,v0.18.0 枚举目录的直接子项,只把 Task.is_valid_dir() 接受的目录转成 TaskConfig;然后依次应用 include glob、exclude glob 和 n_tasks 截断。远程或 package Task 则先解析引用,随后由 TaskClient 批量下载或命中缓存。Job 在提交任何 Trial 前完成这一步,并保留每个 Task 的下载解析结果,供 lock 计算 digest 或 resolved commit 使用。8
这个顺序带来三个排障结论。第一,本地 Dataset 中一个拼错 task.toml 或缺少 tests 的目录可能根本不会形成 Trial;看到 Trial 数少了,应先审查 Dataset 展开,而不是等待队列日志。第二,n_tasks 在 include/exclude 之后生效,所以它是筛选后截断,不是先随机抽样;依赖目录顺序做“随机小样本”会把文件系统状态混进实验。第三,远程下载失败发生在 Job.create(),此时 Trial 目录尚未出现,把它归类为“Agent 没启动”虽不算错,却会遮住真正的 Task resolution 阶段。
建议把期望组合写成执行前门禁。对于固定 Dataset,保存 Task 名称清单、数量和 lock 中的 digest;对于开发中本地 Dataset,至少输出排序后的相对路径。只断言总数仍不够,因为“少了一个任务又多了一个重复任务”可能保持总数不变。一个稳妥的断言同时检查集合、唯一性和展开公式,再允许 Job 进入 Environment preflight。
25.3 Trial 内部不是一条“Agent→分数”直线
Trial.create() 会先解析 Task;Task 含 [[steps]] 时返回 MultiStepTrial,否则返回 SingleStepTrial。基类构造阶段就创建 Trial 目录和 lock.json,初始化 Agent、Environment、ArtifactHandler 与 timeout;run() 再初始化 TrialResult,发出 START,执行 prepare 和具体 workload,最后无论成功还是失败都尝试停止 Environment、写 result.json 并发出 END。9
对于本书贯穿项目中的单步 Task,顺序更精确地说是:
- 启动 Agent Environment,运行 healthcheck,上传注入的 Skill;
- 在 Task 指定用户下执行
agent.setup(); - 执行
agent.run(),同步 Agent 日志与AgentContext; - 收集约定目录及配置声明的 Artifact;
- 在 shared 或 separate Environment 中执行 Verifier;
- 停止 Environment,由基类写最终 TrialResult。10
多步 Task 不会为每个 step 创建新的 Job Trial。MultiStepTrial 在同一个 TrialResult 中顺序追加 StepResult,每步依次准备 workdir/setup/healthcheck、运行 Agent、收集并归档本步 Artifact、运行本步 Verifier;满足停止条件时不再进入后续 step,最后按 Task 的 multi-step reward strategy 选择或聚合 Trial 级 VerifierResult。11 因此 JobStats 仍把它算作一个 Trial,逐步诊断则要进入 step_results 和 steps/<step-name>/,不能拿 step 数替换 n_total_trials。
用 lifecycle event 给阶段定界
Trial 提供 START、ENVIRONMENT_START、AGENT_START、AGENT_END、VERIFICATION_START、END 与 CANCEL 七类事件。传给 hook 的 TrialHookEvent 同时包含配置、当前 Result、TrialLock 和时间戳;Result 在 START 前已初始化,到 END 时才应视为完整。12 这些事件比在自然语言日志里搜索“starting”更适合作为监控边界,但 hook 依然是执行链的一部分:一个缓慢或失败的 callback 会影响正在等待它的 Trial,不能塞入无界网络请求。
排查时可以把阶段证据按下面的顺序收窄:
| 最后可见事件 | 优先检查 | 暂时不要归因于 |
|---|---|---|
| 只有 START | Task 加载后到 Environment 事件前的本地初始化、hook | 模型质量 |
| ENVIRONMENT_START,无 AGENT_START | build/start、healthcheck、Skill 上传、Agent setup | Prompt |
| AGENT_START,无 AGENT_END | Agent 进程、模型请求、工具调用、Agent timeout | Verifier |
| AGENT_END,无 VERIFICATION_START | 日志同步、Artifact 收集、阶段异常 | Reward 聚合 |
| VERIFICATION_START,无 END | tests、Reward 文件、Verifier timeout、cleanup | Dataset Metric |
| CANCEL 后 END | 外部取消后的恢复与清理 | 普通 retry 分支 |
这张表是基于单步调用顺序的工程排查法,不保证某个 Provider 一定会输出同名底层日志。特别是 Agent timeout:SingleStepTrial._run_agent() 会记录 AgentTimeoutError,随后仍同步日志、收集 Artifact,并继续尝试 Verifier。因此看到 VERIFICATION_START 并不能反证 Agent 阶段曾超时;应读取同一 TrialResult 的 exception_info。
如果需要流式日志,v0.18.0 另有 LogEntry,phase 只取 agent_setup、agent 或 verification,并携带 stdout/stderr、文本、时间戳和可选 step name。12 它适合把阶段输出接到观察系统,但不应替代最终文件:流可能在断网时丢失,Trial 目录里的 result、lock 与 Artifact 才是恢复后可重新扫描的事实面。
Artifact 不是 Verifier 之后附带复制的一堆文件。单步路径在 Agent 结束后、Verifier 开始前收集 Artifact;separate verifier 会把收集结果重新放到独立 Environment 的原始 source 路径。每次收集还生成 artifacts/manifest.json,记录 source、destination、service、类型与 ok、failed 或 skipped 状态。Artifact 下载是 best-effort:某项失败会进入 manifest,而不是自动把 Trial 判成失败。13
“Solution”也不是所有 Trial 都会执行的一般阶段。普通 Agent 不读取 solution/;只有 OracleAgent 在 run() 中选择 OS 兼容的 solve 脚本,把 solution 目录上传到 /solution 后执行。14 由此可以推断:在自定义 Agent 的调用链图里加入固定的 Solution.run() 节点,会错误暗示参考答案对被测 Agent 可见。正确模型是“Agent phase;当 Agent 恰好为 oracle 时,其实现使用 Solution”。
四类 timeout 要分开看
Harbor 在 Trial 中分别计算 Environment start、Agent setup、Agent execution 和 Verifier 的 timeout。Environment start、setup 和 Agent execution 都由 asyncio.wait_for() 包裹并转换为不同异常;Verifier 也通过 wait_for() 限时。通用 timeout_multiplier 是后备值,各 phase multiplier 非空时优先;Agent 和 Verifier 还可以设置 override 与 max。15
| phase | timeout 后的异常 | 默认 retry 排除列表是否包含 |
|---|---|---|
| Environment start | EnvironmentStartTimeoutError | 否 |
| Agent setup | AgentSetupTimeoutError | 否 |
| Agent execution | AgentTimeoutError | 是 |
| Verifier | VerifierTimeoutError | 是 |
这张表描述 v0.18.0 的默认配置,不是重试建议。Environment 无法构建通常是确定性问题;即使默认类型过滤允许重试,也应通过 --retry-include 建立显式白名单,避免删除首轮证据后重复同一失败。
25.4 并发发生在两个不同的时间尺度
TrialQueue 用一个 asyncio.Semaphore(n_concurrent_trials) 包住整个 Trial,因此 build、setup、Agent、Verifier 和 teardown 都占用全局 Trial 配额。Job 用 asyncio.TaskGroup 调度所有剩余 coroutine;返回顺序保持与输入配置一致。16
AgentConfig.n_concurrent 则只在 AGENT_START hook 获取 permit,在 AGENT_END 释放;取消或 hook 失败时,END/CANCEL hook 负责兜底释放。同一个 concurrency_group 可以让多个 AgentConfig 共用 Agent phase 池。JobConfig 会拒绝 Agent 上限高于全局上限、只写 group 不写上限,或同一 pool 使用不同上限。17
例如 n_concurrent_trials=8、同一 API Provider 的 Agent pool 为 2,可能同时构建或验证 8 个 Trial,但最多 2 个进入 Agent phase。由此可以推断,这种配置能保护模型限流,却不能把 Docker build、Verifier CPU 或云沙箱数量也限制为 2;这些资源需要全局上限或 Provider 侧策略。
还要避免把“提交顺序”理解为“完成顺序”。Job 创建的 coroutine 列表按展开顺序排列,最终收集结果也按任务对象列表取回,但 semaphore 只控制同时进入的数量;Environment 启动速度、Agent 响应和 Verifier 耗时都能让后提交的 Trial 先 END。实时仪表盘应根据 event timestamp 和 trial identity 更新,不要用列表下标推断当前 attempt。最终统计又是按 evals key 聚合,完成顺序不会改变计数,却会改变你在运行中看到的样本组成。
容量设计可以分两步完成。先用全局并发约束最昂贵的共享资源,例如本机可承受的沙箱数;再用 Agent pool 约束模型 Provider 的并发请求。若 Verifier 自身也调用模型,Agent pool 不会保护它,必须在 Verifier 实现或外部服务上另设限流。这里没有一个“越大越好”的值:提高全局并发可能缩短墙钟时间,也可能增加 build 争用、API 限流和取消清理压力,必须以小规模故障注入验证。
25.5 落盘状态就是恢复协议
Job 开始时先写 config.json、lock.json 和不含 TrialResult 列表的初始 result.json;Trial 的 START、CANCEL、END hook 会在锁保护下刷新运行数、取消数、完成数、异常数和 retry 数。全部 Trial 收束后,内存中的 JobResult.trial_results 包含组合结果,但 Job 级 result.json 仍以 exclude_trial_results=True 写出;完整 Trial 结果各自在子目录的 result.json 中。18
jobs/system-diagnosis-chain-audit/
├── config.json # JobConfig 请求
├── lock.json # 解析后的 Harbor/Task/Agent/Environment/Retry 输入
├── result.json # Job 进度和聚合统计,不内嵌 TrialResult[]
├── job.log
└── <task-short-name>__<7 chars>/
├── config.json # TrialConfig
├── lock.json # 解析后的单 Trial 输入
├── result.json # TrialResult;结束后写入
├── exception.txt # 发生异常时才有
├── trial.log
├── agent/
├── verifier/
└── artifacts/
└── manifest.json
源码中 TrialPaths.result_path 明确返回 result.json,Trial finalize 也写这个路径。19 排障脚本应读取属性对应的实际文件,不要凭旧截图或记忆猜成 results.json。
一个可靠的状态检查顺序是:
JOB=jobs/system-diagnosis-chain-audit
python3 -m json.tool "$JOB/config.json" >/dev/null
python3 -m json.tool "$JOB/lock.json" >/dev/null
jq '{finished_at, n_total_trials, stats}' "$JOB/result.json"
find "$JOB" -mindepth 2 -maxdepth 2 -name config.json | sort
find "$JOB" -mindepth 2 -maxdepth 2 -name result.json | sort
find "$JOB" -mindepth 2 -maxdepth 2 -name exception.txt | sort
三个 find 集合的差异比“子目录数”有意义:有 Trial config 没有 Trial result,表示它可能仍在运行或曾中断;有 exception 不代表没有 VerifierResult,因为 Agent execution timeout 会被记录后继续收集 Artifact 和尝试验证;最终判断应解析 TrialResult.exception_info 与 verifier_result,不能只看文件存在性。
正确读取 JobStats
n_completed_trials 是已经产生 END 结果的逻辑 Trial 数,n_errored_trials 是其中带 exception_info 的数量,n_running_trials 和 n_pending_trials 则由事件更新;取消同时属于 completed、errored 和 cancelled,而不是第四个互斥集合。18 因此,下面的等式比“completed 等于成功”准确:
未收束 = n_total_trials - n_completed_trials
成功候选 = n_completed_trials - n_errored_trials
即使如此,“成功候选”也不等于通过。一个没有 Python 异常、但 Reward 为 0 的 Trial 属于正常完成的失败答案;一个带 Agent timeout、但 Verifier 恰好从已有状态得到 Reward 的 Trial 同时有 exception 和 verifier result。发布门禁应把生命周期、异常和评分分成三列,而不是压成一个布尔值。
JobStats 的 evals key 由 Agent 名、可选 Model 名和 Dataset source 拼接。只有存在 verifier_result.rewards 的 Trial 才增加该 eval 的 n_trials 和 Reward 分布;带异常的 Trial会增加 exception 统计。token 与 cost 则从单步 AgentContext 或多步各 step context 汇总;字段没有数据时保持 None,不能把“未报告”自动解释为零成本。20
这也说明为什么 Job 级 result 不应成为唯一归档。它适合快速判断进度和聚合异常,却不内嵌最终 TrialResult;要审计某次 Reward 的来源,仍需找到对应 Trial 目录,核对 config、lock、agent/verifier 日志和 Artifact manifest。反过来,只保存 Trial 子目录而丢掉 Job config 与 lock,也会失去展开规模、全局 retry 和并发上下文。生产归档必须保留整个 Job 目录,并对归档内容做敏感信息扫描。
25.6 重试、取消与恢复的真实边界
重试会替换一次 attempt,而不是新增一个实验样本
TrialQueue 每轮重新调用 Trial.create()。若返回的 TrialResult.exception_info 为空就结束;否则先应用 exclude,再应用 include。满足策略且尚未达到 max_retries 时,队列删除整个 Trial 目录,按 min_wait_sec × wait_multiplier**attempt 计算并受 max_wait_sec 截断的等待,然后用同一个 TrialConfig 重建。21
默认 max_retries=0。默认 exclude 包含 Agent/Verifier timeout、Reward 文件缺失或为空、Verifier 输出解析错误和 API usage limit;exclude 优先于 include。22 每轮 Trial 都会发出 END,所以 Job 会短暂记录失败;下一轮 START 时,Job 移除前一轮对统计、Reward、token 和 cost 的贡献,再增加 n_retries。最终一个逻辑 Trial 只保留最后一次贡献。
警告:重试前的
shutil.rmtree(trial_dir)会删除本地首轮日志、异常和 Artifact。若某种失败需要保留原始证据,不要盲目开启 retry;使用 END hook 或外部日志系统在目录删除前归档,并避免把凭据一并上传。
取消不走上述结果驱动的 retry
CLI 把 SIGTERM 转成 KeyboardInterrupt。当异步任务被取消时,Trial.run() 捕获 CancelledError,记录异常、尽力恢复输出、发出 CANCEL,然后重新抛出;finally 仍停止 Environment、写 Trial result 并发出 END。23 因为取消被重新抛出而不是作为普通 TrialResult 返回,TrialQueue 的 exception-type retry 分支不会接管它。
这也解释了为什么“设置 --retry-include CancelledError”不是可靠的中断恢复方案。公开运维入口是:
harbor job resume \
--job-path jobs/system-diagnosis-chain-audit
resume 默认过滤 CancelledError:CLI 先删除对应 Trial 目录,再从 Job 的 config.json 创建 Job,完成的其他 Trial 得以保留,被删除的组合重新进入 remaining 集合。可以多次传 --filter-error-type 选择其他需要重跑的异常。24
同名 Job 不是覆盖开关
Job 构造时若发现已有 Job result.json,会复用原 Job UUID;若已有 Job config.json 与当前配置不同,则抛出 FileExistsError。JobConfig 相等比较只忽略 job_name 和 debug;TrialConfig 对齐只忽略 trial_name 与 job_id。有效的旧 Trial config/result 会与新规划组合匹配并跳过;没有 result.json 的子目录会被删除;空、不可读或无法解析的 result 会记录 warning 并跳过,不会被静默算作完成。25
Job lock 还独立比较 schema、全局并发、RetryConfig 以及无序的 TrialLock 集合;TrialLock 包含 Task digest、Agent、Skill digest、Environment、额外 compose、Verifier 与 timeout 等解析结果。旧 lock 无法解析或解析结果不同都会拒绝覆盖。26
由此可以推断:同一个 job_name 应视为受约束的恢复地址,而不是“永远覆盖最新结果”的标签。想运行新实验就使用新名称;想继续旧实验就调用 harbor job resume,并先备份目录。不要通过修改旧 config.json 强行通过相等检查,那会让 config、lock 和已有 TrialResult 互相矛盾。
一张恢复决策表
恢复前先把每个 Trial 归入证据状态,而不是按异常名称批量删除:
| Trial 目录状态 | Job 构造时的行为 | 建议操作 |
|---|---|---|
无 result.json | 删除目录,组合重新进入 remaining | 先归档仍有价值的 agent/trial 日志 |
| result 为空或 JSON 无法解析 | warning 后跳过,不计完成;目录原样保留 | 隔离副本,查写入中断或磁盘问题 |
| result 有效、config 有效且组合匹配 | 作为 existing result 保留 | 抽检 lock 与 Result 后继续 |
| result 有效但组合无法与计划匹配 | 最终对齐时报错 | 禁止自动删除,先查 config 漂移或孤儿目录 |
| result 的异常命中 resume filter | CLI 在 Job.create 前删除整个目录 | 明确审批过滤集合和证据归档 |
| Job config 或 lock 与当前解析结果不同 | 拒绝恢复/覆盖 | 新建 Job,或恢复正确的历史输入 |
注意空或坏 result 与“没有 result”的行为不同:前者保留目录并跳过,后者会被 Job 构造直接删除。这个差异适合做恢复脚本的负向测试。若脚本先把坏 JSON 删成不存在,随后调用 resume,就把“需要调查的损坏状态”变成“允许清理并重跑”,审计含义已经改变。
恢复的验收也不应只看 CLI 退出码。至少验证:Job UUID 与旧 result 一致;旧完成 Trial 的目录和 Result digest 未改变;被过滤的组合生成了新的 Trial identity;n_retries 与本次恢复语义一致;最终 pending/running 为 0;每个 planned 组合恰好有一个最终 Result。最后一条必须按 TrialConfig 相等语义对齐,不能用随机 trial_name 猜测。
警告:
harbor job resume会按过滤条件递归删除对应 Trial 目录。运行前复制或不可变归档原目录,并把过滤的异常类型写入变更记录。恢复提升的是可继续性,不会自动满足取证保留要求。
25.7 无 Docker 的执行链探针
下面的探针只解析两个教学 Task、构造 Job、写合成的状态文件并用 fake Trial 注入一次 RuntimeError。它不调用 job.run(),不启动 Environment,也不把合成状态声称为真实评测。保存为 scripts/ch25_chain_probe.py,从 Harbor v0.18.0 源码根目录运行。
import asyncio
from datetime import datetime, timezone
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from unittest.mock import patch
from harbor import (
AgentInfo, DatasetConfig, ExceptionInfo, Job, JobConfig,
JobResult, JobStats, RetryConfig, TrialQueue, TrialResult,
)
from harbor.models.trial.config import AgentConfig
from harbor.viewer.scanner import JobScanner
TASK_TOML = """\
[task]
name = "diag/{name}"
description = "chapter 25 probe"
[agent]
timeout_sec = 5.0
"""
def make_task(root: Path, name: str) -> None:
task = root / name
(task / "environment").mkdir(parents=True)
(task / "tests").mkdir()
(task / "task.toml").write_text(TASK_TOML.format(name=name))
(task / "instruction.md").write_text(f"diagnose {name}\n")
(task / "environment" / "Dockerfile").write_text("FROM scratch\n")
(task / "tests" / "test.sh").write_text("#!/bin/sh\nexit 0\n")
def result_with_error(config, kind="RuntimeError") -> TrialResult:
return TrialResult(
task_name=config.task.get_task_id().get_name(),
trial_name=config.trial_name,
trial_uri=f"file:///synthetic/{config.trial_name}",
task_id=config.task.get_task_id(),
task_checksum="probe-only",
config=config,
agent_info=AgentInfo(name="nop", version="probe"),
exception_info=ExceptionInfo(
exception_type=kind,
exception_message="injected",
exception_traceback="injected by chapter probe",
occurred_at=datetime.now(timezone.utc),
),
)
async def main() -> None:
with TemporaryDirectory(prefix="harbor-ch25-") as tmp:
root = Path(tmp)
dataset = root / "diag-dataset"
dataset.mkdir()
make_task(dataset, "disk")
make_task(dataset, "network")
config = JobConfig(
job_name="ch25-chain-probe",
jobs_dir=root / "jobs",
n_attempts=2,
n_concurrent_trials=2,
agents=[AgentConfig(name="nop"), AgentConfig(name="oracle")],
datasets=[DatasetConfig(path=dataset)],
)
job = await Job.create(config)
try:
# 以下下划线属性只用于锁定版本的源码追踪测试。
assert len(job._task_configs) == 2
assert len(job._trial_configs) == 8
assert len({c.trial_name for c in job._trial_configs}) == 8
print("expand: tasks=2 agents=2 attempts=2 trials=8 unique_names=8")
# 只构造运行中状态;绝不启动 Environment。
job._job_config_path.write_text(config.model_dump_json(indent=2))
job._job_result_path.write_text(JobResult(
id=job.id,
started_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
n_total_trials=8,
stats=JobStats.from_counts(n_total_trials=8),
).model_dump_json(indent=2))
pending = job._trial_configs[0]
pending_dir = job.job_dir / pending.trial_name
pending_dir.mkdir()
(pending_dir / "config.json").write_text(pending.model_dump_json())
scanner = JobScanner(config.jobs_dir)
assert scanner.list_trials(config.job_name) == [pending.trial_name]
assert scanner.get_trial_result(config.job_name, pending.trial_name) is None
print("viewer: config-only trial is visible; result is null")
finally:
job._close_logger_handlers()
resumed = await Job.create(config)
try:
assert not pending_dir.exists()
assert len(resumed._remaining_trial_configs) == 8
print("resume: incomplete trial dir removed; remaining=8")
finally:
resumed._close_logger_handlers()
changed = config.model_copy(update={"n_attempts": 3})
try:
await Job.create(changed)
except FileExistsError as exc:
assert "different config" in str(exc)
print("guard: same job name + different config rejected")
else:
raise AssertionError("changed config unexpectedly accepted")
calls = 0
retry_dir = root / "retry-trial"
class FakeTrial:
def __init__(self, trial_config):
self.config = trial_config
self.paths = SimpleNamespace(trial_dir=retry_dir)
def add_hook(self, _event, _hook):
return None
async def run(self):
nonlocal calls
calls += 1
retry_dir.mkdir(exist_ok=True)
if calls == 1:
(retry_dir / "stale.txt").write_text("first attempt")
return result_with_error(self.config)
assert not (retry_dir / "stale.txt").exists()
result = result_with_error(self.config)
result.exception_info = None
return result
async def fake_create(trial_config):
return FakeTrial(trial_config)
queue = TrialQueue(
n_concurrent=1,
retry_config=RetryConfig(
max_retries=1,
include_exceptions={"RuntimeError"},
exclude_exceptions=None,
min_wait_sec=0,
max_wait_sec=0,
),
)
with patch("harbor.trial.trial.Trial.create", side_effect=fake_create):
final = await queue.submit(resumed._trial_configs[0])
assert calls == 2 and final.exception_info is None
print("retry: attempts=2 stale_dir_removed=true final=success")
asyncio.run(main())
执行命令:
cd /private/tmp/harbor-framework-v0.18.0
uv run --frozen python \
/path/to/system-diagnosis-benchmark/scripts/ch25_chain_probe.py
本章在锁定源码环境中的实际输出如下;这些行是结构探针结果,不是 Docker 或模型评测结果:
expand: tasks=2 agents=2 attempts=2 trials=8 unique_names=8
viewer: config-only trial is visible; result is null
resume: incomplete trial dir removed; remaining=8
guard: same job name + different config rejected
retry: attempts=2 stale_dir_removed=true final=success
把这五行当作最小验收,而不是演示输出即可。第一行证明展开公式与唯一命名;第二行证明 Viewer 能表达运行中状态;第三行证明 incomplete 目录会在同配置恢复时清理;第四行证明同名地址不能接受不同实验;第五行同时证明 exception filter、目录删除和 Trial 重建。任意一行失败,都应停止升级或部署,因为执行链的关键假设已经变化。
如果要把探针放进项目 CI,建议固定 Harbor wheel 或 commit,保留 --frozen,并把探针放在快速确定性阶段。不要把它与真实 Docker benchmark 混成一个测试:结构探针失败说明 API/实现契约变化;容器集成失败可能来自镜像、daemon、网络或 Task;模型评测失败还可能来自 Provider。分层报告能让值班人员在几分钟内知道该联系框架维护者、基础设施团队还是 Benchmark 作者。
探针还验证了 Viewer 的一个重要契约:扫描器会列出有 config.json 或 result.json 的 Trial 目录;运行中的 Trial 没有结果时,Viewer 可从 config 构造部分摘要,而不是假装它已完成。27 因此可视化与落盘状态并不冲突,前提是操作员理解 reward=None、finished_at=None 的含义。
启动本地 Viewer 时,应把参数指向 Job 的父目录:
harbor view jobs/system-diagnosis-runs \
--jobs \
--host 127.0.0.1 \
--port 8080-8089
v0.18.0 会在范围内寻找可用端口;静态前端缺失且禁止构建时仍可进入 API-only 模式。默认绑定本机地址更适合包含轨迹、日志和 Artifact 的结果目录。28
25.8 失败模式与排查顺序
Job result 已存在,但 finished_at 为空。 这是合法的运行中快照。先看 stats.n_running_trials、n_pending_trials,再比较 Trial config/result 集合;不要据文件存在就发布结果。
同名重跑报 different config。 比较保存的 Job config.json 与本次 --print-config,再比较 lock。常见差异包括 attempts、Agent/Model、Dataset ref、timeout、Artifact 和 RetryConfig。新实验换名字;恢复旧实验用 resume。
开启 retry 后首轮证据消失。 这是队列在下一轮前删除 Trial 目录的设计结果。先把可重试异常收窄为暂时性错误,再通过 hook 做受控归档;不要把 retry 当日志保留机制。
取消后 Job 级完成数看似增加。 Trial finally 会写包含 CancelledError 的结果并触发 END,完成数表示生命周期已收束,不等于成功数。结合 n_errored_trials、n_cancelled_trials 与 Trial exception 判断。
Viewer 里出现无 Reward 的 Trial。 若只有 config,可能仍在运行;若有 result,检查 exception_info、verifier_result 和 verifier 日志。不要把 None 强制转成 0 后与确定性失败混为一类。
25.9 本章小结
- CLI 先建立并覆盖 JobConfig;
--print-config是执行前的第一道门禁。 - Job 将解析后的 Task、Agent 和 attempt 展开成 TrialConfig,随机 Trial 名不是跨 Job 主键。
- Trial 的真实顺序包含 Environment、setup、Agent、Artifact、Verifier、cleanup 与落盘;Solution 只属于 oracle 实现。
- 全局 semaphore 限制整个 Trial,Agent semaphore 只限制 Agent phase。
- retry 删除旧 Trial 目录并替换统计;取消重新抛出,应通过 resume 恢复。
- Job result 是聚合状态,Trial result 才是单次事实;Viewer 同时理解完成与 config-only 的运行中目录。
- 同名 Job 受 config 与 lock 一致性保护,不能当作覆盖开关。
25.10 练习
- 把探针改为 3 个 Task、2 个 Agent、3 次 attempt,先写出预期 Trial 数,再用断言验证;解释为什么不能从目录名恢复 attempt 编号。
- 给
RetryConfig同时设置include_exceptions={"RuntimeError"}与exclude_exceptions={"RuntimeError"},验证 exclude 优先,并说明这条负向门禁应放在何处。 - 构造一个有效 TrialResult 和一个空
result.json,再创建同配置 Job;比较两者进入_existing_trial_results与_remaining_trial_configs的差异。 - 为 Job 注册 START、CANCEL、END hook,把事件写入临时 JSONL;用一个会取消的 fake Trial 验证事件顺序,同时保证不启动 Docker。
- 设计一份生产恢复操作单:包括目录备份、config/lock 校验、异常过滤审批、Viewer 复核和恢复后完成标准。明确哪些步骤是 Harbor 行为,哪些是团队策略。