| author | |
| committer | |
| log | a0a601c262a8a7b260d18fd806302183ea529036 |
| tree | c71905916b5157b95f057318a43417542ab2a932 |
| parent | 968ac2e82b8fbc3ce6d87fbb4159a20a4971d0d3 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>19 files changed, 827 insertions(+), 23 deletions(-)
apps/server/src/cloud/bootService.ts+13-4| ... | ... | @@ -106,13 +106,22 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { |
| 106 | 106 | // Let the launcher mark an explicit stop before it signals the server. |
| 107 | 107 | // systemd still SIGKILLs the whole cgroup if graceful shutdown times out. |
| 108 | 108 | "KillMode=mixed", |
| 109 | // Agent tool calls run as children of the server, so they share this cgroup. | |
| 110 | // With the systemd default of OOMPolicy=stop, the kernel killing one greedy | |
| 111 | // child stops the whole unit: the server, every live agent, and the user's | |
| 112 | // connection. Keep running and let Restart=always cover the main process. | |
| 109 | // Agents and terminals run in their own scopes in app-t3code-agents.slice | |
| 110 | // (see process/agentScope.ts). Short helper commands still share this | |
| 111 | // cgroup, and with the systemd default of OOMPolicy=stop, the kernel | |
| 112 | // killing one of them stops the whole unit. Keep running and let | |
| 113 | // Restart=always cover the main process. | |
| 113 | 114 | "OOMPolicy=continue", |
| 114 | 115 | "Restart=always", |
| 115 | 116 | "RestartSec=5", |
| 117 | // The agents slice is a sibling in app.slice, so these weights keep the | |
| 118 | // server responsive while agents saturate CPU or disk. | |
| 119 | "CPUWeight=1000", | |
| 120 | "IOWeight=1000", | |
| 121 | // systemd-oomd only honors this when the cgroup it watches is owned by the | |
| 122 | // same user. Distros watch user-1000.slice, which root owns, so there it is | |
| 123 | // ignored. The agent scopes are what keep oomd off the server. | |
| 124 | "ManagedOOMPreference=avoid", | |
| 116 | 125 | `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`, |
| 117 | 126 | `StandardError=append:${escapeSystemdSpecifiers(plan.logPath)}`, |
| 118 | 127 | "", |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+29-1| ... | ... | @@ -31,6 +31,7 @@ import type { |
| 31 | 31 | WebSearchOutput, |
| 32 | 32 | } from "@anthropic-ai/claude-agent-sdk/sdk-tools"; |
| 33 | 33 | import { parseCliArgs } from "@t3tools/shared/cliArgs"; |
| 34 | import { AgentScope } from "@t3tools/shared/AgentScope"; | |
| 34 | 35 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 35 | 36 | import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; |
| 36 | 37 | import { |
| ... | ... | @@ -178,6 +179,8 @@ export function claudeProviderTurnTokenUsage( |
| 178 | 179 | } |
| 179 | 180 | export const CLAUDE_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(CLAUDE_PROVIDER); |
| 180 | 181 | const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({}); |
| 182 | // Entry extensions the Claude SDK runs through node instead of executing. | |
| 183 | const CLAUDE_SCRIPT_EXTENSIONS = [".js", ".mjs", ".tsx", ".ts", ".jsx"]; | |
| 181 | 184 | |
| 182 | 185 | export const ClaudeProviderCapabilitiesV2 = { |
| 183 | 186 | sessions: { |
| ... | ... | @@ -605,6 +608,7 @@ export const layerQueryRunner: Layer.Layer< |
| 605 | 608 | Effect.gen(function* () { |
| 606 | 609 | const crypto = yield* Crypto.Crypto; |
| 607 | 610 | const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers; |
| 611 | const agentScope = yield* AgentScope; | |
| 608 | 612 | |
| 609 | 613 | return ClaudeAgentSdkQueryRunner.of({ |
| 610 | 614 | allocateSessionId: crypto.randomUUIDv4.pipe( |
| ... | ... | @@ -627,11 +631,35 @@ export const layerQueryRunner: Layer.Layer< |
| 627 | 631 | ), |
| 628 | 632 | Stream.toAsyncIterable, |
| 629 | 633 | ); |
| 634 | // The SDK spawns a native binary as `path ...executableArgs ...sdkArgs`, | |
| 635 | // which lets the agent scope wrapper go in front of the CLI while the | |
| 636 | // SDK keeps its own stderr capture and exit reporting. The SDK runs a | |
| 637 | // script entry through node instead, so those launch unwrapped. | |
| 638 | const binaryPath = input.options.pathToClaudeCodeExecutable; | |
| 639 | const launch = | |
| 640 | binaryPath === undefined || | |
| 641 | CLAUDE_SCRIPT_EXTENSIONS.some((ext) => binaryPath.endsWith(ext)) | |
| 642 | ? undefined | |
| 643 | : yield* agentScope.wrap({ | |
| 644 | command: binaryPath, | |
| 645 | args: [], | |
| 646 | name: "claude", | |
| 647 | threadId: input.threadId, | |
| 648 | env: input.options.env, | |
| 649 | }); | |
| 650 | const options = | |
| 651 | launch === undefined || launch.args.length === 0 | |
| 652 | ? input.options | |
| 653 | : { | |
| 654 | ...input.options, | |
| 655 | pathToClaudeCodeExecutable: launch.command, | |
| 656 | executableArgs: [...launch.args, ...(input.options.executableArgs ?? [])], | |
| 657 | }; | |
| 630 | 658 | const queryRuntime = yield* Effect.try({ |
| 631 | 659 | try: () => |
| 632 | 660 | query({ |
| 633 | 661 | prompt, |
| 634 | options: input.options, | |
| 662 | options, | |
| 635 | 663 | }), |
| 636 | 664 | catch: (cause) => queryRunnerError(cause, "query"), |
| 637 | 665 | }); |
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+13-2| ... | ... | @@ -30,6 +30,7 @@ import { |
| 30 | 30 | type ProviderSetupError, |
| 31 | 31 | } from "@t3tools/contracts"; |
| 32 | 32 | import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; |
| 33 | import { AgentScope } from "@t3tools/shared/AgentScope"; | |
| 33 | 34 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 34 | 35 | import { dynamicToolTitle } from "@t3tools/shared/toolActivity"; |
| 35 | 36 | import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; |
| ... | ... | @@ -1357,8 +1358,16 @@ export const makeCodexAppServerSpawnCommand = Effect.fn( |
| 1357 | 1358 | readonly cwd?: string | undefined; |
| 1358 | 1359 | readonly env?: NodeJS.ProcessEnv | undefined; |
| 1359 | 1360 | readonly extendEnv?: boolean | undefined; |
| 1361 | readonly threadId?: string | undefined; | |
| 1360 | 1362 | }) { |
| 1361 | const spawnCommand = yield* resolveSpawnCommand(input.command, input.args, { | |
| 1363 | const launch = yield* (yield* AgentScope).wrap({ | |
| 1364 | command: input.command, | |
| 1365 | args: input.args, | |
| 1366 | name: "codex", | |
| 1367 | threadId: input.threadId, | |
| 1368 | env: input.env, | |
| 1369 | }); | |
| 1370 | const spawnCommand = yield* resolveSpawnCommand(launch.command, launch.args, { | |
| 1362 | 1371 | ...(input.env === undefined ? {} : { env: input.env }), |
| 1363 | 1372 | ...(input.extendEnv === undefined ? {} : { extendEnv: input.extendEnv }), |
| 1364 | 1373 | }); |
| ... | ... | @@ -1495,6 +1504,7 @@ export const layerAppServerClientFactory: Layer.Layer< |
| 1495 | 1504 | Effect.gen(function* () { |
| 1496 | 1505 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 1497 | 1506 | const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers; |
| 1507 | const agentScope = yield* AgentScope; | |
| 1498 | 1508 | |
| 1499 | 1509 | return CodexAppServerClientFactory.of({ |
| 1500 | 1510 | open: (input) => |
| ... | ... | @@ -1510,7 +1520,8 @@ export const layerAppServerClientFactory: Layer.Layer< |
| 1510 | 1520 | resolveCodexLaunchArgs(input.settings.launchArgs, input.environment), |
| 1511 | 1521 | ), |
| 1512 | 1522 | env: environment, |
| 1513 | }); | |
| 1523 | threadId: input.threadId, | |
| 1524 | }).pipe(Effect.provideService(AgentScope, agentScope)); | |
| 1514 | 1525 | const handle = yield* spawner.spawn(command).pipe( |
| 1515 | 1526 | Effect.provideService(Scope.Scope, scope), |
| 1516 | 1527 | Effect.mapError( |
apps/server/src/orchestration-v2/ProviderEventIngestor.ts+8-1| ... | ... | @@ -26,6 +26,7 @@ import * as Effect from "effect/Effect"; |
| 26 | 26 | import * as Layer from "effect/Layer"; |
| 27 | 27 | import * as Schema from "effect/Schema"; |
| 28 | 28 | |
| 29 | import { AgentScope } from "@t3tools/shared/AgentScope"; | |
| 29 | 30 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 30 | 31 | import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; |
| 31 | 32 | import * as EventSink from "./EventSink.ts"; |
| ... | ... | @@ -316,6 +317,7 @@ export const layer: Layer.Layer< |
| 316 | 317 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 317 | 318 | const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; |
| 318 | 319 | const analytics = yield* ProviderTurnAnalytics; |
| 320 | const agentScope = yield* AgentScope; | |
| 319 | 321 | const completedTurnAnalytics = new Set<string>(); |
| 320 | 322 | |
| 321 | 323 | const makeDomainEvent = ( |
| ... | ... | @@ -652,6 +654,11 @@ export const layer: Layer.Layer< |
| 652 | 654 | return dismissed; |
| 653 | 655 | } |
| 654 | 656 | const occurredAt = yield* DateTime.now; |
| 657 | // A turn that failed because systemd killed the agent's scope for | |
| 658 | // memory would otherwise read as a plain provider crash. | |
| 659 | const failure = (yield* agentScope.oomKilled(input.threadId)) | |
| 660 | ? { ...input.event.failure, message: "Killed: out of memory.", code: "oom_kill" } | |
| 661 | : input.event.failure; | |
| 655 | 662 | return [ |
| 656 | 663 | ...dismissed, |
| 657 | 664 | yield* makeDomainEvent(input, { |
| ... | ... | @@ -664,7 +671,7 @@ export const layer: Layer.Layer< |
| 664 | 671 | providerThreadId: input.event.providerThreadId, |
| 665 | 672 | providerTurnId: input.event.providerTurnId, |
| 666 | 673 | itemOrdinal: input.event.failureItemOrdinal, |
| 667 | failure: input.event.failure, | |
| 674 | failure, | |
| 668 | 675 | ...(input.event.retry === undefined ? {} : { retry: input.event.retry }), |
| 669 | 676 | ...(input.event.retryStartedAt === undefined |
| 670 | 677 | ? {} |
apps/server/src/orchestration-v2/ProviderSessionManager.test.ts+53| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as NetAddress from "effect/net/NetAddress"; |
| 2 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | 3 | import { assert, it } from "@effect/vitest"; |
| 4 | import { AgentScope } from "@t3tools/shared/AgentScope"; | |
| 4 | 5 | import { |
| 5 | 6 | EnvironmentId, |
| 6 | 7 | type ModelSelection, |
| ... | ... | @@ -1092,6 +1093,58 @@ it.effect("ProviderSessionManagerV2 opens a duplicate session only once", () => |
| 1092 | 1093 | }), |
| 1093 | 1094 | ); |
| 1094 | 1095 | |
| 1096 | it.effect( | |
| 1097 | "ProviderSessionManagerV2 starts a thread's session without its last agent's OOM kill", | |
| 1098 | () => | |
| 1099 | Effect.gen(function* () { | |
| 1100 | const state = yield* Ref.make(emptyState); | |
| 1101 | const owner = ThreadId.make("thread-provider-session-manager-oom-owner"); | |
| 1102 | const joiner = ThreadId.make("thread-provider-session-manager-oom-joiner"); | |
| 1103 | // Both threads' previous agents were OOM-killed. A new session may run | |
| 1104 | // without a scope (Cursor, OpenCode, a shared Codex session), so it must | |
| 1105 | // not inherit that answer. | |
| 1106 | const oomKilledThreads = new Set<string>([owner, joiner]); | |
| 1107 | const agentScope = { | |
| 1108 | ...AgentScope.defaultValue(), | |
| 1109 | oomKilled: (id: string) => Effect.sync(() => oomKilledThreads.has(id)), | |
| 1110 | clear: (id: string) => Effect.sync(() => void oomKilledThreads.delete(id)), | |
| 1111 | }; | |
| 1112 | ||
| 1113 | const effect = Effect.gen(function* () { | |
| 1114 | const eventSink = yield* EventSink.EventSinkV2; | |
| 1115 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 1116 | const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; | |
| 1117 | const now = yield* DateTime.now; | |
| 1118 | const providerSessionId = yield* idAllocator.allocate.providerSession({ | |
| 1119 | providerInstanceId: modelSelection.instanceId, | |
| 1120 | threadId: owner, | |
| 1121 | }); | |
| 1122 | yield* eventSink.write({ | |
| 1123 | events: [ | |
| 1124 | yield* makeThreadCreatedEvent({ idAllocator, threadId: owner, now }), | |
| 1125 | yield* makeThreadCreatedEvent({ idAllocator, threadId: joiner, now }), | |
| 1126 | ], | |
| 1127 | }); | |
| 1128 | yield* manager.open({ threadId: owner, providerSessionId, modelSelection, runtimePolicy }); | |
| 1129 | assert.isFalse(yield* agentScope.oomKilled(owner)); | |
| 1130 | ||
| 1131 | yield* manager.open({ threadId: joiner, providerSessionId, modelSelection, runtimePolicy }); | |
| 1132 | assert.isFalse(yield* agentScope.oomKilled(joiner)); | |
| 1133 | ||
| 1134 | // Reopening a session the thread already uses keeps its answer. | |
| 1135 | oomKilledThreads.add(owner); | |
| 1136 | yield* manager.open({ threadId: owner, providerSessionId, modelSelection, runtimePolicy }); | |
| 1137 | assert.isTrue(yield* agentScope.oomKilled(owner)); | |
| 1138 | assert.equal((yield* Ref.get(state)).openCount, 1); | |
| 1139 | }); | |
| 1140 | ||
| 1141 | yield* effect.pipe( | |
| 1142 | Effect.provide(layerTest({ state, idleTimeoutMs: 60_000 })), | |
| 1143 | Effect.provideService(AgentScope, agentScope), | |
| 1144 | ); | |
| 1145 | }), | |
| 1146 | ); | |
| 1147 | ||
| 1095 | 1148 | it.effect("ProviderSessionManagerV2 cleans up an open interrupted mid-handshake", () => |
| 1096 | 1149 | Effect.gen(function* () { |
| 1097 | 1150 | const state = yield* Ref.make(emptyState); |
apps/server/src/orchestration-v2/ProviderSessionManager.ts+9-4| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { AgentScope } from "@t3tools/shared/AgentScope"; | |
| 1 | 2 | import * as KeyedLock from "@t3tools/shared/KeyedLock"; |
| 2 | 3 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 3 | 4 | import { |
| ... | ... | @@ -358,6 +359,7 @@ export const layerWithOptions = ( |
| 358 | 359 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 359 | 360 | const providerEventIngestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; |
| 360 | 361 | const projectionStore = yield* ProjectionStore.ProjectionStoreV2; |
| 362 | const agentScope = yield* AgentScope; | |
| 361 | 363 | const agentAccessSettings = Effect.fn("ProviderSessionManagerV2.agentAccessSettings")( |
| 362 | 364 | function* (threadId: ThreadId) { |
| 363 | 365 | if (Option.isNone(serverSettings)) return { browser: true, device: false }; |
| ... | ... | @@ -2037,16 +2039,16 @@ export const layerWithOptions = ( |
| 2037 | 2039 | const key = sessionKey(input.providerSessionId); |
| 2038 | 2040 | const existing = (yield* Ref.get(sessions)).get(key); |
| 2039 | 2041 | if (existing !== undefined) { |
| 2040 | if ( | |
| 2041 | !existing.attachedThreadIds.has(input.threadId) && | |
| 2042 | !existing.supportsMultipleProviderThreads | |
| 2043 | ) { | |
| 2042 | const attached = existing.attachedThreadIds.has(input.threadId); | |
| 2043 | if (!attached && !existing.supportsMultipleProviderThreads) { | |
| 2044 | 2044 | return yield* new ProviderSessionOpenError({ |
| 2045 | 2045 | instanceId: input.modelSelection.instanceId, |
| 2046 | 2046 | providerSessionId: input.providerSessionId, |
| 2047 | 2047 | cause: `Provider ${existing.runtime.driver} does not support attaching multiple app threads to one session.`, |
| 2048 | 2048 | }); |
| 2049 | 2049 | } |
| 2050 | // Joining a shared session is a new session for this thread. | |
| 2051 | if (!attached) yield* agentScope.clear(input.threadId); | |
| 2050 | 2052 | yield* ensureThreadAttached({ |
| 2051 | 2053 | providerSessionId: input.providerSessionId, |
| 2052 | 2054 | threadId: input.threadId, |
| ... | ... | @@ -2083,6 +2085,9 @@ export const layerWithOptions = ( |
| 2083 | 2085 | } |
| 2084 | 2086 | }); |
| 2085 | 2087 | const sessionScope = yield* Scope.fork(sessionScopes); |
| 2088 | // The previous session's agent scope must not explain this | |
| 2089 | // session's failures, even when this provider runs without one. | |
| 2090 | yield* agentScope.clear(input.threadId); | |
| 2086 | 2091 | const runtime = yield* adapter |
| 2087 | 2092 | .openSession({ |
| 2088 | 2093 | threadId: input.threadId, |
apps/server/src/process/agentScope.test.ts created+145| ... | ... | @@ -0,0 +1,145 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off -- fake systemd binaries live in a temp dir. | |
| 2 | import * as NodeFS from "node:fs"; | |
| 3 | import * as NodeOS from "node:os"; | |
| 4 | import * as NodePath from "node:path"; | |
| 5 | ||
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 7 | import { describe, expect, it } from "@effect/vitest"; | |
| 8 | import * as HostProcess from "@t3tools/shared/HostProcess"; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import { ChildProcessSpawner } from "effect/process"; | |
| 11 | ||
| 12 | import * as ProcessRunner from "../processRunner.ts"; | |
| 13 | import { agentSliceMemoryLimits, classifyScope, make, type ScopeState } from "./agentScope.ts"; | |
| 14 | ||
| 15 | const GiB = 1024 ** 3; | |
| 16 | ||
| 17 | const running: ScopeState = { | |
| 18 | loadState: "loaded", | |
| 19 | activeState: "active", | |
| 20 | result: "success", | |
| 21 | oomKills: 0, | |
| 22 | populated: true, | |
| 23 | }; | |
| 24 | ||
| 25 | describe("classifyScope", () => { | |
| 26 | it("reports an OOM kill only once systemd records it", () => { | |
| 27 | expect(classifyScope({ ...running, activeState: "failed", result: "oom-kill" })).toBe( | |
| 28 | "oom-killed", | |
| 29 | ); | |
| 30 | // The agent pipe can close before systemd handles the kill. | |
| 31 | expect(classifyScope({ ...running, oomKills: 1 })).toBe("stopping"); | |
| 32 | expect(classifyScope({ ...running, populated: false })).toBe("stopping"); | |
| 33 | expect(classifyScope({ ...running, activeState: "deactivating" })).toBe("stopping"); | |
| 34 | }); | |
| 35 | ||
| 36 | it("does not wait on a live agent or a scope that ended for another reason", () => { | |
| 37 | expect(classifyScope(running)).toBe("running"); | |
| 38 | expect(classifyScope({ ...running, activeState: "inactive", populated: undefined })).toBe( | |
| 39 | "gone", | |
| 40 | ); | |
| 41 | expect(classifyScope({ ...running, loadState: "not-found", activeState: "inactive" })).toBe( | |
| 42 | "gone", | |
| 43 | ); | |
| 44 | }); | |
| 45 | }); | |
| 46 | ||
| 47 | describe("agentSliceMemoryLimits", () => { | |
| 48 | it("leaves 6 GB before throttling and 4 GB before a kill on large machines", () => { | |
| 49 | expect(agentSliceMemoryLimits(64 * GiB)).toEqual({ high: 58 * GiB, max: 60 * GiB }); | |
| 50 | }); | |
| 51 | ||
| 52 | it("still gives agents most of a small machine", () => { | |
| 53 | expect(agentSliceMemoryLimits(8 * GiB)).toEqual({ high: 4 * GiB, max: 6 * GiB }); | |
| 54 | }); | |
| 55 | }); | |
| 56 | ||
| 57 | // Runs the real service against a fake user manager. `units` holds the | |
| 58 | // ActiveState and Result that `systemctl show` reports for each scope. | |
| 59 | const withFakeSystemd = <A, E>( | |
| 60 | body: (input: { | |
| 61 | readonly scope: Effect.Success<typeof make>; | |
| 62 | readonly units: Map<string, { activeState: string; result: string }>; | |
| 63 | }) => Effect.Effect<A, E>, | |
| 64 | ) => | |
| 65 | Effect.gen(function* () { | |
| 66 | const bin = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-agent-scope-")); | |
| 67 | for (const name of ["systemd-run", "systemctl", "agent"]) { | |
| 68 | NodeFS.writeFileSync(NodePath.join(bin, name), "", { mode: 0o755 }); | |
| 69 | } | |
| 70 | const units = new Map<string, { activeState: string; result: string }>(); | |
| 71 | const output = (stdout: string): ProcessRunner.ProcessRunOutput => ({ | |
| 72 | stdout, | |
| 73 | stderr: "", | |
| 74 | code: ChildProcessSpawner.ExitCode(0), | |
| 75 | timedOut: false, | |
| 76 | stdoutTruncated: false, | |
| 77 | stderrTruncated: false, | |
| 78 | stdoutInvalidUtf8: false, | |
| 79 | stderrInvalidUtf8: false, | |
| 80 | }); | |
| 81 | const runner = ProcessRunner.ProcessRunner.of({ | |
| 82 | run: ({ args }) => | |
| 83 | Effect.sync(() => { | |
| 84 | const [, verb, unit = ""] = args; | |
| 85 | if (verb === "reset-failed") units.delete(unit); | |
| 86 | if (verb !== "show") return output(""); | |
| 87 | const state = units.get(unit); | |
| 88 | return output( | |
| 89 | state === undefined | |
| 90 | ? "LoadState=not-found\nActiveState=inactive\nResult=success\n" | |
| 91 | : `LoadState=loaded\nActiveState=${state.activeState}\nResult=${state.result}\n`, | |
| 92 | ); | |
| 93 | }), | |
| 94 | }); | |
| 95 | const scope = yield* make.pipe( | |
| 96 | Effect.provideService(ProcessRunner.ProcessRunner, runner), | |
| 97 | Effect.provideService(HostProcess.Platform, "linux"), | |
| 98 | Effect.provideService(HostProcess.Environment, { PATH: bin, XDG_RUNTIME_DIR: "/run/user/1" }), | |
| 99 | ); | |
| 100 | return yield* body({ scope, units }).pipe( | |
| 101 | Effect.ensuring(Effect.sync(() => NodeFS.rmSync(bin, { recursive: true, force: true }))), | |
| 102 | ); | |
| 103 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); | |
| 104 | ||
| 105 | const unitOf = (launch: { readonly args: ReadonlyArray<string> }) => | |
| 106 | launch.args.find((arg) => arg.startsWith("--unit="))?.slice("--unit=".length) ?? ""; | |
| 107 | ||
| 108 | describe("AgentScope service", () => { | |
| 109 | it.effect("labels a thread from its newest scope until its next session clears it", () => | |
| 110 | withFakeSystemd(({ scope, units }) => | |
| 111 | Effect.gen(function* () { | |
| 112 | const first = yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" }); | |
| 113 | units.set(unitOf(first), { activeState: "failed", result: "oom-kill" }); | |
| 114 | const second = yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" }); | |
| 115 | units.set(unitOf(second), { activeState: "active", result: "success" }); | |
| 116 | // The older scope was OOM-killed, but the newest one is still running. | |
| 117 | expect(yield* scope.oomKilled("a")).toBe(false); | |
| 118 | ||
| 119 | units.set(unitOf(second), { activeState: "failed", result: "oom-kill" }); | |
| 120 | expect(yield* scope.oomKilled("a")).toBe(true); | |
| 121 | // A retried failure reads the same, even after the failed unit is reset. | |
| 122 | units.delete(unitOf(second)); | |
| 123 | expect(yield* scope.oomKilled("a")).toBe(true); | |
| 124 | ||
| 125 | // The next session may run without a scope, for example on Cursor. | |
| 126 | yield* scope.clear("a"); | |
| 127 | expect(yield* scope.oomKilled("a")).toBe(false); | |
| 128 | }), | |
| 129 | ), | |
| 130 | ); | |
| 131 | ||
| 132 | it.effect("keeps a running scope while hundreds of other threads launch", () => | |
| 133 | withFakeSystemd(({ scope, units }) => | |
| 134 | Effect.gen(function* () { | |
| 135 | const live = yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "live" }); | |
| 136 | units.set(unitOf(live), { activeState: "active", result: "success" }); | |
| 137 | for (let index = 0; index < 600; index++) { | |
| 138 | yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: `other-${index}` }); | |
| 139 | } | |
| 140 | units.set(unitOf(live), { activeState: "failed", result: "oom-kill" }); | |
| 141 | expect(yield* scope.oomKilled("live")).toBe(true); | |
| 142 | }), | |
| 143 | ), | |
| 144 | ); | |
| 145 | }); |
apps/server/src/process/agentScope.ts created+318| ... | ... | @@ -0,0 +1,318 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off -- unit names and RAM size come from the host. | |
| 2 | import * as NodeCrypto from "node:crypto"; | |
| 3 | import * as NodeOS from "node:os"; | |
| 4 | ||
| 5 | import { | |
| 6 | AgentScope, | |
| 7 | agentScopeCommand, | |
| 8 | type AgentScopeShape, | |
| 9 | type AgentSystemdScope, | |
| 10 | } from "@t3tools/shared/AgentScope"; | |
| 11 | import * as HostProcess from "@t3tools/shared/HostProcess"; | |
| 12 | import { resolveCommandPath } from "@t3tools/shared/shell"; | |
| 13 | import * as Effect from "effect/Effect"; | |
| 14 | import * as FileSystem from "effect/FileSystem"; | |
| 15 | import * as Layer from "effect/Layer"; | |
| 16 | import * as Path from "effect/Path"; | |
| 17 | ||
| 18 | import * as ProcessRunner from "../processRunner.ts"; | |
| 19 | ||
| 20 | /** | |
| 21 | * Agents and terminals share this slice. It sits in app.slice next to the | |
| 22 | * server's own unit, so the server's CPUWeight and IOWeight compete with the | |
| 23 | * agents directly. | |
| 24 | */ | |
| 25 | const AGENT_SLICE = "app-t3code-agents.slice"; | |
| 26 | ||
| 27 | const GiB = 1024 ** 3; | |
| 28 | ||
| 29 | /** | |
| 30 | * Memory limits for the agents slice. Agents together leave about 6 GB free | |
| 31 | * before they are throttled and 4 GB before the kernel kills one, so the | |
| 32 | * server, sshd, and tailscaled keep working. Small machines still give agents | |
| 33 | * at least half of RAM before throttling and three quarters before a kill. | |
| 34 | */ | |
| 35 | export function agentSliceMemoryLimits(totalBytes: number) { | |
| 36 | return { | |
| 37 | high: Math.max(totalBytes - 6 * GiB, Math.floor(totalBytes / 2)), | |
| 38 | max: Math.max(totalBytes - 4 * GiB, Math.floor(totalBytes * 0.75)), | |
| 39 | }; | |
| 40 | } | |
| 41 | ||
| 42 | export interface ScopeState { | |
| 43 | readonly loadState: string; | |
| 44 | readonly activeState: string; | |
| 45 | readonly result: string; | |
| 46 | /** `oom_kill` from the scope's memory.events, while its cgroup exists. */ | |
| 47 | readonly oomKills: number; | |
| 48 | /** `populated` from the scope's cgroup.events, while its cgroup exists. */ | |
| 49 | readonly populated: boolean | undefined; | |
| 50 | } | |
| 51 | ||
| 52 | /** | |
| 53 | * Reads one scope as the server sees it right after the agent process exits. | |
| 54 | * Our pipe can close before systemd handles the kill, so a scope that is | |
| 55 | * still stopping, is empty, or already counted an OOM kill is "stopping": | |
| 56 | * systemd records `Result=oom-kill` for it in a moment. | |
| 57 | */ | |
| 58 | export function classifyScope(state: ScopeState): "oom-killed" | "gone" | "running" | "stopping" { | |
| 59 | if (state.result === "oom-kill") return "oom-killed"; | |
| 60 | if ( | |
| 61 | state.loadState !== "loaded" || | |
| 62 | state.activeState === "inactive" || | |
| 63 | state.activeState === "failed" | |
| 64 | ) { | |
| 65 | return "gone"; | |
| 66 | } | |
| 67 | if (state.activeState === "deactivating" || state.oomKills > 0 || state.populated === false) { | |
| 68 | return "stopping"; | |
| 69 | } | |
| 70 | return "running"; | |
| 71 | } | |
| 72 | ||
| 73 | function parseKeyValues(text: string): Map<string, string> { | |
| 74 | const values = new Map<string, string>(); | |
| 75 | for (const line of text.split("\n")) { | |
| 76 | const index = line.indexOf("="); | |
| 77 | if (index > 0) values.set(line.slice(0, index), line.slice(index + 1).trim()); | |
| 78 | } | |
| 79 | return values; | |
| 80 | } | |
| 81 | ||
| 82 | function parseCgroupCounter(text: string, key: string): number | undefined { | |
| 83 | const match = new RegExp(`^${key} (\\d+)$`, "m").exec(text); | |
| 84 | return match === null ? undefined : Number(match[1]); | |
| 85 | } | |
| 86 | ||
| 87 | const STOPPING_POLL_INTERVAL = "100 millis"; | |
| 88 | const STOPPING_POLL_ATTEMPTS = 20; | |
| 89 | // A stopping scope can wait out TimeoutStopSec (90 s by default) for a stuck process. | |
| 90 | const CLEAR_FAILED_INTERVAL = "1 second"; | |
| 91 | const CLEAR_FAILED_ATTEMPTS = 120; | |
| 92 | const MAX_TRACKED_THREADS = 500; | |
| 93 | ||
| 94 | export const make = Effect.gen(function* () { | |
| 95 | const platform = yield* HostProcess.Platform; | |
| 96 | if (platform !== "linux") return AgentScope.defaultValue(); | |
| 97 | ||
| 98 | const runner = yield* ProcessRunner.ProcessRunner; | |
| 99 | const fileSystem = yield* FileSystem.FileSystem; | |
| 100 | const path = yield* Path.Path; | |
| 101 | const hostEnvironment = yield* HostProcess.Environment; | |
| 102 | const layerScope = yield* Effect.scope; | |
| 103 | ||
| 104 | const resolve = (command: string, env: NodeJS.ProcessEnv) => | |
| 105 | resolveCommandPath(command, { env }).pipe( | |
| 106 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 107 | Effect.provideService(Path.Path, path), | |
| 108 | Effect.provideService(HostProcess.Platform, platform), | |
| 109 | Effect.option, | |
| 110 | ); | |
| 111 | ||
| 112 | const run = (command: string, args: ReadonlyArray<string>) => | |
| 113 | runner.run({ command, args, timeout: "5 seconds" }).pipe(Effect.option); | |
| 114 | ||
| 115 | // Runs once, on the first agent launch. It proves this process can reach a | |
| 116 | // systemd user manager, creates the agents slice, and sets its limits. | |
| 117 | const systemd = yield* Effect.cached( | |
| 118 | Effect.gen(function* () { | |
| 119 | const runtimeDir = hostEnvironment.XDG_RUNTIME_DIR; | |
| 120 | if (runtimeDir === undefined || runtimeDir.length === 0) return undefined; | |
| 121 | const systemdRun = yield* resolve("systemd-run", hostEnvironment); | |
| 122 | const systemctl = yield* resolve("systemctl", hostEnvironment); | |
| 123 | if (systemdRun._tag === "None" || systemctl._tag === "None") return undefined; | |
| 124 | const probe = (options: ReadonlyArray<string>) => | |
| 125 | run(systemdRun.value, [ | |
| 126 | "--user", | |
| 127 | "--scope", | |
| 128 | "--quiet", | |
| 129 | "--collect", | |
| 130 | `--slice=${AGENT_SLICE}`, | |
| 131 | ...options, | |
| 132 | "--", | |
| 133 | "true", | |
| 134 | ]).pipe(Effect.map((result) => result._tag === "Some" && result.value.code === 0)); | |
| 135 | // Stop the whole scope when the kernel kills one of its processes, and | |
| 136 | // record the result as oom-kill. Scopes take OOMPolicy from systemd 253; | |
| 137 | // older user managers still get plain scopes, without the OOM label. | |
| 138 | const oomOptions = ["--property=OOMPolicy=stop"]; | |
| 139 | const scopeOptions = (yield* probe(oomOptions)) | |
| 140 | ? oomOptions | |
| 141 | : (yield* probe([])) | |
| 142 | ? [] | |
| 143 | : undefined; | |
| 144 | if (scopeOptions === undefined) { | |
| 145 | yield* Effect.logInfo("Agent scopes are off: no systemd user manager"); | |
| 146 | return undefined; | |
| 147 | } | |
| 148 | const limits = agentSliceMemoryLimits(NodeOS.totalmem()); | |
| 149 | const limited = yield* run(systemctl.value, [ | |
| 150 | "--user", | |
| 151 | "set-property", | |
| 152 | "--runtime", | |
| 153 | AGENT_SLICE, | |
| 154 | `MemoryHigh=${limits.high}`, | |
| 155 | `MemoryMax=${limits.max}`, | |
| 156 | ]); | |
| 157 | if (limited._tag === "None" || limited.value.code !== 0) { | |
| 158 | yield* Effect.logWarning("Could not set memory limits on the agents slice", { | |
| 159 | stderr: limited._tag === "Some" ? limited.value.stderr.trim() : undefined, | |
| 160 | }); | |
| 161 | } | |
| 162 | yield* Effect.logInfo("Agent scopes are on", { | |
| 163 | slice: AGENT_SLICE, | |
| 164 | ...limits, | |
| 165 | oomPolicy: scopeOptions.length > 0, | |
| 166 | }); | |
| 167 | return { | |
| 168 | systemdRun: systemdRun.value, | |
| 169 | systemctl: systemctl.value, | |
| 170 | runtimeDir, | |
| 171 | scopeOptions, | |
| 172 | }; | |
| 173 | }), | |
| 174 | ); | |
| 175 | ||
| 176 | // The newest scope per thread, oldest thread first. A thread runs one agent | |
| 177 | // at a time, so only its latest scope can explain a failure. | |
| 178 | const scopeByThread = new Map<string, { unit: string; oomKilled: boolean }>(); | |
| 179 | ||
| 180 | const readCgroupFile = (controlGroup: string, file: string) => | |
| 181 | fileSystem | |
| 182 | .readFileString(path.join("/sys/fs/cgroup", controlGroup, file)) | |
| 183 | .pipe(Effect.orElseSucceed(() => "")); | |
| 184 | ||
| 185 | const readScope = (systemctl: string, unit: string) => | |
| 186 | Effect.gen(function* () { | |
| 187 | const shown = yield* run(systemctl, [ | |
| 188 | "--user", | |
| 189 | "show", | |
| 190 | unit, | |
| 191 | "--property=LoadState,ActiveState,Result,ControlGroup", | |
| 192 | ]); | |
| 193 | // A failed query says nothing about the scope; the caller keeps the unit. | |
| 194 | if (shown._tag === "None" || shown.value.code !== 0) return undefined; | |
| 195 | const values = parseKeyValues(shown.value.stdout); | |
| 196 | const controlGroup = values.get("ControlGroup") ?? ""; | |
| 197 | const memoryEvents = controlGroup ? yield* readCgroupFile(controlGroup, "memory.events") : ""; | |
| 198 | const cgroupEvents = controlGroup ? yield* readCgroupFile(controlGroup, "cgroup.events") : ""; | |
| 199 | const populated = parseCgroupCounter(cgroupEvents, "populated"); | |
| 200 | return { | |
| 201 | loadState: values.get("LoadState") ?? "", | |
| 202 | activeState: values.get("ActiveState") ?? "", | |
| 203 | result: values.get("Result") ?? "", | |
| 204 | oomKills: parseCgroupCounter(memoryEvents, "oom_kill") ?? 0, | |
| 205 | populated: populated === undefined ? undefined : populated === 1, | |
| 206 | } satisfies ScopeState; | |
| 207 | }); | |
| 208 | ||
| 209 | const clearFailed = (systemctl: string, unit: string) => | |
| 210 | Effect.gen(function* () { | |
| 211 | for (let attempt = 0; attempt < CLEAR_FAILED_ATTEMPTS; attempt++) { | |
| 212 | const state = yield* readScope(systemctl, unit); | |
| 213 | if (state === undefined) return; | |
| 214 | if (state.activeState === "failed") { | |
| 215 | yield* run(systemctl, ["--user", "reset-failed", unit]); | |
| 216 | return; | |
| 217 | } | |
| 218 | if (state.activeState !== "deactivating") return; | |
| 219 | yield* Effect.sleep(CLEAR_FAILED_INTERVAL); | |
| 220 | } | |
| 221 | }); | |
| 222 | ||
| 223 | // Failed scopes stay loaded so we can read their result. Clear them once | |
| 224 | // systemd finishes stopping them. | |
| 225 | const forget = (systemctl: string, unit: string) => | |
| 226 | clearFailed(systemctl, unit).pipe(Effect.forkIn(layerScope), Effect.asVoid); | |
| 227 | ||
| 228 | // Drops the oldest thread's scope once it has ended. A scope that still | |
| 229 | // runs moves to the back, so its thread keeps its OOM answer and cleanup. | |
| 230 | const evictOldest = (systemctl: string) => | |
| 231 | Effect.gen(function* () { | |
| 232 | const oldest = scopeByThread.entries().next(); | |
| 233 | if (oldest.done === true) return; | |
| 234 | const [threadId, tracked] = oldest.value; | |
| 235 | const state = yield* readScope(systemctl, tracked.unit); | |
| 236 | // A newer launch replaced it while we read, and forgets it itself. | |
| 237 | if (scopeByThread.get(threadId) !== tracked) return; | |
| 238 | scopeByThread.delete(threadId); | |
| 239 | const status = state === undefined ? "running" : classifyScope(state); | |
| 240 | if (status === "running" || status === "stopping") { | |
| 241 | scopeByThread.set(threadId, tracked); | |
| 242 | return; | |
| 243 | } | |
| 244 | yield* forget(systemctl, tracked.unit); | |
| 245 | }); | |
| 246 | ||
| 247 | // Records a thread's newest scope, or forgets the thread when `unit` is | |
| 248 | // undefined. The replaced scope has its failed state cleared. | |
| 249 | const track = (systemctl: string, threadId: string, unit: string | undefined) => | |
| 250 | Effect.gen(function* () { | |
| 251 | const previous = scopeByThread.get(threadId); | |
| 252 | scopeByThread.delete(threadId); | |
| 253 | if (unit !== undefined) scopeByThread.set(threadId, { unit, oomKilled: false }); | |
| 254 | if (previous !== undefined) yield* forget(systemctl, previous.unit); | |
| 255 | if (scopeByThread.size > MAX_TRACKED_THREADS) yield* evictOldest(systemctl); | |
| 256 | }); | |
| 257 | ||
| 258 | const oomKilled: AgentScopeShape["oomKilled"] = (threadId) => | |
| 259 | Effect.gen(function* () { | |
| 260 | const tracked = scopeByThread.get(threadId); | |
| 261 | if (tracked === undefined) return false; | |
| 262 | if (tracked.oomKilled) return true; | |
| 263 | const scope = yield* systemd; | |
| 264 | if (scope === undefined) return false; | |
| 265 | for (let attempt = 0; attempt < STOPPING_POLL_ATTEMPTS; attempt++) { | |
| 266 | const state = yield* readScope(scope.systemctl, tracked.unit); | |
| 267 | if (state === undefined) return false; | |
| 268 | const status = classifyScope(state); | |
| 269 | if (status === "running") return false; | |
| 270 | if (status === "stopping") { | |
| 271 | yield* Effect.sleep(STOPPING_POLL_INTERVAL); | |
| 272 | continue; | |
| 273 | } | |
| 274 | yield* forget(scope.systemctl, tracked.unit); | |
| 275 | if (status === "gone") return false; | |
| 276 | yield* Effect.logWarning("Agent scope was killed: out of memory", { | |
| 277 | threadId, | |
| 278 | unit: tracked.unit, | |
| 279 | }); | |
| 280 | tracked.oomKilled = true; | |
| 281 | return true; | |
| 282 | } | |
| 283 | return false; | |
| 284 | }); | |
| 285 | ||
| 286 | const clear: AgentScopeShape["clear"] = (threadId) => | |
| 287 | Effect.gen(function* () { | |
| 288 | if (!scopeByThread.has(threadId)) return; | |
| 289 | const scope = yield* systemd; | |
| 290 | if (scope !== undefined) yield* track(scope.systemctl, threadId, undefined); | |
| 291 | }); | |
| 292 | ||
| 293 | return { | |
| 294 | wrap: Effect.fn("AgentScope.wrap")(function* ({ command, args, name, threadId, env }) { | |
| 295 | const resolved = yield* resolve(command, env ?? hostEnvironment); | |
| 296 | // Leave unresolved commands alone so the spawn reports the missing binary. | |
| 297 | if (resolved._tag === "None") return { command, args }; | |
| 298 | const scope = yield* systemd; | |
| 299 | if (scope === undefined) return agentScopeCommand({ command: resolved.value, args }); | |
| 300 | const unit = `t3code-${name}-${NodeCrypto.randomUUID().slice(0, 8)}.scope`; | |
| 301 | if (threadId !== undefined) yield* track(scope.systemctl, threadId, unit); | |
| 302 | const systemdScope: AgentSystemdScope = { | |
| 303 | systemdRun: scope.systemdRun, | |
| 304 | runtimeDir: scope.runtimeDir, | |
| 305 | slice: AGENT_SLICE, | |
| 306 | unit, | |
| 307 | // Without a thread nothing reads the result, so let systemd unload | |
| 308 | // the unit even when it fails. | |
| 309 | options: threadId === undefined ? [...scope.scopeOptions, "--collect"] : scope.scopeOptions, | |
| 310 | }; | |
| 311 | return agentScopeCommand({ command: resolved.value, args, scope: systemdScope }); | |
| 312 | }), | |
| 313 | oomKilled, | |
| 314 | clear, | |
| 315 | } satisfies AgentScopeShape; | |
| 316 | }); | |
| 317 | ||
| 318 | export const layer = Layer.effect(AgentScope, make).pipe(Layer.provide(ProcessRunner.layer)); |
apps/server/src/server.ts+3| ... | ... | @@ -31,6 +31,7 @@ import * as ServerHttp from "./http.ts"; |
| 31 | 31 | import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; |
| 32 | 32 | import { fixPath } from "./os-jank.ts"; |
| 33 | 33 | import * as Ws from "./ws.ts"; |
| 34 | import * as AgentScopeLive from "./process/agentScope.ts"; | |
| 34 | 35 | import * as ExternalLauncher from "./process/externalLauncher.ts"; |
| 35 | 36 | import * as NodePtyAdapter from "./terminal/NodePtyAdapter.ts"; |
| 36 | 37 | import * as PullRequestHttp from "./pullRequest/http.ts"; |
| ... | ... | @@ -1100,6 +1101,8 @@ const layerMakeServer = Layer.unwrap( |
| 1100 | 1101 | Layer.provideMerge(FetchHttpClient.layer), |
| 1101 | 1102 | // PR reads, Git operations, and WebSocket discovery share one process limiter. |
| 1102 | 1103 | Layer.provide(VcsProcess.layer), |
| 1104 | // Every agent and terminal spawn reads this, so it sits below everything. | |
| 1105 | Layer.provideMerge(AgentScopeLive.layer), | |
| 1103 | 1106 | Layer.provideMerge(layerPlatformServices), |
| 1104 | 1107 | ); |
| 1105 | 1108 | }), |
apps/server/src/terminal/Manager.ts+10-2| ... | ... | @@ -42,6 +42,7 @@ import { |
| 42 | 42 | } from "@t3tools/contracts"; |
| 43 | 43 | import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; |
| 44 | 44 | import * as KeyedLock from "@t3tools/shared/KeyedLock"; |
| 45 | import { AgentScope } from "@t3tools/shared/AgentScope"; | |
| 45 | 46 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 46 | 47 | import { mergePathEntries } from "@t3tools/shared/shell"; |
| 47 | 48 | |
| ... | ... | @@ -1480,6 +1481,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1480 | 1481 | const historyByteLimit = options.historyByteLimit ?? DEFAULT_HISTORY_BYTE_LIMIT; |
| 1481 | 1482 | const platform = yield* HostProcess.Platform; |
| 1482 | 1483 | const architecture = yield* HostProcess.Architecture; |
| 1484 | const agentScope = yield* AgentScope; | |
| 1483 | 1485 | // Terminals must inherit the user's full environment (minus the blocklist |
| 1484 | 1486 | // applied in createTerminalSpawnEnv) — an allowlist here silently strips |
| 1485 | 1487 | // things like PSModulePath, DISPLAY, proxies, and toolchain variables. |
| ... | ... | @@ -2180,10 +2182,16 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2180 | 2182 | ); |
| 2181 | 2183 | } |
| 2182 | 2184 | |
| 2185 | const launch = yield* agentScope.wrap({ | |
| 2186 | command: candidate.shell, | |
| 2187 | args: candidate.args ?? [], | |
| 2188 | name: "terminal", | |
| 2189 | env: spawnEnv, | |
| 2190 | }); | |
| 2183 | 2191 | const attempt = yield* Effect.result( |
| 2184 | 2192 | options.ptyAdapter.spawn({ |
| 2185 | shell: candidate.shell, | |
| 2186 | ...(candidate.args ? { args: candidate.args } : {}), | |
| 2193 | shell: launch.command, | |
| 2194 | ...(launch.args.length > 0 ? { args: [...launch.args] } : {}), | |
| 2187 | 2195 | cwd: session.cwd, |
| 2188 | 2196 | cols: session.cols, |
| 2189 | 2197 | rows: session.rows, |
packages/provider-acp/src/server/AcpSessionRuntime.ts+18-1| ... | ... | @@ -27,6 +27,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; |
| 27 | 27 | import type * as EffectAcpSchema from "effect-acp/compat"; |
| 28 | 28 | import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 29 | 29 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 30 | import { AgentScope, AgentScopeThreadId, RAISE_OOM_SCORE_LINE } from "@t3tools/shared/AgentScope"; | |
| 30 | 31 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 31 | 32 | |
| 32 | 33 | import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; |
| ... | ... | @@ -275,6 +276,7 @@ export function wrapCommandForLinuxCgroup( |
| 275 | 276 | "-c", |
| 276 | 277 | [ |
| 277 | 278 | "lease_path=$1; expected=$2; shift 2", |
| 279 | RAISE_OOM_SCORE_LINE, | |
| 278 | 280 | 'printf "%s\\n" "$$" > "$lease_path/cgroup.procs" || exit 125', |
| 279 | 281 | "actual=", |
| 280 | 282 | "while IFS= read -r line; do", |
| ... | ... | @@ -1552,9 +1554,24 @@ export const make = ( |
| 1552 | 1554 | cause: new Error("Contained ACP command was not found on PATH"), |
| 1553 | 1555 | }); |
| 1554 | 1556 | }); |
| 1557 | // A cgroup lease is already its own leaf cgroup, so it only needs the | |
| 1558 | // raised OOM score (in its wrapper). Other agents get their own scope. | |
| 1559 | const scopedSpawnCommand = | |
| 1560 | linuxCgroupLease !== undefined || spawnCommand.shell | |
| 1561 | ? spawnCommand | |
| 1562 | : { | |
| 1563 | ...(yield* (yield* AgentScope).wrap({ | |
| 1564 | command: spawnCommand.command, | |
| 1565 | args: spawnCommand.args, | |
| 1566 | name: "acp", | |
| 1567 | threadId: yield* AgentScopeThreadId, | |
| 1568 | env: { ...process.env, ...options.spawn.env }, | |
| 1569 | })), | |
| 1570 | shell: false, | |
| 1571 | }; | |
| 1555 | 1572 | const containedSpawnCommand = |
| 1556 | 1573 | linuxCgroupLease === undefined |
| 1557 | ? spawnCommand | |
| 1574 | ? scopedSpawnCommand | |
| 1558 | 1575 | : { |
| 1559 | 1576 | ...wrapCommandForLinuxCgroup( |
| 1560 | 1577 | linuxCgroupLease, |
packages/provider-acp/src/server/adapter.ts+6| ... | ... | @@ -31,6 +31,7 @@ import { |
| 31 | 31 | type ThreadTokenUsageSnapshot, |
| 32 | 32 | type ThreadId, |
| 33 | 33 | } from "@t3tools/contracts"; |
| 34 | import { AgentScope, AgentScopeThreadId } from "@t3tools/shared/AgentScope"; | |
| 34 | 35 | import { modelSelectionsEqual } from "@t3tools/shared/model"; |
| 35 | 36 | import { type SelfInvocation, selfInvocationArgs } from "@t3tools/shared/nodeRuntime"; |
| 36 | 37 | import { FILE_HEADERS_ONLY, formatPatch, structuredPatch } from "diff"; |
| ... | ... | @@ -1486,6 +1487,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( |
| 1486 | 1487 | const crypto = yield* Crypto.Crypto; |
| 1487 | 1488 | const fileSystem = yield* FileSystem.FileSystem; |
| 1488 | 1489 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 1490 | const agentScope = yield* AgentScope; | |
| 1489 | 1491 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 1490 | 1492 | const host = yield* ProviderHost.ProviderHost; |
| 1491 | 1493 | const mcpSessions = yield* McpProviderSessions.McpProviderSessions; |
| ... | ... | @@ -6020,6 +6022,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( |
| 6020 | 6022 | Effect.provideService(Scope.Scope, runtimeScope), |
| 6021 | 6023 | Effect.provideService(Crypto.Crypto, crypto), |
| 6022 | 6024 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), |
| 6025 | Effect.provideService(AgentScope, agentScope), | |
| 6026 | Effect.provideService(AgentScopeThreadId, threadId ?? undefined), | |
| 6023 | 6027 | ); |
| 6024 | 6028 | }); |
| 6025 | 6029 | |
| ... | ... | @@ -6099,6 +6103,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( |
| 6099 | 6103 | Effect.provideService(Scope.Scope, replacementScope), |
| 6100 | 6104 | Effect.provideService(Crypto.Crypto, crypto), |
| 6101 | 6105 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), |
| 6106 | Effect.provideService(AgentScope, agentScope), | |
| 6107 | Effect.provideService(AgentScopeThreadId, threadId ?? undefined), | |
| 6102 | 6108 | ); |
| 6103 | 6109 | // Session setup may publish commands before it returns. Buffer those |
| 6104 | 6110 | // notifications, but do not expose request or extension handlers |
packages/provider-muse/src/server/adapter.ts+16-5| ... | ... | @@ -22,6 +22,7 @@ import { |
| 22 | 22 | type ServerProviderModel, |
| 23 | 23 | } from "@t3tools/contracts"; |
| 24 | 24 | import type { MuseSettings } from "../settings.ts"; |
| 25 | import { AgentScope } from "@t3tools/shared/AgentScope"; | |
| 25 | 26 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 26 | 27 | import * as Cause from "effect/Cause"; |
| 27 | 28 | import * as DateTime from "effect/DateTime"; |
| ... | ... | @@ -250,6 +251,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( |
| 250 | 251 | const providerHost = yield* ProviderHost.ProviderHost; |
| 251 | 252 | const fileSystem = yield* FileSystem.FileSystem; |
| 252 | 253 | const mcpSessions = yield* McpProviderSessions.McpProviderSessions; |
| 254 | const agentScope = yield* AgentScope; | |
| 253 | 255 | |
| 254 | 256 | const protocolError = (detail: string, payload?: unknown) => |
| 255 | 257 | new ProviderAdapter.ProviderAdapterProtocolError({ |
| ... | ... | @@ -1367,15 +1369,24 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( |
| 1367 | 1369 | const launchHost = Effect.fnUntraced(function* () { |
| 1368 | 1370 | const epoch = ++hostEpoch; |
| 1369 | 1371 | const mcpSession = yield* mcpSessions.read(input.threadId); |
| 1372 | const environment = McpProviderSession.withAgentDeviceEnvironment( | |
| 1373 | options.environment, | |
| 1374 | mcpSession, | |
| 1375 | ); | |
| 1376 | const launch = yield* agentScope.wrap({ | |
| 1377 | command: options.settings.binaryPath || "muse", | |
| 1378 | args: [], | |
| 1379 | name: "muse", | |
| 1380 | threadId: input.threadId, | |
| 1381 | env: environment, | |
| 1382 | }); | |
| 1370 | 1383 | const created = yield* Effect.acquireRelease( |
| 1371 | 1384 | createMuseSdkHostEffect( |
| 1372 | 1385 | { |
| 1373 | binaryPath: options.settings.binaryPath || "muse", | |
| 1386 | binaryPath: launch.command, | |
| 1387 | launchArgs: launch.args, | |
| 1374 | 1388 | cwd, |
| 1375 | environment: McpProviderSession.withAgentDeviceEnvironment( | |
| 1376 | options.environment, | |
| 1377 | mcpSession, | |
| 1378 | ), | |
| 1389 | environment, | |
| 1379 | 1390 | runtimeMode: input.runtimePolicy.runtimeMode, |
| 1380 | 1391 | }, |
| 1381 | 1392 | options.createHost, |
packages/provider-muse/src/server/sdk.ts+3-1| ... | ... | @@ -29,6 +29,8 @@ export interface MuseSdkHost { |
| 29 | 29 | |
| 30 | 30 | export interface MuseSdkHostOptions { |
| 31 | 31 | readonly binaryPath: string; |
| 32 | /** Arguments before the serve arguments, for a launcher in front of the binary. */ | |
| 33 | readonly launchArgs?: ReadonlyArray<string>; | |
| 32 | 34 | readonly cwd?: string; |
| 33 | 35 | readonly environment?: NodeJS.ProcessEnv; |
| 34 | 36 | readonly runtimeMode?: RuntimeMode; |
| ... | ... | @@ -89,7 +91,7 @@ export async function createMuseSdkHost( |
| 89 | 91 | options.signal?.throwIfAborted(); |
| 90 | 92 | const handshake = spawn({ |
| 91 | 93 | command: options.binaryPath, |
| 92 | args: museServeArgs(options), | |
| 94 | args: [...(options.launchArgs ?? []), ...museServeArgs(options)], | |
| 93 | 95 | ...(options.cwd ? { cwd: options.cwd } : {}), |
| 94 | 96 | // Callers pass an environment already built with makeMuseEnvironment. |
| 95 | 97 | env: options.environment ?? makeMuseEnvironment(), |
packages/provider-opencode/src/server/OpenCodeRuntime.ts+10-1| ... | ... | @@ -36,6 +36,7 @@ import { isWindowsCommandNotFound } from "@t3tools/provider-core/server/snapshot |
| 36 | 36 | import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; |
| 37 | 37 | import { collectStreamAsString } from "@t3tools/provider-core/server/snapshotProbe"; |
| 38 | 38 | import * as NetService from "@t3tools/shared/Net"; |
| 39 | import { AgentScope } from "@t3tools/shared/AgentScope"; | |
| 39 | 40 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 40 | 41 | import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; |
| 41 | 42 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| ... | ... | @@ -597,6 +598,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 597 | 598 | const netService = yield* NetService.NetService; |
| 598 | 599 | const hostPlatform = yield* HostProcess.Platform; |
| 599 | 600 | const serverLedger = yield* OpenCodeServerLedger.OpenCodeServerLedger; |
| 601 | const agentScope = yield* AgentScope; | |
| 600 | 602 | const resolveCommand = (command: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) => |
| 601 | 603 | resolveSpawnCommand(command, args, env ? { env } : {}); |
| 602 | 604 | |
| ... | ... | @@ -693,7 +695,14 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 693 | 695 | )); |
| 694 | 696 | const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; |
| 695 | 697 | const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; |
| 696 | const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment); | |
| 698 | // One server runs every session of the instance, so its scope has no thread. | |
| 699 | const launch = yield* agentScope.wrap({ | |
| 700 | command: input.binaryPath, | |
| 701 | args, | |
| 702 | name: "opencode", | |
| 703 | env: input.environment, | |
| 704 | }); | |
| 705 | const spawnCommand = yield* resolveCommand(launch.command, launch.args, input.environment); | |
| 697 | 706 | const serverPassword = resolveOpenCodeServerPassword({ |
| 698 | 707 | external: false, |
| 699 | 708 | ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), |
packages/provider-pi/src/server/adapter.ts+10-1| ... | ... | @@ -24,6 +24,7 @@ |
| 24 | 24 | * updates has no matching T3 surface and is ignored. |
| 25 | 25 | */ |
| 26 | 26 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 27 | import { AgentScope } from "@t3tools/shared/AgentScope"; | |
| 27 | 28 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 28 | 29 | import { |
| 29 | 30 | defaultInstanceIdForDriver, |
| ... | ... | @@ -387,6 +388,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 387 | 388 | options: PiAdapterV2Options, |
| 388 | 389 | ) { |
| 389 | 390 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 391 | const agentScope = yield* AgentScope; | |
| 390 | 392 | const fileSystem = yield* FileSystem.FileSystem; |
| 391 | 393 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 392 | 394 | const host = yield* ProviderHost.ProviderHost; |
| ... | ... | @@ -440,9 +442,16 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 440 | 442 | extensionPath, |
| 441 | 443 | runtimeMode: input.runtimePolicy.runtimeMode, |
| 442 | 444 | }); |
| 443 | const connection: PiRpcConnection = yield* makePiRpcConnection({ | |
| 445 | const scopedLaunch = yield* agentScope.wrap({ | |
| 444 | 446 | command: options.settings.binaryPath || "pi", |
| 445 | 447 | args: launch.args, |
| 448 | name: "pi", | |
| 449 | threadId: input.threadId, | |
| 450 | env: launch.env, | |
| 451 | }); | |
| 452 | const connection: PiRpcConnection = yield* makePiRpcConnection({ | |
| 453 | command: scopedLaunch.command, | |
| 454 | args: scopedLaunch.args, | |
| 446 | 455 | cwd, |
| 447 | 456 | env: launch.env, |
| 448 | 457 | }).pipe( |
packages/shared/package.json+4| ... | ... | @@ -311,6 +311,10 @@ |
| 311 | 311 | "types": "./src/nodeRuntime.ts", |
| 312 | 312 | "import": "./src/nodeRuntime.ts" |
| 313 | 313 | }, |
| 314 | "./AgentScope": { | |
| 315 | "types": "./src/AgentScope.ts", | |
| 316 | "import": "./src/AgentScope.ts" | |
| 317 | }, | |
| 314 | 318 | "./HostProcess": { |
| 315 | 319 | "types": "./src/HostProcess.ts", |
| 316 | 320 | "import": "./src/HostProcess.ts" |
packages/shared/src/AgentScope.test.ts created+30| ... | ... | @@ -0,0 +1,30 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import * as NodeChildProcess from "node:child_process"; | |
| 3 | ||
| 4 | import { describe, expect, it } from "@effect/vitest"; | |
| 5 | ||
| 6 | import { AGENT_OOM_SCORE_ADJ, agentScopeCommand } from "./AgentScope.ts"; | |
| 7 | import * as HostProcess from "./HostProcess.ts"; | |
| 8 | ||
| 9 | describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("agentScopeCommand", () => { | |
| 10 | it("raises the OOM score and execs the command with its arguments in the same process", () => { | |
| 11 | const launch = agentScopeCommand({ | |
| 12 | command: "/bin/sh", | |
| 13 | args: [ | |
| 14 | "-c", | |
| 15 | 'printf "%s\\n" "$$" "$(cat /proc/self/oom_score_adj)" "$@"', | |
| 16 | "agent", | |
| 17 | "a b", | |
| 18 | "$HOME", | |
| 19 | ], | |
| 20 | }); | |
| 21 | const child = NodeChildProcess.spawnSync(launch.command, [...launch.args], { | |
| 22 | encoding: "utf8", | |
| 23 | }); | |
| 24 | const [pid, score, ...args] = child.stdout.trimEnd().split("\n"); | |
| 25 | // The command runs as the spawned process, so signals and PIDs still reach it. | |
| 26 | expect(Number(pid)).toBe(child.pid); | |
| 27 | expect(Number(score)).toBe(AGENT_OOM_SCORE_ADJ); | |
| 28 | expect(args).toEqual(["a b", "$HOME"]); | |
| 29 | }); | |
| 30 | }); |
packages/shared/src/AgentScope.ts created+129| ... | ... | @@ -0,0 +1,129 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | ||
| 4 | /** | |
| 5 | * Isolates long-lived agent and terminal processes from the server on Linux. | |
| 6 | * | |
| 7 | * Each wrapped command runs with a high `oom_score_adj`, so the kernel OOM | |
| 8 | * killer picks it before the server. Under a systemd user manager it also runs | |
| 9 | * in its own transient scope, so systemd-oomd (which kills whole leaf cgroups) | |
| 10 | * kills one agent instead of the server and every other agent. | |
| 11 | * | |
| 12 | * The server provides the real implementation. The default leaves commands | |
| 13 | * unchanged, which is also the behavior on macOS, Windows, and in tests. | |
| 14 | */ | |
| 15 | export interface AgentScopeShape { | |
| 16 | /** | |
| 17 | * Wraps the command that starts an agent or terminal. `name` labels the | |
| 18 | * scope unit, for example "claude" or "terminal". `env` is the environment | |
| 19 | * the command will run with, used to resolve it on PATH. A command that does | |
| 20 | * not resolve stays unwrapped, so a missing binary still fails at spawn. | |
| 21 | */ | |
| 22 | readonly wrap: (input: { | |
| 23 | readonly command: string; | |
| 24 | readonly args: ReadonlyArray<string>; | |
| 25 | readonly name: string; | |
| 26 | readonly threadId?: string | undefined; | |
| 27 | readonly env?: NodeJS.ProcessEnv | undefined; | |
| 28 | }) => Effect.Effect<AgentScopeCommand>; | |
| 29 | /** | |
| 30 | * Whether the latest agent scope of this thread was killed because the | |
| 31 | * machine or the agents slice ran out of memory. The answer holds until the | |
| 32 | * thread launches its next agent, so a retried failure reads the same. | |
| 33 | */ | |
| 34 | readonly oomKilled: (threadId: string) => Effect.Effect<boolean>; | |
| 35 | /** | |
| 36 | * Forgets the thread's scope. The session manager calls it before it opens | |
| 37 | * a provider session, so a scope only answers for the session that launched | |
| 38 | * it, even when the next session runs without one (Cursor, OpenCode). | |
| 39 | */ | |
| 40 | readonly clear: (threadId: string) => Effect.Effect<void>; | |
| 41 | } | |
| 42 | ||
| 43 | export interface AgentScopeCommand { | |
| 44 | readonly command: string; | |
| 45 | readonly args: ReadonlyArray<string>; | |
| 46 | } | |
| 47 | ||
| 48 | export const AgentScope = Context.Reference<AgentScopeShape>("@t3tools/shared/AgentScope", { | |
| 49 | defaultValue: () => ({ | |
| 50 | wrap: ({ command, args }) => Effect.succeed({ command, args }), | |
| 51 | oomKilled: () => Effect.succeed(false), | |
| 52 | clear: () => Effect.void, | |
| 53 | }), | |
| 54 | }); | |
| 55 | ||
| 56 | /** | |
| 57 | * The thread an ACP runtime is spawned for. ACP runtimes are shared by several | |
| 58 | * provider flavors and do not take a thread id, so the ACP adapter provides it. | |
| 59 | */ | |
| 60 | export const AgentScopeThreadId = Context.Reference<string | undefined>( | |
| 61 | "@t3tools/shared/AgentScope/ThreadId", | |
| 62 | { defaultValue: () => undefined }, | |
| 63 | ); | |
| 64 | ||
| 65 | /** | |
| 66 | * Agents get this `oom_score_adj` so the kernel kills them before the server. | |
| 67 | * An unprivileged process may raise its own score, never lower it. | |
| 68 | */ | |
| 69 | export const AGENT_OOM_SCORE_ADJ = 800; | |
| 70 | ||
| 71 | /** Shell line that raises the score of the current process, ignoring failure. */ | |
| 72 | export const RAISE_OOM_SCORE_LINE = `{ echo ${AGENT_OOM_SCORE_ADJ} >/proc/self/oom_score_adj; } 2>/dev/null`; | |
| 73 | ||
| 74 | const LAUNCH_SCRIPT = [RAISE_OOM_SCORE_LINE, 'exec "$@"'].join("\n"); | |
| 75 | ||
| 76 | // systemd-run needs XDG_RUNTIME_DIR to reach the user manager. Fill it in when | |
| 77 | // the agent environment does not carry it; it is the same user either way. | |
| 78 | const SCOPED_LAUNCH_SCRIPT = [ | |
| 79 | RAISE_OOM_SCORE_LINE, | |
| 80 | ': "${XDG_RUNTIME_DIR:=$1}"', | |
| 81 | "export XDG_RUNTIME_DIR", | |
| 82 | "shift", | |
| 83 | 'exec "$@"', | |
| 84 | ].join("\n"); | |
| 85 | ||
| 86 | export interface AgentSystemdScope { | |
| 87 | readonly systemdRun: string; | |
| 88 | readonly slice: string; | |
| 89 | readonly unit: string; | |
| 90 | readonly runtimeDir: string; | |
| 91 | /** Extra `systemd-run` options, such as `--property=OOMPolicy=stop`. */ | |
| 92 | readonly options: ReadonlyArray<string>; | |
| 93 | } | |
| 94 | ||
| 95 | /** | |
| 96 | * Builds the Linux launch for an agent command. Every step `exec`s, so the | |
| 97 | * spawned PID, stdio, and signals all belong to the agent itself. | |
| 98 | * `systemd-run --scope` registers its own PID as the scope and then runs the | |
| 99 | * command, so the agent and all of its children start inside the scope. | |
| 100 | */ | |
| 101 | export function agentScopeCommand(input: { | |
| 102 | readonly command: string; | |
| 103 | readonly args: ReadonlyArray<string>; | |
| 104 | readonly scope?: AgentSystemdScope | undefined; | |
| 105 | }): AgentScopeCommand { | |
| 106 | const { command, args, scope } = input; | |
| 107 | if (scope === undefined) { | |
| 108 | return { command: "/bin/sh", args: ["-c", LAUNCH_SCRIPT, "t3-agent", command, ...args] }; | |
| 109 | } | |
| 110 | return { | |
| 111 | command: "/bin/sh", | |
| 112 | args: [ | |
| 113 | "-c", | |
| 114 | SCOPED_LAUNCH_SCRIPT, | |
| 115 | "t3-agent", | |
| 116 | scope.runtimeDir, | |
| 117 | scope.systemdRun, | |
| 118 | "--user", | |
| 119 | "--scope", | |
| 120 | "--quiet", | |
| 121 | `--slice=${scope.slice}`, | |
| 122 | `--unit=${scope.unit}`, | |
| 123 | ...scope.options, | |
| 124 | "--", | |
| 125 | command, | |
| 126 | ...args, | |
| 127 | ], | |
| 128 | }; | |
| 129 | } |