Merge remote-tracking branch 'origin/master' into docs/post-v3-release-proofreading

# Conflicts:
#	.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md
#	README.i18n.yaml
#	README.zh.md
#	scripts/snapshots/translation-prompt-v4/request-response.expected.json
This commit is contained in:
xjt
2026-08-12 16:45:48 +08:00
254 changed files with 5437 additions and 517 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/tasks/tasks-local/README.md
README.md: cc2e8422c367eeacfc5fc504298ecd6bfeae4c67
README.zh.md: f4a88b9549dd967f0d8e3c3c329a975bbad83e85
README.md: f558676b36bb5462453bde553eac27b458e1268e
README.zh.md: 1f1d47ffc806eb7c9471929796ea792def4ddd59

View File

@@ -2,7 +2,13 @@
English | [中文](README.zh.md)
Process-local implementation of the [`@deepseek-ai/dsh-tasks`](../tasks/README.md) registry contract: `LocalTaskService` keeps every record in memory, issues per-kind `<kind>-N` ids, and hands out fresh snapshots, never live state. It has no config; load it as a plugin and it registers as `ctx.tasks`.
Process-local implementation of the [`@deepseek-ai/dsh-tasks`](../tasks/README.md) registry contract: `LocalTaskService` keeps every record in memory, issues per-kind `<kind>-N` ids, and hands out fresh snapshots, never live state. Load it as a plugin and it registers as `ctx.tasks`.
## Admission
`maxConcurrentTasksPerOwner` is a positive safe integer and defaults to `10`. Before invoking a producer, `start()` counts the exact owner's `running` and `stopping` records; all unowned tasks share one separate service bucket. Terminal history does not occupy capacity, and only producer `done` settlement releases a stopping task's place.
At capacity, `start()` fails before producer execution and id allocation with an error that names the limit and tells the model to use `task_kill`, wait for the task to finish stopping, and retry. The registry does not queue, preempt, or maintain a second mutable counter.
## Lifecycle
@@ -25,4 +31,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Tasks are process-local** — records die with the harness process; durable or cross-restart execution needs a separate backend implementing the seam.
- **A silently ineffective cancel can stall teardown** — only an explicit throw can be force-failed safely.
- **A silently ineffective cancel can stall teardown and hold capacity** — if `cancel` returns without settling `done`, the registry cannot distinguish it from a slow stop; the task keeps one bucket slot for the rest of the service lifetime, and only an explicit throw can be force-failed safely.

View File

@@ -2,7 +2,13 @@
[English](README.md) | 中文
[`@deepseek-ai/dsh-tasks`](../tasks/README.md) 注册表约定的进程本地实现:`LocalTaskService` 把每条记录保存在内存中,按 kind 签发 `<kind>-N` id,并且只交出全新快照,从不交出实时状态。它没有配置;作为插件加载后即注册为 `ctx.tasks`。
[`@deepseek-ai/dsh-tasks`](../tasks/README.md) 注册表约定的进程本地实现:`LocalTaskService` 把每条记录保存在内存中,按 kind 签发 `<kind>-N` id,并且只交出全新快照,从不交出实时状态。作为插件加载后即注册为 `ctx.tasks`。
## 准入
`maxConcurrentTasksPerOwner` 必须是正的安全整数,默认值为 `10`。调用生产方之前,`start()` 会统计确切 owner 的 `running` 与 `stopping` 记录;所有无 owner 任务共享另一个独立的服务级桶。终止历史不占用容量,处于 `stopping` 的任务只有在生产方 `done` 结算后才释放名额。
达到容量时,`start()` 会在生产方执行和 id 分配前失败;错误会给出上限,并告诉模型使用 `task_kill`、等待任务完全停稳后再重试。注册表不会排队或抢占任务,也不会维护第二份可变计数。
## 生命周期
@@ -25,4 +31,4 @@
## 已知限制与暂缓事项
- **任务只存在于进程本地**:记录会随 harness 进程终止而消失;持久或跨重启执行需要一个单独实现该 seam 的后端。
- **静默无效的取消可能使销毁过程停滞**:只有显式抛出异常才能安全地强制标为失败。
- **静默无效的取消可能使销毁过程停滞并持续占用容量**:如果 `cancel` 返回后始终未结算 `done`,注册表就无法将其与缓慢停止区分开;该任务会在服务剩余生命周期内持续占用一个桶名额,只有显式抛出异常才能安全地强制标为失败。

