Repository navigation
feat(managed-agent): H3 background Shell and Monitor runtime #13265
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
bdb04e9
2d70954
3582607
8173837
007289f
0c14259
cddd231
b144c4e
72c5e2c
5a606b7
5cf54e8
9c1437d
4fb9f0a
b6dc42a
a13e7db
24dba6a
424a79a
fd47530
184dd6a
44af8cb
210847d
9b8d875
797f8f7
d976609
3c074ce
bdfcfbe
151d765
8979766
d00ec4f
1e3b6b0
e090205
4724f31
1c9dae7
555130d
b21e5f4
dde1183
fd31234
7b63430
aaaa8a2
b20dbdb
61fb973
1a59457
ce04b21
b8a5bff
bf60c9b
c205831
d17163b
71bb5e7
e44ca7c
46b899f
5b41e53
4777beb
bd653de
36cbe1c
04ea968
c745d31
1d0455d
0485bd7
518cac5
375082f
5969410
f647866
66bb438
707c2a9
6f6b227
325cf02
7647596
2c6c596
accedcf
f38c7db
eb90f96
8e27858
d19c178
a31ddca
5bb9641
c548e1f
bf2d497
ac20c21
46d633a
4ace7d5
a1e9730
225f355
12916c3
e2b401f
fecfb78
47a14bc
b18571e
4b339c9
2dea416
c900927
370d933
a0fd230
2a3688d
b95765d
ce870e6
5986e18
5815784
be54aab
058ff3a
9fca22b
edcbe0e
68ff69a
954d9c5
3cd92c3
3951037
c19022c
abf9687
78e461a
17cf17c
b5c0766
82e0dea
cafc7ec
bd35049
601c6bd
b883b66
15ec440
b618077
117e011
0e01478
3a700a3
2033aca
8d88d6f
2c9a696
74a907a
dad53cb
95cb1fc
3b51fe1
c4d68c7
c370c55
b732391
6c264cb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
…isor
- Loading branch information
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Qwen Team | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import { HookCommandCgroup } from '../hooks/hook-command-cgroup.js'; | ||
| import { | ||
| HookCommandIsolationUnavailableError, | ||
| ManagedChildRunSupervisor, | ||
| } from './managed-child-run-supervisor.js'; | ||
|
|
||
| let directory: string; | ||
| beforeEach(async () => { | ||
| directory = await mkdtemp(join(tmpdir(), 'child-supervisor-')); | ||
| }); | ||
| afterEach(async () => { | ||
| vi.restoreAllMocks(); | ||
| await rm(directory, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| async function fakeUnit(name: string) { | ||
| const dir = join(directory, name); | ||
| await mkdir(dir); | ||
| const unit = Reflect.construct(HookCommandCgroup, [dir]); | ||
| const removed = { value: false }; | ||
| vi.spyOn(unit, 'remove').mockImplementation(() => { | ||
| removed.value = true; | ||
| }); | ||
| return { unit, removed }; | ||
| } | ||
|
|
||
| describe('ManagedChildRunSupervisor', () => { | ||
| it('refuses creation without a delegated root', () => { | ||
| expect(() => | ||
| ManagedChildRunSupervisor.create({ cgroupRoot: undefined }), | ||
| ).toThrow(HookCommandIsolationUnavailableError); | ||
| }); | ||
|
|
||
| it('starts a process whose output and exit evidence are captured', async () => { | ||
| const { unit, removed } = await fakeUnit('qwen-bg-shell-1'); | ||
| const create = vi.spyOn(HookCommandCgroup, 'create').mockReturnValue(unit); | ||
| const supervisor = ManagedChildRunSupervisor.create({ | ||
| cgroupRoot: '/root', | ||
| }); | ||
| const chunks: string[] = []; | ||
| const proc = supervisor.start({ | ||
| unitName: 'qwen-bg-shell-1', | ||
| executable: '/bin/sh', | ||
|
wenshao marked this conversation as resolved.
Outdated
|
||
| args: ['-c', 'printf hello'], | ||
| env: { PATH: '/bin:/usr/bin' }, | ||
| cwd: directory, | ||
| onOutput: (_stream, chunk) => chunks.push(chunk.toString()), | ||
| }); | ||
| expect(create).toHaveBeenCalledWith('/root', 'qwen-bg-shell-1'); | ||
| expect(supervisor.size).toBe(1); | ||
| await new Promise((resolve) => proc.child.once('exit', resolve)); | ||
| await writeFile( | ||
| join(directory, 'qwen-bg-shell-1', 'cgroup.events'), | ||
| 'populated 0\n', | ||
| ); | ||
| expect(chunks.join('')).toBe('hello'); | ||
| expect(proc.evidence).toEqual({ exitCode: 0, exitSignal: null }); | ||
| await expect(proc.terminate(1_000)).resolves.toEqual({ | ||
| exitCode: 0, | ||
| exitSignal: null, | ||
| }); | ||
| expect(removed.value).toBe(true); | ||
| }); | ||
|
|
||
| it('settles a terminated process only after the unit is empty', async () => { | ||
| const { unit, removed } = await fakeUnit('qwen-bg-shell-2'); | ||
| vi.spyOn(HookCommandCgroup, 'create').mockReturnValue(unit); | ||
| const supervisor = ManagedChildRunSupervisor.create({ | ||
| cgroupRoot: '/root', | ||
| }); | ||
| const proc = supervisor.start({ | ||
| unitName: 'qwen-bg-shell-2', | ||
| executable: '/bin/sh', | ||
| args: ['-c', 'sleep 30'], | ||
| env: { PATH: '/bin:/usr/bin' }, | ||
| cwd: directory, | ||
| onOutput: () => undefined, | ||
| }); | ||
| await writeFile( | ||
| join(directory, 'qwen-bg-shell-2', 'cgroup.procs'), | ||
| `${proc.child.pid}\n`, | ||
| ); | ||
| const markEmpty = proc.child.once('exit', () => | ||
| writeFile( | ||
| join(directory, 'qwen-bg-shell-2', 'cgroup.events'), | ||
| 'populated 0\n', | ||
| ), | ||
| ); | ||
| const [evidence] = await Promise.all([proc.terminate(5_000), markEmpty]); | ||
| expect(evidence).toEqual({ exitCode: null, exitSignal: 'SIGTERM' }); | ||
| expect(removed.value).toBe(true); | ||
| }); | ||
|
|
||
| it('keeps the process when emptiness cannot be proven', async () => { | ||
| const { unit, removed } = await fakeUnit('qwen-bg-shell-3'); | ||
| const empty = vi.spyOn(unit, 'empty').mockReturnValue(false); | ||
| vi.spyOn(unit, 'terminate').mockResolvedValue(undefined); | ||
| vi.spyOn(HookCommandCgroup, 'create').mockReturnValue(unit); | ||
| const supervisor = ManagedChildRunSupervisor.create({ | ||
| cgroupRoot: '/root', | ||
| }); | ||
| const proc = supervisor.start({ | ||
| unitName: 'qwen-bg-shell-3', | ||
| executable: '/bin/sh', | ||
| args: ['-c', 'sleep 30'], | ||
| env: { PATH: '/bin:/usr/bin' }, | ||
| cwd: directory, | ||
| onOutput: () => undefined, | ||
| }); | ||
| await expect(proc.terminate(100)).resolves.toBeNull(); | ||
| expect(empty).toHaveBeenCalled(); | ||
| expect(removed.value).toBe(false); | ||
| expect(supervisor.process('qwen-bg-shell-3')).toBe(proc); | ||
| proc.child.kill('SIGKILL'); | ||
| await supervisor.process('qwen-bg-shell-3')?.child.once('exit', () => {}); | ||
| }); | ||
|
|
||
| it('forwards attachment with its root only', () => { | ||
| const attached = { present: true }; | ||
| const attach = vi | ||
| .spyOn(HookCommandCgroup, 'attach') | ||
| .mockReturnValue(attached as unknown as HookCommandCgroup); | ||
| const supervisor = ManagedChildRunSupervisor.create({ | ||
| cgroupRoot: '/root', | ||
| }); | ||
| expect(supervisor.attach('qwen-bg-x')).toBe(attached); | ||
| expect(attach).toHaveBeenCalledWith('/root', 'qwen-bg-x'); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Qwen Team | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { spawn, type ChildProcess } from 'node:child_process'; | ||
| import { | ||
| HookCommandCgroup, | ||
| HookCommandIsolationUnavailableError, | ||
| } from '../hooks/hook-command-cgroup.js'; | ||
|
|
||
| // H3 of #12827: the per-process supervisor for a managed background Shell. | ||
| // Each process lives in its own delegated cgroup v2 unit whose name derives | ||
| // from the execution identity, so a replacement worker re-attaches by name | ||
| // across its own restarts. Exit is claimed only with evidence; a unit that | ||
| // cannot be proven empty keeps the hold instead. See | ||
| // docs/design/2026-10-03-managed-shell-monitor-runtime.md. | ||
|
|
||
| export { HookCommandIsolationUnavailableError }; | ||
|
|
||
| export interface ChildRunExitEvidence { | ||
| readonly exitCode: number | null; | ||
| readonly exitSignal: string | null; | ||
| } | ||
|
|
||
| export interface ChildRunSpawnSpec { | ||
| /** The unit's stable name, derived from the execution identity. */ | ||
| readonly unitName: string; | ||
| readonly executable: string; | ||
| readonly args: readonly string[]; | ||
| readonly env: NodeJS.ProcessEnv; | ||
| readonly cwd: string; | ||
| /** The caller's bounded capture sink, one call per pipe chunk. */ | ||
| readonly onOutput: (stream: 'stdout' | 'stderr', chunk: Buffer) => void; | ||
| } | ||
|
|
||
| export class ManagedChildRunProcess { | ||
| private exitEvidence: ChildRunExitEvidence | null = null; | ||
| private settled = false; | ||
|
|
||
| constructor( | ||
| readonly unitName: string, | ||
| private readonly unit: HookCommandCgroup, | ||
| readonly child: ChildProcess, | ||
| ) { | ||
| child.on('error', () => undefined); | ||
| child.on('exit', (code, signal) => { | ||
| this.exitEvidence = { | ||
| exitCode: code, | ||
| exitSignal: typeof signal === 'string' ? signal : null, | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] R3-21: [certifies-falsely] [new-surface] A background Shell's persisted exit evidence can never carry the command's signal. The A background command is OOM-killed (SIGKILL) or segfaults (SIGSEGV). The record commits Reachability at this commit: Witness: Suggested fix: Have the launcher forward the signal — The fix must not violate an existing fact: managed-child-run-record.ts:226 — 中文说明后台 Shell 持久化的退出证据永远带不上被监督命令的信号。 本提交上的可达性: — qwen3.8-max via Qwen Code /review (v0.25.0) |
||
| }; | ||
| }); | ||
| } | ||
|
|
||
| get exited(): boolean { | ||
| return this.exitEvidence !== null; | ||
| } | ||
|
|
||
| get evidence(): ChildRunExitEvidence | null { | ||
| return this.exitEvidence; | ||
| } | ||
|
|
||
| /** | ||
| * Drains output, then TERM, then `cgroup.kill` after the grace window, and | ||
| * settles only once the unit is proven empty via `cgroup.events` — never on | ||
| * the root process's exit alone. Answers the exit evidence on success and | ||
| * `null` while nothing is proven, in which case the caller keeps the hold. | ||
| */ | ||
| async terminate(graceMs: number): Promise<ChildRunExitEvidence | null> { | ||
| if (this.settled) return this.exitEvidence; | ||
| if (this.exited && this.unit.empty()) { | ||
| this.unit.remove(); | ||
| this.settled = true; | ||
| return this.exitEvidence; | ||
| } | ||
| await this.unit.terminate(graceMs); | ||
| if (!this.unit.empty()) return null; | ||
| this.unit.remove(); | ||
|
Comment on lines
+99
to
+101
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] R3-20: [fails-closed] [new-surface] A background Shell ignores SIGTERM and is escalated to Reachability at this commit: Witness: Suggested fix: Wait for emptiness after the escalation before answering, e.g. The fix must not violate an existing fact: The file header rule at managed-child-run-supervisor.ts:19-20 — "Exit is claimed only with evidence; a unit that cannot be proven empty keeps the hold instead." The bounded wait must still answer 中文说明
本提交上的可达性: — qwen3.8-max via Qwen Code /review (v0.25.0) |
||
| this.settled = true; | ||
| return this.exitEvidence; | ||
| } | ||
| } | ||
|
|
||
| export class ManagedChildRunSupervisor { | ||
| private readonly processes = new Map<string, ManagedChildRunProcess>(); | ||
|
|
||
| private constructor(private readonly cgroupRoot: string) {} | ||
|
|
||
| static create(options: { cgroupRoot: string | undefined }) { | ||
| if (options.cgroupRoot === undefined) | ||
| throw new HookCommandIsolationUnavailableError(); | ||
| return new ManagedChildRunSupervisor(options.cgroupRoot); | ||
| } | ||
|
|
||
| get size(): number { | ||
| return this.processes.size; | ||
| } | ||
|
|
||
| process(unitName: string): ManagedChildRunProcess | undefined { | ||
| return this.processes.get(unitName); | ||
| } | ||
|
|
||
| /** Starts a new process under a fresh unit named after the execution. */ | ||
| start(spec: ChildRunSpawnSpec): ManagedChildRunProcess { | ||
| const unit = HookCommandCgroup.create(this.cgroupRoot, spec.unitName); | ||
| // The launcher joins the unit before the command exists, so no | ||
| // deployment-provided executable or environment is read outside it. | ||
| const launch = unit.launch(spec.executable, [...spec.args], spec.env); | ||
| const child = spawn(launch.executable, launch.args, { | ||
| cwd: spec.cwd, | ||
| env: launch.env, | ||
| stdio: ['ignore', 'pipe', 'pipe'], | ||
| }); | ||
|
wenshao marked this conversation as resolved.
Outdated
|
||
| child.stdout?.on('data', (chunk: Buffer) => spec.onOutput('stdout', chunk)); | ||
| child.stderr?.on('data', (chunk: Buffer) => spec.onOutput('stderr', chunk)); | ||
| const process_ = new ManagedChildRunProcess(spec.unitName, unit, child); | ||
| this.processes.set(spec.unitName, process_); | ||
| return process_; | ||
| } | ||
|
|
||
| /** | ||
| * Re-attaches a unit that a previous incarnation of this worker started: | ||
| * nothing spawns, and the caller verifies the membership evidence itself. | ||
| */ | ||
| attach(unitName: string): HookCommandCgroup | undefined { | ||
| return HookCommandCgroup.attach(this.cgroupRoot, unitName); | ||
| } | ||
|
|
||
| forget(unitName: string): void { | ||
| this.processes.delete(unitName); | ||
| } | ||
|
Comment on lines
+229
to
+231
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] R3-13: The Reachability at this commit: Witness: Suggested fix: 让不变量留在 supervisor 内部:在 The fix must not violate an existing fact: 中文说明
触发场景: 本提交上的可达性: — qwen3.8-max via Qwen Code /review (v0.25.0) |
||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.