@loucompanion/forge-bridge 0.1.3-dev.d46a8e73d03d → 0.1.3-dev.e7b62d8fdd33

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 (145) 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 +125 -40
  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 +49 -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.repair-epoch.d.ts +6 -0
  97. package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
  98. package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
  99. package/dist/session/provider-cli/provider-cli.runtime.js +6 -3
  100. package/dist/session/provider-cli/provider-cli.service.d.ts +22 -2
  101. package/dist/session/provider-cli/provider-cli.service.js +524 -100
  102. package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
  103. package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
  104. package/dist/session/provider-cli/provider-output-state.js +91 -16
  105. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  106. package/dist/session/provider-cli/provider-usage.js +28 -5
  107. package/dist/session/session-redaction.d.ts +3 -0
  108. package/dist/session/session-redaction.js +38 -0
  109. package/dist/session/session.base.d.ts +6 -1
  110. package/dist/session/session.service.d.ts +8 -2
  111. package/dist/session/session.service.js +522 -41
  112. package/dist/session/session.types.d.ts +72 -0
  113. package/dist/session/session.types.js +74 -3
  114. package/dist/shared/format/format-bytes.d.ts +1 -0
  115. package/dist/shared/format/format-bytes.js +9 -0
  116. package/dist/shared/path/path.d.ts +1 -0
  117. package/dist/shared/path/path.js +21 -1
  118. package/dist/shared/process/dotnet-toolchain.d.ts +14 -0
  119. package/dist/shared/process/dotnet-toolchain.js +179 -0
  120. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  121. package/dist/shared/process/host-process-recovery.js +78 -0
  122. package/dist/shared/process/host-process-supervisor.d.ts +59 -0
  123. package/dist/shared/process/host-process-supervisor.js +330 -0
  124. package/dist/shared/process/process.d.ts +1 -0
  125. package/dist/shared/process/process.js +1 -0
  126. package/dist/shared/redaction/redaction.js +12 -2
  127. package/dist/version.d.ts +1 -1
  128. package/dist/version.js +1 -1
  129. package/dist/worktree/artifact-injector.d.ts +3 -2
  130. package/dist/worktree/artifact-injector.js +43 -4
  131. package/dist/worktree/dependency-warmer.d.ts +28 -1
  132. package/dist/worktree/dependency-warmer.js +308 -9
  133. package/dist/worktree/index.d.ts +2 -1
  134. package/dist/worktree/index.js +1 -0
  135. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  136. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  137. package/dist/worktree/worktree-paths.d.ts +4 -0
  138. package/dist/worktree/worktree-paths.js +30 -0
  139. package/dist/worktree/worktree.base.d.ts +3 -0
  140. package/dist/worktree/worktree.service.d.ts +13 -2
  141. package/dist/worktree/worktree.service.js +238 -47
  142. package/dist/worktree/worktree.types.d.ts +5 -1
  143. package/package.json +5 -3
  144. package/runtime/windows-job-supervisor.ps1 +444 -0
  145. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -1,42 +1,85 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { RepositoryCoordinatorLock } from "../../sandbox/repository-lock.js";
3
4
  import { GitClient } from "../../shared/git/git.js";
4
5
  import { buildSpecBody, selectLaneCommand } from "../../worktree/artifact-injector.js";
6
+ import { defaultWorktreeCoordinatorStateRoot } from "../../worktree/worktree-paths.js";
5
7
  import { completionPhaseFromDispatch, prepareCompletionToolRuntime, readCompletionArtifact, usesDebugCycleCompletion, usesDevelopCycleCompletion } from "../phase-completion.js";
6
- import { cleanupCompletionTools, commitWorktreeChanges, completionPhaseFromArtifact, emitArchitectStructuredEvents, emitCompletionArtifactRecorded, emitDecomposeGateArtifacts, emitDraftGateArtifacts, emitPhaseCompletionEvent, emitPhaseGateEvents, emitQaArtifactRecorded, emitQaStructuredEvents, enforceQaBrowserLaunchTargetGate, LiveArchitectArtifactEmitter, LiveCompletionArtifactEmitter, LiveQaArtifactEmitter, readCompletionArtifactsForRun, readQaCompletionArtifactBundle, summarizeStderr, waitForCompletionArtifact } from "./provider-cli.artifacts.js";
8
+ import { cleanupCompletionTools, commitWorktreeChanges, completionPhaseFromArtifact, emitArchitectStructuredEvents, emitCompletionArtifactRecorded, emitDecomposeGateArtifacts, emitDraftGateArtifacts, emitPhaseCompletionEvent, emitPhaseGateEvents, emitQaArtifactRecorded, emitQaStructuredEvents, enforceQaBrowserLaunchTargetGate, LiveArchitectArtifactEmitter, LiveCompletionArtifactEmitter, LiveQaArtifactEmitter, readCompletionArtifactsForRun, readQaCompletionArtifactBundle, waitForCompletionArtifact } from "./provider-cli.artifacts.js";
7
9
  import { DeterministicProviderCliService } from "./provider-cli.deterministic.js";
8
- import { buildArchitectPrompt, buildConflictResolvePrompt, buildDebugCyclePrompt, buildDevelopCyclePrompt, buildDraftPrompt, buildDraftSpec, buildImplementationPrompt, buildProviderCliCommand, durableDraftInput, buildPullRequestCheckRepairPrompt, buildPullRequestCommentsPrompt, buildQaPrompt, formatProviderCliCommandForCheckpoint, isDecomposeDraftDispatch, isPullRequestCheckRepairDispatch, isPullRequestCommentsDispatch } from "./provider-cli.prompts.js";
10
+ import { buildArchitectPrompt, buildConflictResolvePrompt, buildDebugCyclePrompt, buildDevelopCyclePrompt, buildDraftPrompt, buildDraftSpec, buildImplementationPrompt, buildProviderCliPrompt, buildProviderCliCommand, durableDraftInput, buildPullRequestCheckRepairPrompt, buildPullRequestCommentsPrompt, buildQaPrompt, formatProviderCliCommandForCheckpoint, isDecomposeDraftDispatch, isPullRequestCheckRepairDispatch, isPullRequestCommentsDispatch } from "./provider-cli.prompts.js";
9
11
  import { StreamingProviderProcessRunner } from "./provider-cli.process.js";
10
12
  import { emitProviderUsageEvent, providerProcessEnv } from "./provider-cli.runtime.js";
11
13
  import { clip, createProviderOutputState, parseJsonLine } from "./provider-output-state.js";
14
+ import { writeCiRepairReadinessManifest } from "./ci-repair-readiness.js";
15
+ import { assertRepairHeadEpoch } from "./provider-cli.repair-epoch.js";
16
+ import { PrePublicationValidator, } from "../pre-publication-validation.js";
12
17
  export { DeterministicProviderCliService } from "./provider-cli.deterministic.js";
