@loucompanion/forge-bridge 0.1.3-dev.ba7d36726eda → 0.1.3-dev.bca7d0ccee53

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 (73) hide show
  1. package/dist/bridge/bridge.base.d.ts +2 -1
  2. package/dist/bridge/bridge.service.d.ts +8 -0
  3. package/dist/bridge/bridge.service.js +184 -12
  4. package/dist/bridge/bridge.types.d.ts +37 -0
  5. package/dist/cli/cli.help.js +1 -1
  6. package/dist/cli/cli.service.js +1 -1
  7. package/dist/cli/local-config.js +2 -1
  8. package/dist/runtime/attempt-node-runtime-launcher.d.ts +3 -1
  9. package/dist/runtime/attempt-node-runtime-launcher.js +62 -11
  10. package/dist/runtime/attempt-process-supervisor-registry.js +1 -1
  11. package/dist/runtime/attempt-process-supervisor.d.ts +12 -0
  12. package/dist/runtime/attempt-process-supervisor.js +75 -15
  13. package/dist/runtime/generated-output-reset.d.ts +10 -0
  14. package/dist/runtime/generated-output-reset.js +103 -0
  15. package/dist/runtime/host-listener-inspector.d.ts +16 -0
  16. package/dist/runtime/host-listener-inspector.js +143 -4
  17. package/dist/runtime/host-runtime-contract.d.ts +9 -1
  18. package/dist/runtime/host-runtime-contract.js +411 -6
  19. package/dist/runtime/host-runtime-lease.d.ts +12 -1
  20. package/dist/runtime/host-runtime-lease.js +271 -50
  21. package/dist/runtime/host-runtime-promotion.d.ts +2 -0
  22. package/dist/runtime/host-runtime-promotion.js +35 -4
  23. package/dist/runtime/node-host-runtime-adapter.d.ts +7 -0
  24. package/dist/runtime/node-host-runtime-adapter.js +71 -8
  25. package/dist/runtime/playwright-host-runtime-adapter.js +84 -0
  26. package/dist/sandbox/preflight.d.ts +9 -0
  27. package/dist/sandbox/preflight.js +39 -4
  28. package/dist/sandbox/workspace.service.js +7 -2
  29. package/dist/session/managed-attempt-tools.d.ts +17 -0
  30. package/dist/session/managed-attempt-tools.js +712 -0
  31. package/dist/session/phase-completion.artifacts.js +9 -2
  32. package/dist/session/phase-completion.d.ts +11 -2
  33. package/dist/session/phase-completion.helpers.d.ts +5 -0
  34. package/dist/session/phase-completion.helpers.js +45 -9
  35. package/dist/session/phase-completion.js +12 -3
  36. package/dist/session/phase-completion.prompts.d.ts +3 -3
  37. package/dist/session/phase-completion.prompts.js +21 -6
  38. package/dist/session/phase-completion.runtime.d.ts +13 -1
  39. package/dist/session/phase-completion.runtime.js +183 -8
  40. package/dist/session/phase-completion.schemas.js +108 -3
  41. package/dist/session/phase-completion.validation.d.ts +4 -1
  42. package/dist/session/phase-completion.validation.js +144 -18
  43. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  44. package/dist/session/provider-cli/ci-repair-readiness.js +95 -0
  45. package/dist/session/provider-cli/provider-cli.deterministic.js +47 -1
  46. package/dist/session/provider-cli/provider-cli.process.d.ts +14 -0
  47. package/dist/session/provider-cli/provider-cli.process.js +176 -18
  48. package/dist/session/provider-cli/provider-cli.prompts.js +30 -8
  49. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
  50. package/dist/session/provider-cli/provider-cli.service.d.ts +19 -2
  51. package/dist/session/provider-cli/provider-cli.service.js +390 -83
  52. package/dist/session/provider-cli/provider-cli.types.d.ts +3 -0
  53. package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
  54. package/dist/session/provider-cli/provider-output-state.js +30 -2
  55. package/dist/session/session.base.d.ts +2 -1
  56. package/dist/session/session.service.d.ts +2 -1
  57. package/dist/session/session.service.js +303 -30
  58. package/dist/session/session.types.d.ts +38 -0
  59. package/dist/session/session.types.js +17 -0
  60. package/dist/shared/process/host-process-supervisor.d.ts +18 -1
  61. package/dist/shared/process/host-process-supervisor.js +94 -7
  62. package/dist/shared/process/process.d.ts +1 -0
  63. package/dist/shared/process/process.js +1 -0
  64. package/dist/version.d.ts +1 -1
  65. package/dist/version.js +1 -1
  66. package/dist/worktree/artifact-injector.d.ts +3 -2
  67. package/dist/worktree/artifact-injector.js +43 -4
  68. package/dist/worktree/dependency-warmer.d.ts +10 -1
  69. package/dist/worktree/dependency-warmer.js +94 -5
  70. package/dist/worktree/worktree.service.d.ts +1 -0
  71. package/dist/worktree/worktree.service.js +12 -1
  72. package/package.json +1 -1
  73. package/runtime/windows-job-supervisor.ps1 +89 -20
