@gajae-code/coding-agent 0.11.9 → 0.11.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/dist/types/cli.d.ts +8 -1
  3. package/dist/types/config/model-profile-activation.d.ts +1 -0
  4. package/dist/types/config/model-profile-contract.d.ts +2 -0
  5. package/dist/types/config/model-registry.d.ts +2 -0
  6. package/dist/types/config/settings-schema.d.ts +57 -2
  7. package/dist/types/config/settings.d.ts +1 -0
  8. package/dist/types/extensibility/extensions/types.d.ts +21 -0
  9. package/dist/types/gjc-runtime/linux-proc.d.ts +22 -14
  10. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +60 -0
  11. package/dist/types/gjc-runtime/state-runtime.d.ts +3 -1
  12. package/dist/types/gjc-runtime/state-writer.d.ts +1 -0
  13. package/dist/types/modes/acp/acp-agent.d.ts +14 -9
  14. package/dist/types/modes/components/queue-pane.d.ts +8 -0
  15. package/dist/types/modes/components/queued-message-selector.d.ts +6 -0
  16. package/dist/types/modes/components/welcome.d.ts +1 -0
  17. package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
  18. package/dist/types/modes/interactive-mode.d.ts +5 -1
  19. package/dist/types/runtime/memory-domain.d.ts +9 -0
  20. package/dist/types/runtime/memory-guard-contract.d.ts +71 -0
  21. package/dist/types/runtime/memory-guard.d.ts +39 -0
  22. package/dist/types/runtime/memory-limit.d.ts +11 -0
  23. package/dist/types/runtime-mcp/manager.d.ts +8 -0
  24. package/dist/types/sdk/acp/final-text.d.ts +19 -0
  25. package/dist/types/sdk/acp/mcp.d.ts +23 -0
  26. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  27. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  28. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  29. package/dist/types/sdk/bus/index.d.ts +11 -2
  30. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
  31. package/dist/types/sdk/bus/rate-limit-pool.d.ts +2 -0
  32. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +14 -7
  33. package/dist/types/sdk/bus/telegram-daemon.d.ts +97 -8
  34. package/dist/types/sdk/host/control/operations.d.ts +1 -0
  35. package/dist/types/sdk/lifecycle-session.d.ts +10 -1
  36. package/dist/types/sdk/session.d.ts +7 -0
  37. package/dist/types/sdk/startup-capability.d.ts +6 -0
  38. package/dist/types/session/agent-session.d.ts +11 -11
  39. package/dist/types/session/fallback-chain-controller.d.ts +18 -2
  40. package/dist/types/session/internal/managed-session-scope.d.ts +28 -1
  41. package/dist/types/session/session-manager.d.ts +34 -0
  42. package/dist/types/task/executor.d.ts +6 -0
  43. package/dist/types/task/provider-retry-status.d.ts +13 -0
  44. package/dist/types/task/types.d.ts +39 -0
  45. package/dist/types/task/ultragoal-redteam-activation.d.ts +33 -0
  46. package/dist/types/tools/index.d.ts +3 -1
  47. package/dist/types/tools/resource-gc.d.ts +19 -0
  48. package/package.json +8 -8
  49. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  50. package/scripts/run-test-manifest.ts +6 -4
  51. package/src/cli/args.ts +14 -8
  52. package/src/cli/fast-help.ts +1 -1
  53. package/src/cli/notify-cli.ts +1 -0
  54. package/src/cli.ts +48 -2
  55. package/src/commands/sdk.ts +91 -7
  56. package/src/config/model-profile-activation.ts +1 -0
  57. package/src/config/model-profile-contract.ts +13 -0
  58. package/src/config/model-profiles.ts +31 -7
  59. package/src/config/model-registry.ts +4 -2
  60. package/src/config/settings-schema.ts +61 -2
  61. package/src/config/settings.ts +8 -0
  62. package/src/defaults/gjc/skills/ralplan/SKILL.md +21 -2
  63. package/src/defaults/gjc/skills/ultragoal/SKILL.md +1 -1
  64. package/src/eval/js/tool-bridge.ts +1 -1
  65. package/src/extensibility/extensions/runner.ts +4 -0
  66. package/src/extensibility/extensions/types.ts +14 -0
  67. package/src/gjc-runtime/deep-interview-ambiguity.ts +16 -2
  68. package/src/gjc-runtime/deep-interview-draft.ts +2 -1
  69. package/src/gjc-runtime/deep-interview-recorder.ts +16 -1
  70. package/src/gjc-runtime/deep-interview-state.ts +25 -3
  71. package/src/gjc-runtime/linux-proc.ts +70 -36
  72. package/src/gjc-runtime/ralplan-runtime.ts +252 -1
  73. package/src/gjc-runtime/state-runtime.ts +48 -11
  74. package/src/gjc-runtime/state-writer.ts +1 -1
  75. package/src/hooks/skill-state.ts +23 -1
  76. package/src/internal-urls/docs-index.generated.ts +7 -7
  77. package/src/internal-urls/local-protocol.ts +23 -4
  78. package/src/modes/acp/acp-agent.ts +554 -60
  79. package/src/modes/acp/acp-event-mapper.ts +93 -6
  80. package/src/modes/components/model-selector.ts +15 -1
  81. package/src/modes/components/notifications-settings-editor.ts +1 -1
  82. package/src/modes/components/queue-pane.ts +55 -9
  83. package/src/modes/components/queued-message-selector.ts +53 -10
  84. package/src/modes/components/tool-status-header.ts +1 -4
  85. package/src/modes/components/welcome.ts +18 -3
  86. package/src/modes/controllers/command-controller.ts +4 -0
  87. package/src/modes/controllers/extension-ui-controller.ts +4 -10
  88. package/src/modes/controllers/input-controller.ts +44 -3
  89. package/src/modes/controllers/selector-controller.ts +38 -8
  90. package/src/modes/interactive-mode.ts +65 -26
  91. package/src/modes/runtime-init.ts +1 -0
  92. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  93. package/src/prompts/agents/architect.md +8 -1
  94. package/src/prompts/agents/critic.md +8 -1
  95. package/src/prompts/agents/executor.md +1 -1
  96. package/src/prompts/agents/planner.md +8 -1
  97. package/src/runtime/memory-domain.ts +66 -0
  98. package/src/runtime/memory-guard-contract.ts +73 -0
  99. package/src/runtime/memory-guard.ts +292 -0
  100. package/src/runtime/memory-limit.ts +50 -0
  101. package/src/runtime-mcp/manager.ts +26 -6
  102. package/src/sdk/acp/adapter.ts +8 -1
  103. package/src/sdk/acp/final-text.ts +40 -0
  104. package/src/sdk/acp/mcp.ts +26 -0
  105. package/src/sdk/broker/identity.ts +22 -12
  106. package/src/sdk/broker/lifecycle.ts +82 -1
  107. package/src/sdk/bus/chat-daemon-cli.ts +2 -2
  108. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  109. package/src/sdk/bus/config.ts +1 -1
  110. package/src/sdk/bus/daemon-paths.ts +2 -0
  111. package/src/sdk/bus/index.ts +222 -27
  112. package/src/sdk/bus/notification-service.ts +19 -5
  113. package/src/sdk/bus/operator-runtime.ts +31 -5
  114. package/src/sdk/bus/rate-limit-pool.ts +8 -0
  115. package/src/sdk/bus/telegram-cli.ts +6 -13
  116. package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
  117. package/src/sdk/bus/telegram-daemon-contract.ts +15 -7
  118. package/src/sdk/bus/telegram-daemon-control.ts +14 -5
  119. package/src/sdk/bus/telegram-daemon.ts +1191 -164
  120. package/src/sdk/host/control/dispatch.ts +2 -0
  121. package/src/sdk/host/control/operations.ts +1 -0
  122. package/src/sdk/host/host.ts +2 -2
  123. package/src/sdk/lifecycle-session.ts +19 -3
  124. package/src/sdk/protocol/operation-inventory.generated.json +58 -0
  125. package/src/sdk/protocol/operation-registry.ts +1 -0
  126. package/src/sdk/session.ts +24 -1
  127. package/src/sdk/startup-capability.ts +6 -0
  128. package/src/session/agent-session.ts +290 -132
  129. package/src/session/fallback-chain-controller.ts +47 -8
  130. package/src/session/internal/managed-session-scope.ts +173 -127
  131. package/src/session/internal/native-publish-outcome.ts +8 -3
  132. package/src/session/session-manager.ts +608 -25
  133. package/src/skill-state/workflow-mutation-guard.ts +41 -7
  134. package/src/task/executor.ts +152 -42
  135. package/src/task/index.ts +2 -0
  136. package/src/task/provider-retry-status.ts +88 -0
  137. package/src/task/render.ts +45 -26
  138. package/src/task/types.ts +25 -11
  139. package/src/task/ultragoal-redteam-activation.ts +75 -0
  140. package/src/tools/bash-allowed-prefixes.ts +45 -1
  141. package/src/tools/index.ts +3 -1
  142. package/src/tools/resource-gc.ts +496 -120
  143. package/src/tools/subagent-render.ts +74 -16
  144. package/src/tools/subagent.ts +2 -0
  145. package/src/workflow/workflow-intent-diff.ts +4 -1
  146. package/vendor/insane-search/MANIFEST.json +4 -1
  147. package/vendor/insane-search/engine/bias_check.py +5 -0
  148. package/vendor/insane-search/engine/fetch_chain.py +67 -1
  149. package/vendor/insane-search/engine/transport.py +13 -2
  150. package/vendor/insane-search/engine/validators.py +4 -1
