fix(cli): close shared config review gaps

This commit is contained in:
Turtle
2026-07-30 14:56:39 +08:00
parent 5a490553bc
commit a51143bded
51 changed files with 277 additions and 171 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
README.md: 88c4501d87b7f24de1f5cc0d67f4c0e03ec49aa4
README.zh.md: f03120e5a7820e2bcb572ab31b535211cf859c82
README.md: d3ea5b41e8398c92853c6f96160129d7b1c475ed
README.zh.md: 9ee509abe2cf5bf6360b7c91d318b2b69745d224

View File

@@ -75,7 +75,7 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
```
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops extension admission, unloads the `ctx.tui` provider and its dependent plugins, aborts running commands, removes the TUI definitions, 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. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops extension admission, unloads the `ctx.tui` provider and its dependent plugins, aborts running commands, removes the TUI definitions, 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. A user exit disposes the application root so sibling resources close, then exits; a five-second fallback prevents one stuck disposer from trapping the process.
## Color

View File

@@ -75,7 +75,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
```
任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI再挂载由配置创建的 agent使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出而不是留下空白终端。dispose资源释放会停止接收扩展请求卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader拒绝待处理问题排空终端输入恢复终端状态注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。
任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI再挂载由配置创建的 agent使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出而不是留下空白终端。dispose资源释放会停止接收扩展请求卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader拒绝待处理问题排空终端输入恢复终端状态注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。用户退出会先 dispose 应用根上下文以关闭同级资源,再退出进程;五秒兜底可避免某个卡住的 disposer 困住进程。
## 颜色

View File

@@ -841,11 +841,9 @@ export function createTuiChat(
palette,
overlayManager,
// Optional and independently mounted: read at each use so config row order
// cannot decide whether /resume works.
// The TUI and query provider are sibling Loader fibers. During a command
// callback Cordis may transiently mark the provider non-ACTIVE even though
// its init completed and its disposal is ordered after this consumer.
sessionQuery: () => ctx.get('sessionQuery', false),
// cannot decide whether /resume works. Strict lookup excludes a closing or
// closed provider rather than dispatching into a stale SQLite handle.
sessionQuery: () => ctx.get('sessionQuery'),
ui,
editor,
appendNotice,
@@ -1654,9 +1652,35 @@ export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): voi
if (existing !== undefined) start(existing)
}
const ROOT_DISPOSE_TIMEOUT_MS = 5_000
/**
* Dispose the whole application before process exit, with a bounded fallback.
* @param ctx - The TUI plugin context whose root owns sibling resources.
* @param code - Process status to report.
* @param exit - Exit boundary, replaceable by tests.
*/
export function disposeRootAndExit(
ctx: Context,
code: number,
exit: (status: number) => void = (status) => { process.exit(status) },
): void {
let exited = false
const exitOnce = (): void => {
if (exited) return
exited = true
exit(code)
}
const timeout = setTimeout(exitOnce, ROOT_DISPOSE_TIMEOUT_MS)
void ctx.root.fiber.dispose().then(
() => { clearTimeout(timeout); exitOnce() },
() => { clearTimeout(timeout); exitOnce() },
)
}
/** Cordis entry point using the process terminal; explicit TUI composition requires a TTY pair. */
/* v8 ignore start -- production process wiring; fake-terminal tests cover mountTui/createTuiChat,
and the tui-agent PTY smoke covers the real entry */
and apps/cli PTY smokes cover the real entry */
export function apply(ctx: Context, config: Config): void {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw new Error('ui-tui: both stdin and stdout must be TTYs; use the one-shot @deepseek-ai/dsh-cli-demo app for pipes')
@@ -1676,9 +1700,7 @@ export function apply(ctx: Context, config: Config): void {
initialSkill === undefined ? {} : { initialSkill },
), {
terminal: new ProcessTerminal(),
exit: (code) => {
void ctx.fiber.dispose().finally(() => { process.exit(code) })
},
exit: (code) => { disposeRootAndExit(ctx, code) },
...resumeHost === undefined ? {} : { handoffResume: (sessionId, cwd) => resumeHost.handoff(sessionId, cwd) },
...goodbyeMessage === undefined ? {} : { goodbyeMessage },
})

View File

@@ -29,6 +29,7 @@ import SessionReferenceService, { formatSessionReferenceMention } from '@deepsee
import type {} from '@deepseek-ai/dsh-llm-retry'
import {
createTuiChat,
disposeRootAndExit,
FILE_REFERENCE_PROMPT,
mountTui,
renderSkillInvocation,
@@ -502,6 +503,30 @@ describe('goodbye message and /resume', () => {
await dispose(result)
})
it('treats a closed session-query provider as unavailable', async () => {
let queryCtx: Context | undefined
const result = await setup({
cwd: '/workspace',
async configureContext(ctx) {
await ctx.plugin({
apply(child: Context) {
queryCtx = child
child.provide('sessionQuery', {
listSessions: () => Promise.reject(new Error('closed database must not be called')),
} as never)
},
})
},
})
await queryCtx!.fiber.dispose()
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('session query is not mounted')
expect(result.terminal.output).not.toContain('closed database')
await dispose(result)
})
it('keeps persisted query records readable without a persistence service', async () => {
const target = header('query-only-persisted', 10, '/workspace')
const result = await setup({
@@ -4962,6 +4987,59 @@ describe('TUI extension service', () => {
})
})
describe('application exit', () => {
it('disposes the root fiber rather than only the TUI child before exiting', async () => {
const rootDispose = vi.fn(() => Promise.resolve())
const childDispose = vi.fn(() => Promise.resolve())
const ctx = {
root: { fiber: { dispose: rootDispose } },
fiber: { dispose: childDispose },
} as unknown as Context
const exit = vi.fn()
disposeRootAndExit(ctx, 7, exit)
await Promise.resolve()
expect(rootDispose).toHaveBeenCalledOnce()
expect(childDispose).not.toHaveBeenCalled()
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(7)
})
it('forces exit when root disposal does not settle', async () => {
vi.useFakeTimers()
try {
let settle!: () => void
const disposal = new Promise<void>((resolve) => { settle = resolve })
const ctx = {
root: { fiber: { dispose: () => disposal } },
} as unknown as Context
const exit = vi.fn()
disposeRootAndExit(ctx, 9, exit)
await vi.advanceTimersByTimeAsync(4_999)
expect(exit).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(9)
settle()
await disposal
await Promise.resolve()
expect(exit).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('exits after a rejected root disposal without an unhandled rejection', async () => {
const ctx = {
root: { fiber: { dispose: () => Promise.reject(new Error('cleanup failed')) } },
} as unknown as Context
const exit = vi.fn()
disposeRootAndExit(ctx, 5, exit)
await Promise.resolve()
await Promise.resolve()
expect(exit).toHaveBeenCalledWith(5)
})
})
describe('terminal mounting', () => {
it('starts immediately when the configured agent already exists', async () => {
const ctx = new Context()