Log inGet started

jobs

Updated October 20, 2026

The jobs namespace: 21 functions.

globals/jobs/context

jobs.context() -> JobContext?

What the running item runs as, on a worker: worker, the pool slot of its worker; key, the item's key (nil for an item dispatched without one, and outside an item, as in a watcher); fresh, true for the first item a key runs in this VM since the key bound, so a module tells a cold cache from a missing entry. On the engine thread it answers nil, which is also how code asks whether it runs on a worker.

Returns JobContext? — { worker, key, fresh } on a worker; nil on the engine thread.

local ctx = jobs.context(); if ctx and ctx.fresh then cache = {} end

globals/jobs/dispatch

jobs.dispatch(module: string, entry: string, args: any?, opts: DispatchOptions?) -> PromiseHandle

Run the function entry of the module module on a worker thread and answer a promise of its result, so a long computation holds no frame. module is a require path, read from this line as require reads it. The module and the modules it requires run in a worker VM that boots the prelude and requires modules as the engine thread's VM does, so it has the same globals and modules/api modules: the entry computes from its arguments and returns a value, which the code awaiting the promise applies to the world. A call to a function only the engine thread serves (one that reads or changes the ECS world, the component instances its VM holds, or a table the engine thread has not published yet) is forwarded: from a job it runs on the engine thread, with the same arguments, after every write the job recorded before it, as the principal the job runs as, and its answer or its error returns to the job. The task that made it waits as it waits on a promise, so the worker keeps serving its other tasks and the time parked never counts toward the deadline; each forwarded call takes at least one engine frame, and job:info().forwarded_calls and the system.schedule.frame.forwarded_calls profiler count report them. Arguments and answers cross as copies; a call whose argument holds a function, coroutine or userdata handle, whose answer holds an object of the engine thread's VM, or that is made where the script cannot wait (a metamethod, the top level of a module being required, a batch or queue body) raises an error naming the function and the reason. A watcher the module registers (engine.onModeChange, modules.watch, world.on, vfs.watch and the other engine events) is kept by the worker VM and fires when that VM next starts an item, for the events raised since it registered; save.onMarksLanded is refused there, because the engine reads the value its callback returns inside its own frame. A function the module registers with renderfeature.setTick, jobs.register or httpserver.route stays in the worker VM that registered it, and that VM stays in service for it after the item returns, until the function is unregistered or the world changes or the scripting state resets: the engine posts each call to the worker and never waits for it, and reads the latest completed output (a tick's passes are drawn one frame after the tick that made them, and a frame whose call is still running keeps the last completed output and counts a late call). The entry is a task of the worker VM's own scheduler: it waits on time (task.wait), on frames (task.waitFrames) and on promises (task.await), starts tasks and scopes, and returns its result. The promise settles once the engine thread has applied every write the entry and the tasks it started made before it returned, so the code awaiting it reads all of those writes, as it would after making the same calls on the engine thread; an entry whose last write the engine thread applies in the frame the entry returned settles as soon as one that writes nothing, and otherwise one frame later. It waits as long as the writes do (while no world is open, say) and then resolves with the entry's result. It rejects only when the engine resets the scripting state or changes its world first, and then none of those writes is applied. The tasks it started keep running until they end or the item's deadline ends them; a write one of them makes after the entry returned is not part of what the promise waits for. A promise the worker makes settles on that worker (delay, http.* and jobs.dispatch serve one), and a call whose promise an engine-thread system settles (queue, shell.run of a command it would queue) is refused, naming it. An item runs on whichever worker takes it. With opts.key (a string of 1 to 256 bytes) it runs in the worker VM the key is bound to, after every item of the key dispatched before it has settled and its writes have applied, one at a time and in order, so module state an item of the key leaves (a cache, a plan, a parsed asset) is there for the next; jobs.context().fresh tells the first item of a key in a VM since the key bound, jobs.release ends the binding, and jobs.warm runs a module's one-time setup in every worker VM before items need it. A key's item waits for its VM even while other workers are idle, and /runtime/workers reports the wait per key. An item awaiting an item of its own key is refused at the await, naming the key: the awaiting item holds the key's turn, and the awaited item starts only after it settles; dispatching to its own key without awaiting runs that item after it. Items of a key queued before the world changes or the scripting state resets are refused unrun, naming the reset. opts.supersede = true with a key rejects every item of the key that has not started, naming this one; the running item is untouched. The pool holds at most 65,536 keys at once, and a dispatch with a key past that is refused at the call, naming the bound. args and the result cross as copies: nil, booleans, numbers, strings, buffers, vectors and tables of those, and any other value is refused at this call naming its key path. The promise rejects with the entry's error and stack, or names the deadline when one run of the entry, from a resume to the next yield, takes longer than opts.deadline seconds (default and at most the longest hold on one run, 60 seconds on every platform, native and browser alike; any number above 0 up to it, which a caller may lower and may not raise: a larger one is refused at the call naming the cap). The clock starts at each resume and stops at each yield, so time parked on a promise, task.wait or a frame never counts, every yield resets it, and no sum across runs is kept: an entry that keeps yielding runs until it returns or is cancelled. The tasks it resumed are held to the same deadline. Cancelling the promise (p:cancel()) halts a running entry and keeps a queued one from running. An edit of the module reaches the next dispatch.

Parameters

  • module string — Require path of the module, as require takes it.
  • entry string — Name of the function in the module's export table.
  • args any (optional) — The value the entry receives, copied.
  • opts DispatchOptions (optional) — Optional { deadline: number?, name: string?, key: string?, supersede: boolean? }: the deadline in seconds, what a rejection calls the item, the key it runs as, and whether it supersedes the key's items that have not started.

Returns PromiseHandle — A PromiseHandle settling with a copy of the entry's result.

local heights = jobs.dispatch("@root::terrain.noise", "heights", { size = 256, seed = 7 }):await()
local p = jobs.dispatch("@root::sim.solver", "solve", state, { deadline = 2, name = "solver" }); p:cancel()
local route = jobs.dispatch("@root::paths.graph", "route", { map = "level3", from = a, to = b }, { key = "level3" }):await()
local preview = jobs.dispatch("@root::graph.cook", "cook", edit, { key = graphId, supersede = true })

globals/jobs/find

jobs.find(name: string) -> JobHandle?

Look up a registered job by name. Returns a JobHandle or nil for anonymous / unknown names. Single FFI crossing — returns the id directly, no registry snapshot.

Parameters

  • name string — Job name supplied to jobs.register.

Returns JobHandle? — JobHandle or nil.

local job = jobs.find("animation_blend_main")

globals/jobs/inspect

jobs.inspect(target: JobHandle | string) -> JobInfo?

Return a snapshot row by job handle or by name without retrieving a full handle. Single FFI crossing — pulls only the matching row.

Parameters

  • target JobHandle | string — JobHandle, or job name string.

Returns JobInfo? — Snapshot row or nil.

local info = jobs.inspect("animation_blend_main")

globals/jobs/list

jobs.list(phase: string?) -> { JobInfo }

List registered job summaries. Pass a phase name to filter to a single phase. Single FFI crossing — only the requested rows cross the bridge.

Parameters

  • phase string (optional) — Optional phase filter — nil returns every job.

Returns { JobInfo } — Array of JobInfo rows.

local rows = jobs.list("main")

globals/jobs/onRelease

jobs.onRelease(watcher: (key: string) -> ()) -> () -> ()

Hold watcher in the VM of the module that calls it: each time a key bound to that VM is released (jobs.release), watcher(key) runs there as a task of the VM's scheduler, once per release, so the module drops what it keeps for the key. A worker VM keeps its own watchers, as it keeps engine.on* watchers; a module registers at its top level, which every worker VM that requires it runs. The engine thread's VM holds no key, so a watcher there is held and never called. Answers the function that removes the watcher.

Parameters

  • watcher (key: string) -> () — Called with the released key.

Returns () -> () — A function that removes the watcher.

jobs.onRelease(function(key) plans[key] = nil end)

globals/jobs/parallelFor

jobs.parallelFor(module: string, entry: string, over: number | { first: number, last: number } | buffer, args: any?, opts: { deadline: number?, name: string?, chunks: number?, stride: number? }?) -> PromiseHandle

Split a loop across the worker pool and answer a promise of the chunks' results in chunk order, so a loop over many indices or many elements of a buffer holds no frame and uses every worker. over is a count of indices (1..over), a table { first = a, last = b } of whole numbers, or a buffer whose elements are opts.stride bytes each (default 1). The range is cut into opts.chunks runs (default four per worker, at most one per element), as even as it divides, and each run is an item the pool's workers take: entry, a function of the module module, is called with one table chunk = { index, count, first, last, args, data }, where first and last are the run's indices (for a buffer, its element numbers from 1), args is a copy of args, and data is the run's slice of a buffer. The promise settles once every chunk has returned and the engine thread has applied every write the chunks made before they returned, with { chunk 1 result, chunk 2 result, ... } in chunk order whichever chunk finished first. A chunk that raises, or runs past its deadline (opts.deadline seconds for one run of each chunk between a resume and the next yield, default and at most the hold on one run, 60 seconds on every platform), rejects the promise naming the chunk (chunk 3 of 8) and cancels the chunks still queued or running; cancelling the promise (p:cancel()) cancels every chunk. It runs from the engine thread and from a worker: a worker that awaits the call runs queued chunks itself, so a call made inside a chunk completes however many workers are busy. The module, its worker VMs, args and result copies follow the rules of jobs.dispatch.

Parameters

  • module string — Require path of the module, as require takes it.
  • entry string — Name of the function in the module's export table; it receives the chunk table.
  • over number | { first: number, last: number } | buffer — A count of indices, a { first, last } range, or a buffer.
  • args any (optional) — A value every chunk receives as chunk.args, copied.
  • opts { deadline: number?, name: string?, chunks: number?, stride: number? } (optional) — Optional { deadline: number?, name: string?, chunks: number?, stride: number? }: the deadline in seconds of one run of each chunk, what a rejection calls the call, the number of chunks, and the bytes in one element of a buffer.

Returns PromiseHandle — A PromiseHandle settling with a table of the chunks' results in chunk order.

local sums = jobs.parallelFor("@root::sim.reduce", "sumChunk", 100000, { scale = 2 }):await()
local out = jobs.parallelFor("@root::pixels.tint", "tint", pixels, nil, { stride = 4, chunks = 16 }):await()

globals/jobs/register

jobs.register(descriptor: table) -> JobHandle?

Register a substrate job. Returns a JobHandle on success, nil on validation failure. Dispatches by executor.kind:\n - "kernel" / "stub" → standard __jobs.register (JSON-only descriptor).\n - "luau" → __jobs.register_luau(descriptor, executor.run) so the Luau function survives the JSON crossing as a stable registry ref. The dispatcher invokes the run closure once per frame; the closure captures any bindings/buffers it needs.\n - "compute" → the shader reference resolves to its registration key, and the job queues one dispatch per frame.\n - "worker" → __jobs.register_worker(descriptor, executor.input, executor.apply): each frame input() runs on the engine thread, its value is copied to a worker where the module's entry runs, and the result reaches apply(result) on the engine thread the frame it lands, one item in flight at a time. An input that returns nil queues no item that frame. A job that errors logs its name and message once, and job:info().last_error holds it. Without a key, its items can run on different workers, each with module state of its own, so what carries from one item to the next travels through the result and input. key (a string of 1 to 256 bytes) runs every item of the job as that key, in the worker VM the key is bound to, so module state one item leaves there is there for the next (see jobs.dispatch); job:info().waited_for_vm_ms is the time its items waited for that VM. A job registered from a worker VM keeps its function (run, or input and apply) in that VM: the dispatcher posts each frame's call to the worker and never waits for it, the writes it records apply one frame later, a raise marks the job errored one frame later, and job:info().worker reports the calls, the late calls and the failed calls. A worker job whose input or apply is not a function, whose deadline is not a number of seconds above 0 and at most the hold on one run (60 seconds on every platform), or whose key is not a string of 1 to 256 bytes, is raised at the call, naming why. deadline (default and at most that hold: it may be lowered and never raised) is the longest one run of an item may take between a resume and the next yield, counted on monotonic time per run and never summed across runs.\n\norigin is auto-filled with the VFS path of the calling script unless the descriptor already supplies one — surfaced under /zero/runtime/jobs/<phase>/<key>/origin.txt for agent traceability. Single FFI crossing — auto-origin runs Rust-side via lua_getinfo, no separate stack-inspection trip.

Parameters

  • descriptor table — Job declaration with the JobDescriptor shape — name?, phase, reads?, writes?, executor, ordering?, pure?, origin?, metadata?. Param is typed as table rather than JobDescriptor because the LSP doesn't yet narrow string literals to their literal types in record fields, so a JobDescriptor annotation rejects the tagged-union executor discriminator on every call site (literal kind = "kernel" infers as kind: string, doesn't subtype KernelExecutor.kind: "kernel"). Runtime validation in __jobs.register enforces the actual structure; see the JobDescriptor type alias above for the canonical shape.

