Delete @deepseek-ai/dsh-invariants, every package's ./invariant companion and its tests, the invariant gates and Vitest setup, the scoped-event resolver generator, and the INVARIANT-coded listener rethrow paths. Update docs, READMEs, Agent Notes, and add an upgrade guide.
7.4 KiB
Agent Note: One output ring, one projection, one event stream for background jobs
Status: implemented
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.
JobSpecreplacesJobStart: a spec names optional pulloutputsources (JobOutputSource, the subprocessreadFromfamily) that the registry pumps at its own cadence (pumpPollMsondsh-jobs-local), and every producer receives aJobHandlewhoseappendpushes chunks into the same ring. The model's consuming read and every observer's absolute-offset read serve the same bytes;readOutputand therecorddiscriminator are gone. Shell producers hand the registry theirobservedoffset readers and fold sandbox facts into the terminaldetail; the terminal producer adapts its consuming send reader through a byte-counting source; the subagent returns its answer asJobOutcome.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 excludelogchunks (producer narration for observers) and render stdout, then one[stderr]section, exactly as the shell tools always did. updateProgressreplacesupdateDetail.JobView.progressis the live line and is cleared at settlement;JobView.detailis the terminal reason, with the model'sjob_killreason 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;owneris the session id, never theAgent. - Direct caller-scoped operations.
JobRegistry.list,get,read,readAt,kill, andwaitaccept 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.settlednames its cause (producer,kill,teardown);outputcarries only the id and the new total. - The notice ledger moves to
dsh-tool-jobs. The registry no longer tracksreported. The tool claims a job when a wait starts (and withdraws on timeout or abort) and whenjob_killis 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-controllerownsjob.list({ sessionId })— whole-set frames after each coalesced burst of lifecycle commits, never per append — besidejob.follow, and its client installsctx.jobs(watchRows,observe, one snapshot).dsh-api-session-controllercarries queues and projections only;ui-jobswatches 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
- 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 anAgentin every projection. - Keep
reportedin 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
recordflag the ring's byte count now answers directly (output.total). - Serve
logchunks to the model too. Producer narration is for observers; the model already receives the same facts throughprogressand the terminaldetail.
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.