@pushpalsdev/cli 1.0.18 → 1.0.20
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/dist/pushpals-cli.js +291 -44
- package/package.json +1 -1
- package/runtime/configs/backend.toml +1 -1
- package/runtime/configs/default.toml +1 -1
- package/runtime/sandbox/apps/workerpals/.python-version +1 -0
- package/runtime/sandbox/apps/workerpals/Dockerfile.sandbox +71 -0
- package/runtime/sandbox/apps/workerpals/package.json +25 -0
- package/runtime/sandbox/apps/workerpals/pyproject.toml +8 -0
- package/runtime/sandbox/apps/workerpals/src/backends/backend_config.ts +119 -0
- package/runtime/sandbox/apps/workerpals/src/backends/miniswe/miniswe_executor.py +2029 -0
- package/runtime/sandbox/apps/workerpals/src/backends/miniswe_backend.ts +48 -0
- package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/openai_codex_executor.py +1259 -0
- package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/test_openai_codex_runtime_config.py +110 -0
- package/runtime/sandbox/apps/workerpals/src/backends/openai_codex_backend.ts +67 -0
- package/runtime/sandbox/apps/workerpals/src/backends/openhands/openhands_executor.py +563 -0
- package/runtime/sandbox/apps/workerpals/src/backends/openhands_backend.ts +161 -0
- package/runtime/sandbox/apps/workerpals/src/backends/openhands_task_execute.ts +536 -0
- package/runtime/sandbox/apps/workerpals/src/backends/shared/executor_base.py +746 -0
- package/runtime/sandbox/apps/workerpals/src/backends/shared/test_settings_resolver.py +60 -0
- package/runtime/sandbox/apps/workerpals/src/backends/task_execute_registry.ts +21 -0
- package/runtime/sandbox/apps/workerpals/src/backends/types.ts +52 -0
- package/runtime/sandbox/apps/workerpals/src/common/execution_utils.ts +149 -0
- package/runtime/sandbox/apps/workerpals/src/common/executor_backend.ts +15 -0
- package/runtime/sandbox/apps/workerpals/src/common/generic_python_executor.ts +210 -0
- package/runtime/sandbox/apps/workerpals/src/common/logger.ts +65 -0
- package/runtime/sandbox/apps/workerpals/src/common/types.ts +9 -0
- package/runtime/sandbox/apps/workerpals/src/common/worktree_cleanup.ts +66 -0
- package/runtime/sandbox/apps/workerpals/src/context_manager.ts +45 -0
- package/runtime/sandbox/apps/workerpals/src/docker_executor.ts +1842 -0
- package/runtime/sandbox/apps/workerpals/src/execute_job.ts +3063 -0
- package/runtime/sandbox/apps/workerpals/src/job_runner.ts +194 -0
- package/runtime/sandbox/apps/workerpals/src/shell_manager.ts +210 -0
- package/runtime/sandbox/apps/workerpals/src/timeout_policy.ts +24 -0
- package/runtime/sandbox/apps/workerpals/src/workerpals_main.ts +1436 -0
- package/runtime/sandbox/apps/workerpals/tsconfig.json +15 -0
- package/runtime/sandbox/apps/workerpals/uv.lock +2014 -0
- package/runtime/sandbox/bun.lock +2591 -0
- package/runtime/sandbox/configs/backend.toml +79 -0
- package/runtime/sandbox/configs/default.toml +260 -0
- package/runtime/sandbox/configs/dev.toml +2 -0
- package/runtime/sandbox/configs/local.example.toml +129 -0
- package/runtime/sandbox/package.json +65 -0
- package/runtime/sandbox/packages/protocol/README.md +168 -0
- package/runtime/sandbox/packages/protocol/package.json +37 -0
- package/runtime/sandbox/packages/protocol/scripts/copy-schemas.js +17 -0
- package/runtime/sandbox/packages/protocol/src/a2a/README.md +52 -0
- package/runtime/sandbox/packages/protocol/src/a2a/mapping.ts +55 -0
- package/runtime/sandbox/packages/protocol/src/index.browser.ts +25 -0
- package/runtime/sandbox/packages/protocol/src/index.ts +25 -0
- package/runtime/sandbox/packages/protocol/src/schemas/approvals.schema.json +6 -0
- package/runtime/sandbox/packages/protocol/src/schemas/envelope.schema.json +96 -0
- package/runtime/sandbox/packages/protocol/src/schemas/events.schema.json +679 -0
- package/runtime/sandbox/packages/protocol/src/schemas/http.schema.json +50 -0
- package/runtime/sandbox/packages/protocol/src/types.ts +267 -0
- package/runtime/sandbox/packages/protocol/src/validate.browser.ts +154 -0
- package/runtime/sandbox/packages/protocol/src/validate.ts +233 -0
- package/runtime/sandbox/packages/protocol/src/version.ts +1 -0
- package/runtime/sandbox/packages/protocol/tsconfig.json +20 -0
- package/runtime/sandbox/packages/shared/package.json +19 -0
- package/runtime/sandbox/packages/shared/src/autonomy_policy.ts +400 -0
- package/runtime/sandbox/packages/shared/src/client_preflight.ts +286 -0
- package/runtime/sandbox/packages/shared/src/communication.ts +313 -0
- package/runtime/sandbox/packages/shared/src/config.ts +2180 -0
- package/runtime/sandbox/packages/shared/src/config_template_parity.ts +70 -0
- package/runtime/sandbox/packages/shared/src/git_backend.ts +205 -0
- package/runtime/sandbox/packages/shared/src/index.ts +101 -0
- package/runtime/sandbox/packages/shared/src/local_network.ts +101 -0
- package/runtime/sandbox/packages/shared/src/localbuddy_runtime.ts +314 -0
- package/runtime/sandbox/packages/shared/src/prompts.ts +64 -0
- package/runtime/sandbox/packages/shared/src/repo.ts +134 -0
- package/runtime/sandbox/packages/shared/src/session_event_visibility.ts +25 -0
- package/runtime/sandbox/packages/shared/src/vision.ts +247 -0
- package/runtime/sandbox/packages/shared/tsconfig.json +16 -0
- package/runtime/sandbox/prompts/workerpals/codex_quality_critic_instruction_prompt.md +14 -0
- package/runtime/sandbox/prompts/workerpals/commit_message_prompt.md +36 -0
- package/runtime/sandbox/prompts/workerpals/commit_message_user_prompt.md +7 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_broker_system_prompt.md +33 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_broker_task_prompt.md +5 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_completion_requirement.md +1 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_context_compaction_retry_prompt.md +1 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_explicit_targets_block.md +2 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_recovery_guidance_base.md +4 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_recovery_guidance_blocker_line.md +1 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_strict_tool_use_guidance.md +6 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_supplemental_guidance_section.md +2 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_timeout_note.md +1 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_toolcall_retry_guidance.md +1 -0
- package/runtime/sandbox/prompts/workerpals/openai_codex_default_system_prompt.md +4 -0
- package/runtime/sandbox/prompts/workerpals/openai_codex_instruction_wrapper.md +5 -0
- package/runtime/sandbox/prompts/workerpals/openai_codex_runtime_policy_appendix.md +5 -0
- package/runtime/sandbox/prompts/workerpals/openai_codex_supplemental_guidance_section.md +2 -0
- package/runtime/sandbox/prompts/workerpals/openai_codex_task_execute_system_prompt.md +12 -0
- package/runtime/sandbox/prompts/workerpals/openhands_minimal_security_policy.j2 +8 -0
- package/runtime/sandbox/prompts/workerpals/openhands_minimal_system_prompt.j2 +20 -0
- package/runtime/sandbox/prompts/workerpals/openhands_strict_tool_use_message.md +1 -0
- package/runtime/sandbox/prompts/workerpals/openhands_supplemental_guidance_message.md +2 -0
- package/runtime/sandbox/prompts/workerpals/openhands_task_execute_fallback_system_prompt.md +1 -0
- package/runtime/sandbox/prompts/workerpals/openhands_task_execute_system_prompt.md +21 -0
- package/runtime/sandbox/prompts/workerpals/openhands_task_user_prompt.md +6 -0
- package/runtime/sandbox/prompts/workerpals/openhands_timeout_note.md +1 -0
- package/runtime/sandbox/prompts/workerpals/pr_description.md +42 -0
- package/runtime/sandbox/prompts/workerpals/task_quality_critic_system_prompt.md +9 -0
- package/runtime/sandbox/prompts/workerpals/task_quality_critic_user_prompt.md +17 -0
- package/runtime/sandbox/prompts/workerpals/workerpals_system_prompt.md +115 -0
- package/runtime/sandbox/protocol/schemas/approvals.schema.json +6 -0
- package/runtime/sandbox/protocol/schemas/envelope.schema.json +96 -0
- package/runtime/sandbox/protocol/schemas/events.schema.json +679 -0
- package/runtime/sandbox/protocol/schemas/http.schema.json +50 -0
|
@@ -0,0 +1,1259 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PushPals OpenAI Codex backend wrapper.
|
|
3
|
+
|
|
4
|
+
Runs `codex exec` in non-interactive mode and emits one structured result line
|
|
5
|
+
that the TypeScript host parses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from shutil import which
|
|
14
|
+
import shlex
|
|
15
|
+
import signal
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
import traceback
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Dict, List, Optional
|
|
25
|
+
|
|
26
|
+
_SHARED_DIR = Path(__file__).resolve().parents[1] / "shared"
|
|
27
|
+
if str(_SHARED_DIR) not in sys.path:
|
|
28
|
+
sys.path.insert(0, str(_SHARED_DIR))
|
|
29
|
+
|
|
30
|
+
from executor_base import (
|
|
31
|
+
Logger,
|
|
32
|
+
SettingsResolver,
|
|
33
|
+
build_settings_resolver,
|
|
34
|
+
emit,
|
|
35
|
+
log_git_status,
|
|
36
|
+
looks_local_base_url,
|
|
37
|
+
parse_task_execute_payload,
|
|
38
|
+
resolve_llm_config,
|
|
39
|
+
summarize_git_changes,
|
|
40
|
+
to_int,
|
|
41
|
+
to_single_line,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
LOG_PREFIX = "[OpenAICodexExecutor]"
|
|
45
|
+
DEFAULT_CODEX_MODEL = "gpt-5-codex"
|
|
46
|
+
_ACTIVE_CHILD: Optional[subprocess.Popen[str]] = None
|
|
47
|
+
_INTERRUPTED_SIGNAL: Optional[int] = None
|
|
48
|
+
log = Logger(LOG_PREFIX)
|
|
49
|
+
|
|
50
|
+
_PROMPT_TEMPLATE_CACHE: Dict[str, str] = {}
|
|
51
|
+
_PROMPT_TOKEN_REGEX = re.compile(r"\{\{\s*([a-zA-Z0-9_]+)\s*\}\}")
|
|
52
|
+
_TASK_SYSTEM_PROMPT_PATH = "workerpals/openai_codex_task_execute_system_prompt.md"
|
|
53
|
+
_DEFAULT_TASK_SYSTEM_PROMPT_PATH = "workerpals/openai_codex_default_system_prompt.md"
|
|
54
|
+
_MANDATORY_RUNTIME_POLICY_APPENDIX_PATH = "workerpals/openai_codex_runtime_policy_appendix.md"
|
|
55
|
+
_INSTRUCTION_WRAPPER_PROMPT_PATH = "workerpals/openai_codex_instruction_wrapper.md"
|
|
56
|
+
_SUPPLEMENTAL_GUIDANCE_SECTION_PATH = "workerpals/openai_codex_supplemental_guidance_section.md"
|
|
57
|
+
_CODEX_WORKAROUND_PATTERNS = (
|
|
58
|
+
re.compile(
|
|
59
|
+
r"\bcodex cli\b.{0,120}\b(isn't|is not|not)\b.{0,120}\bavailable\b.{0,120}\b(so|therefore|instead|fallback|workaround|without|using)\b",
|
|
60
|
+
re.IGNORECASE,
|
|
61
|
+
),
|
|
62
|
+
re.compile(r"\bwithout requiring\b.{0,120}\bcodex\b", re.IGNORECASE),
|
|
63
|
+
re.compile(r"\bavoid(?:ing)?\b.{0,120}\bcodex\b.{0,120}\bcall", re.IGNORECASE),
|
|
64
|
+
re.compile(r"\b(fell back|fallback|worked around|workaround|bypass(?:ed)?|switched to)\b.{0,120}\bcodex\b", re.IGNORECASE),
|
|
65
|
+
)
|
|
66
|
+
_CODEX_WORKAROUND_NEGATION_HINTS = (
|
|
67
|
+
"do not",
|
|
68
|
+
"don't",
|
|
69
|
+
"never",
|
|
70
|
+
"must not",
|
|
71
|
+
"fail loudly",
|
|
72
|
+
"hard-fail",
|
|
73
|
+
"hard fail",
|
|
74
|
+
"explicit failure",
|
|
75
|
+
"codex cli is required infrastructure",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
_VALID_APPROVAL_POLICIES = {"untrusted", "on-failure", "on-request", "never"}
|
|
79
|
+
_VALID_SANDBOX_POLICIES = {"read-only", "workspace-write", "danger-full-access"}
|
|
80
|
+
_VALID_COLORS = {"always", "never", "auto"}
|
|
81
|
+
_VALID_AUTH_MODES = {"auto", "api_key", "chatgpt"}
|
|
82
|
+
_VALID_REASONING_EFFORTS = {"low", "medium", "high"}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class OpenAICodexRuntimeConfig:
|
|
87
|
+
codex_bin_json: str
|
|
88
|
+
codex_bin: str
|
|
89
|
+
auth_mode: str
|
|
90
|
+
base_url_override: str
|
|
91
|
+
timeout_seconds_override: int
|
|
92
|
+
timeout_ms_top_level: int
|
|
93
|
+
timeout_ms_llm_codex: int
|
|
94
|
+
timeout_ms_backend: int
|
|
95
|
+
progress_log_interval_s: int
|
|
96
|
+
reasoning_effort: str
|
|
97
|
+
approval_policy: str
|
|
98
|
+
sandbox: str
|
|
99
|
+
color: str
|
|
100
|
+
json_output: bool
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def from_sources(cls, settings: Optional[SettingsResolver] = None) -> "OpenAICodexRuntimeConfig":
|
|
104
|
+
cfg = settings or build_settings_resolver()
|
|
105
|
+
return cls(
|
|
106
|
+
codex_bin_json=cfg.get_str(
|
|
107
|
+
env_names=("PUSHPALS_OPENAI_CODEX_BIN_JSON",),
|
|
108
|
+
config_paths=("workerpals.llm.codex_bin_json", "workerpals.openai_codex.bin_json"),
|
|
109
|
+
default="",
|
|
110
|
+
),
|
|
111
|
+
codex_bin=cfg.get_str(
|
|
112
|
+
env_names=("PUSHPALS_OPENAI_CODEX_BIN",),
|
|
113
|
+
config_paths=("workerpals.llm.codex_bin", "workerpals.openai_codex.bin"),
|
|
114
|
+
default="",
|
|
115
|
+
),
|
|
116
|
+
auth_mode=cfg.get_str(
|
|
117
|
+
env_names=("PUSHPALS_OPENAI_CODEX_AUTH_MODE",),
|
|
118
|
+
config_paths=("workerpals.llm.codex_auth_mode", "workerpals.openai_codex.auth_mode"),
|
|
119
|
+
default="auto",
|
|
120
|
+
),
|
|
121
|
+
base_url_override=cfg.get_str(
|
|
122
|
+
env_names=("PUSHPALS_OPENAI_CODEX_BASE_URL",),
|
|
123
|
+
config_paths=("workerpals.llm.codex_base_url", "workerpals.openai_codex.base_url"),
|
|
124
|
+
default="",
|
|
125
|
+
),
|
|
126
|
+
timeout_seconds_override=cfg.get_int(
|
|
127
|
+
env_names=("WORKERPALS_OPENAI_CODEX_TIMEOUT_S",),
|
|
128
|
+
config_paths=("workerpals.openai_codex.timeout_s",),
|
|
129
|
+
default=0,
|
|
130
|
+
),
|
|
131
|
+
timeout_ms_top_level=cfg.get_int(
|
|
132
|
+
env_names=("WORKERPALS_OPENAI_CODEX_TIMEOUT_MS",),
|
|
133
|
+
config_paths=("workerpals.openai_codex_timeout_ms",),
|
|
134
|
+
default=0,
|
|
135
|
+
),
|
|
136
|
+
timeout_ms_llm_codex=cfg.get_int(
|
|
137
|
+
env_names=("WORKERPALS_LLM_CODEX_TIMEOUT_MS",),
|
|
138
|
+
config_paths=("workerpals.llm.codex_timeout_ms",),
|
|
139
|
+
default=0,
|
|
140
|
+
),
|
|
141
|
+
timeout_ms_backend=cfg.get_int(
|
|
142
|
+
env_names=("WORKERPALS_OPENAI_CODEX_BACKEND_TIMEOUT_MS",),
|
|
143
|
+
config_paths=("workerpals.openai_codex.timeout_ms",),
|
|
144
|
+
default=0,
|
|
145
|
+
),
|
|
146
|
+
progress_log_interval_s=cfg.get_int(
|
|
147
|
+
env_names=("WORKERPALS_OPENAI_CODEX_PROGRESS_LOG_INTERVAL_S",),
|
|
148
|
+
config_paths=("workerpals.openai_codex.progress_log_interval_s",),
|
|
149
|
+
default=30,
|
|
150
|
+
),
|
|
151
|
+
reasoning_effort=cfg.get_str(
|
|
152
|
+
env_names=("WORKERPALS_LLM_REASONING_EFFORT", "WORKERPALS_OPENAI_CODEX_REASONING_EFFORT"),
|
|
153
|
+
config_paths=("workerpals.llm.reasoning_effort", "workerpals.openai_codex.reasoning_effort"),
|
|
154
|
+
default="high",
|
|
155
|
+
),
|
|
156
|
+
approval_policy=cfg.get_str(
|
|
157
|
+
env_names=("WORKERPALS_OPENAI_CODEX_APPROVAL_POLICY",),
|
|
158
|
+
config_paths=("workerpals.openai_codex.approval_policy",),
|
|
159
|
+
default="never",
|
|
160
|
+
),
|
|
161
|
+
sandbox=cfg.get_str(
|
|
162
|
+
env_names=("WORKERPALS_OPENAI_CODEX_SANDBOX",),
|
|
163
|
+
config_paths=("workerpals.openai_codex.sandbox",),
|
|
164
|
+
default="workspace-write",
|
|
165
|
+
),
|
|
166
|
+
color=cfg.get_str(
|
|
167
|
+
env_names=("WORKERPALS_OPENAI_CODEX_COLOR",),
|
|
168
|
+
config_paths=("workerpals.openai_codex.color",),
|
|
169
|
+
default="never",
|
|
170
|
+
),
|
|
171
|
+
json_output=cfg.get_bool(
|
|
172
|
+
env_names=("WORKERPALS_OPENAI_CODEX_JSON",),
|
|
173
|
+
config_paths=("workerpals.openai_codex.json",),
|
|
174
|
+
default=False,
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def shutil_which(binary: str) -> str:
|
|
180
|
+
return which(binary) or ""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _truncate(text: str, max_chars: int = 4000) -> str:
|
|
184
|
+
value = str(text or "")
|
|
185
|
+
if len(value) <= max_chars:
|
|
186
|
+
return value
|
|
187
|
+
return value[: max(1, max_chars - 15)] + "\n...[truncated]"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _repo_root_for_prompt_loading() -> Path:
|
|
191
|
+
current = Path(__file__).resolve()
|
|
192
|
+
for parent in current.parents:
|
|
193
|
+
if (parent / "prompts").is_dir():
|
|
194
|
+
return parent
|
|
195
|
+
# Fallback to historical layout depth if prompts/ cannot be discovered.
|
|
196
|
+
return current.parents[5]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _resolve_prompt_file(relative_path: str) -> Path:
|
|
200
|
+
return _repo_root_for_prompt_loading() / "prompts" / relative_path
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _load_prompt_template(
|
|
204
|
+
relative_path: str, replacements: Optional[Dict[str, str]] = None
|
|
205
|
+
) -> str:
|
|
206
|
+
prompt_path = _resolve_prompt_file(relative_path)
|
|
207
|
+
cache_key = str(prompt_path)
|
|
208
|
+
cached = _PROMPT_TEMPLATE_CACHE.get(cache_key)
|
|
209
|
+
if cached is not None:
|
|
210
|
+
template = cached
|
|
211
|
+
else:
|
|
212
|
+
try:
|
|
213
|
+
template = prompt_path.read_text(encoding="utf-8").strip()
|
|
214
|
+
except Exception:
|
|
215
|
+
template = ""
|
|
216
|
+
_PROMPT_TEMPLATE_CACHE[cache_key] = template
|
|
217
|
+
if not replacements:
|
|
218
|
+
return template
|
|
219
|
+
|
|
220
|
+
def _replace(match: re.Match[str]) -> str:
|
|
221
|
+
key = match.group(1)
|
|
222
|
+
value = replacements.get(key)
|
|
223
|
+
if value is None:
|
|
224
|
+
raise KeyError(f"Missing prompt replacement '{{{{{key}}}}}' for {prompt_path}")
|
|
225
|
+
return value
|
|
226
|
+
|
|
227
|
+
return _PROMPT_TOKEN_REGEX.sub(_replace, template)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _to_positive_int(raw: str) -> Optional[int]:
|
|
231
|
+
try:
|
|
232
|
+
parsed = int(raw)
|
|
233
|
+
except Exception:
|
|
234
|
+
return None
|
|
235
|
+
return parsed if parsed > 0 else None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _normalize_choice(
|
|
239
|
+
value: str,
|
|
240
|
+
valid: set[str],
|
|
241
|
+
default: str,
|
|
242
|
+
*,
|
|
243
|
+
env_name: str,
|
|
244
|
+
) -> str:
|
|
245
|
+
normalized = value.strip().lower()
|
|
246
|
+
if normalized in valid:
|
|
247
|
+
return normalized
|
|
248
|
+
if normalized:
|
|
249
|
+
log.info(
|
|
250
|
+
f"Invalid {env_name}={value!r}; using default {default!r}. "
|
|
251
|
+
f"Allowed: {', '.join(sorted(valid))}."
|
|
252
|
+
)
|
|
253
|
+
return default
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _is_git_repo(repo: str) -> bool:
|
|
257
|
+
try:
|
|
258
|
+
proc = subprocess.run(
|
|
259
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
260
|
+
cwd=repo,
|
|
261
|
+
capture_output=True,
|
|
262
|
+
text=True,
|
|
263
|
+
timeout=10,
|
|
264
|
+
check=False,
|
|
265
|
+
)
|
|
266
|
+
if proc.returncode != 0:
|
|
267
|
+
return False
|
|
268
|
+
return (proc.stdout or "").strip().lower() == "true"
|
|
269
|
+
except Exception:
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _resolve_codex_command_prefix(config: OpenAICodexRuntimeConfig) -> List[str]:
|
|
274
|
+
override_json = config.codex_bin_json
|
|
275
|
+
if override_json:
|
|
276
|
+
try:
|
|
277
|
+
parsed = json.loads(override_json)
|
|
278
|
+
if isinstance(parsed, list):
|
|
279
|
+
parts = [str(p).strip() for p in parsed if str(p).strip()]
|
|
280
|
+
if parts:
|
|
281
|
+
return parts
|
|
282
|
+
except Exception:
|
|
283
|
+
log.info(
|
|
284
|
+
"Invalid PUSHPALS_OPENAI_CODEX_BIN_JSON; expected JSON array of command segments."
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
override = config.codex_bin
|
|
288
|
+
if override:
|
|
289
|
+
try:
|
|
290
|
+
parts = [p for p in shlex.split(override) if p.strip()]
|
|
291
|
+
except Exception:
|
|
292
|
+
log.info(
|
|
293
|
+
"Invalid PUSHPALS_OPENAI_CODEX_BIN value; expected a command string parseable by shlex."
|
|
294
|
+
)
|
|
295
|
+
return []
|
|
296
|
+
return parts
|
|
297
|
+
|
|
298
|
+
# Prefer bunx to avoid requiring a separate node runtime in the container.
|
|
299
|
+
if shutil_which("bunx"):
|
|
300
|
+
return ["bunx", "--yes", "@openai/codex"]
|
|
301
|
+
if shutil_which("codex"):
|
|
302
|
+
return ["codex"]
|
|
303
|
+
return []
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _resolve_communicate_timeout_seconds(config: OpenAICodexRuntimeConfig) -> Optional[int]:
|
|
307
|
+
explicit_s = _to_positive_int(str(config.timeout_seconds_override))
|
|
308
|
+
if explicit_s is not None:
|
|
309
|
+
return explicit_s
|
|
310
|
+
# Top-level execution budget (e.g. openai_codex_timeout_ms = 7200000 in [workerpals])
|
|
311
|
+
# takes precedence over the more granular LLM/CLI-level timeout settings.
|
|
312
|
+
top_level_ms = config.timeout_ms_top_level
|
|
313
|
+
if top_level_ms > 0:
|
|
314
|
+
return max(1, top_level_ms // 1000)
|
|
315
|
+
timeout_ms = config.timeout_ms_llm_codex
|
|
316
|
+
if timeout_ms <= 0:
|
|
317
|
+
timeout_ms = config.timeout_ms_backend
|
|
318
|
+
if timeout_ms <= 0:
|
|
319
|
+
return None
|
|
320
|
+
return max(1, timeout_ms // 1000)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _resolve_reasoning_effort(config: OpenAICodexRuntimeConfig) -> str:
|
|
324
|
+
raw = config.reasoning_effort
|
|
325
|
+
normalized = str(raw).strip().lower()
|
|
326
|
+
if normalized in _VALID_REASONING_EFFORTS:
|
|
327
|
+
return normalized
|
|
328
|
+
log.info(
|
|
329
|
+
"Invalid workerpals.openai_codex.reasoning_effort="
|
|
330
|
+
f"{raw!r}; using default 'high'. Allowed: low, medium, high."
|
|
331
|
+
)
|
|
332
|
+
return "high"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _resolve_progress_log_interval_seconds(config: OpenAICodexRuntimeConfig) -> int:
|
|
336
|
+
interval = to_int(config.progress_log_interval_s, 30)
|
|
337
|
+
# Avoid noisy logs (<30s) and stale logs (>120s).
|
|
338
|
+
return max(30, min(120, interval))
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _normalize_auth_mode(raw: str) -> str:
|
|
342
|
+
lowered = (raw or "").strip().lower()
|
|
343
|
+
aliases = {
|
|
344
|
+
"apikey": "api_key",
|
|
345
|
+
"api": "api_key",
|
|
346
|
+
"api-key": "api_key",
|
|
347
|
+
"chatgpt_login": "chatgpt",
|
|
348
|
+
"chatgpt-pro": "chatgpt",
|
|
349
|
+
"subscription": "chatgpt",
|
|
350
|
+
}
|
|
351
|
+
normalized = aliases.get(lowered, lowered)
|
|
352
|
+
if normalized in _VALID_AUTH_MODES:
|
|
353
|
+
return normalized
|
|
354
|
+
if lowered:
|
|
355
|
+
log.info(
|
|
356
|
+
f"Invalid PUSHPALS_OPENAI_CODEX_AUTH_MODE={raw!r}; using default 'auto'. "
|
|
357
|
+
f"Allowed: {', '.join(sorted(_VALID_AUTH_MODES))}."
|
|
358
|
+
)
|
|
359
|
+
return "auto"
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _run_codex_login_status(codex_cmd_prefix: List[str], repo: str, env: Dict[str, str]) -> Dict[str, Any]:
|
|
363
|
+
try:
|
|
364
|
+
proc = subprocess.run(
|
|
365
|
+
[*codex_cmd_prefix, "login", "status"],
|
|
366
|
+
cwd=repo,
|
|
367
|
+
env=env,
|
|
368
|
+
capture_output=True,
|
|
369
|
+
text=True,
|
|
370
|
+
encoding="utf-8",
|
|
371
|
+
errors="replace",
|
|
372
|
+
timeout=25,
|
|
373
|
+
check=False,
|
|
374
|
+
)
|
|
375
|
+
return {
|
|
376
|
+
"ok": proc.returncode == 0,
|
|
377
|
+
"exitCode": int(proc.returncode),
|
|
378
|
+
"stdout": proc.stdout or "",
|
|
379
|
+
"stderr": proc.stderr or "",
|
|
380
|
+
}
|
|
381
|
+
except Exception as exc:
|
|
382
|
+
return {
|
|
383
|
+
"ok": False,
|
|
384
|
+
"exitCode": 1,
|
|
385
|
+
"stdout": "",
|
|
386
|
+
"stderr": f"Failed to run `codex login status`: {exc}",
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _terminate_active_child() -> None:
|
|
391
|
+
global _ACTIVE_CHILD
|
|
392
|
+
proc = _ACTIVE_CHILD
|
|
393
|
+
if proc is None or proc.poll() is not None:
|
|
394
|
+
return
|
|
395
|
+
try:
|
|
396
|
+
proc.terminate()
|
|
397
|
+
except Exception:
|
|
398
|
+
pass
|
|
399
|
+
try:
|
|
400
|
+
proc.wait(timeout=3)
|
|
401
|
+
except Exception:
|
|
402
|
+
try:
|
|
403
|
+
proc.kill()
|
|
404
|
+
except Exception:
|
|
405
|
+
pass
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _truncate_inline(text: str, max_chars: int = 180) -> str:
|
|
409
|
+
value = " ".join(str(text or "").split())
|
|
410
|
+
if len(value) <= max_chars:
|
|
411
|
+
return value
|
|
412
|
+
return value[: max(1, max_chars - 3)] + "..."
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _contains_reasoning_marker(value: str) -> bool:
|
|
416
|
+
lowered = str(value or "").strip().lower()
|
|
417
|
+
if not lowered:
|
|
418
|
+
return False
|
|
419
|
+
return "reasoning" in lowered or "thinking" in lowered
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _event_contains_reasoning(value: Any) -> bool:
|
|
423
|
+
max_nodes = 256
|
|
424
|
+
visited = 0
|
|
425
|
+
stack: List[Any] = [value]
|
|
426
|
+
while stack and visited < max_nodes:
|
|
427
|
+
current = stack.pop()
|
|
428
|
+
visited += 1
|
|
429
|
+
if isinstance(current, str):
|
|
430
|
+
if _contains_reasoning_marker(current):
|
|
431
|
+
return True
|
|
432
|
+
continue
|
|
433
|
+
if isinstance(current, list):
|
|
434
|
+
for item in reversed(current[:80]):
|
|
435
|
+
if isinstance(item, (dict, list, str)):
|
|
436
|
+
stack.append(item)
|
|
437
|
+
continue
|
|
438
|
+
if not isinstance(current, dict):
|
|
439
|
+
continue
|
|
440
|
+
|
|
441
|
+
for raw_key, nested in current.items():
|
|
442
|
+
key = str(raw_key or "")
|
|
443
|
+
key_lower = key.lower()
|
|
444
|
+
if _contains_reasoning_marker(key_lower):
|
|
445
|
+
return True
|
|
446
|
+
if key_lower in ("type", "kind", "event", "item_type", "role", "channel"):
|
|
447
|
+
if isinstance(nested, str) and _contains_reasoning_marker(nested):
|
|
448
|
+
return True
|
|
449
|
+
if isinstance(nested, (dict, list, str)):
|
|
450
|
+
stack.append(nested)
|
|
451
|
+
|
|
452
|
+
return False
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _collect_text_fragments(value: Any, out: List[str]) -> None:
|
|
456
|
+
if isinstance(value, str):
|
|
457
|
+
text = _truncate_inline(value, 220)
|
|
458
|
+
if text:
|
|
459
|
+
out.append(text)
|
|
460
|
+
return
|
|
461
|
+
if isinstance(value, list):
|
|
462
|
+
for item in value:
|
|
463
|
+
_collect_text_fragments(item, out)
|
|
464
|
+
return
|
|
465
|
+
if isinstance(value, dict):
|
|
466
|
+
matched_key = False
|
|
467
|
+
for raw_key, nested in value.items():
|
|
468
|
+
key = str(raw_key or "").lower()
|
|
469
|
+
if key.endswith("_text") or key.endswith("_message"):
|
|
470
|
+
matched_key = True
|
|
471
|
+
_collect_text_fragments(nested, out)
|
|
472
|
+
continue
|
|
473
|
+
if (
|
|
474
|
+
key in ("text", "content", "summary", "message", "error", "reason", "delta", "output", "item")
|
|
475
|
+
or _contains_reasoning_marker(key)
|
|
476
|
+
):
|
|
477
|
+
matched_key = True
|
|
478
|
+
_collect_text_fragments(nested, out)
|
|
479
|
+
if not matched_key:
|
|
480
|
+
# Fallback: recurse into nested containers so unknown payload shapes still surface text.
|
|
481
|
+
for nested in value.values():
|
|
482
|
+
if isinstance(nested, (dict, list)):
|
|
483
|
+
_collect_text_fragments(nested, out)
|
|
484
|
+
return
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _summarize_json_event(obj: Dict[str, Any]) -> str:
|
|
488
|
+
event_type = str(obj.get("type") or obj.get("event") or obj.get("kind") or "event").strip()
|
|
489
|
+
if not event_type:
|
|
490
|
+
event_type = "event"
|
|
491
|
+
# Skip noisy streaming deltas unless they contain meaningful text fragments.
|
|
492
|
+
delta_like = event_type.endswith(".delta") or event_type.endswith("_delta")
|
|
493
|
+
# Reasoning/thinking events are always surfaced — they show the model's reasoning process.
|
|
494
|
+
reasoning_like = _contains_reasoning_marker(event_type) or _event_contains_reasoning(obj)
|
|
495
|
+
|
|
496
|
+
tool_name = ""
|
|
497
|
+
for key in ("tool_name", "tool", "name"):
|
|
498
|
+
raw = obj.get(key)
|
|
499
|
+
if isinstance(raw, str) and raw.strip():
|
|
500
|
+
tool_name = raw.strip()
|
|
501
|
+
break
|
|
502
|
+
if isinstance(raw, dict):
|
|
503
|
+
nested = raw.get("name")
|
|
504
|
+
if isinstance(nested, str) and nested.strip():
|
|
505
|
+
tool_name = nested.strip()
|
|
506
|
+
break
|
|
507
|
+
# For Codex CLI item.* events, the tool/function name is nested under obj["item"]["name"].
|
|
508
|
+
if not tool_name and isinstance(obj.get("item"), dict):
|
|
509
|
+
nested = obj["item"].get("name")
|
|
510
|
+
if isinstance(nested, str) and nested.strip():
|
|
511
|
+
tool_name = nested.strip()
|
|
512
|
+
|
|
513
|
+
fragments: List[str] = []
|
|
514
|
+
# "item" covers Codex CLI's item.started/updated/completed events where reasoning and
|
|
515
|
+
# tool call content is nested under the item object.
|
|
516
|
+
# "output" covers turn.completed and similar events that carry output arrays.
|
|
517
|
+
# "delta" covers reasoning delta events (response.reasoning_summary_text.delta).
|
|
518
|
+
extract_keys = ["message", "text", "summary", "content", "output_text", "error", "item", "output", "delta"]
|
|
519
|
+
for key in extract_keys:
|
|
520
|
+
if key in obj:
|
|
521
|
+
_collect_text_fragments(obj.get(key), fragments)
|
|
522
|
+
deduped: List[str] = []
|
|
523
|
+
seen: set[str] = set()
|
|
524
|
+
for frag in fragments:
|
|
525
|
+
if frag in seen:
|
|
526
|
+
continue
|
|
527
|
+
seen.add(frag)
|
|
528
|
+
deduped.append(frag)
|
|
529
|
+
text_part = deduped[0] if deduped else ""
|
|
530
|
+
|
|
531
|
+
# Suppress noisy deltas, but always surface reasoning events even if text is empty.
|
|
532
|
+
if delta_like and not text_part and not reasoning_like:
|
|
533
|
+
return ""
|
|
534
|
+
|
|
535
|
+
parts = [event_type]
|
|
536
|
+
if tool_name:
|
|
537
|
+
parts.append(f"tool={tool_name}")
|
|
538
|
+
if text_part:
|
|
539
|
+
parts.append(text_part)
|
|
540
|
+
elif reasoning_like:
|
|
541
|
+
parts.append("reasoning update")
|
|
542
|
+
return " | ".join(parts)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _format_codex_trace_excerpt(trace: Dict[str, Any], max_items: int = 20) -> str:
|
|
546
|
+
summaries = trace.get("summaries")
|
|
547
|
+
if isinstance(summaries, list):
|
|
548
|
+
items = [str(item).strip() for item in summaries if str(item).strip()]
|
|
549
|
+
if items:
|
|
550
|
+
shown = items[:max_items]
|
|
551
|
+
lines = [f"- {item}" for item in shown]
|
|
552
|
+
omitted = len(items) - len(shown)
|
|
553
|
+
if omitted > 0:
|
|
554
|
+
lines.append(f"- ... ({omitted} more event(s) omitted)")
|
|
555
|
+
return "Codex event trace:\n" + "\n".join(lines)
|
|
556
|
+
|
|
557
|
+
event_counts = trace.get("event_type_counts")
|
|
558
|
+
if isinstance(event_counts, dict):
|
|
559
|
+
pairs = [
|
|
560
|
+
(str(key).strip() or "event", to_int(value, 0))
|
|
561
|
+
for key, value in event_counts.items()
|
|
562
|
+
if to_int(value, 0) > 0
|
|
563
|
+
]
|
|
564
|
+
if pairs:
|
|
565
|
+
pairs.sort(key=lambda item: item[1], reverse=True)
|
|
566
|
+
listed = ", ".join(f"{name}={count}" for name, count in pairs[:8])
|
|
567
|
+
return f"Codex event types: {listed}"
|
|
568
|
+
|
|
569
|
+
return ""
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _empty_codex_trace() -> Dict[str, Any]:
|
|
573
|
+
return {
|
|
574
|
+
"line_count": 0,
|
|
575
|
+
"valid_json": 0,
|
|
576
|
+
"invalid_json": 0,
|
|
577
|
+
"summaries": [],
|
|
578
|
+
"event_type_counts": {},
|
|
579
|
+
"live_logged": 0,
|
|
580
|
+
"live_omitted": 0,
|
|
581
|
+
"raw_logged": 0,
|
|
582
|
+
"raw_omitted": 0,
|
|
583
|
+
"reasoning_events": 0,
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _record_live_codex_stdout_line(line: str, use_json: bool, trace: Dict[str, Any]) -> None:
|
|
588
|
+
stripped = line.strip()
|
|
589
|
+
if not stripped:
|
|
590
|
+
return
|
|
591
|
+
|
|
592
|
+
trace["line_count"] = to_int(trace.get("line_count"), 0) + 1
|
|
593
|
+
summaries = trace.setdefault("summaries", [])
|
|
594
|
+
event_type_counts = trace.setdefault("event_type_counts", {})
|
|
595
|
+
max_recorded_summaries = 500
|
|
596
|
+
max_live_logged = 300
|
|
597
|
+
max_raw_logged = 5
|
|
598
|
+
|
|
599
|
+
if use_json:
|
|
600
|
+
try:
|
|
601
|
+
parsed = json.loads(stripped)
|
|
602
|
+
trace["valid_json"] = to_int(trace.get("valid_json"), 0) + 1
|
|
603
|
+
except Exception:
|
|
604
|
+
trace["invalid_json"] = to_int(trace.get("invalid_json"), 0) + 1
|
|
605
|
+
raw_logged = to_int(trace.get("raw_logged"), 0)
|
|
606
|
+
if raw_logged < max_raw_logged:
|
|
607
|
+
log.info(f"[codex/raw] {_truncate_inline(stripped, 220)}")
|
|
608
|
+
trace["raw_logged"] = raw_logged + 1
|
|
609
|
+
else:
|
|
610
|
+
trace["raw_omitted"] = to_int(trace.get("raw_omitted"), 0) + 1
|
|
611
|
+
return
|
|
612
|
+
|
|
613
|
+
if isinstance(parsed, dict):
|
|
614
|
+
event_type = (
|
|
615
|
+
str(parsed.get("type") or parsed.get("event") or parsed.get("kind") or "event")
|
|
616
|
+
.strip()
|
|
617
|
+
or "event"
|
|
618
|
+
)
|
|
619
|
+
event_type_counts[event_type] = to_int(event_type_counts.get(event_type), 0) + 1
|
|
620
|
+
summary = _summarize_json_event(parsed)
|
|
621
|
+
# Reasoning can arrive under generic event types (for example item.updated).
|
|
622
|
+
priority = _event_contains_reasoning(parsed)
|
|
623
|
+
if priority:
|
|
624
|
+
trace["reasoning_events"] = to_int(trace.get("reasoning_events"), 0) + 1
|
|
625
|
+
if summary:
|
|
626
|
+
if len(summaries) < max_recorded_summaries:
|
|
627
|
+
summaries.append(summary)
|
|
628
|
+
live_logged = to_int(trace.get("live_logged"), 0)
|
|
629
|
+
if live_logged < max_live_logged or priority:
|
|
630
|
+
log.info(f"[codex] {summary}")
|
|
631
|
+
trace["live_logged"] = live_logged + 1
|
|
632
|
+
else:
|
|
633
|
+
trace["live_omitted"] = to_int(trace.get("live_omitted"), 0) + 1
|
|
634
|
+
return
|
|
635
|
+
|
|
636
|
+
summary = _truncate_inline(stripped, 220)
|
|
637
|
+
if summary:
|
|
638
|
+
if len(summaries) < max_recorded_summaries:
|
|
639
|
+
summaries.append(summary)
|
|
640
|
+
live_logged = to_int(trace.get("live_logged"), 0)
|
|
641
|
+
if live_logged < max_live_logged:
|
|
642
|
+
log.info(f"[codex] {summary}")
|
|
643
|
+
trace["live_logged"] = live_logged + 1
|
|
644
|
+
else:
|
|
645
|
+
trace["live_omitted"] = to_int(trace.get("live_omitted"), 0) + 1
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _finalize_codex_stdout_trace(trace: Dict[str, Any], use_json: bool) -> Dict[str, Any]:
|
|
649
|
+
line_count = to_int(trace.get("line_count"), 0)
|
|
650
|
+
valid_json = to_int(trace.get("valid_json"), 0)
|
|
651
|
+
invalid_json = to_int(trace.get("invalid_json"), 0)
|
|
652
|
+
summaries = trace.get("summaries")
|
|
653
|
+
if not isinstance(summaries, list):
|
|
654
|
+
summaries = []
|
|
655
|
+
else:
|
|
656
|
+
summaries = [str(item).strip() for item in summaries if str(item).strip()]
|
|
657
|
+
event_type_counts_raw = trace.get("event_type_counts")
|
|
658
|
+
event_type_counts: Dict[str, int] = {}
|
|
659
|
+
if isinstance(event_type_counts_raw, dict):
|
|
660
|
+
for key, value in event_type_counts_raw.items():
|
|
661
|
+
name = str(key).strip() or "event"
|
|
662
|
+
count = to_int(value, 0)
|
|
663
|
+
if count > 0:
|
|
664
|
+
event_type_counts[name] = count
|
|
665
|
+
|
|
666
|
+
if use_json:
|
|
667
|
+
log.info(
|
|
668
|
+
f"Codex JSON stream captured ({line_count} line(s), valid_json={valid_json}, invalid={invalid_json})."
|
|
669
|
+
)
|
|
670
|
+
else:
|
|
671
|
+
log.info(f"Codex stdout captured ({line_count} non-empty line(s)).")
|
|
672
|
+
|
|
673
|
+
live_omitted = to_int(trace.get("live_omitted"), 0)
|
|
674
|
+
if live_omitted > 0:
|
|
675
|
+
log.info(f"[codex] ... {live_omitted} additional event(s) omitted.")
|
|
676
|
+
raw_omitted = to_int(trace.get("raw_omitted"), 0)
|
|
677
|
+
if raw_omitted > 0:
|
|
678
|
+
log.info(f"[codex/raw] ... {raw_omitted} additional line(s) omitted.")
|
|
679
|
+
reasoning_events = to_int(trace.get("reasoning_events"), 0)
|
|
680
|
+
if reasoning_events > 0:
|
|
681
|
+
log.info(f"[codex] Reasoning-like event(s): {reasoning_events}")
|
|
682
|
+
elif use_json and valid_json > 0:
|
|
683
|
+
log.info("[codex] No reasoning-like events observed in this run.")
|
|
684
|
+
|
|
685
|
+
if not summaries and event_type_counts:
|
|
686
|
+
ranked = sorted(event_type_counts.items(), key=lambda item: item[1], reverse=True)
|
|
687
|
+
top = ", ".join(f"{name}={count}" for name, count in ranked[:8])
|
|
688
|
+
log.info(f"[codex] Event types: {top}")
|
|
689
|
+
|
|
690
|
+
return {
|
|
691
|
+
"line_count": line_count,
|
|
692
|
+
"valid_json": valid_json,
|
|
693
|
+
"invalid_json": invalid_json,
|
|
694
|
+
"summaries": summaries,
|
|
695
|
+
"event_type_counts": event_type_counts,
|
|
696
|
+
"reasoning_events": reasoning_events,
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _log_stderr(stderr: str) -> None:
|
|
701
|
+
lines = [line.strip() for line in stderr.splitlines() if line.strip()]
|
|
702
|
+
if not lines:
|
|
703
|
+
return
|
|
704
|
+
max_lines = 20
|
|
705
|
+
for line in lines[:max_lines]:
|
|
706
|
+
log.info(f"[stderr] {line}")
|
|
707
|
+
if len(lines) > max_lines:
|
|
708
|
+
log.info(f"[stderr] ... {len(lines) - max_lines} additional line(s) omitted.")
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _safe_model_for_codex(raw_model: str, base_url: str) -> str:
|
|
712
|
+
model = str(raw_model or "").strip()
|
|
713
|
+
if not model:
|
|
714
|
+
return DEFAULT_CODEX_MODEL
|
|
715
|
+
if "/" not in model:
|
|
716
|
+
return model
|
|
717
|
+
provider, bare = model.split("/", 1)
|
|
718
|
+
provider = provider.strip().lower()
|
|
719
|
+
bare = bare.strip()
|
|
720
|
+
if provider == "openai" and bare:
|
|
721
|
+
return bare
|
|
722
|
+
if looks_local_base_url(base_url) and bare:
|
|
723
|
+
return bare
|
|
724
|
+
return DEFAULT_CODEX_MODEL
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _build_instruction(instruction: str, supplemental_guidance: List[str]) -> str:
|
|
728
|
+
system_prompt = (_load_prompt_template(_TASK_SYSTEM_PROMPT_PATH) or "").strip()
|
|
729
|
+
if not system_prompt:
|
|
730
|
+
system_prompt = (_load_prompt_template(_DEFAULT_TASK_SYSTEM_PROMPT_PATH) or "").strip()
|
|
731
|
+
if not system_prompt:
|
|
732
|
+
raise RuntimeError(
|
|
733
|
+
"Missing required OpenAI Codex system prompt template. "
|
|
734
|
+
f"Expected one of: {_TASK_SYSTEM_PROMPT_PATH}, {_DEFAULT_TASK_SYSTEM_PROMPT_PATH}"
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
runtime_policy_appendix = (
|
|
738
|
+
_load_prompt_template(_MANDATORY_RUNTIME_POLICY_APPENDIX_PATH) or ""
|
|
739
|
+
).strip()
|
|
740
|
+
if not runtime_policy_appendix:
|
|
741
|
+
raise RuntimeError(
|
|
742
|
+
"Missing required OpenAI Codex runtime policy appendix template. "
|
|
743
|
+
f"Expected: {_MANDATORY_RUNTIME_POLICY_APPENDIX_PATH}"
|
|
744
|
+
)
|
|
745
|
+
if runtime_policy_appendix.lower() not in system_prompt.lower():
|
|
746
|
+
system_prompt = f"{system_prompt}\n\n{runtime_policy_appendix}".strip()
|
|
747
|
+
|
|
748
|
+
supplemental_section = ""
|
|
749
|
+
filtered_guidance = [str(item).strip() for item in supplemental_guidance if str(item).strip()]
|
|
750
|
+
if filtered_guidance:
|
|
751
|
+
supplemental_section_template = _load_prompt_template(_SUPPLEMENTAL_GUIDANCE_SECTION_PATH)
|
|
752
|
+
if not supplemental_section_template.strip():
|
|
753
|
+
raise RuntimeError(
|
|
754
|
+
"Missing required OpenAI Codex supplemental guidance section template. "
|
|
755
|
+
f"Expected: {_SUPPLEMENTAL_GUIDANCE_SECTION_PATH}"
|
|
756
|
+
)
|
|
757
|
+
supplemental_section = "\n\n" + _load_prompt_template(
|
|
758
|
+
_SUPPLEMENTAL_GUIDANCE_SECTION_PATH,
|
|
759
|
+
{"guidance_lines": "\n".join(filtered_guidance)},
|
|
760
|
+
).strip()
|
|
761
|
+
|
|
762
|
+
wrapped = _load_prompt_template(
|
|
763
|
+
_INSTRUCTION_WRAPPER_PROMPT_PATH,
|
|
764
|
+
{
|
|
765
|
+
"system_prompt": system_prompt,
|
|
766
|
+
"instruction": instruction,
|
|
767
|
+
"supplemental_section": supplemental_section,
|
|
768
|
+
},
|
|
769
|
+
)
|
|
770
|
+
if not wrapped.strip():
|
|
771
|
+
raise RuntimeError(
|
|
772
|
+
"Missing required OpenAI Codex instruction wrapper template. "
|
|
773
|
+
f"Expected: {_INSTRUCTION_WRAPPER_PROMPT_PATH}"
|
|
774
|
+
)
|
|
775
|
+
return wrapped.strip()
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _detect_codex_workaround_signal(*texts: str) -> Optional[str]:
|
|
779
|
+
for text in texts:
|
|
780
|
+
source = str(text or "")
|
|
781
|
+
if not source:
|
|
782
|
+
continue
|
|
783
|
+
for pattern in _CODEX_WORKAROUND_PATTERNS:
|
|
784
|
+
for match in pattern.finditer(source):
|
|
785
|
+
snippet = match.group(0).strip()
|
|
786
|
+
if not snippet:
|
|
787
|
+
continue
|
|
788
|
+
lowered = snippet.lower()
|
|
789
|
+
if any(hint in lowered for hint in _CODEX_WORKAROUND_NEGATION_HINTS):
|
|
790
|
+
continue
|
|
791
|
+
return snippet
|
|
792
|
+
return None
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _read_text_if_exists(path: Path) -> str:
|
|
796
|
+
try:
|
|
797
|
+
if not path.exists():
|
|
798
|
+
return ""
|
|
799
|
+
return path.read_text(encoding="utf-8", errors="replace").strip()
|
|
800
|
+
except Exception:
|
|
801
|
+
return ""
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _terminate_child(signum: int, _frame: Any) -> None:
|
|
805
|
+
global _INTERRUPTED_SIGNAL
|
|
806
|
+
_INTERRUPTED_SIGNAL = signum
|
|
807
|
+
_terminate_active_child()
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _install_signal_handlers() -> None:
|
|
811
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
812
|
+
try:
|
|
813
|
+
signal.signal(sig, _terminate_child)
|
|
814
|
+
except Exception:
|
|
815
|
+
pass
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _run_codex_task(
|
|
819
|
+
repo: str,
|
|
820
|
+
instruction: str,
|
|
821
|
+
supplemental_guidance: List[str],
|
|
822
|
+
) -> Dict[str, Any]:
|
|
823
|
+
global _ACTIVE_CHILD, _INTERRUPTED_SIGNAL
|
|
824
|
+
_INTERRUPTED_SIGNAL = None
|
|
825
|
+
_install_signal_handlers()
|
|
826
|
+
|
|
827
|
+
if not _is_git_repo(repo):
|
|
828
|
+
return {
|
|
829
|
+
"ok": False,
|
|
830
|
+
"summary": "openai_codex requires a git repository",
|
|
831
|
+
"stderr": (
|
|
832
|
+
f"Refusing to run codex in a non-git directory: {repo}. "
|
|
833
|
+
"Validate repo/worktree setup before dispatching this backend."
|
|
834
|
+
),
|
|
835
|
+
"exitCode": 2,
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
runtime_config = OpenAICodexRuntimeConfig.from_sources()
|
|
839
|
+
codex_cmd_prefix = _resolve_codex_command_prefix(runtime_config)
|
|
840
|
+
if not codex_cmd_prefix:
|
|
841
|
+
return {
|
|
842
|
+
"ok": False,
|
|
843
|
+
"summary": "openai_codex CLI is not installed",
|
|
844
|
+
"stderr": (
|
|
845
|
+
"Could not find a runnable Codex command. "
|
|
846
|
+
"Expected one of: `bunx --yes @openai/codex` or `codex` in PATH. "
|
|
847
|
+
"You can also set PUSHPALS_OPENAI_CODEX_BIN explicitly."
|
|
848
|
+
),
|
|
849
|
+
"exitCode": 3,
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
configured_model, api_key, base_url = resolve_llm_config(DEFAULT_CODEX_MODEL, logger=log)
|
|
853
|
+
auth_mode_raw = runtime_config.auth_mode
|
|
854
|
+
auth_mode_configured = _normalize_auth_mode(auth_mode_raw)
|
|
855
|
+
model = _safe_model_for_codex(configured_model, base_url)
|
|
856
|
+
approval = _normalize_choice(
|
|
857
|
+
runtime_config.approval_policy,
|
|
858
|
+
_VALID_APPROVAL_POLICIES,
|
|
859
|
+
"never",
|
|
860
|
+
env_name="workerpals.openai_codex.approval_policy",
|
|
861
|
+
)
|
|
862
|
+
sandbox = _normalize_choice(
|
|
863
|
+
runtime_config.sandbox,
|
|
864
|
+
_VALID_SANDBOX_POLICIES,
|
|
865
|
+
"workspace-write",
|
|
866
|
+
env_name="workerpals.openai_codex.sandbox",
|
|
867
|
+
)
|
|
868
|
+
color = _normalize_choice(
|
|
869
|
+
runtime_config.color,
|
|
870
|
+
_VALID_COLORS,
|
|
871
|
+
"never",
|
|
872
|
+
env_name="workerpals.openai_codex.color",
|
|
873
|
+
)
|
|
874
|
+
# JSON event output is noisy by default; prefer plain text + output-last-message.
|
|
875
|
+
use_json = runtime_config.json_output
|
|
876
|
+
reasoning_effort = _resolve_reasoning_effort(runtime_config)
|
|
877
|
+
communicate_timeout_s = _resolve_communicate_timeout_seconds(runtime_config)
|
|
878
|
+
prompt = _build_instruction(instruction, supplemental_guidance)
|
|
879
|
+
baseline_changes = summarize_git_changes(repo)
|
|
880
|
+
|
|
881
|
+
with tempfile.TemporaryDirectory(prefix="pushpals-codex-") as tmp_dir:
|
|
882
|
+
last_message_path = Path(tmp_dir) / "codex-last-message.txt"
|
|
883
|
+
cmd: List[str] = [
|
|
884
|
+
*codex_cmd_prefix,
|
|
885
|
+
"-c",
|
|
886
|
+
f'model_reasoning_effort="{reasoning_effort}"',
|
|
887
|
+
"-a",
|
|
888
|
+
approval,
|
|
889
|
+
"exec",
|
|
890
|
+
"-s",
|
|
891
|
+
sandbox,
|
|
892
|
+
"--color",
|
|
893
|
+
color,
|
|
894
|
+
"--output-last-message",
|
|
895
|
+
str(last_message_path),
|
|
896
|
+
]
|
|
897
|
+
if use_json:
|
|
898
|
+
cmd.append("--json")
|
|
899
|
+
if model:
|
|
900
|
+
cmd.extend(["-m", model])
|
|
901
|
+
cmd.append("-")
|
|
902
|
+
|
|
903
|
+
env = os.environ.copy()
|
|
904
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
905
|
+
env["PUSHPALS_REPO_PATH"] = repo
|
|
906
|
+
env["PUSHPALS_ASSIGNED_REPO_ROOT"] = repo
|
|
907
|
+
existing_openai_key = (env.get("OPENAI_API_KEY") or "").strip()
|
|
908
|
+
llm_key = api_key.strip()
|
|
909
|
+
if llm_key.lower() == "lmstudio":
|
|
910
|
+
llm_key = ""
|
|
911
|
+
auth_mode = auth_mode_configured
|
|
912
|
+
if auth_mode == "auto":
|
|
913
|
+
auth_mode = "api_key" if (llm_key or existing_openai_key) else "chatgpt"
|
|
914
|
+
log.info(f"Codex auth mode: {auth_mode} (configured={auth_mode_configured})")
|
|
915
|
+
|
|
916
|
+
existing_openai_base = (
|
|
917
|
+
env.get("OPENAI_BASE_URL", "").strip() or env.get("OPENAI_API_BASE", "").strip()
|
|
918
|
+
)
|
|
919
|
+
override_base = runtime_config.base_url_override
|
|
920
|
+
effective_base = ""
|
|
921
|
+
|
|
922
|
+
if auth_mode == "chatgpt":
|
|
923
|
+
if llm_key or existing_openai_key:
|
|
924
|
+
log.info(
|
|
925
|
+
"ChatGPT auth mode selected; ignoring OPENAI_API_KEY to use Codex CLI login credentials."
|
|
926
|
+
)
|
|
927
|
+
if override_base or existing_openai_base or base_url:
|
|
928
|
+
log.info("ChatGPT auth mode selected; ignoring OPENAI_BASE_URL/OPENAI_API_BASE overrides.")
|
|
929
|
+
env.pop("OPENAI_API_KEY", None)
|
|
930
|
+
env.pop("OPENAI_BASE_URL", None)
|
|
931
|
+
env.pop("OPENAI_API_BASE", None)
|
|
932
|
+
login_status = _run_codex_login_status(codex_cmd_prefix, repo, env)
|
|
933
|
+
if not login_status.get("ok"):
|
|
934
|
+
detail = (
|
|
935
|
+
str(login_status.get("stderr") or "").strip()
|
|
936
|
+
or str(login_status.get("stdout") or "").strip()
|
|
937
|
+
or "codex login status returned non-zero"
|
|
938
|
+
)
|
|
939
|
+
return {
|
|
940
|
+
"ok": False,
|
|
941
|
+
"summary": "openai_codex chatgpt auth is not ready",
|
|
942
|
+
"stdout": _truncate(str(login_status.get("stdout") or "")),
|
|
943
|
+
"stderr": _truncate(
|
|
944
|
+
"Codex CLI is not logged in for ChatGPT subscription mode. "
|
|
945
|
+
"Run `bunx --yes @openai/codex login` on the host (no global install needed), "
|
|
946
|
+
"complete browser sign-in, then retry.\n"
|
|
947
|
+
f"Details: {detail}"
|
|
948
|
+
),
|
|
949
|
+
"exitCode": int(login_status.get("exitCode") or 1),
|
|
950
|
+
}
|
|
951
|
+
else:
|
|
952
|
+
final_key = llm_key or existing_openai_key
|
|
953
|
+
if not final_key:
|
|
954
|
+
return {
|
|
955
|
+
"ok": False,
|
|
956
|
+
"summary": "openai_codex api_key auth requires OPENAI_API_KEY",
|
|
957
|
+
"stderr": (
|
|
958
|
+
"API-key auth mode selected, but no API key is available. "
|
|
959
|
+
"Set OPENAI_API_KEY (or WORKERPALS_LLM_API_KEY), "
|
|
960
|
+
"or set PUSHPALS_OPENAI_CODEX_AUTH_MODE=chatgpt."
|
|
961
|
+
),
|
|
962
|
+
"exitCode": 2,
|
|
963
|
+
}
|
|
964
|
+
env["OPENAI_API_KEY"] = final_key
|
|
965
|
+
effective_base = override_base or base_url
|
|
966
|
+
if (
|
|
967
|
+
not override_base
|
|
968
|
+
and not existing_openai_base
|
|
969
|
+
and looks_local_base_url(base_url)
|
|
970
|
+
and (env.get("OPENAI_API_KEY") or "").strip()
|
|
971
|
+
):
|
|
972
|
+
# If an OpenAI key exists but base URL came from local worker LLM config,
|
|
973
|
+
# prefer Codex/OpenAI defaults unless explicitly overridden.
|
|
974
|
+
log.info(
|
|
975
|
+
"Detected local worker LLM endpoint with OPENAI_API_KEY present; "
|
|
976
|
+
"using Codex default OpenAI endpoint (set PUSHPALS_OPENAI_CODEX_BASE_URL "
|
|
977
|
+
"to force local)."
|
|
978
|
+
)
|
|
979
|
+
effective_base = ""
|
|
980
|
+
if effective_base:
|
|
981
|
+
env["OPENAI_BASE_URL"] = effective_base
|
|
982
|
+
env["OPENAI_API_BASE"] = effective_base
|
|
983
|
+
else:
|
|
984
|
+
env.pop("OPENAI_BASE_URL", None)
|
|
985
|
+
env.pop("OPENAI_API_BASE", None)
|
|
986
|
+
|
|
987
|
+
log.info(f"Starting codex exec in {repo}")
|
|
988
|
+
log.debug(f"Codex command: {' '.join(codex_cmd_prefix)}")
|
|
989
|
+
log.debug(f"Model: {model}")
|
|
990
|
+
base_for_log = (
|
|
991
|
+
env.get("OPENAI_BASE_URL", "").strip()
|
|
992
|
+
or env.get("OPENAI_API_BASE", "").strip()
|
|
993
|
+
or "<default>"
|
|
994
|
+
)
|
|
995
|
+
log.debug(f"Base URL: {base_for_log}")
|
|
996
|
+
if communicate_timeout_s:
|
|
997
|
+
log.debug(f"communicate timeout: {communicate_timeout_s}s")
|
|
998
|
+
|
|
999
|
+
proc = subprocess.Popen(
|
|
1000
|
+
cmd,
|
|
1001
|
+
cwd=repo,
|
|
1002
|
+
env=env,
|
|
1003
|
+
stdout=subprocess.PIPE,
|
|
1004
|
+
stderr=subprocess.PIPE,
|
|
1005
|
+
stdin=subprocess.PIPE,
|
|
1006
|
+
text=True,
|
|
1007
|
+
encoding="utf-8",
|
|
1008
|
+
errors="replace",
|
|
1009
|
+
)
|
|
1010
|
+
_ACTIVE_CHILD = proc
|
|
1011
|
+
started_at = time.monotonic()
|
|
1012
|
+
progress_interval_s = _resolve_progress_log_interval_seconds(runtime_config)
|
|
1013
|
+
|
|
1014
|
+
stdout_chunks: List[str] = []
|
|
1015
|
+
stderr_chunks: List[str] = []
|
|
1016
|
+
stdout_trace_state = _empty_codex_trace()
|
|
1017
|
+
trace_lock = threading.Lock()
|
|
1018
|
+
last_activity_at = {"ts": started_at}
|
|
1019
|
+
|
|
1020
|
+
def _drain_stdout() -> None:
|
|
1021
|
+
stream = proc.stdout
|
|
1022
|
+
if stream is None:
|
|
1023
|
+
return
|
|
1024
|
+
try:
|
|
1025
|
+
for chunk in iter(stream.readline, ""):
|
|
1026
|
+
if chunk == "":
|
|
1027
|
+
break
|
|
1028
|
+
stdout_chunks.append(chunk)
|
|
1029
|
+
line = chunk.strip()
|
|
1030
|
+
if not line:
|
|
1031
|
+
continue
|
|
1032
|
+
with trace_lock:
|
|
1033
|
+
last_activity_at["ts"] = time.monotonic()
|
|
1034
|
+
_record_live_codex_stdout_line(line, use_json, stdout_trace_state)
|
|
1035
|
+
except Exception:
|
|
1036
|
+
pass
|
|
1037
|
+
finally:
|
|
1038
|
+
try:
|
|
1039
|
+
stream.close()
|
|
1040
|
+
except Exception:
|
|
1041
|
+
pass
|
|
1042
|
+
|
|
1043
|
+
def _drain_stderr() -> None:
|
|
1044
|
+
stream = proc.stderr
|
|
1045
|
+
if stream is None:
|
|
1046
|
+
return
|
|
1047
|
+
try:
|
|
1048
|
+
for chunk in iter(stream.readline, ""):
|
|
1049
|
+
if chunk == "":
|
|
1050
|
+
break
|
|
1051
|
+
stderr_chunks.append(chunk)
|
|
1052
|
+
except Exception:
|
|
1053
|
+
pass
|
|
1054
|
+
finally:
|
|
1055
|
+
try:
|
|
1056
|
+
stream.close()
|
|
1057
|
+
except Exception:
|
|
1058
|
+
pass
|
|
1059
|
+
|
|
1060
|
+
stdout_thread = threading.Thread(target=_drain_stdout, daemon=True)
|
|
1061
|
+
stderr_thread = threading.Thread(target=_drain_stderr, daemon=True)
|
|
1062
|
+
stdout_thread.start()
|
|
1063
|
+
stderr_thread.start()
|
|
1064
|
+
|
|
1065
|
+
if proc.stdin is not None:
|
|
1066
|
+
try:
|
|
1067
|
+
proc.stdin.write(prompt)
|
|
1068
|
+
proc.stdin.close()
|
|
1069
|
+
except Exception:
|
|
1070
|
+
pass
|
|
1071
|
+
|
|
1072
|
+
deadline = (
|
|
1073
|
+
started_at + float(communicate_timeout_s)
|
|
1074
|
+
if communicate_timeout_s and communicate_timeout_s > 0
|
|
1075
|
+
else None
|
|
1076
|
+
)
|
|
1077
|
+
next_progress_at = started_at + float(progress_interval_s)
|
|
1078
|
+
timed_out = False
|
|
1079
|
+
|
|
1080
|
+
while proc.poll() is None:
|
|
1081
|
+
now = time.monotonic()
|
|
1082
|
+
if deadline is not None and now >= deadline:
|
|
1083
|
+
timed_out = True
|
|
1084
|
+
_terminate_active_child()
|
|
1085
|
+
break
|
|
1086
|
+
|
|
1087
|
+
if now >= next_progress_at:
|
|
1088
|
+
elapsed = int(max(0.0, now - started_at))
|
|
1089
|
+
with trace_lock:
|
|
1090
|
+
last_event = float(last_activity_at.get("ts", started_at))
|
|
1091
|
+
valid_json = to_int(stdout_trace_state.get("valid_json"), 0)
|
|
1092
|
+
total_lines = to_int(stdout_trace_state.get("line_count"), 0)
|
|
1093
|
+
idle_for = int(max(0.0, now - last_event))
|
|
1094
|
+
if use_json:
|
|
1095
|
+
log.info(
|
|
1096
|
+
f"codex exec still running ({elapsed}s elapsed, json_events={valid_json}, idle={idle_for}s)"
|
|
1097
|
+
)
|
|
1098
|
+
else:
|
|
1099
|
+
log.info(
|
|
1100
|
+
f"codex exec still running ({elapsed}s elapsed, stdout_lines={total_lines}, idle={idle_for}s)"
|
|
1101
|
+
)
|
|
1102
|
+
next_progress_at = now + float(progress_interval_s)
|
|
1103
|
+
|
|
1104
|
+
time.sleep(1.0)
|
|
1105
|
+
|
|
1106
|
+
try:
|
|
1107
|
+
proc.wait(timeout=5)
|
|
1108
|
+
except Exception:
|
|
1109
|
+
try:
|
|
1110
|
+
proc.kill()
|
|
1111
|
+
proc.wait(timeout=5)
|
|
1112
|
+
except Exception:
|
|
1113
|
+
pass
|
|
1114
|
+
|
|
1115
|
+
stdout_thread.join(timeout=2)
|
|
1116
|
+
stderr_thread.join(timeout=2)
|
|
1117
|
+
|
|
1118
|
+
return_code = proc.returncode
|
|
1119
|
+
_ACTIVE_CHILD = None
|
|
1120
|
+
elapsed_total = int(max(0.0, time.monotonic() - started_at))
|
|
1121
|
+
log.info(f"codex exec finished in {elapsed_total}s")
|
|
1122
|
+
|
|
1123
|
+
stdout = "".join(stdout_chunks)
|
|
1124
|
+
stderr = "".join(stderr_chunks)
|
|
1125
|
+
stdout_trace = _finalize_codex_stdout_trace(stdout_trace_state, use_json)
|
|
1126
|
+
trace_excerpt = _format_codex_trace_excerpt(stdout_trace)
|
|
1127
|
+
_log_stderr(stderr)
|
|
1128
|
+
|
|
1129
|
+
if timed_out:
|
|
1130
|
+
detail = (
|
|
1131
|
+
f"codex exec timed out after {communicate_timeout_s}s"
|
|
1132
|
+
if communicate_timeout_s
|
|
1133
|
+
else "codex exec timed out"
|
|
1134
|
+
)
|
|
1135
|
+
if trace_excerpt:
|
|
1136
|
+
detail = f"{detail}\n{trace_excerpt}"
|
|
1137
|
+
return {
|
|
1138
|
+
"ok": False,
|
|
1139
|
+
"summary": "openai_codex execution timed out",
|
|
1140
|
+
"stdout": _truncate(stdout),
|
|
1141
|
+
"stderr": _truncate(f"{detail}\n{stderr}".strip()),
|
|
1142
|
+
"exitCode": 124,
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
last_message = _read_text_if_exists(last_message_path)
|
|
1146
|
+
log_git_status(repo, log)
|
|
1147
|
+
|
|
1148
|
+
if _INTERRUPTED_SIGNAL is not None:
|
|
1149
|
+
return {
|
|
1150
|
+
"ok": False,
|
|
1151
|
+
"summary": f"openai_codex interrupted by signal {_INTERRUPTED_SIGNAL}",
|
|
1152
|
+
"stdout": _truncate(stdout),
|
|
1153
|
+
"stderr": _truncate(stderr),
|
|
1154
|
+
"exitCode": 128 + int(_INTERRUPTED_SIGNAL),
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if return_code is None:
|
|
1158
|
+
return {
|
|
1159
|
+
"ok": False,
|
|
1160
|
+
"summary": "openai_codex execution ended without a process return code",
|
|
1161
|
+
"stdout": _truncate(stdout),
|
|
1162
|
+
"stderr": _truncate(stderr),
|
|
1163
|
+
"exitCode": 1,
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
exit_code = int(return_code)
|
|
1167
|
+
|
|
1168
|
+
if exit_code != 0:
|
|
1169
|
+
detail = stderr.strip() or stdout.strip() or "codex exec exited with a non-zero status"
|
|
1170
|
+
if last_message:
|
|
1171
|
+
detail = f"{detail}\nLast assistant message:\n{last_message}"
|
|
1172
|
+
if trace_excerpt:
|
|
1173
|
+
detail = f"{detail}\n{trace_excerpt}"
|
|
1174
|
+
return {
|
|
1175
|
+
"ok": False,
|
|
1176
|
+
"summary": f"openai_codex execution failed (exit {exit_code})",
|
|
1177
|
+
"stdout": _truncate(stdout),
|
|
1178
|
+
"stderr": _truncate(detail),
|
|
1179
|
+
"exitCode": exit_code,
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
policy_signal = _detect_codex_workaround_signal(last_message)
|
|
1183
|
+
if not policy_signal and not last_message.strip():
|
|
1184
|
+
# Fallback only when the CLI did not emit a final assistant message.
|
|
1185
|
+
policy_signal = _detect_codex_workaround_signal(stdout)
|
|
1186
|
+
if policy_signal:
|
|
1187
|
+
detail = (
|
|
1188
|
+
"Codex CLI is mandatory in this backend, but worker output suggests a workaround "
|
|
1189
|
+
f"instead of hard-failing: {policy_signal!r}. "
|
|
1190
|
+
"Return an explicit failure if Codex auth/execution is unavailable."
|
|
1191
|
+
)
|
|
1192
|
+
if last_message:
|
|
1193
|
+
detail = f"{detail}\nLast assistant message:\n{last_message}"
|
|
1194
|
+
if trace_excerpt:
|
|
1195
|
+
detail = f"{detail}\n{trace_excerpt}"
|
|
1196
|
+
return {
|
|
1197
|
+
"ok": False,
|
|
1198
|
+
"summary": "openai_codex policy violation: Codex CLI workaround detected",
|
|
1199
|
+
"stdout": _truncate(stdout),
|
|
1200
|
+
"stderr": _truncate(detail),
|
|
1201
|
+
"exitCode": 5,
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
changed_paths = summarize_git_changes(repo)
|
|
1205
|
+
delta = [p for p in changed_paths if p not in baseline_changes]
|
|
1206
|
+
effective = delta if delta else changed_paths
|
|
1207
|
+
stdout_parts: List[str] = []
|
|
1208
|
+
if last_message:
|
|
1209
|
+
stdout_parts.append(last_message)
|
|
1210
|
+
elif trace_excerpt:
|
|
1211
|
+
stdout_parts.append(trace_excerpt)
|
|
1212
|
+
if effective:
|
|
1213
|
+
listed = "\n".join(f"- {path}" for path in effective[:40])
|
|
1214
|
+
if len(effective) > 40:
|
|
1215
|
+
listed += "\n- ..."
|
|
1216
|
+
stdout_parts.append(f"Changed files:\n{listed}")
|
|
1217
|
+
return {
|
|
1218
|
+
"ok": True,
|
|
1219
|
+
"summary": f"Executed task and modified {len(effective)} file(s)",
|
|
1220
|
+
"stdout": "\n\n".join(stdout_parts),
|
|
1221
|
+
"stderr": "",
|
|
1222
|
+
"exitCode": 0,
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
if not stdout_parts:
|
|
1226
|
+
stdout_parts.append("No modified files were detected after execution.")
|
|
1227
|
+
return {
|
|
1228
|
+
"ok": True,
|
|
1229
|
+
"summary": "Executed task via openai_codex (no file changes detected)",
|
|
1230
|
+
"stdout": "\n\n".join(stdout_parts),
|
|
1231
|
+
"stderr": "",
|
|
1232
|
+
"exitCode": 0,
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def main() -> int:
|
|
1237
|
+
try:
|
|
1238
|
+
task = parse_task_execute_payload(sys.argv, logger=log)
|
|
1239
|
+
result = _run_codex_task(
|
|
1240
|
+
task.repo,
|
|
1241
|
+
task.instruction,
|
|
1242
|
+
task.supplemental_guidance,
|
|
1243
|
+
)
|
|
1244
|
+
except Exception as exc:
|
|
1245
|
+
result = {
|
|
1246
|
+
"ok": False,
|
|
1247
|
+
"summary": "openai_codex wrapper crashed while executing task.execute",
|
|
1248
|
+
"stdout": "",
|
|
1249
|
+
"stderr": traceback.format_exc(),
|
|
1250
|
+
"exitCode": 1,
|
|
1251
|
+
"error": to_single_line(exc, 300),
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
emit(result)
|
|
1255
|
+
return 0 if bool(result.get("ok")) else to_int(result.get("exitCode"), 1)
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
if __name__ == "__main__":
|
|
1259
|
+
raise SystemExit(main())
|