Files
skills-win/langgraph-plan-review/scripts/review_task.py
T
2026-09-09 09:51:06 +08:00

204 lines
7.4 KiB
Python

from __future__ import annotations
"""Codex review shim called by agents.py.
argv: task_path latest_discuss_path codex_session_id work_dir
"""
import json
import logging
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Any
def parse_jsonl(stdout: str) -> tuple[str, str]:
session_id = ""
final_message = ""
text_parts: list[str] = []
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
try:
event: dict[str, Any] = json.loads(line)
except json.JSONDecodeError:
text_parts.append(line)
continue
for key in (
"session_id",
"sessionId",
"thread_id",
"threadId",
"conversation_id",
"conversationId",
):
value = event.get(key)
if isinstance(value, str) and value:
session_id = value
event_type = str(event.get("type", ""))
if event_type in {"session.started", "session.created", "thread.started"}:
value = event.get("id") or event.get("session_id") or event.get("thread_id")
if isinstance(value, str) and value:
session_id = value
if event_type == "item.completed":
item = event.get("item")
if isinstance(item, dict) and item.get("type") == "agent_message":
value = item.get("text")
if isinstance(value, str) and value:
text_parts.append(value)
if event_type in {"agent_message", "message", "response.output_text.done"}:
value = event.get("text") or event.get("message") or event.get("content")
if isinstance(value, str) and value:
text_parts.append(value)
if event_type in {"exec.completed", "turn.completed", "task.completed"}:
value = event.get("last_message") or event.get("message") or event.get("output")
if isinstance(value, str) and value:
final_message = value
comment = (final_message or "\n".join(text_parts)).strip()
return session_id, comment
def resolve_codex() -> str:
import shutil
# 1. Explicit env var
exe = os.environ.get("CODEX_EXE", "")
if exe and Path(exe).exists():
return exe
# 2. Known install paths (checked before PATH to avoid picking up wrong codex variant)
home = Path.home()
for candidate in [
home / "miniconda3" / "envs" / "langgraph" / "codex.cmd",
home / ".conda" / "envs" / "langgraph" / "codex.cmd",
]:
if candidate.exists():
return str(candidate)
# 3. PATH lookup fallback
found = shutil.which("codex") or shutil.which("codex.cmd")
if found:
return found
raise FileNotFoundError(
"Cannot find codex executable. Set CODEX_EXE environment variable or add codex to PATH."
)
def main() -> int:
task_path = sys.argv[1]
latest_discuss = sys.argv[2] if len(sys.argv) > 2 and sys.argv[2] else ""
codex_session_id = sys.argv[3] if len(sys.argv) > 3 and sys.argv[3] else ""
work_dir = sys.argv[4] if len(sys.argv) > 4 and sys.argv[4] else str(Path.cwd())
timeout = int(sys.argv[5]) if len(sys.argv) > 5 and sys.argv[5] else 300
prompt = (
f"Working directory: {work_dir}\n"
f"Review the plan document at {task_path}.\n"
"Only report severe issues.\n"
"Final answer rules: if there is no severe issue, output exactly NO_SERIOUS_ISSUE.\n"
"If there is a severe issue, briefly describe it in one paragraph.\n"
)
if latest_discuss:
prompt += f"Also verify that issues raised in {latest_discuss} were addressed.\n"
codex = resolve_codex()
fd, last_message_name = tempfile.mkstemp(
prefix="codex-last-message-",
suffix=".txt",
dir=None,
text=True,
)
os.close(fd)
last_message_path = Path(last_message_name)
try:
# Sandbox mode: default "read-only" -- review needs no writes, and on
# Windows the config.toml "elevated" sandbox hangs every shell command
# when codex runs outside the ChatGPT app (probed 2026-08-26).
# Pass it with -c, NOT -s: `codex exec` accepts -s but `codex exec
# resume` does not, and the second and later review passes go through
# resume -- an -s here fails with a bare
# For more information, try '--help'
# two seconds in, on exactly the passes that matter.
# (unquoted value: fails TOML parse -> codex falls back to the raw
# string, which survives cmd /c quoting untouched)
#
# Overridable via CODEX_SANDBOX because codex's Windows sandbox cannot
# start on every machine. Observed with codex-cli 0.147.0:
#
# execution error: Io(Custom { kind: Other, error: "windows sandbox:
# orchestrator_helper_launch_canceled: ShellExecuteExW failed to
# launch setup helper: 1223" })
#
# 1223 is ERROR_CANCELLED -- the helper's UAC elevation prompt was
# declined. The sandbox then never comes up, every shell command the
# reviewer issues fails, and codex answers "Unable to read file."
#
# THAT FAILURE IS INDISTINGUISHABLE FROM A CLEAN REVIEW unless someone
# reads the discuss file: the workflow still completes, still writes a
# discuss file, and the text inside is a polite sentence rather than an
# error. Same class as the known "instant NO_SERIOUS_ISSUE" trap.
# On such a machine set CODEX_SANDBOX=danger-full-access.
sandbox = os.environ.get("CODEX_SANDBOX", "").strip() or "read-only"
flags = ["--json", "--skip-git-repo-check", "-c", "sandbox_mode=" + sandbox,
"--output-last-message", str(last_message_path)]
if codex_session_id:
command_args = ["cmd", "/c", codex, "exec", "resume"] + flags + [codex_session_id, "-"]
else:
command_args = ["cmd", "/c", codex, "exec"] + flags + ["-"]
logging.info("Invoking codex: %s", command_args[0] if command_args else "")
t0 = time.monotonic()
result = subprocess.run(
command_args,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
input=prompt,
cwd=work_dir,
)
logging.info("Codex finished in %.1fs, exit=%d", time.monotonic() - t0, result.returncode)
session_id, stream_comment = parse_jsonl(result.stdout)
comment = last_message_path.read_text(encoding="utf-8").strip()
if not comment:
comment = stream_comment
if comment.strip() == "NO_SERIOUS_ISSUE":
comment = ""
if codex_session_id:
session_id = codex_session_id
elif not session_id:
session_id = codex_session_id
finally:
try:
last_message_path.unlink()
except FileNotFoundError:
pass
if result.returncode != 0 and not comment:
sys.stderr.write(result.stderr)
return result.returncode
print(json.dumps(
{
"has_serious_issue": bool(comment),
"comment": comment,
"codex_session_id": session_id,
},
ensure_ascii=False,
))
return 0
if __name__ == "__main__":
raise SystemExit(main())