@@ -19,6 +19,32 @@ export class HostParallelPromotionStore {
19
19
  throw error;
20
20
  }
21
21
  }
22
+ async list() {
23
+ const root = path.join(this.stateRoot, "runtime-promotion", "host");
24
+ let entries;
25
+ try {
26
+ entries = await fs.readdir(root);
27
+ }
28
+ catch (error) {
29
+ if (error.code === "ENOENT")
30
+ return [];
31
+ throw error;
32
+ }
33
+ const records = [];
34
+ for (const entry of entries.filter(value => /^[0-9a-f]{64}\.json$/u.test(value)).slice(0, 4096)) {
35
+ try {
36
+ const parsed = JSON.parse(await fs.readFile(path.join(root, entry), "utf8"));
37
+ const repoId = typeof parsed.repoId === "string" ? normalizeRepoId(parsed.repoId) : "";
38
+ if (repoId && validRecord(parsed, repoId))
39
+ records.push(parsed);
40
+ }
41
+ catch (error) {
42
+ if (!(error instanceof SyntaxError))
43
+ throw error;
44
+ }
45
+ }
46
+ return records.sort((left, right) => left.repoId.localeCompare(right.repoId));
47
+ }
22
48
  async observe(repoId, runtimeContract, dispatchAttemptId, verifiedPortNames) {
23
49
  if (!hostRuntimeParallelEligible(runtimeContract)) {
24
50
  throw new Error("host_parallel_promotion_ineligible: the runtime contract is not eligible for parallel host execution.");
@@ -34,6 +60,7 @@ export class HostParallelPromotionStore {
34
60
  schemaVersion: 1,
35
61
  repoId: normalizedRepoId,
36
62
  runtimeContractFingerprint: hostRuntimeContractFingerprint(runtimeContract),
63
+ runtimeContractSource: runtimeContract.source === "project" ? "project" : "derived",
37
64
  observedAt: new Date().toISOString(),
38
65
  observedDispatchAttemptId: normalizedUuid(dispatchAttemptId, "dispatch attempt"),
39
66
  verifiedPortNames: observedPorts,
@@ -71,18 +98,22 @@ export function hostRuntimeContractFingerprint(runtimeContract) {
71
98
  const canonical = JSON.stringify({
72
99
  schemaVersion: runtimeContract.contract.schemaVersion,
73
100
  runtime: runtimeContract.contract.runtime,
101
+ derivedCandidateFingerprint: runtimeContract.source === "derived"
102
+ ? runtimeContract.discovery?.candidateFingerprint ?? null
103
+ : null,
74
104
  });
75
105
  return `sha256:${createHash("sha256").update(canonical).digest("hex")}`;
76
106
  }
77
107
  export function hostRuntimeParallelEligible(runtimeContract) {
78
108
  const runtime = runtimeContract.contract.runtime;
79
- return runtimeContract.source === "project"
109
+ const launchedPorts = runtime.ports.filter(port => port.launch !== null);
110
+ return (runtimeContract.source === "project" || runtimeContract.source === "derived")
80
111
  && runtime.hostConcurrency === "parallel"
81
112
  && runtime.browserProfile === "isolated"
82
113
  && runtime.singletonResources.length === 0
83
- && runtime.ports.length === 1
84
- && runtime.ports[0]?.adapter !== null
85
- && runtime.ports[0]?.launch !== null;
114
+ && launchedPorts.length > 0
115
+ && launchedPorts.length === runtime.ports.length
116
+ && launchedPorts.every(port => port.adapter !== null);
86
117
  }
87
118
  export function configuredLaunchPortNames(runtimeContract) {
88
119
  return runtimeContract.contract.runtime.ports
@@ -15,8 +15,15 @@ export interface NodeRuntimeLaunchRequest extends NodeRuntimeCommand {
15
15
  export interface NodeRuntimeCommandLauncher<T> {
16
16
  readonly launch: (request: NodeRuntimeLaunchRequest, signal?: AbortSignal) => Promise<T>;
17
17
  readonly inspectListener: (value: T, request: NodeRuntimeLaunchRequest, signal?: AbortSignal) => Promise<HostListenerOwnership>;
18
+ readonly readinessDiagnostics?: (value: T) => Promise<NodeRuntimeReadinessDiagnostics>;
18
19
  readonly stop: (value: T) => Promise<void>;
19
20
  }
21
+ export interface NodeRuntimeReadinessDiagnostics {
22
+ readonly exited: boolean;
23
+ readonly exitCode?: number;
24
+ readonly stdoutTail?: string;
25
+ readonly stderrTail?: string;
26
+ }
20
27
  export interface NodeHostRuntimeAdapterOptions {
21
28
  readonly readinessTimeoutMs?: number;
22
29
  readonly readinessPollIntervalMs?: number;
@@ -13,7 +13,7 @@ export class NodeHostRuntimeAdapter {
13
13
  probeHttp;
14
14
  constructor(broker, options = {}) {
15
15
  this.broker = broker;
16
- this.readinessTimeoutMs = boundedInteger(options.readinessTimeoutMs ?? 30_000, 100, 120_000, "readinessTimeoutMs");
16
+ this.readinessTimeoutMs = boundedInteger(options.readinessTimeoutMs ?? 120_000, 100, 120_000, "readinessTimeoutMs");
17
17
  this.readinessPollIntervalMs = boundedInteger(options.readinessPollIntervalMs ?? 100, 10, 5_000, "readinessPollIntervalMs");
18
18
  this.httpProbeTimeoutMs = boundedInteger(options.httpProbeTimeoutMs ?? 1_000, 50, 10_000, "httpProbeTimeoutMs");
19
19
  this.probeHttp = options.probeHttp ?? probeHttpReadiness;
@@ -50,10 +50,16 @@ export class NodeHostRuntimeAdapter {
50
50
  }
51
51
  async waitUntilReady(value, request, launcher, signal) {
52
52
  const deadline = Date.now() + this.readinessTimeoutMs;
53
+ let observedOwnership = "unbound";
53
54
  while (Date.now() <= deadline) {
54
55
  if (signal?.aborted)
55
56
  throw cancellationError(signal);
57
+ const diagnostics = await launcher.readinessDiagnostics?.(value);
58
+ if (diagnostics?.exited) {
59
+ throw new Error(readinessFailure("host_runtime_process_exited", `the supervised process exited with code ${diagnostics.exitCode ?? "unknown"} before its assigned runtime became ready`, request, diagnostics));
60
+ }
56
61
  const ownership = await launcher.inspectListener(value, request, signal);
62
+ observedOwnership = ownership;
57
63
  if (ownership === "foreign")
58
64
  return false;
59
65
  if (ownership === "owned" && await this.probeHttp(request.baseUrl, this.httpProbeTimeoutMs, signal)) {
@@ -61,9 +67,32 @@ export class NodeHostRuntimeAdapter {
61
67
  }
62
68
  await wait(this.readinessPollIntervalMs, signal);
63
69
  }
64
- return false;
70
+ const diagnostics = await launcher.readinessDiagnostics?.(value);
71
+ if (diagnostics?.exited) {
72
+ throw new Error(readinessFailure("host_runtime_process_exited", `the supervised process exited with code ${diagnostics.exitCode ?? "unknown"} before its assigned runtime became ready`, request, diagnostics));
73
+ }
74
+ const code = observedOwnership === "owned"
75
+ ? "host_runtime_http_readiness_timeout"
76
+ : "host_runtime_listener_timeout";
77
+ const detail = observedOwnership === "owned"
78
+ ? `the supervised process owned its assigned loopback port but HTTP readiness did not succeed within ${this.readinessTimeoutMs}ms`
79
+ : `the supervised process did not bind its assigned loopback port within ${this.readinessTimeoutMs}ms`;
80
+ throw new Error(readinessFailure(code, detail, request, diagnostics));
65
81
  }
66
82
  }
83
+ function readinessFailure(code, detail, request, diagnostics) {
84
+ const evidence = [
85
+ boundedDiagnostic("stderr", diagnostics?.stderrTail),
86
+ boundedDiagnostic("stdout", diagnostics?.stdoutTail),
87
+ ].filter((value) => value !== null);
88
+ return `${code}: ${detail} (${request.baseUrl}).${evidence.length > 0 ? ` ${evidence.join(" ")}` : ""}`;
89
+ }
90
+ function boundedDiagnostic(label, value) {
91
+ const normalized = value?.replaceAll(/\s+/gu, " ").trim();
92
+ if (!normalized)
93
+ return null;
94
+ return `${label} tail: ${normalized.slice(-2_000)}`;
95
+ }
67
96
  export function buildNodeRuntimeLaunchRequest(framework, portConfig, command, port) {
68
97
  validateCommand(command);
69
98
  if (port.hostIp !== "127.0.0.1" || port.protocol !== "tcp") {
@@ -81,25 +110,56 @@ export function buildNodeRuntimeLaunchRequest(framework, portConfig, command, po
81
110
  ? aspNetCoreArguments(command.command, command.args)
82
111
  : [...command.args, ...frameworkArguments(framework, port.hostIp, port.hostPort)];
83
112
  const adapterEnvironment = framework === "aspnetcore"
84
- ? { ASPNETCORE_URLS: baseUrl, DOTNET_NOLOGO: "1" }
113
+ ? {
114
+ ASPNETCORE_URLS: baseUrl,
115
+ Hosting__Port: String(port.hostPort),
116
+ Harness__Reconciler__Hosted: "false",
117
+ Projects__RuntimeReadiness__Hosted: "false",
118
+ Runners__ServerLocalHeartbeat__Hosted: "false",
119
+ FORGE_RUNTIME_MODE: "host",
120
+ FORGE_DISPATCH_ATTEMPT_ID: port.dispatchAttemptId,
121
+ DOTNET_CLI_USE_MSBUILD_SERVER: "0",
122
+ DOTNET_NOLOGO: "1",
123
+ }
85
124
  : { HOST: port.hostIp };
125
+ const forgeEnvironment = {
126
+ FORGE_RUNTIME_PORT_NAME: portConfig.name,
127
+ FORGE_RUNTIME_PORT: String(port.hostPort),
128
+ FORGE_RUNTIME_BASE_URL: baseUrl,
129
+ };
130
+ const expansionScope = {
131
+ ...command.environment,
132
+ ...portEnvironment,
133
+ ...adapterEnvironment,
134
+ ...forgeEnvironment,
135
+ };
86
136
  return {
87
137
  framework,
88
138
  command: command.command,
89
139
  args,
90
140
  cwd: path.resolve(command.cwd),
91
141
  environment: {
92
- ...command.environment,
142
+ ...expandCommandEnvironment(command.environment, expansionScope),
93
143
  ...portEnvironment,
94
144
  ...adapterEnvironment,
95
- FORGE_RUNTIME_PORT_NAME: portConfig.name,
96
- FORGE_RUNTIME_PORT: String(port.hostPort),
97
- FORGE_RUNTIME_BASE_URL: baseUrl,
145
+ ...forgeEnvironment,
98
146
  },
99
147
  port,
100
148
  baseUrl,
101
149
  };
102
150
  }
151
+ function expandCommandEnvironment(environment, scope) {
152
+ return Object.fromEntries(Object.entries(environment ?? {}).map(([name, value]) => [
153
+ name,
154
+ value.replaceAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/gu, (_match, variable) => {
155
+ const resolved = scope[variable];
156
+ if (!resolved) {
157
+ throw new Error(`node_runtime_adapter_invalid: launch environment references unavailable variable '${variable}'.`);
158
+ }
159
+ return resolved;
160
+ }),
161
+ ]));
162
+ }
103
163
  function frameworkArguments(framework, hostIp, hostPort) {
104
164
  if (framework === "vite")
105
165
  return ["--host", hostIp, "--port", String(hostPort), "--strictPort"];
@@ -139,6 +199,9 @@ function aspNetCoreArguments(command, args) {
139
199
  const insertionIndex = applicationArguments < 0 ? args.length : applicationArguments;
140
200
  return [
141
201
  ...args.slice(0, insertionIndex),
202
+ "--disable-build-servers",
203
+ "-p:UseSharedCompilation=false",
204
+ "-p:DefaultItemExcludesInProjectFolder=TestResults/**",
142
205
  "--no-launch-profile",
143
206
  ...args.slice(insertionIndex),
144
207
  ];
@@ -151,7 +214,7 @@ function validateCommand(command) {
151
214
  throw new Error("node_runtime_adapter_invalid: command, arguments, or absolute working directory are invalid.");
152
215
  }
153
216
  for (const [name, value] of Object.entries(command.environment ?? {})) {
154
- if (!/^[A-Z_][A-Z0-9_]{0,127}$/u.test(name) || !boundedText(value, 8192)) {
217
+ if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/u.test(name) || !boundedText(value, 8192)) {
155
218
  throw new Error("node_runtime_adapter_invalid: command environment is invalid.");
156
219
  }
157
220
  }
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs/promises";
2
+ import os from "node:os";
2
3
  import path from "node:path";
3
4
  import { pathToFileURL } from "node:url";
4
5
  const maximumConfigBytes = 1024 * 1024;
@@ -29,6 +30,8 @@ export class PlaywrightHostRuntimeAdapter {
29
30
  }
30
31
  const baseUrl = loopbackHttpUrl(context.baseUrl);
31
32
  const overlayPath = path.join(temporaryDirectory, "forge.playwright.config.mjs");
33
+ const browserCache = await resolvePlaywrightBrowserCache(attemptRoot);
34
+ const browserInstallCommand = await resolvePlaywrightBrowserInstallCommand(context.worktreePath, path.dirname(realSource));
32
35
  await fs.writeFile(overlayPath, renderOverlay({
33
36
  projectConfigUrl: pathToFileURL(realSource).href,
34
37
  projectConfigDirectory: path.dirname(realSource),
@@ -41,6 +44,10 @@ export class PlaywrightHostRuntimeAdapter {
41
44
  environment: {
42
45
  FORGE_PLAYWRIGHT_CONFIG: overlayPath,
43
46
  FORGE_PLAYWRIGHT_BASE_URL: baseUrl,
47
+ FORGE_E2E_WEB_URL: baseUrl,
48
+ FORGE_PLAYWRIGHT_BROWSER_READY: browserCache.ready ? "true" : "false",
49
+ FORGE_PLAYWRIGHT_BROWSER_INSTALL_COMMAND: browserInstallCommand,
50
+ PLAYWRIGHT_BROWSERS_PATH: browserCache.path,
44
51
  PLAYWRIGHT_HTML_OUTPUT_DIR: path.join(testOutputDirectory, "html-report"),
45
52
  PLAYWRIGHT_BLOB_OUTPUT_DIR: path.join(testOutputDirectory, "blob-report"),
46
53
  PLAYWRIGHT_JUNIT_OUTPUT_NAME: path.join(testOutputDirectory, "results.xml"),
@@ -51,6 +58,71 @@ export class PlaywrightHostRuntimeAdapter {
51
58
  };
52
59
  }
53
60
  }
61
+ async function resolvePlaywrightBrowserInstallCommand(worktreePath, configDirectory) {
62
+ const relativeDirectory = path.relative(worktreePath, configDirectory).replaceAll(path.sep, "/") || ".";
63
+ if (await isFile(path.join(configDirectory, "pnpm-lock.yaml"))) {
64
+ return `pnpm --dir ${quoteCommandArgument(relativeDirectory)} exec playwright install chromium`;
65
+ }
66
+ if (await isFile(path.join(configDirectory, "yarn.lock"))) {
67
+ return `yarn --cwd ${quoteCommandArgument(relativeDirectory)} playwright install chromium`;
68
+ }
69
+ return `npm --prefix ${quoteCommandArgument(relativeDirectory)} exec playwright install chromium`;
70
+ }
71
+ async function isFile(candidate) {
72
+ try {
73
+ return (await fs.stat(candidate)).isFile();
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ function quoteCommandArgument(value) {
80
+ return /[\s"']/u.test(value) ? JSON.stringify(value) : value;
81
+ }
82
+ async function resolvePlaywrightBrowserCache(attemptRoot) {
83
+ const candidates = [
84
+ process.env.PLAYWRIGHT_BROWSERS_PATH,
85
+ process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, "ms-playwright") : null,
86
+ path.join(os.homedir(), ".cache", "ms-playwright"),
87
+ ].filter((candidate) => Boolean(candidate?.trim()));
88
+ for (const candidate of [...new Set(candidates.map(candidate => path.resolve(candidate)))]) {
89
+ if (await hasChromiumExecutable(candidate)) {
90
+ return { path: candidate, ready: true };
91
+ }
92
+ }
93
+ const attemptCache = path.join(attemptRoot, "cache", "ms-playwright");
94
+ await fs.mkdir(attemptCache, { recursive: true });
95
+ return { path: attemptCache, ready: false };
96
+ }
97
+ async function hasChromiumExecutable(cachePath) {
98
+ let entries;
99
+ try {
100
+ entries = await fs.readdir(cachePath, { withFileTypes: true });
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ const executableSuffixes = process.platform === "win32"
106
+ ? [path.join("chrome-win64", "chrome.exe"), path.join("chrome-win", "chrome.exe")]
107
+ : process.platform === "darwin"
108
+ ? [path.join("chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium")]
109
+ : [path.join("chrome-linux", "chrome"), path.join("chrome-linux64", "chrome")];
110
+ for (const entry of entries) {
111
+ if (!entry.isDirectory() || !entry.name.startsWith("chromium-"))
112
+ continue;
113
+ for (const suffix of executableSuffixes) {
114
+ try {
115
+ const stat = await fs.stat(path.join(cachePath, entry.name, suffix));
116
+ if (stat.isFile())
117
+ return true;
118
+ }
119
+ catch {
120
+ // Continue through known Playwright layouts.
121
+ }
122
+ }
123
+ }
124
+ return false;
125
+ }
54
126
  function renderOverlay(values) {
55
127
  return [
56
128
  "// Generated by forge-bridge for one fenced host attempt.",
@@ -82,6 +154,17 @@ function renderOverlay(values) {
82
154
  " ? [resolveModulePath(reporter[0]), ...reporter.slice(1)]",
83
155
  " : resolveModulePath(reporter))",
84
156
  " : resolveModulePath(projectConfig.reporter);",
157
+ "const forgeProviderOnlySpecs = (() => {",
158
+ " try {",
159
+ " const parsed = JSON.parse(process.env.FORGE_CI_REPAIR_PROVIDER_ONLY_SPECS ?? '[]');",
160
+ " return Array.isArray(parsed) ? parsed.filter(value => typeof value === 'string' && value.length > 0) : [];",
161
+ " } catch {",
162
+ " return [];",
163
+ " }",
164
+ "})();",
165
+ "const forgeTestIgnore = forgeProviderOnlySpecs.length === 0",
166
+ " ? projectConfig.testIgnore",
167
+ " : [...(Array.isArray(projectConfig.testIgnore) ? projectConfig.testIgnore : projectConfig.testIgnore ? [projectConfig.testIgnore] : []), ...forgeProviderOnlySpecs];",
85
168
  "export default {",
86
169
  " ...projectConfig,",
87
170
  " testDir: resolveConfigPath(projectConfig.testDir),",
@@ -89,6 +172,7 @@ function renderOverlay(values) {
89
172
  " globalSetup: resolveModulePath(projectConfig.globalSetup),",
90
173
  " globalTeardown: resolveModulePath(projectConfig.globalTeardown),",
91
174
  " reporter: forgeReporter,",
175
+ " testIgnore: forgeTestIgnore,",
92
176
  ` outputDir: ${JSON.stringify(values.testOutputDirectory)},`,
93
177
  ` workers: ${values.workers},`,
94
178
  " webServer: undefined,",
@@ -39,7 +39,16 @@ export declare function evaluateDiskPressure(availableBytes: number, totalBytes:
39
39
  export declare function storagePolicyAvailable(storageQuota: boolean, environment?: NodeJS.ProcessEnv): boolean;
40
40
  export declare class DockerContextOwnershipLock {
41
41
  private lockPath;
42
+ private lockToken;
42
43
  acquire(contextName?: string): Promise<void>;
43
44
  release(): Promise<void>;
44
45
  }
45
46
  export declare function parseDockerMemoryUsage(value: string): number;
47
+ interface DockerContextLockOwner {
48
+ readonly token?: string;
49
+ readonly pid?: number;
50
+ readonly processIdentity?: string | null;
51
+ readonly acquiredAt?: string;
52
+ }
53
+ export declare function dockerContextOwnerIsActive(owner: DockerContextLockOwner | null): boolean;
54
+ export {};
@@ -1,7 +1,8 @@
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 { captureHostProcessIdentity } from "../shared/process/host-process-supervisor.js";
5
6
  import { BoundedCommandRunner } from "./command-runner.js";
6
7
  const minimumWorkspaceBytes = 10 * 1024 * 1024 * 1024;
7
8
  const defaultHostMemoryReserveBytes = 2 * 1024 ** 3;
@@ -189,34 +190,48 @@ export function storagePolicyAvailable(storageQuota, environment = process.env)
189
190
  }
190
191
  export class DockerContextOwnershipLock {
191
192
  lockPath = null;
193
+ lockToken = null;
192
194
  async acquire(contextName) {
193
195
  if (this.lockPath)
194
196
  return;
195
197
  contextName = contextName?.trim() || process.env.DOCKER_CONTEXT?.trim() || process.env.DOCKER_HOST?.trim() || "default";
196
198
  const key = createHash("sha256").update(contextName).digest("hex").slice(0, 24);
197
199
  const lockPath = path.join(os.tmpdir(), `forge-docker-context-${key}.lock`);
200
+ const token = randomUUID();
198
201
  await fs.mkdir(path.dirname(lockPath), { recursive: true });
199
202
  try {
200
203
  const handle = await fs.open(lockPath, "wx", 0o600);
201
- await handle.writeFile(JSON.stringify({ pid: process.pid, contextName, acquiredAt: new Date().toISOString() }));
204
+ await handle.writeFile(JSON.stringify({
205
+ schemaVersion: 2,
206
+ token,
207
+ pid: process.pid,
208
+ processIdentity: captureHostProcessIdentity(process.pid),
209
+ contextName,
210
+ acquiredAt: new Date().toISOString(),
211
+ }));
202
212
  await handle.sync();
203
213
  await handle.close();
204
214
  }
205
215
  catch (error) {
206
216
  const owner = await readLock(lockPath);
207
- if (owner?.pid && !processExists(owner.pid)) {
217
+ if (!dockerContextOwnerIsActive(owner)) {
208
218
  await fs.rm(lockPath, { force: true });
209
219
  return await this.acquire(contextName);
210
220
  }
211
221
  throw new Error(`docker_context_owned: Docker context '${contextName}' is already owned by Forge runner pid ${owner?.pid ?? "unknown"}.`, { cause: error });
212
222
  }
213
223
  this.lockPath = lockPath;
224
+ this.lockToken = token;
214
225
  }
215
226
  async release() {
216
227
  if (!this.lockPath)
217
228
  return;
218
- await fs.rm(this.lockPath, { force: true });
229
+ const owner = await readLock(this.lockPath);
230
+ if (owner?.token === this.lockToken) {
231
+ await fs.rm(this.lockPath, { force: true });
232
+ }
219
233
  this.lockPath = null;
234
+ this.lockToken = null;
220
235
  }
221
236
  }
222
237
  function parseInfo(stdout) {
@@ -289,6 +304,26 @@ export function parseDockerMemoryUsage(value) {
289
304
  async function readLock(lockPath) {
290
305
  return await fs.readFile(lockPath, "utf8").then((value) => JSON.parse(value)).catch(() => null);
291
306
  }
307
+ export function dockerContextOwnerIsActive(owner) {
308
+ if (!owner || !Number.isSafeInteger(owner.pid) || (owner.pid ?? 0) <= 0 || !processExists(owner.pid)) {
309
+ return false;
310
+ }
311
+ const liveIdentity = captureHostProcessIdentity(owner.pid);
312
+ if (owner.processIdentity) {
313
+ // A live but unverifiable process must remain protected. Windows and Linux
314
+ // normally provide a birth marker, so a mismatch is positive PID-reuse proof.
315
+ return !liveIdentity || liveIdentity === owner.processIdentity;
316
+ }
317
+ if (process.platform === "win32" && liveIdentity?.startsWith("win32:") && owner.acquiredAt) {
318
+ const ticks = Number(liveIdentity.slice("win32:".length));
319
+ const acquiredAt = Date.parse(owner.acquiredAt);
320
+ const processStartedAt = ticks / 10_000 - 62_135_596_800_000;
321
+ if (Number.isFinite(processStartedAt) && Number.isFinite(acquiredAt) && processStartedAt > acquiredAt) {
322
+ return false;
323
+ }
324
+ }
325
+ return true;
326
+ }
292
327
  function processExists(pid) {
293
328
  try {
294
329
  process.kill(pid, 0);
@@ -38,8 +38,13 @@ export class SandboxWorkspaceService {
38
38
  await this.git.run(this.workspace, ["config", "core.hooksPath", "/dev/null"]);
39
39
  await clearRunArtifactRoots(this.workspace, request);
40
40
  const artifacts = filterArtifactsForLane(request.artifacts, request.lane, request.phase);
41
- if (artifacts.length > 0)
42
- await injectWorktreeArtifacts(this.workspace, artifacts);
41
+ if (artifacts.length > 0) {
42
+ const trackedForgePaths = new Set((await this.git.run(this.workspace, ["ls-files", "-z", "--", ".forge"]))
43
+ .split("\0")
44
+ .filter(Boolean)
45
+ .map(value => value.replaceAll("\\", "/")));
46
+ await injectWorktreeArtifacts(this.workspace, artifacts, trackedForgePaths);
47
+ }
43
48
  const hydratedPriorPhaseArtifactCount = await hydratePriorPhaseArtifacts(this.workspace, request.priorPhaseArtifacts);
44
49
  return {
45
50
  repoPath: this.workspace,
@@ -0,0 +1,17 @@
1
+ export declare const managedAttemptToolNames: readonly ["forge.run_managed_command", "run_managed_command", "forge.start_managed_command", "start_managed_command", "forge.get_managed_command", "get_managed_command", "forge.wait_managed_command", "wait_managed_command", "forge.start_managed_verification", "start_managed_verification", "forge.reset_generated_outputs", "reset_generated_outputs"];
2
+ export declare function isManagedAttemptTool(name: string): boolean;
3
+ export declare function managedAttemptToolDefinition(name: string): unknown;
4
+ export interface ManagedAttemptToolResult {
5
+ readonly content: readonly {
6
+ readonly type: "text";
7
+ readonly text: string;
8
+ }[];
9
+ readonly structuredContent: Readonly<Record<string, unknown>>;
10
+ readonly isError?: boolean;
11
+ }
12
+ export declare function callManagedAttemptTool(name: string, input: unknown, worktreePath: string): Promise<ManagedAttemptToolResult>;
13
+ export declare function buildManagedVerificationCommand(profile: string, outputDirectory: string, shardCount: number, shardIndex: number, noBuild: boolean): {
14
+ command: string;
15
+ args: readonly string[];
16
+ timeoutMs: number;
17
+ };