Returns JobHandle? — JobHandle or nil.

local job = jobs.register({ phase = "main", executor = { kind = "kernel", kernel = "copy_buffer" }, reads = {{resource={kind="buffer",id=src.id},mode="r"}}, writes = {{resource={kind="buffer",id=dst.id},mode="w"}} })
local job = jobs.register({ phase = "main", executor = { kind = "luau", run = function() print("tick") end } })
local job = jobs.register({ phase = "main", executor = { kind = "compute", shader = asset.resolve("carve", "computeShader"), buffers = { "heights" }, workgroups = { 64 } } })
local job = jobs.register({ name = "crowd", phase = "main", executor = { kind = "worker", module = "@root::crowd.sim", entry = "step", input = function() return { count = 100 } end, apply = function(result) print(result) end } })

globals/jobs/release

jobs.release(key: string) -> boolean

End the binding of key to its worker VM once the items of the key dispatched before this call have settled. That VM then runs its jobs.onRelease watchers with the key, each as a task of its scheduler, and the key's next item binds anew and runs with jobs.context().fresh true. Without a release, a VM keeps each key's state for as long as it lives (until the world changes or the scripting state resets); /runtime/workers lists the keys each worker holds. Runs from the engine thread and from a worker.

Parameters

  • key string — The key, as jobs.dispatch was given it in opts.key.

