Merge remote-tracking branch 'origin/master' into codex/rfc-subagent-background-tasks

Adopts #185 (dsh-timeout: clampTimeout/deadline/timeoutOf drive bash
run() timeout classification; runBash loses its own timer) and #108
(ask_user_question) across the task-runtime rework: bash-local keeps
the BashProcess handle shape with master's deadline mechanics, tool
catalogs/expectations carry both the task_* and ask-user tools, and
generated docs are regenerated on the union.
This commit is contained in:
Yichen Jiang
2026-07-09 21:32:07 +08:00
128 changed files with 6732 additions and 332 deletions

View File

@@ -5,5 +5,8 @@ Zero-dependency primitives shared across the other groups. A package lands here
| Package | Role |
|---|---|
| `brand/` | The type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) |
| `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability |
`dsh-brand` is the canonical case: it owns ONLY the `Branded<B>` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`.
`dsh-timeout` follows the same shape for the timeout family: `dsh-bash` and `dsh-web-fetch-local` each fuse a caller's cancellation with a deadline and later classify "timed out" vs "cancelled" by depending on `dsh-timeout` alone. It deliberately owns only the timing/classification half — the *termination* (SIGKILL a process group, tear down a fetch socket) stays in each capability, because no shared layer can own every capability's kill (see [the timeout-library RFC](../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md)).

View File

@@ -0,0 +1,42 @@
# dsh-timeout
The **timing-and-classification** half of a timeout — a zero-dependency library of pure functions (no runtime harness deps) shared by every capability that clamps a caller's timeout hint, arms a deadline, and later has to tell "timed out" apart from "cancelled".
It owns **no termination**. The signal it hands out only *notifies*; actually stopping the work stays in each capability, because that mechanism differs — bash SIGKILLs an OS process group, web tears down a `fetch` socket — and no shared layer can own all of them. This is the boundary the [RFC](../../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md) draws: share the timing/classification, keep the hard kill local.
It is a **library, not a service or plugin**: no `ctx`, registers nothing, holds no state, emits no events. A "timeout service" would have to understand how to stop every capability's work — exactly the knowledge a microkernel keeps out of shared layers.
## Surface
```ts
import { clampTimeout, deadline, timeoutOf, TimeoutReason } from '@deepseek-ai/dsh-timeout'
```
| Export | Role |
|---|---|
| `clampTimeout(requested, def, max, name?)` | Validate the caller's optional positive-finite hint, fill from `def`, cap at `max`. Throws (with `name`) on a non-positive/non-finite hint. |
| `deadline(upstream, timeoutMs, code)` | Fuse `upstream` cancellation with a timeout into one `AbortSignal` (`AbortSignal.any`); the timeout carries a `TimeoutReason`. `[Symbol.dispose]` clears the timer. |
| `timeoutOf(signal \| { reason }, code?)` | Recover the `TimeoutReason` from an aborted signal/error, else `undefined` — the timeout-vs-cancel classifier. Pass `code` to match only THIS deadline's timer (see nesting below). |
| `TimeoutReason` | The internal reason (`code` + `timeoutMs`) stamped on a timeout abort. Not a public error — providers translate it into their own error/field. |
## The `timeoutMs <= 0` sentinel
`0` is the **internal** "no timeout" value for backend-owned background work (bash `start()`): `deadline()` arms no timer and forwards only `upstream`; with no upstream either, it returns a never-aborting signal plus a no-op disposer, so every caller keeps one call shape. External request hints validate as **positive finite** via `clampTimeout` before they reach `deadline`, so `0` is never a model-/plugin-facing "disable timeout" value.
## Usage shape
```ts ignore-check
// Scope-lifetime consumer (foreground bash, one fetch): `using` disposes the timer.
using d = deadline(upstream, timeoutMs, 'BASH_TIMEOUT')
const outcome = await runWork({ signal: d.signal }) // work listens on d.signal and terminates itself
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined // classify the first abort, scoped to OUR code
const aborted = d.signal.aborted && !timedOut // mutually exclusive: timeout won, or cancel did
```
The signal only *notifies* — the caller MUST attach its own termination (`d.signal.addEventListener('abort', kill)`, or hand `d.signal` to `fetch`). Racing a promise against a timer would resolve the tool-call while the child process or socket leaks on; handing out a signal forces a real termination path to exist.
Pass your own `code` to `timeoutOf` so classification composes under nesting: when the `upstream` you were handed is *itself* a deadline signal (a future `tools/execute` middleware arming a per-call deadline), `AbortSignal.any` preserves the outer `TimeoutReason` if the outer timer fires first. Scoping to your `code` makes a foreign timeout read as an ordinary upstream cancel — the correct classification from your capability's view — instead of your own timeout firing when your local timer never expired.
## What does NOT get a timeout
Local file `read`/`write`/`edit` take no `timeoutMs`: a syscall is best-effort-abortable at most, a timeout could not force `fsync`/`rename` to stop, and adding one would be an implicit default that violates explicit-over-implicit. See [`fs/`](../../fs/README.md).

