@loucompanion/forge-bridge 0.1.3-dev.fbe15023cdfe → 0.1.4-dev.41da3780c451

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 (165) hide show
  1. package/README.md +88 -2
  2. package/dist/bridge/bridge-chat.types.d.ts +111 -0
  3. package/dist/bridge/bridge-chat.types.js +401 -0
  4. package/dist/bridge/bridge.base.d.ts +4 -1
  5. package/dist/bridge/bridge.service.d.ts +72 -3
  6. package/dist/bridge/bridge.service.js +1563 -152
  7. package/dist/bridge/bridge.types.d.ts +46 -1
  8. package/dist/bridge/dispatch-result-outbox.d.ts +25 -0
  9. package/dist/bridge/dispatch-result-outbox.js +62 -0
  10. package/dist/bridge/dispatch-result-worker.d.ts +2 -0
  11. package/dist/bridge/dispatch-result-worker.js +122 -0
  12. package/dist/bridge/internals.d.ts +2 -0
  13. package/dist/bridge/internals.js +1 -0
  14. package/dist/bridge/readiness-result-outbox.d.ts +21 -0
  15. package/dist/bridge/readiness-result-outbox.js +54 -0
  16. package/dist/bridge/readiness-result-worker.d.ts +2 -0
  17. package/dist/bridge/readiness-result-worker.js +121 -0
  18. package/dist/bridge/runner-host-capabilities.d.ts +15 -0
  19. package/dist/bridge/runner-host-capabilities.js +93 -0
  20. package/dist/cleanup/cleanup.service.d.ts +1 -0
  21. package/dist/cleanup/cleanup.service.js +70 -9
  22. package/dist/cli/cli.help.js +2 -2
  23. package/dist/cli/cli.service.d.ts +14 -6
  24. package/dist/cli/cli.service.js +75 -16
  25. package/dist/cli/local-config.d.ts +1 -0
  26. package/dist/cli/local-config.js +4 -0
  27. package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
  28. package/dist/runtime/aspnet-runtime-prebuild.js +341 -0
  29. package/dist/runtime/attempt-database-broker.d.ts +89 -0
  30. package/dist/runtime/attempt-database-broker.js +390 -0
  31. package/dist/runtime/attempt-node-runtime-launcher.d.ts +2 -1
  32. package/dist/runtime/attempt-node-runtime-launcher.js +57 -18
  33. package/dist/runtime/attempt-path-operation.d.ts +21 -0
  34. package/dist/runtime/attempt-path-operation.js +94 -0
  35. package/dist/runtime/attempt-private-directory.d.ts +6 -0
  36. package/dist/runtime/attempt-private-directory.js +54 -0
  37. package/dist/runtime/attempt-process-supervisor.d.ts +11 -0
  38. package/dist/runtime/attempt-process-supervisor.js +70 -22
  39. package/dist/runtime/canonical-source-file.d.ts +3 -0
  40. package/dist/runtime/canonical-source-file.js +54 -0
  41. package/dist/runtime/docker-engine-probe.d.ts +90 -0
  42. package/dist/runtime/docker-engine-probe.js +333 -0
  43. package/dist/runtime/generated-output-reset.d.ts +10 -0
  44. package/dist/runtime/generated-output-reset.js +62 -8
  45. package/dist/runtime/host-database-capability-boundary.d.ts +16 -0
  46. package/dist/runtime/host-database-capability-boundary.js +62 -0
  47. package/dist/runtime/host-diagnostics.d.ts +11 -0
  48. package/dist/runtime/host-diagnostics.js +192 -0
  49. package/dist/runtime/host-execution-root.d.ts +4 -0
  50. package/dist/runtime/host-execution-root.js +39 -0
  51. package/dist/runtime/host-listener-inspector.d.ts +8 -1
  52. package/dist/runtime/host-listener-inspector.js +121 -60
  53. package/dist/runtime/host-runtime-contract.d.ts +12 -1
  54. package/dist/runtime/host-runtime-contract.js +168 -55
  55. package/dist/runtime/host-runtime-lease.d.ts +92 -8
  56. package/dist/runtime/host-runtime-lease.js +1141 -114
  57. package/dist/runtime/host-runtime-promotion.d.ts +12 -3
  58. package/dist/runtime/host-runtime-promotion.js +59 -12
  59. package/dist/runtime/memory-capacity.d.ts +70 -0
  60. package/dist/runtime/memory-capacity.js +123 -0
  61. package/dist/runtime/native-test-postgres-fallback.d.ts +25 -0
  62. package/dist/runtime/native-test-postgres-fallback.js +91 -0
  63. package/dist/runtime/node-host-runtime-adapter.d.ts +3 -0
  64. package/dist/runtime/node-host-runtime-adapter.js +29 -11
  65. package/dist/runtime/pinned-directory-process.d.ts +9 -0
  66. package/dist/runtime/pinned-directory-process.js +61 -0
  67. package/dist/runtime/pinned-source-directory.d.ts +17 -0
  68. package/dist/runtime/pinned-source-directory.js +94 -0
  69. package/dist/runtime/playwright-host-runtime-adapter.d.ts +1 -0
  70. package/dist/runtime/playwright-host-runtime-adapter.js +53 -5
  71. package/dist/runtime/prepared-runtime-identity.d.ts +6 -0
  72. package/dist/runtime/prepared-runtime-identity.js +218 -0
  73. package/dist/runtime/prepared-runtime-readiness.d.ts +30 -0
  74. package/dist/runtime/prepared-runtime-readiness.js +285 -0
  75. package/dist/runtime/runtime-isolation-lease.js +4 -1
  76. package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
  77. package/dist/runtime/workspace-resource-estimator.js +174 -0
  78. package/dist/sandbox/preflight.d.ts +31 -1
  79. package/dist/sandbox/preflight.js +237 -51
  80. package/dist/sandbox/repository-lock.d.ts +2 -1
  81. package/dist/sandbox/repository-lock.js +19 -9
  82. package/dist/sandbox/runtime.d.ts +6 -1
  83. package/dist/sandbox/runtime.js +137 -107
  84. package/dist/sandbox/sandbox.types.d.ts +7 -0
  85. package/dist/sandbox/workspace.service.js +2 -0
  86. package/dist/session/managed-attempt-tools.d.ts +17 -1
  87. package/dist/session/managed-attempt-tools.js +489 -34
  88. package/dist/session/managed-build-coordinator.d.ts +5 -0
  89. package/dist/session/managed-build-coordinator.js +106 -0
  90. package/dist/session/managed-build-receipt.d.ts +21 -0
  91. package/dist/session/managed-build-receipt.js +337 -0
  92. package/dist/session/managed-verification-timeouts.d.ts +8 -0
  93. package/dist/session/managed-verification-timeouts.js +17 -0
  94. package/dist/session/phase-completion.artifacts.d.ts +17 -0
  95. package/dist/session/phase-completion.artifacts.js +224 -22
  96. package/dist/session/phase-completion.d.ts +11 -1
  97. package/dist/session/phase-completion.helpers.d.ts +5 -0
  98. package/dist/session/phase-completion.helpers.js +30 -7
  99. package/dist/session/phase-completion.js +14 -3
  100. package/dist/session/phase-completion.prompts.js +17 -18
  101. package/dist/session/phase-completion.runtime.d.ts +3 -1
  102. package/dist/session/phase-completion.runtime.js +126 -17
  103. package/dist/session/phase-completion.schemas.js +49 -2
  104. package/dist/session/phase-completion.validation.d.ts +1 -0
  105. package/dist/session/phase-completion.validation.js +111 -21
  106. package/dist/session/pre-publication-validation.d.ts +75 -0
  107. package/dist/session/pre-publication-validation.js +661 -0
  108. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.d.ts +72 -0
  109. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.js +462 -0
  110. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  111. package/dist/session/provider-cli/ci-repair-readiness.js +23 -3
  112. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +4 -2
  113. package/dist/session/provider-cli/provider-cli.artifacts.js +47 -13
  114. package/dist/session/provider-cli/provider-cli.deterministic.js +34 -2
  115. package/dist/session/provider-cli/provider-cli.process.d.ts +51 -0
  116. package/dist/session/provider-cli/provider-cli.process.js +422 -22
  117. package/dist/session/provider-cli/provider-cli.prompts.d.ts +5 -0
  118. package/dist/session/provider-cli/provider-cli.prompts.js +136 -16
  119. package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
  120. package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
  121. package/dist/session/provider-cli/provider-cli.runtime.d.ts +14 -1
  122. package/dist/session/provider-cli/provider-cli.runtime.js +54 -3
  123. package/dist/session/provider-cli/provider-cli.service.d.ts +30 -5
  124. package/dist/session/provider-cli/provider-cli.service.js +773 -69
  125. package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
  126. package/dist/session/provider-cli/provider-output-state.d.ts +10 -0
  127. package/dist/session/provider-cli/provider-output-state.js +361 -17
  128. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  129. package/dist/session/provider-cli/provider-usage.js +28 -5
  130. package/dist/session/session-redaction.js +434 -9
  131. package/dist/session/session.service.js +145 -23
  132. package/dist/session/session.types.d.ts +147 -2
  133. package/dist/session/session.types.js +587 -7
  134. package/dist/session/unreferenced-exports.d.ts +12 -0
  135. package/dist/session/unreferenced-exports.js +59 -0
  136. package/dist/shared/format/format-bytes.d.ts +1 -0
  137. package/dist/shared/format/format-bytes.js +9 -0
  138. package/dist/shared/git/git.js +2 -1
  139. package/dist/shared/path/path.d.ts +1 -0
  140. package/dist/shared/path/path.js +18 -0
  141. package/dist/shared/process/dotnet-toolchain.d.ts +17 -0
  142. package/dist/shared/process/dotnet-toolchain.js +307 -0
  143. package/dist/shared/process/host-process-recovery.js +3 -2
  144. package/dist/shared/process/host-process-supervisor.d.ts +2 -0
  145. package/dist/shared/process/host-process-supervisor.js +60 -11
  146. package/dist/shared/process/process.d.ts +2 -0
  147. package/dist/shared/process/process.js +12 -7
  148. package/dist/shared/provider-capabilities.d.ts +3 -0
  149. package/dist/shared/provider-capabilities.js +85 -0
  150. package/dist/shared/redaction/redaction.d.ts +1 -0
  151. package/dist/shared/redaction/redaction.js +39 -3
  152. package/dist/shared/time/monotonic.d.ts +1 -0
  153. package/dist/shared/time/monotonic.js +8 -0
  154. package/dist/version.d.ts +1 -1
  155. package/dist/version.js +1 -1
  156. package/dist/worktree/dependency-warmer.d.ts +41 -9
  157. package/dist/worktree/dependency-warmer.js +497 -22
  158. package/dist/worktree/index.d.ts +1 -1
  159. package/dist/worktree/story-qa-reference-hydrator.d.ts +4 -0
  160. package/dist/worktree/story-qa-reference-hydrator.js +253 -0
  161. package/dist/worktree/worktree.service.d.ts +7 -0
  162. package/dist/worktree/worktree.service.js +253 -21
  163. package/dist/worktree/worktree.types.d.ts +7 -1
  164. package/package.json +7 -5
  165. package/runtime/windows-job-supervisor.ps1 +380 -14