Returns boolean — True when the pool held the key: a binding, or items queued or running, which the release runs after; false, doing nothing, for a key with none of those, a key whose only entry is a release under way among them.

jobs.release(graphId)

globals/jobs/shared/atomic

jobs.shared.atomic(t: SharedTable<T>, key: string | number, fn: (current: T?) -> T?) -> T?

Read-modify-write of one key of a shared table without a race. fn receives the key's current value (nil when absent; a table arrives as a plain copy) and returns the new one (nil removes the key); the new value is stored only if no other write reached the table meanwhile, otherwise fn runs again on the newer value, so fn must be quick, pure and must not yield. A call that lost four attempts in a row takes its turn at the table, and the other atomic calls on a worker wait for it, so atomic calls never starve one another; after 256 attempts that all lost to plain writes the call raises naming the table and the key. Counters, flags and compare-and-set are this one call; a change across several keys is one atomic on the parent key that returns the whole new table.

Parameters

  • t SharedTable<T> — A shared table.
  • key string | number — The key to update.
  • fn (current: T?) -> T (optional) — (current) -> new.

Returns T? — The value now stored under the key.

jobs.shared.atomic(counts, "done", function(n) return (n or 0) + 1 end)
jobs.shared.atomic(flags, "claimed", function(was) if was then return was end return true end)

