@loucompanion/forge-bridge 0.1.3-dev.f66395c3ac21 → 0.1.4-dev.04a1e5b9e484

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 (223) hide show
  1. package/README.md +165 -1
  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 +9 -1
  5. package/dist/bridge/bridge.service.d.ts +129 -3
  6. package/dist/bridge/bridge.service.js +2427 -70
  7. package/dist/bridge/bridge.types.d.ts +218 -2
  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 +14 -2
  21. package/dist/cleanup/cleanup.service.js +496 -3
  22. package/dist/cleanup/cleanup.types.d.ts +10 -0
  23. package/dist/cleanup/index.d.ts +1 -1
  24. package/dist/cleanup/index.js +1 -1
  25. package/dist/cleanup/internals.d.ts +2 -2
  26. package/dist/cleanup/internals.js +1 -1
  27. package/dist/cli/cli.help.d.ts +2 -0
  28. package/dist/cli/cli.help.js +217 -0
  29. package/dist/cli/cli.service.d.ts +55 -7
  30. package/dist/cli/cli.service.js +429 -219
  31. package/dist/cli/local-config.d.ts +7 -0
  32. package/dist/cli/local-config.js +51 -3
  33. package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
  34. package/dist/runtime/aspnet-runtime-prebuild.js +341 -0
  35. package/dist/runtime/attempt-database-broker.d.ts +89 -0
  36. package/dist/runtime/attempt-database-broker.js +390 -0
  37. package/dist/runtime/attempt-node-runtime-launcher.d.ts +18 -0
  38. package/dist/runtime/attempt-node-runtime-launcher.js +140 -0
  39. package/dist/runtime/attempt-path-operation.d.ts +21 -0
  40. package/dist/runtime/attempt-path-operation.js +94 -0
  41. package/dist/runtime/attempt-private-directory.d.ts +6 -0
  42. package/dist/runtime/attempt-private-directory.js +54 -0
  43. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  44. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  45. package/dist/runtime/attempt-process-supervisor.d.ts +102 -0
  46. package/dist/runtime/attempt-process-supervisor.js +421 -0
  47. package/dist/runtime/canonical-source-file.d.ts +3 -0
  48. package/dist/runtime/canonical-source-file.js +54 -0
  49. package/dist/runtime/docker-engine-probe.d.ts +90 -0
  50. package/dist/runtime/docker-engine-probe.js +333 -0
  51. package/dist/runtime/generated-output-reset.d.ts +20 -0
  52. package/dist/runtime/generated-output-reset.js +157 -0
  53. package/dist/runtime/host-database-capability-boundary.d.ts +16 -0
  54. package/dist/runtime/host-database-capability-boundary.js +62 -0
  55. package/dist/runtime/host-diagnostics.d.ts +11 -0
  56. package/dist/runtime/host-diagnostics.js +192 -0
  57. package/dist/runtime/host-execution-root.d.ts +4 -0
  58. package/dist/runtime/host-execution-root.js +39 -0
  59. package/dist/runtime/host-listener-inspector.d.ts +51 -0
  60. package/dist/runtime/host-listener-inspector.js +385 -0
  61. package/dist/runtime/host-port-broker.d.ts +76 -0
  62. package/dist/runtime/host-port-broker.js +333 -0
  63. package/dist/runtime/host-runtime-contract.d.ts +58 -0
  64. package/dist/runtime/host-runtime-contract.js +813 -0
  65. package/dist/runtime/host-runtime-lease.d.ts +217 -0
  66. package/dist/runtime/host-runtime-lease.js +2487 -0
  67. package/dist/runtime/host-runtime-promotion.d.ts +32 -0
  68. package/dist/runtime/host-runtime-promotion.js +206 -0
  69. package/dist/runtime/memory-capacity.d.ts +70 -0
  70. package/dist/runtime/memory-capacity.js +123 -0
  71. package/dist/runtime/native-test-postgres-fallback.d.ts +25 -0
  72. package/dist/runtime/native-test-postgres-fallback.js +91 -0
  73. package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
  74. package/dist/runtime/node-host-runtime-adapter.js +286 -0
  75. package/dist/runtime/pinned-directory-process.d.ts +9 -0
  76. package/dist/runtime/pinned-directory-process.js +61 -0
  77. package/dist/runtime/pinned-source-directory.d.ts +17 -0
  78. package/dist/runtime/pinned-source-directory.js +94 -0
  79. package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
  80. package/dist/runtime/playwright-host-runtime-adapter.js +268 -0
  81. package/dist/runtime/prepared-runtime-identity.d.ts +6 -0
  82. package/dist/runtime/prepared-runtime-identity.js +218 -0
  83. package/dist/runtime/prepared-runtime-readiness.d.ts +30 -0
  84. package/dist/runtime/prepared-runtime-readiness.js +285 -0
  85. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  86. package/dist/runtime/runtime-isolation-lease.js +161 -0
  87. package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
  88. package/dist/runtime/workspace-resource-estimator.js +174 -0
  89. package/dist/sandbox/command-runner.d.ts +4 -0
  90. package/dist/sandbox/command-runner.js +47 -0
  91. package/dist/sandbox/contract.d.ts +5 -0
  92. package/dist/sandbox/contract.js +276 -0
  93. package/dist/sandbox/event-budget.d.ts +11 -0
  94. package/dist/sandbox/event-budget.js +27 -0
  95. package/dist/sandbox/image-policy.d.ts +35 -0
  96. package/dist/sandbox/image-policy.js +247 -0
  97. package/dist/sandbox/index.d.ts +5 -0
  98. package/dist/sandbox/index.js +5 -0
  99. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  100. package/dist/sandbox/lease-watchdog.js +67 -0
  101. package/dist/sandbox/preflight.d.ts +84 -0
  102. package/dist/sandbox/preflight.js +521 -0
  103. package/dist/sandbox/process.d.ts +20 -0
  104. package/dist/sandbox/process.js +209 -0
  105. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  106. package/dist/sandbox/provider-auth/codex.js +212 -0
  107. package/dist/sandbox/repository-lock.d.ts +6 -0
  108. package/dist/sandbox/repository-lock.js +129 -0
  109. package/dist/sandbox/runtime.d.ts +302 -0
  110. package/dist/sandbox/runtime.js +2368 -0
  111. package/dist/sandbox/sandbox.types.d.ts +93 -0
  112. package/dist/sandbox/sandbox.types.js +1 -0
  113. package/dist/sandbox/smoke.d.ts +76 -0
  114. package/dist/sandbox/smoke.js +380 -0
  115. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  116. package/dist/sandbox/worker-supervisor.js +36 -0
  117. package/dist/sandbox/worker.d.ts +1 -0
  118. package/dist/sandbox/worker.js +225 -0
  119. package/dist/sandbox/workspace.service.d.ts +13 -0
  120. package/dist/sandbox/workspace.service.js +74 -0
  121. package/dist/session/managed-attempt-tools.d.ts +28 -0
  122. package/dist/session/managed-attempt-tools.js +1016 -0
  123. package/dist/session/managed-build-coordinator.d.ts +5 -0
  124. package/dist/session/managed-build-coordinator.js +106 -0
  125. package/dist/session/managed-build-receipt.d.ts +21 -0
  126. package/dist/session/managed-build-receipt.js +337 -0
  127. package/dist/session/managed-verification-timeouts.d.ts +8 -0
  128. package/dist/session/managed-verification-timeouts.js +17 -0
  129. package/dist/session/phase-completion.artifacts.d.ts +27 -0
  130. package/dist/session/phase-completion.artifacts.js +829 -0
  131. package/dist/session/phase-completion.d.ts +53 -42
  132. package/dist/session/phase-completion.helpers.d.ts +117 -0
  133. package/dist/session/phase-completion.helpers.js +707 -0
  134. package/dist/session/phase-completion.js +125 -3007
  135. package/dist/session/phase-completion.prompts.d.ts +14 -0
  136. package/dist/session/phase-completion.prompts.js +233 -0
  137. package/dist/session/phase-completion.runtime.d.ts +49 -0
  138. package/dist/session/phase-completion.runtime.js +840 -0
  139. package/dist/session/phase-completion.schemas.d.ts +3 -0
  140. package/dist/session/phase-completion.schemas.js +859 -0
  141. package/dist/session/phase-completion.validation.d.ts +38 -0
  142. package/dist/session/phase-completion.validation.js +918 -0
  143. package/dist/session/pre-publication-validation.d.ts +75 -0
  144. package/dist/session/pre-publication-validation.js +661 -0
  145. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.d.ts +72 -0
  146. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.js +462 -0
  147. package/dist/session/provider-cli/ci-repair-readiness.d.ts +16 -0
  148. package/dist/session/provider-cli/ci-repair-readiness.js +110 -0
  149. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +95 -0
  150. package/dist/session/provider-cli/provider-cli.artifacts.js +812 -0
  151. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  152. package/dist/session/provider-cli/provider-cli.deterministic-payloads.d.ts +12 -0
  153. package/dist/session/provider-cli/provider-cli.deterministic-payloads.js +279 -0
  154. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +14 -0
  155. package/dist/session/provider-cli/provider-cli.deterministic.js +407 -0
  156. package/dist/session/provider-cli/provider-cli.process.d.ts +99 -0
  157. package/dist/session/provider-cli/provider-cli.process.js +874 -0
  158. package/dist/session/provider-cli/provider-cli.prompts.d.ts +52 -0
  159. package/dist/session/provider-cli/provider-cli.prompts.js +715 -0
  160. package/dist/session/provider-cli/provider-cli.qa-sidecars.d.ts +11 -0
  161. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +66 -0
  162. package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
  163. package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
  164. package/dist/session/provider-cli/provider-cli.runtime.d.ts +22 -0
  165. package/dist/session/provider-cli/provider-cli.runtime.js +92 -0
  166. package/dist/session/provider-cli/provider-cli.service.d.ts +47 -53
  167. package/dist/session/provider-cli/provider-cli.service.js +1258 -2497
  168. package/dist/session/provider-cli/provider-cli.types.d.ts +8 -0
  169. package/dist/session/provider-cli/provider-output-state.d.ts +32 -0
  170. package/dist/session/provider-cli/provider-output-state.js +817 -0
  171. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  172. package/dist/session/provider-cli/provider-usage.js +28 -5
  173. package/dist/session/session-redaction.d.ts +3 -0
  174. package/dist/session/session-redaction.js +448 -0
  175. package/dist/session/session.base.d.ts +6 -1
  176. package/dist/session/session.service.d.ts +8 -2
  177. package/dist/session/session.service.js +586 -59
  178. package/dist/session/session.types.d.ts +324 -3
  179. package/dist/session/session.types.js +939 -10
  180. package/dist/session/unreferenced-exports.d.ts +12 -0
  181. package/dist/session/unreferenced-exports.js +59 -0
  182. package/dist/shared/format/format-bytes.d.ts +1 -0
  183. package/dist/shared/format/format-bytes.js +9 -0
  184. package/dist/shared/git/git.js +2 -1
  185. package/dist/shared/path/path.d.ts +1 -0
  186. package/dist/shared/path/path.js +21 -1
  187. package/dist/shared/process/dotnet-toolchain.d.ts +17 -0
  188. package/dist/shared/process/dotnet-toolchain.js +307 -0
  189. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  190. package/dist/shared/process/host-process-recovery.js +79 -0
  191. package/dist/shared/process/host-process-supervisor.d.ts +59 -0
  192. package/dist/shared/process/host-process-supervisor.js +331 -0
  193. package/dist/shared/process/process.d.ts +3 -0
  194. package/dist/shared/process/process.js +13 -7
  195. package/dist/shared/process/windows-launch.d.ts +8 -0
  196. package/dist/shared/process/windows-launch.js +41 -0
  197. package/dist/shared/provider-capabilities.d.ts +4 -0
  198. package/dist/shared/provider-capabilities.js +88 -0
  199. package/dist/shared/redaction/redaction.d.ts +1 -0
  200. package/dist/shared/redaction/redaction.js +47 -4
  201. package/dist/shared/time/monotonic.d.ts +1 -0
  202. package/dist/shared/time/monotonic.js +8 -0
  203. package/dist/version.d.ts +1 -1
  204. package/dist/version.js +1 -1
  205. package/dist/worktree/artifact-injector.d.ts +3 -2
  206. package/dist/worktree/artifact-injector.js +82 -10
  207. package/dist/worktree/dependency-warmer.d.ts +53 -1
  208. package/dist/worktree/dependency-warmer.js +619 -11
  209. package/dist/worktree/index.d.ts +2 -1
  210. package/dist/worktree/index.js +1 -0
  211. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  212. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  213. package/dist/worktree/story-qa-reference-hydrator.d.ts +4 -0
  214. package/dist/worktree/story-qa-reference-hydrator.js +253 -0
  215. package/dist/worktree/worktree-paths.d.ts +4 -0
  216. package/dist/worktree/worktree-paths.js +30 -0
  217. package/dist/worktree/worktree.base.d.ts +3 -0
  218. package/dist/worktree/worktree.service.d.ts +16 -3
  219. package/dist/worktree/worktree.service.js +334 -54
  220. package/dist/worktree/worktree.types.d.ts +9 -1
  221. package/package.json +12 -7
  222. package/runtime/windows-job-supervisor.ps1 +722 -0
  223. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -1,61 +1,110 @@
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";
9
+ import { materializeStoryQaReferences } from "./story-qa-reference-hydrator.js";
6
10
  import { warmWorktreeDependencies } from "./dependency-warmer.js";
