@pushpalsdev/cli 1.1.34 → 1.1.35

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.34",
3
+ "version": "1.1.35",
4
4
  "description": "PushPals terminal CLI for LocalBuddy -> RemoteBuddy orchestration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -116,6 +116,8 @@ _WEB_REVIEW_NO_EDIT_WATCHDOG_S = 240
116
116
  _BACKGROUND_NO_EDIT_WATCHDOG_S = 120
117
117
  _NO_EDIT_RECOVERY_WATCHDOG_S = 90
118
118
  _DEFAULT_NO_EDIT_RECHECK_S = 120
119
+ _DEFAULT_STARTUP_STALL_WATCHDOG_S = 210
120
+ _RECOVERY_STARTUP_STALL_WATCHDOG_S = 150
119
121
  _DEFAULT_ROLLOUT_WATCHDOG_S = 300
120
122
  _SMALL_TASK_ROLLOUT_WATCHDOG_S = 240
121
123
  _NARROW_TEST_TASK_ROLLOUT_WATCHDOG_S = 150
@@ -755,6 +757,44 @@ def _resolve_no_edit_recheck_seconds(communicate_timeout_s: Optional[int]) -> in
755
757
  return max(1, min(_DEFAULT_NO_EDIT_RECHECK_S, upper))
756
758
 
757
759
 
760
+ def _resolve_startup_stall_watchdog_seconds(
761
+ communicate_timeout_s: Optional[int],
762
+ recovery_attempt: int = 0,
763
+ ) -> Optional[int]:
764
+ if not communicate_timeout_s:
765
+ return None
766
+
767
+ raw = os.environ.get("WORKERPALS_OPENAI_CODEX_STARTUP_STALL_WATCHDOG_S", "").strip()
768
+ if raw:
769
+ if raw == "0":
770
+ return None
771
+ parsed = _to_positive_int(raw)
772
+ if parsed is None:
773
+ log.info(
774
+ "Invalid WORKERPALS_OPENAI_CODEX_STARTUP_STALL_WATCHDOG_S="
775
+ f"{raw!r}; using default startup-stall watchdog."
776
+ )
777
+ else:
778
+ return max(1, min(parsed, max(1, communicate_timeout_s - 1)))
779
+
780
+ default_s = (
781
+ _RECOVERY_STARTUP_STALL_WATCHDOG_S
782
+ if recovery_attempt > 0
783
+ else _DEFAULT_STARTUP_STALL_WATCHDOG_S
784
+ )
785
+ floor_s = 60
786
+ return max(floor_s, min(default_s, max(floor_s, communicate_timeout_s - 1)))
787
+
788
+
789
+ def _startup_stall_recovery_model(current_model: str) -> str:
790
+ normalized = str(current_model or "").strip()
791
+ if not normalized:
792
+ return LEGACY_CODEX_MODEL_FALLBACK
793
+ if normalized.lower() == LEGACY_CODEX_MODEL_FALLBACK.lower():
794
+ return normalized
795
+ return LEGACY_CODEX_MODEL_FALLBACK
796
+
797
+
758
798
  def _looks_like_web_review_prompt(prompt: str) -> bool:
759
799
  text = str(prompt or "").lower()
760
800
  return "repo-native web review" in text or "web review path" in text
@@ -2337,6 +2377,15 @@ def _run_codex_task(
2337
2377
  else None
2338
2378
  )
2339
2379
  no_edit_recheck_s = _resolve_no_edit_recheck_seconds(communicate_timeout_s)
