jobs
Substrate job registry. Wraps the `__jobs` FFI surface with a typed Luau API and a JobHandle metatable. Every registered job returns a stable handle; the `__index` lookup hits a small METHODS table, so each operation makes exactly one FFI crossing — none of the methods snapshot t…
jobs
Substrate job registry. Wraps the __jobs FFI surface with a typed
Luau API and a JobHandle metatable. Every registered job returns a
stable handle; the __index lookup hits a small METHODS table, so each
operation makes exactly one FFI crossing — none of the methods
snapshot the full registry.
Exposed as the jobs Luau global via --!global. Authored scripts
call jobs.register, jobs.list, jobs.find, jobs.inspect directly
without a require.
Exports
-
jobs.register(descriptor: table) -> JobHandle?— register a job. Dispatches byexecutor.kind:"kernel"/"stub"→__jobs.register(JSON-only descriptor),"luau"→__jobs.register_luauso the run function survives the JSON crossing as a stable registry ref. Auto-fillsoriginwith the calling script's VFS path. -
jobs.list(phase: string?) -> { JobInfo }— list registered job summaries, optionally filtered to a single phase. -
jobs.find(name: string) -> JobHandle?— look up a registered job by name; returns a handle or nil. -
jobs.inspect(target: JobHandle | string) -> JobInfo?— return a snapshot row by handle or by name. -
jobs.dispatch(module: string, entry: string, args: any, opts?) -> PromiseHandle— run the functionentryof the modulemodule(a require path, read from the calling line asrequirereads it) on a worker thread, and answer a promise of its result.opts = { deadline: number?, name: string?, key: string?, supersede: boolean? }: the key the item runs as and whether it supersedes the key's items that have not started (see "Keep state in a worker: keys" below), the deadline in seconds (default and at most the longest hold on one run, 60 seconds on every platform, native and browser alike: it may be lowered and never raised, and a larger one is refused at the call naming the cap; the longest one run of the item may take between a resume and the next yield, counted per run on monotonic time and never summed across runs) and what a rejection calls the item. -
jobs.parallelFor(module: string, entry: string, over: number | { first, last } | buffer, args: any, opts?) -> PromiseHandle— split a loop across the worker pool; the promise settles with the chunks' results in chunk order.opts = { deadline: number?, name: string?, chunks: number?, stride: number? }. -
jobs.release(key: string) -> boolean— end the binding of a key to its worker VM once the key's items dispatched before the call have settled; that VM'sjobs.onReleasewatchers run with the key. False for a key the pool does not hold. -
jobs.onRelease(watcher: (key: string) -> ()) -> () -> ()— hold a watcher in the calling VM that runs once per release of each key bound to it; answers the function that removes it. -
jobs.warm(module: string, entry: string) -> PromiseHandle— runentryofmoduleonce in every worker VM, before items need what it builds; the promise settles once every VM up at the call has run it. -
jobs.context() -> { worker, key?, fresh }?— on a worker, the running item's worker slot, key and whether it is the first of its key in this VM since the key bound; nil on the engine thread.
Handle methods (called via :):
job:pause()/job:resume()— skip / resume the job on subsequent ticks.job:status() -> JobStatus?—"pending"/"running"/"errored"or nil.job:valid() -> boolean— whether the substrate still tracks this job.job:info() -> JobInfo?— full snapshot row.job:cancel()/job:destroy()— drop one refcount; last drop unregisters.
Types:
JobStatus = "pending" | "running" | "errored"ResourceAccess = { resource = { kind, id?, component?, field?, entity? }, mode? = "r"/"w"/"rw", type? }JobExecutor = KernelExecutor | StubExecutor | LuauExecutor | ComputeExecutorJobOrdering = { after? = { string }, before? = { string } }JobDescriptor = { name?, phase, reads?, writes?, executor, ordering?, pure?, origin?, metadata? }JobInfo = { id, name?, phase, executor, executor_detail, status, paused, refcount, last_error?, origin?, metadata, pure, ordering, reads, writes, key?, waited_for_vm_ms? }DispatchOptions = { deadline?, name?, key?, supersede? }JobContext = { worker?, key?, fresh }JobHandle = { id, pause, resume, status, info, valid, cancel, destroy }
Usage
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" }},
})
-- Luau-kind executor — the dispatcher invokes `run` once per frame.
local tick = jobs.register({
phase = "main",
executor = { kind = "luau", run = function() print("tick") end },
})
job:pause()
local rows = jobs.list("main")
local info = jobs.inspect("animation_blend_main")
Work on a worker thread
jobs.dispatch runs one function of a module on a worker thread, so a
computation that would hold a frame for tens or hundreds of milliseconds
holds none. 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, and the code awaiting
the promise applies it 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. The task that made
the call waits as it waits on a promise, the worker keeps serving its other
tasks, and the time parked never counts toward the deadline. The engine
thread runs the same function with the same arguments after every write the
job recorded before the call, as the principal the job runs as, and the
answer returns to the job; an error the call raises raises in the job. Each
forwarded call takes at least one engine frame to answer, so a loop of them
costs a frame each; tasks that wait at once are answered in the same frame.
Arguments and answers cross as copies (nil, booleans, numbers, strings,
buffers, vectors and tables of those). 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. job:info().forwarded_calls counts
a worker job's forwarded calls, and
profiler.counters("system.schedule.frame.forwarded_calls") and
profiler.stats("system.schedule.frame.forwarded_call") report the calls the
engine thread served each frame and the milliseconds they took.
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 on a worker,
because the engine reads the value its callback returns inside its own frame.
A function the module hands the engine through renderfeature.setTick,
jobs.register (the luau and worker kinds) or httpserver.route stays in
the worker VM that registered it, and that VM stays in service for it after
the item returns: the engine posts each call to the worker and never waits for
it, and reads the output of the latest call that completed. A tick or a job
body runs once per frame, and what a tick enqueues is drawn one frame after
the tick that made it; a job's input result is read, and its apply runs,
one frame after the call that made them. A frame whose call is still running
keeps the last completed output, and the call it would have made is a late
call, counted in the workers report, in jobs.inspect(job).worker.lateCalls
and in the profiler count system.schedule.frame.worker_late_calls. A route's
handler runs on the worker once per request and the request waits for its
value, as it waits on the engine thread. The function runs as the job that
registered it. The registration lives until it is unregistered, or the world
changes or the scripting state resets, whatever became of the job that made it;
the worker's thread serves it for that long. 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;
otherwise it settles 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, or a run of one passes the deadline; the deadline is for one call, one run from a resume to the next yield, and time parked never counts. A write one of those tasks 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 as the task that dispatched it: its caller key and its whole
call context (the actor token of the agent whose call began the chain) are
captured at the dispatch, carried by the item's task and every task, watcher
callback, nested jobs.dispatch and jobs.parallelFor chunk it starts, and
nothing in Luau can state another. A write the item records is applied by the
engine thread as that caller, so a component it adds, a publish it makes and a
metered service call it issues belong to the dispatcher. A callback runs as the
caller that registered it (none when none did), from the worker's own loop and
never inside the call of another task, and one run of it is bound by the same
hold as an item's run. No run of Luau on a worker thread is unbounded: a task's
resume, a watcher or handler the worker's loop fires, and a call nothing routed
each have the hold, and a run that has not armed one is given it from its first
safepoint. A worker VM ends when the engine resets its scripting
state or its world changes: its tasks, watchers and handles end, the item it
serves fails naming the reset, and the next item runs on a new VM over the
modules the engine holds then. /runtime/workers names what a worker keeps
serving after its item returned (serving: the item, its owner tag, the caller,
and the tasks and watchers it holds).
The pool runs several workers, each with a VM of its own, and an item dispatched without a key runs on whichever worker takes it. A module's top-level state stays in the VM that set it; items that keep state there for the next one run with a key (below).
-- /source/terrain/noise.luau
local M = {}
function M.heights(args)
local out = buffer.create(args.size * args.size * 4)
for i = 0, args.size * args.size - 1 do
buffer.writef32(out, i * 4, math.noise(i % args.size / 16, i // args.size / 16, args.seed))
end
return out
end
return M
local heights = jobs.dispatch("@root::terrain.noise", "heights", { size = 256, seed = 7 }):await()
- The pool's size is its share of the engine's one thread budget (the
hardware threads less the engine thread, shared among the compute-task,
data-parallel, off-frame, luau-workers and pipeline-build pools by
weight, at least one thread each), so that many items run at once;
/runtime/workers/poolreads the number asmax_workers, and__threads.budget()lists every pool's share and size. - The promise is a future (
core/scripting-and-tasks): code that must not wait, a component'supdateamong it, readsp:state()("pending","resolved","rejected","cancelled") each frame and takes the value withp:await()once it has settled, which then returns at once. - A promise settles when the engine thread takes in finished items, once a frame, so a round trip lasts at least a frame however small the entry: an item is worth sending when its work outweighs that frame.
argsand the result cross as copies: nil, booleans, numbers, strings, buffers, vectors and tables of those. Any other value (a function, an entity, a cyclic table) is refused at the call, naming its key path (args.fn).- The promise rejects with the entry's error and its stack, or names the
deadline when one run of the entry takes longer than
opts.deadline; an entry that keeps yielding has no lifetime limit. p:cancel()halts a running entry, and keeps a queued one from running; the awaiter raises the cancellationtask.isCancellationrecognises.- An edit of the module reaches the next dispatch.
Keep state in a worker: keys
An item dispatched with opts.key runs in the worker VM its key is bound to,
so the state a module keeps for the key (a cache, a plan, a parsed asset, a
compiled rule set) is there for the key's next item:
-- /source/paths/graph.luau
local graphs = {}
local M = {}
jobs.onRelease(function(key)
graphs[key] = nil
end)
function M.route(args)
local ctx = jobs.context()
local graph = graphs[ctx.key]
if graph == nil then
graph = buildGraph(args.map)
graphs[ctx.key] = graph
end
return graph:route(args.from, args.to)
end
function M.loadTables()
loadSharedTables()
end
return M
jobs.warm("@root::paths.graph", "loadTables")
local route = jobs.dispatch("@root::paths.graph", "route", { map = map, from = a, to = b }, { key = "level3" }):await()
jobs.release("level3")
- The first item of a key binds the key to a worker VM: an idle worker holding the fewest keys; when none is idle, a worker the pool starts for it while it is under its cap, or else the worker holding the fewest keys. Every later item of the key runs in that VM. A key holds no worker: between the key's items the VM serves other items, keyed or not.
- The items of a key run one at a time, in the order they were dispatched: the
next starts once the one before it has settled and the writes it made have
applied, so it reads them, also when that one waits and its worker serves
other work meanwhile. An item that awaits 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 (queueing a next stage) runs that item after it.
A cycle through other keys (an item of
aawaiting one ofbthat awaits one ofa) waits for itself in the same way and is not refused. - A worker waiting inside an item takes queued work of the principal that item runs as, and the queued entries of keys that item itself awaits, or that a task of its VM belonging to no item (a watcher, a tick, a route handler) awaits, whatever principal queued them, since an entry bound to the worker can run on no other. An entry of a key none of those awaits waits for a worker of its own.
- A key's item waits for its VM even while other workers are idle: that wait
is the price of the state it finds there.
/runtime/workersreports it per key (keys[].waited_for_vm_ms), andjob:info().waited_for_vm_msfor a keyed worker job. opts.supersede = truewith a key rejects every item of the key that has not started, each naming the item that replaced it; the running item is untouched. An edit stream sends each request with it and runs only the newest.jobs.context()answers, on a worker,{ worker, key, fresh }: the pool slot of the worker, the running item's key (nil for an item dispatched without one, and outside an item, as in a watcher), andfresh, 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.- A binding ends when
jobs.release(key)reaches it (after the key's items dispatched before the call have settled), when its VM ends (the world changes, the scripting state resets, an item panics in the engine, a browser worker is lost), or when the pool ends. The key's next item binds anew and runs fresh; an item waiting for its turn when the VM ended is not lost with it. A key with items queued or running, bound or not yet bound (the pool binds a key to the worker that takes its first item), is released after them, andjobs.releaseanswers true; it answers false, doing nothing, only for a key with no binding and no item queued or running (a key whose only entry is a release under way among them). Items of a key queued before the world changes or the scripting state resets are refused unrun, naming the reset, as every queued item is: they belong to the state that was reset. The key's items dispatched after it run. jobs.onRelease(watcher)holds the watcher in the VM of the module that registers it, as a worker VM holds itsengine.on*watchers: it runs as a task of that VM's scheduler, once per release of each key bound to the VM, and drops what the module keeps for the key. It answers the function that removes it. Without a release, a VM keeps each key's state for as long as it lives;/runtime/workerslists the keys each worker holds (workers[].keys).jobs.warm(module, entry)runsentryofmoduleonce in every worker VM, to build the state the module keeps in its own VM (a registry, a parsed asset, 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 runs it before its first item, whichever boot made it. 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. A second call for the same module and entry, for the life of the scripting state, runs nothing more and answers the same.- A warm entry builds state in its own VM and does not wait on the pool. 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: 60 seconds of wall time
from its start to its end, on every platform. The bound is 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 (
task.wait,task.waitFrames). An await of ajobs.dispatch, ajobs.parallelForor ajobs.warmpromise by the entry or a task it started, while the entry runs, raises, naming what it awaited, whether or not the promise has settled; so does making afuturecombinator over one, at the call. Tasks the entry leaves behind await freely once it is over. Dispatching without awaiting is fine: the item runs once the VM is warm, so an entry that waits for that item another way (polling a flag it sets, popping a queue it fills) runs to the warm bound. - No item starts on a VM until its warm pass is over, keyed or not. Tasks the VM already holds, those of items that started earlier and its watchers, ticks and route handlers, go on between the entry's waits, and their awaits are their own.
- An entry that raises is logged with 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 a VM it panicked is followed by one that 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 the pass once more. These endings are counted per entry, across every worker of the pool, for the epoch: the second one fails the entry for the epoch, naming the panic. A VM that cannot be made is reported with that reason, and an item that waited for it fails naming it when it cannot be made a second time.
- An item may call
jobs.warm, awaited or not, whatever principal it runs as: its worker runs the warm job for its own VM before the item resumes. A module calls it as it is first used in a world, so a world that never uses the module pays nothing; the entry runs on workers only./runtime/workerslists each worker's warm runs with their milliseconds, and the failed ones with why, those of a VM that ended included (workers[].warm); a keyed item that waited behind a warm pass counts that time inwaited_for_vm_ms. - A
kind = "worker"job takesexecutor.key: every item of the job runs as that key. - A key is a string of 1 to 256 bytes, and the pool holds at most 65,536 keys at once; a dispatch past either bound is refused at the call, naming it. A bound key stays until it is released or its VM ends, so a key names a thing that persists (a graph, a map, an asset), and a module releases it when the thing goes.
Split a loop across the workers
jobs.parallelFor(module, entry, over, args, opts?) cuts a loop into
chunks that run on the pool's workers and answers one promise, which
settles when every chunk is done with the chunks' results in chunk order.
over is a count of indices (1..over), a { first, last } range of
whole numbers, or a buffer whose elements are opts.stride bytes each
(default 1). Each chunk calls entry with one table:
-- /source/sim/reduce.luau
local M = {}
function M.sumChunk(chunk)
local sum = 0
for i = chunk.first, chunk.last do
sum += i * chunk.args.scale
end
return sum
end
return M
local sums = jobs.parallelFor("@root::sim.reduce", "sumChunk", 100000, { scale = 2 }):await()
local total = 0
for _, s in sums do total += s end
chunkis{ index, count, first, last, args, data }: the chunk's place amongcountchunks, the first and last index it covers (for a buffer, element numbers from 1), a copy ofargs, and for a bufferdata, the chunk's slice of it. Every index is in exactly one chunk.opts.chunksis the number of chunks (default four per worker, at most one per index or element, at most 4096); chunks are as even as the range divides.opts.deadline(seconds, default and at most the hold on one run: 60 seconds on every platform) bounds one run of each chunk between a resume and the next yield;opts.nameis what a rejection calls the call.- A chunk that raises or runs past its deadline rejects the promise,
naming the chunk (
chunk 3 of 8) and its error, and cancels the other chunks.p:cancel()cancels every chunk. - It runs from the engine thread and from inside a worker entry. A worker
that awaits the promise runs queued chunks itself while it waits, so a
chunk may call
parallelForagain and complete with every worker busy. - The promise settles once every chunk has returned and the engine thread has applied every write the chunks made before they returned.
- Everything else (the worker VM, copied values, module edits) follows
jobs.dispatch. Splitting is worth its cost when each chunk's work outweighs the frame a round trip takes.
Work every frame on a worker thread
A kind = "worker" job runs the same kind of module entry every frame,
pipelined: the engine thread pays only for input and apply.
local job = jobs.register({
name = "crowd",
phase = "main",
executor = {
kind = "worker",
module = "@root::crowd.sim",
entry = "step",
input = function() return snapshot() end, -- engine thread
apply = function(result) write(result) end, -- engine thread, the frame it lands
deadline = 2, -- seconds one run of an item may take between a resume and the next yield (default and at most the hold on one run: 60 seconds on every platform)
},
})
- At the job's phase each frame, a result that has landed goes to
apply(result); then, when no item is in flight,input()runs and its value is copied to a worker, whereentryruns on it. Aninputthat returns nil queues no item that frame, so a job with work only some of the time returns nil until it has some. - One item is in flight at a time. A frame whose item has not returned runs
no
input;job:info()reportsin_flight,frames_behind(the frames waited on the current item) andlast_ms(how long the last item held its worker) andforwarded_calls(the calls its items made to functions only the engine thread serves). - The module follows the
jobs.dispatchrules: a worker VM that boots the engine's prelude, copied values, and an edit reaching the next item. - Without
executor.key, two items of one job can run on different workers, and module state stays in the worker VM that set it, so what one item hands the next travels through the result andinput:applykeeps it on the engine thread andinputsends it back. Withexecutor.key, every item runs as that key, in the VM the key is bound to, and finds the state the item before it left there. - An entry that raises, runs past its deadline, or whose
inputvalue cannot be copied errors the job with the message (job:info().last_error, and once in the engine's error log, naming the job);job:resume()runs it again. Anapplythat raises errors its own job and no other. - Destroying, pausing or erroring the job stops its item, and
applynever runs for it.inputandapplyrun as the principal that registered the job, and so does each item. /runtime/workersreads the pool (pool) and each worker that has run an item (0,1, ...): its state (running,idle, orlostfor a worker lost while it ran an item), the item it is running, its item count and busy time.__threads.pools()reports theluau-workerspool: its workers, the items queued and running, and the workers lost.
Share state with the game while a job runs: jobs.shared
jobs.dispatch hands its result back when the entry returns. State that has to
cross while a job runs (progress, partial results, a flag the game sets, work
handed from one job to another) lives in jobs.shared: tables and queues that
the engine's VM and every worker VM read and write. Process memory, not
persisted and not replicated: runtime_data and synced component fields are
the persisted, replicated stores.
-- /source/terrain/build.luau (runs in a job)
local M = {}
function M.run(args)
local regions = jobs.shared.table("terrain.regions")
for index = 1, args.count do
regions[index] = buildRegion(index) -- a table of numbers, strings, buffers
jobs.shared.atomic(jobs.shared.table("terrain.progress"), "done", function(n) return (n or 0) + 1 end)
end
return true
end
return M
-- the game, each frame
local regions = jobs.shared.table("terrain.regions")
local progress = jobs.shared.table("terrain.progress")
local seen = 0
task.loop(function()
local now = jobs.shared.revision(regions)
if now ~= seen then
seen = now
for index, region in regions do showRegion(index, region) end
end
end)
jobs.shared.table(name)is a table every VM reads and writes:t.x = 1,t.player.hp -= 5,#t,for k, v in t,pairs,ipairs,next,table.insert,table.remove,table.sort,table.concat,table.find,table.unpack,table.clone,table.clear,rawget,rawset, andtype(t) == "table"behave as on an ordinary table. It holds nil, boolean, number, integer, string, vector, buffer and tables of those. Assigning a table copies it in: later edits of the local table are not seen. A function, coroutine, userdata, or a table with a metatable raises, naming the key path.t.awheret.ais a table is that table, sot.a.b = 1writes through andt.a == t.a. A table that was 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: it never sees a torn table and never holds a writer up.
A change that spans keys is one
jobs.shared.atomicon the parent key. jobs.shared.atomic(t, key, fn)reads, changes and writes one key without a race.fn(current)returns the new value; if another write reached the table meanwhilefnruns again on the newer value (up to 256 attempts, then the call raises naming the table and key), sofnis quick, pure and does not yield. A call that lost four attempts in a row takes its turn at the table: the otheratomiccalls on a worker wait for it, soatomiccalls never starve one another, and the 256 attempts bound only a storm of plain writes. Counters, flags and compare-and-set are this call.jobs.shared.revision(t)is one number that every write to the table or to any table nested in it advances: game code that polls each frame reads the table only when the number moved.jobs.shared.snapshot(t)is a plain deep copy.jobs.shared.queue(name, { capacity = 1024 })is a bounded first-in-first-out queue (at most 65,536 values).q:push(v)copiesvin from anywhere;q:pop()takes the front value and waits for one (a worker parks and uses no cpu; on the engine thread only the calling task suspends, so no frame is held);q:tryPop()never waits and answers nil when empty;q:length()counts. A full queue raises naming the queue and its capacity: it never drops a value.- Created on first use; it lasts until the world changes or the scripting state
resets (entering play keeps it). After that a held table or queue raises
naming the reason, and tasks parked on
popare rejected with it. - Bounds: 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.
- Reach: the engine's VM and worker VMs, not the trusted VM.
Notes
- JobHandles are cached by id with weak values — looking the same id up again returns the same handle table.
JobHandleis sealed:rawseton the handle and__newindexboth raise. The id is the only field, exposed via direct access.cancelanddestroyare aliases for symmetry with other resource handles (entity:destroy(),buffer:destroy()); both drop one refcount.originis auto-filled byregisterwith the calling script's VFS path unless the descriptor already supplies one — surfaced under/zero/runtime/jobs/<phase>/<key>/origin.txtfor agent traceability.register'sdescriptorparameter is typedtablerather thanJobDescriptorbecause the LSP doesn't yet narrow string literals to their literal types in record fields, which would reject the tagged-unionexecutordiscriminator on every call site. Runtime validation in__jobs.registerenforces the actual structure.
Scoped to this part · feeds back into the world's score.