Login
Check-in [e06e736748]
Login

Many hyperlinks are disabled.
Use anonymous login to enable hyperlinks.

Overview
Comment:The node's check, finding a watcher gone, ends the process group GNU timeout leads in the watcher's session for a walled sandbox, as before aeabbd54e0; and the comments and docs/fossil.md say the repository and a checkout's own database hold a command's start as Fossil's settings do

Intent: protocol v1's promise that a run's ended comes only once its program and all it left have ended (docs/protocol-v1.md, sections 7 and 10; HISTORY.md decision 118), and comments that say what Fossil does. Two review findings, each confirmed. 1. An adversarial review of aeabbd54e0 (scratchpad adv-sg6: gtimeout.c, endleft_new.cr, endleft_old.cr, endleft_fix.cr, out-endleft.txt): aeabbd54e0 changed Node.end_left's test from the launched process leading its own group to its leading its own session, dropping a process that leads its group but not its session. GNU coreutils timeout, the server host's, calls setpgid(0, 0), so a walled run in a sandbox, started as timeout -s KILL N bwrap ... in the watcher's session, leads its own group, not its session: when its watcher died the check killed nothing and answered the run lost while timeout, bubblewrap and the sandbox ran on to the wall, and the user's next run, or a second run from a user holding R alone, could start beside it (the probe: 1 s after the check, timeout and the sandbox's sleep still ran; with the fix, gone; under BusyBox timeout, which becomes bubblewrap in the watcher's group, bubblewrap, the watcher's own child, ends with it by --die-with-parent, its sandbox with it, either way). 2. A review of 31c712a101 (scratchpad rv31: hold.sh, batch.sh, m/): the comments of Fossil::LET_GO, Fossil::TRIES and Place#hosted, and docs/fossil.md, blamed a slow start of a command, or of OCX's wait, on Fossil's settings alone, while the repository and a checkout's own database each hold a command's start the same way.

Expression: src/ocx/care.cr: Node.end_left, past the watcher's group, when the launched process started when the run's lock says, ends every process group of the session it leads, or else the group it leads, by its own number: session == launched ? groups_of(launched) : ([launched] if group == launched). Its comment now names the three places the node's own launcher can be in (Watcher.launched): bubblewrap, or BusyBox timeout become bubblewrap, in the watcher's group; setsid, for an unconfined run or one at a terminal, leading a session; GNU timeout leading a group in the watcher's session. docs/protocol-v1-fossil.md, section 5, gets back "or its own group, for one that leads one, as GNU timeout does"; docs/design/modules.json says so of care.cr, and docs/design/modules.md is generated from it. src/ocx/fossil.cr, comments alone: LET_GO says a holder keeping new readers out of the repository, as a writer does at its COMMIT while a reader holds it, fails the wait at its start, in a checkout too, after twice BUSY, its last line "not a valid repository", and one keeping them out of a checkout's own database fails the wait there after three times BUSY; TRIES and Place#hosted give each wait's start up to twice BUSY for the repository or the settings, three times for a checkout's own database. Tried again with Fossil 2.27 (scratchpad gt-trial: trial.sh, trial2.sh, trial3.sh, out-repo.txt, out-repo2.txt, out-co2.txt, out-set.txt), a reader holding the database and a writer of it waiting at its COMMIT: the repository so held, a wait by -R, fossil info, and in its checkout fossil add, fossil changes and the wait there each failed after 30.03 s, last line "not a valid repository", "SQLITE_BUSY(5): database is locked" before it, so Place#hosted still reads it as a holder met; the checkout's own database, its fossil add, fossil changes and wait after 45.08 s, "database is locked" at an ATTACH, a wait by -R at once; the settings, each after 30.04 s, in the checkout too. docs/fossil.md, at 400 lines, folds these into its point "Every fossil command reads what it opens as it starts", and two of its points, on a checkout's own database and on fossil commit --branch, are re-wrapped to width 76, their words unchanged, so it stays at 400 lines. HISTORY.md decision 119, the next free number, names both reviews and amends decision 118.

Review: Gemma (gemma4:31b) read the change four times (scratchpad gemma-gnu-timeout-1.txt to -4.txt). Held: none. Misread, each a clarity finding, the code changed and asked again: in the first reading, that a launched process in a group it does not lead, other than the watcher's, is missed, reading the launched process as any program: end_left's comment now says it is the node's own launcher and names the three places one can be in; in the second, that kill(-launched) may miss the group when the group's number differs from the process's, inside a branch taken only when they are equal: the comment over the groups to end now says a group or a session a process leads takes its number from it. Wrong, in the third reading: the first finding again, by a wrapper between the watcher and the launched process that leads its group, proposing to end whatever group the launched process is in; no such wrapper exists, as the launched process is the watcher's own child (Sandbox.start), starting in the watcher's group, and none of the node's launchers, bubblewrap, BusyBox or GNU timeout and setsid, joins a group it does not lead but the watcher's, so the instructed test stands. The fourth reading, of the change as landed, the BusyBox example's reads moved before the watcher's end (below), raised the same and concluded it found nothing that contradicts the intent or fails at run time.

Demonstrates: spec/ocx/care_spec.cr, "ends a gone watcher's walled run, its timeout, bubblewrap and sandbox, in the group GNU timeout leads in the watcher's session, only when it started when its lock says": a stand-in for GNU timeout's handling of processes, from its timeout.c, built with cc (pending, saying so, where cc is not on the PATH), as timeout -s KILL 300 bwrap ... sleep 300 under a watcher leading its own session, killed once the sandbox's sleep runs; the stand-in leads its own group in the watcher's session; a lock naming another start ends nothing, the right one ends timeout, bubblewrap, the sandbox's init and its sleep. Against aeabbd54e0's src/ocx/care.cr it fails, "waited 30 s for the walled run ended". "leaves nothing of a gone watcher's walled run under BusyBox timeout, which becomes bubblewrap in the watcher's group, ending with the watcher": the same with BusyBox timeout (pending where busybox is not on the PATH), bubblewrap in the watcher's group, every process of the run ended, on aeabbd54e0 as now. As first built it read bubblewrap's /proc/PID/stat after the watcher's end, and the gate's first run failed there, No such process: bubblewrap, the watcher's own child, has --die-with-parent and ends with it, tried by hand too; the reads now come before the watcher's end. The gate's first run also failed spec/guards/delineation_spec.cr, HISTORY.md naming docs/fossil.md without a link, now a link. These hold src/ocx/care.cr; src/ocx/fossil.cr changes in comments alone, held by the trials named above. spec/guards/size_spec.cr (docs/fossil.md at 400 lines, spec/ocx/care_spec.cr at 241), spec/guards/derivation_spec.cr (docs/design/modules.md as modules.json generates it), spec/guards/documents_spec.cr (each edited document's links) and spec/guards/decisions_spec.cr. crystal run ci.cr green: 502 examples, 0 failures.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVpmCsBwVF3d4zhMHHDAxs

Downloads: Tarball | ZIP archive
Timelines: family | ancestors | descendants | both | agent-tools
Files: files | file ages | folders
SHA3-256: e06e736748d03f9d0a19d245d21c33747cb674c394489c4191bdccdf1fc8bce2
User & Date: opus-ocx2 2026-10-23 09:28:16.296
Context
2026-10-23
10:55
An import pipes git's stream straight into Fossil, never a file, each step's largest file within a third of the space free; from the web page it goes on detached, answering at once, a TERM stopping it as a failure, its outcome logged where status shows it with the imports running and those that ended unreleased; two imports of one name never meet, and taking a directory never waits on a fossil command

Intent: the maintainer, 2026-10-21: "My `import` of https://github.com/beagleboard/beagleboard-org keeps failing. The post times out, so I don't know what happened." The tool's purpose, slice 39's: "comfort that i can import projects and reliably serve development back to git with ocx2". Reproduced on this host, the same code at the command line: the upstream, 3.0 GB packed with 527,641 commits, stopped after 10 minutes at import_branches, "git in the import's sandbox stopped by its files limit (SIGXFSZ)": the whole fast-export stream was written to a file, past the 8G files limit; and the page ran the whole import inside its request, so the browser gave up and the person never learned the outcome. An adversarial review of the change as first built (uncommitted on 22d4decadd) found seven defects, each fixed here: 1. a detached import that TERM or KILL ended logged no outcome, and status then showed nothing of it; 2. the 64 GB limit on each file alone could not stop a runaway before the disk filled; 3. the web page waited with no limit on the new repository's turn, which fossil git export held for two minutes in the proof; 4. a failed import left NAME.fossil-ocx.lock beside the served repository; 5. a detached import refused at check_import or read_served_setup left its directory, its job.json with any token in the upstream's URL; 6. Fossil's import took its turn from a checkout above its directory, failing at a .fslckout that did not read; 7. HISTORY.md and docs/interfaces.md gave the detached import's argument as its job, not its directory.

Expression: src/ocx/commands/import.cr: ImportBranches runs git's fast-export and `fossil import --git --no-vacuum` side by side, each in a sandbox of its own, through an unnamed pipe, each end handed to its task, which closes it in this process as the task starts (src/ocx/launch.cr, src/ocx/tasks.cr), so either task's end ends the other's; fossil's failure is kept as a value, so git's is always received, and the import says both, git's first, leaving out git's SIGPIPE when fossil failed (Git::Failed keeps the signal, src/ocx/git.cr). Each task's limits (ImportCommand.limits): 48 hours of wall clock and as much CPU time, and a largest file of FILES, 24 GB, about twice the 10.6 GB repository the proof made, or a third of the space free where it works as it starts, when that is less, read by statvfs (Tasks.free, src/ocx/tasks.cr), so the new repository and its journal, or the clone, leave a third free (finding 2). TakeWork takes the directory first and answers whether it took it now; ImportCommand.run releases it only then, an import the web page started releasing it itself once its outcome is logged. From the web page (StartImport) the release's program starts detached, by setsid (src/ocx/session.cr, answering the process), as `ocx2 --import-detached WORK` (src/ocx/entry.cr), WORK's job.json, 0600, holding the served repository, the user and the input. src/ocx/imports.cr, new: Imported, with unmade! and discard (moved from ImportCommand.discard); the directory `.NAME.import` named `PID @TICKS`; Imports.take, Imports.release and Imports.standing hold the imports' lock, an flock on the served repository's directory itself (Imports.holding), exclusive to take or release, shared to read, never a fossil command, so no taking waits on one and no lock file is left (findings 3 and 4); release removes the directory only while it names the calling process; running reads a process as importing only while /proc shows it at that number, with that start, not ended; standing gives the imports running, `left`, each directory whose process ended without releasing it, as KILL leaves one (finding 1), and the log's last outcome. src/ocx/imports_detached.cr, new, split from imports.cr to keep both within 400 lines: the Job, Outcome and Detached elements, StartImport, Imports.start, and Imports.detached, which traps TERM for an Ending: Ending#ask stops the import's log (Log#stop, src/ocx/log.cr: each operation after is refused, "OP was not begun: SIGTERM asked the import to end") and sends TERM to each child not yet waited for (Sandbox.signal_children, src/ocx/sandbox.cr), so the step running fails, its sandbox with it, under GNU timeout too, and the import stops as at any failure, discarding what it made (finding 1); a failure no operation refused, as a File::Error opening a repository's lock, which Entry.identified, before the answer, does not take for a refusal, is logged as the outcome too, its backtrace on standard error; then, however it stopped, refused at its first checks too, it releases its directory and job (finding 5). src/ocx/command.cr: a tool's answer takes the log its caller holds. src/ocx/fossil/sync.cr: import_git names the new repository by `on:`, as clone does, so its turn is that file's, never a checkout's (finding 6); src/ocx/fossil.cr: a sandboxed fossil reads a pipe's end, and its failure says how it ended. src/ocx/commands/status.cr: ReadImports, for whoever may import, in a release root. src/ocx/fossil/direct.cr: the export's state seeded in one transaction that waits on a reader (33 minutes for 399,783 check-ins before, 9 s for 527,641 now). src/ocx/hook.cr: Hook.last reads the last line of a log of its form; src/ocx/care.cr: Node.stat_of public. HISTORY.md decision 120, the next free number; USAGE.md's import section and row; docs/interfaces.md's import row (finding 7); docs/slices-git.md; docs/linux.md; docs/backlog.md, two items: an old check-in of so long a history reads in minutes, and an upstream's URL shows on git clone's command line while the clone runs; docs/fossil.md, over 400 lines with this change's points, its "Git export and import" moved whole to the new docs/fossil-git.md, named in knowledge-map.json; docs/survey/functionality.json; docs/design/modules.json and the modules.md and knowledge-map.md generated. The specs' git helpers moved unchanged to spec/support/upstreams.cr, and the detached examples to spec/ocx/imports_detached_spec.cr. Every file within precept C1: src/ocx/commands/import.cr 391 lines, src/ocx/hook.cr 400, USAGE.md 400.

Review: Gemma (gemma4:31b) read the change ten times (scratchpad gemma-import-large-land-1.txt to -10.txt, requests and replies in gemma-reviews/import-large-1 and import-large-land-2 to -10). Held: the third reading's, that "left" was said of imports KILL ended, or that logged no outcome, while it is any directory whose process ended without releasing it, its outcome logged or not: status's help, USAGE.md, HISTORY.md and the module docs now say so. The fourth's, that an exception no operation turned into a refusal escaped Imports.detached unlogged: as a File::Error opening the served repository's lock, raised in Entry.identified before the answer, whose capabilities rescue Fossil::Failed alone; it is now logged as the outcome, and a spec holds it. Misread, each a clarity finding, the code changed and asked again: the first's, that a failed start left the directory (take's comment now says a failing block leaves none, and only the named process releases it); that ImportBranches left git's result unreceived when fossil failed (the call now says fossil's failure is a value, git's always received); that release missed a zombie (its comment now says the importing process releases its own directory); that USAGE.md put the outcome in NAME.log (reworded). The third's and fourth's, that the started process writes `importer`, so a taking or status could look between: the line naming it now says this taking writes it before letting go of the lock that every taking and standing hold; not raised again. The fifth's, that a process dead at once, or one that took its number, reads as running: running's checks are now three lines, each named; not raised again. The sixth's, that `answer` is undefined after the rescue: Crystal makes it nil, as the spec of the jammed lock runs, and it is now declared nil first; not raised again. The seventh's, that the backtrace goes nowhere: the doc now says standard error is the same output file. Wrong: the second's, a directory KILL left lingering until the next import of its name: by design, status lists it as left, and OCX keeps no sweep or timer (decision 39; causation precept); its job.json, 0600 in a 0700 directory, holds no more than the clone's and the export's git configuration already keep. The third's, that a raise skips the TERM reset (ensure runs), and that KILL bypasses discard (no code runs on KILL; status lists the directory). The fourth's, that git's signal might not compare (Ended.signal is the name, "PIPE", as Signal::PIPE.to_s). The fifth's, a job.json corrupted by hand: the import then logs nothing and its directory stays, which status lists as left. The seventh's, that status should report a KILL'd import's outcome: `left` is that report. The eighth reading weighed several and concluded it found nothing. The ninth and tenth, after the specs' fixes below, the first's zombie again: that release, called by the importing process itself, may find it ended in /proc and leave its directory; a process calling release runs and has not ended, so running reads it whenever the directory names it, as a comment there now says, and the check is what keeps a late release from removing a directory taken since for another process, while take clears one whose process has ended; the tenth also had take refuse a left directory, which it clears.

Demonstrates: spec/ocx/import_spec.cr, "imports a stream larger than its tasks' files limit, git's stream piped into Fossil and never written to a file": as before, now in a directory below one holding an empty .fslckout, the directory then holding its turn's made-ocx.lock; with import_git's `on:` taken out it fails ".fslckout could not be read: no such table: vvar" (tried); ImportCommand.limits gives 10 GiB of 30 GiB free and FILES of 300 GiB, and Tasks.free reads the space free and refuses a directory not there. "takes an import's directory for one process at a time, under the imports' lock alone, and anew once that one has ended, which status shows": a take while another holder keeps the new repository's turn, as a fossil command of the import does (the take as first built took that turn itself, so it waited as long as the holder held it: the review's run, 7.7 s for a holder of 8 s), release by a process not named leaving the directory, standing showing the running sleep, a process named with its start that has ended and is not yet waited for read as not running, then once it ended `left` [work], cleared by the next take, and no lock file left. "makes a served repository whose export keeps the upstream's commits..." and "leaves nothing of an import that fails..." pass as before, the latter through Imported#discard. spec/ocx/imports_detached_spec.cr, "starts an import from the web page detached, answering at once, refuses another of its name meanwhile, and logs its outcome, which status shows": as before, plus the stand-in program's directory shown as left once it ended unreleased; the failed import leaves no lost.db-ocx.lock and nothing left; a detached import refused at check_import, its repository made meanwhile, releases the directory taken for it; one whose served repository's lock is a directory, so that opening it raises a File::Error no operation refuses, logs "File::Error: ..." as its outcome, its backtrace on standard error, and releases its directory. "ends a detached import that TERM asks to end as at a failure: its outcome logged, its sandbox ended, its directory released": git's clone of an upstream that accepts and never answers, a TERM sent to this process once the upstream has its connection, which the import's trap hears: exit 1, the outcome "git in the import's sandbox ended by SIGTERM...", the directory gone; and one whose Ending was asked before its first operation logs "check_import was not begun: SIGTERM asked the import to end". spec/ocx/fossil_spec.cr: a sandboxed fossil's failure says it exited 1, and only a sandbox reads a pipe. spec/support/upstreams.cr: the helpers, moved. spec/guards/size_spec.cr, spec/guards/derivation_spec.cr, spec/guards/documents_spec.cr, spec/guards/delineation_spec.cr and spec/guards/survey_spec.cr hold the documents, docs/fossil-git.md among them. These hold src/ocx/imports.cr, src/ocx/imports_detached.cr, src/ocx/commands/import.cr, src/ocx/commands/status.cr, src/ocx/log.cr, src/ocx/command.cr, src/ocx/sandbox.cr, src/ocx/tasks.cr, src/ocx/launch.cr, src/ocx/session.cr, src/ocx/entry.cr, src/ocx/git.cr, src/ocx/fossil.cr, src/ocx/fossil/sync.cr, src/ocx/fossil/direct.cr, src/ocx/hook.cr and src/ocx/care.cr. The proof, on this host, of the change as first built, before these fixes: beagleboard-org posted as setup to a scratch served repository's web page, niced, answered in 48 ms; 14 h 13 min later the import log said ok and status showed it: 527,641 check-ins, main branch master, in the login group, a 10.6 GB repository, its export's refs the upstream's own commits, fossil git status up-to-date; the clone 7 minutes, git's fast-export 10 h 5 min and 11 minutes of CPU for a 50.6 GB stream, Fossil's import 14 h 4 min and 13 h 17 min of CPU. The fixes were not run on that upstream again. The gate's first run failed its coverage ratchet, src/ocx/imports.cr missing 1 statement and src/ocx/imports_detached.cr 3: running's check of an ended process, the TERM trap's block, and the rescue of a failure no operation refused, whose first spec, a served repository gone, reached a refusal instead, as Entry.identified rescues Fossil::Failed; the specs above now run each. crystal run ci.cr green: 506 examples, 0 failures.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVpmCsBwVF3d4zhMHHDAxs check-in: 905756db67 user: opus-ocx2 tags: agent-tools

09:28
The node's check, finding a watcher gone, ends the process group GNU timeout leads in the watcher's session for a walled sandbox, as before aeabbd54e0; and the comments and docs/fossil.md say the repository and a checkout's own database hold a command's start as Fossil's settings do

Intent: protocol v1's promise that a run's ended comes only once its program and all it left have ended (docs/protocol-v1.md, sections 7 and 10; HISTORY.md decision 118), and comments that say what Fossil does. Two review findings, each confirmed. 1. An adversarial review of aeabbd54e0 (scratchpad adv-sg6: gtimeout.c, endleft_new.cr, endleft_old.cr, endleft_fix.cr, out-endleft.txt): aeabbd54e0 changed Node.end_left's test from the launched process leading its own group to its leading its own session, dropping a process that leads its group but not its session. GNU coreutils timeout, the server host's, calls setpgid(0, 0), so a walled run in a sandbox, started as timeout -s KILL N bwrap ... in the watcher's session, leads its own group, not its session: when its watcher died the check killed nothing and answered the run lost while timeout, bubblewrap and the sandbox ran on to the wall, and the user's next run, or a second run from a user holding R alone, could start beside it (the probe: 1 s after the check, timeout and the sandbox's sleep still ran; with the fix, gone; under BusyBox timeout, which becomes bubblewrap in the watcher's group, bubblewrap, the watcher's own child, ends with it by --die-with-parent, its sandbox with it, either way). 2. A review of 31c712a101 (scratchpad rv31: hold.sh, batch.sh, m/): the comments of Fossil::LET_GO, Fossil::TRIES and Place#hosted, and docs/fossil.md, blamed a slow start of a command, or of OCX's wait, on Fossil's settings alone, while the repository and a checkout's own database each hold a command's start the same way.

Expression: src/ocx/care.cr: Node.end_left, past the watcher's group, when the launched process started when the run's lock says, ends every process group of the session it leads, or else the group it leads, by its own number: session == launched ? groups_of(launched) : ([launched] if group == launched). Its comment now names the three places the node's own launcher can be in (Watcher.launched): bubblewrap, or BusyBox timeout become bubblewrap, in the watcher's group; setsid, for an unconfined run or one at a terminal, leading a session; GNU timeout leading a group in the watcher's session. docs/protocol-v1-fossil.md, section 5, gets back "or its own group, for one that leads one, as GNU timeout does"; docs/design/modules.json says so of care.cr, and docs/design/modules.md is generated from it. src/ocx/fossil.cr, comments alone: LET_GO says a holder keeping new readers out of the repository, as a writer does at its COMMIT while a reader holds it, fails the wait at its start, in a checkout too, after twice BUSY, its last line "not a valid repository", and one keeping them out of a checkout's own database fails the wait there after three times BUSY; TRIES and Place#hosted give each wait's start up to twice BUSY for the repository or the settings, three times for a checkout's own database. Tried again with Fossil 2.27 (scratchpad gt-trial: trial.sh, trial2.sh, trial3.sh, out-repo.txt, out-repo2.txt, out-co2.txt, out-set.txt), a reader holding the database and a writer of it waiting at its COMMIT: the repository so held, a wait by -R, fossil info, and in its checkout fossil add, fossil changes and the wait there each failed after 30.03 s, last line "not a valid repository", "SQLITE_BUSY(5): database is locked" before it, so Place#hosted still reads it as a holder met; the checkout's own database, its fossil add, fossil changes and wait after 45.08 s, "database is locked" at an ATTACH, a wait by -R at once; the settings, each after 30.04 s, in the checkout too. docs/fossil.md, at 400 lines, folds these into its point "Every fossil command reads what it opens as it starts", and two of its points, on a checkout's own database and on fossil commit --branch, are re-wrapped to width 76, their words unchanged, so it stays at 400 lines. HISTORY.md decision 119, the next free number, names both reviews and amends decision 118.

Review: Gemma (gemma4:31b) read the change four times (scratchpad gemma-gnu-timeout-1.txt to -4.txt). Held: none. Misread, each a clarity finding, the code changed and asked again: in the first reading, that a launched process in a group it does not lead, other than the watcher's, is missed, reading the launched process as any program: end_left's comment now says it is the node's own launcher and names the three places one can be in; in the second, that kill(-launched) may miss the group when the group's number differs from the process's, inside a branch taken only when they are equal: the comment over the groups to end now says a group or a session a process leads takes its number from it. Wrong, in the third reading: the first finding again, by a wrapper between the watcher and the launched process that leads its group, proposing to end whatever group the launched process is in; no such wrapper exists, as the launched process is the watcher's own child (Sandbox.start), starting in the watcher's group, and none of the node's launchers, bubblewrap, BusyBox or GNU timeout and setsid, joins a group it does not lead but the watcher's, so the instructed test stands. The fourth reading, of the change as landed, the BusyBox example's reads moved before the watcher's end (below), raised the same and concluded it found nothing that contradicts the intent or fails at run time.

Demonstrates: spec/ocx/care_spec.cr, "ends a gone watcher's walled run, its timeout, bubblewrap and sandbox, in the group GNU timeout leads in the watcher's session, only when it started when its lock says": a stand-in for GNU timeout's handling of processes, from its timeout.c, built with cc (pending, saying so, where cc is not on the PATH), as timeout -s KILL 300 bwrap ... sleep 300 under a watcher leading its own session, killed once the sandbox's sleep runs; the stand-in leads its own group in the watcher's session; a lock naming another start ends nothing, the right one ends timeout, bubblewrap, the sandbox's init and its sleep. Against aeabbd54e0's src/ocx/care.cr it fails, "waited 30 s for the walled run ended". "leaves nothing of a gone watcher's walled run under BusyBox timeout, which becomes bubblewrap in the watcher's group, ending with the watcher": the same with BusyBox timeout (pending where busybox is not on the PATH), bubblewrap in the watcher's group, every process of the run ended, on aeabbd54e0 as now. As first built it read bubblewrap's /proc/PID/stat after the watcher's end, and the gate's first run failed there, No such process: bubblewrap, the watcher's own child, has --die-with-parent and ends with it, tried by hand too; the reads now come before the watcher's end. The gate's first run also failed spec/guards/delineation_spec.cr, HISTORY.md naming docs/fossil.md without a link, now a link. These hold src/ocx/care.cr; src/ocx/fossil.cr changes in comments alone, held by the trials named above. spec/guards/size_spec.cr (docs/fossil.md at 400 lines, spec/ocx/care_spec.cr at 241), spec/guards/derivation_spec.cr (docs/design/modules.md as modules.json generates it), spec/guards/documents_spec.cr (each edited document's links) and spec/guards/decisions_spec.cr. crystal run ci.cr green: 502 examples, 0 failures.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVpmCsBwVF3d4zhMHHDAxs check-in: e06e736748 user: opus-ocx2 tags: agent-tools

08:35
A run's ended is written once its program and all it left have ended: the program's end, a stop and the wall end every process group of an unconfined program's session, a job's among them, and a sandbox's init whether or not it ends with the node

Intent: protocol v1's promise that a run's ended is written once its program and everything it left have ended (docs/protocol-v1.md, sections 7 and 10), under the maintainer's words of 2026-10-22: "the contract on this box is as long as the development doesn't disrupt the server, let the agents go a bit nuts"; a run whose program left a process running for ever holds its run's and its user's locks for ever, and a user's next run resetting the password under a running program breaks its work. An adversarial review of 22d4decadd (scratchpad review-lost-path.md, its probes and outputs in rv/) found two defects, each confirmed. 1. A child that puts itself in a process group of its own, as a shell's job control does for each job (set -m, or any shell at a terminal), and as setpgid, Python's os.setpgrp and GNU timeout do, leaves the program's group but stays in its session, and the watcher signalled the program's group alone: such a child outlived the program's end, a stop and the wall, plain and at a terminal (J1 to J6, each ended at 17.2 s, when the child's sleep 17 did), and with its output elsewhere the run's ended was written while it ran (J7 at 0.69 s, J8 at 0.22 s), so the user's next run could reset the password under it (case 26); Node.end_left had the same gap. 2. Older: a sandbox whose ends_with_node is false outlived a stop and its wall when not at a terminal, as a stop sends TERM to bubblewrap's outer process alone and timeout KILLs that alone, and without --die-with-parent the sandbox's init and its program ran on (K1, stopped at 1.8 s, ended at 17.3 s; K2, a wall of 3s, ended at 17.26 s).

Expression: landed on 31c712a101, the branch's newest, from the tree first built on 86190c5301, whose diff did not apply whole: HISTORY.md, docs/backlog.md, docs/protocol-v1-failures.md, docs/survey/functionality.json and docs/design/modules.md each conflicted where the branch had added since, and each now holds both sides; src/ had no conflict. src/ocx/sandbox.cr: Sandbox.signalled(session, signal) signals each process group of the session the program leads that a child of the watcher not yet waited for is in (/proc/PID/stat's group and session, SESSION_FIELD), so never a group that took its number, by LibC.kill, whose failure, as for a setuid program's group on a node that does not run as root, is no failure in a signal's handler, and answers those children; Sandbox.ending_left(session) KILLs those groups and each child of the watcher that is a sandbox's init (two pids on its NSpid line), which bubblewrap leaves as its outer process ends, and with it, as the kernel ends a pid namespace with its init, every process of the sandbox, answering the children it sent KILL; Sandbox.until_left_ended(session) waits for each by its pid, LibC.waitpid, as the removed Sandbox.whole waited for a removed workspace's sandbox, then ends and waits for what each left as it ended, which became the watcher's child, until none is left, however long that takes, so nothing in it waits on time. As first built, until_ended looked again every 20 ms (LOOK), and the gate's survey guard, spec/guards/survey_spec.cr, found src/ocx/stopping.cr a new timer, a behaviour caused by time; the wait by pid is what replaced the look, the guard and docs/survey/gaps.json unchanged. Sandbox.whole is gone, its wait now every run's. `children` says why each child it lists is still there for its caller's reads, signals and waits. src/ocx/stopping.cr: a stop TERMs the program and those groups; `left`, on each SIGCHLD once the program has ended, calls ending_left; until_ended, which was `ended`, waits for the program, then for what it left (Sandbox.until_left_ended), never on the CHLD trap, which is the whole process's and which a spec watching two runs in one process resets, and then forgets the program, so no later signal reaches a group or session that took its number. src/ocx/watcher.cr: launched calls until_ended right after Process#wait, before it says how the program ended, and the failure path after the stop it asks; watcher.cr is at 398 lines, within precept C1 (File Boundedness). src/ocx/care.cr: Node.end_left, for a program that leads its own session and started when the run's lock says, ends every process group of that session, read from each process /proc lists (groups_of), one gone meanwhile passed over. docs/protocol-v1-failures.md: case 34 rewritten, saying what remains, a process that starts a session of its own, which only a sandbox holds; case 36, a sandbox that does not end with the node stopped, in a section of its own at the end, "What a sandbox leaves running", since the branch gave case 35 to a writer outside OCX and a case keeps its number. docs/protocol-v1.md: section 6's ends_with_node (a stop, the wall and the program's end end it either way), section 7's wall, section 10's stop and exit rows, and section 11, row 1's reason for refusing ends_with_node false to a user holding R alone (what runs inside would outlive the node's process that watches it), each in place, the file at 399 lines. docs/protocol-v1-fossil.md section 5; docs/linux.md (a process group stays in its session; bubblewrap's init without --die-with-parent; the stat fields); docs/crystal.md (a CHLD trap is the whole process's, so the watcher waits by pid); GLOSSARY.md (Watcher); USAGE.md, in place, at 400 lines; docs/interfaces.md; docs/survey/functionality.json; docs/design/modules.json, sandbox.cr's purpose now saying it waits for each by its pid, and the modules.md generated from it; HISTORY.md decision 118, the next free number, as the branch took 110 to 117 since, amending decisions 80 and 97 and decision 107's reason; docs/backlog.md: the item this closes replaced by a question for the maintainer, whether section 11, row 2's need of ends_with_node for remove_on still guards anything.

