Jobs and leases
One agent hands work to several. magus neither runs that fan-out nor polices it. It answers what the working state is, records what the orchestrating agent says it intends, and shows a person the result: the same rule the rest of the agent surface obeys, where answering is the tool's job and deciding is the model's.
Two nouns, and the difference decides how everything below reads. A job is the unit of delegated work: one row, with a goal, a checkpoint, the paths it may write, and the one check it runs. A lease is the grant a holder takes on a job: the write and read paths that job declared, enforced in the checkout that took it. A job is the thing; a lease is permission over it.
How to split the work is not on this page. That is the
magus-multi-agent skill:
partition by write set rather than by affected project, prove the jobs cannot
collide, narrow the scope at every level, match a model to each job. This page is the surface
that skill writes to and reads from.
| step | surface |
|---|---|
| Record the working state | magus vcs checkpoint, client (magus\vcs.checkpoint) |
| Declare the work | magus job fork, client (magus\job.put) |
| Hand out the jobs | the host's own spawn, recorded but never judged |
| Give the holder its terms | magus describe job <job> |
| Take the lease | magus job exec <job> |
| Watch it | magus ls jobs, the console Jobs view |
| Return and verify | magus job exit, magus job wait |
Only one thing in that table enforces, and it is not the store. The job row is a declaration, the checkpoint is a reading, and the console renders both. The guard is what reads the declaration back, on every file write and every command: see what the guard enforces under a lease. It grades only a holder that took its lease, so the last step below, the actual diff against the checkpoint, is still what catches a write nobody could attribute.
Record the working state
A checkpoint is the identity of the tree right now: the head revision, the branch carrying it, whether the tree is dirty, and a digest of the uncommitted patch.
magus vcs checkpoint
# <revision> <branch> clean
# <revision> <branch> dirty <digest>
magus vcs checkpoint -o name
# <revision> when the tree is clean
# <revision>+<digest> when it is not
magus vcs checkpoint -o json
# the whole record: revision, branch, dirty, patch_digest, vcs
It resolves and records; it never mints. No tag, no stash, no ref, no file, nothing changed anywhere, so taking one per job costs the tree nothing, and one nobody keeps costs nothing either.
The digest is the half a revision cannot supply. Every worker on a branch shares
the revision, so a dirty tree's revision does not say WHICH dirty tree was
handed out; comparing two digests does. That is why -o name renders a dirty
checkpoint as <revision>+<digest> and a clean one as the bare revision: the
clean form is a token anyone can check out, and the + marks the other as a
revision plus uncommitted work that nobody can.
-o name is the single citable token, sized for the one cell a job row gives
it. Feed the revision half to anything that takes a revision, such as
magus graph diff --rev <revision>.
The command takes no arguments: it reports the whole workspace's working state.
A path argument is refused rather than ignored, because
magus vcs checkpoint <path> would read as a path-scoped digest, which is a
different and much narrower fact. Agents connected over
MCP call client (magus\vcs.checkpoint), which takes no parameters and
returns the same record. Full flags: magus vcs.
Declare the work
magus job fork records one job: what its holder is handed, where it may write,
and the one check it runs. It replaces any job with the same id.
magus job fork <job> \
--criteria 'move the store' \
--write-paths internal/job/store.go,internal/job/store_test.go \
--check 'test internal/job'
magus job fork --stdin < job.json # the same job as a record
magus job fork --schema # what that record must satisfy
Write paths name files. An existing directory is refused
(MGS3018), a project root and .
included: a directory claims every file under it and overlaps every other job
there. A path that does not exist yet passes, because the job creates it.
A write path can also claim one declaration of a file, so two jobs can start on
one file: run.go#executeStages, docs/scope.md#The knobs. The part after the
# names the declaration line git's diff driver matches for that file (the
diff= attribute in .gitattributes): the whole line, or any run of it that
starts and ends on a word boundary, so executeStages names
func (m *Magus) executeStages(ctx context.Context) error { and execute does
not. fork refuses a claim nothing could grade
(MGS3031): an empty declaration,
a glob, or a file whose extension has no diff driver. Two claims on different
declarations of one file do not overlap, and the lines above a file's first
declaration (its package clause and imports) belong to no claim. A doc comment
directly above a declaration is that declaration's, and in Go a top-level var,
const or type (a single one or a ( block) is a declaration of its own.
fork also refuses a job whose write path and another live job's, held in a
DIFFERENT checkout, both name the same single literal file that has a diff
driver, when neither job is the other's parent, child, or depends_on partner
(MGS3032): a whole-file lease
looks free because two of them on one file both read "mine wins", and this is
the fork-time cost that makes it not, once the file is genuinely shared across
checkouts. The refusal names the holder and what it has touched in the file so
far; the fix is a declaration claim on both rows, --depends-on, or folding
the work into the holder. A file with no diff driver is recorded rather than
refused, since the only move left for one is depends_on, and two rows
sharing a file in the SAME checkout stay the advisory write_proof: overlapping already is: that collision is the declaring orchestrator's own
call.
A path that itself contains # is spelled so the # cannot start a claim:
./notes/a#b.md (with a leading ./ the whole entry is the path) or
notes/a\#b.md (escaped, which also allows notes/a\#b.md#Intro). fork
refuses an unescaped entry that names an existing path, and names both
spellings.
A row carries id and optionally parent (the job this one was forked from),
criteria (the prose half; the machine-checkable half is goals),
checkpoint (as magus vcs checkpoint -o name prints it), write_paths,
deny_paths, read_paths, depends_on, model, check, goals, state,
and read_only. A record naming no state is stored declared. The
store adds schema_version, the actor that recorded the row, created,
updated, releases, unattributed (paths this job owns that somebody
outside it wrote, noticed by the guard), entries (see
entering a job's path), write_proof, checkout_root (where
magus job exec took the job), and end_reason (why magus ended it, see
jobs magus ends itself), all output-only: a
timestamp a client sent would be a fact about that client's clock. updated
moves only on the job's own writes; the guard recording an unattributed write,
and an entry, leave it alone.
There is no --state on fork. It declares a NEW job, and one nobody has taken
is declared; a holder moves its own job with magus job exec and
magus job exit. Every job ends in pass, fail, or no_return, and
no_return is not a failure: a job that failed came back and said so, while one
that died, stalled, or was cancelled said nothing. Silence is not a pass.
Five properties are worth stating plainly.
One author per job, enforced by the store. A session holding a lease may
record the base it landed on, SHRINK its own write_paths (which is how it
releases a path), end its own job, and fork a child of itself inside its own
paths. Every other write is refused, by name and with the remedy: widening a
boundary, changing the plan's shape, and verifying a result are the forking
session's. The rule lives in the store rather than in a guard pattern because
the CLI, the client tool (magus\job) and a magusfile's magus\job all reach the same file and
only one of them is a command a pattern can read. A session holding no lease,
the orchestrator or a person at a terminal, writes anything.
A child is forked with --parent <your job>. The guard lets a leased
session fork a NEW row that names its own job as parent, as flags, as a
--stdin record in a quoted heredoc, or as client calling magus\job.put with
parent, and leaves its boundary to the store: a child claiming more than its
parent is refused there, once. The store grades a child only when it writes as
that lease, which it reads from the checkout's binding; a worker the guard
identified in a checkout bound to nobody may fork only a --read-only child
that declares no paths, check or goals. A child neither read-only nor handed
write paths is refused either way, since an empty write set scopes nothing.
The guard also lets magus job wait through for the session's own
descendants, and refuses it for any other job.
Taking a lease is one-way while the job is in flight. magus job exec from a
caller whose job is still declared or running is refused when it names a
different job: retaking is how a holder would be graded against another job's
paths. Taking the lease it already holds is allowed and only records the base.
Once the held job has exited, passed, failed, or was never returned, the next
magus job exec takes the next job; nothing has to be given up first.
A workspace-load file needs a worktree of its own. fork refuses a job
whose write_paths cover a file magus must READ to load the workspace (any
project's magusfile.buzz or magusfiles/*.buzz, its magus.yaml, and the
workspace-local spell sources those magusfiles import) while another live job
with write paths was already taken in the same checkout. The refusal names the
file and the job that holds the checkout, and the fix it names is a worktree
rather than a narrower boundary. Half-saved, one of those files stops the workspace
loading for EVERY worker in the checkout at once: they lose magus run, magus ls and their own tests, over an edit none of them made and none of them can
see. The same three doors are covered, since magus job fork, client (magus\job.put) and
magus\job.put share one declaration path.
Nothing else about a shared checkout is refused. Two sets of write paths that
merely overlap are the orchestrator's call (it may have sequenced them
deliberately), so the fork RECORDS what it could prove instead, in write_proof:
alone when no other live job with write paths was bound to the checkout, else
disjoint or overlapping. magus ls jobs prints it per row, so a plan read
later says which forks were checked and which were not.
A declared boundary is enforced elsewhere. Beyond the row ownership above, this store gates nothing: it records the text an orchestrator put in a holder's prompt, where a human can read it. The agent guard is the one reader that turns it into a verdict. Every uncertainty there fails open with at most an advisory (no store, an unreadable one, a writer that took no lease) because this is a seatbelt for a harness that opted in and not a sandbox.
One set of jobs per repository. The rows live in one JSON file in the
per-REPOSITORY state directory (<XDG state>/magus/jobs/<repo>/jobs.json),
keyed the way session history is keyed: every worktree and every clone of one repository reads one
set. That is what lets an orchestrator declare a plan in its own checkout and a
holder take its lease from another. A store an older magus left behind is
carried forward the first time the new one opens it.
A read-only job carries an abbreviated row: read_only set, and empty write
and deny paths that then read as deliberate rather than forgotten.
A timeout is optional. magus job fork <job> --timeout 2h (or timeout in
the --stdin record or the client call) has the store stamp a deadline
on the row at fork time; nothing accepts a deadline directly. It is unset by
default, and a job with acceptance criteria needs no bound. Past the deadline the
guard denies every write graded under that lease and its write paths stop
blocking other jobs, while the row stays live: a deadline alone never ends it,
and magus ls jobs marks it overdue.
Limits exist only when the workspace sets them, in magus.yaml:
jobs:
max_depth: 3 # levels below the root job; 0 = unlimited
max_live: 12 # live jobs under one root; 0 = unlimited
default_timeout: 2h # applies when fork names no --timeout
stale_after: 30m # end untaken jobs untouched this long; default 2h, 0 = never
Every key but stale_after is unset by default. magus job fork refuses past
max_depth or max_live, naming the key that set the limit.
Two answers the store gives back
Neither is enforcement. Each is something an orchestrator would otherwise derive by hand from a table it wrote itself, and each leaves the decision where it was.
Overlaps. A list reports every pair of live jobs whose write_paths
intersect, each side's own declarations kept apart, because they are rarely the
same string and which job claimed which is the part a reader acts on. Derived on
the read and stored nowhere, so it cannot go out of date with the rows. A path
is compared by containment (a job owning internal/job overlaps one owning
internal/job/store.go) and a glob is judged by the directories it names, which
over-reports rather than misses a pair. A job in a terminal state is in no pair,
because a finished or released job is not competing for anything. Two jobs
claiming different declarations of one file are still listed, with
claims: disjoint: the pair is an integration order, not a wait.
Releases. Shrinking write_paths is how a job announces it has finished
editing a path, and the store records each dropped path with the digest that
path carried at that moment: the file's sha256, or one of three words when it
cannot be one. absent when nothing is there, dir for a directory, which has
no single content hash, and unreadable for a path that is there and could not
be hashed. absent and unreadable are deliberately not the same answer: "the
releaser deleted it" and "something is there nobody could read" send you to
different places. Hand the digest to the job taking the path over; one that no
longer matches at verification time means that job built on a tree the releaser
never saw. Dropping a declaration claim (run.go#executeStages) releases that
declaration, digested over the lines it spans now, so another job editing the
rest of the file does not change it.
Jobs a holder went quiet on. A list marks a live job stale when it was not
updated within jobs.stale_after, naming magus job exit <id> for each, and
magus doctor's job-tree check reports the same. These are the jobs magus
could not prove dead, so ending them stays yours.
Three doors write the store and they reach one set of rules: magus job is the
person's, client (magus\job) is the agent's, and magus\job is a magusfile's.
magus ls jobs # every job, parents above the ones they forked
magus ls jobs -o json # the same records, overlaps included
magus describe job <job> # one job's terms
Jobs magus ends itself
Every read of the store (magus ls jobs, magus job fork, the guard's lease
lookup, magus doctor, client calling magus\job) first ends each live job magus can
prove nobody holds, as no_return with an end_reason, and prints one line per
job on stderr:
ended fleet/w2: taken in /src/app/.worktrees/w2, which no longer exists
A job is ended when:
- An ancestor ended in
pass,fail, orno_return. The tree dies with its root, children and grandchildren alike. - Its checkout is gone.
magus job execrecords the checkout it took the job in; once that directory no longer exists, nobody can be working there. Anexitedjob is spared: its holder already returned, and removing the worktree is the normal end of that. - Nobody took it. It is still
declared, nomagus job execever took it, it was not updated withinjobs.stale_after, and no live job hangs under it, so a root outlives the children still working. The window is read from the workspace's ownmagus.yaml: 2h by default,0for never.
A job whose liveness cannot be decided stays live: a checkout path on a mount
that cannot be read is not a path that is gone. The server's own maintenance
jobs are never ended this way. Ending a row moves its updated; nothing else
magus does for a job does, so a job other agents keep writing near still ages.
Two habits follow. Remove a worker's worktree only once its job is done, and advance a root row you are still forking under, since an untaken root with no live children is as dead as any other untaken job.
Give the holder its terms
The context a delegated agent receives should come from the row, not from the
orchestrating model's recollection of it. magus describe job <job> renders
that context and nothing else: the goal and acceptance criteria verbatim, the
write, deny and read paths, the projects those paths reach, the paths a sibling
job is holding and who holds them, the affinity the graph knows about, the
single check that job is allowed to run, and the jobs it depends on. A section
with nothing in it is dropped, which is the mechanism rather than a nicety: ci
cannot leak into a holder's terms because nothing renders it. Two renders of one
row are byte-identical, which hand-typed prompts are not.
It REFUSES a job whose check is the gate, or a target that chains to one, and says so rather than printing terms nobody may act on.
Take the lease
magus job exec <job>
That takes the job in this checkout: it records the base this tree landed on beside the checkpoint the job was handed, with the divergence between them as a fact rather than a refusal, and the checkout it was taken in.
The guard binds the caller, not the checkout. When the guard hook lets
magus job exec <job> through, it records that the
caller acts under the job, keyed on exactly the host, session and subagent ids
the host put on the hook call. So several workers sharing one checkout each hold
their own lease, each has its own write paths graded, and none of them binds the
orchestrator: a subagent shares its parent's session id, and only its agent id
tells the two apart. The lookup is exact, with no fallback from a subagent to its
session or from a session to the checkout. The CLI itself binds nobody, because
it cannot tell who ran it.
A worker whose spawn title names its job (see job attribution) is bound already, and its first hook call records its base; its exec changes nothing.
Where the host reports no session or subagent id at all, the binding is the checkout's, read only by callers that report none either: one worker per checkout, which is the arrangement the worktree rule asks for anyway.
The base is the half a revision cannot supply on its own. The checkpoint is what the orchestrator HANDED the job; the base is what the checkout actually LANDED ON, and hosts that isolate workers in per-worker trees routinely branch them from an older revision than the tree that was partitioned. Recording it is a FACT and not a gate: a divergence records, because refusing would leave the orchestrator with no record that a holder went to the wrong base, which is the one case the record exists for.
Wiring the lease into a worker
A declared boundary grades nothing until the writing process says which job it is acting on, and it says that through its ENVIRONMENT. So the orchestrator that spawns a worker exports the id into that worker's environment, and the hook process the worker's host launches inherits it from there:
export BAGGAGE=magus.lease=<the job id>
That is the W3C Baggage channel, carried in the environment under the
OpenTelemetry convention, and magus.lease is the one member a verdict reads.
Export TRACEPARENT too when your host has one, and add
magus.spawner=<your label> to the baggage: magus records the trace, the parent
span and the label as CLAIMS for magus session ls to show, and keys no verdict
on them. The magus-multi-agent skill
requires this of every worker prompt it writes, in the same spelling.
Exporting it is the ORCHESTRATOR's job today. The shipped
guard templates pass --agent-name and --session, which
are attribution, and nothing that names a job, so a worker whose orchestrator
never exported the variable is graded as an editor magus cannot attribute, which
is an advisory rather than a deny and leaves every rule above it inert. A
wrapper that builds its own argv can pass magus shell --lease <id> instead;
an explicit flag outranks every other source.
What the guard enforces under a lease
Once a worker names a live job, the guard reads that row on every file write and every command. It denies:
| the guard refuses | the row field that decided it |
|---|---|
| any write, before the holder takes the lease here | the recorded base |
| any write, once the job's timeout has passed | deadline |
| any write, by a job that gathers evidence and writes nothing | read_only |
| a write covered by this job's own deny list | deny_paths |
| a write covered by another live job's write list | write_paths (that job's) |
| an edit landing in a declaration another live job claims | write_paths (that job's #) |
| a write outside every entry in this job's own write list | write_paths |
a command running the ci gate |
check |
| a READ of a path outside the projects this job may read | read_paths, else write_paths |
It advises on one more: your own path, written from a base that diverges from the checkpoint you were handed.
Whatever the row says, it also refuses magus agent harness install: the skills
are what steer the holder, so only an unbound caller rewrites them. magus writes
no hook wiring at all; magus describe harness prints it for a person to merge.
The command refuses itself under the checkout's binding or a BAGGAGE claim; the
guard adds the subagent and session sources a command cannot see.
A denial for another job's path also says how long ago that job was last
updated and names magus job exit <id>, which releases a job nobody holds any
more. A job past its deadline owns nothing against other jobs.
Entering a job's path
A small change to a path a live job owns does not need a new worker or the job ended. Enter the path first, then write it:
# client tool: magus\job.put("<job>", opts: {"enter": "<path>"})
echo '{"schema_version": 11, "id": "<job>", "enter": "<path>"}' | magus job fork --stdin
The store records the entry on the job's entries and refuses one it cannot
honor: a job that is not live, a caller that holds the job, a holder entering a
job not forked beneath its own, a path outside the job's write paths (that wants
widening), a second open entry for one path, and a third entry on one job.
The guard then lets ONE write into that path through and stamps the entry
consumed. It refuses the write while the job's holder is working: a tool call
graded under the job in the last minute, read from the trail of the checkout the
holder took it in. The next write needs another entry.
The holder learns of it three ways: magus describe job lists the entries, its
next write to an entered path is advised to re-read the file first, and a
continue addressed to it carries the entries. magus job wait lists them too,
and marks each footprint line in an entered path.
The declaration row (claimed-declaration)
reads the edit itself. When the host's payload carries the replacement (an
old_string/new_string pair, or a list of them), the guard applies it to the
file in memory and places the changed lines the way the job footprint does. An
edit landing only in your own claims, in a declaration nobody claims, or above a
file's first declaration passes. A payload carrying no edit, such as a
whole-file write, is graded by path alone, and the rule reads nothing unless
another live job claims a declaration of that file.
The read row is the write paths read the other way. write_paths stands in when
read_paths is empty, because a holder leased to edit a project was pointed at
that project, and the boundary it opens is those projects plus what they declare
depends_on (see the guard's focus rule). Set
read_paths when a holder must READ something it must not WRITE: widening
write_paths to open a read is how two holders end up owning one file. Without
a lease taken on the checkout the same rule only advises, which is the opt-in: a
hard read boundary needs somebody to have declared one.
The gate row is the one that surprises people. The gate runs ONCE per branch, in
the orchestrator's tree, after every job lands; a job's check is the narrow
target it was assigned, so a holder that reaches for the whole pipeline is
refused and handed its own check instead. A row whose check names ci owns
the gate and is not refused.
Two absences are boundaries nobody declared rather than boundaries of size zero,
and both scope nothing: an empty write_paths on a row that is not read_only,
and an empty check.
None of that table is visible from the verdict a worker sees, which is exactly
what makes a wrong lease dangerous: a checkout holding an unknown id, a terminal
row, or a live row whose base was never recorded all render as an ordinary
advisory, indistinguishable from a session these rules are actually enforcing
on. magus doctor's bound-lease check is the other end of that gap. It
reads the same row the guard would and says, in one line, whether this
checkout's lease is live and therefore actually judged, or names why it is not.
What the sandbox enforces under a lease
The guard above is a seatbelt for harnesses that opt in: it explains a boundary and, for a worker, denies the tool call that crosses it. The sandbox is the boundary itself, and it reads the same job row rather than a second declaration, because a boundary written twice is a boundary that disagrees with itself.
When sandbox.mode is not off and the acting lease resolves to a live row with
a parent and non-empty write_paths, every target run and every magus buzz
script in that checkout gets a filesystem WRITE grant of exactly:
- the
write_paths, resolved as globs against the workspace root (a glob that matches nothing grants nothing). A LITERAL path that does not exist yet grants the nearest directory above it that does, because a job routinely owns a file it was forked to create and the guard already admits that write; only a glob keeps the existing-files-only rule, since a typo in a glob is the case that rule protects against; - the workspace cache directory and the sandbox's private temp dir (every
child's
TMPDIR), which a target needs to produce output at all.
Write grants outside the checkout, such as /dev/null and the tool caches, are
kept. Reads are untouched: the row declares a write boundary, and a holder has to read
the tree it is changing. A refusal is recorded on the trail as a
sandbox_denial carrying the job id, so a reader can say whose boundary was hit
rather than only that something was blocked.
Nothing narrows for a ROOT job (a row with no parent is the orchestrator, and it
owns the checkout), for a job id that names no live row, for a writable row with
no write paths, or when the sandbox is off. A read_only row narrows the checkout to
the cache directory and the private temp dir alone, which is the sandbox's reading of the guard
refusing every write under such a job.
Both tiers resolve the acting lease the same way, in this order: an explicit
--lease, the job the guard recorded for the calling session or subagent (by
spawn attribution or its magus job exec), the checkout's record for a caller
that reports neither id, then the BAGGAGE a worker inherited. A process that is
not a hook, the sandbox's included, knows no caller id and reads only the
checkout's record and its claim. Every tier but the last is a record; BAGGAGE
is the worker's claim about itself, so it answers only when no record does, and
a record that disagrees with it wins. The verdict names the tier that answered as
lease_from: flag, agent (the caller's record, session-only callers
included), marker (the checkout's), env, or contested for a record that
overruled a different claim.
A host runs its hooks from wherever it likes, so the guard locates that checkout
from the cwd its hook envelope reports and falls back to the hook process's
own directory only when the envelope carries none.
A deny path inside a write path is refused, and it costs the directory holding it as well: both this policy and landlock are allowlists with no deny rule, so the enclosing grant is replaced by grants on its children, and a new file created beside the denied entry is refused with it.
Return and verify
A holder returns its job with the result of the work:
magus job exit <job> --stdin < result.json
magus job exit <job> # abandoned, recorded no_return
magus job exit --schema # what that result must satisfy
The result carries changed_paths, the validation the holder ran as
{command, output_ref}, the descendants it forked, and its
unresolved_risks, under a required schema_version. The run behind the output
ref is resolved in the holder's OWN checkout and its record is filed alongside,
because the output store belongs to that checkout and nobody else can reopen it.
That is what makes the evidence portable when the parent waits from another
worktree.
There is no passed field. A holder's verdict on its own run is the assertion
the ref exists to replace. Do the work first, then file the result: it is a
record of what happened, not a form to fill in while you are still deciding what
to do.
magus job wait <job>
wait verifies the filed result against the job it was handed, and it verifies
EVIDENCE rather than what the result claims: every changed path inside the write
paths and outside the denied ones, a change set that is not empty, the
descendants the store carries, and a run recording a PASSING execution of this
job's own check. A job that verifies is recorded pass.
For a job that writes, the claimed changed_paths are held to the diff magus
observes since the job's checkpoint: every claimed path must appear in it, at
least one change in it must fall inside write_paths, and a diff magus cannot
read fails the job rather than passing it. A read-only job has no diff to check.
wait also refuses pass while any descendant (by parent chain) is still
live, naming each, and grades a job against its ancestors' symbol goals as well
as its own.
Two failing statuses, because they send a caller somewhere different. Exit 1 is a status: the result was read and rejected, and every rule that failed is named. Exit 2 is magus unable to answer: nothing was filed and nothing was piped in, the result would not decode, or the job would not write.
It checks what is mechanical. Whether the work is GOOD stays the reading of whoever forked it. How much of the job's acceptance criteria magus checks for you is the next section's subject.
Goals
A job's acceptance criteria are prose a person grades. A goal is the part
magus grades itself, and magus job wait will not record pass until every one
verifies. Goals are data: they live in the job record's goals, which
magus job fork --stdin, the client tool's magus\job.put and a magusfile's magus\job.put
all accept. There are no flags for them. fork refuses a job that writes and
declares neither a check nor a goal, since wait would have nothing to grade
it by; a read_only job is exempt.
magus job fork --stdin <<'EOF'
{"schema_version": 11, "id": "api/migrate",
"criteria": "move the accounts table to the new schema",
"write_paths": ["db", "api"], "check": {"target": "go-test", "project": "api"},
"goals": [
{"id": "migration", "kind": "paths", "paths": ["db/migrations/**"]},
{"id": "old-gone", "kind": "symbol", "expect": "absent", "symbols": ["LegacyAccountStore"]}
]}
EOF
A goal names a kind (what it examines) and an expect (what must be true
of it). Two fields rather than a kind per pair, so absent means the same thing
of a file and of a symbol:
| kind | expect | proven by |
|---|---|---|
check |
passed |
a recorded, PASSING run of that target, captured after the job was declared |
paths |
changed, present, absent |
the diff since the checkpoint, or the tree as it is now |
symbol |
changed, present, absent, unreferenced |
the knowledge graph, at the granularity below a file |
Each kind has a natural expectation, so the common goal declares only its kind
and subject: a check is asked whether it passed, files and symbols whether this
job changed them. kind defaults to check. magus job fork --schema prints
every field.
Every kind reads something magus already holds, which is what separates a goal
from an attestation. There is deliberately no escape hatch for "this command
exited 0": magus did not record that run and cannot attribute it, so such a goal
would be the easiest of all to satisfy falsely. Declare a target and use check.
symbol + unreferenced is the one worth knowing about: the symbol still
exists, and nothing may name it. That is the remainder a partitioned rename leaks.
Split the work per project and the callers that live in no project belong to no
job, so every job passes and the rename is unfinished. Grade it while the old
definition is still there: the graph counts references into a definition, so
once it is deleted unreferenced fails rather than report a zero it cannot
vouch for. The job that deletes it declares absent beside a check that builds
the callers.
A symbol goal takes the name the graph gives the symbol, and only that symbol
answers for it: a deleted Legacy is absent even while LegacyAdapter exists.
The single check is one of these goals, under the id check.
A goal is graded against what magus observes and never against the result's own
changed_paths. The holder's account of its work is the thing the goal
replaces, so a result claiming a file the tree does not carry is rejected, and
the refusal names each subject that failed rather than only that the goal
failed:
rejected api/migrate, and its state is unchanged
goal "migration": nothing matching "db/migrations/**" changed since
4a1c0cc8, so this goal is unmet
The diff and the tree are read in the checkout that took the job, while it still exists, so an orchestrator can wait from its own tree before it integrates.
An observation magus could not make FAILS the goal: an unreadable diff, a symbol graph that will not open, a name nobody asked the graph about. That is the opposite of how the guard treats an unanswerable question, and deliberately: a guard that cannot ask must not refuse a person's own command, while a goal that cannot verify must not certify, or the cheapest way past it is to break the observation.
Asking where a job stands
magus job wait verifies and RECORDS. To ask the same question without advancing
anything:
magus describe job api/migrate
It always grades every goal against the evidence magus holds right now,
alongside the job's terms, and writes nothing. Because it runs the same grading
wait does, the two cannot disagree; because it records nothing, an
orchestrator may ask while the holder is still working, and asking never blocks
that holder. The goals that read the tree and the graph answer even for a job
that has filed no result at all.
Sequence goals with depends_on between them when one has to be cleared before
another is approached. A failed prerequisite propagates, and the declaration
refuses a cycle. Goals do not nest: one wanting children is a JOB wanting
splitting, which the multi-agent skill's "every level narrows" rule already
covers, and keeping goals flat leaves the job tree as the only hierarchy with an
owner.
A check goal may not name the release gate. A ci check goal is refused by
the same rule that refuses --check ci, for the same reason: the gate runs
once, in the forking session's tree, after every job lands.
Declaring goals from Buzz
A magusfile or a magus buzz script declares them through the same store:
import "magus";
import "std";
fun declare() > void {
try {
magus\job.put("api/migrate", {
"criteria": "move the accounts table to the new schema",
"write_paths": ["db", "api"],
"goals": [
{"id": "green", "check": {"target": "go-test", "project": "api"}},
{"id": "migration", "kind": "paths", "paths": ["db/migrations/**"]},
{"id": "old-gone", "kind": "symbol", "expect": "absent", "symbols": ["LegacyAccountStore"]},
],
});
} catch (e) {
std\print("could not declare the job: {e}");
}
}
kind defaults to check and expect to the kind's natural expectation, so
the first goal above needs neither. The records are validated on the way in: a
goal carrying both a check and paths is refused, and so is one naming nothing to
examine.
Watch it: the console jobs view
The console draws one Jobs view, because a job
is ONE KIND OF THING however it was created. The server holds its own
maintenance jobs (graph sync, trail rotation, the review check) and a session
holds the ones an orchestrator handed out; both list together, and a HOLDER
column reading server or session is what separates them. magus ls jobs
prints the same two sets, with the same column, as a tree with parents above the
jobs they forked.
A job in a reported overlap is marked on both rows. A live row carries how long since it was last touched, and the released paths and their digests read in the detail beside the row. Beyond the jobs magus ends itself, every state was written by an agent or a person, which is why a row that has merely gone quiet is a job YOU decide is possibly dead.
The service behind it is magus.job.v1alpha1.JobService, the server's one
mutating console surface, mounted behind the same loopback bind and bearer token
as everything else. Start it with magus server start; see
the server. magus server status prints the mcp and console
URLs, and says so explicitly when the server predates the tree, because every
call through an older server is answered by the older build.
The spawn is recorded, never judged
Wire your host's sub-agent tool to the same magus session hook call as the
rest of the guard. A payload carrying a prompt rather than a
command or a file path is a delegation: magus records it as an agent_spawn
event on the local Activity Trail and returns pass without evaluating a single
rule.
That exemption is the point, not an oversight. There is no command and no path to judge, only a context transfer to note, and a prompt that merely MENTIONS a denied command would otherwise block the job that describes it. The handed context is routinely kilobytes, so it lands as a content-addressed blob and only its reference rides the event.
magus does not switch on your host's tool name anywhere: a payload carrying a prompt IS a spawn, and the callee label the host supplies becomes the event's action so a page of them groups by what was spawned.
Joining an event to a job is cooperative. Nothing in a host event names a magus job and magus will not infer one from prose, so the job is stamped only when the handed context's FIRST non-blank line reads:
lease: <id>
Use the same id you forked. An orchestrator that wants the join writes the marker; one that does not gets an event with no job, which is a missing join rather than a wrong one. A marker line quoted deeper in a prompt stamps nothing, on purpose.
Verify against the diff
A holder's report of what it changed is a claim. The checkpoint is what turns it into something you can check.
- Diff the actual tree against the job's checkpoint, and compare THAT against
the row's write and deny paths.
magus graph diff --rev <revision>gives the domain-level answer;git diff <revision> | magus diff -annotates each changed file with its reach, public-surface exposure, and referents. Seemagus diff: it refuses a git ref given positionally, so the pipe is the sanctioned spelling. - Check the dirty half of the token. A checkpoint whose digest differs from the tree you are diffing means the holder saw a different uncommitted tree, and the comparison you are about to make is not the one you think.
- Take acceptance evidence as an output reference the orchestrator reopens, never a holder's prose. A holder that ran a filtered subset and one that quietly restated its criteria both report success, and a transcript cannot tell you which happened.
- Regenerate declared outputs once, centrally, after the source work converges, then run the release gate yourself.
magus job wait prints the job's footprint: the declaration each changed line
of its diff since the checkpoint lands in. For a file the job claims only by
declaration, every declaration the diff touched that none of those claims names
is a violation, and so is a footprint magus could not read for a job that
claims declarations at all.
The same object serves review time. If you recorded a checkpoint when you stopped reading, the delta since then is the incremental-review flow on the agents hub: handing work out and picking review back up read the same identity.
What magus never does here
- Block a writer it cannot attribute. Only a process that named a live job is graded against a declared boundary; anyone else editing this workspace is advised at most, because a human in their own checkout names no job either.
- Transition a row on a guess. magus ends a job only on a fact it can prove
(jobs magus ends itself), always as
no_return, and never derives a pass or a completion; every other state was written by the agent or the person that put it there. - Judge a delegation prompt, or let one change a guard verdict.
- Mint anything for a checkpoint: no tag, no stash, no ref, no file.
- Inject any of this into an agent's context. Every surface here is pull-based, and the knowledge graph the partition is argued from is read the same way.