fix(tasks): harden lifecycle and bundle config
This commit is contained in:
@@ -41,11 +41,12 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
|
||||
|
||||
```ts
|
||||
import type { Config } from '@deepseek-ai/dsh-agent-core'
|
||||
// { agents?, persona?, toolOrder?, tools?, skills? } — the schema intersects the owner schemas,
|
||||
// { agents?, persona?, toolOrder?, tools?, skills?, toolBash?, toolTasks? }
|
||||
// The schema intersects the owner schemas,
|
||||
// so validation and defaulting can never drift from the owners.
|
||||
```
|
||||
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
|
||||
|
||||
## Why a code bundle, not a shared YAML include
|
||||
|
||||
|
||||
@@ -77,7 +77,10 @@ export interface SkillConfig {
|
||||
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
|
||||
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
|
||||
* plugin (the deployment's persona section and the explicit model-facing tool
|
||||
* order), the `tools` object to the tool registry (its presentation `mode`).
|
||||
* order), the `tools` object to the tool registry (its presentation `mode`),
|
||||
* and `toolBash`/`toolTasks` to the two model-facing tool plugins this bundle
|
||||
* owns. Producer opt-in stays producer-local: `toolBash` configures bash only;
|
||||
* future background-capable tools remain independently composed plugins.
|
||||
* Every field is optional INPUT here because each owner's schema
|
||||
* supplies the default (`[]` / `''` / absent — lexicographic / `native`); the
|
||||
* schema is the INTERSECTION of the owners' own schemas (the registry's
|
||||
@@ -95,6 +98,10 @@ export interface Config {
|
||||
tools?: ToolsConfig
|
||||
/** Skill registry, local provider, and model-facing consumer config. */
|
||||
skills?: SkillConfig
|
||||
/** Model-facing bash tool config, including this producer's background opt-in. */
|
||||
toolBash?: toolBash.Config
|
||||
/** Generic background-task control-tool wait bounds. */
|
||||
toolTasks?: toolTasks.Config
|
||||
}
|
||||
|
||||
/** The skill config schema exported for app packages that forward `skills`. */
|
||||
@@ -104,11 +111,22 @@ export const SkillConfigSchema: z<SkillConfig> = z.object({
|
||||
tool: toolSkill.Config,
|
||||
})
|
||||
|
||||
/** The bash-tool config schema exported for app packages that forward `toolBash`. */
|
||||
export const ToolBashConfigSchema: z<toolBash.Config> = toolBash.Config
|
||||
|
||||
/** The task-control-tool config schema exported for app packages that forward `toolTasks`. */
|
||||
export const ToolTasksConfigSchema: z<toolTasks.Config> = toolTasks.Config
|
||||
|
||||
/** Intersect the owners' schemas so validation + defaulting stay identical. */
|
||||
export const Config = z.intersect([
|
||||
AgentLoop.Config,
|
||||
SystemPrompt.Config,
|
||||
z.object({ tools: ToolRegistry.Config, skills: SkillConfigSchema }),
|
||||
z.object({
|
||||
tools: ToolRegistry.Config,
|
||||
skills: SkillConfigSchema,
|
||||
toolBash: ToolBashConfigSchema,
|
||||
toolTasks: ToolTasksConfigSchema,
|
||||
}),
|
||||
]) as unknown as z<Config>
|
||||
|
||||
/**
|
||||
@@ -140,8 +158,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(AgentRegistry)
|
||||
ctx.plugin(TaskService)
|
||||
ctx.plugin(invariants)
|
||||
ctx.plugin(toolBash)
|
||||
ctx.plugin(toolBash, config.toolBash ?? {})
|
||||
ctx.plugin(toolSkill, config.skills?.tool ?? {})
|
||||
ctx.plugin(toolTasks)
|
||||
ctx.plugin(toolTasks, config.toolTasks ?? {})
|
||||
ctx.plugin(AgentLoop, { agents: config.agents ?? [] })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -7,7 +7,7 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import * as agentCore from '../src/index.ts'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
|
||||
const empty: Message[] = []
|
||||
@@ -27,12 +27,13 @@ async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
|
||||
* Loader-path guard (export shape, `unwrapExports`) is the app packages' keyless
|
||||
* bin smokes; here we assert the composition + config forwarding.
|
||||
*/
|
||||
async function mount(config?: agentCore.Config): Promise<Context> {
|
||||
async function mount(config?: agentCore.Config, withBash = false): Promise<Context> {
|
||||
const oldDshHome = process.env.DSH_HOME
|
||||
const oldAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-home-'))
|
||||
process.env.DSH_AGENTS_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-agents-'))
|
||||
const ctx = new Context()
|
||||
if (withBash) ctx.provide('bash', { sandboxMode: undefined })
|
||||
try {
|
||||
await ctx.plugin(agentCore, config)
|
||||
// The bundle mounts its children inside apply() (not awaited there); let their
|
||||
@@ -153,6 +154,33 @@ describe('dsh-agent-core bundle', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards its bundled tool configs to tool-bash and tool-tasks', async () => {
|
||||
const ctx = await mount({
|
||||
toolBash: { enableRunInBackground: false },
|
||||
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
|
||||
}, true)
|
||||
|
||||
const bash = ctx.tools.schemas().find(tool => tool.name === 'bash')
|
||||
expect(bash).toBeDefined()
|
||||
expect(Object.keys((bash!.parameters as { properties: Record<string, unknown> }).properties))
|
||||
.not.toContain('run_in_background')
|
||||
|
||||
const id = ctx.tasks.start({
|
||||
kind: 'probe',
|
||||
label: 'config forwarding probe',
|
||||
run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }),
|
||||
})
|
||||
const wait = vi.spyOn(ctx.tasks, 'wait')
|
||||
await ctx.tools.execute({
|
||||
callId: CallId('task-config-forwarding'),
|
||||
name: 'task_output',
|
||||
arguments: { task_id: id, wait: true },
|
||||
})
|
||||
expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined)
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('uses the default skill config when apply is called directly without skills', async () => {
|
||||
await withIsolatedSkillHomes(async () => {
|
||||
const ctx = new Context()
|
||||
|
||||
@@ -9,7 +9,7 @@ The background task registry (`ctx.tasks`): a runtime-global, CONCRETE service (
|
||||
- `read(id, caller?): TaskRead` — stream kinds consume the per-task cursor (v1's single intended reader is the owning model — a non-consuming multi-reader surface would be a cursor/snapshot API extension, not a `read` change); final kinds read the terminal output idempotently.
|
||||
- `kill(id, caller?, reason?)` — `'requested'` (live task: producer `cancel` runs first — a throw fails the kill loud and leaves the task untouched — then `stopping`) or `'already-terminal'`. Every successful kill marks the task `reported` (the killer saw the end → completion notice suppressed).
|
||||
- `wait(id, timeoutMs, caller?, signal?)` — resolves with the terminal snapshot (marked `reported`), or the live snapshot at timeout; an aborted signal rejects the WAIT only — unless the task already settled, in which case the wait still resolves and delivers the terminal snapshot (settlement suppressed the completion notice on this waiter's behalf, so rejecting would leave the finish both unreported and un-noticed). Timing is a [`dsh-timeout`](../../util/timeout/README.md) `deadline()` scoped to the `TASK_WAIT_TIMEOUT` code, so a nested foreign deadline never misreads as a wait timeout.
|
||||
- `onTaskDone(listener)` — exactly once per task with the terminal snapshot; effect-scoped, per-listener containment, silent after service disposal.
|
||||
- `onTaskDone(listener)` — exactly once per terminal task record; effect-scoped, per-listener containment, silent after service disposal.
|
||||
- `attachSurface(name)` — declares a control surface exists (the model tools, or a deployment's custom surface); effect-scoped.
|
||||
|
||||
Every read/kill/wait/get compares the task's owner session (`owner.session.header.id`) with the caller's and rejects a foreign one — ids are predictable (`bash-1`), so the fence, not id secrecy, is the isolation boundary.
|
||||
@@ -17,8 +17,9 @@ Every read/kill/wait/get compares the task's owner session (`owner.session.heade
|
||||
## Lifecycle
|
||||
|
||||
- Registrations are NOT effect-scoped to the registering fiber: tasks belong to their owning agent + producing backend, so producer/surface HMR reloads never touch them.
|
||||
- An owned task attaches (once per owner) an awaited cleanup via `ctx.agents.onCleanup`: on the owner's disposal the registry cancels its live tasks, awaits each `done`, and drops the snapshots — `AgentHandle.dispose()` resolves only after quiescence.
|
||||
- Service disposal closes the listener registry first (late teardown kills stay silent), cancels every live task with containment, and awaits settlement.
|
||||
- An owned task attaches (once per owner) an awaited cleanup via `ctx.agents.onCleanup`: on owner disposal the registry cancels live tasks, awaits contract-compliant producers to quiescence, and drops their snapshots. If a teardown cancel throws, it force-fails the record and logs that the underlying work may be orphaned rather than deadlocking `AgentHandle.dispose()`.
|
||||
- Service disposal closes the listener registry first (late teardown settlements stay silent), then applies the same cancellation rule to every live task and awaits terminal records.
|
||||
- A producer whose `cancel` returns but never causes `done` to settle remains indistinguishable from a slow stop and can stall teardown; solving that residual requires an explicit bounded-lifetime or forced-disposal design.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
|
||||
@@ -23,8 +23,9 @@
|
||||
* belongs to its owning agent and producing backend, not to the tool plugin
|
||||
* whose call started it, so an HMR reload of a producer or of the control
|
||||
* surface never orphans or kills a running task. The registry's own disposal
|
||||
* cancels every live task and awaits settlement — no orphans survive
|
||||
* `fiber.dispose()`.
|
||||
* cancels every live task and awaits contract-compliant producers to
|
||||
* quiescence. If a teardown cancel throws, the registry force-fails its record
|
||||
* to avoid deadlock and logs that the underlying work may be orphaned.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tasks
|
||||
*/
|
||||
@@ -77,7 +78,7 @@ interface TrackedTask {
|
||||
reported: boolean
|
||||
/** Resolves once the terminal snapshot is recorded and listeners notified. */
|
||||
settled: Promise<void>
|
||||
/** Resolver for {@link settled} (called exactly once, by {@link TaskService.settle}). */
|
||||
/** Resolver for {@link settled} (called by the first effective {@link TaskService.settle}). */
|
||||
markSettled: () => void
|
||||
/** Live {@link TaskService.wait} calls — a settlement with waiters marks the task reported. */
|
||||
waiters: number
|
||||
@@ -98,8 +99,8 @@ export class TaskService extends Service {
|
||||
private surfaces = new Set<symbol>()
|
||||
private listeners = new Set<TaskDoneListener>()
|
||||
private listenersClosed = false
|
||||
/** Owner agents that already have this registry's cleanup attached. */
|
||||
private ownerCleanups = new Set<AgentId>()
|
||||
/** Owner agents whose cleanup effect is attached, mapped to its self-detacher. */
|
||||
private ownerCleanups = new Map<AgentId, () => void>()
|
||||
/**
|
||||
* The service's OWN construction-time context, for work that outlives the
|
||||
* calling fiber: detached settlement continuations (logging), and the
|
||||
@@ -338,8 +339,8 @@ export class TaskService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a completion listener, called exactly once per task with the
|
||||
* terminal snapshot. Effect-scoped (disposed with the calling fiber);
|
||||
* Register a completion listener, called exactly once per terminal task
|
||||
* record with its snapshot. Effect-scoped (disposed with the calling fiber);
|
||||
* per-listener containment (one throwing listener is logged, never starves
|
||||
* the rest); never fires after this service is disposed.
|
||||
* @param listener - called with each settling task's terminal snapshot.
|
||||
@@ -409,13 +410,17 @@ export class TaskService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a task's terminal outcome (called exactly once — the single `done`
|
||||
* continuation is the only caller), notify listeners with containment, then
|
||||
* release waiters. A settlement observed by a pending {@link wait} marks
|
||||
* the task reported BEFORE listeners run, so the notice surface can
|
||||
* Record the first terminal outcome, notify listeners with containment, then
|
||||
* release waiters. Normally the producer's single `done` continuation calls
|
||||
* this; teardown also force-fails the record when `cancel` throws and `done`
|
||||
* may never settle. First-wins makes a producer outcome arriving after that
|
||||
* fallback a no-op, so listeners fire once and the diagnosed terminal state
|
||||
* is never overwritten. A settlement observed by a pending {@link wait}
|
||||
* marks the task reported BEFORE listeners run, so the notice surface can
|
||||
* suppress its redundant "finished".
|
||||
*/
|
||||
private settle(task: TrackedTask, outcome: TaskOutcome): void {
|
||||
if (isTerminal(task.status)) return
|
||||
task.status = outcome.status
|
||||
task.detail = outcome.detail
|
||||
task.output = outcome.output
|
||||
@@ -439,27 +444,38 @@ export class TaskService extends Service {
|
||||
* the agent's disposal chain drains (`ctx.agents.drainCleanups`), the
|
||||
* owner's still-live tasks are cancelled, awaited to settlement, and their
|
||||
* snapshots dropped. Registered through {@link selfCtx} so the cleanup
|
||||
* survives producer-plugin reloads. Fails loud when no agent registry is
|
||||
* mounted — an owned background task without the cleanup seam would outlive
|
||||
* its owner silently.
|
||||
* survives producer-plugin reloads. When the cleanup starts, it detaches its
|
||||
* own effect before awaiting task settlement, so completed owners do not
|
||||
* accumulate effect wrappers (and captured sessions) on the long-lived tasks
|
||||
* fiber. A narrow race remains if new work starts on an agent already being
|
||||
* drained: before this callback clears the owner entry, that start can reuse
|
||||
* the in-flight cleanup after its task snapshot was taken.
|
||||
* Fails loud when no agent registry is mounted — an owned background task
|
||||
* without the cleanup seam would outlive its owner silently.
|
||||
*/
|
||||
private ensureOwnerCleanup(owner: Agent): void {
|
||||
if (this.ownerCleanups.has(owner.id)) return
|
||||
const ownerId = owner.id
|
||||
if (this.ownerCleanups.has(ownerId)) return
|
||||
const agents = this.selfCtx.get('agents')
|
||||
if (agents === undefined) {
|
||||
throw new Error('background task ownership requires the agent registry (load @deepseek-ai/dsh-agent)')
|
||||
}
|
||||
const ownerSession = owner.session.header.id
|
||||
// Attach FIRST, record after: onCleanup throws for an unregistered agent,
|
||||
// and marking the owner as covered before that would make every later
|
||||
// registration for the same owner silently skip the cleanup.
|
||||
agents.onCleanup(owner.id, async () => {
|
||||
this.ownerCleanups.delete(owner.id)
|
||||
await this.disposeOwned(owner.session.header.id)
|
||||
const detach = agents.onCleanup(ownerId, async () => {
|
||||
const disposeEffect = this.ownerCleanups.get(ownerId)
|
||||
this.ownerCleanups.delete(ownerId)
|
||||
// A drain racing lifecycle teardown may find that the effect was already
|
||||
// detached; otherwise this removes its wrapper from the tasks fiber now.
|
||||
disposeEffect?.()
|
||||
await this.disposeOwned(ownerSession)
|
||||
})
|
||||
this.ownerCleanups.add(owner.id)
|
||||
this.ownerCleanups.set(ownerId, detach)
|
||||
}
|
||||
|
||||
/** Cancel (contained), await, and drop every task owned by one session. */
|
||||
/** Cancel, await terminal records, and drop every task owned by one session. */
|
||||
private async disposeOwned(ownerSession: string): Promise<void> {
|
||||
const owned = [...this.store.values()].filter(task => task.ownerSession === ownerSession)
|
||||
this.cancelForTeardown(owned, 'owner disposed')
|
||||
@@ -469,8 +485,10 @@ export class TaskService extends Service {
|
||||
|
||||
/**
|
||||
* Service teardown: close the listener registry FIRST (late completions
|
||||
* from teardown kills stay silent), cancel every live task, and await
|
||||
* quiescence. No orphan child work survives the tasks fiber.
|
||||
* from teardown kills stay silent), cancel every live task, and await each
|
||||
* terminal record. Contract-compliant producers settle at quiescence; a
|
||||
* producer whose cancel throws is force-failed so disposal cannot deadlock,
|
||||
* with the possible underlying orphan logged explicitly.
|
||||
*/
|
||||
private async disposeAll(): Promise<void> {
|
||||
this.listenersClosed = true
|
||||
@@ -483,18 +501,25 @@ export class TaskService extends Service {
|
||||
|
||||
/**
|
||||
* Teardown-path cancellation with per-task containment: unlike the
|
||||
* model-facing {@link kill} (where a throwing producer `cancel` should fail
|
||||
* the tool call loudly), a teardown must reach quiescence past a broken
|
||||
* producer, so a throw is logged and the sweep continues.
|
||||
* model-facing {@link kill} (where a throwing producer `cancel` fails the tool
|
||||
* call and leaves the record live), teardown force-fails a record whose cancel
|
||||
* throws because its `done` may depend on a request that never arrived. This
|
||||
* prevents disposal deadlock but cannot prove the underlying work stopped, so
|
||||
* the potential orphan is carried in the detail and warning. A cancel that
|
||||
* returns but never leads to `done` remains indistinguishable from a slow stop
|
||||
* and can still stall teardown; fixing that requires a separate bounded-lifetime
|
||||
* or forced-disposal design.
|
||||
*/
|
||||
private cancelForTeardown(tasks: TrackedTask[], reason: string): void {
|
||||
for (const task of tasks) {
|
||||
if (isTerminal(task.status)) continue
|
||||
task.status = 'stopping'
|
||||
try {
|
||||
task.cancel(reason)
|
||||
task.status = 'stopping'
|
||||
} catch (error: unknown) {
|
||||
this.selfCtx.logger.warn(`tasks: cancel of ${task.id} threw during teardown: ${String(error)}`)
|
||||
const detail = `cancel threw during teardown; work may be orphaned: ${String(error)}`
|
||||
this.selfCtx.logger.warn(`tasks: cancel of ${task.id} threw during teardown; task record forced failed and work may be orphaned: ${String(error)}`)
|
||||
this.settle(task, { status: 'failed', detail })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,9 @@ export interface TaskHooks {
|
||||
* released the task's resources (process exited, child agent disposed) —
|
||||
* not merely when the work finished. Must never reject; a rejection is
|
||||
* contained as a `failed` outcome and logged as a producer contract
|
||||
* violation.
|
||||
* violation. If `cancel` throws during teardown, the runtime may force-fail
|
||||
* only its registry record to avoid deadlock because this promise may never
|
||||
* settle; that fallback explicitly does not claim work quiescence.
|
||||
*/
|
||||
done: Promise<TaskOutcome>
|
||||
/**
|
||||
|
||||
@@ -444,11 +444,46 @@ describe('TaskService owner cleanup', () => {
|
||||
expect(ctx.tasks.list(owner)).toEqual([])
|
||||
})
|
||||
|
||||
it('contains a throwing producer cancel on the cleanup path', async () => {
|
||||
it('releases the owner-cleanup effect from the tasks fiber after its drain', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const tasksFiber = await ctx.plugin(TaskService)
|
||||
ctx.tasks.attachSurface('test-surface')
|
||||
const owner = stubAgent('owner')
|
||||
ctx.agents.register(owner)
|
||||
const ownerCleanupEffects = () => tasksFiber.getEffects()
|
||||
.filter(effect => effect.label === 'agents.onCleanup()')
|
||||
|
||||
const first = producer({ owner })
|
||||
ctx.tasks.start(first.spec)
|
||||
expect(ownerCleanupEffects()).toHaveLength(1)
|
||||
first.settle({ status: 'completed' })
|
||||
await tick()
|
||||
await ctx.agents.drainCleanups(owner.id)
|
||||
|
||||
// Only the owner registration is released; the long-lived tasks service
|
||||
// and its own teardown effect remain active.
|
||||
expect(ownerCleanupEffects()).toHaveLength(0)
|
||||
expect(ctx.get('tasks')).toBeDefined()
|
||||
expect(tasksFiber.getEffects().some(effect => effect.label === 'tasks teardown')).toBe(true)
|
||||
|
||||
// The same still-live owner can attach and release a fresh registration.
|
||||
const second = producer({ owner })
|
||||
ctx.tasks.start(second.spec)
|
||||
expect(ownerCleanupEffects()).toHaveLength(1)
|
||||
second.settle({ status: 'completed' })
|
||||
await tick()
|
||||
await ctx.agents.drainCleanups(owner.id)
|
||||
expect(ownerCleanupEffects()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('force-fails a throwing teardown cancel without awaiting producer done, first outcome wins', async () => {
|
||||
const ctx = await harness()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const owner = stubAgent('owner')
|
||||
ctx.agents.register(owner)
|
||||
const seen: TaskSnapshot[] = []
|
||||
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
|
||||
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
ctx.tasks.start({
|
||||
@@ -462,9 +497,27 @@ describe('TaskService owner cleanup', () => {
|
||||
})
|
||||
|
||||
const drain = ctx.agents.drainCleanups(owner.id)
|
||||
settle({ status: 'failed', detail: 'gave up' })
|
||||
await drain
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cancel boom'))
|
||||
let drained = false
|
||||
void drain.then(() => { drained = true })
|
||||
await tick()
|
||||
const drainedWithoutProducerDone = drained
|
||||
if (!drainedWithoutProducerDone) {
|
||||
// Failure-path cleanup for the pre-fix implementation: let its pending
|
||||
// drain finish without weakening the assertion captured above.
|
||||
settle({ status: 'completed' })
|
||||
await drain
|
||||
} else {
|
||||
// A late producer completion must not replace the forced failed record or
|
||||
// notify listeners a second time.
|
||||
settle({ status: 'completed' })
|
||||
await tick()
|
||||
}
|
||||
|
||||
expect(drainedWithoutProducerDone).toBe(true)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned'))
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0]?.status).toBe('failed')
|
||||
expect(seen[0]?.detail).toContain('cancel threw during teardown')
|
||||
expect(ctx.tasks.list(owner)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -498,6 +551,44 @@ describe('TaskService disposal', () => {
|
||||
expect(seen).toEqual([])
|
||||
})
|
||||
|
||||
it('force-fails a throwing cancel so service disposal does not await producer done', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const fiber = await ctx.plugin(TaskService)
|
||||
ctx.tasks.attachSurface('test-surface')
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const seen: TaskSnapshot[] = []
|
||||
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
|
||||
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
ctx.tasks.start({
|
||||
kind: 'bash',
|
||||
label: 'broken service task',
|
||||
run: () => ({
|
||||
cancel() { throw new Error('service cancel boom') },
|
||||
done: new Promise<TaskOutcome>((resolve) => { settle = resolve }),
|
||||
}),
|
||||
})
|
||||
|
||||
const disposal = fiber.dispose()
|
||||
let disposed = false
|
||||
void disposal.then(() => { disposed = true })
|
||||
await tick()
|
||||
const disposedWithoutProducerDone = disposed
|
||||
if (!disposedWithoutProducerDone) {
|
||||
// Failure-path cleanup for the pre-fix implementation.
|
||||
settle({ status: 'completed' })
|
||||
await disposal
|
||||
} else {
|
||||
settle({ status: 'completed' })
|
||||
await tick()
|
||||
}
|
||||
|
||||
expect(disposedWithoutProducerDone).toBe(true)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned'))
|
||||
expect(seen).toEqual([])
|
||||
})
|
||||
|
||||
it('detaching the last surface re-arms the register fence', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TaskService)
|
||||
|
||||
@@ -27,6 +27,10 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
|
||||
| `model` | (required) | the per-session agent template the bridge creates agents from |
|
||||
| `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
|
||||
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
|
||||
| `tools` | `{ mode: 'native' }` | tool-registry presentation config through `agent-core` |
|
||||
| `skills` | owner defaults | skill registry, local provider, and model-facing skill-tool config through `agent-core` |
|
||||
| `toolBash` | owner defaults | model-facing bash config through `agent-core`, including bash's producer-local `enableRunInBackground` |
|
||||
| `toolTasks` | owner defaults | generic `task_output` wait bounds through `agent-core` |
|
||||
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
|
||||
|
||||
The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor (`bash-local`).
|
||||
|
||||
@@ -62,6 +62,10 @@ export interface Config {
|
||||
persistenceRoot?: string
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
|
||||
skills?: agentCore.SkillConfig
|
||||
/** Model-facing bash tool config forwarded through agent-core. */
|
||||
toolBash?: NonNullable<agentCore.Config['toolBash']>
|
||||
/** Generic background-task control-tool config forwarded through agent-core. */
|
||||
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
@@ -74,6 +78,8 @@ export const Config: z<Config> = z.object({
|
||||
tools: ToolRegistry.Config,
|
||||
persistenceRoot: z.string().default('./.sessions'),
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
toolBash: agentCore.ToolBashConfigSchema,
|
||||
toolTasks: agentCore.ToolTasksConfigSchema,
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -89,6 +95,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
|
||||
...config.tools !== undefined ? { tools: config.tools } : {},
|
||||
...config.skills !== undefined ? { skills: config.skills } : {},
|
||||
...config.toolBash !== undefined ? { toolBash: config.toolBash } : {},
|
||||
...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {},
|
||||
})
|
||||
ctx.plugin(UserInteractionService)
|
||||
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
|
||||
|
||||
@@ -18,8 +18,9 @@ import * as acpAgent from '../src/index.ts'
|
||||
* ACP operations end-to-end) is the keyless bin smoke in `load-path.e2e.ts`;
|
||||
* this spec asserts the composition and the persistenceRoot default branch.
|
||||
*/
|
||||
async function mount(config: acpAgent.Config): Promise<Context> {
|
||||
async function mount(config: acpAgent.Config, withBash = false): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
if (withBash) ctx.provide('bash', { sandboxMode: undefined })
|
||||
await ctx.plugin(acpAgent, config)
|
||||
// The bundle mounts its children inside apply() (not awaited there); let their
|
||||
// fibers settle so the spine services are ready.
|
||||
@@ -110,6 +111,19 @@ describe('dsh-acp-agent composition', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards bundled tool config into agent-core', async () => {
|
||||
const ctx = await mount({
|
||||
model: 'mock',
|
||||
toolBash: { enableRunInBackground: false },
|
||||
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
|
||||
skills: await isolatedSkillsConfig(),
|
||||
}, true)
|
||||
const bash = ctx.tools.schemas().find(tool => tool.name === 'bash')
|
||||
expect(Object.keys((bash!.parameters as { properties: Record<string, unknown> }).properties))
|
||||
.not.toContain('run_in_background')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('exposes its plugin shape', () => {
|
||||
expect(acpAgent.name).toBe('acp-agent')
|
||||
expect(acpAgent.Config).toBeDefined()
|
||||
|
||||
@@ -28,6 +28,10 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
|
||||
| `model` | (required) | the pre-created `main` agent's model |
|
||||
| `persona` | — | the deployment persona template (may reference `{{model}}`), routed to `dsh-system-prompt` |
|
||||
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
|
||||
| `tools` | `{ mode: 'native' }` | tool-registry presentation config through `agent-core` |
|
||||
| `skills` | owner defaults | skill registry, local provider, and model-facing skill-tool config through `agent-core` |
|
||||
| `toolBash` | owner defaults | model-facing bash config through `agent-core`, including bash's producer-local `enableRunInBackground` |
|
||||
| `toolTasks` | owner defaults | generic `task_output` wait bounds through `agent-core` |
|
||||
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
|
||||
| `welcome` | `ready.` | the stdin-chat banner |
|
||||
| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
|
||||
|
||||
@@ -77,6 +77,10 @@ export interface Config {
|
||||
welcome?: string
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
|
||||
skills?: agentCore.SkillConfig
|
||||
/** Model-facing bash tool config forwarded through agent-core. */
|
||||
toolBash?: NonNullable<agentCore.Config['toolBash']>
|
||||
/** Generic background-task control-tool config forwarded through agent-core. */
|
||||
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
|
||||
/**
|
||||
* If set, the `main` agent RESUMES this persisted session id instead of
|
||||
* starting fresh. Sourced from an env var in the leaf `cordis.yml`
|
||||
@@ -96,6 +100,8 @@ export const Config: z<Config> = z.object({
|
||||
persistenceRoot: z.string().default('./.sessions'),
|
||||
welcome: z.string().default('ready.'),
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
toolBash: agentCore.ToolBashConfigSchema,
|
||||
toolTasks: agentCore.ToolTasksConfigSchema,
|
||||
resumeSessionId: z.string(),
|
||||
})
|
||||
|
||||
@@ -119,6 +125,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
|
||||
}],
|
||||
...config.skills !== undefined ? { skills: config.skills } : {},
|
||||
...config.toolBash !== undefined ? { toolBash: config.toolBash } : {},
|
||||
...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {},
|
||||
})
|
||||
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
|
||||
ctx.plugin(UserInteractionService)
|
||||
|
||||
@@ -25,8 +25,9 @@ import * as stdioAgent from '../src/index.ts'
|
||||
* stray default rather than crash). Here we assert the composition + config
|
||||
* forwarding the unit tier can reach.
|
||||
*/
|
||||
async function mount(config: stdioAgent.Config): Promise<Context> {
|
||||
async function mount(config: stdioAgent.Config, withBash = false): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
if (withBash) ctx.provide('bash', { sandboxMode: undefined })
|
||||
await ctx.plugin(stdioAgent, config)
|
||||
// The app mounts its children inside apply() (not awaited there); let their
|
||||
// fibers settle so the spine services + the pre-created agent are ready.
|
||||
@@ -135,6 +136,19 @@ describe('dsh-stdio-agent app', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards bundled tool config into agent-core', async () => {
|
||||
const ctx = await mount({
|
||||
model: 'mock',
|
||||
toolBash: { enableRunInBackground: false },
|
||||
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
|
||||
skills: await isolatedSkillsConfig(),
|
||||
}, true)
|
||||
const bash = ctx.tools.schemas().find(tool => tool.name === 'bash')
|
||||
expect(Object.keys((bash!.parameters as { properties: Record<string, unknown> }).properties))
|
||||
.not.toContain('run_in_background')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('exposes its name and Config schema', () => {
|
||||
expect(stdioAgent.name).toBe('stdio-agent')
|
||||
expect(stdioAgent.Config).toBeDefined()
|
||||
|
||||
Reference in New Issue
Block a user