Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit61a33237e0

README.md

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 by executor.kind: "kernel" / "stub" → __jobs.register (JSON-only descriptor), "luau" → __jobs.register_luau so the run function survives the JSON crossing as a stable registry ref. Auto-fills origin with 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 function entry of the module module (a require path, read from the calling line as require reads 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's jobs.onRelease watchers 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 — run entry of module once 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 | ComputeExecutor
  • JobOrdering = { 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/pool reads the number as max_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's update among it, reads p:state() ("pending", "resolved", "rejected", "cancelled") each frame and takes the value with p: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.
  • args and 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 cancellation task.isCancellation recognises.
  • 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 a awaiting one of b that awaits one of a) 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/workers reports it per key (keys[].waited_for_vm_ms), and job:info().waited_for_vm_ms for a keyed worker job.
  • opts.supersede = true with 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), and 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.
  • 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, and jobs.release answers 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 its engine.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/workers lists the keys each worker holds (workers[].keys).
  • jobs.warm(module, entry) runs entry of module once 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 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; so does making a future combinator 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/workers lists 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 in waited_for_vm_ms.
  • A kind = "worker" job takes executor.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
  • chunk is { index, count, first, last, args, data }: the chunk's place among count chunks, the first and last index it covers (for a buffer, element numbers from 1), a copy of args, and for a buffer data, the chunk's slice of it. Every index is in exactly one chunk.
  • opts.chunks is 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.name is 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 parallelFor again 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, where entry runs on it. An input that 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() reports in_flight, frames_behind (the frames waited on the current item) and last_ms (how long the last item held its worker) and forwarded_calls (the calls its items made to functions only the engine thread serves).
  • The module follows the jobs.dispatch rules: 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 and input: apply keeps it on the engine thread and input sends it back. With executor.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 input value 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. An apply that raises errors its own job and no other.
  • Destroying, pausing or erroring the job stops its item, and apply never runs for it. input and apply run as the principal that registered the job, and so does each item.
  • /runtime/workers reads the pool (pool) and each worker that has run an item (0, 1, ...): its state (running, idle, or lost for a worker lost while it ran an item), the item it is running, its item count and busy time.
  • __threads.pools() reports the luau-workers pool: 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, and type(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.a where t.a is a table is that table, so t.a.b = 1 writes through and t.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.atomic on 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 meanwhile fn runs again on the newer value (up to 256 attempts, then the call raises naming the table and key), so fn is quick, pure and does not yield. A call that lost four attempts in a row takes its turn at the table: the other atomic calls on a worker wait for it, so atomic calls 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) copies v in 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 pop are 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.
  • JobHandle is sealed: rawset on the handle and __newindex both raise. The id is the only field, exposed via direct access.
  • cancel and destroy are aliases for symmetry with other resource handles (entity:destroy(), buffer:destroy()); both drop one refcount.
  • origin is auto-filled by register with the calling script's VFS path unless the descriptor already supplies one — surfaced under /zero/runtime/jobs/<phase>/<key>/origin.txt for agent traceability.
  • register's descriptor parameter is typed table rather than JobDescriptor because the LSP doesn't yet narrow string literals to their literal types in record fields, which would reject the tagged-union executor discriminator on every call site. Runtime validation in __jobs.register enforces the actual structure.