refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
6
packages/jobs/README.i18n.yaml
Normal file
6
packages/jobs/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/jobs/README.md
|
||||
README.md: daad96ae561f8f943759332b6b1ea1e20fcaf40e
|
||||
README.zh.md: 4e733b1da09656c9d779c43a0ee77703e1c10bfa
|
||||
15
packages/jobs/README.md
Normal file
15
packages/jobs/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# jobs/ — background-job capability family
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
This family gives long-running tools one owner-isolated background-job protocol for observation, cancellation, waiting, and completion notices.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`jobs/`](jobs/README.md) | Defines the job registry and lifecycle contract | `ctx.jobs` |
|
||||
| [`jobs-local/`](jobs-local/README.md) | Implements the process-local job registry | registers on `ctx.jobs` |
|
||||
| [`tool-jobs/`](tool-jobs/README.md) | Exposes job control and completion notices to the model | registers on `ctx.tools` |
|
||||
|
||||
See the [background-job runtime](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and [job-registry](../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md) decisions.
|
||||
|
||||
The subsystem reference — the id scheme, the owner-fenced contract, snapshots — is [docs/subsystems/jobs.md](../../docs/subsystems/jobs.md); design in the [background-job runtime](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and [job-registry contract](../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md) Agent Notes.
|
||||
15
packages/jobs/README.zh.md
Normal file
15
packages/jobs/README.zh.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# jobs/:后台任务能力家族
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
本家族为长时间运行的工具提供一套按所有者隔离的后台任务协议,用于观察、取消、等待和完成通知。
|
||||
|
||||
| 包 | 职责 | ctx 键 |
|
||||
|---|---|---|
|
||||
| [`jobs/`](jobs/README.md) | 定义任务注册表和生命周期约定 | `ctx.jobs` |
|
||||
| [`jobs-local/`](jobs-local/README.md) | 实现进程本地任务注册表 | 注册到 `ctx.jobs` |
|
||||
| [`tool-jobs/`](tool-jobs/README.md) | 向模型公开任务控制和完成通知 | 注册到 `ctx.tools` |
|
||||
|
||||
参见[后台任务运行时](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)和[任务注册表](../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md)决策。
|
||||
|
||||
子系统参考文档——id 方案、所有者隔离约定、快照——见 [docs/subsystems/jobs.md](../../docs/subsystems/jobs.md);设计见[后台任务运行时](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)与[任务注册表约定](../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md)两篇 Agent Note。
|
||||
6
packages/jobs/jobs-local/README.i18n.yaml
Normal file
6
packages/jobs/jobs-local/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/jobs/jobs-local/README.md
|
||||
README.md: e55d9d1747e0dbb12d22f2528527312334f352e6
|
||||
README.zh.md: 78979d9518b1b588c689ce5184b33d79177a1ec0
|
||||
34
packages/jobs/jobs-local/README.md
Normal file
34
packages/jobs/jobs-local/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# @deepseek-ai/dsh-jobs-local
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Process-local implementation of the [`@deepseek-ai/dsh-jobs`](../jobs/README.md) registry contract: `LocalJobRegistry` 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.jobs`.
|
||||
|
||||
## Admission
|
||||
|
||||
`maxConcurrentJobsPerOwner` 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 jobs share one separate service bucket. Terminal history does not occupy capacity, and only producer `done` settlement releases a stopping job'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 `job_kill`, wait for the job to finish stopping, and retry. The registry does not queue, preempt, or maintain a second mutable counter.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Jobs belong to their owner and backend, not the producer tool fiber, so producer and controller reloads do not stop them. The first job for an owner attaches one awaited effect to the exact `Agent` scope. Owner disposal cancels that object's jobs, awaits producer quiescence, and removes their snapshots; reused agent or session ids cannot redirect an old cleanup.
|
||||
|
||||
Service disposal closes listeners, cancels all live jobs, awaits their records, and detaches effects from surviving owner scopes. If teardown cancellation throws, the service force-fails the record and warns that work may be orphaned instead of deadlocking. A cancellation that returns but never settles `done` remains indistinguishable from a slow stop and can stall teardown.
|
||||
|
||||
Settlement is first-wins: the earliest terminal outcome — producer settlement, a rejected `done` contained as `failed`, or a teardown force-failure — records once, releases waiters, and notifies listeners once with per-listener containment. Pending waits mark the job reported before listeners run so completion reporters do not duplicate notices, and a teardown cancel marks it for the same reason: nothing will read a notice addressed to an owner being destroyed. Completion is the last thing a settlement announces, after the record is committed and the visible-set change is published, because a reporter may open a model turn synchronously and every other observer must already have seen the settled record.
|
||||
|
||||
Controllers and listeners are layered by the scope that registered them, in the tools-registry shape: a registration files into its registering context's scope, and a read unions the global layer with the owner's scope chain. One process-wide registry therefore answers per-owner questions per owner — `start()` refuses `background jobs unavailable: no job controller serves this agent (load @deepseek-ai/dsh-tool-jobs in its composition)` for an owner whose own composition attaches none, however many other compositions attach theirs, and a settlement reaches only the listeners its owner's composition registered.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through producer plugins and [`dsh-tool-jobs`](../tool-jobs/README.md), which render job ids, output, status, cancellation, and completion notices.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Jobs 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 and hold capacity** — if `cancel` returns without settling `done`, the registry cannot distinguish it from a slow stop; the job keeps one bucket slot for the rest of the service lifetime, and only an explicit throw can be force-failed safely.
|
||||
34
packages/jobs/jobs-local/README.zh.md
Normal file
34
packages/jobs/jobs-local/README.zh.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# @deepseek-ai/dsh-jobs-local
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
[`@deepseek-ai/dsh-jobs`](../jobs/README.md) 注册表约定的进程本地实现:`LocalJobRegistry` 把每条记录保存在内存中,按 kind 签发 `<kind>-N` id,并且只交出全新快照,从不交出实时状态。作为插件加载后即注册为 `ctx.jobs`。
|
||||
|
||||
## 准入
|
||||
|
||||
`maxConcurrentJobsPerOwner` 必须是正的安全整数,默认值为 `10`。调用生产方之前,`start()` 会统计确切 owner 的 `running` 与 `stopping` 记录;所有无 owner 任务共享另一个独立的服务级桶。终止历史不占用容量,处于 `stopping` 的任务只有在生产方 `done` 结算后才释放名额。
|
||||
|
||||
达到容量时,`start()` 会在生产方执行和 id 分配前失败;错误会给出上限,并告诉模型使用 `job_kill`、等待任务完全停稳后再重试。注册表不会排队或抢占任务,也不会维护第二份可变计数。
|
||||
|
||||
## 生命周期
|
||||
|
||||
任务属于其所有者和后端,而不是生产方工具 fiber,因此重载生产方或控制器不会停止任务。某个所有者的第一个任务会把一个会被等待的 effect 附加到对应 `Agent` 对象的 scope 上。所有者的 dispose(资源释放)会取消该对象的任务,等待生产方完全停稳,并移除其快照;复用的 agent(智能体)id 或会话 id 无法重定向旧的清理操作。
|
||||
|
||||
服务 dispose 会关闭监听器、取消所有存活任务、等待其记录完成,并从仍存活的所有者 scope 中分离 effect。如果销毁期间的取消操作抛出异常,服务会强制将记录标为失败,并警告工作可能成为孤立工作,而不会死锁。取消操作已返回但 `done` 始终未结算时,系统无法将其与缓慢停止区分开,销毁过程可能因此停滞。
|
||||
|
||||
结算遵循首次结算优先原则:最早出现的终止结果(生产方结算、作为 `failed` 隔离处理的 `done` 拒绝,或销毁时的强制失败)只记录一次,随后释放等待方,再只通知监听器一次;各监听器的故障会单独隔离。挂起的等待会在监听器运行前把任务标记为已报告,因此完成报告方不会重复发出通知;销毁时的取消出于同样的理由也会标记:面向正在被销毁的所有者的通知不会有人读到。完成是一次结算最后才宣布的事情,排在记录提交与可见集变更发布之后,因为报告方可能同步开启一个模型轮次,而该结算的其他所有观察者都必须已经看到已结算的记录。
|
||||
|
||||
控制器与监听器按注册方所在的 scope 分层,形状与 tools 注册表一致:一次注册归档到其注册上下文的 scope,一次读取则把全局层与所有者的 scope 链求并集。因此一个进程级注册表能逐所有者地回答逐所有者的问题——对自身组合未附加任何控制器的所有者,无论其他组合附加了多少,`start()` 都会拒绝并抛出 `background jobs unavailable: no job controller serves this agent (load @deepseek-ai/dsh-tool-jobs in its composition)`;一次结算也只会抵达其所有者所属组合注册的监听器。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过生产方插件和 [`dsh-tool-jobs`](../tool-jobs/README.md) 间接影响;它们会呈现 job id、输出、状态、取消和完成通知。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **任务只存在于进程本地**:记录会随 harness 进程终止而消失;持久或跨重启执行需要一个单独实现该 seam 的后端。
|
||||
- **静默无效的取消可能使销毁过程停滞并持续占用容量**:如果 `cancel` 返回后始终未结算 `done`,注册表就无法将其与缓慢停止区分开;该任务会在服务剩余生命周期内持续占用一个桶名额,只有显式抛出异常才能安全地强制标为失败。
|
||||
57
packages/jobs/jobs-local/package.json
Normal file
57
packages/jobs/jobs-local/package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-jobs-local",
|
||||
"description": "Process-local implementation of the DeepSeek Harness background job registry seam",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/jobs/jobs-local"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-jobs": "workspace:^",
|
||||
"@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:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-jobs": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
534
packages/jobs/jobs-local/src/index.ts
Normal file
534
packages/jobs/jobs-local/src/index.ts
Normal file
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* Process-local provider for the background-job capability seam
|
||||
* (`ctx.jobs`). It keeps every record in memory and hands out fresh
|
||||
* snapshots, never live state.
|
||||
*
|
||||
* Registrations outlive producer and controller fibers. Agent or service
|
||||
* disposal cancels live work and awaits compliant producers; a throwing
|
||||
* teardown cancel force-fails only the record and reports a possible orphan.
|
||||
* @module @deepseek-ai/dsh-jobs-local
|
||||
*/
|
||||
|
||||
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'
|
||||
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { JobRegistry, JobId } from '@deepseek-ai/dsh-jobs'
|
||||
import type {
|
||||
JobDoneListener, JobKind, JobOutcome, JobRead, JobSnapshot, JobStart, JobStatus,
|
||||
JobsChangedListener,
|
||||
} from '@deepseek-ai/dsh-jobs'
|
||||
|
||||
/** Timeout code that distinguishes a bounded wait from caller cancellation. */
|
||||
export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT'
|
||||
|
||||
/** Default maximum number of active jobs in one exact-owner bucket. */
|
||||
const DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER = 10
|
||||
|
||||
/** Configuration for the process-local job registry. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Maximum `running` plus `stopping` jobs per exact owner or in the shared unowned bucket;
|
||||
* omission defaults to 10.
|
||||
*/
|
||||
maxConcurrentJobsPerOwner?: number
|
||||
}
|
||||
|
||||
/** The registry's mutable per-job record (never handed out — see {@link LocalJobRegistry.snapshot}). */
|
||||
interface TrackedTask {
|
||||
id: JobId
|
||||
kind: JobKind
|
||||
label: string
|
||||
outputLimitBytes: number | undefined
|
||||
/** Exact lifecycle owner; session-id authorization is derived from it. */
|
||||
owner: Agent | undefined
|
||||
cancel: (reason?: string) => void
|
||||
readOutput: (() => string) | undefined
|
||||
status: JobStatus
|
||||
detail: string | undefined
|
||||
output: string | undefined
|
||||
startedAt: number
|
||||
finishedAt: number | undefined
|
||||
reported: boolean
|
||||
/** Resolves once the terminal snapshot is recorded and listeners notified. */
|
||||
settled: Promise<void>
|
||||
/** Resolver for {@link settled}, called by the first effective settlement. */
|
||||
markSettled: () => void
|
||||
/** Live waits; settlement with a waiter marks the job reported. */
|
||||
waiters: number
|
||||
/** Removable resolvers for live waits; timeout/abort unregister before the job settles. */
|
||||
waitResolvers: Set<() => void>
|
||||
}
|
||||
|
||||
/** True for the three terminal {@link JobStatus} values. */
|
||||
function isTerminal(status: JobStatus): boolean {
|
||||
return status === 'completed' || status === 'killed' || status === 'failed'
|
||||
}
|
||||
|
||||
/**
|
||||
* One scope's contributions: the job controllers attached from it and the
|
||||
* completion listeners registered there. Both tables are anonymous because a
|
||||
* contribution is identified by its own disposer, never by a name a second
|
||||
* registrant could shadow.
|
||||
*/
|
||||
class JobLayer implements ScopeLayer {
|
||||
readonly controllers = new AnonymousEntries<symbol>()
|
||||
readonly listeners = new AnonymousEntries<JobDoneListener>()
|
||||
readonly changed = new AnonymousEntries<JobsChangedListener>()
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.controllers.isEmpty() && this.listeners.isEmpty() && this.changed.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-memory `jobs` registry. See the Service Definition contract in
|
||||
* `@deepseek-ai/dsh-jobs` for the ownership, isolation, and lifecycle
|
||||
* semantics this implementation honors.
|
||||
*/
|
||||
export class LocalJobRegistry extends JobRegistry {
|
||||
static Config: z<Config> = z.object({
|
||||
maxConcurrentJobsPerOwner: z.number()
|
||||
.step(1)
|
||||
.min(1)
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.default(DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER),
|
||||
})
|
||||
|
||||
/** Schemastery-defaulted active-job limit. */
|
||||
private readonly maxConcurrentJobsPerOwner: number
|
||||
private store = new Map<JobId, TrackedTask>()
|
||||
private counters = new Map<string, number>()
|
||||
/**
|
||||
* Surfaces and listeners layered by the scope that registered them, in the
|
||||
* tools-registry shape: a contribution files into its registering context's
|
||||
* scope, and a read unions the global layer with the reader's scope chain.
|
||||
*
|
||||
* The registry is one process-wide instance serving every composition, so a
|
||||
* flat table would answer a per-owner question process-wide: one preset's
|
||||
* job controls would hold `start()` open for an agent whose own composition
|
||||
* loads none, and one settlement would reach every preset's notice listener.
|
||||
* Layers make both reads owner-relative. Nothing derives a cache from a
|
||||
* layer, so change notification is a no-op.
|
||||
*/
|
||||
private readonly layers = new ScopedLayers<JobLayer>(() => new JobLayer(), () => {})
|
||||
private listenersClosed = false
|
||||
/** Owner agents with attached scope cleanup, mapped to the exact disposer. */
|
||||
private ownerCleanups = new Map<Agent, () => Promise<void> | void>()
|
||||
/** Service context used by detached settlement continuations and teardown. */
|
||||
private readonly selfCtx: Context
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
// Schemastery validates and fills the default before constructing the service.
|
||||
this.maxConcurrentJobsPerOwner = (config as Required<Config>).maxConcurrentJobsPerOwner
|
||||
this.selfCtx = ctx
|
||||
ctx.effect(() => () => this.disposeAll(), 'jobs teardown')
|
||||
}
|
||||
|
||||
start(spec: JobStart): JobId {
|
||||
if (!this.servesOwner(spec.owner)) {
|
||||
throw new Error('background jobs unavailable: no job controller serves this agent (load @deepseek-ai/dsh-tool-jobs in its composition)')
|
||||
}
|
||||
if (spec.kind.length === 0) throw new Error('invalid job kind: expected a non-empty string')
|
||||
if (spec.label.length === 0) throw new Error('invalid job label: expected a non-empty string')
|
||||
if (spec.outputLimitBytes !== undefined
|
||||
&& (!Number.isSafeInteger(spec.outputLimitBytes) || spec.outputLimitBytes <= 0)) {
|
||||
throw new Error(`invalid outputLimitBytes: expected a positive safe integer, got ${JSON.stringify(spec.outputLimitBytes)}`)
|
||||
}
|
||||
if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner)
|
||||
|
||||
const active = this.activeTaskCount(spec.owner)
|
||||
if (active >= this.maxConcurrentJobsPerOwner) {
|
||||
throw new Error(
|
||||
`background job limit reached for this owner (limit: ${this.maxConcurrentJobsPerOwner}); use job_kill to stop an unneeded job, 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)
|
||||
const id = JobId(`${spec.kind}-${count}`)
|
||||
|
||||
let markSettled!: () => void
|
||||
const settled = new Promise<void>((resolve) => { markSettled = resolve })
|
||||
const job: TrackedTask = {
|
||||
id,
|
||||
kind: spec.kind,
|
||||
label: spec.label,
|
||||
outputLimitBytes: spec.outputLimitBytes,
|
||||
owner: spec.owner,
|
||||
cancel: hooks.cancel.bind(hooks),
|
||||
readOutput: hooks.readOutput?.bind(hooks),
|
||||
status: 'running',
|
||||
detail: undefined,
|
||||
output: undefined,
|
||||
startedAt: Date.now(),
|
||||
finishedAt: undefined,
|
||||
reported: false,
|
||||
settled,
|
||||
markSettled,
|
||||
waiters: 0,
|
||||
waitResolvers: new Set(),
|
||||
}
|
||||
this.store.set(id, job)
|
||||
|
||||
void hooks.done.then(
|
||||
(outcome) => { this.settle(job, outcome) },
|
||||
(error: unknown) => {
|
||||
// Contain a producer contract violation (`done` rejected) so cleanup and waiters cannot hang.
|
||||
this.selfCtx.logger.warn(`jobs: job ${job.id} producer done promise rejected (producer contract violation): ${String(error)}`)
|
||||
this.settle(job, { status: 'failed', detail: String(error) })
|
||||
},
|
||||
)
|
||||
// Registration is complete and cannot fail from here, so the visible set
|
||||
// has genuinely changed.
|
||||
this.notifyChanged(job.owner)
|
||||
return id
|
||||
}
|
||||
|
||||
list(caller?: Agent): JobSnapshot[] {
|
||||
const session = caller?.id
|
||||
return [...this.store.values()]
|
||||
.filter(job => job.owner === undefined || job.owner.id === session)
|
||||
.map(job => this.snapshot(job))
|
||||
}
|
||||
|
||||
get(id: JobId, caller?: Agent): JobSnapshot {
|
||||
const job = this.expect(id)
|
||||
this.assertAccess(job, caller)
|
||||
return this.snapshot(job)
|
||||
}
|
||||
|
||||
read(id: JobId, caller?: Agent): JobRead {
|
||||
const job = this.expect(id)
|
||||
this.assertAccess(job, caller)
|
||||
const text = job.readOutput !== undefined
|
||||
? job.readOutput()
|
||||
: isTerminal(job.status) ? job.output ?? '' : ''
|
||||
if (isTerminal(job.status)) job.reported = true
|
||||
return { text, snapshot: this.snapshot(job) }
|
||||
}
|
||||
|
||||
kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished' {
|
||||
const job = this.expect(id)
|
||||
this.assertAccess(job, caller)
|
||||
if (isTerminal(job.status)) {
|
||||
job.reported = true
|
||||
return 'already-finished'
|
||||
}
|
||||
// Cancel first so a throw leaves both lifecycle and notice state unchanged.
|
||||
job.cancel(reason)
|
||||
job.status = 'stopping'
|
||||
job.reported = true
|
||||
this.notifyChanged(job.owner)
|
||||
return 'requested'
|
||||
}
|
||||
|
||||
async wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<JobSnapshot> {
|
||||
const job = this.expect(id)
|
||||
this.assertAccess(job, caller)
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new Error(`invalid wait timeout: expected a positive number of milliseconds, got ${JSON.stringify(timeoutMs)}`)
|
||||
}
|
||||
if (!isTerminal(job.status)) {
|
||||
if (signal?.aborted) throw new Error('wait aborted')
|
||||
// Abort removes the waiter synchronously so same-tick settlement cannot
|
||||
// suppress a notice for a wait that will reject.
|
||||
job.waiters += 1
|
||||
let counted = true
|
||||
const uncount = (): void => {
|
||||
if (!counted) return
|
||||
counted = false
|
||||
job.waiters -= 1
|
||||
}
|
||||
try {
|
||||
// The scoped deadline distinguishes a successful wait timeout from
|
||||
// caller cancellation and clears its timer on every exit.
|
||||
using d = deadline(signal, timeoutMs, TASK_WAIT_TIMEOUT)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onSettled = (): void => {
|
||||
job.waitResolvers.delete(onSettled)
|
||||
d.signal.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
job.waitResolvers.delete(onSettled)
|
||||
// A settled job cannot reach here: settlement releases every waiter
|
||||
// before it announces completion, and each released waiter detaches
|
||||
// this listener in the same synchronous span, so nothing that reacts
|
||||
// to a settlement can abort a wait the settlement already owed.
|
||||
if (timeoutOf(d.signal, TASK_WAIT_TIMEOUT) !== undefined) {
|
||||
resolve()
|
||||
} else {
|
||||
uncount()
|
||||
reject(new Error('wait aborted'))
|
||||
}
|
||||
}
|
||||
job.waitResolvers.add(onSettled)
|
||||
d.signal.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
} finally {
|
||||
uncount()
|
||||
}
|
||||
}
|
||||
if (isTerminal(job.status)) job.reported = true
|
||||
return this.snapshot(job)
|
||||
}
|
||||
|
||||
onJobDone(listener: JobDoneListener): () => void {
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.listeners.append(listener),
|
||||
{ label: 'jobs.onJobDone()' },
|
||||
)
|
||||
}
|
||||
|
||||
onJobsChanged(listener: JobsChangedListener): () => void {
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.changed.append(listener),
|
||||
{ label: 'jobs.onJobsChanged()' },
|
||||
)
|
||||
}
|
||||
|
||||
attachController(name: string): () => void {
|
||||
// One token per call keeps duplicate labels independently disposable.
|
||||
const token = Symbol(name)
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.controllers.append(token),
|
||||
{ label: 'jobs.attachController()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an attached job controller can collect and stop work owned by
|
||||
* `owner`. The global layer holds every controller attached from an unscoped
|
||||
* context — a host composition's own controls — and therefore serves every
|
||||
* owner; a scoped controller serves exactly the agents composed under it.
|
||||
* @param owner - the job's owner, or undefined for unowned work.
|
||||
* @returns whether some reachable controller serves the owner.
|
||||
*/
|
||||
private servesOwner(owner?: Agent): boolean {
|
||||
if (!this.layers.global.controllers.isEmpty()) return true
|
||||
return this.layers.chainLayers(owner === undefined ? undefined : scopeOf(owner.ctx))
|
||||
.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 job of this.store.values()) {
|
||||
if (job.owner === owner && (job.status === 'running' || job.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
|
||||
* that chain belongs to another composition and must not deliver, or the
|
||||
* owner reads one notice per mounted preset.
|
||||
* @param owner - the settled job's owner, or undefined for unowned work.
|
||||
* @returns the listeners to notify, in registration order per layer.
|
||||
*/
|
||||
private *listenersFor(owner?: Agent): IterableIterator<JobDoneListener> {
|
||||
yield* this.layers.global.listeners.values()
|
||||
const scope = owner === undefined ? undefined : scopeOf(owner.ctx)
|
||||
for (const layer of this.layers.chainLayers(scope)) yield* layer.listeners.values()
|
||||
}
|
||||
|
||||
/** Look up a job or fail loud. */
|
||||
private expect(id: JobId): TrackedTask {
|
||||
const job = this.store.get(id)
|
||||
if (job === undefined) throw new Error(`unknown job ${id}`)
|
||||
return job
|
||||
}
|
||||
|
||||
/**
|
||||
* The isolation fence: a job with an owner is reachable only by callers
|
||||
* whose session id matches (`!== undefined` semantics — an unowned job is
|
||||
* open, and a no-agent caller can never match an owned one).
|
||||
*/
|
||||
private assertAccess(job: TrackedTask, caller?: Agent): void {
|
||||
if (job.owner !== undefined && job.owner.id !== caller?.id) {
|
||||
throw new Error(`job ${job.id} belongs to another session`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Project a fresh read-only snapshot from the mutable record. */
|
||||
private snapshot(job: TrackedTask): JobSnapshot {
|
||||
const ownerSession = job.owner?.id
|
||||
return {
|
||||
id: job.id,
|
||||
kind: job.kind,
|
||||
label: job.label,
|
||||
...job.outputLimitBytes !== undefined ? { outputLimitBytes: job.outputLimitBytes } : {},
|
||||
...ownerSession !== undefined ? { ownerSession } : {},
|
||||
status: job.status,
|
||||
...job.detail !== undefined ? { detail: job.detail } : {},
|
||||
startedAt: job.startedAt,
|
||||
...job.finishedAt !== undefined ? { finishedAt: job.finishedAt } : {},
|
||||
reported: job.reported,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The change observers that own `owner`'s updates, resolved exactly like
|
||||
* {@link listenersFor}: the global layer — a host composition's own carrier,
|
||||
* which serves every owner — then each scoped layer along the owner's chain.
|
||||
* An observer outside that chain belongs to another composition and would
|
||||
* otherwise be told about agents it does not compose.
|
||||
* @param owner - the owner whose visible set moved, or undefined for unowned work.
|
||||
* @returns the observers to notify, in registration order per layer.
|
||||
*/
|
||||
private *changedFor(owner?: Agent): IterableIterator<JobsChangedListener> {
|
||||
yield* this.layers.global.changed.values()
|
||||
const scope = owner === undefined ? undefined : scopeOf(owner.ctx)
|
||||
for (const layer of this.layers.chainLayers(scope)) yield* layer.changed.values()
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce that one owner's visible set changed. Each listener is contained
|
||||
* so an observer cannot break a lifecycle commit that already happened.
|
||||
*/
|
||||
private notifyChanged(owner: Agent | undefined): void {
|
||||
for (const listener of this.changedFor(owner)) {
|
||||
try {
|
||||
listener(owner)
|
||||
} catch (error: unknown) {
|
||||
this.selfCtx.logger.warn(`jobs: onJobsChanged listener threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the first terminal outcome, release waiters, then announce
|
||||
* completion. First-wins preserves a teardown force-failure against late
|
||||
* producer settlement. Pending waits mark the job reported before listeners
|
||||
* run. Completion is announced last because a reporter may open a model turn
|
||||
* synchronously: every other observer of this settlement must already have
|
||||
* seen the committed record.
|
||||
*/
|
||||
private settle(job: TrackedTask, outcome: JobOutcome): void {
|
||||
if (isTerminal(job.status)) return
|
||||
job.status = outcome.status
|
||||
job.detail = outcome.detail
|
||||
job.output = outcome.output
|
||||
job.finishedAt = Date.now()
|
||||
if (job.waiters > 0) job.reported = true
|
||||
const snapshot = this.snapshot(job)
|
||||
const waitResolvers = [...job.waitResolvers]
|
||||
job.waitResolvers.clear()
|
||||
for (const resolveWait of waitResolvers) resolveWait()
|
||||
job.markSettled()
|
||||
this.notifyChanged(job.owner)
|
||||
if (this.listenersClosed) return
|
||||
for (const listener of this.listenersFor(job.owner)) {
|
||||
try {
|
||||
const returned = listener(snapshot, job.owner)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.selfCtx.logger.warn(`jobs: onJobDone listener rejected for ${job.id}: ${String(error)}`)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
this.selfCtx.logger.warn(`jobs: onJobDone listener threw for ${job.id}: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach one awaited cleanup through the exact owner's scope. This survives
|
||||
* producer reloads and joins agent quiescence; the retained disposer lets
|
||||
* service teardown detach the cross-fiber effect. Fails when the registry is
|
||||
* absent or the owner is not its currently registered instance.
|
||||
*/
|
||||
private ensureOwnerCleanup(owner: Agent): void {
|
||||
const ownerId = owner.id
|
||||
const agents = this.selfCtx.get('agents')
|
||||
if (agents === undefined) {
|
||||
throw new Error('background job 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 job owner must be live)`)
|
||||
}
|
||||
if (this.ownerCleanups.has(owner)) return
|
||||
// Record only after attach succeeds; a disposing scope rejects new effects.
|
||||
const detach = owner.ctx.effect(() => async () => {
|
||||
this.ownerCleanups.delete(owner)
|
||||
await this.disposeOwned(owner)
|
||||
}, 'jobs.ownerCleanup()')
|
||||
this.ownerCleanups.set(owner, detach)
|
||||
}
|
||||
|
||||
/** Cancel, await terminal records, and drop every job owned by one exact agent lifecycle. */
|
||||
private async disposeOwned(owner: Agent): Promise<void> {
|
||||
const owned = [...this.store.values()].filter(job => job.owner === owner)
|
||||
this.cancelForTeardown(owned, 'owner disposed')
|
||||
await Promise.all(owned.map(job => job.settled))
|
||||
for (const job of owned) this.store.delete(job.id)
|
||||
// Removal is the one visible-set change no per-job record carries, so it
|
||||
// must be announced here or an observer keeps the dropped rows forever.
|
||||
if (owned.length > 0) this.notifyChanged(owner)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close listeners, cancel live jobs, await settlement, and detach owner
|
||||
* effects. Throwing cancels are force-failed to avoid teardown deadlock.
|
||||
*/
|
||||
private async disposeAll(): Promise<void> {
|
||||
// The flag is the whole guard: each layer entry's undo belongs to the fiber
|
||||
// that registered it, so this service may not drop them on its own way out.
|
||||
this.listenersClosed = true
|
||||
const all = [...this.store.values()]
|
||||
this.cancelForTeardown(all, 'jobs service disposed')
|
||||
await Promise.all(all.map(job => job.settled))
|
||||
// Distinct owners whose records just disappeared. A change observer files
|
||||
// into the layer of the context that registered it, so a consumer mounted
|
||||
// outside this service — the api-proxy carrier registers from the mux
|
||||
// stream — is still reachable here. Without this it keeps the rows it last
|
||||
// received after a registry reload.
|
||||
const emptied = new Set(all.map(job => job.owner))
|
||||
this.store.clear()
|
||||
for (const owner of emptied) this.notifyChanged(owner)
|
||||
// Detach cross-fiber owner effects after the shared store is quiescent.
|
||||
const ownerCleanups = [...this.ownerCleanups.values()]
|
||||
this.ownerCleanups.clear()
|
||||
await Promise.all(ownerCleanups.map(cleanup => Promise.resolve(cleanup())))
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel jobs during teardown with per-job containment. A throwing cancel
|
||||
* force-fails the record and reports a possible orphan; a cancel that returns
|
||||
* without settling remains indistinguishable from a slow stop and may stall.
|
||||
*/
|
||||
private cancelForTeardown(jobs: TrackedTask[], reason: string): void {
|
||||
for (const job of jobs) {
|
||||
if (isTerminal(job.status)) continue
|
||||
// Teardown cancellation is a kill without a caller, so it claims the
|
||||
// terminal report the same way `kill()` does. Nothing will read a notice
|
||||
// for a job whose owner or service is being destroyed, and a waking
|
||||
// reporter would spend a model request per teardown layer. This is
|
||||
// decided before the producer runs: the force-failure below settles the
|
||||
// record too, so a throwing cancel must not be the one path that
|
||||
// announces an unreported completion into a disposing owner.
|
||||
job.reported = true
|
||||
try {
|
||||
job.cancel(reason)
|
||||
job.status = 'stopping'
|
||||
// Teardown reaches settlement only after the producer releases, which a
|
||||
// slow stop can defer; announcing the transition here is what keeps an
|
||||
// observer from showing `running` for that whole window.
|
||||
this.notifyChanged(job.owner)
|
||||
} catch (error: unknown) {
|
||||
const detail = `cancel threw during teardown; work may be orphaned: ${String(error)}`
|
||||
this.selfCtx.logger.warn(`jobs: cancel of ${job.id} threw during teardown; job record forced failed and work may be orphaned: ${String(error)}`)
|
||||
this.settle(job, { status: 'failed', detail })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalJobRegistry
|
||||
33
packages/jobs/jobs-local/src/invariant.ts
Normal file
33
packages/jobs/jobs-local/src/invariant.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-jobs-local`.
|
||||
* @module @deepseek-ai/dsh-jobs-local/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-jobs-local'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'jobs-local-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: `@deepseek-ai/dsh-jobs/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; `LocalJobRegistry.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 = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
1137
packages/jobs/jobs-local/tests/jobs.spec.ts
Normal file
1137
packages/jobs/jobs-local/tests/jobs.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
66
packages/jobs/jobs-local/tests/loader-composition.spec.ts
Normal file
66
packages/jobs/jobs-local/tests/loader-composition.spec.ts
Normal 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 LocalJobRegistry from '@deepseek-ai/dsh-jobs-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('jobs-local through a real Loader composition', () => {
|
||||
it('applies the provider-owned admission config from a Cordis row', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-jobs-local-loader-'))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
"- name: '@deepseek-ai/dsh-jobs-local'",
|
||||
' config:',
|
||||
' maxConcurrentJobsPerOwner: 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-jobs-local') return LocalJobRegistry
|
||||
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.jobs).toBeInstanceOf(LocalJobRegistry)
|
||||
context.jobs.attachController('loader-test')
|
||||
let settle!: (outcome: { status: 'killed' }) => void
|
||||
context.jobs.start({
|
||||
kind: 'bash',
|
||||
label: 'hold loader slot',
|
||||
run: () => ({
|
||||
cancel: () => { settle({ status: 'killed' }) },
|
||||
done: new Promise((resolve) => { settle = resolve }),
|
||||
}),
|
||||
})
|
||||
expect(() => context!.jobs.start({
|
||||
kind: 'bash',
|
||||
label: 'blocked loader job',
|
||||
run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }),
|
||||
})).toThrow('(limit: 1)')
|
||||
})
|
||||
})
|
||||
36
packages/jobs/jobs-local/tsconfig.json
Normal file
36
packages/jobs/jobs-local/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
},
|
||||
{
|
||||
"path": "../jobs"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/jobs/jobs/README.i18n.yaml
Normal file
6
packages/jobs/jobs/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/jobs/jobs/README.md
|
||||
README.md: f4469286603f687be5789bd016ab462dba26e664
|
||||
README.zh.md: 720c8b550b52716ef66d182da0acf81eae4490ca
|
||||
40
packages/jobs/jobs/README.md
Normal file
40
packages/jobs/jobs/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# @deepseek-ai/dsh-jobs
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The background job registry contract (`ctx.jobs`). The abstract `JobRegistry` and its vocabulary types give long-running producers shared ids, owner isolation, reads, cancellation, waiting, notices, and cleanup under one contract; the process-local registry lives in [`dsh-jobs-local`](../jobs-local/README.md). Producer plugins extend `JobKindMap` with their opaque id namespace.
|
||||
|
||||
## Service contract
|
||||
|
||||
- `start(spec): JobId` 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 job 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 jobs.
|
||||
- `read(id, caller?)` consumes the single cursor for stream jobs and reads terminal output idempotently for final-output jobs.
|
||||
- `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the job running; success changes it to `stopping` and marks terminal delivery reported.
|
||||
- `wait(id, timeoutMs, caller?, signal?)` returns a terminal snapshot or the live snapshot at timeout. Aborting stops only the wait; settlement wins once it has committed terminal delivery to that waiter.
|
||||
- `onJobDone(listener)` observes each terminal record with the exact owner. Listener throws and rejections are contained; listener work is not awaited.
|
||||
- `onJobsChanged(listener)` observes visible-set changes — registration, every stopping transition (teardown's included, before it awaits a slow producer), settlement, owner-disposal removal, and the emptying service disposal commits — carrying only the owner whose set moved, or `undefined` when an unowned job changed and every caller's set moved with it. It is owner-granular because removal is a change no per-job record can express, and it is not a superset of `onJobDone`: it carries no delivery meaning and marks nothing reported. The registration binds to the calling fiber, so an observer mounted outside the registry still sees the disposal emptying.
|
||||
- `attachController(name)` declares a job controller for its effect lifetime. `start()` fails before producer execution when no attached controller serves the spec's owner.
|
||||
|
||||
All three registrations are owner-relative, because one registry serves every composition in the process. A controller or listener registered from an unscoped context serves every owner; one registered under an agent composition's scope serves exactly the agents composed under it. So a composition that loads no controller cannot start background work on the strength of another composition's controls, and one settlement notifies only the listeners its owner's composition registered.
|
||||
|
||||
Owned access compares the job's `SessionId` with the caller's. Ids such as `bash-1` are predictable, so this fence is the boundary. Unowned jobs are open to callers and last until service disposal.
|
||||
|
||||
`outputLimitBytes` is producer-owned model-presentation policy carried unchanged into snapshots. A controller applies it after adding status or notice metadata; the registry does not rewrite producer output or invent a default for producers that omit it.
|
||||
|
||||
Implementations also owe the lifecycle semantics of the contract: registrations outlive producer and controller fibers, owner and service disposal cancel live work and await compliant producers, and settlement is first-wins — one terminal record, one round of contained listener notification, released waiters.
|
||||
|
||||
See the [job type catalog](../../../docs/subsystems/jobs.md), the [runtime Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md), and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through producer plugins and [`dsh-tool-jobs`](../tool-jobs/README.md), which render job ids, output, status, cancellation, and completion notices.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Stream output has one consuming cursor** — independent observers need a cursor or snapshot API.
|
||||
- **Foreground work cannot be promoted** — producers choose foreground or background before starting.
|
||||
- **The contract is in-process** — `JobStart.run()` passes callbacks and exact `Agent` objects; a durable or cross-process backend must reshape identity, restart, ownership, and observation semantics before it can implement this seam.
|
||||
40
packages/jobs/jobs/README.zh.md
Normal file
40
packages/jobs/jobs/README.zh.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# @deepseek-ai/dsh-jobs
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
后台任务注册表约定(`ctx.jobs`)。抽象的 `JobRegistry` 及其词汇类型在同一份约定下为长时间运行的生产方提供共享 id、owner 隔离、读取、取消、等待、通知和清理;进程局部注册表位于 [`dsh-jobs-local`](../jobs-local/README.md)。生产方插件使用其不透明 id namespace 扩展 `JobKindMap`。
|
||||
|
||||
## 服务约定
|
||||
|
||||
- `start(spec): JobId` 验证已附加的任务控制器、spec、确切且仍存活的 owner、可选的正数 `outputLimitBytes`,以及 Service provider 所拥有的准入策略,然后只调用生产方的 `run()` 一次。预检拒绝或启动方抛出异常时都不会生成 job id 或注册工作;成功返回会直接提交,不再执行其他可能失败的步骤。
|
||||
- `get(id, caller?)` 和 `list(caller?)` 返回非消费式快照。列表只包含调用方拥有及无 owner 的任务。
|
||||
- `read(id, caller?)` 消费流任务的唯一游标;对于最终输出任务,则以幂等方式读取终止输出。
|
||||
- `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。
|
||||
- `wait(id, timeoutMs, caller?, signal?)` 返回终止快照,或在超时时返回存活快照。中止只会停止等待;一旦终止交付已向该等待方提交,终止结果优先。
|
||||
- `onJobDone(listener)` 观察每条终止记录及其精确 owner。监听器抛出的异常和产生的拒绝都会被隔离;系统不会等待监听器工作。
|
||||
- `onJobsChanged(listener)` 观察可见集合的变化——注册、每一次转入 stopping(包括 teardown 在等待缓慢生产者之前的那一次)、结算、owner 销毁时的移除,以及服务销毁提交的清空——只携带集合发生变化的那个 owner,或在无主任务变化、因而每个调用方的集合都随之变化时携带 `undefined`。它按 owner 分粒度,因为移除是任何逐任务记录都无法表达的变化;它也不是 `onJobDone` 的超集:它不含任何投递含义,也不把任何东西标为已上报。注册绑定的是调用方 fiber,因此挂在注册表之外的观察者仍能收到销毁时的清空。
|
||||
- `attachController(name)` 在其 effect 生命周期内声明任务控制器。当没有任何已附加的控制器服务于 spec 的所有者时,`start()` 会在生产方执行前失败。
|
||||
|
||||
这三类注册都是相对于所有者的,因为一个注册表要服务进程内的每一套组合。从不带 scope 的上下文注册的控制器或监听器服务于每个所有者;在某套 agent 组合的 scope 下注册的,则恰好服务于在该组合下组合出的 agent。因此,未加载任何控制器的组合无法借另一套组合的控制工具启动后台工作,而一次结算也只会通知其所有者所属组合注册的监听器。
|
||||
|
||||
有 owner 的访问会比较任务的 `SessionId` 与调用方。`bash-1` 等 id 可预测,因此这道隔离是安全边界。无 owner 的任务向调用方开放,并持续到服务 dispose(资源释放)为止。
|
||||
|
||||
`outputLimitBytes` 是生产方拥有的模型呈现策略,会原样携带到快照中。控制器在添加状态或通知元数据后应用它;注册表不会重写生产方输出,也不会为省略此字段的生产方虚构默认值。
|
||||
|
||||
实现还必须兑现约定的生命周期语义:注册的存续期长于生产方 fiber 与控制器 fiber,owner 释放和服务释放会取消仍在运行的工作并等待守约的生产方,结算遵循首次结果优先(一条终止记录、一轮异常受到隔离的监听器通知,然后释放等待方)。
|
||||
|
||||
参见[任务类型目录](../../../docs/subsystems/jobs.md)、[运行时 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)和 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过生产方插件和 [`dsh-tool-jobs`](../tool-jobs/README.md) 间接影响;它们会渲染 job id、输出、状态、取消和完成通知。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **流输出只有一个消费游标**:独立观察者需要游标或快照 API。
|
||||
- **前台工作无法转为后台**:生产方在启动前选择前台或后台。
|
||||
- **约定是进程内的**:`JobStart.run()` 传入回调和确切的 `Agent` 对象;持久化或跨进程后端必须先重塑身份、重启、所有权与观察语义,才能实现此 seam。
|
||||
53
packages/jobs/jobs/package.json
Normal file
53
packages/jobs/jobs/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-jobs",
|
||||
"description": "Background job registry (ctx.jobs) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/jobs/jobs"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./brand": {
|
||||
"types": "./lib/types/brand.d.ts",
|
||||
"default": "./lib/types/brand.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
28
packages/jobs/jobs/src/brand.ts
Normal file
28
packages/jobs/jobs/src/brand.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* dsh-jobs' owned branded id, carried across the registry, the model-facing
|
||||
* control surface, and the client wire.
|
||||
*
|
||||
* It lives in its own leaf because the package root and `./types` both reach
|
||||
* `dsh-agent` through the owner and listener signatures, which a Client program
|
||||
* cannot resolve even as a type. A browser-safe consumer imports the id here;
|
||||
* `Branded<B>` itself comes from the zero-dependency `@deepseek-ai/dsh-brand`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-jobs/brand
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/**
|
||||
* Identifies a background job. The registry generates `<kind>-N`; predictable
|
||||
* ids rely on owner authorization rather than secrecy.
|
||||
*/
|
||||
export type JobId = Branded<'JobId'>
|
||||
|
||||
/**
|
||||
* Brand a string as a {@link JobId}.
|
||||
* @param id - the raw job-id string (the registry generates `<kind>-N`).
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function JobId(id: string): JobId {
|
||||
return id as JobId
|
||||
}
|
||||
179
packages/jobs/jobs/src/index.ts
Normal file
179
packages/jobs/jobs/src/index.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* The background-job Service Definition (`ctx.jobs`). It owns the contract for
|
||||
* job ids, session-scoped access, lifecycle state, completion listeners, and
|
||||
* owner cleanup while producers retain their execution resources. The
|
||||
* process-local registry lives in `@deepseek-ai/dsh-jobs-local`.
|
||||
* @module @deepseek-ai/dsh-jobs
|
||||
*/
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
JobDoneListener, JobId, JobRead, JobSnapshot, JobStart, JobsChangedListener,
|
||||
} from './types.ts'
|
||||
|
||||
export { JobId } from './types.ts'
|
||||
export type {
|
||||
JobDoneListener,
|
||||
JobHooks,
|
||||
JobKind,
|
||||
JobKindMap,
|
||||
JobOutcome,
|
||||
JobRead,
|
||||
JobSnapshot,
|
||||
JobStart,
|
||||
JobStatus,
|
||||
JobsChangedListener,
|
||||
} from './types.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
jobs: JobRegistry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract background job registry. Subclass, implement the abstract methods,
|
||||
* and load the subclass as a plugin — it registers as `ctx.jobs` (one
|
||||
* implementation per context; loading a second throws, which is cordis'
|
||||
* standard duplicate-service behavior).
|
||||
*
|
||||
* Implementations must honor these semantics:
|
||||
* - Registrations outlive producer and controller fibers. Owner and
|
||||
* service disposal cancel live work and await compliant producers; a
|
||||
* throwing teardown cancel force-fails only the record. Teardown
|
||||
* cancellation also marks the record reported, because a record its owner
|
||||
* is being destroyed for has no reader left.
|
||||
* - Owned-job access is fenced by the owner's session id. Ids are
|
||||
* predictable, so authorization — not secrecy — is the boundary.
|
||||
* - Settlement is first-wins: one terminal record, released waiters, and one
|
||||
* round of contained listener notification, even against a late producer
|
||||
* outcome. Completion is announced last, after the record is committed and
|
||||
* every other observer of the settlement has seen it, because a reporter
|
||||
* may open a model turn synchronously.
|
||||
* - {@link start} refuses work while no attached job controller serves the
|
||||
* spec's owner, so a producer cannot start work that owner cannot collect
|
||||
* or stop. One registry serves every composition in the process, so this
|
||||
* question — and completion-listener delivery — is owner-relative rather
|
||||
* than process-wide: registrations made from an unscoped context serve
|
||||
* every owner, and registrations made under an agent composition's scope
|
||||
* serve exactly the agents composed under it.
|
||||
*/
|
||||
export abstract class JobRegistry extends Service {
|
||||
constructor(ctx: Context) {
|
||||
// `abstract` erases at runtime, so a composition row naming this package
|
||||
// would register a ctx.jobs with no method implementations and fail far
|
||||
// from the misconfiguration. Fail loud at load instead.
|
||||
if (new.target === JobRegistry) {
|
||||
throw new Error('@deepseek-ai/dsh-jobs is the abstract job registry seam; load an implementation such as @deepseek-ai/dsh-jobs-local instead')
|
||||
}
|
||||
super(ctx, 'jobs')
|
||||
}
|
||||
|
||||
/**
|
||||
* Preflight access, validation, owner cleanup, and implementation-owned
|
||||
* admission before starting and atomically registering work. Any preflight
|
||||
* rejection leaves no job 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 - job identity, owner, and synchronous starter.
|
||||
* @returns the registry-issued `<kind>-N` id.
|
||||
*/
|
||||
abstract start(spec: JobStart): JobId
|
||||
|
||||
/**
|
||||
* List caller-owned and unowned jobs in registration order without exposing
|
||||
* another session's labels.
|
||||
* @param caller - reading agent; a non-agent caller sees only unowned jobs.
|
||||
* @returns fresh snapshots.
|
||||
*/
|
||||
abstract list(caller?: Agent): JobSnapshot[]
|
||||
|
||||
/**
|
||||
* Return a non-consuming snapshot without changing its read cursor or notice
|
||||
* state. Throws for an unknown or foreign job.
|
||||
* @param id - job to look up.
|
||||
* @param caller - reading agent checked against the owner.
|
||||
* @returns a fresh snapshot.
|
||||
*/
|
||||
abstract get(id: JobId, caller?: Agent): JobSnapshot
|
||||
|
||||
/**
|
||||
* Read the next stream delta, or the idempotent final output after settlement.
|
||||
* A terminal read marks the job reported. Throws for an unknown or foreign
|
||||
* job.
|
||||
* @param id - job to read.
|
||||
* @param caller - reading agent checked against the owner.
|
||||
* @returns output text and the post-read snapshot.
|
||||
*/
|
||||
abstract read(id: JobId, caller?: Agent): JobRead
|
||||
|
||||
/**
|
||||
* Request cancellation, then mark the job stopping and reported. A producer
|
||||
* throw propagates without changing job state. Throws for an unknown or
|
||||
* foreign job.
|
||||
* @param id - job to cancel.
|
||||
* @param caller - killing agent checked against the owner.
|
||||
* @param reason - logged reason forwarded to the producer.
|
||||
* @returns `requested` for live work, otherwise `already-finished`.
|
||||
*/
|
||||
abstract kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished'
|
||||
|
||||
/**
|
||||
* Wait for settlement or timeout without cancelling the job. Caller abort
|
||||
* rejects only while the job is live; after settlement the terminal
|
||||
* snapshot wins so a notice suppressed for this waiter is still delivered.
|
||||
* Throws for invalid, unknown, or foreign input.
|
||||
* @param id - job to wait for.
|
||||
* @param timeoutMs - positive finite wait bound in milliseconds.
|
||||
* @param caller - waiting agent checked against the owner.
|
||||
* @param signal - optional cancellation of the wait itself.
|
||||
* @returns snapshot at settlement or timeout.
|
||||
*/
|
||||
abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<JobSnapshot>
|
||||
|
||||
/**
|
||||
* Register an effect-scoped completion listener. It receives the settlements
|
||||
* of the owners its registering context's scope covers; each listener is
|
||||
* contained; returned promises are observed but not awaited. No listener runs
|
||||
* after service disposal.
|
||||
* @param listener - receives each terminal snapshot and its exact owner.
|
||||
* @returns disposer that unregisters the listener.
|
||||
*/
|
||||
abstract onJobDone(listener: JobDoneListener): () => void
|
||||
|
||||
/**
|
||||
/**
|
||||
* Register an effect-scoped observer of visible-set changes. It fires after
|
||||
* every commit that changes what {@link list} returns for that owner —
|
||||
* registration, every stopping transition (including the one teardown
|
||||
* performs before it awaits a slow producer), settlement, owner-disposal
|
||||
* removal, and the emptying that service disposal commits — so an observer
|
||||
* re-reads rather than accumulating deltas.
|
||||
*
|
||||
* Delivery is owner-relative on the same terms as {@link onJobDone}: an
|
||||
* observer registered from an unscoped context — a host composition's own
|
||||
* carrier — sees every owner, while one registered under an agent
|
||||
* composition's scope sees exactly the agents composed under it.
|
||||
*
|
||||
* This is not a superset of {@link onJobDone}: that one delivers the terminal
|
||||
* record under first-wins semantics a job controller couples to notice
|
||||
* delivery, while this one carries no delivery meaning and marks nothing
|
||||
* reported. Listeners are contained and never awaited.
|
||||
* @param listener - receives the owner whose visible set changed, or
|
||||
* `undefined` when an unowned job changed and every caller's set did.
|
||||
* @returns disposer that unregisters the listener.
|
||||
*/
|
||||
abstract onJobsChanged(listener: JobsChangedListener): () => void
|
||||
|
||||
/**
|
||||
* Attach an effect-scoped controller that can read and stop jobs. It serves the
|
||||
* owners its registering context's scope covers, and {@link start} refuses an
|
||||
* owner no attached controller serves.
|
||||
* @param name - diagnostic label; duplicate names remain independent.
|
||||
* @returns disposer that detaches this controller.
|
||||
*/
|
||||
abstract attachController(name: string): () => void
|
||||
}
|
||||
|
||||
export default JobRegistry
|
||||
57
packages/jobs/jobs/src/invariant.ts
Normal file
57
packages/jobs/jobs/src/invariant.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/** Package-owned background-job snapshot invariants. @module @deepseek-ai/dsh-jobs/invariant */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { JobSnapshot } from './types.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-jobs'
|
||||
const TERMINAL_STATUSES = new Set(['completed', 'killed', 'failed'])
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'jobs-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Validate the cross-field relationships in one registry snapshot. */
|
||||
function validateSnapshot(snapshot: JobSnapshot, owner: Agent | undefined, fail: InvariantFailure): void {
|
||||
const id = String(snapshot.id)
|
||||
const prefix = `${snapshot.kind}-`
|
||||
const ordinal = Number(id.slice(prefix.length))
|
||||
if (snapshot.kind.length === 0 || !id.startsWith(prefix)
|
||||
|| !Number.isSafeInteger(ordinal) || ordinal < 1) {
|
||||
fail(`job snapshot id ${JSON.stringify(id)} must be ${JSON.stringify(prefix)} followed by a positive ordinal`)
|
||||
}
|
||||
if (snapshot.label.length === 0) fail(`job ${JSON.stringify(id)} label must be non-empty`)
|
||||
if (!Number.isSafeInteger(snapshot.startedAt) || snapshot.startedAt < 0) {
|
||||
fail(`job ${JSON.stringify(id)} startedAt must be a non-negative epoch integer`)
|
||||
}
|
||||
|
||||
const terminal = TERMINAL_STATUSES.has(snapshot.status)
|
||||
if (terminal !== (snapshot.finishedAt !== undefined)) {
|
||||
fail(`job ${JSON.stringify(id)} finishedAt must be present exactly for a terminal status`)
|
||||
}
|
||||
if (snapshot.finishedAt !== undefined
|
||||
&& (!Number.isSafeInteger(snapshot.finishedAt) || snapshot.finishedAt < snapshot.startedAt)) {
|
||||
fail(`job ${JSON.stringify(id)} finishedAt must be an epoch integer no earlier than startedAt`)
|
||||
}
|
||||
|
||||
const expectedOwner = owner?.id
|
||||
if (snapshot.ownerSession !== expectedOwner) {
|
||||
fail(`job ${JSON.stringify(id)} ownerSession does not match its completion owner`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Install checks over current unowned records and every terminal snapshot. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
for (const snapshot of ctx.jobs.list()) validateSnapshot(snapshot, undefined, fail)
|
||||
ctx.jobs.onJobDone((snapshot, owner) => { validateSnapshot(snapshot, owner, fail) })
|
||||
}, { inject: ['jobs'] })
|
||||
|
||||
/**
|
||||
* Register the job-registry invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
160
packages/jobs/jobs/src/types.ts
Normal file
160
packages/jobs/jobs/src/types.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Types shared by job producers, the registry, and controllers. The
|
||||
* service implementation lives in `./index.ts`.
|
||||
* @module @deepseek-ai/dsh-jobs/types
|
||||
*/
|
||||
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { JobId } from './brand.ts'
|
||||
|
||||
export { JobId } from './brand.ts'
|
||||
|
||||
/**
|
||||
* Task lifecycle: `running`, optionally `stopping`, then exactly one terminal
|
||||
* status. Producer-specific facts belong in {@link JobSnapshot.detail}.
|
||||
*/
|
||||
export type JobStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
|
||||
|
||||
/**
|
||||
* Producer-defined job kinds. Plugins extend this map by declaration merging;
|
||||
* the registry treats every value as an opaque id namespace.
|
||||
*/
|
||||
export interface JobKindMap {
|
||||
bash: 'bash'
|
||||
subagent: 'subagent'
|
||||
}
|
||||
|
||||
/** The merge-extensible union of registered producer kind names. */
|
||||
export type JobKind = JobKindMap[keyof JobKindMap]
|
||||
|
||||
/** Terminal result supplied by a producer through {@link JobHooks.done}. */
|
||||
export interface JobOutcome {
|
||||
/** How the job ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */
|
||||
status: 'completed' | 'killed' | 'failed'
|
||||
/** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */
|
||||
detail?: string
|
||||
/** Final output for jobs without `readOutput`; stream jobs leave it unset. */
|
||||
output?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Producer declaration passed to {@link JobRegistry.start}. The runtime
|
||||
* preflights access and cleanup before invoking {@link run}; the producer owns
|
||||
* execution resources while the runtime owns identity and lifecycle state.
|
||||
*/
|
||||
export interface JobStart {
|
||||
/** Producer kind — also the id prefix (`bash`, `subagent`, …). */
|
||||
kind: JobKind
|
||||
/** One-line model-facing label (the command; the delegation description). */
|
||||
label: string
|
||||
/**
|
||||
* Optional UTF-8 byte cap for each complete model-facing completion notice or
|
||||
* output read, including controller status metadata.
|
||||
*/
|
||||
outputLimitBytes?: number
|
||||
/**
|
||||
* Owning live agent. Access is fenced by its session id, and agent disposal
|
||||
* cancels and awaits the job. The instance must be the one currently
|
||||
* registered under its agent id. Omitting the owner creates an unowned job,
|
||||
* open to any caller until service disposal.
|
||||
*/
|
||||
owner?: Agent
|
||||
/**
|
||||
* Start the work after preflight and synchronously return its hooks. Called
|
||||
* once; a throw leaves nothing registered, and the producer must clean up any
|
||||
* partially started resources.
|
||||
*/
|
||||
run(): JobHooks
|
||||
}
|
||||
|
||||
/** Hooks through which the runtime controls and observes producer work. */
|
||||
export interface JobHooks {
|
||||
/**
|
||||
* Request termination. Must be synchronous, idempotent, and eventually settle
|
||||
* {@link done}; throws propagate. The optional reason is forwarded verbatim.
|
||||
*/
|
||||
cancel(reason?: string): void
|
||||
/**
|
||||
* Resolves after the producer releases its resources, not merely when work
|
||||
* finishes. Must not reject; the runtime converts a rejection to `failed`.
|
||||
* If teardown cancellation throws, the runtime may force-fail only the
|
||||
* registry record without claiming that the work stopped.
|
||||
*/
|
||||
done: Promise<JobOutcome>
|
||||
/**
|
||||
* Consume output produced since the previous call. The producer formats
|
||||
* truncation and spill notices. Absence marks a final-output-only job; each
|
||||
* job has one consuming cursor.
|
||||
*/
|
||||
readOutput?(): string
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only projection of one job, safe to hand to listeners and tools —
|
||||
* a fresh object per call, never live registry state.
|
||||
*/
|
||||
export interface JobSnapshot {
|
||||
/** The registry-issued id (`<kind>-N`). */
|
||||
id: JobId
|
||||
/** The producer kind the job was registered with. */
|
||||
kind: JobKind
|
||||
/** The producer-supplied one-line label. */
|
||||
label: string
|
||||
/** Producer-owned cap for complete model-facing notices and output reads. */
|
||||
outputLimitBytes?: number
|
||||
/**
|
||||
* Owner session id used for authorization and correlation; absent for
|
||||
* unowned jobs. Completion listeners receive the exact {@link Agent}
|
||||
* separately through {@link JobDoneListener}.
|
||||
*/
|
||||
ownerSession?: SessionId
|
||||
/** Current lifecycle state. */
|
||||
status: JobStatus
|
||||
/** Kind-specific status detail, present once the producer supplied one (usually terminal). */
|
||||
detail?: string
|
||||
/** Epoch ms when the job was registered. */
|
||||
startedAt: number
|
||||
/** Epoch ms when the job settled; absent while `running`/`stopping`. */
|
||||
finishedAt?: number
|
||||
/**
|
||||
* True when a kill, read, wait, or teardown cancel has reported or committed
|
||||
* to report the terminal state. Completion reporters suppress redundant
|
||||
* notices when set. Teardown claims it because the owner or service being
|
||||
* destroyed leaves no reader: a reporter that opens a turn on notice would
|
||||
* otherwise spend a model request per teardown layer.
|
||||
*/
|
||||
reported: boolean
|
||||
}
|
||||
|
||||
/** Output and post-read state returned by {@link JobRegistry.read}. */
|
||||
export interface JobRead {
|
||||
/**
|
||||
* Stream kinds: the consuming delta since the previous read. Final-output
|
||||
* kinds: empty while live, the terminal {@link JobOutcome.output} (or
|
||||
* empty) once settled — idempotent, never consumed.
|
||||
*/
|
||||
text: string
|
||||
/** The job's state at read time. */
|
||||
snapshot: JobSnapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Completion callback with the exact owner supplied at start, or `undefined`
|
||||
* for an unowned job. Returned promises are observed but not awaited.
|
||||
*/
|
||||
export type JobDoneListener = (
|
||||
snapshot: JobSnapshot,
|
||||
owner: Agent | undefined,
|
||||
) => void | PromiseLike<void>
|
||||
|
||||
/**
|
||||
* Observation callback for a change to what one owner's {@link JobRegistry.list}
|
||||
* would return. It is owner-granular rather than job-granular because the
|
||||
* change may be a removal, which no per-job record can express, and because
|
||||
* its consumers re-read the whole visible set anyway.
|
||||
*
|
||||
* An `undefined` owner means an unowned job changed, so every caller's visible
|
||||
* set changed with it.
|
||||
*/
|
||||
export type JobsChangedListener = (owner: Agent | undefined) => void
|
||||
87
packages/jobs/jobs/tests/invariant.spec.ts
Normal file
87
packages/jobs/jobs/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import JobRegistry, { JobId } from '@deepseek-ai/dsh-jobs'
|
||||
import type { JobDoneListener, JobSnapshot } from '@deepseek-ai/dsh-jobs'
|
||||
import * as JobsInvariant from '@deepseek-ai/dsh-jobs/invariant'
|
||||
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const BASE: JobSnapshot = {
|
||||
id: JobId('bash-1'),
|
||||
kind: 'bash',
|
||||
label: 'compile',
|
||||
status: 'completed',
|
||||
startedAt: 10,
|
||||
finishedAt: 20,
|
||||
reported: false,
|
||||
}
|
||||
|
||||
const RUNNING: JobSnapshot = {
|
||||
id: JobId('bash-1'),
|
||||
kind: 'bash',
|
||||
label: 'compile',
|
||||
status: 'running',
|
||||
startedAt: 10,
|
||||
reported: false,
|
||||
}
|
||||
|
||||
const TERMINAL_WITHOUT_FINISH: JobSnapshot = {
|
||||
id: JobId('bash-1'),
|
||||
kind: 'bash',
|
||||
label: 'compile',
|
||||
status: 'completed',
|
||||
startedAt: 10,
|
||||
reported: false,
|
||||
}
|
||||
|
||||
async function setup(seed: JobSnapshot[] = []): Promise<(snapshot: unknown, owner?: Agent) => void> {
|
||||
const ctx = new Context()
|
||||
let listener: JobDoneListener | undefined
|
||||
const probe = {
|
||||
list: () => seed,
|
||||
onJobDone(value: JobDoneListener) {
|
||||
listener = value
|
||||
return () => { listener = undefined }
|
||||
},
|
||||
} as unknown as JobRegistry
|
||||
await ctx.plugin(InvariantRegistry)
|
||||
await ctx.plugin({
|
||||
name: 'job-invariant-probe',
|
||||
apply(child: Context) { child.provide('jobs', probe) },
|
||||
})
|
||||
await ctx.plugin(JobsInvariant)
|
||||
if (listener === undefined) throw new Error('job invariant did not subscribe to terminal snapshots')
|
||||
return (snapshot, owner) => { listener!(snapshot as JobSnapshot, owner) }
|
||||
}
|
||||
|
||||
describe('job-registry invariants', () => {
|
||||
it('accepts coherent current and terminal snapshots', async () => {
|
||||
const notify = await setup([RUNNING])
|
||||
expect(() => { notify(BASE) }).not.toThrow()
|
||||
const owner = { id: SessionId('owner') } as Agent
|
||||
expect(() => { notify({ ...BASE, id: JobId('subagent-2'), kind: 'subagent', ownerSession: owner.id }, owner) })
|
||||
.not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ ...BASE, id: JobId('-1'), kind: '' }, undefined, /positive ordinal/],
|
||||
[{ ...BASE, id: JobId('other-1') }, undefined, /must be "bash-" followed by a positive ordinal/],
|
||||
[{ ...BASE, id: JobId('bash-x') }, undefined, /positive ordinal/],
|
||||
[{ ...BASE, id: JobId('bash-0') }, undefined, /positive ordinal/],
|
||||
[{ ...BASE, startedAt: -1 }, undefined, /startedAt must be a non-negative epoch integer/],
|
||||
[{ ...BASE, startedAt: 0.5 }, undefined, /startedAt must be a non-negative epoch integer/],
|
||||
[{ ...BASE, status: 'running' }, undefined, /finishedAt must be present exactly for a terminal status/],
|
||||
[TERMINAL_WITHOUT_FINISH, undefined, /finishedAt must be present exactly for a terminal status/],
|
||||
[{ ...BASE, finishedAt: 9 }, undefined, /no earlier than startedAt/],
|
||||
[{ ...BASE, finishedAt: 20.5 }, undefined, /no earlier than startedAt/],
|
||||
[{ ...BASE, ownerSession: SessionId('recorded') }, { id: SessionId('actual') } as Agent, /does not match its completion owner/],
|
||||
] as const)('rejects an incoherent registry snapshot', async (snapshot, owner, message) => {
|
||||
const notify = await setup()
|
||||
expect(() => { notify(snapshot, owner) }).toThrow(message)
|
||||
})
|
||||
|
||||
it('rejects an incoherent record already present at installation', async () => {
|
||||
await expect(setup([{ ...BASE, label: '' }])).rejects.toThrow(/label must be non-empty/)
|
||||
})
|
||||
})
|
||||
96
packages/jobs/jobs/tests/service.spec.ts
Normal file
96
packages/jobs/jobs/tests/service.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { JobId, JobRegistry } from '@deepseek-ai/dsh-jobs'
|
||||
import type {
|
||||
JobDoneListener, JobRead, JobSnapshot, JobStart, JobsChangedListener,
|
||||
} from '@deepseek-ai/dsh-jobs'
|
||||
|
||||
/**
|
||||
* Minimal concrete registry: one canned record. The Service Definition owns the contract
|
||||
* only (ids, snapshots, authorization-shaped signatures); the registry
|
||||
* behavior suite lives with `@deepseek-ai/dsh-jobs-local`.
|
||||
*/
|
||||
class StubJobRegistry extends JobRegistry {
|
||||
snapshotOf(id: JobId): JobSnapshot {
|
||||
return {
|
||||
id,
|
||||
kind: 'bash',
|
||||
label: 'sleep 60',
|
||||
status: 'running',
|
||||
startedAt: 0,
|
||||
reported: false,
|
||||
}
|
||||
}
|
||||
|
||||
start(spec: JobStart): JobId {
|
||||
spec.run()
|
||||
return JobId(`${spec.kind}-1`)
|
||||
}
|
||||
|
||||
list(): JobSnapshot[] {
|
||||
return [this.snapshotOf(JobId('bash-1'))]
|
||||
}
|
||||
|
||||
get(id: JobId): JobSnapshot {
|
||||
return this.snapshotOf(id)
|
||||
}
|
||||
|
||||
read(id: JobId): JobRead {
|
||||
return { text: '', snapshot: this.snapshotOf(id) }
|
||||
}
|
||||
|
||||
kill(): 'requested' | 'already-finished' {
|
||||
return 'requested'
|
||||
}
|
||||
|
||||
wait(id: JobId, _timeoutMs: number, _caller?: Agent, _signal?: AbortSignal): Promise<JobSnapshot> {
|
||||
return Promise.resolve(this.snapshotOf(id))
|
||||
}
|
||||
|
||||
onJobDone(_listener: JobDoneListener): () => void {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
onJobsChanged(_listener: JobsChangedListener): () => void {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
attachController(_name: string): () => void {
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
describe('JobRegistry seam', () => {
|
||||
it('a concrete subclass registers as ctx.jobs and serves the abstract API', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(StubJobRegistry)
|
||||
|
||||
const detachController = ctx.jobs.attachController('seam-test')
|
||||
const id = ctx.jobs.start({ kind: 'bash', label: 'sleep 60', run: () => ({ cancel() {}, done: new Promise(() => {}) }) })
|
||||
expect(id).toBe('bash-1')
|
||||
expect(ctx.jobs.list()).toHaveLength(1)
|
||||
expect(ctx.jobs.get(id).status).toBe('running')
|
||||
expect(ctx.jobs.read(id).text).toBe('')
|
||||
expect(ctx.jobs.kill(id)).toBe('requested')
|
||||
await expect(ctx.jobs.wait(id, 5)).resolves.toMatchObject({ id })
|
||||
const detachListener = ctx.jobs.onJobDone(() => {})
|
||||
detachListener()
|
||||
const detachChanges = ctx.jobs.onJobsChanged(() => {})
|
||||
detachChanges()
|
||||
detachController()
|
||||
})
|
||||
|
||||
it('loading a second implementation throws (one jobs service per context — cordis standard)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(StubJobRegistry)
|
||||
class SecondJobRegistry extends StubJobRegistry {}
|
||||
await expect(ctx.plugin(SecondJobRegistry)).rejects.toThrow(/service "jobs" has been registered/)
|
||||
})
|
||||
|
||||
it('mounting the abstract seam directly fails loudly at load (stale-composition fence)', async () => {
|
||||
const ctx = new Context()
|
||||
await expect(ctx.plugin(JobRegistry as unknown as typeof StubJobRegistry))
|
||||
.rejects.toThrow(/abstract job registry seam; load an implementation such as @deepseek-ai\/dsh-jobs-local/)
|
||||
})
|
||||
})
|
||||
30
packages/jobs/jobs/tsconfig.json
Normal file
30
packages/jobs/jobs/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/jobs/tool-jobs/README.i18n.yaml
Normal file
6
packages/jobs/tool-jobs/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/jobs/tool-jobs/README.md
|
||||
README.md: 8d6a00651f69258d764904ac99d000c2055982dc
|
||||
README.zh.md: 15903b6f263fe9d225c48bf567c46f587d23cb00
|
||||
96
packages/jobs/tool-jobs/README.md
Normal file
96
packages/jobs/tool-jobs/README.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# @deepseek-ai/dsh-tool-jobs
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The model-facing controller for `ctx.jobs`: three kind-independent tools, completion notices, and one background-work prompt section. Loading the plugin attaches the controller required by `ctx.jobs.start()`.
|
||||
|
||||
## Tools
|
||||
|
||||
- `job_output(job_id, wait?, timeout_ms?)` reads without blocking by default. Stream jobs return only the next delta; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. `wait: true` waits up to the configured cap and leaves a still-running job alive on timeout.
|
||||
- `job_list()` returns caller-visible jobs as `<id> [<kind>] <status> — <label>`.
|
||||
- `job_kill(job_id, reason?)` requests cancellation immediately and forwards the logged reason. Terminal jobs return a non-consuming snapshot.
|
||||
|
||||
All three use generic UI cards: `read` for output and list, `execute` for kill.
|
||||
|
||||
Their canonical values are `{ text, job }`, `PublicJobSnapshot[]`, and `{ outcome: 'cancellation-requested' | 'already-finished', job }`. A public snapshot carries id, kind, label, status/detail, and start/finish times; it deliberately omits `ownerSession` and the internal `reported` notice bit. Native renderers preserve the status and acknowledgement text above.
|
||||
|
||||
When a producer supplies `outputLimitBytes`, `job_output`, terminal `job_kill`, and completion notices cap the complete Native UTF-8 result after adding status or notice text. Reads retain the output tail and control suffix when they fit; a bounded completion notice instead reserves `background job <id>` and the `job_output` collection instruction before spending remaining bytes on its variable kind, label, status, detail, and truncation marker. A prepended pre-execute listener captures the caller-visible job before policy, and each job-control definition's final-content callback applies its producer cap to single-text denials, short-circuits, normalized tool or pipeline failures, replacements, and blocks; structured multi-block policy results retain their shape. An existing producer truncation marker is reused rather than duplicated. Producers that omit the field retain the existing unbounded controller behavior.
|
||||
|
||||
## Completion notices
|
||||
|
||||
An unreported completion delivers `background job <id> (<kind>: <label>) finished [status: ...]. Read its output with job_output.` to the exact owner. When bounded, the stable id prefix and collection command outrank variable label/detail so the notice remains actionable at PTY's supported 64-byte minimum. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice, as does the teardown cancel that drains an owner or the service.
|
||||
|
||||
Which lane carries it depends on what the owner is doing. A busy owner is injected: the notice joins the next-step inbox, and the turn cannot close while that inbox holds it, so several jobs settling together cost one step rather than one turn each. An idle owner is instead woken with a follow-up turn, because a pending notice nothing claims is a completion the model never learns about. `completionDelivery: quiet` keeps the injection lane for idle owners too, which is what a deterministic transcript needs.
|
||||
|
||||
Waking is bounded. Each owner may open `maxConsecutiveWakes` turns this way before further notices degrade to injection, and claiming any user-authored message restores the budget. The bound exists because the chain is self-exciting: a woken turn may start the background job whose completion wakes it again. Notices this plugin queued never refill the budget they spent.
|
||||
|
||||
One host registry may carry several mounts of this plugin — one per agent preset. The registry routes each settlement to the listeners the owner's scope chain reaches, so a mount under one preset never sees another preset's agents and an agent reads exactly one notice per completion however many presets are mounted. The same routing decides which agents this mount's controller serves: an agent whose composition loads no `tool-jobs` cannot start background work at all.
|
||||
|
||||
## Config
|
||||
|
||||
| key | default | meaning |
|
||||
|---|---|---|
|
||||
| `waitTimeoutMs` | `30000` | wait used when `wait: true` omits `timeout_ms` |
|
||||
| `maxWaitTimeoutMs` | `600000` | cap for model-supplied waits |
|
||||
| `completionDelivery` | `wakeup` | `wakeup` opens a turn on an idle owner; `quiet` leaves the notice pending |
|
||||
| `maxConsecutiveWakes` | `3` | turns one owner may open by wake before notices degrade to injection |
|
||||
|
||||
A default above the cap fails at load.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### System prompt
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Every request in this plugin's registration scope contains this guidance. Agent-scoped tool filtering may hide the tools without removing the independently registered prompt section.
|
||||
|
||||
##### Background-job guidance
|
||||
|
||||
```markdown
|
||||
Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Small fixed input cost per request while active.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while the plugin scope and guidance text are unchanged. Activation or disposal may invalidate reuse from this prompt section.
|
||||
|
||||
### Tool schemas
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The generated [`job_output`, `job_list`, and `job_kill` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-jobs) while this tool set is visible.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost on each request where the tools are visible.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while tool definitions and visibility are unchanged. Registration lifecycle or scoped restrictions may invalidate reuse from the first changed schema token.
|
||||
|
||||
### Results and notices
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Reads return output or `(no new output)` followed by `[status: <status>]` and optional detail. An empty list returns `(no background jobs)`. Kill returns `requested cancellation of job <id>` or the existing terminal status. Unreported owned completion uses the notice above.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output; a producer-supplied `outputLimitBytes` bounds each complete read or notice. Under `wakeup`, a notice reaching an idle owner also buys a model request the user did not ask for, capped per owner by `maxConsecutiveWakes`; a notice reaching a busy owner adds a step to the turn it is already paying for.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A settlement inside the driver's retirement window still strands its notice** — between the turn loop's last inbox check and the driver committing its idle phase the owner still reads as busy, so the notice is injected and nothing wakes. Steering has the same hole; closing it belongs to `agent-loop`.
|
||||
- **A spent wake budget is not restored by time** — only user-authored input refills it, so an unattended agent whose budget ran out collects its remaining notices on the next turn something else opens.
|
||||
- **A notice pending on an idle owner does not survive that owner's disposal** — the disposal cancel clears the unclaimed inbox, and the log keeps the insert/cancel pair as the record.
|
||||
- **Stream reads are single-consumer** — independent observers need another runtime API.
|
||||
- **Unowned jobs have no session fence** — external callers must supply policy or avoid them.
|
||||
96
packages/jobs/tool-jobs/README.zh.md
Normal file
96
packages/jobs/tool-jobs/README.zh.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# @deepseek-ai/dsh-tool-jobs
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`ctx.jobs` 的面向模型控制器:三个与 kind 无关的工具、完成通知和一个后台工作提示词区段。加载该插件会附加 `ctx.jobs.start()` 所要求的控制器。
|
||||
|
||||
## 工具
|
||||
|
||||
- `job_output(job_id, wait?, timeout_ms?)` 默认以非阻塞方式读取。流任务只返回下一个增量;最终输出任务在终止后返回结果。每个响应都以 `[status: ...]` 结尾。`wait: true` 最多等待到配置上限,超时时仍让运行中的任务保持存活。
|
||||
- `job_list()` 以 `<id> [<kind>] <status> — <label>` 返回调用方可见的任务。
|
||||
- `job_kill(job_id, reason?)` 立即请求取消并转发已记录的原因。终止任务返回非消费式快照。
|
||||
|
||||
三个工具都使用通用 UI 卡片:output 和 list 使用 `read`,kill 使用 `execute`。
|
||||
|
||||
它们的规范值依次为 `{ text, job }`、`PublicJobSnapshot[]` 和 `{ outcome: 'cancellation-requested' | 'already-finished', job }`。公共快照携带 id、kind、label、status/detail 及开始/结束时间;它有意省略 `ownerSession` 和内部 `reported` 通知位。原生 renderer 保留上述状态与确认文本。
|
||||
|
||||
当生产方提供 `outputLimitBytes` 时,`job_output`、针对已终止任务的 `job_kill` 和完成通知会在添加状态或通知文本后,对完整的原生 UTF-8 结果施加上限。只要能够容纳,读取就会保留输出尾部与控制后缀;有界完成通知则先为 `background job <id>` 和 `job_output` 收集指令预留空间,再把剩余字节用于可变的 kind、label、status、detail 与截断标记。一个前置 pre-execute 监听器会在策略运行前捕获调用方可见任务;每个任务控制定义的 final-content 回调会把其生产方上限应用到单文本拒绝、短路、规范化工具或流水线失败、替换和阻止;结构化多块策略结果保持自身形状。已有的生产方截断标记会复用,不会重复添加。省略该字段的生产方保留现有的无界控制器行为。
|
||||
|
||||
## 完成通知
|
||||
|
||||
一项尚未报告的完成会把 `background job <id> (<kind>: <label>) finished [status: ...]. Read its output with job_output.` 交付给确切所有者。应用上限时,即使采用 PTY 支持的 64 字节下限,稳定 id 前缀和收集命令的优先级也高于可变 label/detail,因此通知仍可操作。kill 或针对已终止任务的 read/wait 会把交付标为已报告并抑制重复通知;排空 owner 或服务的 teardown 取消同样如此。
|
||||
|
||||
由哪条通道承载取决于所有者当时在做什么。繁忙的所有者走注入:通知进入 next-step inbox,而该 inbox 尚有内容时 turn 无法结束,因此同时结算的多个任务只花掉一步,而不是各占一轮。空闲的所有者则被 follow-up 唤醒,因为无人领取的待发通知等于模型永远不会知道的完成。`completionDelivery: quiet` 让空闲所有者也留在注入通道上,确定性 transcript 需要的正是这一点。
|
||||
|
||||
唤醒是有界的。每个所有者最多可通过唤醒开启 `maxConsecutiveWakes` 轮,此后的通知降级为注入;领取任何用户撰写的消息都会恢复该预算。设界是因为这条链会自激:被唤醒的一轮可能启动某个后台任务,而它的完成又会唤醒同一个所有者。本插件自己排队的通知永远不会补充它刚花掉的预算。
|
||||
|
||||
一个宿主注册表可能承载本插件的多份挂载——每个 agent preset 一份。注册表会把每次结算路由给所有者 scope 链所能抵达的监听器,因此某个 preset 下的挂载永远看不到另一个 preset 的 agent,无论挂载了多少 preset,一个 agent 每次完成都只读到一条通知。同一套路由也决定本挂载的控制器服务哪些 agent:组合中未加载 `tool-jobs` 的 agent 根本无法启动后台工作。
|
||||
|
||||
## 配置
|
||||
|
||||
| key | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `waitTimeoutMs` | `30000` | `wait: true` 省略 `timeout_ms` 时使用的等待时间 |
|
||||
| `maxWaitTimeoutMs` | `600000` | 模型所给等待时间的上限 |
|
||||
| `completionDelivery` | `wakeup` | `wakeup` 为空闲所有者开启一轮;`quiet` 让通知继续待领 |
|
||||
| `maxConsecutiveWakes` | `3` | 一个所有者可由唤醒开启的轮数,超出后通知降级为注入 |
|
||||
|
||||
默认值高于上限时,插件会在加载时失败。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 系统提示词
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
该插件注册 scope 中的每次请求都包含以下指引。按 agent(智能体)scope 过滤工具时,可能会隐藏工具,却不会移除独立注册的提示词区段。
|
||||
|
||||
##### 后台任务指引
|
||||
|
||||
```markdown
|
||||
Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
激活期间,每次请求都会产生少量固定的输入 token 开销。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只要插件 scope 与指引文本不变,前缀就保持稳定。激活或释放可能使从该提示词区段起的复用失效。
|
||||
|
||||
### 工具 schema
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
该工具集可见时,会看到生成的 [`job_output`、`job_list` 和 `job_kill` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-jobs)。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
工具可见时,每次请求都会产生固定的 schema token 开销。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只要工具定义与可见性不变,前缀就保持稳定。注册生命周期或 scope 限制可能使从第一个发生变化的 schema token 起的复用失效。
|
||||
|
||||
### 结果与通知
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
读取会返回输出或 `(no new output)`,随后是 `[status: <status>]` 和可选 detail。空列表返回 `(no background jobs)`。kill 返回 `requested cancellation of job <id>` 或现有终止状态。尚未报告且有 owner 的任务完成时使用上述通知。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
结果与通知在压缩(compaction)前保留于父级历史。流读取不会重复已消费的输出;生产方提供的 `outputLimitBytes` 会限制每次完整读取或通知。在 `wakeup` 下,抵达空闲所有者的通知还会额外买下一次用户并未要求的模型请求,其数量按所有者由 `maxConsecutiveWakes` 封顶;抵达繁忙所有者的通知则只是给它已经在支付的那一轮加一步。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **落在 driver 退休窗口内的结算仍会让通知搁浅**:在轮次循环最后一次检查 inbox 与 driver 提交 idle 相位之间,所有者读起来仍是繁忙,因此通知走注入且无人唤醒。steer 有同样的洞;堵上它属于 `agent-loop`。
|
||||
- **已花掉的唤醒预算不会随时间恢复**:只有用户撰写的输入才能补充,因此预算耗尽的无人值守 agent 要等到其他原因开启下一轮时才收走剩余通知。
|
||||
- **待领于空闲所有者的通知无法在该所有者释放后存活**:释放时的取消会清空未领取的 inbox,日志保留插入/取消这一对作为记录。
|
||||
- **流读取只有单一消费方**:独立观察者需要另一套运行时 API。
|
||||
- **无 owner 的任务没有会话隔离**:外部调用方必须提供策略或避开这些任务。
|
||||
59
packages/jobs/tool-jobs/package.json
Normal file
59
packages/jobs/tool-jobs/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-jobs",
|
||||
"description": "Model-facing background job control tools (job_output, job_list, job_kill) over the ctx.jobs registry",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/jobs/tool-jobs"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-output-retention": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-jobs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-output-retention": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-jobs": "workspace:^",
|
||||
"@deepseek-ai/dsh-jobs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
402
packages/jobs/tool-jobs/src/index.ts
Normal file
402
packages/jobs/tool-jobs/src/index.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Model-facing `job_output`, `job_list`, and `job_kill` tools over
|
||||
* `ctx.jobs`. Loading the plugin attaches the controller required by
|
||||
* producers. It also delivers unreported completions to the owning agent:
|
||||
* injected into a busy owner's next step, or opening a turn on an idle one
|
||||
* under the default `wakeup` delivery, bounded per owner.
|
||||
* @module @deepseek-ai/dsh-tool-jobs
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { boundContextSummary, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { TextRetainer } from '@deepseek-ai/dsh-output-retention'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { JobId } from '@deepseek-ai/dsh-jobs'
|
||||
import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
export const name = 'tool-jobs'
|
||||
export const inject = ['tools', 'jobs', 'systemPrompt']
|
||||
|
||||
/**
|
||||
* How an unreported completion reaches an owner that is already idle: `wakeup`
|
||||
* opens a turn for it, `quiet` leaves it pending until something else wakes the
|
||||
* owner. A busy owner is injected either way.
|
||||
*/
|
||||
export type CompletionDelivery = 'quiet' | 'wakeup'
|
||||
|
||||
/** Configures bounded `job_output` waits and completion-notice delivery. */
|
||||
export interface Config {
|
||||
/** Wait duration applied when `job_output` sets `wait` without `timeout_ms` (default 30s). */
|
||||
waitTimeoutMs?: number
|
||||
/** Hard cap on any single wait; a larger model-supplied `timeout_ms` is clamped down to it (default 10min). */
|
||||
maxWaitTimeoutMs?: number
|
||||
/** Whether a completion opens a turn on an idle owner (default `wakeup`). */
|
||||
completionDelivery?: CompletionDelivery
|
||||
/**
|
||||
* Turns one owner may have opened by completion wakes before the next
|
||||
* notice degrades to injection, reset by any user-authored input (default 3).
|
||||
* Bounds the self-exciting chain where a woken turn starts the job whose
|
||||
* completion wakes it again.
|
||||
*/
|
||||
maxConsecutiveWakes?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
waitTimeoutMs: z.number().min(1).default(30_000),
|
||||
maxWaitTimeoutMs: z.number().min(1).default(600_000),
|
||||
completionDelivery: z.union(['quiet', 'wakeup'] as const).default('wakeup'),
|
||||
maxConsecutiveWakes: z.number().min(1).default(3),
|
||||
})
|
||||
|
||||
/** Task state safe for model-authored programs; ownership/bookkeeping fields are omitted. */
|
||||
export interface PublicJobSnapshot {
|
||||
id: string
|
||||
kind: string
|
||||
label: string
|
||||
status: JobSnapshot['status']
|
||||
detail?: string
|
||||
startedAt: number
|
||||
finishedAt?: number
|
||||
}
|
||||
|
||||
/** Shared schema for job-control outputs. */
|
||||
const PUBLIC_TASK_SCHEMA = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
id: { type: 'string', required: true },
|
||||
kind: { type: 'string', required: true },
|
||||
label: { type: 'string', required: true },
|
||||
status: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
enum: ['running', 'stopping', 'completed', 'killed', 'failed'],
|
||||
},
|
||||
detail: { type: 'string' },
|
||||
startedAt: { type: 'integer', required: true },
|
||||
finishedAt: { type: 'integer' },
|
||||
},
|
||||
} as const
|
||||
|
||||
/** Remove job ownership and notification bookkeeping from a registry snapshot. */
|
||||
function publicJob(snapshot: JobSnapshot): PublicJobSnapshot {
|
||||
return {
|
||||
id: snapshot.id,
|
||||
kind: snapshot.kind,
|
||||
label: snapshot.label,
|
||||
status: snapshot.status,
|
||||
...snapshot.detail !== undefined ? { detail: snapshot.detail } : {},
|
||||
startedAt: snapshot.startedAt,
|
||||
...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render generic status with optional producer detail.
|
||||
* @param snapshot - job state to render.
|
||||
* @returns a bracketed status line.
|
||||
*/
|
||||
export function statusLine(snapshot: Pick<JobSnapshot, 'status' | 'detail'>): string {
|
||||
return snapshot.detail !== undefined
|
||||
? `[status: ${snapshot.status}, ${snapshot.detail}]`
|
||||
: `[status: ${snapshot.status}]`
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
function retainTail(text: string, maxBytes: number): string {
|
||||
const retainer = new TextRetainer({ kind: 'tail', maxBytes })
|
||||
retainer.push(text)
|
||||
return retainer.finish().text
|
||||
}
|
||||
|
||||
function retainHead(text: string, maxBytes: number): string {
|
||||
const retainer = new TextRetainer({ kind: 'head', maxBytes })
|
||||
retainer.push(text)
|
||||
return retainer.finish().text
|
||||
}
|
||||
|
||||
function fitWithSuffix(
|
||||
content: string,
|
||||
suffix: string,
|
||||
maxBytes: number | undefined,
|
||||
omitted: string,
|
||||
): string {
|
||||
const complete = `${content}${suffix}`
|
||||
if (maxBytes === undefined || encoder.encode(complete).byteLength <= maxBytes) return complete
|
||||
const fixed = `${content.endsWith(omitted.trimStart()) ? '' : omitted}${suffix}`
|
||||
const fixedBytes = encoder.encode(fixed).byteLength
|
||||
if (fixedBytes >= maxBytes) return retainTail(fixed, maxBytes)
|
||||
return `${retainTail(content, maxBytes - fixedBytes)}${fixed}`
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line account of a settled job for the `notice` form's collapsed row.
|
||||
* @param snapshot - the settled job.
|
||||
* @returns its kind, label, and status, bounded like every notice summary.
|
||||
*/
|
||||
function completionSummary(snapshot: JobSnapshot): string {
|
||||
return boundContextSummary(`${snapshot.kind} ${snapshot.label} ${statusLine(snapshot)}`)
|
||||
}
|
||||
|
||||
function fitCompletionNotice(snapshot: JobSnapshot): string {
|
||||
const prefix = `background job ${snapshot.id}`
|
||||
const detail = ` (${snapshot.kind}: ${snapshot.label}) finished ${statusLine(snapshot)}`
|
||||
const action = '\nDone; job_output.'
|
||||
const complete = `${prefix}${detail}. Read its output with job_output.`
|
||||
const maxBytes = snapshot.outputLimitBytes
|
||||
if (maxBytes === undefined || encoder.encode(complete).byteLength <= maxBytes) return complete
|
||||
const omitted = '\n[notice truncated]'
|
||||
const fixed = `${prefix}${omitted}${action}`
|
||||
const fixedBytes = encoder.encode(fixed).byteLength
|
||||
if (fixedBytes <= maxBytes) {
|
||||
return fixedBytes === maxBytes
|
||||
? fixed
|
||||
: `${prefix}${retainHead(detail, maxBytes - fixedBytes)}${omitted}${action}`
|
||||
}
|
||||
const compact = `${prefix}${action}`
|
||||
const compactBytes = encoder.encode(compact).byteLength
|
||||
if (compactBytes <= maxBytes) return compact
|
||||
const actionBytes = encoder.encode(action).byteLength
|
||||
if (actionBytes >= maxBytes) return retainTail(action, maxBytes)
|
||||
return `${retainHead(prefix, maxBytes - actionBytes)}${action}`
|
||||
}
|
||||
|
||||
function rawSingleText(content: readonly ContentBlock[]): string | undefined {
|
||||
if (content.length !== 1) return undefined
|
||||
const block = content[0]
|
||||
if (block?.type !== 'text') return undefined
|
||||
return block.text
|
||||
}
|
||||
|
||||
function boundSingleText(content: readonly ContentBlock[], maxBytes: number): ContentBlock[] | undefined {
|
||||
const text = rawSingleText(content)
|
||||
if (text === undefined) return undefined
|
||||
return [{
|
||||
type: 'text',
|
||||
text: fitWithSuffix(text, '', maxBytes, '\n[result truncated]'),
|
||||
}]
|
||||
}
|
||||
|
||||
function visibleOutputLimit(ctx: Context, exec: ToolExecution): number | undefined {
|
||||
if (exec.name !== 'job_output' && exec.name !== 'job_kill') return undefined
|
||||
const jobId = (exec.arguments as { job_id?: unknown } | null | undefined)?.job_id
|
||||
if (typeof jobId !== 'string' || jobId.length === 0) return undefined
|
||||
return ctx.jobs.list(exec.agent).find(snapshot => snapshot.id === jobId)?.outputLimitBytes
|
||||
}
|
||||
|
||||
/** Validate the non-empty constraint that ParameterSchemaSpec cannot express. */
|
||||
function validateJobId(value: string): JobId {
|
||||
if (value.length === 0) {
|
||||
throw new Error(`invalid job_id: expected a non-empty string, got ${JSON.stringify(value)}`)
|
||||
}
|
||||
return JobId(value)
|
||||
}
|
||||
|
||||
/** Pending presentation shared by the three generic job controls. */
|
||||
function presentTaskCall(title: string, kind: 'read' | 'execute', rawInput?: string): GenericCallView {
|
||||
return { card: 'generic', title, kind, ...rawInput !== undefined ? { rawInput } : {} }
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const waitDefault = config.waitTimeoutMs ?? 30_000
|
||||
const waitCap = config.maxWaitTimeoutMs ?? 600_000
|
||||
const delivery = config.completionDelivery ?? 'wakeup'
|
||||
const wakeBudget = config.maxConsecutiveWakes ?? 3
|
||||
|
||||
// Turns this plugin opened on each owner since that owner last consumed
|
||||
// human input. Keyed by the exact Agent, so a same-session replacement
|
||||
// starts with a full budget.
|
||||
const spentWakes = new WeakMap<Agent, number>()
|
||||
if (waitDefault > waitCap) {
|
||||
throw new Error(`tool-jobs: waitTimeoutMs (${waitDefault}) exceeds maxWaitTimeoutMs (${waitCap})`)
|
||||
}
|
||||
// A budget is a count of turns. `Infinity` would leave the runaway chain this
|
||||
// field exists to bound unbounded, and a fraction never names a turn at all.
|
||||
if (!Number.isSafeInteger(wakeBudget)) {
|
||||
throw new Error(`tool-jobs: maxConsecutiveWakes (${wakeBudget}) must be a whole number of turns`)
|
||||
}
|
||||
// Nothing spends the budget under quiet delivery, so nothing needs to refill it.
|
||||
if (delivery === 'wakeup') {
|
||||
ctx.on('agent/inbox/claimed', ({ agent, message }) => {
|
||||
// Claiming is the point the human's input actually enters a step; a notice
|
||||
// this plugin itself queued must not refill the budget it just spent.
|
||||
if (message.source.kind === 'user') spentWakes.delete(agent)
|
||||
})
|
||||
}
|
||||
|
||||
const outputLimits = new WeakMap<ToolExecution, number>()
|
||||
ctx.on('tools/pre-execute', (exec, next) => {
|
||||
const maxBytes = visibleOutputLimit(ctx, exec)
|
||||
if (maxBytes !== undefined) outputLimits.set(exec, maxBytes)
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
const finalizeTaskContent: NonNullable<ToolDefinition['finalizeContent']> = (exec, result) => {
|
||||
const maxBytes = outputLimits.get(exec) ?? visibleOutputLimit(ctx, exec)
|
||||
outputLimits.delete(exec)
|
||||
if (maxBytes === undefined) return undefined
|
||||
if (exec.name === 'job_output' && !result.isError) {
|
||||
// This definition owns and schema-validates the canonical value. Preserve
|
||||
// its output/status split only while policy left the default rendering intact.
|
||||
const value = result.value as unknown as { text: string; job: PublicJobSnapshot }
|
||||
const body = value.text.length > 0 ? value.text : '(no new output)'
|
||||
const content = body.endsWith('\n') ? body.slice(0, -1) : body
|
||||
const suffix = `\n${statusLine(value.job)}`
|
||||
if (rawSingleText(result.content) === `${content}${suffix}`) {
|
||||
return [{
|
||||
type: 'text',
|
||||
text: fitWithSuffix(content, suffix, maxBytes, '\n[output truncated]'),
|
||||
}]
|
||||
}
|
||||
}
|
||||
return boundSingleText(result.content, maxBytes)
|
||||
}
|
||||
|
||||
// Producers may start work only while a controller is attached.
|
||||
ctx.jobs.attachController('tool-jobs')
|
||||
|
||||
// Cross-call guidance follows the bash section and precedes product sections.
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:jobs',
|
||||
order: 106,
|
||||
text: 'Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job\'s work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.',
|
||||
})
|
||||
|
||||
// Use the exact lifecycle owner; reusable ids could resolve to a replacement.
|
||||
// A busy owner is injected: the notice waits in its next-step inbox, which
|
||||
// the turn cannot close over, so jobs settling together cost one step. An
|
||||
// idle owner is woken instead, because an unclaimed notice is a completion
|
||||
// the model never learns about. Either way, disposal before the claim
|
||||
// discards it with the owner, and teardown settlements arrive `reported`.
|
||||
//
|
||||
// The registry routes each settlement to the listeners its owner's scope
|
||||
// chain reaches, so a mount under one preset never sees another preset's
|
||||
// agents; this listener owns delivery, not the choice of whom to deliver to.
|
||||
ctx.jobs.onJobDone((snapshot, owner) => {
|
||||
if (snapshot.reported || owner === undefined) return
|
||||
const message = createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: fitCompletionNotice(snapshot),
|
||||
}],
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'tool-jobs',
|
||||
form: 'notice',
|
||||
summary: completionSummary(snapshot),
|
||||
},
|
||||
})
|
||||
const spent = spentWakes.get(owner) ?? 0
|
||||
if (delivery === 'wakeup' && owner.status === 'idle' && spent < wakeBudget) {
|
||||
spentWakes.set(owner, spent + 1)
|
||||
owner.followup(message)
|
||||
return
|
||||
}
|
||||
owner.inject(message)
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'job_output',
|
||||
description: 'Read a background job. Stream jobs return only output since the previous read; '
|
||||
+ 'final-output jobs return their result after settlement. Every response ends with '
|
||||
+ '`[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.',
|
||||
// A timed-out wait returns job state rather than a TOOL_TIMEOUT error, so
|
||||
// this tool owns its deadline instead of using ToolDefinition.timeoutMs.
|
||||
parameters: {
|
||||
job_id: { type: 'string', required: true, description: 'Job id returned by the tool that started the background work.' },
|
||||
wait: { type: 'boolean', description: 'Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive.' },
|
||||
timeout_ms: { type: 'number', description: 'Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum.' },
|
||||
},
|
||||
finalizeContent: finalizeTaskContent,
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
text: { type: 'string', required: true },
|
||||
job: { ...PUBLIC_TASK_SCHEMA, required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => {
|
||||
const body = value.text.length > 0 ? value.text : '(no new output)'
|
||||
const separator = body.endsWith('\n') ? '' : '\n'
|
||||
return [{ type: 'text', text: `${body}${separator}${statusLine(value.job)}` }]
|
||||
},
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const id = validateJobId(args.job_id)
|
||||
if (args.wait === true) {
|
||||
const timeout = Math.min(args.timeout_ms ?? waitDefault, waitCap)
|
||||
await ctx.jobs.wait(id, timeout, exec.agent, exec.signal)
|
||||
}
|
||||
const read = ctx.jobs.read(id, exec.agent)
|
||||
return { text: read.text, job: publicJob(read.snapshot) }
|
||||
},
|
||||
presentCall: args => presentTaskCall(`Read output from background job ${args.job_id}`, 'read', args.job_id),
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'job_list',
|
||||
description: 'List your background jobs (running and finished) with their ids, kinds, and statuses.',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'array', items: PUBLIC_TASK_SCHEMA },
|
||||
render: (_args, jobs) => [{
|
||||
type: 'text',
|
||||
text: jobs.length === 0
|
||||
? '(no background jobs)'
|
||||
: jobs.map(t => `${t.id} [${t.kind}] ${t.status} — ${t.label}`).join('\n'),
|
||||
}],
|
||||
},
|
||||
execute(_args, exec) {
|
||||
const jobs = ctx.jobs.list(exec.agent)
|
||||
return Promise.resolve(jobs.map(publicJob))
|
||||
},
|
||||
presentCall: () => presentTaskCall('List background jobs', 'read'),
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'job_kill',
|
||||
description: 'Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.',
|
||||
parameters: {
|
||||
job_id: { type: 'string', required: true, description: 'Job id returned by the tool that started the background work.' },
|
||||
reason: { type: 'string', description: 'Optional short reason, recorded in the log and forwarded to the job.' },
|
||||
},
|
||||
finalizeContent: finalizeTaskContent,
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
outcome: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
enum: ['cancellation-requested', 'already-finished'],
|
||||
},
|
||||
job: { ...PUBLIC_TASK_SCHEMA, required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{
|
||||
type: 'text',
|
||||
text: value.outcome === 'already-finished'
|
||||
? `job ${value.job.id} had already finished ${statusLine(value.job)}`
|
||||
: `requested cancellation of job ${value.job.id}`,
|
||||
}],
|
||||
},
|
||||
execute(args, exec) {
|
||||
const id = validateJobId(args.job_id)
|
||||
const result = ctx.jobs.kill(id, exec.agent, args.reason)
|
||||
// A snapshot describes current state without consuming pending output.
|
||||
const snapshot = publicJob(ctx.jobs.get(id, exec.agent))
|
||||
return Promise.resolve({
|
||||
outcome: result === 'already-finished' ? 'already-finished' as const : 'cancellation-requested' as const,
|
||||
job: snapshot,
|
||||
})
|
||||
},
|
||||
presentCall: args => presentTaskCall(`Kill background job ${args.job_id}`, 'execute', args.job_id),
|
||||
}))
|
||||
}
|
||||
30
packages/jobs/tool-jobs/src/invariant.ts
Normal file
30
packages/jobs/tool-jobs/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-jobs`.
|
||||
* @module @deepseek-ai/dsh-tool-jobs/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-jobs'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-jobs-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution
|
||||
* relations are owned by the capability seam it calls.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
890
packages/jobs/tool-jobs/tests/tool-jobs.spec.ts
Normal file
890
packages/jobs/tool-jobs/tests/tool-jobs.spec.ts
Normal file
@@ -0,0 +1,890 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { emitAgentEvent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import { JobId } from '@deepseek-ai/dsh-jobs'
|
||||
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
|
||||
import type { JobHooks, JobOutcome, JobSnapshot, JobStart } from '@deepseek-ai/dsh-jobs'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
|
||||
import { statusLine } from '@deepseek-ai/dsh-tool-jobs'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
const agentRegistryDisposers = new WeakMap<Agent, () => void>()
|
||||
const agentScopeFibers = new WeakMap<Agent, { dispose: () => Promise<void> }>()
|
||||
|
||||
async function setup(config: ToolTasks.Config = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
const agentsFiber = await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalJobRegistry)
|
||||
const toolsFiber = await ctx.plugin(ToolTasks, config)
|
||||
return { ctx, agentsFiber, toolsFiber }
|
||||
}
|
||||
|
||||
/** The delivery surface a completion notice may reach on a fake owner. */
|
||||
interface FakeDelivery {
|
||||
inject?: (...args: unknown[]) => void
|
||||
followup?: (...args: unknown[]) => void
|
||||
/** Defaults to `running`, the lane that never wakes, so notice-content tests pin one lane. */
|
||||
status?: 'idle' | 'running'
|
||||
}
|
||||
|
||||
/**
|
||||
* A fake agent with the shared agent/session identity, registered in
|
||||
* `ctx.agents` with a dedicated lifecycle scope.
|
||||
*/
|
||||
function fakeAgent(ctx: Context, sessionId: string, delivery: FakeDelivery = {}): Agent {
|
||||
const scopeFiber = ctx.plugin(() => {})
|
||||
const id = SessionId(sessionId)
|
||||
const agent = {
|
||||
id,
|
||||
ctx: scopeFiber.ctx,
|
||||
inject: delivery.inject ?? (() => {}),
|
||||
followup: delivery.followup ?? (() => {}),
|
||||
status: delivery.status ?? 'running',
|
||||
session: { id, header: { version: 0, id, createdAt: 0 } },
|
||||
} as unknown as Agent
|
||||
agentRegistryDisposers.set(agent, ctx.agents.register(agent))
|
||||
agentScopeFibers.set(agent, scopeFiber)
|
||||
return agent
|
||||
}
|
||||
|
||||
function detachAgent(agent: Agent): void {
|
||||
const dispose = agentRegistryDisposers.get(agent)
|
||||
if (dispose === undefined) throw new Error(`missing registry disposer for agent "${agent.id}"`)
|
||||
dispose()
|
||||
}
|
||||
|
||||
/** Dispose the agent's own lifecycle scope, which is what drains its owned jobs. */
|
||||
async function disposeAgentScope(agent: Agent): Promise<void> {
|
||||
const fiber = agentScopeFibers.get(agent)
|
||||
if (fiber === undefined) throw new Error(`missing scope fiber for agent "${agent.id}"`)
|
||||
await fiber.dispose()
|
||||
}
|
||||
|
||||
/** A controllable producer start-spec (settle `done` on demand, record cancels). */
|
||||
function producer(overrides: Partial<Omit<JobStart, 'run'> & JobHooks> = {}) {
|
||||
let settle!: (outcome: JobOutcome) => void
|
||||
const cancels: (string | undefined)[] = []
|
||||
const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, ...hookOverrides } = overrides
|
||||
const hooks: JobHooks = {
|
||||
cancel(reason) { cancels.push(reason) },
|
||||
done: new Promise<JobOutcome>((res) => { settle = res }),
|
||||
...hookOverrides,
|
||||
}
|
||||
const spec: JobStart = {
|
||||
kind,
|
||||
label,
|
||||
...owner !== undefined ? { owner } : {},
|
||||
...outputLimitBytes !== undefined ? { outputLimitBytes } : {},
|
||||
run: () => hooks,
|
||||
}
|
||||
return { spec, settle, cancels }
|
||||
}
|
||||
|
||||
let callCounter = 0
|
||||
function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
|
||||
return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
}
|
||||
|
||||
const tick = () => new Promise<void>(r => setTimeout(r, 0))
|
||||
|
||||
/** Start and settle `count` owned jobs one at a time, letting each notice land. */
|
||||
async function settleTasks(ctx: Context, owner: Agent, count: number): Promise<void> {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const p = producer({ owner })
|
||||
ctx.jobs.start(p.spec)
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
}
|
||||
}
|
||||
|
||||
describe('tool-jobs setup', () => {
|
||||
it('attaches the job controller on load and detaches it with the fiber', async () => {
|
||||
const { ctx, toolsFiber } = await setup()
|
||||
expect(() => ctx.jobs.start(producer().spec)).not.toThrow()
|
||||
await toolsFiber.dispose()
|
||||
expect(() => ctx.jobs.start(producer().spec)).toThrow('no job controller serves this agent')
|
||||
})
|
||||
|
||||
it('rejects a config whose default wait exceeds the cap', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(LocalJobRegistry)
|
||||
await expect(ctx.plugin(ToolTasks, { waitTimeoutMs: 100, maxWaitTimeoutMs: 50 }))
|
||||
.rejects.toThrow('waitTimeoutMs (100) exceeds maxWaitTimeoutMs (50)')
|
||||
})
|
||||
|
||||
it('defaults delivery to wakeup and rejects an unknown lane', () => {
|
||||
expect(ToolTasks.Config({}).completionDelivery).toBe('wakeup')
|
||||
expect(ToolTasks.Config({}).maxConsecutiveWakes).toBe(3)
|
||||
expect(() => ToolTasks.Config({ completionDelivery: 'loud' as never })).toThrow()
|
||||
expect(() => ToolTasks.Config({ maxConsecutiveWakes: 0 })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a wake budget that cannot bound anything', async () => {
|
||||
// Reports the load outcome as text: a resolved fiber is not safely printable.
|
||||
const loadWith = async (maxConsecutiveWakes: number): Promise<string> => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(LocalJobRegistry)
|
||||
try {
|
||||
await ctx.plugin(ToolTasks, { maxConsecutiveWakes })
|
||||
return 'loaded'
|
||||
} catch (error: unknown) {
|
||||
return String(error)
|
||||
}
|
||||
}
|
||||
|
||||
// The field exists to bound runaway waking; a fractional budget counts
|
||||
// nothing and an infinite one removes the bound it was configured for.
|
||||
expect(await loadWith(Number.POSITIVE_INFINITY)).toContain('maxConsecutiveWakes')
|
||||
expect(await loadWith(2.5)).toContain('maxConsecutiveWakes')
|
||||
expect(await loadWith(1)).toBe('loaded')
|
||||
})
|
||||
|
||||
it('renders status lines with and without producer detail', () => {
|
||||
const base = { id: 'bash-1', kind: 'bash', label: 'x', startedAt: 0, reported: false } as unknown as JobSnapshot
|
||||
expect(statusLine({ ...base, status: 'running' })).toBe('[status: running]')
|
||||
expect(statusLine({ ...base, status: 'completed', detail: 'exit code: 0' })).toBe('[status: completed, exit code: 0]')
|
||||
})
|
||||
|
||||
it('applies the built-in wait bounds when apply() receives a bare config', async () => {
|
||||
// Bypasses the schemastery defaults on purpose: apply() must stand on its
|
||||
// own `??` fallbacks when embedded programmatically without the schema.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(LocalJobRegistry)
|
||||
ToolTasks.apply(ctx, {})
|
||||
expect(ctx.tools.get('job_output')).toBeDefined()
|
||||
expect(() => ctx.jobs.start(producer().spec)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('job_output', () => {
|
||||
it('reads a consuming delta with a trailing status line', async () => {
|
||||
const { ctx } = await setup()
|
||||
const chunks = ['line one\n', '']
|
||||
ctx.jobs.start(producer({ readOutput: () => chunks.shift() ?? '' }).spec)
|
||||
|
||||
// A body already ending in a newline gets no doubled separator.
|
||||
const first = await call(ctx, 'job_output', { job_id: 'bash-1' })
|
||||
if (first.isError) throw new Error('expected job_output success')
|
||||
const firstValue = first.value as { text: string; job: Record<string, unknown> }
|
||||
expect(firstValue).toMatchObject({
|
||||
text: 'line one\n',
|
||||
job: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'running' },
|
||||
})
|
||||
expect(firstValue.job).not.toHaveProperty('ownerSession')
|
||||
expect(firstValue.job).not.toHaveProperty('reported')
|
||||
expect(text(first)).toBe('line one\n[status: running]')
|
||||
expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('(no new output)\n[status: running]')
|
||||
})
|
||||
|
||||
it('returns the final output of a settled final-output job', async () => {
|
||||
const { ctx } = await setup()
|
||||
const p = producer({ kind: 'subagent', label: 'research' })
|
||||
ctx.jobs.start(p.spec)
|
||||
expect(text(await call(ctx, 'job_output', { job_id: 'subagent-1' }))).toBe('(no new output)\n[status: running]')
|
||||
|
||||
p.settle({ status: 'completed', detail: 'completed', output: 'the answer' })
|
||||
await tick()
|
||||
expect(text(await call(ctx, 'job_output', { job_id: 'subagent-1' }))).toBe('the answer\n[status: completed, completed]')
|
||||
})
|
||||
|
||||
it('applies a producer limit to the complete body and status result', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.jobs.start(producer({
|
||||
outputLimitBytes: 48,
|
||||
readOutput: () => '界'.repeat(100),
|
||||
}).spec)
|
||||
|
||||
const output = text(await call(ctx, 'job_output', { job_id: 'bash-1' }))
|
||||
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(48)
|
||||
expect(output).toContain('[status: running]')
|
||||
})
|
||||
|
||||
it('preserves empty and newline-terminated output under a producer limit', async () => {
|
||||
const { ctx } = await setup()
|
||||
const chunks = ['', 'line\n']
|
||||
ctx.jobs.start(producer({
|
||||
outputLimitBytes: 64,
|
||||
readOutput: () => chunks.shift() ?? '',
|
||||
}).spec)
|
||||
|
||||
expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' })))
|
||||
.toBe('(no new output)\n[status: running]')
|
||||
expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' })))
|
||||
.toBe('line\n[status: running]')
|
||||
})
|
||||
|
||||
it('bounds post-policy output without restoring the canonical status rendering', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.jobs.start(producer({
|
||||
outputLimitBytes: 64,
|
||||
readOutput: () => 'canonical output',
|
||||
}).spec)
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||
if (exec.name !== 'job_output') return next()
|
||||
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'p'.repeat(1_000) }] })
|
||||
})
|
||||
|
||||
const result = await call(ctx, 'job_output', { job_id: 'bash-1' })
|
||||
expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64)
|
||||
expect(text(result)).toContain('[result truncated]')
|
||||
expect(text(result)).not.toContain('[status: running]')
|
||||
})
|
||||
|
||||
it('applies a producer limit to a normalized read failure', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.jobs.start(producer({
|
||||
outputLimitBytes: 64,
|
||||
readOutput: () => { throw new Error('read failed: '.repeat(100)) },
|
||||
}).spec)
|
||||
|
||||
const result = await call(ctx, 'job_output', { job_id: 'bash-1' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64)
|
||||
expect(text(result)).toContain('[result truncated]')
|
||||
})
|
||||
|
||||
it('bounds pre-, around-, and post-execute policy outcomes and failures', async () => {
|
||||
const { ctx } = await setup()
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
ctx.jobs.start(producer({ outputLimitBytes: 64 }).spec)
|
||||
}
|
||||
ctx.on('tools/pre-execute', async (exec, next) => {
|
||||
const jobId = (exec.arguments as { job_id?: unknown }).job_id
|
||||
if (jobId === 'bash-1') return { kind: 'deny', reason: 'd'.repeat(1_000) }
|
||||
if (jobId === 'bash-3') throw new Error(`pre failed: ${'p'.repeat(1_000)}`)
|
||||
return next()
|
||||
})
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
const jobId = (exec.arguments as { job_id?: unknown }).job_id
|
||||
if (jobId === 'bash-2') {
|
||||
return {
|
||||
content: [],
|
||||
isError: false,
|
||||
value: {
|
||||
text: 'a'.repeat(1_000),
|
||||
job: {
|
||||
id: 'bash-2', kind: 'bash', label: 'sleep 60', status: 'running', startedAt: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
if (jobId === 'bash-4') throw new Error(`around failed: ${'e'.repeat(1_000)}`)
|
||||
return next()
|
||||
})
|
||||
ctx.on('tools/post-execute', async (exec, _result, next) => {
|
||||
const jobId = (exec.arguments as { job_id?: unknown }).job_id
|
||||
if (jobId === 'bash-5') throw new Error(`post failed: ${'o'.repeat(1_000)}`)
|
||||
return next()
|
||||
})
|
||||
|
||||
const denied = await call(ctx, 'job_output', { job_id: 'bash-1' })
|
||||
expect(denied.isError).toBe(true)
|
||||
expect(Buffer.byteLength(text(denied))).toBeLessThanOrEqual(64)
|
||||
expect(text(denied)).toContain('[result truncated]')
|
||||
|
||||
const shortCircuited = await call(ctx, 'job_output', { job_id: 'bash-2' })
|
||||
expect(shortCircuited.isError).toBe(false)
|
||||
expect(Buffer.byteLength(text(shortCircuited))).toBeLessThanOrEqual(64)
|
||||
expect(text(shortCircuited)).toContain('[output truncated]')
|
||||
|
||||
const failures = [
|
||||
await call(ctx, 'job_output', { job_id: 'bash-3' }),
|
||||
await call(ctx, 'job_output', { job_id: 'bash-4' }),
|
||||
await call(ctx, 'job_output', { job_id: 'bash-5' }),
|
||||
]
|
||||
for (const failure of failures) {
|
||||
expect(failure.isError).toBe(true)
|
||||
expect(Buffer.byteLength(text(failure))).toBeLessThanOrEqual(64)
|
||||
expect(text(failure)).toContain('[result truncated]')
|
||||
}
|
||||
})
|
||||
|
||||
it('wait: true blocks until settlement and reports the terminal state', async () => {
|
||||
const { ctx } = await setup()
|
||||
const p = producer({ kind: 'subagent', label: 'research' })
|
||||
ctx.jobs.start(p.spec)
|
||||
|
||||
const pending = call(ctx, 'job_output', { job_id: 'subagent-1', wait: true })
|
||||
p.settle({ status: 'completed', output: 'done deal' })
|
||||
expect(text(await pending)).toBe('done deal\n[status: completed]')
|
||||
})
|
||||
|
||||
it('wait: true times out against the configured cap and leaves the job alive', async () => {
|
||||
const { ctx } = await setup({ waitTimeoutMs: 10, maxWaitTimeoutMs: 20 })
|
||||
ctx.jobs.start(producer().spec)
|
||||
|
||||
// A model-supplied timeout far above the cap is clamped: this returns
|
||||
// promptly (≤ the 20ms cap), not after ten minutes.
|
||||
const result = await call(ctx, 'job_output', { job_id: 'bash-1', wait: true, timeout_ms: 600_000 })
|
||||
expect(text(result)).toBe('(no new output)\n[status: running]')
|
||||
})
|
||||
|
||||
it('rejects an empty or unknown job id as an errored result', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect((await call(ctx, 'job_output', { job_id: '' })).isError).toBe(true)
|
||||
const unknown = await call(ctx, 'job_output', { job_id: 'bash-99' })
|
||||
expect(unknown.isError).toBe(true)
|
||||
expect(text(unknown)).toContain('unknown job bash-99')
|
||||
})
|
||||
})
|
||||
|
||||
describe('job_list', () => {
|
||||
it('lists caller-visible jobs and renders the empty case', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(text(await call(ctx, 'job_list', {}))).toBe('(no background jobs)')
|
||||
|
||||
const alice = fakeAgent(ctx, 'sess-alice')
|
||||
ctx.jobs.start(producer({ owner: alice, label: 'pnpm test' }).spec)
|
||||
ctx.jobs.start(producer({ kind: 'subagent', label: 'open research' }).spec)
|
||||
const p = producer({ owner: alice, label: 'build' })
|
||||
ctx.jobs.start(p.spec)
|
||||
p.settle({ status: 'completed', detail: 'exit code: 0' })
|
||||
await tick()
|
||||
|
||||
const listed = await call(ctx, 'job_list', {}, alice)
|
||||
if (listed.isError) throw new Error('expected job_list success')
|
||||
const listedValue = listed.value as Array<Record<string, unknown>>
|
||||
expect(listedValue).toHaveLength(3)
|
||||
expect(listedValue[0]).toMatchObject({ id: 'bash-1', kind: 'bash', label: 'pnpm test', status: 'running' })
|
||||
expect(listedValue[2]).toMatchObject({ id: 'bash-2', kind: 'bash', label: 'build', status: 'completed', detail: 'exit code: 0' })
|
||||
for (const job of listedValue) {
|
||||
expect(job).not.toHaveProperty('ownerSession')
|
||||
expect(job).not.toHaveProperty('reported')
|
||||
}
|
||||
expect(text(listed)).toBe([
|
||||
'bash-1 [bash] running — pnpm test',
|
||||
'subagent-1 [subagent] running — open research',
|
||||
'bash-2 [bash] completed — build',
|
||||
].join('\n'))
|
||||
// A different caller sees only the unowned job.
|
||||
const bob = fakeAgent(ctx, 'sess-bob')
|
||||
expect(text(await call(ctx, 'job_list', {}, bob))).toBe('subagent-1 [subagent] running — open research')
|
||||
})
|
||||
})
|
||||
|
||||
describe('job_kill', () => {
|
||||
it('requests cancellation with the forwarded reason', async () => {
|
||||
const { ctx } = await setup()
|
||||
const p = producer()
|
||||
ctx.jobs.start(p.spec)
|
||||
|
||||
const result = await call(ctx, 'job_kill', { job_id: 'bash-1', reason: 'superseded' })
|
||||
if (result.isError) throw new Error('expected job_kill success')
|
||||
const killValue = result.value as { outcome: string; job: Record<string, unknown> }
|
||||
expect(killValue).toMatchObject({
|
||||
outcome: 'cancellation-requested',
|
||||
job: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'stopping' },
|
||||
})
|
||||
expect(killValue.job).not.toHaveProperty('ownerSession')
|
||||
expect(killValue.job).not.toHaveProperty('reported')
|
||||
expect(text(result)).toBe('requested cancellation of job bash-1')
|
||||
expect(p.cancels).toEqual(['superseded'])
|
||||
})
|
||||
|
||||
it('applies the producer output limit to a cancellation acknowledgement', async () => {
|
||||
const { ctx } = await setup()
|
||||
const p = producer({ outputLimitBytes: 8 })
|
||||
ctx.jobs.start(p.spec)
|
||||
|
||||
const result = await call(ctx, 'job_kill', { job_id: 'bash-1' })
|
||||
expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(8)
|
||||
expect(p.cancels).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('applies the producer output limit to a normalized cancellation failure', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.jobs.start(producer({
|
||||
outputLimitBytes: 64,
|
||||
cancel: () => { throw new Error('cancel failed: '.repeat(100)) },
|
||||
}).spec)
|
||||
|
||||
const result = await call(ctx, 'job_kill', { job_id: 'bash-1' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64)
|
||||
expect(text(result)).toContain('[result truncated]')
|
||||
expect(ctx.jobs.get(JobId('bash-1'))).toMatchObject({ status: 'running', reported: false })
|
||||
})
|
||||
|
||||
it('bounds single-text post policy while preserving structured policy results', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||
if (exec.name !== 'job_kill') return next()
|
||||
const reason = (exec.arguments as { reason?: unknown }).reason
|
||||
if (reason === 'replace') {
|
||||
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'r'.repeat(1_000) }] })
|
||||
}
|
||||
if (reason === 'block') {
|
||||
return Promise.resolve({ kind: 'block', feedback: [{ type: 'text', text: 'b'.repeat(1_000) }] })
|
||||
}
|
||||
if (reason === 'multi') {
|
||||
return Promise.resolve({
|
||||
kind: 'block',
|
||||
feedback: [{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }],
|
||||
})
|
||||
}
|
||||
if (reason === 'reasoning') {
|
||||
return Promise.resolve({ kind: 'block', feedback: [{ type: 'reasoning', text: 'policy detail' }] })
|
||||
}
|
||||
return next()
|
||||
})
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
ctx.jobs.start(producer({ outputLimitBytes: 64 }).spec)
|
||||
}
|
||||
|
||||
const replaced = await call(ctx, 'job_kill', { job_id: 'bash-1', reason: 'replace' })
|
||||
expect(replaced.isError).toBe(false)
|
||||
expect(Buffer.byteLength(text(replaced))).toBeLessThanOrEqual(64)
|
||||
expect(text(replaced)).toContain('[result truncated]')
|
||||
|
||||
const blocked = await call(ctx, 'job_kill', { job_id: 'bash-2', reason: 'block' })
|
||||
expect(blocked.isError).toBe(true)
|
||||
expect(Buffer.byteLength(text(blocked))).toBeLessThanOrEqual(64)
|
||||
expect(text(blocked)).toContain('[result truncated]')
|
||||
|
||||
const multi = await call(ctx, 'job_kill', { job_id: 'bash-3', reason: 'multi' })
|
||||
expect(multi.content).toEqual([{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }])
|
||||
|
||||
const reasoning = await call(ctx, 'job_kill', { job_id: 'bash-4', reason: 'reasoning' })
|
||||
expect(reasoning.content).toEqual([{ type: 'reasoning', text: 'policy detail' }])
|
||||
})
|
||||
|
||||
it('reports an already-finished job without consuming its pending delta', async () => {
|
||||
const { ctx } = await setup()
|
||||
let delta = 'unread tail'
|
||||
const p = producer({ readOutput: () => { const d = delta; delta = ''; return d } })
|
||||
ctx.jobs.start(p.spec)
|
||||
p.settle({ status: 'completed', detail: 'exit code: 0' })
|
||||
await tick()
|
||||
|
||||
const killed = await call(ctx, 'job_kill', { job_id: 'bash-1' })
|
||||
if (killed.isError) throw new Error('expected job_kill success')
|
||||
expect(killed.value).toMatchObject({
|
||||
outcome: 'already-finished',
|
||||
job: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'completed', detail: 'exit code: 0' },
|
||||
})
|
||||
expect(text(killed)).toBe('job bash-1 had already finished [status: completed, exit code: 0]')
|
||||
// The kill described the job via a non-consuming snapshot: the delta is intact.
|
||||
expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('unread tail\n[status: completed, exit code: 0]')
|
||||
})
|
||||
|
||||
it('rejects an empty job id as an errored result', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect((await call(ctx, 'job_kill', { job_id: '' })).isError).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-owned UI presentation (presentCall)', () => {
|
||||
it('renders generic cards for all three control tools', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(ctx.tools.get('job_output')?.presentCall?.({ job_id: 'bash-1' }))
|
||||
.toEqual({ card: 'generic', title: 'Read output from background job bash-1', kind: 'read', rawInput: 'bash-1' })
|
||||
expect(ctx.tools.get('job_list')?.presentCall?.({}))
|
||||
.toEqual({ card: 'generic', title: 'List background jobs', kind: 'read' })
|
||||
expect(ctx.tools.get('job_kill')?.presentCall?.({ job_id: 'subagent-2' }))
|
||||
.toEqual({ card: 'generic', title: 'Kill background job subagent-2', kind: 'execute', rawInput: 'subagent-2' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('completion notices across scoped mounts', () => {
|
||||
/**
|
||||
* Two agent presets mounting `tool-jobs` over ONE host registry: each mount
|
||||
* registers its own `onJobDone` listener on the shared service, and
|
||||
* `settle()` broadcasts one snapshot to every listener with no scope filter.
|
||||
* Only the mount whose scope the owner belongs to may deliver the notice.
|
||||
*/
|
||||
it('delivers one notice from the owning scope when two mounts share the registry', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalJobRegistry)
|
||||
|
||||
const standingA = createScope(ctx, {})
|
||||
const standingB = createScope(ctx, {})
|
||||
await standingA.ctx.plugin(ToolTasks)
|
||||
await standingB.ctx.plugin(ToolTasks)
|
||||
|
||||
// The agent joins preset A exactly as `agentPresets.compose` binds it.
|
||||
const agentKey = {}
|
||||
const agentScope = createScope(ctx, agentKey)
|
||||
bindScopeParent(agentKey, scopeOf(standingA.ctx) as object)
|
||||
|
||||
const inject = vi.fn()
|
||||
const owner = {
|
||||
id: SessionId('sess-scoped'),
|
||||
ctx: agentScope.ctx,
|
||||
inject,
|
||||
session: { id: SessionId('sess-scoped'), header: { version: 0, id: SessionId('sess-scoped'), createdAt: 0 } },
|
||||
} as unknown as Agent
|
||||
const dispose = ctx.agents.register(owner)
|
||||
|
||||
try {
|
||||
// No waiter: `settle()` leaves `reported` false, which is the only path
|
||||
// that reaches the notice listeners at all.
|
||||
const p = producer({ owner, label: 'pnpm test' })
|
||||
ctx.jobs.start(p.spec)
|
||||
p.settle({ status: 'completed', detail: 'exit code: 0' })
|
||||
await tick()
|
||||
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('completion notice delivery', () => {
|
||||
it('opens a turn on an idle owner when a job settles', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const followup = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
|
||||
const p = producer({ owner, label: 'pnpm test' })
|
||||
ctx.jobs.start(p.spec)
|
||||
|
||||
p.settle({ status: 'completed', detail: 'exit code: 0' })
|
||||
await tick()
|
||||
expect(followup).toHaveBeenCalledTimes(1)
|
||||
expect(inject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never wakes an idle owner under quiet delivery', async () => {
|
||||
const { ctx } = await setup({ completionDelivery: 'quiet' })
|
||||
const inject = vi.fn()
|
||||
const followup = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
|
||||
const p = producer({ owner })
|
||||
ctx.jobs.start(p.spec)
|
||||
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('degrades to injection once the consecutive wake budget is spent', async () => {
|
||||
const { ctx } = await setup({ maxConsecutiveWakes: 2 })
|
||||
const inject = vi.fn()
|
||||
const followup = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
|
||||
|
||||
await settleTasks(ctx, owner, 3)
|
||||
// A woken turn that starts another job is the self-exciting case: the
|
||||
// budget stops the chain, and the notice still reaches the inbox.
|
||||
expect(followup).toHaveBeenCalledTimes(2)
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('restores the wake budget when the owner claims a user message', async () => {
|
||||
const { ctx } = await setup({ maxConsecutiveWakes: 1 })
|
||||
const inject = vi.fn()
|
||||
const followup = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
|
||||
|
||||
await settleTasks(ctx, owner, 2)
|
||||
expect(followup).toHaveBeenCalledTimes(1)
|
||||
|
||||
emitAgentEvent(ctx, owner, 'agent/inbox/claimed', {
|
||||
message: createUserMessage({ content: [{ type: 'text', text: 'carry on' }], source: { kind: 'user' } }),
|
||||
turn: 1,
|
||||
})
|
||||
await settleTasks(ctx, owner, 1)
|
||||
expect(followup).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('neither wakes nor injects into an owner its own teardown is draining', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const followup = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
|
||||
let settle!: (outcome: JobOutcome) => void
|
||||
ctx.jobs.start({
|
||||
kind: 'bash',
|
||||
label: 'sleep 60',
|
||||
owner,
|
||||
run: () => ({
|
||||
cancel() { settle({ status: 'killed' }) },
|
||||
done: new Promise<JobOutcome>((res) => { settle = res }),
|
||||
}),
|
||||
})
|
||||
|
||||
// Disposal cancels and settles the owned job. Waking here would spend a
|
||||
// model request on an agent the host is destroying, once per tree layer.
|
||||
await disposeAgentScope(owner)
|
||||
await tick()
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
expect(inject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('neither wakes nor injects when the teardown cancel itself threw', async () => {
|
||||
const { ctx } = await setup()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const inject = vi.fn()
|
||||
const followup = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
|
||||
ctx.jobs.start({
|
||||
kind: 'bash',
|
||||
label: 'broken producer',
|
||||
owner,
|
||||
run: () => ({
|
||||
cancel() { throw new Error('cancel boom') },
|
||||
done: new Promise<JobOutcome>(() => {}),
|
||||
}),
|
||||
})
|
||||
|
||||
// The registry force-fails the record instead of deadlocking. That path
|
||||
// settles the job too, so it must claim the report as the ordinary
|
||||
// teardown cancel does — otherwise a throwing producer is all it takes to
|
||||
// spend a model request on an owner being destroyed.
|
||||
await disposeAgentScope(owner)
|
||||
await tick()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned'))
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
expect(inject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the budget spent when the owner only claims plugin notices', async () => {
|
||||
const { ctx } = await setup({ maxConsecutiveWakes: 1 })
|
||||
const followup = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { followup, status: 'idle' })
|
||||
|
||||
await settleTasks(ctx, owner, 1)
|
||||
emitAgentEvent(ctx, owner, 'agent/inbox/claimed', {
|
||||
message: createUserMessage({
|
||||
content: [{ type: 'text', text: 'background job bash-1 finished' }],
|
||||
source: { kind: 'plugin', plugin: 'tool-jobs', form: 'notice', summary: 'bash' },
|
||||
}),
|
||||
turn: 1,
|
||||
})
|
||||
await settleTasks(ctx, owner, 1)
|
||||
expect(followup).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('completion notices', () => {
|
||||
it('injects a notice into the owning agent when an unreported job settles', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
const p = producer({ owner, label: 'pnpm test' })
|
||||
ctx.jobs.start(p.spec)
|
||||
|
||||
p.settle({ status: 'completed', detail: 'exit code: 0' })
|
||||
await tick()
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
expect(inject).toHaveBeenCalledWith({
|
||||
id: expect.any(String) as unknown,
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'background job bash-1 (bash: pnpm test) finished [status: completed, exit code: 0]. Read its output with job_output.' }],
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'tool-jobs',
|
||||
form: 'notice',
|
||||
summary: 'bash pnpm test [status: completed, exit code: 0]',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves job ids and collection guidance in bounded completion notices', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
const first = producer({
|
||||
owner,
|
||||
kind: 'subagent',
|
||||
label: 'x'.repeat(1_000),
|
||||
outputLimitBytes: 61,
|
||||
})
|
||||
ctx.jobs.start(first.spec)
|
||||
first.settle({ status: 'completed', detail: 'd'.repeat(1_000) })
|
||||
await tick()
|
||||
|
||||
expect(inject).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{
|
||||
id: expect.any(String) as unknown,
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'background job subagent-1\nDone; job_output.' }],
|
||||
// The label and status detail are unbounded caller text, so the durable
|
||||
// one-line account caps itself rather than committing their full length.
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'tool-jobs',
|
||||
form: 'notice',
|
||||
summary: `subagent ${'x'.repeat(110)}…`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const second = producer({
|
||||
owner,
|
||||
kind: 'subagent',
|
||||
label: 'x'.repeat(1_000),
|
||||
outputLimitBytes: 80,
|
||||
})
|
||||
ctx.jobs.start(second.spec)
|
||||
second.settle({ status: 'completed', detail: 'd'.repeat(1_000) })
|
||||
await tick()
|
||||
|
||||
const content = (inject.mock.calls[1]?.[0] as { content?: Array<{ type: string; text?: string }> } | undefined)?.content
|
||||
const notice = content?.[0]?.text ?? ''
|
||||
expect(Buffer.byteLength(notice)).toBeLessThanOrEqual(80)
|
||||
expect(notice).toContain('background job subagent-2 (subagent: xxxx')
|
||||
expect(notice).toContain('[notice truncated]\nDone; job_output.')
|
||||
})
|
||||
|
||||
it('keeps the complete PTY job id and collection action at the minimum PTY limit', async () => {
|
||||
const { ctx } = await setup()
|
||||
for (let index = 0; index < 99; index += 1) {
|
||||
const prior = producer({ kind: 'pty-send' })
|
||||
ctx.jobs.start(prior.spec)
|
||||
prior.settle({ status: 'completed' })
|
||||
await tick()
|
||||
}
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
const target = producer({
|
||||
owner,
|
||||
kind: 'pty-send',
|
||||
label: 'x'.repeat(1_000),
|
||||
outputLimitBytes: 64,
|
||||
})
|
||||
ctx.jobs.start(target.spec)
|
||||
|
||||
target.settle({ status: 'completed', detail: 'd'.repeat(1_000) })
|
||||
await tick()
|
||||
|
||||
const content = (inject.mock.calls[0]?.[0] as { content?: Array<{ type: string; text?: string }> } | undefined)?.content
|
||||
const notice = content?.[0]?.text ?? ''
|
||||
expect(Buffer.byteLength(notice)).toBeLessThanOrEqual(64)
|
||||
expect(notice).toBe('background job pty-send-100\n[notice truncated]\nDone; job_output.')
|
||||
})
|
||||
|
||||
it('reserves the collection-action tail when a producer supplies a smaller budget', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
const tiny = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 8 })
|
||||
const short = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 32 })
|
||||
ctx.jobs.start(tiny.spec)
|
||||
ctx.jobs.start(short.spec)
|
||||
|
||||
tiny.settle({ status: 'completed' })
|
||||
short.settle({ status: 'completed' })
|
||||
await tick()
|
||||
|
||||
const tinyNotice = (inject.mock.calls[0]?.[0] as { content?: Array<{ text?: string }> } | undefined)?.content?.[0]?.text ?? ''
|
||||
const shortNotice = (inject.mock.calls[1]?.[0] as { content?: Array<{ text?: string }> } | undefined)?.content?.[0]?.text ?? ''
|
||||
expect(Buffer.byteLength(tinyNotice)).toBeLessThanOrEqual(8)
|
||||
expect(tinyNotice).toBe('_output.')
|
||||
expect(Buffer.byteLength(shortNotice)).toBeLessThanOrEqual(32)
|
||||
expect(shortNotice).toBe('background job\nDone; job_output.')
|
||||
})
|
||||
|
||||
it('suppresses the notice for a job the model already killed', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
const p = producer({ owner })
|
||||
ctx.jobs.start(p.spec)
|
||||
|
||||
await call(ctx, 'job_kill', { job_id: 'bash-1' }, owner)
|
||||
p.settle({ status: 'killed' })
|
||||
await tick()
|
||||
expect(inject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suppresses the notice when a wait returned the terminal state', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
const p = producer({ owner, kind: 'subagent' })
|
||||
ctx.jobs.start(p.spec)
|
||||
|
||||
const pending = call(ctx, 'job_output', { job_id: 'subagent-1', wait: true }, owner)
|
||||
p.settle({ status: 'completed', output: 'answer' })
|
||||
expect(text(await pending)).toContain('answer')
|
||||
expect(inject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops the notice for unowned jobs without throwing', async () => {
|
||||
const { ctx } = await setup()
|
||||
// Unowned: settles with nobody to notify — nothing throws.
|
||||
const unowned = producer()
|
||||
ctx.jobs.start(unowned.spec)
|
||||
unowned.settle({ status: 'completed' })
|
||||
await tick()
|
||||
})
|
||||
|
||||
it('does not route an old owner completion notice to a same-session replacement', async () => {
|
||||
const { ctx } = await setup()
|
||||
// Delivery into a tearing-down owner is a plain inject: the loop has no
|
||||
// terminal state, so the notice lands in the old owner's (detached)
|
||||
// session instead of throwing or re-routing.
|
||||
const oldInject = vi.fn()
|
||||
const oldOwner = fakeAgent(ctx, 'shared', { inject: oldInject })
|
||||
const p = producer({ owner: oldOwner })
|
||||
ctx.jobs.start(p.spec)
|
||||
|
||||
detachAgent(oldOwner)
|
||||
const replacementInject = vi.fn()
|
||||
fakeAgent(ctx, 'shared', { inject: replacementInject })
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
|
||||
expect(oldInject).toHaveBeenCalledTimes(1)
|
||||
expect(replacementInject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces an inject failure through listener containment (a real bug must be visible)', async () => {
|
||||
const { ctx } = await setup()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject: () => { throw new Error('unexpected inject bug') } })
|
||||
const p = producer({ owner })
|
||||
ctx.jobs.start(p.spec)
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
// The throw escapes the notice listener and is contained (logged) by the
|
||||
// registry's per-listener containment — visible, not swallowed.
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unexpected inject bug'))
|
||||
})
|
||||
|
||||
it('keeps using the exact owner after the agent registry is gone', async () => {
|
||||
const { ctx, agentsFiber } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
|
||||
// Settlement must not depend on a later registry lookup: the exact owner
|
||||
// supplied at start remains the destination while its own scope is live.
|
||||
const p1 = producer({ owner })
|
||||
ctx.jobs.start(p1.spec)
|
||||
const p2 = producer({ owner })
|
||||
ctx.jobs.start(p2.spec)
|
||||
|
||||
await agentsFiber.dispose()
|
||||
p1.settle({ status: 'completed' })
|
||||
p2.settle({ status: 'failed' })
|
||||
await tick()
|
||||
expect(inject).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
39
packages/jobs/tool-jobs/tsconfig.json
Normal file
39
packages/jobs/tool-jobs/tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/output-retention"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../jobs"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user