@loucompanion/forge-bridge 0.1.3-dev.9d8f2e7e5241 → 0.1.3-dev.a58d7cd1092f

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 (143) hide show
  1. package/README.md +83 -0
  2. package/dist/bridge/bridge.base.d.ts +4 -1
  3. package/dist/bridge/bridge.service.d.ts +55 -3
  4. package/dist/bridge/bridge.service.js +834 -87
  5. package/dist/bridge/bridge.types.d.ts +89 -1
  6. package/dist/bridge/runner-host-capabilities.d.ts +15 -0
  7. package/dist/bridge/runner-host-capabilities.js +93 -0
  8. package/dist/cleanup/cleanup.service.d.ts +14 -2
  9. package/dist/cleanup/cleanup.service.js +496 -3
  10. package/dist/cleanup/cleanup.types.d.ts +10 -0
  11. package/dist/cleanup/index.d.ts +1 -1
  12. package/dist/cleanup/index.js +1 -1
  13. package/dist/cleanup/internals.d.ts +2 -2
  14. package/dist/cleanup/internals.js +1 -1
  15. package/dist/cli/cli.help.js +37 -3
  16. package/dist/cli/cli.service.d.ts +33 -2
  17. package/dist/cli/cli.service.js +209 -27
  18. package/dist/cli/local-config.d.ts +5 -0
  19. package/dist/cli/local-config.js +45 -1
  20. package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
  21. package/dist/runtime/aspnet-runtime-prebuild.js +340 -0
  22. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  23. package/dist/runtime/attempt-node-runtime-launcher.js +105 -0
  24. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  25. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  26. package/dist/runtime/attempt-process-supervisor.d.ts +97 -0
  27. package/dist/runtime/attempt-process-supervisor.js +386 -0
  28. package/dist/runtime/docker-engine-probe.d.ts +83 -0
  29. package/dist/runtime/docker-engine-probe.js +300 -0
  30. package/dist/runtime/generated-output-reset.d.ts +10 -0
  31. package/dist/runtime/generated-output-reset.js +103 -0
  32. package/dist/runtime/host-listener-inspector.d.ts +46 -0
  33. package/dist/runtime/host-listener-inspector.js +349 -0
  34. package/dist/runtime/host-port-broker.d.ts +76 -0
  35. package/dist/runtime/host-port-broker.js +333 -0
  36. package/dist/runtime/host-runtime-contract.d.ts +47 -0
  37. package/dist/runtime/host-runtime-contract.js +700 -0
  38. package/dist/runtime/host-runtime-lease.d.ts +197 -0
  39. package/dist/runtime/host-runtime-lease.js +2203 -0
  40. package/dist/runtime/host-runtime-promotion.d.ts +23 -0
  41. package/dist/runtime/host-runtime-promotion.js +159 -0
  42. package/dist/runtime/memory-capacity.d.ts +68 -0
  43. package/dist/runtime/memory-capacity.js +121 -0
  44. package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
  45. package/dist/runtime/node-host-runtime-adapter.js +284 -0
  46. package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
  47. package/dist/runtime/playwright-host-runtime-adapter.js +231 -0
  48. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  49. package/dist/runtime/runtime-isolation-lease.js +158 -0
  50. package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
  51. package/dist/runtime/workspace-resource-estimator.js +174 -0
  52. package/dist/sandbox/preflight.d.ts +45 -1
  53. package/dist/sandbox/preflight.js +299 -34
  54. package/dist/sandbox/process.js +21 -2
  55. package/dist/sandbox/repository-lock.js +41 -3
  56. package/dist/sandbox/runtime.d.ts +66 -3
  57. package/dist/sandbox/runtime.js +670 -105
  58. package/dist/sandbox/sandbox.types.d.ts +10 -0
  59. package/dist/sandbox/smoke.d.ts +76 -0
  60. package/dist/sandbox/smoke.js +380 -0
  61. package/dist/sandbox/worker-supervisor.js +1 -1
  62. package/dist/sandbox/worker.js +2 -5
  63. package/dist/sandbox/workspace.service.d.ts +1 -0
  64. package/dist/sandbox/workspace.service.js +28 -5
  65. package/dist/session/managed-attempt-tools.d.ts +21 -0
  66. package/dist/session/managed-attempt-tools.js +912 -0
  67. package/dist/session/managed-build-coordinator.d.ts +5 -0
  68. package/dist/session/managed-build-coordinator.js +105 -0
  69. package/dist/session/managed-build-receipt.d.ts +21 -0
  70. package/dist/session/managed-build-receipt.js +335 -0
  71. package/dist/session/phase-completion.artifacts.d.ts +17 -0
  72. package/dist/session/phase-completion.artifacts.js +233 -24
  73. package/dist/session/phase-completion.d.ts +12 -2
  74. package/dist/session/phase-completion.helpers.d.ts +5 -0
  75. package/dist/session/phase-completion.helpers.js +51 -10
  76. package/dist/session/phase-completion.js +14 -3
  77. package/dist/session/phase-completion.prompts.d.ts +3 -3
  78. package/dist/session/phase-completion.prompts.js +24 -8
  79. package/dist/session/phase-completion.runtime.d.ts +13 -1
  80. package/dist/session/phase-completion.runtime.js +189 -8
  81. package/dist/session/phase-completion.schemas.js +117 -4
  82. package/dist/session/phase-completion.validation.d.ts +4 -1
  83. package/dist/session/phase-completion.validation.js +153 -22
  84. package/dist/session/pre-publication-validation.d.ts +46 -0
  85. package/dist/session/pre-publication-validation.js +208 -0
  86. package/dist/session/provider-cli/ci-repair-readiness.d.ts +16 -0
  87. package/dist/session/provider-cli/ci-repair-readiness.js +110 -0
  88. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +3 -2
  89. package/dist/session/provider-cli/provider-cli.artifacts.js +65 -7
  90. package/dist/session/provider-cli/provider-cli.deterministic.js +47 -1
  91. package/dist/session/provider-cli/provider-cli.process.d.ts +25 -0
  92. package/dist/session/provider-cli/provider-cli.process.js +463 -34
  93. package/dist/session/provider-cli/provider-cli.prompts.d.ts +4 -1
  94. package/dist/session/provider-cli/provider-cli.prompts.js +108 -25
  95. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
  96. package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
  97. package/dist/session/provider-cli/provider-cli.runtime.js +6 -3
  98. package/dist/session/provider-cli/provider-cli.service.d.ts +22 -2
  99. package/dist/session/provider-cli/provider-cli.service.js +529 -100
  100. package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
  101. package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
  102. package/dist/session/provider-cli/provider-output-state.js +91 -16
  103. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  104. package/dist/session/provider-cli/provider-usage.js +28 -5
  105. package/dist/session/session-redaction.d.ts +3 -0
  106. package/dist/session/session-redaction.js +38 -0
  107. package/dist/session/session.base.d.ts +6 -1
  108. package/dist/session/session.service.d.ts +8 -2
  109. package/dist/session/session.service.js +522 -41
  110. package/dist/session/session.types.d.ts +72 -0
  111. package/dist/session/session.types.js +74 -3
  112. package/dist/shared/format/format-bytes.d.ts +1 -0
  113. package/dist/shared/format/format-bytes.js +9 -0
  114. package/dist/shared/path/path.d.ts +1 -0
  115. package/dist/shared/path/path.js +21 -1
  116. package/dist/shared/process/dotnet-toolchain.d.ts +14 -0
  117. package/dist/shared/process/dotnet-toolchain.js +179 -0
  118. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  119. package/dist/shared/process/host-process-recovery.js +78 -0
  120. package/dist/shared/process/host-process-supervisor.d.ts +59 -0
  121. package/dist/shared/process/host-process-supervisor.js +330 -0
  122. package/dist/shared/process/process.d.ts +1 -0
  123. package/dist/shared/process/process.js +1 -0
  124. package/dist/shared/redaction/redaction.js +12 -2
  125. package/dist/version.d.ts +1 -1
  126. package/dist/version.js +1 -1
  127. package/dist/worktree/artifact-injector.d.ts +3 -2
  128. package/dist/worktree/artifact-injector.js +43 -4
  129. package/dist/worktree/dependency-warmer.d.ts +28 -1
  130. package/dist/worktree/dependency-warmer.js +308 -9
  131. package/dist/worktree/index.d.ts +2 -1
  132. package/dist/worktree/index.js +1 -0
  133. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  134. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  135. package/dist/worktree/worktree-paths.d.ts +4 -0
  136. package/dist/worktree/worktree-paths.js +30 -0
  137. package/dist/worktree/worktree.base.d.ts +3 -0
  138. package/dist/worktree/worktree.service.d.ts +13 -2
  139. package/dist/worktree/worktree.service.js +238 -47
  140. package/dist/worktree/worktree.types.d.ts +5 -1
  141. package/package.json +5 -3
  142. package/runtime/windows-job-supervisor.ps1 +444 -0
  143. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -1,7 +1,10 @@