11
+ import { defaultWorktreeCoordinatorStateRoot, managedRepoRoot, resolveManagedWorktreeRoot, } from "./worktree-paths.js";
7
12
  export class GitWorktreeService {
8
13
  git;
9
- constructor(git = new GitClient()) {
14
+ coordinator;
15
+ constructor(git = new GitClient(), coordinator = new RepositoryCoordinatorLock(defaultWorktreeCoordinatorStateRoot())) {
10
16
  this.git = git;
17
+ this.coordinator = coordinator;
11
18
  }
12
19
  async prepare(request) {
13
20
  const allowedRoot = await ensureDirectory(request.allowedRepoRoot);
14
21
  const baseBranch = normalizeBranchName(request.baseBranch, "develop");
15
- const repoPath = await this.resolveRepoPath(request.repo, allowedRoot, baseBranch);
16
- await this.ensureGitIdentity(repoPath);
17
- await this.ensureBaseBranch(repoPath, baseBranch);
18
22
  const shortId = request.workItem.id.replace(/-/g, "").slice(0, 12);
19
23
  const kind = safeSegment(request.workItem.kind, "work-item");
20
24
  const fallbackWorktreeKey = `${kind}/${shortId}`;
21
25
  const worktreeKey = request.worktreeKey?.trim() || fallbackWorktreeKey;
22
26
  const worktreeSegments = worktreeKeySegments(worktreeKey, kind, shortId);
23
27
  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, baseBranch]);
34
- resolvedWorktreePath = await resolveExistingDirectoryPath(worktreePath);
35
- }
36
- else {
37
- resolvedWorktreePath = await resolveExistingDirectoryPath(worktreePath);
38
- if (!isSubPath(allowedRoot, resolvedWorktreePath)) {
28
+ const creationCoordinationPath = this.repoCreationCoordinationPath(request.repo, allowedRoot);
29
+ const repoPath = await this.coordinator.withLock(creationCoordinationPath, async () => {
30
+ return await this.resolveRepoPath(request.repo, allowedRoot, baseBranch);
31
+ });
32
+ const resolvedWorktreePath = await this.coordinator.withLock(repoPath, async () => {
33
+ await this.ensureGitIdentity(repoPath);
34
+ const baseStartPoint = await this.resolveBaseStartPoint(repoPath, baseBranch);
35
+ const worktreeRoot = resolveManagedWorktreeRoot(allowedRoot, repoPath);
36
+ const worktreePath = path.join(worktreeRoot, ...worktreeSegments);
37
+ const resolvedWorktreeParent = await ensureDirectory(path.dirname(worktreePath));
38
+ if (!isSubPath(allowedRoot, resolvedWorktreeParent)) {
39
+ throw new Error(`Worktree path must stay under ${allowedRoot}.`);
40
+ }
41
+ let selectedWorktreePath = worktreePath;
42
+ if (!(await directoryExists(worktreePath))) {
43
+ // A prior runner can disappear after Git registers its worktree but before
44
+ // normal cleanup completes. Release that missing-directory registration
45
+ // immediately so retrying the same lease remains idempotent.
46
+ await this.git.run(repoPath, ["worktree", "prune", "--expire", "now"]);
47
+ const registeredWorktreePath = await this.registeredManagedWorktreePath(repoPath, branchName, allowedRoot, worktreeSegments);
48
+ if (!registeredWorktreePath) {
49
+ const branchRef = `refs/heads/${branchName}`;
50
+ if (request.preserveExistingState && await this.gitRefExists(repoPath, branchRef)) {
51
+ // The checkout directory can disappear while its branch still
52
+ // contains completed agent work. Reattach that branch verbatim;
53
+ // `-B ... baseStartPoint` would silently reset it and lose progress.
54
+ await this.git.run(repoPath, ["worktree", "add", worktreePath, branchName]);
55
+ }
56
+ else {
57
+ await this.git.run(repoPath, ["worktree", "add", "-B", branchName, worktreePath, baseStartPoint]);
58
+ }
59
+ return await resolveExistingDirectoryPath(worktreePath);
60
+ }
61
+ // The selected checkout can change when a Bridge reconnects or a local
62
+ // runner takes over. Its managed root therefore changes even though the
63
+ // deterministic lease branch does not. Reattach to the registered,
64
+ // Forge-owned worktree instead of trying to check out that branch twice.
65
+ selectedWorktreePath = registeredWorktreePath;
66
+ }
67
+ const existingWorktreePath = await resolveExistingDirectoryPath(selectedWorktreePath);
68
+ if (!isSubPath(allowedRoot, existingWorktreePath)) {
39
69
  throw new Error(`Worktree path must stay under ${allowedRoot}.`);
40
70
  }
41
- const currentBranch = await this.currentBranch(resolvedWorktreePath);
71
+ if (!(await gitMetadataExists(existingWorktreePath, allowedRoot))) {
72
+ await this.restoreOrphanedWorktree({
73
+ repoPath,
74
+ worktreePath: existingWorktreePath,
75
+ branchName,
76
+ baseStartPoint,
77
+ allowedRoot,
78
+ });
79
+ }
80
+ const currentBranch = await this.currentBranch(existingWorktreePath);
42
81
  if (currentBranch !== branchName) {
43
- throw new Error(`Existing worktree ${resolvedWorktreePath} is on branch '${currentBranch}', expected '${branchName}'.`);
82
+ throw new Error(`Existing worktree ${existingWorktreePath} is on branch '${currentBranch}', expected '${branchName}'.`);
83
+ }
84
+ if (!request.preserveExistingState && shouldResetExistingWorktree(request)) {
85
+ await this.resetWorktreeToBase(existingWorktreePath, baseStartPoint);
44
86
  }
87
+ return existingWorktreePath;
88
+ });
89
+ // Reference inputs and an exact-tree local repair checkpoint must survive output cleanup.
90
+ await materializeStoryQaReferences(resolvedWorktreePath, request, this.git);
91
+ if (!request.preserveExistingState) {
92
+ await clearRunArtifactRoots(resolvedWorktreePath, request);
45
93
  }
46
- await this.ensureGitIdentity(resolvedWorktreePath);
47
- await clearRunArtifactRoots(resolvedWorktreePath, request);
94
+ const hydratedPriorPhaseArtifactCount = await hydratePriorPhaseArtifacts(resolvedWorktreePath, request.priorPhaseArtifacts);
48
95
  const qaReworkPacketPath = await materializeQaReworkPacket(resolvedWorktreePath, request);
49
96
  const artifacts = filterArtifactsForLane(request.artifacts, request.lane, request.phase);
50
97
  if (artifacts.length > 0) {
51
- await injectWorktreeArtifacts(resolvedWorktreePath, artifacts);
98
+ await injectWorktreeArtifacts(resolvedWorktreePath, artifacts, await this.trackedForgePaths(resolvedWorktreePath));
52
99
  }
53
- const dependencyWarm = await warmWorktreeDependencies({
54
- baseCheckoutPath: repoPath,
55
- worktreePath: resolvedWorktreePath,
56
- allowedRoot,
57
- warmPathOverrides: request.warmPathOverrides
58
- });
100
+ const dependencyWarm = requiresMutableEnvironmentPreparation(request)
101
+ ? await warmWorktreeDependencies({
102
+ baseCheckoutPath: repoPath,
103
+ worktreePath: resolvedWorktreePath,
104
+ allowedRoot,
105
+ warmPathOverrides: request.warmPathOverrides
106
+ })
107
+ : undefined;
59
108
  return {
60
109
  repoPath,
61
110
  worktreePath: resolvedWorktreePath,
@@ -63,19 +112,88 @@ export class GitWorktreeService {
63
112
  baseBranch,
64
113
  cleanupWorktrees: request.cleanupWorktrees,
65
114
  dependencyWarm,
66
- qaReworkPacketPath
115
+ qaReworkPacketPath,
116
+ hydratedPriorPhaseArtifactCount
67
117
  };
68
118
  }
119
+ async trackedForgePaths(worktreePath) {
120
+ const output = await this.git.run(worktreePath, ["ls-files", "-z", "--", ".forge"]);
121
+ return new Set(output.split("\0").filter(Boolean).map(value => value.replaceAll("\\", "/")));
122
+ }
123
+ async registeredManagedWorktreePath(repoPath, branchName, allowedRoot, worktreeSegments) {
124
+ const listing = await this.git.run(repoPath, ["worktree", "list", "--porcelain", "-z"]);
125
+ const registered = parseRegisteredWorktrees(repoPath, listing)
126
+ .find(entry => entry.branchName === `refs/heads/${branchName}`);
127
+ if (!registered) {
128
+ return null;
129
+ }
130
+ const resolved = await resolveExistingDirectoryPath(registered.worktreePath);
131
+ if (!isManagedLeaseWorktreePath(allowedRoot, resolved, worktreeSegments)) {
132
+ throw new Error(`Branch '${branchName}' is already checked out outside its Forge-managed lease path at ${resolved}.`);
133
+ }
134
+ return resolved;
135
+ }
136
+ repoCreationCoordinationPath(repo, allowedRoot) {
137
+ if (repo.serverLocalPath && repo.serverLocalPath.trim().length > 0) {
138
+ return path.resolve(repo.serverLocalPath);
139
+ }
140
+ return this.managedRepoPath(repo, allowedRoot);
141
+ }
69
142
  async release(handle) {
70
- if (!handle.cleanupWorktrees || !(await directoryExists(handle.worktreePath))) {
143
+ if (!handle.cleanupWorktrees) {
71
144
  return;
72
145
  }
146
+ await this.coordinator.withLock(handle.repoPath, async () => {
147
+ if (await directoryExists(handle.worktreePath)) {
148
+ // On Windows, `git worktree remove` can delete the `.git` link and then
149
+ // fail on a locked build output. That leaves a full source tree which is
150
+ // no longer a Git worktree and makes every retry fail during prepare.
151
+ // Move the tree out of its stable lease path first. If the rename fails,
152
+ // leave the valid worktree untouched rather than partially unregistering it.
153
+ const releasePath = `${handle.worktreePath}.forge-release-${randomUUID()}`;
154
+ await renameWorktreeForRelease(handle.worktreePath, releasePath);
155
+ await this.git.run(handle.repoPath, ["worktree", "prune", "--expire", "now"]);
156
+ await fs.rm(releasePath, {
157
+ recursive: true,
158
+ force: true,
159
+ maxRetries: 10,
160
+ retryDelay: 100,
161
+ }).catch(() => undefined);
162
+ }
163
+ // The process can disappear after Git registers a worktree but before the
164
+ // directory is fully materialized (or after an external cleanup removes it).
165
+ // Always prune so those missing-directory registrations do not accumulate.
166
+ await this.git.run(handle.repoPath, ["worktree", "prune", "--expire", "now"]).catch(() => undefined);
167
+ });
168
+ }
169
+ async restoreOrphanedWorktree(input) {
170
+ const orphanStat = await fs.lstat(input.worktreePath);
171
+ if (orphanStat.isSymbolicLink() || !orphanStat.isDirectory()) {
172
+ throw new Error(`Refusing to recover redirected or non-directory worktree ${input.worktreePath}.`);
173
+ }
174
+ const quarantinePath = `${input.worktreePath}.forge-orphan-${randomUUID()}`;
175
+ if (!isSubPath(input.allowedRoot, quarantinePath)) {
176
+ throw new Error(`Worktree recovery path must stay under ${input.allowedRoot}.`);
177
+ }
178
+ await fs.rename(input.worktreePath, quarantinePath);
73
179
  try {
74
- await this.git.run(handle.repoPath, ["worktree", "remove", "--force", handle.worktreePath]);
75
- await this.git.run(handle.repoPath, ["worktree", "prune"]);
180
+ await this.git.run(input.repoPath, ["worktree", "prune", "--expire", "now"]);
181
+ await this.git.run(input.repoPath, [
182
+ "worktree",
183
+ "add",
184
+ "-B",
185
+ input.branchName,
186
+ input.worktreePath,
187
+ input.baseStartPoint,
188
+ ]);
189
+ await overlayOrphanedWorktree(input.worktreePath, quarantinePath);
190
+ await fs.rm(quarantinePath, { recursive: true, force: true });
76
191
  }
77
- catch {
78
- await fs.rm(handle.worktreePath, { recursive: true, force: true });
192
+ catch (error) {
193
+ await this.git.run(input.repoPath, ["worktree", "remove", "--force", input.worktreePath]).catch(() => undefined);
194
+ await fs.rm(input.worktreePath, { recursive: true, force: true }).catch(() => undefined);
195
+ await fs.rename(quarantinePath, input.worktreePath).catch(() => undefined);
196
+ throw new Error(`Failed to recover orphaned worktree ${input.worktreePath}.`, { cause: error });
79
197
  }
80
198
  }
81
199
  async resolveRepoPath(repo, allowedRoot, baseBranch) {
@@ -93,7 +211,7 @@ export class GitWorktreeService {
93
211
  if (discovered) {
94
212
  return discovered;
95
213
  }
96
- const repoPath = path.join(managedRepoRoot(allowedRoot), `${safeSegment(repo.name, "repo")}-${repo.id.replace(/-/g, "")}`);
214
+ const repoPath = this.managedRepoPath(repo, allowedRoot);
97
215
  if (!(await gitMetadataExists(repoPath, allowedRoot))) {
98
216
  const repoPathExists = await pathExists(repoPath);
99
217
  if (repoPathExists) {
@@ -124,6 +242,9 @@ export class GitWorktreeService {
124
242
  }
125
243
  return resolved;
126
244
  }
245
+ managedRepoPath(repo, allowedRoot) {
246
+ return path.join(managedRepoRoot(allowedRoot), `${safeSegment(repo.name, "repo")}-${repo.id.replace(/-/g, "")}`);
247
+ }
127
248
  async findExistingRepoPath(repo, allowedRoot) {
128
249
  const expectedRemote = normalizeRemote(repo.remoteUrl);
129
250
  const rawName = repo.name.trim();
@@ -164,21 +285,34 @@ export class GitWorktreeService {
164
285
  const resolved = await resolveExistingDirectoryPath(candidate);
165
286
  return isSubPath(allowedRoot, resolved) ? resolved : null;
166
287
  }
167
- async ensureBaseBranch(repoPath, baseBranch) {
168
- if (await this.gitRefExists(repoPath, `refs/heads/${baseBranch}`)) {
169
- return;
288
+ async resolveBaseStartPoint(repoPath, baseBranch) {
289
+ if (baseBranch === "HEAD") {
290
+ const head = (await this.git.run(repoPath, ["rev-parse", "--verify", "HEAD^{commit}"])).trim();
291
+ if (!/^[0-9a-f]{40,64}$/iu.test(head)) {
292
+ throw new Error("The repository checkout does not have a valid committed HEAD revision.");
293
+ }
294
+ return head;
170
295
  }
296
+ const localRef = `refs/heads/${baseBranch}`;
297
+ const remoteRef = `refs/remotes/origin/${baseBranch}`;
171
298
  const origin = await this.git.run(repoPath, ["remote", "get-url", "origin"]).catch(() => "");
172
- if (origin.trim().length === 0) {
173
- throw new Error(`Base branch '${baseBranch}' was not found in the local checkout.`);
299
+ if (origin.trim().length > 0) {
300
+ const fetched = await this.git.run(repoPath, ["fetch", "--prune", "origin"])
301
+ .then(() => true)
302
+ .catch(() => false);
303
+ if (fetched && await this.gitRefExists(repoPath, remoteRef)) {
304
+ return remoteRef;
305
+ }
174
306
  }
175
- await this.git.run(repoPath, ["fetch", "origin", `${baseBranch}:refs/remotes/origin/${baseBranch}`]).catch(() => "");
176
- if (await this.gitRefExists(repoPath, `refs/remotes/origin/${baseBranch}`)) {
177
- await this.git.run(repoPath, ["branch", baseBranch, `refs/remotes/origin/${baseBranch}`]);
178
- return;
307
+ if (await this.gitRefExists(repoPath, localRef)) {
308
+ return localRef;
179
309
  }
180
310
  throw new Error(`Base branch '${baseBranch}' was not found locally or on origin.`);
181
311
  }
312
+ async resetWorktreeToBase(worktreePath, baseStartPoint) {
313
+ await this.git.run(worktreePath, ["reset", "--hard", baseStartPoint]);
314
+ await this.git.run(worktreePath, ["clean", "-fd"]);
315
+ }
182
316
  async gitRefExists(repoPath, gitRef) {
183
317
  return this.git.run(repoPath, ["show-ref", "--verify", "--quiet", gitRef]).then(() => true).catch(() => false);
184
318
  }
@@ -190,14 +324,31 @@ export class GitWorktreeService {
190
324
  await this.git.run(repoPath, ["config", "user.name", "Forge Bridge"]);
191
325
  }
192
326
  }
193
- function managedRepoRoot(allowedRoot) {
194
- return path.join(allowedRoot, ".forge-worktrees", "_repos");
327
+ export async function renameWorktreeForRelease(source, destination, rename = fs.rename, wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)), maxAttempts = 8) {
328
+ const attempts = Number.isFinite(maxAttempts) && maxAttempts > 0
329
+ ? Math.max(1, Math.floor(maxAttempts))
330
+ : 1;
331
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
332
+ try {
333
+ await rename(source, destination);
334
+ return;
335
+ }
336
+ catch (error) {
337
+ if (!isTransientWorktreeRenameError(error) || attempt === attempts)
338
+ throw error;
339
+ await wait(Math.min(1_000, 100 * (2 ** (attempt - 1))));
340
+ }
341
+ }
195
342
  }
196
- function resolveWorktreeRoot(allowedRoot, repoPath) {
197
- const baseName = path.basename(repoPath);
198
- return isSubPath(managedRepoRoot(allowedRoot), repoPath)
199
- ? path.join(allowedRoot, ".forge-worktrees", baseName)
200
- : path.join(path.dirname(repoPath), ".forge-worktrees", baseName);
343
+ function isTransientWorktreeRenameError(error) {
344
+ if (!error || typeof error !== "object" || !("code" in error))
345
+ return false;
346
+ const code = String(error.code ?? "").toUpperCase();
347
+ return code === "EPERM" || code === "EACCES" || code === "EBUSY" || code === "ENOTEMPTY";
348
+ }
349
+ export function requiresMutableEnvironmentPreparation(request) {
350
+ // Missing runtime intent is treated conservatively for older dispatch packets.
351
+ return request.applicationRuntimeRequired !== false;
201
352
  }
202
353
  function isCloneableRemote(remoteUrl) {
203
354
  const trimmed = remoteUrl?.trim() ?? "";
@@ -206,9 +357,12 @@ function isCloneableRemote(remoteUrl) {
206
357
  }
207
358
  return trimmed.startsWith("git@") || trimmed.includes("://") || path.isAbsolute(trimmed);
208
359
  }
209
- async function clearRunArtifactRoots(worktreePath, request) {
360
+ export async function clearRunArtifactRoots(worktreePath, request) {
210
361
  const roots = new Set();
211
362
  roots.add(workItemArtifactRoot(worktreePath, request.workItem.id));
363
+ for (const artifact of request.priorPhaseArtifacts ?? []) {
364
+ roots.add(workItemArtifactRoot(worktreePath, artifact.workItemId));
365
+ }
212
366
  const ownerType = normalizeOwnerType(request.ownerType);
213
367
  const ownerId = request.ownerId?.trim();
214
368
  if (ownerType && ownerId) {
@@ -269,6 +423,132 @@ function normalizeOwnerType(value) {
269
423
  }
270
424
  return null;
271
425
  }
426
+ function shouldResetExistingWorktree(request) {
427
+ const lane = request.lane.trim().toLowerCase();
428
+ const phase = request.phase.trim().toLowerCase().replace(/-/g, "_");
429
+ if (lane === "develop" && (phase === "implementation" || phase === "self_review" || phase === "validation")) {
430
+ return false;
431
+ }
432
+ if (lane === "qa" || phase === "qa") {
433
+ return false;
434
+ }
435
+ return true;
436
+ }
437
+ function parseRegisteredWorktrees(repoPath, output) {
438
+ const worktrees = [];
439
+ let current = null;
440
+ for (const field of output.split("\0")) {
441
+ if (field.startsWith("worktree ")) {
442
+ if (current)
443
+ worktrees.push(current);
444
+ const rawPath = field.slice("worktree ".length);
445
+ current = {
446
+ worktreePath: path.resolve(path.isAbsolute(rawPath) ? rawPath : path.join(repoPath, rawPath)),
447
+ branchName: null,
448
+ };
449
+ }
450
+ else if (field.startsWith("branch ") && current) {
451
+ current.branchName = field.slice("branch ".length).trim();
452
+ }
453
+ }
454
+ if (current)
455
+ worktrees.push(current);
456
+ return worktrees;
457
+ }
458
+ function isManagedLeaseWorktreePath(allowedRoot, candidate, worktreeSegments) {
459
+ if (!isSubPath(allowedRoot, candidate)) {
460
+ return false;
461
+ }
462
+ const relativeSegments = path.relative(path.resolve(allowedRoot), path.resolve(candidate))
463
+ .split(path.sep)
464
+ .filter(Boolean);
465
+ const normalize = (value) => process.platform === "win32" ? value.toLowerCase() : value;
466
+ if (normalize(relativeSegments[0] ?? "") !== ".forge-worktrees"
467
+ || relativeSegments.length !== 2 + worktreeSegments.length) {
468
+ return false;
469
+ }
470
+ const suffix = relativeSegments.slice(-worktreeSegments.length).map(normalize);
471
+ return suffix.every((segment, index) => segment === normalize(worktreeSegments[index] ?? ""));
472
+ }
473
+ async function overlayOrphanedWorktree(worktreePath, orphanPath) {
474
+ // A failed Windows cleanup can remove only part of a worktree before its
475
+ // `.git` link disappears. Start from Git's clean checkout so missing tracked
476
+ // files are restored, then overlay every surviving agent file. Moving entries
477
+ // on the same volume preserves pnpm junctions without requiring symlink
478
+ // privileges, unlike fs.cp.
479
+ const backupRoot = `${worktreePath}.forge-base-${randomUUID()}`;
480
+ const moves = [];
481
+ try {
482
+ await fs.mkdir(backupRoot, { recursive: true });
483
+ const orphanEntries = await fs.readdir(orphanPath, { withFileTypes: true });
484
+ for (const entry of orphanEntries) {
485
+ if (entry.name === ".git") {
486
+ continue;
487
+ }
488
+ await overlayEntry({
489
+ sourcePath: path.join(orphanPath, entry.name),
490
+ destinationPath: path.join(worktreePath, entry.name),
491
+ backupPath: path.join(backupRoot, entry.name),
492
+ moves,
493
+ });
494
+ }
495
+ await fs.rm(backupRoot, { recursive: true, force: true });
496
+ }
497
+ catch (error) {
498
+ for (const move of moves.reverse()) {
499
+ await fs.mkdir(path.dirname(move.sourcePath), { recursive: true }).catch(() => undefined);
500
+ await fs.rename(move.destinationPath, move.sourcePath).catch(() => undefined);
501
+ if (move.backupPath) {
502
+ await fs.mkdir(path.dirname(move.destinationPath), { recursive: true }).catch(() => undefined);
503
+ await fs.rename(move.backupPath, move.destinationPath).catch(() => undefined);
504
+ }
505
+ }
506
+ await fs.rm(backupRoot, { recursive: true, force: true }).catch(() => undefined);
507
+ throw error;
508
+ }
509
+ }
510
+ async function overlayEntry(input) {
511
+ const sourceStat = await fs.lstat(input.sourcePath);
512
+ const destinationStat = await fs.lstat(input.destinationPath).catch(() => null);
513
+ if (sourceStat.isDirectory() &&
514
+ !sourceStat.isSymbolicLink() &&
515
+ destinationStat?.isDirectory() &&
516
+ !destinationStat.isSymbolicLink()) {
517
+ const entries = await fs.readdir(input.sourcePath, { withFileTypes: true });
518
+ for (const entry of entries) {
519
+ await overlayEntry({
520
+ sourcePath: path.join(input.sourcePath, entry.name),
521
+ destinationPath: path.join(input.destinationPath, entry.name),
522
+ backupPath: path.join(input.backupPath, entry.name),
523
+ moves: input.moves,
524
+ });
525
+ }
526
+ return;
527
+ }
528
+ let preservedBasePath;
529
+ if (destinationStat) {
530
+ await fs.mkdir(path.dirname(input.backupPath), { recursive: true });
531
+ await fs.rename(input.destinationPath, input.backupPath);
532
+ preservedBasePath = input.backupPath;
533
+ }
534
+ else {
535
+ await fs.mkdir(path.dirname(input.destinationPath), { recursive: true });
536
+ }
537
+ try {
538
+ await fs.rename(input.sourcePath, input.destinationPath);
539
+ }
540
+ catch (error) {
541
+ if (preservedBasePath) {
542
+ await fs.rename(preservedBasePath, input.destinationPath).catch(() => undefined);
543
+ }
544
+ throw error;
545
+ }
546
+ input.moves.push({
547
+ sourcePath: input.sourcePath,
548
+ destinationPath: input.destinationPath,
549
+ backupPath: preservedBasePath,
550
+ });
551
+ }
272
552
  function qaReworkPacketFileName(packetPath) {
273
553
  const normalized = packetPath.trim().replace(/\\/g, "/");
274
554
  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, DispatchStoryQaContext } 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,15 @@ export interface WorktreePrepareRequest {
12
12
  readonly allowedRepoRoot: string;
13
13
  readonly cleanupWorktrees: boolean;
14
14
  readonly artifacts: readonly DispatchResolvedArtifact[];
15
+ readonly priorPhaseArtifacts?: readonly DispatchPriorPhaseArtifact[];
16
+ readonly storyQa?: DispatchStoryQaContext | null;
17
+ readonly referenceRunId?: string;
18
+ readonly referenceCycleRunId?: string;
19
+ readonly effectivePlanAmendmentHash?: string | null;
15
20
  readonly qaRework?: DispatchQaReworkContext | null;
16
21
  readonly warmPathOverrides?: readonly string[];
22
+ readonly applicationRuntimeRequired?: boolean;
23
+ readonly preserveExistingState?: boolean;
17
24
  }
18
25
  export interface WorktreeHandle {
19
26
  readonly repoPath: string;
@@ -23,4 +30,5 @@ export interface WorktreeHandle {
23
30
  readonly cleanupWorktrees: boolean;
24
31
  readonly dependencyWarm?: WarmDependenciesResult;
25
32
  readonly qaReworkPacketPath?: string;
33
+ readonly hydratedPriorPhaseArtifactCount?: number;
26
34
  }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@loucompanion/forge-bridge",
3
- "version": "0.1.3-dev.f66395c3ac21",
3
+ "version": "0.1.4-dev.04a1e5b9e484",
4
4
  "description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
7
  "bin": {
8
+ "forge": "dist/cli/bin.js",
8
9
  "forge-bridge": "dist/cli/bin.js"
9
10
  },
10
11
  "main": "./dist/index.js",
@@ -18,6 +19,7 @@
18
19
  },
19
20
  "files": [
20
21
  "dist",
22
+ "runtime",
21
23
  "README.md"
22
24
  ],
23
25
  "repository": {
@@ -30,20 +32,22 @@
30
32
  "url": "https://github.com/coleman399/Forge/issues"
31
33
  },
32
34
  "engines": {
33
- "node": ">=22.13.0"
35
+ "node": "^22.13.0 || >=24.0.0"
34
36
  },
35
37
  "publishConfig": {
36
38
  "access": "public"
37
39
  },
38
40
  "scripts": {
39
41
  "build": "tsc -p tsconfig.json",
40
- "test": "vitest run",
42
+ "test": "node scripts/run-owned-tests.mjs",
43
+ "test:residue": "node scripts/run-owned-residue-gate.mjs",
41
44
  "test:watch": "vitest",
42
- "smoke:package": "npm run build && node scripts/smoke-package.mjs",
43
- "lint": "eslint \"src/**/*.ts\" \"scripts/**/*.mjs\" \"vitest.config.ts\""
45
+ "smoke:package": "tsc -p tsconfig.json && node scripts/smoke-package.mjs",
46
+ "lint": "eslint \"src/**/*.ts\" \"scripts/**/*.mjs\" \"vitest.config.ts\" \"vitest.setup.ts\""
44
47
  },
45
48
  "dependencies": {
46
- "@microsoft/signalr": "^8.0.17"
49
+ "@microsoft/signalr": "^8.0.17",
50
+ "yaml": "^2.9.0"
47
51
  },
48
52
  "devDependencies": {
49
53
  "@eslint/js": "^10.0.1",
@@ -52,6 +56,7 @@
52
56
  "globals": "^17.6.0",
53
57
  "typescript": "^5.8.3",
54
58
  "typescript-eslint": "^8.61.1",
55
- "vitest": "^3.2.3"
59
+ "vite": "^7.3.7",
60
+ "vitest": "^4.1.12"
56
61
  }
57
62
  }