Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
55 changes: 53 additions & 2 deletions apps/server/src/process/agentScope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,12 @@ const withFakeSystemd = <A, E>(
{
oomPolicy = true,
stopFails = false,
}: { readonly oomPolicy?: boolean; readonly stopFails?: boolean } = {},
memoryPolicy,
}: {
readonly oomPolicy?: boolean;
readonly stopFails?: boolean;
readonly memoryPolicy?: string;
} = {},
) =>
Effect.gen(function* () {
const bin = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-agent-scope-"));
Expand Down Expand Up @@ -105,7 +110,11 @@ const withFakeSystemd = <A, E>(
const scope = yield* make.pipe(
Effect.provideService(ProcessRunner.ProcessRunner, runner),
Effect.provideService(HostProcess.Platform, "linux"),
Effect.provideService(HostProcess.Environment, { PATH: bin, XDG_RUNTIME_DIR: "/run/user/1" }),
Effect.provideService(HostProcess.Environment, {
PATH: bin,
XDG_RUNTIME_DIR: "/run/user/1",
...(memoryPolicy === undefined ? {} : { T3CODE_AGENT_SLICE_MEMORY_POLICY: memoryPolicy }),
}),
Effect.provideService(FileSystem.FileSystem, {
...fileSystem,
readFileString: (path, encoding) =>
Expand Down Expand Up @@ -172,6 +181,48 @@ const launchCodex = (scope: Effect.Success<typeof make>, threadId: string) =>
);

describe("AgentScope service", () => {
it.effect.each([undefined, "automatic", "host-managed", "invalid"])(
"preserves scopes and OOM handling with memory policy %s",
(memoryPolicy) =>
withFakeSystemd(
({ scope, units, calls, stateDir }) =>
Effect.gen(function* () {
const first = yield* scope.wrap({
command: "agent",
args: [],
name: "agent",
threadId: "a",
});
expect(first.args).toContain(`--slice=${ownedAgentSlice(stateDir)}`);
expect(first.args).toContain("--property=OOMPolicy=stop");
yield* scope.wrap({ command: "agent", args: [], name: "agent", threadId: "b" });
const properties = calls.filter((args) => args.includes("set-property"));
const limits = properties.filter((args) => args.includes("app-t3code-agents.slice"));
expect(properties).toHaveLength(memoryPolicy === "host-managed" ? 1 : 2);
expect(limits).toHaveLength(memoryPolicy === "host-managed" ? 0 : 1);
expect(properties).toContainEqual([
"--user",
"set-property",
"--runtime",
"app-t3code.slice",
"CPUWeight=10",
"IOWeight=10",
"TasksMax=25%",
]);
if (memoryPolicy !== "host-managed") {
expect(limits[0]).toContain("app-t3code-agents.slice");
expect(limits[0]?.some((arg) => arg.startsWith("MemoryHigh="))).toBe(true);
expect(limits[0]?.some((arg) => arg.startsWith("MemoryMax="))).toBe(true);
}
units.set(unitOf(first), { activeState: "failed", result: "oom-kill" });
expect(yield* scope.oomKilled("a")).toBe(true);
yield* scope.clear("a");
expect(yield* scope.oomKilled("a")).toBe(false);
}),
memoryPolicy === undefined ? {} : { memoryPolicy },
),
);

it.effect("labels a thread from its newest scope until its next session clears it", () =>
withFakeSystemd(({ scope, units }) =>
Effect.gen(function* () {
Expand Down
16 changes: 14 additions & 2 deletions apps/server/src/process/agentScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,11 +198,23 @@ export const make = Effect.gen(function* () {
});
}
});
const limits = agentSliceMemoryLimits(NodeOS.totalmem());
yield* setProperties(AGENT_SLICE, [`MemoryHigh=${limits.high}`, `MemoryMax=${limits.max}`]);
const memoryPolicy = hostEnvironment.T3CODE_AGENT_SLICE_MEMORY_POLICY;
if (
memoryPolicy !== undefined &&
memoryPolicy !== "automatic" &&
memoryPolicy !== "host-managed"
) {
yield* Effect.logWarning("Unknown agent slice memory policy; using automatic limits");
}
const limits =
memoryPolicy === "host-managed" ? undefined : agentSliceMemoryLimits(NodeOS.totalmem());
if (limits !== undefined) {
yield* setProperties(AGENT_SLICE, [`MemoryHigh=${limits.high}`, `MemoryMax=${limits.max}`]);
}
yield* setProperties(AGENTS_PARENT_SLICE, AGENTS_PARENT_PROPERTIES);
yield* Effect.logInfo("Agent scopes are on", {
slice: AGENT_SLICE,
memoryPolicy: limits === undefined ? "host-managed" : "automatic",
...limits,
oomPolicy: supportsOomPolicy,
});
Expand Down
16 changes: 16 additions & 0 deletions docs/user/background-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,22 @@ enabled, or keep `t3 serve` running in a terminal. Minimize the desktop app rath
than closing its last window, which stops its local server. Linking T3 Connect
does not configure automatic startup at Windows sign-in.

### Host-managed agent memory on Linux

By default, T3 Code applies automatic memory limits to the systemd user slice
`app-t3code-agents.slice`. If you manage that slice's memory budget yourself, set
`T3CODE_AGENT_SLICE_MEMORY_POLICY=host-managed` in the server's environment (or
its `service.env`) and restart the server. T3 Code then leaves `MemoryHigh` and
`MemoryMax` untouched while keeping per-thread scopes, cleanup and OOM reporting.
Unset the variable or set it to `automatic` to restore automatic limits on restart.

The slice is shared by T3 servers running as the same OS user, so configure all
of them consistently. This option does not remove runtime overrides written by
earlier starts. Apply your intended limits with `systemctl --user set-property
--runtime app-t3code-agents.slice MemoryHigh=… MemoryMax=…`. Restarting the server
or user manager does not clear those runtime configuration files; they can remain
until the next reboot.

`t3 up` installs the service when you set up T3 Connect, but the two are managed
separately. Signing out of T3 Connect does not stop or uninstall the service.

Expand Down
Loading