@pushpalsdev/cli 1.0.40 → 1.0.42
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/dist/pushpals-cli.js +112 -35
- 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
|
@@ -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) => {
|