@@ -2,26 +2,45 @@ import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { createHash, randomUUID } from "node:crypto";
5
+ import { formatBytes } from "../shared/format/format-bytes.js";
5
6
  import { captureHostProcessIdentity } from "../shared/process/host-process-supervisor.js";
7
+ import { RecoveryStabilizedMemory, resolveAdaptiveMemoryReserveBytes } from "../runtime/memory-capacity.js";
6
8
  import { BoundedCommandRunner } from "./command-runner.js";
7
9
  const minimumWorkspaceBytes = 10 * 1024 * 1024 * 1024;
8
- const defaultHostMemoryReserveBytes = 2 * 1024 ** 3;
9
10
  export class DockerPreflight {
10
11
  commands;
11
12
  cacheMs;
12
13
  generation = 0;
13
14
  cached = null;
14
- cacheAt = 0;
15
+ fullPreflightAt = 0;
16
+ staticPreflight = null;
17
+ inspectionInFlight = null;
18
+ lastCapacitySample = null;
15
19
  options;
16
20
  docker;
17
21
  ownership = new DockerContextOwnershipLock();
18
22
  diskPressureLatched = false;
23
+ memoryAvailability = new RecoveryStabilizedMemory();
19
24
  constructor(options = {}, commands = new BoundedCommandRunner(), cacheMs = 30_000) {
20
25
  this.commands = commands;
21
26
  this.cacheMs = cacheMs;
22
27
  this.options = typeof options === "string" ? { dockerExecutable: options } : options;
23
28
  this.docker = this.options.dockerExecutable ?? process.env.FORGE_DOCKER_EXECUTABLE ?? "docker";
24
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
+ }
25
44
  current() {
26
45
  return this.cached;
27
46
  }
@@ -37,25 +56,97 @@ export class DockerPreflight {
37
56
  await this.ownership.release();
38
57
  }
39
58
  async run(force = false) {
40
- 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)
41
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) {
42
119
  this.generation += 1;
43
- const result = await this.inspect().catch((error) => unavailable(this.generation, "docker_unreachable", error instanceof Error ? error.message : String(error)));
44
- this.cached = result;
45
- this.cacheAt = Date.now();
46
- 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;
47
138
  }
