|
|
|
|
@@ -59,9 +59,9 @@ interface TaskOutcome {
|
|
|
|
|
|
|
|
|
|
The task status vocabulary is generic and closed: `running`, `stopping` (cancel requested, not yet settled), and the three terminal values above. Kind-specific meaning rides in `detail`, so the registry never learns process or agent semantics — the method presence (`readOutput`) is the capability, mirroring `SubagentRun.sendMessage`.
|
|
|
|
|
|
|
|
|
|
The registry attaches ONE continuation to `done`: record the terminal snapshot, then notify task-done listeners with per-listener containment (the guarantee the bash seam's `notifyTaskDone` used to give its own listener set). `done` settling at quiescence — not merely at completion — is what makes owner cleanup and service disposal awaitable without a second completion surface; this resolves the old seam's duplication of a per-task `done` promise AND a global `onTaskDone` registry by making the promise the producer contract and the listener registry the consumer surface.
|
|
|
|
|
The registry attaches ONE continuation to `done`: record the terminal snapshot, then notify task-done listeners with per-listener containment (the guarantee the bash seam's `notifyTaskDone` used to give its own listener set). `done` settling at quiescence — not merely at completion — makes ordinary owner cleanup and service disposal awaitable without a second completion surface. Settlement is first-wins because teardown has one explicit failure fallback: if producer `cancel` throws before the request is delivered, `done` may never settle, so the registry force-fails the record with a possible-orphan detail instead of deadlocking disposal; a late producer outcome cannot overwrite that diagnosis or notify twice. This fallback terminates bookkeeping, not necessarily the underlying work.
|
|
|
|
|
|
|
|
|
|
Registrations are NOT effect-scoped to the registering fiber: a task belongs to its owning agent and its producing backend, not to the tool plugin whose call started it, so an HMR reload of `dsh-tool-bash` or `dsh-tool-tasks` never orphans or kills a running task (the same argument that used to keep bash ownership in the executor). The registry's own disposal cancels every live task and awaits settlement — no orphans survive `fiber.dispose()`.
|
|
|
|
|
Registrations are NOT effect-scoped to the registering fiber: a task belongs to its owning agent and its producing backend, not to the tool plugin whose call started it, so an HMR reload of `dsh-tool-bash` or `dsh-tool-tasks` never orphans or kills a running task (the same argument that used to keep bash ownership in the executor). The registry's own disposal cancels every live task and awaits contract-compliant producers to quiescence. A teardown cancel that throws force-fails the terminal record and logs that work may be orphaned; this prevents a broken producer from deadlocking the fiber without pretending the work stopped.
|
|
|
|
|
|
|
|
|
|
## Authorization and the service surface
|
|
|
|
|
|
|
|
|
|
@@ -100,16 +100,16 @@ Completion notices stay durable context, not a wake-up (`agent.inject()` appends
|
|
|
|
|
|
|
|
|
|
## Producer opt-in and schema exposure
|
|
|
|
|
|
|
|
|
|
Whether a producer tool offers `run_in_background` is that producer's own defaulted config: `enableRunInBackground?: boolean` on `dsh-tool-bash` and on each `dsh-tool-subagent` instance (both default `true` — bash keeps its always-exposed behavior, and a deployment disables either per instance from cordis.yml, no code edit). A disabled producer omits the parameter from its schema entirely — and, because the arg validator deliberately allows undeclared keys, its `execute` ALSO refuses a forced `run_in_background: true` loud (the omission is advertising; the execution-time check is the enforcement). `ctx.tasks` plays no part in schema shaping — it never rewrites or decorates a producer's tool schema (Kimi Code regex-rewrites its bash description when background is disabled; config-owns-the-schema makes that trick unnecessary) — it only provides runtime registration. The two halves compose fail-loud: the producer's config decides what the model sees, and a background call that still reaches `start()` without a control surface throws the load-this-package error. `start()` preflights every failable check (the fence, validation, the owner-cleanup attach) BEFORE invoking the producer's `run()` and commits atomically after — background work started without a collectable id is structurally impossible, not a producer rollback obligation.
|
|
|
|
|
Whether a producer tool offers `run_in_background` is that producer's own defaulted config: `enableRunInBackground?: boolean` on `dsh-tool-bash` and on each `dsh-tool-subagent` instance (both default `true` — bash keeps its always-exposed behavior, and a deployment disables either per instance from cordis.yml, no code edit). A bundle forwards the configs of the child plugins it owns: `dsh-agent-core` exposes `toolBash` for its built-in producer and `toolTasks` for the generic control surface, while independently composed producers such as subagent instances receive config directly. This forwarding is config reachability, not producer registration: future background-capable tools do not become `agent-core` fields unless that bundle also chooses to own them. A disabled producer omits the parameter from its schema entirely — and, because the arg validator deliberately allows undeclared keys, its `execute` ALSO refuses a forced `run_in_background: true` loud (the omission is advertising; the execution-time check is the enforcement). `ctx.tasks` plays no part in schema shaping — it never rewrites or decorates a producer's tool schema (Kimi Code regex-rewrites its bash description when background is disabled; config-owns-the-schema makes that trick unnecessary) — it only provides runtime registration. The two halves compose fail-loud: the producer's config decides what the model sees, and a background call that still reaches `start()` without a control surface throws the load-this-package error. `start()` preflights every failable check (the fence, validation, the owner-cleanup attach) BEFORE invoking the producer's `run()` and commits atomically after — background work started without a collectable id is structurally impossible, not a producer rollback obligation.
|
|
|
|
|
|
|
|
|
|
## The awaited owner-cleanup seam
|
|
|
|
|
|
|
|
|
|
A background task must not outlive its owner: the subagent case leaks live child agents/sessions otherwise, and `agent/disposed` is emitted synchronously inside the disposal chain without awaiting listener work, so an emit listener cannot promise quiescence (the analysis in [the feature RFC](../feature/2026-07-08-background-subagent-tasks.md)). The runtime therefore needs a seam the owning agent's disposal chain actually awaits, and that seam belongs to `dsh-agent`, where every lifecycle consumer can reach it:
|
|
|
|
|
A contract-compliant background task must not outlive its owner: the subagent case leaks live child agents/sessions otherwise, and `agent/disposed` is emitted synchronously inside the disposal chain without awaiting listener work, so an emit listener cannot promise quiescence (the analysis in [the feature RFC](../feature/2026-07-08-background-subagent-tasks.md)). The runtime therefore needs a seam the owning agent's disposal chain actually awaits, and that seam belongs to `dsh-agent`, where every lifecycle consumer can reach it:
|
|
|
|
|
|
|
|
|
|
- `AgentRegistry.onCleanup(agentId, cleanup: () => Promise<void>): () => void` — a per-agent cleanup registry (registrations are effects; the disposer unregisters).
|
|
|
|
|
- The loop's composite disposal chain carries one link for it: after stop-and-drain and before unregister, `await ctx.agents.drainCleanups(agent.id)` runs every registered cleanup with per-cleanup containment (a throwing cleanup is logged and never starves later cleanups or the rest of the chain). This is a documented `dsh-agent-loop` change; running cleanups is part of the `AgentFactory` dispose contract so a replacement loop honors it too.
|
|
|
|
|
|
|
|
|
|
`dsh-tasks` consumes the seam: the first task registered for an owner attaches one cleanup that cancels the owner's still-live tasks, awaits each task's `done` (quiescence), and drops the owner's snapshots. `AgentHandle.dispose()` thus resolves only after the owner's background children are actually gone, and the guarantee composes transitively: a background subagent that started background tasks of its own drains them when its child agent disposes inside the parent task's settlement path (the cascade OpenCode implements with explicit parent-chain walking falls out of the seam here). This is a deliberate behavior change for bash — a background bash task used to outlive its owning agent until service disposal — adopted for uniformity: an ownerless task is the sanctioned way to outlive an agent, and a future durable-job RFC is the way to outlive the runtime.
|
|
|
|
|
`dsh-tasks` consumes the seam: the first task registered for an owner attaches one cleanup that cancels the owner's still-live tasks, normally awaits each task's `done` (quiescence), and drops the owner's snapshots. For contract-compliant producers, `AgentHandle.dispose()` therefore resolves only after the owner's background children are actually gone, and the guarantee composes transitively: a background subagent that started background tasks of its own drains them when its child agent disposes inside the parent task's settlement path (the cascade OpenCode implements with explicit parent-chain walking falls out of the seam here). A producer whose teardown cancel throws is the explicit degradation: its record settles `failed` with a possible-orphan detail and cleanup continues. An ownerless task is the sanctioned way for healthy work to outlive an agent, and a future durable-job RFC is the way to outlive the runtime.
|
|
|
|
|
|
|
|
|
|
## Bash migration
|
|
|
|
|
|
|
|
|
|
@@ -169,7 +169,7 @@ Everything model-visible already lands in the log: starts and reads are tool cal
|
|
|
|
|
|
|
|
|
|
## Testing
|
|
|
|
|
|
|
|
|
|
Unit coverage pins the registry lifecycle (register/read/kill/wait/list, owner isolation including no-agent callers, stream-vs-final read semantics, listener containment, notice suppression after an explicit kill or terminal read/wait, the `attachSurface` fence, start atomicity — a failed preflight mutates nothing and burns no counter; a failed producer `cancel` leaves the task untouched — disposal quiescence, per-kind id counters), the `onCleanup` drain ordering + containment (including mid-drain registration), both producers' start mapping plus the structural no-orphan guarantee (a failed preflight means the producer's `run()` — the spawn — was never invoked), and unchanged foreground bash/subagent behavior. Snapshot coverage pins the task tool schemas and the prompt section through the pinned-header fixture.
|
|
|
|
|
Unit coverage pins the registry lifecycle (register/read/kill/wait/list, owner isolation including no-agent callers, stream-vs-final read semantics, listener containment, notice suppression after an explicit kill or terminal read/wait, the `attachSurface` fence, start atomicity — a failed preflight mutates nothing and burns no counter; a model-facing failed `cancel` leaves the task untouched; a teardown failed `cancel` force-fails the record once without awaiting `done` — ordinary disposal quiescence, per-kind id counters), the `onCleanup` drain ordering + containment (including mid-drain registration and effect self-release), both producers' start mapping plus the structural no-uncollectable-work guarantee (a failed preflight means the producer's `run()` — the spawn — was never invoked), and unchanged foreground bash/subagent behavior. Snapshot coverage pins the task tool schemas and the prompt section through the pinned-header fixture.
|
|
|
|
|
|
|
|
|
|
## Consequences
|
|
|
|
|
|
|
|
|
|
@@ -177,4 +177,6 @@ One background-task contract exists instead of a per-capability clone family: a
|
|
|
|
|
|
|
|
|
|
Owner-scoped cleanup changed bash semantics: a task that previously outlived its agent now dies with it. Deployments that relied on fire-and-forget background commands start them unowned (a non-agent caller) or accept the new lifecycle; the uniformity was judged worth the change, and the durable-job direction remains open for real survival requirements.
|
|
|
|
|
|
|
|
|
|
Teardown trusts the producer contract that a successful `cancel` leads to `done` settling at quiescence. An explicit cancel throw is detectable and force-fails the record with a possible-orphan warning so disposal cannot deadlock; a cancel that returns but silently fails is indistinguishable from a slow stop and can still stall teardown. Covering that residual requires a bounded lifetime or a separate forced-disposal contract, both outside this runtime shape.
|
|
|
|
|
|
|
|
|
|
`wait` is the first blocking tool call whose duration is model-controlled; the config cap bounds it, but a model that serializes on `wait` loses the parallelism the feature exists for — prompt guidance mitigates, and a future continuation-policy guard can enforce. Recording live background-flow snapshot scenarios (a polled and killed background command, a completion-notice turn) and a with-key e2e background lifecycle require a `DEEPSEEK_API_KEY` re-record and remain named follow-up work. The runtime deliberately defers durable/cross-restart tasks, non-consuming observation cursors, and foreground→background promotion (see Alternatives).
|
|
|
|
|
|