Background Jobs

慢工作在别处完成时,Agent 可以继续推理。

job_start / job_output / job_list。

85 行3 个工具

问题

s06 的子代理是阻塞的:父循环等它完成才继续。如果子代理要跑 5 分钟,父循环就干等 5 分钟——期间模型在烧 token 空转,什么也做不了。

同样的问题出现在长命令上:npm test 跑 3 分钟,git push 等 CI……慢工作不该阻塞推理。你需要:把工作丢到后台,拿到一个 id,继续推理,需要时再查结果。

解决方案

后台任务协议:

  1. job_start 启动任务,立即返回 job id
  2. job_output 按 id 读取(部分)输出;
  3. job_list 列出所有任务与状态;
  4. 任务完成时,harness 给模型一条完成通知,模型决定是否继续。
const jobs = new Map(); // id -> { kind, status, output, startedAt }
let nextJob = 1;

define("job_start", {
  type: "object", properties: { kind: { type: "string", enum: ["bash", "subagent"] }, label: { type: "string" }, command: { type: "string" } },
  required: ["kind", "label", "command"],
}, ({ kind, command, label }) => {
  const id = `${kind}-${nextJob++}`;
  const job = { id, kind, label, status: "running", output: "" };
  jobs.set(id, job);
  runInBackground(job, kind, command); // 见下:不 await!
  return JSON.stringify({ id, status: "running" });
});

工作原理

第 1 步:后台执行——启动任务后立即返回,任务完成时回调更新状态:

function runInBackground(job, kind, command) {
  if (kind === "bash") {
    const { exec } = awaitImport("node:child_process");
    exec(command, (err, stdout, stderr) => {
      job.output = stdout || stderr || err?.message;
      job.status = err ? "failed" : "completed";
    });
  } else {
    agentLoop(command, { tools: CHILD_TOOLS }).then((r) => {
      job.output = r; job.status = "completed";
    }).catch((e) => { job.output = String(e); job.status = "failed"; });
  }
}

第 2 步:job_output 读取任务状态与输出(可增量读取),job_list 返回概览:

define("job_output", { type: "object", properties: { job_id: { type: "string" } }, required: ["job_id"] },
  ({ job_id }) => { const j = jobs.get(job_id); return j ? JSON.stringify(j) : `未知任务 ${job_id}`; });

define("job_list", { type: "object", properties: {}, required: [] },
  () => [...jobs.values()].map((j) => `[${j.status}] ${j.id} ${j.label}`).join("\n") || "(空)");

第 3 步:完成通知——这是后台任务的关键体验:模型不该轮询,而该被通知。在每轮循环开始前检查是否有新完成的任务:

const notified = new Set();
function pendingCompletions() {
  return [...jobs.values()].filter((j) => j.status !== "running" && !notified.has(j.id))
    .map((j) => { notified.add(j.id); return `任务 ${j.id} 已完成:${j.status}`; });
}
// 每轮:completions 作为 user 消息注入

模型于是可以:"后台跑着测试,我先改代码;测试好了通知我,我再决定下一步。"

试一下

运行 s13:

  • "后台运行 npm test,同时继续做别的事"——观察模型是否先启动任务、再干别的
  • 用 job_output 查看进行中的输出
  • 等完成通知到达后,让模型基于结果继续

观察重点:完成通知在什么时候注入最自然?如果模型不看通知就继续干,会怎样?(DSH 的做法:通知作为 user 消息进入队列,模型迟早会看到。)

以下内容基于 packages/jobsdocs/subsystems/jobs.md 的核查。DSH 的后台任务协议(ctx.jobs)比你的实现多几个关键设计:

一、所有权围栏(owner fencing)

  • 每个 job 有所有者 Agent:访问被其 session id 围栏;Agent 销毁时任务被取消并等待。你的 jobs Map 任何人可读,DSH 的访问受所有权约束。
  • JobId<kind>-N 形式的品牌化 id(如 bash-3),权限依赖所有者授权而非 id 保密。

二、生命周期与生产者契约

  • JobStatusrunning | stopping | completed | killed | failed——比你的三元组完整,因为需要区分"主动停止"与"失败"。
  • JobStart 声明身份与启动器:运行时先做预检(preflight)再调用 run();生产者拥有执行资源,运行时拥有身份与生命周期状态。

三、消费方工具

tool-jobs 暴露 job_output / job_list / job_kill 给模型——与你的工具同名(命名直接来自设计),并负责完成通知的模型可见格式。bashsubagent 是内置的 JobKind。

你的最小实现 DSH 的真实实现
jobs Map 全局可见 owner-fenced 注册表,销毁即取消
完成通知手写注入 tool-jobs 的完成通知协议(含输出上限 outputLimitBytes
状态三元组 五态生命周期 + 停止/等待语义

一句话:后台任务 = "执行与推理解耦"。DSH 的版本把这种解耦做成了有所有权、有生命周期、有通知协议的一等机制。