DSH / Atlas
2026-09-03implementedarchitecture

One output ring, one projection, one event stream for background jobs

后台 job 的一个输出环、一个投影、一条事件流

The job seam had grown by accretion. Producers declared their output twice — a consuming `readOutput` hook the model read and an optional `record: true` ring observers read — and the registry kept two vocabularies for one job (`JobSnapshot` for the model, `SessionJob` on the session control stream, `JobWireChunk` on the observation wire). Three listener families (`onJobDone`, `onJobsChanged`, `onOutput`) delivered ov

Source compatibility: Chinese title uses a compatible non-canonical header

English

Problem

The job seam had grown by accretion. Producers declared their output twice — a consuming readOutput hook the model read and an optional record: true ring observers read — and the registry kept two vocabularies for one job (JobSnapshot for the model, SessionJob on the session control stream, JobWireChunk on the observation wire). Three listener families (onJobDone, onJobsChanged, onOutput) delivered overlapping facts with different owner filters, and the registry itself tracked reported, a bit that only the model-facing tool could interpret. Review of the merged design called the interface layer the weakest part: a reader could not tell which of read, readRecord, JobStart, RecordingJob, and updateDetail was the contract and which was an accident of history.

Decision

  • One ring per job. JobSpec replaces JobStart: a spec names optional pull output sources (JobOutputSource, the subprocess readFrom family) that the registry pumps at its own cadence (pumpPollMs on dsh-jobs-local), and every producer receives a JobHandle whose append pushes chunks into the same ring. The model's consuming read and every observer's absolute-offset read serve the same bytes; readOutput and the record discriminator are gone. Shell producers hand the registry their observed offset readers and fold sandbox facts into the terminal detail; the terminal producer adapts its consuming send reader through a byte-counting source; the subagent returns its answer as JobOutcome.result, which the first read after settlement carries once.
  • Two cursors, one storage. JobRegistry.read(id) is the stateful model cursor; JobRegistry.readAt(id, from) is the stateless observer read. Model reads exclude log chunks (producer narration for observers) and render stdout, then one [stderr] section, exactly as the shell tools always did.
  • updateProgress replaces updateDetail. JobView.progress is the live line and is cleared at settlement; JobView.detail is the terminal reason, with the model's job_kill reason merged in.
  • One projection. JobView (client-safe leaf @deepseek-ai/dsh-jobs/view) is what the model tools, the browser roster, and the observation frames consume; owner is the session id, never the Agent.
  • Direct caller-scoped operations. JobRegistry.list, get, read, readAt, kill, and wait accept the caller session on each call. Omitting the caller permits only unowned jobs. The output ring and browser streams do not require a separate caller-bound operation object.
  • One event stream. events.subscribe(filter, listener) with { owner }, { owners: 'scope' }, or { owners: 'all' } replaces the three listener families. settled names its cause (producer, kill, teardown); output carries only the id and the new total.
  • The notice ledger moves to dsh-tool-jobs. The registry no longer tracks reported. The tool claims a job when a wait starts (and withdraws on timeout or abort) and when job_kill is accepted; the settlement event drops the entry, teardown settlements and unowned jobs are skipped, and the destination is the agent registered for the owner session at settlement.
  • The roster leaves the session control stream. dsh-api-job-controller owns job.list({ sessionId }) — whole-set frames after each coalesced burst of lifecycle commits, never per append — beside job.follow, and its client installs ctx.jobs (watchRows, observe, one snapshot). dsh-api-session-controller carries queues and projections only; ui-jobs watches its session's roster while mounted, and a row is expandable while live or once it left retained output behind.

attachController and the owner-relative servesOwner gate stay as they were; whether a producer may offer run_in_background without a mounted controller is deferred, since the gap predates this change.

Alternatives considered

Separate lifecycle and observation registries. One producer would register the same work twice and coordinate terminal state, correlation IDs, and two client rosters. A job therefore owns both lifecycle and observation. Foreground-only or model-invisible jobs would require separate reporting and visibility rules; foreground workflow narration gains live observation by becoming an ordinary background job instead.

  • Keep three listeners with a shared owner filter. It preserves the accreted names but not the property reviewers asked for — one place to learn what the registry announces — and onJobDone's exact-owner delivery is what forced the registry to keep an Agent in every projection.
  • Keep reported in the registry. Only the tool knows which deliveries reach the model (a wait's tool result, a kill's acknowledgement); the registry could only approximate it, and the approximation was the source of the double-notice and missed-notice bugs the review listed.
  • Keep the roster on the session control stream. It saved one Remote stream but coupled the session controller to the job registry and mirrored a record flag the ring's byte count now answers directly (output.total).
  • Serve log chunks to the model too. Producer narration is for observers; the model already receives the same facts through progress and the terminal detail.