Review: Gemma (gemma4:31b) read the change as first built six times (scratchpad gemma-session-groups-1.txt to -6.txt), no finding held: its misreads, that a child Crystal's reaper took between `children` and the read of its /proc/PID/status raises File::Error, and that groups_of raises for a process gone between the listing and stat_of, were answered in the comments of `children` and groups_of, which say why neither happens; its misread and its wrong finding about until_ended's looks went with the looks. Landed, with the wait by pid, it read the change twice more (gemma-session-groups-7.txt and -8.txt). Held: none. Misread: none. Wrong, in both readings: that until_left_ended may wait for ever, every fiber of the watcher with it, on a child KILL cannot end yet, as one in uninterruptible sleep on a hung NFS mount, or one the watcher may not signal, proposing waitpid with WNOHANG in a loop with a sleep, or a timeout. The run's ended is to come only once all the program left has ended, so the wait for such a process is the intent, which the second reading was given in the comment ("however long that takes, as the run's ended comes only after") and raised again; the looks as first built waited for as long; a sleep is the timer the survey's guard refuses; and a timeout would write ended while the process runs, the defect this fixes. The second reading's "a zombie the watcher cannot reap" is wrong too: waitpid on a child that has ended takes its status at once. After the readings, spec/ocx/care_spec.cr's example put its child's script in a variable, as the gate's format check asked; nothing else changed.

