fix(trainer): improve checkpoint consistency on resume - #253
fix(trainer): improve checkpoint consistency on resume#253Kaiwei LIU (KAIWEILIUCC) wants to merge 3 commits into
Conversation
|
@microsoft-github-policy-service agree |
There was a problem hiding this comment.
Pull request overview
Improves trainer checkpoint recovery and resume consistency.
Changes:
- Validates committed checkpoint prefixes and removes stale artifacts.
- Restores scheduler and step-buffer state.
- Adds atomic persistence and recovery tests.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Review summary |
|---|---|
tests/test_trainer_resume.py |
Adds focused recovery, phase, buffer, and atomic-write tests. |
skillopt/engine/trainer.py |
Implements checkpoint recovery and resume restoration. Two unresolved moderate findings concern unguarded marker conversion and trusting best_skill.md after recovery. |
Suppressed comments (7)
skillopt/engine/trainer.py:1394
- An accepted slow update can change
current_score, but this amendment only persists the best fields. The amended history/step record therefore reports the pre-slow current score, making epoch summaries and history-only recovery inconsistent with the runtime state. Persistcurrent_scorehere as well.
"best_origin": best_origin,
"best_score": best_score,
"best_step": best_step,
"best_skill_hash": skill_hash(best_skill),
skillopt/engine/trainer.py:625
- These checks never compare
runtime_state.scheduler_statewith the committed step record. If the marker has the same step/hash metadata but a stale or malformed scheduler state, the resume path below loads it and either uses the wrong learning-rate phase or fails on the nextscheduler.step(). Validate it againstlast_record(or fall back to the record state) before accepting the marker.
runtime.get("current_skill_hash") in (None, previous_skill_hash)
and runtime.get("history_hash") in (None, _json_digest(committed))
and runtime.get("step_record_hash")
in (None, _json_digest(last_record) if last_record is not None else "")
skillopt/engine/trainer.py:1341
- This clears the hash whenever a phase checkpoint calls
_persist_runtime_statewithoutstep_record(the meta-skill andcompletecalls do exactly that). Recovery treatsNoneas an opt-out, so after an epoch/final checkpoint it no longer validates the last step record against the runtime marker. Preserve the hash forhistory[-1]when it is the record forlast_completed_step, or pass that record at these call sites.
"step_record_hash": (
_json_digest(step_record) if step_record is not None else None
),
skillopt/engine/trainer.py:1304
- With skill-aware reflection enabled, this conditional persists raw
skill_initbefore theinject_empty_appendix_fieldcall at line 1314. The first step records its input hash after injection, while recovery computes the previous hash from rawskill_v0000and rejects step 1 on the next startup, so a baseline resume becomes unrecoverable after that step. Persist the normalized initial skill before starting/resuming.
if not (runtime_state or history) or not os.path.exists(skill_zero_path):
_save_skill(out_root, 0, skill_init)
skillopt/engine/trainer.py:638
- The normalization above replaces any non-list history with
[], sonot isinstance(history, list)is always false here. For a non-list or otherwise invalidhistory.json,changedis also false whencommittedis empty, meaning the invalid file is never rewritten and subsequent startups continue without a usable history document. Preserve a validity flag before normalization and rewrite invalid history.
if changed or (os.path.exists(history_path) and not isinstance(history, list)):
skillopt/engine/trainer.py:1230
- For legacy runtime markers,
best_skill_hashis absent, so this validation is skipped andbest_skill.mdis trusted. The old writer updated that convenience file before publishing its runtime marker; a crash can therefore leave it at an uncommitted step, and the resumed gate will use the wrong best skill. When the hash is absent, rebuild/validatebest_skillfrom the versionedbest_stepsnapshot instead of accepting this file.
expected_best_hash = runtime_state.get("best_skill_hash")
if expected_best_hash and skill_hash(best_skill) != expected_best_hash:
skillopt/engine/trainer.py:636
- Pre-v2 runtime markers are valid dictionaries but have no
phase. Defaulting them tostepmakes an epoch-boundary legacy checkpoint delete that epoch'sslow_updateandmeta_skilldirectories, including completed done artifacts. The next resume then reruns potentially nondeterministic LLM updates and can produce a different skill; missing legacy phase should preserve structurally complete artifacts or otherwise distinguish unknown phase from an in-progress step.
phase=str(runtime.get("phase", "step")) if runtime else "step",
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
This is a useful checkpoint-hardening direction, but resume is still not equivalent to an uninterrupted run in three cases.
Please make phase resumption explicit (continue with the next unfinished phase and reconstruct the pre-slow-update meta input), preserve numeric zero without truthiness fallbacks, and derive current/best files from the resumed |
Thanks for the detailed report and reproductions. I addressed all three cases.
I added regressions that compare uninterrupted runs with interruptions after Targeted verification:
Result: 84 passed. |
Summary
Improve checkpoint consistency when resuming trainer runs.
No new CLI flags or configuration options are required.
Problem
The previous resume logic primarily relied on
runtime_state.last_completed_step, or the last history entry, without cross-validating all related step records and skill snapshots.If a run is interrupted while checkpoint files are being written, there is a possibility that runtime state, history, skill snapshots, step directories, and learning-rate history may reflect slightly different progress.
For a mid-epoch resume, the accumulated step buffer may also be unavailable, and scheduler progress may be reconstructed from the global step number instead of its previously persisted internal state.
Changes
On startup, the trainer now finds the longest contiguous sequence of valid committed steps.
If the last valid step is
N, it:NNN + 1New checkpoints include skill hashes, commit IDs, scheduler state, and the current commit phase.
runtime_state.jsonis written last and acts as the commit marker. History, runtime state, skill snapshots, and step records are written atomically.Compatibility
Tests
Focused resume-related tests: