Jelajahi Sumber

refactor: separate persistent Bash changes from runtime packaging

fz 1 bulan lalu
induk
melakukan
3b869b6f6d

+ 1 - 1
docs/config-catalog.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 docs/config-catalog.md
-config-catalog.md: 2e4aad7532b061e8328f25a53c2c3b0c4bb4dfa0
+config-catalog.md: 82f6d26c79d32c6952f3bc11c96fa1c2ddceecdc
 config-catalog.zh.md: 958d3115447db37de248bbf30b0744308ff8dbb8

+ 1 - 1
docs/config-catalog.md

@@ -2370,7 +2370,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/shell/tool-bash-persistent/src/index.ts:406`](../packages/shell/tool-bash-persistent/src/index.ts)
+Source: [`packages/shell/tool-bash-persistent/src/index.ts:405`](../packages/shell/tool-bash-persistent/src/index.ts)
 
 <a id="deepseek-aidsh-tool-fs"></a>
 

+ 2 - 2
docs/subsystems/terminal.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 docs/subsystems/terminal.md
-terminal.md: c7031143117a514f8579a22fed07a1461babb15e
-terminal.zh.md: 7c469c028e8b3da7c9d5012f9f49c1ae65a9bd03
+terminal.md: eedf6157b256e83d3b57e07bf738429773b87574
+terminal.zh.md: 3cc2c281e8c2090bb7e92808f932be689fd0b710

+ 1 - 1
docs/subsystems/terminal.md

@@ -180,5 +180,5 @@ list(owner: Agent): TerminalSessionSnapshot[]
 
 Types: [Agent](core.md)
 
-Source: [`packages/terminal/terminal/src/index.ts:108`](../../packages/terminal/terminal/src/index.ts)
+Source: [`packages/terminal/terminal/src/index.ts:105`](../../packages/terminal/terminal/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 1 - 1
docs/subsystems/terminal.zh.md

@@ -180,5 +180,5 @@ list(owner: Agent): TerminalSessionSnapshot[]
 
 Types: [Agent](core.md)
 
-Source: [`packages/terminal/terminal/src/index.ts:108`](../../packages/terminal/terminal/src/index.ts)
+Source: [`packages/terminal/terminal/src/index.ts:105`](../../packages/terminal/terminal/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 1 - 2
packages/shell/tool-bash-persistent/src/index.ts

@@ -7,7 +7,6 @@ import { randomUUID } from 'node:crypto'
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import type { Agent } from '@deepseek-ai/dsh-agent'
-import { CONTROLLED_PROMPT } from '@deepseek-ai/dsh-terminal'
 import type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
 import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
 import { defineTool } from '@deepseek-ai/dsh-tools'
@@ -16,7 +15,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
 const TRUNCATED_MESSAGE = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>'
 const LOST_PREFIX_MESSAGE = '<response clipped><NOTE>The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.</NOTE>\n'
 const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.'
-const SHELL_PROMPT = CONTROLLED_PROMPT
+const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ '
 const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT'
 // One page is enough to find a just-emitted completion marker; the full
 // scrollback is assembled only when a command settles or needs partial output.

+ 2 - 4
packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts

@@ -84,8 +84,8 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => {
       '  config:',
       '    pollIntervalMs: 10',
       '    exactProbeAfterMs: 20',
-      '    idleSilenceMs: 3000',
-      '    handoffGraceMs: 500',
+      '    idleSilenceMs: 100',
+      '    handoffGraceMs: 100',
       '    scrollbackLines: 20000',
       '    timeoutMs: 2000',
       '    disposeGraceMs: 500',
@@ -131,9 +131,7 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => {
     })
 
     expect(context.tools.schemas().map(schema => schema.name)).toEqual(['bash'])
-    const startedAt = Date.now()
     await execute('state', 'export KEEP=loader; mkdir -p nested; cd nested')
-    expect(Date.now() - startedAt).toBeLessThan(2_000)
     const observed = text(await execute('observe', 'printf "cwd=%s keep=%s\\n" "$PWD" "$KEEP"'))
     expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`)
     expect(observed).not.toContain('DSH_PERSISTENT_BASH')

