Studio: Add checkpoint resume for stopped training runs (#5255)

* feat: add checkpoint resume for stopped training runs

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix:add resume checkpoint helpers

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: use checkpoint parent as resume output dir

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: save optimizer and scheduler state on stop-and-save

Use Trainer._save_checkpoint instead of save_state so resume restores
optimizer momentum and LR-schedule position via the checkpoint-NNN/
subdir written by HF's official path.

* fix: clean up resume training history and startup progress

* fix: preserve resume output dirs

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: tighten resume run lookup

* fix: remove stale output-dir lookup

* fix: preserve startup download progress

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Roland Tannous <rolandtannous@gravityq.ai>
Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
This commit is contained in:
Lee Jackson 2026-05-03 21:34:46 +01:00 committed by GitHub
commit 2de17c0a96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 456 additions and 24 deletions

View file

@ -0,0 +1,75 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Helpers for validating resumable training outputs."""
from pathlib import Path
from typing import Optional
from utils.paths import outputs_root, resolve_output_dir
def _is_under_outputs(path: Path) -> bool:
resolved = path.resolve(strict = False)
root = outputs_root().resolve(strict = False)
try:
resolved.relative_to(root)
return True
except ValueError:
return False
def has_resume_state(path_value: Optional[str]) -> bool:
if not path_value:
return False
return get_resume_checkpoint_path(path_value) is not None
def _checkpoint_step(path: Path) -> int:
try:
return int(path.name.removeprefix("checkpoint-"))
except ValueError:
return -1
def get_resume_checkpoint_path(path_value: str) -> Optional[str]:
path = resolve_output_dir(path_value)
if not _is_under_outputs(path) or not path.is_dir():
return None
if (path / "trainer_state.json").is_file():
return str(path)
checkpoints = [
child
for child in path.glob("checkpoint-*")
if child.is_dir() and (child / "trainer_state.json").is_file()
]
if not checkpoints:
return None
return str(max(checkpoints, key = _checkpoint_step))
def normalize_resume_output_dir(path_value: str) -> str:
path = resolve_output_dir(path_value)
if not _is_under_outputs(path):
raise ValueError("Resume checkpoint must be inside Studio outputs.")
return str(path)
def can_resume_run(run: dict) -> bool:
if run.get("resumed_later"):
return False
final_step = run.get("final_step")
total_steps = run.get("total_steps")
has_remaining_steps = (
not isinstance(final_step, int)
or not isinstance(total_steps, int)
or total_steps <= 0
or final_step < total_steps
)
return (
run.get("status") == "stopped"
and has_remaining_steps
and has_resume_state(run.get("output_dir"))
)

View file

@ -376,6 +376,7 @@ class UnslothTrainer:
def _finalize_training(self, output_dir, label = ""):
"""Save model after training and update progress. Used by all training branches."""
if self.should_stop and self.save_on_stop:
self.trainer._save_checkpoint(self.trainer.model, trial = None)
self.trainer.save_model()
self.tokenizer.save_pretrained(output_dir)
self._patch_adapter_config(output_dir)
@ -2828,7 +2829,9 @@ class UnslothTrainer:
total_steps = total, status_message = "Starting CSM training..."
)
logger.info(f"CSM training config: {config}\n")
self.trainer.train()
self.trainer.train(
resume_from_checkpoint = training_args.get("resume_from_checkpoint")
)
self._finalize_training(output_dir, "CSM")
return
@ -2867,7 +2870,9 @@ class UnslothTrainer:
total_steps = total, status_message = "Starting SNAC training..."
)
logger.info(f"SNAC training config: {config}\n")
self.trainer.train()
self.trainer.train(
resume_from_checkpoint = training_args.get("resume_from_checkpoint")
)
self._finalize_training(output_dir, "SNAC")
return
@ -2913,7 +2918,9 @@ class UnslothTrainer:
total_steps = total, status_message = "Starting Whisper training..."
)
logger.info(f"Whisper training config: {config}\n")
self.trainer.train()
self.trainer.train(
resume_from_checkpoint = training_args.get("resume_from_checkpoint")
)
self._finalize_training(output_dir, "Whisper")
return
@ -3408,7 +3415,9 @@ class UnslothTrainer:
# ========== START TRAINING ==========
self._update_progress(status_message = "Starting training...")
logger.info("Starting training...\n")
self.trainer.train()
self.trainer.train(
resume_from_checkpoint = training_args.get("resume_from_checkpoint")
)
# ========== SAVE MODEL ==========
self._finalize_training(output_dir)

View file

@ -185,6 +185,7 @@ class TrainingBackend:
"wandb_project": kwargs.get("wandb_project", "unsloth-training"),
"enable_tensorboard": kwargs.get("enable_tensorboard", False),
"tensorboard_dir": kwargs.get("tensorboard_dir", "runs"),
"resume_from_checkpoint": kwargs.get("resume_from_checkpoint"),
"trust_remote_code": kwargs.get("trust_remote_code", False),
"gpu_ids": kwargs.get("gpu_ids"),
}

View file

@ -35,6 +35,15 @@ from utils.wheel_utils import (
)
def _output_dir_from_resume_checkpoint(
resume_from_checkpoint: str | None,
) -> str | None:
if not resume_from_checkpoint:
return None
path = Path(resume_from_checkpoint)
return str(path.parent if path.name.startswith("checkpoint-") else path)
_CAUSAL_CONV1D_RELEASE_TAG = "v1.6.1.post4"
_CAUSAL_CONV1D_PACKAGE_VERSION = "1.6.1"
_MAMBA_SSM_RELEASE_TAG = "v2.3.1"
@ -757,7 +766,10 @@ def run_training_process(
return
# Generate output dir
output_dir = config.get("output_dir")
resume_from_checkpoint = config.get("resume_from_checkpoint")
output_dir = config.get("output_dir") or _output_dir_from_resume_checkpoint(
resume_from_checkpoint
)
if not output_dir:
output_dir = f"{model_name.replace('/', '_')}_{int(time.time())}"
output_dir = str(resolve_output_dir(output_dir))
@ -805,6 +817,7 @@ def run_training_process(
max_seq_length = config.get("max_seq_length", 2048),
optim = config.get("optim", "adamw_8bit"),
lr_scheduler_type = config.get("lr_scheduler_type", "linear"),
resume_from_checkpoint = resume_from_checkpoint,
)
_tqdm_stop.set()
@ -821,10 +834,13 @@ def run_training_process(
}
)
else:
saved_output_dir = (
None if trainer.should_stop and not trainer.save_on_stop else output_dir
)
event_queue.put(
{
"type": "complete",
"output_dir": output_dir,
"output_dir": saved_output_dir,
"status_message": progress.status_message or "Training completed",
"ts": time.time(),
}
@ -1109,11 +1125,15 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
)
return
output_dir = config.get("output_dir")
resume_from_checkpoint = config.get("resume_from_checkpoint")
output_dir = config.get("output_dir") or _output_dir_from_resume_checkpoint(
resume_from_checkpoint
)
if not output_dir:
output_dir = str(
resolve_output_dir(f"{model_name.replace('/', '_')}_{int(time.time())}")
)
output_dir = str(resolve_output_dir(output_dir))
num_epochs = config.get("num_epochs", 2)
batch_size = config.get("batch_size", 256)
@ -1221,7 +1241,7 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
callbacks = [_EmbeddingProgressCallback()],
)
trainer.train()
trainer.train(resume_from_checkpoint = resume_from_checkpoint)
except Exception as e:
event_queue.put(
{
@ -1247,6 +1267,8 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
_send_status(event_queue, "Saving model...")
try:
if _should_stop and _save_on_stop:
trainer._save_checkpoint(trainer.model, trial = None)
model.save_pretrained(output_dir)
model.tokenizer.save_pretrained(output_dir)
logger.info("Embedding model saved to %s", output_dir)

View file

@ -127,6 +127,9 @@ class TrainingStartRequest(BaseModel):
wandb_project: Optional[str] = Field(None, description = "W&B project name")
enable_tensorboard: bool = Field(False, description = "Enable TensorBoard logging")
tensorboard_dir: Optional[str] = Field(None, description = "TensorBoard directory")
resume_from_checkpoint: Optional[str] = Field(
None, description = "Saved training output directory to resume from"
)
# GPU selection
gpu_ids: Optional[List[int]] = Field(
@ -220,6 +223,8 @@ class TrainingRunSummary(BaseModel):
duration_seconds: Optional[float] = None
error_message: Optional[str] = None
loss_sparkline: Optional[List[float]] = None
can_resume: bool = False
resumed_later: bool = False
class TrainingRunListResponse(BaseModel):

View file

@ -25,6 +25,12 @@ if str(backend_path) not in sys.path:
# Import backend functions
try:
from core.training import get_training_backend
from core.training.resume import (
can_resume_run,
get_resume_checkpoint_path,
normalize_resume_output_dir,
)
from storage.studio_db import get_resumable_run_by_output_dir
from utils.models.model_config import load_model_defaults
from utils.paths import resolve_dataset_path
except ImportError:
@ -33,6 +39,12 @@ except ImportError:
if str(parent_backend) not in sys.path:
sys.path.insert(0, str(parent_backend))
from core.training import get_training_backend
from core.training.resume import (
can_resume_run,
get_resume_checkpoint_path,
normalize_resume_output_dir,
)
from storage.studio_db import get_resumable_run_by_output_dir
from utils.models.model_config import load_model_defaults
from utils.paths import resolve_dataset_path
@ -152,6 +164,28 @@ async def start_training(
request.local_eval_datasets = _validate_local_dataset_paths(
request.local_eval_datasets, "Local eval dataset"
)
resume_output_dir: Optional[str] = None
if request.resume_from_checkpoint:
try:
resume_output_dir = normalize_resume_output_dir(
request.resume_from_checkpoint
)
except ValueError as e:
raise HTTPException(status_code = 400, detail = str(e))
resume_run = get_resumable_run_by_output_dir(resume_output_dir)
if not resume_run or not can_resume_run(resume_run):
raise HTTPException(
status_code = 400,
detail = "Resume checkpoint must belong to a stopped run with saved trainer state.",
)
resume_checkpoint = get_resume_checkpoint_path(resume_output_dir)
if not resume_checkpoint:
raise HTTPException(
status_code = 400,
detail = "Resume checkpoint must include saved trainer state.",
)
request.resume_from_checkpoint = resume_checkpoint
# Convert request to kwargs for backend
training_kwargs = {
@ -209,6 +243,8 @@ async def start_training(
"wandb_project": request.wandb_project or "",
"enable_tensorboard": request.enable_tensorboard,
"tensorboard_dir": request.tensorboard_dir or "",
"output_dir": resume_output_dir,
"resume_from_checkpoint": request.resume_from_checkpoint,
"trust_remote_code": request.trust_remote_code,
"gpu_ids": request.gpu_ids,
}
@ -437,6 +473,9 @@ async def get_training_status(
"loss": getattr(progress, "loss", None),
"learning_rate": getattr(progress, "learning_rate", None),
}
output_dir = getattr(backend, "_output_dir", None)
if output_dir:
details["output_dir"] = output_dir
# Build metric history for chart recovery after SSE reconnection
metric_history = None

View file

@ -11,6 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from loggers import get_logger
from auth.authentication import get_current_subject
from core.training.resume import can_resume_run
from models import (
TrainingRunDeleteResponse,
TrainingRunDetailResponse,
@ -34,7 +35,10 @@ async def list_training_runs(
"""List training runs, newest first."""
result = list_runs(limit = limit, offset = offset)
return TrainingRunListResponse(
runs = [TrainingRunSummary(**r) for r in result["runs"]],
runs = [
TrainingRunSummary(**{**r, "can_resume": can_resume_run(r)})
for r in result["runs"]
],
total = result["total"],
)
@ -58,7 +62,12 @@ async def get_training_run_detail(
metrics_data = get_run_metrics(run_id)
return TrainingRunDetailResponse(
run = TrainingRunSummary(**{k: v for k, v in run.items() if k != "config_json"}),
run = TrainingRunSummary(
**{
**{k: v for k, v in run.items() if k != "config_json"},
"can_resume": can_resume_run(run),
}
),
config = config,
metrics = TrainingRunMetrics(**metrics_data),
)

View file

@ -267,10 +267,23 @@ def list_runs(limit: int = 50, offset: int = 0) -> dict:
total = conn.execute("SELECT COUNT(*) FROM training_runs").fetchone()[0]
rows = conn.execute(
"""
SELECT id, status, model_name, dataset_name, started_at, ended_at,
total_steps, final_step, final_loss, output_dir,
duration_seconds, error_message, loss_sparkline
FROM training_runs
SELECT r.id, r.status, r.model_name, r.dataset_name, r.started_at,
r.ended_at, r.total_steps, r.final_step, r.final_loss,
r.output_dir, r.duration_seconds, r.error_message,
r.loss_sparkline,
CASE
WHEN r.status = 'stopped'
AND r.output_dir IS NOT NULL
AND EXISTS (
SELECT 1
FROM training_runs newer
WHERE newer.output_dir = r.output_dir
AND newer.status IN ('stopped', 'completed')
AND newer.started_at > r.started_at
)
THEN 1 ELSE 0
END AS resumed_later
FROM training_runs r
ORDER BY started_at DESC
LIMIT ? OFFSET ?
""",
@ -297,7 +310,26 @@ def list_runs(limit: int = 50, offset: int = 0) -> dict:
def get_run(id: str) -> Optional[dict]:
conn = get_connection()
try:
row = conn.execute("SELECT * FROM training_runs WHERE id = ?", (id,)).fetchone()
row = conn.execute(
"""
SELECT r.*,
CASE
WHEN r.status = 'stopped'
AND r.output_dir IS NOT NULL
AND EXISTS (
SELECT 1
FROM training_runs newer
WHERE newer.output_dir = r.output_dir
AND newer.status IN ('stopped', 'completed')
AND newer.started_at > r.started_at
)
THEN 1 ELSE 0
END AS resumed_later
FROM training_runs r
WHERE r.id = ?
""",
(id,),
).fetchone()
if row is None:
return None
run = dict(row)
@ -313,6 +345,45 @@ def get_run(id: str) -> Optional[dict]:
conn.close()
def get_resumable_run_by_output_dir(output_dir: str) -> Optional[dict]:
conn = get_connection()
try:
row = conn.execute(
"""
SELECT r.*,
0 AS resumed_later
FROM training_runs r
WHERE r.output_dir = ?
AND r.status = 'stopped'
AND NOT EXISTS (
SELECT 1
FROM training_runs newer
WHERE newer.output_dir = r.output_dir
AND newer.status IN ('stopped', 'completed')
AND newer.started_at > r.started_at
)
ORDER BY r.started_at DESC
LIMIT 1
""",
(output_dir,),
).fetchone()
if row is None:
return None
run = dict(row)
sparkline = run.get("loss_sparkline")
if sparkline:
try:
run["loss_sparkline"] = json.loads(sparkline)
except (json.JSONDecodeError, TypeError):
logger.debug(
"Failed to parse loss_sparkline for output_dir %s", output_dir
)
run["loss_sparkline"] = None
return run
finally:
conn.close()
def get_run_metrics(id: str) -> dict:
"""Return metric arrays for a run, using paired step arrays per metric."""
conn = get_connection()

View file

@ -60,6 +60,7 @@ function mapToViewData(detail: TrainingRunDetailResponse): TrainingViewData {
currentGradNorm: metrics.grad_norm_history.at(-1) ?? null,
currentEpoch: metrics.final_epoch,
currentNumTokens: metrics.final_num_tokens ?? null,
outputDir: run.output_dir ?? null,
progressPercent:
run.total_steps && run.final_step
? (run.final_step / run.total_steps) * 100

View file

@ -13,7 +13,12 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import type { TrainingRunSummary } from "@/features/training";
import { deleteTrainingRun, listTrainingRuns } from "@/features/training";
import {
deleteTrainingRun,
listTrainingRuns,
useTrainingActions,
useTrainingRuntimeStore,
} from "@/features/training";
import { formatDuration } from "@/features/studio/sections/progress-section-lib";
import { cn } from "@/lib/utils";
import { Delete02Icon } from "@hugeicons/core-free-icons";
@ -47,8 +52,28 @@ const statusBadge: Record<
className:
"bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400",
},
resumed_later: {
label: "Continued",
className:
"bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400",
},
};
function wasContinuedInVisibleRuns(
run: TrainingRunSummary,
runs: TrainingRunSummary[],
): boolean {
if (run.status !== "stopped" || !run.output_dir) return false;
const startedAt = new Date(run.started_at).getTime();
return runs.some(
(other) =>
other.id !== run.id &&
other.output_dir === run.output_dir &&
(other.status === "stopped" || other.status === "completed") &&
new Date(other.started_at).getTime() > startedAt,
);
}
function catmullRomPath(points: { x: number; y: number }[]): string {
if (points.length < 2) return "";
const d = [`M${points[0]!.x.toFixed(1)},${points[0]!.y.toFixed(1)}`];
@ -133,10 +158,12 @@ function formatRelativeTime(isoDate: string): string {
interface HistoryCardGridProps {
onSelectRun: (runId: string) => void;
onResumeStarted?: () => void;
}
export function HistoryCardGrid({
onSelectRun,
onResumeStarted,
}: HistoryCardGridProps): ReactElement {
const [runs, setRuns] = useState<TrainingRunSummary[]>([]);
const [total, setTotal] = useState(0);
@ -144,7 +171,10 @@ export function HistoryCardGrid({
const [error, setError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [resumeTarget, setResumeTarget] = useState<string | null>(null);
const [manualFetchInFlight, setManualFetchInFlight] = useState(false);
const { resumeTrainingRunFromHistory } = useTrainingActions();
const isStarting = useTrainingRuntimeStore((state) => state.isStarting);
const userControllerRef = useRef<AbortController | null>(null);
const pollControllerRef = useRef<AbortController | null>(null);
@ -233,6 +263,18 @@ export function HistoryCardGrid({
setDeleteTarget(null);
};
const handleResume = async (runId: string) => {
setResumeTarget(runId);
try {
const ok = await resumeTrainingRunFromHistory(runId);
if (ok) {
onResumeStarted?.();
}
} finally {
setResumeTarget(null);
}
};
if (!loading && error && runs.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-16 text-center">
@ -264,18 +306,25 @@ export function HistoryCardGrid({
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{runs.map((run) => {
const badge = statusBadge[run.status] ?? statusBadge.error;
const wasContinued =
run.resumed_later || wasContinuedInVisibleRuns(run, runs);
const badge = wasContinued
? statusBadge.resumed_later
: (statusBadge[run.status] ?? statusBadge.error);
const isRunning = run.status === "running";
const canResume = run.can_resume && !wasContinued;
const isResuming = resumeTarget === run.id;
return (
<div
role="button"
tabIndex={0}
key={run.id}
className={cn(
"group relative flex cursor-pointer flex-col gap-3 rounded-xl border bg-card p-4 text-left transition-colors hover:border-border hover:bg-accent/30",
"group relative flex h-[11.5rem] cursor-pointer flex-col gap-3 rounded-xl border bg-card p-4 text-left transition-colors hover:border-border hover:bg-accent/30",
isRunning
? "border-blue-400/50 dark:border-blue-500/30"
: "border-border/60",
canResume && "gap-2",
)}
onClick={() => onSelectRun(run.id)}
onKeyDown={(e) => {
@ -299,6 +348,21 @@ export function HistoryCardGrid({
{formatRelativeTime(run.started_at)}
</span>
</div>
{canResume && (
<Button
type="button"
size="xs"
variant="outline"
className="absolute bottom-3 left-4 h-6 rounded-full px-2.5 text-[11px] leading-none shadow-sm"
disabled={isStarting || isResuming}
onClick={(e) => {
e.stopPropagation();
void handleResume(run.id);
}}
>
{isResuming ? "Resuming..." : "Resume training"}
</Button>
)}
<div className="min-w-0">
<p
className="truncate text-sm font-medium"
@ -311,7 +375,9 @@ export function HistoryCardGrid({
</p>
</div>
{run.loss_sparkline && run.loss_sparkline.length >= 2 && (
<Sparkline values={run.loss_sparkline} id={run.id} />
<div className={cn(canResume && "h-7 overflow-hidden")}>
<Sparkline values={run.loss_sparkline} id={run.id} />
</div>
)}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-muted-foreground">
<span>

View file

@ -31,6 +31,7 @@ export function LiveTrainingView(): ReactElement {
elapsedSeconds: state.elapsedSeconds,
etaSeconds: state.etaSeconds,
evalEnabled: state.evalEnabled,
outputDir: state.outputDir,
isTrainingRunning: state.isTrainingRunning,
lossHistory: state.lossHistory,
lrHistory: state.lrHistory,
@ -57,6 +58,7 @@ export function LiveTrainingView(): ReactElement {
currentGradNorm: runtime.currentGradNorm,
currentEpoch: runtime.currentEpoch,
currentNumTokens: runtime.currentNumTokens,
outputDir: runtime.outputDir,
progressPercent: runtime.progressPercent,
elapsedSeconds: runtime.elapsedSeconds,
etaSeconds: runtime.etaSeconds,

View file

@ -209,6 +209,9 @@ export function StudioPage(): ReactElement {
} else {
setSelectedHistoryRunId(runId);
}
}} onResumeStarted={() => {
setSelectedHistoryRunId(null);
handleTabChange("current-run");
}} />
)}
</TabsContent>

View file

@ -64,6 +64,22 @@ const EMPTY_DOWNLOAD_STATE: DownloadState = {
cachePath: null,
};
function coerceCachedStateReady(state: DownloadState): DownloadState {
if (!state.cachePath) return state;
if (state.downloadedBytes > 0 && state.percent < 100) return state;
const totalBytes =
state.totalBytes > 0 ? state.totalBytes : state.downloadedBytes;
if (totalBytes <= 0) {
return { ...state, percent: 100 };
}
return {
...state,
downloadedBytes: totalBytes,
totalBytes,
percent: 100,
};
}
type Fetcher = (repoId: string) => Promise<DownloadProgressResponse>;
/**
@ -88,10 +104,12 @@ function useHfDownloadProgress(
phase === "downloading_model" ||
phase === "downloading_dataset" ||
phase === "loading_model" ||
phase === "loading_dataset";
phase === "loading_dataset" ||
phase === "training";
useEffect(() => {
if (!repoId || !HF_REPO_REGEX.test(repoId) || !shouldPoll) {
setState(EMPTY_DOWNLOAD_STATE);
return;
}
@ -229,14 +247,42 @@ export function TrainingStartOverlay({
}: TrainingStartOverlayProps): ReactElement {
const { stopTrainingRun, dismissTrainingRun } = useTrainingActions();
const isStarting = useTrainingRuntimeStore((s) => s.isStarting);
const selectedModel = useTrainingConfigStore((s) => s.selectedModel);
const phase = useTrainingRuntimeStore((s) => s.phase);
const startModelName = useTrainingRuntimeStore((s) => s.startModelName);
const startDatasetName = useTrainingRuntimeStore((s) => s.startDatasetName);
const startFromResume = useTrainingRuntimeStore((s) => s.startFromResume);
const configuredModel = useTrainingConfigStore((s) => s.selectedModel);
const datasetSource = useTrainingConfigStore((s) => s.datasetSource);
const dataset = useTrainingConfigStore((s) => s.dataset);
// Only HF datasets have a download phase to track. Uploaded files are
// already on disk by the time the overlay shows up.
const hfDatasetName = datasetSource === "huggingface" ? dataset : null;
const modelDownload = useModelDownloadProgress(selectedModel);
const datasetDownload = useDatasetDownloadProgress(hfDatasetName);
const hasStartResources = startModelName !== null;
const useConfiguredResources = !isStarting && !hasStartResources;
const isDownloadPhase =
phase === "downloading_model" || phase === "downloading_dataset";
const modelName = hasStartResources
? startModelName
: useConfiguredResources
? configuredModel
: null;
const datasetName = hasStartResources
? startDatasetName
: useConfiguredResources
? hfDatasetName
: null;
const displayMessage =
startFromResume && !isDownloadPhase && /^download/i.test(message)
? "Resuming training..."
: message || "starting training...";
const rawModelDownload = useModelDownloadProgress(modelName);
const rawDatasetDownload = useDatasetDownloadProgress(datasetName);
const modelDownload = isDownloadPhase
? rawModelDownload
: coerceCachedStateReady(rawModelDownload);
const datasetDownload = isDownloadPhase
? rawDatasetDownload
: coerceCachedStateReady(rawDatasetDownload);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelRequested, setCancelRequested] = useState(false);
@ -314,7 +360,7 @@ export function TrainingStartOverlay({
{"> We are getting everything ready for your run..."}
</TypingAnimation>
<AnimatedSpan className="mt-2 text-muted-foreground">
{`> ${message || "starting training..."} | waiting for first step... (${currentStep})`}
{`> ${displayMessage} | waiting for first step... (${currentStep})`}
</AnimatedSpan>
{datasetDownload.downloadedBytes > 0 || datasetDownload.cachePath ? (
<AnimatedSpan className="mt-3">

View file

@ -3,6 +3,7 @@
import { useCallback } from "react";
import { checkDatasetFormat } from "../api/datasets-api";
import { getTrainingRun } from "../api/history-api";
import { buildTrainingStartPayload } from "../api/mappers";
import { startTraining, stopTraining, resetTraining } from "../api/train-api";
import { syncTrainingRuntimeFromBackend } from "../lib/sync-runtime";
@ -10,6 +11,7 @@ import { validateTrainingConfig } from "../lib/validation";
import { useDatasetPreviewDialogStore } from "../stores/dataset-preview-dialog-store";
import { useTrainingConfigStore } from "../stores/training-config-store";
import { useTrainingRuntimeStore } from "../stores/training-runtime-store";
import type { TrainingStartRequest } from "../types/api";
import type { TrainingConfigState } from "../types/config";
import { toast } from "sonner";
@ -48,6 +50,11 @@ export function useTrainingActions() {
return false;
}
runtimeStore.setStartResources(
config.selectedModel ?? null,
getHfDatasetName(config),
false,
);
runtimeStore.setStarting(true);
try {
@ -114,6 +121,7 @@ export function useTrainingActions() {
// Re-read config after potential store updates from dataset check
const payload = buildTrainingStartPayload(useTrainingConfigStore.getState());
runtimeStore.setStartResources(payload.model_name, payload.hf_dataset, false);
const response = await startTraining(payload);
if (response.status === "error") {
@ -153,6 +161,54 @@ export function useTrainingActions() {
}
}, []);
const resumeTrainingRunFromHistory = useCallback(async (runId: string): Promise<boolean> => {
const runtimeStore = useTrainingRuntimeStore.getState();
runtimeStore.setStartError(null);
runtimeStore.setStartResources(null, null, true);
runtimeStore.setStarting(true);
try {
const detail = await getTrainingRun(runId);
const outputDir = detail.run.output_dir;
if (!detail.run.can_resume || !outputDir) {
throw new Error("Only stopped runs with a saved checkpoint can be resumed.");
}
const config = useTrainingConfigStore.getState();
const savedConfig = detail.config as Partial<TrainingStartRequest>;
const payload = {
...savedConfig,
hf_token:
typeof savedConfig.hf_token === "string"
? savedConfig.hf_token
: config.hfToken.trim() || null,
wandb_token: null,
resume_from_checkpoint: outputDir,
} as TrainingStartRequest;
runtimeStore.setStartResources(payload.model_name, payload.hf_dataset, true);
const response = await startTraining(payload);
if (response.status === "error") {
throw new Error(response.error || response.message);
}
runtimeStore.setStartQueued(response.job_id, response.message);
await syncTrainingRuntimeFromBackend();
return true;
} catch (error) {
const rawMessage =
error instanceof Error ? error.message : "Failed to resume training";
const safeMessage = normalizeTrainingStartError(rawMessage);
runtimeStore.setStartError(safeMessage);
runtimeStore.setStarting(false);
toast.error("Could not resume training", {
description: safeMessage,
});
return false;
}
}, []);
const dismissTrainingRun = useCallback(async (): Promise<void> => {
try {
await resetTraining();
@ -173,6 +229,7 @@ export function useTrainingActions() {
isStarting,
startError,
startTrainingRun,
resumeTrainingRunFromHistory,
stopTrainingRun,
dismissTrainingRun,
};
@ -184,6 +241,10 @@ function getDatasetName(config: TrainingConfigState): string | null {
: config.uploadedFile;
}
function getHfDatasetName(config: TrainingConfigState): string | null {
return config.datasetSource === "huggingface" ? config.dataset : null;
}
function hasManualMapping(config: TrainingConfigState, isVlm = false, isAudio = false): boolean {
const mapping = config.datasetManualMapping;
const roles = new Set(Object.values(mapping));

View file

@ -22,6 +22,9 @@ const initialState: TrainingRuntimeState = {
hasHydrated: false,
isStarting: false,
startError: null,
startModelName: null,
startDatasetName: null,
startFromResume: false,
sseConnected: false,
firstStepReceived: false,
lastEventId: null,
@ -35,6 +38,7 @@ const initialState: TrainingRuntimeState = {
etaSeconds: null,
currentGradNorm: null,
currentNumTokens: null,
outputDir: null,
lossHistory: [],
lrHistory: [],
gradNormHistory: [],
@ -120,6 +124,8 @@ export const useTrainingRuntimeStore = create<TrainingRuntimeStore>()((set) => (
setHasHydrated: (value) => set({ hasHydrated: value }),
setStarting: (value) => set({ isStarting: value }),
setStartError: (value) => set({ startError: value }),
setStartResources: (startModelName, startDatasetName, startFromResume = false) =>
set({ startModelName, startDatasetName, startFromResume }),
setSseConnected: (value) => set({ sseConnected: value }),
setLastEventId: (value) => set({ lastEventId: value }),
@ -156,6 +162,7 @@ export const useTrainingRuntimeStore = create<TrainingRuntimeStore>()((set) => (
etaSeconds: null,
currentGradNorm: null,
currentNumTokens: null,
outputDir: null,
lossHistory: [],
lrHistory: [],
gradNormHistory: [],
@ -207,6 +214,7 @@ export const useTrainingRuntimeStore = create<TrainingRuntimeStore>()((set) => (
typeof detailLr === "number" ? detailLr : state.currentLearningRate,
currentEpoch:
typeof detailEpoch === "number" ? detailEpoch : state.currentEpoch,
outputDir: payload.details?.output_dir ?? state.outputDir,
lossHistory: metricHistory.lossHistory ?? state.lossHistory,
lrHistory: metricHistory.lrHistory ?? state.lrHistory,
gradNormHistory: metricHistory.gradNormHistory ?? state.gradNormHistory,

View file

@ -54,6 +54,7 @@ export interface TrainingStartRequest {
wandb_project: string | null;
enable_tensorboard: boolean;
tensorboard_dir: string | null;
resume_from_checkpoint?: string | null;
}
export interface TrainingStartResponse {

View file

@ -12,6 +12,8 @@ export interface TrainingRunSummary {
final_step: number | null;
final_loss: number | null;
output_dir: string | null;
can_resume: boolean;
resumed_later: boolean;
duration_seconds: number | null;
error_message: string | null;
loss_sparkline: number[] | null;

View file

@ -26,6 +26,7 @@ export interface TrainingStatusResponse {
total_steps?: number;
loss?: number;
learning_rate?: number;
output_dir?: string;
} | null;
metric_history?: {
steps?: number[];
@ -80,6 +81,9 @@ export interface TrainingRuntimeState {
hasHydrated: boolean;
isStarting: boolean;
startError: string | null;
startModelName: string | null;
startDatasetName: string | null;
startFromResume: boolean;
sseConnected: boolean;
firstStepReceived: boolean;
lastEventId: number | null;
@ -93,6 +97,7 @@ export interface TrainingRuntimeState {
etaSeconds: number | null;
currentGradNorm: number | null;
currentNumTokens: number | null;
outputDir: string | null;
lossHistory: TrainingSeriesPoint[];
lrHistory: TrainingSeriesPoint[];
gradNormHistory: TrainingSeriesPoint[];
@ -108,6 +113,11 @@ export interface TrainingRuntimeActions {
setHasHydrated: (value: boolean) => void;
setStarting: (value: boolean) => void;
setStartError: (value: string | null) => void;
setStartResources: (
modelName: string | null,
datasetName: string | null,
fromResume?: boolean,
) => void;
setSseConnected: (value: boolean) => void;
setLastEventId: (value: number | null) => void;
resetRuntime: () => void;
@ -131,6 +141,7 @@ export interface TrainingViewData {
currentGradNorm: number | null;
currentEpoch: number | null;
currentNumTokens: number | null;
outputDir: string | null;
progressPercent: number;
elapsedSeconds: number | null;
etaSeconds: number | null;