@pushpalsdev/cli 1.1.32 → 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 +1 -1
- package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/openai_codex_executor.py +107 -5
- package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/test_openai_codex_runtime_config.py +112 -2
- package/runtime/sandbox/apps/workerpals/src/backends/openhands_task_execute.ts +8 -1
- package/runtime/sandbox/apps/workerpals/src/backends/shared/executor_base.py +50 -7
- package/runtime/sandbox/apps/workerpals/src/common/generic_python_executor.ts +14 -6
- package/runtime/sandbox/apps/workerpals/src/common/python_payload_transport.ts +26 -0
- package/runtime/sandbox/apps/workerpals/src/docker_executor.ts +0 -2
package/package.json
CHANGED
|
@@ -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
|
-
|
|
2381
|
-
|
|
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
|
)
|
|
@@ -350,6 +351,85 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
|
|
|
350
351
|
self.assertIn("Home shell startup is assertable", guidance)
|
|
351
352
|
self.assertIn("bun run web:e2e", guidance)
|
|
352
353
|
|
|
354
|
+
def test_parse_payload_accepts_file_backed_payload_transport(self) -> None:
|
|
355
|
+
with tempfile.TemporaryDirectory(prefix="pushpals-payload-file-") as temp_dir:
|
|
356
|
+
repo = Path(temp_dir) / "repo"
|
|
357
|
+
repo.mkdir(parents=True, exist_ok=True)
|
|
358
|
+
payload = {
|
|
359
|
+
"kind": "task.execute",
|
|
360
|
+
"repo": str(repo),
|
|
361
|
+
"params": {"instruction": "Make one small publishable change"},
|
|
362
|
+
}
|
|
363
|
+
encoded = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii")
|
|
364
|
+
payload_file = Path(temp_dir) / "payload.b64"
|
|
365
|
+
payload_file.write_text(encoded, encoding="utf-8")
|
|
366
|
+
|
|
367
|
+
task = parse_task_execute_payload(
|
|
368
|
+
["executor", "--payload-file", str(payload_file)],
|
|
369
|
+
logger=Logger("[test]"),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
self.assertEqual(task.kind, "task.execute")
|
|
373
|
+
self.assertEqual(task.repo, str(repo.resolve()))
|
|
374
|
+
self.assertEqual(task.instruction, "Make one small publishable change")
|
|
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
|
+
|
|
353
433
|
def test_parse_payload_prefers_helper_tests_for_visual_derivation_tasks(self) -> None:
|
|
354
434
|
with tempfile.TemporaryDirectory(prefix="pushpals-visual-guidance-") as temp_dir:
|
|
355
435
|
repo = Path(temp_dir) / "repo"
|
|
@@ -1069,13 +1149,16 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
|
|
|
1069
1149
|
"",
|
|
1070
1150
|
"argv = sys.argv[1:]",
|
|
1071
1151
|
"last_message_path = None",
|
|
1152
|
+
"model = ''",
|
|
1072
1153
|
"for index, arg in enumerate(argv):",
|
|
1073
1154
|
" if arg == '--output-last-message' and index + 1 < len(argv):",
|
|
1074
1155
|
" last_message_path = argv[index + 1]",
|
|
1156
|
+
" if arg == '-m' and index + 1 < len(argv):",
|
|
1157
|
+
" model = argv[index + 1]",
|
|
1075
1158
|
" break",
|
|
1076
1159
|
"",
|
|
1077
1160
|
"prompt = sys.stdin.read()",
|
|
1078
|
-
"if 'Codex startup-stall recovery' in prompt:",
|
|
1161
|
+
"if 'Codex startup-stall recovery' in prompt and model == 'gpt-5.4':",
|
|
1079
1162
|
" Path('src').mkdir(exist_ok=True)",
|
|
1080
1163
|
" Path('src/startup-stall-recovered.txt').write_text('patched after restart\\n', encoding='utf-8')",
|
|
1081
1164
|
" if last_message_path:",
|
|
@@ -1097,7 +1180,8 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
|
|
|
1097
1180
|
"OPENAI_API_KEY": "pushpals-startup-stall-test-key",
|
|
1098
1181
|
"WORKERPALS_OPENAI_CODEX_JSON": "true",
|
|
1099
1182
|
"WORKERPALS_OPENAI_CODEX_TIMEOUT_S": "20",
|
|
1100
|
-
"WORKERPALS_OPENAI_CODEX_NO_EDIT_WATCHDOG_S": "
|
|
1183
|
+
"WORKERPALS_OPENAI_CODEX_NO_EDIT_WATCHDOG_S": "0",
|
|
1184
|
+
"WORKERPALS_OPENAI_CODEX_STARTUP_STALL_WATCHDOG_S": "1",
|
|
1101
1185
|
"WORKERPALS_OPENAI_CODEX_PROGRESS_LOG_INTERVAL_S": "1",
|
|
1102
1186
|
}
|
|
1103
1187
|
with mock.patch.dict(os.environ, env_overrides, clear=False):
|
|
@@ -1167,6 +1251,7 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
|
|
|
1167
1251
|
"WORKERPALS_OPENAI_CODEX_JSON": "true",
|
|
1168
1252
|
"WORKERPALS_OPENAI_CODEX_TIMEOUT_S": "20",
|
|
1169
1253
|
"WORKERPALS_OPENAI_CODEX_NO_EDIT_WATCHDOG_S": "1",
|
|
1254
|
+
"WORKERPALS_OPENAI_CODEX_STARTUP_STALL_WATCHDOG_S": "1",
|
|
1170
1255
|
"WORKERPALS_OPENAI_CODEX_PROGRESS_LOG_INTERVAL_S": "1",
|
|
1171
1256
|
}
|
|
1172
1257
|
with mock.patch.dict(os.environ, env_overrides, clear=False):
|
|
@@ -1565,6 +1650,31 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
|
|
|
1565
1650
|
|
|
1566
1651
|
self.assertEqual(watchdog_s, 180)
|
|
1567
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
|
+
|
|
1568
1678
|
def test_narrow_contract_regression_with_required_e2e_uses_fast_no_edit_watchdog(self) -> None:
|
|
1569
1679
|
prompt = (
|
|
1570
1680
|
"Harden the opportunity graph contract around autonomous delivery-loop failure signals. "
|
|
@@ -17,6 +17,10 @@ import {
|
|
|
17
17
|
filterResultLines,
|
|
18
18
|
} from "../common/execution_utils.js";
|
|
19
19
|
import { buildWorkerSandboxWritableEnv } from "../common/sandbox_env.js";
|
|
20
|
+
import {
|
|
21
|
+
createPythonPayloadTransport,
|
|
22
|
+
type PythonPayloadTransport,
|
|
23
|
+
} from "../common/python_payload_transport.js";
|
|
20
24
|
import { computeTimeoutWarningWindow } from "../timeout_policy.js";
|
|
21
25
|
|
|
22
26
|
// ---- Script path (resolved relative to this file) ----------------------------
|
|
@@ -274,6 +278,7 @@ export async function executeWithOpenHands(
|
|
|
274
278
|
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
275
279
|
let stuckNudgeStartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
276
280
|
let stuckNudgeTimer: ReturnType<typeof setInterval> | null = null;
|
|
281
|
+
let payloadTransport: PythonPayloadTransport | null = null;
|
|
277
282
|
const outputPolicy = {
|
|
278
283
|
maxOutputChars: runtimeConfig.workerpals.outputMaxChars,
|
|
279
284
|
maxOutputLines: runtimeConfig.workerpals.outputMaxLines,
|
|
@@ -282,7 +287,8 @@ export async function executeWithOpenHands(
|
|
|
282
287
|
};
|
|
283
288
|
|
|
284
289
|
try {
|
|
285
|
-
|
|
290
|
+
payloadTransport = createPythonPayloadTransport(payload);
|
|
291
|
+
const proc = Bun.spawn([pythonBin, scriptPath, ...payloadTransport.args], {
|
|
286
292
|
cwd: repo,
|
|
287
293
|
stdout: "pipe",
|
|
288
294
|
stderr: "pipe",
|
|
@@ -623,5 +629,6 @@ export async function executeWithOpenHands(
|
|
|
623
629
|
if (stuckNudgeTimer) {
|
|
624
630
|
clearInterval(stuckNudgeTimer);
|
|
625
631
|
}
|
|
632
|
+
payloadTransport?.cleanup();
|
|
626
633
|
}
|
|
627
634
|
}
|
|
@@ -155,14 +155,60 @@ def fail(summary: str, stderr: Optional[str] = None, exit_code: int = 1) -> int:
|
|
|
155
155
|
return exit_code
|
|
156
156
|
|
|
157
157
|
|
|
158
|
-
def
|
|
159
|
-
|
|
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
|
+
|
|
191
|
+
def read_encoded_payload_arg(argv: List[str]) -> str:
|
|
192
|
+
if len(argv) < 2:
|
|
193
|
+
raise ValueError("missing base64 job payload")
|
|
194
|
+
mode = argv[1]
|
|
195
|
+
if mode == "--payload-file":
|
|
196
|
+
if len(argv) < 3 or not str(argv[2] or "").strip():
|
|
197
|
+
raise ValueError("missing payload file path")
|
|
198
|
+
path = Path(str(argv[2])).expanduser()
|
|
199
|
+
return path.read_text(encoding="utf-8").strip()
|
|
200
|
+
if mode == "--payload-stdin":
|
|
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
|
|
209
|
+
return mode
|
|
210
|
+
|
|
211
|
+
|
|
166
212
|
def resolve_repo_within_assigned_root(repo: str) -> Tuple[Optional[str], Optional[str]]:
|
|
167
213
|
raw_repo = str(repo or "").strip()
|
|
168
214
|
if not raw_repo:
|
|
@@ -968,11 +1014,8 @@ def parse_task_execute_payload(
|
|
|
968
1014
|
don't need to handle them.
|
|
969
1015
|
"""
|
|
970
1016
|
log = logger or Logger("[Executor]")
|
|
971
|
-
if len(argv) < 2:
|
|
972
|
-
raise SystemExit(fail("Missing base64 job payload", exit_code=2))
|
|
973
|
-
|
|
974
1017
|
try:
|
|
975
|
-
payload = decode_payload(argv
|
|
1018
|
+
payload = decode_payload(read_encoded_payload_arg(argv))
|
|
976
1019
|
except Exception as exc:
|
|
977
1020
|
raise SystemExit(fail(f"Failed to decode job payload: {exc}", exit_code=2))
|
|
978
1021
|
|
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
streamLines,
|
|
20
20
|
} from "./execution_utils.js";
|
|
21
21
|
import { buildWorkerSandboxWritableEnv } from "./sandbox_env.js";
|
|
22
|
+
import {
|
|
23
|
+
createPythonPayloadTransport,
|
|
24
|
+
type PythonPayloadTransport,
|
|
25
|
+
} from "./python_payload_transport.js";
|
|
22
26
|
|
|
23
27
|
interface GenericPythonExecutorConfig {
|
|
24
28
|
backendName: string;
|
|
@@ -357,7 +361,6 @@ export function createGenericPythonExecutor(
|
|
|
357
361
|
}),
|
|
358
362
|
"utf-8",
|
|
359
363
|
).toString("base64");
|
|
360
|
-
const args = [pythonBin, scriptPath, payloadBase64];
|
|
361
364
|
const childTimeoutMs = resolveGenericPythonExecutorChildTimeoutMs({
|
|
362
365
|
backendName,
|
|
363
366
|
hostTimeoutMs: timeoutMs,
|
|
@@ -379,12 +382,15 @@ export function createGenericPythonExecutor(
|
|
|
379
382
|
)}ms`
|
|
380
383
|
: "";
|
|
381
384
|
|
|
382
|
-
|
|
383
|
-
"stdout",
|
|
384
|
-
`[${backendLabel}Executor] Spawning ${backendName} executor (timeout=${timeoutMs}ms; ${timeoutDetail}${childTimeoutDetail})`,
|
|
385
|
-
);
|
|
386
|
-
|
|
385
|
+
let payloadTransport: PythonPayloadTransport | null = null;
|
|
387
386
|
try {
|
|
387
|
+
payloadTransport = createPythonPayloadTransport(payloadBase64);
|
|
388
|
+
const args = [pythonBin, scriptPath, ...payloadTransport.args];
|
|
389
|
+
onLog?.(
|
|
390
|
+
"stdout",
|
|
391
|
+
`[${backendLabel}Executor] Spawning ${backendName} executor (timeout=${timeoutMs}ms; ${timeoutDetail}${childTimeoutDetail})`,
|
|
392
|
+
);
|
|
393
|
+
|
|
388
394
|
const outputPolicy = {
|
|
389
395
|
maxOutputChars: runtimeConfig.workerpals.outputMaxChars,
|
|
390
396
|
maxOutputLines: runtimeConfig.workerpals.outputMaxLines,
|
|
@@ -539,6 +545,8 @@ export function createGenericPythonExecutor(
|
|
|
539
545
|
"",
|
|
540
546
|
),
|
|
541
547
|
};
|
|
548
|
+
} finally {
|
|
549
|
+
payloadTransport?.cleanup();
|
|
542
550
|
}
|
|
543
551
|
};
|
|
544
552
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
export interface PythonPayloadTransport {
|
|
6
|
+
args: string[];
|
|
7
|
+
filePath: string;
|
|
8
|
+
cleanup: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createPythonPayloadTransport(payloadBase64: string): PythonPayloadTransport {
|
|
12
|
+
const dir = mkdtempSync(join(tmpdir(), "pushpals-python-payload-"));
|
|
13
|
+
const filePath = join(dir, "payload.b64");
|
|
14
|
+
writeFileSync(filePath, payloadBase64, { encoding: "utf8", mode: 0o600 });
|
|
15
|
+
|
|
16
|
+
let cleaned = false;
|
|
17
|
+
return {
|
|
18
|
+
args: ["--payload-file", filePath],
|
|
19
|
+
filePath,
|
|
20
|
+
cleanup: () => {
|
|
21
|
+
if (cleaned) return;
|
|
22
|
+
cleaned = true;
|
|
23
|
+
rmSync(dir, { recursive: true, force: true });
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -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,
|