Merge remote-tracking branch 'origin/master' into worktree/web-background-tasks-display-258f7e
# Conflicts: # docs/subsystems/tasks.i18n.yaml # docs/subsystems/tasks.md # docs/subsystems/tasks.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/tasks/tasks-local/src/index.ts # packages/tasks/tasks/README.i18n.yaml # packages/tasks/tasks/README.md # packages/tasks/tasks/README.zh.md # packages/tasks/tasks/src/index.ts
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/tasks/tasks-local/README.md
|
||||
README.md: 663ce0d333c6df0f84900a2570d5487d8d7abe55
|
||||
README.zh.md: a5d1acaa50f63dc95b7607657b157272c15a4f11
|
||||
README.md: 80d7932466188955ade1c14e4968d51b739ba818
|
||||
README.zh.md: 5e4263e4685be64f91ec2a7c74edbf89e1148866
|
||||
|
||||
@@ -12,6 +12,8 @@ Service disposal closes listeners, cancels all live tasks, awaits their records,
|
||||
|
||||
Settlement is first-wins: the earliest terminal outcome — producer settlement, a rejected `done` contained as `failed`, or a teardown force-failure — records once, notifies listeners once with per-listener containment, and releases waiters. Pending waits mark the task reported before listeners run so completion surfaces do not duplicate notices.
|
||||
|
||||
Surfaces 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 tasks unavailable: no control surface serves this agent (load @deepseek-ai/dsh-tool-tasks 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-tasks`](../tool-tasks/README.md), which render task ids, output, status, cancellation, and completion notices.
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
结算遵循首次结算优先原则:最早出现的终止结果(生产方结算、作为 `failed` 隔离处理的 `done` 拒绝,或销毁时的强制失败)只记录一次,也只通知监听器一次;各监听器的故障会单独隔离,随后释放等待方。挂起的等待会在监听器运行前把任务标记为已报告,因此呈现完成情况的表层不会重复发出通知。
|
||||
|
||||
表层与监听器按注册方所在的 scope 分层,形状与 tools 注册表一致:一次注册归档到其注册上下文的 scope,一次读取则把全局层与所有者的 scope 链求并集。因此一个进程级注册表能逐所有者地回答逐所有者的问题——对自身组合未附加任何表层的所有者,无论其他组合附加了多少,`start()` 都会拒绝并抛出 `background tasks unavailable: no control surface serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)`;一次结算也只会抵达其所有者所属组合注册的监听器。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过生产方插件和 [`dsh-tool-tasks`](../tool-tasks/README.md) 间接影响;它们会呈现任务 id、输出、状态、取消和完成通知。
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tasks-local",
|
||||
"description": "Process-local implementation of the DeepSeek Harness background task registry seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/tasks/tasks-local"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -25,19 +32,21 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
* @module @deepseek-ai/dsh-tasks-local
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
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 { TaskService, TaskId } from '@deepseek-ai/dsh-tasks'
|
||||
import type {
|
||||
@@ -52,6 +54,22 @@ function isTerminal(status: TaskStatus): boolean {
|
||||
return status === 'completed' || status === 'killed' || status === 'failed'
|
||||
}
|
||||
|
||||
/**
|
||||
* One scope's contributions: the control surfaces 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 TaskLayer implements ScopeLayer {
|
||||
readonly surfaces = new AnonymousEntries<symbol>()
|
||||
readonly listeners = new AnonymousEntries<TaskDoneListener>()
|
||||
readonly changed = new AnonymousEntries<TasksChangedListener>()
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.surfaces.isEmpty() && this.listeners.isEmpty() && this.changed.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-memory `tasks` registry. See the Service Definition contract in
|
||||
* `@deepseek-ai/dsh-tasks` for the ownership, isolation, and lifecycle
|
||||
@@ -60,9 +78,19 @@ function isTerminal(status: TaskStatus): boolean {
|
||||
export class LocalTaskService extends TaskService {
|
||||
private store = new Map<TaskId, TrackedTask>()
|
||||
private counters = new Map<string, number>()
|
||||
private surfaces = new Set<symbol>()
|
||||
private listeners = new Set<TaskDoneListener>()
|
||||
private changeListeners = new Set<TasksChangedListener>()
|
||||
/**
|
||||
* 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
|
||||
* task 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<TaskLayer>(() => new TaskLayer(), () => {})
|
||||
private listenersClosed = false
|
||||
/** Owner agents with attached scope cleanup, mapped to the exact disposer. */
|
||||
private ownerCleanups = new Map<Agent, () => Promise<void> | void>()
|
||||
@@ -76,8 +104,8 @@ export class LocalTaskService extends TaskService {
|
||||
}
|
||||
|
||||
start(spec: TaskStart): TaskId {
|
||||
if (this.surfaces.size === 0) {
|
||||
throw new Error('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)')
|
||||
if (!this.servesOwner(spec.owner)) {
|
||||
throw new Error('background tasks unavailable: no control surface serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)')
|
||||
}
|
||||
if (spec.kind.length === 0) throw new Error('invalid task kind: expected a non-empty string')
|
||||
if (spec.label.length === 0) throw new Error('invalid task label: expected a non-empty string')
|
||||
@@ -118,8 +146,8 @@ export class LocalTaskService extends TaskService {
|
||||
void hooks.done.then(
|
||||
(outcome) => { this.settle(task, outcome) },
|
||||
(error: unknown) => {
|
||||
// Contain a producer contract violation so cleanup and waiters cannot hang.
|
||||
this.selfCtx.logger.warn(`tasks: task ${task.id} 'done' rejected (producer contract violation): ${String(error)}`)
|
||||
// Contain a producer contract violation (`done` rejected) so cleanup and waiters cannot hang.
|
||||
this.selfCtx.logger.warn(`tasks: task ${task.id} producer done promise rejected (producer contract violation): ${String(error)}`)
|
||||
this.settle(task, { status: 'failed', detail: String(error) })
|
||||
},
|
||||
)
|
||||
@@ -218,29 +246,57 @@ export class LocalTaskService extends TaskService {
|
||||
}
|
||||
|
||||
onTaskDone(listener: TaskDoneListener): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}, 'tasks.onTaskDone()')
|
||||
return () => void dispose()
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.listeners.append(listener),
|
||||
{ label: 'tasks.onTaskDone()' },
|
||||
)
|
||||
}
|
||||
|
||||
onTasksChanged(listener: TasksChangedListener): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
this.changeListeners.add(listener)
|
||||
return () => this.changeListeners.delete(listener)
|
||||
}, 'tasks.onTasksChanged()')
|
||||
return () => void dispose()
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.changed.append(listener),
|
||||
{ label: 'tasks.onTasksChanged()' },
|
||||
)
|
||||
}
|
||||
|
||||
attachSurface(name: string): () => void {
|
||||
// One token per call keeps duplicate labels independently disposable.
|
||||
const token = Symbol(name)
|
||||
const dispose = this.ctx.effect(() => {
|
||||
this.surfaces.add(token)
|
||||
return () => this.surfaces.delete(token)
|
||||
}, 'tasks.attachSurface()')
|
||||
return () => void dispose()
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.surfaces.append(token),
|
||||
{ label: 'tasks.attachSurface()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an attached control surface can collect and stop work owned by
|
||||
* `owner`. The global layer holds every surface attached from an unscoped
|
||||
* context — a host composition's own controls — and therefore serves every
|
||||
* owner; a scoped surface serves exactly the agents composed under it.
|
||||
* @param owner - the task's owner, or undefined for unowned work.
|
||||
* @returns whether some reachable surface serves the owner.
|
||||
*/
|
||||
private servesOwner(owner?: Agent): boolean {
|
||||
if (!this.layers.global.surfaces.isEmpty()) return true
|
||||
return this.layers.chainLayers(owner === undefined ? undefined : scopeOf(owner.ctx))
|
||||
.some(layer => !layer.surfaces.isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 task's owner, or undefined for unowned work.
|
||||
* @returns the listeners to notify, in registration order per layer.
|
||||
*/
|
||||
private *listenersFor(owner?: Agent): IterableIterator<TaskDoneListener> {
|
||||
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 task or fail loud. */
|
||||
@@ -278,12 +334,27 @@ export class LocalTaskService extends TaskService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TasksChangedListener> {
|
||||
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.changeListeners) {
|
||||
for (const listener of this.changedFor(owner)) {
|
||||
try {
|
||||
listener(owner)
|
||||
} catch (error: unknown) {
|
||||
@@ -306,7 +377,7 @@ export class LocalTaskService extends TaskService {
|
||||
if (task.waiters > 0) task.reported = true
|
||||
if (!this.listenersClosed) {
|
||||
const snapshot = this.snapshot(task)
|
||||
for (const listener of this.listeners) {
|
||||
for (const listener of this.listenersFor(task.owner)) {
|
||||
try {
|
||||
const returned = listener(snapshot, task.owner)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
@@ -364,20 +435,20 @@ export class LocalTaskService extends TaskService {
|
||||
* 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
|
||||
this.listeners.clear()
|
||||
const all = [...this.store.values()]
|
||||
this.cancelForTeardown(all, 'tasks service disposed')
|
||||
await Promise.all(all.map(task => task.settled))
|
||||
// Distinct owners whose records just disappeared. `onTasksChanged` binds to
|
||||
// the CALLING fiber (the traceable proxy rebinds `this.ctx`), so a consumer
|
||||
// mounted outside this service — the api-proxy carrier reads `ctx.get` from
|
||||
// the mux stream — is still listening here. Without this it keeps the rows
|
||||
// it last received after a registry reload.
|
||||
// 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(task => task.owner))
|
||||
this.store.clear()
|
||||
for (const owner of emptied) this.notifyChanged(owner)
|
||||
this.changeListeners.clear()
|
||||
// Detach cross-fiber owner effects after the shared store is quiescent.
|
||||
const ownerCleanups = [...this.ownerCleanups.values()]
|
||||
this.ownerCleanups.clear()
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tasks-local'
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
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'
|
||||
@@ -15,9 +17,18 @@ declare module '@deepseek-ai/dsh-tasks' {
|
||||
|
||||
const agentScopeDisposers = new WeakMap<Agent, () => Promise<void>>()
|
||||
|
||||
function stubAgent(ctx: Context, rawId: string): Agent {
|
||||
function stubAgent(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent {
|
||||
const id = SessionId(rawId)
|
||||
const scopeFiber = ctx.plugin(() => {})
|
||||
// `presetScope` reproduces what `agentPresets.compose` does: the agent gets
|
||||
// its own key parented to the standing mount's, so the registry's chain walk
|
||||
// reaches that preset's layer.
|
||||
let agentCtx = scopeFiber.ctx
|
||||
if (presetScope !== undefined) {
|
||||
const key = {}
|
||||
bindScopeParent(key, presetScope)
|
||||
agentCtx = createScope(scopeFiber.ctx, key).ctx
|
||||
}
|
||||
const session = Session.create(id)
|
||||
const agent = {
|
||||
id,
|
||||
@@ -25,7 +36,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
|
||||
session,
|
||||
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle' as const,
|
||||
ctx: scopeFiber.ctx,
|
||||
ctx: agentCtx,
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
@@ -73,6 +84,21 @@ async function harness() {
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a control surface the way `tool-tasks` does: from a plugin whose own
|
||||
* `inject` resolves `ctx.tasks`, so the service method binds to the REGISTERING
|
||||
* context and the surface files into that context's scope layer. Reading the
|
||||
* service off a bare scoped context instead throws `cannot get property "tasks"
|
||||
* without inject`, which is the same rule the shipped plugin obeys.
|
||||
* @param ctx - the context whose scope should own the surface.
|
||||
*/
|
||||
async function attachSurfaceIn(ctx: Context): Promise<void> {
|
||||
await ctx.plugin({
|
||||
inject: ['tasks'],
|
||||
apply(pluginCtx: Context) { pluginCtx.tasks.attachSurface('tool-tasks') },
|
||||
})
|
||||
}
|
||||
|
||||
/** Let the settlement continuation (a `done.then`) run. */
|
||||
const tick = () => new Promise<void>(r => setTimeout(r, 0))
|
||||
|
||||
@@ -89,11 +115,48 @@ describe('LocalTaskService.start', () => {
|
||||
expectTypeOf<TaskSnapshot['ownerSession']>().toEqualTypeOf<SessionId | undefined>()
|
||||
})
|
||||
|
||||
it('refuses to register while no control surface is attached', async () => {
|
||||
it('refuses to register while no control surface serves the owner', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalTaskService)
|
||||
expect(() => ctx.tasks.start(producer().spec))
|
||||
.toThrow('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)')
|
||||
.toThrow('background tasks unavailable: no control surface serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)')
|
||||
})
|
||||
|
||||
it('refuses an owner whose own composition attaches no surface', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
// Two standing preset mounts over one registry; only the first loads the
|
||||
// task controls. The second must not inherit the first's open gate.
|
||||
const withControls = createScope(ctx, {})
|
||||
const withoutControls = createScope(ctx, {})
|
||||
await attachSurfaceIn(withControls.ctx)
|
||||
|
||||
const served = stubAgent(ctx, 'served', scopeOf(withControls.ctx))
|
||||
const unserved = stubAgent(ctx, 'unserved', scopeOf(withoutControls.ctx))
|
||||
ctx.agents.register(served)
|
||||
ctx.agents.register(unserved)
|
||||
|
||||
expect(() => ctx.tasks.start(producer({ owner: served }).spec)).not.toThrow()
|
||||
expect(() => ctx.tasks.start(producer({ owner: unserved }).spec))
|
||||
.toThrow('no control surface serves this agent')
|
||||
// An unowned producer has no chain to walk, so only a global surface serves it.
|
||||
expect(() => ctx.tasks.start(producer().spec))
|
||||
.toThrow('no control surface serves this agent')
|
||||
})
|
||||
|
||||
it('lets a surface attached without a scope serve every owner', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
// The host-plane composition's own controls: no scope, so the global layer
|
||||
// holds them and every owner's read includes it.
|
||||
await attachSurfaceIn(ctx)
|
||||
const scoped = stubAgent(ctx, 'scoped', scopeOf(createScope(ctx, {}).ctx))
|
||||
ctx.agents.register(scoped)
|
||||
|
||||
expect(() => ctx.tasks.start(producer({ owner: scoped }).spec)).not.toThrow()
|
||||
expect(() => ctx.tasks.start(producer().spec)).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects an empty kind, empty label, and invalid output limit', async () => {
|
||||
@@ -201,7 +264,7 @@ describe('LocalTaskService reads and settlement', () => {
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('async listener boom'))
|
||||
})
|
||||
|
||||
it('contains a rejecting done as a failed outcome (producer contract violation)', async () => {
|
||||
it("contains rejection from the producer's done promise as a failed outcome (producer contract violation)", async () => {
|
||||
const ctx = await harness()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const p = producer()
|
||||
@@ -742,6 +805,30 @@ describe('LocalTaskService disposal', () => {
|
||||
expect(ownerEffects()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops a scoped layer when its registrations dispose', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
const standing = createScope(ctx, {})
|
||||
// One mount contributes both kinds into the same layer, as `tool-tasks`
|
||||
// does; unloading it must leave nothing serving the agents that joined it.
|
||||
const mount = await standing.ctx.plugin({
|
||||
inject: ['tasks'],
|
||||
apply(pluginCtx: Context) {
|
||||
pluginCtx.tasks.attachSurface('tool-tasks')
|
||||
pluginCtx.tasks.onTaskDone(() => {})
|
||||
},
|
||||
})
|
||||
const owner = stubAgent(ctx, 'joined', scopeOf(standing.ctx))
|
||||
ctx.agents.register(owner)
|
||||
expect(() => ctx.tasks.start(producer({ owner }).spec)).not.toThrow()
|
||||
|
||||
await mount.dispose()
|
||||
|
||||
expect(() => ctx.tasks.start(producer({ owner }).spec))
|
||||
.toThrow('no control surface serves this agent')
|
||||
})
|
||||
|
||||
it('detaching the last surface re-arms the register fence', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalTaskService)
|
||||
@@ -757,7 +844,7 @@ describe('LocalTaskService disposal', () => {
|
||||
detachA2()
|
||||
expect(() => ctx.tasks.start(producer().spec)).not.toThrow() // b remains
|
||||
await fiber.dispose() // detaches b with its fiber (HMR safety)
|
||||
expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface is attached')
|
||||
expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface serves this agent')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user