| 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 | /** |
| 29 | * Set for terminals, so a job the user detached (tmux, nohup) outlives a |
| 30 | * server restart. Agents leave it unset: the next server stops agents a |
| 31 | * killed server left running before it resumes their sessions. |
| 32 | */ |
| 33 | readonly survivesRestart?: boolean | undefined; |
| 34 | }) => Effect.Effect<AgentScopeCommand>; |
| 35 | /** |
| 36 | * Whether the latest agent scope of this thread was killed because the |
| 37 | * machine or the agents slice ran out of memory. The answer holds until the |
| 38 | * thread launches its next agent, so a retried failure reads the same. |
| 39 | */ |
| 40 | readonly oomKilled: (threadId: string) => Effect.Effect<boolean>; |
| 41 | /** |
| 42 | * Forgets the thread's scope. The session manager calls it before it opens |
| 43 | * a provider session, so a scope only answers for the session that launched |
| 44 | * it, even when the next session runs without one (Cursor, OpenCode). |
| 45 | */ |
| 46 | readonly clear: (threadId: string) => Effect.Effect<void>; |
| 47 | } |
| 48 | |
| 49 | export interface AgentScopeCommand { |
| 50 | readonly command: string; |
| 51 | readonly args: ReadonlyArray<string>; |
| 52 | } |
| 53 | |
| 54 | export const AgentScope = Context.Reference<AgentScopeShape>("@t3tools/shared/AgentScope", { |
| 55 | defaultValue: () => ({ |
| 56 | wrap: ({ command, args }) => Effect.succeed({ command, args }), |
| 57 | oomKilled: () => Effect.succeed(false), |
| 58 | clear: () => Effect.void, |
| 59 | }), |
| 60 | }); |
| 61 | |
| 62 | /** |
| 63 | * The thread an ACP runtime is spawned for. ACP runtimes are shared by several |
| 64 | * provider flavors and do not take a thread id, so the ACP adapter provides it. |
| 65 | */ |
| 66 | export const AgentScopeThreadId = Context.Reference<string | undefined>( |
| 67 | "@t3tools/shared/AgentScope/ThreadId", |
| 68 | { defaultValue: () => undefined }, |
| 69 | ); |
| 70 | |
| 71 | /** |
| 72 | * Agents get this `oom_score_adj` so the kernel kills them before the server. |
| 73 | * An unprivileged process may raise its own score, never lower it. |
| 74 | */ |
| 75 | export const AGENT_OOM_SCORE_ADJ = 800; |
| 76 | |
| 77 | /** Shell line that raises the score of the current process, ignoring failure. */ |
| 78 | export const RAISE_OOM_SCORE_LINE = `{ echo ${AGENT_OOM_SCORE_ADJ} >/proc/self/oom_score_adj; } 2>/dev/null`; |
| 79 | |
| 80 | const LAUNCH_SCRIPT = [RAISE_OOM_SCORE_LINE, 'exec "$@"'].join("\n"); |
| 81 | |
| 82 | // systemd-run needs XDG_RUNTIME_DIR to reach the user manager. Fill it in when |
| 83 | // the agent environment does not carry it; it is the same user either way. |
| 84 | const SCOPED_LAUNCH_SCRIPT = [ |
| 85 | RAISE_OOM_SCORE_LINE, |
| 86 | ': "${XDG_RUNTIME_DIR:=$1}"', |
| 87 | "export XDG_RUNTIME_DIR", |
| 88 | "shift", |
| 89 | 'exec "$@"', |
| 90 | ].join("\n"); |
| 91 | |
| 92 | export interface AgentSystemdScope { |
| 93 | readonly systemdRun: string; |
| 94 | readonly slice: string; |
| 95 | readonly unit: string; |
| 96 | readonly runtimeDir: string; |
| 97 | /** Extra `systemd-run` options, such as `--property=OOMPolicy=stop`. */ |
| 98 | readonly options: ReadonlyArray<string>; |
| 99 | } |
| 100 | |
| 101 | /** |
| 102 | * Builds the Linux launch for an agent command. Every step `exec`s, so the |
| 103 | * spawned PID, stdio, and signals all belong to the agent itself. |
| 104 | * `systemd-run --scope` registers its own PID as the scope and then runs the |
| 105 | * command, so the agent and all of its children start inside the scope. |
| 106 | */ |
| 107 | export function agentScopeCommand(input: { |
| 108 | readonly command: string; |
| 109 | readonly args: ReadonlyArray<string>; |
| 110 | readonly scope?: AgentSystemdScope | undefined; |
| 111 | }): AgentScopeCommand { |
| 112 | const { command, args, scope } = input; |
| 113 | if (scope === undefined) { |
| 114 | return { command: "/bin/sh", args: ["-c", LAUNCH_SCRIPT, "t3-agent", command, ...args] }; |
| 115 | } |
| 116 | return { |
| 117 | command: "/bin/sh", |
| 118 | args: [ |
| 119 | "-c", |
| 120 | SCOPED_LAUNCH_SCRIPT, |
| 121 | "t3-agent", |
| 122 | scope.runtimeDir, |
| 123 | scope.systemdRun, |
| 124 | "--user", |
| 125 | "--scope", |
| 126 | "--quiet", |
| 127 | `--slice=${scope.slice}`, |
| 128 | `--unit=${scope.unit}`, |
| 129 | ...scope.options, |
| 130 | "--", |
| 131 | command, |
| 132 | ...args, |
| 133 | ], |
| 134 | }; |
| 135 | } |