globals/jobs/shared/queue

jobs.shared.queue(name: string, opts: SharedQueueOptions?) -> SharedQueue<T>

The shared queue named name, made on first use: a bounded first-in-first-out queue between any VMs, for handing work or results from one job to another, or from a job to the game. push(v) puts a copy of v at the back from anywhere; pop() takes the front value and waits for one when there is none (a worker parks, and on the engine thread only the calling task suspends, so a frame is never held); tryPop() takes it without waiting and answers nil when empty. A value is any non-nil data that a shared table holds. A full queue raises naming the queue and its capacity: it never drops. The capacity is opts.capacity (default 1024, at most 65,536); asking for the name again with another capacity raises. The queue is process memory with the lifetime of a shared table: when the world changes or the scripting state resets, every task parked on pop is rejected with the reason.

Parameters

  • name string — The queue's name, at most 256 bytes.
  • opts SharedQueueOptions (optional) — Optional { capacity: number? }.

Returns SharedQueue<T> — The queue.

local work = jobs.shared.queue("terrain.chunks", { capacity = 256 }); work:push({ x = 3, z = 4 })
-- in a job: while true do local chunk = work:pop(); build(chunk) end

globals/jobs/shared/revision

jobs.shared.revision(t: SharedTable<T>) -> number

A number that every write to the shared table, or to any table nested in it, advances. One atomic read: game code that polls a table each frame compares it with the number it saw and reads the table only when it changed.

