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.