@@ -1,5 +1,27 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import type { WindowsJobMemoryProbeResult } from "@gajae-code/natives";
5
+
6
+ function safeProbeWindowsJobMemory(): WindowsJobMemoryProbeResult {
7
+ try {
8
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
9
+ const natives = require("@gajae-code/natives") as { probeWindowsJobMemory?: () => unknown };
10
+ if (typeof natives.probeWindowsJobMemory === "function") {
11
+ return natives.probeWindowsJobMemory() as WindowsJobMemoryProbeResult;
12
+ }
13
+ } catch {
14
+ // Native addon unbuilt or missing
15
+ }
16
+ return { kind: "unsupported_platform", platform: process.platform };
17
+ }
18
+
1
19
  import { logger } from "@gajae-code/utils";
2
20
  import type { Settings } from "../config/settings";
21
+ import { computeMemoryGuardDomain } from "../runtime/memory-domain";
22
+ import { chooseMemoryGuardAction, MemoryGuardHost, resolveMemoryGuardPolicy } from "../runtime/memory-guard";
23
+ import type { MemoryGuardPolicy } from "../runtime/memory-guard-contract";
24
+ import { resolveEffectiveMemoryLimit } from "../runtime/memory-limit";
3
25
  import { listTabsForGc, releaseTabIfGcEligible, type TabGcSnapshot } from "./browser/tab-supervisor";
4
26
  import { cleanupStaleScreenshotFallbackDirs, hasCreatedScreenshotFallbackDir } from "./computer-gc";
5
27
 
@@ -17,7 +39,6 @@ import { cleanupStaleScreenshotFallbackDirs, hasCreatedScreenshotFallbackDir } f
17
39
  * eviction is best-effort and never force-evicts.
18
40
  */
19
41
 
