Procházet zdrojové kódy

fix(tasks): validate owner identity and brand session ids

Yichen Jiang před 2 měsíci
rodič
revize
8f5ea04c3f

+ 1 - 1
docs/cordis-catalog/services.md

@@ -271,7 +271,7 @@ attachSurface(name: string): () => void
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/tasks/tasks/src/index.ts:96`](../../packages/tasks/tasks/src/index.ts)
+Source: [`packages/tasks/tasks/src/index.ts:97`](../../packages/tasks/tasks/src/index.ts)
 
 ## `ctx.tools` — `ToolRegistry`
 

+ 8 - 5
docs/core-data-structures/tasks.md

@@ -21,7 +21,9 @@ interface TaskStart {
   /**
    * The spawning agent. Its `session.header.id` becomes the task's owner
    * token (read/kill/wait/list are fenced to that session), and its disposal
-   * cancels and awaits the task through the `ctx.agents.onCleanup` seam.
+   * cancels and awaits the task through the `ctx.agents.onCleanup` seam. It
+   * must be the exact live instance currently registered under its agent id;
+   * a stale object whose id has been reused is rejected before work starts.
    * `undefined` starts an UNOWNED task: open to any caller, alive until the
    * tasks service disposes.
    */
@@ -86,7 +88,7 @@ interface TaskOutcome {
 
 ## What consumers see: `TaskSnapshot` and `TaskRead`
 
-Snapshots are fresh projections, never live registry state. `reported` is the notice-suppression flag: the completion-notice injector (`dsh-tool-tasks`) skips a task whose terminal state the model already saw.
+Snapshots are fresh projections, never live registry state. `ownerSession` retains the shared branded `SessionId` type across the package boundary. `reported` is the notice-suppression flag: the completion-notice injector (`dsh-tool-tasks`) skips a task whose terminal state the model already saw.
 
 ```ts type-equiv
 interface TaskSnapshot {
@@ -100,9 +102,10 @@ interface TaskSnapshot {
    * The owner's session id (`session.header.id`), for surfaces that must
    * reach the owning agent (the completion-notice injector); absent for
    * unowned tasks. Session ids are runtime-shared identifiers, not secrets —
-   * the read/kill/wait/list FENCE is what isolation rests on.
+   * the read/kill/wait/list FENCE is what isolation rests on. The shared
+   * {@link SessionId} brand is preserved across this package boundary.
    */
-  ownerSession?: string
+  ownerSession?: SessionId
   /** Current lifecycle state. */
   status: TaskStatus
   /** Kind-specific status detail, present once the producer supplied one (usually terminal). */
@@ -137,4 +140,4 @@ interface TaskRead {
 
 ## The service
 
-`TaskService` (`ctx.tasks` — [`packages/tasks/tasks/src/index.ts`](../../packages/tasks/tasks/src/index.ts)): `start` (preflight → producer `run()` → atomic commit, fenced by `attachSurface`), non-consuming `get`/`list` (caller-scoped — owned-by-caller plus unowned only), `read` (consuming for stream kinds), `kill` (producer `cancel` first; a throw leaves the task untouched), `wait` (bounded, abort cancels the wait only), and `onTaskDone` (a `TaskDoneListener` per terminal record, effect-scoped, contained). Every read/kill/wait/get compares the task's owner session with the caller's and rejects a foreign one. Owned tasks are cancelled and normally awaited to producer quiescence when their owning agent disposes (the `ctx.agents.onCleanup` seam); a teardown cancel that throws force-fails only the registry record and reports that the underlying work may be orphaned, preventing disposal deadlock without claiming quiescence. The model-facing surface over all of this is [dsh-tool-tasks](../../packages/tasks/tool-tasks/README.md).
+`TaskService` (`ctx.tasks` — [`packages/tasks/tasks/src/index.ts`](../../packages/tasks/tasks/src/index.ts)): `start` (preflight → producer `run()` → atomic commit, fenced by `attachSurface`), non-consuming `get`/`list` (caller-scoped — owned-by-caller plus unowned only), `read` (consuming for stream kinds), `kill` (producer `cancel` first; a throw leaves the task untouched), `wait` (bounded, abort cancels the wait only), and `onTaskDone` (a `TaskDoneListener` per terminal record, effect-scoped, contained). Start validates that an owned task names the exact live Agent instance currently registered under its id, so an old reference cannot bind work to a replacement agent's cleanup after id reuse. Every read/kill/wait/get separately compares the task's owner session with the caller's and rejects a foreign one. Owned tasks are cancelled and normally awaited to producer quiescence when their owning agent disposes (the `ctx.agents.onCleanup` seam); a teardown cancel that throws force-fails only the registry record and reports that the underlying work may be orphaned, preventing disposal deadlock without claiming quiescence. The model-facing surface over all of this is [dsh-tool-tasks](../../packages/tasks/tool-tasks/README.md).

+ 2 - 1
docs/module-graph.md

@@ -173,6 +173,7 @@ flowchart TD
   pkg_user_interaction --> pkg_llm
   pkg_tasks --> pkg_agent
   pkg_tasks --> pkg_brand
+  pkg_tasks --> pkg_session
   pkg_tasks --> pkg_timeout
   pkg_workflow --> pkg_agent
   pkg_workflow --> pkg_brand
@@ -356,7 +357,7 @@ flowchart TD
 | [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
 | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
-| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`timeout`](../packages/util/timeout) |
+| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
 | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
 | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) |
 | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) |

+ 5 - 5
docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md

@@ -65,7 +65,7 @@ Registrations are NOT effect-scoped to the registering fiber: a task belongs to
 
 ## Authorization and the service surface
 
-Cross-session isolation lives IN the runtime so every consumer gets the same rule for free: read/kill/wait/get take the caller (`Agent | undefined`), and a task whose owner session differs from the caller's session is rejected (`!== undefined` comparison — an unowned task is open, a no-agent caller cannot match an owned task). `list(caller)` returns only the caller-visible tasks (owned-by-caller or unowned) — a global listing would leak other sessions' labels. Owner identity is `session.header.id`, the canonical id every other subsystem keys on; because both sides of the comparison come from live `Agent`s, the freestanding `OwnerToken` brand the bash seam used to carry became internal state rather than a seam type.
+Cross-session isolation lives IN the runtime so every consumer gets the same rule for free: read/kill/wait/get take the caller (`Agent | undefined`), and a task whose owner session differs from the caller's session is rejected (`!== undefined` comparison — an unowned task is open, a no-agent caller cannot match an owned one). `list(caller)` returns only the caller-visible tasks (owned-by-caller or unowned) — a global listing would leak other sessions' labels. The snapshot carries that owner as the canonical branded `SessionId`, not a package-local token or bare string. Lifecycle ownership is checked independently at start: the supplied owner must be the exact live `Agent` instance currently registered under its id, so an old object cannot attach its session's work to a replacement agent's cleanup after id reuse.
 
 ```ts ignore-check
 class TaskService extends Service {           // ctx.tasks
@@ -80,7 +80,7 @@ class TaskService extends Service {           // ctx.tasks
 }
 ```
 
-`TaskSnapshot` is the read-only projection: id, kind, label, owner session, status, detail, started/finished timestamps, and the `reported` notice-suppression flag (below). `wait` resolves with the terminal snapshot, or with the still-`running` snapshot on timeout; aborting the wait cancels only the wait — unless the task already settled, in which case the wait still delivers the terminal snapshot (settlement suppressed the completion notice on this live waiter's behalf, and an aborted waiter un-counts itself synchronously so a same-tick settlement never suppresses a notice nobody will deliver).
+`TaskSnapshot` is the read-only projection: id, kind, label, branded owner `SessionId`, status, detail, started/finished timestamps, and the `reported` notice-suppression flag (below). `wait` resolves with the terminal snapshot, or with the still-`running` snapshot on timeout; aborting the wait cancels only the wait — unless the task already settled, in which case the wait still delivers the terminal snapshot (settlement suppressed the completion notice on this live waiter's behalf, and an aborted waiter un-counts itself synchronously so a same-tick settlement never suppresses a notice nobody will deliver).
 
 **Misconfiguration fails loud**: a deployment that loads a background-capable producer without any control surface would let the model start tasks it can never read or stop — the half-loaded failure mode the subagent RFC's first draft reshaped a whole plugin to avoid. The fence is `attachSurface()`: `dsh-tool-tasks` attaches (effect-scoped) on load, and `start()` throws `background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)` when none is attached — the earliest self-contained moment, since concurrent plugin start makes a load-time check racy. The registry stays ignorant of tool names; a deployment with a custom (non-model) surface attaches its own.
 
@@ -100,7 +100,7 @@ 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 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.
+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, exact live owner instance, and 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
 
@@ -109,7 +109,7 @@ A contract-compliant background task must not outlive its owner: the subagent ca
 - `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, 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.
+`dsh-tasks` consumes the seam: after validating the exact registered owner instance, the first task for that 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 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.
+Unit coverage pins the registry lifecycle (register/read/kill/wait/list, owner isolation including no-agent callers and stale owner objects after id reuse, 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 branded `SessionId` snapshot boundary, 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
 

+ 1 - 1
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -883,7 +883,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'TaskSnapshot',
-    declaration: 'export interface TaskSnapshot {\n    id: TaskId;\n    kind: string;\n    label: string;\n    ownerSession?: string;\n    status: TaskStatus;\n    detail?: string;\n    startedAt: number;\n    finishedAt?: number;\n    reported: boolean;\n}',
+    declaration: 'export interface TaskSnapshot {\n    id: TaskId;\n    kind: string;\n    label: string;\n    ownerSession?: SessionId;\n    status: TaskStatus;\n    detail?: string;\n    startedAt: number;\n    finishedAt?: number;\n    reported: boolean;\n}',
   },
   {
     name: 'TaskStart',

+ 1 - 1
packages/tasks/tasks/README.md

@@ -17,7 +17,7 @@ Every read/kill/wait/get compares the task's owner session (`owner.session.heade
 ## Lifecycle
 
 - Registrations are NOT effect-scoped to the registering fiber: tasks belong to their owning agent + producing backend, so producer/surface HMR reloads never touch them.
-- An owned task attaches (once per owner) an awaited cleanup via `ctx.agents.onCleanup`: on owner disposal the registry cancels live tasks, awaits contract-compliant producers to quiescence, and drops their snapshots. If a teardown cancel throws, it force-fails the record and logs that the underlying work may be orphaned rather than deadlocking `AgentHandle.dispose()`.
+- An owned task must name the exact live `Agent` instance currently registered under its id (stale objects are rejected after id reuse), then attaches once per owner an awaited cleanup via `ctx.agents.onCleanup`: on owner disposal the registry cancels live tasks, awaits contract-compliant producers to quiescence, and drops their snapshots. If a teardown cancel throws, it force-fails the record and logs that the underlying work may be orphaned rather than deadlocking `AgentHandle.dispose()`.
 - Service disposal closes the listener registry first (late teardown settlements stay silent), then applies the same cancellation rule to every live task and awaits terminal records.
 - A producer whose `cancel` returns but never causes `done` to settle remains indistinguishable from a slow stop and can stall teardown; solving that residual requires an explicit bounded-lifetime or forced-disposal design.
 

+ 1 - 0
packages/tasks/tasks/package.json

@@ -24,6 +24,7 @@
   "peerDependencies": {
     "@deepseek-ai/dsh-agent": "^0.0.1",
     "@deepseek-ai/dsh-brand": "^0.0.1",
+    "@deepseek-ai/dsh-session": "^0.0.1",
     "@deepseek-ai/dsh-timeout": "^0.0.1",
     "cordis": "^4.0.0-rc.6"
   },

+ 14 - 7
packages/tasks/tasks/src/index.ts

@@ -32,6 +32,7 @@
 
 import { Context, Service } from 'cordis'
 import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
+import type { SessionId } from '@deepseek-ai/dsh-session'
 import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
 import { TaskId } from './types.ts'
 import type { TaskDoneListener, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus } from './types.ts'
@@ -67,7 +68,7 @@ interface TrackedTask {
   kind: string
   label: string
   /** The owner's session id (`session.header.id`), or undefined for an unowned task. */
-  ownerSession: string | undefined
+  ownerSession: SessionId | undefined
   cancel: (reason?: string) => void
   readOutput: (() => string) | undefined
   status: TaskStatus
@@ -121,8 +122,9 @@ export class TaskService extends Service {
    * task id (`<kind>-N`, per-kind counter). Every check that can fail — the
    * control-surface fence ({@link attachSurface}; a task the model could
    * never read or stop must fail loud before it exists), kind/label
-   * validation, and the owner's awaited disposal-cleanup attach (once per
-   * owner agent, through `ctx.agents.onCleanup`) — runs BEFORE
+   * validation, exact live owner-instance identity, and the owner's awaited
+   * disposal-cleanup attach (once per owner agent, through
+   * `ctx.agents.onCleanup`) — runs BEFORE
    * `spec.run()` starts the actual work, and nothing in the runtime can fail
    * after it returns: "work started but never got a collectable id" is
    * structurally impossible, not a producer rollback obligation. The runtime
@@ -450,16 +452,21 @@ export class TaskService extends Service {
    * fiber. A narrow race remains if new work starts on an agent already being
    * drained: before this callback clears the owner entry, that start can reuse
    * the in-flight cleanup after its task snapshot was taken.
-   * Fails loud when no agent registry is mounted — an owned background task
-   * without the cleanup seam would outlive its owner silently.
+   * Fails loud when no agent registry is mounted or when `owner` is not the
+   * exact live instance currently registered under its id — accepting a stale
+   * object after id reuse would attach its session's task to another agent's
+   * lifecycle.
    */
   private ensureOwnerCleanup(owner: Agent): void {
     const ownerId = owner.id
-    if (this.ownerCleanups.has(ownerId)) return
     const agents = this.selfCtx.get('agents')
     if (agents === undefined) {
       throw new Error('background task ownership requires the agent registry (load @deepseek-ai/dsh-agent)')
     }
+    if (agents.get(ownerId) !== owner) {
+      throw new Error(`agent "${ownerId}" is not the registered agent instance (background task owner must be live)`)
+    }
+    if (this.ownerCleanups.has(ownerId)) return
     const ownerSession = owner.session.header.id
     // Attach FIRST, record after: onCleanup throws for an unregistered agent,
     // and marking the owner as covered before that would make every later
@@ -476,7 +483,7 @@ export class TaskService extends Service {
   }
 
   /** Cancel, await terminal records, and drop every task owned by one session. */
-  private async disposeOwned(ownerSession: string): Promise<void> {
+  private async disposeOwned(ownerSession: SessionId): Promise<void> {
     const owned = [...this.store.values()].filter(task => task.ownerSession === ownerSession)
     this.cancelForTeardown(owned, 'owner disposed')
     await Promise.all(owned.map(task => task.settled))

+ 7 - 3
packages/tasks/tasks/src/types.ts

@@ -9,6 +9,7 @@
 
 import type { Branded } from '@deepseek-ai/dsh-brand'
 import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { SessionId } from '@deepseek-ai/dsh-session'
 
 /**
  * Identifies one background task in the runtime-global registry. Generated by
@@ -71,7 +72,9 @@ export interface TaskStart {
   /**
    * The spawning agent. Its `session.header.id` becomes the task's owner
    * token (read/kill/wait/list are fenced to that session), and its disposal
-   * cancels and awaits the task through the `ctx.agents.onCleanup` seam.
+   * cancels and awaits the task through the `ctx.agents.onCleanup` seam. It
+   * must be the exact live instance currently registered under its agent id;
+   * a stale object whose id has been reused is rejected before work starts.
    * `undefined` starts an UNOWNED task: open to any caller, alive until the
    * tasks service disposes.
    */
@@ -136,9 +139,10 @@ export interface TaskSnapshot {
    * The owner's session id (`session.header.id`), for surfaces that must
    * reach the owning agent (the completion-notice injector); absent for
    * unowned tasks. Session ids are runtime-shared identifiers, not secrets —
-   * the read/kill/wait/list FENCE is what isolation rests on.
+   * the read/kill/wait/list FENCE is what isolation rests on. The shared
+   * {@link SessionId} brand is preserved across this package boundary.
    */
-  ownerSession?: string
+  ownerSession?: SessionId
   /** Current lifecycle state. */
   status: TaskStatus
   /** Kind-specific status detail, present once the producer supplied one (usually terminal). */

+ 34 - 5
packages/tasks/tasks/tests/tasks.spec.ts

@@ -1,4 +1,4 @@
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, expectTypeOf, it, vi } from 'vitest'
 import { Context } from 'cordis'
 import { Session, SessionId } from '@deepseek-ai/dsh-session'
 import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
@@ -6,12 +6,12 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
 import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks'
 import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
 
-function stubAgent(rawId: string): Agent {
+function stubAgent(rawId: string, rawSessionId = `${rawId}-session`): Agent {
   const id = AgentId(rawId)
   return {
     id,
     options: {},
-    session: new Session(SessionId(`${id}-session`)),
+    session: new Session(SessionId(rawSessionId)),
     status: 'idle',
     send() {},
     steer() {},
@@ -48,6 +48,10 @@ async function harness() {
 const tick = () => new Promise<void>(r => setTimeout(r, 0))
 
 describe('TaskService.start', () => {
+  it('preserves the SessionId brand on public owner snapshots', () => {
+    expectTypeOf<TaskSnapshot['ownerSession']>().toEqualTypeOf<SessionId | undefined>()
+  })
+
   it('refuses to register while no control surface is attached', async () => {
     const ctx = new Context()
     await ctx.plugin(TaskService)
@@ -365,9 +369,10 @@ describe('TaskService owner isolation', () => {
     const ctx = await harness()
     const ghost = stubAgent('ghost') // never registered in ctx.agents
 
-    // onCleanup rejects the unregistered agent BEFORE any registry mutation.
+    // Exact-instance preflight rejects the unregistered agent BEFORE any
+    // registry mutation or owner-cleanup attachment.
     expect(() => ctx.tasks.start(producer({ owner: ghost }).spec))
-      .toThrow('is not registered')
+      .toThrow('is not the registered agent instance')
     expect(ctx.tasks.list(ghost)).toEqual([])
 
     // Once the agent actually exists, the same owner gets a WORKING cleanup —
@@ -389,6 +394,30 @@ describe('TaskService owner isolation', () => {
     expect(cancels).toEqual(['owner disposed'])
     expect(ctx.tasks.list(ghost)).toEqual([])
   })
+
+  it('rejects a stale owner instance after another agent reuses its id', async () => {
+    const ctx = await harness()
+    const staleOwner = stubAgent('owner', 'stale-session')
+    const unregisterStale = ctx.agents.register(staleOwner)
+    unregisterStale()
+
+    const currentOwner = stubAgent('owner', 'current-session')
+    ctx.agents.register(currentOwner)
+    const current = producer({ owner: currentOwner })
+    ctx.tasks.start(current.spec) // Attach the current owner's cleanup first.
+
+    const stale = producer({ owner: staleOwner })
+    const staleRun = vi.fn(() => stale.spec.run())
+    expect(() => ctx.tasks.start({ ...stale.spec, run: staleRun }))
+      .toThrow('is not the registered agent instance')
+    expect(staleRun).not.toHaveBeenCalled()
+    expect(ctx.tasks.list(staleOwner)).toEqual([])
+    expect(ctx.tasks.list(currentOwner)).toHaveLength(1)
+
+    current.settle({ status: 'completed' })
+    await tick()
+    await ctx.agents.drainCleanups(currentOwner.id)
+  })
 })
 
 describe('TaskService owner cleanup', () => {

+ 3 - 0
packages/tasks/tasks/tsconfig.json

@@ -20,6 +20,9 @@
     {
       "path": "../../core/agent"
     },
+    {
+      "path": "../../core/session"
+    },
     {
       "path": "../../util/timeout"
     }