Parameters

  • t SharedTable<T> — A shared table.

Returns number — The revision.

local seen = 0; -- each frame: local now = jobs.shared.revision(progress); if now ~= seen then seen = now; redraw(progress) end

globals/jobs/shared/snapshot

jobs.shared.snapshot(t: SharedTable<T>) -> { [string | number]: T }

An ordinary table holding a deep copy of a shared table, for the code that wants a stable view (JSON, comparison, the table library on a frozen picture). Each table of the copy is read at one version; tables read at different moments may be of different versions.

Parameters

  • t SharedTable<T> — A shared table.

Returns { [string | number]: T } — A plain table.

local picture = jobs.shared.snapshot(progress)

globals/jobs/shared/table

jobs.shared.table(name: string) -> SharedTable<T>

The shared table named name, made on first use. The engine's VM and every worker VM read and write the same table with the syntax of an ordinary table: t.x = 1, t.player.hp -= 5, #t, for k, v in t, pairs, next, table.insert, table.remove, table.sort, table.concat, table.find, table.unpack, table.clone, table.clear, rawget, rawset. A job streams results to the game through it while it runs, and the game reads them each frame. It holds data: booleans, numbers, integers, strings, vectors, buffers and tables of those; assigning a table copies it in (later edits of the local table are not seen), and a function, coroutine, userdata or a table with a metatable raises naming the key path. A value reads back as the type it was written with. t.a where t.a is a table is that table, so t.a.b = 1 writes through; a table replaced or removed raises on any use, naming where it was. Each access is atomic on its table; a loop walks the table as it stood when the loop started. The store is process memory: not persisted and not replicated (runtime_data and synced component fields are those). It lasts until the world changes or the scripting state resets (play entry keeps it); after that a held table raises, naming the reason. A table holds at most 1,048,576 entries and 64 MiB (32 MiB in the browser), nested at most 64 deep; the process holds at most 4,096 names and 256 MiB (128 MiB in the browser); a write past a bound raises and changes nothing.

Parameters

  • name string — The table's name, at most 256 bytes.

Returns SharedTable<T> — The shared table.

local progress = jobs.shared.table("terrain.progress"); progress.done = 0
-- in a job: progress.done += 1 -- in the game each frame: print(progress.done)

globals/jobs/warm

jobs.warm(module: string, entry: string) -> PromiseHandle

