Merge remote-tracking branch 'origin/master' into worktree/attachment-alignment-2
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/examples/acp-demo/README.md
|
||||
README.md: edc45c9857a631cef72eb41b1a98c390f112291e
|
||||
README.zh.md: c2946aa3d1feaed558408cf0921e2480c031187d
|
||||
README.md: c1a15a424d9d66b90bbec451e220198bfe0a45df
|
||||
README.zh.md: 16a8d782c8fa38fe9e2ae1e634dbc406be1cbd12
|
||||
|
||||
@@ -34,6 +34,7 @@ The app does not install commands, user interaction, session navigation, configu
|
||||
| `workspaceContext` | required | Workspace-instruction byte budget/config, or `false`. |
|
||||
| `skills` | owner defaults | Skill registry, local provider, and model-facing skill tool. |
|
||||
| `toolBash` | owner defaults | Model-facing bash tool config. |
|
||||
| `tasks` | `{ maxConcurrentTasksPerOwner: 10 }` | Process-local per-owner active-task admission. |
|
||||
| `toolTasks` | owner defaults | Generic background-task control config, or `false`. |
|
||||
| `goals` | owner defaults | Persisted same-session goal domain and model tools, or `false`. |
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ ACP(Agent Client Protocol)自动化服务器应用:默认 agent(智能
|
||||
| `workspaceContext` | 必填 | 工作区指令字节预算/配置,或 `false`。 |
|
||||
| `skills` | 拥有者默认值 | skill 注册表、本地提供方和面向模型的 skill 工具。 |
|
||||
| `toolBash` | 拥有者默认值 | 面向模型的 bash 工具配置。 |
|
||||
| `tasks` | `{ maxConcurrentTasksPerOwner: 10 }` | 进程内按 owner 限制活动任务的准入配置。 |
|
||||
| `toolTasks` | 拥有者默认值 | 通用后台任务控制配置,或 `false`。 |
|
||||
| `goals` | 拥有者默认值 | 持久化的同会话目标领域与模型工具,或 `false`。 |
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ export interface Config {
|
||||
skills?: agentCore.SkillConfig
|
||||
/** Model-facing bash tool config forwarded through agent-core. */
|
||||
toolBash?: NonNullable<agentCore.Config['toolBash']>
|
||||
/** Process-local background-task admission config forwarded through agent-core. */
|
||||
tasks?: NonNullable<agentCore.Config['tasks']>
|
||||
/** Generic background-task controls forwarded through agent-core; set false to omit their tools. */
|
||||
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
|
||||
/** Persisted same-session goals; owner defaults enable them, or false disables the stack and tools. */
|
||||
@@ -92,6 +94,7 @@ export const Config: z<Config> = z.object({
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
toolBash: agentCore.ToolBashConfigSchema,
|
||||
tasks: agentCore.TasksConfigSchema,
|
||||
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
|
||||
goals: z.union([z.const(false), agentCore.GoalConfigSchema]),
|
||||
})
|
||||
|
||||
@@ -182,6 +182,31 @@ describe('dsh-acp-demo composition', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards task admission config to the bundled task provider', async () => {
|
||||
const ctx = await mount({
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
tasks: { maxConcurrentTasksPerOwner: 1 },
|
||||
skills: await isolatedSkillsConfig(),
|
||||
workspaceContext: false,
|
||||
})
|
||||
let settle!: (outcome: { status: 'killed' }) => void
|
||||
ctx.tasks.start({
|
||||
kind: 'bash',
|
||||
label: 'hold configured slot',
|
||||
run: () => ({
|
||||
cancel: () => { settle({ status: 'killed' }) },
|
||||
done: new Promise((resolve) => { settle = resolve }),
|
||||
}),
|
||||
})
|
||||
expect(() => ctx.tasks.start({
|
||||
kind: 'bash',
|
||||
label: 'blocked configured task',
|
||||
run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }),
|
||||
})).toThrow('(limit: 1)')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards bundled tool config into agent-core', async () => {
|
||||
const ctx = await mount({
|
||||
provider: 'mock',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/examples/agent-spine-demo/README.md
|
||||
README.md: 5957d9a8e9218e18d5d7d0f620b6be811f2c230f
|
||||
README.zh.md: 78372240764ff3c779ea0805aedd26a00baf1768
|
||||
README.md: 789715e53038f610d1e2db79cf56f9aabd681fac
|
||||
README.zh.md: 6561dd8948d9a4763123f9aae31e6dde64c54b27
|
||||
|
||||
@@ -55,11 +55,11 @@ This applies the [Service Definition / Service provider / Consumer separation](.
|
||||
|
||||
```ts
|
||||
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? }
|
||||
// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, tasks?, toolTasks?, goals?, invariants? }
|
||||
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
|
||||
```
|
||||
|
||||
The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. Prompt, tool, title, skill, workspace-context, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition.
|
||||
The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. Prompt, tool, title, skill, workspace-context, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages; `tasks.maxConcurrentTasksPerOwner` configures the local provider independently of the model-facing `toolTasks` controls. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition.
|
||||
|
||||
For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules.
|
||||
|
||||
|
||||
@@ -55,11 +55,11 @@
|
||||
|
||||
```ts
|
||||
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? }
|
||||
// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, tasks?, toolTasks?, goals?, invariants? }
|
||||
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
|
||||
```
|
||||
|
||||
组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。
|
||||
组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值;`tasks.maxConcurrentTasksPerOwner` 配置本地 Service provider,并与面向模型的 `toolTasks` 控制工具相互独立。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。
|
||||
|
||||
例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../support/invariants/README.md)。
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import GoalService, { type Config as GoalDomainConfig } from '@deepseek-ai/dsh-goal'
|
||||
import * as goalSession from '@deepseek-ai/dsh-goal-session'
|
||||
import * as toolGoal from '@deepseek-ai/dsh-tool-goal'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import LocalTaskService, { type Config as TasksConfig } from '@deepseek-ai/dsh-tasks-local'
|
||||
import InvariantService, { type Config as InvariantConfig } from '@deepseek-ai/dsh-invariants'
|
||||
import * as sessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as agentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
@@ -75,9 +75,10 @@ export interface GoalConfig {
|
||||
* `dshHome` to bash environment and local skill discovery, `sessionTitle` to
|
||||
* the fallback title service, `skills` to the
|
||||
* skill registry/local provider/tool consumer, `workspaceContext` to the
|
||||
* workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool
|
||||
* plugins this bundle owns. Provider adapters own their `retryPolicy`; this
|
||||
* bundle always mounts its executor.
|
||||
* workspace-context loader, `tasks` to the process-local task provider, and
|
||||
* `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns.
|
||||
* Provider adapters own their `retryPolicy`; this bundle always mounts its
|
||||
* executor.
|
||||
* `goals` opts into and configures the persisted goal domain plus its model tool
|
||||
* and same-session driver; `invariants` configures global and package-filtered
|
||||
* relational checks. Owner schemas supply defaults for optional input;
|
||||
@@ -114,6 +115,8 @@ export interface Config {
|
||||
skills?: SkillConfig
|
||||
/** Model-facing bash tool config, or false when another plugin owns `bash`. */
|
||||
toolBash?: toolBash.Config | false
|
||||
/** Process-local background-task admission config. */
|
||||
tasks?: TasksConfig
|
||||
/** Generic background-task controls; set false to keep the task service without model-facing task tools. */
|
||||
toolTasks?: toolTasks.Config | false
|
||||
/** Global enablement and package-name filters for invariant companions. */
|
||||
@@ -138,6 +141,9 @@ export const SessionTitleConfigSchema: z<SessionTitleConfig> = SessionTitleServi
|
||||
export const ToolBashConfigSchema: z<toolBash.Config | false> =
|
||||
z.union([z.const(false), toolBash.Config])
|
||||
|
||||
/** The process-local task registry schema exported for app packages that forward `tasks`. */
|
||||
export const TasksConfigSchema: z<TasksConfig> = LocalTaskService.Config
|
||||
|
||||
/** The task-control-tool config schema exported for app packages that forward `toolTasks`. */
|
||||
export const ToolTasksConfigSchema: z<toolTasks.Config> = toolTasks.Config
|
||||
|
||||
@@ -158,10 +164,11 @@ export const Config = z.intersect([
|
||||
skills: SkillConfigSchema,
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
|
||||
toolBash: ToolBashConfigSchema,
|
||||
tasks: TasksConfigSchema,
|
||||
toolTasks: z.union([z.const(false), ToolTasksConfigSchema]),
|
||||
invariants: InvariantService.Config,
|
||||
goals: z.union([z.const(false), GoalConfigSchema]),
|
||||
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'sessionTitle' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks' | 'invariants' | 'goals'>>,
|
||||
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'sessionTitle' | 'skills' | 'workspaceContext' | 'toolBash' | 'tasks' | 'toolTasks' | 'invariants' | 'goals'>>,
|
||||
]) as unknown as z<Config>
|
||||
|
||||
/**
|
||||
@@ -181,6 +188,7 @@ export function pickSpineConfig(config: Omit<Config, 'agents'>): Omit<Config, 'a
|
||||
workspaceContext: config.workspaceContext,
|
||||
...config.skills !== undefined ? { skills: config.skills } : {},
|
||||
...config.toolBash !== undefined ? { toolBash: config.toolBash } : {},
|
||||
...config.tasks !== undefined ? { tasks: config.tasks } : {},
|
||||
...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {},
|
||||
...config.invariants !== undefined ? { invariants: config.invariants } : {},
|
||||
...config.goals !== undefined ? { goals: config.goals } : {},
|
||||
@@ -228,7 +236,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(toolGoal, config.goals.tool ?? {})
|
||||
ctx.plugin(goalSession)
|
||||
}
|
||||
ctx.plugin(LocalTaskService)
|
||||
ctx.plugin(LocalTaskService, config.tasks ?? {})
|
||||
ctx.plugin(InvariantService, config.invariants ?? {})
|
||||
ctx.plugin(sessionInvariant)
|
||||
ctx.plugin(agentInvariant)
|
||||
|
||||
@@ -300,6 +300,28 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards task admission config to the process-local provider', async () => {
|
||||
const ctx = await mount({
|
||||
tasks: { maxConcurrentTasksPerOwner: 1 },
|
||||
workspaceContext: false,
|
||||
})
|
||||
let settle!: (outcome: { status: 'killed' }) => void
|
||||
ctx.tasks.start({
|
||||
kind: 'probe',
|
||||
label: 'hold configured slot',
|
||||
run: () => ({
|
||||
cancel: () => { settle({ status: 'killed' }) },
|
||||
done: new Promise((resolve) => { settle = resolve }),
|
||||
}),
|
||||
})
|
||||
expect(() => ctx.tasks.start({
|
||||
kind: 'probe',
|
||||
label: 'blocked configured task',
|
||||
run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }),
|
||||
})).toThrow('(limit: 1)')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('tolerates a schema-bypassing direct apply (the ?? fallbacks fire)', async () => {
|
||||
// ctx.plugin validates + defaults the bundle config first; a direct apply
|
||||
// skips the schema, so the forwarding `?? []` / `?? ''` are what fire.
|
||||
@@ -716,6 +738,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
workspaceContext: false as const,
|
||||
skills: { enabled: false },
|
||||
toolBash: { enableRunInBackground: false },
|
||||
tasks: { maxConcurrentTasksPerOwner: 4 },
|
||||
toolTasks: false as const,
|
||||
invariants: { enabled: false },
|
||||
}
|
||||
@@ -730,6 +753,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
workspaceContext: false,
|
||||
skills: appConfig.skills,
|
||||
toolBash: appConfig.toolBash,
|
||||
tasks: appConfig.tasks,
|
||||
toolTasks: appConfig.toolTasks,
|
||||
invariants: appConfig.invariants,
|
||||
})
|
||||
|
||||
@@ -1150,7 +1150,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
methods: [
|
||||
{
|
||||
signature: 'abstract start(spec: TaskStart): TaskId',
|
||||
jsDoc: '/**\n * Preflight access, validation, and owner cleanup before starting and\n * atomically registering work. A throwing starter leaves nothing registered;\n * after it returns, registration cannot fail. Settlement records the outcome,\n * notifies listeners, and releases waiters.\n * @param spec - task identity, owner, and synchronous starter.\n * @returns the registry-issued `<kind>-N` id.\n */',
|
||||
jsDoc: '/**\n * Preflight access, validation, owner cleanup, and implementation-owned\n * admission before starting and atomically registering work. Any preflight\n * rejection leaves no task id or execution resource. A throwing starter\n * leaves nothing registered; after it returns, registration cannot fail.\n * Settlement records the outcome, notifies listeners, and releases waiters.\n * @param spec - task identity, owner, and synchronous starter.\n * @returns the registry-issued `<kind>-N` id.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract list(caller?: Agent): TaskSnapshot[]',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/tasks/tasks-local/README.md
|
||||
README.md: cc2e8422c367eeacfc5fc504298ecd6bfeae4c67
|
||||
README.zh.md: 81fc0a5b1e12b2b15705370bb0748b1733b312aa
|
||||
README.md: f558676b36bb5462453bde553eac27b458e1268e
|
||||
README.zh.md: ed8f2b1220692a00c09b6605b18231f5263f00e5
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Process-local implementation of the [`@deepseek-ai/dsh-tasks`](../tasks/README.md) registry contract: `LocalTaskService` keeps every record in memory, issues per-kind `<kind>-N` ids, and hands out fresh snapshots, never live state. It has no config; load it as a plugin and it registers as `ctx.tasks`.
|
||||
Process-local implementation of the [`@deepseek-ai/dsh-tasks`](../tasks/README.md) registry contract: `LocalTaskService` keeps every record in memory, issues per-kind `<kind>-N` ids, and hands out fresh snapshots, never live state. Load it as a plugin and it registers as `ctx.tasks`.
|
||||
|
||||
## Admission
|
||||
|
||||
`maxConcurrentTasksPerOwner` is a positive safe integer and defaults to `10`. Before invoking a producer, `start()` counts the exact owner's `running` and `stopping` records; all unowned tasks share one separate service bucket. Terminal history does not occupy capacity, and only producer `done` settlement releases a stopping task's place.
|
||||
|
||||
At capacity, `start()` fails before producer execution and id allocation with an error that names the limit and tells the model to use `task_kill`, wait for the task to finish stopping, and retry. The registry does not queue, preempt, or maintain a second mutable counter.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
@@ -25,4 +31,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Tasks are process-local** — records die with the harness process; durable or cross-restart execution needs a separate backend implementing the seam.
|
||||
- **A silently ineffective cancel can stall teardown** — only an explicit throw can be force-failed safely.
|
||||
- **A silently ineffective cancel can stall teardown and hold capacity** — if `cancel` returns without settling `done`, the registry cannot distinguish it from a slow stop; the task keeps one bucket slot for the rest of the service lifetime, and only an explicit throw can be force-failed safely.
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
[`@deepseek-ai/dsh-tasks`](../tasks/README.md) 注册表约定的进程本地实现:`LocalTaskService` 把每条记录保存在内存中,按 kind 签发 `<kind>-N` id,并且只交出全新快照,从不交出实时状态。它没有配置;作为插件加载后即注册为 `ctx.tasks`。
|
||||
[`@deepseek-ai/dsh-tasks`](../tasks/README.md) 注册表约定的进程本地实现:`LocalTaskService` 把每条记录保存在内存中,按 kind 签发 `<kind>-N` id,并且只交出全新快照,从不交出实时状态。作为插件加载后即注册为 `ctx.tasks`。
|
||||
|
||||
## 准入
|
||||
|
||||
`maxConcurrentTasksPerOwner` 必须是正的安全整数,默认值为 `10`。调用生产方之前,`start()` 会统计确切 owner 的 `running` 与 `stopping` 记录;所有无 owner 任务共享另一个独立的服务级桶。终止历史不占用容量,处于 `stopping` 的任务只有在生产方 `done` 结算后才释放名额。
|
||||
|
||||
达到容量时,`start()` 会在生产方执行和 id 分配前失败;错误会给出上限,并告诉模型使用 `task_kill`、等待任务完全停稳后再重试。注册表不会排队或抢占任务,也不会维护第二份可变计数。
|
||||
|
||||
## 生命周期
|
||||
|
||||
@@ -25,4 +31,4 @@
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **任务只存在于进程本地**:记录会随 harness 进程终止而消失;持久或跨重启执行需要一个单独实现该 seam 的后端。
|
||||
- **静默无效的取消可能使销毁过程停滞**:只有显式抛出异常才能安全地强制标为失败。
|
||||
- **静默无效的取消可能使销毁过程停滞并持续占用容量**:如果 `cancel` 返回后始终未结算 `done`,注册表就无法将其与缓慢停止区分开;该任务会在服务剩余生命周期内持续占用一个桶名额,只有显式抛出异常才能安全地强制标为失败。
|
||||
|
||||
@@ -39,7 +39,12 @@
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { AnonymousEntries, ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeLayer } from '@deepseek-ai/dsh-scope'
|
||||
@@ -23,6 +24,18 @@ import type {
|
||||
/** Timeout code that distinguishes a bounded wait from caller cancellation. */
|
||||
export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT'
|
||||
|
||||
/** Default maximum number of active tasks in one exact-owner bucket. */
|
||||
const DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER = 10
|
||||
|
||||
/** Configuration for the process-local task registry. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Maximum `running` plus `stopping` tasks per exact owner or in the shared unowned bucket;
|
||||
* omission defaults to 10.
|
||||
*/
|
||||
maxConcurrentTasksPerOwner?: number
|
||||
}
|
||||
|
||||
/** The registry's mutable per-task record (never handed out — see {@link LocalTaskService.snapshot}). */
|
||||
interface TrackedTask {
|
||||
id: TaskId
|
||||
@@ -76,6 +89,16 @@ class TaskLayer implements ScopeLayer {
|
||||
* semantics this implementation honors.
|
||||
*/
|
||||
export class LocalTaskService extends TaskService {
|
||||
static Config: z<Config> = z.object({
|
||||
maxConcurrentTasksPerOwner: z.number()
|
||||
.step(1)
|
||||
.min(1)
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.default(DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER),
|
||||
})
|
||||
|
||||
/** Schemastery-defaulted active-task limit. */
|
||||
private readonly maxConcurrentTasksPerOwner: number
|
||||
private store = new Map<TaskId, TrackedTask>()
|
||||
private counters = new Map<string, number>()
|
||||
/**
|
||||
@@ -97,8 +120,10 @@ export class LocalTaskService extends TaskService {
|
||||
/** Service context used by detached settlement continuations and teardown. */
|
||||
private readonly selfCtx: Context
|
||||
|
||||
constructor(ctx: Context) {
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
// Schemastery validates and fills the default before constructing the service.
|
||||
this.maxConcurrentTasksPerOwner = (config as Required<Config>).maxConcurrentTasksPerOwner
|
||||
this.selfCtx = ctx
|
||||
ctx.effect(() => () => this.disposeAll(), 'tasks teardown')
|
||||
}
|
||||
@@ -115,6 +140,13 @@ export class LocalTaskService extends TaskService {
|
||||
}
|
||||
if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner)
|
||||
|
||||
const active = this.activeTaskCount(spec.owner)
|
||||
if (active >= this.maxConcurrentTasksPerOwner) {
|
||||
throw new Error(
|
||||
`background task limit reached for this owner (limit: ${this.maxConcurrentTasksPerOwner}); use task_kill to stop an unneeded task, wait for it to finish, then retry`,
|
||||
)
|
||||
}
|
||||
|
||||
const hooks = spec.run()
|
||||
const count = (this.counters.get(spec.kind) ?? 0) + 1
|
||||
this.counters.set(spec.kind, count)
|
||||
@@ -286,6 +318,15 @@ export class LocalTaskService extends TaskService {
|
||||
.some(layer => !layer.controllers.isEmpty())
|
||||
}
|
||||
|
||||
/** Count authoritative active records for one exact owner or the shared unowned bucket. */
|
||||
private activeTaskCount(owner: Agent | undefined): number {
|
||||
let count = 0
|
||||
for (const task of this.store.values()) {
|
||||
if (task.owner === owner && (task.status === 'running' || task.status === 'stopping')) count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* The completion listeners that own `owner`'s notices: the global layer's
|
||||
* first, then each scoped layer along the owner's chain. A listener outside
|
||||
|
||||
@@ -15,8 +15,11 @@ export const name = 'tasks-local-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the Service Definition companion in `@deepseek-ai/dsh-tasks` already
|
||||
* validates every registry snapshot this implementation publishes.
|
||||
* No runtime invariant: `@deepseek-ai/dsh-tasks/invariant` owns per-snapshot identity, status,
|
||||
* timestamp, and owner checks. This provider's admission decision uses private configuration and
|
||||
* must fail before a backend starter runs; `LocalTaskService.start()` enforces it synchronously
|
||||
* for current producers. Repeating an aggregate after publication would expose private
|
||||
* configuration solely to this companion and would not verify the fail-closed pre-start guarantee.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
66
packages/tasks/tasks-local/tests/loader-composition.spec.ts
Normal file
66
packages/tasks/tasks-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 LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
|
||||
let root: string | undefined
|
||||
let context: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await context?.fiber.dispose()
|
||||
context = undefined
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
})
|
||||
|
||||
describe('tasks-local through a real Loader composition', () => {
|
||||
it('applies the provider-owned admission config from a Cordis row', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-tasks-local-loader-'))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
"- name: '@deepseek-ai/dsh-tasks-local'",
|
||||
' config:',
|
||||
' maxConcurrentTasksPerOwner: 1',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
context = new Context()
|
||||
context.baseUrl = pathToFileURL(root).href + '/'
|
||||
await context.plugin(Loader)
|
||||
context.loader.builtins.include = Include
|
||||
context.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (specifier === '@deepseek-ai/dsh-tasks-local') return LocalTaskService
|
||||
throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
},
|
||||
} as unknown as NonNullable<typeof context.loader.internal>
|
||||
await context.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(configPath).href },
|
||||
})
|
||||
await context.loader.await()
|
||||
|
||||
expect(context.tasks).toBeInstanceOf(LocalTaskService)
|
||||
context.tasks.attachController('loader-test')
|
||||
let settle!: (outcome: { status: 'killed' }) => void
|
||||
context.tasks.start({
|
||||
kind: 'bash',
|
||||
label: 'hold loader slot',
|
||||
run: () => ({
|
||||
cancel: () => { settle({ status: 'killed' }) },
|
||||
done: new Promise((resolve) => { settle = resolve }),
|
||||
}),
|
||||
})
|
||||
expect(() => context!.tasks.start({
|
||||
kind: 'bash',
|
||||
label: 'blocked loader task',
|
||||
run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }),
|
||||
})).toThrow('(limit: 1)')
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import { TaskId } from '@deepseek-ai/dsh-tasks'
|
||||
import type { TaskHooks, TaskKind, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import LocalTaskService, { type Config as TasksConfig } from '@deepseek-ai/dsh-tasks-local'
|
||||
|
||||
declare module '@deepseek-ai/dsh-tasks' {
|
||||
interface TaskKindMap {
|
||||
@@ -76,10 +76,10 @@ function producer(overrides: Partial<Omit<TaskStart, 'run'> & TaskHooks> = {}) {
|
||||
return { spec, settle, reject, cancels }
|
||||
}
|
||||
|
||||
async function harness() {
|
||||
async function harness(config: TasksConfig = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(LocalTaskService, config)
|
||||
ctx.tasks.attachController('test-controller')
|
||||
return ctx
|
||||
}
|
||||
@@ -166,6 +166,101 @@ describe('LocalTaskService.start', () => {
|
||||
expect(() => ctx.tasks.start(producer({ outputLimitBytes: 0 }).spec)).toThrow('outputLimitBytes')
|
||||
})
|
||||
|
||||
it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])(
|
||||
'rejects invalid maxConcurrentTasksPerOwner config: %s',
|
||||
async (maxConcurrentTasksPerOwner) => {
|
||||
const ctx = new Context()
|
||||
await expect(ctx.plugin(LocalTaskService, { maxConcurrentTasksPerOwner }))
|
||||
.rejects.toThrow()
|
||||
},
|
||||
)
|
||||
|
||||
it('accepts the largest safe integer limit', async () => {
|
||||
const ctx = await harness({ maxConcurrentTasksPerOwner: Number.MAX_SAFE_INTEGER })
|
||||
expect(ctx.tasks).toBeInstanceOf(LocalTaskService)
|
||||
})
|
||||
|
||||
it('defaults each owner bucket to ten active tasks', async () => {
|
||||
const ctx = await harness()
|
||||
const live = Array.from({ length: 10 }, () => producer())
|
||||
for (const task of live) ctx.tasks.start(task.spec)
|
||||
|
||||
const blocked = producer()
|
||||
const run = vi.fn(() => blocked.spec.run())
|
||||
expect(() => ctx.tasks.start({ ...blocked.spec, run }))
|
||||
.toThrow('background task limit reached for this owner (limit: 10)')
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
for (const task of live) task.settle({ status: 'completed' })
|
||||
})
|
||||
|
||||
it('rejects before producer start and id allocation, then admits immediately after settlement', async () => {
|
||||
const ctx = await harness({ maxConcurrentTasksPerOwner: 1 })
|
||||
const first = producer()
|
||||
expect(ctx.tasks.start(first.spec)).toBe('bash-1')
|
||||
|
||||
const blocked = producer()
|
||||
const run = vi.fn(() => blocked.spec.run())
|
||||
expect(() => ctx.tasks.start({ ...blocked.spec, run }))
|
||||
.toThrow('use task_kill to stop an unneeded task, wait for it to finish, then retry')
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
|
||||
first.settle({ status: 'completed' })
|
||||
await tick()
|
||||
expect(ctx.tasks.start(blocked.spec)).toBe('bash-2')
|
||||
})
|
||||
|
||||
it('keeps a stopping task in the bucket until producer settlement', async () => {
|
||||
const ctx = await harness({ maxConcurrentTasksPerOwner: 1 })
|
||||
const first = producer()
|
||||
const id = ctx.tasks.start(first.spec)
|
||||
expect(ctx.tasks.kill(id)).toBe('requested')
|
||||
|
||||
const replacement = producer()
|
||||
expect(() => ctx.tasks.start(replacement.spec)).toThrow('(limit: 1)')
|
||||
|
||||
first.settle({ status: 'killed' })
|
||||
await tick()
|
||||
expect(ctx.tasks.start(replacement.spec)).toBe('bash-2')
|
||||
})
|
||||
|
||||
it.each(['completed', 'killed', 'failed'] as const)(
|
||||
'releases the bucket after a %s terminal outcome',
|
||||
async (status) => {
|
||||
const ctx = await harness({ maxConcurrentTasksPerOwner: 1 })
|
||||
const first = producer()
|
||||
ctx.tasks.start(first.spec)
|
||||
first.settle({ status })
|
||||
await tick()
|
||||
expect(() => ctx.tasks.start(producer().spec)).not.toThrow()
|
||||
},
|
||||
)
|
||||
|
||||
it('isolates exact owners, replacement objects with the same session id, and the unowned bucket', async () => {
|
||||
const ctx = await harness({ maxConcurrentTasksPerOwner: 1 })
|
||||
const oldOwner = stubAgent(ctx, 'shared-session')
|
||||
const detachOld = ctx.agents.register(oldOwner)
|
||||
const oldTask = producer({ owner: oldOwner })
|
||||
ctx.tasks.start(oldTask.spec)
|
||||
|
||||
const otherOwner = stubAgent(ctx, 'other-session')
|
||||
ctx.agents.register(otherOwner)
|
||||
expect(() => ctx.tasks.start(producer({ owner: otherOwner }).spec)).not.toThrow()
|
||||
|
||||
detachOld()
|
||||
const replacement = stubAgent(ctx, 'shared-session')
|
||||
ctx.agents.register(replacement)
|
||||
expect(() => ctx.tasks.start(producer({ owner: replacement }).spec)).not.toThrow()
|
||||
|
||||
ctx.tasks.start(producer().spec)
|
||||
expect(() => ctx.tasks.start(producer().spec)).toThrow('(limit: 1)')
|
||||
expect(() => ctx.tasks.start(producer({ owner: oldOwner }).spec))
|
||||
.toThrow('is not the registered agent instance')
|
||||
|
||||
oldTask.settle({ status: 'completed' })
|
||||
await tick()
|
||||
await disposeAgentScope(oldOwner)
|
||||
})
|
||||
|
||||
it('issues kind-prefixed ids from per-kind counters', async () => {
|
||||
const ctx = await harness()
|
||||
expect(ctx.tasks.start(producer().spec)).toBe('bash-1')
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/tasks/tasks/README.md
|
||||
README.md: 18029a2e93396336139612ba72804aeb11e87edf
|
||||
README.zh.md: 93efabb212274cdb54114167ff38f0a884b987f5
|
||||
README.md: 60898e8de8ffa29a823c537ba5f5876b63a03c88
|
||||
README.zh.md: b726ca85374de68514473130bb3732c45f352cf7
|
||||
|
||||
@@ -6,7 +6,7 @@ The background task registry contract (`ctx.tasks`). The abstract `TaskService`
|
||||
|
||||
## Service contract
|
||||
|
||||
- `start(spec): TaskId` validates the attached controller, spec, exact live owner, and optional positive `outputLimitBytes` before calling the producer's `run()` once. A starter throw leaves nothing registered; successful return commits without another failable step.
|
||||
- `start(spec): TaskId` validates the attached controller, spec, exact live owner, optional positive `outputLimitBytes`, and any provider-owned admission policy before calling the producer's `run()` once. A preflight rejection or starter throw leaves no task id or registered work; successful return commits without another failable step.
|
||||
- `get(id, caller?)` and `list(caller?)` return non-consuming snapshots. Listing includes only caller-owned and unowned tasks.
|
||||
- `read(id, caller?)` consumes the single cursor for stream tasks and reads terminal output idempotently for final-output tasks.
|
||||
- `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the task running; success changes it to `stopping` and marks terminal delivery reported.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 服务约定
|
||||
|
||||
- `start(spec): TaskId` 验证已附加的任务控制器、spec、确切且仍存活的 owner,以及可选的 `outputLimitBytes`(如提供则须为正数),然后只调用生产方的 `run()` 一次。启动方抛出异常时不注册任何内容;成功返回会直接提交,不再执行其他可能失败的步骤。
|
||||
- `start(spec): TaskId` 验证已附加的任务控制器、spec、确切且仍存活的 owner、可选的正数 `outputLimitBytes`,以及 Service provider 所拥有的准入策略,然后只调用生产方的 `run()` 一次。预检拒绝或启动方抛出异常时都不会生成 task id 或注册工作;成功返回会直接提交,不再执行其他可能失败的步骤。
|
||||
- `get(id, caller?)` 和 `list(caller?)` 返回非消费式快照。列表只包含调用方拥有及无 owner 的任务。
|
||||
- `read(id, caller?)` 消费流任务的唯一游标;对于最终输出任务,则以幂等方式读取终止输出。
|
||||
- `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。
|
||||
|
||||
@@ -71,10 +71,11 @@ export abstract class TaskService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Preflight access, validation, and owner cleanup before starting and
|
||||
* atomically registering work. A throwing starter leaves nothing registered;
|
||||
* after it returns, registration cannot fail. Settlement records the outcome,
|
||||
* notifies listeners, and releases waiters.
|
||||
* Preflight access, validation, owner cleanup, and implementation-owned
|
||||
* admission before starting and atomically registering work. Any preflight
|
||||
* rejection leaves no task id or execution resource. A throwing starter
|
||||
* leaves nothing registered; after it returns, registration cannot fail.
|
||||
* Settlement records the outcome, notifies listeners, and releases waiters.
|
||||
* @param spec - task identity, owner, and synchronous starter.
|
||||
* @returns the registry-issued `<kind>-N` id.
|
||||
*/
|
||||
|
||||
@@ -759,6 +759,7 @@ describe('completion notices', () => {
|
||||
const prior = producer({ kind: 'pty-send' })
|
||||
ctx.tasks.start(prior.spec)
|
||||
prior.settle({ status: 'completed' })
|
||||
await tick()
|
||||
}
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
|
||||
Reference in New Issue
Block a user