+ 2 - 2
packages/shell/tool-bash-persistent/tests/tools.spec.ts

@@ -4,7 +4,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
 import { Session, SessionId } from '@deepseek-ai/dsh-session'
 import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
-import TerminalSessionService, { CONTROLLED_PROMPT } from '@deepseek-ai/dsh-terminal'
+import TerminalSessionService from '@deepseek-ai/dsh-terminal'
 import type {
   TerminalBackend,
   TerminalBackendSession,
@@ -100,7 +100,7 @@ type StubMode =
   | 'paged-scrollback'
 
 class StubPtySession implements TerminalBackendSession {
-  readonly motd = CONTROLLED_PROMPT
+  readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ '
   readonly pid = 123
   statusValue: TerminalSessionStatus = { kind: 'running' }
   scrollback = this.motd

+ 13 - 67
packages/subprocess/subprocess-local/src/process-inspector.ts

@@ -13,9 +13,9 @@ export interface ProcessIdentity {
 /** Injectable OS process operations used by one local PTY session. */
 export interface ProcessInspector {
   foregroundPgid(shellPid: number): number | undefined
-  isStdinWaiting(pgid: number, scanNamespace?: boolean): boolean
+  isStdinWaiting(pgid: number): boolean
   /** Return the root and its current transitive descendants, children first. */
-  processTree(rootPid: number, scanNamespace?: boolean): ProcessIdentity[]
+  processTree(rootPid: number): ProcessIdentity[]
   /** Return current members of one POSIX process session when the platform exposes them. */
   processSession(sessionId: number): ProcessIdentity[]
   /** Return whether the exact identity remains a non-quiescent process. */
@@ -226,26 +226,12 @@ function syscallWaitsOnStdin(
   return false
 }
 
-function processWaitsOnStdin(
-  internals: ProcessInspectorInternals,
-  pid: number,
-  processGroupId: number,
-  table: SyscallTable,
-): boolean {
-  if (readLinuxStat(internals, pid)?.pgrp !== processGroupId) return false
-  for (const tid of numericEntries(internals, `/proc/${pid}/task`)) {
-    const syscall = readSyscall(internals, pid, tid)
-    if (syscall !== undefined && syscallWaitsOnStdin(internals, pid, syscall, table)) return true
-  }
-  return false
-}
-
 abstract class PosixProcessInspector implements ProcessInspector {
   constructor(protected readonly internals: ProcessInspectorInternals) {}
 
   abstract foregroundPgid(shellPid: number): number | undefined
-  abstract isStdinWaiting(pgid: number, scanNamespace?: boolean): boolean
-  abstract processTree(rootPid: number, scanNamespace?: boolean): ProcessIdentity[]
+  abstract isStdinWaiting(pgid: number): boolean
+  abstract processTree(rootPid: number): ProcessIdentity[]
   abstract processSession(sessionId: number): ProcessIdentity[]
   abstract isAlive(identity: ProcessIdentity): boolean
 
@@ -284,34 +270,6 @@ function processTree(entries: ProcessTreeEntry[], rootPid: number): ProcessIdent
   return result
 }
 
-function linuxProcessTreeFromChildren(
-  internals: ProcessInspectorInternals,
-  rootPid: number,
-): ProcessIdentity[] | undefined {
-  const root = readLinuxStat(internals, rootPid)
-  if (root === undefined) return []
-  const visited = new Set<number>()
-  const result: ProcessIdentity[] = []
-  const visit = (entry: ProcStat): boolean => {
-    if (visited.has(entry.pid)) return true
-    visited.add(entry.pid)
-    let children: string
-    try {
-      children = internals.readFile(`/proc/${entry.pid}/task/${entry.pid}/children`)
-    } catch (_unreadableChildren) {
-      return false
-    }
-    for (const token of children.trim().split(/\s+/)) {
-      if (token.length === 0 || !/^\d+$/.test(token)) continue
-      const child = readLinuxStat(internals, Number(token))
-      if (child !== undefined && !visit(child)) return false
-    }
-    result.push({ pid: entry.pid, started: entry.started })
-    return true
-  }
-  return visit(root) ? result : undefined
-}
-
 class LinuxProcessInspector extends PosixProcessInspector {
   constructor(
     private readonly arch: NodeJS.Architecture,
@@ -325,32 +283,20 @@ class LinuxProcessInspector extends PosixProcessInspector {
     return tpgid !== undefined && tpgid > 0 ? tpgid : undefined
   }
 
-  isStdinWaiting(pgid: number, scanNamespace = true): boolean {
+  isStdinWaiting(pgid: number): boolean {
     const table = SYSCALLS[this.arch]
     if (table === undefined) return false
-    // A POSIX process group is normally led by PID == PGID. Interactive shells
-    // wait on stdin in that leader, so inspect it before walking the whole PID
-    // namespace. Large container PID namespaces otherwise make every PTY
-    // readiness poll scan thousands of unrelated processes.
-    if (processWaitsOnStdin(this.internals, pgid, pgid, table)) return true
-    if (!scanNamespace) return false
     for (const pid of numericEntries(this.internals, '/proc')) {
-      if (pid === pgid) continue
-      if (processWaitsOnStdin(this.internals, pid, pgid, table)) return true
+      if (readLinuxStat(this.internals, pid)?.pgrp !== pgid) continue
+      for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) {
+        const syscall = readSyscall(this.internals, pid, tid)
+        if (syscall !== undefined && syscallWaitsOnStdin(this.internals, pid, syscall, table)) return true
+      }
     }
     return false
   }
 
-  processTree(rootPid: number, scanNamespace = true): ProcessIdentity[] {
-    // Linux exposes each process's direct children without requiring a scan of
-    // the container's whole PID namespace. Fall back for kernels or procfs
-    // mounts that do not provide the children file.
-    const rooted = linuxProcessTreeFromChildren(this.internals, rootPid)
-    if (rooted !== undefined) return rooted
-    if (!scanNamespace) {
-      const root = readLinuxStat(this.internals, rootPid)
-      return root === undefined ? [] : [{ pid: root.pid, started: root.started }]
-    }
+  processTree(rootPid: number): ProcessIdentity[] {
     const entries = numericEntries(this.internals, '/proc').flatMap((pid) => {
       const stat = readLinuxStat(this.internals, pid)
       return stat === undefined ? [] : [{ pid, parentPid: stat.parentPid, started: stat.started }]
@@ -392,11 +338,11 @@ class MacProcessInspector extends PosixProcessInspector {
     }
   }
 
-  isStdinWaiting(_pgid: number, _scanNamespace = true): boolean {
+  isStdinWaiting(_pgid: number): boolean {
     return false
   }
 
-  processTree(rootPid: number, _scanNamespace = true): ProcessIdentity[] {
+  processTree(rootPid: number): ProcessIdentity[] {
     return processTree(macProcessTable(this.internals), rootPid)
   }
 

+ 6 - 10
packages/subprocess/subprocess-local/src/terminal.ts

@@ -57,7 +57,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
     private readonly graceMs: number,
   ) {
     this.pid = terminal.pid
-    this.rootIdentity = inspector.processTree(this.pid, false).find(member => member.pid === this.pid)
+    this.rootIdentity = inspector.processTree(this.pid).find(member => member.pid === this.pid)
     this.done = this.outcome.promise
     this.dataDisposable = terminal.onData((data) => { this.output.write(Buffer.from(data, 'utf8')) })
     this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => {
@@ -81,14 +81,12 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
   // Local inspection is synchronous; the seam returns a promise for remote transports.
   // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract.
   async inspectForeground(): Promise<SubprocessTerminalForeground | undefined> {
-    // Readiness polling may run every few milliseconds. Track the rooted tree
-    // here, but reserve the full process-session sweep for teardown.
-    this.descendants(false, false)
+    this.descendants()
     const processGroupId = this.inspector.foregroundPgid(this.pid)
     if (processGroupId === undefined) return undefined
     return {
       processGroupId,
-      inputWaiting: this.inspector.isStdinWaiting(processGroupId, false),
+      inputWaiting: this.inspector.isStdinWaiting(processGroupId),
     }
   }
 
@@ -143,22 +141,20 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
     return members.filter(member => this.inspector.isAlive(member))
   }
 
-  private descendants(includeSession = true, scanNamespace = true): ProcessIdentity[] {
+  private descendants(): ProcessIdentity[] {
     // Adopt newly scanned members only while the numeric root pid provably
     // still carries the spawned shell's start identity: after the shell dies,
     // a recycled pid's tree and session must not donate an unrelated
     // process's children to this session's signalling. Already-adopted
     // members keep their own start identities, which every signal rechecks.
-    const tree = this.inspector.processTree(this.pid, scanNamespace)
+    const tree = this.inspector.processTree(this.pid)
     const root = tree.find(member => member.pid === this.pid)
     const rootVerified = this.rootIdentity !== undefined
       && root !== undefined
       && root.started === this.rootIdentity.started
     this.trackedDescendants = this.survivors(this.unionMembers(
       this.trackedDescendants,
-      ...rootVerified
-        ? [tree, ...includeSession ? [this.inspector.processSession(this.pid)] : []]
-        : [],
+      ...rootVerified ? [tree, this.inspector.processSession(this.pid)] : [],
     ).filter(member => member.pid !== this.pid))
     return this.trackedDescendants
   }

+ 1 - 70
packages/subprocess/subprocess-local/tests/process-inspector.spec.ts

@@ -95,9 +95,6 @@ describe('Linux process inspector', () => {
     fake.files.set('/proc/11/stat', stat(11, 21, 30, -1, '501'))
     fake.files.set('/proc/12/stat', stat(12, 22, 30, -1, '502', 10))
     fake.files.set('/proc/13/stat', stat(13, 23, 30, -1, '503', 12))
-    fake.files.set('/proc/10/task/10/children', '12')
-    fake.files.set('/proc/12/task/12/children', '13')
-    fake.files.set('/proc/13/task/13/children', '')
     const inspector = createProcessInspector('linux', 'x64', fake.internals)
     expect(inspector.foregroundPgid(10)).toBe(40)
     expect(inspector.foregroundPgid(11)).toBeUndefined()
@@ -127,65 +124,9 @@ describe('Linux process inspector', () => {
     expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
   })
 
-  it('walks a rooted process tree without enumerating the PID namespace', () => {
-    const fake = fakeInternals()
-    fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500'))
-    fake.files.set('/proc/10/task/10/children', '11')
-    fake.files.set('/proc/11/stat', stat(11, 10, 10, 10, '501', 10))
-    fake.files.set('/proc/11/task/11/children', '')
-
-    expect(createProcessInspector('linux', 'x64', fake.internals).processTree(10)).toEqual([
-      { pid: 11, started: '501' },
-      { pid: 10, started: '500' },
-    ])
-  })
-
-  it('contains cycles in the procfs children index', () => {
-    const fake = fakeInternals()
-    fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500'))
-    fake.files.set('/proc/10/task/10/children', '11')
-    fake.files.set('/proc/11/stat', stat(11, 10, 10, 10, '501', 10))
-    fake.files.set('/proc/11/task/11/children', '10')
-
-    expect(createProcessInspector('linux', 'x64', fake.internals).processTree(10)).toEqual([
-      { pid: 11, started: '501' },
-      { pid: 10, started: '500' },
-    ])
-  })
-
-  it('falls back to the PID namespace when a descendant children index is unreadable', () => {
-    const fake = fakeInternals()
-    fake.dirs.set('/proc', ['10', '11', '12'])
-    fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500'))
-    fake.files.set('/proc/10/task/10/children', '12 invalid 11')
-    fake.files.set('/proc/11/stat', stat(11, 10, 10, 10, '501', 10))
-
-    expect(createProcessInspector('linux', 'x64', fake.internals).processTree(10)).toEqual([
-      { pid: 11, started: '501' },
-      { pid: 10, started: '500' },
-    ])
-  })
-
-  it('keeps readiness inspection local when procfs has no children index', () => {
-    const fake = fakeInternals()
-    fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500'))
-    const inspector = createProcessInspector('linux', 'x64', fake.internals)
-
-    expect(inspector.processTree(10, false)).toEqual([{ pid: 10, started: '500' }])
-    expect(inspector.isStdinWaiting(10, false)).toBe(false)
-
-    const readFile = fake.internals.readFile.bind(fake.internals)
-    let statReads = 0
-    fake.internals.readFile = (path) => {
-      if (path === '/proc/10/stat' && statReads++ > 0) throw new Error('process exited')
-      return readFile(path)
-    }
-    expect(inspector.processTree(10, false)).toEqual([])
-  })
-
   it('detects read, select, poll, and epoll waits across non-leader threads', () => {
     const fake = fakeInternals()
-    fake.dirs.set('/proc', ['77', '100', '101'])
+    fake.dirs.set('/proc', ['100', '101'])
     fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
     fake.files.set('/proc/101/stat', stat(101, 77, 100, 77, '2'))
     fake.dirs.set('/proc/100/task', ['100'])
@@ -215,16 +156,6 @@ describe('Linux process inspector', () => {
     expect(inspector.isStdinWaiting(77)).toBe(true)
   })
 
-  it('checks a waiting process-group leader without scanning the PID namespace', () => {
-    const fake = fakeInternals()
-    fake.files.set('/proc/77/stat', stat(77, 77, 77, 77, '1'))
-    fake.dirs.set('/proc/77/task', ['77'])
-    fake.files.set('/proc/77/task/77/syscall', syscall(0, 0))
-
-    const inspector = createProcessInspector('linux', 'x64', fake.internals)
-    expect(inspector.isStdinWaiting(77)).toBe(true)
-  })
-
   it('fails closed on unsupported, malformed, unreadable, or non-stdin waits', () => {
     const fake = fakeInternals()
     fake.dirs.set('/proc', ['100'])

+ 3 - 2
packages/terminal/terminal-bash/src/sanitize.ts

@@ -2,11 +2,12 @@
 
 import { Buffer } from 'node:buffer'
 
-export { CONTROLLED_PROMPT } from '@deepseek-ai/dsh-terminal'
-
 /** OSC marker emitted by the controlled bash before each prompt. */
 export const PROMPT_MARKER_PREFIX = '133;D;'
 
+/** Exact printable prompt emitted after the private marker. */
+export const CONTROLLED_PROMPT = 'dsh> '
+
 /** One sanitized chunk plus whether it contained the owned prompt marker. */
 export interface SanitizedChunk {
   text: string

+ 0 - 3
packages/terminal/terminal/src/index.ts

@@ -42,9 +42,6 @@ export type {
 } from './types.ts'
 export { TerminalBackendCleanupError } from './types.ts'
 
-/** Printable prompt shared by terminal backends and persistent shell consumers. */
-export const CONTROLLED_PROMPT = 'dsh> '
-
 /** Opaque identity minted by {@link TerminalSessionService} for one live PTY session. */
 export type TerminalSessionId = TerminalSessionIdValue