48
139
  async inspect() {
49
140
  if (!new Set(["linux", "darwin", "win32"]).has(process.platform)) {
50
- 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}.`);
51
142
  }
52
143
  const dockerArgs = this.options.dockerContext ? ["--context", this.options.dockerContext] : [];
53
144
  const infoResult = await this.commands.run(this.docker, [...dockerArgs, "info", "--format", "{{json .}}"], { timeoutMs: 10_000, maxOutputBytes: 512 * 1024 });
54
145
  if (infoResult.exitCode !== 0)
55
- 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."));
56
147
  const info = parseInfo(infoResult.stdout);
57
148
  if (String(info.OSType ?? "").toLowerCase() !== "linux")
58
- 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.");
59
150
  const securityOptions = strings(info.SecurityOptions).map((value) => value.toLowerCase());
60
151
  const rootless = securityOptions.some((value) => value.includes("rootless"));
61
152
  const userNamespaces = rootless || securityOptions.some((value) => value.includes("userns"));
@@ -67,7 +158,7 @@ export class DockerPreflight {
67
158
  const desktopDeveloperMode = process.platform !== "linux" && process.env.FORGE_ALLOW_DOCKER_DESKTOP_SANDBOX === "true";
68
159
  const requireRootless = this.options.requireRootless ?? process.env.FORGE_SANDBOX_REQUIRE_ROOTLESS !== "false";
69
160
  if (!rootless && !desktopDeveloperMode && requireRootless) {
70
- 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.", {
71
162
  rootless, userNamespaces, cgroupV2, seccomp, lsm, storageQuota, storagePolicy: false, compose: false, internalNetwork: true, buildkit: false, imagePolicy: false, securityProfile: false, egressProxy: false
72
163
  });
73
164
  }
@@ -101,32 +192,82 @@ export class DockerPreflight {
101
192
  const dockerRoot = process.platform === "linux" && typeof info.DockerRootDir === "string" && path.isAbsolute(info.DockerRootDir)
102
193
  ? info.DockerRootDir
103
194
  : configuredStateRoot;
104
- 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);
105
214
  const requiredWorkspaceBytes = this.options.minimumWorkspaceBytes ?? minimumWorkspaceBytes;
106
215
  const highWatermark = Math.min(99, Math.max(1, this.options.diskHighWatermarkPercent ?? 90));
107
216
  const lowWatermark = Math.min(highWatermark - 1, Math.max(0, this.options.diskLowWatermarkPercent ?? highWatermark - 10));
108
217
  const diskLow = evaluateDiskPressure(disk.available, disk.total, requiredWorkspaceBytes, highWatermark, lowWatermark, this.diskPressureLatched);
109
218
  this.diskPressureLatched = diskLow;
110
- const hostAvailableMemoryBytes = os.freemem();
111
- let totalMemoryBytes = os.totalmem();
112
- let availableMemoryBytes = hostAvailableMemoryBytes;
113
- const dockerDesktop = process.platform !== "linux"
114
- && String(info.OperatingSystem ?? "").toLowerCase().includes("docker desktop");
115
- const dockerMemoryBytes = Number(info.MemTotal);
116
- if (dockerDesktop && Number.isFinite(dockerMemoryBytes) && dockerMemoryBytes > 0) {
117
- totalMemoryBytes = dockerMemoryBytes;
118
- const stats = await this.commands.run(this.docker, [...dockerArgs, "stats", "--no-stream", "--format", "{{.MemUsage}}"], { timeoutMs: 10_000, maxOutputBytes: 512 * 1024 }).catch(() => null);
119
- if (stats?.exitCode === 0) {
120
- const dockerUsedMemoryBytes = stats.stdout
121
- .split(/\r?\n/gu)
122
- .map(line => parseDockerMemoryUsage(line))
123
- .reduce((total, value) => total + value, 0);
124
- availableMemoryBytes = Math.max(0, dockerMemoryBytes - dockerUsedMemoryBytes);
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.";
125
253
  }
126
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);
127
265
  const cpuCount = Math.max(1, os.availableParallelism?.() ?? os.cpus().length);
128
266
  const availableCpuMillis = Math.max(0, (cpuCount - 1) * 1000);
129
- const memoryReserveBytes = positiveInteger(this.options.hostMemoryReserveBytes, defaultHostMemoryReserveBytes);
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);
130
271
  const workerMemoryLimitBytes = positiveInteger(this.options.defaultWorkerMemoryLimitBytes, 2 * 1024 ** 3);
131
272
  const serviceMemoryLimitBytes = nonNegativeInteger(this.options.defaultServiceMemoryLimitBytes, 0);
132
273
  const runtimeOverheadBytes = Math.max(128 * 1024 ** 2, nonNegativeInteger(this.options.runtimeOverheadBytes, 128 * 1024 ** 2));
@@ -136,29 +277,38 @@ export class DockerPreflight {
136
277
  const cpuSlots = Math.max(0, Math.floor(availableCpuMillis / sandboxCpuMillis));
137
278
  const configuredMax = positiveInteger(this.options.maxConcurrentSandboxes, positiveInteger(process.env.FORGE_MAX_CONCURRENT_SANDBOXES, 3));
138
279
  const availableForAdmissionBytes = Math.max(0, availableMemoryBytes - memoryReserveBytes);
139
- const capacityLowCode = !diskLow && availableForAdmissionBytes < minimumAdmissionMemoryBytes
140
- ? "docker_memory_low"
141
- : !diskLow && cpuSlots <= 0
142
- ? "docker_cpu_low"
143
- : null;
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;
144
287
  const maxConcurrentSandboxes = diskLow || capacityLowCode ? 0 : Math.min(3, configuredMax, cpuSlots);
145
288
  const unavailableCode = diskLow ? "docker_disk_low" : capacityLowCode;
146
289
  const unavailableReason = diskLow
147
- ? `Docker workspace has only ${disk.available} bytes available.`
148
- : unavailableCode === "docker_memory_low"
149
- ? `Docker reports ${availableMemoryBytes} bytes available and Forge reserves ${memoryReserveBytes} host bytes, leaving ${availableForAdmissionBytes} bytes for admission; the smallest supported sandbox requires ${minimumAdmissionMemoryBytes} bytes. Exact attempt memory is calculated dynamically from its worker profile, sidecars, and runtime helpers.`
150
- : unavailableCode === "docker_cpu_low"
151
- ? `Docker host has ${availableCpuMillis} available CPU millis; at least ${sandboxCpuMillis} are needed for one sandbox.`
152
- : null;
153
- 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;
154
300
  const capacityAvailable = unavailableCode === null;
155
301
  return {
156
302
  available: capacityAvailable,
157
303
  healthy: capacityAvailable,
158
304
  generation: this.generation,
159
- runtimeVersion: `docker/${String(info.ServerVersion ?? "unknown")}${rootless ? "-rootless" : "-desktop"}`,
305
+ sampledAt,
306
+ runtimeVersion: staticPreflight.runtimeVersion,
160
307
  maxConcurrentSandboxes,
161
308
  totalMemoryBytes,
309
+ rawHostAvailableMemoryBytes,
310
+ rawDaemonAvailableMemoryBytes,
311
+ rawAvailableMemoryBytes,
162
312
  availableMemoryBytes,
163
313
  hostReserveBytes: memoryReserveBytes,
164
314
  admissionMemoryBytes: sandboxMemoryBytes,
@@ -167,11 +317,29 @@ export class DockerPreflight {
167
317
  diskLow,
168
318
  unavailableCode,
169
319
  unavailableReason,
170
- features,
320
+ features: staticPreflight.features,
171
321
  };
172
322
  }
173
323
  }
174
- 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
+ ]);
175
343
  export function canRunDockerMaintenance(preflight) {
176
344
  return preflight.healthy || dockerMaintenanceUnavailableCodes.has(preflight.unavailableCode ?? "");
177
345
  }
@@ -185,6 +353,12 @@ export function evaluateDiskPressure(availableBytes, totalBytes, minimumAvailabl
185
353
  return true;
186
354
  return pressureLatched && usedPercent > lowWatermarkPercent;
187
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
+ }
188
362
  export function storagePolicyAvailable(storageQuota, environment = process.env) {
189
363
  return storageQuota || environment.FORGE_SANDBOX_DEDICATED_DISK === "true";
190
364
  }
@@ -245,19 +419,24 @@ function parseInfo(stdout) {
245
419
  throw new Error("Docker returned an invalid info document.", { cause: error });
246
420
  }
247
421
  }
248
- function unavailable(generation, code, reason, features = {
422
+ function unavailable(generation, code, reason, hostReserveBytes, admissionMemoryBytes, totalMemoryBytes, features = {
249
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
250
424
  }) {
425
+ const rawHostAvailableMemoryBytes = Math.max(0, os.freemem());
251
426
  return {
252
427
  available: false,
253
428
  healthy: false,
254
429
  generation,
430
+ sampledAt: new Date().toISOString(),
255
431
  runtimeVersion: null,
256
432
  maxConcurrentSandboxes: 0,
257
- totalMemoryBytes: os.totalmem(),
258
- availableMemoryBytes: os.freemem(),
259
- hostReserveBytes: defaultHostMemoryReserveBytes,
260
- admissionMemoryBytes: 2 * 1024 ** 3 + 128 * 1024 ** 2,
433
+ totalMemoryBytes,
434
+ rawHostAvailableMemoryBytes,
435
+ rawDaemonAvailableMemoryBytes: null,
436
+ rawAvailableMemoryBytes: rawHostAvailableMemoryBytes,
437
+ availableMemoryBytes: rawHostAvailableMemoryBytes,
438
+ hostReserveBytes,
439
+ admissionMemoryBytes,
261
440
  availableCpuMillis: Math.max(0, (os.availableParallelism?.() ?? os.cpus().length) * 1000),
262
441
  availableWorkspaceBytes: 0,
263
442
  diskLow: code === "docker_disk_low",
@@ -292,14 +471,21 @@ function nonNegativeInteger(value, fallback) {
292
471
  const parsed = Number(value);
293
472
  return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
294
473
  }
295
- export function parseDockerMemoryUsage(value) {
474
+ function positiveNumber(value, fallback) {
475
+ const parsed = Number(value);
476
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
477
+ }
478
+ function tryParseDockerMemoryUsage(value) {
296
479
  const usage = value.split("/", 1)[0]?.trim().replaceAll(" ", "") ?? "";
297
480
  const match = /^(\d+(?:\.\d+)?)(b|kib|mib|gib|tib)$/iu.exec(usage);
298
481
  if (!match)
299
- return 0;
482
+ return null;
300
483
  const amount = Number(match[1]);
301
484
  const multiplier = { b: 1, kib: 1024, mib: 1024 ** 2, gib: 1024 ** 3, tib: 1024 ** 4 }[match[2].toLowerCase()];
302
- return Number.isFinite(amount) ? Math.round(amount * multiplier) : 0;
485
+ return Number.isFinite(amount) ? Math.round(amount * multiplier) : null;
486
+ }
487
+ export function parseDockerMemoryUsage(value) {
488
+ return tryParseDockerMemoryUsage(value) ?? 0;
303
489
  }
304
490
  async function readLock(lockPath) {
305
491
  return await fs.readFile(lockPath, "utf8").then((value) => JSON.parse(value)).catch(() => null);
@@ -1,5 +1,6 @@
1
1
  export declare class RepositoryCoordinatorLock {
2
2
  private readonly stateRoot;
3
- constructor(stateRoot: string);
3
+ private readonly lockTimeoutMs;
4
+ constructor(stateRoot: string, lockTimeoutMs?: number);
4
5
  withLock<T>(repoPath: string, action: () => Promise<T>, signal?: AbortSignal): Promise<T>;
5
6
  }
@@ -2,24 +2,31 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { constants as fsConstants } from "node:fs";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import { resolvePathIdentity } from "../shared/path/path.js";
6
+ import { ForgeFailureError } from "../session/session.types.js";
7
+ import { monotonicNowMs } from "../shared/time/monotonic.js";
5
8
  const LOCK_HEARTBEAT_INTERVAL_MS = 30_000;
6
9
  const LOCK_STALE_AFTER_MS = 2 * 60_000;
7
10
  export class RepositoryCoordinatorLock {
8
11
  stateRoot;
9
- constructor(stateRoot) {
12
+ lockTimeoutMs;
13
+ constructor(stateRoot, lockTimeoutMs = 2 * 60_000) {
10
14
  this.stateRoot = stateRoot;
15
+ this.lockTimeoutMs = lockTimeoutMs;
11
16
  }
12
17
  async withLock(repoPath, action, signal) {
13
- const resolved = path.resolve(repoPath);
14
- const key = createHash("sha256").update(resolved.toLowerCase()).digest("hex");
18
+ const resolved = await resolvePathIdentity(repoPath);
19
+ const keyInput = process.platform === "win32" ? resolved.toLowerCase() : resolved;
20
+ const key = createHash("sha256").update(keyInput).digest("hex");
15
21
  const root = path.join(this.stateRoot, "repo-locks");
16
22
  const target = path.join(root, `${key}.lock`);
17
23
  const token = randomUUID();
18
24
  await fs.mkdir(root, { recursive: true, mode: 0o700 });
19
- const started = Date.now();
25
+ const started = monotonicNowMs();
20
26
  while (true) {
21
- if (signal?.aborted)
22
- throw new Error("sandbox_import_cancelled: repository coordination was cancelled.");
27
+ if (signal?.aborted) {
28
+ throw new ForgeFailureError("sandbox_import_cancelled", "sandbox_import_cancelled: repository coordination was cancelled.", "sandbox");
29
+ }
23
30
  try {
24
31
  const handle = await fs.open(target, "wx", 0o600);
25
32
  try {
@@ -38,8 +45,8 @@ export class RepositoryCoordinatorLock {
38
45
  await fs.rm(target, { force: true });
39
46
  continue;
40
47
  }
41
- if (Date.now() - started >= 2 * 60_000) {
42
- throw new Error("sandbox_import_unavailable: repository coordinator lock timed out.", { cause: error });
48
+ if (monotonicNowMs() - started >= this.lockTimeoutMs) {
49
+ throw new ForgeFailureError("sandbox_import_unavailable", "sandbox_import_unavailable: repository coordinator lock timed out.", "sandbox", { cause: error });
43
50
  }
44
51
  await abortableDelay(50, signal);
45
52
  }
@@ -112,7 +119,10 @@ async function abortableDelay(delayMs, signal) {
112
119
  return await new Promise(resolve => setTimeout(resolve, delayMs));
113
120
  await new Promise((resolve, reject) => {
114
121
  const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve(); }, delayMs);
115
- const abort = () => { clearTimeout(timer); reject(new Error("sandbox_import_cancelled: repository coordination was cancelled.")); };
122
+ const abort = () => {
123
+ clearTimeout(timer);
124
+ reject(new ForgeFailureError("sandbox_import_cancelled", "sandbox_import_cancelled: repository coordination was cancelled.", "sandbox"));
125
+ };
116
126
  signal.addEventListener("abort", abort, { once: true });
117
127
  timer.unref?.();
118
128
  });
@@ -37,6 +37,10 @@ export interface SandboxCleanupAction {
37
37
  readonly message?: string | null;
38
38
  readonly occurredAt: string;
39
39
  }
40
+ export interface TrustedSandboxHostFailure {
41
+ readonly code: string;
42
+ readonly message: string;
43
+ }
40
44
  export interface SandboxRuntimeSnapshot {
41
45
  readonly starting: number;
42
46
  readonly active: number;
@@ -285,11 +289,12 @@ export declare function egressProxyConfig(allowedHosts: readonly string[]): stri
285
289
  export declare function serviceContainerArgs(service: SandboxService, image: string, resources: ResourceNames, labels: Record<string, string>, securityOptions: readonly string[], environment?: NodeJS.ProcessEnv): string[];
286
290
  export declare function resolveSandboxSecurityOptions(executionPolicy: string, environment?: NodeJS.ProcessEnv): string[];
287
291
  export declare function networkCreateArgs(dispatch: RunOnceDispatchPacket, environment?: NodeJS.ProcessEnv, allowOpenPublicSandbox?: boolean): string[];
288
- export declare function classifySandboxRuntimeFailure(value: string): {
292
+ export declare function classifySandboxRuntimeFailure(value: unknown): {
289
293
  readonly code: string;
290
294
  readonly phase: string;
291
295
  readonly message: string;
292
296
  };
297
+ export declare function applyTrustedHostFailure(result: RunOnceResultPacket, trustedFailure: TrustedSandboxHostFailure | null): RunOnceResultPacket;
293
298
  export declare function classifySandboxResourceFailure(value: string): {
294
299
  readonly code: string;
295
300
  readonly message: string;