fix(skillopt-sleep): surface codex auth/model/version failures instead of silently scoring 0
A nightly sleep cycle could run for weeks emitting held-out 0.0 -> 0.0 (gate reject, zero edits), indistinguishable from "nothing to learn", when the real cause was the codex backend returning an error (expired auth / model unsupported on the account / outdated CLI) that got scored as a failed rollout. backend (CodexCliBackend): - split _call into _call_once + a retry wrapper: transient empties/timeouts are retried instead of silently returning "" (mirrors AzureOpenAIBackend's guard); - on a non-zero exit, surface the reason via last_call_error and return "" rather than leaking the CLI error text as if it were a model response; - fail fast (no retries) on fatal auth/model/version errors (401, refresh_token_reused, token_expired, "not supported when using Codex with a ChatGPT account", "requires a newer version of Codex"). backend (CliBackend.reflect): retain last_reflect_raw so a no-edits night is diagnosable. consolidate: ConsolidationResult now carries per-task held-out detail (response, hard/soft, fail_reason) + reflect_raw + call_error. cycle: write diagnostics.json per cycle so a 0.0 night self-explains instead of being a black box. tests: 4 new (retry-not-silent-zero, auth-error-surfaced-not-scored, holdout-detail, reflect-raw). Also gitignore the .skillopt-sleep/ runtime dir. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -520,6 +520,10 @@ class CliBackend(Backend):
|
||||
arr = _extract_json(raw, "array")
|
||||
if isinstance(arr, list) and arr:
|
||||
break
|
||||
# Expose the last raw optimizer reply so a no-edits night is diagnosable:
|
||||
# a 0.0->0.0 gate with zero edits is otherwise indistinguishable from
|
||||
# "nothing to learn" (the cycle persists this in diagnostics.json).
|
||||
self.last_reflect_raw = raw or ""
|
||||
edits: List[EditRecord] = []
|
||||
if isinstance(arr, list):
|
||||
for e in arr[:edit_budget]:
|
||||
@@ -750,9 +754,11 @@ class CodexCliBackend(CliBackend):
|
||||
os.path.abspath(os.path.expanduser(project_dir)) if project_dir else ""
|
||||
)
|
||||
|
||||
def _call(self, prompt: str, *, max_tokens: int = 1024) -> str:
|
||||
def _call_once(self, prompt: str, *, max_tokens: int = 1024) -> str:
|
||||
"""One codex exec attempt: returns the response text, or "" on
|
||||
timeout/exception/empty-output (with last_call_error set). ``_call``
|
||||
wraps this with retries so a transient failure is NOT silently scored 0."""
|
||||
import tempfile
|
||||
self.last_call_error = ""
|
||||
out_path = tempfile.NamedTemporaryFile(
|
||||
prefix="codex_last_", suffix=".txt", delete=False
|
||||
).name
|
||||
@@ -793,6 +799,12 @@ class CodexCliBackend(CliBackend):
|
||||
stderr = (proc.stderr or "").strip() if proc is not None else ""
|
||||
if proc is not None and proc.returncode != 0 and not self.last_call_error:
|
||||
self.last_call_error = f"codex exec exited {proc.returncode}: {stderr[:500]}"
|
||||
# Do NOT return the CLI's error text as if it were a model response: it
|
||||
# pollutes rollout/judge/reflect and gets silently scored 0, hiding the
|
||||
# real cause (e.g. an expired codex auth token surfacing as a 9k-char 401).
|
||||
# Surface it via last_call_error and return empty instead.
|
||||
if self.last_call_error:
|
||||
return ""
|
||||
return stdout or stderr
|
||||
finally:
|
||||
try:
|
||||
@@ -800,6 +812,45 @@ class CodexCliBackend(CliBackend):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fatal codex failures that will NOT recover on retry — fail fast + loud so a
|
||||
# 0.0 night reads as "codex auth/model/version problem" not "nothing to learn".
|
||||
# Covers: auth (re-login), and 400 config errors like an unsupported model on a
|
||||
# ChatGPT account or a model that needs a newer codex CLI (upgrade).
|
||||
_AUTH_MARKERS = (
|
||||
"401 Unauthorized", "refresh_token_reused", "token_expired",
|
||||
"Please log out and sign in", "Not logged in", "Please run /login",
|
||||
"authentication token is expired", "Unauthorized: invalid",
|
||||
"is not supported when using Codex", "requires a newer version of Codex",
|
||||
)
|
||||
|
||||
def _call(self, prompt: str, *, max_tokens: int = 1024, retries: int = 3) -> str:
|
||||
"""Retry transient empties/timeouts instead of silently returning "".
|
||||
|
||||
An empty reply scores 0 on every judge, which deflates the held-out
|
||||
baseline AND blocks the candidate from ever improving — making a flaky
|
||||
backend indistinguishable from "nothing to learn". The Azure backend
|
||||
already guards this way (AzureOpenAIBackend._call); codex now does too.
|
||||
Auth errors are NOT retried (hopeless until the user re-logs-in).
|
||||
"""
|
||||
import logging
|
||||
import random as _r
|
||||
import time as _t
|
||||
out = ""
|
||||
for attempt in range(max(1, retries)):
|
||||
self.last_call_error = ""
|
||||
out = self._call_once(prompt, max_tokens=max_tokens)
|
||||
if out:
|
||||
return out
|
||||
err = self.last_call_error or ""
|
||||
if any(m in err for m in self._AUTH_MARKERS):
|
||||
logging.getLogger("skillopt_sleep").error(
|
||||
"codex auth error — re-login required (`codex login`): %s", err[:200]
|
||||
)
|
||||
break # fail fast: retrying a 401 just burns calls
|
||||
if attempt < retries - 1:
|
||||
_t.sleep(min(6.0, (2 ** attempt) * 0.5) + _r.random() * 0.3)
|
||||
return out
|
||||
|
||||
def attempt_with_tools(self, task, skill, memory, tools):
|
||||
# Codex exec runs in a sandbox with shell access; expose the same real
|
||||
# `search` shim and let it run (workspace-write so the shim can log).
|
||||
|
||||
@@ -9,7 +9,7 @@ validation gate, vendored self-contained in ``skillopt_sleep.gate``.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from skillopt_sleep.backend import Backend
|
||||
@@ -36,6 +36,10 @@ class ConsolidationResult:
|
||||
rejected_edits: List[EditRecord]
|
||||
holdout_baseline: float
|
||||
holdout_candidate: float
|
||||
# ── observability (so a 0.0->0.0 night is self-diagnosing, not a black box) ──
|
||||
holdout_detail: List[dict] = field(default_factory=list) # per val task: hard/soft/resp/why
|
||||
reflect_raw: str = "" # the optimizer's last raw reply (empty => reflect produced nothing)
|
||||
call_error: str = "" # backend's last call error (timeout/auth/empty)
|
||||
|
||||
|
||||
def _split(tasks: List[TaskRecord]) -> Tuple[List[TaskRecord], List[TaskRecord]]:
|
||||
@@ -61,6 +65,25 @@ def _split(tasks: List[TaskRecord]) -> Tuple[List[TaskRecord], List[TaskRecord]]
|
||||
return train, val
|
||||
|
||||
|
||||
def _holdout_detail(pairs: List[Tuple[TaskRecord, ReplayResult]]) -> List[dict]:
|
||||
"""Per-task held-out evidence so a 0.0 night explains itself: was the
|
||||
response empty (backend call failed) or non-empty-but-failing-checks
|
||||
(judge too strict / edit didn't help)? The two need opposite fixes."""
|
||||
out: List[dict] = []
|
||||
for t, r in pairs:
|
||||
resp = r.response or ""
|
||||
out.append({
|
||||
"id": t.id,
|
||||
"reference_kind": t.reference_kind,
|
||||
"hard": r.hard,
|
||||
"soft": r.soft,
|
||||
"response_len": len(resp),
|
||||
"response_head": resp[:200],
|
||||
"why": (r.fail_reason or r.judge_rationale or "")[:200],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def consolidate(
|
||||
backend: Backend,
|
||||
tasks: List[TaskRecord],
|
||||
@@ -87,6 +110,7 @@ def consolidate(
|
||||
"""
|
||||
train_tasks, val_tasks = _split(tasks)
|
||||
gate_off = str(gate_mode).strip().lower() in {"off", "none", "false", "greedy"}
|
||||
holdout_detail: List[dict] = []
|
||||
|
||||
# ── baseline on the VAL slice (the gate reference) ────────────────────
|
||||
# When the gate is OFF the user has opted out of holding out a validation set
|
||||
@@ -98,6 +122,7 @@ def consolidate(
|
||||
else:
|
||||
base_pairs = replay_batch(backend, val_tasks, skill, memory)
|
||||
base_hard, base_soft = aggregate_scores(base_pairs)
|
||||
holdout_detail = _holdout_detail(base_pairs)
|
||||
base_score = select_gate_score(base_hard, base_soft, gate_metric, gate_mixed_weight)
|
||||
|
||||
# ── reflect over TRAIN-split failures/successes ───────────────────────
|
||||
@@ -235,4 +260,7 @@ def consolidate(
|
||||
rejected_edits=all_rejected,
|
||||
holdout_baseline=base_hard,
|
||||
holdout_candidate=final_hard,
|
||||
holdout_detail=holdout_detail,
|
||||
reflect_raw=getattr(backend, "last_reflect_raw", "") or "",
|
||||
call_error=getattr(backend, "last_call_error", "") or "",
|
||||
)
|
||||
|
||||
@@ -276,6 +276,28 @@ def run_sleep_cycle(
|
||||
live_memory_path=live_memory_path,
|
||||
report_md=report_md,
|
||||
)
|
||||
# Observability: persist per-task held-out evidence + optimizer/codex errors so a
|
||||
# 0.0->0.0 night self-explains (empty responses vs failing checks vs no edits) — the
|
||||
# cycle previously captured none of this, making the gate a black box (#learning-stall).
|
||||
try:
|
||||
import json as _json
|
||||
with open(os.path.join(staging_dir, "diagnostics.json"), "w", encoding="utf-8") as _fh:
|
||||
_json.dump({
|
||||
"night": night,
|
||||
"backend": cfg.get("backend"),
|
||||
"gate_mode": cfg.get("gate_mode"),
|
||||
"n_tasks": len(tasks),
|
||||
"baseline_score": result.baseline_score,
|
||||
"candidate_score": result.candidate_score,
|
||||
"accepted": result.accepted,
|
||||
"n_applied_edits": len(result.applied_edits),
|
||||
"n_rejected_edits": len(result.rejected_edits),
|
||||
"call_error": getattr(result, "call_error", ""),
|
||||
"reflect_raw_head": (getattr(result, "reflect_raw", "") or "")[:1200],
|
||||
"holdout_detail": getattr(result, "holdout_detail", []),
|
||||
}, _fh, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
state.set_last_harvest(project, started)
|
||||
state.record_night({
|
||||
"night": night, "accepted": result.accepted,
|
||||
|
||||
Reference in New Issue
Block a user