fix(ui): surface declarative startup failures

This commit is contained in:
Turtle
2026-07-17 16:44:25 +08:00
parent 1119c537d0
commit 12b7ac67ba
20 changed files with 484 additions and 72 deletions
+1 -1
View File
@@ -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.
+14 -3
View File
@@ -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()
})