View File

@@ -39,7 +39,12 @@
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",

View File

@@ -10,6 +10,7 @@
*/
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { AnonymousEntries, ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope'
import type { ScopeLayer } from '@deepseek-ai/dsh-scope'
@@ -23,6 +24,18 @@ import type {
/** Timeout code that distinguishes a bounded wait from caller cancellation. */
export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT'
/** Default maximum number of active tasks in one exact-owner bucket. */
const DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER = 10
/** Configuration for the process-local task registry. */
export interface Config {
/**
* Maximum `running` plus `stopping` tasks per exact owner or in the shared unowned bucket;
* omission defaults to 10.
*/
maxConcurrentTasksPerOwner?: number
}
/** The registry's mutable per-task record (never handed out — see {@link LocalTaskService.snapshot}). */
interface TrackedTask {
id: TaskId
@@ -76,6 +89,16 @@ class TaskLayer implements ScopeLayer {
* semantics this implementation honors.
*/
export class LocalTaskService extends TaskService {
static Config: z<Config> = z.object({
maxConcurrentTasksPerOwner: z.number()
.step(1)
.min(1)
.max(Number.MAX_SAFE_INTEGER)
.default(DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER),
})
/** Schemastery-defaulted active-task limit. */
private readonly maxConcurrentTasksPerOwner: number
private store = new Map<TaskId, TrackedTask>()
private counters = new Map<string, number>()
/**
@@ -97,8 +120,10 @@ export class LocalTaskService extends TaskService {
/** Service context used by detached settlement continuations and teardown. */
private readonly selfCtx: Context
constructor(ctx: Context) {
constructor(ctx: Context, config: Config) {
super(ctx)
// Schemastery validates and fills the default before constructing the service.
this.maxConcurrentTasksPerOwner = (config as Required<Config>).maxConcurrentTasksPerOwner
this.selfCtx = ctx
ctx.effect(() => () => this.disposeAll(), 'tasks teardown')
}
@@ -115,6 +140,13 @@ export class LocalTaskService extends TaskService {
}
if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner)
const active = this.activeTaskCount(spec.owner)
if (active >= this.maxConcurrentTasksPerOwner) {
throw new Error(
`background task limit reached for this owner (limit: ${this.maxConcurrentTasksPerOwner}); use task_kill to stop an unneeded task, wait for it to finish, then retry`,
)
}
const hooks = spec.run()
const count = (this.counters.get(spec.kind) ?? 0) + 1
this.counters.set(spec.kind, count)
@@ -286,6 +318,15 @@ export class LocalTaskService extends TaskService {
.some(layer => !layer.controllers.isEmpty())
}
/** Count authoritative active records for one exact owner or the shared unowned bucket. */
private activeTaskCount(owner: Agent | undefined): number {
let count = 0
for (const task of this.store.values()) {
if (task.owner === owner && (task.status === 'running' || task.status === 'stopping')) count += 1
}
return count
}
/**
* The completion listeners that own `owner`'s notices: the global layer's
* first, then each scoped layer along the owner's chain. A listener outside

View File

@@ -15,8 +15,11 @@ export const name = 'tasks-local-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: the Service Definition companion in `@deepseek-ai/dsh-tasks` already
* validates every registry snapshot this implementation publishes.
* No runtime invariant: `@deepseek-ai/dsh-tasks/invariant` owns per-snapshot identity, status,
* timestamp, and owner checks. This provider's admission decision uses private configuration and
* must fail before a backend starter runs; `LocalTaskService.start()` enforces it synchronously
* for current producers. Repeating an aggregate after publication would expose private
* configuration solely to this companion and would not verify the fail-closed pre-start guarantee.
*/
const install: InvariantInstaller = () => {}

View File

@@ -0,0 +1,66 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
import Include from '@deepseek-ai/cordis-plugin-include'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
describe('tasks-local through a real Loader composition', () => {
it('applies the provider-owned admission config from a Cordis row', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-tasks-local-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-tasks-local'",
' config:',
' maxConcurrentTasksPerOwner: 1',
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (specifier === '@deepseek-ai/dsh-tasks-local') return LocalTaskService
throw new Error(`unexpected Loader import: ${specifier}`)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
expect(context.tasks).toBeInstanceOf(LocalTaskService)
context.tasks.attachController('loader-test')
let settle!: (outcome: { status: 'killed' }) => void
context.tasks.start({
kind: 'bash',
label: 'hold loader slot',
run: () => ({
cancel: () => { settle({ status: 'killed' }) },
done: new Promise((resolve) => { settle = resolve }),
}),
})
expect(() => context!.tasks.start({
kind: 'bash',
label: 'blocked loader task',
run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }),
})).toThrow('(limit: 1)')
})
})

View File

@@ -7,7 +7,7 @@ import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import { TaskId } from '@deepseek-ai/dsh-tasks'
import type { TaskHooks, TaskKind, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import LocalTaskService, { type Config as TasksConfig } from '@deepseek-ai/dsh-tasks-local'
declare module '@deepseek-ai/dsh-tasks' {
interface TaskKindMap {
@@ -76,10 +76,10 @@ function producer(overrides: Partial<Omit<TaskStart, 'run'> & TaskHooks> = {}) {
return { spec, settle, reject, cancels }
}
async function harness() {
async function harness(config: TasksConfig = {}) {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalTaskService)
await ctx.plugin(LocalTaskService, config)
ctx.tasks.attachController('test-controller')
return ctx
}
@@ -166,6 +166,101 @@ describe('LocalTaskService.start', () => {
expect(() => ctx.tasks.start(producer({ outputLimitBytes: 0 }).spec)).toThrow('outputLimitBytes')
})
it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])(
'rejects invalid maxConcurrentTasksPerOwner config: %s',
async (maxConcurrentTasksPerOwner) => {
const ctx = new Context()
await expect(ctx.plugin(LocalTaskService, { maxConcurrentTasksPerOwner }))
.rejects.toThrow()
},
)
it('accepts the largest safe integer limit', async () => {
const ctx = await harness({ maxConcurrentTasksPerOwner: Number.MAX_SAFE_INTEGER })
expect(ctx.tasks).toBeInstanceOf(LocalTaskService)
})
it('defaults each owner bucket to ten active tasks', async () => {
const ctx = await harness()
const live = Array.from({ length: 10 }, () => producer())
for (const task of live) ctx.tasks.start(task.spec)
const blocked = producer()
const run = vi.fn(() => blocked.spec.run())
expect(() => ctx.tasks.start({ ...blocked.spec, run }))
.toThrow('background task limit reached for this owner (limit: 10)')
expect(run).not.toHaveBeenCalled()
for (const task of live) task.settle({ status: 'completed' })
})
it('rejects before producer start and id allocation, then admits immediately after settlement', async () => {
const ctx = await harness({ maxConcurrentTasksPerOwner: 1 })
const first = producer()
expect(ctx.tasks.start(first.spec)).toBe('bash-1')
const blocked = producer()
const run = vi.fn(() => blocked.spec.run())
expect(() => ctx.tasks.start({ ...blocked.spec, run }))
.toThrow('use task_kill to stop an unneeded task, wait for it to finish, then retry')
expect(run).not.toHaveBeenCalled()
first.settle({ status: 'completed' })
await tick()
expect(ctx.tasks.start(blocked.spec)).toBe('bash-2')
})
it('keeps a stopping task in the bucket until producer settlement', async () => {
const ctx = await harness({ maxConcurrentTasksPerOwner: 1 })
const first = producer()
const id = ctx.tasks.start(first.spec)
expect(ctx.tasks.kill(id)).toBe('requested')
const replacement = producer()
expect(() => ctx.tasks.start(replacement.spec)).toThrow('(limit: 1)')
first.settle({ status: 'killed' })
await tick()
expect(ctx.tasks.start(replacement.spec)).toBe('bash-2')
})
it.each(['completed', 'killed', 'failed'] as const)(
'releases the bucket after a %s terminal outcome',
async (status) => {
const ctx = await harness({ maxConcurrentTasksPerOwner: 1 })
const first = producer()
ctx.tasks.start(first.spec)
first.settle({ status })
await tick()
expect(() => ctx.tasks.start(producer().spec)).not.toThrow()
},
)
it('isolates exact owners, replacement objects with the same session id, and the unowned bucket', async () => {
const ctx = await harness({ maxConcurrentTasksPerOwner: 1 })
const oldOwner = stubAgent(ctx, 'shared-session')
const detachOld = ctx.agents.register(oldOwner)
const oldTask = producer({ owner: oldOwner })
ctx.tasks.start(oldTask.spec)
const otherOwner = stubAgent(ctx, 'other-session')
ctx.agents.register(otherOwner)
expect(() => ctx.tasks.start(producer({ owner: otherOwner }).spec)).not.toThrow()
detachOld()
const replacement = stubAgent(ctx, 'shared-session')
ctx.agents.register(replacement)
expect(() => ctx.tasks.start(producer({ owner: replacement }).spec)).not.toThrow()
ctx.tasks.start(producer().spec)
expect(() => ctx.tasks.start(producer().spec)).toThrow('(limit: 1)')
expect(() => ctx.tasks.start(producer({ owner: oldOwner }).spec))
.toThrow('is not the registered agent instance')
oldTask.settle({ status: 'completed' })
await tick()
await disposeAgentScope(oldOwner)
})
it('issues kind-prefixed ids from per-kind counters', async () => {
const ctx = await harness()
expect(ctx.tasks.start(producer().spec)).toBe('bash-1')

View File

@@ -14,6 +14,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/agent"
},

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/tasks/tasks/README.md
README.md: 18029a2e93396336139612ba72804aeb11e87edf
README.zh.md: 611014c820c2d2d1e0891a93352a4f1b198fea98
README.md: 60898e8de8ffa29a823c537ba5f5876b63a03c88
README.zh.md: 5d58375183227e27cc3978e4d354edd4fee3bd81

View File

@@ -6,7 +6,7 @@ The background task registry contract (`ctx.tasks`). The abstract `TaskService`
## Service contract
- `start(spec): TaskId` validates the attached controller, spec, exact live owner, and optional positive `outputLimitBytes` before calling the producer's `run()` once. A starter throw leaves nothing registered; successful return commits without another failable step.
- `start(spec): TaskId` validates the attached controller, spec, exact live owner, optional positive `outputLimitBytes`, and any provider-owned admission policy before calling the producer's `run()` once. A preflight rejection or starter throw leaves no task id or registered work; successful return commits without another failable step.
- `get(id, caller?)` and `list(caller?)` return non-consuming snapshots. Listing includes only caller-owned and unowned tasks.
- `read(id, caller?)` consumes the single cursor for stream tasks and reads terminal output idempotently for final-output tasks.
- `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the task running; success changes it to `stopping` and marks terminal delivery reported.

View File

@@ -6,7 +6,7 @@
## 服务约定
- `start(spec): TaskId` 验证已附加的任务控制器、spec、确切且仍存活的 owner,以及可选的 `outputLimitBytes`(如提供则须为正数),然后只调用生产方的 `run()` 一次。启动方抛出异常时不注册任何内容;成功返回会直接提交,不再执行其他可能失败的步骤。
- `start(spec): TaskId` 验证已附加的任务控制器、spec、确切且仍存活的 owner、可选的正数 `outputLimitBytes`,以及 Service provider 所拥有的准入策略,然后只调用生产方的 `run()` 一次。预检拒绝或启动方抛出异常时都不会生成 task id 或注册工作;成功返回会直接提交,不再执行其他可能失败的步骤。
- `get(id, caller?)` 和 `list(caller?)` 返回非消费式快照。列表只包含调用方拥有及无 owner 的任务。
- `read(id, caller?)` 消费流任务的唯一游标;对于最终输出任务,则以幂等方式读取终止输出。
- `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。

View File

@@ -71,10 +71,11 @@ export abstract class TaskService extends Service {
}
/**
* Preflight access, validation, and owner cleanup before starting and
* atomically registering work. A throwing starter leaves nothing registered;
* after it returns, registration cannot fail. Settlement records the outcome,
* notifies listeners, and releases waiters.
* Preflight access, validation, owner cleanup, and implementation-owned
* admission before starting and atomically registering work. Any preflight
* rejection leaves no task id or execution resource. A throwing starter
* leaves nothing registered; after it returns, registration cannot fail.
* Settlement records the outcome, notifies listeners, and releases waiters.
* @param spec - task identity, owner, and synchronous starter.
* @returns the registry-issued `<kind>-N` id.
*/

View File

@@ -759,6 +759,7 @@ describe('completion notices', () => {
const prior = producer({ kind: 'pty-send' })
ctx.tasks.start(prior.spec)
prior.settle({ status: 'completed' })
await tick()
}
const inject = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', { inject })