Consequences

The model-visible text is unchanged: status lines, tool descriptions and schemas, and notice wording are byte-identical, and the only new model-visible fact is the kill reason a killed status line now carries. A producer that needs to stream output picks pull sources or append; it never formats a consuming delta. The Web client renders every live job as expandable, so a job that never writes shows an empty running panel instead of a static row. JobKindMap merges are host-side, so a browser program types JobView.kind as the kinds it knows and treats the rest as opaque strings, which is all the roster does with it.

Review follow-ups after the master merge tightened four of these edges. A job's first event is always registered: the announcement precedes the pump, whose first drain runs synchronously and may announce output. A settled ring keeps every byte the model cursor has not consumed until the first terminal model read, so a job that finishes before its first job_output loses nothing the live cap retained. dsh-tool-jobs holds one claim per wait call with a live flag that the caller's abort clears synchronously from the abort event, so a settlement announced before the registry's rejection reaches the tool still delivers its notice, and a timed-out wait drops only its own claim while a concurrent wait keeps the settlement covered. The browser records an observation failure that precedes the anchor as an errored view, so the panel shows the notice instead of staying blank. The spill file a pull source keeps is job metadata (JobView.output.spillPaths, reported by every pump read and withdrawn once a read stops naming it), not per-chunk metadata, and the model's read renders the shell tools' dropped-output notice for a producer-side gap as well as for ring eviction, naming those files even after the gap chunk itself was evicted — the before this read variant this change had introduced is gone. An observation generation that sees its job removed mid-stream (the owner's teardown) closes with the removed job's terminal projection instead of a failed read. A rejected background spawn produces no process output, so the shell providers now serve the spawn failed: … note as the whole observed stderr stream (the consuming read still folds it in once), and ShellProcess.observed with both readers is required: the model's first job_output after such a failure reads exactly what master's consuming read rendered.

The Session lifecycle decision records registration and settlement for durable SDK consumers while preserving this live event stream and output ownership.

中文

问题

job seam 是逐步堆出来的。生产者要把输出声明两次——模型读取的消耗式 readOutput 钩子,以及观察者读取的可选 record: true 环——注册表也为同一个 job 保留了两套词汇(模型侧的 JobSnapshot、会话控制流上的 SessionJob、观测线路上的 JobWireChunk)。三个监听器家族(onJobDone、onJobsChanged、onOutput)用不同的拥有者过滤投递彼此重叠的事实,注册表自己还跟踪着只有模型侧工具才解释得了的 reported 位。合并后设计的评审把接口层称作最薄弱的部分:读者分不清 read、readRecord、JobStart、RecordingJob 与 updateDetail 里哪些是约定、哪些是历史遗留。

决策

  • 每个 job 一个环。 JobSpec 取代 JobStart:spec 声明可选的拉取式 output 源(JobOutputSource,子进程的 readFrom 家族),由注册表按自己的节奏(dsh-jobs-local 的 pumpPollMs)泵送;每个生产者都收到一个 JobHandle,其 append 把块推进同一个环。模型的消耗式读取与每个观察者的绝对偏移读取服务同一批字节;readOutput 与 record 判别字段被移除。shell 生产者把它们的 observed 偏移读取器交给注册表,并把沙箱事实折进终态 detail;terminal 生产者通过一个计字节的源适配其消耗式发送读取器;subagent 把回答作为 JobOutcome.result 返回,结算后的第一次读取携带它一次。
  • 两个游标,一份存储。 JobRegistry.read(id) 是有状态的模型游标;JobRegistry.readAt(id, from) 是无状态的观察者读取。模型读取排除 log 块(给观察者的生产者叙述),并渲染 stdout,再接一段 [stderr],与 shell 工具一贯的做法完全一致。
  • updateProgress 取代 updateDetail。 JobView.progress 是实时行,结算时清除;JobView.detail 是终态原因,合并了模型 job_kill 给出的原因。
  • 一个投影。 JobView(客户端安全叶子 @deepseek-ai/dsh-jobs/view)是模型工具、浏览器名册与观测帧共同消费的形状;owner 是会话 id,从不是 Agent。
  • 直接传入调用方的操作。 JobRegistry.list、get、read、readAt、kill 与 wait 在每次调用时接收调用方会话。省略调用方时仅允许访问无主 job。输出环与浏览器流不需要独立的调用方操作对象。
  • 一条事件流。 带 { owner }、{ owners: 'scope' } 或 { owners: 'all' } 过滤的 events.subscribe(filter, listener) 取代三个监听器家族。settled 标出原因(producer、kill、teardown);output 只携带 id 与新的 total。
  • 播报台账移到 dsh-tool-jobs。 注册表不再跟踪 reported。工具在等待开始时认领 job(超时或中止则撤回),在 job_kill 被接受时也认领;结算事件删除条目,teardown 结算与无主 job 被跳过,投递目标是结算时登记在拥有者会话下的 agent。
  • 名册离开会话控制流。 dsh-api-job-controller 在 job.follow 旁拥有 job.list({ sessionId })——每轮聚合过的生命周期提交之后发一次整集帧,从不按追加发——其客户端安装 ctx.jobs(watchRows、observe、一份快照)。dsh-api-session-controller 只承载队列与投影;ui-jobs 在挂载期间关注其会话的名册,行在 job 进行中或留有保留输出时可展开。

