Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
#	.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
#	.agents/notes/implemented/feature/2026-07-30-search-render-card.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-30-search-render-card.md
#	.agents/notes/implemented/feature/2026-07-30-search-render-card.zh.md
#	.agents/notes/implemented/feature/2026-07-30-web-read-card.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-30-web-read-card.md
#	.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md
#	.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-30-web-result-card.md
#	.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md
This commit is contained in:
_Kerman
2026-08-04 15:49:07 +08:00
34 changed files with 600 additions and 103 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 apps/cli/README.md
README.md: 360ab26ec3dfecf2841a012fda8947d6a84fdfec
README.zh.md: 3ffa5d7726a798b784c67fdb8c4154fddbdea7a4
README.md: 6fdca68eed11dffe46bf2fbde9a7899359690dca
README.zh.md: d8d7122729df1dd8aaed8207ddfb0a0470778b01

View File

@@ -49,6 +49,8 @@ The production Web runner needs built package and frontend artifacts (`pnpm run
`dsh -p "task"` uses the same base and Web composition with the startup personal config, starts its Web host on an OS-assigned port, runs one fresh persisted session, prints the final answer, and exits. It accepts neither `--config` nor raw config-dump flags.
Web and headless process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If headless normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
Both modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Web watches valid personal config edits; headless reads the file once at startup. The [app-boot personal-config contract](../../packages/ui/app-boot/README.md#personal-config) owns layer precedence, credential storage, live-update failure behavior, and `$DSH_HOME` resolution.
New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.

View File

@@ -49,6 +49,8 @@ dsh web --dump-config
`dsh -p "task"` 使用相同的 base 与 Web 组合及启动时个人配置,在由操作系统分配的端口上启动 Web 宿主,运行一个全新的持久会话,打印最终答案后退出。它不接受 `--config` 或原始配置输出标志。
Web 与 headless 的进程关闭流程最多给插件树 5 秒执行 dispose。第一次 `SIGINT`/`SIGTERM` 会启动这次优雅排空;第二次信号会立即强制退出。如果 headless 的正常完成流程已经卡在 dispose 中,第一次 `Ctrl+C` 就会触发强制退出:进程立即结束,该信号不再被吞掉。
两种模式都以调用目录作为默认 workspace 根目录,加载适用的 `AGENTS.md``CLAUDE.md` 指令,渲染预算为 65,536 字节,并使用内存 SQLite 会话内容索引。Web 会持续应用有效的个人配置编辑headless 只在启动时读取该文件一次。层次优先级、凭据存储、实时更新失败行为与 `$DSH_HOME` 解析均由 [app-boot 个人配置契约](../../packages/ui/app-boot/README.md#personal-config) 统一定义。
新会话默认使用 `workspace-write` 权限 preset。Bash 和文件系统写操作受限于会话 workspace 与平台临时根目录;读取、网络访问与进程可见性不受限制。`DSH_PERMISSION_MODE` 会改变进程回退值。已存储的常规设置权限会影响之后的 Web 会话,不会更改已打开的会话。

View File

@@ -111,17 +111,19 @@
# process out (the launchers patch the row disabled; config cannot disable
# a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.userid,
# random UUID; delete the file to reset the identity) as the Resource's
# user.id. The exporter/processor values bound the shutdown drain to ~1s
# against an unreachable collector: exporter.timeoutMillis is both the
# per-attempt socket timeout and the retry deadline (1s effectively
# disables the SDK's 5-try backoff), maxExportBatchSize == maxQueueSize
# (both explicit) makes the drain a single batch, and exportTimeoutMillis
# is the processor's own cap on that one export cycle — the second bound
# when the exporter's clock alone does not fire. Every CLI exit path drains it
# by disposing the root on SIGINT/SIGTERM.
# user.id. The exporter/processor values normally bound the shutdown drain
# to ~1s against an unreachable collector: exporter.timeoutMillis is both
# the per-attempt socket timeout and the retry deadline (1s effectively
# disables the SDK's 5-try backoff), while maxExportBatchSize == maxQueueSize
# (both explicit) makes the drain a single batch. The SDK awaits
# exporter.forceFlush() outside exportTimeoutMillis, so the backend's 3s
# shutdownTimeoutMillis is the load-bearing outer bound when a transport
# promise never settles. Every CLI exit path drains it by disposing the root
# on SIGINT/SIGTERM.
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
shutdownTimeoutMillis: 3000
exporter:
url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs'
compression: gzip

View File

@@ -14,6 +14,7 @@ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { AppCLIEntry } from './app-cli-entry.ts'
import { createProcessShutdown } from './process-shutdown.ts'
/** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
interface TurnOutcome {
@@ -21,12 +22,12 @@ interface TurnOutcome {
reason: string
}
/** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (dispose first). */
async function unwrap<T>(response: RpcResponse<T>, dispose: () => Promise<void>): Promise<T> {
/** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (shutdown first). */
async function unwrap<T>(response: RpcResponse<T>, shutdown: () => Promise<void>): Promise<T> {
if (response.result.ok) return response.result.value
const { code, message } = response.result.error
process.stderr.write(`dsh: ${code}: ${message}\n`)
await dispose()
await shutdown()
process.exit(1)
}
@@ -93,23 +94,16 @@ export async function runHeadless(task: string): Promise<void> {
port: 0,
})
const { ctx, port } = await entry.run()
const dispose = async (): Promise<void> => { await ctx.fiber.dispose() }
// Signal exits must still dispose the tree: the composition mounts
// exit-drained plugins (telemetry's queued tail and shutdown marker would
// otherwise be lost), and Node's default signal exit skips disposal.
let signalled = false
const disposeAndExit = (code: number): void => {
if (signalled) return
signalled = true
void dispose().finally(() => { process.exit(code) })
}
process.on('SIGTERM', () => { disposeAndExit(143) })
process.on('SIGINT', () => { disposeAndExit(130) })
// Normal completion and signals share one bounded drain. A signal received
// during that drain escalates immediately instead of becoming a no-op.
const shutdown = createProcessShutdown(async () => { await ctx.fiber.dispose() })
process.on('SIGTERM', () => { shutdown.interrupt(143) })
process.on('SIGINT', () => { shutdown.interrupt(130) })
// The headless session is web-observable while it runs (same composition).
process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
const created = await unwrap(await api.sessions.create({}), dispose)
const created = await unwrap(await api.sessions.create({}), () => shutdown.shutdown(1))
// Open the stream before prompting so no frame is lost — kept in this order
// even though in-process delivery has no race, so the code survives a move
@@ -127,11 +121,10 @@ export async function runHeadless(task: string): Promise<void> {
sessionId: created.sessionId,
mode: 'queue',
content: [{ type: 'text', text: task }],
}), dispose)
}), () => shutdown.shutdown(1))
const outcome = await done
process.stdout.write(outcome.text + '\n')
abort.abort()
await dispose()
process.exit(outcome.reason === 'completed' ? 0 : 1)
await shutdown.shutdown(outcome.reason === 'completed' ? 0 : 1)
}

View File

@@ -0,0 +1,58 @@
/** Bounded, escalating process shutdown for the long-lived CLI surfaces. */
/** Maximum grace allowed for the application tree to dispose before process exit. */
export const PROCESS_SHUTDOWN_TIMEOUT_MS = 5_000
/** Process-exit controller shared by normal completion and Unix signal handlers. */
export interface ProcessShutdown {
/** Start or join graceful disposal before exiting with `code`. */
shutdown(code: number): Promise<void>
/** Start graceful disposal, or force exit when a shutdown is already running. */
interrupt(code: number): void
}
/**
* Create one process-exit controller around an application disposer.
* @param dispose - Whole-application teardown that resolves at quiescence.
* @param exit - Process exit boundary, replaceable by tests.
* @param timeoutMs - Grace before forced exit, replaceable by tests.
* @returns A controller whose normal calls coalesce and whose repeated signal call escalates.
*/
export function createProcessShutdown(
dispose: () => Promise<void>,
exit: (code: number) => void = (code) => { process.exit(code) },
timeoutMs = PROCESS_SHUTDOWN_TIMEOUT_MS,
): ProcessShutdown {
let pending: Promise<void> | undefined
let timeout: ReturnType<typeof setTimeout> | undefined
let exited = false
const exitOnce = (code: number): void => {
if (exited) return
exited = true
/* v8 ignore else -- shutdown() arms the timer before any asynchronous exit path can run. */
if (timeout !== undefined) clearTimeout(timeout)
exit(code)
}
const shutdown = (code: number): Promise<void> => {
if (pending !== undefined) return pending
timeout = setTimeout(() => { exitOnce(code) }, timeoutMs)
pending = Promise.resolve().then(dispose).then(
() => { exitOnce(code) },
() => { exitOnce(code) },
)
return pending
}
return {
shutdown,
interrupt(code) {
if (pending !== undefined) {
exitOnce(code)
return
}
void shutdown(code)
},
}
}

View File

@@ -13,6 +13,7 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tool-bash'
import { AppCLIEntry } from './app-cli-entry.ts'
import { createProcessShutdown } from './process-shutdown.ts'
// The shipped base plus the Web application's overlay.
const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
@@ -118,17 +119,12 @@ export async function runWeb(
const { ctx, port: boundPort } = await entry.run()
const resolvedLocalWebUrl = localWebUrl(ctx)
let exiting = false
const shutdown = (code: number): void => {
if (exiting) return
exiting = true
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
}
const shutdown = createProcessShutdown(async () => { await ctx.fiber.dispose() })
// Install shutdown handling before publishing readiness: supervisors may
// send a signal as soon as they observe the URL line.
process.on('SIGTERM', () => { shutdown(0) })
process.on('SIGINT', () => { shutdown(130) })
process.on('SIGTERM', () => { shutdown.interrupt(0) })
process.on('SIGINT', () => { shutdown.interrupt(130) })
// The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
// must name an address the /api trust fence was configured with.

View File

@@ -0,0 +1,18 @@
/** Test-only Cordis plugin whose disposer announces entry and never settles. */
import { existsSync } from 'node:fs'
/**
* Register a disposer that keeps process shutdown pending until it is forced.
* @param {import('cordis').Context} ctx - loader-mounted test plugin context.
*/
export function apply(ctx) {
const keepAlive = setInterval(() => {}, 60_000)
ctx.effect(() => async () => {
clearInterval(keepAlive)
const armFile = process.env.DSH_TEST_SHUTDOWN_ARM_FILE
if (armFile === undefined || !existsSync(armFile)) return
process.stderr.write('dsh-test: never-dispose started\n')
await new Promise(() => {})
})
}

View File

@@ -0,0 +1,121 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { execa } from 'execa'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
const neverDisposePlugin = pathToFileURL(
fileURLToPath(new URL('./fixtures/never-dispose.mjs', import.meta.url)),
).href
const POSIX_HEADLESS_PTY_DRIVER = String.raw`
import errno, json, os, pty, select, signal, sys, time
node, launch_args_json, launch_env_json, cwd, timeout_seconds = sys.argv[1:]
env = os.environ.copy()
env.update(json.loads(launch_env_json))
pid, fd = pty.fork()
if pid == 0:
os.chdir(cwd)
os.execvpe(node, [node, *json.loads(launch_args_json)], env)
markers = [b"dsh: observing at ", b"dsh-test: never-dispose started"]
output = bytearray()
marker_index = 0
deadline = time.monotonic() + float(timeout_seconds)
status = None
while time.monotonic() < deadline:
ready, _, _ = select.select([fd], [], [], 0.05)
if ready:
try:
chunk = os.read(fd, 65536)
except OSError as error:
if error.errno != errno.EIO:
raise
chunk = b""
if chunk:
output.extend(chunk)
while marker_index < len(markers) and markers[marker_index] in output:
if marker_index == 0:
open(os.path.join(cwd, "shutdown-armed"), "w").close()
os.write(fd, b"\x03")
marker_index += 1
waited, candidate = os.waitpid(pid, os.WNOHANG)
if waited == pid:
status = candidate
break
if status is None:
os.kill(pid, signal.SIGKILL)
_, status = os.waitpid(pid, 0)
sys.stdout.buffer.write(output)
if marker_index != len(markers):
sys.stderr.write(f"completed {marker_index}/{len(markers)} PTY actions before timeout\n")
sys.exit(124)
actual_exit = os.waitstatus_to_exitcode(status)
if actual_exit != 130:
sys.stderr.write(f"expected exit 130, got {actual_exit}\n")
sys.exit(125)
`
async function runHeadlessPtySmoke(): Promise<string> {
const cwd = await mkdtemp(join(tmpdir(), 'dsh-headless-shutdown-'))
try {
const home = join(cwd, '.dsh')
await mkdir(home, { recursive: true })
await writeFile(join(home, 'config.yaml'), [
'- insert:',
' - id: never-dispose',
` name: '${neverDisposePlugin}'`,
'',
].join('\n'))
const launch = resolveExampleLaunch({
srcBin: dshBinScript,
configArgs: ['-p', 'never complete'],
tsconfigPath,
env: {
DSH_HOME: home,
DSH_AGENTS_HOME: join(cwd, '.agents'),
DEEPSEEK_API_KEY: 'keyless-shutdown-no-call',
DSH_TELEMETRY_DISABLED: '1',
DSH_TEST_SHUTDOWN_ARM_FILE: join(cwd, 'shutdown-armed'),
},
})
const timeoutMs = 15_000
const result = await execa('python3', [
'-c',
POSIX_HEADLESS_PTY_DRIVER,
launch.command,
JSON.stringify(launch.args),
JSON.stringify(launch.env),
cwd,
String(timeoutMs / 1_000),
], {
stdin: 'ignore',
timeout: timeoutMs + 5_000,
killSignal: 'SIGKILL',
reject: false,
stripFinalNewline: false,
})
if (result.timedOut) {
throw new Error(`dsh headless PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
}
if (result.failed) {
throw new Error(`dsh headless PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
}
return result.stdout
} finally {
await rm(cwd, { recursive: true, force: true })
}
}
describe.skipIf(process.platform === 'win32')('headless process shutdown (real Loader tree in a PTY)', () => {
it('lets a second Ctrl+C force exit while the first signal is draining', async () => {
const output = await runHeadlessPtySmoke()
expect(output).toContain('dsh: observing at ')
expect(output).toContain('dsh-test: never-dispose started')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createProcessShutdown,
PROCESS_SHUTDOWN_TIMEOUT_MS,
} from '../src/process-shutdown.ts'
function deferred(): { promise: Promise<void>; resolve: () => void; reject: (error: Error) => void } {
let resolve!: () => void
let reject!: (error: Error) => void
const promise = new Promise<void>((accept, fail) => {
resolve = accept
reject = fail
})
return { promise, resolve, reject }
}
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('process shutdown', () => {
it('exits once after graceful disposal resolves or rejects', async () => {
const resolvedExit = vi.fn()
const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit)
await resolved.shutdown(0)
expect(resolvedExit).toHaveBeenCalledOnce()
expect(resolvedExit).toHaveBeenCalledWith(0)
const rejectedExit = vi.fn()
const rejected = createProcessShutdown(() => Promise.reject(new Error('dispose failed')), rejectedExit)
await rejected.shutdown(1)
expect(rejectedExit).toHaveBeenCalledOnce()
expect(rejectedExit).toHaveBeenCalledWith(1)
})
it('uses process.exit as the default process boundary', async () => {
const exit = vi.spyOn(process, 'exit').mockImplementation(_code => undefined as never)
const shutdown = createProcessShutdown(() => Promise.resolve())
await shutdown.shutdown(7)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(7)
})
it('forces exit when graceful disposal reaches its bound', async () => {
vi.useFakeTimers()
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const pending = shutdown.shutdown(0)
await vi.advanceTimersByTimeAsync(PROCESS_SHUTDOWN_TIMEOUT_MS - 1)
expect(exit).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(0)
disposal.resolve()
await pending
expect(exit).toHaveBeenCalledOnce()
})
it('honors a caller-supplied grace period', async () => {
vi.useFakeTimers()
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit, 25)
const pending = shutdown.shutdown(0)
await vi.advanceTimersByTimeAsync(24)
expect(exit).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(exit).toHaveBeenCalledOnce()
disposal.resolve()
await pending
})
it('lets Ctrl+C force a normal shutdown already stuck in disposal', async () => {
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const pending = shutdown.shutdown(0)
shutdown.interrupt(130)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(130)
disposal.resolve()
await pending
expect(exit).toHaveBeenCalledOnce()
})
it('drains on the first signal and forces on the second signal', async () => {
const disposal = deferred()
const dispose = vi.fn(() => disposal.promise)
const exit = vi.fn()
const shutdown = createProcessShutdown(dispose, exit)
shutdown.interrupt(143)
await Promise.resolve()
expect(dispose).toHaveBeenCalledOnce()
expect(exit).not.toHaveBeenCalled()
shutdown.interrupt(130)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(130)
disposal.resolve()
await shutdown.shutdown(0)
expect(exit).toHaveBeenCalledOnce()
})
it('coalesces normal shutdown calls without treating them as escalation', async () => {
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const first = shutdown.shutdown(0)
const second = shutdown.shutdown(1)
expect(second).toBe(first)
expect(exit).not.toHaveBeenCalled()
disposal.resolve()
await first
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(0)
})
})