@pushpalsdev/cli 1.1.6 → 1.1.7

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.1.6",
3
+ "version": "1.1.7",
4
4
  "description": "PushPals terminal CLI for LocalBuddy -> RemoteBuddy orchestration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -63,5 +63,6 @@ export const OPENAI_CODEX_BACKEND: DockerBackendSpec = {
63
63
  scriptPath: resolve(import.meta.dir, "openai_codex", "openai_codex_executor.py"),
64
64
  pythonConfigKey: "openaiCodexPython",
65
65
  timeoutConfigKey: "openaiCodexTimeoutMs",
66
+ capTimeoutToExecutionBudget: false,
66
67
  }),
67
68
  };
@@ -25,6 +25,7 @@ interface GenericPythonExecutorConfig {
25
25
  scriptPath: string;
26
26
  pythonConfigKey: string;
27
27
  timeoutConfigKey: string;
28
+ capTimeoutToExecutionBudget?: boolean;
28
29
  }
29
30
 
30
31
  function estimateTokensFromText(text: string): number {
@@ -119,6 +120,22 @@ function resolveRuntimeSettings(
119
120
  return { pythonBin, timeoutMs };
120
121
  }
121
122
 
123
+ export function resolveGenericPythonExecutorTimeoutMs(params: {
124
+ configuredTimeoutMs: number;
125
+ executionBudgetMs?: number | null;
126
+ capTimeoutToExecutionBudget?: boolean;
127
+ }): number {
128
+ const configuredTimeoutMs = Math.max(10_000, Math.floor(params.configuredTimeoutMs));
129
+ const executionBudgetMs =
130
+ typeof params.executionBudgetMs === "number" && Number.isFinite(params.executionBudgetMs)
131
+ ? Math.max(10_000, Math.floor(params.executionBudgetMs))
132
+ : null;
133
+ if (executionBudgetMs != null && params.capTimeoutToExecutionBudget !== false) {
134
+ return Math.min(configuredTimeoutMs, executionBudgetMs);
135
+ }
136
+ return configuredTimeoutMs;
137
+ }
138
+
122
139
  export function createGenericPythonExecutor(
123
140
  config: GenericPythonExecutorConfig,
124
141
  ): BackendTaskExecutor {
@@ -150,10 +167,11 @@ export function createGenericPythonExecutor(
150
167
  typeof budgets?.executionBudgetMs === "number" && Number.isFinite(budgets.executionBudgetMs)
151
168
  ? Math.max(10_000, Math.floor(budgets.executionBudgetMs))
152
169
  : null;
153
- const timeoutMs =
154
- executionBudgetMs != null
155
- ? Math.min(configuredTimeoutMs, executionBudgetMs)
156
- : configuredTimeoutMs;
170
+ const timeoutMs = resolveGenericPythonExecutorTimeoutMs({
171
+ configuredTimeoutMs,
172
+ executionBudgetMs,
173
+ capTimeoutToExecutionBudget: config.capTimeoutToExecutionBudget,
174
+ });
157
175
  const payloadBase64 = Buffer.from(
158
176
  JSON.stringify({
159
177
  kind,
@@ -107,7 +107,12 @@ function dockerBuildFileArg(root: string, dockerfilePath: string): string {
107
107
  }
108
108
 
109
109
  function isMissingDockerImageDetail(detail: string): boolean {
110
- return /\b(no such object|no such image|not found)\b/i.test(String(detail ?? ""));
110
+ const text = String(detail ?? "");
111
+ return (
112
+ /\b(no such object|no such image|not found)\b/i.test(text) ||
113
+ /\bunable to find image\b.*\blocally\b/i.test(text) ||
114
+ /\bpull access denied\b.*\brepository does not exist\b/i.test(text)
115
+ );
111
116
  }
112
117
 
113
118
  type ParsedWorktreeRecord = {
@@ -1600,12 +1605,28 @@ export class DockerExecutor {
1600
1605
  onLog?: (stream: "stdout" | "stderr", line: string) => void,
1601
1606
  ): Promise<void> {
1602
1607
  const backend = resolveExecutor(this.config);
1603
- for (let attempt = 1; attempt <= this.warmSetupMaxAttempts; attempt++) {
1608
+ let attempt = 1;
1609
+ let recoveredMissingImage = false;
1610
+ while (attempt <= this.warmSetupMaxAttempts) {
1604
1611
  try {
1605
1612
  await this.ensureWarmContainer();
1606
1613
  await this.ensureBackendWarmup(backend);
1607
1614
  return;
1608
1615
  } catch (err) {
1616
+ if (this.isMissingDockerImageError(err) && !recoveredMissingImage) {
1617
+ recoveredMissingImage = true;
1618
+ const rebuildNote = `[DockerExecutor] Warm runtime image ${this.options.imageName} is missing locally; rebuilding before retrying warm container startup.`;
1619
+ console.warn(rebuildNote);
1620
+ onLog?.("stderr", rebuildNote);
1621
+ await this.stopWarmContainer("missing image recovery", true);
1622
+ this.warmedBackends.clear();
1623
+ if (await this.pullImage()) {
1624
+ const retryNote = `[DockerExecutor] Warm runtime image ${this.options.imageName} is available again; retrying warm container startup.`;
1625
+ console.log(retryNote);
1626
+ onLog?.("stdout", retryNote);
1627
+ continue;
1628
+ }
1629
+ }
1609
1630
  const retryable = this.isRetryableError(err);
1610
1631
  if (attempt >= this.warmSetupMaxAttempts || !retryable) {
1611
1632
  if (
@@ -1631,6 +1652,7 @@ export class DockerExecutor {
1631
1652
  onLog?.("stderr", note);
1632
1653
  await this.stopWarmContainer("warm setup retry", true);
1633
1654
  await this.sleep(retryInMs);
1655
+ attempt += 1;
1634
1656
  }
1635
1657
  }
1636
1658
  }
@@ -1727,6 +1749,10 @@ export class DockerExecutor {
1727
1749
  return this.matchesRetryablePattern(text);
1728
1750
  }
1729
1751
 
1752
+ private isMissingDockerImageError(err: unknown): boolean {
1753
+ return isMissingDockerImageDetail(this.compactError(err));
1754
+ }
1755
+
1730
1756
  private isRetryableJobFailure(result: DockerJobResult): boolean {
1731
1757
  const text = `${result.summary ?? ""}\n${result.stderr ?? ""}`.toLowerCase();
1732
1758
  return this.matchesRetryablePattern(text);
@@ -635,6 +635,7 @@ export function tokenizeValidationCommandArgv(command: string): string[] | null
635
635
  const out: string[] = [];
636
636
  let current = "";
637
637
  let quote: "'" | '"' | null = null;
638
+ let escaped = false;
638
639
 
639
640
  const pushCurrent = () => {
640
641
  if (!current) return;
@@ -643,7 +644,16 @@ export function tokenizeValidationCommandArgv(command: string): string[] | null
643
644
  };
644
645
 
645
646
  for (const ch of trimmed) {
647
+ if (escaped) {
648
+ current += ch;
649
+ escaped = false;
650
+ continue;
651
+ }
646
652
  if (quote) {
653
+ if (quote === '"' && ch === "\\") {
654
+ escaped = true;
655
+ continue;
656
+ }
647
657
  if (ch === quote) {
648
658
  quote = null;
649
659
  } else {
@@ -662,6 +672,7 @@ export function tokenizeValidationCommandArgv(command: string): string[] | null
662
672
  }
663
673
  current += ch;
664
674
  }
675
+ if (escaped) current += "\\";
665
676
  if (quote) return null;
666
677
  pushCurrent();
667
678
  if (out.length === 0) return null;
@@ -2289,14 +2300,19 @@ export function inferFallbackValidationCommandsForTestTask(
2289
2300
  /\b(pytest|python)\b/.test(lowerInstruction) ||
2290
2301
  changedTestPaths.some((entry) => entry.toLowerCase().endsWith(".py"));
2291
2302
 
2303
+ const bunTestPath = (path: string) => formatBunTestPathArg(path);
2292
2304
  const normalizedTarget = (targetPath ?? "").replace(/\\/g, "/").trim();
2293
2305
  if (normalizedTarget && isLikelyTestPath(normalizedTarget)) {
2294
- add(pythonSignal ? `pytest ${normalizedTarget}` : `bun test ${normalizedTarget}`);
2306
+ add(pythonSignal ? `pytest ${normalizedTarget}` : `bun test ${bunTestPath(normalizedTarget)}`);
2295
2307
  }
2296
2308
 
2297
2309
  if (changedTestPaths.length > 0) {
2298
- const focused = changedTestPaths.slice(0, 4).join(" ");
2299
- add(pythonSignal ? `pytest ${focused}` : `bun test ${focused}`);
2310
+ const focused = changedTestPaths.slice(0, 4);
2311
+ add(
2312
+ pythonSignal
2313
+ ? `pytest ${focused.join(" ")}`
2314
+ : `bun test ${focused.map((entry) => bunTestPath(entry)).join(" ")}`,
2315
+ );
2300
2316
  }
2301
2317
 
2302
2318
  const scopeHints = [
@@ -2324,6 +2340,24 @@ export function inferFallbackValidationCommandsForTestTask(
2324
2340
  return candidates.slice(0, 4);
2325
2341
  }
2326
2342
 
2343
+ export function formatBunTestPathArg(path: string): string {
2344
+ const normalized = String(path ?? "").replace(/\\/g, "/").trim();
2345
+ if (!normalized) return normalized;
2346
+ const pathArg =
2347
+ normalized.startsWith("./") ||
2348
+ normalized.startsWith("../") ||
2349
+ normalized.startsWith("/") ||
2350
+ /^[A-Za-z]:\//.test(normalized)
2351
+ ? normalized
2352
+ : `./${normalized}`;
2353
+ return quoteValidationCommandArg(pathArg);
2354
+ }
2355
+
2356
+ function quoteValidationCommandArg(arg: string): string {
2357
+ if (!/[\s"\\]/.test(arg)) return arg;
2358
+ return `"${arg.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
2359
+ }
2360
+
2327
2361
  export function isTestFocusedTask(
2328
2362
  instruction: string,
2329
2363
  planning: TaskExecutePlanning,
@@ -125,11 +125,31 @@ function isTestPath(path: string): boolean {
125
125
  return /(^tests\/|__tests__\/|\.test\.[cm]?[jt]sx?$|\.spec\.[cm]?[jt]sx?$)/i.test(path);
126
126
  }
127
127
 
128
+ function formatBunTestPathArg(path: string): string {
129
+ const normalized = String(path ?? "").replace(/\\/g, "/").trim();
130
+ if (!normalized) return normalized;
131
+ const pathArg =
132
+ normalized.startsWith("./") ||
133
+ normalized.startsWith("../") ||
134
+ normalized.startsWith("/") ||
135
+ /^[A-Za-z]:\//.test(normalized)
136
+ ? normalized
137
+ : `./${normalized}`;
138
+ return quoteValidationCommandArg(pathArg);
139
+ }
140
+
141
+ function quoteValidationCommandArg(arg: string): string {
142
+ if (!/[\s"\\]/.test(arg)) return arg;
143
+ return `"${arg.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
144
+ }
145
+
128
146
  function deriveValidationSteps(existing: unknown, conflictPaths: string[]): string[] {
129
147
  const preserved = Array.isArray(existing)
130
148
  ? existing.map((entry) => String(entry ?? "").trim()).filter(Boolean)
131
149
  : [];
132
- const targeted = conflictPaths.filter(isTestPath).map((entry) => `bun test ${entry}`);
150
+ const targeted = conflictPaths
151
+ .filter(isTestPath)
152
+ .map((entry) => `bun test ${formatBunTestPathArg(entry)}`);
133
153
  const merged = dedupeStrings([...targeted, ...preserved], 8);
134
154
  return merged.length > 0 ? merged : ["bun test"];
135
155
  }
@@ -987,7 +987,11 @@ function failNoChangeReviewFixJob(jobId: string, result: WorkerJobResult): Worke
987
987
  ok: false,
988
988
  summary:
989
989
  `Rejected review-fix job ${jobId} produced no code changes; refusing unchanged branch re-review.`,
990
- stderr: [result.stderr, "Apply at least one concrete fix before requesting another review."]
990
+ stderr: [
991
+ result.stderr,
992
+ "Review-fix jobs must make at least one concrete code/test/docs change before requesting another review.",
993
+ "If the reviewer feedback is invalid, commit a narrow explanatory change that documents the decision; unchanged branch re-review is refused.",
994
+ ]
991
995
  .filter(Boolean)
992
996
  .join("\n"),
993
997
  exitCode: typeof result.exitCode === "number" ? result.exitCode : 4,