mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge branch 'fix/pty-raw-ready-wait' into fix/pty-handoff-grace
This commit is contained in:
@@ -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: a3e6637985dbcb2a13bcb8f5a79c0a2999162465
|
||||
2026-07-16-persistent-pty-sessions.zh.md: 753d4976543e939bc1480dc03ebe08189956998f
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
|
||||
2026-07-16-persistent-pty-sessions.md: 69468e4a2860c045ae711acdbd0dbef96e092c61
|
||||
2026-07-16-persistent-pty-sessions.zh.md: ceaa5990deb79a263764bc9d6b442c5d6286458f
|
||||
|
||||
@@ -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. When a prompt marker was already seen, Tier 2 waits a further `handoffGraceMs` so a bash foreground handoff that lands on the silence boundary still settles as the exact `stdin_read` attribution instead of the weaker inference; the grace is a deployment-owned config field validated to cover at least one `pollIntervalMs`, because a grace shorter than the poll period cannot contain a single readiness poll and so cannot change any outcome. It bounds only sends that saw a marker, so its cost is the interactive return latency of that one case rather than every send. 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
|
||||
|
||||
@@ -80,6 +80,8 @@ macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `
|
||||
|
||||
Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。如果此前已经见过 prompt marker,Tier 2 会再等待 `handoffGraceMs`,使恰好落在静默边界上的 bash 前台交接仍然以精确的 `stdin_read` 归因结束,而不是退到较弱的推断;该宽限是由部署方拥有的配置字段,并被校验为至少覆盖一个 `pollIntervalMs`——短于轮询周期的宽限装不下一次就绪轮询,因此不可能改变任何结果。它只约束见过 marker 的 send,代价是这一种情况的交互返回延迟,而不是每一次 send。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 被拆开,也只会生成一个换行,而不会产生改变分页的空行。实现会规范化行式输出,但不承诺正确操作全屏应用。
|
||||
|
||||
### 模型可见输出与持久性
|
||||
|
||||
@@ -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; handoffGraceMs?: number; timeoutMs?: number } = {},
|
||||
) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-'))
|
||||
roots.push(root)
|
||||
const ctx = new Context()
|
||||
@@ -51,9 +54,9 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') {
|
||||
const fiber = await ctx.plugin(ptyLocal, {
|
||||
pollIntervalMs: 10,
|
||||
exactProbeAfterMs: 20,
|
||||
idleSilenceMs: 250,
|
||||
handoffGraceMs: 250,
|
||||
timeoutMs: 2000,
|
||||
idleSilenceMs: overrides.idleSilenceMs ?? 250,
|
||||
handoffGraceMs: overrides.handoffGraceMs ?? 250,
|
||||
timeoutMs: overrides.timeoutMs ?? 2_000,
|
||||
disposeGraceMs: 500,
|
||||
scrollbackLines: 100,
|
||||
scrollbackMaxBytes: 32_768,
|
||||
@@ -64,8 +67,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
|
||||
@@ -142,7 +147,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'
|
||||
@@ -165,5 +176,5 @@ describe('pty-local real shell', () => {
|
||||
expect(after.viewport).toContain('AFTER_SIGINT')
|
||||
expectReadyForNextSend(after.waitReason)
|
||||
await ctx.pty.kill(agent, created.sessionId)
|
||||
}, 10_000)
|
||||
}, 20_000)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user