Files
deepseek-harness/packages/shell/tool-bash/src/background.ts
T
Yichen Jiang 3a03d68891 fix(shell): serve a background spawn failure on the observed stderr stream
A rejected background spawn produces no process output, so the observed stderr reader the job registry pumps was empty and the model's job_output after such a failure showed only killed before exit, where master's consuming read rendered [stderr] followed by spawn failed: … with the cause. The local providers now serve the note as the whole observed stderr stream once the spawn rejected, and the consuming read still folds it in exactly once, so the model's first read after the failure is byte-identical to master's. ShellProcess.observed and both of its readers are required: every background process exposes offset readers, and the shell tools' pull sources no longer carry a no-readers branch.
2026-09-04 12:59:41 +08:00

75 lines
3.7 KiB
TypeScript

/**
* Generic-job adaptation for background bash process handles: the terminal
* outcome the registry records and the pull sources it pumps.
*
* @module @deepseek-ai/dsh-tool-bash/background
*/
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox'
import type { ShellProcess, ShellSandboxInfo } from '@deepseek-ai/dsh-shell'
import type { JobOutcome, JobOutputSource } from '@deepseek-ai/dsh-jobs'
/**
* Sandbox facts worth the terminal detail: a runner that never ran the
* command, or a denial (with the escalation hint this composition offers).
* @param sandbox - settled sandbox facts, when this was a confined process.
* @param escalationModes - escalation targets advertised by this composition.
* @returns the markers to append, oldest first.
*/
function sandboxNotes(sandbox: ShellSandboxInfo | undefined, escalationModes: readonly SandboxMode[]): string[] {
if (sandbox?.runnerFailed) {
return [`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`]
}
if (sandbox?.denied) {
const notes = [sandboxDenialMarker(sandbox.mode)]
if (escalationModes.length > 0) notes.push(escalationHintMarker('command'))
return notes
}
return []
}
/**
* Map a settled background process onto the generic job-outcome vocabulary:
* `killed` stays `killed` (detail: the signal when one is known), everything
* else is `completed` with the exit code as detail. A nonzero command exit is
* reported, not failed, exactly like the foreground rendering. Sandbox facts
* join the detail, since a job's terminal reason is the one line every
* reader — the model's status line, the roster row — shows.
* @param proc - the settled process handle.
* @param escalationModes - escalation targets advertised by this composition.
* @returns the outcome for the `ctx.jobs` registration.
*/
export function processOutcome(proc: ShellProcess, escalationModes: readonly SandboxMode[] = []): JobOutcome {
// TODO(background-infrastructure-outcome): widen ShellProcess with an explicit
// infrastructure-failure outcome, then map it to job `failed`. Restricted
// runner failures expose sandbox.runnerFailed, but unconfined spawn failures
// still alias a signal-less kill; real nonzero command exits must remain
// `completed`.
const base: JobOutcome = proc.status === 'killed'
? { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
: { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
const notes = sandboxNotes(proc.sandbox, escalationModes)
return notes.length === 0 ? base : { ...base, detail: `${base.detail}; ${notes.join(' ')}` }
}
/**
* The process's non-consuming stream readers as registry pull sources. They
* bind lazily because the process is spawned inside the starter, after the
* registry admitted the job; a read before the spawn yields nothing, and the
* pump keeps the model's consuming cursor untouched. A rejected spawn's
* stderr reader carries the provider's `spawn failed: …` note.
* @param proc - the started process, once the starter has spawned it.
* @returns one source per stream, stdout first.
*/
export function processSources(proc: () => ShellProcess | undefined): JobOutputSource[] {
const source = (channel: 'stdout' | 'stderr'): JobOutputSource => ({
channel,
read: (fromByte) => {
const live = proc()
return live === undefined ? { text: '', nextOffset: fromByte, lossy: false } : live.observed[channel].readFrom(fromByte)
},
})
return [source('stdout'), source('stderr')]
}