attachController 与相对拥有者的 servesOwner 门保持原样;没有挂载控制器的生产者是否可以提供 run_in_background,因为这个缺口早于本次变更,暂不处理。

考虑过的替代方案

分离生命周期与观测注册表。 一个生产者需要为同一份工作注册两次,并协调终态、关联 ID 和两份客户端名册。因此,一个 job 同时拥有生命周期与观测。仅前台或对模型不可见的 job 需要另一套报告与可见性规则;前台 workflow 叙述通过成为普通后台 job 获得实时观测。

  • 保留三个监听器并共享一个拥有者过滤。 它保住了堆出来的名字,却保不住评审要的性质——一个地方就能学会注册表宣布什么——而且 onJobDone 的精确拥有者投递正是迫使注册表在每个投影里保留 Agent 的原因。
  • 把 reported留在注册表。 只有工具知道哪些投递到达了模型(等待的工具结果、kill 的确认);注册表只能近似,而这个近似正是评审列出的重复播报与漏播报 bug 的来源。
  • 把名册留在会话控制流上。 省了一条 Remote 流,却把会话控制器耦合到 job 注册表,还镜像了一个环的字节数如今直接回答的 record 标志(output.total)。
  • 也把 log 块交给模型。 生产者叙述是给观察者的;模型已经通过 progress 与终态 detail 收到同样的事实。

后果

模型可见文本保持不变:状态行、工具描述与 schema、播报措辞逐字节相同,唯一新增的模型可见事实是 killed 状态行现在携带的 kill 原因。需要流式输出的生产者选择拉取源或 append,从不格式化消耗式增量。Web 客户端把每个进行中的 job 渲染为可展开,因此从不写输出的 job 显示为空的运行中面板而非静态行。JobKindMap 的合并发生在宿主侧,浏览器程序把 JobView.kind 视为它认识的 kind 集合,其余当作不透明字符串——名册对它也只做这些。

合并 master 之后的评审跟进收紧了其中四条边。job 的第一个事件永远是 registered:通告先于泵,而泵的首轮 drain 同步执行且可能通告输出。已结算的环在模型的首次终态读取之前保留模型游标尚未消费的全部字节,因此在首次 job_output 之前结束的 job 不会丢失运行期上限保留的任何内容。dsh-tool-jobs 为每次等待调用保存一条带 live 标记的认领,调用方 abort 时在 abort 事件里同步清掉该标记,因此注册表的拒绝尚未传回工具之前宣布的结算仍会投递通知;超时的等待只丢弃自己的认领,并发的另一个等待仍覆盖该结算。浏览器把先于锚点发生的观察失败记录为带错误的视图,面板显示提示而不是留白。拉取源保留的 spill 文件是 job 元数据(JobView.output.spillPaths,每次泵读取都会上报,读取不再点名时即撤回),不是逐块元数据;模型读取对生产者侧缺口与环淘汰都渲染 shell 工具的丢失输出提示并列出这些文件,即使缺口块本身已被淘汰——本次变更曾引入的 before this read 变体已删除。观察生成过程若在流中看到其 job 被移除(拥有者 teardown),以被移除 job 的终态投影收尾,而不是一次失败的读取。被拒绝的后台 spawn 不产生任何进程输出,因此 shell provider 现在把 spawn failed: … 提示作为 observed stderr 的整条流提供(消耗式读取仍只折入一次),且 ShellProcess.observed 及其两个读取器改为必填:这类失败之后模型的首次 job_output 读到的正是 master 的消耗式读取所渲染的内容。

Session 生命周期决策为持久化 SDK 消费方记录注册与结算,同时保留这里的运行期事件流和输出归属。