The registry's owner fence is the only access rule for a human kill: a job
is killable by the session whose list carries it, and a child session's own
jobs are no exception. The fence had pulled two implementation helpers out
of the Session Controller's package entry (plus a safe-host-export exception
for them) so the job controller could import them; both go, and the job
controller no longer depends on the agent registry or the Session Controller.
With a job registry composed, tool-bash and tool-pwsh now register every
command with ctx.jobs as it starts and wait on the job with the registry's
own bounded wait: a command that finishes in time returns the ordinary
foreground result and its record is removed, one that outlives the timeout
returns the same job id it already had, and a kill from outside the call is
reported as `[stopped: <reason>]`. Nothing changes hands at the deadline, so
the shell seam loses `onExpiry: 'offer'`, `ShellExecution.promotion`, and
`ShellPromotionOffer`, and both local executors drop their offer arm.
The registry stays optional: the tools register a foreground-only definition
while no registry is composed and swap to the job-backed one inside a
`ctx.inject(['jobs'])` fork, so a composition without jobs keeps a plain bash
with no job tools in the model's schema. A refused registration at the start
(admission, no controller) runs under the executor's deadline kill, and a wait
that expires before the process spawned ends as the preparation timeout.
JobRegistry gains `remove(id, caller?)` and the `settled` event reports
`awaited`, so dsh-tool-jobs no longer keeps its own wait ledger; it withholds
notices for awaited settlements and for kills the model requested. The tool
catalog harvest composes the registry so the shipped schema stays documented,
and the two compaction fixtures move their context window from 10000 to 10300
tokens because the branch's larger tool schemas pushed their condensed history
over the old trigger, which requested a fourth scripted model call.
Three review follow-ups on the consolidated jobs seam.
The spill file a pull source keeps is job metadata now: the pump reports it
to the registry on every read (present or withdrawn), the projection lists
it under output.spillPaths, and the model's dropped-output notice names it
from there. Attached to a gap chunk it was lost whenever the ring itself
evicted (a pump that kept up never produced a gap) or whenever the gap chunk
was evicted later, and it could not follow a source that withdrew its file.
A job_output wait's claim on the settlement now carries a live flag that
the caller's abort clears synchronously from the abort event. The registry
rejects an aborted wait on a later microtask, so a settlement announced in
between used to find the claim counted and skip the notice the tool result
was not going to carry either.
The job-controller client entry and the tool-jobs entry stop exporting
implementation classes and render helpers nothing outside the packages
consumes; the client entry keeps IJobs, which also carries the ctx.jobs
Context augmentation to consumers.
The caller-bound operation set now says what it is: the jobs one caller
may operate on. The caller argument is required (SessionId | undefined),
so an anonymous view has to be requested explicitly instead of falling
out of a forgotten argument.
An owner's teardown settles and removes the record within one microtask chain, while the observation generation sleeps its flush window after the wake and then reads an id the registry no longer returns, failing the stream and flashing an error in the panel until the roster frame drops the row. The subscription now records a removed event for the observed id and the generation closes with that terminal projection.
An observation stream that failed before its opened frame left no observed state, so the expanded row rendered nothing: no output, no notice. The client model now records such a failure as an errored, non-streaming view with no resume cursor, and the panel renders the interruption notice it promises.
- docs/subsystems/jobs.md (+zh) records JobSpec, JobHandle, JobHooks,
JobOutcome, the pull-source types, JobChunk, JobOutputRead, JobView,
JobRead, VisibleJobs, and the event types as type-equivalence blocks; the
manifest and the cordis catalog link map follow
- the jobs, jobs-local, and tool-jobs READMEs (+zh) describe the ring, the
caller-bound view, the event stream, and the notice ledger; the
architecture table and the adding-a-tool cookbook drop record: true and
readOutput
- Agent Note 2026-09-03-jobs-seam-consolidation owns the decision; the
2026-09-01 record note and the 2026-08-08 web display note carry
Superseded pointers and the display note links the rows spec
- regenerated catalogs (cordis, api, inspect, client, tool, config,
persistence, doc graphs, module graph) with their Chinese mirrors; the
cordis catalog drops the jobOutput exemption and the tool catalog mounts
the agent registry for tool-jobs; the client model imports JobView from
its leaf so the typert analyzer resolves it
- job.rows({ sessionId }) streams the jobs one session can see as whole-set
frames: once on open, then after each coalesced burst of lifecycle
commits; output appends never refresh it
- job.observe reads through visibleTo(sessionId) and carries JobView in its
opened and status frames; the wire chunk is the registry's JobChunk
- the client installs ctx.jobs (ClientJobs over ClientJobsModel): watchRows
and observe are reference-counted streams, rows drop with the last
watcher or a terminal failure, observations keep their render tail
- the controller no longer needs the agent registry
Review corrections on the observation record:
- `pumpJobOutput` subscribes to `done` once and keeps one pending timer;
racing the same pending promise every poll round retained a reaction
per round for the life of the job.
- The `ctx.jobOutput` effect disposer is async and awaits every open
carrier's `dispose()`, so the fiber reports unloaded only once the old
iterators are quiescent.
- `SessionJob.record: true` replaces `outputTotal`: `append` never commits
a roster change, so the mirrored count was stale while output flowed;
the live offsets ride the observation stream's `opened` anchor.
- The record is documented as a best-effort live preview whose
cross-stream reordering is bounded by the poll interval.
- The web e2e scenarios hold their background job on a test-owned barrier
file instead of a wall-clock sleep, and each is one test rather than a
chain sharing a job id.
`session.killJob` becomes `job.kill` on `dsh-api-job-controller`, beside
`job.observe`, so the Session Controller carries only session control and
the roster while every per-job Remote lives on the job namespace. The
client `ctx.jobOutput` gains `kill(sessionId, jobId)`; `dsh-client-ui-jobs`
routes its stop control through it and no longer requires `sessions`.
Unknown or foreign jobs reject with `job/not-found`; the controller
requires `ctx.jobs` to load, so `jobs-unavailable` disappears. The two
subagent ownership-fence helpers are classified duplicate-install-safe
Host exports of the Session Controller for the cross-controller import.
Review asked for the observation wire and its client service to leave
session-controller: a job should reach the Host only through its own
Remote namespace, and the session plugin's apply had to materialize
{ $stream, session } for a service that is not session state.
@deepseek-ai/dsh-api-job-controller now owns the Host JobController
(job.observe over ctx.jobs, requiring the agent and job registries) and
the client ClientJobOutput (ctx.jobOutput) over ClientJobOutputModel,
resolving both Remote faces in its own apply. The remotes assembly
mounts the job namespace; ui-jobs injects the new package; the wire
types drop their Session prefix and spell the status union out so the
client face pulls no Host declaration merges into browser programs.
session-controller keeps the jobsBySession roster and loses observe-job,
job-output, and the observe cadence config.
ui-chat's slots and snapshot-builder import CommandNode and friends from
the declaring package: the typert analyzer's cross-package rule trips on
the snapshot re-export as soon as another client face joins the root
Client program (the new controller does), which is why this branch
carried the change before. JobPumpSource documents why it is an offset
snapshot reader rather than a Node.js Readable.