From 85ac747208748c6e28197f6537e1d87c1b22d34c Mon Sep 17 00:00:00 2001 From: NI0317 Date: Tue, 21 Jul 2026 16:12:42 +0800 Subject: [PATCH] fix: wait for PTY startup readiness --- ...26-07-16-persistent-pty-sessions.i18n.yaml | 4 ++-- .../2026-07-16-persistent-pty-sessions.md | 2 +- .../2026-07-16-persistent-pty-sessions.zh.md | 2 +- packages/pty/pty-local/README.md | 2 +- packages/pty/pty-local/src/session.ts | 22 +++++++++++++------ packages/pty/pty-local/tests/session.spec.ts | 19 ++++++++++++++++ 6 files changed, 39 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index 1ba553852e..9c81509ac7 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -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 -2026-07-16-persistent-pty-sessions.md: ef2d149c9d7ba4b1df03f003166f94a2996e5d45 -2026-07-16-persistent-pty-sessions.zh.md: 7f2ee00804b6971b5c64242c8ee84d1631e8b656 +2026-07-16-persistent-pty-sessions.md: 1be87fcd8275b493bc0c552fb34a500a2c8bcce4 +2026-07-16-persistent-pty-sessions.zh.md: ffb0c490197120b6065ddeaf0584263a65ffd61c diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index ef2d149c9d..1be87fcd82 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -70,7 +70,7 @@ With `run_in_background: true`, `dsh-tool-pty` registers the in-flight send on ` ### Local readiness detection -The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then runs three bounded fallback tiers. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, and `timeoutMs`. +The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then runs three bounded fallback tiers. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, and `timeoutMs`. On Linux, the inspector reads the shell's terminal foreground PGID from `/proc//stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1. diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 7f2ee00804..ffb0c49019 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -70,7 +70,7 @@ agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出 ### 本地就绪检测 -本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,再执行 3 个有界 fallback 层级。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs` 和 `timeoutMs`。 +本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,再执行 3 个有界 fallback 层级。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs` 和 `timeoutMs`。 在 Linux 上,检查器从 `/proc//stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。 diff --git a/packages/pty/pty-local/README.md b/packages/pty/pty-local/README.md index 6c1eae3c05..4e38437d50 100644 --- a/packages/pty/pty-local/README.md +++ b/packages/pty/pty-local/README.md @@ -6,7 +6,7 @@ Local `node-pty` backend for `ctx.pty`. It starts an interactive shell under the The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The current session-level sandbox override is resolved at spawn and remains fixed for the PTY lifetime. -Linux readiness combines a private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the prompt marker plus silence/timeout because it has no `/proc` syscall surface. Unrecognized or unreadable process state is never a positive exact-idle signal. +Linux readiness combines a private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the prompt marker plus silence/timeout because it has no `/proc` syscall surface. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. ## Model Experience diff --git a/packages/pty/pty-local/src/session.ts b/packages/pty/pty-local/src/session.ts index af9d7ce121..34d1d189d6 100644 --- a/packages/pty/pty-local/src/session.ts +++ b/packages/pty/pty-local/src/session.ts @@ -148,6 +148,7 @@ export class LocalPtySession implements PtyBackendSession { private activeTimer: NodeJS.Timeout | undefined private activeAbort: (() => void) | undefined private promptSeen = false + private initializing = false private lastOutputAt = Date.now() private closePromise: Promise | undefined @@ -171,13 +172,19 @@ export class LocalPtySession implements PtyBackendSession { /** * Capture startup output through the same readiness contract as later sends. * @param signal - optional cancellation while the shell reaches its first prompt. - * @returns Resolves after startup readiness; rejects if the shell exits. + * @returns Resolves after startup readiness; rejects on exit or readiness timeout. */ async initialize(signal?: AbortSignal): Promise { - const operation = this.startSend({ text: '', submit: false, ...signal !== undefined ? { signal } : {} }) - const result = await operation.done - if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup') - this.motd = result.viewport + this.initializing = true + try { + const operation = this.startSend({ text: '', submit: false, ...signal !== undefined ? { signal } : {} }) + const result = await operation.done + if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup') + if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout') + this.motd = result.viewport + } finally { + this.initializing = false + } } startSend(request: PtySendRequest): PtySendOperation { @@ -289,14 +296,15 @@ export class LocalPtySession implements PtyBackendSession { return } const elapsed = Date.now() - operation.startedAt - if (elapsed >= this.config.exactProbeAfterMs) { + const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0 + if (startupHasOutput && elapsed >= this.config.exactProbeAfterMs) { const pgid = this.inspector.foregroundPgid(this.pid) if (pgid !== undefined && this.inspector.isStdinWaiting(pgid)) { this.settleActive('stdin_read') return } } - if (Date.now() - this.lastOutputAt >= this.config.idleSilenceMs) { + if (startupHasOutput && Date.now() - this.lastOutputAt >= this.config.idleSilenceMs) { this.settleActive('inferred_idle') return } diff --git a/packages/pty/pty-local/tests/session.spec.ts b/packages/pty/pty-local/tests/session.spec.ts index 1dba05b71a..5433fc2653 100644 --- a/packages/pty/pty-local/tests/session.spec.ts +++ b/packages/pty/pty-local/tests/session.spec.ts @@ -219,6 +219,25 @@ describe('LocalPtySession readiness and output', () => { expect(cancellable.cancel()).toBe(true) await expect(cancellable.done).rejects.toThrow('write failed') }) + + it('does not treat zero-output startup silence as readiness and fails on startup timeout', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config()) + let settled = false + const initializing = session.initialize().then(() => { settled = true }) + await vi.advanceTimersByTimeAsync(60) + expect(settled).toBe(false) + terminal.emitData('\x1b]133;D;0\x07dsh> ') + await vi.advanceTimersByTimeAsync(10) + await initializing + + const timeoutTerminal = new FakeTerminal() + const timeout = new LocalPtySession(timeoutTerminal.asPty(), new FakeInspector(), config()) + const timedOut = expect(timeout.initialize()).rejects.toThrow('startup timeout') + await vi.advanceTimersByTimeAsync(100) + await timedOut + }) }) describe('LocalPtySession bounds, signals, and teardown', () => {