2380
+ startup_stall_watchdog_s = _resolve_startup_stall_watchdog_seconds(
2381
+ communicate_timeout_s,
2382
+ recovery_attempt=startup_stall_recovery_attempt,
2383
+ )
2384
+ startup_stall_deadline = (
2385
+ started_at + float(startup_stall_watchdog_s)
2386
+ if startup_stall_watchdog_s is not None
2387
+ else None
2388
+ )
2340
2389
  rollout_watchdog_s = (
2341
2390
  _resolve_rollout_watchdog_seconds(
2342
2391
  prompt,
@@ -2364,9 +2413,50 @@ def _run_codex_task(
2364
2413
  _terminate_active_child()
2365
2414
  break
2366
2415
 
2416
+ if startup_stall_deadline is not None and now >= startup_stall_deadline:
2417
+ with trace_lock:
2418
+ live_trace = dict(stdout_trace_state)
2419
+ summaries = stdout_trace_state.get("summaries")
2420
+ if isinstance(summaries, list):
2421
+ live_trace["summaries"] = list(summaries)
2422
+ if _codex_trace_is_startup_stall(live_trace):
2423
+ changed_paths, _, effective_paths = _codex_changed_paths(repo, baseline_snapshot)
2424
+ if not effective_paths:
2425
+ no_edit_artifact_only_paths = _describe_non_publishable_paths(
2426
+ changed_paths,
2427
+ baseline_snapshot,
2428
+ )
2429
+ no_edit_watchdog_fired = True
2430
+ elapsed_s = int(max(0.0, now - started_at))
2431
+ log.info(
2432
+ f"Startup-stall watchdog fired after {elapsed_s}s with no assistant/tool progress."
2433
+ )
2434
+ _terminate_active_child()
2435
+ break
2436
+ startup_stall_deadline = None
2437
+
2367
2438
  if no_edit_deadline is not None and now >= no_edit_deadline:
2368
2439
  changed_paths, _, effective_paths = _codex_changed_paths(repo, baseline_snapshot)
2369
2440
  if not effective_paths:
2441
+ with trace_lock:
2442
+ live_trace = dict(stdout_trace_state)
2443
+ summaries = stdout_trace_state.get("summaries")
2444
+ if isinstance(summaries, list):
2445
+ live_trace["summaries"] = list(summaries)
2446
+ startup_only = _codex_trace_is_startup_stall(live_trace)
2447
+ if (
2448
+ startup_only
2449
+ and startup_stall_deadline is not None
2450
+ and now < startup_stall_deadline
2451
+ ):
2452
+ no_edit_deadline = startup_stall_deadline
2453
+ remaining_s = int(max(1.0, startup_stall_deadline - now))
2454
+ log.info(
2455
+ "No-edit watchdog observed only Codex startup events; "
2456
+ f"allowing {remaining_s}s for first assistant/tool progress "
2457
+ "before startup-stall recovery."
2458
+ )
2459
+ continue
2370
2460
  no_edit_artifact_only_paths = _describe_non_publishable_paths(
2371
2461
  changed_paths,
2372
2462
  baseline_snapshot,
@@ -2377,9 +2467,15 @@ def _run_codex_task(
2377
2467
  if no_edit_artifact_only_paths
2378
2468
  else ""
2379
2469
  )
2380
- log.info(
2381
- f"No-edit watchdog fired after {int(no_edit_watchdog_s or 0)}s with no publishable file changes.{artifact_detail} Retrying with patch-first guidance."
2382
- )
2470
+ if startup_only:
2471
+ elapsed_s = int(max(0.0, now - started_at))
2472
+ log.info(
2473
+ f"Startup-stall watchdog fired after {elapsed_s}s with no assistant/tool progress."
2474
+ )
2475
+ else:
2476
+ log.info(
2477
+ f"No-edit watchdog fired after {int(no_edit_watchdog_s or 0)}s with no publishable file changes.{artifact_detail} Retrying with patch-first guidance."
2478
+ )
2383
2479
  _terminate_active_child()
2384
2480
  break
2385
2481
  no_edit_deadline = now + float(no_edit_recheck_s)
@@ -2550,9 +2646,15 @@ def _run_codex_task(
2550
2646
  *supplemental_guidance,
2551
2647
  _build_startup_stall_recovery_guidance(trace_excerpt),
2552
2648
  ]
2649
+ recovery_model = _startup_stall_recovery_model(model)
2650
+ recovery_detail = (
2651
+ f" using fallback model {recovery_model!r}"
2652
+ if recovery_model and recovery_model != model
2653
+ else ""
2654
+ )
2553
2655
  log.warning(
2554
2656
  "Codex emitted only startup events before the no-edit watchdog; "
2555
- "restarting Codex once before classifying the job terminally."
2657
+ f"restarting Codex once{recovery_detail} before classifying the job terminally."
2556
2658
  )
2557
2659
  retry_result = _run_codex_task(
2558
2660
  repo,
@@ -2563,7 +2665,7 @@ def _run_codex_task(
2563
2665
  startup_stall_recovery_attempt=startup_stall_recovery_attempt + 1,
2564
2666
  no_edit_recovery_attempt=no_edit_recovery_attempt,
2565
2667
  rollout_recovery_attempt=rollout_recovery_attempt,
2566
- model_override=model_override,
2668
+ model_override=recovery_model or model_override,
2567
2669
  baseline_changes=baseline_snapshot,
2568
2670
  )
2569
2671
  retry_result["usage"] = _merge_usage_records(usage, retry_result.get("usage"))
@@ -49,6 +49,7 @@ from openai_codex_executor import (
49
49
  _resolve_codex_command_prefix,
50
50
  _resolve_no_edit_watchdog_seconds,
51
51
  _resolve_rollout_watchdog_seconds,
52
+ _resolve_startup_stall_watchdog_seconds,
52
53
  _unwrap_shell_wrapper_command,
53
54
  _usage_from_trace_or_estimate,
54
55
  )
@@ -372,6 +373,63 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
372
373
  self.assertEqual(task.repo, str(repo.resolve()))
373
374
  self.assertEqual(task.instruction, "Make one small publishable change")
374
375
 
376
+ def test_parse_payload_accepts_positional_payload_file_path(self) -> None:
377
+ with tempfile.TemporaryDirectory(prefix="pushpals-payload-file-positional-") as temp_dir:
378
+ repo = Path(temp_dir) / "repo"
379
+ repo.mkdir(parents=True, exist_ok=True)
380
+ payload = {
381
+ "kind": "task.execute",
382
+ "repo": str(repo),
383
+ "params": {"instruction": "Recover from a direct-worker payload handoff"},
384
+ }
385
+ encoded = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii")
386
+ payload_file = Path(temp_dir) / "payload.b64"
387
+ payload_file.write_text(encoded, encoding="utf-8")
388
+
389
+ task = parse_task_execute_payload(
390
+ ["executor", str(payload_file)],
391
+ logger=Logger("[test]"),
392
+ )
393
+
394
+ self.assertEqual(task.kind, "task.execute")
395
+ self.assertEqual(task.repo, str(repo.resolve()))
396
+ self.assertEqual(task.instruction, "Recover from a direct-worker payload handoff")
397
+
398
+ def test_parse_payload_accepts_unpadded_base64_payload(self) -> None:
399
+ with tempfile.TemporaryDirectory(prefix="pushpals-payload-unpadded-") as temp_dir:
400
+ repo = Path(temp_dir) / "repo"
401
+ repo.mkdir(parents=True, exist_ok=True)
402
+ payload = {
403
+ "kind": "task.execute",
404
+ "repo": str(repo),
405
+ "params": {"instruction": "Accept wrapper-normalized payload padding"},
406
+ }
407
+ encoded = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii")
408
+ unpadded = encoded.rstrip("=")
409
+
410
+ task = parse_task_execute_payload(["executor", unpadded], logger=Logger("[test]"))
411
+
412
+ self.assertEqual(task.kind, "task.execute")
413
+ self.assertEqual(task.repo, str(repo.resolve()))
414
+ self.assertEqual(task.instruction, "Accept wrapper-normalized payload padding")
415
+
416
+ def test_parse_payload_accepts_raw_json_payload(self) -> None:
417
+ with tempfile.TemporaryDirectory(prefix="pushpals-payload-raw-json-") as temp_dir:
418
+ repo = Path(temp_dir) / "repo"
419
+ repo.mkdir(parents=True, exist_ok=True)
420
+ payload = {
421
+ "kind": "task.execute",
422
+ "repo": str(repo),
423
+ "params": {"instruction": "Accept raw JSON from a recovery wrapper"},
424
+ }
425
+ raw_json = json.dumps(payload)
426
+
427
+ task = parse_task_execute_payload(["executor", raw_json], logger=Logger("[test]"))
428
+
429
+ self.assertEqual(task.kind, "task.execute")
430
+ self.assertEqual(task.repo, str(repo.resolve()))
431
+ self.assertEqual(task.instruction, "Accept raw JSON from a recovery wrapper")
432
+
375
433
  def test_parse_payload_prefers_helper_tests_for_visual_derivation_tasks(self) -> None:
376
434
  with tempfile.TemporaryDirectory(prefix="pushpals-visual-guidance-") as temp_dir:
377
435
  repo = Path(temp_dir) / "repo"
@@ -1091,13 +1149,16 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
1091
1149
  "",
1092
1150
  "argv = sys.argv[1:]",
1093
1151
  "last_message_path = None",
1152
+ "model = ''",
1094
1153
  "for index, arg in enumerate(argv):",
1095
1154
  " if arg == '--output-last-message' and index + 1 < len(argv):",
1096
1155
  " last_message_path = argv[index + 1]",
1156
+ " if arg == '-m' and index + 1 < len(argv):",
1157
+ " model = argv[index + 1]",
1097
1158
  " break",
1098
1159
  "",
1099
1160
  "prompt = sys.stdin.read()",
1100
- "if 'Codex startup-stall recovery' in prompt:",
1161
+ "if 'Codex startup-stall recovery' in prompt and model == 'gpt-5.4':",
1101
1162
  " Path('src').mkdir(exist_ok=True)",
1102
1163
  " Path('src/startup-stall-recovered.txt').write_text('patched after restart\\n', encoding='utf-8')",
1103
1164
  " if last_message_path:",
@@ -1119,7 +1180,8 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
1119
1180
  "OPENAI_API_KEY": "pushpals-startup-stall-test-key",
1120
1181
  "WORKERPALS_OPENAI_CODEX_JSON": "true",
1121
1182
  "WORKERPALS_OPENAI_CODEX_TIMEOUT_S": "20",
1122
- "WORKERPALS_OPENAI_CODEX_NO_EDIT_WATCHDOG_S": "1",
1183
+ "WORKERPALS_OPENAI_CODEX_NO_EDIT_WATCHDOG_S": "0",
1184
+ "WORKERPALS_OPENAI_CODEX_STARTUP_STALL_WATCHDOG_S": "1",
1123
1185
  "WORKERPALS_OPENAI_CODEX_PROGRESS_LOG_INTERVAL_S": "1",
1124
1186
  }
1125
1187
  with mock.patch.dict(os.environ, env_overrides, clear=False):
@@ -1189,6 +1251,7 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
1189
1251
  "WORKERPALS_OPENAI_CODEX_JSON": "true",
1190
1252
  "WORKERPALS_OPENAI_CODEX_TIMEOUT_S": "20",
1191
1253
  "WORKERPALS_OPENAI_CODEX_NO_EDIT_WATCHDOG_S": "1",
1254
+ "WORKERPALS_OPENAI_CODEX_STARTUP_STALL_WATCHDOG_S": "1",
1192
1255
  "WORKERPALS_OPENAI_CODEX_PROGRESS_LOG_INTERVAL_S": "1",
1193
1256
  }
1194
1257
  with mock.patch.dict(os.environ, env_overrides, clear=False):
@@ -1587,6 +1650,31 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
1587
1650
 
1588
1651
  self.assertEqual(watchdog_s, 180)
1589
1652
 
1653
+ def test_startup_stall_watchdog_allows_slower_first_response_than_no_edit_watchdog(self) -> None:
1654
+ with mock.patch.dict(
1655
+ os.environ,
1656
+ {"WORKERPALS_OPENAI_CODEX_STARTUP_STALL_WATCHDOG_S": ""},
1657
+ clear=False,
1658
+ ):
1659
+ watchdog_s = _resolve_startup_stall_watchdog_seconds(1200)
1660
+ recovery_watchdog_s = _resolve_startup_stall_watchdog_seconds(
1661
+ 1200,
1662
+ recovery_attempt=1,
1663
+ )
1664
+
1665
+ self.assertEqual(watchdog_s, 210)
1666
+ self.assertEqual(recovery_watchdog_s, 150)
1667
+
1668
+ def test_explicit_startup_stall_watchdog_override_is_bounded(self) -> None:
1669
+ with mock.patch.dict(
1670
+ os.environ,
1671
+ {"WORKERPALS_OPENAI_CODEX_STARTUP_STALL_WATCHDOG_S": "500"},
1672
+ clear=False,
1673
+ ):
1674
+ watchdog_s = _resolve_startup_stall_watchdog_seconds(120)
1675
+
1676
+ self.assertEqual(watchdog_s, 119)
1677
+
1590
1678
  def test_narrow_contract_regression_with_required_e2e_uses_fast_no_edit_watchdog(self) -> None:
1591
1679
  prompt = (
1592
1680
  "Harden the opportunity graph contract around autonomous delivery-loop failure signals. "
@@ -155,14 +155,39 @@ def fail(summary: str, stderr: Optional[str] = None, exit_code: int = 1) -> int:
155
155
  return exit_code
156
156
 
157
157
 
158
- def decode_payload(raw: str) -> Dict[str, Any]:
159
- decoded = base64.b64decode(raw).decode("utf-8")
160
- payload = json.loads(decoded)
158
+ def _parse_payload_json(raw: str) -> Dict[str, Any]:
159
+ payload = json.loads(raw)
161
160
  if not isinstance(payload, dict):
162
161
  raise ValueError("payload must be a JSON object")
163
162
  return payload
164
163
 
165
164
 
165
+ def decode_payload(raw: str) -> Dict[str, Any]:
166
+ stripped = str(raw or "").strip()
167
+ if not stripped:
168
+ raise ValueError("empty job payload")
169
+
170
+ # Direct workers normally receive a file-backed base64 payload, but this
171
+ # parser intentionally accepts the safe adjacent encodings too. That keeps
172
+ # executor startup resilient if an outer wrapper normalizes padding, uses
173
+ # url-safe base64, or hands through raw JSON during recovery.
174
+ if stripped.startswith("{"):
175
+ return _parse_payload_json(stripped)
176
+
177
+ compact = "".join(stripped.split())
178
+ padded = compact + ("=" * ((4 - len(compact) % 4) % 4))
179
+ decode_errors: List[str] = []
180
+ for decoder in (base64.b64decode, base64.urlsafe_b64decode):
181
+ try:
182
+ decoded = decoder(padded).decode("utf-8")
183
+ return _parse_payload_json(decoded)
184
+ except Exception as exc:
185
+ decode_errors.append(str(exc))
186
+
187
+ detail = "; ".join(error for error in decode_errors if error) or "unknown decode error"
188
+ raise ValueError(f"invalid base64/JSON job payload: {detail}")
189
+
190
+
166
191
  def read_encoded_payload_arg(argv: List[str]) -> str:
167
192
  if len(argv) < 2:
168
193
  raise ValueError("missing base64 job payload")
@@ -174,6 +199,13 @@ def read_encoded_payload_arg(argv: List[str]) -> str:
174
199
  return path.read_text(encoding="utf-8").strip()
175
200
  if mode == "--payload-stdin":
176
201
  return sys.stdin.read().strip()
202
+ if len(mode) < 4096:
203
+ try:
204
+ path = Path(mode).expanduser()
205
+ if path.is_file():
206
+ return path.read_text(encoding="utf-8").strip()
207
+ except OSError:
208
+ pass
177
209
  return mode
178
210
 
179
211
 
@@ -1918,8 +1918,6 @@ export class DockerExecutor {
1918
1918
 
1919
1919
  private matchesRetryablePattern(text: string): boolean {
1920
1920
  const transientPatterns: RegExp[] = [
1921
- /\bstalled before first response\b/i,
1922
- /\bstartup stall\b/i,
1923
1921
  /warm .*runtime/i,
1924
1922
  /failed to start warm container/i,
1925
1923
  /docker execution error/i,