diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ede449cb1c..ed0a5554e5 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:283`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -59,7 +59,7 @@ Awaited serial checkpoint for session-surface mutation after prompt assembly and Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:211`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -71,7 +71,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:221`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -83,7 +83,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:176`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -95,7 +95,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:233`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -107,7 +107,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -119,7 +119,17 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:189`](../../packages/core/agent/src/types.ts) + +### `agent/start-failed` — emit + +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. + +```ts cordis-catalog +'agent/start-failed'(agentId: AgentId, error: Error): void +``` + +Source: [`packages/core/agent/src/types.ts:157`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -131,7 +141,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:157`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:166`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -143,7 +153,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:250`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -155,7 +165,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:260`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:269`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -167,7 +177,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:270`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:279`](../../packages/core/agent/src/types.ts) ## `approval/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 18e00b7904..aa2289ff0d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -32,13 +32,15 @@ async resume(options: ResumeAgentOptions): Promise register(agent: Agent): () => void enter(agent: Agent): () => void announce(agent: Agent): void +reportStartFailure(id: AgentId, error: Error): () => void +getStartFailure(id: AgentId): Error | undefined get(id: AgentId): Agent | undefined list(): Agent[] ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:133`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:187`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b67788ea6b..de932e0999 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,19 +7,20 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent`](../packages/core/agent), [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:180`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:270`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:292`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:211`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:221`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:176`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:233`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:248`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:189`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/start-failed` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent`](../packages/core/agent) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:166`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:269`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:279`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:68`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index c1c4747a22..9fdadf809d 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -23,7 +23,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio RESUME_SESSION_ID= pnpm run demo:repl ``` -The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing or unreadable id starts no `main` agent, so unset it or choose an existing session id. +The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing or unreadable id starts no `main` agent and emits `agent/start-failed`; both terminal front doors print the failure and exit nonzero, so unset it or choose an existing session id. ## Code Mode diff --git a/examples/coding-agent/tests/tui-keyless-smoke.e2e.ts b/examples/coding-agent/tests/tui-keyless-smoke.e2e.ts index a1057ba02f..f5f9c5db36 100644 --- a/examples/coding-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/tui-keyless-smoke.e2e.ts @@ -13,7 +13,7 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const PTY_DRIVER = String.raw` import errno, os, pty, select, signal, sys, time -node, tsx_loader, bin_script, config_path, tsconfig_path, cwd = sys.argv[1:] +node, tsx_loader, bin_script, config_path, tsconfig_path, cwd, resume_session_id = sys.argv[1:] env = os.environ.copy() env.update({ "DEEPSEEK_API_KEY": "keyless-tui-no-call", @@ -21,6 +21,8 @@ env.update({ "DSH_AGENTS_HOME": os.path.join(cwd, ".agents"), "TSX_TSCONFIG_PATH": tsconfig_path, }) +if resume_session_id: + env["RESUME_SESSION_ID"] = resume_session_id pid, fd = pty.fork() if pid == 0: os.chdir(cwd) @@ -53,15 +55,23 @@ if status is None: os.kill(pid, signal.SIGKILL) _, status = os.waitpid(pid, 0) sys.stdout.buffer.write(output) -if not sent_exit: - sys.stderr.write("TUI did not render its welcome marker before timeout\n") - sys.exit(124) -if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0: - sys.stderr.write("TUI child did not exit cleanly\n") - sys.exit(125) +if resume_session_id: + if b'ui-tui: agent "main" failed to start:' not in output: + sys.stderr.write("TUI did not render the startup failure before timeout\n") + sys.exit(126) + if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 1: + sys.stderr.write("TUI startup failure did not exit with status 1\n") + sys.exit(127) +else: + if not sent_exit: + sys.stderr.write("TUI did not render its welcome marker before timeout\n") + sys.exit(124) + if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0: + sys.stderr.write("TUI child did not exit cleanly\n") + sys.exit(125) ` -async function runTuiLoaderSmoke(): Promise { +async function runTuiLoaderSmoke(resumeSessionId = ''): Promise { const cwd = await mkdtemp(join(tmpdir(), 'coding-tui-smoke-')) try { return await new Promise((resolve, reject) => { @@ -74,6 +84,7 @@ async function runTuiLoaderSmoke(): Promise { configPath, tsconfigPath, cwd, + resumeSessionId, ], { stdio: ['ignore', 'pipe', 'pipe'] }) let stdout = '' let stderr = '' @@ -98,4 +109,10 @@ describe('coding-agent TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('DEEPSEEK') expect(output).toContain('agent REPL ready.') }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('prints a config-resume failure and exits instead of leaving a blank terminal', async () => { + const output = await runTuiLoaderSmoke('missing-session') + expect(output).toContain('ui-tui: agent "main" failed to start:') + expect(output).toContain('missing-session') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 51b4a5155f..1eef429d96 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -71,6 +71,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'register(agent: Agent): () => void', 'enter(agent: Agent): () => void', 'announce(agent: Agent): void', + 'reportStartFailure(id: AgentId, error: Error): () => void', + 'getStartFailure(id: AgentId): Error | undefined', 'get(id: AgentId): Agent | undefined', 'list(): Agent[]', ], @@ -317,6 +319,12 @@ export const EVENT_API: readonly EventApiEntry[] = [ signature: '\'agent/session-start\'(this: Scoped, agent: Agent, source: SessionStartSource): void', summary: 'The session lifecycle began, once before the first turn.', }, + { + name: 'agent/start-failed', + mode: 'emit', + signature: '\'agent/start-failed\'(agentId: AgentId, error: Error): void', + summary: 'Declarative startup failed before an agent could be published.', + }, { name: 'agent/status', mode: 'emit', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index a07aa5fa70..d46aabac0d 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -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-` 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-` 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. diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 6cd66f622c..6d3ef8851a 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -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})`) } } diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 8a5d9ce264..f91c7f6585 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -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() + const pendingLoad = Promise.withResolvers<{ meta: SessionHeader; events: SessionEvent[] }>() + class DeferredSessionPersistence extends SessionPersistence { + create(_meta: SessionHeader): Promise { return Promise.resolve() } + append(_id: SessionId, _events: readonly SessionEvent[]): Promise { return Promise.resolve() } + load(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + loadStarted.resolve(undefined) + return pendingLoad.promise + } + list(): Promise { 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 { return Promise.resolve() } + append(_id: SessionId, _events: readonly SessionEvent[]): Promise { 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 { 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- 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() }) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 5639f30daf..ed91da24ce 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -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)`). diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index fc5ad371b4..fc3a59b43f 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -80,6 +80,60 @@ export interface AgentHandle { dispose(): Promise } +/** 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() private factory: FactorySlot | undefined + private startFailures = new Map() 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. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 3aad65a70e..a9c16dec49 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -146,6 +146,15 @@ declare module 'cordis' { * @mode emit */ 'agent/disposed'(this: Scoped, 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. diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 5d541d56a8..b9acf9ef4e 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -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) diff --git a/packages/examples/stdio-demo/README.md b/packages/examples/stdio-demo/README.md index 96704998c4..05dd4d34c2 100644 --- a/packages/examples/stdio-demo/README.md +++ b/packages/examples/stdio-demo/README.md @@ -15,8 +15,8 @@ A terminal chat always wants the same cluster, so the package owns it rather tha | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools | | `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool | | `@cordisjs/plugin-logger-console` | readline diagnostics for non-TTY operation; omitted from the fullscreen TUI path | -| `@deepseek-ai/dsh-stdio` | the line-oriented terminal channel, bound to `main` for pipes and automation | -| `@deepseek-ai/dsh-tui` | the interactive pi-tui channel, bound to `main` for TTY pairs | +| `@deepseek-ai/dsh-stdio` | the line-oriented terminal channel, bound to `main` for pipes and automation; matching `agent/start-failed` errors print and exit nonzero | +| `@deepseek-ai/dsh-tui` | the interactive pi-tui channel, bound to `main` for TTY pairs; matching `agent/start-failed` startup errors are printed before fullscreen mode and exit nonzero | `@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, not baked in here: it is a Loader-only, subprocess-only dev plugin whose constructor needs `node --expose-internals` plus a live `loader`. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`. diff --git a/packages/ui/stdio/README.md b/packages/ui/stdio/README.md index b7d320880d..0ee46b3a3d 100644 --- a/packages/ui/stdio/README.md +++ b/packages/ui/stdio/README.md @@ -11,7 +11,7 @@ This package owns the terminal channel only. It injects `agents` and `userIntera | `welcome` | `ready.` | Banner printed before the first prompt | | `agent` | `main` | Agent id driven by stdin and observed for EOF shutdown | -The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. Disposal closes readline and unregisters every listener/provider through Cordis effects. +The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. While waiting for its configured agent, it also observes retained and live `agent/start-failed` notifications; a matching failure is printed and exits with status 1 instead of waiting forever. Disposal closes readline and unregisters every listener/provider through Cordis effects. ```yaml - id: stdio diff --git a/packages/ui/stdio/src/index.ts b/packages/ui/stdio/src/index.ts index 1e665381ce..3773a55e92 100644 --- a/packages/ui/stdio/src/index.ts +++ b/packages/ui/stdio/src/index.ts @@ -13,7 +13,7 @@ import { createInterface } from 'node:readline' import type { Readable, Writable } from 'node:stream' import type { Context } from 'cordis' import z from 'schemastery' -import { AgentId } from '@deepseek-ai/dsh-agent' +import { AgentId, observeAgentStart } from '@deepseek-ai/dsh-agent' import { UserInteractionError, type AskUserQuestionAnswer, @@ -363,14 +363,12 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt */ export function mountStdio(ctx: Context, config: Config, runtime: StdioRuntime): void { const agentId = AgentId(config.agent ?? 'main') - if (ctx.agents.get(agentId) !== undefined) { - createStdioChat(ctx, config, runtime) - return - } - const dispose = ctx.on('agent/created', (agent) => { - if (agent.id !== agentId) return - dispose() - createStdioChat(ctx, config, runtime) + observeAgentStart(ctx, agentId, { + onStarted: () => { createStdioChat(ctx, config, runtime) }, + onFailed: (error) => { + runtime.output.write(`ui-stdio: agent "${agentId}" failed to start: ${error.message}\n`) + runtime.exit(1) + }, }) } diff --git a/packages/ui/stdio/tests/stdio.spec.ts b/packages/ui/stdio/tests/stdio.spec.ts index 7bb6a6f245..60407df416 100644 --- a/packages/ui/stdio/tests/stdio.spec.ts +++ b/packages/ui/stdio/tests/stdio.spec.ts @@ -2,7 +2,7 @@ import { Readable, Writable } from 'node:stream' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -111,6 +111,42 @@ describe('mountStdio readiness', () => { await fiber.dispose() }) + it('prints a matching live startup failure and exits instead of waiting forever', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const { runtime, out, exit } = makeRuntime() + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + mountStdio(inner, CONFIG, runtime) + }, { inject: ['agents', 'userInteraction'] })) + + ctx.agents.reportStartFailure(AgentId('other'), new Error('other failed')) + expect(out.text()).toBe('') + expect(exit).not.toHaveBeenCalled() + ctx.agents.reportStartFailure(AgentId('main'), new Error('resume failed')) + expect(out.text()).toBe('ui-stdio: agent "main" failed to start: resume failed\n') + expect(exit).toHaveBeenCalledWith(1) + + ctx.agents.register(makeAgent('main')) + expect(out.text()).toBe('ui-stdio: agent "main" failed to start: resume failed\n') + await fiber.dispose() + }) + + it('prints a retained startup failure for a late-mounted stdio channel', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + ctx.agents.reportStartFailure(AgentId('main'), new Error('already failed')) + const { runtime, out, exit } = makeRuntime() + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + mountStdio(inner, CONFIG, runtime) + }, { inject: ['agents', 'userInteraction'] })) + + expect(out.text()).toBe('ui-stdio: agent "main" failed to start: already failed\n') + expect(exit).toHaveBeenCalledWith(1) + await fiber.dispose() + }) + it('opens immediately when the configured agent already exists', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 1e678beac8..7cc84cee3d 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -33,7 +33,7 @@ While the agent is running, editor submissions call `agent.steer()`; otherwise t maxToolOutputLines: 12 ``` -Startup fails before mounting when either process stream is not a TTY. Disposal stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. +Startup fails before mounting when either process stream is not a TTY. While waiting for its configured agent, the front door also observes retained and live `agent/start-failed` notifications; a matching failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. ## Model Experience diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 14f45242d7..13b5b3866c 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -34,7 +34,7 @@ import { } from '@earendil-works/pi-tui' import type { Context } from 'cordis' import z from 'schemastery' -import { AgentId, type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent' +import { AgentId, observeAgentStart, type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, TodoItem } from '@deepseek-ai/dsh-session' import type { @@ -133,7 +133,7 @@ export interface ResolvedTuiConfig { export interface TuiRuntime { /** Terminal implementation; production uses pi-tui's `ProcessTerminal`. */ terminal: Terminal - /** Exit hook used by `/exit`, Ctrl+D, or Ctrl+C while idle. */ + /** Exit hook used by terminal shutdown or a target-agent startup failure. */ exit(code: number): void } @@ -1252,20 +1252,17 @@ export function createTuiChat( */ export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): void { const agentId = AgentId(config.agent ?? 'main') - const start = (): void => { - ctx.effect(() => { - const controller = createTuiChat(ctx, config, runtime) - return () => controller.dispose() - }, 'ui-tui') - } - if (ctx.agents.get(agentId) !== undefined) { - start() - return - } - const dispose = ctx.on('agent/created', (agent) => { - if (agent.id !== agentId) return - dispose() - start() + observeAgentStart(ctx, agentId, { + onStarted: () => { + ctx.effect(() => { + const controller = createTuiChat(ctx, config, runtime) + return () => controller.dispose() + }, 'ui-tui') + }, + onFailed: (error) => { + runtime.terminal.write(`ui-tui: agent "${agentId}" failed to start: ${error.message}\n`) + runtime.exit(1) + }, }) } diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index e4ffe2efd0..c0d1d419b8 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -910,6 +910,51 @@ describe('terminal mounting', () => { await ctx.fiber.dispose() }) + it('prints a matching live startup failure and exits instead of waiting forever', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + ctx.provide('tools', { get: () => undefined } as never) + const terminal = new FakeTerminal() + const exit = vi.fn() + mountTui(ctx, { agent: 'main', color: false }, { terminal, exit }) + + ctx.agents.reportStartFailure(AgentId('other'), new Error('other failed')) + expect(terminal.output).toBe('') + expect(exit).not.toHaveBeenCalled() + ctx.agents.reportStartFailure(AgentId('main'), new Error('resume failed')) + expect(terminal.output).toBe('ui-tui: agent "main" failed to start: resume failed\n') + expect(exit).toHaveBeenCalledWith(1) + + const session = ctx.sessions.create(SessionId('must-not-start')) + ctx.agents.register({ + id: AgentId('main'), options: {}, session, status: 'idle', ctx, + send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + }) + await tick() + expect(terminal.started).toBe(0) + await ctx.fiber.dispose() + }) + + it('prints a retained startup failure for a late-mounted TUI', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + ctx.provide('tools', { get: () => undefined } as never) + ctx.agents.reportStartFailure(AgentId('main'), new Error('already failed')) + const terminal = new FakeTerminal() + const exit = vi.fn() + + mountTui(ctx, { agent: 'main', color: false }, { terminal, exit }) + + expect(terminal.started).toBe(0) + expect(terminal.output).toBe('ui-tui: agent "main" failed to start: already failed\n') + expect(exit).toHaveBeenCalledWith(1) + await ctx.fiber.dispose() + }) + it('rolls back providers, listeners, and terminal state when startup fails', async () => { const ctx = new Context() await ctx.plugin(SessionStore)