@pushpalsdev/cli 1.0.41 → 1.0.43
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/package.json +1 -1
- package/runtime/prompts/review_agent/merge_conflict_instruction.md +1 -0
- package/runtime/prompts/workerpals/openai_codex_task_execute_system_prompt.md +1 -0
- package/runtime/sandbox/apps/workerpals/src/execute_job.ts +379 -21
- package/runtime/sandbox/apps/workerpals/src/job_runner.ts +81 -54
- package/runtime/sandbox/apps/workerpals/src/merge_conflict_job.ts +359 -0
- package/runtime/sandbox/apps/workerpals/src/workerpals_main.ts +24 -0
- package/runtime/sandbox/prompts/workerpals/openai_codex_task_execute_system_prompt.md +1 -0
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
Resolve merge conflicts for PR #{{pr_number}} ({{pr_url}}) on branch {{pr_head_ref}}.
|
|
2
2
|
This PR already passed ReviewAgent ({{review_score}}/10) but GitHub reports it is not mergeable due to conflicts.
|
|
3
3
|
Rebase {{pr_head_ref}} onto {{pr_base_ref}}, resolve all conflicts, keep intended behavior, run relevant tests, and push updates to the same branch.
|
|
4
|
+
If the worker sandbox already prepared an isolated branch or in-progress rebase state, use that current repo state as authoritative instead of re-deriving branch topology.
|
|
4
5
|
Do not create a new PR; update only the existing PR branch.
|
|
@@ -6,6 +6,7 @@ Non-negotiable runtime invariants:
|
|
|
6
6
|
- Do not modify tests or production code to bypass, stub, or remove Codex CLI usage due to assumed environment limitations.
|
|
7
7
|
- Do not "adapt around" missing Codex access by rewriting coverage or behavior expectations.
|
|
8
8
|
- If Codex CLI authentication/execution is unavailable, fail loudly with a clear error and stop.
|
|
9
|
+
- When worker guidance provides exact repo/branch/conflict state, treat that prepared sandbox state as authoritative and start from the current checkout instead of re-discovering topology.
|
|
9
10
|
|
|
10
11
|
Execution rules:
|
|
11
12
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Used by both the host Worker (direct mode) and the Docker job runner.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { readFileSync, unlinkSync } from "fs";
|
|
6
|
+
import { existsSync, readFileSync, unlinkSync } from "fs";
|
|
7
7
|
import { resolve } from "path";
|
|
8
8
|
import {
|
|
9
9
|
deriveAutonomyComponentArea,
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
export { compactJobOutput, truncate, streamLines } from "./common/execution_utils.js";
|
|
27
27
|
export { extractClarificationQuestionFromOutput } from "./backends/openhands_task_execute.js";
|
|
28
28
|
import { getBackendTaskExecutor } from "./backends/task_execute_registry.js";
|
|
29
|
+
import { extractMergeConflictReviewContext } from "./merge_conflict_job.js";
|
|
29
30
|
|
|
30
31
|
const DEFAULT_CONFIG = loadPushPalsConfig();
|
|
31
32
|
|
|
@@ -80,6 +81,23 @@ interface CriticReview {
|
|
|
80
81
|
raw: string;
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
export interface ReviewFixContext {
|
|
85
|
+
resolutionType: "review_fix";
|
|
86
|
+
prHeadRef: string | null;
|
|
87
|
+
prBaseRef: string | null;
|
|
88
|
+
previousReviewScore: number | null;
|
|
89
|
+
reviewThreshold: number | null;
|
|
90
|
+
previousReviewSummary: string;
|
|
91
|
+
reviewerFindings: string[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface QualityGatePolicy {
|
|
95
|
+
mode: "default" | "review_fix";
|
|
96
|
+
maxAutoRevisions: number;
|
|
97
|
+
softPassOnExhausted: boolean;
|
|
98
|
+
criticMinScore: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
83
101
|
// ─── Utilities ───────────────────────────────────────────────────────────────
|
|
84
102
|
|
|
85
103
|
export function shouldCommit(
|
|
@@ -223,6 +241,102 @@ export function resolveReviewNoChangeCompletionBranch(
|
|
|
223
241
|
return resolved.overridden ? resolved.branch : null;
|
|
224
242
|
}
|
|
225
243
|
|
|
244
|
+
function toFiniteReviewScore(value: unknown): number | null {
|
|
245
|
+
const parsed = Number(value);
|
|
246
|
+
if (!Number.isFinite(parsed)) return null;
|
|
247
|
+
return Math.max(0, Math.min(10, parsed));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function toNonEmptyReviewStringArray(value: unknown, limit: number = 8): string[] {
|
|
251
|
+
if (!Array.isArray(value)) return [];
|
|
252
|
+
return value
|
|
253
|
+
.map((entry) => String(entry ?? "").trim())
|
|
254
|
+
.filter(Boolean)
|
|
255
|
+
.slice(0, limit);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function extractReviewFixContext(
|
|
259
|
+
params: Record<string, unknown> | null | undefined,
|
|
260
|
+
): ReviewFixContext | null {
|
|
261
|
+
if (!params || typeof params !== "object" || Array.isArray(params)) return null;
|
|
262
|
+
const reviewAgent =
|
|
263
|
+
params.reviewAgent &&
|
|
264
|
+
typeof params.reviewAgent === "object" &&
|
|
265
|
+
!Array.isArray(params.reviewAgent)
|
|
266
|
+
? (params.reviewAgent as Record<string, unknown>)
|
|
267
|
+
: null;
|
|
268
|
+
if (!reviewAgent) return null;
|
|
269
|
+
const resolutionType = String(reviewAgent.resolutionType ?? "")
|
|
270
|
+
.trim()
|
|
271
|
+
.toLowerCase();
|
|
272
|
+
if (resolutionType === "merge_conflict") return null;
|
|
273
|
+
const looksLikeLegacyReviewFix =
|
|
274
|
+
typeof reviewAgent.prHeadRef === "string" ||
|
|
275
|
+
typeof reviewAgent.previousReviewSummary === "string" ||
|
|
276
|
+
Number.isFinite(Number(reviewAgent.previousReviewScore)) ||
|
|
277
|
+
Array.isArray(reviewAgent.reviewerFindings);
|
|
278
|
+
if (resolutionType && resolutionType !== "review_fix") return null;
|
|
279
|
+
if (!resolutionType && !looksLikeLegacyReviewFix) return null;
|
|
280
|
+
return {
|
|
281
|
+
resolutionType: "review_fix",
|
|
282
|
+
prHeadRef: typeof reviewAgent.prHeadRef === "string" ? reviewAgent.prHeadRef.trim() || null : null,
|
|
283
|
+
prBaseRef: typeof reviewAgent.prBaseRef === "string" ? reviewAgent.prBaseRef.trim() || null : null,
|
|
284
|
+
previousReviewScore: toFiniteReviewScore(reviewAgent.previousReviewScore),
|
|
285
|
+
reviewThreshold: toFiniteReviewScore(reviewAgent.reviewThreshold),
|
|
286
|
+
previousReviewSummary: String(reviewAgent.previousReviewSummary ?? "").trim(),
|
|
287
|
+
reviewerFindings: toNonEmptyReviewStringArray(reviewAgent.reviewerFindings),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function shouldEnqueueNoChangeReviewCompletion(
|
|
292
|
+
params: Record<string, unknown> | null | undefined,
|
|
293
|
+
): boolean {
|
|
294
|
+
return extractReviewFixContext(params) == null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function deriveQualityGatePolicy(
|
|
298
|
+
params: Record<string, unknown> | null | undefined,
|
|
299
|
+
runtimeConfig: WorkerpalsRuntimeConfig = DEFAULT_CONFIG,
|
|
300
|
+
): QualityGatePolicy {
|
|
301
|
+
const baseMaxAutoRevisions = Math.max(
|
|
302
|
+
0,
|
|
303
|
+
Math.min(
|
|
304
|
+
10,
|
|
305
|
+
Number.isFinite(Number(runtimeConfig.workerpals.qualityMaxAutoRevisions))
|
|
306
|
+
? Math.floor(Number(runtimeConfig.workerpals.qualityMaxAutoRevisions))
|
|
307
|
+
: 4,
|
|
308
|
+
),
|
|
309
|
+
);
|
|
310
|
+
const baseSoftPassOnExhausted =
|
|
311
|
+
typeof runtimeConfig.workerpals.qualitySoftPassOnExhausted === "boolean"
|
|
312
|
+
? runtimeConfig.workerpals.qualitySoftPassOnExhausted
|
|
313
|
+
: true;
|
|
314
|
+
const baseCriticMinScore = (() => {
|
|
315
|
+
const value = Number(runtimeConfig.workerpals.qualityCriticMinScore);
|
|
316
|
+
if (!Number.isFinite(value)) return 8;
|
|
317
|
+
return Math.max(0, Math.min(10, value));
|
|
318
|
+
})();
|
|
319
|
+
const reviewFix = extractReviewFixContext(params);
|
|
320
|
+
if (!reviewFix) {
|
|
321
|
+
return {
|
|
322
|
+
mode: "default",
|
|
323
|
+
maxAutoRevisions: baseMaxAutoRevisions,
|
|
324
|
+
softPassOnExhausted: baseSoftPassOnExhausted,
|
|
325
|
+
criticMinScore: baseCriticMinScore,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
const tightenedCriticMinScore =
|
|
329
|
+
reviewFix.reviewThreshold != null
|
|
330
|
+
? Math.max(baseCriticMinScore, Math.max(0, Math.min(10, reviewFix.reviewThreshold - 0.2)))
|
|
331
|
+
: baseCriticMinScore;
|
|
332
|
+
return {
|
|
333
|
+
mode: "review_fix",
|
|
334
|
+
maxAutoRevisions: Math.max(baseMaxAutoRevisions, 2),
|
|
335
|
+
softPassOnExhausted: false,
|
|
336
|
+
criticMinScore: tightenedCriticMinScore,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
226
340
|
function normalizeChatCompletionsEndpoint(endpoint: string): string {
|
|
227
341
|
const source = endpoint.trim().replace(/\/+$/, "");
|
|
228
342
|
if (!source) return "http://127.0.0.1:1234/v1/chat/completions";
|
|
@@ -907,13 +1021,39 @@ async function runTaskCriticReview(
|
|
|
907
1021
|
}
|
|
908
1022
|
}
|
|
909
1023
|
|
|
910
|
-
function buildQualityRevisionHint(
|
|
1024
|
+
export function buildQualityRevisionHint(
|
|
911
1025
|
issues: string[],
|
|
912
1026
|
critic: CriticReview | null,
|
|
913
1027
|
planning: TaskExecutePlanning,
|
|
1028
|
+
reviewFixContext?: ReviewFixContext | null,
|
|
914
1029
|
): string {
|
|
915
1030
|
const lines: string[] = [];
|
|
916
1031
|
lines.push("Quality revision required before completion.");
|
|
1032
|
+
if (reviewFixContext) {
|
|
1033
|
+
lines.push("Rejected PR retry requirements:");
|
|
1034
|
+
if (reviewFixContext.previousReviewScore != null) {
|
|
1035
|
+
lines.push(
|
|
1036
|
+
`Previous ReviewAgent score: ${reviewFixContext.previousReviewScore.toFixed(1)} / 10`,
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
if (reviewFixContext.reviewThreshold != null) {
|
|
1040
|
+
lines.push(
|
|
1041
|
+
`Required approval threshold: ${reviewFixContext.reviewThreshold.toFixed(1)} / 10`,
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
if (reviewFixContext.previousReviewSummary) {
|
|
1045
|
+
lines.push(
|
|
1046
|
+
`Previous reviewer summary: ${toSingleLine(reviewFixContext.previousReviewSummary, 220)}`,
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
if (reviewFixContext.reviewerFindings.length > 0) {
|
|
1050
|
+
lines.push("Previous reviewer must-fix items:");
|
|
1051
|
+
for (const finding of reviewFixContext.reviewerFindings.slice(0, 5)) {
|
|
1052
|
+
lines.push(`- ${finding}`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
lines.push("Raise the score above the approval threshold without reopening already accepted behavior.");
|
|
1056
|
+
}
|
|
917
1057
|
if (issues.length > 0) {
|
|
918
1058
|
lines.push("Deterministic quality issues:");
|
|
919
1059
|
for (const issue of issues) lines.push(`- ${issue}`);
|
|
@@ -1200,6 +1340,9 @@ export async function createJobCommit(
|
|
|
1200
1340
|
defaultPublicBranchName,
|
|
1201
1341
|
);
|
|
1202
1342
|
const publicBranchName = resolvedPublicBranch.branch;
|
|
1343
|
+
if (extractMergeConflictReviewContext(job.params ?? null)) {
|
|
1344
|
+
return createMergeConflictJobCommit(repo, workerId, job, publicBranchName, runtimeConfig);
|
|
1345
|
+
}
|
|
1203
1346
|
const requirePush = runtimeConfig.workerpals.requirePush || resolvedPublicBranch.overridden;
|
|
1204
1347
|
const pushAgentBranch =
|
|
1205
1348
|
requirePush || runtimeConfig.workerpals.pushAgentBranch || resolvedPublicBranch.overridden;
|
|
@@ -1865,6 +2008,220 @@ async function currentRefSha(repo: string, ref: string): Promise<string | null>
|
|
|
1865
2008
|
return result.stdout.trim() || null;
|
|
1866
2009
|
}
|
|
1867
2010
|
|
|
2011
|
+
async function currentBranchName(repo: string): Promise<string | null> {
|
|
2012
|
+
const result = await git(repo, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
2013
|
+
if (!result.ok) return null;
|
|
2014
|
+
return result.stdout.trim() || null;
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
async function gitDirPath(repo: string): Promise<string | null> {
|
|
2018
|
+
const result = await git(repo, ["rev-parse", "--git-dir"]);
|
|
2019
|
+
if (!result.ok) return null;
|
|
2020
|
+
const gitDir = result.stdout.trim();
|
|
2021
|
+
if (!gitDir) return null;
|
|
2022
|
+
return resolve(repo, gitDir);
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
async function activeGitOperation(repo: string): Promise<"rebase" | "merge" | "cherry-pick" | null> {
|
|
2026
|
+
const gitDir = await gitDirPath(repo);
|
|
2027
|
+
if (!gitDir) return null;
|
|
2028
|
+
if (existsSync(resolve(gitDir, "rebase-merge")) || existsSync(resolve(gitDir, "rebase-apply"))) {
|
|
2029
|
+
return "rebase";
|
|
2030
|
+
}
|
|
2031
|
+
if (existsSync(resolve(gitDir, "MERGE_HEAD"))) return "merge";
|
|
2032
|
+
if (existsSync(resolve(gitDir, "CHERRY_PICK_HEAD"))) return "cherry-pick";
|
|
2033
|
+
return null;
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
async function isAncestorRef(repo: string, ancestor: string, descendant: string): Promise<boolean> {
|
|
2037
|
+
const result = await git(repo, ["merge-base", "--is-ancestor", ancestor, descendant]);
|
|
2038
|
+
return result.ok;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
async function refreshMergeConflictTrackingRefs(
|
|
2042
|
+
repo: string,
|
|
2043
|
+
publicBranchName: string,
|
|
2044
|
+
baseBranchName: string,
|
|
2045
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
2046
|
+
const refspecs = [
|
|
2047
|
+
`+refs/heads/${publicBranchName}:refs/remotes/origin/${publicBranchName}`,
|
|
2048
|
+
`+refs/heads/${baseBranchName}:refs/remotes/origin/${baseBranchName}`,
|
|
2049
|
+
];
|
|
2050
|
+
const fetch = await git(repo, ["fetch", "--quiet", "origin", ...new Set(refspecs)]);
|
|
2051
|
+
if (!fetch.ok) {
|
|
2052
|
+
return {
|
|
2053
|
+
ok: false,
|
|
2054
|
+
error: `Failed to refresh merge-conflict refs for ${publicBranchName}: ${redactSensitiveText(fetch.stderr || fetch.stdout)}`,
|
|
2055
|
+
};
|
|
2056
|
+
}
|
|
2057
|
+
return { ok: true };
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
async function createMergeConflictJobCommit(
|
|
2061
|
+
repo: string,
|
|
2062
|
+
workerId: string,
|
|
2063
|
+
job: {
|
|
2064
|
+
id: string;
|
|
2065
|
+
taskId: string;
|
|
2066
|
+
kind: string;
|
|
2067
|
+
params?: Record<string, unknown>;
|
|
2068
|
+
},
|
|
2069
|
+
publicBranchName: string,
|
|
2070
|
+
runtimeConfig: WorkerpalsRuntimeConfig,
|
|
2071
|
+
): Promise<{ ok: boolean; branch?: string; sha?: string; error?: string }> {
|
|
2072
|
+
const mergeConflictContext = extractMergeConflictReviewContext(job.params ?? null);
|
|
2073
|
+
if (!mergeConflictContext) {
|
|
2074
|
+
return { ok: false, error: "Merge-conflict context is missing required branch metadata." };
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
const sequencer = await activeGitOperation(repo);
|
|
2078
|
+
if (sequencer) {
|
|
2079
|
+
return {
|
|
2080
|
+
ok: false,
|
|
2081
|
+
error: `Merge-conflict job ${job.id} left a git ${sequencer} in progress. Finish the ${sequencer} before returning control to WorkerPals.`,
|
|
2082
|
+
};
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
const refreshed = await refreshMergeConflictTrackingRefs(
|
|
2086
|
+
repo,
|
|
2087
|
+
publicBranchName,
|
|
2088
|
+
mergeConflictContext.baseBranch,
|
|
2089
|
+
);
|
|
2090
|
+
if (!refreshed.ok) return refreshed;
|
|
2091
|
+
|
|
2092
|
+
const currentBranch = await currentBranchName(repo);
|
|
2093
|
+
if (!currentBranch) {
|
|
2094
|
+
return {
|
|
2095
|
+
ok: false,
|
|
2096
|
+
error: `Merge-conflict job ${job.id} must finish on a local branch inside the isolated sandbox, but HEAD is detached.`,
|
|
2097
|
+
};
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
const remoteHeadSha = await currentRefSha(repo, `refs/remotes/origin/${publicBranchName}`);
|
|
2101
|
+
if (
|
|
2102
|
+
mergeConflictContext.expectedHeadSha &&
|
|
2103
|
+
remoteHeadSha &&
|
|
2104
|
+
remoteHeadSha !== mergeConflictContext.expectedHeadSha
|
|
2105
|
+
) {
|
|
2106
|
+
return {
|
|
2107
|
+
ok: false,
|
|
2108
|
+
error:
|
|
2109
|
+
`origin/${publicBranchName} moved from expected ${mergeConflictContext.expectedHeadSha.slice(0, 8)} ` +
|
|
2110
|
+
`to ${remoteHeadSha.slice(0, 8)} while the job was running. Requeue on the newer branch head instead of overwriting it.`,
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
let result: { ok: boolean; stdout: string; stderr: string };
|
|
2115
|
+
const stageArgs = buildStageCommand(job.kind, job.params);
|
|
2116
|
+
if (!stageArgs) {
|
|
2117
|
+
return {
|
|
2118
|
+
ok: false,
|
|
2119
|
+
error: `Unable to determine files to stage for merge-conflict job kind: ${job.kind}`,
|
|
2120
|
+
};
|
|
2121
|
+
}
|
|
2122
|
+
result = await git(repo, stageArgs);
|
|
2123
|
+
if (!result.ok) {
|
|
2124
|
+
const stageErr = result.stderr || result.stdout;
|
|
2125
|
+
if (
|
|
2126
|
+
/pathspec .* did not match any files/i.test(stageErr) ||
|
|
2127
|
+
/invalid path/i.test(stageErr) ||
|
|
2128
|
+
/outside repository/i.test(stageErr)
|
|
2129
|
+
) {
|
|
2130
|
+
console.warn(
|
|
2131
|
+
`[WorkerPals] Stage target invalid/missing for merge-conflict job ${job.id}; retrying with fallback "git add -A".`,
|
|
2132
|
+
);
|
|
2133
|
+
result = await git(repo, [
|
|
2134
|
+
"add",
|
|
2135
|
+
"-A",
|
|
2136
|
+
"--",
|
|
2137
|
+
".",
|
|
2138
|
+
":(exclude)workspace/**",
|
|
2139
|
+
":(exclude)outputs/**",
|
|
2140
|
+
]);
|
|
2141
|
+
}
|
|
2142
|
+
if (!result.ok) {
|
|
2143
|
+
return { ok: false, error: `Failed to stage merge-conflict changes: ${result.stderr || result.stdout}` };
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
const cachedDiffQuiet = await git(repo, ["diff", "--cached", "--quiet"]);
|
|
2148
|
+
let headSha = await currentRefSha(repo, "HEAD");
|
|
2149
|
+
if (!headSha) {
|
|
2150
|
+
return { ok: false, error: `Failed to resolve HEAD SHA for merge-conflict job ${job.id}.` };
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
if (!cachedDiffQuiet.ok) {
|
|
2154
|
+
const cachedDiff = await git(repo, ["diff", "--cached"]);
|
|
2155
|
+
const diff = cachedDiff.ok ? cachedDiff.stdout : "";
|
|
2156
|
+
const cachedNameOnly = await git(repo, ["diff", "--cached", "--name-only"]);
|
|
2157
|
+
const changedPaths = cachedNameOnly.ok
|
|
2158
|
+
? parseChangedPathsFromNameOnlyOutput(cachedNameOnly.stdout)
|
|
2159
|
+
: [];
|
|
2160
|
+
const jobPlanning = job.params?.planning as Record<string, unknown> | undefined;
|
|
2161
|
+
const jobValidationSteps = toNonEmptyStringArray(
|
|
2162
|
+
jobPlanning?.validationSteps ?? job.params?.validationSteps,
|
|
2163
|
+
);
|
|
2164
|
+
const llmCommitMsg = await generateCommitMessageFromDiff(
|
|
2165
|
+
diff,
|
|
2166
|
+
{
|
|
2167
|
+
instruction: String(job.params?.instruction ?? ""),
|
|
2168
|
+
type: normalizeCommitType(job.kind, job.params),
|
|
2169
|
+
area: inferCommitArea(job.kind, job.params, changedPaths),
|
|
2170
|
+
validationSteps: jobValidationSteps,
|
|
2171
|
+
},
|
|
2172
|
+
repo,
|
|
2173
|
+
runtimeConfig,
|
|
2174
|
+
).catch(() => null);
|
|
2175
|
+
if (!llmCommitMsg) {
|
|
2176
|
+
console.warn(
|
|
2177
|
+
`[WorkerPals] Commit message generator unavailable for merge-conflict job ${job.id}; using deterministic fallback.`,
|
|
2178
|
+
);
|
|
2179
|
+
}
|
|
2180
|
+
const commitMsg = llmCommitMsg ?? buildWorkerCommitMessage(workerId, job, changedPaths);
|
|
2181
|
+
const commit = await git(repo, ["commit", "-m", commitMsg]);
|
|
2182
|
+
if (!commit.ok) {
|
|
2183
|
+
return { ok: false, error: `Failed to commit merge-conflict resolution: ${commit.stderr}` };
|
|
2184
|
+
}
|
|
2185
|
+
headSha = await currentRefSha(repo, "HEAD");
|
|
2186
|
+
if (!headSha) {
|
|
2187
|
+
return { ok: false, error: `Failed to resolve committed HEAD SHA for merge-conflict job ${job.id}.` };
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
const baseRemoteRef = `refs/remotes/origin/${mergeConflictContext.baseBranch}`;
|
|
2192
|
+
const rebasedOntoBase = await isAncestorRef(repo, baseRemoteRef, "HEAD");
|
|
2193
|
+
if (!rebasedOntoBase) {
|
|
2194
|
+
return {
|
|
2195
|
+
ok: false,
|
|
2196
|
+
error:
|
|
2197
|
+
`Merge-conflict job ${job.id} did not finish rebased onto origin/${mergeConflictContext.baseBranch}. ` +
|
|
2198
|
+
`Current branch ${currentBranch} must be a descendant of ${baseRemoteRef} before WorkerPals will push it.`,
|
|
2199
|
+
};
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
if (remoteHeadSha && remoteHeadSha === headSha) {
|
|
2203
|
+
return { ok: true, branch: publicBranchName, sha: headSha };
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
const pushArgs = [
|
|
2207
|
+
"push",
|
|
2208
|
+
mergeConflictContext.expectedHeadSha
|
|
2209
|
+
? `--force-with-lease=refs/heads/${publicBranchName}:${mergeConflictContext.expectedHeadSha}`
|
|
2210
|
+
: "--force-with-lease",
|
|
2211
|
+
"origin",
|
|
2212
|
+
`HEAD:refs/heads/${publicBranchName}`,
|
|
2213
|
+
];
|
|
2214
|
+
const push = await git(repo, pushArgs);
|
|
2215
|
+
if (!push.ok) {
|
|
2216
|
+
return {
|
|
2217
|
+
ok: false,
|
|
2218
|
+
error: `Failed to push rebased merge-conflict branch ${publicBranchName}: ${redactSensitiveText(push.stderr || push.stdout)}`,
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
return { ok: true, branch: publicBranchName, sha: headSha };
|
|
2223
|
+
}
|
|
2224
|
+
|
|
1868
2225
|
async function autoResolveRebaseConflicts(
|
|
1869
2226
|
repo: string,
|
|
1870
2227
|
maxPasses = 8,
|
|
@@ -2983,29 +3340,30 @@ export async function executeJob(
|
|
|
2983
3340
|
};
|
|
2984
3341
|
const executionBudgetMs = Number(planning.executionBudgetMs);
|
|
2985
3342
|
const finalizationBudgetMs = Number(planning.finalizationBudgetMs);
|
|
2986
|
-
const
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
? Math.floor(Number(runtimeConfig.workerpals.qualityMaxAutoRevisions))
|
|
2992
|
-
: 4,
|
|
2993
|
-
),
|
|
2994
|
-
);
|
|
2995
|
-
const qualitySoftPassOnExhausted =
|
|
2996
|
-
typeof runtimeConfig.workerpals.qualitySoftPassOnExhausted === "boolean"
|
|
2997
|
-
? runtimeConfig.workerpals.qualitySoftPassOnExhausted
|
|
2998
|
-
: true;
|
|
2999
|
-
const qualityCriticMinScore = (() => {
|
|
3000
|
-
const value = Number(runtimeConfig.workerpals.qualityCriticMinScore);
|
|
3001
|
-
if (!Number.isFinite(value)) return 8;
|
|
3002
|
-
return Math.max(0, Math.min(10, value));
|
|
3003
|
-
})();
|
|
3343
|
+
const reviewFixContext = extractReviewFixContext(normalizedParams);
|
|
3344
|
+
const qualityGatePolicy = deriveQualityGatePolicy(normalizedParams, runtimeConfig);
|
|
3345
|
+
const qualityMaxAutoRevisions = qualityGatePolicy.maxAutoRevisions;
|
|
3346
|
+
const qualitySoftPassOnExhausted = qualityGatePolicy.softPassOnExhausted;
|
|
3347
|
+
const qualityCriticMinScore = qualityGatePolicy.criticMinScore;
|
|
3004
3348
|
|
|
3005
3349
|
onLog?.(
|
|
3006
3350
|
"stdout",
|
|
3007
3351
|
`[QualityGate] Policy: max_auto_revisions=${qualityMaxAutoRevisions}, soft_pass_on_exhausted=${qualitySoftPassOnExhausted ? "true" : "false"}, critic_min_score=${qualityCriticMinScore}`,
|
|
3008
3352
|
);
|
|
3353
|
+
if (qualityGatePolicy.mode === "review_fix") {
|
|
3354
|
+
const priorScore =
|
|
3355
|
+
reviewFixContext?.previousReviewScore != null
|
|
3356
|
+
? reviewFixContext.previousReviewScore.toFixed(1)
|
|
3357
|
+
: "unknown";
|
|
3358
|
+
const threshold =
|
|
3359
|
+
reviewFixContext?.reviewThreshold != null
|
|
3360
|
+
? reviewFixContext.reviewThreshold.toFixed(1)
|
|
3361
|
+
: qualityCriticMinScore.toFixed(1);
|
|
3362
|
+
onLog?.(
|
|
3363
|
+
"stdout",
|
|
3364
|
+
`[QualityGate] review_fix override active: prior_score=${priorScore}, target_threshold=${threshold}, soft_pass_on_exhausted=false.`,
|
|
3365
|
+
);
|
|
3366
|
+
}
|
|
3009
3367
|
|
|
3010
3368
|
let revisionAttempt = 0;
|
|
3011
3369
|
let revisionHint = "";
|
|
@@ -3102,7 +3460,7 @@ export async function executeJob(
|
|
|
3102
3460
|
}
|
|
3103
3461
|
|
|
3104
3462
|
revisionAttempt += 1;
|
|
3105
|
-
revisionHint = buildQualityRevisionHint(issues, critic, planning);
|
|
3463
|
+
revisionHint = buildQualityRevisionHint(issues, critic, planning, reviewFixContext);
|
|
3106
3464
|
onLog?.(
|
|
3107
3465
|
"stderr",
|
|
3108
3466
|
`[QualityGate] Quality gate requested revision ${revisionAttempt}/${qualityMaxAutoRevisions}: ${toSingleLine(
|
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
import { executeJob, shouldCommit, createJobCommit } from "./execute_job.js";
|
|
19
19
|
import { loadPushPalsConfig } from "shared";
|
|
20
20
|
import { writeFileSync } from "fs";
|
|
21
|
+
import {
|
|
22
|
+
applyMergeConflictExecutionHints,
|
|
23
|
+
isMergeConflictResolutionParams,
|
|
24
|
+
prepareMergeConflictTaskRepo,
|
|
25
|
+
} from "./merge_conflict_job.js";
|
|
21
26
|
|
|
22
27
|
const CONFIG = loadPushPalsConfig();
|
|
23
28
|
|
|
@@ -125,69 +130,91 @@ async function main(): Promise<void> {
|
|
|
125
130
|
// Setup git credentials for pushing
|
|
126
131
|
setupGitCredentials();
|
|
127
132
|
// Execute inside the mounted job worktree (docker -w), not the baked image copy.
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
133
|
+
const mountedJobRepo = process.cwd();
|
|
134
|
+
let jobRepo = mountedJobRepo;
|
|
135
|
+
let cleanupJobRepo: (() => void) | null = null;
|
|
136
|
+
let effectiveParams = spec.params;
|
|
137
|
+
try {
|
|
138
|
+
if (isMergeConflictResolutionParams(spec.params)) {
|
|
139
|
+
const prepared = await prepareMergeConflictTaskRepo(
|
|
140
|
+
mountedJobRepo,
|
|
141
|
+
spec.jobId,
|
|
142
|
+
spec.params,
|
|
143
|
+
log,
|
|
144
|
+
);
|
|
145
|
+
jobRepo = prepared.repoPath;
|
|
146
|
+
cleanupJobRepo = prepared.cleanup;
|
|
147
|
+
effectiveParams = applyMergeConflictExecutionHints(spec.params, prepared);
|
|
148
|
+
log(
|
|
149
|
+
"stdout",
|
|
150
|
+
`[JobRunner] Merge-conflict job ${spec.jobId} is running in isolated sandbox repo ${jobRepo}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const result = await executeJob(
|
|
154
|
+
spec.kind,
|
|
155
|
+
effectiveParams,
|
|
150
156
|
jobRepo,
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
id: spec.jobId,
|
|
154
|
-
taskId: spec.taskId,
|
|
155
|
-
kind: spec.kind,
|
|
156
|
-
params: spec.params,
|
|
157
|
-
context: "docker",
|
|
157
|
+
(stream, line) => {
|
|
158
|
+
log(stream, line);
|
|
158
159
|
},
|
|
159
160
|
CONFIG,
|
|
160
161
|
);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
162
|
+
// Build result object
|
|
163
|
+
const jobResult: JobResult = {
|
|
164
|
+
ok: result.ok,
|
|
165
|
+
summary: result.summary,
|
|
166
|
+
stdout: result.stdout,
|
|
167
|
+
stderr: result.stderr,
|
|
168
|
+
exitCode: result.exitCode,
|
|
169
|
+
};
|
|
170
|
+
// Create commit for file-modifying jobs
|
|
171
|
+
if (result.ok && shouldCommit(spec.kind, CONFIG)) {
|
|
172
|
+
log("stdout", `[JobRunner] Job modified files, creating commit...`);
|
|
173
|
+
const commitResult = await createJobCommit(
|
|
174
|
+
jobRepo,
|
|
175
|
+
spec.workerId,
|
|
176
|
+
{
|
|
177
|
+
id: spec.jobId,
|
|
178
|
+
taskId: spec.taskId,
|
|
179
|
+
kind: spec.kind,
|
|
180
|
+
params: effectiveParams,
|
|
181
|
+
context: "docker",
|
|
182
|
+
},
|
|
183
|
+
CONFIG,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
if (commitResult.ok && commitResult.sha && commitResult.branch) {
|
|
187
|
+
jobResult.commit = {
|
|
188
|
+
branch: commitResult.branch!,
|
|
189
|
+
sha: commitResult.sha,
|
|
190
|
+
};
|
|
191
|
+
if (commitResult.sha === "no-changes") {
|
|
192
|
+
log("stdout", `[JobRunner] No changes to commit for ${spec.jobId}`);
|
|
193
|
+
} else {
|
|
194
|
+
log("stdout", `[JobRunner] Created commit ${commitResult.sha} on ${commitResult.branch}`);
|
|
195
|
+
}
|
|
169
196
|
} else {
|
|
170
|
-
|
|
197
|
+
const commitError =
|
|
198
|
+
commitResult.error ??
|
|
199
|
+
`Commit metadata missing for ${spec.kind} (${spec.jobId}) while running in Docker mode`;
|
|
200
|
+
jobResult.ok = false;
|
|
201
|
+
jobResult.summary = `Failed to create commit for ${spec.kind}`;
|
|
202
|
+
jobResult.stderr = [jobResult.stderr, commitError].filter(Boolean).join("\n");
|
|
203
|
+
jobResult.exitCode = jobResult.exitCode && jobResult.exitCode !== 0 ? jobResult.exitCode : 1;
|
|
204
|
+
log("stderr", `[JobRunner] Failed to create commit: ${commitError}`);
|
|
171
205
|
}
|
|
172
|
-
} else {
|
|
173
|
-
const commitError =
|
|
174
|
-
commitResult.error ??
|
|
175
|
-
`Commit metadata missing for ${spec.kind} (${spec.jobId}) while running in Docker mode`;
|
|
176
|
-
jobResult.ok = false;
|
|
177
|
-
jobResult.summary = `Failed to create commit for ${spec.kind}`;
|
|
178
|
-
jobResult.stderr = [jobResult.stderr, commitError].filter(Boolean).join("\n");
|
|
179
|
-
jobResult.exitCode = jobResult.exitCode && jobResult.exitCode !== 0 ? jobResult.exitCode : 1;
|
|
180
|
-
log("stderr", `[JobRunner] Failed to create commit: ${commitError}`);
|
|
181
206
|
}
|
|
182
|
-
}
|
|
183
207
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
208
|
+
// Output result with sentinel
|
|
209
|
+
const resultJson = JSON.stringify(jobResult);
|
|
210
|
+
// eslint-disable-next-line no-console
|
|
211
|
+
console.log(`___RESULT___ ${resultJson}`);
|
|
188
212
|
|
|
189
|
-
|
|
190
|
-
|
|
213
|
+
// Exit with appropriate code
|
|
214
|
+
process.exit(jobResult.exitCode ?? (jobResult.ok ? 0 : 1));
|
|
215
|
+
} finally {
|
|
216
|
+
cleanupJobRepo?.();
|
|
217
|
+
}
|
|
191
218
|
}
|
|
192
219
|
|
|
193
220
|
main().catch((err) => {
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync } from "fs";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { basename, dirname, join, resolve } from "path";
|
|
4
|
+
|
|
5
|
+
type LogFn = (stream: "stdout" | "stderr", line: string) => void;
|
|
6
|
+
|
|
7
|
+
type GitResult = {
|
|
8
|
+
ok: boolean;
|
|
9
|
+
stdout: string;
|
|
10
|
+
stderr: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type MergeConflictReviewContext = {
|
|
14
|
+
publicBranch: string;
|
|
15
|
+
baseBranch: string;
|
|
16
|
+
expectedHeadSha: string;
|
|
17
|
+
mergeError: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type MergeConflictSandboxPreparation = {
|
|
21
|
+
repoPath: string;
|
|
22
|
+
cleanup: () => void;
|
|
23
|
+
conflictPaths: string[];
|
|
24
|
+
plannerGuidance: string;
|
|
25
|
+
rebasedCleanly: boolean;
|
|
26
|
+
currentHeadSha: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
30
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
31
|
+
return value as Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeBranchName(value: unknown): string {
|
|
35
|
+
const trimmed = String(value ?? "").trim().replace(/^refs\/heads\//, "");
|
|
36
|
+
const normalized = trimmed
|
|
37
|
+
.replace(/\\/g, "/")
|
|
38
|
+
.replace(/\/+/g, "/")
|
|
39
|
+
.replace(/^\/+|\/+$/g, "");
|
|
40
|
+
if (!normalized.startsWith("agent/")) return "";
|
|
41
|
+
if (
|
|
42
|
+
normalized.includes("..") ||
|
|
43
|
+
normalized.includes("@{") ||
|
|
44
|
+
normalized.endsWith(".") ||
|
|
45
|
+
normalized.endsWith(".lock")
|
|
46
|
+
) {
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
if (/[~^:?*\[\]\s]/.test(normalized)) return "";
|
|
50
|
+
return normalized;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeBaseBranch(value: unknown): string {
|
|
54
|
+
return String(value ?? "")
|
|
55
|
+
.trim()
|
|
56
|
+
.replace(/^refs\/heads\//, "")
|
|
57
|
+
.replace(/\\/g, "/")
|
|
58
|
+
.replace(/\/+/g, "/")
|
|
59
|
+
.replace(/^\/+|\/+$/g, "");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isMergeConflictOutput(text: string): boolean {
|
|
63
|
+
const normalized = String(text ?? "").toLowerCase();
|
|
64
|
+
return (
|
|
65
|
+
normalized.includes("could not apply") ||
|
|
66
|
+
normalized.includes("resolve all conflicts manually") ||
|
|
67
|
+
normalized.includes("merge conflict") ||
|
|
68
|
+
normalized.includes("fix conflicts and then run")
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function git(cwd: string, args: string[]): Promise<GitResult> {
|
|
73
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
74
|
+
cwd,
|
|
75
|
+
stdout: "pipe",
|
|
76
|
+
stderr: "pipe",
|
|
77
|
+
});
|
|
78
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
79
|
+
new Response(proc.stdout).text(),
|
|
80
|
+
new Response(proc.stderr).text(),
|
|
81
|
+
proc.exited,
|
|
82
|
+
]);
|
|
83
|
+
return {
|
|
84
|
+
ok: exitCode === 0,
|
|
85
|
+
stdout: stdout.trim(),
|
|
86
|
+
stderr: stderr.trim(),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function mustGit(cwd: string, args: string[], label: string): Promise<string> {
|
|
91
|
+
const result = await git(cwd, args);
|
|
92
|
+
if (!result.ok) {
|
|
93
|
+
throw new Error(`${label} failed: git ${args.join(" ")}\n${result.stderr || result.stdout}`);
|
|
94
|
+
}
|
|
95
|
+
return result.stdout;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function dedupeStrings(values: string[], maxItems = 16): string[] {
|
|
99
|
+
const seen = new Set<string>();
|
|
100
|
+
const out: string[] = [];
|
|
101
|
+
for (const entry of values) {
|
|
102
|
+
const trimmed = String(entry ?? "").trim();
|
|
103
|
+
if (!trimmed || seen.has(trimmed)) continue;
|
|
104
|
+
seen.add(trimmed);
|
|
105
|
+
out.push(trimmed);
|
|
106
|
+
if (out.length >= maxItems) break;
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function deriveLikelyDirs(paths: string[]): string[] {
|
|
112
|
+
return dedupeStrings(
|
|
113
|
+
paths
|
|
114
|
+
.map((entry) => dirname(entry).replace(/\\/g, "/"))
|
|
115
|
+
.filter((entry) => entry && entry !== "."),
|
|
116
|
+
12,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function deriveRipgrepQueries(paths: string[]): string[] {
|
|
121
|
+
return dedupeStrings(paths.map((entry) => basename(entry)).filter(Boolean), 8);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isTestPath(path: string): boolean {
|
|
125
|
+
return /(^tests\/|__tests__\/|\.test\.[cm]?[jt]sx?$|\.spec\.[cm]?[jt]sx?$)/i.test(path);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function deriveValidationSteps(existing: unknown, conflictPaths: string[]): string[] {
|
|
129
|
+
const preserved = Array.isArray(existing)
|
|
130
|
+
? existing.map((entry) => String(entry ?? "").trim()).filter(Boolean)
|
|
131
|
+
: [];
|
|
132
|
+
const targeted = conflictPaths.filter(isTestPath).map((entry) => `bun test ${entry}`);
|
|
133
|
+
const merged = dedupeStrings([...targeted, ...preserved], 8);
|
|
134
|
+
return merged.length > 0 ? merged : ["bun test"];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function extractConflictPaths(stdout: string): string[] {
|
|
138
|
+
return dedupeStrings(
|
|
139
|
+
String(stdout ?? "")
|
|
140
|
+
.split(/\r?\n/)
|
|
141
|
+
.map((line) => line.trim())
|
|
142
|
+
.filter(Boolean),
|
|
143
|
+
32,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function buildPlannerGuidance(
|
|
148
|
+
context: MergeConflictReviewContext,
|
|
149
|
+
conflictPaths: string[],
|
|
150
|
+
rebasedCleanly: boolean,
|
|
151
|
+
): string {
|
|
152
|
+
const lines = [
|
|
153
|
+
"Merge-conflict sandbox state:",
|
|
154
|
+
`- You are on local branch ${context.publicBranch} inside an isolated container-local clone. This branch exists only inside the worker sandbox and does not switch the user's active checkout.`,
|
|
155
|
+
`- Remote push target: origin/${context.publicBranch}`,
|
|
156
|
+
`- Rebase target: origin/${context.baseBranch}`,
|
|
157
|
+
];
|
|
158
|
+
if (context.expectedHeadSha) {
|
|
159
|
+
lines.push(
|
|
160
|
+
`- Expected remote lease SHA for push-back: ${context.expectedHeadSha}. If origin/${context.publicBranch} moved, stop and report the mismatch instead of overwriting newer work.`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (context.mergeError) {
|
|
164
|
+
lines.push(`- GitHub mergeability error: ${context.mergeError}`);
|
|
165
|
+
}
|
|
166
|
+
if (rebasedCleanly) {
|
|
167
|
+
lines.push(
|
|
168
|
+
`- The branch already rebased cleanly onto origin/${context.baseBranch} in this sandbox. Validate the rebased result and leave the repo clean for finalization.`,
|
|
169
|
+
);
|
|
170
|
+
} else {
|
|
171
|
+
lines.push(
|
|
172
|
+
`- The sandbox branch is already paused mid-rebase onto origin/${context.baseBranch}. Resolve the conflicts in the current repo state instead of re-discovering branch topology.`,
|
|
173
|
+
);
|
|
174
|
+
if (conflictPaths.length > 0) {
|
|
175
|
+
lines.push(`- Unresolved conflict files: ${conflictPaths.join(", ")}`);
|
|
176
|
+
}
|
|
177
|
+
lines.push(
|
|
178
|
+
"- After editing, run `git add <files>` and `git -c core.editor=true rebase --continue` until the rebase completes.",
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
lines.push("- Do not create a new PR or alternate branch. Update only the existing PR branch.");
|
|
182
|
+
return lines.join("\n");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function extractMergeConflictReviewContext(
|
|
186
|
+
params: Record<string, unknown> | null | undefined,
|
|
187
|
+
): MergeConflictReviewContext | null {
|
|
188
|
+
const reviewAgent = asRecord(params?.reviewAgent);
|
|
189
|
+
if (!reviewAgent) return null;
|
|
190
|
+
const resolutionType = String(reviewAgent.resolutionType ?? "").trim().toLowerCase();
|
|
191
|
+
if (resolutionType !== "merge_conflict") return null;
|
|
192
|
+
|
|
193
|
+
const publicBranch = normalizeBranchName(params?.completionBranch ?? reviewAgent.prHeadRef);
|
|
194
|
+
const baseBranch = normalizeBaseBranch(reviewAgent.prBaseRef);
|
|
195
|
+
if (!publicBranch || !baseBranch) return null;
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
publicBranch,
|
|
199
|
+
baseBranch,
|
|
200
|
+
expectedHeadSha: String(reviewAgent.prHeadSha ?? "").trim(),
|
|
201
|
+
mergeError: String(reviewAgent.mergeError ?? "").trim(),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function isMergeConflictResolutionParams(
|
|
206
|
+
params: Record<string, unknown> | null | undefined,
|
|
207
|
+
): boolean {
|
|
208
|
+
return extractMergeConflictReviewContext(params) !== null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function applyMergeConflictExecutionHints(
|
|
212
|
+
params: Record<string, unknown>,
|
|
213
|
+
preparation: MergeConflictSandboxPreparation,
|
|
214
|
+
): Record<string, unknown> {
|
|
215
|
+
const next: Record<string, unknown> = { ...params };
|
|
216
|
+
const planning = asRecord(next.planning) ? { ...(next.planning as Record<string, unknown>) } : {};
|
|
217
|
+
const existingGuidance = String(next.plannerWorkerInstruction ?? "").trim();
|
|
218
|
+
next.plannerWorkerInstruction = [existingGuidance, preparation.plannerGuidance]
|
|
219
|
+
.filter(Boolean)
|
|
220
|
+
.join("\n\n");
|
|
221
|
+
|
|
222
|
+
const hintedPaths = preparation.conflictPaths;
|
|
223
|
+
if (hintedPaths.length > 0) {
|
|
224
|
+
const currentTargetPaths = Array.isArray(planning.targetPaths)
|
|
225
|
+
? planning.targetPaths.map((entry) => String(entry ?? "")).filter(Boolean)
|
|
226
|
+
: [];
|
|
227
|
+
planning.targetPaths = dedupeStrings([...hintedPaths, ...currentTargetPaths], 24);
|
|
228
|
+
|
|
229
|
+
const discovery = asRecord(planning.discovery)
|
|
230
|
+
? { ...(planning.discovery as Record<string, unknown>) }
|
|
231
|
+
: {};
|
|
232
|
+
const likelyDirs = Array.isArray(discovery.likelyDirs)
|
|
233
|
+
? discovery.likelyDirs.map((entry) => String(entry ?? "")).filter(Boolean)
|
|
234
|
+
: [];
|
|
235
|
+
const ripgrepQueries = Array.isArray(discovery.ripgrepQueries)
|
|
236
|
+
? discovery.ripgrepQueries.map((entry) => String(entry ?? "")).filter(Boolean)
|
|
237
|
+
: [];
|
|
238
|
+
discovery.likelyDirs = dedupeStrings([...deriveLikelyDirs(hintedPaths), ...likelyDirs], 16);
|
|
239
|
+
discovery.ripgrepQueries = dedupeStrings(
|
|
240
|
+
[...deriveRipgrepQueries(hintedPaths), ...ripgrepQueries],
|
|
241
|
+
16,
|
|
242
|
+
);
|
|
243
|
+
planning.discovery = discovery;
|
|
244
|
+
planning.validationSteps = deriveValidationSteps(planning.validationSteps, hintedPaths);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
next.planning = planning;
|
|
248
|
+
const reviewAgent = asRecord(next.reviewAgent);
|
|
249
|
+
if (reviewAgent) {
|
|
250
|
+
next.reviewAgent = {
|
|
251
|
+
...reviewAgent,
|
|
252
|
+
preparedWorkspaceMode: "isolated_container_clone",
|
|
253
|
+
preparedRebaseState: preparation.rebasedCleanly ? "clean" : "conflicted",
|
|
254
|
+
preparedConflictPaths: preparation.conflictPaths,
|
|
255
|
+
preparedHeadSha: preparation.currentHeadSha,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
return next;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function prepareMergeConflictTaskRepo(
|
|
262
|
+
sourceRepo: string,
|
|
263
|
+
jobId: string,
|
|
264
|
+
params: Record<string, unknown>,
|
|
265
|
+
onLog?: LogFn,
|
|
266
|
+
): Promise<MergeConflictSandboxPreparation> {
|
|
267
|
+
const context = extractMergeConflictReviewContext(params);
|
|
268
|
+
if (!context) {
|
|
269
|
+
return {
|
|
270
|
+
repoPath: sourceRepo,
|
|
271
|
+
cleanup: () => {},
|
|
272
|
+
conflictPaths: [],
|
|
273
|
+
plannerGuidance: "",
|
|
274
|
+
rebasedCleanly: false,
|
|
275
|
+
currentHeadSha: "",
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const remoteUrl = await mustGit(sourceRepo, ["remote", "get-url", "origin"], "read origin URL");
|
|
280
|
+
const sourceUserName = (await git(sourceRepo, ["config", "--get", "user.name"])).stdout.trim();
|
|
281
|
+
const sourceUserEmail = (await git(sourceRepo, ["config", "--get", "user.email"])).stdout.trim();
|
|
282
|
+
const sandboxRoot = mkdtempSync(join(tmpdir(), "pushpals-merge-conflict-"));
|
|
283
|
+
const repoPath = join(sandboxRoot, "repo");
|
|
284
|
+
mkdirSync(repoPath, { recursive: true });
|
|
285
|
+
|
|
286
|
+
const cleanup = () => {
|
|
287
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
await mustGit(repoPath, ["init", "--quiet"], "init sandbox repo");
|
|
292
|
+
await mustGit(repoPath, ["remote", "add", "origin", remoteUrl], "add origin");
|
|
293
|
+
await mustGit(
|
|
294
|
+
repoPath,
|
|
295
|
+
["config", "user.name", sourceUserName || "PushPals WorkerPal"],
|
|
296
|
+
"configure user.name",
|
|
297
|
+
);
|
|
298
|
+
await mustGit(
|
|
299
|
+
repoPath,
|
|
300
|
+
["config", "user.email", sourceUserEmail || "pushpals-worker@local"],
|
|
301
|
+
"configure user.email",
|
|
302
|
+
);
|
|
303
|
+
await mustGit(repoPath, ["config", "rerere.enabled", "true"], "enable rerere");
|
|
304
|
+
await mustGit(repoPath, ["config", "rerere.autoupdate", "true"], "enable rerere autoupdate");
|
|
305
|
+
|
|
306
|
+
const fetchArgs = [
|
|
307
|
+
"fetch",
|
|
308
|
+
"--quiet",
|
|
309
|
+
"origin",
|
|
310
|
+
`+refs/heads/${context.publicBranch}:refs/remotes/origin/${context.publicBranch}`,
|
|
311
|
+
`+refs/heads/${context.baseBranch}:refs/remotes/origin/${context.baseBranch}`,
|
|
312
|
+
];
|
|
313
|
+
await mustGit(repoPath, fetchArgs, "fetch merge-conflict refs");
|
|
314
|
+
await mustGit(
|
|
315
|
+
repoPath,
|
|
316
|
+
["checkout", "-B", context.publicBranch, `refs/remotes/origin/${context.publicBranch}`],
|
|
317
|
+
"checkout PR branch in sandbox",
|
|
318
|
+
);
|
|
319
|
+
await mustGit(
|
|
320
|
+
repoPath,
|
|
321
|
+
["branch", "--set-upstream-to", `origin/${context.publicBranch}`, context.publicBranch],
|
|
322
|
+
"set sandbox upstream",
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const baseRemoteRef = `refs/remotes/origin/${context.baseBranch}`;
|
|
326
|
+
const rebase = await git(repoPath, ["-c", "core.editor=true", "rebase", baseRemoteRef]);
|
|
327
|
+
let conflictPaths: string[] = [];
|
|
328
|
+
let rebasedCleanly = false;
|
|
329
|
+
if (rebase.ok) {
|
|
330
|
+
rebasedCleanly = true;
|
|
331
|
+
const note = `[MergeConflictSandbox] ${jobId}: ${context.publicBranch} rebased cleanly onto origin/${context.baseBranch}.`;
|
|
332
|
+
onLog?.("stdout", note);
|
|
333
|
+
} else if (isMergeConflictOutput(`${rebase.stderr}\n${rebase.stdout}`)) {
|
|
334
|
+
const unresolved = await mustGit(
|
|
335
|
+
repoPath,
|
|
336
|
+
["diff", "--name-only", "--diff-filter=U"],
|
|
337
|
+
"list unresolved conflict paths",
|
|
338
|
+
);
|
|
339
|
+
conflictPaths = extractConflictPaths(unresolved);
|
|
340
|
+
const note = `[MergeConflictSandbox] ${jobId}: prepared isolated sandbox for ${context.publicBranch} with ${conflictPaths.length} unresolved file(s).`;
|
|
341
|
+
onLog?.("stdout", note);
|
|
342
|
+
} else {
|
|
343
|
+
throw new Error(rebase.stderr || rebase.stdout || "unknown rebase failure");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const currentHeadSha = await mustGit(repoPath, ["rev-parse", "HEAD"], "resolve sandbox HEAD");
|
|
347
|
+
return {
|
|
348
|
+
repoPath: resolve(repoPath),
|
|
349
|
+
cleanup,
|
|
350
|
+
conflictPaths,
|
|
351
|
+
plannerGuidance: buildPlannerGuidance(context, conflictPaths, rebasedCleanly),
|
|
352
|
+
rebasedCleanly,
|
|
353
|
+
currentHeadSha,
|
|
354
|
+
};
|
|
355
|
+
} catch (error) {
|
|
356
|
+
cleanup();
|
|
357
|
+
throw error;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
git,
|
|
40
40
|
redactSensitiveText,
|
|
41
41
|
resolveReviewNoChangeCompletionBranch,
|
|
42
|
+
shouldEnqueueNoChangeReviewCompletion,
|
|
42
43
|
type JobResult,
|
|
43
44
|
} from "./execute_job.js";
|
|
44
45
|
import { DockerExecutionExhaustedError, DockerExecutor } from "./docker_executor.js";
|
|
@@ -852,6 +853,19 @@ async function resolveReReviewNoChangeCommit(
|
|
|
852
853
|
return null;
|
|
853
854
|
}
|
|
854
855
|
|
|
856
|
+
function failNoChangeReviewFixJob(jobId: string, result: WorkerJobResult): WorkerJobResult {
|
|
857
|
+
return {
|
|
858
|
+
...result,
|
|
859
|
+
ok: false,
|
|
860
|
+
summary:
|
|
861
|
+
`Rejected review-fix job ${jobId} produced no code changes; refusing unchanged branch re-review.`,
|
|
862
|
+
stderr: [result.stderr, "Apply at least one concrete fix before requesting another review."]
|
|
863
|
+
.filter(Boolean)
|
|
864
|
+
.join("\n"),
|
|
865
|
+
exitCode: typeof result.exitCode === "number" ? result.exitCode : 4,
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
855
869
|
async function enqueueCompletion(
|
|
856
870
|
server: string,
|
|
857
871
|
headers: Record<string, string>,
|
|
@@ -1289,6 +1303,11 @@ async function workerLoop(
|
|
|
1289
1303
|
if (result.commit) {
|
|
1290
1304
|
if (result.commit.sha !== "no-changes") {
|
|
1291
1305
|
completionCommit = result.commit;
|
|
1306
|
+
} else if (!shouldEnqueueNoChangeReviewCompletion(parsedParams)) {
|
|
1307
|
+
console.warn(
|
|
1308
|
+
`[WorkerPals] Job ${job.id} produced no code changes for a rejected review-fix request; marking the job failed instead of enqueueing unchanged branch re-review.`,
|
|
1309
|
+
);
|
|
1310
|
+
result = failNoChangeReviewFixJob(job.id, result);
|
|
1292
1311
|
} else {
|
|
1293
1312
|
const reReviewCommit = await resolveReReviewNoChangeCommit(
|
|
1294
1313
|
executionRepo,
|
|
@@ -1336,6 +1355,11 @@ async function workerLoop(
|
|
|
1336
1355
|
branch: commitResult.branch,
|
|
1337
1356
|
sha: commitResult.sha,
|
|
1338
1357
|
};
|
|
1358
|
+
} else if (!shouldEnqueueNoChangeReviewCompletion(parsedParams)) {
|
|
1359
|
+
console.warn(
|
|
1360
|
+
`[WorkerPals] Job ${job.id} produced no staged review-fix changes; marking the job failed instead of enqueueing unchanged branch re-review.`,
|
|
1361
|
+
);
|
|
1362
|
+
result = failNoChangeReviewFixJob(job.id, result);
|
|
1339
1363
|
}
|
|
1340
1364
|
} else if (commitResult.error) {
|
|
1341
1365
|
console.error(`[WorkerPals] Failed to create commit: ${commitResult.error}`);
|
|
@@ -6,6 +6,7 @@ Non-negotiable runtime invariants:
|
|
|
6
6
|
- Do not modify tests or production code to bypass, stub, or remove Codex CLI usage due to assumed environment limitations.
|
|
7
7
|
- Do not "adapt around" missing Codex access by rewriting coverage or behavior expectations.
|
|
8
8
|
- If Codex CLI authentication/execution is unavailable, fail loudly with a clear error and stop.
|
|
9
|
+
- When worker guidance provides exact repo/branch/conflict state, treat that prepared sandbox state as authoritative and start from the current checkout instead of re-discovering topology.
|
|
9
10
|
|
|
10
11
|
Execution rules:
|
|
11
12
|
|