diff --git a/apps/server/src/process/agentScope.test.ts b/apps/server/src/process/agentScope.test.ts
index 0095146a3021f..366bc442795d6 100644
--- a/apps/server/src/process/agentScope.test.ts
+++ b/apps/server/src/process/agentScope.test.ts
@@ -55,7 +55,12 @@ const withFakeSystemd = (
{
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-"));
@@ -105,7 +110,11 @@ const withFakeSystemd = (
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) =>
@@ -172,6 +181,48 @@ const launchCodex = (scope: Effect.Success, 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* () {
diff --git a/apps/server/src/process/agentScope.ts b/apps/server/src/process/agentScope.ts
index b9b6b56acb140..247789c38f5f0 100644
--- a/apps/server/src/process/agentScope.ts
+++ b/apps/server/src/process/agentScope.ts
@@ -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,
});
diff --git a/docs/user/background-service.md b/docs/user/background-service.md
index 83974146c2cdf..ac3c127a2e2f4 100644
--- a/docs/user/background-service.md
+++ b/docs/user/background-service.md
@@ -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.