13
18
  export { buildProviderCliCommand, codexSandboxModeForPlatform, formatProviderCliCommand, formatProviderCliCommandForCheckpoint } from "./provider-cli.prompts.js";
14
- export { buildProviderChildEnvironment, chooseWindowsCommandCandidate, StreamingProviderProcessRunner } from "./provider-cli.process.js";
19
+ export { buildProviderChildEnvironment, chooseWindowsCommandCandidate, resolveCodexNpmDirectLaunch, resolvePackageManagerDirectLaunch, resolveWindowsCommandFromPath, PROVIDER_OUTPUT_CAPTURE_LIMIT_CHARS, StreamingProviderProcessRunner } from "./provider-cli.process.js";
15
20
  export { buildWindowsLaunchCommand } from "../../shared/process/windows-launch.js";
16
- async function pushWorktreeBranch(git, worktreePath, branchName, emit) {
17
- try {
18
- await git.run(worktreePath, ["push", "origin", `HEAD:${branchName}`]);
19
- return;
20
- }
21
- catch (err) {
22
- if (!isNonFastForwardPushError(err)) {
23
- throw err;
21
+ async function pushWorktreeBranch(git, worktreePath, branchName, repoPath, coordinator, signal, emit, expectedRemoteHeadSha) {
22
+ await coordinator.withLock(repoPath, async () => {
23
+ if (expectedRemoteHeadSha) {
24
+ const expected = expectedRemoteHeadSha.trim().toLowerCase();
25
+ const remoteListing = await git.run(worktreePath, ["ls-remote", "--heads", "origin", `refs/heads/${branchName}`]);
26
+ const observed = remoteListing.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
27
+ if (observed !== expected) {
28
+ throw new Error(`stale_head: repair publication expected remote ${branchName} at ${expected}, but observed ${observed || "no branch"}; refresh provider state before retrying.`);
29
+ }
30
+ try {
31
+ await git.run(worktreePath, [
32
+ "push",
33
+ "--porcelain",
34
+ "origin",
35
+ `HEAD:refs/heads/${branchName}`,
36
+ `--force-with-lease=refs/heads/${branchName}:${expected}`,
37
+ ]);
38
+ }
39
+ catch (error) {
40
+ throw new Error(`stale_head: repair publication for ${branchName} lost its expected-head lease; Forge did not rebase or publish the stale repair: ${errorMessage(error)}`, { cause: error });
41
+ }
42
+ return;
24
43
  }
25
- await emit?.({
26
- role: "runner",
27
- kind: "checkpoint",
28
- content: `Remote branch ${branchName} moved before push; fetching latest and rebasing local changes.`,
29
- eventType: "worktree_branch_push_rebase"
30
- });
31
44
  try {
32
- await git.run(worktreePath, ["fetch", "origin", remoteTrackingRefspec(branchName)]);
33
- await git.run(worktreePath, ["rebase", `refs/remotes/origin/${branchName}`]);
34
45
  await git.run(worktreePath, ["push", "origin", `HEAD:${branchName}`]);
46
+ return;
35
47
  }
36
- catch (retryErr) {
37
- throw new Error(`git push origin HEAD:${branchName} was rejected because the remote branch moved, and automatic fetch/rebase failed: ${errorMessage(retryErr)}`, { cause: retryErr });
48
+ catch (err) {
49
+ if (!isNonFastForwardPushError(err)) {
50
+ throw err;
51
+ }
52
+ await emit?.({
53
+ role: "runner",
54
+ kind: "checkpoint",
55
+ content: `Remote branch ${branchName} moved before push; fetching latest and rebasing local changes.`,
56
+ eventType: "worktree_branch_push_rebase"
57
+ });
58
+ try {
59
+ await git.run(worktreePath, ["fetch", "origin", remoteTrackingRefspec(branchName)]);
60
+ await git.run(worktreePath, ["rebase", `refs/remotes/origin/${branchName}`]);
61
+ await git.run(worktreePath, ["push", "origin", `HEAD:${branchName}`]);
62
+ }
63
+ catch (retryErr) {
64
+ throw new Error(`worktree_branch_integration_conflict: git push origin HEAD:${branchName} was rejected because the remote branch moved, and automatic fetch/rebase failed: ${errorMessage(retryErr)}`, { cause: retryErr });
65
+ }
38
66
  }
67
+ }, signal);
68
+ }
69
+ function providerOwnsBranchPublication(context) {
70
+ return context.dispatch.sandbox?.executionMode !== "docker";
71
+ }
72
+ function prePublicationExecution(context, environment, defaultRunner) {
73
+ if (context.processRunner)
74
+ return { environment, processRunner: context.processRunner };
75
+ if (context.dispatch.sandbox?.executionMode !== "docker")
76
+ return { environment };
77
+ const expectedSandboxId = context.dispatch.sandbox.dispatchAttemptId;
78
+ const observedSandboxId = context.runtimeEnvironment?.FORGE_SANDBOX_ID ?? process.env.FORGE_SANDBOX_ID;
79
+ if (observedSandboxId !== expectedSandboxId) {
80
+ throw new Error(`prepublication_execution_scope_invalid: Docker attempt ${expectedSandboxId} has no runner proven to execute inside that sandbox.`);
39
81
  }
82
+ return { environment, processRunner: defaultRunner };
40
83
  }
41
84
  function remoteTrackingRefspec(branchName) {
42
85
  return `refs/heads/${branchName}:refs/remotes/origin/${branchName}`;
@@ -52,6 +95,226 @@ function isNonFastForwardPushError(err) {
52
95
  function errorMessage(err) {
53
96
  return err instanceof Error ? err.message : String(err);
54
97
  }
98
+ function providerCliExitFailure(command, result) {
99
+ const output = [result.stderr.trim(), result.stdout.trim()]
100
+ .filter(value => value.length > 0)
101
+ .join("\n");
102
+ const detail = clip(output, 1200);
103
+ const suffix = detail ? `: ${detail}` : "";
104
+ if (isProviderNetworkFailure(output)) {
105
+ return new Error(`provider_network_unavailable: ${command} could not reach its provider service${suffix}`);
106
+ }
107
+ return new Error(`${command} exited ${result.exitCode}${suffix}`);
108
+ }
109
+ export function isProviderNetworkFailure(output) {
110
+ const normalized = output.trim().toLowerCase();
111
+ if (!normalized) {
112
+ return false;
113
+ }
114
+ return [
115
+ "eai_again",
116
+ "enotfound",
117
+ "could not resolve host",
118
+ "failed to lookup address",
119
+ "name or service not known",
120
+ "no such host is known",
121
+ "temporary failure in name resolution",
122
+ "network is unreachable",
123
+ "no route to host",
124
+ "error sending request for url",
125
+ "client error (connect)",
126
+ "connection refused",
127
+ "connection reset by peer",
128
+ "stream disconnected before completion",
129
+ ].some(marker => normalized.includes(marker));
130
+ }
131
+ const defaultProviderToolHeartbeatIntervalMs = 60_000;
132
+ const defaultManagedVerificationProgressIntervalMs = 30_000;
133
+ function compareManagedVerificationSnapshots(left, right) {
134
+ const leftOrder = managedVerificationStartedAt(left.startedAt) ?? Number.MAX_SAFE_INTEGER;
135
+ const rightOrder = managedVerificationStartedAt(right.startedAt) ?? Number.MAX_SAFE_INTEGER;
136
+ return leftOrder - rightOrder || left.commandId.localeCompare(right.commandId);
137
+ }
138
+ function managedVerificationStartedAt(value) {
139
+ const parsed = Date.parse(value ?? "");
140
+ return Number.isFinite(parsed) ? parsed : null;
141
+ }
142
+ export class ManagedVerificationProgressEmitter {
143
+ logDirectory;
144
+ emit;
145
+ intervalMs;
146
+ timer = null;
147
+ observedStatuses = new Map();
148
+ completedCommands = new Set();
149
+ constructor(logDirectory, emit, intervalMs = defaultManagedVerificationProgressIntervalMs) {
150
+ this.logDirectory = logDirectory;
151
+ this.emit = emit;
152
+ this.intervalMs = intervalMs;
153
+ }
154
+ start() {
155
+ if (!this.logDirectory?.trim() || this.timer)
156
+ return;
157
+ this.timer = setInterval(() => {
158
+ void this.scan().catch(() => undefined);
159
+ }, Math.max(1, this.intervalMs));
160
+ void this.scan().catch(() => undefined);
161
+ }
162
+ async stop() {
163
+ if (this.timer) {
164
+ clearInterval(this.timer);
165
+ this.timer = null;
166
+ }
167
+ await this.scan().catch(() => undefined);
168
+ }
169
+ async scan() {
170
+ if (!this.logDirectory?.trim())
171
+ return;
172
+ const directory = path.join(this.logDirectory, "managed-commands");
173
+ const names = await fs.readdir(directory).catch(() => []);
174
+ const snapshots = await Promise.all(names
175
+ .filter(name => name.endsWith(".receipt.json"))
176
+ .map(async (name) => parseManagedVerificationSnapshot(path.join(directory, name))));
177
+ const current = snapshots
178
+ .filter((entry) => entry !== null)
179
+ .sort(compareManagedVerificationSnapshots);
180
+ if (current.some(snapshot => isManagedVerificationNonterminal(snapshot.status))) {
181
+ // Durable managed-command receipts are authoritative evidence that Forge
182
+ // still supervises work even when the provider is not inside an MCP tool
183
+ // call. Refresh the API idle deadline without inflating the transcript.
184
+ await this.emit({
185
+ role: "runner",
186
+ kind: "status",
187
+ content: "A supervised managed verification command is still running.",
188
+ eventType: "provider_tool_heartbeat",
189
+ });
190
+ }
191
+ for (const snapshot of current) {
192
+ await this.emitTransition(snapshot);
193
+ }
194
+ }
195
+ async emitTransition(current) {
196
+ const terminal = ["pass", "fail", "timed_out", "timeout_termination_failed", "runner_error"].includes(current.status);
197
+ if (terminal && this.completedCommands.has(current.commandId))
198
+ return;
199
+ const previousStatus = this.observedStatuses.get(current.commandId);
200
+ if (previousStatus === current.status)
201
+ return;
202
+ this.observedStatuses.set(current.commandId, current.status);
203
+ if (terminal)
204
+ this.completedCommands.add(current.commandId);
205
+ // Starting and running are one externally meaningful state. The receipt is
206
+ // still refreshed on disk for UI telemetry, but repeated output/elapsed
207
+ // changes must not inflate the provider transcript or session history.
208
+ if (!terminal && previousStatus === "starting" && current.status === "running")
209
+ return;
210
+ const startedAt = managedVerificationStartedAt(current.startedAt);
211
+ const elapsedMs = startedAt !== null
212
+ ? Math.max(current.elapsedMs ?? 0, Date.now() - startedAt)
213
+ : current.elapsedMs;
214
+ const elapsed = elapsedMs === null ? "an unknown duration" : formatElapsed(elapsedMs);
215
+ const outputTail = current.lastOutputStream === "stderr"
216
+ ? current.stderrTail || current.stdoutTail
217
+ : current.stdoutTail || current.stderrTail;
218
+ const recovered = previousStatus === undefined && terminal;
219
+ await this.emit({
220
+ role: "runner",
221
+ kind: terminal && current.status !== "pass" ? "error" : "status",
222
+ content: terminal
223
+ ? `Managed verification ${current.status} after ${elapsed}${recovered ? " (recovered from its durable receipt)" : ""}${outputTail ? `: ${clip(outputTail, 600)}` : "."}`
224
+ : `QA started managed verification ${current.commandId}. Forge is supervising it; durable progress remains available in the UI while the provider continues independent work.`,
225
+ eventType: terminal ? "verification_completed" : "verification_waiting",
226
+ data: JSON.stringify({
227
+ command_id: current.commandId,
228
+ status: current.status,
229
+ elapsed_ms: elapsedMs,
230
+ output_revision: current.outputRevision,
231
+ last_output_at: current.lastOutputAt,
232
+ recovered,
233
+ }),
234
+ });
235
+ }
236
+ }
237
+ function isManagedVerificationNonterminal(status) {
238
+ return status === "starting" || status === "running" || status === "waiting_for_build_slot";
239
+ }
240
+ async function parseManagedVerificationSnapshot(receiptPath) {
241
+ try {
242
+ const receipt = JSON.parse(await fs.readFile(receiptPath, "utf8"));
243
+ const commandId = typeof receipt.command_id === "string" ? receipt.command_id : null;
244
+ const status = typeof receipt.status === "string" ? receipt.status : null;
245
+ if (!commandId || !status)
246
+ return null;
247
+ return {
248
+ commandId,
249
+ status,
250
+ elapsedMs: typeof receipt.elapsed_ms === "number" ? receipt.elapsed_ms : null,
251
+ startedAt: typeof receipt.started_at === "string" ? receipt.started_at : null,
252
+ outputRevision: typeof receipt.output_revision === "number" ? receipt.output_revision : null,
253
+ lastOutputAt: typeof receipt.last_output_at === "string" ? receipt.last_output_at : null,
254
+ lastOutputStream: receipt.last_output_stream === "stdout" || receipt.last_output_stream === "stderr"
255
+ ? receipt.last_output_stream
256
+ : null,
257
+ stdoutTail: tail(typeof receipt.stdout === "string" ? receipt.stdout : ""),
258
+ stderrTail: tail(typeof receipt.stderr === "string" ? receipt.stderr : ""),
259
+ };
260
+ }
261
+ catch {
262
+ return null;
263
+ }
264
+ }
265
+ function tail(value) {
266
+ return value.trim().split(/\r?\n/u).slice(-4).join("\n");
267
+ }
268
+ function formatElapsed(milliseconds) {
269
+ const seconds = Math.max(0, Math.round(milliseconds / 1000));
270
+ const minutes = Math.floor(seconds / 60);
271
+ return minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
272
+ }
273
+ class ProviderToolActivityHeartbeat {
274
+ outputState;
275
+ emit;
276
+ intervalMs;
277
+ timer = null;
278
+ constructor(outputState, emit, intervalMs) {
279
+ this.outputState = outputState;
280
+ this.emit = emit;
281
+ this.intervalMs = intervalMs;
282
+ }
283
+ async consume(line) {
284
+ const events = this.outputState.consume(line);
285
+ this.sync();
286
+ for (const event of events) {
287
+ await this.emit(event);
288
+ }
289
+ }
290
+ stop() {
291
+ if (this.timer) {
292
+ clearInterval(this.timer);
293
+ this.timer = null;
294
+ }
295
+ }
296
+ sync() {
297
+ if (!this.outputState.hasActiveToolCall) {
298
+ this.stop();
299
+ return;
300
+ }
301
+ if (this.timer) {
302
+ return;
303
+ }
304
+ this.timer = setInterval(() => {
305
+ if (!this.outputState.hasActiveToolCall) {
306
+ this.stop();
307
+ return;
308
+ }
309
+ void this.emit({
310
+ role: "runner",
311
+ kind: "status",
312
+ content: "A supervised provider tool is still running.",
313
+ eventType: "provider_tool_heartbeat",
314
+ }).catch(() => undefined);
315
+ }, Math.max(1, this.intervalMs));
316
+ }
317
+ }
55
318
  const fakeWorkerModel = "server-local-fake-worker";
56
319
  export class RoutingProviderCliService {
57
320
  realProvider;
@@ -94,9 +357,15 @@ export class RoutingProviderCliService {
94
357
  export class ProcessBackedProviderCliService {
95
358
  processRunner;
96
359
  git;
97
- constructor(processRunner = new StreamingProviderProcessRunner(), git = new GitClient()) {
360
+ providerToolHeartbeatIntervalMs;
361
+ publicationCoordinator;
362
+ prePublicationValidator;
363
+ constructor(processRunner = new StreamingProviderProcessRunner(), git = new GitClient(), providerToolHeartbeatIntervalMs = defaultProviderToolHeartbeatIntervalMs, publicationCoordinator = new RepositoryCoordinatorLock(defaultWorktreeCoordinatorStateRoot()), prePublicationValidator = new PrePublicationValidator()) {
98
364
  this.processRunner = processRunner;
99
365
  this.git = git;
366
+ this.providerToolHeartbeatIntervalMs = providerToolHeartbeatIntervalMs;
367
+ this.publicationCoordinator = publicationCoordinator;
368
+ this.prePublicationValidator = prePublicationValidator;
100
369
  }
101
370
  async runDraft(context) {
102
371
  const { dispatch } = context;
@@ -110,15 +379,18 @@ export class ProcessBackedProviderCliService {
110
379
  await fs.writeFile(path.join(metadataRoot, "spec.md"), buildDraftSpec(dispatch), "utf8");
111
380
  const stagedAttachments = await stageDraftAttachments(dispatch, metadataRoot);
112
381
  const decomposeDraft = isDecomposeDraftDispatch(dispatch);
113
- const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase: decomposeDraft ? "decompose" : "draft" });
382
+ const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase: decomposeDraft ? "decompose" : "draft", runtimeEnvironment: context.runtimeEnvironment });
114
383
  const draftPrompt = buildDraftPrompt(dispatch, { attachmentReferences: stagedAttachments.textReferences });
115
- const pipePromptToStdin = shouldPipePromptToStdin(dispatch.execution.provider, draftPrompt);
384
+ const providerPrompt = buildProviderCliPrompt(dispatch, draftPrompt, context.runtimeEnvironment);
385
+ const pipePromptToStdin = shouldPipePromptToStdin(dispatch.execution.provider, providerPrompt, usesManagedWindowsHost(dispatch));
116
386
  const command = buildProviderCliCommand(dispatch, {
117
387
  worktreePath: worktree.worktreePath,
118
- prompt: pipePromptToStdin ? "-" : draftPrompt,
388
+ prompt: draftPrompt,
389
+ promptFromStdin: pipePromptToStdin,
119
390
  completionTools: completionTools ?? undefined,
120
391
  skipGitRepoCheck: false,
121
- imagePaths: stagedAttachments.imagePaths
392
+ imagePaths: stagedAttachments.imagePaths,
393
+ runtimeEnvironment: context.runtimeEnvironment
122
394
  });
123
395
  const draftArtifactSlugs = dispatch.artifacts
124
396
  .filter((artifact) => artifact.scope === "operational" && ["command", "skill", "hook"].includes(artifact.kind))
@@ -133,7 +405,7 @@ export class ProcessBackedProviderCliService {
133
405
  role: "runner",
134
406
  kind: "checkpoint",
135
407
  content: `Starting provider CLI Draft: ${formatProviderCliCommandForCheckpoint(command)}`,
136
- eventType: "provider_cli_started"
408
+ eventType: "provider_cli_starting"
137
409
  });
138
410
  await context.emit({
139
411
  role: "runner",
@@ -144,6 +416,7 @@ export class ProcessBackedProviderCliService {
144
416
  eventType: "completion_tools_configured"
145
417
  });
146
418
  const outputState = createProviderOutputState(dispatch.execution.provider);
419
+ const toolHeartbeat = new ProviderToolActivityHeartbeat(outputState, context.emit, this.providerToolHeartbeatIntervalMs);
147
420
  try {
148
421
  const providerCancellation = new AbortController();
149
422
  const abortProvider = () => providerCancellation.abort();
@@ -154,17 +427,23 @@ export class ProcessBackedProviderCliService {
154
427
  context.signal?.addEventListener("abort", abortProvider, { once: true });
155
428
  }
156
429
  try {
157
- const runPromise = this.processRunner.run({
430
+ const runPromise = (context.processRunner ?? this.processRunner).run({
158
431
  command: command.command,
159
432
  args: command.args,
160
433
  cwd: worktree.worktreePath,
161
- env: providerProcessEnv(dispatch),
162
- stdin: pipePromptToStdin ? draftPrompt : undefined
434
+ env: providerProcessEnv(dispatch, context.runtimeEnvironment),
435
+ stdin: pipePromptToStdin ? providerPrompt : undefined
163
436
  }, {
437
+ onStarted: async () => {
438
+ await context.emit({
439
+ role: "runner",
440
+ kind: "checkpoint",
441
+ content: "Provider CLI Draft is supervised and ready.",
442
+ eventType: "provider_cli_started"
443
+ });
444
+ },
164
445
  onStdoutLine: async (line) => {
165
- for (const event of outputState.consume(line)) {
166
- await context.emit(event);
167
- }
446
+ await toolHeartbeat.consume(line);
168
447
  }
169
448
  }, providerCancellation.signal);
170
449
  const artifactWaitCancellation = new AbortController();
@@ -199,7 +478,7 @@ export class ProcessBackedProviderCliService {
199
478
  context.signal?.removeEventListener("abort", abortArtifactWait);
200
479
  }
201
480
  if (result.exitCode !== 0 && !earlyProposalArtifact) {
202
- throw new Error(`Provider CLI exited with code ${result.exitCode}${summarizeStderr(result.stderr)}.`);
481
+ throw providerCliExitFailure(command.command, result);
203
482
  }
204
483
  await context.emit({
205
484
  role: "runner",
@@ -260,6 +539,7 @@ export class ProcessBackedProviderCliService {
260
539
  }
261
540
  }
262
541
  finally {
542
+ toolHeartbeat.stop();
263
543
  await cleanupCompletionTools(completionTools, context.emit);
264
544
  }
265
545
  }
@@ -278,21 +558,26 @@ export class ProcessBackedProviderCliService {
278
558
  const specPath = path.join(metadataRoot, "spec.md");
279
559
  await fs.writeFile(specPath, specBody, "utf8");
280
560
  await fs.writeFile(path.join(metadataRoot, "spec.meta.json"), JSON.stringify({ schema_version: 1, status: "approved", produced_by: "forge-lane-command" }) + "\n", "utf8");
281
- const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase });
561
+ const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase, runtimeEnvironment: context.runtimeEnvironment });
562
+ const implementationPrompt = debugCycle
563
+ ? buildDebugCyclePrompt(dispatch, phase)
564
+ : developCycle
565
+ ? buildDevelopCyclePrompt(dispatch, phase)
566
+ : buildImplementationPrompt(dispatch, phase);
567
+ const providerPrompt = buildProviderCliPrompt(dispatch, implementationPrompt, context.runtimeEnvironment);
568
+ const pipePromptToStdin = shouldPipePromptToStdin(dispatch.execution.provider, providerPrompt, usesManagedWindowsHost(dispatch));
282
569
  const command = buildProviderCliCommand(dispatch, {
283
570
  worktreePath: worktree.worktreePath,
284
- prompt: debugCycle
285
- ? buildDebugCyclePrompt(dispatch, phase)
286
- : developCycle
287
- ? buildDevelopCyclePrompt(dispatch, phase)
288
- : buildImplementationPrompt(dispatch, phase),
289
- completionTools
571
+ prompt: implementationPrompt,
572
+ promptFromStdin: pipePromptToStdin,
573
+ completionTools,
574
+ runtimeEnvironment: context.runtimeEnvironment
290
575
  });
291
576
  await context.emit({
292
577
  role: "runner",
293
578
  kind: "checkpoint",
294
579
  content: `Starting provider CLI: ${formatProviderCliCommandForCheckpoint(command)} in ${worktree.worktreePath}`,
295
- eventType: "provider_cli_started"
580
+ eventType: "provider_cli_starting"
296
581
  });
297
582
  await context.emit({
298
583
  role: "runner",
@@ -305,30 +590,38 @@ export class ProcessBackedProviderCliService {
305
590
  eventType: "completion_tools_configured"
306
591
  });
307
592
  const outputState = createProviderOutputState(dispatch.execution.provider);
593
+ const toolHeartbeat = new ProviderToolActivityHeartbeat(outputState, context.emit, this.providerToolHeartbeatIntervalMs);
308
594
  const liveCompletionEvents = new LiveCompletionArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, phase, developCycle, debugCycle, context.emit);
309
595
  let result;
310
596
  try {
311
597
  liveCompletionEvents.start();
312
- result = await this.processRunner.run({
598
+ result = await (context.processRunner ?? this.processRunner).run({
313
599
  command: command.command,
314
600
  args: command.args,
315
601
  cwd: worktree.worktreePath,
316
- env: providerProcessEnv(dispatch)
602
+ env: providerProcessEnv(dispatch, context.runtimeEnvironment),
603
+ stdin: pipePromptToStdin ? providerPrompt : undefined
317
604
  }, {
605
+ onStarted: async () => {
606
+ await context.emit({
607
+ role: "runner",
608
+ kind: "checkpoint",
609
+ content: "Provider CLI is supervised and ready.",
610
+ eventType: "provider_cli_started"
611
+ });
612
+ },
318
613
  onStdoutLine: async (line) => {
319
- for (const event of outputState.consume(line)) {
320
- await context.emit(event);
321
- }
614
+ await toolHeartbeat.consume(line);
322
615
  }
323
616
  }, context.signal);
324
617
  }
325
618
  finally {
619
+ toolHeartbeat.stop();
326
620
  await liveCompletionEvents.stop();
327
621
  await cleanupCompletionTools(completionTools, context.emit);
328
622
  }
329
623
  if (result.exitCode !== 0) {
330
- const stderr = clip(result.stderr.trim() || result.stdout.trim(), 1200);
331
- throw new Error(`${command.command} exited ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
624
+ throw providerCliExitFailure(command.command, result);
332
625
  }
333
626
  await context.emit({
334
627
  role: "runner",
@@ -337,9 +630,12 @@ export class ProcessBackedProviderCliService {
337
630
  eventType: "provider_cli_completed"
338
631
  });
339
632
  await emitProviderUsageEvent(context.emit, dispatch, outputState);
633
+ if (developCycle || phase === "implementation") {
634
+ await this.prePublicationValidator.validate(worktree.worktreePath, "implementation", publicationCapabilities(context, context.runtimeEnvironment), context.emit, context.signal, prePublicationExecution(context, providerProcessEnv(dispatch, context.runtimeEnvironment), this.processRunner));
635
+ }
340
636
  const committed = await commitWorktreeChanges(this.git, worktree.worktreePath, dispatch.workItem.id);
341
- if (developCycle || debugCycle || phase === "implementation") {
342
- await pushWorktreeBranch(this.git, worktree.worktreePath, worktree.branchName, context.emit);
637
+ if ((developCycle || debugCycle || phase === "implementation") && providerOwnsBranchPublication(context)) {
638
+ await pushWorktreeBranch(this.git, worktree.worktreePath, worktree.branchName, worktree.repoPath, this.publicationCoordinator, context.signal, context.emit);
343
639
  await context.emit({
344
640
  role: "runner",
345
641
  kind: "checkpoint",
@@ -397,18 +693,23 @@ export class ProcessBackedProviderCliService {
397
693
  const specPath = path.join(metadataRoot, "spec.md");
398
694
  await fs.writeFile(specPath, specBody, "utf8");
399
695
  await fs.writeFile(path.join(metadataRoot, "spec.meta.json"), JSON.stringify({ schema_version: 1, status: "pending_approval", produced_by: "forge-lane-command" }) + "\n", "utf8");
400
- const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase: "architect" });
696
+ const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase: "architect", runtimeEnvironment: context.runtimeEnvironment });
697
+ const architectPrompt = buildArchitectPrompt(dispatch);
698
+ const providerPrompt = buildProviderCliPrompt(dispatch, architectPrompt, context.runtimeEnvironment);
699
+ const pipePromptToStdin = shouldPipePromptToStdin(dispatch.execution.provider, providerPrompt, usesManagedWindowsHost(dispatch));
401
700
  const command = buildProviderCliCommand(dispatch, {
402
701
  worktreePath: worktree.worktreePath,
403
- prompt: buildArchitectPrompt(dispatch),
404
- completionTools
702
+ prompt: architectPrompt,
703
+ promptFromStdin: pipePromptToStdin,
704
+ completionTools,
705
+ runtimeEnvironment: context.runtimeEnvironment
405
706
  });
406
707
  const commandText = formatProviderCliCommandForCheckpoint(command);
407
708
  await context.emit({
408
709
  role: "runner",
409
710
  kind: "checkpoint",
410
711
  content: `Starting provider CLI Architect: ${commandText} in ${worktree.worktreePath}`,
411
- eventType: "provider_cli_started"
712
+ eventType: "provider_cli_starting"
412
713
  });
413
714
  await context.emit({
414
715
  role: "runner",
@@ -417,30 +718,36 @@ export class ProcessBackedProviderCliService {
417
718
  eventType: "completion_tools_configured"
418
719
  });
419
720
  const outputState = createProviderOutputState(dispatch.execution.provider);
721
+ const toolHeartbeat = new ProviderToolActivityHeartbeat(outputState, context.emit, this.providerToolHeartbeatIntervalMs);
420
722
  const liveArchitectEvents = new LiveArchitectArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, context.emit);
421
723
  let result;
422
724
  try {
423
725
  liveArchitectEvents.start();
424
- result = await this.processRunner.run({
726
+ result = await (context.processRunner ?? this.processRunner).run({
425
727
  command: command.command,
426
728
  args: command.args,
427
729
  cwd: worktree.worktreePath,
428
- env: providerProcessEnv(dispatch)
730
+ env: providerProcessEnv(dispatch, context.runtimeEnvironment),
731
+ stdin: pipePromptToStdin ? providerPrompt : undefined
429
732
  }, {
430
- onStdoutLine: async (line) => {
431
- for (const event of outputState.consume(line)) {
432
- await context.emit(event);
433
- }
434
- }
733
+ onStarted: async () => {
734
+ await context.emit({
735
+ role: "runner",
736
+ kind: "checkpoint",
737
+ content: "Provider CLI Architect is supervised and ready.",
738
+ eventType: "provider_cli_started"
739
+ });
740
+ },
741
+ onStdoutLine: async (line) => await toolHeartbeat.consume(line)
435
742
  }, context.signal);
436
743
  }
437
744
  finally {
745
+ toolHeartbeat.stop();
438
746
  await liveArchitectEvents.stop();
439
747
  await cleanupCompletionTools(completionTools, context.emit);
440
748
  }
441
749
  if (result.exitCode !== 0) {
442
- const stderr = clip(result.stderr.trim() || result.stdout.trim(), 1200);
443
- throw new Error(`${command.command} exited ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
750
+ throw providerCliExitFailure(command.command, result);
444
751
  }
445
752
  await context.emit({
446
753
  role: "runner",
@@ -488,18 +795,23 @@ export class ProcessBackedProviderCliService {
488
795
  const specPath = path.join(metadataRoot, "spec.md");
489
796
  await fs.writeFile(specPath, specBody, "utf8");
490
797
  await fs.writeFile(path.join(metadataRoot, "spec.meta.json"), JSON.stringify({ schema_version: 1, status: "approved", produced_by: "forge-lane-command" }) + "\n", "utf8");
491
- const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase: "qa" });
798
+ const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase: "qa", runtimeEnvironment: context.runtimeEnvironment });
799
+ const qaPrompt = buildQaPrompt(dispatch, specBody);
800
+ const providerPrompt = buildProviderCliPrompt(dispatch, qaPrompt, context.runtimeEnvironment);
801
+ const pipePromptToStdin = shouldPipePromptToStdin(dispatch.execution.provider, providerPrompt, usesManagedWindowsHost(dispatch));
492
802
  const command = buildProviderCliCommand(dispatch, {
493
803
  worktreePath: worktree.worktreePath,
494
- prompt: buildQaPrompt(dispatch, specBody),
495
- completionTools
804
+ prompt: qaPrompt,
805
+ promptFromStdin: pipePromptToStdin,
806
+ completionTools,
807
+ runtimeEnvironment: context.runtimeEnvironment
496
808
  });
497
809
  const commandText = formatProviderCliCommandForCheckpoint(command);
498
810
  await context.emit({
499
811
  role: "runner",
500
812
  kind: "checkpoint",
501
813
  content: `Starting provider CLI QA: ${commandText} in ${worktree.worktreePath}`,
502
- eventType: "provider_cli_started"
814
+ eventType: "provider_cli_starting"
503
815
  });
504
816
  await context.emit({
505
817
  role: "runner",
@@ -508,30 +820,39 @@ export class ProcessBackedProviderCliService {
508
820
  eventType: "completion_tools_configured"
509
821
  });
510
822
  const outputState = createProviderOutputState(dispatch.execution.provider);
823
+ const toolHeartbeat = new ProviderToolActivityHeartbeat(outputState, context.emit, this.providerToolHeartbeatIntervalMs);
824
+ const verificationProgress = new ManagedVerificationProgressEmitter(context.runtimeEnvironment?.FORGE_LOG_DIR, context.emit);
511
825
  const liveQaEvents = new LiveQaArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, dispatch, context.emit);
512
826
  let result;
513
827
  try {
514
828
  liveQaEvents.start();
515
- result = await this.processRunner.run({
829
+ verificationProgress.start();
830
+ result = await (context.processRunner ?? this.processRunner).run({
516
831
  command: command.command,
517
832
  args: command.args,
518
833
  cwd: worktree.worktreePath,
519
- env: providerProcessEnv(dispatch)
834
+ env: providerProcessEnv(dispatch, context.runtimeEnvironment),
835
+ stdin: pipePromptToStdin ? providerPrompt : undefined
520
836
  }, {
521
- onStdoutLine: async (line) => {
522
- for (const event of outputState.consume(line)) {
523
- await context.emit(event);
524
- }
525
- }
837
+ onStarted: async () => {
838
+ await context.emit({
839
+ role: "runner",
840
+ kind: "checkpoint",
841
+ content: "Provider CLI QA is supervised and ready.",
842
+ eventType: "provider_cli_started"
843
+ });
844
+ },
845
+ onStdoutLine: async (line) => await toolHeartbeat.consume(line)
526
846
  }, context.signal);
527
847
  }
528
848
  finally {
849
+ toolHeartbeat.stop();
850
+ await verificationProgress.stop();
529
851
  await liveQaEvents.stop();
530
852
  await cleanupCompletionTools(completionTools, context.emit);
531
853
  }
532
854
  if (result.exitCode !== 0) {
533
- const stderr = clip(result.stderr.trim() || result.stdout.trim(), 1200);
534
- throw new Error(`${command.command} exited ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
855
+ throw providerCliExitFailure(command.command, result);
535
856
  }
536
857
  await context.emit({
537
858
  role: "runner",
@@ -547,7 +868,7 @@ export class ProcessBackedProviderCliService {
547
868
  ? "Provider CLI QA completed but did not produce the complete durable QA artifact bundle."
548
869
  : "Provider CLI QA completed but did not call the Forge QA completion tool.");
549
870
  }
550
- enforceQaBrowserLaunchTargetGate(dispatch, qaArtifact);
871
+ await enforceQaBrowserLaunchTargetGate(dispatch, qaArtifact);
551
872
  if (!liveQaEvents.hasEmitted()) {
552
873
  await emitQaArtifactRecorded(context.emit, worktree.worktreePath, qaArtifact);
553
874
  await emitQaStructuredEvents(context.emit, qaArtifact);
@@ -579,41 +900,126 @@ export class ProcessBackedProviderCliService {
579
900
  }
580
901
  async runPullRequestRepair(context, route) {
581
902
  const { dispatch, worktree } = context;
903
+ const { expectedRepairHeadSha, preparedHeadSha } = await assertRepairHeadEpoch(context, this.git);
582
904
  const repairChecks = route === "ci_repair" || isPullRequestCheckRepairDispatch(dispatch);
583
905
  const addressComments = !repairChecks && isPullRequestCommentsDispatch(dispatch);
906
+ let repairSpec = null;
907
+ let repairEnvironment = context.runtimeEnvironment;
908
+ let completionTools = null;
909
+ if (repairChecks) {
910
+ const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
911
+ await fs.mkdir(metadataRoot, { recursive: true });
912
+ const laneCommand = selectLaneCommand(dispatch.artifacts, dispatch.execution.lane, dispatch.cycleRun.phase);
913
+ if (!laneCommand) {
914
+ throw new Error(`CI repair readiness failed: lane command for '${dispatch.execution.lane}' was not resolved, so the approved spec cannot be materialized.`);
915
+ }
916
+ const specBody = buildSpecBody(laneCommand, dispatch.workItem, "ci_repair");
917
+ const specPath = path.join(metadataRoot, "spec.md");
918
+ await fs.writeFile(specPath, specBody, "utf8");
919
+ await fs.writeFile(path.join(metadataRoot, "spec.meta.json"), `${JSON.stringify({ schema_version: 1, status: "approved", produced_by: "forge-lane-command" })}\n`, "utf8");
920
+ repairSpec = {
921
+ repoPath: worktree.worktreePath,
922
+ path: specPath,
923
+ body: specBody,
924
+ producedBy: "forge-lane-command",
925
+ status: "approved",
926
+ };
927
+ const readiness = await writeCiRepairReadinessManifest(dispatch, worktree.worktreePath, specPath, context.runtimeEnvironment);
928
+ repairEnvironment = readiness.environment;
929
+ completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase: "ci_repair", runtimeEnvironment: repairEnvironment });
930
+ await context.emit({
931
+ role: "runner",
932
+ kind: "checkpoint",
933
+ content: `CI repair readiness contract prepared at ${path.relative(worktree.worktreePath, readiness.path).replaceAll(path.sep, "/")}; approved spec, attempt URLs, browser readiness, and local-equivalence policy are available before provider start.`,
934
+ eventType: "ci_repair_readiness_ready",
935
+ });
936
+ await context.emit({
937
+ role: "runner",
938
+ kind: "checkpoint",
939
+ content: "Forge CI repair completion tool configured; provider must call forge.complete_ci_repair before Bridge-owned commit and push.",
940
+ eventType: "completion_tools_configured",
941
+ });
942
+ }
943
+ const repairPrompt = repairChecks
944
+ ? buildPullRequestCheckRepairPrompt(dispatch, worktree.branchName)
945
+ : addressComments
946
+ ? buildPullRequestCommentsPrompt(dispatch, worktree.branchName)
947
+ : buildConflictResolvePrompt(dispatch, worktree.branchName);
948
+ const providerPrompt = buildProviderCliPrompt(dispatch, repairPrompt, repairEnvironment);
949
+ const pipePromptToStdin = shouldPipePromptToStdin(dispatch.execution.provider, providerPrompt, usesManagedWindowsHost(dispatch));
584
950
  const command = buildProviderCliCommand(dispatch, {
585
951
  worktreePath: worktree.worktreePath,
586
- prompt: repairChecks
587
- ? buildPullRequestCheckRepairPrompt(dispatch, worktree.branchName)
588
- : addressComments
589
- ? buildPullRequestCommentsPrompt(dispatch, worktree.branchName)
590
- : buildConflictResolvePrompt(dispatch, worktree.branchName)
952
+ prompt: repairPrompt,
953
+ promptFromStdin: pipePromptToStdin,
954
+ completionTools: completionTools ?? undefined,
955
+ runtimeEnvironment: repairEnvironment
591
956
  });
592
957
  await context.emit({
593
958
  role: "runner",
594
959
  kind: "checkpoint",
595
960
  content: `Starting provider CLI ${repairChecks ? "CI repair" : addressComments ? "PR comment address" : "conflict resolve"}: ${formatProviderCliCommandForCheckpoint(command)} in ${worktree.worktreePath}`,
596
- eventType: "provider_cli_started"
961
+ eventType: "provider_cli_starting"
597
962
  });
598
963
  const outputState = createProviderOutputState(dispatch.execution.provider);
599
- const result = await this.processRunner.run({
600
- command: command.command,
601
- args: command.args,
602
- cwd: worktree.worktreePath,
603
- env: providerProcessEnv(dispatch)
604
- }, {
605
- onStdoutLine: async (line) => {
606
- for (const event of outputState.consume(line)) {
607
- await context.emit(event);
964
+ const toolHeartbeat = new ProviderToolActivityHeartbeat(outputState, context.emit, this.providerToolHeartbeatIntervalMs);
965
+ let result;
966
+ try {
967
+ result = await (context.processRunner ?? this.processRunner).run({
968
+ command: command.command,
969
+ args: command.args,
970
+ cwd: worktree.worktreePath,
971
+ env: providerProcessEnv(dispatch, repairEnvironment),
972
+ stdin: pipePromptToStdin ? providerPrompt : undefined
973
+ }, {
974
+ onStarted: async () => {
975
+ await context.emit({
976
+ role: "runner",
977
+ kind: "checkpoint",
978
+ content: `Provider CLI ${repairChecks ? "CI repair" : addressComments ? "PR comment address" : "conflict resolve"} is supervised and ready.`,
979
+ eventType: "provider_cli_started"
980
+ });
981
+ },
982
+ onStdoutLine: async (line) => {
983
+ await toolHeartbeat.consume(line);
608
984
  }
985
+ }, context.signal);
986
+ }
987
+ finally {
988
+ toolHeartbeat.stop();
989
+ if (completionTools) {
990
+ await cleanupCompletionTools(completionTools, context.emit);
609
991
  }
610
- }, context.signal);
992
+ }
611
993
  if (result.exitCode !== 0) {
612
- const stderr = clip(result.stderr.trim() || result.stdout.trim(), 1200);
613
- throw new Error(`${command.command} exited ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
994
+ throw providerCliExitFailure(command.command, result);
995
+ }
996
+ const postProviderHeadSha = (await this.git.run(worktree.worktreePath, ["rev-parse", "HEAD"])).trim();
997
+ if (postProviderHeadSha.toLowerCase() !== preparedHeadSha.toLowerCase()) {
998
+ throw new Error(`provider_publication_boundary_violation: PR repair changed HEAD from ${preparedHeadSha} to ${postProviderHeadSha}; Forge requires provider changes to remain uncommitted and unpushed until validation passes.`);
614
999
  }
1000
+ let repairArtifact = null;
1001
+ if (repairChecks) {
1002
+ repairArtifact = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, "ci_repair");
1003
+ if (!repairArtifact) {
1004
+ throw new Error("Provider CLI completed but did not call forge.complete_ci_repair. Forge did not commit or push the repair.");
1005
+ }
1006
+ if ((repairArtifact.status ?? "").trim().toLowerCase() !== "pass") {
1007
+ throw new Error(`CI repair completion gate recorded ${repairArtifact.status}; Forge did not commit or push the repair.`);
1008
+ }
1009
+ await emitCompletionArtifactRecorded(context.emit, worktree.worktreePath, repairArtifact);
1010
+ await emitPhaseCompletionEvent(context.emit, repairArtifact);
1011
+ await emitPhaseGateEvents(context.emit, repairArtifact);
1012
+ }
1013
+ const validationProfile = repairChecks
1014
+ ? "ci_repair"
1015
+ : addressComments
1016
+ ? "comment_repair"
1017
+ : "conflict_repair";
1018
+ await this.prePublicationValidator.validate(worktree.worktreePath, validationProfile, publicationCapabilities(context, repairEnvironment), context.emit, context.signal, prePublicationExecution(context, providerProcessEnv(dispatch, repairEnvironment), this.processRunner));
615
1019
  const committed = await commitWorktreeChanges(this.git, worktree.worktreePath, dispatch.workItem.id);
616
- await pushWorktreeBranch(this.git, worktree.worktreePath, worktree.branchName, context.emit);
1020
+ if (providerOwnsBranchPublication(context)) {
1021
+ await pushWorktreeBranch(this.git, worktree.worktreePath, worktree.branchName, worktree.repoPath, this.publicationCoordinator, context.signal, context.emit, expectedRepairHeadSha);
1022
+ }
617
1023
  await emitProviderUsageEvent(context.emit, dispatch, outputState);
618
1024
  await context.emit({
619
1025
  role: "assistant",
@@ -630,10 +1036,24 @@ export class ProcessBackedProviderCliService {
630
1036
  ? `${dispatch.execution.provider} CLI repaired PR checks on ${worktree.branchName} (${committed.commitSha.slice(0, 12)}).`
631
1037
  : addressComments
632
1038
  ? `${dispatch.execution.provider} CLI addressed PR comments on ${worktree.branchName} (${committed.commitSha.slice(0, 12)}).`
633
- : `${dispatch.execution.provider} CLI resolved conflicts on ${worktree.branchName} (${committed.commitSha.slice(0, 12)}).`
1039
+ : `${dispatch.execution.provider} CLI resolved conflicts on ${worktree.branchName} (${committed.commitSha.slice(0, 12)}).`,
1040
+ spec: repairSpec,
1041
+ artifact: repairArtifact,
1042
+ artifacts: repairArtifact ? [repairArtifact] : [],
634
1043
  };
635
1044
  }
636
1045
  }
1046
+ function publicationCapabilities(context, runtimeEnvironment) {
1047
+ const capabilities = new Set();
1048
+ const launchTarget = context.dispatch.qaBrowser?.launchTarget;
1049
+ if (runtimeEnvironment?.FORGE_PLAYWRIGHT_CONFIG
1050
+ || launchTarget?.url
1051
+ || launchTarget?.command
1052
+ || launchTarget?.managed_runtime_port) {
1053
+ capabilities.add("browser_launch");
1054
+ }
1055
+ return capabilities;
1056
+ }
637
1057
  async function stageDraftAttachments(dispatch, metadataRoot) {
638
1058
  const attachments = dispatch.draft?.attachments ?? [];
639
1059
  const stageable = attachments.filter((attachment) => (attachment.kind === "text" && typeof attachment.textContent === "string")
@@ -696,8 +1116,12 @@ function clipFileName(fileName, maxLength) {
696
1116
  return `${stem.slice(0, maxStemLength)}${extension}`;
697
1117
  }
698
1118
  const providerPromptStdinThreshold = 8_000;
699
- function shouldPipePromptToStdin(provider, prompt) {
700
- return provider.trim().toLowerCase() === "codex" && Buffer.byteLength(prompt, "utf8") > providerPromptStdinThreshold;
1119
+ function shouldPipePromptToStdin(provider, prompt, force = false) {
1120
+ return provider.trim().toLowerCase() === "codex"
1121
+ && (force || Buffer.byteLength(prompt, "utf8") > providerPromptStdinThreshold);
1122
+ }
1123
+ function usesManagedWindowsHost(dispatch) {
1124
+ return process.platform === "win32" && dispatch.sandbox?.executionMode === "host";
701
1125
  }
702
1126
  function isFakeWorkerModel(model) {
703
1127
  return model.trim().toLowerCase() === fakeWorkerModel;