@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.
@@ -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 qualityMaxAutoRevisions = Math.max(
2987
- 0,
2988
- Math.min(
2989
- 10,
2990
- Number.isFinite(Number(runtimeConfig.workerpals.qualityMaxAutoRevisions))
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 jobRepo = process.cwd();
129
- const result = await executeJob(
130
- spec.kind,
131
- spec.params,
132
- jobRepo,
133
- (stream, line) => {
134
- log(stream, line);
135
- },
136
- CONFIG,
137
- );
138
- // Build result object
139
- const jobResult: JobResult = {
140
- ok: result.ok,
141
- summary: result.summary,
142
- stdout: result.stdout,
143
- stderr: result.stderr,
144
- exitCode: result.exitCode,
145
- };
146
- // Create commit for file-modifying jobs
147
- if (result.ok && shouldCommit(spec.kind, CONFIG)) {
148
- log("stdout", `[JobRunner] Job modified files, creating commit...`);
149
- const commitResult = await createJobCommit(
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
- spec.workerId,
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
- if (commitResult.ok && commitResult.sha && commitResult.branch) {
163
- jobResult.commit = {
164
- branch: commitResult.branch!,
165
- sha: commitResult.sha,
166
- };
167
- if (commitResult.sha === "no-changes") {
168
- log("stdout", `[JobRunner] No changes to commit for ${spec.jobId}`);
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
- log("stdout", `[JobRunner] Created commit ${commitResult.sha} on ${commitResult.branch}`);
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
- // Output result with sentinel
185
- const resultJson = JSON.stringify(jobResult);
186
- // eslint-disable-next-line no-console
187
- console.log(`___RESULT___ ${resultJson}`);
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
- // Exit with appropriate code
190
- process.exit(jobResult.exitCode ?? (jobResult.ok ? 0 : 1));
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) => {