Run the function entry of the module module once in every worker VM, to build the state the module keeps in its own VM (a registry, compiled rules, a table it loads) before an item needs it. The pool starts every worker it may start; each VM up runs the entry as its next item, and a VM made later (after a panic, or one the pool starts later) runs it before its first item, whichever boot made it. The entry is called with nil and builds state in its own VM: it runs under the hold on one run (60 seconds from a resume to the next yield), and its whole run, waits included, under the warm bound of 60 seconds of wall time from its start to its end, read between runs, so a run in progress at the bound goes on to its next yield: an entry that does not yield runs at most the bound plus one hold, about 120 seconds. It may wait on time and frames, and it does not wait on the pool: an await of a jobs.dispatch, a jobs.parallelFor or a jobs.warm promise by the entry or a task it started, while the entry runs, raises, naming what it awaited, whether or not the promise has settled, and so does making a future combinator over one, at the call (dispatching without awaiting is fine: the item runs once the VM is warm). No item starts on the VM until its warm pass is over; tasks the VM already holds go on between the entry's waits. The promise settles once every VM up at the call has run it: it resolves when every run ended well, and rejects naming each worker whose run raised, panicked or ran past the warm bound, and its error. It is kept for the life of the scripting state: a second call for the same module and entry runs nothing more and answers the same. An entry that raises is logged, naming its module, entry and worker, and the VM serves on. An entry that panics the engine or runs past the warm bound runs on no later VM until the scripting state resets: it is logged once and ended, and the VM made after a panic runs the rest of the pass before any item. A panic in code outside the entry while it runs (the VM's boot, a watcher, a task another entry left behind) ends that VM, and the VM made next runs it once more; these endings are counted per entry, across every worker of the pool, for the epoch, and the second one fails the entry for the epoch, naming the panic. The entry runs on workers only; the calling thread pays the dispatch. Call it from the engine thread or from an item, awaited or not, whatever principal the item runs as: the item's worker runs the warm job for its own VM before the item resumes. Call it when the module is first used in a world, so a world that never uses it pays nothing; /runtime/workers lists each worker's warm runs with their milliseconds, and the failed ones with why.

Parameters

  • module string — Require path of the module, as require takes it.
  • entry string — Name of the function in the module's export table.

Returns PromiseHandle — A PromiseHandle settling once every worker VM up at the call has run the entry: nil when every run ended well, otherwise rejected naming each failed run.

jobs.warm("@root::graph.nodes", "buildRegistry")
jobs.warm("@root::paths.graph", "loadTables"):await()

typed/builtin//modules/api/engine/jobs/jobhandle_cancel

jobhandle_cancel(self: JobHandle) -> boolean

Cancel the job — drops one refcount, last drop unregisters and the dispatcher stops invoking it. Symmetric with vfs.remove("/runtime/jobs/<phase>/<id>").

Parameters

  • self JobHandle — JobHandle returned by jobs.register.

Returns boolean — True if the substrate still tracked the job at call time.

job:cancel()

typed/builtin//modules/api/engine/jobs/jobhandle_info

jobhandle_info(self: JobHandle) -> JobInfo?

Latest snapshot row for this job (id, name, phase, status, origin, metadata, reads, writes…). Single FFI crossing — pulls only this job's row, not the full registry.

Parameters

  • self JobHandle — JobHandle returned by jobs.register.

Returns JobInfo? — Snapshot row or nil if the job is no longer tracked.

local info = job:info(); print(info.origin)

typed/builtin//modules/api/engine/jobs/jobhandle_pause

jobhandle_pause(self: JobHandle) -> boolean

Pause the job — skipped on subsequent ticks but stays registered.

Parameters

  • self JobHandle — JobHandle returned by jobs.register.

Returns boolean — True if the substrate still tracked the job.

local job = jobs.register({...}); job:pause()

typed/builtin//modules/api/engine/jobs/jobhandle_resume

jobhandle_resume(self: JobHandle) -> boolean

Resume a paused or errored job (clears Errored → Pending).

Parameters

  • self JobHandle — JobHandle returned by jobs.register.

Returns boolean — True if the substrate still tracked the job.

job:resume()

typed/builtin//modules/api/engine/jobs/jobhandle_status

jobhandle_status(self: JobHandle) -> JobStatus?

Read the job's current scheduler status.

Parameters

  • self JobHandle — JobHandle returned by jobs.register.

Returns JobStatus? — "pending" / "running" / "errored", or nil if the id is no longer tracked.

local s = job:status() -- "pending"

typed/builtin//modules/api/engine/jobs/jobhandle_valid

jobhandle_valid(self: JobHandle) -> boolean

Whether the substrate still tracks this job (false after :cancel/:destroy).

Parameters

  • self JobHandle — JobHandle returned by jobs.register.

Returns boolean — True if the underlying job is still registered.

if not job:valid() then return end
  • api
  • reference