Compare commits

..

1 Commits

Author SHA1 Message Date
Yif Yang fad8fdb01f fix(sleep): reset ClaudeCliBackend.last_call_error at each call start
Follow-up to #126. ClaudeCliBackend recorded last_call_error on failure but
never reset it, so a failure followed by a successful call still reported the
stale error in persisted diagnostics — making a healthy run look failed. Both
_call() and attempt_with_tools() now clear it at the start, matching
CodexCliBackend.

Note: the "TimeoutExpired leaks the prompt into logs" concern raised in review
does NOT apply on current main — the prompt is passed via stdin (input=), not
argv (fixed by #98), so TimeoutExpired's str() only contains the argv (no
prompt). Verified empirically.

Adds a regression test asserting a prior failure is cleared on the next
success.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 16:33:18 +00:00
4 changed files with 30 additions and 60 deletions
+6 -21
View File
@@ -311,13 +311,6 @@ def _handoff_mine_and_pin(cfg, args, backend, snapshot: str, dry: bool):
# branch, or every later run re-scans the same stale window.
state.set_last_harvest(project, started)
state.save()
# Drop the pinned (often empty) digests so the NEXT run re-harvests
# instead of reloading a []-pin and never calling harvest again.
try:
if os.path.exists(digests_path):
os.remove(digests_path)
except OSError:
pass
return 0, None
payload = make_tasks_payload(
tasks,
@@ -349,21 +342,13 @@ def _run_handoff(cfg, args, *, seed_tasks, task_meta: Dict[str, Any], dry: bool)
tasks = seed_tasks
if tasks is None:
snapshot = os.path.join(hdir, "tasks.json")
tasks = None
if os.path.exists(snapshot):
try:
tasks, _meta = load_tasks_file(
snapshot,
holdout_fraction=cfg.get("holdout_fraction", 0.34),
seed=cfg.get("seed", 42),
)
except (ValueError, json.JSONDecodeError, OSError):
# Interrupted/corrupt pin from an earlier round: re-mine instead
# of crashing the run.
print("[sleep] handoff: tasks.json unreadable — re-mining",
file=sys.stderr)
tasks = None
if tasks is None:
tasks, _meta = load_tasks_file(
snapshot,
holdout_fraction=cfg.get("holdout_fraction", 0.34),
seed=cfg.get("seed", 42),
)
else:
rc, tasks = _handoff_mine_and_pin(cfg, args, backend, snapshot, dry)
if tasks is None:
return rc
+4
View File
@@ -615,6 +615,9 @@ class ClaudeCliBackend(CliBackend):
# --exclude-dynamic-... drop per-machine cwd/env/memory/git sections
# cwd=<clean temp> no project CLAUDE.md
import tempfile
# Reset per call so a prior failure does not make a later successful
# call look failed in persisted diagnostics (matches CodexCliBackend).
self.last_call_error = ""
cmd = [self.claude_path, "-p", "--output-format", "text"]
if os.environ.get("ANTHROPIC_API_KEY"):
cmd.append("--bare")
@@ -657,6 +660,7 @@ class ClaudeCliBackend(CliBackend):
# validated honestly: we detect the call from the shim's log, not from
# a self-reported marker. Other tools are stubbed the same way.
import tempfile, shutil, stat
self.last_call_error = ""
work = tempfile.mkdtemp(prefix="skillopt_sleep_tools_")
calllog = os.path.join(work, "_tool_calls.log")
try:
-39
View File
@@ -215,45 +215,6 @@ class TestHandoffCli(unittest.TestCase):
state = SleepState.load(cfg.state_path)
self.assertIsNotNone(state.last_harvest_for(proj))
def test_no_tasks_removes_digests_pin_so_next_run_reharvests(self):
"""After a no-task night the (often empty) digests.json pin must be
removed, or every later run reloads a []-pin and never harvests again."""
from skillopt_sleep.__main__ import main
with tempfile.TemporaryDirectory() as proj, \
tempfile.TemporaryDirectory() as home:
hdir = os.path.join(proj, ".skillopt-sleep-handoff")
os.makedirs(hdir, exist_ok=True)
digests_path = os.path.join(hdir, "digests.json")
with open(digests_path, "w", encoding="utf-8") as f:
json.dump([], f)
rc = main([
"run", "--backend", "handoff",
"--project", proj,
"--claude-home", os.path.join(home, ".claude"),
])
self.assertEqual(rc, 0)
# the empty pin must be gone so the next run re-harvests
self.assertFalse(os.path.exists(digests_path))
def test_corrupt_tasks_pin_falls_back_to_remine(self):
"""An interrupted/corrupt tasks.json snapshot must not crash the run."""
from skillopt_sleep.__main__ import main
with tempfile.TemporaryDirectory() as proj, \
tempfile.TemporaryDirectory() as home:
hdir = os.path.join(proj, ".skillopt-sleep-handoff")
os.makedirs(hdir, exist_ok=True)
with open(os.path.join(hdir, "tasks.json"), "w", encoding="utf-8") as f:
f.write('{"tasks": [ truncated...')
rc = main([
"run", "--backend", "handoff",
"--project", proj,
"--claude-home", os.path.join(home, ".claude"),
])
# corrupt snapshot -> re-mine -> no tasks -> 0 (not a crash)
self.assertEqual(rc, 0)
if __name__ == "__main__":
unittest.main()
+20
View File
@@ -1148,6 +1148,26 @@ class TestClaudeCliBackendBare(unittest.TestCase):
self.assertEqual(resp, "")
self.assertIn("Claude CLI spawn failed", be.last_call_error)
def test_last_call_error_cleared_on_next_success(self):
"""A prior failure must not make a later successful call look failed:
_call resets last_call_error at the start of each call."""
from unittest import mock
from skillopt_sleep.backend import ClaudeCliBackend
be = ClaudeCliBackend(claude_path="claude", timeout=3)
# First call fails to spawn -> error recorded.
with mock.patch("skillopt_sleep.backend.subprocess.run",
side_effect=FileNotFoundError("no claude")):
self.assertEqual(be._call("p1"), "")
self.assertIn("Claude CLI spawn failed", be.last_call_error)
# Next call succeeds -> stale error must be cleared.
class _OK:
stdout = "answer"
stderr = ""
with mock.patch("skillopt_sleep.backend.subprocess.run", return_value=_OK()):
self.assertEqual(be._call("p2"), "answer")
self.assertEqual(be.last_call_error, "")