fix: surface config startup failures

This commit is contained in:
Tianyi Cui
2026-07-14 11:23:38 +08:00
parent e8f1bd41f5
commit c1fb97c63a
16 changed files with 151 additions and 21 deletions

View File

@@ -51,14 +51,17 @@ Waterfall events behave like around-middleware: a listener delegates by calling
## Default Loop Lifecycle
The shipped loop drains work, assembles requests, streams model answers, executes tools, applies continuation policy, and checkpoints state. Every pause is a service call or event available to plugins.
Default loop processing remains exposed through plugin-visible services and events.
A **session** is one agent's append-only event log. A **turn** drains one queued batch and runs until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points.
Declarative startup chooses one agent/session identity. No id mints `<config-id>-session-<uuid>`; exact `sessionId` resumes when stored and otherwise creates; `resumeSessionId` requires stored history. Failures emit `agent-loop/config-start-failed(sessionId, error)`, letting front doors reject buffered work.
### Turn Flow
```text
prepare private session + agent.ctx -> await unpublished setup
choose declarative identity and fresh/resume path
-> prepare private session + agent.ctx -> await unpublished setup
-> enter session + agent -> session/created -> agent/created
-> enable driving -> agent/session-start(source) -> start driver
forever:

View File

@@ -131,7 +131,7 @@ export interface Config {
Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts)
Source: [`packages/core/agent-loop/src/index.ts:333`](../packages/core/agent-loop/src/index.ts)
Source: [`packages/core/agent-loop/src/index.ts:344`](../packages/core/agent-loop/src/index.ts)
## `@deepseek-ai/dsh-bash-local`

View File

@@ -175,6 +175,18 @@ Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:576`](../../packages/core/agent/src/types.ts)
## `agent-loop/*`
### `agent-loop/config-start-failed` — emit
A declarative agent entry failed before it could publish a live agent. Consumers that buffer work for the configured identity use this transient signal to reject that work instead of waiting forever.
```ts cordis-catalog
'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void
```
Source: [`packages/core/agent-loop/src/index.ts:339`](../../packages/core/agent-loop/src/index.ts)
## `approval/*`
### `approval/request` — waterfall

View File

@@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<Agent
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
```
Source: [`packages/core/agent-loop/src/index.ts:348`](../../packages/core/agent-loop/src/index.ts)
Source: [`packages/core/agent-loop/src/index.ts:359`](../../packages/core/agent-loop/src/index.ts)
## `ctx.agents` — `AgentRegistry`

View File

@@ -7,6 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:339`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:593`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |

View File

@@ -316,6 +316,7 @@ flowchart TD
pkg_acp_agent --> pkg_user_interaction
pkg_stdio_agent --> pkg_agent
pkg_stdio_agent --> pkg_agent_core
pkg_stdio_agent --> pkg_agent_loop
pkg_stdio_agent --> pkg_app_boot
pkg_stdio_agent --> pkg_llm
pkg_stdio_agent --> pkg_session
@@ -394,4 +395,4 @@ flowchart TD
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`agent-loop`](../packages/core/agent-loop), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |

View File

@@ -237,6 +237,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
/** Every harness event, sorted by name. */
export const EVENT_API: readonly EventApiEntry[] = [
{
name: 'agent-loop/config-start-failed',
mode: 'emit',
signature: '\'agent-loop/config-start-failed\'(sessionId: SessionId, error: unknown): void',
summary: 'A declarative agent entry failed before it could publish a live agent.',
},
{
name: 'agent/created',
mode: 'emit',

View File

@@ -41,7 +41,7 @@ interface Config {
}
```
Agents listed in config are auto-created at startup. `cwd` seeds a fresh config-created session; a materialized exact `sessionId` remount and an explicit `resumeSessionId` keep the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin.
Agents listed in config are auto-created at startup. `cwd` seeds a fresh config-created session; a materialized exact `sessionId` remount and an explicit `resumeSessionId` keep the persisted session header. A declarative lookup, resume, setup, or publication failure is contained, logged, and emitted as `agent-loop/config-start-failed(sessionId, error)` because no live `Agent` exists for an `agent/*` signal. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin.
### Exported concrete class

View File

