From 83e3be9298e08bb094adcdc5c112e2c095015eba Mon Sep 17 00:00:00 2001 From: Yif Yang Date: Sun, 12 Jul 2026 16:36:23 +0000 Subject: [PATCH] fix(sleep): handoff no-task cleanup + corrupt tasks.json fallback Follow-up to #125. Two resumability defects in the handoff driver: 1. A no-task night left digests.json (often []) pinned, so every later run reloaded the empty pin and never called harvest_for_config again. Remove the pin on terminal no-task completion so the next run re-harvests. 2. An interrupted/corrupt tasks.json snapshot raised an uncaught JSONDecodeError in _run_handoff. Wrap load_tasks_file in try/except and re-mine instead of crashing. Adds regression tests for both. Co-Authored-By: Claude --- skillopt_sleep/__main__.py | 27 ++++++++++++++++++------ tests/test_handoff_backend.py | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/skillopt_sleep/__main__.py b/skillopt_sleep/__main__.py index 01293e1..f055d18 100644 --- a/skillopt_sleep/__main__.py +++ b/skillopt_sleep/__main__.py @@ -311,6 +311,13 @@ 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, @@ -342,13 +349,21 @@ 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): - tasks, _meta = load_tasks_file( - snapshot, - holdout_fraction=cfg.get("holdout_fraction", 0.34), - seed=cfg.get("seed", 42), - ) - else: + 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: rc, tasks = _handoff_mine_and_pin(cfg, args, backend, snapshot, dry) if tasks is None: return rc diff --git a/tests/test_handoff_backend.py b/tests/test_handoff_backend.py index 0ecfae1..1e816dd 100644 --- a/tests/test_handoff_backend.py +++ b/tests/test_handoff_backend.py @@ -215,6 +215,45 @@ 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()