Utility: background, jobs, foreground - work that outlives the prompt

Status: Built + QEMU-verified (osdev test jobs, 36/0, QEMU with a real disk). Shell built-ins backed by the copier service. Trails CLAUDE.md; does not amend it. The design reasoning, including the option that was rejected, is docs/job-control-design.md.

One file for three names, deliberately. They are one mechanism with three surfaces: a way in, a table, and a way back. Splitting them would put the state model in three files, and a state model described in three places is the drift this repository keeps having to repair. Precedent: 30_first-last.md, 31_records.md.


1. What it is

A long command should not own the prompt for its whole life.

gsh> background copy /big.bin /backup/big.bin
[backgrounded] job 1 - `foreground 1` brings it back

gsh> jobs
JOB  STATE    PROGRESS  COMMAND
1    running   38%      copy /big.bin /backup/big.bin

gsh> foreground 1
[q] quit   [b] background
copying... 41%
job 1 done

2. Invocation

CommandMeaning
background copy <src> <dst>start the copy detached; print its job id; return to the prompt
background delete <path> recursiveremove a whole subtree detached
background drives checkcheck the volume detached; foreground replays the verdict
background drives scrubread-only CRC sweep, detached
background churn <seconds>sustained write traffic, detached - the prompt stays yours
jobsthe table: id, state, progress, command
jobs quit <job>stop a job without attaching to it first
foreground <job>attach the console to a running job, or report a finished one
q, while attachedstop the job, not just the view (conventions rule 11)
b, while attacheddetach it again, leaving it running

Typed at the prompt, four of these run as jobs anyway (2026-10-14, backlog/fixed/89): copy <file> <dst>, delete <dir> recursive, drives check and drives scrub start as the job background would start, and the console attaches to it as foreground does. Nothing changes until 2 s; then the notice is [q] quit [b] background, so the key the conventions promise (rule 10) is there exactly where it can work. b leaves the job running and gives the prompt back:

gsh> drives check
  [q] quit   [b] background
[backgrounded] job 3 - `foreground 3` brings it back

q stops it where it can be stopped: a copy is cancelled between its chunks and its incomplete destination removed. A check, a scrub or a tree delete is ONE request to fs, which cannot be stopped part-way, so q says that and leaves it running as job N. A job that ends while attached prints what it found, in the shell's own words - copier writes the lines the shell would have printed, after one line naming the job (copying, deleting a subtree, drives check - walking the volume) - and its result is the command's. In a script, a pipe, a function, or while a job is already running, the command runs in the shell as before and offers [q] alone: a script's drives check must not let the script run on past it.

jobs quit exists for the reason background does when b already would (§2 of the design note): making somebody attach to a job in order to stop it is the interface forgetting what they just told it. It is not kill copier - killing the service skips its cleanup, so a half-written destination survives as a full-size file with an undefined tail. It routes through the job's own cancel, which removes it. A job is not the service that happens to be running it.

3. Two commands run detached, and the test is not "is it slow"

background is not a modifier that can be put in front of anything. The question a candidate has to pass is:

is this command's value its EFFECT, or its OUTPUT?

A detached job does not print to the prompt (§4): what it says goes into its transcript (§4a) until somebody asks for it, so a command whose whole product is a report has nowhere useful to put it. That sorts the candidates cleanly:

commanddetached?why
copy <src> <dst>yesthe effect is the file; progress is a count, not a screen
delete <path> recursiveyesfs does the whole walk in ONE operation, so the job is one request and a wait
drives checkyesone fs request, and its report lives in the transcript (§4a) until asked for
drives scrubyesthe same shape as check - one request, one verdict - which is what made it nearly free
churn <seconds>yesthe effect is thousands of transactions on disk. It is also the command somebody most wants the prompt back during: it holds the console for its whole run, so a ten-minute churn is ten minutes of a blind machine
selfcheck, chaos, runno, permanentlynot an output problem. These drive other shell built-ins through the shell's own dispatch; a service cannot call into it, and there is one console input ring with one reader. See §3a - the obvious way to make them detach was designed, costed and REJECTED
findnot yetthe shell walks directories itself, so the service would need that walk. The transcript already solves its other half
copy <src> <dst> recursivenoan interrupted WALK leaves a prefix of a tree - a different permitted-outcome question, and one nothing here answers
delete <path> (no recursive)noone metadata edit; it would be over before jobs could list it

Two different blockers, and they are worth keeping apart. "Where does the output go" was solved by the transcript below and is what let drives check in. "Who can run the command" is untouched by it, and is why selfcheck stays out however good the buffer gets.

Refused by name, leading with the reason, and naming what to type instead:

gsh> background selfcheck
background: `selfcheck` not supported for it runs inside the shell - a job runs as a detachable separate service.
  detachable services: copy <src> <dst>, delete <path> recursive, drives check, drives scrub, churn <seconds>

That list is built from the table the dispatch reads, not written into the sentence. An earlier version was a hand-kept string with a comment asking the next person to update it - the same drift facts_check.py exists to catch elsewhere - so adding a kind now updates the message on its own, and osdev test jobs asserts the advertised list names every verb that works.

Refusing is the honest answer rather than the incomplete one (§26.2: the preferred state of an unneeded feature is "not implemented; will be implemented when a test requires it"). background chaos in particular would be a storm nobody can watch or stop, which is worse than no answer.

CHURN HAS A REAL PERCENTAGE, and that sharpens the rule. The column shows a number when something MEASURES it, not when the job happens to be a copy: churn's bound is a duration, so elapsed-over-total is measured. A finished churn reads 100%, because the clock stops at the full duration rather than at the last second sampled before the deadline - a completed run sitting at 91% reads as one that stopped short.

Only the duration form detaches. churn verify, churn tear and churn reset are one-shot and stay at the prompt.

THE TEAR PATTERN LIVES IN sdk::churn, NOT IN EITHER CALLER. Churn's whole purpose is that a torn file is detectable: every byte encodes the generation that wrote it (byte[k] = (gen + k) mod 251), so a file holding a mix of two writes breaks the relation at the exact byte where the tear happened. Detaching churn put the WRITER in services/copier and left the CHECKER in the shell, in different crates that deliberately do not share headers - and if those two expressions ever disagreed, churn verify would report NONE torn while no longer able to recognise a tear at all. A safety check that passes because it broke is worse than no check, because somebody trusts it. So the pattern moved to the SDK, where there is one of it and both sides name the same function.

3a. NOT ALL COMMANDS ARE BACKGROUNDABLE, and that is the settled answer

This is worth stating flatly, because the shape of the feature invites the opposite assumption: that the list above is a work queue and everything reaches it eventually. It is not.

The obvious way to detach selfcheck and run is to spawn a SECOND, throwaway shell to run them and capture its output. That was designed and costed - about two days, and none of it hard - and then rejected on security, which is backlog/fixed/41:

A service whose job is to execute whatever command arrives on its endpoint must hold the authority to execute ANY command. Its power is then exercisable by whoever can send to it, rather than by whoever is at the console. That is a confused deputy: it acts on instructions without being able to know whether the instructor was entitled to what is being asked. And the protection degrades from a property of the SERVICE (it holds fs and nothing else, true no matter what else changes) to a property of the whole GRANT TABLE (it holds everything, and nobody dangerous can currently reach it

  • one grant away from false, with nothing failing when it becomes false).

It is also the exact failure the capability model exists to prevent: authority flowing from IDENTITY rather than from possession, which is invariant 3.

So the honest statement is not "these cannot detach yet". It is: a command whose execution needs the shell's own authority cannot become a background job without giving that authority to a service that takes orders, and that trade is refused. find is the one genuine "not yet" in the table above - it needs a directory walk and nothing more.

A recursive delete shows no percentage, and does not invent one. fs owns the walk, so nothing here can say how far it has got. The column shows -; 0% would read as stuck and 100% as finished, and both would be fabrications.

