Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
131 commits
Select commit Hold shift + click to select a range
bdb04e9
docs(managed-agent): design H3 background Shell and Monitor runtime
wenshao Oct 3, 2026
2d70954
feat(core): add managed child_run (kind shell) record body for H3
wenshao Oct 3, 2026
3582607
feat(managed-agent): mirror child_run shell record body in Java store
wenshao Oct 3, 2026
8173837
test(core): commit and rebuild child_run shell chains through the ses…
wenshao Oct 3, 2026
007289f
feat(managed-agent): close child_run reference closure and add stop-r…
wenshao Oct 3, 2026
0c14259
feat(core): add named cgroup unit attach and managed child-run superv…
wenshao Oct 3, 2026
cddd231
feat(cli): admit v3 background shell under the supervised worker regi…
wenshao Oct 3, 2026
b144c4e
Merge branch 'main' into docs/h3-shell-monitor-design
wenshao Oct 3, 2026
72c5e2c
feat(core): add open-ended shell stream capture with manifest revisions
wenshao Oct 3, 2026
5a606b7
Merge remote-tracking branch 'origin/docs/h3-shell-monitor-design' in…
wenshao Oct 3, 2026
5cf54e8
fix(core): type-narrow stream capture finalize and refused publish do…
wenshao Oct 3, 2026
9c1437d
fix(cli): close background shell type gaps after the main merge
wenshao Oct 3, 2026
4fb9f0a
fix(managed-agent): answer the R1 review round on the child_run contract
wenshao Oct 3, 2026
b6dc42a
fix(managed-agent): repair the CI-only cgroup root and env-guard fail…
wenshao Oct 3, 2026
a13e7db
feat(managed-agent): inject the child-run supervisor at worker boot
wenshao Oct 3, 2026
24dba6a
docs(managed-agent): pin the two-row ledger shape for background proc…
wenshao Oct 3, 2026
424a79a
feat(managed-agent): commit child_run shell lines from the hosted side
wenshao Oct 4, 2026
fd47530
feat(managed-agent): add the detached capture family to Tool v3 results
wenshao Oct 4, 2026
184dd6a
fix(managed-agent): answer the R2 review round on worker, capture and…
wenshao Oct 4, 2026
44af8cb
feat(managed-agent): thread the child-run orchestrator and the detach…
wenshao Oct 4, 2026
210847d
test(cli): type the background-shell capture double
wenshao Oct 4, 2026
9b8d875
feat(managed-agent): admit background Shell starts from the hosted to…
wenshao Oct 4, 2026
797f8f7
feat(managed-agent): admit background Tool v3 dispatches and acknowle…
wenshao Oct 4, 2026
d976609
feat(managed-agent): answer shell-status and shell-terminate from the…
wenshao Oct 4, 2026
3c074ce
feat(managed-agent): admit the background process row and answer its …
wenshao Oct 4, 2026
bdfcfbe
fix(managed-agent): answer maintenance views under the target identity
wenshao Oct 4, 2026
151d765
feat(managed-agent): run the background exit leg through the record
wenshao Oct 4, 2026
8979766
feat(managed-agent): close monitor_run revisions over their cited Ses…
wenshao Oct 4, 2026
d00ec4f
feat(managed-agent): gate monitor_run behind the managed-session/2 re…
wenshao Oct 4, 2026
1e3b6b0
feat(managed-agent): funnel monitor_run revisions through a hosted or…
wenshao Oct 4, 2026
e090205
feat(managed-agent): drive monitor observations through a debounced loop
wenshao Oct 4, 2026
4724f31
feat(managed-agent): notify from monitor observations in the same tra…
wenshao Oct 4, 2026
1c9dae7
fix(managed-agent): keep a finished background Shell's receipt answer…
wenshao Oct 4, 2026
555130d
fix(managed-agent): settle provable background exits before release's…
wenshao Oct 4, 2026
b21e5f4
fix(managed-agent): check the journaled receipt before attaching a ba…
wenshao Oct 4, 2026
dde1183
fix(managed-agent): backpressure the background Shell output pipes
wenshao Oct 4, 2026
fd31234
feat(managed-agent): promote the Shell publisher to Session scope
wenshao Oct 4, 2026
7b63430
feat(managed-agent): drain a Session's background Shells ahead of rel…
wenshao Oct 4, 2026
aaaa8a2
docs(managed-agent): sync the H3 design's status line with landed slices
wenshao Oct 4, 2026
b20dbdb
feat(managed-agent): spawn Monitor watches through a cgroup watcher
wenshao Oct 4, 2026
61fb973
feat(managed-agent): answer monitor status and stop from a worker reg…
wenshao Oct 4, 2026
1a59457
feat(managed-agent): admit monitor watches into the open-ended captur…
wenshao Oct 4, 2026
ce04b21
feat(managed-agent): admit Monitor watches through the v3 executor
wenshao Oct 4, 2026
b8a5bff
feat(managed-agent): admit Monitor watches through the hosted tool turn
wenshao Oct 4, 2026
bf60c9b
feat(managed-agent): fan a monitor watch's observations into the host…
wenshao Oct 4, 2026
c205831
feat(managed-agent): rebuild a read-only Monitor after its Runtime is…
wenshao Oct 4, 2026
d17163b
feat(managed-agent): serve the task events routes with their SQL journal
wenshao Oct 4, 2026
71bb5e7
docs(managed-agent): add the task events lane's build report
wenshao Oct 4, 2026
e44ca7c
feat(managed-agent): build the monitor wake envelope and the pending-…
wenshao Oct 4, 2026
46b899f
merge: bring the task events SQL journal lane into the H3 branch
wenshao Oct 4, 2026
5b41e53
feat(managed-agent): deliver the Legacy notification envelope on moni…
wenshao Oct 4, 2026
4777beb
feat(managed-agent): run the monitor wake through an embedded scheduler
wenshao Oct 4, 2026
bd653de
fix(managed-agent): keep every new Session on the managed-session/1 r…
wenshao Oct 4, 2026
36cbe1c
fix(managed-agent): keep monitor output byte-true and advance only fo…
wenshao Oct 4, 2026
04ea968
feat(managed-agent): drain a busy background Shell at release, and an…
wenshao Oct 4, 2026
c745d31
fix(managed-agent): re-assert the output pause behind every flushed c…
wenshao Oct 4, 2026
1d0455d
Merge remote-tracking branch 'origin/main' into docs/h3-shell-monitor…
wenshao Oct 4, 2026
0485bd7
merge: absorb main's latest into the H3 branch, task journal at V40
wenshao Oct 4, 2026
518cac5
test(integration): expect the monitor tool on the hosted shell profile
wenshao Oct 4, 2026
375082f
fix(managed-agent): read the whole journal when deriving pending moni…
wenshao Oct 4, 2026
5969410
fix(managed-agent): stop a full task journal from wedging record commits
wenshao Oct 4, 2026
f647866
refactor(managed-agent): drop the shell view's never-set error field
wenshao Oct 4, 2026
66bb438
fix(managed-agent): let a replayed output advance pass instead of ref…
wenshao Oct 4, 2026
707c2a9
chore(managed-agent): keep the task-events lane report out of the PR …
wenshao Oct 4, 2026
6f6b227
merge: absorb main's #13388 and retool the H8 lock discipline onto it
wenshao Oct 4, 2026
325cf02
fix(managed-agent): quiet the wake pump's consume guard at a blocked …
wenshao Oct 4, 2026
7647596
fix(managed-agent): meld a monitor watch through one terminal step
wenshao Oct 4, 2026
2c6c596
fix(managed-agent): settle the process row when the Runtime proves no…
wenshao Oct 4, 2026
accedcf
fix(managed-agent): attribute wake receipts to their own turn and sto…
wenshao Oct 4, 2026
f38c7db
fix(managed-agent): read the task event floor after the events, not b…
wenshao Oct 4, 2026
eb90f96
fix(managed-agent): move the task artifact references by compare-and-set
wenshao Oct 4, 2026
8e27858
test(managed-agent): straighten three small voice and fixture drifts
wenshao Oct 4, 2026
d19c178
fix(managed-agent): admit named cgroup units and keep fast-exit evidence
wenshao Oct 4, 2026
a31ddca
fix(managed-agent): account capture pages from the batch they publish
wenshao Oct 4, 2026
5bb9641
fix(managed-agent): answer the detached capture family in the validator
wenshao Oct 4, 2026
c548e1f
fix(managed-agent): harden the admission, capture and hold edges of t…
wenshao Oct 4, 2026
bf2d497
test(managed-agent): pin the unproven-end, early-line and settled-res…
wenshao Oct 4, 2026
ac20c21
fix(managed-agent): ground the release sweep on physical stops
wenshao Oct 4, 2026
46d633a
fix(managed-agent): never step a terminal run line back to life
wenshao Oct 4, 2026
4ace7d5
Merge remote-tracking branch 'origin/main' into docs/h3-shell-monitor…
wenshao Oct 5, 2026
a1e9730
Merge remote-tracking branch 'origin/main' into docs/h3-shell-monitor…
wenshao Oct 5, 2026
225f355
fix(managed-agent): close the wake path's lost-turnup and haunted turns
wenshao Oct 5, 2026
12916c3
fix(managed-agent): straighten two slice-D indentation slips
wenshao Oct 5, 2026
e2b401f
fix(managed-agent): let refused finalizes retry and commit ownerless …
wenshao Oct 5, 2026
fecfb78
fix(managed-agent): keep monitor wake turns off prompt claims and rec…
wenshao Oct 5, 2026
47a14bc
fix(managed-agent): settle unstarted background siblings on reconcile…
wenshao Oct 5, 2026
b18571e
Merge remote-tracking branch 'origin/main' into docs/h3-shell-monitor…
wenshao Oct 5, 2026
4b339c9
fix(managed-agent): renumber the task journal migration past main's V40
wenshao Oct 5, 2026
2dea416
fix(managed-agent): classify wake recovery exceptions by type, never …
wenshao Oct 5, 2026
c900927
fix(managed-agent): re-drive a refused background settle once the rec…
wenshao Oct 5, 2026
370d933
test(managed-agent): opt token-bearing store fixtures into non-loopba…
wenshao Oct 5, 2026
a0fd230
fix(managed-agent): own the background capture lane in publication mode
wenshao Oct 5, 2026
2a3688d
fix(managed-agent): demand unit emptiness before settling a natural end
wenshao Oct 5, 2026
b95765d
fix(managed-agent): let only the same capture's lineage advance a rec…
wenshao Oct 5, 2026
ce870e6
fix(managed-agent): admit the native Monitor payload through the v3 gate
wenshao Oct 5, 2026
5986e18
Merge remote-tracking branch 'origin/main' into docs/h3-shell-monitor…
wenshao Oct 5, 2026
5815784
fix(managed-agent): prove, refuse and resume correctly at the v3 admi…
wenshao Oct 5, 2026
be54aab
fix(managed-agent): count pending turn, truncate and register resumes…
wenshao Oct 5, 2026
058ff3a
fix(managed-agent): answer the natural end's evidence when its settle…
wenshao Oct 5, 2026
9fca22b
fix(managed-agent): let foreground and background captures pick their…
wenshao Oct 5, 2026
edcbe0e
fix(managed-agent): enable the Monitor line on the publication lane
wenshao Oct 5, 2026
68ff69a
fix(managed-agent): retry a thrown record forward and refuse before a…
wenshao Oct 5, 2026
954d9c5
Merge branch 'main' into docs/h3-shell-monitor-design
wenshao Oct 5, 2026
3cd92c3
fix(managed-agent): settle a started monitor wake in the cancelled Ho…
wenshao Oct 5, 2026
3951037
fix(managed-agent): admit a Monitor only where its own validation adm…
wenshao Oct 5, 2026
c19022c
Merge remote-tracking branch 'origin/docs/h3-shell-monitor-design' in…
wenshao Oct 5, 2026
abf9687
Merge remote-tracking branch 'origin/main' into docs/h3-shell-monitor…
wenshao Oct 5, 2026
78e461a
fix(managed-agent): settle the admitted run records the recovery catc…
wenshao Oct 5, 2026
17cf17c
fix(managed-agent): sweep durable background rows the fresh broker ne…
wenshao Oct 5, 2026
b5c0766
fix(managed-agent): name the isolation cause in the recorded start re…
wenshao Oct 5, 2026
82e0dea
fix(managed-agent): share the capture status rule and announce a blin…
wenshao Oct 5, 2026
cafc7ec
fix(managed-agent): narrow the shared status rule at the result envel…
wenshao Oct 5, 2026
bd35049
fix(managed-agent): redrive a refused final forward on an attached re…
wenshao Oct 5, 2026
601c6bd
Merge remote-tracking branch 'origin/main' into docs/h3-shell-monitor…
wenshao Oct 5, 2026
b883b66
fix(managed-agent): renumber the task journal migration past main's V44
wenshao Oct 5, 2026
15ec440
fix(managed-agent): restore monitor-bearing sessions and pin the clos…
wenshao Oct 5, 2026
b618077
test(managed-agent): cover the write-arm forward retry on the next ed…
wenshao Oct 5, 2026
117e011
fix(managed-agent): keep the publication-installed flag on the publis…
wenshao Oct 5, 2026
0e01478
test(managed-agent): de-race the redrive witness behind fake timers
wenshao Oct 5, 2026
3a700a3
fix(managed-agent): bound the refused-finalize redrive and wait it in…
wenshao Oct 5, 2026
2033aca
fix(managed-agent): forward a blind-capture revision to the record as…
wenshao Oct 5, 2026
8d88d6f
fix(managed-agent): freeze each stream at its flush boundary and fly …
wenshao Oct 5, 2026
2c9a696
fix(managed-agent): admit the one settled leg over a fully sealed man…
wenshao Oct 5, 2026
74a907a
fix(managed-agent): stop every monitor observation loop before the Se…
wenshao Oct 5, 2026
dad53cb
fix(managed-agent): resolve the background Shell environment for its …
wenshao Oct 5, 2026
95cb1fc
test(managed-agent): pin the created-only cleanup of a failed cgroup …
wenshao Oct 5, 2026
3b51fe1
fix(managed-agent): flush every stream's page on its own boundary bef…
wenshao Oct 6, 2026
c4d68c7
fix(managed-agent): wait every re-drive that starts inside the publis…
wenshao Oct 6, 2026
c370c55
fix(managed-agent): verify a detached capture by its own record linea…
wenshao Oct 6, 2026
b732391
fix(managed-agent): resolve the background environment for the tool s…
wenshao Oct 6, 2026
6c264cb
fix(managed-agent): park an unconfirmed stop on runtime_lost instead …
wenshao Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(core): add named cgroup unit attach and managed child-run superv…
…isor
  • Loading branch information
