@pushpalsdev/cli 1.1.22 → 1.1.23

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.22",
3
+ "version": "1.1.23",
4
4
  "description": "PushPals terminal CLI for LocalBuddy -> RemoteBuddy orchestration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -8,6 +8,7 @@ that the TypeScript host parses.
8
8
  from __future__ import annotations
9
9
 
10
10
  import json
11
+ import hashlib
11
12
  import os
12
13
  import re
13
14
  from shutil import rmtree, which
@@ -713,9 +714,39 @@ def _resolve_rollout_watchdog_seconds(
713
714
  return max(90, min(default_s, max(90, communicate_timeout_s - 60)))
714
715
 
715
716
 
716
- def _describe_non_publishable_paths(changed_paths: List[str], baseline_snapshot: List[str]) -> str:
717
- delta = [p for p in changed_paths if p not in baseline_snapshot]
718
- inspected = delta if delta else changed_paths
717
+ def _baseline_snapshot_paths(baseline_snapshot: Any) -> List[str]:
718
+ if isinstance(baseline_snapshot, dict):
719
+ return [str(path) for path in baseline_snapshot.keys()]
720
+ if isinstance(baseline_snapshot, list):
721
+ return [str(path) for path in baseline_snapshot]
722
+ return []
723
+
724
+
725
+ def _paths_changed_after_baseline(
726
+ repo: str,
727
+ changed_paths: List[str],
728
+ baseline_snapshot: Any,
729
+ ) -> List[str]:
730
+ baseline_paths = set(_baseline_snapshot_paths(baseline_snapshot))
731
+ if not baseline_paths:
732
+ return list(changed_paths)
733
+
734
+ delta: List[str] = []
735
+ baseline_fingerprints = baseline_snapshot if isinstance(baseline_snapshot, dict) else {}
736
+ for path in changed_paths:
737
+ if path not in baseline_paths:
738
+ delta.append(path)
739
+ continue
740
+ if baseline_fingerprints:
741
+ current_fingerprint = _changed_path_fingerprint(repo, path)
742
+ if current_fingerprint != str(baseline_fingerprints.get(path) or ""):
743
+ delta.append(path)
744
+ return delta
745
+
746
+
747
+ def _describe_non_publishable_paths(changed_paths: List[str], baseline_snapshot: Any) -> str:
748
+ baseline_paths = set(_baseline_snapshot_paths(baseline_snapshot))
749
+ inspected = [p for p in changed_paths if p not in baseline_paths] if baseline_paths else changed_paths
719
750
  non_publishable = [p for p in inspected if not _is_publishable_changed_path(p)]
720
751
  if not non_publishable:
721
752
  return ""
@@ -1686,10 +1717,99 @@ def _is_publishable_changed_path(path: str) -> bool:
1686
1717
  return not re.search(r"(^|/)(outputs|node_modules|\.worktrees|\.codex|dist|build|coverage)(/|$)", normalized)
1687
1718
 
1688
1719
 
1689
- def _codex_changed_paths(repo: str, baseline_snapshot: List[str]) -> Tuple[List[str], List[str], List[str]]:
1720
+ def _filesystem_fingerprint(repo: str, raw_path: str) -> str:
1721
+ root = Path(repo)
1722
+ target = (root / raw_path).resolve()
1723
+ try:
1724
+ root_resolved = root.resolve()
1725
+ common = os.path.commonpath([str(root_resolved), str(target)])
1726
+ if common != str(root_resolved):
1727
+ return "outside-repo"
1728
+ except Exception:
1729
+ return "unresolved"
1730
+ digest = hashlib.sha256()
1731
+ if not target.exists():
1732
+ return "missing"
1733
+ if target.is_file():
1734
+ digest.update(b"file\0")
1735
+ try:
1736
+ digest.update(str(target.stat().st_size).encode("utf-8"))
1737
+ with target.open("rb") as handle:
1738
+ while True:
1739
+ chunk = handle.read(1024 * 1024)
1740
+ if not chunk:
1741
+ break
1742
+ digest.update(chunk)
1743
+ except Exception as exc:
1744
+ digest.update(f"read-error:{type(exc).__name__}:{exc}".encode("utf-8", errors="replace"))
1745
+ return digest.hexdigest()
1746
+ if target.is_dir():
1747
+ digest.update(b"dir\0")
1748
+ files_seen = 0
1749
+ try:
1750
+ for dirpath, dirnames, filenames in os.walk(target):
1751
+ dirnames.sort()
1752
+ filenames.sort()
1753
+ for filename in filenames:
1754
+ if files_seen >= 128:
1755
+ digest.update(b"\0truncated")
1756
+ return digest.hexdigest()
1757
+ child = Path(dirpath) / filename
1758
+ try:
1759
+ rel = child.relative_to(root_resolved).as_posix()
1760
+ except Exception:
1761
+ rel = child.name
1762
+ digest.update(rel.encode("utf-8", errors="replace"))
1763
+ digest.update(b"\0")
1764
+ digest.update(str(child.stat().st_size).encode("utf-8"))
1765
+ digest.update(b"\0")
1766
+ try:
1767
+ with child.open("rb") as handle:
1768
+ digest.update(handle.read(64 * 1024))
1769
+ except Exception as exc:
1770
+ digest.update(f"read-error:{type(exc).__name__}:{exc}".encode("utf-8", errors="replace"))
1771
+ files_seen += 1
1772
+ except Exception as exc:
1773
+ digest.update(f"walk-error:{type(exc).__name__}:{exc}".encode("utf-8", errors="replace"))
1774
+ return digest.hexdigest()
1775
+ return "special"
1776
+
1777
+
1778
+ def _changed_path_fingerprint(repo: str, path: str) -> str:
1779
+ normalized = str(path or "").strip()
1780
+ if not normalized:
1781
+ return ""
1782
+ digest = hashlib.sha256()
1783
+ digest.update(normalized.replace("\\", "/").encode("utf-8", errors="replace"))
1784
+ digest.update(b"\0fs\0")
1785
+ digest.update(_filesystem_fingerprint(repo, normalized).encode("utf-8", errors="replace"))
1786
+ return digest.hexdigest()
1787
+
1788
+
1789
+ def _capture_git_change_snapshot(repo: str) -> Dict[str, str]:
1790
+ return {path: _changed_path_fingerprint(repo, path) for path in summarize_git_changes(repo)}
1791
+
1792
+
1793
+ def _normalize_baseline_snapshot(repo: str, baseline_changes: Any) -> Dict[str, str]:
1794
+ if isinstance(baseline_changes, dict):
1795
+ return {
1796
+ str(path): str(fingerprint)
1797
+ for path, fingerprint in baseline_changes.items()
1798
+ if str(path or "").strip()
1799
+ }
1800
+ if isinstance(baseline_changes, list):
1801
+ return {
1802
+ str(path): _changed_path_fingerprint(repo, str(path))
1803
+ for path in baseline_changes
1804
+ if str(path or "").strip()
1805
+ }
1806
+ return _capture_git_change_snapshot(repo)
1807
+
1808
+
1809
+ def _codex_changed_paths(repo: str, baseline_snapshot: Any) -> Tuple[List[str], List[str], List[str]]:
1690
1810
  changed_paths = summarize_git_changes(repo)
1691
- delta = [p for p in changed_paths if p not in baseline_snapshot]
1692
- effective = [p for p in (delta if delta else changed_paths) if _is_publishable_changed_path(p)]
1811
+ delta = _paths_changed_after_baseline(repo, changed_paths, baseline_snapshot)
1812
+ effective = [p for p in delta if _is_publishable_changed_path(p)]
1693
1813
  return changed_paths, delta, effective
1694
1814
 
1695
1815
 
@@ -1851,7 +1971,7 @@ def _run_codex_task(
1851
1971
  prompt,
1852
1972
  model,
1853
1973
  )
1854
- baseline_snapshot = list(baseline_changes) if baseline_changes is not None else summarize_git_changes(repo)
1974
+ baseline_snapshot = _normalize_baseline_snapshot(repo, baseline_changes)
1855
1975
 
1856
1976
  with tempfile.TemporaryDirectory(prefix="pushpals-codex-") as tmp_dir:
1857
1977
  last_message_path = Path(tmp_dir) / "codex-last-message.txt"
@@ -36,6 +36,7 @@ from openai_codex_executor import (
36
36
  _build_rollout_recovery_guidance,
37
37
  _collect_disallowed_shell_wrapper_rejections,
38
38
  _codex_changed_paths,
39
+ _capture_git_change_snapshot,
39
40
  _describe_non_publishable_paths,
40
41
  _detect_offtrack_rollout,
41
42
  _detect_codex_workaround_signal,
@@ -944,6 +945,75 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
944
945
  self.assertIn("too broad/noisy", str(result.get("stderr") or ""))
945
946
  self.assertIn("area0", str(result.get("stderr") or ""))
946
947
 
948
+ def test_run_codex_task_timeout_ignores_broad_dirty_baseline(self) -> None:
949
+ with tempfile.TemporaryDirectory(prefix="pushpals-codex-timeout-dirty-baseline-") as temp_dir:
950
+ repo = Path(temp_dir) / "repo"
951
+ repo.mkdir(parents=True, exist_ok=True)
952
+ (repo / "README.md").write_text("# timeout dirty baseline repo\n", encoding="utf-8")
953
+ subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True, text=True)
954
+ subprocess.run(
955
+ ["git", "config", "user.name", "PushPals Test"],
956
+ cwd=repo,
957
+ check=True,
958
+ capture_output=True,
959
+ text=True,
960
+ )
961
+ subprocess.run(
962
+ ["git", "config", "user.email", "pushpals-tests@example.com"],
963
+ cwd=repo,
964
+ check=True,
965
+ capture_output=True,
966
+ text=True,
967
+ )
968
+ subprocess.run(["git", "add", "README.md"], cwd=repo, check=True, capture_output=True, text=True)
969
+ subprocess.run(
970
+ ["git", "commit", "-m", "chore: seed timeout dirty baseline repo"],
971
+ cwd=repo,
972
+ check=True,
973
+ capture_output=True,
974
+ text=True,
975
+ )
976
+ for index in range(5):
977
+ root = repo / f"area{index}"
978
+ root.mkdir(exist_ok=True)
979
+ (root / "changed.txt").write_text("pre-existing dirty change\n", encoding="utf-8")
980
+
981
+ stub_path = Path(temp_dir) / "fake_codex_timeout_dirty_baseline.py"
982
+ stub_path.write_text(
983
+ "\n".join(
984
+ [
985
+ "import sys",
986
+ "import time",
987
+ "",
988
+ "sys.stdin.read()",
989
+ "print('item.completed | Still thinking without changing baseline files.', flush=True)",
990
+ "time.sleep(5)",
991
+ ]
992
+ ),
993
+ encoding="utf-8",
994
+ )
995
+
996
+ env_overrides = {
997
+ "PUSHPALS_OPENAI_CODEX_BIN_JSON": json.dumps([sys.executable, str(stub_path)]),
998
+ "PUSHPALS_OPENAI_CODEX_AUTH_MODE": "api_key",
999
+ "OPENAI_API_KEY": "pushpals-timeout-dirty-baseline-test-key",
1000
+ "WORKERPALS_OPENAI_CODEX_TIMEOUT_S": "1",
1001
+ "WORKERPALS_OPENAI_CODEX_NO_EDIT_WATCHDOG_S": "0",
1002
+ "WORKERPALS_OPENAI_CODEX_PROGRESS_LOG_INTERVAL_S": "1",
1003
+ }
1004
+ with mock.patch.dict(os.environ, env_overrides, clear=False):
1005
+ result = _run_codex_task(
1006
+ str(repo),
1007
+ "Make a compact scoped patch, then continue thinking too long.",
1008
+ [],
1009
+ )
1010
+
1011
+ self.assertFalse(result.get("ok"), result)
1012
+ self.assertEqual(result.get("exitCode"), 124)
1013
+ self.assertIn("execution timed out", str(result.get("summary") or ""))
1014
+ self.assertNotIn("broad/noisy", str(result.get("summary") or ""))
1015
+ self.assertNotIn("too broad/noisy", str(result.get("stderr") or ""))
1016
+
947
1017
  def test_run_codex_task_retries_once_when_no_edit_watchdog_fires(self) -> None:
948
1018
  with tempfile.TemporaryDirectory(prefix="pushpals-codex-no-edit-watchdog-") as temp_dir:
949
1019
  repo = Path(temp_dir) / "repo"
@@ -1215,6 +1285,86 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
1215
1285
  self.assertGreaterEqual(len(delta), 2)
1216
1286
  self.assertEqual(effective, [])
1217
1287
 
1288
+ def test_codex_changed_paths_ignores_publishable_paths_dirty_at_baseline(self) -> None:
1289
+ with tempfile.TemporaryDirectory(prefix="pushpals-codex-dirty-baseline-") as temp_dir:
1290
+ repo = Path(temp_dir) / "repo"
1291
+ repo.mkdir(parents=True, exist_ok=True)
1292
+ (repo / "README.md").write_text("# dirty baseline repo\n", encoding="utf-8")
1293
+ (repo / "src").mkdir()
1294
+ (repo / "src" / "existing.ts").write_text("export const value = 1;\n", encoding="utf-8")
1295
+ subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True, text=True)
1296
+ subprocess.run(
1297
+ ["git", "config", "user.name", "PushPals Test"],
1298
+ cwd=repo,
1299
+ check=True,
1300
+ capture_output=True,
1301
+ text=True,
1302
+ )
1303
+ subprocess.run(
1304
+ ["git", "config", "user.email", "pushpals-tests@example.com"],
1305
+ cwd=repo,
1306
+ check=True,
1307
+ capture_output=True,
1308
+ text=True,
1309
+ )
1310
+ subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True, text=True)
1311
+ subprocess.run(
1312
+ ["git", "commit", "-m", "chore: seed dirty baseline repo"],
1313
+ cwd=repo,
1314
+ check=True,
1315
+ capture_output=True,
1316
+ text=True,
1317
+ )
1318
+ (repo / "README.md").write_text("# dirty baseline repo\n\npre-existing edit\n", encoding="utf-8")
1319
+ (repo / "src" / "existing.ts").write_text("export const value = 2;\n", encoding="utf-8")
1320
+ baseline = _capture_git_change_snapshot(str(repo))
1321
+
1322
+ changed_paths, delta, effective = _codex_changed_paths(str(repo), baseline)
1323
+
1324
+ self.assertIn("README.md", changed_paths)
1325
+ self.assertEqual(delta, [])
1326
+ self.assertEqual(effective, [])
1327
+
1328
+ def test_codex_changed_paths_counts_worker_edits_to_dirty_baseline_paths(self) -> None:
1329
+ with tempfile.TemporaryDirectory(prefix="pushpals-codex-dirty-baseline-mutated-") as temp_dir:
1330
+ repo = Path(temp_dir) / "repo"
1331
+ repo.mkdir(parents=True, exist_ok=True)
1332
+ (repo / "README.md").write_text("# dirty baseline mutation repo\n", encoding="utf-8")
1333
+ subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True, text=True)
1334
+ subprocess.run(
1335
+ ["git", "config", "user.name", "PushPals Test"],
1336
+ cwd=repo,
1337
+ check=True,
1338
+ capture_output=True,
1339
+ text=True,
1340
+ )
1341
+ subprocess.run(
1342
+ ["git", "config", "user.email", "pushpals-tests@example.com"],
1343
+ cwd=repo,
1344
+ check=True,
1345
+ capture_output=True,
1346
+ text=True,
1347
+ )
1348
+ subprocess.run(["git", "add", "README.md"], cwd=repo, check=True, capture_output=True, text=True)
1349
+ subprocess.run(
1350
+ ["git", "commit", "-m", "chore: seed dirty baseline mutation repo"],
1351
+ cwd=repo,
1352
+ check=True,
1353
+ capture_output=True,
1354
+ text=True,
1355
+ )
1356
+ (repo / "README.md").write_text("# dirty baseline mutation repo\n\npre-existing edit\n", encoding="utf-8")
1357
+ baseline = _capture_git_change_snapshot(str(repo))
1358
+ (repo / "README.md").write_text(
1359
+ "# dirty baseline mutation repo\n\npre-existing edit\nworker edit\n",
1360
+ encoding="utf-8",
1361
+ )
1362
+
1363
+ _, delta, effective = _codex_changed_paths(str(repo), baseline)
1364
+
1365
+ self.assertEqual(delta, ["README.md"])
1366
+ self.assertEqual(effective, ["README.md"])
1367
+
1218
1368
  def test_non_publishable_path_summary_names_artifact_only_dirty_paths(self) -> None:
1219
1369
  changed_paths = [
1220
1370
  "node_modules/react/index.js",