1
1
  import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { createHash } from "node:crypto";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { formatBytes } from "../shared/format/format-bytes.js";
6
+ import { captureHostProcessIdentity } from "../shared/process/host-process-supervisor.js";
7
+ import { RecoveryStabilizedMemory, resolveAdaptiveMemoryReserveBytes } from "../runtime/memory-capacity.js";
5
8
  import { BoundedCommandRunner } from "./command-runner.js";
6
9
  const minimumWorkspaceBytes = 10 * 1024 * 1024 * 1024;
7
10
  export class DockerPreflight {
@@ -9,17 +12,35 @@ export class DockerPreflight {
9
12
  cacheMs;
10
13
  generation = 0;
11
14
  cached = null;
12
- cacheAt = 0;
15
+ fullPreflightAt = 0;
16
+ staticPreflight = null;
17
+ inspectionInFlight = null;
18
+ lastCapacitySample = null;
13
19
  options;
14
20
  docker;
15
21
  ownership = new DockerContextOwnershipLock();
16
22
  diskPressureLatched = false;
23
+ memoryAvailability = new RecoveryStabilizedMemory();
17
24
  constructor(options = {}, commands = new BoundedCommandRunner(), cacheMs = 30_000) {
18
25
  this.commands = commands;
19
26
  this.cacheMs = cacheMs;
20
27
  this.options = typeof options === "string" ? { dockerExecutable: options } : options;
21
28
  this.docker = this.options.dockerExecutable ?? process.env.FORGE_DOCKER_EXECUTABLE ?? "docker";
22
29
  }
30
+ unavailable(code, reason, features) {
31
+ const totalMemoryBytes = os.totalmem();
32
+ this.memoryAvailability.observe(0);
33
+ return unavailable(this.generation, code, reason, this.memoryReserveBytes(totalMemoryBytes), this.defaultAdmissionMemoryBytes(), totalMemoryBytes, features);
34
+ }
35
+ memoryReserveBytes(totalMemoryBytes) {
36
+ return resolveSandboxHostMemoryReserveBytes(totalMemoryBytes, this.options.hostMemoryReserveBytes);
37
+ }
38
+ defaultAdmissionMemoryBytes() {
39
+ const workerMemoryLimitBytes = positiveInteger(this.options.defaultWorkerMemoryLimitBytes, 2 * 1024 ** 3);
40
+ const serviceMemoryLimitBytes = nonNegativeInteger(this.options.defaultServiceMemoryLimitBytes, 0);
41
+ const runtimeOverheadBytes = Math.max(128 * 1024 ** 2, nonNegativeInteger(this.options.runtimeOverheadBytes, 128 * 1024 ** 2));
42
+ return workerMemoryLimitBytes + serviceMemoryLimitBytes + runtimeOverheadBytes;
43
+ }
23
44
  current() {
24
45
  return this.cached;
25
46
  }
@@ -35,25 +56,97 @@ export class DockerPreflight {
35
56
  await this.ownership.release();
36
57
  }
37
58
  async run(force = false) {
38
- if (!force && this.cached && Date.now() - this.cacheAt < this.cacheMs)
59
+ const inFlight = this.inspectionInFlight;
60
+ if (inFlight) {
61
+ const result = await inFlight.promise;
62
+ if (inFlight.kind === "full" || (!force && Date.now() - this.fullPreflightAt < this.cacheMs)) {
63
+ return result;
64
+ }
65
+ return await this.run(force);
66
+ }
67
+ if (!force && this.cached && Date.now() - this.fullPreflightAt < this.cacheMs)
39
68
  return this.cached;
69
+ return await this.startInspection("full", () => this.inspect().catch((error) => this.unavailable("docker_unreachable", error instanceof Error ? error.message : String(error))), true);
70
+ }
71
+ /**
72
+ * Refreshes only the admission inputs that change while the daemon's static
73
+ * capability snapshot remains valid. Heartbeats use this path so periodic
74
+ * liveness reporting does not wake Docker/WSL to rerun feature probes.
75
+ */
76
+ async refreshCapacity() {
77
+ if (this.inspectionInFlight) {
78
+ return await this.inspectionInFlight.promise;
79
+ }
80
+ const staticPreflight = this.staticPreflight;
81
+ if (!staticPreflight || !this.cached || !canRunDockerMaintenance(this.cached)) {
82
+ return this.cached ?? this.unavailable("docker_preflight_required", "Docker capability preflight has not completed.");
83
+ }
84
+ return await this.startInspection("capacity", () => this.inspectCapacity(staticPreflight).catch((error) => this.unavailable("docker_capacity_unavailable", error instanceof Error ? error.message : String(error), staticPreflight.features)), false);
85
+ }
86
+ /**
87
+ * When the cached static preflight remains usable, produces a capacity sample
88
+ * that began after this admission request. An older inspection is awaited,
89
+ * not preempted or reused. Later admission callers may share the next sample
90
+ * only when it began after they entered this method. Without a usable static
91
+ * snapshot, the existing fail-closed result is returned without probing.
92
+ */
93
+ async refreshCapacityForAdmission() {
94
+ const generationAtRequest = this.generation;
95
+ while (true) {
96
+ const inFlight = this.inspectionInFlight;
97
+ if (inFlight) {
98
+ if (inFlight.kind === "capacity" && inFlight.generation > generationAtRequest) {
99
+ return await inFlight.promise;
100
+ }
101
+ await inFlight.promise;
102
+ continue;
103
+ }
104
+ const lastCapacitySample = this.lastCapacitySample;
105
+ if (lastCapacitySample
106
+ && lastCapacitySample.generation > generationAtRequest
107
+ && lastCapacitySample.generation === this.generation
108
+ && lastCapacitySample.result === this.cached) {
109
+ return lastCapacitySample.result;
110
+ }
111
+ const staticPreflight = this.staticPreflight;
112
+ if (!staticPreflight || !this.cached || !canRunDockerMaintenance(this.cached)) {
113
+ return this.cached ?? this.unavailable("docker_preflight_required", "Docker capability preflight has not completed.");
114
+ }
115
+ return await this.startInspection("capacity", () => this.inspectCapacity(staticPreflight).catch((error) => this.unavailable("docker_capacity_unavailable", error instanceof Error ? error.message : String(error), staticPreflight.features)), false);
116
+ }
117
+ }
118
+ startInspection(kind, inspect, full) {
40
119
  this.generation += 1;
41
- const result = await this.inspect().catch((error) => unavailable(this.generation, "docker_unreachable", error instanceof Error ? error.message : String(error)));
42
- this.cached = result;
43
- this.cacheAt = Date.now();
44
- return result;
120
+ const generation = this.generation;
121
+ const operation = Promise.resolve()
122
+ .then(inspect)
123
+ .then((result) => {
124
+ this.cached = result;
125
+ if (kind === "capacity")
126
+ this.lastCapacitySample = { generation, result };
127
+ if (full)
128
+ this.fullPreflightAt = Date.now();
129
+ return result;
130
+ })
131
+ .finally(() => {
132
+ if (this.inspectionInFlight?.promise === operation) {
133
+ this.inspectionInFlight = null;
134
+ }
135
+ });
136
+ this.inspectionInFlight = { kind, generation, promise: operation };
137
+ return operation;
45
138
  }
46
139
  async inspect() {
47
140
  if (!new Set(["linux", "darwin", "win32"]).has(process.platform)) {
48
- return unavailable(this.generation, "platform_unsupported", `Docker sandboxes are not supported on ${process.platform}.`);
141
+ return this.unavailable("platform_unsupported", `Docker sandboxes are not supported on ${process.platform}.`);
49
142
  }
50
143
  const dockerArgs = this.options.dockerContext ? ["--context", this.options.dockerContext] : [];
51
144
  const infoResult = await this.commands.run(this.docker, [...dockerArgs, "info", "--format", "{{json .}}"], { timeoutMs: 10_000, maxOutputBytes: 512 * 1024 });
52
145
  if (infoResult.exitCode !== 0)
53
- return unavailable(this.generation, "docker_unreachable", singleLine(infoResult.stderr || infoResult.stdout || "Docker daemon is unreachable."));
146
+ return this.unavailable("docker_unreachable", singleLine(infoResult.stderr || infoResult.stdout || "Docker daemon is unreachable."));
54
147
  const info = parseInfo(infoResult.stdout);
55
148
  if (String(info.OSType ?? "").toLowerCase() !== "linux")
56
- return unavailable(this.generation, "docker_linux_required", "Forge sandbox images require a Linux Docker daemon.");
149
+ return this.unavailable("docker_linux_required", "Forge sandbox images require a Linux Docker daemon.");
57
150
  const securityOptions = strings(info.SecurityOptions).map((value) => value.toLowerCase());
58
151
  const rootless = securityOptions.some((value) => value.includes("rootless"));
59
152
  const userNamespaces = rootless || securityOptions.some((value) => value.includes("userns"));
@@ -65,7 +158,7 @@ export class DockerPreflight {
65
158
  const desktopDeveloperMode = process.platform !== "linux" && process.env.FORGE_ALLOW_DOCKER_DESKTOP_SANDBOX === "true";
66
159
  const requireRootless = this.options.requireRootless ?? process.env.FORGE_SANDBOX_REQUIRE_ROOTLESS !== "false";
67
160
  if (!rootless && !desktopDeveloperMode && requireRootless) {
68
- return unavailable(this.generation, "docker_rootless_required", "Docker daemon is reachable but is not running rootless.", {
161
+ return this.unavailable("docker_rootless_required", "Docker daemon is reachable but is not running rootless.", {
69
162
  rootless, userNamespaces, cgroupV2, seccomp, lsm, storageQuota, storagePolicy: false, compose: false, internalNetwork: true, buildkit: false, imagePolicy: false, securityProfile: false, egressProxy: false
70
163
  });
71
164
  }
@@ -81,7 +174,7 @@ export class DockerPreflight {
81
174
  && buildxPruneHelp?.exitCode === 0
82
175
  && ["--max-used-space", "--min-free-space", "--reserved-space"].every(flag => buildxPruneHelp.stdout.includes(flag))
83
176
  && containerUpdateHelp?.exitCode === 0
84
- && containerUpdateHelp.stdout.includes("--pids-limit");
177
+ && ["--pids-limit", "--memory", "--memory-swap"].every(flag => containerUpdateHelp.stdout.includes(flag));
85
178
  const imagePolicy = process.env.FORGE_SANDBOX_IMAGE_POLICY_ENFORCED === "true"
86
179
  && Boolean(process.env.FORGE_SANDBOX_SBOM_EXECUTABLE?.trim())
87
180
  && Boolean(process.env.FORGE_SANDBOX_VULNERABILITY_EXECUTABLE?.trim())
@@ -99,50 +192,154 @@ export class DockerPreflight {
99
192
  const dockerRoot = process.platform === "linux" && typeof info.DockerRootDir === "string" && path.isAbsolute(info.DockerRootDir)
100
193
  ? info.DockerRootDir
101
194
  : configuredStateRoot;
102
- const disk = await diskSpace(dockerRoot);
195
+ const dockerDesktop = process.platform !== "linux"
196
+ && String(info.OperatingSystem ?? "").toLowerCase().includes("docker desktop");
197
+ const reportedDockerMemoryBytes = Number(info.MemTotal);
198
+ const features = { rootless, userNamespaces, cgroupV2, seccomp, lsm, storageQuota, storagePolicy, compose, internalNetwork: true, buildkit, imagePolicy, securityProfile, egressProxy };
199
+ const staticPreflight = {
200
+ dockerArgs,
201
+ dockerRoot,
202
+ dockerDesktop,
203
+ dockerMemoryBytes: Number.isFinite(reportedDockerMemoryBytes) && reportedDockerMemoryBytes > 0
204
+ ? reportedDockerMemoryBytes
205
+ : null,
206
+ runtimeVersion: `docker/${String(info.ServerVersion ?? "unknown")}${rootless ? "-rootless" : "-desktop"}`,
207
+ features,
208
+ };
209
+ this.staticPreflight = staticPreflight;
210
+ return await this.inspectCapacity(staticPreflight);
211
+ }
212
+ async inspectCapacity(staticPreflight) {
213
+ const disk = await diskSpace(staticPreflight.dockerRoot);
103
214
  const requiredWorkspaceBytes = this.options.minimumWorkspaceBytes ?? minimumWorkspaceBytes;
104
215
  const highWatermark = Math.min(99, Math.max(1, this.options.diskHighWatermarkPercent ?? 90));
105
216
  const lowWatermark = Math.min(highWatermark - 1, Math.max(0, this.options.diskLowWatermarkPercent ?? highWatermark - 10));
106
217
  const diskLow = evaluateDiskPressure(disk.available, disk.total, requiredWorkspaceBytes, highWatermark, lowWatermark, this.diskPressureLatched);
107
218
  this.diskPressureLatched = diskLow;
108
- const availableMemoryBytes = os.freemem();
219
+ const requiredAvailableBytes = requiredAvailableBytesToClearDiskPressure(disk.total, requiredWorkspaceBytes, lowWatermark);
220
+ const rawHostAvailableMemoryBytesBefore = Math.max(0, os.freemem());
221
+ const hostTotalMemoryBytes = os.totalmem();
222
+ let totalMemoryBytes = hostTotalMemoryBytes;
223
+ // Native Docker shares the host capacity domain. Docker Desktop has a
224
+ // second VM/daemon domain, so neither domain may be treated as a substitute
225
+ // for the other when deciding whether new committed memory is safe.
226
+ let rawDaemonAvailableMemoryBytes = rawHostAvailableMemoryBytesBefore;
227
+ let daemonMemorySampleFailure = null;
228
+ if (staticPreflight.dockerDesktop) {
229
+ rawDaemonAvailableMemoryBytes = null;
230
+ if (staticPreflight.dockerMemoryBytes) {
231
+ totalMemoryBytes = staticPreflight.dockerMemoryBytes;
232
+ const stats = await this.commands.run(this.docker, [...staticPreflight.dockerArgs, "stats", "--no-stream", "--format", "{{.MemUsage}}"], { timeoutMs: 10_000, maxOutputBytes: 512 * 1024 }).catch(() => null);
233
+ if (stats?.exitCode === 0) {
234
+ let dockerUsedMemoryBytes = 0;
235
+ for (const line of stats.stdout.split(/\r?\n/gu).map(value => value.trim()).filter(Boolean)) {
236
+ const usage = tryParseDockerMemoryUsage(line);
237
+ if (usage === null) {
238
+ daemonMemorySampleFailure = "Docker returned an invalid memory-usage sample.";
239
+ break;
240
+ }
241
+ dockerUsedMemoryBytes += usage;
242
+ }
243
+ if (daemonMemorySampleFailure === null) {
244
+ rawDaemonAvailableMemoryBytes = Math.max(0, staticPreflight.dockerMemoryBytes - dockerUsedMemoryBytes);
245
+ }
246
+ }
247
+ else {
248
+ daemonMemorySampleFailure = singleLine(stats?.stderr || stats?.stdout || "Docker Desktop memory capacity could not be sampled.");
249
+ }
250
+ }
251
+ else {
252
+ daemonMemorySampleFailure = "Docker Desktop did not report a valid memory-capacity limit.";
253
+ }
254
+ }
255
+ const rawHostAvailableMemoryBytesAfter = Math.max(0, os.freemem());
256
+ const rawHostAvailableMemoryBytes = Math.min(rawHostAvailableMemoryBytesBefore, rawHostAvailableMemoryBytesAfter);
257
+ if (!staticPreflight.dockerDesktop) {
258
+ rawDaemonAvailableMemoryBytes = rawHostAvailableMemoryBytes;
259
+ }
260
+ const sampledAt = new Date().toISOString();
261
+ const rawAvailableMemoryBytes = rawDaemonAvailableMemoryBytes === null
262
+ ? 0
263
+ : Math.min(rawHostAvailableMemoryBytes, rawDaemonAvailableMemoryBytes);
264
+ const availableMemoryBytes = this.memoryAvailability.observe(rawAvailableMemoryBytes);
109
265
  const cpuCount = Math.max(1, os.availableParallelism?.() ?? os.cpus().length);
110
266
  const availableCpuMillis = Math.max(0, (cpuCount - 1) * 1000);
111
- const memoryReserveBytes = 2 * 1024 ** 3;
112
- const sandboxMemoryBytes = 2 * 1024 ** 3;
267
+ // The reserve protects the physical host and its control plane. Docker
268
+ // Desktop's VM total remains the independent daemon commitment ceiling,
269
+ // but must not shrink the reserve derived from the Windows host.
270
+ const memoryReserveBytes = this.memoryReserveBytes(hostTotalMemoryBytes);
271
+ const workerMemoryLimitBytes = positiveInteger(this.options.defaultWorkerMemoryLimitBytes, 2 * 1024 ** 3);
272
+ const serviceMemoryLimitBytes = nonNegativeInteger(this.options.defaultServiceMemoryLimitBytes, 0);
273
+ const runtimeOverheadBytes = Math.max(128 * 1024 ** 2, nonNegativeInteger(this.options.runtimeOverheadBytes, 128 * 1024 ** 2));
274
+ const sandboxMemoryBytes = workerMemoryLimitBytes + serviceMemoryLimitBytes + runtimeOverheadBytes;
275
+ const minimumAdmissionMemoryBytes = 128 * 1024 ** 2 + Math.max(128 * 1024 ** 2, runtimeOverheadBytes);
113
276
  const sandboxCpuMillis = 2_000;
114
- const memorySlots = Math.max(0, Math.floor((availableMemoryBytes - memoryReserveBytes) / sandboxMemoryBytes));
115
277
  const cpuSlots = Math.max(0, Math.floor(availableCpuMillis / sandboxCpuMillis));
116
278
  const configuredMax = positiveInteger(this.options.maxConcurrentSandboxes, positiveInteger(process.env.FORGE_MAX_CONCURRENT_SANDBOXES, 3));
117
- const capacityLowCode = !diskLow && memorySlots <= 0 ? "docker_memory_low" : !diskLow && cpuSlots <= 0 ? "docker_cpu_low" : null;
118
- const maxConcurrentSandboxes = diskLow || capacityLowCode ? 0 : Math.min(3, configuredMax, cpuSlots, memorySlots);
279
+ const availableForAdmissionBytes = Math.max(0, availableMemoryBytes - memoryReserveBytes);
280
+ const capacityLowCode = !diskLow && rawDaemonAvailableMemoryBytes === null
281
+ ? "docker_memory_sample_unavailable"
282
+ : !diskLow && availableForAdmissionBytes < minimumAdmissionMemoryBytes
283
+ ? "docker_memory_low"
284
+ : !diskLow && cpuSlots <= 0
285
+ ? "docker_cpu_low"
286
+ : null;
287
+ const maxConcurrentSandboxes = diskLow || capacityLowCode ? 0 : Math.min(3, configuredMax, cpuSlots);
119
288
  const unavailableCode = diskLow ? "docker_disk_low" : capacityLowCode;
120
289
  const unavailableReason = diskLow
121
- ? `Docker workspace has only ${disk.available} bytes available.`
122
- : unavailableCode === "docker_memory_low"
123
- ? `Docker host has ${availableMemoryBytes} bytes free; at least ${memoryReserveBytes + sandboxMemoryBytes} bytes are needed for one sandbox plus reserve.`
124
- : unavailableCode === "docker_cpu_low"
125
- ? `Docker host has ${availableCpuMillis} available CPU millis; at least ${sandboxCpuMillis} are needed for one sandbox.`
126
- : null;
127
- const features = { rootless, userNamespaces, cgroupV2, seccomp, lsm, storageQuota, storagePolicy, compose, internalNetwork: true, buildkit, imagePolicy, securityProfile, egressProxy };
290
+ ? `Docker workspace has ${formatBytes(disk.available)} available · ${formatBytes(requiredAvailableBytes)} required to clear disk pressure.`
291
+ : unavailableCode === "docker_memory_sample_unavailable"
292
+ ? `Docker Desktop memory capacity is unavailable (${daemonMemorySampleFailure ?? "unknown sampler failure"}); the host reports ${formatBytes(rawHostAvailableMemoryBytes)} free, but Forge will not admit a sandbox until both memory domains are known.`
293
+ : unavailableCode === "docker_memory_low"
294
+ ? `${staticPreflight.dockerDesktop
295
+ ? `The host reports ${formatBytes(rawHostAvailableMemoryBytes)} free and the Docker domain reports ${formatBytes(rawDaemonAvailableMemoryBytes ?? 0)} free; Forge uses the conservative ${formatBytes(rawAvailableMemoryBytes)} bound`
296
+ : `Docker reports ${formatBytes(rawAvailableMemoryBytes)} free`} and reserves ${formatBytes(memoryReserveBytes)} for the host, leaving ${formatBytes(availableForAdmissionBytes)} for admission; the smallest supported sandbox requires ${formatBytes(minimumAdmissionMemoryBytes)}. Exact attempt memory is calculated dynamically from its worker profile, sidecars, and runtime helpers.`
297
+ : unavailableCode === "docker_cpu_low"
298
+ ? `Docker host has ${availableCpuMillis} available CPU millis; at least ${sandboxCpuMillis} are needed for one sandbox.`
299
+ : null;
128
300
  const capacityAvailable = unavailableCode === null;
129
301
  return {
130
302
  available: capacityAvailable,
131
303
  healthy: capacityAvailable,
132
304
  generation: this.generation,
133
- runtimeVersion: `docker/${String(info.ServerVersion ?? "unknown")}${rootless ? "-rootless" : "-desktop"}`,
305
+ sampledAt,
306
+ runtimeVersion: staticPreflight.runtimeVersion,
134
307
  maxConcurrentSandboxes,
308
+ totalMemoryBytes,
309
+ rawHostAvailableMemoryBytes,
310
+ rawDaemonAvailableMemoryBytes,
311
+ rawAvailableMemoryBytes,
135
312
  availableMemoryBytes,
313
+ hostReserveBytes: memoryReserveBytes,
314
+ admissionMemoryBytes: sandboxMemoryBytes,
136
315
  availableCpuMillis,
137
316
  availableWorkspaceBytes: disk.available,
138
317
  diskLow,
139
318
  unavailableCode,
140
319
  unavailableReason,
141
- features,
320
+ features: staticPreflight.features,
142
321
  };
143
322
  }
144
323
  }
145
- const dockerMaintenanceUnavailableCodes = new Set(["docker_disk_low", "docker_memory_low", "docker_cpu_low"]);
324
+ /**
325
+ * Uses the same reviewed reserve vector as host-runtime admission. The input
326
+ * is always the physical host total; Docker Desktop's VM total is tracked as
327
+ * a separate daemon commitment domain.
328
+ */
329
+ export function resolveSandboxHostMemoryReserveBytes(totalMemoryBytes, explicitBytes, environment = process.env) {
330
+ return resolveAdaptiveMemoryReserveBytes(totalMemoryBytes, {
331
+ explicitBytes,
332
+ floorBytes: positiveInteger(environment.FORGE_SANDBOX_HOST_MEMORY_RESERVE_FLOOR_BYTES, 1024 ** 3),
333
+ ceilingBytes: positiveInteger(environment.FORGE_SANDBOX_HOST_MEMORY_RESERVE_CEILING_BYTES, 4 * 1024 ** 3),
334
+ percent: positiveNumber(environment.FORGE_SANDBOX_HOST_MEMORY_RESERVE_PERCENT, 12.5),
335
+ });
336
+ }
337
+ const dockerMaintenanceUnavailableCodes = new Set([
338
+ "docker_disk_low",
339
+ "docker_memory_low",
340
+ "docker_memory_sample_unavailable",
341
+ "docker_cpu_low",
342
+ ]);
146
343
  export function canRunDockerMaintenance(preflight) {
147
344
  return preflight.healthy || dockerMaintenanceUnavailableCodes.has(preflight.unavailableCode ?? "");
148
345
  }
@@ -156,39 +353,59 @@ export function evaluateDiskPressure(availableBytes, totalBytes, minimumAvailabl
156
353
  return true;
157
354
  return pressureLatched && usedPercent > lowWatermarkPercent;
158
355
  }
356
+ export function requiredAvailableBytesToClearDiskPressure(totalBytes, minimumAvailableBytes, lowWatermarkPercent) {
357
+ const watermarkBytes = totalBytes > 0
358
+ ? Math.ceil(totalBytes * (1 - Math.min(100, Math.max(0, lowWatermarkPercent)) / 100))
359
+ : 0;
360
+ return Math.max(0, minimumAvailableBytes, watermarkBytes);
361
+ }
159
362
  export function storagePolicyAvailable(storageQuota, environment = process.env) {
160
363
  return storageQuota || environment.FORGE_SANDBOX_DEDICATED_DISK === "true";
161
364
  }
162
365
  export class DockerContextOwnershipLock {
163
366
  lockPath = null;
367
+ lockToken = null;
164
368
  async acquire(contextName) {
165
369
  if (this.lockPath)
166
370
  return;
167
371
  contextName = contextName?.trim() || process.env.DOCKER_CONTEXT?.trim() || process.env.DOCKER_HOST?.trim() || "default";
168
372
  const key = createHash("sha256").update(contextName).digest("hex").slice(0, 24);
169
373
  const lockPath = path.join(os.tmpdir(), `forge-docker-context-${key}.lock`);
374
+ const token = randomUUID();
170
375
  await fs.mkdir(path.dirname(lockPath), { recursive: true });
171
376
  try {
172
377
  const handle = await fs.open(lockPath, "wx", 0o600);
173
- await handle.writeFile(JSON.stringify({ pid: process.pid, contextName, acquiredAt: new Date().toISOString() }));
378
+ await handle.writeFile(JSON.stringify({
379
+ schemaVersion: 2,
380
+ token,
381
+ pid: process.pid,
382
+ processIdentity: captureHostProcessIdentity(process.pid),
383
+ contextName,
384
+ acquiredAt: new Date().toISOString(),
385
+ }));
174
386
  await handle.sync();
175
387
  await handle.close();
176
388
  }
177
389
  catch (error) {
178
390
  const owner = await readLock(lockPath);
179
- if (owner?.pid && !processExists(owner.pid)) {
391
+ if (!dockerContextOwnerIsActive(owner)) {
180
392
  await fs.rm(lockPath, { force: true });
181
393
  return await this.acquire(contextName);
182
394
  }
183
395
  throw new Error(`docker_context_owned: Docker context '${contextName}' is already owned by Forge runner pid ${owner?.pid ?? "unknown"}.`, { cause: error });
184
396
  }
185
397
  this.lockPath = lockPath;
398
+ this.lockToken = token;
186
399
  }
187
400
  async release() {
188
401
  if (!this.lockPath)
189
402
  return;
190
- await fs.rm(this.lockPath, { force: true });
403
+ const owner = await readLock(this.lockPath);
404
+ if (owner?.token === this.lockToken) {
405
+ await fs.rm(this.lockPath, { force: true });
406
+ }
191
407
  this.lockPath = null;
408
+ this.lockToken = null;
192
409
  }
193
410
  }
194
411
  function parseInfo(stdout) {
@@ -202,16 +419,24 @@ function parseInfo(stdout) {
202
419
  throw new Error("Docker returned an invalid info document.", { cause: error });
203
420
  }
204
421
  }
205
- function unavailable(generation, code, reason, features = {
422
+ function unavailable(generation, code, reason, hostReserveBytes, admissionMemoryBytes, totalMemoryBytes, features = {
206
423
  rootless: false, userNamespaces: false, cgroupV2: false, seccomp: false, lsm: false, storageQuota: false, storagePolicy: false, compose: false, internalNetwork: false, buildkit: false, imagePolicy: false, securityProfile: false, egressProxy: false
207
424
  }) {
425
+ const rawHostAvailableMemoryBytes = Math.max(0, os.freemem());
208
426
  return {
209
427
  available: false,
210
428
  healthy: false,
211
429
  generation,
430
+ sampledAt: new Date().toISOString(),
212
431
  runtimeVersion: null,
213
432
  maxConcurrentSandboxes: 0,
214
- availableMemoryBytes: os.freemem(),
433
+ totalMemoryBytes,
434
+ rawHostAvailableMemoryBytes,
435
+ rawDaemonAvailableMemoryBytes: null,
436
+ rawAvailableMemoryBytes: rawHostAvailableMemoryBytes,
437
+ availableMemoryBytes: rawHostAvailableMemoryBytes,
438
+ hostReserveBytes,
439
+ admissionMemoryBytes,
215
440
  availableCpuMillis: Math.max(0, (os.availableParallelism?.() ?? os.cpus().length) * 1000),
216
441
  availableWorkspaceBytes: 0,
217
442
  diskLow: code === "docker_disk_low",
@@ -242,9 +467,49 @@ function positiveInteger(value, fallback) {
242
467
  function singleLine(value) {
243
468
  return value.replace(/[\r\n\t]+/gu, " ").trim().slice(0, 1000);
244
469
  }
470
+ function nonNegativeInteger(value, fallback) {
471
+ const parsed = Number(value);
472
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
473
+ }
474
+ function positiveNumber(value, fallback) {
475
+ const parsed = Number(value);
476
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
477
+ }
478
+ function tryParseDockerMemoryUsage(value) {
479
+ const usage = value.split("/", 1)[0]?.trim().replaceAll(" ", "") ?? "";
480
+ const match = /^(\d+(?:\.\d+)?)(b|kib|mib|gib|tib)$/iu.exec(usage);
481
+ if (!match)
482
+ return null;
483
+ const amount = Number(match[1]);
484
+ const multiplier = { b: 1, kib: 1024, mib: 1024 ** 2, gib: 1024 ** 3, tib: 1024 ** 4 }[match[2].toLowerCase()];
485
+ return Number.isFinite(amount) ? Math.round(amount * multiplier) : null;
486
+ }
487
+ export function parseDockerMemoryUsage(value) {
488
+ return tryParseDockerMemoryUsage(value) ?? 0;
489
+ }
245
490
  async function readLock(lockPath) {
246
491
  return await fs.readFile(lockPath, "utf8").then((value) => JSON.parse(value)).catch(() => null);
247
492
  }
493
+ export function dockerContextOwnerIsActive(owner) {
494
+ if (!owner || !Number.isSafeInteger(owner.pid) || (owner.pid ?? 0) <= 0 || !processExists(owner.pid)) {
495
+ return false;
496
+ }
497
+ const liveIdentity = captureHostProcessIdentity(owner.pid);
498
+ if (owner.processIdentity) {
499
+ // A live but unverifiable process must remain protected. Windows and Linux
500
+ // normally provide a birth marker, so a mismatch is positive PID-reuse proof.
501
+ return !liveIdentity || liveIdentity === owner.processIdentity;
502
+ }
503
+ if (process.platform === "win32" && liveIdentity?.startsWith("win32:") && owner.acquiredAt) {
504
+ const ticks = Number(liveIdentity.slice("win32:".length));
505
+ const acquiredAt = Date.parse(owner.acquiredAt);
506
+ const processStartedAt = ticks / 10_000 - 62_135_596_800_000;
507
+ if (Number.isFinite(processStartedAt) && Number.isFinite(acquiredAt) && processStartedAt > acquiredAt) {
508
+ return false;
509
+ }
510
+ }
511
+ return true;
512
+ }
248
513
  function processExists(pid) {
249
514
  try {
250
515
  process.kill(pid, 0);
@@ -2,6 +2,12 @@ import { spawn } from "node:child_process";
2
2
  import { StringDecoder } from "node:string_decoder";
3
3
  const terminationSigkillDelayMs = 5_000;
4
4
  const forcedCloseSettleDelayMs = 5_000;
5
+ const expectedStdinCloseErrorCodes = new Set(["EPIPE", "ECONNRESET", "ERR_STREAM_DESTROYED"]);
6
+ function isExpectedStdinCloseError(error) {
7
+ return "code" in error
8
+ && typeof error.code === "string"
9
+ && expectedStdinCloseErrorCodes.has(error.code);
10
+ }
5
11
  export async function runSandboxProcess(request) {
6
12
  const limit = Math.max(1024, request.maxOutputBytes ?? 1024 * 1024);
7
13
  return await new Promise((resolve, reject) => {
@@ -161,12 +167,21 @@ export async function runSandboxProcess(request) {
161
167
  reject(error);
162
168
  }
163
169
  };
170
+ const onStdinError = (error) => {
171
+ if (isExpectedStdinCloseError(error))
172
+ return;
173
+ onProcessError(error);
174
+ };
164
175
  const onProcessClose = (code) => {
165
176
  void settle(code, false);
166
177
  };
167
178
  child.stdout.on("data", onStdoutData);
168
179
  child.stderr.on("data", onStderrData);
169
180
  child.on("error", onProcessError);
181
+ child.on("close", onProcessClose);
182
+ // Keep this listener for the lifetime of stdin. A pipe error can be emitted
183
+ // after the child close event has settled the supervisor promise.
184
+ child.stdin?.on("error", onStdinError);
170
185
  timeout = request.timeoutMs
171
186
  ? setTimeout(() => terminate(`process timeout after ${request.timeoutMs}ms`), request.timeoutMs)
172
187
  : null;
@@ -176,9 +191,13 @@ export async function runSandboxProcess(request) {
176
191
  abort();
177
192
  }
178
193
  if (request.input !== undefined) {
179
- child.stdin.end(request.input);
194
+ try {
195
+ child.stdin.end(request.input);
196
+ }
197
+ catch (error) {
198
+ onStdinError(error instanceof Error ? error : new Error(String(error)));
199
+ }
180
200
  }
181
- child.on("close", onProcessClose);
182
201
  });
183
202
  }
184
203
  export async function requireSandboxProcess(request) {
@@ -1,14 +1,19 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
2
3
  import fs from "node:fs/promises";
3
4
  import path from "node:path";
5
+ import { resolvePathIdentity } from "../shared/path/path.js";
6
+ const LOCK_HEARTBEAT_INTERVAL_MS = 30_000;
7
+ const LOCK_STALE_AFTER_MS = 2 * 60_000;
4
8
  export class RepositoryCoordinatorLock {
5
9
  stateRoot;
6
10
  constructor(stateRoot) {
7
11
  this.stateRoot = stateRoot;
8
12
  }
9
13
  async withLock(repoPath, action, signal) {
10
- const resolved = path.resolve(repoPath);
11
- const key = createHash("sha256").update(resolved.toLowerCase()).digest("hex");
14
+ const resolved = await resolvePathIdentity(repoPath);
15
+ const keyInput = process.platform === "win32" ? resolved.toLowerCase() : resolved;
16
+ const key = createHash("sha256").update(keyInput).digest("hex");
12
17
  const root = path.join(this.stateRoot, "repo-locks");
13
18
  const target = path.join(root, `${key}.lock`);
14
19
  const token = randomUUID();
@@ -41,10 +46,12 @@ export class RepositoryCoordinatorLock {
41
46
  await abortableDelay(50, signal);
42
47
  }
43
48
  }
49
+ const heartbeat = startHeartbeat(target, token);
44
50
  try {
45
51
  return await action();
46
52
  }
47
53
  finally {
54
+ clearInterval(heartbeat);
48
55
  const owner = await readOwner(target);
49
56
  if (owner?.token === token)
50
57
  await fs.rm(target, { force: true });
@@ -58,7 +65,7 @@ async function staleLock(target) {
58
65
  const owner = await readOwner(target);
59
66
  if (!owner || typeof owner.pid !== "number" || !Number.isInteger(owner.pid) || owner.pid <= 0)
60
67
  return true;
61
- if (Date.now() - stat.mtimeMs > 10 * 60_000)
68
+ if (Date.now() - stat.mtimeMs >= LOCK_STALE_AFTER_MS)
62
69
  return true;
63
70
  try {
64
71
  process.kill(owner.pid, 0);
@@ -68,6 +75,37 @@ async function staleLock(target) {
68
75
  return true;
69
76
  }
70
77
  }
78
+ function startHeartbeat(target, token) {
79
+ let refreshing = false;
80
+ const timer = setInterval(() => {
81
+ if (refreshing)
82
+ return;
83
+ refreshing = true;
84
+ void refreshHeartbeat(target, token).finally(() => { refreshing = false; });
85
+ }, LOCK_HEARTBEAT_INTERVAL_MS);
86
+ timer.unref?.();
87
+ return timer;
88
+ }
89
+ async function refreshHeartbeat(target, token) {
90
+ let handle = null;
91
+ try {
92
+ handle = await fs.open(target, fsConstants.O_RDWR | fsConstants.O_NOFOLLOW);
93
+ const stat = await handle.stat();
94
+ if (!stat.isFile() || stat.size > 16 * 1024)
95
+ return;
96
+ const owner = await handle.readFile("utf8").then(value => JSON.parse(value)).catch(() => null);
97
+ if (owner?.token !== token)
98
+ return;
99
+ const now = new Date();
100
+ await handle.utimes(now, now);
101
+ }
102
+ catch {
103
+ // A missing or replaced lock is no longer owned by this action.
104
+ }
105
+ finally {
106
+ await handle?.close().catch(() => undefined);
107
+ }
108
+ }
71
109
  async function readOwner(target) {
72
110
  return await fs.readFile(target, "utf8").then(value => JSON.parse(value)).catch(() => null);
73
111
  }