Demonstrates: spec/ocx/ending_spec.cr, "ends with its program what it left in a process group of its own, in its session, its output held or elsewhere, plain and at a terminal (case 34)": the review's J3 and J7, plain, a child that os.setpgrp puts in a group of its own, holding the output or sending it to /dev/null, and J1 and J8, at a terminal, a set -m job, each exited 3 within 30 s, the child sleeping 40 s gone once the ended is written. "ends at a stop and past its wall what an unconfined program left in a process group of its own, plain and at a terminal (case 34)": J5, plain, and J2, at a terminal, a set -m job in the terminal's foreground ignoring the hang-up, each stopped within 30 s, its child gone; J4 and J6, with a wall of 2s, budget, "it went past its wall, 2s", within 30 s, its child gone. "ends at a stop and past its wall a sandbox that does not end with the node, every process of it with its init (case 36)": K1, sleep 40 holding its output, stopped once it has made `ready`, ended stopped within 30 s, and K2, with a wall of 2s, budget. Its stop helper now also serves the example before them. spec/ocx/care_spec.cr, "ends every process group of the session a gone watcher's program leads, only when it started when its lock says": the program's child in a group of its own, in its session; a lock naming another start ends neither, the right one ends both. spec/support/runs.cr: Runs.sandbox takes ends_with_node. These hold src/ocx/sandbox.cr, src/ocx/stopping.cr, src/ocx/watcher.cr and src/ocx/care.cr. Against 3869da228e's src (scratch-import/sg-before-specs.txt), each of these four examples fails at its first run: J3 and J5 ended past 30 s, K1 stopped past 30 s, and end_left left the job running. The review's probes against this src, landed with the wait by pid (scratchpad sg5probe/out.txt): J1 to J8 ended at once, at the stop or at the wall (J7 at 0.64 s, J8 at 0.16 s), K1 stopped at 1.84 s, K2 budget at 3.21 s, K3 and R1 to R7 as before, none leaving its child running. spec/guards/survey_spec.cr: no new timer, src/ocx/stopping.cr among the timers as first built. spec/guards/size_spec.cr (src/ocx/watcher.cr at 398 lines, USAGE.md at 400, docs/protocol-v1.md at 399), spec/guards/documents_spec.cr (the new anchor sandbox-left and every edited document's links), spec/guards/derivation_spec.cr (docs/design/modules.md as modules.json generates it) and spec/guards/decisions_spec.cr (decision 118). crystal run ci.cr green: 500 examples, 0 failures.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVpmCsBwVF3d4zhMHHDAxs check-in: aeabbd54e0 user: opus-ocx2 tags: agent-tools

Changes
Unified Diff Side-by-Side Diff Patch
Changes to HISTORY.md.
Changes to docs/design/modules.json.
Changes to docs/design/modules.md.
Changes to docs/fossil.md.
Changes to docs/protocol-v1-fossil.md.
Changes to spec/ocx/care_spec.cr.
Changes to src/ocx/care.cr.
Changes to src/ocx/fossil.cr.