apps/server/src/process/agentScope.test.ts
// @effect-diagnostics nodeBuiltinImport:off -- fake systemd binaries live in a temp dir.
import * as NodeChildProcess from "node:child_process";
import * as NodeCrypto from "node:crypto";
import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, expect, it } from "@effect/vitest";
import { ProviderSessionId, ThreadId } from "@t3tools/contracts";
import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
import { AgentScope } from "@t3tools/shared/AgentScope";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import type * as Scope from "effect/Scope";
import * as Schema from "effect/Schema";
import { ChildProcess, ChildProcessSpawner } from "effect/process";
import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts";
import * as ServerConfig from "../config.ts";
import { makeCodexAppServerSpawnCommand } from "../orchestration-v2/Adapters/CodexAdapterV2.ts";
import * as ProcessRunner from "../processRunner.ts";
import * as ProviderProcesses from "./providerProcesses.ts";
import { classifyScope, layer, make, ownedAgentSlice, type ScopeState } from "./agentScope.ts";
const running: ScopeState = {
loadState: "loaded",
activeState: "active",
result: "success",
oomKills: 0,
populated: true,
};
describe("classifyScope", () => {
it("reports an OOM kill only once systemd records it", () => {
expect(classifyScope({ ...running, activeState: "failed", result: "oom-kill" })).toBe(
"oom-killed",
);
expect(classifyScope({ ...running, oomKills: 1 })).toBe("stopping");
expect(classifyScope({ ...running, populated: false })).toBe("stopping");
expect(classifyScope({ ...running, activeState: "deactivating" })).toBe("stopping");
});
});
const withFakeSystemd = <A, E>(
body: (input: {
readonly scope: Effect.Success<typeof make>;
readonly units: Map<string, { activeState: string; result: string }>;
readonly calls: ReadonlyArray<ReadonlyArray<string>>;
readonly stateDir: string;
}) => Effect.Effect<A, E>,
{
oomPolicy = true,
leftovers = false,
liveOwner = false,
}: {
readonly oomPolicy?: boolean;
readonly leftovers?: boolean;
readonly liveOwner?: boolean;
} = {},
) =>
Effect.gen(function* () {
const bin = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-agent-scope-"));
for (const name of ["systemd-run", "systemctl", "agent"]) {
NodeFS.writeFileSync(NodePath.join(bin, name), "", { mode: 0o755 });
}
const units = new Map<string, { activeState: string; result: string }>();
const calls: Array<ReadonlyArray<string>> = [];
const output = (stdout: string, code = 0): ProcessRunner.ProcessRunOutput => ({
stdout,
stderr: "",
code: ChildProcessSpawner.ExitCode(code),
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
});
const runner = ProcessRunner.ProcessRunner.of({
run: ({ args }) =>
Effect.sync(() => {
calls.push(args);
if (!oomPolicy && args.some((arg) => arg.startsWith("--property=OOMPolicy="))) {
return output("", 1);
}
const [, verb, unit = ""] = args;
if (verb === "reset-failed") units.delete(unit);
if (verb !== "show") return output("");
const state = units.get(unit);
return output(
state === undefined
? "LoadState=not-found\nActiveState=inactive\nResult=success\n"
: `LoadState=loaded\nActiveState=${state.activeState}\nResult=${state.result}\n`,
);
}),
});
const { stateDir } = yield* ServerConfig.ServerConfig;
if (leftovers)
units.set(ownedAgentSlice(stateDir), { activeState: "active", result: "success" });
const scope = yield* make.pipe(
Effect.provideService(ProviderProcesses.ProviderProcesses, { hasOtherLiveOwner: liveOwner }),
Effect.provideService(ProcessRunner.ProcessRunner, runner),
Effect.provideService(HostProcess.Platform, "linux"),
Effect.provideService(HostProcess.Environment, { PATH: bin, XDG_RUNTIME_DIR: "/run/user/1" }),
);
return yield* body({ scope, units, calls, stateDir }).pipe(
Effect.ensuring(Effect.sync(() => NodeFS.rmSync(bin, { recursive: true, force: true }))),
);
}).pipe(
Effect.scoped,
Effect.provide(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-agent-scope-" }).pipe(
Layer.provideMerge(NodeServices.layer),
),
),
);
const unitOf = (launch: { readonly args: ReadonlyArray<string> }) =>
launch.args.find((arg) => arg.startsWith("--unit="))?.slice("--unit=".length) ?? "";
const sliceOf = (launch: { readonly args: ReadonlyArray<string> }) =>
launch.args.find((arg) => arg.startsWith("--slice="))?.slice("--slice=".length) ?? "";
const launchCodex = (scope: Effect.Success<typeof make>, threadId: string) =>
makeCodexAppServerSpawnCommand({ command: "agent", args: ["app-server"], threadId }).pipe(
Effect.provideService(AgentScope, scope),
Effect.provideService(HostProcess.Platform, "linux"),
Effect.map((command) => {
assert(ChildProcess.isStandardCommand(command));
return command;
}),
);
describe("AgentScope service", () => {
it.effect("labels a thread from its newest scope until its next session clears it", () =>
withFakeSystemd(({ scope, units }) =>
Effect.gen(function* () {
const first = yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" });
units.set(unitOf(first), { activeState: "failed", result: "oom-kill" });
const second = yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" });
units.set(unitOf(second), { activeState: "active", result: "success" });
expect(yield* scope.oomKilled("a")).toBe(false);
units.set(unitOf(second), { activeState: "failed", result: "oom-kill" });
expect(yield* scope.oomKilled("a")).toBe(true);
units.delete(unitOf(second));
expect(yield* scope.oomKilled("a")).toBe(true);
yield* scope.clear("a");
expect(yield* scope.oomKilled("a")).toBe(false);
}),
),
);
it.effect(
"keeps shared Codex scopes running without attributing their OOM to the launch thread",
() =>
withFakeSystemd(({ scope, units, calls }) =>
Effect.gen(function* () {
const agent = yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" });
expect(agent.args).toContain("--property=OOMPolicy=stop");
units.set(unitOf(agent), { activeState: "failed", result: "oom-kill" });
expect(yield* scope.oomKilled("a")).toBe(true);
const codex = yield* launchCodex(scope, "a");
expect(codex.args).toContain("--property=OOMPolicy=continue");
expect(codex.args).not.toContain("--property=OOMPolicy=stop");
expect(codex.args).toContain("--property=TimeoutStopSec=10s");
expect(codex.args).toContain("--collect");
units.set(unitOf(codex), { activeState: "active", result: "success" });
expect(yield* scope.oomKilled("a")).toBe(false);
units.set(unitOf(codex), { activeState: "failed", result: "oom-kill" });
expect(yield* scope.oomKilled("a")).toBe(false);
expect(
calls.filter(([, verb, unit]) => verb === "show" && unit === unitOf(codex)),
).toEqual([]);
}),
),
);
it.effect("keeps a running scope while hundreds of other threads launch", () =>
withFakeSystemd(({ scope, units }) =>
Effect.gen(function* () {
const live = yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "live" });
units.set(unitOf(live), { activeState: "active", result: "success" });
for (let index = 0; index < 600; index++) {
yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: `other-${index}` });
}
units.set(unitOf(live), { activeState: "failed", result: "oom-kill" });
expect(yield* scope.oomKilled("live")).toBe(true);
}),
),
);
it.effect("stops agents a killed server left during acquisition before the first launch", () =>
withFakeSystemd(
({ scope, calls, stateDir }) =>
Effect.gen(function* () {
const stops = calls.filter(([, verb]) => verb === "stop");
expect(stops).toEqual([["--user", "stop", ownedAgentSlice(stateDir)]]);
const agent = yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" });
expect(sliceOf(agent)).toBe(ownedAgentSlice(stateDir));
expect(agent.args).toContain("--property=TimeoutStopSec=10s");
const terminal = yield* scope.wrap({
command: "agent",
args: [],
name: "terminal",
survivesRestart: true,
});
expect(sliceOf(terminal)).toBe("app-t3code-agents.slice");
expect(calls.filter(([, verb]) => verb === "stop")).toHaveLength(1);
}),
{ leftovers: true },
),
);
it.effect("does not stop the owned slice while another recorded server is alive", () =>
withFakeSystemd(
({ calls }) =>
Effect.sync(() => {
expect(calls.filter(([, verb]) => verb === "stop")).toEqual([]);
}),
{ leftovers: true, liveOwner: true },
),
);
});
// These fixtures only signal PIDs captured at spawn; no server or provider credentials are used.
const ownedProcessFixture = Effect.gen(function* () {
const ready = yield* Deferred.make<void>();
const exited = yield* Deferred.make<void>();
const child = yield* Effect.acquireRelease(
Effect.sync(() => {
const child = NodeChildProcess.spawn(
process.execPath,
["-e", 'process.stdin.resume(); process.send("ready");'],
{ stdio: ["pipe", "ignore", "ignore", "ipc"] },
);
child.once("message", () => Deferred.doneUnsafe(ready, Effect.void));
child.once("error", (error) => Deferred.doneUnsafe(ready, Effect.die(error)));
child.once("exit", () => Deferred.doneUnsafe(exited, Effect.void));
return child;
}),
(child) =>
Effect.sync(() => child.kill("SIGKILL")).pipe(Effect.andThen(Deferred.await(exited))),
);
yield* Deferred.await(ready);
assert(child.pid !== undefined);
const controller = AcpSessionRuntime.makePosixProcessTreeController("linux");
const pid = child.pid;
const identity = controller.identity(pid);
assert(identity !== undefined);
const boot = NodeFS.readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
return {
pid,
startTime: `${boot}:${identity.startTime}`,
pgid: 0,
running: () => {
const observed = controller.identity(pid);
return observed !== undefined && !AcpSessionRuntime.posixProcessIsZombie(observed);
},
stop: Effect.sync(() => child.kill("SIGKILL")).pipe(Effect.andThen(Deferred.await(exited))),
};
});
const withRestartOwnership = <A, E, R>(
body: (input: {
readonly record: (
owner: { readonly pid: number; readonly startTime: string },
processes: ReadonlyArray<{
readonly pid: number;
readonly startTime: string;
readonly pgid: number;
}>,
) => void;
readonly restart: Effect.Effect<void, never, Scope.Scope>;
}) => Effect.Effect<A, E, R>,
) =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const { stateDir } = config;
const directory = NodePath.join(stateDir, "provider-processes");
NodeFS.mkdirSync(directory, { recursive: true });
const record = (
owner: { readonly pid: number; readonly startTime: string },
processes: ReadonlyArray<{
readonly pid: number;
readonly startTime: string;
readonly pgid: number;
}>,
) => {
const instanceId = NodeCrypto.randomUUID();
NodeFS.writeFileSync(
NodePath.join(directory, `${instanceId}.json`),
JSON.stringify({
version: 1,
instanceId,
hostname: NodeOS.hostname(),
stateDir,
owner,
processes,
}),
);
};
// Acquiring the production layer is the barrier before orchestration recovery may run.
const restart = Layer.build(layer).pipe(
Effect.provideService(ServerConfig.ServerConfig, config),
Effect.provide(NodeServices.layer),
Effect.asVoid,
);
return yield* body({ record, restart });
}).pipe(
Effect.scoped,
Effect.provideService(HostProcess.Platform, "linux"),
Effect.provideService(HostProcess.Environment, { PATH: process.env.PATH }),
Effect.provide(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-restart-ownership-" }).pipe(
Layer.provideMerge(NodeServices.layer),
),
),
);
describe.runIf(HostProcess.Platform.defaultValue() === "linux")(
"provider restart ownership without systemd",
() => {
it.live(
"stops both recorded survivors before startup completes and leaves an unrecorded process alone",
() =>
withRestartOwnership(({ record, restart }) =>
Effect.gen(function* () {
const owner = yield* ownedProcessFixture;
const first = yield* ownedProcessFixture;
const second = yield* ownedProcessFixture;
const unrelated = yield* ownedProcessFixture;
record(owner, [first]);
record(owner, [second]);
yield* owner.stop;
yield* restart;
expect([first.running(), second.running()]).toEqual([false, false]);
expect(unrelated.running()).toBe(true);
}),
),
);
it.live(
"preserves a live server's provider and a reused provider PID while reaping a matching orphan",
() =>
withRestartOwnership(({ record, restart }) =>
Effect.gen(function* () {
const deadOwner = yield* ownedProcessFixture;
const liveOwner = yield* ownedProcessFixture;
const liveProvider = yield* ownedProcessFixture;
const reusedProvider = yield* ownedProcessFixture;
const orphan = yield* ownedProcessFixture;
record(liveOwner, [liveProvider]);
record(deadOwner, [
{ ...reusedProvider, startTime: reusedProvider.startTime.replace(/:\d+$/, ":1") },
]);
record(deadOwner, [orphan]);
yield* deadOwner.stop;
yield* restart;
expect(liveProvider.running()).toBe(true);
expect(reusedProvider.running()).toBe(true);
expect(orphan.running()).toBe(false);
}),
),
);
it.live("does not mistake a reused owner PID for a live owning server", () =>
withRestartOwnership(({ record, restart }) =>
Effect.gen(function* () {
const reusedOwner = yield* ownedProcessFixture;
const orphan = yield* ownedProcessFixture;
record({ ...reusedOwner, startTime: reusedOwner.startTime.replace(/:\d+$/, ":1") }, [
orphan,
]);
yield* restart;
expect(reusedOwner.running()).toBe(true);
expect(orphan.running()).toBe(false);
}),
),
);
},
);
const LaunchObservation = Schema.Struct({
pid: Schema.Number,
record: Schema.Struct({
version: Schema.Literal(1),
owner: Schema.Struct({ pid: Schema.Number, startTime: Schema.String }),
processes: Schema.Array(Schema.Struct({ pid: Schema.Number, startTime: Schema.String })),
}),
});
// The fake CLI reports ownership at entry and answers SDK control requests.
// It never starts a provider or reads provider credentials.
const decodeLaunchObservation = Schema.decodeEffect(Schema.fromJsonString(LaunchObservation));
const FAKE_CLI = `
import * as fs from "node:fs";
import * as path from "node:path";
import * as readline from "node:readline";
const directory = process.env.T3_TEST_PROCESS_DIRECTORY;
const ready = process.env.T3_TEST_PROCESS_READY;
const records = fs.readdirSync(directory)
.filter((file) => file.endsWith(".json"))
.map((file) => JSON.parse(fs.readFileSync(path.join(directory, file), "utf8")));
const record = records.find((record) => record.processes.some((entry) => entry.pid === process.pid));
fs.writeFileSync(ready + ".pending", JSON.stringify({ pid: process.pid, record }));
fs.renameSync(ready + ".pending", ready);
readline.createInterface({ input: process.stdin }).on("line", (line) => {
const frame = JSON.parse(line);
if (frame.type !== "control_request") return;
process.stdout.write(JSON.stringify({
type: "control_response",
response: { subtype: "success", request_id: frame.request_id, response: {} },
}) + "\\n");
});
`;
describe.runIf(HostProcess.Platform.defaultValue() === "linux")("Claude process ownership", () => {
it.live("records the production SDK child before it executes and forgets it after exit", () =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const directory = NodePath.join(config.stateDir, "provider-processes");
const readyPath = NodePath.join(config.stateDir, "ready.json");
const cliPath = NodePath.join(config.stateDir, "fake-cli.mjs");
NodeFS.writeFileSync(cliPath, FAKE_CLI);
const ready = yield* Deferred.make<void>();
const removed = yield* Deferred.make<void>();
yield* Effect.acquireRelease(
Effect.sync(() =>
NodeFS.watch(config.stateDir, (_, file) => {
if (file === "ready.json" && NodeFS.existsSync(readyPath)) {
Deferred.doneUnsafe(ready, Effect.void);
}
}),
),
(watcher) => Effect.sync(() => watcher.close()),
);
yield* Effect.gen(function* () {
const runner = yield* ClaudeAdapterV2.ClaudeAgentSdkQueryRunner;
yield* Effect.acquireRelease(
Effect.sync(() =>
NodeFS.watch(directory, (_, file) => {
if (
file !== null &&
file.endsWith(".json") &&
!NodeFS.existsSync(NodePath.join(directory, file))
) {
Deferred.doneUnsafe(removed, Effect.void);
}
}),
),
(watcher) => Effect.sync(() => watcher.close()),
);
yield* Effect.gen(function* () {
yield* Effect.acquireRelease(
runner.open({
threadId: ThreadId.make("ownership-thread"),
providerSessionId: ProviderSessionId.make("ownership-session"),
options: {
...ClaudeAdapterV2.makeClaudeQueryOptions({
modelSelection: {
instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
model: "claude-sonnet-4-6",
},
nativeThreadId: NodeCrypto.randomUUID(),
resume: false,
cwd: config.stateDir,
}),
pathToClaudeCodeExecutable: cliPath,
env: {
PATH: `${NodePath.dirname(process.execPath)}:/usr/bin:/bin`,
T3_TEST_PROCESS_DIRECTORY: directory,
T3_TEST_PROCESS_READY: readyPath,
},
},
}),
(session) => session.close.pipe(Effect.orDie),
);
yield* Deferred.await(ready);
const observation = yield* decodeLaunchObservation(
NodeFS.readFileSync(readyPath, "utf8"),
);
assert.equal(observation.record.owner.pid, process.pid);
assert.deepEqual(
observation.record.processes.map(({ pid }) => pid),
[observation.pid],
);
const boot = NodeFS.readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
assert.isTrue(observation.record.processes[0]?.startTime.startsWith(`${boot}:`));
const files = NodeFS.readdirSync(directory).filter((file) => file.endsWith(".json"));
// One record belongs to the server; the other belongs to this CLI.
assert.lengthOf(files, 2);
}).pipe(Effect.scoped);
yield* Deferred.await(removed);
}).pipe(
Effect.provide(ClaudeAdapterV2.layerQueryRunner),
Effect.provideService(
ProviderEventLoggers.ProviderEventLoggers,
ProviderEventLoggers.NoOpProviderEventLoggers,
),
Effect.provide(layer),
);
}).pipe(
Effect.scoped,
Effect.provideService(HostProcess.Platform, "linux"),
Effect.provideService(HostProcess.Environment, { PATH: process.env.PATH }),
Effect.provide(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-process-launch-" }).pipe(
Layer.provideMerge(NodeServices.layer),
),
),
),
);
});