-
Notifications
You must be signed in to change notification settings - Fork 12
fix(code): preserve completed Codex review feedback #204
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| #!/usr/bin/env python3 | ||
| """Extract a completed Codex review from UTF-8 JSONL without losing old feedback.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import os | ||
| import sys | ||
| import tempfile | ||
| from pathlib import Path | ||
|
|
||
|
|
||
| def extract(source: Path, feedback: Path) -> str: | ||
| messages: list[str] = [] | ||
| thread_id = "" | ||
| completed = False | ||
| line_number = 0 | ||
| with source.open("r", encoding="utf-8") as stream: | ||
| for line_number, line in enumerate(stream, 1): | ||
| try: | ||
| event = json.loads(line) | ||
| except json.JSONDecodeError as exc: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This makes any single unparseable line fatal for the whole stream, which reintroduces the bug you're fixing with the sign flipped. Repro against your parser: printf '{"type":"thread.started","thread_id":"t1"}\n\n{"type":"item.completed","item":{"type":"agent_message","text":"VERDICT: APPROVED"}}\n{"type":"turn.completed"}\n' > /tmp/blank.jsonl feedback extraction failed from /tmp/blank.jsonl: JSONL line 2: Expecting valueexit=2, no feedback writtenOne blank line and a completed, approved review with the verdict sitting right there in the log gets thrown away and reported as CODEX_FAILED. The old inline parser swallowed per-line errors, so this is a behavior regression on a stdout format we don't own; any stray newline, deprecation notice, or progress line the codex CLI ever prints turns a paid review into a failure. The guarantee you actually want is "don't publish unless turn.completed and an agent message were seen" and that doesn't require per-line strictness. Skip lines where |
||
| raise ValueError(f"JSONL line {line_number}: {exc.msg}") from exc | ||
| if not isinstance(event, dict): | ||
| raise TypeError(f"JSONL line {line_number}: event must be an object") | ||
| kind = event.get("type") | ||
| if kind == "thread.started" and isinstance(event.get("thread_id"), str): | ||
| thread_id = event["thread_id"] | ||
| elif kind == "item.completed": | ||
| item = event.get("item") | ||
| if not isinstance(item, dict): | ||
| raise ValueError(f"JSONL line {line_number}: item must be an object") | ||
| if item.get("type") == "agent_message": | ||
| message = item.get("text") | ||
| if not isinstance(message, str): | ||
| raise ValueError(f"JSONL line {line_number}: agent text must be a string") | ||
| if message: | ||
| messages.append(message) | ||
| elif kind == "turn.completed": | ||
| completed = True | ||
|
|
||
| if not completed: | ||
| raise ValueError(f"JSONL ended after line {line_number} without turn.completed") | ||
| if not messages: | ||
| raise ValueError("completed JSONL contains no agent message") | ||
|
|
||
| payload = "\n".join(messages).encode("utf-8") | ||
| feedback.parent.mkdir(parents=True, exist_ok=True) | ||
| staged: Path | None = None | ||
| try: | ||
| with tempfile.NamedTemporaryFile( | ||
| mode="wb", prefix=".codex-feedback-", dir=feedback.parent, delete=False, | ||
| ) as stream: | ||
| staged = Path(stream.name) | ||
| stream.write(payload) | ||
| stream.flush() | ||
| os.fsync(stream.fileno()) | ||
| os.replace(staged, feedback) | ||
| staged = None | ||
| finally: | ||
| if staged is not None: | ||
| staged.unlink(missing_ok=True) | ||
| return thread_id | ||
|
|
||
|
|
||
| def session_from_partial(source: Path) -> str: | ||
| """Recover an already-started session without accepting partial feedback.""" | ||
| try: | ||
| with source.open("r", encoding="utf-8") as stream: | ||
| for line in stream: | ||
| try: | ||
| event = json.loads(line) | ||
| except json.JSONDecodeError: | ||
| return "" | ||
| if isinstance(event, dict) and event.get("type") == "thread.started": | ||
| thread_id = event.get("thread_id") | ||
| return thread_id if isinstance(thread_id, str) else "" | ||
| except (OSError, UnicodeError): | ||
| pass | ||
| return "" | ||
|
|
||
|
|
||
| def main() -> int: | ||
| if len(sys.argv) != 3: | ||
| print("usage: parse_codex_json.py JSONL FEEDBACK", file=sys.stderr) | ||
| return 2 | ||
| source, feedback = map(Path, sys.argv[1:]) | ||
| try: | ||
| thread_id = extract(source, feedback) | ||
| except (OSError, UnicodeError, TypeError, ValueError) as exc: | ||
| sys.stdout.buffer.write(session_from_partial(source).encode("utf-8")) | ||
| print(f"feedback extraction failed from {source}: {exc}", file=sys.stderr) | ||
| return 2 | ||
| # Codex session IDs are ASCII. Avoid the host's stdout encoding for the payload. | ||
| sys.stdout.buffer.write(thread_id.encode("utf-8")) | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| raise SystemExit(main()) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,7 @@ | |
| # Diagnostics go to stderr only. | ||
|
|
||
| set -euo pipefail | ||
| umask 077 | ||
|
|
||
| # Single source of truth for the state directory name | ||
| CLOSEDLOOP_STATE_DIR=".closedloop-ai" | ||
|
|
@@ -77,6 +78,8 @@ fi | |
| LOG_DIR="$HOME/$CLOSEDLOOP_STATE_DIR/plan-with-codex" | ||
| mkdir -p "$LOG_DIR" | ||
| LOG_FILE="$LOG_DIR/$LOG_ID.jsonl" | ||
| ERROR_LOG="$LOG_DIR/$LOG_ID.stderr" | ||
| SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) | ||
|
|
||
| # ── Temp directory with cleanup ─────────────────────────────────────────────── | ||
|
|
||
|
|
@@ -220,40 +223,23 @@ PROMPT_EOF | |
|
|
||
| # ── JSON parsing helpers ───────────────────────────────────────────────────── | ||
|
|
||
| # Extract thread_id from thread.started event in JSON stream. | ||
| # Prints the thread_id or empty string. | ||
| parse_thread_id() { | ||
| python3 -c " | ||
| import json, sys | ||
| for line in open(sys.argv[1]): | ||
| try: | ||
| e = json.loads(line.strip()) | ||
| if e.get('type') == 'thread.started' and e.get('thread_id'): | ||
| print(e['thread_id']); break | ||
| except Exception: | ||
| pass | ||
| " "$1" 2>/dev/null || true | ||
| } | ||
|
|
||
| # Extract agent_message text from item.completed events. | ||
| # Writes concatenated text to the specified output file. | ||
| # Publish feedback only after the complete JSONL stream validates. | ||
| parse_feedback_text() { | ||
| local json_file="$1" | ||
| local output_file="$2" | ||
| python3 -c " | ||
| import json, sys | ||
| lines = [] | ||
| for line in open(sys.argv[1]): | ||
| try: | ||
| e = json.loads(line.strip()) | ||
| if e.get('type') == 'item.completed': | ||
| item = e.get('item', {}) | ||
| if item.get('type') == 'agent_message' and item.get('text'): | ||
| lines.append(item['text']) | ||
| except Exception: | ||
| pass | ||
| sys.stdout.write('\n'.join(lines)) | ||
| " "$json_file" > "$output_file" 2>/dev/null | ||
| local parsed_session | ||
| if ! parsed_session=$(python3 "$SCRIPT_DIR/parse_codex_json.py" "$json_file" "$FEEDBACK_FILE" 2>> "$ERROR_LOG"); then | ||
| if [[ -n "$parsed_session" ]]; then | ||
| effective_session_id="$parsed_session" | ||
| fi | ||
| echo "Feedback extraction failed; details retained in $ERROR_LOG" >&2 | ||
| echo "CODEX_FAILED:feedback extraction failed; log=$LOG_FILE; diagnostics=$ERROR_LOG" | ||
| echo "CODEX_SESSION:${effective_session_id:-none}" | ||
| echo "LOG_ID:$LOG_ID" | ||
| return 1 | ||
| fi | ||
| if [[ -n "$parsed_session" ]]; then | ||
| effective_session_id="$parsed_session" | ||
| fi | ||
| } | ||
|
|
||
| # ── Run codex ──────────────────────────────────────────────────────────────── | ||
|
|
@@ -263,7 +249,7 @@ run_codex_cmd() { | |
| # Log round header | ||
| printf '\n--- Round %s | %s ---\n' "$ROUND" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$LOG_FILE" | ||
| # Tee raw JSON stream to both the capture file and the persistent log | ||
| codex "$@" 2>/dev/null | tee -a "$LOG_FILE" > "$json_out" | ||
| codex "$@" 2>> "$ERROR_LOG" | tee -a "$LOG_FILE" > "$json_out" | ||
| } | ||
|
|
||
| effective_session_id="$SESSION_ID" | ||
|
|
@@ -280,48 +266,25 @@ if [[ -n "$SESSION_ID" ]]; then | |
| codex_exit=$? | ||
| set -e | ||
|
|
||
| new_session=$(parse_thread_id "$codex_json") | ||
| if [[ -n "$new_session" ]]; then | ||
| effective_session_id="$new_session" | ||
| fi | ||
| # else: preserve the input SESSION_ID | ||
|
|
||
| parse_feedback_text "$codex_json" "$FEEDBACK_FILE" | ||
|
|
||
| if [[ $codex_exit -eq 0 ]] || [[ -s "$FEEDBACK_FILE" ]]; then | ||
| # Resume succeeded -- skip to verdict extraction | ||
| : | ||
| else | ||
| if [[ $codex_exit -ne 0 ]] && [[ ! -s "$codex_json" ]]; then | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Narrowing the fresh-session fallback to a totally empty stream is right in spirit, but You already have the right signal in |
||
| # Only an empty failed resume can safely start a fresh review. A partial | ||
| # stream may already contain paid work and must be diagnosed from its log. | ||
| echo "Codex session resume failed, starting fresh session..." >&2 | ||
| effective_session_id="" | ||
| rm -f "$codex_json" | ||
|
|
||
| # Fall through to fresh session below | ||
| set +e | ||
| run_codex_cmd "$codex_json" exec "${base_args[@]}" "$prompt_content" | ||
| codex_exit=$? | ||
| set -e | ||
|
|
||
| new_session=$(parse_thread_id "$codex_json") | ||
| if [[ -n "$new_session" ]]; then | ||
| effective_session_id="$new_session" | ||
| fi | ||
|
|
||
| parse_feedback_text "$codex_json" "$FEEDBACK_FILE" | ||
| fi | ||
| if ! parse_feedback_text "$codex_json"; then exit 0; fi | ||
| else | ||
| # No session to resume -- fresh start | ||
| set +e | ||
| run_codex_cmd "$codex_json" exec "${base_args[@]}" "$prompt_content" | ||
| codex_exit=$? | ||
| set -e | ||
|
|
||
| new_session=$(parse_thread_id "$codex_json") | ||
| if [[ -n "$new_session" ]]; then | ||
| effective_session_id="$new_session" | ||
| fi | ||
|
|
||
| parse_feedback_text "$codex_json" "$FEEDBACK_FILE" | ||
| if ! parse_feedback_text "$codex_json"; then exit 0; fi | ||
| fi | ||
|
|
||
| # ── Emit structured tokens ─────────────────────────────────────────────────── | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Heads up that CLAUDE.md says new tool scripts are TypeScript, no new Python. I don't think it bites here, since run_codex_review.sh already hard-requires python3 at line 63 and this is inline heredoc Python moved into a file rather than a new tool, but worth a maintainer ruling before it sets precedent for the next skill.