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.