20
- const DEFAULT_SWEEP_INTERVAL_MS = 30_000;
21
42
  const BYTES_PER_MB = 1024 * 1024;
22
43
 
23
44
  export interface BrowserGcPolicy {
@@ -55,7 +76,10 @@ export function resolveSweepIntervalMs(settings: Settings): number {
55
76
  /** Injectable seams so the controller is fully testable without real browsers/filesystem/RSS. */
56
77
  export interface ResourceGcDeps {
57
78
  now: () => number;
79
+ monotonicNow: () => number;
58
80
  rssBytes: () => number;
81
+ memorySnapshot: () => Promise<MemoryPressureSnapshot>;
82
+ runGc: () => void;
59
83
  logWarn: (msg: string, meta?: Record<string, unknown>) => void;
60
84
  listTabs: () => TabGcSnapshot[];
61
85
  releaseTab: (name: string, policy: { now: () => number; idleMs: number }) => Promise<boolean>;
@@ -65,7 +89,10 @@ export interface ResourceGcDeps {
65
89
 
66
90
  const defaultDeps: ResourceGcDeps = {
67
91
  now: () => Date.now(),
92
+ monotonicNow: () => performance.now(),
68
93
  rssBytes: () => process.memoryUsage().rss,
94
+ memorySnapshot: () => sampleMemoryPressure(),
95
+ runGc: () => Bun.gc(true),
69
96
  logWarn: (msg, meta) => logger.warn(msg, meta),
70
97
  listTabs: () => listTabsForGc(),
71
98
  releaseTab: (name, policy) => releaseTabIfGcEligible(name, policy),
@@ -75,33 +102,18 @@ const defaultDeps: ResourceGcDeps = {
75
102
 
76
103
  // ── Controller state (process-global; tabs/browsers are module-global too) ──────────────────
77
104
  const activeSessions = new Map<string, Settings>();
78
-
79
- interface ScheduleOwner {
80
- generation: number;
81
- token: number;
82
- }
83
-
84
- interface DeferredSchedule {
85
- generation: number;
86
- deadline: number;
87
- }
88
-
89
- interface WorkOwner {
90
- generation: number;
91
- source: "timer" | "external";
92
- }
93
-
94
- let pendingTimer: NodeJS.Timeout | null = null;
95
- let pendingDeadline: number | null = null;
96
- let pendingOwner: ScheduleOwner | null = null;
97
- let deferredSchedule: DeferredSchedule | null = null;
98
- let inProgressOwner: WorkOwner | null = null;
99
- let stopped = false;
100
- let timerGeneration = 0;
101
- let nextTimerToken = 0;
102
- let schedulerNow = (): number => performance.now();
105
+ const scheduler = new MemoryGuardHost({
106
+ run: async () => {
107
+ await sweepOnce(deps);
108
+ },
109
+ logDebug: (message, meta) => logger.debug(message, meta),
110
+ });
103
111
  let rssWarningActive = false;
104
112
  let lastScreenshotScanAt = 0;
113
+ const memoryGuardGcActive = new Set<string>();
114
+ const memoryGuardLastEvaluatedAt = new Map<string, number>();
115
+ const memoryGuardRestartAboveSince = new Map<string, number>();
116
+ const memoryGuardRestartCooldownUntil = new Map<string, number>();
105
117
  let deps: ResourceGcDeps = defaultDeps;
106
118
 
107
119
  export interface ResourceGcRegistration {
@@ -109,120 +121,479 @@ export interface ResourceGcRegistration {
109
121
  settings: Settings;
110
122
  }
111
123
 
124
+ function resolveSessionSweepIntervalMs(settings: Settings): number {
125
+ const memoryPolicy = resolveMemoryGuardPolicy(settings);
126
+ return memoryPolicy.enabled
127
+ ? Math.min(resolveSweepIntervalMs(settings), memoryPolicy.checkIntervalMs)
128
+ : resolveSweepIntervalMs(settings);
129
+ }
130
+
112
131
  /**
113
132
  * Register a session with the resource GC. Starts the single shared timer on the first
114
133
  * registration. Returns an idempotent unregister function; the timer stops only when the last
115
134
  * session unregisters.
116
135
  */
117
136
  export function registerResourceGcSession(reg: ResourceGcRegistration): () => void {
118
- const isNewSession = !activeSessions.has(reg.sessionId);
119
137
  activeSessions.set(reg.sessionId, reg.settings);
120
- stopped = false;
121
- if (isNewSession) {
122
- const deadline = schedulerNow() + resolveSweepIntervalMs(reg.settings);
138
+ const unregisterSchedule = scheduler.register({
139
+ ownerId: reg.sessionId,
140
+ intervalMs: resolveSessionSweepIntervalMs(reg.settings),
141
+ });
142
+ const unregisterSettings = reg.settings.onChanged(path => {
123
143
  if (
124
- inProgressOwner?.generation === timerGeneration &&
125
- (pendingDeadline === null || pendingDeadline <= deadline)
144
+ path === "memoryGuard.enabled" ||
145
+ path === "memoryGuard.checkIntervalMs" ||
146
+ path === "resourceGc.sweepIntervalMs"
126
147
  ) {
127
- deferSchedule(timerGeneration, deadline);
128
- } else {
129
- requestSchedule(deadline);
148
+ scheduler.updateInterval(reg.sessionId, resolveSessionSweepIntervalMs(reg.settings));
149
+ if (path === "memoryGuard.enabled" && !resolveMemoryGuardPolicy(reg.settings).enabled) {
150
+ memoryGuardGcActive.delete(reg.sessionId);
151
+ memoryGuardRestartAboveSince.delete(reg.sessionId);
152
+ memoryGuardRestartCooldownUntil.delete(reg.sessionId);
153
+ memoryGuardLastEvaluatedAt.delete(reg.sessionId);
154
+ }
130
155
  }
131
- }
156
+ });
132
157
  let unregistered = false;
133
158
  return () => {
134
159
  if (unregistered) return;
135
160
  unregistered = true;
136
161
  activeSessions.delete(reg.sessionId);
137
- if (activeSessions.size === 0) stopTimer();
162
+ memoryGuardLastEvaluatedAt.delete(reg.sessionId);
163
+ memoryGuardGcActive.delete(reg.sessionId);
164
+ memoryGuardRestartAboveSince.delete(reg.sessionId);
165
+ memoryGuardRestartCooldownUntil.delete(reg.sessionId);
166
+ unregisterSchedule();
167
+ unregisterSettings();
138
168
  };
139
169
  }
140
170
 
141
- function currentSweepIntervalMs(): number {
142
- let min = Number.POSITIVE_INFINITY;
143
- for (const settings of activeSessions.values()) min = Math.min(min, resolveSweepIntervalMs(settings));
144
- return Number.isFinite(min) ? min : DEFAULT_SWEEP_INTERVAL_MS;
171
+ export async function sweepOnce(d: ResourceGcDeps = deps): Promise<void> {
172
+ if (activeSessions.size === 0) return;
173
+ try {
174
+ const memorySweep = sweepMemoryPressureGuard(d);
175
+ if (memorySweep) await memorySweep;
176
+ } catch (error) {
177
+ d.logWarn("Memory guard: sweep failed; continuing with browser/screenshot cleanup", { error: String(error) });
178
+ }
179
+ await sweepBrowserTabs(d);
180
+ await sweepScreenshots(d);
145
181
  }
146
182
 
147
- function clearPendingSchedule(): void {
148
- if (pendingTimer) clearTimeout(pendingTimer);
149
- pendingTimer = null;
150
- pendingDeadline = null;
151
- pendingOwner = null;
183
+ export interface MemoryPressureDomain {
184
+ hardCapBytes: number;
185
+ totalUsageBytes: number;
186
+ source: MemoryPressureSnapshot["source"];
187
+ }
188
+
189
+ export interface MemoryPressureSnapshot {
190
+ hardCapBytes: number;
191
+ totalUsageBytes: number;
192
+ parentBytes: number;
193
+ source: "host" | "linux_cgroup_v2" | "linux_cgroup_v1" | "windows_job" | "windows_process_job_limit";
194
+ domains?: MemoryPressureDomain[];
195
+ }
196
+
197
+ function decodeMountInfoPath(value: string): string {
198
+ return value.replace(/\\([0-7]{3})/g, (_match, octal: string) => String.fromCharCode(Number.parseInt(octal, 8)));
199
+ }
200
+
201
+ interface CgroupDirectoryCandidate {
202
+ directory: string;
203
+ mountPoint: string;
204
+ fallback: boolean;
205
+ }
206
+
207
+ function resolveCgroupDirectories(
208
+ mountInfo: string,
209
+ membershipPath: string,
210
+ fsType: "cgroup" | "cgroup2",
211
+ ): CgroupDirectoryCandidate[] {
212
+ const contained: CgroupDirectoryCandidate[] = [];
213
+ const fallbacks: CgroupDirectoryCandidate[] = [];
214
+ const seen = new Set<string>();
215
+ for (const line of mountInfo.split("\n")) {
216
+ const [left, right] = line.split(" - ", 2);
217
+ if (!left || !right) continue;
218
+ const leftFields = left.split(" ");
219
+ const rightFields = right.split(" ");
220
+ if (leftFields.length < 5 || rightFields[0] !== fsType) continue;
221
+ if (fsType === "cgroup" && !rightFields.slice(2).join(",").split(",").includes("memory")) continue;
222
+ const mountRoot = decodeMountInfoPath(leftFields[3]!);
223
+ const mountPoint = decodeMountInfoPath(leftFields[4]!);
224
+ const relative = path.posix.relative(mountRoot, membershipPath);
225
+ const directory =
226
+ !relative.startsWith("..") && !path.posix.isAbsolute(relative)
227
+ ? path.join(mountPoint, relative)
228
+ : path.join(mountPoint, membershipPath.replace(/^\/+/, ""));
229
+ const key = `${directory}\0${mountPoint}`;
230
+ if (seen.has(key)) continue;
231
+ seen.add(key);
232
+ const fallback = relative.startsWith("..") || path.posix.isAbsolute(relative);
233
+ const candidate = { directory, mountPoint, fallback };
234
+ if (!relative.startsWith("..") && !path.posix.isAbsolute(relative)) contained.push(candidate);
235
+ else fallbacks.push(candidate);
236
+ }
237
+ return [...contained, ...fallbacks];
152
238
  }
153
239
 
154
- function requestSchedule(deadline: number): void {
155
- if (stopped || activeSessions.size === 0) return;
156
- if (pendingDeadline !== null && pendingDeadline <= deadline) return;
157
- clearPendingSchedule();
158
- const owner = { generation: timerGeneration, token: ++nextTimerToken };
159
- pendingDeadline = deadline;
160
- pendingOwner = owner;
161
- pendingTimer = setTimeout(
162
- () => {
163
- void handleTimerCallback(owner, deadline);
164
- },
165
- Math.max(0, deadline - schedulerNow()),
166
- );
167
- pendingTimer.unref?.();
240
+ async function readMemoryCounter(file: string): Promise<number | null> {
241
+ try {
242
+ const value = (await fs.readFile(file, "utf8")).trim();
243
+ if (value === "max" || !/^\d+$/.test(value)) return null;
244
+ const parsed = Number(value);
245
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
246
+ } catch {
247
+ return null;
248
+ }
168
249
  }
169
250
 
170
- function deferSchedule(generation: number, deadline: number): void {
171
- if (generation !== timerGeneration) return;
172
- if (deferredSchedule?.generation === generation) {
173
- deferredSchedule.deadline = Math.min(deferredSchedule.deadline, deadline);
174
- return;
251
+ type MemoryLimitCounter = { kind: "finite"; bytes: number } | { kind: "unlimited" };
252
+
253
+ async function readMemoryLimit(file: string, fsType: "cgroup" | "cgroup2"): Promise<MemoryLimitCounter | null> {
254
+ try {
255
+ const value = (await fs.readFile(file, "utf8")).trim();
256
+ if (value === "max") return { kind: "unlimited" };
257
+ if (!/^\d+$/.test(value)) return null;
258
+ const bytes = BigInt(value);
259
+ if (fsType === "cgroup" && bytes > BigInt(Number.MAX_SAFE_INTEGER)) return { kind: "unlimited" };
260
+ const numericBytes = Number(bytes);
261
+ return Number.isSafeInteger(numericBytes) ? { kind: "finite", bytes: numericBytes } : null;
262
+ } catch {
263
+ return null;
175
264
  }
176
- deferredSchedule = { generation, deadline };
177
265
  }
178
266
 
179
- async function handleTimerCallback(owner: ScheduleOwner, deadline: number): Promise<void> {
180
- if (pendingOwner?.generation !== owner.generation || pendingOwner.token !== owner.token) return;
181
- pendingTimer = null;
182
- pendingDeadline = null;
183
- pendingOwner = null;
184
- if (inProgressOwner) {
185
- deferSchedule(owner.generation, deadline);
186
- return;
267
+ function parseCgroupEntry(line: string): [string, string, string] | null {
268
+ const first = line.indexOf(":");
269
+ const second = first < 0 ? -1 : line.indexOf(":", first + 1);
270
+ if (first < 0 || second < 0) return null;
271
+ return [line.slice(0, first), line.slice(first + 1, second), line.slice(second + 1)];
272
+ }
273
+
274
+ async function sampleLinuxCgroupDirectory(
275
+ candidate: CgroupDirectoryCandidate,
276
+ fsType: "cgroup" | "cgroup2",
277
+ hostBytes: number,
278
+ ): Promise<MemoryPressureDomain[]> {
279
+ const limitName = fsType === "cgroup2" ? "memory.max" : "memory.limit_in_bytes";
280
+ const usageName = fsType === "cgroup2" ? "memory.current" : "memory.usage_in_bytes";
281
+ const source = fsType === "cgroup2" ? "linux_cgroup_v2" : "linux_cgroup_v1";
282
+ const domains: MemoryPressureDomain[] = [];
283
+ let current = candidate.directory;
284
+ while (true) {
285
+ const [limit, usage] = await Promise.all([
286
+ readMemoryLimit(path.join(current, limitName), fsType),
287
+ readMemoryCounter(path.join(current, usageName)),
288
+ ]);
289
+ if (limit !== null && usage !== null && (limit.kind === "unlimited" || limit.bytes > 0)) {
290
+ domains.push({
291
+ hardCapBytes: limit.kind === "unlimited" ? hostBytes : Math.min(hostBytes, limit.bytes),
292
+ totalUsageBytes: usage,
293
+ source,
294
+ });
295
+ }
296
+ if (current === candidate.mountPoint) break;
297
+ const parent = path.dirname(current);
298
+ if (
299
+ parent === current ||
300
+ (parent !== candidate.mountPoint && !parent.startsWith(`${candidate.mountPoint}${path.sep}`))
301
+ ) {
302
+ break;
303
+ }
304
+ current = parent;
305
+ }
306
+ return domains;
307
+ }
308
+
309
+ export async function __sampleLinuxCgroupHierarchyForTest(
310
+ mountInfo: string,
311
+ membership: string,
312
+ fsType: "cgroup" | "cgroup2",
313
+ hostBytes: number,
314
+ parentBytes: number,
315
+ ): Promise<MemoryPressureSnapshot | null> {
316
+ const containedDomains: MemoryPressureDomain[] = [];
317
+ const fallbackDomains: MemoryPressureDomain[] = [];
318
+ for (const candidate of resolveCgroupDirectories(mountInfo, membership, fsType)) {
319
+ const domains = await sampleLinuxCgroupDirectory(candidate, fsType, hostBytes);
320
+ if (candidate.fallback) fallbackDomains.push(...domains);
321
+ else containedDomains.push(...domains);
187
322
  }
188
- await runTick(owner.generation, "timer");
323
+ const domains = containedDomains.length > 0 ? containedDomains : fallbackDomains;
324
+ if (domains.length === 0) return null;
325
+ const selected = domains.reduce((current, domain) =>
326
+ domain.totalUsageBytes / Math.min(hostBytes, domain.hardCapBytes) >
327
+ current.totalUsageBytes / Math.min(hostBytes, current.hardCapBytes)
328
+ ? domain
329
+ : current,
330
+ );
331
+ return {
332
+ ...selected,
333
+ totalUsageBytes: Math.max(parentBytes, selected.totalUsageBytes),
334
+ parentBytes,
335
+ domains,
336
+ };
189
337
  }
190
338
 
191
- function reconcileCurrentSchedule(): void {
192
- if (stopped || activeSessions.size === 0 || inProgressOwner) return;
193
- const normalDeadline = schedulerNow() + currentSweepIntervalMs();
194
- const deferredDeadline = deferredSchedule?.generation === timerGeneration ? deferredSchedule.deadline : null;
195
- if (deferredDeadline !== null) deferredSchedule = null;
196
- requestSchedule(deferredDeadline === null ? normalDeadline : Math.min(deferredDeadline, normalDeadline));
339
+ async function sampleLinuxCgroupMemory(hostBytes: number, parentBytes: number): Promise<MemoryPressureSnapshot | null> {
340
+ let cgroup: string;
341
+ let mountInfo: string;
342
+ try {
343
+ [cgroup, mountInfo] = await Promise.all([
344
+ fs.readFile("/proc/self/cgroup", "utf8"),
345
+ fs.readFile("/proc/self/mountinfo", "utf8"),
346
+ ]);
347
+ } catch {
348
+ return null;
349
+ }
350
+
351
+ const entries = cgroup
352
+ .split("\n")
353
+ .map(parseCgroupEntry)
354
+ .filter((entry): entry is [string, string, string] => entry !== null);
355
+ const v2Membership = entries.find(parts => parts[0] === "0" && parts[1] === "")?.[2];
356
+ const v1Membership = entries.find(parts => parts[1].split(",").includes("memory"))?.[2];
357
+ if (v2Membership) {
358
+ const snapshot = await __sampleLinuxCgroupHierarchyForTest(
359
+ mountInfo,
360
+ v2Membership,
361
+ "cgroup2",
362
+ hostBytes,
363
+ parentBytes,
364
+ );
365
+ if (snapshot) return snapshot;
366
+ }
367
+ if (v1Membership) {
368
+ return __sampleLinuxCgroupHierarchyForTest(mountInfo, v1Membership, "cgroup", hostBytes, parentBytes);
369
+ }
370
+ return null;
371
+ }
372
+
373
+ export function __sampleWindowsJobMemoryForTest(
374
+ hostBytes: number,
375
+ parentBytes: number,
376
+ probeResult?: WindowsJobMemoryProbeResult,
377
+ ): MemoryPressureSnapshot | null {
378
+ return sampleWindowsJobMemory(hostBytes, parentBytes, probeResult);
379
+ }
380
+
381
+ function sampleWindowsJobMemory(
382
+ hostBytes: number,
383
+ parentBytes: number,
384
+ result = safeProbeWindowsJobMemory(),
385
+ ): MemoryPressureSnapshot | null {
386
+ if (result.kind !== "job_snapshot") return null;
387
+ const domains: MemoryPressureDomain[] = [];
388
+ const jobUsage = Number(result.jobMemoryUsedBytes);
389
+ const jobLimitRaw = result.jobMemoryLimitBytes;
390
+ const hasJobLimit = jobLimitRaw !== undefined && jobLimitRaw !== null;
391
+ const jobLimit = hasJobLimit ? Number(jobLimitRaw) : NaN;
392
+ if (Number.isSafeInteger(jobUsage) && jobUsage >= 0) {
393
+ if (hasJobLimit && Number.isSafeInteger(jobLimit) && jobLimit > 0) {
394
+ domains.push({
395
+ hardCapBytes: jobLimit,
396
+ totalUsageBytes: jobUsage,
397
+ source: "windows_job",
398
+ });
399
+ } else {
400
+ // Uncapped Job Object: usage participates against policy limit (no physical RAM clamp)
401
+ domains.push({
402
+ hardCapBytes: Number.MAX_SAFE_INTEGER,
403
+ totalUsageBytes: jobUsage,
404
+ source: "windows_job",
405
+ });
406
+ }
407
+ }
408
+ const processUsage = Number(result.processPrivateUsageBytes);
409
+ const processLimitRaw = result.processMemoryLimitBytes;
410
+ const hasProcessLimit = processLimitRaw !== undefined && processLimitRaw !== null;
411
+ const processLimit = hasProcessLimit ? Number(processLimitRaw) : NaN;
412
+ if (Number.isSafeInteger(processUsage) && processUsage >= 0) {
413
+ if (hasProcessLimit && Number.isSafeInteger(processLimit) && processLimit > 0) {
414
+ domains.push({
415
+ hardCapBytes: processLimit,
416
+ totalUsageBytes: processUsage,
417
+ source: "windows_process_job_limit",
418
+ });
419
+ } else {
420
+ domains.push({
421
+ hardCapBytes: Number.MAX_SAFE_INTEGER,
422
+ totalUsageBytes: processUsage,
423
+ source: "windows_process_job_limit",
424
+ });
425
+ }
426
+ }
427
+ const workingSetUsage = Number(result.processWorkingSetBytes);
428
+ if (Number.isSafeInteger(workingSetUsage) && workingSetUsage >= 0) {
429
+ domains.push({
430
+ hardCapBytes: hostBytes,
431
+ totalUsageBytes: Math.max(parentBytes, workingSetUsage),
432
+ source: "windows_process_job_limit",
433
+ });
434
+ }
435
+ if (domains.length === 0) return null;
436
+ const selected = domains.reduce((current, candidate) =>
437
+ candidate.totalUsageBytes / candidate.hardCapBytes > current.totalUsageBytes / current.hardCapBytes
438
+ ? candidate
439
+ : current,
440
+ );
441
+ return {
442
+ ...selected,
443
+ parentBytes,
444
+ domains,
445
+ };
197
446
  }
198
447
 
199
- function stopTimer(): void {
200
- stopped = true;
201
- timerGeneration++;
202
- clearPendingSchedule();
203
- deferredSchedule = null;
448
+ async function sampleMemoryPressure(): Promise<MemoryPressureSnapshot> {
449
+ const parentBytes = process.memoryUsage().rss;
450
+ const hostBytes = os.totalmem();
451
+ if (process.platform === "linux") {
452
+ const cgroup = await sampleLinuxCgroupMemory(hostBytes, parentBytes);
453
+ if (cgroup) return cgroup;
454
+ }
455
+ if (process.platform === "win32") {
456
+ const job = sampleWindowsJobMemory(hostBytes, parentBytes);
457
+ if (job) return job;
458
+ }
459
+ return { hardCapBytes: hostBytes, totalUsageBytes: parentBytes, parentBytes, source: "host" };
460
+ }
461
+
462
+ export function __selectMemoryPressureDomainForTest(
463
+ snapshot: MemoryPressureSnapshot,
464
+ policyLimitBytes: number | null,
465
+ ): MemoryPressureSnapshot {
466
+ const domains = snapshot.domains;
467
+ if (!domains || domains.length === 0) return snapshot;
468
+ const selected = domains.reduce((current, domain) => {
469
+ const currentLimit = Math.min(current.hardCapBytes, policyLimitBytes ?? current.hardCapBytes);
470
+ const domainLimit = Math.min(domain.hardCapBytes, policyLimitBytes ?? domain.hardCapBytes);
471
+ return domain.totalUsageBytes / domainLimit > current.totalUsageBytes / currentLimit ? domain : current;
472
+ });
473
+ return {
474
+ ...snapshot,
475
+ ...selected,
476
+ totalUsageBytes: Math.max(snapshot.parentBytes, selected.totalUsageBytes),
477
+ };
204
478
  }
205
479
 
206
- async function runTick(generation = timerGeneration, source: WorkOwner["source"] = "external"): Promise<void> {
207
- if (inProgressOwner || activeSessions.size === 0) return;
208
- const owner: WorkOwner = { generation, source };
209
- inProgressOwner = owner;
210
- try {
211
- await sweepOnce(deps);
212
- } catch (err) {
213
- logger.debug("resource GC sweep failed", { error: (err as Error).message });
214
- } finally {
215
- if (inProgressOwner === owner) inProgressOwner = null;
216
- // A stale completion only releases its own work lock. Once that lock is released, current
217
- // generation demand (which may have deferred behind it) can be reconciled independently.
218
- reconcileCurrentSchedule();
480
+ function sweepMemoryPressureGuard(d: ResourceGcDeps): Promise<void> | undefined {
481
+ let enabled = false;
482
+ for (const [sessionId, settings] of activeSessions) {
483
+ if (resolveMemoryGuardPolicy(settings).enabled) {
484
+ enabled = true;
485
+ continue;
486
+ }
487
+ memoryGuardGcActive.delete(sessionId);
488
+ memoryGuardRestartAboveSince.delete(sessionId);
489
+ memoryGuardRestartCooldownUntil.delete(sessionId);
490
+ memoryGuardLastEvaluatedAt.delete(sessionId);
219
491
  }
492
+ if (!enabled) return undefined;
493
+ return sweepEnabledMemoryPressureGuard(d);
220
494
  }
221
495
 
222
- export async function sweepOnce(d: ResourceGcDeps = deps): Promise<void> {
223
- if (activeSessions.size === 0) return;
224
- await sweepBrowserTabs(d);
225
- await sweepScreenshots(d);
496
+ async function sweepEnabledMemoryPressureGuard(d: ResourceGcDeps): Promise<void> {
497
+ const now = d.monotonicNow();
498
+ const dueSessions: Array<{ sessionId: string; policy: MemoryGuardPolicy }> = [];
499
+ for (const [sessionId, settings] of activeSessions) {
500
+ const policy = resolveMemoryGuardPolicy(settings);
501
+ if (!policy.enabled) {
502
+ memoryGuardGcActive.delete(sessionId);
503
+ memoryGuardRestartAboveSince.delete(sessionId);
504
+ memoryGuardRestartCooldownUntil.delete(sessionId);
505
+ memoryGuardLastEvaluatedAt.delete(sessionId);
506
+ continue;
507
+ }
508
+ const lastEvaluated = memoryGuardLastEvaluatedAt.get(sessionId);
509
+ if (lastEvaluated !== undefined && now - lastEvaluated < policy.checkIntervalMs) continue;
510
+ memoryGuardLastEvaluatedAt.set(sessionId, now);
511
+ dueSessions.push({ sessionId, policy });
512
+ }
513
+ if (dueSessions.length === 0) return;
514
+
515
+ const snapshot = await d.memorySnapshot();
516
+ let gcRequested = false;
517
+ const gcTelemetry: Array<{ sessionId: string } & Record<string, unknown>> = [];
518
+ for (const { sessionId, policy } of dueSessions) {
519
+ const pressure = __selectMemoryPressureDomainForTest(snapshot, policy.policyLimitBytes);
520
+ const limit = resolveEffectiveMemoryLimit({
521
+ hardCapBytes: pressure.hardCapBytes,
522
+ policyLimitBytes: policy.policyLimitBytes,
523
+ });
524
+ if (limit.effectiveBytes === null) {
525
+ memoryGuardGcActive.delete(sessionId);
526
+ memoryGuardRestartAboveSince.delete(sessionId);
527
+ memoryGuardRestartCooldownUntil.delete(sessionId);
528
+ continue;
529
+ }
530
+ const domain = computeMemoryGuardDomain({
531
+ effectiveLimitBytes: limit.effectiveBytes,
532
+ totalUsageBytes: pressure.totalUsageBytes,
533
+ parentBytes: pressure.parentBytes,
534
+ parentReserveBytes: policy.parentReserveBytes,
535
+ workers: [],
536
+ });
537
+ const decision = chooseMemoryGuardAction({
538
+ domain,
539
+ hostSupported: false,
540
+ workerSupported: () => false,
541
+ });
542
+ const usageRatio = pressure.totalUsageBytes / limit.effectiveBytes;
543
+ if (usageRatio >= policy.gcThresholdRatio) {
544
+ if (!memoryGuardGcActive.has(sessionId)) {
545
+ gcRequested = true;
546
+ gcTelemetry.push({
547
+ sessionId,
548
+ parentBytes: pressure.parentBytes,
549
+ totalUsageBytes: pressure.totalUsageBytes,
550
+ effectiveLimitBytes: limit.effectiveBytes,
551
+ domainSource: pressure.source,
552
+ limitSource: limit.source,
553
+ usageRatio,
554
+ decision: decision.kind,
555
+ });
556
+ }
557
+ } else {
558
+ memoryGuardGcActive.delete(sessionId);
559
+ }
560
+
561
+ if (usageRatio < policy.restartThresholdRatio) {
562
+ memoryGuardRestartAboveSince.delete(sessionId);
563
+ continue;
564
+ }
565
+ const aboveSince = memoryGuardRestartAboveSince.get(sessionId);
566
+ if (aboveSince === undefined) {
567
+ memoryGuardRestartAboveSince.set(sessionId, now);
568
+ continue;
569
+ }
570
+ const cooldownUntil = memoryGuardRestartCooldownUntil.get(sessionId) ?? 0;
571
+ if (now - aboveSince < policy.restartThresholdWindowMs || now < cooldownUntil) continue;
572
+ memoryGuardRestartCooldownUntil.set(sessionId, now + policy.cooldownMs);
573
+ d.logWarn("Memory guard: restart threshold sustained; restart remains advisory-only", {
574
+ sessionId,
575
+ parentBytes: pressure.parentBytes,
576
+ totalUsageBytes: pressure.totalUsageBytes,
577
+ effectiveLimitBytes: limit.effectiveBytes,
578
+ domainSource: pressure.source,
579
+ limitSource: limit.source,
580
+ usageRatio,
581
+ windowMs: policy.restartThresholdWindowMs,
582
+ cooldownMs: policy.cooldownMs,
583
+ decision: decision.kind,
584
+ });
585
+ }
586
+ if (gcRequested) {
587
+ try {
588
+ d.runGc();
589
+ for (const { sessionId, ...telemetry } of gcTelemetry) {
590
+ memoryGuardGcActive.add(sessionId);
591
+ d.logWarn("Memory guard: GC threshold reached", { sessionId, ...telemetry });
592
+ }
593
+ } catch (error) {
594
+ d.logWarn("Memory guard: GC invocation failed; latch not set", { error: String(error) });
595
+ }
596
+ }
226
597
  }
227
598
 
228
599
  function ownerBrowserPolicy(snapshot: TabGcSnapshot): BrowserGcPolicy | null {
@@ -323,22 +694,26 @@ async function sweepScreenshots(d: ResourceGcDeps): Promise<void> {
323
694
 
324
695
  // ── Test-only seams ─────────────────────────────────────────────────────────────────────────
325
696
  export function __setResourceGcDepsForTest(overrides: Partial<ResourceGcDeps>): void {
326
- deps = { ...defaultDeps, ...overrides };
697
+ deps = {
698
+ ...defaultDeps,
699
+ ...overrides,
700
+ monotonicNow: overrides.monotonicNow ?? overrides.now ?? defaultDeps.monotonicNow,
701
+ };
327
702
  }
328
703
 
329
704
  export function __setResourceGcSchedulerNowForTest(now: () => number): void {
330
- schedulerNow = now;
705
+ scheduler.setSchedulerNowForTest(now);
331
706
  }
332
707
 
333
708
  export async function __runResourceGcTickForTest(): Promise<void> {
334
- await runTick();
709
+ await scheduler.runTick();
335
710
  }
336
711
 
337
712
  export async function __runResourceGcTimerCallbackForTest(
338
713
  owner: { generation: number; token: number },
339
714
  deadline: number,
340
715
  ): Promise<void> {
341
- await handleTimerCallback(owner, deadline);
716
+ await scheduler.runTimerCallbackForTest(owner, deadline);
342
717
  }
343
718
 
344
719
  export function __getResourceGcStateForTest(): {
@@ -353,28 +728,29 @@ export function __getResourceGcStateForTest(): {
353
728
  deferredGeneration: number | null;
354
729
  activeGeneration: number | null;
355
730
  } {
731
+ const state = scheduler.getStateForTest();
356
732
  return {
357
- timerActive: pendingTimer !== null,
733
+ timerActive: state.timerActive,
358
734
  sessionCount: activeSessions.size,
359
735
  rssWarningActive,
360
- inProgress: inProgressOwner !== null,
361
- generation: timerGeneration,
362
- pendingDeadline,
363
- pendingOwner: pendingOwner ? { ...pendingOwner } : null,
364
- deferredDeadline: deferredSchedule?.deadline ?? null,
365
- deferredGeneration: deferredSchedule?.generation ?? null,
366
- activeGeneration: inProgressOwner?.generation ?? null,
736
+ inProgress: state.inProgress,
737
+ generation: state.generation,
738
+ pendingDeadline: state.pendingDeadline,
739
+ pendingOwner: state.pendingOwner,
740
+ deferredDeadline: state.deferredDeadline,
741
+ deferredGeneration: state.deferredGeneration,
742
+ activeGeneration: state.activeGeneration,
367
743
  };
368
744
  }
369
745
 
370
746
  export function __resetResourceGcForTest(): void {
371
- stopTimer();
747
+ scheduler.resetForTest();
372
748
  activeSessions.clear();
373
- inProgressOwner = null;
374
- deferredSchedule = null;
375
749
  rssWarningActive = false;
750
+ memoryGuardGcActive.clear();
751
+ memoryGuardRestartAboveSince.clear();
752
+ memoryGuardRestartCooldownUntil.clear();
753
+ memoryGuardLastEvaluatedAt.clear();
376
754
  lastScreenshotScanAt = 0;
377
- nextTimerToken = 0;
378
- schedulerNow = (): number => performance.now();
379
755
  deps = defaultDeps;
380
756
  }