diff --git a/apps/server/src/process/agentScope.test.ts b/apps/server/src/process/agentScope.test.ts
index a44ff15359f35..74538066c5bc8 100644
--- a/apps/server/src/process/agentScope.test.ts
+++ b/apps/server/src/process/agentScope.test.ts
@@ -56,11 +56,13 @@ const withFakeSystemd = (
{
oomPolicy = true,
stopFails = false,
+ memoryPolicy,
leftovers = false,
liveOwner = false,
}: {
readonly oomPolicy?: boolean;
readonly stopFails?: boolean;
+ readonly memoryPolicy?: string;
readonly leftovers?: boolean;
readonly liveOwner?: boolean;
} = {},
@@ -118,7 +120,11 @@ const withFakeSystemd = (
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" }),
+ 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) =>
@@ -184,6 +190,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 7adc4c453be08..19748ad84d0a6 100644
--- a/apps/server/src/process/agentScope.ts
+++ b/apps/server/src/process/agentScope.ts
@@ -201,11 +201,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 1bc41addf8c96..655b78ca6e273 100644
--- a/docs/user/background-service.md
+++ b/docs/user/background-service.md
@@ -99,6 +99,22 @@ of signing out, and keep the PC awake, for unattended remote access. Installing
over SSH while nobody is signed in at the PC can fail at the final start step;
the service is still installed and will start at the next 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.