Jobs
A Promise represents work your process is waiting on. A Job represents work a backend owns, which you can find again later from a serializable JobRef. That could be a fine-tune, a batch inference run, a crawl, a render, a deployment or a remote coding task: training is its first user, but nothing about it is training-specific.
import { job, type JobBackend, type JobRef } from "june/train";
declare const renders: JobBackend<string>;
declare const ref: JobRef;
const render = job(ref, renders);
await render.status(); // { state: "running", progress: { fraction: 0.5 } }
await render.snapshot(); // the ref and last state, as JSON
await render.cancel();
const url = await render.result(); // polls until the job settles
Why not a Promise
A Promise only exists inside the process that made it. If a three-hour fine-tune were one Promise, a crash or a deploy would lose it, and the only way back would be to start over.
A Job holds nothing but its ref. Every call asks its backend (a provider's API, files on disk, your database), which owns the job's real state. So a Job rebuilt from a saved ref behaves exactly like the original:
| Promise | Job |
|---|---|
| lives and dies with the process | identified by a JSON ref you can save |
| resolves or rejects once | can be asked for its status any number of times |
| can't be canceled | cancel() asks the backend to stop it |
| has no progress | status() reports progress the backend knows |
Refs and the lifecycle
start → save the ref → status, as often as you like → your process exits
→ rebuild the Job from the ref → status, result or cancel
A JobRef is { id, kind, data? }. kind names the sort of job ("render", "fine-tune") and appears in errors; data holds whatever JSON the backend needs to find the job, such as a region or a directory. Keep secrets out of it: refs are meant to be stored and logged.
Jobs don't make your own work durable. A job keeps running only because something outside your process (a provider, a runner with its state on disk, a service) owns it. Code that runs inside your process, like a workflow's steps, stops when the process does; snapshots and resume reuse the steps that already finished, which is a different thing.
States
status() resolves with a JobStatus<T>:
import type { JobError, JobProgress, JobState } from "june/train";
interface JobStatus<T> {
state: JobState; // "queued" | "running" | "succeeded" | "failed" | "canceled"
progress?: JobProgress; // { fraction?, step?, totalSteps?, message?, metrics? }
result?: T; // once succeeded
error?: JobError; // { name, message, details? } once failed
}
succeeded, failed and canceled are final.
result(options) polls status() every pollInterval milliseconds (5000 by default) until the job settles. It resolves with the result, or rejects with a JobFailedError whose job is the final snapshot, error included. Its signal only stops the waiting: the job keeps running, and you can wait again later. Call cancel() to stop the job itself.
Resuming after a restart
June doesn't persist anything. Save the ref yourself, wherever suits you:
import { job, type JobBackend, type JobRef } from "june/train";
declare const renders: JobBackend<string>;
declare function submitRender(scene: string): Promise<JobRef>;
declare const db: {
put(key: string, value: string): Promise<void>;
get(key: string): Promise<string>;
};
const ref = await submitRender("intro");
await db.put("render", JSON.stringify(ref));
// in another process
const render = job<string>(JSON.parse(await db.get("render")), renders);
const url = await render.result();
Whether a job can be found again after a restart is up to its backend. A provider that keeps jobs server-side can always be asked again; a local backend has to keep its state somewhere that outlives the process, such as files on disk.
Writing a backend
A backend is two calls that take a ref:
import type { JobBackend, JobRef } from "june/train";
declare const api: {
get(id: string): Promise<{
phase: "waiting" | "working" | "done" | "error" | "stopped";
percent: number;
url?: string;
message?: string;
}>;
stop(id: string): Promise<void>;
};
export const renders: JobBackend<string> = {
async status(ref: JobRef) {
const remote = await api.get(ref.id);
const progress = { fraction: remote.percent / 100 };
switch (remote.phase) {
case "waiting":
return { state: "queued" };
case "working":
return { state: "running", progress };
case "done":
return { state: "succeeded", result: remote.url! };
case "error":
return {
state: "failed",
error: { name: "RenderError", message: remote.message ?? "" },
};
case "stopped":
return { state: "canceled" };
}
},
async cancel(ref: JobRef) {
await api.stop(ref.id);
},
};
Fine-tunes are jobs
startFineTune() returns a FineTuneJob: a Job<FineTuneResult> whose backend is the trainer. trl() owns its jobs through a Python process and the files in its run directory, and vertex() through Vertex AI, so a fine-tune can be checked on, waited for or canceled from any process (for trl(), one on the same machine that can read the run directory). See Fine-tuning, and <FineTune> for the same in a workflow.