@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushpalsdev/cli",
3
- "version": "1.0.41",
3
+ "version": "1.0.43",
4
4
  "description": "PushPals terminal CLI for LocalBuddy -> RemoteBuddy orchestration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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 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) => {
@@ -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