@loucompanion/forge-bridge 0.1.3-dev.fbe15023cdfe → 0.1.4-dev.04a1e5b9e484
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -2
- package/dist/bridge/bridge-chat.types.d.ts +111 -0
- package/dist/bridge/bridge-chat.types.js +401 -0
- package/dist/bridge/bridge.base.d.ts +4 -1
- package/dist/bridge/bridge.service.d.ts +72 -3
- package/dist/bridge/bridge.service.js +1563 -152
- package/dist/bridge/bridge.types.d.ts +46 -1
- package/dist/bridge/dispatch-result-outbox.d.ts +25 -0
- package/dist/bridge/dispatch-result-outbox.js +62 -0
- package/dist/bridge/dispatch-result-worker.d.ts +2 -0
- package/dist/bridge/dispatch-result-worker.js +122 -0
- package/dist/bridge/internals.d.ts +2 -0
- package/dist/bridge/internals.js +1 -0
- package/dist/bridge/readiness-result-outbox.d.ts +21 -0
- package/dist/bridge/readiness-result-outbox.js +54 -0
- package/dist/bridge/readiness-result-worker.d.ts +2 -0
- package/dist/bridge/readiness-result-worker.js +121 -0
- package/dist/bridge/runner-host-capabilities.d.ts +15 -0
- package/dist/bridge/runner-host-capabilities.js +93 -0
- package/dist/cleanup/cleanup.service.d.ts +1 -0
- package/dist/cleanup/cleanup.service.js +70 -9
- package/dist/cli/cli.help.js +2 -2
- package/dist/cli/cli.service.d.ts +14 -6
- package/dist/cli/cli.service.js +75 -16
- package/dist/cli/local-config.d.ts +1 -0
- package/dist/cli/local-config.js +4 -0
- package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
- package/dist/runtime/aspnet-runtime-prebuild.js +341 -0
- package/dist/runtime/attempt-database-broker.d.ts +89 -0
- package/dist/runtime/attempt-database-broker.js +390 -0
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +2 -1
- package/dist/runtime/attempt-node-runtime-launcher.js +57 -18
- package/dist/runtime/attempt-path-operation.d.ts +21 -0
- package/dist/runtime/attempt-path-operation.js +94 -0
- package/dist/runtime/attempt-private-directory.d.ts +6 -0
- package/dist/runtime/attempt-private-directory.js +54 -0
- package/dist/runtime/attempt-process-supervisor.d.ts +16 -0
- package/dist/runtime/attempt-process-supervisor.js +94 -23
- package/dist/runtime/canonical-source-file.d.ts +3 -0
- package/dist/runtime/canonical-source-file.js +54 -0
- package/dist/runtime/docker-engine-probe.d.ts +90 -0
- package/dist/runtime/docker-engine-probe.js +333 -0
- package/dist/runtime/generated-output-reset.d.ts +10 -0
- package/dist/runtime/generated-output-reset.js +62 -8
- package/dist/runtime/host-database-capability-boundary.d.ts +16 -0
- package/dist/runtime/host-database-capability-boundary.js +62 -0
- package/dist/runtime/host-diagnostics.d.ts +11 -0
- package/dist/runtime/host-diagnostics.js +192 -0
- package/dist/runtime/host-execution-root.d.ts +4 -0
- package/dist/runtime/host-execution-root.js +39 -0
- package/dist/runtime/host-listener-inspector.d.ts +8 -1
- package/dist/runtime/host-listener-inspector.js +121 -60
- package/dist/runtime/host-runtime-contract.d.ts +12 -1
- package/dist/runtime/host-runtime-contract.js +168 -55
- package/dist/runtime/host-runtime-lease.d.ts +92 -8
- package/dist/runtime/host-runtime-lease.js +1141 -114
- package/dist/runtime/host-runtime-promotion.d.ts +12 -3
- package/dist/runtime/host-runtime-promotion.js +59 -12
- package/dist/runtime/memory-capacity.d.ts +70 -0
- package/dist/runtime/memory-capacity.js +123 -0
- package/dist/runtime/native-test-postgres-fallback.d.ts +25 -0
- package/dist/runtime/native-test-postgres-fallback.js +91 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +3 -0
- package/dist/runtime/node-host-runtime-adapter.js +29 -11
- package/dist/runtime/pinned-directory-process.d.ts +9 -0
- package/dist/runtime/pinned-directory-process.js +61 -0
- package/dist/runtime/pinned-source-directory.d.ts +17 -0
- package/dist/runtime/pinned-source-directory.js +94 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +1 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +53 -5
- package/dist/runtime/prepared-runtime-identity.d.ts +6 -0
- package/dist/runtime/prepared-runtime-identity.js +218 -0
- package/dist/runtime/prepared-runtime-readiness.d.ts +30 -0
- package/dist/runtime/prepared-runtime-readiness.js +285 -0
- package/dist/runtime/runtime-isolation-lease.js +4 -1
- package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
- package/dist/runtime/workspace-resource-estimator.js +174 -0
- package/dist/sandbox/preflight.d.ts +31 -1
- package/dist/sandbox/preflight.js +237 -51
- package/dist/sandbox/repository-lock.d.ts +2 -1
- package/dist/sandbox/repository-lock.js +19 -9
- package/dist/sandbox/runtime.d.ts +6 -1
- package/dist/sandbox/runtime.js +137 -107
- package/dist/sandbox/sandbox.types.d.ts +7 -0
- package/dist/sandbox/workspace.service.js +2 -0
- package/dist/session/managed-attempt-tools.d.ts +17 -1
- package/dist/session/managed-attempt-tools.js +489 -34
- package/dist/session/managed-build-coordinator.d.ts +5 -0
- package/dist/session/managed-build-coordinator.js +106 -0
- package/dist/session/managed-build-receipt.d.ts +21 -0
- package/dist/session/managed-build-receipt.js +337 -0
- package/dist/session/managed-verification-timeouts.d.ts +8 -0
- package/dist/session/managed-verification-timeouts.js +17 -0
- package/dist/session/phase-completion.artifacts.d.ts +17 -0
- package/dist/session/phase-completion.artifacts.js +224 -22
- package/dist/session/phase-completion.d.ts +11 -1
- package/dist/session/phase-completion.helpers.d.ts +5 -0
- package/dist/session/phase-completion.helpers.js +30 -7
- package/dist/session/phase-completion.js +14 -3
- package/dist/session/phase-completion.prompts.js +17 -18
- package/dist/session/phase-completion.runtime.d.ts +3 -1
- package/dist/session/phase-completion.runtime.js +126 -17
- package/dist/session/phase-completion.schemas.js +49 -2
- package/dist/session/phase-completion.validation.d.ts +1 -0
- package/dist/session/phase-completion.validation.js +111 -21
- package/dist/session/pre-publication-validation.d.ts +75 -0
- package/dist/session/pre-publication-validation.js +661 -0
- package/dist/session/provider-cli/bridge-cli.conformance-fixtures.d.ts +72 -0
- package/dist/session/provider-cli/bridge-cli.conformance-fixtures.js +462 -0
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +23 -3
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +4 -2
- package/dist/session/provider-cli/provider-cli.artifacts.js +47 -13
- package/dist/session/provider-cli/provider-cli.deterministic.js +34 -2
- package/dist/session/provider-cli/provider-cli.process.d.ts +51 -0
- package/dist/session/provider-cli/provider-cli.process.js +422 -22
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +5 -0
- package/dist/session/provider-cli/provider-cli.prompts.js +136 -16
- package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
- package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +14 -1
- package/dist/session/provider-cli/provider-cli.runtime.js +54 -3
- package/dist/session/provider-cli/provider-cli.service.d.ts +30 -5
- package/dist/session/provider-cli/provider-cli.service.js +773 -69
- package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +10 -0
- package/dist/session/provider-cli/provider-output-state.js +361 -17
- package/dist/session/provider-cli/provider-usage.d.ts +7 -0
- package/dist/session/provider-cli/provider-usage.js +28 -5
- package/dist/session/session-redaction.js +434 -9
- package/dist/session/session.service.js +145 -23
- package/dist/session/session.types.d.ts +147 -2
- package/dist/session/session.types.js +587 -7
- package/dist/session/unreferenced-exports.d.ts +12 -0
- package/dist/session/unreferenced-exports.js +59 -0
- package/dist/shared/format/format-bytes.d.ts +1 -0
- package/dist/shared/format/format-bytes.js +9 -0
- package/dist/shared/git/git.js +2 -1
- package/dist/shared/path/path.d.ts +1 -0
- package/dist/shared/path/path.js +18 -0
- package/dist/shared/process/dotnet-toolchain.d.ts +17 -0
- package/dist/shared/process/dotnet-toolchain.js +307 -0
- package/dist/shared/process/host-process-recovery.js +3 -2
- package/dist/shared/process/host-process-supervisor.d.ts +2 -0
- package/dist/shared/process/host-process-supervisor.js +60 -11
- package/dist/shared/process/process.d.ts +2 -0
- package/dist/shared/process/process.js +12 -7
- package/dist/shared/provider-capabilities.d.ts +3 -0
- package/dist/shared/provider-capabilities.js +85 -0
- package/dist/shared/redaction/redaction.d.ts +1 -0
- package/dist/shared/redaction/redaction.js +39 -3
- package/dist/shared/time/monotonic.d.ts +1 -0
- package/dist/shared/time/monotonic.js +8 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/dependency-warmer.d.ts +41 -9
- package/dist/worktree/dependency-warmer.js +497 -22
- package/dist/worktree/index.d.ts +1 -1
- package/dist/worktree/story-qa-reference-hydrator.d.ts +4 -0
- package/dist/worktree/story-qa-reference-hydrator.js +253 -0
- package/dist/worktree/worktree.service.d.ts +7 -0
- package/dist/worktree/worktree.service.js +253 -21
- package/dist/worktree/worktree.types.d.ts +7 -1
- package/package.json +7 -5
- package/runtime/windows-job-supervisor.ps1 +380 -14
|
@@ -1,47 +1,186 @@
|
|
|
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 {
|
|
8
|
+
import { ForgeFailureError } from "../session.types.js";
|
|
9
|
+
import { cleanupCompletionTools, commitWorktreeChanges, completionPhaseFromArtifact, emitArchitectStructuredEvents, emitCompletionArtifactRecorded, emitDecomposeGateArtifacts, emitDraftGateArtifacts, emitPhaseCompletionEvent, emitPhaseGateEvents, emitQaArtifactRecorded, emitQaStructuredEvents, enforceQaBrowserLaunchTargetGate, LiveArchitectArtifactEmitter, LiveCompletionArtifactEmitter, LiveQaArtifactEmitter, readCompletionArtifactsForRun, readQaCompletionArtifactBundle, stagePublishableWorktreeChanges, waitForCompletionArtifact } from "./provider-cli.artifacts.js";
|
|
7
10
|
import { DeterministicProviderCliService } from "./provider-cli.deterministic.js";
|
|
8
11
|
import { buildArchitectPrompt, buildConflictResolvePrompt, buildDebugCyclePrompt, buildDevelopCyclePrompt, buildDraftPrompt, buildDraftSpec, buildImplementationPrompt, buildProviderCliPrompt, buildProviderCliCommand, durableDraftInput, buildPullRequestCheckRepairPrompt, buildPullRequestCommentsPrompt, buildQaPrompt, formatProviderCliCommandForCheckpoint, isDecomposeDraftDispatch, isPullRequestCheckRepairDispatch, isPullRequestCommentsDispatch } from "./provider-cli.prompts.js";
|
|
9
|
-
import { StreamingProviderProcessRunner } from "./provider-cli.process.js";
|
|
10
|
-
import { emitProviderUsageEvent, providerProcessEnv } from "./provider-cli.runtime.js";
|
|
12
|
+
import { isTransientCodexProviderNotFoundFailure, recoveringProviderProcessRunner, StreamingProviderProcessRunner } from "./provider-cli.process.js";
|
|
13
|
+
import { emitProviderUsageEvent, prePublicationValidationEnv, providerProcessEnv } from "./provider-cli.runtime.js";
|
|
11
14
|
import { clip, createProviderOutputState, parseJsonLine } from "./provider-output-state.js";
|
|
12
15
|
import { writeCiRepairReadinessManifest } from "./ci-repair-readiness.js";
|
|
16
|
+
import { assertRepairHeadEpoch } from "./provider-cli.repair-epoch.js";
|
|
17
|
+
import { PrePublicationValidationError, PrePublicationValidator, } from "../pre-publication-validation.js";
|
|
18
|
+
const maxPrePublicationRepairTurns = 2;
|
|
19
|
+
const maxQaCompletionRecoveryTurns = 2;
|
|
20
|
+
function assertCommentRepairArtifactMatchesDispatch(dispatch, artifact) {
|
|
21
|
+
const pullRequest = dispatch.pullRequest;
|
|
22
|
+
if (!pullRequest)
|
|
23
|
+
throw new Error("Comment repair completion is missing pull-request context.");
|
|
24
|
+
const payload = JSON.parse(artifact.verdictJson);
|
|
25
|
+
if (payload.repair_head_sha !== pullRequest.headSha || payload.repair_issue_key !== pullRequest.reviewIssueKey) {
|
|
26
|
+
throw new Error("Comment repair completion head or issue key does not match the immutable dispatch snapshot.");
|
|
27
|
+
}
|
|
28
|
+
const expected = new Map(pullRequest.reviewFindings
|
|
29
|
+
.filter(finding => finding.actionable)
|
|
30
|
+
.map(finding => [`${finding.kind}:${finding.claimId}:${finding.bodySha256.toLowerCase()}`, finding]));
|
|
31
|
+
const dispositions = Array.isArray(payload.dispositions) ? payload.dispositions : [];
|
|
32
|
+
const actual = new Set();
|
|
33
|
+
for (const value of dispositions) {
|
|
34
|
+
const record = value;
|
|
35
|
+
const identity = `${String(record.kind ?? "")}:${String(record.claim_id ?? "")}:${String(record.body_sha256 ?? "").toLowerCase()}`;
|
|
36
|
+
if (actual.has(identity))
|
|
37
|
+
throw new Error("Comment repair completion contains a duplicate finding disposition.");
|
|
38
|
+
actual.add(identity);
|
|
39
|
+
const finding = expected.get(identity);
|
|
40
|
+
if (finding && !finding.contentAvailable && record.disposition !== "needs_decision") {
|
|
41
|
+
throw new Error("Comment repair completion cannot resolve or reject a finding whose content was unavailable.");
|
|
42
|
+
}
|
|
43
|
+
if (record.disposition === "owned_elsewhere") {
|
|
44
|
+
const owner = (record.owned_elsewhere ?? {});
|
|
45
|
+
if (!(pullRequest.openWorkOrderStories ?? []).some(story => story.workItemId.toLowerCase() === String(owner.target_work_item_id ?? "").toLowerCase())) {
|
|
46
|
+
throw new Error("Comment repair completion names an owned_elsewhere owner that is not an open Story in this Work Order.");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (expected.size !== actual.size || [...expected.keys()].some(identity => !actual.has(identity))) {
|
|
51
|
+
throw new Error("Comment repair completion must disposition every actionable dispatch finding exactly once and no other finding.");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function assertCommentRepairArtifactMatchesChanges(artifact, actualChangedFiles) {
|
|
55
|
+
const payload = JSON.parse(artifact.verdictJson);
|
|
56
|
+
const dispositions = Array.isArray(payload.dispositions) ? payload.dispositions : [];
|
|
57
|
+
const hasFixedDisposition = dispositions.some(value => {
|
|
58
|
+
const record = value;
|
|
59
|
+
return record.disposition === "fixed";
|
|
60
|
+
});
|
|
61
|
+
const claimedChangedFiles = Array.isArray(payload.changed_files)
|
|
62
|
+
? payload.changed_files.map(value => {
|
|
63
|
+
const record = value;
|
|
64
|
+
return String(record.path ?? "").trim().replaceAll("\\", "/");
|
|
65
|
+
})
|
|
66
|
+
: [];
|
|
67
|
+
const normalizedActual = actualChangedFiles.map(value => value.trim().replaceAll("\\", "/"));
|
|
68
|
+
const claimed = [...new Set(claimedChangedFiles)].sort();
|
|
69
|
+
const actual = [...new Set(normalizedActual)].sort();
|
|
70
|
+
if (hasFixedDisposition && actual.length === 0) {
|
|
71
|
+
throw new Error("Comment repair fixed dispositions require an actual publishable source diff.");
|
|
72
|
+
}
|
|
73
|
+
if (claimed.length !== actual.length || claimed.some((value, index) => value !== actual[index])) {
|
|
74
|
+
throw new Error("Comment repair changed_files must exactly match the publishable staged source diff.");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function finalizeCommentRepairChangedFiles(artifact, baselineChangedFiles, actualChangedFiles) {
|
|
78
|
+
const payload = JSON.parse(artifact.verdictJson);
|
|
79
|
+
const claimedRecords = Array.isArray(payload.changed_files)
|
|
80
|
+
? payload.changed_files.map(value => value)
|
|
81
|
+
: [];
|
|
82
|
+
const claimedByPath = new Map(claimedRecords.map(record => [
|
|
83
|
+
String(record.path ?? "").trim().replaceAll("\\", "/"),
|
|
84
|
+
record,
|
|
85
|
+
]));
|
|
86
|
+
const authoritativePaths = [...new Set(actualChangedFiles
|
|
87
|
+
.map(value => value.trim().replaceAll("\\", "/"))
|
|
88
|
+
.filter(Boolean))].sort();
|
|
89
|
+
const authoritative = new Set(authoritativePaths);
|
|
90
|
+
const dispositions = Array.isArray(payload.dispositions) ? payload.dispositions : [];
|
|
91
|
+
const hasFixedDisposition = dispositions.some(value => value.disposition === "fixed");
|
|
92
|
+
if (hasFixedDisposition && authoritativePaths.length === 0) {
|
|
93
|
+
throw new Error("Comment repair fixed dispositions require an actual publishable source diff.");
|
|
94
|
+
}
|
|
95
|
+
for (const baselinePath of baselineChangedFiles.map(value => value.trim().replaceAll("\\", "/"))) {
|
|
96
|
+
if (baselinePath && !authoritative.has(baselinePath)) {
|
|
97
|
+
throw new Error(`Comment repair pre-publication repair removed '${baselinePath}' from the publishable staged source diff.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
payload.changed_files = authoritativePaths.map(filePath => {
|
|
101
|
+
const claimed = claimedByPath.get(filePath);
|
|
102
|
+
return claimed
|
|
103
|
+
? { ...claimed, path: filePath }
|
|
104
|
+
: {
|
|
105
|
+
path: filePath,
|
|
106
|
+
summary: "Updated during bounded Forge pre-publication repair.",
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
const verdictJson = `${JSON.stringify(payload, null, 2)}\n`;
|
|
110
|
+
const finalized = { ...artifact, verdictJson };
|
|
111
|
+
assertCommentRepairArtifactMatchesChanges(finalized, authoritativePaths);
|
|
112
|
+
await fs.writeFile(artifact.path, verdictJson, "utf8");
|
|
113
|
+
return finalized;
|
|
114
|
+
}
|
|
115
|
+
export { parseClaudeMcpToolName } from "./provider-output-state.js";
|
|
13
116
|
export { DeterministicProviderCliService } from "./provider-cli.deterministic.js";
|
|
14
117
|
export { buildProviderCliCommand, codexSandboxModeForPlatform, formatProviderCliCommand, formatProviderCliCommandForCheckpoint } from "./provider-cli.prompts.js";
|
|
15
|
-
export { buildProviderChildEnvironment, chooseWindowsCommandCandidate, resolveCodexNpmDirectLaunch, resolvePackageManagerDirectLaunch, resolveWindowsCommandFromPath, StreamingProviderProcessRunner } from "./provider-cli.process.js";
|
|
118
|
+
export { buildProviderChildEnvironment, chooseWindowsCommandCandidate, resolveCodexNpmDirectLaunch, resolveClaudeNpmDirectLaunch, resolvePackageManagerDirectLaunch, resolveWindowsCommandFromPath, PROVIDER_OUTPUT_CAPTURE_LIMIT_CHARS, CodexModelsCacheRecoveringProcessRunner, CodexResumeRecoveringProcessRunner, isTransientCodexProviderNotFoundFailure, StreamingProviderProcessRunner } from "./provider-cli.process.js";
|
|
16
119
|
export { buildWindowsLaunchCommand } from "../../shared/process/windows-launch.js";
|
|
17
|
-
async function pushWorktreeBranch(git, worktreePath, branchName, emit) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
120
|
+
async function pushWorktreeBranch(git, worktreePath, branchName, repoPath, coordinator, signal, emit, expectedRemoteHeadSha) {
|
|
121
|
+
return await coordinator.withLock(repoPath, async () => {
|
|
122
|
+
if (expectedRemoteHeadSha) {
|
|
123
|
+
const expected = expectedRemoteHeadSha.trim().toLowerCase();
|
|
124
|
+
const remoteListing = await git.run(worktreePath, ["ls-remote", "--heads", "origin", `refs/heads/${branchName}`]);
|
|
125
|
+
const observed = remoteListing.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
|
|
126
|
+
if (observed !== expected) {
|
|
127
|
+
throw new Error(`stale_head: repair publication expected remote ${branchName} at ${expected}, but observed ${observed || "no branch"}; refresh provider state before retrying.`);
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
await git.run(worktreePath, [
|
|
131
|
+
"push",
|
|
132
|
+
"--porcelain",
|
|
133
|
+
"origin",
|
|
134
|
+
`HEAD:refs/heads/${branchName}`,
|
|
135
|
+
`--force-with-lease=refs/heads/${branchName}:${expected}`,
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
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 });
|
|
140
|
+
}
|
|
141
|
+
return (await git.run(worktreePath, ["rev-parse", "HEAD"])).trim();
|
|
25
142
|
}
|
|
26
|
-
await emit?.({
|
|
27
|
-
role: "runner",
|
|
28
|
-
kind: "checkpoint",
|
|
29
|
-
content: `Remote branch ${branchName} moved before push; fetching latest and rebasing local changes.`,
|
|
30
|
-
eventType: "worktree_branch_push_rebase"
|
|
31
|
-
});
|
|
32
143
|
try {
|
|
33
|
-
await git.run(worktreePath, ["fetch", "origin", remoteTrackingRefspec(branchName)]);
|
|
34
|
-
await git.run(worktreePath, ["rebase", `refs/remotes/origin/${branchName}`]);
|
|
35
144
|
await git.run(worktreePath, ["push", "origin", `HEAD:${branchName}`]);
|
|
145
|
+
return (await git.run(worktreePath, ["rev-parse", "HEAD"])).trim();
|
|
36
146
|
}
|
|
37
|
-
catch (
|
|
38
|
-
|
|
147
|
+
catch (err) {
|
|
148
|
+
if (!isNonFastForwardPushError(err)) {
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
await emit?.({
|
|
152
|
+
role: "runner",
|
|
153
|
+
kind: "checkpoint",
|
|
154
|
+
content: `Remote branch ${branchName} moved before push; fetching latest and rebasing local changes.`,
|
|
155
|
+
eventType: "worktree_branch_push_rebase"
|
|
156
|
+
});
|
|
157
|
+
try {
|
|
158
|
+
await git.run(worktreePath, ["fetch", "origin", remoteTrackingRefspec(branchName)]);
|
|
159
|
+
await git.run(worktreePath, ["rebase", `refs/remotes/origin/${branchName}`]);
|
|
160
|
+
await git.run(worktreePath, ["push", "origin", `HEAD:${branchName}`]);
|
|
161
|
+
}
|
|
162
|
+
catch (retryErr) {
|
|
163
|
+
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 });
|
|
164
|
+
}
|
|
39
165
|
}
|
|
40
|
-
|
|
166
|
+
return (await git.run(worktreePath, ["rev-parse", "HEAD"])).trim();
|
|
167
|
+
}, signal);
|
|
41
168
|
}
|
|
42
169
|
function providerOwnsBranchPublication(context) {
|
|
43
170
|
return context.dispatch.sandbox?.executionMode !== "docker";
|
|
44
171
|
}
|
|
172
|
+
function prePublicationExecution(context, environment, defaultRunner, baselineRevision) {
|
|
173
|
+
if (context.processRunner)
|
|
174
|
+
return { baselineRevision, environment, processRunner: context.processRunner };
|
|
175
|
+
if (context.dispatch.sandbox?.executionMode !== "docker")
|
|
176
|
+
return { baselineRevision, environment };
|
|
177
|
+
const expectedSandboxId = context.dispatch.sandbox.dispatchAttemptId;
|
|
178
|
+
const observedSandboxId = context.runtimeEnvironment?.FORGE_SANDBOX_ID ?? process.env.FORGE_SANDBOX_ID;
|
|
179
|
+
if (observedSandboxId !== expectedSandboxId) {
|
|
180
|
+
throw new ForgeFailureError("prepublication_execution_scope_invalid", `prepublication_execution_scope_invalid: Docker attempt ${expectedSandboxId} has no runner proven to execute inside that sandbox.`, "bridge");
|
|
181
|
+
}
|
|
182
|
+
return { baselineRevision, environment, processRunner: defaultRunner };
|
|
183
|
+
}
|
|
45
184
|
function remoteTrackingRefspec(branchName) {
|
|
46
185
|
return `refs/heads/${branchName}:refs/remotes/origin/${branchName}`;
|
|
47
186
|
}
|
|
@@ -56,14 +195,111 @@ function isNonFastForwardPushError(err) {
|
|
|
56
195
|
function errorMessage(err) {
|
|
57
196
|
return err instanceof Error ? err.message : String(err);
|
|
58
197
|
}
|
|
198
|
+
function providerCliExitFailure(command, result) {
|
|
199
|
+
const output = [result.stderr.trim(), result.stdout.trim()]
|
|
200
|
+
.filter(value => value.length > 0)
|
|
201
|
+
.join("\n");
|
|
202
|
+
const detail = clipTail(output, 1200);
|
|
203
|
+
const suffix = detail ? `: ${detail}` : "";
|
|
204
|
+
if (isProviderConfigurationFailure(command, result)) {
|
|
205
|
+
return new ForgeFailureError("provider_configuration_invalid", `${command} rejected its model configuration; correct the configuration before retrying${suffix}`, "provider");
|
|
206
|
+
}
|
|
207
|
+
if (isTransientCodexProviderNotFoundFailure(command, result)) {
|
|
208
|
+
return new ForgeFailureError("provider_request_not_found", `${command} received a transient not-found response from its provider service${suffix}`, "provider");
|
|
209
|
+
}
|
|
210
|
+
if (isProviderCapacityFailure(result)) {
|
|
211
|
+
return new ForgeFailureError("provider_capacity_unavailable", `${command} was deferred because the selected provider model is temporarily at capacity${suffix}`, "provider");
|
|
212
|
+
}
|
|
213
|
+
if (isProviderContextWindowExhausted(command, result)) {
|
|
214
|
+
return new ForgeFailureError("provider_context_window_exhausted", `${command} exhausted the provider context window and must continue in a fresh session${suffix}`, "provider");
|
|
215
|
+
}
|
|
216
|
+
return new Error(`${command} exited ${result.exitCode}${suffix}`);
|
|
217
|
+
}
|
|
218
|
+
function clipTail(value, maximumLength) {
|
|
219
|
+
if (value.length <= maximumLength)
|
|
220
|
+
return value;
|
|
221
|
+
return `...${value.slice(-(maximumLength - 3))}`;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Capacity is trusted only when a nonzero provider process writes one of the
|
|
225
|
+
* narrow service-level messages to stderr. Stdout can contain arbitrary agent
|
|
226
|
+
* command output and account usage-limit messages remain ordinary failures.
|
|
227
|
+
*/
|
|
228
|
+
export function isProviderCapacityFailure(result) {
|
|
229
|
+
if (result.exitCode === 0)
|
|
230
|
+
return false;
|
|
231
|
+
const capacityMessages = new Set([
|
|
232
|
+
"the selected model is at capacity. please try again later.",
|
|
233
|
+
"the selected model is currently at capacity. please try again later.",
|
|
234
|
+
"the model is currently overloaded. please try again later.",
|
|
235
|
+
"the provider is currently overloaded. please try again later.",
|
|
236
|
+
]);
|
|
237
|
+
return result.stderr
|
|
238
|
+
.split(/\r?\n/u)
|
|
239
|
+
.map(line => line.trim().toLowerCase().replace(/^error:\s*/u, ""))
|
|
240
|
+
.some(line => capacityMessages.has(line));
|
|
241
|
+
}
|
|
242
|
+
/** Only a nonzero Codex service diagnostic can classify a configuration rejection.
|
|
243
|
+
* JSONL turn.failed is a provider envelope; item/tool output is not retry authority. */
|
|
244
|
+
export function isProviderConfigurationFailure(command, result) {
|
|
245
|
+
const names = [path.posix.parse(command).name, path.win32.parse(command).name];
|
|
246
|
+
if (result.exitCode === 0 || !names.some(name => name.toLowerCase() === "codex"))
|
|
247
|
+
return false;
|
|
248
|
+
const reasoningRejected = /Unsupported value: '[^'\r\n]+' is not supported with the '[^'\r\n]+' model\./u.test(result.stderr)
|
|
249
|
+
&& result.stderr.includes("reasoning.effort")
|
|
250
|
+
&& result.stderr.includes("invalid_request_error")
|
|
251
|
+
&& result.stderr.includes("unsupported_value");
|
|
252
|
+
if (reasoningRejected)
|
|
253
|
+
return true;
|
|
254
|
+
return [result.stderr, result.stdout].some(output => output.split(/\r?\n/u).some(line => {
|
|
255
|
+
try {
|
|
256
|
+
const event = JSON.parse(line);
|
|
257
|
+
if (event.type !== "turn.failed")
|
|
258
|
+
return false;
|
|
259
|
+
const error = event.error;
|
|
260
|
+
if (typeof error?.message !== "string")
|
|
261
|
+
return false;
|
|
262
|
+
const service = JSON.parse(error.message);
|
|
263
|
+
return service.type === "error" && service.status === 400
|
|
264
|
+
&& service.error?.type === "invalid_request_error"
|
|
265
|
+
&& typeof service.error.message === "string"
|
|
266
|
+
&& /^The '[^'\r\n]+' model is not supported when using Codex with a ChatGPT account\.$/u.test(service.error.message);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}));
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* A context limit is trusted only at the provider process boundary. The exact
|
|
275
|
+
* Codex message can be embedded in its JSONL stderr envelope, so substring
|
|
276
|
+
* matching is intentional but remains narrow and stderr-only.
|
|
277
|
+
*/
|
|
278
|
+
export function isProviderContextWindowExhausted(command, result) {
|
|
279
|
+
const executableNames = [path.posix.parse(command).name, path.win32.parse(command).name]
|
|
280
|
+
.map(name => name.toLowerCase());
|
|
281
|
+
if (result.exitCode === 0 || !executableNames.includes("codex"))
|
|
282
|
+
return false;
|
|
283
|
+
return result.stderr.toLowerCase().includes("codex ran out of room in the model's context window");
|
|
284
|
+
}
|
|
59
285
|
const defaultProviderToolHeartbeatIntervalMs = 60_000;
|
|
60
286
|
const defaultManagedVerificationProgressIntervalMs = 30_000;
|
|
287
|
+
function compareManagedVerificationSnapshots(left, right) {
|
|
288
|
+
const leftOrder = managedVerificationStartedAt(left.startedAt) ?? Number.MAX_SAFE_INTEGER;
|
|
289
|
+
const rightOrder = managedVerificationStartedAt(right.startedAt) ?? Number.MAX_SAFE_INTEGER;
|
|
290
|
+
return leftOrder - rightOrder || left.commandId.localeCompare(right.commandId);
|
|
291
|
+
}
|
|
292
|
+
function managedVerificationStartedAt(value) {
|
|
293
|
+
const parsed = Date.parse(value ?? "");
|
|
294
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
295
|
+
}
|
|
61
296
|
export class ManagedVerificationProgressEmitter {
|
|
62
297
|
logDirectory;
|
|
63
298
|
emit;
|
|
64
299
|
intervalMs;
|
|
65
300
|
timer = null;
|
|
66
|
-
|
|
301
|
+
observedStatuses = new Map();
|
|
302
|
+
completedCommands = new Set();
|
|
67
303
|
constructor(logDirectory, emit, intervalMs = defaultManagedVerificationProgressIntervalMs) {
|
|
68
304
|
this.logDirectory = logDirectory;
|
|
69
305
|
this.emit = emit;
|
|
@@ -94,40 +330,67 @@ export class ManagedVerificationProgressEmitter {
|
|
|
94
330
|
.map(async (name) => parseManagedVerificationSnapshot(path.join(directory, name))));
|
|
95
331
|
const current = snapshots
|
|
96
332
|
.filter((entry) => entry !== null)
|
|
97
|
-
.sort(
|
|
98
|
-
if (
|
|
333
|
+
.sort(compareManagedVerificationSnapshots);
|
|
334
|
+
if (current.some(snapshot => isManagedVerificationNonterminal(snapshot.status))) {
|
|
335
|
+
// Durable managed-command receipts are authoritative evidence that Forge
|
|
336
|
+
// still supervises work even when the provider is not inside an MCP tool
|
|
337
|
+
// call. Refresh the API idle deadline without inflating the transcript.
|
|
338
|
+
await this.emit({
|
|
339
|
+
role: "runner",
|
|
340
|
+
kind: "status",
|
|
341
|
+
content: "A supervised managed verification command is still running.",
|
|
342
|
+
eventType: "provider_tool_heartbeat",
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
for (const snapshot of current) {
|
|
346
|
+
await this.emitTransition(snapshot);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
async emitTransition(current) {
|
|
350
|
+
const terminal = ["pass", "fail", "timed_out", "timeout_termination_failed", "runner_error"].includes(current.status);
|
|
351
|
+
if (terminal && this.completedCommands.has(current.commandId))
|
|
99
352
|
return;
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
: current.elapsedMs;
|
|
103
|
-
const progressBucket = elapsedMs === null ? "none" : Math.floor(elapsedMs / this.intervalMs);
|
|
104
|
-
const signature = `${current.commandId}:${current.status}:${current.outputRevision ?? "none"}:${progressBucket}`;
|
|
105
|
-
if (signature === this.lastSignature)
|
|
353
|
+
const previousStatus = this.observedStatuses.get(current.commandId);
|
|
354
|
+
if (previousStatus === current.status)
|
|
106
355
|
return;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
356
|
+
this.observedStatuses.set(current.commandId, current.status);
|
|
357
|
+
if (terminal)
|
|
358
|
+
this.completedCommands.add(current.commandId);
|
|
359
|
+
// Starting and running are one externally meaningful state. The receipt is
|
|
360
|
+
// still refreshed on disk for UI telemetry, but repeated output/elapsed
|
|
361
|
+
// changes must not inflate the provider transcript or session history.
|
|
362
|
+
if (!terminal && previousStatus === "starting" && current.status === "running")
|
|
363
|
+
return;
|
|
364
|
+
const startedAt = managedVerificationStartedAt(current.startedAt);
|
|
365
|
+
const elapsedMs = startedAt !== null
|
|
366
|
+
? Math.max(current.elapsedMs ?? 0, Date.now() - startedAt)
|
|
367
|
+
: current.elapsedMs;
|
|
110
368
|
const elapsed = elapsedMs === null ? "an unknown duration" : formatElapsed(elapsedMs);
|
|
111
|
-
const outputTail = current.
|
|
369
|
+
const outputTail = current.lastOutputStream === "stderr"
|
|
370
|
+
? current.stderrTail || current.stdoutTail
|
|
371
|
+
: current.stdoutTail || current.stderrTail;
|
|
372
|
+
const recovered = previousStatus === undefined && terminal;
|
|
112
373
|
await this.emit({
|
|
113
374
|
role: "runner",
|
|
114
375
|
kind: terminal && current.status !== "pass" ? "error" : "status",
|
|
115
376
|
content: terminal
|
|
116
|
-
? `Managed verification ${current.status} after ${elapsed}${outputTail ? `: ${clip(outputTail, 600)}` : "."}`
|
|
117
|
-
:
|
|
118
|
-
|
|
119
|
-
: `Managed verification is still running after ${elapsed}${outputTail ? `: ${clip(outputTail, 600)}` : "."}`,
|
|
120
|
-
eventType: terminal ? "verification_completed" : firstObservation ? "verification_waiting" : "verification_progress",
|
|
377
|
+
? `Managed verification ${current.status} after ${elapsed}${recovered ? " (recovered from its durable receipt)" : ""}${outputTail ? `: ${clip(outputTail, 600)}` : "."}`
|
|
378
|
+
: `QA started managed verification ${current.commandId}. Forge is supervising it; durable progress remains available in the UI while the provider continues independent work.`,
|
|
379
|
+
eventType: terminal ? "verification_completed" : "verification_waiting",
|
|
121
380
|
data: JSON.stringify({
|
|
122
381
|
command_id: current.commandId,
|
|
123
382
|
status: current.status,
|
|
124
383
|
elapsed_ms: elapsedMs,
|
|
125
384
|
output_revision: current.outputRevision,
|
|
126
385
|
last_output_at: current.lastOutputAt,
|
|
386
|
+
recovered,
|
|
127
387
|
}),
|
|
128
388
|
});
|
|
129
389
|
}
|
|
130
390
|
}
|
|
391
|
+
function isManagedVerificationNonterminal(status) {
|
|
392
|
+
return status === "starting" || status === "running" || status === "waiting_for_build_slot";
|
|
393
|
+
}
|
|
131
394
|
async function parseManagedVerificationSnapshot(receiptPath) {
|
|
132
395
|
try {
|
|
133
396
|
const receipt = JSON.parse(await fs.readFile(receiptPath, "utf8"));
|
|
@@ -142,6 +405,9 @@ async function parseManagedVerificationSnapshot(receiptPath) {
|
|
|
142
405
|
startedAt: typeof receipt.started_at === "string" ? receipt.started_at : null,
|
|
143
406
|
outputRevision: typeof receipt.output_revision === "number" ? receipt.output_revision : null,
|
|
144
407
|
lastOutputAt: typeof receipt.last_output_at === "string" ? receipt.last_output_at : null,
|
|
408
|
+
lastOutputStream: receipt.last_output_stream === "stdout" || receipt.last_output_stream === "stderr"
|
|
409
|
+
? receipt.last_output_stream
|
|
410
|
+
: null,
|
|
145
411
|
stdoutTail: tail(typeof receipt.stdout === "string" ? receipt.stdout : ""),
|
|
146
412
|
stderrTail: tail(typeof receipt.stderr === "string" ? receipt.stderr : ""),
|
|
147
413
|
};
|
|
@@ -246,10 +512,14 @@ export class ProcessBackedProviderCliService {
|
|
|
246
512
|
processRunner;
|
|
247
513
|
git;
|
|
248
514
|
providerToolHeartbeatIntervalMs;
|
|
249
|
-
|
|
515
|
+
publicationCoordinator;
|
|
516
|
+
prePublicationValidator;
|
|
517
|
+
constructor(processRunner = new StreamingProviderProcessRunner(), git = new GitClient(), providerToolHeartbeatIntervalMs = defaultProviderToolHeartbeatIntervalMs, publicationCoordinator = new RepositoryCoordinatorLock(defaultWorktreeCoordinatorStateRoot()), prePublicationValidator = new PrePublicationValidator()) {
|
|
250
518
|
this.processRunner = processRunner;
|
|
251
519
|
this.git = git;
|
|
252
520
|
this.providerToolHeartbeatIntervalMs = providerToolHeartbeatIntervalMs;
|
|
521
|
+
this.publicationCoordinator = publicationCoordinator;
|
|
522
|
+
this.prePublicationValidator = prePublicationValidator;
|
|
253
523
|
}
|
|
254
524
|
async runDraft(context) {
|
|
255
525
|
const { dispatch } = context;
|
|
@@ -311,7 +581,7 @@ export class ProcessBackedProviderCliService {
|
|
|
311
581
|
context.signal?.addEventListener("abort", abortProvider, { once: true });
|
|
312
582
|
}
|
|
313
583
|
try {
|
|
314
|
-
const runPromise = (context.processRunner ?? this.processRunner).run({
|
|
584
|
+
const runPromise = recoveringProviderProcessRunner(context.processRunner ?? this.processRunner).run({
|
|
315
585
|
command: command.command,
|
|
316
586
|
args: command.args,
|
|
317
587
|
cwd: worktree.worktreePath,
|
|
@@ -362,7 +632,7 @@ export class ProcessBackedProviderCliService {
|
|
|
362
632
|
context.signal?.removeEventListener("abort", abortArtifactWait);
|
|
363
633
|
}
|
|
364
634
|
if (result.exitCode !== 0 && !earlyProposalArtifact) {
|
|
365
|
-
throw
|
|
635
|
+
throw providerCliExitFailure(command.command, result);
|
|
366
636
|
}
|
|
367
637
|
await context.emit({
|
|
368
638
|
role: "runner",
|
|
@@ -429,6 +699,7 @@ export class ProcessBackedProviderCliService {
|
|
|
429
699
|
}
|
|
430
700
|
async runImplementation(context) {
|
|
431
701
|
const { dispatch, worktree } = context;
|
|
702
|
+
const validationBaselineRevision = (await this.git.run(worktree.worktreePath, ["rev-parse", "HEAD"])).trim();
|
|
432
703
|
const phase = completionPhaseFromDispatch(dispatch);
|
|
433
704
|
const developCycle = usesDevelopCycleCompletion(dispatch);
|
|
434
705
|
const debugCycle = usesDebugCycleCompletion(dispatch);
|
|
@@ -479,7 +750,7 @@ export class ProcessBackedProviderCliService {
|
|
|
479
750
|
let result;
|
|
480
751
|
try {
|
|
481
752
|
liveCompletionEvents.start();
|
|
482
|
-
result = await (context.processRunner ?? this.processRunner).run({
|
|
753
|
+
result = await recoveringProviderProcessRunner(context.processRunner ?? this.processRunner).run({
|
|
483
754
|
command: command.command,
|
|
484
755
|
args: command.args,
|
|
485
756
|
cwd: worktree.worktreePath,
|
|
@@ -505,8 +776,7 @@ export class ProcessBackedProviderCliService {
|
|
|
505
776
|
await cleanupCompletionTools(completionTools, context.emit);
|
|
506
777
|
}
|
|
507
778
|
if (result.exitCode !== 0) {
|
|
508
|
-
|
|
509
|
-
throw new Error(`${command.command} exited ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
|
|
779
|
+
throw providerCliExitFailure(command.command, result);
|
|
510
780
|
}
|
|
511
781
|
await context.emit({
|
|
512
782
|
role: "runner",
|
|
@@ -515,9 +785,20 @@ export class ProcessBackedProviderCliService {
|
|
|
515
785
|
eventType: "provider_cli_completed"
|
|
516
786
|
});
|
|
517
787
|
await emitProviderUsageEvent(context.emit, dispatch, outputState);
|
|
788
|
+
if (developCycle || phase === "implementation") {
|
|
789
|
+
await this.validatePrePublicationWithRecovery({
|
|
790
|
+
context,
|
|
791
|
+
validationProfile: "implementation",
|
|
792
|
+
repairEnvironment: context.runtimeEnvironment,
|
|
793
|
+
outputState,
|
|
794
|
+
preparedHeadSha: validationBaselineRevision,
|
|
795
|
+
repairScope: "implementation",
|
|
796
|
+
});
|
|
797
|
+
}
|
|
518
798
|
const committed = await commitWorktreeChanges(this.git, worktree.worktreePath, dispatch.workItem.id);
|
|
799
|
+
let publishedCommitSha = null;
|
|
519
800
|
if ((developCycle || debugCycle || phase === "implementation") && providerOwnsBranchPublication(context)) {
|
|
520
|
-
await pushWorktreeBranch(this.git, worktree.worktreePath, worktree.branchName, context.emit);
|
|
801
|
+
publishedCommitSha = await pushWorktreeBranch(this.git, worktree.worktreePath, worktree.branchName, worktree.repoPath, this.publicationCoordinator, context.signal, context.emit);
|
|
521
802
|
await context.emit({
|
|
522
803
|
role: "runner",
|
|
523
804
|
kind: "checkpoint",
|
|
@@ -551,7 +832,9 @@ export class ProcessBackedProviderCliService {
|
|
|
551
832
|
});
|
|
552
833
|
}
|
|
553
834
|
return {
|
|
554
|
-
summary: `${dispatch.execution.provider} CLI completed ${worktree.branchName} (${debugCycle ? "debug cycle" : developCycle ? "develop cycle" : phase}, ${committed.commitSha.slice(0, 12)}).`,
|
|
835
|
+
summary: `${dispatch.execution.provider} CLI completed ${worktree.branchName} (${debugCycle ? "debug cycle" : developCycle ? "develop cycle" : phase}, ${(publishedCommitSha ?? committed.commitSha).slice(0, 12)}).`,
|
|
836
|
+
resultCommitSha: publishedCommitSha ?? committed.commitSha,
|
|
837
|
+
publishedCommitSha: publishedCommitSha ?? undefined,
|
|
555
838
|
spec: {
|
|
556
839
|
repoPath: worktree.worktreePath,
|
|
557
840
|
path: specPath,
|
|
@@ -605,7 +888,7 @@ export class ProcessBackedProviderCliService {
|
|
|
605
888
|
let result;
|
|
606
889
|
try {
|
|
607
890
|
liveArchitectEvents.start();
|
|
608
|
-
result = await (context.processRunner ?? this.processRunner).run({
|
|
891
|
+
result = await recoveringProviderProcessRunner(context.processRunner ?? this.processRunner).run({
|
|
609
892
|
command: command.command,
|
|
610
893
|
args: command.args,
|
|
611
894
|
cwd: worktree.worktreePath,
|
|
@@ -629,8 +912,7 @@ export class ProcessBackedProviderCliService {
|
|
|
629
912
|
await cleanupCompletionTools(completionTools, context.emit);
|
|
630
913
|
}
|
|
631
914
|
if (result.exitCode !== 0) {
|
|
632
|
-
|
|
633
|
-
throw new Error(`${command.command} exited ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
|
|
915
|
+
throw providerCliExitFailure(command.command, result);
|
|
634
916
|
}
|
|
635
917
|
await context.emit({
|
|
636
918
|
role: "runner",
|
|
@@ -710,7 +992,7 @@ export class ProcessBackedProviderCliService {
|
|
|
710
992
|
try {
|
|
711
993
|
liveQaEvents.start();
|
|
712
994
|
verificationProgress.start();
|
|
713
|
-
result = await (context.processRunner ?? this.processRunner).run({
|
|
995
|
+
result = await recoveringProviderProcessRunner(context.processRunner ?? this.processRunner).run({
|
|
714
996
|
command: command.command,
|
|
715
997
|
args: command.args,
|
|
716
998
|
cwd: worktree.worktreePath,
|
|
@@ -735,8 +1017,7 @@ export class ProcessBackedProviderCliService {
|
|
|
735
1017
|
await cleanupCompletionTools(completionTools, context.emit);
|
|
736
1018
|
}
|
|
737
1019
|
if (result.exitCode !== 0) {
|
|
738
|
-
|
|
739
|
-
throw new Error(`${command.command} exited ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
|
|
1020
|
+
throw providerCliExitFailure(command.command, result);
|
|
740
1021
|
}
|
|
741
1022
|
await context.emit({
|
|
742
1023
|
role: "runner",
|
|
@@ -745,7 +1026,12 @@ export class ProcessBackedProviderCliService {
|
|
|
745
1026
|
eventType: "provider_cli_completed"
|
|
746
1027
|
});
|
|
747
1028
|
await emitProviderUsageEvent(context.emit, dispatch, outputState);
|
|
748
|
-
|
|
1029
|
+
let qaArtifact = await readQaCompletionArtifactBundle(worktree.worktreePath, dispatch.workItem.id, dispatch.cycleRun.ownerType, dispatch.cycleRun.ownerId);
|
|
1030
|
+
if (!qaArtifact && !(await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, "qa"))) {
|
|
1031
|
+
// A headless provider that ends its turn to "wait for a background watcher"
|
|
1032
|
+
// exits for good. Resume the same thread instead of failing the session.
|
|
1033
|
+
qaArtifact = await this.recoverMissingQaCompletion({ context, outputState });
|
|
1034
|
+
}
|
|
749
1035
|
if (!qaArtifact) {
|
|
750
1036
|
const verdictOnly = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, "qa");
|
|
751
1037
|
throw new Error(verdictOnly
|
|
@@ -784,6 +1070,7 @@ export class ProcessBackedProviderCliService {
|
|
|
784
1070
|
}
|
|
785
1071
|
async runPullRequestRepair(context, route) {
|
|
786
1072
|
const { dispatch, worktree } = context;
|
|
1073
|
+
const { expectedRepairHeadSha, preparedHeadSha } = await assertRepairHeadEpoch(context, this.git);
|
|
787
1074
|
const repairChecks = route === "ci_repair" || isPullRequestCheckRepairDispatch(dispatch);
|
|
788
1075
|
const addressComments = !repairChecks && isPullRequestCommentsDispatch(dispatch);
|
|
789
1076
|
let repairSpec = null;
|
|
@@ -823,6 +1110,15 @@ export class ProcessBackedProviderCliService {
|
|
|
823
1110
|
eventType: "completion_tools_configured",
|
|
824
1111
|
});
|
|
825
1112
|
}
|
|
1113
|
+
if (addressComments) {
|
|
1114
|
+
completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase: "comment_repair", runtimeEnvironment: repairEnvironment });
|
|
1115
|
+
await context.emit({
|
|
1116
|
+
role: "runner",
|
|
1117
|
+
kind: "checkpoint",
|
|
1118
|
+
content: "Forge comment-repair completion tool configured; provider must record an evidence-backed disposition for every actionable snapshot finding.",
|
|
1119
|
+
eventType: "completion_tools_configured",
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
826
1122
|
const repairPrompt = repairChecks
|
|
827
1123
|
? buildPullRequestCheckRepairPrompt(dispatch, worktree.branchName)
|
|
828
1124
|
: addressComments
|
|
@@ -846,8 +1142,9 @@ export class ProcessBackedProviderCliService {
|
|
|
846
1142
|
const outputState = createProviderOutputState(dispatch.execution.provider);
|
|
847
1143
|
const toolHeartbeat = new ProviderToolActivityHeartbeat(outputState, context.emit, this.providerToolHeartbeatIntervalMs);
|
|
848
1144
|
let result;
|
|
1145
|
+
let providerStarted = false;
|
|
849
1146
|
try {
|
|
850
|
-
result = await (context.processRunner ?? this.processRunner).run({
|
|
1147
|
+
result = await recoveringProviderProcessRunner(context.processRunner ?? this.processRunner).run({
|
|
851
1148
|
command: command.command,
|
|
852
1149
|
args: command.args,
|
|
853
1150
|
cwd: worktree.worktreePath,
|
|
@@ -861,6 +1158,7 @@ export class ProcessBackedProviderCliService {
|
|
|
861
1158
|
content: `Provider CLI ${repairChecks ? "CI repair" : addressComments ? "PR comment address" : "conflict resolve"} is supervised and ready.`,
|
|
862
1159
|
eventType: "provider_cli_started"
|
|
863
1160
|
});
|
|
1161
|
+
providerStarted = true;
|
|
864
1162
|
},
|
|
865
1163
|
onStdoutLine: async (line) => {
|
|
866
1164
|
await toolHeartbeat.consume(line);
|
|
@@ -869,13 +1167,28 @@ export class ProcessBackedProviderCliService {
|
|
|
869
1167
|
}
|
|
870
1168
|
finally {
|
|
871
1169
|
toolHeartbeat.stop();
|
|
872
|
-
|
|
873
|
-
|
|
1170
|
+
try {
|
|
1171
|
+
if (providerStarted) {
|
|
1172
|
+
await context.emit({
|
|
1173
|
+
role: "runner",
|
|
1174
|
+
kind: "checkpoint",
|
|
1175
|
+
content: `Provider CLI ${repairChecks ? "CI repair" : addressComments ? "PR comment address" : "conflict resolve"} turn exited.`,
|
|
1176
|
+
eventType: "provider_cli_completed"
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
finally {
|
|
1181
|
+
if (completionTools) {
|
|
1182
|
+
await cleanupCompletionTools(completionTools, context.emit);
|
|
1183
|
+
}
|
|
874
1184
|
}
|
|
875
1185
|
}
|
|
876
1186
|
if (result.exitCode !== 0) {
|
|
877
|
-
|
|
878
|
-
|
|
1187
|
+
throw providerCliExitFailure(command.command, result);
|
|
1188
|
+
}
|
|
1189
|
+
const postProviderHeadSha = (await this.git.run(worktree.worktreePath, ["rev-parse", "HEAD"])).trim();
|
|
1190
|
+
if (postProviderHeadSha.toLowerCase() !== preparedHeadSha.toLowerCase()) {
|
|
1191
|
+
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.`);
|
|
879
1192
|
}
|
|
880
1193
|
let repairArtifact = null;
|
|
881
1194
|
if (repairChecks) {
|
|
@@ -886,14 +1199,57 @@ export class ProcessBackedProviderCliService {
|
|
|
886
1199
|
if ((repairArtifact.status ?? "").trim().toLowerCase() !== "pass") {
|
|
887
1200
|
throw new Error(`CI repair completion gate recorded ${repairArtifact.status}; Forge did not commit or push the repair.`);
|
|
888
1201
|
}
|
|
1202
|
+
}
|
|
1203
|
+
if (addressComments) {
|
|
1204
|
+
repairArtifact = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, "comment_repair");
|
|
1205
|
+
if (!repairArtifact) {
|
|
1206
|
+
repairArtifact = await this.recoverMissingCommentRepairCompletion({
|
|
1207
|
+
context,
|
|
1208
|
+
repairEnvironment,
|
|
1209
|
+
outputState,
|
|
1210
|
+
preparedHeadSha,
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
if (!repairArtifact) {
|
|
1214
|
+
throw new Error("Provider CLI completed but did not call forge.complete_comment_repair. Forge did not publish or resolve review findings.");
|
|
1215
|
+
}
|
|
1216
|
+
if ((repairArtifact.status ?? "").trim().toLowerCase() !== "pass") {
|
|
1217
|
+
throw new Error(`Comment repair completion gate recorded ${repairArtifact.status}; Forge did not publish or resolve review findings.`);
|
|
1218
|
+
}
|
|
1219
|
+
assertCommentRepairArtifactMatchesDispatch(dispatch, repairArtifact);
|
|
1220
|
+
}
|
|
1221
|
+
let commentRepairBaselineChangedFiles = null;
|
|
1222
|
+
if (addressComments && repairArtifact) {
|
|
1223
|
+
commentRepairBaselineChangedFiles = await stagePublishableWorktreeChanges(this.git, worktree.worktreePath);
|
|
1224
|
+
assertCommentRepairArtifactMatchesChanges(repairArtifact, commentRepairBaselineChangedFiles);
|
|
1225
|
+
}
|
|
1226
|
+
const validationProfile = repairChecks
|
|
1227
|
+
? "ci_repair"
|
|
1228
|
+
: addressComments
|
|
1229
|
+
? "comment_repair"
|
|
1230
|
+
: "conflict_repair";
|
|
1231
|
+
await this.validatePrePublicationWithRecovery({
|
|
1232
|
+
context,
|
|
1233
|
+
validationProfile,
|
|
1234
|
+
repairEnvironment,
|
|
1235
|
+
outputState,
|
|
1236
|
+
preparedHeadSha,
|
|
1237
|
+
repairScope: "pull_request",
|
|
1238
|
+
});
|
|
1239
|
+
const stagedChangedFiles = await stagePublishableWorktreeChanges(this.git, worktree.worktreePath);
|
|
1240
|
+
if (addressComments && repairArtifact && commentRepairBaselineChangedFiles) {
|
|
1241
|
+
repairArtifact = await finalizeCommentRepairChangedFiles(repairArtifact, commentRepairBaselineChangedFiles, stagedChangedFiles);
|
|
1242
|
+
}
|
|
1243
|
+
const committed = await commitWorktreeChanges(this.git, worktree.worktreePath, dispatch.workItem.id, stagedChangedFiles);
|
|
1244
|
+
const publishedByProvider = providerOwnsBranchPublication(context);
|
|
1245
|
+
const publishedCommitSha = publishedByProvider
|
|
1246
|
+
? await pushWorktreeBranch(this.git, worktree.worktreePath, worktree.branchName, worktree.repoPath, this.publicationCoordinator, context.signal, context.emit, expectedRepairHeadSha)
|
|
1247
|
+
: null;
|
|
1248
|
+
if (repairArtifact) {
|
|
889
1249
|
await emitCompletionArtifactRecorded(context.emit, worktree.worktreePath, repairArtifact);
|
|
890
1250
|
await emitPhaseCompletionEvent(context.emit, repairArtifact);
|
|
891
1251
|
await emitPhaseGateEvents(context.emit, repairArtifact);
|
|
892
1252
|
}
|
|
893
|
-
const committed = await commitWorktreeChanges(this.git, worktree.worktreePath, dispatch.workItem.id);
|
|
894
|
-
if (providerOwnsBranchPublication(context)) {
|
|
895
|
-
await pushWorktreeBranch(this.git, worktree.worktreePath, worktree.branchName, context.emit);
|
|
896
|
-
}
|
|
897
1253
|
await emitProviderUsageEvent(context.emit, dispatch, outputState);
|
|
898
1254
|
await context.emit({
|
|
899
1255
|
role: "assistant",
|
|
@@ -907,15 +1263,359 @@ export class ProcessBackedProviderCliService {
|
|
|
907
1263
|
});
|
|
908
1264
|
return {
|
|
909
1265
|
summary: repairChecks
|
|
910
|
-
? `${dispatch.execution.provider} CLI repaired PR checks on ${worktree.branchName} (${committed.commitSha.slice(0, 12)}).`
|
|
1266
|
+
? `${dispatch.execution.provider} CLI repaired PR checks on ${worktree.branchName} (${(publishedCommitSha ?? committed.commitSha).slice(0, 12)}).`
|
|
911
1267
|
: addressComments
|
|
912
|
-
? `${dispatch.execution.provider} CLI addressed PR comments on ${worktree.branchName} (${committed.commitSha.slice(0, 12)}).`
|
|
913
|
-
: `${dispatch.execution.provider} CLI resolved conflicts on ${worktree.branchName} (${committed.commitSha.slice(0, 12)}).`,
|
|
1268
|
+
? `${dispatch.execution.provider} CLI addressed PR comments on ${worktree.branchName} (${(publishedCommitSha ?? committed.commitSha).slice(0, 12)}).`
|
|
1269
|
+
: `${dispatch.execution.provider} CLI resolved conflicts on ${worktree.branchName} (${(publishedCommitSha ?? committed.commitSha).slice(0, 12)}).`,
|
|
1270
|
+
resultCommitSha: publishedCommitSha ?? committed.commitSha,
|
|
1271
|
+
publishedCommitSha,
|
|
914
1272
|
spec: repairSpec,
|
|
915
1273
|
artifact: repairArtifact,
|
|
916
1274
|
artifacts: repairArtifact ? [repairArtifact] : [],
|
|
917
1275
|
};
|
|
918
1276
|
}
|
|
1277
|
+
async recoverMissingQaCompletion(options) {
|
|
1278
|
+
const { context, outputState } = options;
|
|
1279
|
+
const { dispatch, worktree } = context;
|
|
1280
|
+
const normalizedProvider = dispatch.execution.provider.trim().toLowerCase();
|
|
1281
|
+
const providerThreadId = outputState.providerThreadId?.trim();
|
|
1282
|
+
if ((normalizedProvider !== "codex" && normalizedProvider !== "cursor" && normalizedProvider !== "claude")
|
|
1283
|
+
|| !providerThreadId) {
|
|
1284
|
+
await context.emit({
|
|
1285
|
+
role: "runner",
|
|
1286
|
+
kind: "error",
|
|
1287
|
+
content: `Forge could not resume QA for the missing verdict because provider '${dispatch.execution.provider}' did not report a resumable thread.`,
|
|
1288
|
+
eventType: "completion_tool_recovery_unavailable",
|
|
1289
|
+
data: JSON.stringify({ phase: "qa", provider: normalizedProvider }),
|
|
1290
|
+
});
|
|
1291
|
+
return null;
|
|
1292
|
+
}
|
|
1293
|
+
const recoveryDispatch = {
|
|
1294
|
+
...dispatch,
|
|
1295
|
+
session: { ...dispatch.session, resumeProviderThreadId: providerThreadId },
|
|
1296
|
+
};
|
|
1297
|
+
const recoveryPrompt = [
|
|
1298
|
+
"Your previous turn ended before Forge received the QA verdict, so the provider process exited.",
|
|
1299
|
+
"Background watchers, monitors and notifications from that turn did not survive and will never report back.",
|
|
1300
|
+
"Resume QA exactly where it stopped. Do not repeat gates you already recorded.",
|
|
1301
|
+
"Check each managed command with forge.get_managed_command, restart any that were interrupted,",
|
|
1302
|
+
"and block on long commands with forge.wait_managed_command, calling it again while command_continues is true.",
|
|
1303
|
+
"Do not end your turn until you have called every remaining QA gate through forge.complete_qa_verdict.",
|
|
1304
|
+
"If a gate cannot get evidence, record it as blocked with the reason instead of ending the turn.",
|
|
1305
|
+
].join(" ");
|
|
1306
|
+
for (let turn = 1; turn <= maxQaCompletionRecoveryTurns; turn += 1) {
|
|
1307
|
+
const providerPrompt = buildProviderCliPrompt(recoveryDispatch, recoveryPrompt, context.runtimeEnvironment);
|
|
1308
|
+
const pipePromptToStdin = shouldPipePromptToStdin(recoveryDispatch.execution.provider, providerPrompt, usesManagedWindowsHost(recoveryDispatch));
|
|
1309
|
+
const completionTools = await prepareCompletionToolRuntime({
|
|
1310
|
+
dispatch: recoveryDispatch,
|
|
1311
|
+
worktree,
|
|
1312
|
+
phase: "qa",
|
|
1313
|
+
runtimeEnvironment: context.runtimeEnvironment,
|
|
1314
|
+
});
|
|
1315
|
+
const command = buildProviderCliCommand(recoveryDispatch, {
|
|
1316
|
+
worktreePath: worktree.worktreePath,
|
|
1317
|
+
prompt: recoveryPrompt,
|
|
1318
|
+
promptFromStdin: pipePromptToStdin,
|
|
1319
|
+
completionTools,
|
|
1320
|
+
runtimeEnvironment: context.runtimeEnvironment,
|
|
1321
|
+
});
|
|
1322
|
+
await context.emit({
|
|
1323
|
+
role: "runner",
|
|
1324
|
+
kind: "checkpoint",
|
|
1325
|
+
content: `QA ended without a verdict. Resuming ${dispatch.execution.provider} ${outputState.sessionLabel ?? "provider thread"} (turn ${turn}/${maxQaCompletionRecoveryTurns}) to finish the remaining QA gates.`,
|
|
1326
|
+
eventType: "completion_tool_recovery_started",
|
|
1327
|
+
data: JSON.stringify({ phase: "qa", providerThreadId, turn }),
|
|
1328
|
+
});
|
|
1329
|
+
const toolHeartbeat = new ProviderToolActivityHeartbeat(outputState, context.emit, this.providerToolHeartbeatIntervalMs);
|
|
1330
|
+
let result;
|
|
1331
|
+
try {
|
|
1332
|
+
result = await recoveringProviderProcessRunner(context.processRunner ?? this.processRunner).run({
|
|
1333
|
+
command: command.command,
|
|
1334
|
+
args: command.args,
|
|
1335
|
+
cwd: worktree.worktreePath,
|
|
1336
|
+
env: providerProcessEnv(recoveryDispatch, context.runtimeEnvironment),
|
|
1337
|
+
stdin: pipePromptToStdin ? providerPrompt : undefined,
|
|
1338
|
+
}, { onStdoutLine: async (line) => await toolHeartbeat.consume(line) }, context.signal);
|
|
1339
|
+
}
|
|
1340
|
+
finally {
|
|
1341
|
+
toolHeartbeat.stop();
|
|
1342
|
+
await cleanupCompletionTools(completionTools, context.emit);
|
|
1343
|
+
}
|
|
1344
|
+
if (result.exitCode !== 0)
|
|
1345
|
+
throw providerCliExitFailure(command.command, result);
|
|
1346
|
+
const artifact = await readQaCompletionArtifactBundle(worktree.worktreePath, dispatch.workItem.id, dispatch.cycleRun.ownerType, dispatch.cycleRun.ownerId);
|
|
1347
|
+
if (artifact || await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, "qa")) {
|
|
1348
|
+
await context.emit({
|
|
1349
|
+
role: "runner",
|
|
1350
|
+
kind: "checkpoint",
|
|
1351
|
+
content: `Forge resumed the QA thread and received its completion record on recovery turn ${turn}.`,
|
|
1352
|
+
eventType: "completion_tool_recovery_completed",
|
|
1353
|
+
data: JSON.stringify({ phase: "qa", providerThreadId, turn }),
|
|
1354
|
+
});
|
|
1355
|
+
return artifact;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
await context.emit({
|
|
1359
|
+
role: "runner",
|
|
1360
|
+
kind: "error",
|
|
1361
|
+
content: `The resumed QA thread still did not call the Forge QA completion tool after ${maxQaCompletionRecoveryTurns} recovery turns.`,
|
|
1362
|
+
eventType: "completion_tool_recovery_exhausted",
|
|
1363
|
+
data: JSON.stringify({ phase: "qa", providerThreadId }),
|
|
1364
|
+
});
|
|
1365
|
+
return null;
|
|
1366
|
+
}
|
|
1367
|
+
async recoverMissingCommentRepairCompletion(options) {
|
|
1368
|
+
const { context, repairEnvironment, outputState, preparedHeadSha } = options;
|
|
1369
|
+
const { dispatch, worktree } = context;
|
|
1370
|
+
const normalizedProvider = dispatch.execution.provider.trim().toLowerCase();
|
|
1371
|
+
if (normalizedProvider !== "codex" && normalizedProvider !== "cursor" && normalizedProvider !== "claude") {
|
|
1372
|
+
await context.emit({
|
|
1373
|
+
role: "runner",
|
|
1374
|
+
kind: "error",
|
|
1375
|
+
content: `Forge could not recover the missing comment-repair completion record because provider '${dispatch.execution.provider}' has no declared thread-resume contract. The dirty worktree remains preserved.`,
|
|
1376
|
+
eventType: "completion_tool_recovery_unavailable",
|
|
1377
|
+
data: JSON.stringify({
|
|
1378
|
+
phase: "comment_repair",
|
|
1379
|
+
provider: normalizedProvider,
|
|
1380
|
+
reason: "thread_resume_unsupported",
|
|
1381
|
+
}),
|
|
1382
|
+
});
|
|
1383
|
+
return null;
|
|
1384
|
+
}
|
|
1385
|
+
const providerThreadId = outputState.providerThreadId?.trim();
|
|
1386
|
+
if (!providerThreadId) {
|
|
1387
|
+
await context.emit({
|
|
1388
|
+
role: "runner",
|
|
1389
|
+
kind: "error",
|
|
1390
|
+
content: "Forge could not recover the missing comment-repair completion record because the provider did not report a resumable thread id. The dirty worktree remains preserved.",
|
|
1391
|
+
eventType: "completion_tool_recovery_unavailable",
|
|
1392
|
+
});
|
|
1393
|
+
return null;
|
|
1394
|
+
}
|
|
1395
|
+
const recoveryDispatch = {
|
|
1396
|
+
...dispatch,
|
|
1397
|
+
session: { ...dispatch.session, resumeProviderThreadId: providerThreadId },
|
|
1398
|
+
};
|
|
1399
|
+
const recoveryPrompt = [
|
|
1400
|
+
"Forge did not receive the required forge.complete_comment_repair call from your previous turn.",
|
|
1401
|
+
"Resume the existing work exactly where it is. Do not reimplement or discard the prepared changes.",
|
|
1402
|
+
"Review the current diff and the durable review snapshot, preserve the validation evidence already gathered,",
|
|
1403
|
+
"then call forge.complete_comment_repair with an evidence-backed disposition for every actionable finding.",
|
|
1404
|
+
"Do not commit or push; Forge owns publication after the completion gate succeeds.",
|
|
1405
|
+
].join(" ");
|
|
1406
|
+
const providerPrompt = buildProviderCliPrompt(recoveryDispatch, recoveryPrompt, repairEnvironment);
|
|
1407
|
+
const pipePromptToStdin = shouldPipePromptToStdin(recoveryDispatch.execution.provider, providerPrompt, usesManagedWindowsHost(recoveryDispatch));
|
|
1408
|
+
const completionTools = await prepareCompletionToolRuntime({
|
|
1409
|
+
dispatch: recoveryDispatch,
|
|
1410
|
+
worktree,
|
|
1411
|
+
phase: "comment_repair",
|
|
1412
|
+
runtimeEnvironment: repairEnvironment,
|
|
1413
|
+
});
|
|
1414
|
+
const command = buildProviderCliCommand(recoveryDispatch, {
|
|
1415
|
+
worktreePath: worktree.worktreePath,
|
|
1416
|
+
prompt: recoveryPrompt,
|
|
1417
|
+
promptFromStdin: pipePromptToStdin,
|
|
1418
|
+
completionTools,
|
|
1419
|
+
runtimeEnvironment: repairEnvironment,
|
|
1420
|
+
});
|
|
1421
|
+
await context.emit({
|
|
1422
|
+
role: "runner",
|
|
1423
|
+
kind: "checkpoint",
|
|
1424
|
+
content: `Resuming ${dispatch.execution.provider} ${outputState.sessionLabel ?? "provider thread"} once to record the missing comment-repair completion artifact without repeating implementation work.`,
|
|
1425
|
+
eventType: "completion_tool_recovery_started",
|
|
1426
|
+
data: JSON.stringify({ phase: "comment_repair", providerThreadId }),
|
|
1427
|
+
});
|
|
1428
|
+
const toolHeartbeat = new ProviderToolActivityHeartbeat(outputState, context.emit, this.providerToolHeartbeatIntervalMs);
|
|
1429
|
+
let result;
|
|
1430
|
+
let providerStarted = false;
|
|
1431
|
+
try {
|
|
1432
|
+
result = await recoveringProviderProcessRunner(context.processRunner ?? this.processRunner).run({
|
|
1433
|
+
command: command.command,
|
|
1434
|
+
args: command.args,
|
|
1435
|
+
cwd: worktree.worktreePath,
|
|
1436
|
+
env: providerProcessEnv(recoveryDispatch, repairEnvironment),
|
|
1437
|
+
stdin: pipePromptToStdin ? providerPrompt : undefined,
|
|
1438
|
+
}, {
|
|
1439
|
+
onStarted: async () => {
|
|
1440
|
+
providerStarted = true;
|
|
1441
|
+
await context.emit({
|
|
1442
|
+
role: "runner",
|
|
1443
|
+
kind: "checkpoint",
|
|
1444
|
+
content: "Provider completion-tool recovery turn is supervised and ready.",
|
|
1445
|
+
eventType: "provider_cli_started",
|
|
1446
|
+
});
|
|
1447
|
+
},
|
|
1448
|
+
onStdoutLine: async (line) => {
|
|
1449
|
+
await toolHeartbeat.consume(line);
|
|
1450
|
+
},
|
|
1451
|
+
}, context.signal);
|
|
1452
|
+
}
|
|
1453
|
+
finally {
|
|
1454
|
+
toolHeartbeat.stop();
|
|
1455
|
+
try {
|
|
1456
|
+
if (providerStarted) {
|
|
1457
|
+
await context.emit({
|
|
1458
|
+
role: "runner",
|
|
1459
|
+
kind: "checkpoint",
|
|
1460
|
+
content: "Provider completion-tool recovery turn exited.",
|
|
1461
|
+
eventType: "provider_cli_completed",
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
finally {
|
|
1466
|
+
await cleanupCompletionTools(completionTools, context.emit);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
if (result.exitCode !== 0)
|
|
1470
|
+
throw providerCliExitFailure(command.command, result);
|
|
1471
|
+
const postRecoveryHeadSha = (await this.git.run(worktree.worktreePath, ["rev-parse", "HEAD"])).trim();
|
|
1472
|
+
if (postRecoveryHeadSha.toLowerCase() !== preparedHeadSha.toLowerCase()) {
|
|
1473
|
+
throw new Error(`provider_publication_boundary_violation: completion-tool recovery changed HEAD from ${preparedHeadSha} to ${postRecoveryHeadSha}; Forge requires provider changes to remain uncommitted and unpushed until validation passes.`);
|
|
1474
|
+
}
|
|
1475
|
+
const artifact = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, "comment_repair");
|
|
1476
|
+
await context.emit({
|
|
1477
|
+
role: "runner",
|
|
1478
|
+
kind: artifact ? "checkpoint" : "error",
|
|
1479
|
+
content: artifact
|
|
1480
|
+
? "Forge recovered the missing comment-repair completion artifact from the same provider thread."
|
|
1481
|
+
: "The resumed provider thread still did not call forge.complete_comment_repair. The dirty worktree remains preserved and unpublished.",
|
|
1482
|
+
eventType: artifact ? "completion_tool_recovery_completed" : "completion_tool_recovery_exhausted",
|
|
1483
|
+
data: JSON.stringify({ phase: "comment_repair", providerThreadId }),
|
|
1484
|
+
});
|
|
1485
|
+
return artifact;
|
|
1486
|
+
}
|
|
1487
|
+
async validatePrePublicationWithRecovery(options) {
|
|
1488
|
+
const { context, validationProfile, repairEnvironment, outputState, preparedHeadSha, repairScope } = options;
|
|
1489
|
+
const { dispatch, worktree } = context;
|
|
1490
|
+
const processRunner = recoveringProviderProcessRunner(context.processRunner ?? this.processRunner);
|
|
1491
|
+
const environment = providerProcessEnv(dispatch, repairEnvironment);
|
|
1492
|
+
const validationEnvironment = prePublicationValidationEnv(dispatch, repairEnvironment);
|
|
1493
|
+
for (let repairTurn = 0;; repairTurn += 1) {
|
|
1494
|
+
try {
|
|
1495
|
+
await this.prePublicationValidator.validate(worktree.worktreePath, validationProfile, publicationCapabilities(context, repairEnvironment), context.emit, context.signal, prePublicationExecution(context, validationEnvironment, this.processRunner, preparedHeadSha));
|
|
1496
|
+
if (repairTurn > 0) {
|
|
1497
|
+
await context.emit({
|
|
1498
|
+
role: "runner",
|
|
1499
|
+
kind: "checkpoint",
|
|
1500
|
+
content: `Pre-publication validation passed after ${repairTurn} in-place repair turn${repairTurn === 1 ? "" : "s"}.`,
|
|
1501
|
+
eventType: "prepublication_repair_completed",
|
|
1502
|
+
data: JSON.stringify({ repairTurn, maxRepairTurns: maxPrePublicationRepairTurns }),
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
catch (error) {
|
|
1508
|
+
if (!(error instanceof PrePublicationValidationError))
|
|
1509
|
+
throw error;
|
|
1510
|
+
if (repairTurn >= maxPrePublicationRepairTurns) {
|
|
1511
|
+
await context.emit({
|
|
1512
|
+
role: "runner",
|
|
1513
|
+
kind: "error",
|
|
1514
|
+
content: `Pre-publication validation still fails after ${maxPrePublicationRepairTurns} in-place repair turns: ${clip(error.message, 4000)}`,
|
|
1515
|
+
eventType: "prepublication_repair_exhausted",
|
|
1516
|
+
data: JSON.stringify({ checkId: error.checkId, repairTurns: repairTurn, maxRepairTurns: maxPrePublicationRepairTurns }),
|
|
1517
|
+
});
|
|
1518
|
+
throw error;
|
|
1519
|
+
}
|
|
1520
|
+
const providerThreadId = outputState.providerThreadId?.trim();
|
|
1521
|
+
if (!providerThreadId) {
|
|
1522
|
+
throw new Error(`prepublication_repair_unavailable: ${error.message}; provider did not report a resumable thread id, so Forge preserved the dirty worktree without starting a blind replacement run.`, { cause: error });
|
|
1523
|
+
}
|
|
1524
|
+
const nextRepairTurn = repairTurn + 1;
|
|
1525
|
+
const focusedPrompt = buildPrePublicationRepairPrompt(error, nextRepairTurn, repairScope);
|
|
1526
|
+
const repairDispatch = {
|
|
1527
|
+
...dispatch,
|
|
1528
|
+
session: { ...dispatch.session, resumeProviderThreadId: providerThreadId },
|
|
1529
|
+
};
|
|
1530
|
+
const providerPrompt = buildProviderCliPrompt(repairDispatch, focusedPrompt, repairEnvironment);
|
|
1531
|
+
const pipePromptToStdin = shouldPipePromptToStdin(repairDispatch.execution.provider, providerPrompt, usesManagedWindowsHost(repairDispatch));
|
|
1532
|
+
const command = buildProviderCliCommand(repairDispatch, {
|
|
1533
|
+
worktreePath: worktree.worktreePath,
|
|
1534
|
+
prompt: focusedPrompt,
|
|
1535
|
+
promptFromStdin: pipePromptToStdin,
|
|
1536
|
+
runtimeEnvironment: repairEnvironment,
|
|
1537
|
+
});
|
|
1538
|
+
await context.emit({
|
|
1539
|
+
role: "runner",
|
|
1540
|
+
kind: "checkpoint",
|
|
1541
|
+
content: `Resuming ${dispatch.execution.provider} ${outputState.sessionLabel ?? "provider thread"} for bounded pre-publication repair ${nextRepairTurn}/${maxPrePublicationRepairTurns} after '${error.checkId}' failed.`,
|
|
1542
|
+
eventType: "prepublication_repair_started",
|
|
1543
|
+
data: JSON.stringify({ checkId: error.checkId, repairTurn: nextRepairTurn, maxRepairTurns: maxPrePublicationRepairTurns }),
|
|
1544
|
+
});
|
|
1545
|
+
const toolHeartbeat = new ProviderToolActivityHeartbeat(outputState, context.emit, this.providerToolHeartbeatIntervalMs);
|
|
1546
|
+
let result;
|
|
1547
|
+
let providerStarted = false;
|
|
1548
|
+
try {
|
|
1549
|
+
result = await processRunner.run({
|
|
1550
|
+
command: command.command,
|
|
1551
|
+
args: command.args,
|
|
1552
|
+
cwd: worktree.worktreePath,
|
|
1553
|
+
env: environment,
|
|
1554
|
+
stdin: pipePromptToStdin ? providerPrompt : undefined,
|
|
1555
|
+
}, {
|
|
1556
|
+
onStarted: async () => {
|
|
1557
|
+
await context.emit({
|
|
1558
|
+
role: "runner",
|
|
1559
|
+
kind: "checkpoint",
|
|
1560
|
+
content: `Provider pre-publication repair turn ${nextRepairTurn}/${maxPrePublicationRepairTurns} is supervised and ready.`,
|
|
1561
|
+
eventType: "provider_cli_started",
|
|
1562
|
+
});
|
|
1563
|
+
providerStarted = true;
|
|
1564
|
+
},
|
|
1565
|
+
onStdoutLine: async (line) => {
|
|
1566
|
+
await toolHeartbeat.consume(line);
|
|
1567
|
+
},
|
|
1568
|
+
}, context.signal);
|
|
1569
|
+
}
|
|
1570
|
+
finally {
|
|
1571
|
+
toolHeartbeat.stop();
|
|
1572
|
+
if (providerStarted) {
|
|
1573
|
+
await context.emit({
|
|
1574
|
+
role: "runner",
|
|
1575
|
+
kind: "checkpoint",
|
|
1576
|
+
content: `Provider pre-publication repair turn ${nextRepairTurn}/${maxPrePublicationRepairTurns} exited.`,
|
|
1577
|
+
eventType: "provider_cli_completed",
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
if (result.exitCode !== 0)
|
|
1582
|
+
throw providerCliExitFailure(command.command, result);
|
|
1583
|
+
const postRepairHeadSha = (await this.git.run(worktree.worktreePath, ["rev-parse", "HEAD"])).trim();
|
|
1584
|
+
if (postRepairHeadSha.toLowerCase() !== preparedHeadSha.toLowerCase()) {
|
|
1585
|
+
throw new Error(`provider_publication_boundary_violation: pre-publication repair changed HEAD from ${preparedHeadSha} to ${postRepairHeadSha}; Forge requires provider changes to remain uncommitted and unpushed until validation passes.`, { cause: error });
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
function buildPrePublicationRepairPrompt(error, repairTurn, repairScope) {
|
|
1592
|
+
const invocation = [error.command, ...error.args].join(" ");
|
|
1593
|
+
const priorWork = repairScope === "implementation"
|
|
1594
|
+
? "Keep the completed implementation and its Forge-owned completion artifacts intact."
|
|
1595
|
+
: "Keep the existing PR-comment or CI repairs intact.";
|
|
1596
|
+
return [
|
|
1597
|
+
`Forge pre-publication validation failed after your ${repairScope === "implementation" ? "implementation" : "PR repair"} turn.`,
|
|
1598
|
+
`This is bounded in-place repair turn ${repairTurn}/${maxPrePublicationRepairTurns}.`,
|
|
1599
|
+
`Failed check: ${error.checkId}`,
|
|
1600
|
+
`Command: ${invocation}`,
|
|
1601
|
+
`Exit code: ${error.exitCode}`,
|
|
1602
|
+
"Diagnostics:",
|
|
1603
|
+
error.detail || "The validation command returned no diagnostic output.",
|
|
1604
|
+
"Fix the root cause in the current worktree and run the focused failing check.",
|
|
1605
|
+
`${priorWork} Do not commit, push, rebase, or modify Forge-owned protocol artifacts.`,
|
|
1606
|
+
"When the focused check passes, stop; Bridge will rerun the complete required pre-publication profile and owns commit/push.",
|
|
1607
|
+
].join("\n\n");
|
|
1608
|
+
}
|
|
1609
|
+
function publicationCapabilities(context, runtimeEnvironment) {
|
|
1610
|
+
const capabilities = new Set();
|
|
1611
|
+
const launchTarget = context.dispatch.qaBrowser?.launchTarget;
|
|
1612
|
+
if (runtimeEnvironment?.FORGE_PLAYWRIGHT_CONFIG
|
|
1613
|
+
|| launchTarget?.url
|
|
1614
|
+
|| launchTarget?.command
|
|
1615
|
+
|| launchTarget?.managed_runtime_port) {
|
|
1616
|
+
capabilities.add("browser_launch");
|
|
1617
|
+
}
|
|
1618
|
+
return capabilities;
|
|
919
1619
|
}
|
|
920
1620
|
async function stageDraftAttachments(dispatch, metadataRoot) {
|
|
921
1621
|
const attachments = dispatch.draft?.attachments ?? [];
|
|
@@ -980,7 +1680,11 @@ function clipFileName(fileName, maxLength) {
|
|
|
980
1680
|
}
|
|
981
1681
|
const providerPromptStdinThreshold = 8_000;
|
|
982
1682
|
function shouldPipePromptToStdin(provider, prompt, force = false) {
|
|
983
|
-
|
|
1683
|
+
const normalized = provider.trim().toLowerCase();
|
|
1684
|
+
if (normalized === "claude") {
|
|
1685
|
+
return true;
|
|
1686
|
+
}
|
|
1687
|
+
return normalized === "codex"
|
|
984
1688
|
&& (force || Buffer.byteLength(prompt, "utf8") > providerPromptStdinThreshold);
|
|
985
1689
|
}
|
|
986
1690
|
function usesManagedWindowsHost(dispatch) {
|