@pushpalsdev/cli 1.1.19 → 1.1.21

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.
@@ -1040,6 +1040,7 @@ function loadPushPalsConfig(options = {}) {
1040
1040
  enabled: parseBoolEnv("REMOTEBUDDY_AUTONOMY_ENABLED") ?? asBoolean(remoteAutonomyNode.enabled, true),
1041
1041
  killSwitchEnabled: parseBoolEnv("REMOTEBUDDY_AUTONOMY_KILL_SWITCH_ENABLED") ?? asBoolean(remoteAutonomyNode.kill_switch_enabled, false),
1042
1042
  tickIntervalMs: Math.max(5000, asInt(parseIntEnv("REMOTEBUDDY_AUTONOMY_TICK_INTERVAL_MS") ?? remoteAutonomyNode.tick_interval_ms, 120000)),
1043
+ startupGraceMs: Math.max(0, asInt(parseIntEnv("REMOTEBUDDY_AUTONOMY_STARTUP_GRACE_MS") ?? remoteAutonomyNode.startup_grace_ms, 120000)),
1043
1044
  heartbeatLogMs: Math.max(1000, asInt(parseIntEnv("REMOTEBUDDY_AUTONOMY_HEARTBEAT_LOG_MS") ?? remoteAutonomyNode.heartbeat_log_ms, 30000)),
1044
1045
  visionContextMaxChars: Math.max(1000, Math.min(1e6, asInt(parseIntEnv("REMOTEBUDDY_AUTONOMY_VISION_CONTEXT_MAX_CHARS") ?? remoteAutonomyNode.vision_context_max_chars, 65536))),
1045
1046
  ideationBudgetMs: Math.max(1000, asInt(parseIntEnv("REMOTEBUDDY_AUTONOMY_IDEATION_BUDGET_MS") ?? remoteAutonomyNode.ideation_budget_ms, 20000)),
@@ -1648,7 +1649,8 @@ var DEFAULT_STARTUP_GIT_PROBE_TIMEOUT_MS = 5000;
1648
1649
  var DEFAULT_STARTUP_GIT_REMOTE_TIMEOUT_MS = 1e4;
1649
1650
  var DEFAULT_EMBEDDED_SERVICE_LAUNCH_WARN_MS = 5000;
1650
1651
  var EMBEDDED_SERVICE_RESTART_MAX_ATTEMPTS = 4;
1651
- var WORKERPAL_STARTUP_READINESS_PROBE_MAX_MS = 15000;
1652
+ var DEFAULT_WORKERPAL_STARTUP_READINESS_PROBE_MAX_MS = 5000;
1653
+ var WORKERPAL_STARTUP_READINESS_PROBE_MAX_MS_ENV = "PUSHPALS_WORKERPAL_STARTUP_READINESS_PROBE_MAX_MS";
1652
1654
  var BLOCKING_WORKERPAL_IMAGE_BUILD_ENV = "PUSHPALS_BLOCKING_WORKERPAL_IMAGE_BUILD";
1653
1655
  var CLI_SESSION_JOB_LOG_MAX_CHARS = 700;
1654
1656
  var CLI_SESSION_SHOW_JOB_EVENTS_ENV = "PUSHPALS_CLI_SHOW_JOB_EVENTS";
@@ -1681,6 +1683,13 @@ function formatTimestampedCliLine(line, at = new Date) {
1681
1683
  function isTruthyCliEnvValue(value) {
1682
1684
  return /^(1|true|yes|on)$/i.test(String(value ?? "").trim());
1683
1685
  }
1686
+ function parseCliIntEnv(name, env = process.env) {
1687
+ const raw = env[name];
1688
+ if (raw == null || String(raw).trim() === "")
1689
+ return null;
1690
+ const parsed = Number.parseInt(String(raw), 10);
1691
+ return Number.isFinite(parsed) ? parsed : null;
1692
+ }
1684
1693
  function shouldShowCliSessionOperationalEvents(env = process.env) {
1685
1694
  return isTruthyCliEnvValue(env[CLI_SESSION_SHOW_JOB_EVENTS_ENV]);
1686
1695
  }
@@ -3899,8 +3908,12 @@ async function precheckWorkerpalDockerAvailability(opts) {
3899
3908
  function resolveWorkerpalCapacityTimeoutMs(config) {
3900
3909
  return Math.max(config.remotebuddy.waitForWorkerpalMs, config.remotebuddy.workerpalStartupTimeoutMs, config.remotebuddy.workerpalDocker ? config.workerpals.dockerAgentStartupTimeoutMs + 15000 : 0, 1e4);
3901
3910
  }
3911
+ function resolveWorkerpalStartupReadinessProbeMaxMs(env = process.env) {
3912
+ const configured = parseCliIntEnv(WORKERPAL_STARTUP_READINESS_PROBE_MAX_MS_ENV, env);
3913
+ return Math.max(1000, configured ?? DEFAULT_WORKERPAL_STARTUP_READINESS_PROBE_MAX_MS);
3914
+ }
3902
3915
  function resolveWorkerpalStartupReadinessProbeTimeoutMs(config) {
3903
- return Math.max(5000, Math.min(resolveWorkerpalCapacityTimeoutMs(config), WORKERPAL_STARTUP_READINESS_PROBE_MAX_MS));
3916
+ return Math.max(1000, Math.min(resolveWorkerpalCapacityTimeoutMs(config), resolveWorkerpalStartupReadinessProbeMaxMs()));
3904
3917
  }
3905
3918
  function shouldPrepareEmbeddedWorkerpalDockerImageBlocking(opts = {}) {
3906
3919
  const env = opts.env ?? process.env;
@@ -6082,6 +6095,7 @@ export {
6082
6095
  shouldPrepareEmbeddedWorkerpalDockerImageBlocking,
6083
6096
  shouldDeferRemoteBuddySessionConsumerReadiness,
6084
6097
  runCommandWithEnv,
6098
+ resolveWorkerpalStartupReadinessProbeMaxMs,
6085
6099
  resolveWorkerpalDockerProbe,
6086
6100
  resolveWorkerExecutionReadiness,
6087
6101
  resolveWindowsWhereExecutableCandidatesForEnv,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushpalsdev/cli",
3
- "version": "1.1.19",
3
+ "version": "1.1.21",
4
4
  "description": "PushPals terminal CLI for LocalBuddy -> RemoteBuddy orchestration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -75,6 +75,7 @@ session_id = "remotebuddy-dev"
75
75
  enabled = true
76
76
  kill_switch_enabled = false
77
77
  tick_interval_ms = 300000
78
+ startup_grace_ms = 120000
78
79
  heartbeat_log_ms = 30000
79
80
  vision_context_max_chars = 65536
80
81
  ideation_budget_ms = 20000
@@ -1323,6 +1323,7 @@ function loadPushPalsConfig(options = {}) {
1323
1323
  enabled: parseBoolEnv("REMOTEBUDDY_AUTONOMY_ENABLED") ?? asBoolean(remoteAutonomyNode.enabled, true),
1324
1324
  killSwitchEnabled: parseBoolEnv("REMOTEBUDDY_AUTONOMY_KILL_SWITCH_ENABLED") ?? asBoolean(remoteAutonomyNode.kill_switch_enabled, false),
1325
1325
  tickIntervalMs: Math.max(5000, asInt(parseIntEnv("REMOTEBUDDY_AUTONOMY_TICK_INTERVAL_MS") ?? remoteAutonomyNode.tick_interval_ms, 120000)),
1326
+ startupGraceMs: Math.max(0, asInt(parseIntEnv("REMOTEBUDDY_AUTONOMY_STARTUP_GRACE_MS") ?? remoteAutonomyNode.startup_grace_ms, 120000)),
1326
1327
  heartbeatLogMs: Math.max(1000, asInt(parseIntEnv("REMOTEBUDDY_AUTONOMY_HEARTBEAT_LOG_MS") ?? remoteAutonomyNode.heartbeat_log_ms, 30000)),
1327
1328
  visionContextMaxChars: Math.max(1000, Math.min(1e6, asInt(parseIntEnv("REMOTEBUDDY_AUTONOMY_VISION_CONTEXT_MAX_CHARS") ?? remoteAutonomyNode.vision_context_max_chars, 65536))),
1328
1329
  ideationBudgetMs: Math.max(1000, asInt(parseIntEnv("REMOTEBUDDY_AUTONOMY_IDEATION_BUDGET_MS") ?? remoteAutonomyNode.ideation_budget_ms, 20000)),
@@ -6569,6 +6570,7 @@ class RemoteBuddyAutonomousEngine {
6569
6570
  cfg;
6570
6571
  runtimeEnabled = true;
6571
6572
  timer = null;
6573
+ startupGraceTimer = null;
6572
6574
  startupFastTickTimer = null;
6573
6575
  heartbeatTimer = null;
6574
6576
  inFlight = false;
@@ -6641,7 +6643,8 @@ class RemoteBuddyAutonomousEngine {
6641
6643
  console.log(`[RemoteBuddyAutonomousEngine] heartbeat: status=running run=${this.currentRunId} phase=${this.currentPhase} run_elapsed_ms=${runElapsedMs} phase_elapsed_ms=${phaseElapsedMs}`);
6642
6644
  return;
6643
6645
  }
6644
- const nextTickInMs = this.timer && this.nextTickAtMs > 0 ? Math.max(0, this.nextTickAtMs - now) : 0;
6646
+ const hasScheduledTick = Boolean(this.timer || this.startupGraceTimer || this.startupFastTickTimer);
6647
+ const nextTickInMs = hasScheduledTick && this.nextTickAtMs > 0 ? Math.max(0, this.nextTickAtMs - now) : 0;
6645
6648
  const lastAgeMs = this.lastCompletedAtMs > 0 ? Math.max(0, now - this.lastCompletedAtMs) : -1;
6646
6649
  console.log(`[RemoteBuddyAutonomousEngine] heartbeat: status=idle last_outcome=${this.lastOutcome} detail=${this.lastDetail} last_tick_age_ms=${lastAgeMs} next_tick_in_ms=${nextTickInMs}`);
6647
6650
  }
@@ -6667,6 +6670,15 @@ class RemoteBuddyAutonomousEngine {
6667
6670
  startupFastTickDelayMs() {
6668
6671
  return Math.max(1000, Math.min(STARTUP_FAST_TICK_MAX_DELAY_MS, Math.floor(this.cfg.tickIntervalMs / 10)));
6669
6672
  }
6673
+ startupGraceMs() {
6674
+ return Math.max(0, this.cfg.startupGraceMs ?? 0);
6675
+ }
6676
+ clearStartupGraceTimer() {
6677
+ if (this.startupGraceTimer) {
6678
+ clearTimeout(this.startupGraceTimer);
6679
+ this.startupGraceTimer = null;
6680
+ }
6681
+ }
6670
6682
  clearStartupFastTickTimer() {
6671
6683
  if (this.startupFastTickTimer) {
6672
6684
  clearTimeout(this.startupFastTickTimer);
@@ -8160,22 +8172,42 @@ Scope:
8160
8172
  });
8161
8173
  }
8162
8174
  start() {
8163
- if (!this.runtimeEnabled || this.timer)
8175
+ if (!this.runtimeEnabled || this.timer || this.startupGraceTimer)
8164
8176
  return;
8165
8177
  console.log(`[RemoteBuddyAutonomousEngine] Using dedicated autonomy worktree ${this.autonomyRepo} (remote=${this.gitRemote} integration=${this.integrationBranch} base=${this.baseBranch}).`);
8166
8178
  this.startupFastTickAttemptsRemaining = STARTUP_FAST_TICK_MAX_ATTEMPTS;
8167
- this.nextTickAtMs = Date.now();
8168
- this.timer = setInterval(() => {
8169
- this.nextTickAtMs = Date.now() + this.cfg.tickIntervalMs;
8170
- this.tick();
8171
- }, this.cfg.tickIntervalMs);
8179
+ const startInterval = () => {
8180
+ if (this.timer)
8181
+ return;
8182
+ this.timer = setInterval(() => {
8183
+ this.nextTickAtMs = Date.now() + this.cfg.tickIntervalMs;
8184
+ this.tick();
8185
+ }, this.cfg.tickIntervalMs);
8186
+ };
8187
+ const firstTickDelayMs = this.startupGraceMs();
8188
+ this.nextTickAtMs = Date.now() + firstTickDelayMs;
8172
8189
  this.heartbeatTimer = setInterval(() => {
8173
8190
  this.logHeartbeat();
8174
8191
  }, this.cfg.heartbeatLogMs);
8175
8192
  this.logHeartbeat();
8193
+ if (firstTickDelayMs > 0) {
8194
+ console.log(`[RemoteBuddyAutonomousEngine] startup autonomy tick delayed by ${firstTickDelayMs}ms to leave cold-start capacity available for user work.`);
8195
+ this.startupGraceTimer = setTimeout(() => {
8196
+ this.startupGraceTimer = null;
8197
+ if (!this.runtimeEnabled)
8198
+ return;
8199
+ startInterval();
8200
+ this.nextTickAtMs = Date.now() + this.cfg.tickIntervalMs;
8201
+ this.tick();
8202
+ }, firstTickDelayMs);
8203
+ return;
8204
+ }
8205
+ startInterval();
8206
+ this.nextTickAtMs = Date.now() + this.cfg.tickIntervalMs;
8176
8207
  this.tick();
8177
8208
  }
8178
8209
  stop() {
8210
+ this.clearStartupGraceTimer();
8179
8211
  this.clearStartupFastTickTimer();
8180
8212
  if (this.timer) {
8181
8213
  clearInterval(this.timer);
@@ -9014,7 +9046,7 @@ class RemoteBuddyOrchestrator {
9014
9046
  console.log(`[RemoteBuddy] Budgets: interactive=${this.executionBudgetInteractiveMs}ms normal=${this.executionBudgetNormalMs}ms background=${this.executionBudgetBackgroundMs}ms finalization=${this.finalizationBudgetMs}ms`);
9015
9047
  console.log(`[RemoteBuddy] Failure log fetch on job failures: ${this.fetchFailureLogsOnJobFailure ? "on" : "off"}`);
9016
9048
  console.log(`[RemoteBuddy] Persistent memory: ${this.memoryEnabled ? "on" : "off"} crossSession=${this.memoryIncludeCrossSession ? "on" : "off"} recallItems=${this.memoryMaxRecallItems} recallChars=${this.memoryMaxRecallChars} retentionDays=${this.memoryRetentionDays}`);
9017
- console.log(`[RemoteBuddy] Autonomous engine: ${CONFIG.remotebuddy.autonomy.enabled ? "enabled" : "disabled"} tick=${CONFIG.remotebuddy.autonomy.tickIntervalMs}ms maxConcurrentObjectives=${CONFIG.remotebuddy.autonomy.maxConcurrentObjectives} maxDispatchPerHour=${CONFIG.remotebuddy.autonomy.maxDispatchPerHour} exploreRate=${CONFIG.remotebuddy.autonomy.exploreRate.toFixed(2)} allowDirtyWorktree=${CONFIG.remotebuddy.autonomy.allowDirtyWorktree ? "on" : "off"}`);
9049
+ console.log(`[RemoteBuddy] Autonomous engine: ${CONFIG.remotebuddy.autonomy.enabled ? "enabled" : "disabled"} tick=${CONFIG.remotebuddy.autonomy.tickIntervalMs}ms startupGrace=${CONFIG.remotebuddy.autonomy.startupGraceMs}ms maxConcurrentObjectives=${CONFIG.remotebuddy.autonomy.maxConcurrentObjectives} maxDispatchPerHour=${CONFIG.remotebuddy.autonomy.maxDispatchPerHour} exploreRate=${CONFIG.remotebuddy.autonomy.exploreRate.toFixed(2)} allowDirtyWorktree=${CONFIG.remotebuddy.autonomy.allowDirtyWorktree ? "on" : "off"}`);
9018
9050
  console.log(`[RemoteBuddy] Autonomy runtime-config polling: every ${this.autonomyConfigPollMs}ms`);
9019
9051
  }
9020
9052
  async emitStartupStatus() {
@@ -29,6 +29,9 @@ interface GenericPythonExecutorConfig {
29
29
  }
30
30
 
31
31
  const BACKEND_TIMEOUT_RESULT_GRACE_MS = 30_000;
32
+ const OPENAI_CODEX_MIN_VALIDATION_RESERVE_MS = 180_000;
33
+ const OPENAI_CODEX_MAX_VALIDATION_RESERVE_MS = 600_000;
34
+ const OPENAI_CODEX_MIN_PRIMARY_TURN_BUDGET_MS = 600_000;
32
35
 
33
36
  function estimateTokensFromText(text: string): number {
34
37
  return Math.max(0, Math.ceil(String(text ?? "").length / 3));
@@ -143,6 +146,27 @@ export function resolveGenericPythonExecutorTimeoutMs(params: {
143
146
  return configuredTimeoutMs;
144
147
  }
145
148
 
149
+ export function resolveOpenAICodexValidationReserveMs(
150
+ executionBudgetMs: number | null | undefined,
151
+ ): number {
152
+ if (typeof executionBudgetMs !== "number" || !Number.isFinite(executionBudgetMs)) return 0;
153
+ const budgetMs = Math.max(10_000, Math.floor(executionBudgetMs));
154
+ const targetReserveMs = Math.floor(
155
+ Math.min(
156
+ budgetMs,
157
+ Math.max(
158
+ OPENAI_CODEX_MIN_VALIDATION_RESERVE_MS,
159
+ Math.min(OPENAI_CODEX_MAX_VALIDATION_RESERVE_MS, budgetMs * 0.35),
160
+ ),
161
+ ),
162
+ );
163
+ const maxReserveAfterPrimaryTurn = Math.max(
164
+ 0,
165
+ budgetMs - OPENAI_CODEX_MIN_PRIMARY_TURN_BUDGET_MS,
166
+ );
167
+ return Math.max(0, Math.min(targetReserveMs, maxReserveAfterPrimaryTurn));
168
+ }
169
+
146
170
  export function resolveGenericPythonExecutorChildTimeoutMs(params: {
147
171
  backendName: string;
148
172
  hostTimeoutMs: number;
@@ -154,8 +178,11 @@ export function resolveGenericPythonExecutorChildTimeoutMs(params: {
154
178
  typeof params.executionBudgetMs === "number" && Number.isFinite(params.executionBudgetMs)
155
179
  ? Math.max(10_000, Math.floor(params.executionBudgetMs))
156
180
  : null;
181
+ const validationReserveMs = resolveOpenAICodexValidationReserveMs(executionBudgetMs);
157
182
  const childBudgetMs =
158
- executionBudgetMs == null ? hostTimeoutMs : Math.min(hostTimeoutMs, executionBudgetMs);
183
+ executionBudgetMs == null
184
+ ? hostTimeoutMs
185
+ : Math.min(hostTimeoutMs, Math.max(1_000, executionBudgetMs - validationReserveMs));
159
186
  const graceMs = Math.min(
160
187
  BACKEND_TIMEOUT_RESULT_GRACE_MS,
161
188
  Math.max(2_000, Math.floor(childBudgetMs / 10)),
@@ -305,15 +332,30 @@ export function createGenericPythonExecutor(
305
332
  "utf-8",
306
333
  ).toString("base64");
307
334
  const args = [pythonBin, scriptPath, payloadBase64];
308
- const childTimeoutEnv = resolveGenericPythonExecutorChildTimeoutEnv({
335
+ const childTimeoutMs = resolveGenericPythonExecutorChildTimeoutMs({
309
336
  backendName,
310
337
  hostTimeoutMs: timeoutMs,
311
338
  executionBudgetMs,
312
339
  });
340
+ const childTimeoutEnv =
341
+ childTimeoutMs == null
342
+ ? {}
343
+ : {
344
+ WORKERPALS_OPENAI_CODEX_TIMEOUT_MS: String(childTimeoutMs),
345
+ WORKERPALS_OPENAI_CODEX_TIMEOUT_S: String(
346
+ Math.max(1, Math.floor(childTimeoutMs / 1000)),
347
+ ),
348
+ };
349
+ const childTimeoutDetail =
350
+ childTimeoutMs != null
351
+ ? `; codex_child_timeout=${childTimeoutMs}ms; reserved_validation_budget=${resolveOpenAICodexValidationReserveMs(
352
+ executionBudgetMs,
353
+ )}ms`
354
+ : "";
313
355
 
314
356
  onLog?.(
315
357
  "stdout",
316
- `[${backendLabel}Executor] Spawning ${backendName} executor (timeout=${timeoutMs}ms; ${timeoutDetail})`,
358
+ `[${backendLabel}Executor] Spawning ${backendName} executor (timeout=${timeoutMs}ms; ${timeoutDetail}${childTimeoutDetail})`,
317
359
  );
318
360
 
319
361
  try {
@@ -34,6 +34,7 @@ import type {
34
34
  DockerWarmShellResult,
35
35
  DockerWarmStartupContext,
36
36
  } from "./backends/types.js";
37
+ import { resolveFreshWorktreeBaseRef } from "./worktree_base_ref.js";
37
38
 
38
39
  const DEFAULT_OPENHANDS_MODEL = "local-model";
39
40
  const DEFAULT_CONFIG = loadPushPalsConfig();
@@ -2106,7 +2107,27 @@ export class DockerExecutor {
2106
2107
  reviewAgent && typeof reviewAgent.resolutionType === "string"
2107
2108
  ? reviewAgent.resolutionType.trim().toLowerCase()
2108
2109
  : "";
2109
- if (resolutionType !== "merge_conflict") return this.options.baseRef;
2110
+ if (resolutionType !== "merge_conflict") {
2111
+ return resolveFreshWorktreeBaseRef({
2112
+ requestedRef: this.options.baseRef,
2113
+ integrationBranch:
2114
+ this.config.sourceControlManager.mainBranch ||
2115
+ this.config.workerpals.baseRef ||
2116
+ this.options.baseRef,
2117
+ sourceBaseBranch: this.config.sourceControlManager.baseBranch,
2118
+ git: (args) => this.runGitBaseRefCommand(args),
2119
+ log: (level, message) => {
2120
+ const line = `[DockerExecutor] ${message}`;
2121
+ if (level === "warn") {
2122
+ console.warn(line);
2123
+ onLog?.("stderr", line);
2124
+ } else {
2125
+ console.log(line);
2126
+ onLog?.("stdout", line);
2127
+ }
2128
+ },
2129
+ });
2130
+ }
2110
2131
 
2111
2132
  const normalizedHeadRef = normalizeMergeConflictHeadRef(reviewAgent?.prHeadRef);
2112
2133
  if (!normalizedHeadRef) {
@@ -2150,6 +2171,26 @@ export class DockerExecutor {
2150
2171
  return remoteRef;
2151
2172
  }
2152
2173
 
2174
+ private async runGitBaseRefCommand(
2175
+ args: string[],
2176
+ ): Promise<{ ok: boolean; stdout: string; stderr: string }> {
2177
+ const proc = Bun.spawn(["git", ...args], {
2178
+ cwd: this.options.repo,
2179
+ stdout: "pipe",
2180
+ stderr: "pipe",
2181
+ });
2182
+ const [exitCode, stdout, stderr] = await Promise.all([
2183
+ proc.exited,
2184
+ new Response(proc.stdout).text(),
2185
+ new Response(proc.stderr).text(),
2186
+ ]);
2187
+ return {
2188
+ ok: exitCode === 0,
2189
+ stdout,
2190
+ stderr,
2191
+ };
2192
+ }
2193
+
2153
2194
  /**
2154
2195
  * Pull the Docker image
2155
2196
  */
@@ -190,6 +190,7 @@ export interface QualityGatePolicy {
190
190
  }
191
191
 
192
192
  const BROWSER_VALIDATION_MAX_AUTO_REVISIONS = 3;
193
+ const CRITIC_COMPACT_RETRY_MIN_REDUCTION_RATIO = 0.25;
193
194
 
194
195
  export function qualityRevisionLoopUpperBound(policy: {
195
196
  maxAutoRevisions: number;
@@ -232,6 +233,37 @@ export function qualityRevisionBudgetDecision(opts: {
232
233
  };
233
234
  }
234
235
 
236
+ export function shouldRetryCriticTimeoutWithCompact(opts: {
237
+ timeoutBehavior: string;
238
+ qualityOk: boolean;
239
+ validationPassed: boolean;
240
+ initialPromptChars: number;
241
+ compactPromptChars: number;
242
+ }): boolean {
243
+ if (opts.timeoutBehavior !== "retry_once") return false;
244
+ if (!opts.qualityOk || !opts.validationPassed) return true;
245
+ const initialPromptChars = Math.max(1, Math.floor(opts.initialPromptChars));
246
+ const compactPromptChars = Math.max(0, Math.floor(opts.compactPromptChars));
247
+ const reductionRatio = 1 - compactPromptChars / initialPromptChars;
248
+ return reductionRatio >= CRITIC_COMPACT_RETRY_MIN_REDUCTION_RATIO;
249
+ }
250
+
251
+ export function shouldSkipCriticAfterExecutorTimeout(opts: {
252
+ executor: string;
253
+ policyMode: string;
254
+ executorText: string;
255
+ qualityOk: boolean;
256
+ validationPassed: boolean;
257
+ qualityIssues: string[];
258
+ changedPaths: string[];
259
+ }): boolean {
260
+ if (opts.executor !== "openai_codex") return false;
261
+ if (opts.policyMode !== "default") return false;
262
+ if (!opts.qualityOk || !opts.validationPassed) return false;
263
+ if (opts.qualityIssues.length > 0 || opts.changedPaths.length === 0) return false;
264
+ return /\b(openai_codex|codex(?: exec)?)\b[^\r\n]*\btimed out\b/i.test(opts.executorText);
265
+ }
266
+
235
267
  export function workerAttemptRolloutScore(params: {
236
268
  executorElapsedMs: number;
237
269
  qualityElapsedMs: number;
@@ -879,6 +911,10 @@ function parseJsonObjectLoose(text: string): Record<string, unknown> | null {
879
911
  }
880
912
 
881
913
  const COMMIT_MSG_MAX_DIFF_CHARS = 120_000;
914
+ const COMMIT_MSG_LLM_MAX_CHANGED_PATHS = 20;
915
+ const COMMIT_MSG_GENERATOR_DEFAULT_TIMEOUT_MS = 15_000;
916
+ const COMMIT_MSG_GENERATOR_MIN_TIMEOUT_MS = 3_000;
917
+ const COMMIT_MSG_GENERATOR_MAX_TIMEOUT_MS = 30_000;
882
918
 
883
919
  const SHELL_CONTROL_TOKENS = new Set(["&&", "||", ";", "|"]);
884
920
 
@@ -2096,6 +2132,18 @@ function classifyBrowserValidationFailureKindFromText(text: string): BrowserVali
2096
2132
  return "unknown";
2097
2133
  }
2098
2134
 
2135
+ export function shouldRetryBrowserValidationRunOnce(run: ValidationExecutionResult): boolean {
2136
+ if (run.ok || !isLongRunningBrowserValidationCommand(run.command)) return false;
2137
+ const combined = stripAnsiControlSequences([run.stderr, run.stdout].filter(Boolean).join("\n"));
2138
+ const digest = extractValidationFailureDigest(run);
2139
+ const failureKind = classifyBrowserValidationFailureKindFromText(`${digest}\n${combined}`);
2140
+ if (failureKind === "runtime" || failureKind === "network") return true;
2141
+ if (failureKind === "startup") return true;
2142
+ return /\b(Route\/startup smoke failure|startup smoke failure|home route startup)\b/i.test(
2143
+ `${digest}\n${combined}`,
2144
+ );
2145
+ }
2146
+
2099
2147
  function extractBrowserValidationStage(text: string): string | null {
2100
2148
  const patterns = [
2101
2149
  /\bBrowser validation failed during\s+([^:.\r\n|]+?)\s+stage\b/i,
@@ -3662,12 +3710,34 @@ async function runDeterministicQualityGate(
3662
3710
  continue;
3663
3711
  }
3664
3712
  onLog?.("stdout", `[ValidationGate] Running "${command}"`);
3665
- const run = await runValidationCommand(
3713
+ let run = await runValidationCommand(
3666
3714
  repo,
3667
3715
  command,
3668
3716
  resolveValidationCommandTimeoutMs(command, qualityValidationStepTimeoutMs),
3669
3717
  outputPolicy,
3670
3718
  );
3719
+ const firstDigest = run.ok ? "" : extractValidationFailureDigest(run);
3720
+ if (shouldRetryBrowserValidationRunOnce(run)) {
3721
+ onLog?.(
3722
+ "stderr",
3723
+ `[ValidationGate] Retrying browser validation once after retryable startup/runtime failure: ${command}${firstDigest ? ` - ${firstDigest}` : ""}`,
3724
+ );
3725
+ const retryRun = await runValidationCommand(
3726
+ repo,
3727
+ command,
3728
+ resolveValidationCommandTimeoutMs(command, qualityValidationStepTimeoutMs),
3729
+ outputPolicy,
3730
+ );
3731
+ if (!retryRun.ok && firstDigest) {
3732
+ retryRun.stderr = [
3733
+ `Previous browser validation attempt failed before retry: ${firstDigest}`,
3734
+ retryRun.stderr,
3735
+ ]
3736
+ .filter(Boolean)
3737
+ .join("\n");
3738
+ }
3739
+ run = retryRun;
3740
+ }
3671
3741
  validationRuns.push(run);
3672
3742
  const digest = run.ok ? "" : extractValidationFailureDigest(run);
3673
3743
  const runSummary = `[ValidationGate] ${run.ok ? "Passed" : "Failed"} (${run.elapsedMs}ms, exit ${run.exitCode}): ${command}${digest ? ` - ${digest}` : ""}`;
@@ -4768,17 +4838,19 @@ export async function createJobCommit(
4768
4838
  ...toNonEmptyStringArray(jobPlanning?.requiredValidationSteps),
4769
4839
  ...loadRequiredValidationStepsFromVision(repo),
4770
4840
  ];
4771
- const llmCommitMsg = await generateCommitMessageFromDiff(
4772
- diff,
4773
- {
4774
- instruction: String(job.params?.instruction ?? ""),
4775
- type: normalizeCommitType(job.kind, job.params),
4776
- area: inferCommitArea(job.kind, job.params, changedPaths),
4777
- validationSteps: jobValidationSteps,
4778
- },
4779
- repo,
4780
- runtimeConfig,
4781
- ).catch(() => null);
4841
+ const llmCommitMsg = shouldUseLlmCommitMessageForStagedDiff({ changedPaths, diff })
4842
+ ? await generateCommitMessageFromDiff(
4843
+ diff,
4844
+ {
4845
+ instruction: String(job.params?.instruction ?? ""),
4846
+ type: normalizeCommitType(job.kind, job.params),
4847
+ area: inferCommitArea(job.kind, job.params, changedPaths),
4848
+ validationSteps: jobValidationSteps,
4849
+ },
4850
+ repo,
4851
+ runtimeConfig,
4852
+ ).catch(() => null)
4853
+ : null;
4782
4854
  if (!llmCommitMsg) {
4783
4855
  console.warn(
4784
4856
  `[WorkerPals] Commit message generator unavailable for job ${job.id}; using deterministic fallback.`,
@@ -5745,17 +5817,19 @@ async function createMergeConflictJobCommit(
5745
5817
  ...toNonEmptyStringArray(jobPlanning?.requiredValidationSteps),
5746
5818
  ...loadRequiredValidationStepsFromVision(repo),
5747
5819
  ];
5748
- const llmCommitMsg = await generateCommitMessageFromDiff(
5749
- diff,
5750
- {
5751
- instruction: String(job.params?.instruction ?? ""),
5752
- type: normalizeCommitType(job.kind, job.params),
5753
- area: inferCommitArea(job.kind, job.params, changedPaths),
5754
- validationSteps: jobValidationSteps,
5755
- },
5756
- repo,
5757
- runtimeConfig,
5758
- ).catch(() => null);
5820
+ const llmCommitMsg = shouldUseLlmCommitMessageForStagedDiff({ changedPaths, diff })
5821
+ ? await generateCommitMessageFromDiff(
5822
+ diff,
5823
+ {
5824
+ instruction: String(job.params?.instruction ?? ""),
5825
+ type: normalizeCommitType(job.kind, job.params),
5826
+ area: inferCommitArea(job.kind, job.params, changedPaths),
5827
+ validationSteps: jobValidationSteps,
5828
+ },
5829
+ repo,
5830
+ runtimeConfig,
5831
+ ).catch(() => null)
5832
+ : null;
5759
5833
  if (!llmCommitMsg) {
5760
5834
  console.warn(
5761
5835
  `[WorkerPals] Commit message generator unavailable for merge-conflict job ${job.id}; using deterministic fallback.`,
@@ -6206,6 +6280,38 @@ async function generateCommitMessageFromDiff(
6206
6280
  return generateCommitMessageFromDiffViaHttp(prompt, opts, runtimeConfig);
6207
6281
  }
6208
6282
 
6283
+ export function resolveCommitMessageGeneratorTimeoutMs(
6284
+ runtimeConfig: WorkerpalsRuntimeConfig = DEFAULT_CONFIG,
6285
+ ): number {
6286
+ const workerpalsConfig = runtimeConfig.workerpals as Record<string, unknown>;
6287
+ const llmConfig =
6288
+ workerpalsConfig.llm && typeof workerpalsConfig.llm === "object"
6289
+ ? (workerpalsConfig.llm as Record<string, unknown>)
6290
+ : {};
6291
+ const configuredRaw =
6292
+ workerpalsConfig.commitMessageTimeoutMs ??
6293
+ workerpalsConfig.commit_message_timeout_ms ??
6294
+ llmConfig.commitMessageTimeoutMs ??
6295
+ llmConfig.commit_message_timeout_ms ??
6296
+ Bun.env.WORKERPALS_COMMIT_MESSAGE_TIMEOUT_MS;
6297
+ const configured = Number(configuredRaw);
6298
+ const value = Number.isFinite(configured)
6299
+ ? configured
6300
+ : COMMIT_MSG_GENERATOR_DEFAULT_TIMEOUT_MS;
6301
+ return Math.max(
6302
+ COMMIT_MSG_GENERATOR_MIN_TIMEOUT_MS,
6303
+ Math.min(COMMIT_MSG_GENERATOR_MAX_TIMEOUT_MS, Math.floor(value)),
6304
+ );
6305
+ }
6306
+
6307
+ export function shouldUseLlmCommitMessageForStagedDiff(params: {
6308
+ changedPaths: string[];
6309
+ diff: string;
6310
+ }): boolean {
6311
+ if (!String(params.diff ?? "").trim()) return false;
6312
+ return params.changedPaths.length <= COMMIT_MSG_LLM_MAX_CHANGED_PATHS;
6313
+ }
6314
+
6209
6315
  type CommitMessagePrompt = {
6210
6316
  systemPrompt: string;
6211
6317
  userMessage: string;
@@ -6244,11 +6350,7 @@ async function generateCommitMessageFromDiffViaCodex(
6244
6350
  if (!model) return null;
6245
6351
  const codexPrefix = await resolveCodexCommandPrefix(repo, runtimeConfig.workerpals.llm.codexBin);
6246
6352
  if (!codexPrefix) return null;
6247
- const timeoutMs = (() => {
6248
- const value = Number(runtimeConfig.workerpals.llm.codexTimeoutMs);
6249
- if (!Number.isFinite(value)) return 120_000;
6250
- return Math.max(10_000, Math.min(600_000, Math.floor(value)));
6251
- })();
6353
+ const timeoutMs = resolveCommitMessageGeneratorTimeoutMs(runtimeConfig);
6252
6354
  const reasoningEffort = normalizeCodexReasoningEffort(
6253
6355
  runtimeConfig.workerpals.llm.reasoningEffort,
6254
6356
  model,
@@ -6338,7 +6440,7 @@ async function generateCommitMessageFromDiffViaHttp(
6338
6440
  if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
6339
6441
 
6340
6442
  const controller = new AbortController();
6341
- const timer = setTimeout(() => controller.abort(), 30_000);
6443
+ const timer = setTimeout(() => controller.abort(), resolveCommitMessageGeneratorTimeoutMs(runtimeConfig));
6342
6444
  try {
6343
6445
  const response = await fetch(endpoint, {
6344
6446
  method: "POST",
@@ -6952,6 +7054,7 @@ async function runCodexCriticReview(
6952
7054
  validationChars: validationSummary.length,
6953
7055
  };
6954
7056
  };
7057
+ type CodexCriticPayload = Awaited<ReturnType<typeof buildCriticInstruction>>;
6955
7058
 
6956
7059
  const tmpOutputPath = `/tmp/pushpals-critic-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.txt`;
6957
7060
  const buildCmd = () => {
@@ -6980,13 +7083,17 @@ async function runCodexCriticReview(
6980
7083
  const runAttempt = async (
6981
7084
  attempt: number,
6982
7085
  compact: boolean,
6983
- ): Promise<{ status: "timeout" } | { status: "done"; review: CriticReview | null }> => {
7086
+ payloadOverride?: CodexCriticPayload,
7087
+ ): Promise<
7088
+ | { status: "timeout"; payload: CodexCriticPayload }
7089
+ | { status: "done"; review: CriticReview | null; payload: CodexCriticPayload }
7090
+ > => {
6984
7091
  try {
6985
7092
  unlinkSync(tmpOutputPath);
6986
7093
  } catch {
6987
7094
  /* ignore stale/missing critic output */
6988
7095
  }
6989
- const payload = await buildCriticInstruction(compact);
7096
+ const payload = payloadOverride ?? (await buildCriticInstruction(compact));
6990
7097
  const startedAt = Date.now();
6991
7098
  onLog?.(
6992
7099
  "stdout",
@@ -7014,7 +7121,7 @@ async function runCodexCriticReview(
7014
7121
  clearTimeout(timer);
7015
7122
 
7016
7123
  if (timedOut) {
7017
- return { status: "timeout" };
7124
+ return { status: "timeout", payload };
7018
7125
  }
7019
7126
  if (exitCode !== 0) {
7020
7127
  const stderrText = await new Response(proc.stderr).text();
@@ -7022,7 +7129,7 @@ async function runCodexCriticReview(
7022
7129
  "stderr",
7023
7130
  `[CriticGate] Codex exited ${exitCode}: ${toSingleLine(stderrText, 220)}`,
7024
7131
  );
7025
- return { status: "done", review: null };
7132
+ return { status: "done", review: null, payload };
7026
7133
  }
7027
7134
 
7028
7135
  let lastMessage = "";
@@ -7039,7 +7146,7 @@ async function runCodexCriticReview(
7039
7146
 
7040
7147
  if (!lastMessage) {
7041
7148
  onLog?.("stderr", "[CriticGate] Codex: no output message captured; skipping.");
7042
- return { status: "done", review: null };
7149
+ return { status: "done", review: null, payload };
7043
7150
  }
7044
7151
 
7045
7152
  const reviewObj = parseJsonObjectLoose(lastMessage);
@@ -7048,7 +7155,7 @@ async function runCodexCriticReview(
7048
7155
  "stderr",
7049
7156
  `[CriticGate] Codex returned non-JSON: ${toSingleLine(lastMessage, 220)}`,
7050
7157
  );
7051
- return { status: "done", review: null };
7158
+ return { status: "done", review: null, payload };
7052
7159
  }
7053
7160
 
7054
7161
  const scoreRaw = Number(reviewObj.score);
@@ -7068,6 +7175,7 @@ async function runCodexCriticReview(
7068
7175
  );
7069
7176
  return {
7070
7177
  status: "done",
7178
+ payload,
7071
7179
  review: {
7072
7180
  score,
7073
7181
  findings,
@@ -7081,11 +7189,36 @@ async function runCodexCriticReview(
7081
7189
  try {
7082
7190
  let attempt = await runAttempt(1, false);
7083
7191
  if (attempt.status === "timeout" && timeoutBehavior === "retry_once") {
7084
- onLog?.(
7085
- "stderr",
7086
- `[CriticGate] Codex timed out after ${qualityCriticTimeoutMs}ms; retrying once with compact critic input.`,
7087
- );
7088
- attempt = await runAttempt(2, true);
7192
+ const compactPayload = await buildCriticInstruction(true);
7193
+ const validationPassed =
7194
+ quality.validationRuns.length > 0 && quality.validationRuns.every((run) => run.ok);
7195
+ if (
7196
+ shouldRetryCriticTimeoutWithCompact({
7197
+ timeoutBehavior,
7198
+ qualityOk: quality.ok,
7199
+ validationPassed,
7200
+ initialPromptChars: attempt.payload.promptChars,
7201
+ compactPromptChars: compactPayload.promptChars,
7202
+ })
7203
+ ) {
7204
+ onLog?.(
7205
+ "stderr",
7206
+ `[CriticGate] Codex timed out after ${qualityCriticTimeoutMs}ms; retrying once with compact critic input.`,
7207
+ );
7208
+ attempt = await runAttempt(2, true, compactPayload);
7209
+ } else {
7210
+ const reductionPct = Math.max(
7211
+ 0,
7212
+ Math.round(
7213
+ (1 - compactPayload.promptChars / Math.max(1, attempt.payload.promptChars)) * 100,
7214
+ ),
7215
+ );
7216
+ onLog?.(
7217
+ "stderr",
7218
+ `[CriticGate] Codex timed out after ${qualityCriticTimeoutMs}ms; compact critic input only reduced prompt by ${reductionPct}% after clean validation; skipping retry.`,
7219
+ );
7220
+ return null;
7221
+ }
7089
7222
  }
7090
7223
  if (attempt.status === "timeout") {
7091
7224
  if (timeoutBehavior === "block") {
@@ -7316,6 +7449,42 @@ export async function executeJob(
7316
7449
  );
7317
7450
  continue;
7318
7451
  }
7452
+ if (sequencer === "rebase" && !resume.resumed) {
7453
+ mergeConflictPass += 1;
7454
+ const budget = qualityRevisionBudgetDecision({
7455
+ jobElapsedMs: Date.now() - attemptStartedAt,
7456
+ executionBudgetMs,
7457
+ });
7458
+ if (mergeConflictPass < MAX_MERGE_CONFLICT_RESOLUTION_PASSES && budget.shouldStart) {
7459
+ const retryDetail =
7460
+ resume.detail ??
7461
+ "the previous resolver pass returned before the prepared rebase completed";
7462
+ const previousHint = String(attemptParams.qualityRevisionHint ?? "").trim();
7463
+ attemptParams.qualityRevisionHint = [
7464
+ previousHint,
7465
+ [
7466
+ `Merge-conflict resolver pass ${mergeConflictPass} left the rebase unfinished: ${retryDetail}.`,
7467
+ "Focus only on completing the active rebase. Inspect unresolved files with `git diff --name-only --diff-filter=U`, remove remaining conflict markers, stage resolved files, and run `git -c core.editor=true rebase --continue` until no rebase remains.",
7468
+ "Do not broaden the patch or run full validation before the rebase is complete.",
7469
+ ].join("\n"),
7470
+ ]
7471
+ .filter(Boolean)
7472
+ .join("\n\n");
7473
+ onLog?.(
7474
+ "stdout",
7475
+ `[MergeConflict] ${retryDetail}; rerunning resolver pass ${
7476
+ mergeConflictPass + 1
7477
+ } with focused rebase-completion guidance.`,
7478
+ );
7479
+ continue;
7480
+ }
7481
+ if (!budget.shouldStart) {
7482
+ onLog?.(
7483
+ "stderr",
7484
+ `[MergeConflict] Not rerunning unfinished rebase resolver: remaining execution budget is ${budget.remainingBudgetMs}ms (< ${budget.minimumRevisionBudgetMs}ms).`,
7485
+ );
7486
+ }
7487
+ }
7319
7488
  const detail =
7320
7489
  `Merge-conflict job returned with git ${sequencer} still in progress. ` +
7321
7490
  `Finish the ${sequencer} before returning control to WorkerPals.`;
@@ -7440,14 +7609,30 @@ export async function executeJob(
7440
7609
  blocker: null,
7441
7610
  }
7442
7611
  : quality;
7612
+ const validationPassed =
7613
+ quality.validationRuns.length > 0 && quality.validationRuns.every((run) => run.ok);
7614
+ const skipCriticAfterExecutorTimeout = shouldSkipCriticAfterExecutorTimeout({
7615
+ executor,
7616
+ policyMode: qualityGatePolicy.mode,
7617
+ executorText,
7618
+ qualityOk: quality.ok,
7619
+ validationPassed,
7620
+ qualityIssues: qualityForCritic.issues,
7621
+ changedPaths: quality.changedPaths,
7622
+ });
7443
7623
  const critic =
7444
- quality.skipped || !qualityGatePolicy.criticGateEnabled
7624
+ quality.skipped || !qualityGatePolicy.criticGateEnabled || skipCriticAfterExecutorTimeout
7445
7625
  ? null
7446
7626
  : executor === "openai_codex"
7447
7627
  ? await runCodexCriticReview(repo, attemptParams, qualityForCritic, runtimeConfig, onLog)
7448
7628
  : await runTaskCriticReview(repo, attemptParams, qualityForCritic, runtimeConfig, onLog);
7449
7629
  if (!qualityGatePolicy.criticGateEnabled) {
7450
7630
  onLog?.("stdout", "[CriticGate] Disabled by workerpals.quality_critic_gate_enabled=false.");
7631
+ } else if (skipCriticAfterExecutorTimeout) {
7632
+ onLog?.(
7633
+ "stdout",
7634
+ "[CriticGate] Skipping Codex critic after primary Codex executor timeout because deterministic quality and validation are clean.",
7635
+ );
7451
7636
  }
7452
7637
  const rolloutScore = workerAttemptRolloutScore({
7453
7638
  executorElapsedMs,
@@ -197,6 +197,15 @@ function buildPlannerGuidance(
197
197
  lines.push(
198
198
  "- Use direct commands only while resolving this rebase. Prefer `git diff -- <path>`, `git add <path>`, and `git -c core.editor=true rebase --continue` instead of `/bin/bash -lc`, `sh -lc`, `awk`, or chained shell snippets.",
199
199
  );
200
+ lines.push(
201
+ "- Primary success condition: finish the git rebase and leave no active rebase/merge/cherry-pick state. Do not spend budget polishing, broadening, or refactoring tests beyond what is required to remove conflict markers and keep both sides' intended behavior.",
202
+ );
203
+ lines.push(
204
+ "- Rebase convergence rule: after resolving each conflicted file, run `git diff --name-only --diff-filter=U`. If no unresolved paths remain, stage the resolved files and continue the rebase immediately before doing broader validation.",
205
+ );
206
+ lines.push(
207
+ "- Budget rule: if conflict resolution is running long, choose the smallest side-preserving resolution, stage it, and continue the rebase. A clean rebased branch with focused follow-up validation is better than a richer partial patch left mid-rebase.",
208
+ );
200
209
  lines.push(
201
210
  "- After editing, run `git add <files>` and `git -c core.editor=true rebase --continue` until the rebase completes.",
202
211
  );
@@ -47,6 +47,7 @@ import { DockerExecutionExhaustedError, DockerExecutor } from "./docker_executor
47
47
  import { forceDeleteWorktreePath } from "./common/worktree_cleanup.js";
48
48
  import { WorkerServerTransport, type WorkerHeartbeatPayload } from "./common/server_transport.js";
49
49
  import { DEFAULT_DOCKER_TIMEOUT_MS, parseDockerTimeoutMs } from "./timeout_policy.js";
50
+ import { resolveFreshWorktreeBaseRef } from "./worktree_base_ref.js";
50
51
 
51
52
  type CommitRef = {
52
53
  branch: string;
@@ -314,6 +315,8 @@ async function reportWorkerLlmUsage(
314
315
  }
315
316
 
316
317
  function integrationBranchName(): string {
318
+ const configuredIntegrationBranch = CONFIG.sourceControlManager.mainBranch.trim();
319
+ if (configuredIntegrationBranch) return configuredIntegrationBranch;
317
320
  const configuredBaseRef = CONFIG.workerpals.baseRef.trim();
318
321
  if (!configuredBaseRef) return "main_agents";
319
322
  return configuredBaseRef.replace(/^origin\//, "").trim() || "main_agents";
@@ -665,33 +668,17 @@ async function runJob(
665
668
  }
666
669
 
667
670
  async function resolveWorktreeBaseRef(repo: string, requestedRef: string): Promise<string> {
668
- const integrationBranch = integrationBranchName();
669
- const integrationRemoteRef = `origin/${integrationBranch}`;
670
- const candidates = new Set<string>([
671
+ return resolveFreshWorktreeBaseRef({
671
672
  requestedRef,
672
- integrationRemoteRef,
673
- integrationBranch,
674
- "HEAD",
675
- ]);
676
- if (requestedRef.startsWith("origin/")) {
677
- const branch = requestedRef.slice("origin/".length);
678
- const fetchResult = await git(repo, ["fetch", "origin", branch, "--quiet"]);
679
- if (!fetchResult.ok) {
680
- console.warn(
681
- `[WorkerPals] Could not refresh ${requestedRef}; continuing with local refs (${fetchResult.stderr || fetchResult.stdout})`,
682
- );
683
- }
684
- candidates.add(branch);
685
- } else if (requestedRef !== "HEAD") {
686
- candidates.add(`origin/${requestedRef}`);
687
- }
688
-
689
- for (const ref of candidates) {
690
- const parsed = await git(repo, ["rev-parse", "--verify", "--quiet", ref]);
691
- if (parsed.ok) return ref;
692
- }
693
-
694
- return "HEAD";
673
+ integrationBranch: integrationBranchName(),
674
+ sourceBaseBranch: CONFIG.sourceControlManager.baseBranch,
675
+ git: (args) => git(repo, args),
676
+ log: (level, message) => {
677
+ const line = `[WorkerPals] ${message}`;
678
+ if (level === "warn") console.warn(line);
679
+ else console.log(line);
680
+ },
681
+ });
695
682
  }
696
683
 
697
684
  async function createIsolatedWorktree(
@@ -0,0 +1,141 @@
1
+ export type GitBaseRefCommandResult = {
2
+ ok: boolean;
3
+ stdout?: string;
4
+ stderr?: string;
5
+ };
6
+
7
+ export type GitBaseRefCommand = (args: string[]) => Promise<GitBaseRefCommandResult>;
8
+
9
+ export type WorktreeBaseRefLogLevel = "info" | "warn";
10
+
11
+ export type ResolveFreshWorktreeBaseRefOptions = {
12
+ requestedRef: string;
13
+ integrationBranch: string;
14
+ sourceBaseBranch: string;
15
+ remote?: string;
16
+ git: GitBaseRefCommand;
17
+ log?: (level: WorktreeBaseRefLogLevel, message: string) => void;
18
+ };
19
+
20
+ function normalizeBranchName(value: string): string {
21
+ return value
22
+ .trim()
23
+ .replace(/^refs\/heads\//, "")
24
+ .replace(/^origin\//, "")
25
+ .replace(/^\/+|\/+$/g, "");
26
+ }
27
+
28
+ function normalizeRequestedRef(value: string): string {
29
+ return value.trim() || "HEAD";
30
+ }
31
+
32
+ function remoteRef(remote: string, branch: string): string {
33
+ return `${remote}/${branch}`;
34
+ }
35
+
36
+ function isIntegrationBaseRequest(ref: string, integrationBranch: string, remote: string): boolean {
37
+ const normalized = normalizeRequestedRef(ref);
38
+ const branch = normalizeBranchName(normalized);
39
+ return (
40
+ branch === integrationBranch ||
41
+ normalized === remoteRef(remote, integrationBranch) ||
42
+ normalized === `refs/remotes/${remote}/${integrationBranch}`
43
+ );
44
+ }
45
+
46
+ async function fetchRemoteBranch(
47
+ git: GitBaseRefCommand,
48
+ remote: string,
49
+ branch: string,
50
+ ): Promise<GitBaseRefCommandResult> {
51
+ if (!remote || !branch || branch === "HEAD") return { ok: true };
52
+ return git(["fetch", remote, branch, "--quiet"]);
53
+ }
54
+
55
+ async function refExists(git: GitBaseRefCommand, ref: string): Promise<boolean> {
56
+ const result = await git(["rev-parse", "--verify", "--quiet", ref]);
57
+ return result.ok;
58
+ }
59
+
60
+ async function isAncestor(
61
+ git: GitBaseRefCommand,
62
+ ancestorRef: string,
63
+ descendantRef: string,
64
+ ): Promise<boolean> {
65
+ const result = await git(["merge-base", "--is-ancestor", ancestorRef, descendantRef]);
66
+ return result.ok;
67
+ }
68
+
69
+ export async function resolveExistingWorktreeBaseRef(
70
+ options: Omit<ResolveFreshWorktreeBaseRefOptions, "sourceBaseBranch" | "log">,
71
+ ): Promise<string> {
72
+ const remote = (options.remote ?? "origin").trim() || "origin";
73
+ const requestedRef = normalizeRequestedRef(options.requestedRef);
74
+ const integrationBranch = normalizeBranchName(options.integrationBranch) || "main_agents";
75
+ const integrationRemoteRef = remoteRef(remote, integrationBranch);
76
+ const candidates = new Set<string>([
77
+ requestedRef,
78
+ integrationRemoteRef,
79
+ integrationBranch,
80
+ "HEAD",
81
+ ]);
82
+
83
+ if (requestedRef.startsWith(`${remote}/`)) {
84
+ const branch = requestedRef.slice(`${remote}/`.length);
85
+ await fetchRemoteBranch(options.git, remote, branch);
86
+ candidates.add(branch);
87
+ } else if (requestedRef !== "HEAD") {
88
+ candidates.add(remoteRef(remote, requestedRef));
89
+ }
90
+
91
+ for (const ref of candidates) {
92
+ if (await refExists(options.git, ref)) return ref;
93
+ }
94
+
95
+ return "HEAD";
96
+ }
97
+
98
+ export async function resolveFreshWorktreeBaseRef(
99
+ options: ResolveFreshWorktreeBaseRefOptions,
100
+ ): Promise<string> {
101
+ const remote = (options.remote ?? "origin").trim() || "origin";
102
+ const requestedRef = normalizeRequestedRef(options.requestedRef);
103
+ const integrationBranch = normalizeBranchName(options.integrationBranch) || "main_agents";
104
+ const sourceBaseBranch = normalizeBranchName(options.sourceBaseBranch) || "main";
105
+ const resolvedRef = await resolveExistingWorktreeBaseRef({
106
+ requestedRef,
107
+ integrationBranch,
108
+ remote,
109
+ git: options.git,
110
+ });
111
+
112
+ if (
113
+ !sourceBaseBranch ||
114
+ sourceBaseBranch === integrationBranch ||
115
+ !isIntegrationBaseRequest(requestedRef, integrationBranch, remote)
116
+ ) {
117
+ return resolvedRef;
118
+ }
119
+
120
+ const sourceBaseRef = remoteRef(remote, sourceBaseBranch);
121
+ const fetchSource = await fetchRemoteBranch(options.git, remote, sourceBaseBranch);
122
+ if (!fetchSource.ok) {
123
+ options.log?.(
124
+ "warn",
125
+ `Could not refresh ${sourceBaseRef}; checking local ref before keeping ${resolvedRef} (${fetchSource.stderr || fetchSource.stdout || "fetch failed"}).`,
126
+ );
127
+ }
128
+
129
+ if (!(await refExists(options.git, sourceBaseRef))) return resolvedRef;
130
+
131
+ if (resolvedRef !== "HEAD" && (await refExists(options.git, resolvedRef))) {
132
+ const sourceAlreadyIncluded = await isAncestor(options.git, sourceBaseRef, resolvedRef);
133
+ if (sourceAlreadyIncluded) return resolvedRef;
134
+ }
135
+
136
+ options.log?.(
137
+ "warn",
138
+ `Worktree base ${resolvedRef} does not contain ${sourceBaseRef}; using ${sourceBaseRef} for new WorkerPal jobs to avoid stale integration-branch checkouts.`,
139
+ );
140
+ return sourceBaseRef;
141
+ }
@@ -75,6 +75,7 @@ session_id = "remotebuddy-dev"
75
75
  enabled = true
76
76
  kill_switch_enabled = false
77
77
  tick_interval_ms = 300000
78
+ startup_grace_ms = 120000
78
79
  heartbeat_log_ms = 30000
79
80
  vision_context_max_chars = 65536
80
81
  ideation_budget_ms = 20000
@@ -128,6 +128,7 @@ export interface PushPalsConfig {
128
128
  enabled: boolean;
129
129
  killSwitchEnabled: boolean;
130
130
  tickIntervalMs: number;
131
+ startupGraceMs: number;
131
132
  heartbeatLogMs: number;
132
133
  visionContextMaxChars: number;
133
134
  ideationBudgetMs: number;
@@ -1671,6 +1672,14 @@ export function loadPushPalsConfig(options: LoadOptions = {}): PushPalsConfig {
1671
1672
  120_000,
1672
1673
  ),
1673
1674
  ),
1675
+ startupGraceMs: Math.max(
1676
+ 0,
1677
+ asInt(
1678
+ parseIntEnv("REMOTEBUDDY_AUTONOMY_STARTUP_GRACE_MS") ??
1679
+ remoteAutonomyNode.startup_grace_ms,
1680
+ 120_000,
1681
+ ),
1682
+ ),
1674
1683
  heartbeatLogMs: Math.max(
1675
1684
  1_000,
1676
1685
  asInt(