wenshao committed Oct 3, 2026
commit 0c1425926eff15fdba8e67ac1274957dcd4b233f
22 changes: 21 additions & 1 deletion packages/core/src/hooks/hook-command-cgroup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { HookCommandCgroup } from './hook-command-cgroup.js';
import {
HookCommandCgroup,
HookCommandIsolationUnavailableError,
} from './hook-command-cgroup.js';

let directory: string;
beforeEach(async () => {
Expand All @@ -19,6 +22,23 @@ afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});

describe('unit creation and attachment', () => {
it('refuses to create or attach without a delegated Linux root', () => {
expect(() => HookCommandCgroup.create(undefined)).toThrow(
HookCommandIsolationUnavailableError,
);
expect(() =>
HookCommandCgroup.create(join(directory, 'not-a-cgroup')),
).toThrow(HookCommandIsolationUnavailableError);
expect(() =>
HookCommandCgroup.create(join(directory, 'not-a-cgroup'), 'qwen-bg-1'),
).toThrow(HookCommandIsolationUnavailableError);
expect(() =>
HookCommandCgroup.attach(join(directory, 'not-a-cgroup'), 'qwen-bg-1'),
).toThrow(HookCommandIsolationUnavailableError);
});
});

describe('managed command launcher environment', () => {
it('keeps environment values out of argv and applies them only after membership', async () => {
const preload = join(directory, 'preload.cjs');
Expand Down
57 changes: 45 additions & 12 deletions packages/core/src/hooks/hook-command-cgroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,20 +48,28 @@ child.on('exit', (code) => process.exit(code ?? 1));
export class HookCommandCgroup {
private constructor(readonly directory: string) {}

static create(root: string | undefined): HookCommandCgroup {
private static resolveRoot(root: string | undefined): string {
if (process.platform !== 'linux' || !root || !isAbsolute(root))
throw new HookCommandIsolationUnavailableError();
const resolved = realpathSync(root);
if (statfsSync(resolved).type !== 0x63677270)
throw new HookCommandIsolationUnavailableError();
if (readFileSync(join(resolved, 'cgroup.type'), 'utf8').trim() !== 'domain')
throw new HookCommandIsolationUnavailableError();
return resolved;
}

static create(
root: string | undefined,
unitName?: string,
): HookCommandCgroup {
let directory: string | undefined;
let created = false;
try {
if (process.platform !== 'linux' || !root || !isAbsolute(root))
throw new HookCommandIsolationUnavailableError();
const resolved = realpathSync(root);
if (statfsSync(resolved).type !== 0x63677270)
throw new HookCommandIsolationUnavailableError();
if (
readFileSync(join(resolved, 'cgroup.type'), 'utf8').trim() !== 'domain'
)
throw new HookCommandIsolationUnavailableError();
directory = join(resolved, `qwen-hook-${randomUUID()}`);
const resolved = HookCommandCgroup.resolveRoot(root);
directory = join(resolved, unitName ?? `qwen-hook-${randomUUID()}`);
Comment thread
wenshao marked this conversation as resolved.
mkdirSync(directory, { mode: 0o700 });
created = true;
const unit = new HookCommandCgroup(directory);
if (!unit.empty()) throw new HookCommandIsolationUnavailableError();
for (const file of ['cgroup.procs', 'cgroup.kill']) {
Expand All @@ -70,7 +78,9 @@ export class HookCommandCgroup {
}
return unit;
} catch {
if (directory) {
// Only a unit this call created may be removed: a named unit that
// already exists belongs to whoever made it, never to us.
if (created && directory) {
Comment thread
wenshao marked this conversation as resolved.
try {
rmdirSync(directory);
} catch {
Expand All @@ -81,6 +91,29 @@ export class HookCommandCgroup {
}
}

/**
* Opens a unit somebody else created, for a worker that (re)attaches a
* supervised process after a replacement. A missing unit answers
* `undefined`; an unusable root answers the isolation error, never a guess.
*/
static attach(
root: string | undefined,
unitName: string,
): HookCommandCgroup | undefined {
const resolved = HookCommandCgroup.resolveRoot(root);
if (unitName.includes('/') || unitName.includes('')) return undefined;
let directory: string;
try {
directory = realpathSync(join(resolved, unitName));
if (!directory.startsWith(resolved + '/')) return undefined;
if (statfsSync(directory).type !== 0x63677270) return undefined;
readFileSync(join(directory, 'cgroup.events'), 'utf8');
} catch {
return undefined;
}
return Reflect.construct(HookCommandCgroup, [directory]);
}

launch(executable: string, args: string[], env: NodeJS.ProcessEnv) {
return {
executable: process.execPath,
Expand Down
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',
Comment thread
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');
});
});
133 changes: 133 additions & 0 deletions packages/core/src/managed-runtime/managed-child-run-supervisor.ts
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,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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 HookCommandCgroup launcher ends with child.on('exit', (code) => process.exit(code ?? 1)); — it drops the signal and exits 1. This PR makes that launcher the root process of a supervised background Shell (managed-child-run-supervisor.ts, new) and persists its exit facts as the Shell's evidence (managed-child-run-record.ts, new), so exitSignal is only ever reachable from the terminate path, where the launcher itself is signalled.

A background command is OOM-killed (SIGKILL) or segfaults (SIGSEGV). The record commits exitCode: 1 (or 139, which is the outer shell's 128+signal encoding, not the command's exit code at all) with exitSignal: null, under the invariant "Child run exitCode or exitSignal is proven exactly when it exits" — for a command that never exited. managed-background-shell-registry.ts:216 persists signal: signalNumber(null) === null and :232-236 seals the capture with 'Background Shell exited nonzero.' instead of 'Background Shell terminated with SIGKILL.' The signal is unrecoverable from the durable record, so an operator triaging a killed background job reads a plain nonzero exit.

Reachability at this commit: child_run / monitor_run are not in MANAGED_SESSION_ENABLED_DOMAINS (packages/core/src/managed-runtime/managed-session-records.ts:123-133), and ToolPublicationContract.requirePayload still refuses a background or monitor payload, so this path cannot be entered in production yet. The mechanism is verified; it is a pre-enablement obligation rather than a live regression.

Witness:

RA14 CONTROL (exit 3) OBSERVED {"exitCode":3,"exitSignal":null} / RA14 SIGKILL OBSERVED {"exitCode":1,"exitSignal":null} / RA14 SIGSEGV OBSERVED {"exitCode":139,"exitSignal":null} / RA14 SUPERVISOR (via ManagedChildRunSupervisor.start) OBSERVED {"exitCode":1,"exitSignal":null}. WITH THE FIX (launcher forwards the signal): SIGKILL -> {"exitCode":null,"exitSignal":"SIGKILL"}, CONTROL unchanged. The control arm is what makes this evidence rather than a harness artefact.

Suggested fix: Have the launcher forward the signal — child.on('exit', (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exit(code ?? 1); }) — or write the inner child's code/signal to fd 3 before exiting and have the supervisor prefer that evidence over the launcher's own exit facts.

The fix must not violate an existing fact: managed-child-run-record.ts:226 — fail('Child run exitCode must be an integer from 0 to 255.'). Forwarding the signal makes exitCode null, which is exactly why the field is nullable; the fix must not push a 128+signal value into it. Acceptance criterion: packages/core/src/managed-runtime/managed-child-run-supervisor.test.ts — a case whose command is sh -c 'kill -SEGV $$', asserting proc.evidence answers exitSignal: 'SIGSEGV'. It is red today because the launcher's flattening is invisible to the supervisor. Please prove it by mutation — remove the fix, run that test, and confirm it goes red.

中文说明

后台 Shell 持久化的退出证据永远带不上被监督命令的信号。HookCommandCgroup 的 launcher 以 child.on('exit', (code) => process.exit(code ?? 1)); 结尾——它丢掉信号并以 1 退出。本 PR 让这个 launcher 成为后台 Shell 的 root 进程(新增 managed-child-run-supervisor.ts),并把它的退出事实作为 Shell 的证据持久化(新增 managed-child-run-record.ts),于是 exitSignal 只在 terminate 路径上可达(那里被信号杀死的是 launcher 自己)。命令被 OOM kill 或段错误时,记录会在「exitCode 或 exitSignal 恰在其退出时被证明」这条不变量下提交 exitCode: 1(或 139,那是外层 shell 的 128+signal 编码,根本不是命令的退出码)而 exitSignal: null,信号信息不可恢复。压平那一行本身是既有代码(在 hunk 中以上下文出现,不是 + 行);本 PR 新增的是让它「新近变错」的消费者。

本提交上的可达性:child_run / monitor_run 不在 MANAGED_SESSION_ENABLED_DOMAINS(managed-session-records.ts:123-133)中,且 ToolPublicationContract.requirePayload 仍拒绝 background / monitor 载荷,因此该路径在生产上尚不可进入。机制已验证;它属于「启用切片前必须修」的义务,而非当前可触发的回归。

— 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R3-20: [fails-closed] [new-surface] ManagedChildRunProcess.terminate() answers null on every escalated stop. HookCommandCgroup.terminate ends with if (!(await this.waitForEmpty(graceMs))) this.kill();, and kill() is a bare writeFileSync(cgroup.kill, '1') that returns immediately. The await in the supervisor then resumes on a microtask, so if (!this.unit.empty()) return null; reads cgroup.events in the same tick as the kill write — before the worker's own exit handler can reap the child and flip populated to 0. The measured gap is 1-2 ms.

A background Shell ignores SIGTERM and is escalated to cgroup.kill. terminate() returns null, so managed-background-shell-registry.ts:124-126 answers {evidence: null} and keeps the entry; managed-shell-runtime.ts:105-108 reports state: 'unknown'; managed-workspace-activation.ts:105-111 re-checks hasActiveSession and answers 409 managed_activation_conflict; and the Broker's controlProcessRow keeps the row so releaseSessionAfterSweep answers runtime_session_busy. The HEAD commit's own policy — "park an unconfirmed stop on runtime_lost instead of claiming it stopped" — is therefore fed a false negative on every escalated stop, and the fail-closed machinery parks a process that was provably killed.

Reachability at this commit: child_run / monitor_run are not in MANAGED_SESSION_ENABLED_DOMAINS (packages/core/src/managed-runtime/managed-session-records.ts:123-133), and ToolPublicationContract.requirePayload still refuses a background or monitor payload, so this path cannot be entered in production yet. The mechanism is verified; it is a pre-enablement obligation rather than a live regression.

Witness:

INTACT PR: +322ms kill() -> SIGKILL delivered / +322ms empty()#8 -> false (supervisor.ts:100, same millisecond) / +322ms terminate() ANSWERED null / +324ms worker reaped child (code=null signal=SIGKILL) -> populated 0 / +425ms second terminate() ANSWERED {"exitCode":null,"exitSignal":"SIGKILL"}. WITH THE FIX: +389ms terminate() ANSWERED {"exitCode":null,"exitSignal":"SIGKILL"} on the first call. The probe flips.

Suggested fix: Wait for emptiness after the escalation before answering, e.g. if (!(await this.unit.waitForEmpty(2_000, () => this.settled))) return null; in the escalated branch, so the read happens after the reap rather than in the kill's own tick. A unit that genuinely will not empty still times out and still returns null.

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 null on timeout and must never synthesize a ChildRunExitEvidence. Acceptance criterion: packages/core/src/managed-runtime/managed-child-run-supervisor.test.ts — an escalated-stop case asserting terminate() resolves with {exitCode: null, exitSignal: 'SIGKILL'} rather than null. Measured cost of the fix: the existing keeps the process when emptiness cannot be proven case goes from ~0 ms to 2056 ms because it now pays the bounded wait; suite green with the fix (12/12 + 7/7). Please prove it by mutation — remove the fix, run that test, and confirm it goes red.

中文说明

ManagedChildRunProcess.terminate() 在每一次升级停止上都回答 null。HookCommandCgroup.terminate 以 if (!(await this.waitForEmpty(graceMs))) this.kill(); 结尾,而 kill() 只是一次立即返回的 writeFileSync(cgroup.kill, '1')。supervisor 里的 await 随后在微任务上恢复,所以 if (!this.unit.empty()) return null; 与写 kill 处在同一个 tick 内读 cgroup.events——此时 worker 自己的 exit 处理器还没回收子进程、populated 还没翻成 0。实测这个间隔是 1–2 毫秒。后果是每次升级停止都给出假阴性,而 HEAD 提交自己的策略(「park an unconfirmed stop on runtime_lost instead of claiming it stopped」)正被这个假阴性喂错。

本提交上的可达性:child_run / monitor_run 不在 MANAGED_SESSION_ENABLED_DOMAINS(managed-session-records.ts:123-133)中,且 ToolPublicationContract.requirePayload 仍拒绝 background / monitor 载荷,因此该路径在生产上尚不可进入。机制已验证;它属于「启用切片前必须修」的义务,而非当前可触发的回归。

— 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'],
});
Comment thread
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R3-13: The processes map only ever grows: forget() has no caller anywhere in the repository (production or test), and the map has no reader on any production path, so its only observable effect is to retain every settled child run for the worker's whole lifetime. ManagedChildRunSupervisor.create is called once per worker, so the lifetime is the worker's.

Reachability at this commit: child_run / monitor_run are not in MANAGED_SESSION_ENABLED_DOMAINS (packages/core/src/managed-runtime/managed-session-records.ts:123-133), and ToolPublicationContract.requirePayload still refuses a background or monitor payload, so this path cannot be entered in production yet. The mechanism is verified; it is a pre-enablement obligation rather than a live regression.

Witness:

intact PR : PROBE-M17 settled=3 supervisor.size=3 stillListed=qwen-bg-probe-1,qwen-bg-probe-2,qwen-bg-probe-3
fix arm : PROBE-M17 settled=3 supervisor.size=0 stillListed + managed-child-run-supervisor.test.ts (12 tests) 全绿
sizing : PROBE-M17B warmEntries=20 entriesAfter=320 count=300 heapBefore=12946704 heapAfter=17762976 bytesPerEntry=16054 gcAvailable=true
sweep : forget( 在 supervisor 上的调用点 = 0;生产侧 supervisor 读取点 = 0(仅 .start/.attach)

Suggested fix: 让不变量留在 supervisor 内部:在 terminate()/settleOnEmpty() 真正把 this.settled = true 的三处,同步从所属 supervisor 的 map 删除自己(构造时传入一个 onSettled: (unitName) => void,或直接持有 supervisor 引用);forget() 若无调用者就一并删掉。若倾向保留 forget(),则在 registry 释放条目的 finally(managed-background-shell-registry.ts:254)里调用它。

The fix must not violate an existing fact: expect(supervisor.process('qwen-bg-shell-3')).toBe(proc);(packages/core/src/managed-runtime/managed-child-run-supervisor.test.ts:158)——未证明为空、terminate() 返回 null 的进程必须仍可按名找到,所以清理只能发生在 settled 置真之后,不能在返回 null 的路径上做。 Acceptance criterion: packages/core/src/managed-runtime/managed-child-run-supervisor.test.ts 新增一例:start() 一个进程、驱动其 settle(写 cgroup.events 为 populated 0 后 await proc.terminate(...)),断言 supervisor.size 回到 0 且 supervisor.process(unitName) 为 undefined;去掉 settle 时的清理,该断言变红。 Please prove it by mutation — remove the fix, run that test, and confirm it goes red.

中文说明

processes map 只增不减——forget() 在整个仓库没有任何调用者(生产与测试都没有),而该 map 在生产路径上也没有任何读者,于是它唯一可观测的效果就是永久持有每一次已结案的 child run。

触发场景: ManagedChildRunSupervisor.create 每个 worker 只调用一次(managed-context-worker.ts:326-328),生命周期等于 worker。每次 start() 都 this.processes.set(spec.unitName, process_);registry 在 completion 结束时通过 finally { this.entries.delete(unitName); }(managed-background-shell-registry.ts:254)释放自己那份引用,但从不调用 supervisor.forget(unitName)。于是每个跑完的 background Shell / Monitor watch 都经由 supervisor 的 map 继续可达:ManagedChildRunProcess → ChildProcess → stdout/stderr 上仍挂着的 data 监听 → executor 的 onOutput 闭包(捕获 sink、applyPause)或 watcher 的 onOutput 闭包(捕获 decoder、remainder、onLine、identity)。一个长期存活、跑过成千上万次后台 Shell 与 Monitor watch 的 worker,其驻留集合随运行次数线性增长且永不回收;size/process() 也会把早已 settle 的进程报成在册。

本提交上的可达性:child_run / monitor_run 不在 MANAGED_SESSION_ENABLED_DOMAINS(managed-session-records.ts:123-133)中,且 ToolPublicationContract.requirePayload 仍拒绝 background / monitor 载荷,因此该路径在生产上尚不可进入。机制已验证;它属于「启用切片前必须修」的义务,而非当前可触发的回归。

— qwen3.8-max via Qwen Code /review (v0.25.0)

}