@@ -327,6 +327,17 @@ declare module 'cordis' {
interface Context {
agentLoop: AgentLoop
}
interface Events {
/**
* A declarative agent entry failed before it could publish a live agent.
* Consumers that buffer work for the configured identity use this
* transient signal to reject that work instead of waiting forever.
* @param sessionId - exact shared agent/session identity that failed startup.
* @param error - persistence, setup, or publication failure.
* @mode emit
*/
'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void
}
}
/** Plugin configuration for declarative startup agents. */
@@ -381,7 +392,7 @@ export class AgentLoop extends Service implements AgentFactory {
this.create(configuredId, options, meta)
} else {
const startup = this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => {
ctx.logger.warn(`agent "${id}": config-driven restore of "${configuredId}" failed: ${String(error)}`)
this.reportConfiguredStartupFailure(id, 'restore', configuredId, error)
})
this.ownership.trackStartup(startup)
}
@@ -396,7 +407,7 @@ export class AgentLoop extends Service implements AgentFactory {
resumeSessionId,
agentOptions: options,
}).catch((error: unknown) => {
ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
this.reportConfiguredStartupFailure(id, 'resume', resumeSessionId, error)
})
})
return fiber.dispose
@@ -404,6 +415,21 @@ export class AgentLoop extends Service implements AgentFactory {
}
}
/** Report a contained declarative-start failure to identity-bound consumers. */
private reportConfiguredStartupFailure(
configId: string,
action: 'restore' | 'resume',
sessionId: SessionId,
error: unknown,
): void {
this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${String(error)}`)
try {
this.ctx.emit('agent-loop/config-start-failed', sessionId, error)
} catch (listenerError) {
this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${String(listenerError)}`)
}
}
/** Restore a materialized exact config identity on remount, or create it on first use. */
private async restoreOrCreateConfigured(
ownerCtx: Context,

View File

@@ -98,6 +98,12 @@ describe('config-driven session id', () => {
const ctx = await makeCoreContext()
await ctx.plugin(SessionPersistenceJsonl, { root })
const failure = new Error('persistence index failed')
const listenerFailure = new Error('failure observer failed')
const failures: { sessionId: SessionId; error: unknown }[] = []
ctx.on('agent-loop/config-start-failed', (sessionId, error) => {
failures.push({ sessionId, error })
})
ctx.on('agent-loop/config-start-failed', () => { throw listenerFailure })
vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(failure)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
@@ -108,6 +114,10 @@ describe('config-driven session id', () => {
await expect.poll(() => warn).toHaveBeenCalledWith(expect.stringContaining(
'config-driven restore of "stdio-exact-failure" failed: Error: persistence index failed',
))
expect(failures).toEqual([{ sessionId: SessionId('stdio-exact-failure'), error: failure }])
expect(warn).toHaveBeenCalledWith(
'agent "main": config-start-failed listener threw: Error: failure observer failed',
)
expect(ctx.agents.get(SessionId('stdio-exact-failure'))).toBeUndefined()
warn.mockRestore()
await ctx.fiber.dispose()

View File

@@ -32,7 +32,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
| `welcome` | `ready.` | the stdin-chat banner |
| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-<uuid>` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to both its config-created agent and UI; an AgentLoop-only reload resumes materialized history under that id, while the UI's `main` text remains only a display label and never selects another registry root by prefix or insertion order. Readline buffers nonblank startup input for that identity until `agent/session-start`, so piped stdin cannot outrun asynchronous exact-id restoration or let EOF discard the queued prompt. A resumed run binds both components to the exact `resumeSessionId` and keeps the cwd stored in the persisted session header.
Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-<uuid>` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to both its config-created agent and UI; an AgentLoop-only reload resumes materialized history under that id, while the UI's `main` text remains only a display label and never selects another registry root by prefix or insertion order. Readline buffers nonblank startup input for that identity until `agent/session-start`, so piped stdin cannot outrun asynchronous exact-id restoration or let EOF discard the queued prompt; `agent-loop/config-start-failed` instead drains and reports buffered input so a missing or corrupt persisted session cannot hang EOF. A resumed run binds both components to the exact `resumeSessionId` and keeps the cwd stored in the persisted session header.
## The bin

View File

@@ -35,6 +35,7 @@
"@deepseek-ai/dsh-app-boot": "^0.0.1",
"@cordisjs/plugin-logger-console": "^1.0.0",
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-agent-core": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -51,6 +52,7 @@
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@cordisjs/plugin-logger-console": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-agent-core": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",

View File

@@ -102,16 +102,23 @@ export const Config: z<Config> = z.object({
})
/**
* Compose the spine with the stdio front door. The console logger comes first
* (infra), then the agent-core bundle pre-creating one agent from this app's
* `model`/`resumeSessionId` with the deployment `persona`, then the JSONL
* backend, then the readline UI rendering that object as `main`. The `hmr` dev-reload plugin is
* a leaf concern (see the module doc), so it is not mounted here.
* Compose the spine with the stdio front door. Console logging, persistence,
* and user interaction mount first; the readline UI then waits on the agent
* registry and subscribes to config-start failures before agent-core can start
* the configured identity. The ask-user tool waits on the completed spine.
* The `hmr` dev-reload plugin is a leaf concern (see the module doc), so it is
* not mounted here.
*/
export function apply(ctx: Context, config: Config): void {
const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId
const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`)
ctx.plugin(ConsoleExporter)
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
ctx.plugin(UserInteractionService)
ctx.plugin(uiStdio, {
welcome: config.welcome ?? 'ready.',
sessionId,
})
ctx.plugin(agentCore, {
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
@@ -124,11 +131,5 @@ export function apply(ctx: Context, config: Config): void {
}],
...config.skills !== undefined ? { skills: config.skills } : {},
})
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
ctx.plugin(UserInteractionService)
ctx.plugin(toolAskUser)
ctx.plugin(uiStdio, {
welcome: config.welcome ?? 'ready.',
sessionId,
})
}

View File

@@ -21,6 +21,7 @@ import type { Readable, Writable } from 'node:stream'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-loop'
import {
UserInteractionError,
type AskUserQuestionAnswer,
@@ -173,6 +174,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
const queuedInput: string[] = []
let targetReady = target !== undefined
let hadReadyTarget = targetReady
let failedStartup: { error: unknown } | undefined
const submit = (agent: Agent, text: string): void => {
submittedWork = true
@@ -187,6 +189,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
if (!matchesConfiguredIdentity(agent)) return
target = agent
targetReady = false
failedStartup = undefined
})
const disposeSessionStartListener = ctx.on('agent/session-start', (agent) => {
if (agent !== target) return
@@ -220,6 +223,18 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
exitTimer = setTimeout(() => { exit(0) }, 200)
}
const disposeStartupFailedListener = ctx.on('agent-loop/config-start-failed', (sessionId, error) => {
if (sessionId !== config.sessionId || targetReady) return
failedStartup = { error }
const dropped = queuedInput.length
queuedInput.length = 0
submittedWork = sawRunning
if (dropped > 0) {
ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (${dropped} line(s)): ${String(error)}`)
}
maybeExit()
})
const disposeStatusListener = ctx.on('agent/status', (subject, status) => {
if (subject !== target) return
if (status === 'running') sawRunning = true
@@ -374,6 +389,10 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
}
const text = line.trim()
if (!text) return
if (failedStartup !== undefined) {
ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ${String(failedStartup.error)}`)
return
}
const agent = target
if (agent === undefined || !targetReady) {
// Initial exact-id restoration is asynchronous. Preserve input until
@@ -407,6 +426,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
disposeCreatedListener()
disposeSessionStartListener()
disposeDisposedListener()
disposeStartupFailedListener()
reader.close()
}
}, 'ui-stdio')

View File

@@ -4,7 +4,7 @@ import { Context } from 'cordis'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { createStdioChat, type Config, type StdioRuntime } from '../src/stdio-chat.ts'
@@ -750,6 +750,32 @@ describe('createStdioChat input', () => {
expect(agent.sent).toEqual([[{ type: 'text', text: 'nobody home' }]])
})
it('drops later input after the configured startup fails', async () => {
const { ctx, input } = await setup()
const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
const failure = new Error('persisted session is corrupt')
ctx.emit('agent-loop/config-start-failed', SessionId('main'), failure)
input.feed('cannot run')
await new Promise(r => setImmediate(r))
expect(error).toHaveBeenCalledWith(
'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): Error: persisted session is corrupt',
)
})
it('ignores a stale config-start failure after the exact target is ready', async () => {
const { ctx, input } = await setup()
const agent = makeAgent('main')
registerReady(ctx, agent)
ctx.emit('agent-loop/config-start-failed', SessionId('main'), new Error('stale'))
input.feed('still live')
await new Promise(r => setImmediate(r))
expect(agent.sent).toEqual([[{ type: 'text', text: 'still live' }]])
})
it('drives the exact app-configured resumed session', async () => {
const { ctx, input } = await setup({ welcome: 'w', sessionId: 'worker' })
const agent = makeAgent('worker')
@@ -808,6 +834,25 @@ describe('createStdioChat EOF exit', () => {
expect(exit).toHaveBeenCalledWith(0)
})
it('drains buffered piped input and exits when configured startup fails', async () => {
const { ctx, input, exit } = await setup()
const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
input.feed('work')
input.finish()
await new Promise(r => setImmediate(r))
ctx.emit('agent-loop/config-start-failed', SessionId('other'), new Error('unrelated'))
await flushExit()
expect(exit).not.toHaveBeenCalled()
ctx.emit('agent-loop/config-start-failed', SessionId('main'), new Error('missing persisted session'))
await flushExit()
expect(error).toHaveBeenCalledWith(
'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): Error: missing persisted session',
)
expect(exit).toHaveBeenCalledWith(0)
})
it('schedules the exit only once when idle fires repeatedly', async () => {
const { ctx, input, exit } = await setup()
const agent = makeAgent('main', 'running')

3
pnpm-lock.yaml generated
View File

@@ -1328,6 +1328,9 @@ importers:
'@deepseek-ai/dsh-agent-core':
specifier: workspace:^
version: link:../../core/agent-core
'@deepseek-ai/dsh-agent-loop':
specifier: workspace:^
version: link:../../core/agent-loop
'@deepseek-ai/dsh-app-boot':
specifier: workspace:^
version: link:../app-boot