View File

@@ -0,0 +1,30 @@
{
"name": "@deepseek-ai/dsh-timeout",
"description": "Zero-dependency timeout/deadline primitive: clampTimeout, deadline, timeoutOf, TimeoutReason (timing + classification only, no termination)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,162 @@
/**
* The timing-and-classification half of a timeout — a zero-dependency library
* of pure functions shared by every capability that clamps a caller's timeout
* hint, arms a deadline, and later has to tell "timed out" apart from
* "cancelled". It owns NO termination: the returned {@link deadline} signal only
* NOTIFIES; actually stopping the work (SIGKILL a process group, tear down a
* fetch socket, …) stays in each capability's implementation, because that
* mechanism differs per capability and no shared layer can own all of them.
*
* This is deliberately a library, not a cordis service or plugin: it takes no
* `ctx`, registers nothing, holds no cross-call state, and emits no events. A
* "timeout service" would have to understand how to stop every capability's
* work — exactly the knowledge a microkernel keeps out of shared layers.
*
* The four exports and their division of labor:
* - {@link clampTimeout} — validate a caller's optional positive hint, fill the
* backend default, cap at the backend max (pure arithmetic + the shared
* positive-finite request contract).
* - {@link deadline} — fuse upstream cancellation with a timeout into one
* `AbortSignal`, the timeout carrying an identifiable {@link TimeoutReason};
* `[Symbol.dispose]` clears the timer.
* - {@link timeoutOf} — classify an aborted signal (or error): a
* {@link TimeoutReason} means the timeout fired, anything else (or nothing)
* means it did not.
* - {@link TimeoutReason} — the internal classification reason; providers
* translate it into their own public error/result shape before returning.
*
* @module @deepseek-ai/dsh-timeout
*/
/**
* The internal reason attached to a timeout abort so consumers can classify it
* after the fact. It carries the failing `code` (each capability's own string —
* `BASH_TIMEOUT`, `WEB_FETCH_TIMEOUT`, …) and the `timeoutMs` that elapsed.
*
* It is an INTERNAL classification reason, not a public error: providers
* translate it into their seam-specific error code or result field (via
* {@link timeoutOf}) before returning to callers. Native `AbortSignal.timeout()`
* yields a fixed `TimeoutError` indistinguishable across timeout kinds; this
* type is identifiable and carries the code/duration.
*/
export class TimeoutReason extends Error {
override name = 'TimeoutReason'
/**
* @param code Capability-owned timeout code (e.g. `BASH_TIMEOUT`).
* @param timeoutMs The deadline that elapsed, in milliseconds.
*/
constructor(readonly code: string, readonly timeoutMs: number) {
super(`${code} after ${timeoutMs}ms`)
}
}
/**
* Validate a caller's optional timeout hint, fill it from the backend default,
* then cap at the backend max. The shared positive-finite request contract:
* a supplied `requested` must be a positive finite number or this throws —
* `0` is NOT a caller-facing "disable timeout" value (that sentinel is internal
* to {@link deadline}). A missing `requested` falls back to `def`.
*
* @param requested The caller's optional hint; validated when present.
* @param def The backend default applied when `requested` is absent.
* @param max The backend upper bound the result is capped to.
* @param name Field name used in the thrown message (so the caller sees which input was bad).
* @returns The effective timeout in milliseconds: `min(requested ?? def, max)`.
*/
export function clampTimeout(
requested: number | undefined,
def: number,
max: number,
name = 'timeoutMs',
): number {
if (requested !== undefined && (!Number.isFinite(requested) || requested <= 0)) {
throw new Error(`${name} must be a positive finite number`)
}
return Math.min(requested ?? def, max)
}
/** A deadline signal plus the cleanup that clears its timer (dispose-once). */
export interface Deadline {
/** Aborts on upstream cancellation OR on timeout (the timeout carries a {@link TimeoutReason}). */
readonly signal: AbortSignal
/** Clear the timer. Safe to call once; `using` calls it at scope exit. */
[Symbol.dispose](): void
}
/**
* Build a deadline signal that aborts on upstream cancellation OR on timeout,
* with the timeout carrying an identifiable {@link TimeoutReason} (unlike
* native `AbortSignal.timeout()`, whose fixed `TimeoutError` is opaque). It is
* `AbortSignal.any([upstream, <timeout>])` — the single primitive that fuses
* two abort sources — with the reason and a disposable timer added on top.
*
* `timeoutMs <= 0` is the INTERNAL "no timeout" sentinel for backend-owned
* background work: arm no timer and forward only the upstream signal; with no
* upstream either, return a never-aborting signal so callers keep one call
* shape. External request hints validate as positive finite via
* {@link clampTimeout} before reaching here, so `0` never arrives from a model
* or plugin.
*
* The returned object's `[Symbol.dispose]` clears the timer — use `using` for a
* scope-lifetime consumer, or call it manually for an event-lifetime one. The
* signal only NOTIFIES; the caller must attach its own termination (kill the
* process group, abort the fetch, …).
*
* @param upstream The caller's cancellation signal, if any, fused into the result.
* @param timeoutMs Deadline in milliseconds; `<= 0` means "no timeout" (arm no timer).
* @param code Capability-owned code stamped onto the timeout's {@link TimeoutReason}.
* @returns The fused {@link Deadline} (signal + timer cleanup).
*/
export function deadline(
upstream: AbortSignal | undefined,
timeoutMs: number,
code: string,
): Deadline {
if (timeoutMs <= 0) {
// No timeout (background work): forward only the upstream signal, or a
// never-aborting one when there is no upstream. No timer, so dispose is a
// no-op — the empty method keeps the one call shape for every caller.
return { signal: upstream ?? new AbortController().signal, [Symbol.dispose]() {} }
}
const timer = new AbortController()
const id = setTimeout(() => { timer.abort(new TimeoutReason(code, timeoutMs)) }, timeoutMs)
return {
// AbortSignal.any adopts the reason of whichever source aborts FIRST, so a
// race resolves to a single cause: timeoutOf() reads TimeoutReason only
// when the timeout won, and upstream-wins leaves an ordinary abort reason.
signal: upstream !== undefined ? AbortSignal.any([upstream, timer.signal]) : timer.signal,
[Symbol.dispose]() { clearTimeout(id) },
}
}
/**
* Recover the {@link TimeoutReason} from an aborted signal (or any object with a
* `reason`), else `undefined`. This is the classification half: a provider
* calls it on the deadline signal after an abort to decide whether the cause
* was its timeout (translate to the capability's timeout error/field) or an
* ordinary upstream cancellation (`undefined` → the cancel path).
*
* Pass `code` to scope the match to THIS deadline's timer. It matters under
* nesting: when the `upstream` handed to {@link deadline} is itself a deadline
* signal (e.g. a future `tools/execute` middleware arming a per-call deadline),
* `AbortSignal.any` preserves the OUTER `TimeoutReason` if the outer timer fires
* first. Without `code`, the inner capability would misclassify that outer
* timeout as its own (`timedOut:true` / `WEB_FETCH_TIMEOUT`) though its local
* timer never expired; with `code`, a foreign timeout reads as `undefined` and
* falls through to the upstream-cancel path, which is the correct classification
* from the inner capability's view. Omit `code` only to ask "was this ANY
* timeout" (a generic middleware that owns no single code).
*
* @param x An {@link AbortSignal} or any `{ reason }` carrier (e.g. a caught abort error).
* @param code When provided, only a {@link TimeoutReason} with this exact `code` matches.
* @returns The matching {@link TimeoutReason}, else `undefined`.
*/
export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined {
// AbortSignal.reason is typed `any`; pin it to `unknown` so no `any` leaks and
// the instanceof narrows cleanly for both a signal and a bare reason carrier.
const reason: unknown = x.reason
if (!(reason instanceof TimeoutReason)) return undefined
return code === undefined || reason.code === code ? reason : undefined
}

View File

@@ -0,0 +1,185 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clampTimeout, deadline, timeoutOf, TimeoutReason } from '@deepseek-ai/dsh-timeout'
describe('TimeoutReason', () => {
it('is an Error carrying the code and elapsed ms', () => {
const reason = new TimeoutReason('BASH_TIMEOUT', 100)
expect(reason).toBeInstanceOf(Error)
expect(reason.name).toBe('TimeoutReason')
expect(reason.code).toBe('BASH_TIMEOUT')
expect(reason.timeoutMs).toBe(100)
expect(reason.message).toBe('BASH_TIMEOUT after 100ms')
})
})
describe('clampTimeout', () => {
it('fills the default when the hint is absent', () => {
expect(clampTimeout(undefined, 120_000, 600_000)).toBe(120_000)
})
it('caps the hint at max', () => {
expect(clampTimeout(999_999, 120_000, 600_000)).toBe(600_000)
})
it('keeps a valid hint under the cap', () => {
expect(clampTimeout(5_000, 120_000, 600_000)).toBe(5_000)
})
it('caps the default itself when the default exceeds max', () => {
// min(def, max) applies even with no hint — a misconfigured backend never
// exceeds its own cap.
expect(clampTimeout(undefined, 900_000, 600_000)).toBe(600_000)
})
it('rejects a non-finite hint with the caller-provided name', () => {
expect(() => clampTimeout(Number.NaN, 100, 200, 'bash-local: request.timeoutMs'))
.toThrow(/bash-local: request\.timeoutMs must be a positive finite number/)
expect(() => clampTimeout(Number.POSITIVE_INFINITY, 100, 200))
.toThrow(/timeoutMs must be a positive finite number/)
})
it('rejects a non-positive hint', () => {
expect(() => clampTimeout(0, 100, 200)).toThrow(/must be a positive finite number/)
expect(() => clampTimeout(-1, 100, 200)).toThrow(/must be a positive finite number/)
})
})
describe('deadline — timeout arm', () => {
afterEach(() => { vi.useRealTimers() })
it('aborts on timeout with a TimeoutReason after the elapsed ms', () => {
vi.useFakeTimers()
using d = deadline(undefined, 100, 'BASH_TIMEOUT')
expect(d.signal.aborted).toBe(false)
vi.advanceTimersByTime(100)
expect(d.signal.aborted).toBe(true)
const reason = timeoutOf(d.signal)
expect(reason).toBeInstanceOf(TimeoutReason)
expect(reason?.code).toBe('BASH_TIMEOUT')
expect(reason?.timeoutMs).toBe(100)
})
it('[Symbol.dispose] clears the timer so no abort fires afterward', () => {
vi.useFakeTimers()
const d = deadline(undefined, 100, 'BASH_TIMEOUT')
d[Symbol.dispose]()
vi.advanceTimersByTime(1_000)
expect(d.signal.aborted).toBe(false)
expect(timeoutOf(d.signal)).toBeUndefined()
})
})
describe('deadline — fuse with upstream', () => {
it('aborts on upstream cancellation, classified as NOT a timeout', () => {
const upstream = new AbortController()
using d = deadline(upstream.signal, 60_000, 'BASH_TIMEOUT')
upstream.abort('user cancelled')
expect(d.signal.aborted).toBe(true)
expect(timeoutOf(d.signal)).toBeUndefined()
})
it('cancel wins when it fires before the timeout', () => {
vi.useFakeTimers()
try {
const upstream = new AbortController()
using d = deadline(upstream.signal, 100, 'BASH_TIMEOUT')
upstream.abort('user cancelled') // fires first, before the 100ms timer
vi.advanceTimersByTime(200)
expect(d.signal.aborted).toBe(true)
// AbortSignal.any adopts the FIRST source's reason: cancel won, so no
// TimeoutReason even though the timer later elapsed.
expect(timeoutOf(d.signal)).toBeUndefined()
} finally {
vi.useRealTimers()
}
})
it('timeout wins when it fires before upstream cancellation', () => {
vi.useFakeTimers()
try {
const upstream = new AbortController()
using d = deadline(upstream.signal, 100, 'WEB_FETCH_TIMEOUT')
vi.advanceTimersByTime(150) // past the 100ms deadline: the timer fires first
expect(d.signal.aborted).toBe(true)
expect(timeoutOf(d.signal)?.code).toBe('WEB_FETCH_TIMEOUT')
// A later upstream abort is a no-op on the already-aborted fused signal:
// AbortSignal.any keeps the FIRST cause, so the timeout classification stands.
upstream.abort('too late')
expect(timeoutOf(d.signal)?.code).toBe('WEB_FETCH_TIMEOUT')
} finally {
vi.useRealTimers()
}
})
it('forwards a pre-aborted upstream signal immediately', () => {
const upstream = new AbortController()
upstream.abort('already gone')
using d = deadline(upstream.signal, 60_000, 'BASH_TIMEOUT')
expect(d.signal.aborted).toBe(true)
expect(timeoutOf(d.signal)).toBeUndefined()
})
})
describe('deadline — timeoutMs <= 0 (no-timeout sentinel)', () => {
afterEach(() => { vi.useRealTimers() })
it('arms no timer and forwards only the upstream signal', () => {
vi.useFakeTimers()
const upstream = new AbortController()
using d = deadline(upstream.signal, 0, 'BASH_TIMEOUT')
vi.advanceTimersByTime(1_000_000)
expect(d.signal.aborted).toBe(false) // no timer ever armed
upstream.abort('kill')
expect(d.signal.aborted).toBe(true)
expect(timeoutOf(d.signal)).toBeUndefined() // never a timeout
})
it('returns a never-aborting signal with a no-op disposer when there is no upstream', () => {
vi.useFakeTimers()
const d = deadline(undefined, 0, 'BASH_TIMEOUT')
expect(() => { d[Symbol.dispose]() }).not.toThrow()
vi.advanceTimersByTime(1_000_000)
expect(d.signal.aborted).toBe(false)
expect(timeoutOf(d.signal)).toBeUndefined()
})
it('treats a negative timeout the same as zero', () => {
const d = deadline(undefined, -5, 'BASH_TIMEOUT')
expect(d.signal.aborted).toBe(false)
d[Symbol.dispose]()
})
})
describe('timeoutOf', () => {
it('classifies a bare reason carrier that holds a TimeoutReason', () => {
const reason = new TimeoutReason('WEB_FETCH_TIMEOUT', 50)
expect(timeoutOf({ reason })).toBe(reason)
})
it('returns undefined for a non-timeout reason', () => {
expect(timeoutOf({ reason: new Error('other') })).toBeUndefined()
expect(timeoutOf({ reason: 'user cancelled' })).toBeUndefined()
expect(timeoutOf({})).toBeUndefined()
})
it('matches only the requested code when one is given', () => {
const reason = new TimeoutReason('BASH_TIMEOUT', 100)
expect(timeoutOf({ reason }, 'BASH_TIMEOUT')).toBe(reason)
expect(timeoutOf({ reason }, 'WEB_FETCH_TIMEOUT')).toBeUndefined()
})
})
describe('deadline — nested deadlines', () => {
it("does not misclassify an outer deadline's timeout as the inner code", () => {
// The upstream handed to the inner deadline is ITSELF a deadline that has
// already timed out (outer). AbortSignal.any preserves the outer reason;
// scoping timeoutOf to the inner code keeps the inner capability from
// reporting the outer timeout as its own — it reads as an upstream cancel.
const outer = new AbortController()
outer.abort(new TimeoutReason('OUTER_TIMEOUT', 30))
using inner = deadline(outer.signal, 60_000, 'BASH_TIMEOUT')
expect(inner.signal.aborted).toBe(true)
expect(timeoutOf(inner.signal, 'BASH_TIMEOUT')).toBeUndefined() // not ours → upstream-cancel path
expect(timeoutOf(inner.signal)?.code).toBe('OUTER_TIMEOUT') // but IS a timeout, unscoped
})
})

View File

@@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": []
}