@loucompanion/forge-bridge 0.1.3-dev.9bc5014a2270 → 0.1.3-dev.a58d7cd1092f

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