@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,64 +1,90 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import fs from "node:fs/promises";
2
3
  import path from "node:path";
4
+ import { RepositoryCoordinatorLock } from "../sandbox/repository-lock.js";
3
5
  import { GitClient } from "../shared/git/git.js";
4
6
  import { ensureDirectory, isSubPath, resolveExistingDirectoryPath, safeSegment } from "../shared/path/path.js";
5
7
  import { filterArtifactsForLane, injectWorktreeArtifacts } from "./artifact-injector.js";
8
+ import { hydratePriorPhaseArtifacts } from "./prior-phase-artifact-hydrator.js";
6
9
  import { warmWorktreeDependencies } from "./dependency-warmer.js";
10
+ import { defaultWorktreeCoordinatorStateRoot, managedRepoRoot, resolveManagedWorktreeRoot, } from "./worktree-paths.js";
7
11
  export class GitWorktreeService {
8
12
  git;
9
- constructor(git = new GitClient()) {
13
+ coordinator;
14
+ constructor(git = new GitClient(), coordinator = new RepositoryCoordinatorLock(defaultWorktreeCoordinatorStateRoot())) {
10
15
  this.git = git;
16
+ this.coordinator = coordinator;
11
17
  }
12
18
  async prepare(request) {
13
19
  const allowedRoot = await ensureDirectory(request.allowedRepoRoot);
14
20
  const baseBranch = normalizeBranchName(request.baseBranch, "develop");
15
- const repoPath = await this.resolveRepoPath(request.repo, allowedRoot, baseBranch);
16
- await this.ensureGitIdentity(repoPath);
17
- const baseStartPoint = await this.resolveBaseStartPoint(repoPath, baseBranch);
18
21
  const shortId = request.workItem.id.replace(/-/g, "").slice(0, 12);
19
22
  const kind = safeSegment(request.workItem.kind, "work-item");
20
23
  const fallbackWorktreeKey = `${kind}/${shortId}`;
21
24
  const worktreeKey = request.worktreeKey?.trim() || fallbackWorktreeKey;
22
25
  const worktreeSegments = worktreeKeySegments(worktreeKey, kind, shortId);
23
26
  const branchName = normalizeBranchName(worktreeSegments.join("/"), "work-item/unknown");
24
- const worktreeRoot = resolveWorktreeRoot(allowedRoot, repoPath);
25
- const worktreePath = path.join(worktreeRoot, ...worktreeSegments);
26
- const resolvedWorktreeParent = await ensureDirectory(path.dirname(worktreePath));
27
- if (!isSubPath(allowedRoot, resolvedWorktreeParent)) {
28
- throw new Error(`Worktree path must stay under ${allowedRoot}.`);
29
- }
30
- const exists = await directoryExists(worktreePath);
31
- let resolvedWorktreePath;
32
- if (!exists) {
33
- await this.git.run(repoPath, ["worktree", "add", "-B", branchName, worktreePath, baseStartPoint]);
34
- resolvedWorktreePath = await resolveExistingDirectoryPath(worktreePath);
35
- }
36
- else {
37
- resolvedWorktreePath = await resolveExistingDirectoryPath(worktreePath);
38
- if (!isSubPath(allowedRoot, resolvedWorktreePath)) {
27
+ const creationCoordinationPath = this.repoCreationCoordinationPath(request.repo, allowedRoot);
28
+ const repoPath = await this.coordinator.withLock(creationCoordinationPath, async () => {
29
+ return await this.resolveRepoPath(request.repo, allowedRoot, baseBranch);
30
+ });
31
+ const resolvedWorktreePath = await this.coordinator.withLock(repoPath, async () => {
32
+ await this.ensureGitIdentity(repoPath);
33
+ const baseStartPoint = await this.resolveBaseStartPoint(repoPath, baseBranch);
34
+ const worktreeRoot = resolveManagedWorktreeRoot(allowedRoot, repoPath);
35
+ const worktreePath = path.join(worktreeRoot, ...worktreeSegments);
36
+ const resolvedWorktreeParent = await ensureDirectory(path.dirname(worktreePath));
37
+ if (!isSubPath(allowedRoot, resolvedWorktreeParent)) {
38
+ throw new Error(`Worktree path must stay under ${allowedRoot}.`);
39
+ }
40
+ if (!(await directoryExists(worktreePath))) {
41
+ // A prior runner can disappear after Git registers its worktree but before
42
+ // normal cleanup completes. Release that missing-directory registration
43
+ // immediately so retrying the same lease remains idempotent.
44
+ await this.git.run(repoPath, ["worktree", "prune", "--expire", "now"]);
45
+ await this.git.run(repoPath, ["worktree", "add", "-B", branchName, worktreePath, baseStartPoint]);
46
+ return await resolveExistingDirectoryPath(worktreePath);
47
+ }
48
+ const existingWorktreePath = await resolveExistingDirectoryPath(worktreePath);
49
+ if (!isSubPath(allowedRoot, existingWorktreePath)) {
39
50
  throw new Error(`Worktree path must stay under ${allowedRoot}.`);
40
51
  }
41
- const currentBranch = await this.currentBranch(resolvedWorktreePath);
52
+ if (!(await gitMetadataExists(existingWorktreePath, allowedRoot))) {
53
+ await this.restoreOrphanedWorktree({
54
+ repoPath,
55
+ worktreePath: existingWorktreePath,
56
+ branchName,
57
+ baseStartPoint,
58
+ allowedRoot,
59
+ });
60
+ }
61
+ const currentBranch = await this.currentBranch(existingWorktreePath);
42
62
  if (currentBranch !== branchName) {
43
- throw new Error(`Existing worktree ${resolvedWorktreePath} is on branch '${currentBranch}', expected '${branchName}'.`);
63
+ throw new Error(`Existing worktree ${existingWorktreePath} is on branch '${currentBranch}', expected '${branchName}'.`);
44
64
  }
45
- if (shouldResetExistingWorktree(request)) {
46
- await this.resetWorktreeToBase(resolvedWorktreePath, baseStartPoint);
65
+ if (!request.preserveExistingState && shouldResetExistingWorktree(request)) {
66
+ await this.resetWorktreeToBase(existingWorktreePath, baseStartPoint);
47
67
  }
48
- }
68
+ return existingWorktreePath;
69
+ });
49
70
  await this.ensureGitIdentity(resolvedWorktreePath);
50
- await clearRunArtifactRoots(resolvedWorktreePath, request);
71
+ if (!request.preserveExistingState) {
72
+ await clearRunArtifactRoots(resolvedWorktreePath, request);
73
+ }
74
+ const hydratedPriorPhaseArtifactCount = await hydratePriorPhaseArtifacts(resolvedWorktreePath, request.priorPhaseArtifacts);
51
75
  const qaReworkPacketPath = await materializeQaReworkPacket(resolvedWorktreePath, request);
52
76
  const artifacts = filterArtifactsForLane(request.artifacts, request.lane, request.phase);
53
77
  if (artifacts.length > 0) {
54
- await injectWorktreeArtifacts(resolvedWorktreePath, artifacts);
78
+ await injectWorktreeArtifacts(resolvedWorktreePath, artifacts, await this.trackedForgePaths(resolvedWorktreePath));
55
79
  }
56
- const dependencyWarm = await warmWorktreeDependencies({
57
- baseCheckoutPath: repoPath,
58
- worktreePath: resolvedWorktreePath,
59
- allowedRoot,
60
- warmPathOverrides: request.warmPathOverrides
61
- });
80
+ const dependencyWarm = requiresMutableEnvironmentPreparation(request)
81
+ ? await warmWorktreeDependencies({
82
+ baseCheckoutPath: repoPath,
83
+ worktreePath: resolvedWorktreePath,
84
+ allowedRoot,
85
+ warmPathOverrides: request.warmPathOverrides
86
+ })
87
+ : undefined;
62
88
  return {
63
89
  repoPath,
64
90
  worktreePath: resolvedWorktreePath,
@@ -66,19 +92,75 @@ export class GitWorktreeService {
66
92
  baseBranch,
67
93
  cleanupWorktrees: request.cleanupWorktrees,
68
94
  dependencyWarm,
69
- qaReworkPacketPath
95
+ qaReworkPacketPath,
96
+ hydratedPriorPhaseArtifactCount
70
97
  };
71
98
  }
99
+ async trackedForgePaths(worktreePath) {
100
+ const output = await this.git.run(worktreePath, ["ls-files", "-z", "--", ".forge"]);
101
+ return new Set(output.split("\0").filter(Boolean).map(value => value.replaceAll("\\", "/")));
102
+ }
103
+ repoCreationCoordinationPath(repo, allowedRoot) {
104
+ if (repo.serverLocalPath && repo.serverLocalPath.trim().length > 0) {
105
+ return path.resolve(repo.serverLocalPath);
106
+ }
107
+ return this.managedRepoPath(repo, allowedRoot);
108
+ }
72
109
  async release(handle) {
73
- if (!handle.cleanupWorktrees || !(await directoryExists(handle.worktreePath))) {
110
+ if (!handle.cleanupWorktrees) {
74
111
  return;
75
112
  }
113
+ await this.coordinator.withLock(handle.repoPath, async () => {
114
+ if (await directoryExists(handle.worktreePath)) {
115
+ // On Windows, `git worktree remove` can delete the `.git` link and then
116
+ // fail on a locked build output. That leaves a full source tree which is
117
+ // no longer a Git worktree and makes every retry fail during prepare.
118
+ // Move the tree out of its stable lease path first. If the rename fails,
119
+ // leave the valid worktree untouched rather than partially unregistering it.
120
+ const releasePath = `${handle.worktreePath}.forge-release-${randomUUID()}`;
121
+ await renameWorktreeForRelease(handle.worktreePath, releasePath);
122
+ await this.git.run(handle.repoPath, ["worktree", "prune", "--expire", "now"]);
123
+ await fs.rm(releasePath, {
124
+ recursive: true,
125
+ force: true,
126
+ maxRetries: 10,
127
+ retryDelay: 100,
128
+ }).catch(() => undefined);
129
+ }
130
+ // The process can disappear after Git registers a worktree but before the
131
+ // directory is fully materialized (or after an external cleanup removes it).
132
+ // Always prune so those missing-directory registrations do not accumulate.
133
+ await this.git.run(handle.repoPath, ["worktree", "prune", "--expire", "now"]).catch(() => undefined);
134
+ });
135
+ }
136
+ async restoreOrphanedWorktree(input) {
137
+ const orphanStat = await fs.lstat(input.worktreePath);
138
+ if (orphanStat.isSymbolicLink() || !orphanStat.isDirectory()) {
139
+ throw new Error(`Refusing to recover redirected or non-directory worktree ${input.worktreePath}.`);
140
+ }
141
+ const quarantinePath = `${input.worktreePath}.forge-orphan-${randomUUID()}`;
142
+ if (!isSubPath(input.allowedRoot, quarantinePath)) {
143
+ throw new Error(`Worktree recovery path must stay under ${input.allowedRoot}.`);
144
+ }
145
+ await fs.rename(input.worktreePath, quarantinePath);
76
146
  try {
77
- await this.git.run(handle.repoPath, ["worktree", "remove", "--force", handle.worktreePath]);
78
- await this.git.run(handle.repoPath, ["worktree", "prune"]);
147
+ await this.git.run(input.repoPath, ["worktree", "prune", "--expire", "now"]);
148
+ await this.git.run(input.repoPath, [
149
+ "worktree",
150
+ "add",
151
+ "-B",
152
+ input.branchName,
153
+ input.worktreePath,
154
+ input.baseStartPoint,
155
+ ]);
156
+ await overlayOrphanedWorktree(input.worktreePath, quarantinePath);
157
+ await fs.rm(quarantinePath, { recursive: true, force: true });
79
158
  }
80
- catch {
81
- await fs.rm(handle.worktreePath, { recursive: true, force: true });
159
+ catch (error) {
160
+ await this.git.run(input.repoPath, ["worktree", "remove", "--force", input.worktreePath]).catch(() => undefined);
161
+ await fs.rm(input.worktreePath, { recursive: true, force: true }).catch(() => undefined);
162
+ await fs.rename(quarantinePath, input.worktreePath).catch(() => undefined);
163
+ throw new Error(`Failed to recover orphaned worktree ${input.worktreePath}.`, { cause: error });
82
164
  }
83
165
  }
84
166
  async resolveRepoPath(repo, allowedRoot, baseBranch) {
@@ -96,7 +178,7 @@ export class GitWorktreeService {
96
178
  if (discovered) {
97
179
  return discovered;
98
180
  }
99
- const repoPath = path.join(managedRepoRoot(allowedRoot), `${safeSegment(repo.name, "repo")}-${repo.id.replace(/-/g, "")}`);
181
+ const repoPath = this.managedRepoPath(repo, allowedRoot);
100
182
  if (!(await gitMetadataExists(repoPath, allowedRoot))) {
101
183
  const repoPathExists = await pathExists(repoPath);
102
184
  if (repoPathExists) {
@@ -127,6 +209,9 @@ export class GitWorktreeService {
127
209
  }
128
210
  return resolved;
129
211
  }
212
+ managedRepoPath(repo, allowedRoot) {
213
+ return path.join(managedRepoRoot(allowedRoot), `${safeSegment(repo.name, "repo")}-${repo.id.replace(/-/g, "")}`);
214
+ }
130
215
  async findExistingRepoPath(repo, allowedRoot) {
131
216
  const expectedRemote = normalizeRemote(repo.remoteUrl);
132
217
  const rawName = repo.name.trim();
@@ -168,6 +253,13 @@ export class GitWorktreeService {
168
253
  return isSubPath(allowedRoot, resolved) ? resolved : null;
169
254
  }
170
255
  async resolveBaseStartPoint(repoPath, baseBranch) {
256
+ if (baseBranch === "HEAD") {
257
+ const head = (await this.git.run(repoPath, ["rev-parse", "--verify", "HEAD^{commit}"])).trim();
258
+ if (!/^[0-9a-f]{40,64}$/iu.test(head)) {
259
+ throw new Error("The repository checkout does not have a valid committed HEAD revision.");
260
+ }
261
+ return head;
262
+ }
171
263
  const localRef = `refs/heads/${baseBranch}`;
172
264
  const remoteRef = `refs/remotes/origin/${baseBranch}`;
173
265
  const origin = await this.git.run(repoPath, ["remote", "get-url", "origin"]).catch(() => "");
@@ -199,14 +291,31 @@ export class GitWorktreeService {
199
291
  await this.git.run(repoPath, ["config", "user.name", "Forge Bridge"]);
200
292
  }
201
293
  }
202
- function managedRepoRoot(allowedRoot) {
203
- return path.join(allowedRoot, ".forge-worktrees", "_repos");
294
+ export async function renameWorktreeForRelease(source, destination, rename = fs.rename, wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)), maxAttempts = 8) {
295
+ const attempts = Number.isFinite(maxAttempts) && maxAttempts > 0
296
+ ? Math.max(1, Math.floor(maxAttempts))
297
+ : 1;
298
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
299
+ try {
300
+ await rename(source, destination);
301
+ return;
302
+ }
303
+ catch (error) {
304
+ if (!isTransientWorktreeRenameError(error) || attempt === attempts)
305
+ throw error;
306
+ await wait(Math.min(1_000, 100 * (2 ** (attempt - 1))));
307
+ }
308
+ }
204
309
  }
205
- function resolveWorktreeRoot(allowedRoot, repoPath) {
206
- const baseName = path.basename(repoPath);
207
- return isSubPath(managedRepoRoot(allowedRoot), repoPath)
208
- ? path.join(allowedRoot, ".forge-worktrees", baseName)
209
- : path.join(path.dirname(repoPath), ".forge-worktrees", baseName);
310
+ function isTransientWorktreeRenameError(error) {
311
+ if (!error || typeof error !== "object" || !("code" in error))
312
+ return false;
313
+ const code = String(error.code ?? "").toUpperCase();
314
+ return code === "EPERM" || code === "EACCES" || code === "EBUSY" || code === "ENOTEMPTY";
315
+ }
316
+ export function requiresMutableEnvironmentPreparation(request) {
317
+ // Missing runtime intent is treated conservatively for older dispatch packets.
318
+ return request.applicationRuntimeRequired !== false;
210
319
  }
211
320
  function isCloneableRemote(remoteUrl) {
212
321
  const trimmed = remoteUrl?.trim() ?? "";
@@ -215,9 +324,12 @@ function isCloneableRemote(remoteUrl) {
215
324
  }
216
325
  return trimmed.startsWith("git@") || trimmed.includes("://") || path.isAbsolute(trimmed);
217
326
  }
218
- async function clearRunArtifactRoots(worktreePath, request) {
327
+ export async function clearRunArtifactRoots(worktreePath, request) {
219
328
  const roots = new Set();
220
329
  roots.add(workItemArtifactRoot(worktreePath, request.workItem.id));
330
+ for (const artifact of request.priorPhaseArtifacts ?? []) {
331
+ roots.add(workItemArtifactRoot(worktreePath, artifact.workItemId));
332
+ }
221
333
  const ownerType = normalizeOwnerType(request.ownerType);
222
334
  const ownerId = request.ownerId?.trim();
223
335
  if (ownerType && ownerId) {
@@ -289,6 +401,85 @@ function shouldResetExistingWorktree(request) {
289
401
  }
290
402
  return true;
291
403
  }
404
+ async function overlayOrphanedWorktree(worktreePath, orphanPath) {
405
+ // A failed Windows cleanup can remove only part of a worktree before its
406
+ // `.git` link disappears. Start from Git's clean checkout so missing tracked
407
+ // files are restored, then overlay every surviving agent file. Moving entries
408
+ // on the same volume preserves pnpm junctions without requiring symlink
409
+ // privileges, unlike fs.cp.
410
+ const backupRoot = `${worktreePath}.forge-base-${randomUUID()}`;
411
+ const moves = [];
412
+ try {
413
+ await fs.mkdir(backupRoot, { recursive: true });
414
+ const orphanEntries = await fs.readdir(orphanPath, { withFileTypes: true });
415
+ for (const entry of orphanEntries) {
416
+ if (entry.name === ".git") {
417
+ continue;
418
+ }
419
+ await overlayEntry({
420
+ sourcePath: path.join(orphanPath, entry.name),
421
+ destinationPath: path.join(worktreePath, entry.name),
422
+ backupPath: path.join(backupRoot, entry.name),
423
+ moves,
424
+ });
425
+ }
426
+ await fs.rm(backupRoot, { recursive: true, force: true });
427
+ }
428
+ catch (error) {
429
+ for (const move of moves.reverse()) {
430
+ await fs.mkdir(path.dirname(move.sourcePath), { recursive: true }).catch(() => undefined);
431
+ await fs.rename(move.destinationPath, move.sourcePath).catch(() => undefined);
432
+ if (move.backupPath) {
433
+ await fs.mkdir(path.dirname(move.destinationPath), { recursive: true }).catch(() => undefined);
434
+ await fs.rename(move.backupPath, move.destinationPath).catch(() => undefined);
435
+ }
436
+ }
437
+ await fs.rm(backupRoot, { recursive: true, force: true }).catch(() => undefined);
438
+ throw error;
439
+ }
440
+ }
441
+ async function overlayEntry(input) {
442
+ const sourceStat = await fs.lstat(input.sourcePath);
443
+ const destinationStat = await fs.lstat(input.destinationPath).catch(() => null);
444
+ if (sourceStat.isDirectory() &&
445
+ !sourceStat.isSymbolicLink() &&
446
+ destinationStat?.isDirectory() &&
447
+ !destinationStat.isSymbolicLink()) {
448
+ const entries = await fs.readdir(input.sourcePath, { withFileTypes: true });
449
+ for (const entry of entries) {
450
+ await overlayEntry({
451
+ sourcePath: path.join(input.sourcePath, entry.name),
452
+ destinationPath: path.join(input.destinationPath, entry.name),
453
+ backupPath: path.join(input.backupPath, entry.name),
454
+ moves: input.moves,
455
+ });
456
+ }
457
+ return;
458
+ }
459
+ let preservedBasePath;
460
+ if (destinationStat) {
461
+ await fs.mkdir(path.dirname(input.backupPath), { recursive: true });
462
+ await fs.rename(input.destinationPath, input.backupPath);
463
+ preservedBasePath = input.backupPath;
464
+ }
465
+ else {
466
+ await fs.mkdir(path.dirname(input.destinationPath), { recursive: true });
467
+ }
468
+ try {
469
+ await fs.rename(input.sourcePath, input.destinationPath);
470
+ }
471
+ catch (error) {
472
+ if (preservedBasePath) {
473
+ await fs.rename(preservedBasePath, input.destinationPath).catch(() => undefined);
474
+ }
475
+ throw error;
476
+ }
477
+ input.moves.push({
478
+ sourcePath: input.sourcePath,
479
+ destinationPath: input.destinationPath,
480
+ backupPath: preservedBasePath,
481
+ });
482
+ }
292
483
  function qaReworkPacketFileName(packetPath) {
293
484
  const normalized = packetPath.trim().replace(/\\/g, "/");
294
485
  const fileName = normalized.split("/").filter(Boolean).at(-1) ?? "";
@@ -1,4 +1,4 @@
1
- import type { DispatchQaReworkContext, DispatchRepo, DispatchWorkItem, DispatchResolvedArtifact } from "../session/session.types.js";
1
+ import type { DispatchQaReworkContext, DispatchPriorPhaseArtifact, DispatchRepo, DispatchWorkItem, DispatchResolvedArtifact } from "../session/session.types.js";
2
2
  import type { WarmDependenciesResult } from "./dependency-warmer.js";
3
3
  export interface WorktreePrepareRequest {
4
4
  readonly repo: DispatchRepo;
@@ -12,8 +12,11 @@ export interface WorktreePrepareRequest {
12
12
  readonly allowedRepoRoot: string;
13
13
  readonly cleanupWorktrees: boolean;
14
14
  readonly artifacts: readonly DispatchResolvedArtifact[];
15
+ readonly priorPhaseArtifacts?: readonly DispatchPriorPhaseArtifact[];
15
16
  readonly qaRework?: DispatchQaReworkContext | null;
16
17
  readonly warmPathOverrides?: readonly string[];
18
+ readonly applicationRuntimeRequired?: boolean;
19
+ readonly preserveExistingState?: boolean;
17
20
  }
18
21
  export interface WorktreeHandle {
19
22
  readonly repoPath: string;
@@ -23,4 +26,5 @@ export interface WorktreeHandle {
23
26
  readonly cleanupWorktrees: boolean;
24
27
  readonly dependencyWarm?: WarmDependenciesResult;
25
28
  readonly qaReworkPacketPath?: string;
29
+ readonly hydratedPriorPhaseArtifactCount?: number;
26
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loucompanion/forge-bridge",
3
- "version": "0.1.3-dev.9d8f2e7e5241",
3
+ "version": "0.1.3-dev.a58d7cd1092f",
4
4
  "description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  },
20
20
  "files": [
21
21
  "dist",
22
+ "runtime",
22
23
  "README.md"
23
24
  ],
24
25
  "repository": {
@@ -38,10 +39,11 @@
38
39
  },
39
40
  "scripts": {
40
41
  "build": "tsc -p tsconfig.json",
41
- "test": "vitest run",
42
+ "test": "node scripts/run-owned-tests.mjs",
43
+ "test:residue": "node scripts/run-owned-residue-gate.mjs",
42
44
  "test:watch": "vitest",
43
45
  "smoke:package": "npm run build && node scripts/smoke-package.mjs",
44
- "lint": "eslint \"src/**/*.ts\" \"scripts/**/*.mjs\" \"vitest.config.ts\""
46
+ "lint": "eslint \"src/**/*.ts\" \"scripts/**/*.mjs\" \"vitest.config.ts\" \"vitest.setup.ts\""
45
47
  },
46
48
  "dependencies": {
47
49
  "@microsoft/signalr": "^8.0.17",