JOB  STATE    PROGRESS  COMMAND
1    done     100%      copy /fill3.bin /copy.bin
2    stopped    0%      copy /fill3.bin /cancelme.bin
3    done        -      delete /tree recursive

It also cannot be cancelled once started, and says so rather than pretending. The tree delete is one blocking fs request, so while it runs the service is not reading its endpoint; a cancel waits behind it and arrives after the job has ended. There is no half-measure available, because fs owns the walk.

4a. The transcript: where a detached job's output lives

A job holds no console. Its output goes into a fixed 4 KiB ring inside the job service, and foreground pulls it out when somebody asks. Nothing is ever pushed, so a job does not write over a prompt somebody is typing at. (That is by convention - the job never calls gs::io - not by capability: copier holds log_write, and printing is log_write. See §4.)

gsh> background drives check
[backgrounded] job 4 - `foreground 4` brings it back

gsh> jobs
JOB  STATE    PROGRESS  COMMAND
4    done        -      drives check

gsh> foreground 4
job 4 is done
  drives check - walking the volume
  check: 6 files, 5 dirs, 0 bad; 11352 blocks used, 21416 free (bitmap + free count rebuilt from the tree)
  check: the free count already agreed with the tree - nothing was repaired
  check: ok - filesystem is consistent

The verdict is RENDERED here, not passed through. fs answers a check or a scrub with COUNTS - [files:u32, dirs:u32, bad:u32, ..] - and the first version of this wrote that body straight into the ring on the principle that the filesystem owns what its verdict says. That principle is right about the FACTS and wrong about their presentation: raw little-endian integers printed as characters put , on the screen. It survived a 48-check suite because the check path happened to return an empty body on the test volume and a fallback sentence printed instead; the scrub returned real numbers and showed the bug. The assertions were widened to require the rendered words, since an assertion that cannot tell prose from binary is not testing the replay.

It is bounded and it says when it dropped. 4 KiB of fixed storage, no heap, no growth (§26.6.1). A job that says more ages out its oldest LINES - not bytes, so a replay never begins mid-word - and the replay opens with the count:

  ... 612 earlier byte(s) dropped - the transcript is 4 KiB and this job said more

A bounded buffer that silently truncates is worse than no buffer: it hands somebody a partial drives check that reads as a complete one, which is precisely the silent failure invariant 12 forbids.

There is no jobs output <id> verb. foreground <id> already means "attach the console to this job"; replaying what it said IS attaching. A second word for it would be a second way to say foreground (§26.2).

Only the newest job's transcript exists. The service runs one job at a time and clears the ring when the next starts. foreground on an older finished job reports its outcome and stays silent rather than replaying a different job's text under the right job's heading - a lie that would read perfectly.

Read once, then copier is ended (2026-10-11). copier is not a service, and it used to stay up until reboot. The shell now ends it after the command that finishes its work: once the job has stopped running and its transcript has been replayed by foreground - or was empty, so there was nothing to collect. A later foreground on that job says the text was shown when it was first read back and is not kept after that. The shell learns that a job ended only when jobs or foreground asks, so a job that finishes while nobody looks keeps copier until then.

4. What a job can reach

