@pushpalsdev/cli 1.0.51 → 1.0.52

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.
@@ -3048,6 +3048,44 @@ async function cleanupLingeringWorkerpalWarmContainers(opts) {
3048
3048
  removed: containerIds.length
3049
3049
  };
3050
3050
  }
3051
+ async function cleanupLocalWorkerpalSandboxImage(opts) {
3052
+ const imageName = String(opts.dockerImage ?? "").trim();
3053
+ if (!imageName) {
3054
+ return {
3055
+ ok: true,
3056
+ detail: "no local WorkerPal sandbox image configured",
3057
+ removed: false,
3058
+ imageName: ""
3059
+ };
3060
+ }
3061
+ const runCommandWithEnvFn = opts.runCommandWithEnvFn ?? runCommandWithEnv;
3062
+ const commandTimeoutMs = typeof opts.commandTimeoutMs === "number" && Number.isFinite(opts.commandTimeoutMs) ? Math.max(1, Math.floor(opts.commandTimeoutMs)) : WORKERPAL_IMAGE_INSPECT_TIMEOUT_MS;
3063
+ const dockerExecutable = resolveConfiguredDockerExecutable(opts.env, opts.platform ?? process.platform);
3064
+ const remove = await runCommandWithEnvFn([dockerExecutable, "image", "rm", "-f", imageName], opts.repoRoot, opts.env, commandTimeoutMs);
3065
+ if (!remove.ok) {
3066
+ const detail = remove.stderr || remove.stdout || `exit ${remove.exitCode}`;
3067
+ if (isMissingDockerImageDetail(detail)) {
3068
+ return {
3069
+ ok: true,
3070
+ detail: `no local WorkerPal sandbox image found for ${imageName}`,
3071
+ removed: false,
3072
+ imageName
3073
+ };
3074
+ }
3075
+ return {
3076
+ ok: false,
3077
+ detail: `failed to remove local WorkerPal sandbox image ${imageName}: ${detail}`,
3078
+ removed: false,
3079
+ imageName
3080
+ };
3081
+ }
3082
+ return {
3083
+ ok: true,
3084
+ detail: `removed local WorkerPal sandbox image ${imageName}`,
3085
+ removed: true,
3086
+ imageName
3087
+ };
3088
+ }
3051
3089
  async function cleanupLingeringPushPalsGitWorktrees(opts) {
3052
3090
  const runCommandWithEnvFn = opts.runCommandWithEnvFn ?? runCommandWithEnv;
3053
3091
  const forceDeleteWorktreePathFn = opts.forceDeleteWorktreePathFn ?? forceDeleteWorktreePath;
@@ -3564,6 +3602,36 @@ async function clearPushpalsState(opts) {
3564
3602
  for (const target of missing) {
3565
3603
  console.log(`[pushpals] Nothing to clear for ${target.label}: ${target.path}`);
3566
3604
  }
3605
+ if (opts.config.remotebuddy.workerpalDocker || opts.config.remotebuddy.workerpalRequireDocker) {
3606
+ const dockerEnv = normalizeChildProcessEnv(process.env);
3607
+ const warmCleanup = await cleanupLingeringWorkerpalWarmContainers({
3608
+ repoRoot: opts.repoRoot,
3609
+ env: dockerEnv
3610
+ });
3611
+ if (warmCleanup.ok) {
3612
+ console.log(warmCleanup.removed > 0 ? `[pushpals] Cleared WorkerPal warm containers: ${warmCleanup.detail}` : `[pushpals] Nothing to clear for WorkerPal warm containers: ${warmCleanup.detail}`);
3613
+ } else {
3614
+ failed.push({
3615
+ label: "WorkerPal warm containers",
3616
+ path: opts.repoRoot,
3617
+ detail: warmCleanup.detail
3618
+ });
3619
+ }
3620
+ const imageCleanup = await cleanupLocalWorkerpalSandboxImage({
3621
+ repoRoot: opts.repoRoot,
3622
+ env: dockerEnv,
3623
+ dockerImage: opts.config.remotebuddy.workerpalImage ?? opts.config.workerpals.dockerImage
3624
+ });
3625
+ if (imageCleanup.ok) {
3626
+ console.log(imageCleanup.removed ? `[pushpals] Cleared WorkerPal sandbox image: ${imageCleanup.imageName}` : `[pushpals] Nothing to clear for WorkerPal sandbox image: ${imageCleanup.detail}`);
3627
+ } else {
3628
+ failed.push({
3629
+ label: "WorkerPal sandbox image",
3630
+ path: imageCleanup.imageName || opts.repoRoot,
3631
+ detail: imageCleanup.detail
3632
+ });
3633
+ }
3634
+ }
3567
3635
  for (const failure of failed) {
3568
3636
  console.error(`[pushpals] Failed to clear ${failure.label}: ${failure.path} (${failure.detail})`);
3569
3637
  }
@@ -5276,6 +5344,7 @@ export {
5276
5344
  createSessionEventReplayFilter,
5277
5345
  copyTrackedRepoPath,
5278
5346
  computeEmbeddedServiceRestartBackoffMs,
5347
+ cleanupLocalWorkerpalSandboxImage,
5279
5348
  cleanupLingeringWorkerpalWarmContainers,
5280
5349
  cleanupLingeringPushPalsGitWorktrees,
5281
5350
  bundledMonitoringHubNeedsRefresh,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushpalsdev/cli",
3
- "version": "1.0.51",
3
+ "version": "1.0.52",
4
4
  "description": "PushPals terminal CLI for LocalBuddy -> RemoteBuddy orchestration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import re
2
3
  import sys
3
4
  import unittest
4
5
  import tempfile
@@ -10,7 +11,13 @@ for path in (_HERE, _SHARED):
10
11
  if str(path) not in sys.path:
11
12
  sys.path.insert(0, str(path))
12
13
 
13
- from executor_base import SettingsResolver, config_dir_for_runtime_config, runtime_config
14
+ from executor_base import (
15
+ LOGGER_STANDARD_METHODS,
16
+ Logger,
17
+ SettingsResolver,
18
+ config_dir_for_runtime_config,
19
+ runtime_config,
20
+ )
14
21
  from openai_codex_executor import (
15
22
  OpenAICodexRuntimeConfig,
16
23
  _augment_supplemental_guidance,
@@ -225,6 +232,37 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
225
232
  "Get-ChildItem src",
226
233
  )
227
234
 
235
+ def test_logger_supports_warning_alias_used_by_recovery_paths(self) -> None:
236
+ logger = Logger("[test]")
237
+ self.assertTrue(callable(getattr(logger, "warn", None)))
238
+ self.assertTrue(callable(getattr(logger, "warning", None)))
239
+
240
+ def test_logger_supports_standard_backend_method_surface(self) -> None:
241
+ logger = Logger("[test]")
242
+ for method_name in LOGGER_STANDARD_METHODS:
243
+ self.assertTrue(
244
+ callable(getattr(logger, method_name, None)),
245
+ f"Logger is missing required method: {method_name}",
246
+ )
247
+
248
+ def test_backend_log_method_usage_matches_shared_logger_contract(self) -> None:
249
+ backend_root = _HERE.parent
250
+ method_pattern = re.compile(r"\blog\.(\w+)\(")
251
+ used_methods = set()
252
+ for path in backend_root.rglob("*.py"):
253
+ if path.name.startswith("test_"):
254
+ continue
255
+ text = path.read_text(encoding="utf-8")
256
+ used_methods.update(method_pattern.findall(text))
257
+
258
+ self.assertTrue(used_methods, "Expected to discover backend logger usage")
259
+ unsupported = sorted(method for method in used_methods if method not in LOGGER_STANDARD_METHODS)
260
+ self.assertEqual(
261
+ unsupported,
262
+ [],
263
+ f"Backend code uses logger method(s) not covered by executor_base.Logger: {unsupported}",
264
+ )
265
+
228
266
  def test_augments_guidance_with_direct_command_policy_once(self) -> None:
229
267
  guidance = _augment_supplemental_guidance(["Run bun test tests/example.test.ts"])
230
268
  self.assertGreaterEqual(len(guidance), 2)
@@ -14,6 +14,7 @@ import os
14
14
  import re
15
15
  import subprocess
16
16
  import sys
17
+ import traceback
17
18
  from dataclasses import dataclass, field
18
19
  from pathlib import Path
19
20
  from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple
@@ -50,6 +51,15 @@ KNOWN_LITELLM_PROVIDER_PREFIXES: Set[str] = {
50
51
  }
51
52
 
52
53
  DEFAULT_TOOLCALL_RETRY_MAX = 1
54
+ LOGGER_STANDARD_METHODS: Tuple[str, ...] = (
55
+ "debug",
56
+ "info",
57
+ "warn",
58
+ "warning",
59
+ "error",
60
+ "exception",
61
+ "critical",
62
+ )
53
63
 
54
64
  # Superset of signals from both executors indicating the model failed to
55
65
  # emit tool calls / tool actions.
@@ -100,12 +110,43 @@ class Logger:
100
110
  def __init__(self, prefix: str) -> None:
101
111
  self.prefix = prefix
102
112
 
103
- def info(self, message: str) -> None:
104
- executor_log(f"{self.prefix} {message}")
113
+ def _coerce_message(self, message: Any, args: Tuple[Any, ...]) -> str:
114
+ text = str(message)
115
+ if not args:
116
+ return text
117
+ try:
118
+ return text % args
119
+ except Exception:
120
+ pieces = [text, *(str(arg) for arg in args)]
121
+ return " ".join(piece for piece in pieces if piece)
122
+
123
+ def _emit(self, _level: str, message: Any, *args: Any) -> None:
124
+ executor_log(f"{self.prefix} {self._coerce_message(message, args)}")
105
125
 
106
- def debug(self, message: str) -> None:
126
+ def info(self, message: Any, *args: Any) -> None:
127
+ self._emit("info", message, *args)
128
+
129
+ def debug(self, message: Any, *args: Any) -> None:
107
130
  if _debug_enabled():
108
- executor_log(f"{self.prefix} {message}")
131
+ self._emit("debug", message, *args)
132
+
133
+ def warn(self, message: Any, *args: Any) -> None:
134
+ self._emit("warn", message, *args)
135
+
136
+ def warning(self, message: Any, *args: Any) -> None:
137
+ self.warn(message, *args)
138
+
139
+ def error(self, message: Any, *args: Any) -> None:
140
+ self._emit("error", message, *args)
141
+
142
+ def critical(self, message: Any, *args: Any) -> None:
143
+ self._emit("critical", message, *args)
144
+
145
+ def exception(self, message: Any, *args: Any, exc_info: Any = True) -> None:
146
+ detail = self._coerce_message(message, args)
147
+ if exc_info:
148
+ detail = f"{detail}\n{traceback.format_exc().strip()}"
149
+ self._emit("exception", detail)
109
150
 
110
151
 
111
152
  def fail(summary: str, stderr: Optional[str] = None, exit_code: int = 1) -> int:
@@ -2042,6 +2042,126 @@ async function activeGitOperation(repo: string): Promise<"rebase" | "merge" | "c
2042
2042
  return null;
2043
2043
  }
2044
2044
 
2045
+ export async function resumePreparedMergeConflictRebase(
2046
+ repo: string,
2047
+ kind: string,
2048
+ params?: Record<string, unknown>,
2049
+ onLog?: (stream: "stdout" | "stderr", line: string) => void,
2050
+ ): Promise<
2051
+ | { ok: true; resumed: boolean; sequencer: "rebase" | "merge" | "cherry-pick" | null; detail?: string }
2052
+ | { ok: false; error: string }
2053
+ > {
2054
+ const sequencer = await activeGitOperation(repo);
2055
+ if (sequencer !== "rebase") {
2056
+ return { ok: true, resumed: false, sequencer };
2057
+ }
2058
+
2059
+ const unresolved = await git(repo, ["diff", "--name-only", "--diff-filter=U"]);
2060
+ if (!unresolved.ok) {
2061
+ return {
2062
+ ok: false,
2063
+ error: `Failed to inspect unresolved merge-conflict paths: ${combinedGitOutput(unresolved)}`,
2064
+ };
2065
+ }
2066
+ const unresolvedPaths = parseChangedPathsFromNameOnlyOutput(unresolved.stdout);
2067
+ if (unresolvedPaths.length > 0) {
2068
+ const stillMarked = unresolvedPaths.filter((relativePath) => {
2069
+ try {
2070
+ const contents = readFileSync(resolve(repo, relativePath), "utf8");
2071
+ return /^(<{7}|={7}|>{7})( .*)?$/m.test(contents);
2072
+ } catch {
2073
+ return true;
2074
+ }
2075
+ });
2076
+ if (stillMarked.length > 0) {
2077
+ return {
2078
+ ok: true,
2079
+ resumed: false,
2080
+ sequencer,
2081
+ detail: `rebase still has ${stillMarked.length} unresolved conflict marker file(s)`,
2082
+ };
2083
+ }
2084
+ onLog?.(
2085
+ "stdout",
2086
+ `[MergeConflict] Found ${unresolvedPaths.length} resolved-but-unstaged conflict file(s); staging them before continuing the rebase.`,
2087
+ );
2088
+ }
2089
+
2090
+ let stageResult: { ok: boolean; stdout: string; stderr: string };
2091
+ const stageArgs = buildStageCommand(kind, params);
2092
+ if (stageArgs) {
2093
+ stageResult = await git(repo, stageArgs);
2094
+ if (!stageResult.ok) {
2095
+ const stageErr = stageResult.stderr || stageResult.stdout;
2096
+ if (
2097
+ /pathspec .* did not match any files/i.test(stageErr) ||
2098
+ /invalid path/i.test(stageErr) ||
2099
+ /outside repository/i.test(stageErr)
2100
+ ) {
2101
+ onLog?.(
2102
+ "stdout",
2103
+ `[MergeConflict] Stage target invalid/missing for ${kind}; retrying with fallback "git add -A".`,
2104
+ );
2105
+ stageResult = await git(repo, [
2106
+ "add",
2107
+ "-A",
2108
+ "--",
2109
+ ".",
2110
+ ":(exclude)workspace/**",
2111
+ ":(exclude)outputs/**",
2112
+ ]);
2113
+ }
2114
+ }
2115
+ } else {
2116
+ stageResult = await git(repo, [
2117
+ "add",
2118
+ "-A",
2119
+ "--",
2120
+ ".",
2121
+ ":(exclude)workspace/**",
2122
+ ":(exclude)outputs/**",
2123
+ ]);
2124
+ }
2125
+ if (!stageResult.ok) {
2126
+ return {
2127
+ ok: false,
2128
+ error:
2129
+ "Failed to stage resolved merge-conflict changes before continuing rebase: " +
2130
+ combinedGitOutput(stageResult),
2131
+ };
2132
+ }
2133
+
2134
+ let rebaseContinue = await git(repo, ["-c", "core.editor=true", "rebase", "--continue"]);
2135
+ let continueOutput = combinedGitOutput(rebaseContinue);
2136
+ if (!rebaseContinue.ok && isRebaseEditorPromptOutput(continueOutput)) {
2137
+ rebaseContinue = await git(repo, ["-c", "core.editor=true", "rebase", "--continue"]);
2138
+ continueOutput = combinedGitOutput(rebaseContinue);
2139
+ }
2140
+ if (!rebaseContinue.ok) {
2141
+ return {
2142
+ ok: false,
2143
+ error: `Failed to continue prepared merge-conflict rebase: ${continueOutput}`,
2144
+ };
2145
+ }
2146
+
2147
+ const remainingSequencer = await activeGitOperation(repo);
2148
+ if (!remainingSequencer) {
2149
+ onLog?.(
2150
+ "stdout",
2151
+ "[MergeConflict] Auto-continued the prepared rebase after the executor returned with no unresolved conflicts.",
2152
+ );
2153
+ }
2154
+ return {
2155
+ ok: true,
2156
+ resumed: true,
2157
+ sequencer: remainingSequencer,
2158
+ detail:
2159
+ remainingSequencer === "rebase"
2160
+ ? "rebase advanced but another continuation step is still required"
2161
+ : undefined,
2162
+ };
2163
+ }
2164
+
2045
2165
  async function isAncestorRef(repo: string, ancestor: string, descendant: string): Promise<boolean> {
2046
2166
  const result = await git(repo, ["merge-base", "--is-ancestor", ancestor, descendant]);
2047
2167
  return result.ok;
@@ -3409,7 +3529,18 @@ export async function executeJob(
3409
3529
  );
3410
3530
  if (!result.ok) return result;
3411
3531
  if (mergeConflictContext) {
3412
- const sequencer = await activeGitOperation(repo);
3532
+ const resume = await resumePreparedMergeConflictRebase(repo, kind, attemptParams, onLog);
3533
+ if (!resume.ok) {
3534
+ onLog?.("stderr", `[MergeConflict] ${resume.error}`);
3535
+ return {
3536
+ ok: false,
3537
+ summary: "Merge-conflict rebase continuation failed",
3538
+ stdout: result.stdout,
3539
+ stderr: [result.stderr ?? "", resume.error].filter(Boolean).join("\n"),
3540
+ exitCode: 4,
3541
+ };
3542
+ }
3543
+ const sequencer = resume.sequencer;
3413
3544
  if (sequencer) {
3414
3545
  const detail =
3415
3546
  `Merge-conflict job returned with git ${sequencer} still in progress. ` +