test(pty-local): keep the raw-mode send active until python3 prints its marker

The darwin-parity job failed intermittently on the SIGINT test with the
operation buffer holding only the echoed command line, never RAW_READY. The
harness sets idleSilenceMs to 250, so when a cold python3 start stays silent
past that bound the send settles as inferred_idle; PtySendOperation.append then
drops all later output, and the marker reaches only the scrollback.

Give the harness per-test idleSilenceMs/timeoutMs overrides and let this
scenario raise both above interpreter startup latency, so the readiness marker
lands inside the send it belongs to. waitForOutput's own deadline and the test
timeout grow to match the new bounds.

The product timings are unchanged; the pty Agent Note records why a test that
waits on an operation must outlast the child's startup.
This commit is contained in:
Chinesezjc
2026-07-27 13:45:04 +08:00
parent 79eb3a9035
commit e56afd718d
4 changed files with 24 additions and 9 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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: 148d4a2f47689e38a3ec83a7a41e4f75c4b73d95
2026-07-16-persistent-pty-sessions.zh.md: 9a9d9cd4b0f61e8abaf011996ecd8739d13851f8
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
2026-07-16-persistent-pty-sessions.md: 72d903828f8152009a2dd3433d4f7c86b7382ba6
2026-07-16-persistent-pty-sessions.zh.md: 27dce02618ab7908f38a6b613c109fcbb4b3854c

View File

@@ -80,6 +80,8 @@ On macOS there is no exact syscall tier. Output silence returns `inferred_idle`
Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.tasks`, signal the foreground group, or inspect from another session.
Once a send settles under any tier, `PtySendOperation.append` stops accepting output, so later child output reaches only the scrollback. A test that waits for a marker on the operation must therefore set `idleSilenceMs` and `timeoutMs` above the child's own startup latency; interpreter startup on a loaded macOS runner otherwise ends the send before the marker is printed.
`node-pty` data notifications feed one terminal parser. Parser carry state handles control sequences and a trailing carriage return split across callbacks, so a divided CRLF produces one newline rather than a pagination-changing blank line. The implementation normalizes line-oriented output, but it does not promise correct interaction with a full-screen application.
### Model-visible output and durability

View File

@@ -80,6 +80,8 @@ macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `
Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别调用方可以通过 `ctx.tasks` 等待、向前台组发信号,或从另一个会话排查。
一次 send 在任一层级 settle 之后,`PtySendOperation.append` 就不再接受输出,此后子进程的输出只会进入 scrollback。因此在 operation 上等待标记的测试必须把 `idleSilenceMs``timeoutMs` 设得高于子进程自身的启动耗时;否则在负载较高的 macOS runner 上,解释器启动会在标记打印之前就结束这次 send。
`node-pty` data 通知进入同一个终端 parser。parser 的 carry state 会处理跨 callback 的控制序列和位于 callback 末尾的回车;因此,即使 CRLF 被拆开,也只会生成一个换行,而不会产生改变分页的空行。实现会规范化行式输出,但不承诺正确操作全屏应用。
### 模型可见输出与持久性

View File

@@ -39,7 +39,10 @@ function stubAgent(ctx: Context, rawId: string): Agent {
}
}
async function harness(mode: 'danger-full-access' | 'workspace-write') {
async function harness(
mode: 'danger-full-access' | 'workspace-write',
overrides: { idleSilenceMs?: number; timeoutMs?: number } = {},
) {
const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-'))
roots.push(root)
const ctx = new Context()
@@ -51,8 +54,8 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') {
const fiber = await ctx.plugin(ptyLocal, {
pollIntervalMs: 10,
exactProbeAfterMs: 20,
idleSilenceMs: 250,
timeoutMs: 2000,
idleSilenceMs: overrides.idleSilenceMs ?? 250,
timeoutMs: overrides.timeoutMs ?? 2_000,
disposeGraceMs: 500,
scrollbackLines: 100,
scrollbackMaxBytes: 32_768,
@@ -63,8 +66,10 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') {
return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox }
}
// PtySendOperation.append drops output once the operation settles, so this only
// observes a marker the child prints while the send is still active.
async function waitForOutput(operation: PtySendOperation, expected: string): Promise<void> {
const deadline = Date.now() + 2_000
const deadline = Date.now() + 5_000
let output = ''
while (!output.includes(expected) && Date.now() < deadline) {
output += operation.readOutput().delta
@@ -132,7 +137,13 @@ describe('pty-local real shell', () => {
}, 10_000)
it('cancels a raw-mode foreground process with a real SIGINT', async () => {
const { ctx, agent } = await harness('danger-full-access')
// A cold `python3` start can stay silent for longer than the 250 ms default
// this harness uses, which settles the send as inferred_idle before the
// interpreter prints its readiness marker; the marker then reaches only the
// scrollback and waitForOutput sees the echoed command line alone. Raise the
// silence bound, and the absolute bound above it, so process startup cannot
// end the send it belongs to.
const { ctx, agent } = await harness('danger-full-access', { idleSilenceMs: 4_000, timeoutMs: 6_000 })
const created = await ctx.pty.spawn(agent, { type: 'shell' })
const controller = new AbortController()
const ready = 'RAW_READY'
@@ -155,5 +166,5 @@ describe('pty-local real shell', () => {
expect(after.viewport).toContain('AFTER_SIGINT')
expect(after.waitReason).toBe('stdin_read')
await ctx.pty.kill(agent, created.sessionId)
}, 10_000)
}, 20_000)
})