The copier service holds fs, the board's audio driver (audio-driver on the PCs, pwm-audio on the Pis, for the sound a finished job makes) and its log. It has no console_push, so a detached job cannot inject keystrokes into the shell's input. It CAN write to the console - every task can, through log_write - and stays off the prompt by convention: it never calls gs::io. (This said a capability stopped it; printing needs only log_write, so none does. Corrected 2026-10-09, with the contract's own comment.) It cannot spawn, reboot, or reach the network.

The honest limit of that claim. The design note describes minting a READ capability for the source and a WRITE capability for the destination and handing over exactly those, so a job could reach nothing else for its whole life. That is not what this does: the shell's spawn takes a name and nothing else, and per-invocation capability delegation is listed as future work in 10_spawn.md §5. So the bound is the contract's (fs, entire, plus the audio driver) rather than the two paths'. It is a real reduction from the alternative - a job running inside the shell's loop would hold the SHELL's authority, which includes spawn and reboot - and it is not the bound the design claimed. Recorded here rather than left to be discovered (§26.7).

5. States

Four words from the service, and one the shell adds:

statemeans
runningin progress
donethe job finished - for a copy, the whole file was copied
failedit stopped on an error; the reason is printed with the row
stoppedq cancelled it
lostthe shell's own verdict: copier is gone, so the job did not finish and how far it got is unknown

lost is a separate word from failed on purpose. failed claims knowledge of a failure that was observed; when the service dies mid-job nobody observed anything, and saying failed would assert more than is known. It is also not left as running, which would be a row that never changes again.

ALIVE-AND-SILENT IS NOT GONE, and conflating them was a real bug caught by adding the second job kind. The job service is single-threaded, so while it is inside one long fs request - which a recursive delete is, entirely - it reads no messages and answers no status. Treating "did not reply" as "is not running" would mark a job lost at precisely the moment it is doing its work. There are three cases: an answer, a service that is busy (the row keeps its last true figures), and a service that is actually gone (lost).

6. An interrupted copy DELETES its destination

fs allocates a file's whole extent up front, so an interrupted copy would otherwise leave a file of the right size whose tail is undefined content. That is worse than no file: dir shows the expected byte count, read returns something, and nothing says the tail is garbage. So a cancelled or failed job removes what it made, and says that it did. If the removal itself fails, that is reported too - a failed recovery is still a failure (§26.7).

7. Bounds

  • Eight rows, fixed. The table does not grow and does not queue (§26.6). With all eight rows live a ninth background is refused, naming what is running. Otherwise the ninth takes the row of the oldest finished job, which is a record that may not have been read - deliberately, because keeping finished rows forever is not a bound but a leak: it refused the ninth job of a session permanently to protect a row that had usually already been read. A LIVE row is never taken.
  • One job at a time. A second background copy while one runs is refused, naming the job that is still going. A queue is unbounded growth wearing a small word.
  • Finished rows are kept so a job that ended while nobody was looking is still reportable.

8. jobs is a producer

One row per line, so it filters like everything else:

jobs | where state=running

There is no jobs running subcommand. Rule 12 makes a utility's output a pipeable structure, and a bespoke positional filter for one table would be a second way to say what where already says.

jobs quit <id> is a subcommand and that is not a contradiction. The rule is about FILTERING - selecting rows is where's job and must not be reinvented per table. quit does not select anything; it acts on one job. The house already splits these the same way: drives is a producer and drives check acts, events is a producer and events persist start acts.

9. Non-goals

  • No wait. Nothing has needed one.
  • No & suffix. background <cmd> is the word; a sigil would be a second way to say it, and POSIX vocabulary besides (rule 8). There is no fork, no exec and no process groups here, so borrowing bg/fg would borrow a model this system does not implement.
  • No nesting. background background x is refused, not defined.
  • No job control over services the shell did not start. kill and restart address those by name; two kinds of row would make the table mean two things.
  • No detached subtree copy. background copy <dir> <dir> is refused: an interrupted walk leaves a prefix of a tree, which is a different permitted-outcome question and one nothing here answers.
  • No detached run or selfcheck. Not an output problem: a .gsh script drives shell built-ins through the shell's own dispatch, and no buffer helps with that.
  • Applications do not need this. An application IS a service, and spawn <name> already returns immediately while it runs - status, kill and restart manage it. background exists only to give shell BUILT-INS, which have no service of their own, somewhere to run. The real gap is that spawn takes a name and nothing else, so an application cannot be given arguments or invocation-scoped capabilities (10_spawn.md §5). Note that a detached recursive DELETE is fine for the opposite reason - fs performs that walk itself, in one operation, so this feature never holds a half-finished tree.
  • No cancelling a running recursive delete. It is one blocking fs request; a cancel arrives after it has ended. Refusing to pretend is the whole of §3's last paragraph.
  • No second job while one runs. Refused, naming the job that is still going, rather than queued.