Skip to content

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:

PromiseJob
lives and dies with the processidentified by a JSON ref you can save
resolves or rejects oncecan be asked for its status any number of times
can't be canceledcancel() asks the backend to stop it
has no progressstatus() 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.