fix(ui): surface declarative startup failures
This commit is contained in:
@@ -79,6 +79,6 @@ Everything that goes beyond "call the model, run the tools, repeat" belongs to p
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Tool calls within a step execute sequentially** — parallel execution waits on concurrency-safety metadata in the tool contract (see `dsh-tools`).
|
||||
- **No resume-or-create policy on the config path** — config-driven `create()` starts a fresh `${id}-session-<uuid>` every run (`TODO(demo)`), and a config `resumeSessionId` whose resume fails logs a warning and creates no agent.
|
||||
- **No resume-or-create policy on the config path** — config-driven `create()` starts a fresh `${id}-session-<uuid>` every run (`TODO(demo)`), and a config `resumeSessionId` whose resume fails logs a warning, emits and retains `agent/start-failed` while that declaration remains loaded, and creates no agent.
|
||||
- **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona/tool composition is available only through the programmatic `ctx.agents.create()` / `resume()` factory options.
|
||||
- **No built-in turn budget** — the default continuation is `continue` whenever a step had tool calls or steering; bounding a runaway turn requires an `agent/turn-continuation` force-stop plugin.
|
||||
|
||||
@@ -363,18 +363,29 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
this.create(id, options, cwd === undefined ? {} : { cwd })
|
||||
continue
|
||||
}
|
||||
ctx.effect(() => {
|
||||
ctx.effect(function* (this: AgentLoop) {
|
||||
let active = true
|
||||
let releaseFailure = (): void => {}
|
||||
const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
||||
void this.resumeWith(ctx, childCtx.sessionPersistence, {
|
||||
agentId: id,
|
||||
resumeSessionId,
|
||||
agentOptions: options,
|
||||
}).catch((error: unknown) => {
|
||||
if (!active) return
|
||||
const failure = new Error(error instanceof Error ? error.message : String(error), { cause: error })
|
||||
ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
|
||||
releaseFailure = ctx.agents.reportStartFailure(id, failure)
|
||||
})
|
||||
})
|
||||
return fiber.dispose
|
||||
}, `agentLoop.resume(${id})`)
|
||||
yield fiber.dispose
|
||||
// Yielded last, disposed first: suppress teardown rejection before the
|
||||
// deferred persistence child wakes and clear any retained record.
|
||||
yield () => {
|
||||
active = false
|
||||
releaseFailure()
|
||||
}
|
||||
}.bind(this), `agentLoop.resume(${id})`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
@@ -42,6 +43,72 @@ describe('config-driven session id', () => {
|
||||
await loopFiber.dispose()
|
||||
})
|
||||
|
||||
it('drops an in-flight declarative resume when its owner is disposed', async () => {
|
||||
const loadStarted = Promise.withResolvers<undefined>()
|
||||
const pendingLoad = Promise.withResolvers<{ meta: SessionHeader; events: SessionEvent[] }>()
|
||||
class DeferredSessionPersistence extends SessionPersistence {
|
||||
create(_meta: SessionHeader): Promise<void> { return Promise.resolve() }
|
||||
append(_id: SessionId, _events: readonly SessionEvent[]): Promise<void> { return Promise.resolve() }
|
||||
load(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
loadStarted.resolve(undefined)
|
||||
return pendingLoad.promise
|
||||
}
|
||||
list(): Promise<SessionHeader[]> { return Promise.resolve([]) }
|
||||
}
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const failures: Error[] = []
|
||||
ctx.on('agent/start-failed', (_id, error) => { failures.push(error) })
|
||||
const loopFiber = await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('deferred') }],
|
||||
})
|
||||
await ctx.plugin(DeferredSessionPersistence)
|
||||
await loadStarted.promise
|
||||
|
||||
await loopFiber.dispose()
|
||||
await Promise.resolve()
|
||||
expect(failures).toEqual([])
|
||||
expect(ctx.agents.getStartFailure(AgentId('main'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('normalizes a non-Error declarative resume rejection without duplicating an Error prefix', async () => {
|
||||
class RejectingSessionPersistence extends SessionPersistence {
|
||||
create(_meta: SessionHeader): Promise<void> { return Promise.resolve() }
|
||||
append(_id: SessionId, _events: readonly SessionEvent[]): Promise<void> { return Promise.resolve() }
|
||||
load(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
// Third-party backends can reject arbitrary values; this exercises normalization.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
return Promise.reject('plain failure')
|
||||
}
|
||||
list(): Promise<SessionHeader[]> { return Promise.resolve([]) }
|
||||
}
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const failures: Error[] = []
|
||||
ctx.on('agent/start-failed', (_id, error) => { failures.push(error) })
|
||||
const loopFiber = await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('rejected') }],
|
||||
})
|
||||
await ctx.plugin(RejectingSessionPersistence)
|
||||
|
||||
await vi.waitFor(() => { expect(failures).toHaveLength(1) })
|
||||
expect(failures[0]?.message).toBe('plain failure')
|
||||
expect(failures[0]?.cause).toBe('plain failure')
|
||||
await loopFiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('config-driven create uses a fresh ${id}-session-<uuid> per run (restart-safe)', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-session-'))
|
||||
dirs.push(root)
|
||||
@@ -137,7 +204,9 @@ describe('config-driven session id', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('does-not-exist') }] })
|
||||
const failures: Array<{ id: string; error: Error }> = []
|
||||
ctx.on('agent/start-failed', (id, error) => { failures.push({ id, error }) })
|
||||
const loopFiber = await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('does-not-exist') }] })
|
||||
const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn')
|
||||
.mockImplementation(() => undefined)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
@@ -148,6 +217,13 @@ describe('config-driven session id', () => {
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
expect(ctx.agents.get(AgentId('main'))).toBeUndefined()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('config-driven resume of "does-not-exist" failed'))
|
||||
expect(failures).toHaveLength(1)
|
||||
expect(failures[0]?.id).toBe('main')
|
||||
expect(failures[0]?.error.message).toBe('session "does-not-exist" not found')
|
||||
expect(failures[0]?.error.cause).toBeInstanceOf(Error)
|
||||
expect(ctx.agents.getStartFailure(AgentId('main'))).toBe(failures[0]?.error)
|
||||
await loopFiber.dispose()
|
||||
expect(ctx.agents.getStartFailure(AgentId('main'))).toBeUndefined()
|
||||
warn.mockRestore()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -14,6 +14,9 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i
|
||||
- Advanced factory lifecycle: `enter(agent)` publishes without announcing and returns an entry-bound detach; `announce(agent)` emits creation once. Detach during creation dispatch is deferred. Ordinary plugins use `register()`.
|
||||
- `ctx.agents.get(id: AgentId): Agent | undefined`
|
||||
- `ctx.agents.list(): Agent[]`
|
||||
- `ctx.agents.reportStartFailure(id, error): () => void` announces and retains a declarative startup failure; the disposer clears that exact record without deleting a newer replacement.
|
||||
- `ctx.agents.getStartFailure(id: AgentId): Error | undefined` returns a retained declarative startup failure for a configured id that never became live. Successful publication clears it.
|
||||
- `observeAgentStart(ctx, id, handlers)` observes publication or declarative startup failure, including the retained-failure race for late-mounted front doors; its listeners belong to `ctx` and the returned disposer cancels the observation.
|
||||
|
||||
#### Factory seam (creation)
|
||||
|
||||
@@ -29,7 +32,7 @@ The loop plugin registers `AgentFactory`, keeping consumers independent of its c
|
||||
|
||||
`dsh-agent` declares the live `agent/*` coordination vocabulary so plugins do not depend on the concrete loop. Exact signatures, dispatch modes, scope-filtering rules, and payload contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the [architecture turn flow](../../../docs/architecture.md#turn-flow) shows their order relative to durable session events.
|
||||
|
||||
`agent/created` runs after setup and both registry entries; the following `agent/session-start` is the first supported startup injection point. `agent/disposed` means the exact entry left the registry. The loop quiesces its driver first; directly registered custom agents own any stronger ordering.
|
||||
`agent/created` runs after setup and both registry entries; the following `agent/session-start` is the first supported startup injection point. `agent/disposed` means the exact entry left the registry. `agent/start-failed` reports a contained declarative startup failure before publication; programmatic factory calls report failures through rejection. The loop quiesces its driver first; directly registered custom agents own any stronger ordering.
|
||||
|
||||
Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way).
|
||||
|
||||
@@ -69,7 +72,7 @@ The handle every plugin programs against:
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam.
|
||||
- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead.
|
||||
- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead. Declarative startup failures are reported separately through `agent/start-failed`.
|
||||
- **No public step-only abort** — `cancel()` clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer ([stop-surface RFC](../../../docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
|
||||
- **`HookContext` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable.
|
||||
- **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`).
|
||||
|
||||
@@ -80,6 +80,60 @@ export interface AgentHandle {
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/** Handlers for observing one configured agent's publication or startup failure. */
|
||||
export interface AgentStartHandlers {
|
||||
/**
|
||||
* Handle publication of the requested agent.
|
||||
* @param agent - the live agent that was published.
|
||||
*/
|
||||
onStarted: (agent: Agent) => void
|
||||
/**
|
||||
* Handle a retained or live declarative startup failure.
|
||||
* @param error - the contained startup failure.
|
||||
*/
|
||||
onFailed: (error: Error) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe one configured agent until it is published or its declarative startup fails.
|
||||
* Listeners are owned by the supplied context fiber and the returned disposer is
|
||||
* idempotent through Cordis's event-disposer semantics.
|
||||
* @param ctx - the context whose fiber owns the observation listeners.
|
||||
* @param id - the configured agent id to observe.
|
||||
* @param handlers - publication and failure callbacks.
|
||||
* @returns a disposer for the observation listeners.
|
||||
*/
|
||||
export function observeAgentStart(ctx: Context, id: AgentId, handlers: AgentStartHandlers): () => void {
|
||||
const stop = (): void => {
|
||||
disposeCreated()
|
||||
disposeFailure()
|
||||
}
|
||||
const handleStarted = (agent: Agent): void => {
|
||||
if (agent.id !== id) return
|
||||
stop()
|
||||
handlers.onStarted(agent)
|
||||
}
|
||||
const handleFailed = (failedId: AgentId, error: Error): void => {
|
||||
if (failedId !== id) return
|
||||
stop()
|
||||
handlers.onFailed(error)
|
||||
}
|
||||
const disposeCreated = ctx.on('agent/created', handleStarted)
|
||||
const disposeFailure = ctx.on('agent/start-failed', handleFailed)
|
||||
const agent = ctx.agents.get(id)
|
||||
if (agent !== undefined) {
|
||||
stop()
|
||||
handlers.onStarted(agent)
|
||||
} else {
|
||||
const failure = ctx.agents.getStartFailure(id)
|
||||
if (failure !== undefined) {
|
||||
stop()
|
||||
handlers.onFailed(failure)
|
||||
}
|
||||
}
|
||||
return stop
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent-creation factory the loop implementation provides to the registry
|
||||
* via {@link AgentRegistry.setFactory}. Kept on the `dsh-agent` interface so
|
||||
@@ -136,6 +190,7 @@ export class AgentRegistry extends Service {
|
||||
// plus entry.agent identity; this WeakMap mirrors the authoritative id map.
|
||||
private entries = new WeakMap<Agent, AgentEntry>()
|
||||
private factory: FactorySlot | undefined
|
||||
private startFailures = new Map<AgentId, { error: Error; token: object }>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'agents')
|
||||
@@ -224,6 +279,7 @@ export class AgentRegistry extends Service {
|
||||
const carrier = scopeTarget(agent, agent)
|
||||
// Prepared transactions arbitrate identity at this publication boundary.
|
||||
if (this.entries.has(agent) || this.store.has(id)) throw new Error(`agent "${id}" is already registered`)
|
||||
this.startFailures.delete(id)
|
||||
const entry: AgentEntry = {
|
||||
id,
|
||||
agent,
|
||||
@@ -316,6 +372,45 @@ export class AgentRegistry extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a contained declarative startup failure and retain it for late UI observers.
|
||||
* @param id - the configured agent id that failed before publication.
|
||||
* @param error - the normalized startup error.
|
||||
* @returns a disposer that clears this exact failure record.
|
||||
*/
|
||||
reportStartFailure(id: AgentId, error: Error): () => void {
|
||||
const token = {}
|
||||
this.startFailures.set(id, { error, token })
|
||||
this.emitStartFailed(id, error)
|
||||
return () => {
|
||||
if (this.startFailures.get(id)?.token === token) this.startFailures.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a retained declarative startup failure for an id that never became live.
|
||||
* @param id - the configured agent id.
|
||||
* @returns the startup error, or undefined when the id has no retained failure.
|
||||
*/
|
||||
getStartFailure(id: AgentId): Error | undefined {
|
||||
return this.startFailures.get(id)?.error
|
||||
}
|
||||
|
||||
/** Emit an unscoped startup failure with the same listener containment as agent lifecycle events. */
|
||||
private emitStartFailed(id: AgentId, error: Error): void {
|
||||
const args: unknown[] = ['agent/start-failed', id, error]
|
||||
for (const callback of this.ctx.events.dispatch('emit', args)) {
|
||||
try {
|
||||
const returned: unknown = callback(id, error)
|
||||
void Promise.resolve(returned).catch((listenerError: unknown) => {
|
||||
this.ctx.logger.warn(`agent "${id}": agent/start-failed listener rejected: ${String(listenerError)}`)
|
||||
})
|
||||
} catch (listenerError: unknown) {
|
||||
this.ctx.logger.warn(`agent "${id}": agent/start-failed listener threw: ${String(listenerError)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a live agent.
|
||||
* @param id - the agent id to look up.
|
||||
|
||||
@@ -146,6 +146,15 @@ declare module 'cordis' {
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
|
||||
/**
|
||||
* Declarative startup failed before an agent could be published. Programmatic
|
||||
* `ctx.agents.create()` / `resume()` calls report failure through rejection;
|
||||
* this event covers the fire-and-forget config path.
|
||||
* @param agentId - the configured id that could not be started.
|
||||
* @param error - the contained startup failure.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/start-failed'(agentId: AgentId, error: Error): void
|
||||
/**
|
||||
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does
|
||||
* not enter `running` synchronously; drive lifecycle from this event.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import { Context, Service, symbols } from 'cordis'
|
||||
import type { Events } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { AgentId, agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { AgentId, agentEvents, observeAgentStart } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
function stubAgent(rawId: string): Agent {
|
||||
@@ -85,6 +85,110 @@ describe('AgentRegistry', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('retains declarative startup failures, contains listeners, and clears stale records', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const warnings: string[] = []
|
||||
const heard: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
ctx.on('agent/start-failed', () => { throw new Error('start sync') })
|
||||
ctx.on('agent/start-failed', () => Promise.reject(new Error('start async')) as never)
|
||||
ctx.on('agent/start-failed', id => void heard.push(id))
|
||||
|
||||
const firstError = new Error('first failure')
|
||||
const disposeFirst = ctx.agents.reportStartFailure(AgentId('main'), firstError)
|
||||
expect(ctx.agents.getStartFailure(AgentId('main'))).toBe(firstError)
|
||||
await Promise.resolve()
|
||||
expect(heard).toEqual(['main'])
|
||||
expect(warnings).toEqual([
|
||||
'agent "main": agent/start-failed listener threw: Error: start sync',
|
||||
'agent "main": agent/start-failed listener rejected: Error: start async',
|
||||
])
|
||||
disposeFirst()
|
||||
expect(ctx.agents.getStartFailure(AgentId('main'))).toBeUndefined()
|
||||
|
||||
const disposeSecond = ctx.agents.reportStartFailure(AgentId('main'), new Error('second failure'))
|
||||
const thirdError = new Error('third failure')
|
||||
const disposeThird = ctx.agents.reportStartFailure(AgentId('main'), thirdError)
|
||||
expect(ctx.agents.getStartFailure(AgentId('main'))).toBe(thirdError)
|
||||
disposeSecond()
|
||||
expect(ctx.agents.getStartFailure(AgentId('main'))).toBe(thirdError)
|
||||
disposeThird()
|
||||
|
||||
const disposeOccupiedAgent = ctx.agents.register(stubAgent('occupied'))
|
||||
const occupiedError = new Error('occupied failure')
|
||||
const disposeOccupiedFailure = ctx.agents.reportStartFailure(AgentId('occupied'), occupiedError)
|
||||
expect(() => { ctx.agents.enter(stubAgent('occupied')) }).toThrow(/already registered/)
|
||||
expect(ctx.agents.getStartFailure(AgentId('occupied'))).toBe(occupiedError)
|
||||
disposeOccupiedFailure()
|
||||
disposeOccupiedAgent()
|
||||
|
||||
const disposeCleared = ctx.agents.reportStartFailure(AgentId('main'), new Error('cleared failure'))
|
||||
ctx.agents.register(stubAgent('main'))()
|
||||
expect(ctx.agents.getStartFailure(AgentId('main'))).toBeUndefined()
|
||||
disposeCleared()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('observes immediate, retained, live, unrelated, and cancelled startup outcomes', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const outcomes: string[] = []
|
||||
|
||||
const existing = stubAgent('existing')
|
||||
const disposeExisting = ctx.agents.register(existing)
|
||||
observeAgentStart(ctx, existing.id, {
|
||||
onStarted: agent => void outcomes.push(`started:${agent.id}`),
|
||||
onFailed: error => void outcomes.push(`failed:${error.message}`),
|
||||
})()
|
||||
|
||||
const retainedError = new Error('retained')
|
||||
const disposeRetained = ctx.agents.reportStartFailure(AgentId('retained'), retainedError)
|
||||
observeAgentStart(ctx, AgentId('retained'), {
|
||||
onStarted: agent => void outcomes.push(`started:${agent.id}`),
|
||||
onFailed: error => void outcomes.push(`failed:${error.message}`),
|
||||
})()
|
||||
|
||||
const stopLiveStart = observeAgentStart(ctx, AgentId('live-start'), {
|
||||
onStarted: agent => void outcomes.push(`started:${agent.id}`),
|
||||
onFailed: error => void outcomes.push(`failed:${error.message}`),
|
||||
})
|
||||
const disposeUnrelatedAgent = ctx.agents.register(stubAgent('unrelated'))
|
||||
const disposeUnrelatedFailure = ctx.agents.reportStartFailure(AgentId('unrelated'), new Error('unrelated'))
|
||||
const disposeLiveAgent = ctx.agents.register(stubAgent('live-start'))
|
||||
stopLiveStart()
|
||||
|
||||
const stopLiveFailure = observeAgentStart(ctx, AgentId('live-failure'), {
|
||||
onStarted: agent => void outcomes.push(`started:${agent.id}`),
|
||||
onFailed: error => void outcomes.push(`failed:${error.message}`),
|
||||
})
|
||||
const disposeLiveFailure = ctx.agents.reportStartFailure(AgentId('live-failure'), new Error('live'))
|
||||
stopLiveFailure()
|
||||
|
||||
const stopCancelled = observeAgentStart(ctx, AgentId('cancelled'), {
|
||||
onStarted: agent => void outcomes.push(`started:${agent.id}`),
|
||||
onFailed: error => void outcomes.push(`failed:${error.message}`),
|
||||
})
|
||||
stopCancelled()
|
||||
const disposeCancelledFailure = ctx.agents.reportStartFailure(AgentId('cancelled'), new Error('cancelled'))
|
||||
|
||||
expect(outcomes).toEqual([
|
||||
'started:existing',
|
||||
'failed:retained',
|
||||
'started:live-start',
|
||||
'failed:live',
|
||||
])
|
||||
|
||||
disposeCancelledFailure()
|
||||
disposeLiveFailure()
|
||||
disposeLiveAgent()
|
||||
disposeUnrelatedFailure()
|
||||
disposeUnrelatedAgent()
|
||||
disposeRetained()
|
||||
disposeExisting()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('separates entry from announcement and stale/idempotent detach cannot remove a replacement', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
|
||||
Reference in New Issue
Block a user