# Running a web feature with no session editing The aim: a web feature goes from a spec to a landed commit and a community announcement on folknet hardware with nobody editing anything. A person or an interactive session that edits files inside a feature's track is a failure of the system, not a step in it. This document surveys how far the repository is from that, ranks what stops it, and proposes a design. Nothing here is built. Evidence is cited as file plus symbol, or as the overnight run of 2026-09-24/25 (the roster `handoff.md` on `main` → "Overnight ramp", and the tick log `$HOME/.local/state/spec-watch/spec-watch.log`). Every claim not checked against code or a run is marked *unverified*. Hosts and addresses stay in `$HOME/rig-notes`. ## The principle The whole lifecycle of one feature is packaged as one job. The job is shipped to a build lane, started there, and runs to the end there. The only thing watched from outside is a commit appearing on a result branch. A live writer session in a development-box worktree, one per feature, is the failure state, because it needs something to keep it alive (a tick, a relaunch, a local rebase) and every such touch is a place a person is pulled in. The development box does three things: submit a job, poll for its result commit, and land it. The loop starts at an enabled spec and ends at a result commit. It does not write or enable specs: a person or a coordinator session does that, outside the loop, so spec authorship stays with a reviewer. Finished features are reviewed afterwards, asynchronously, and a review that finds problems sends the feature back through the loop as a new or amended spec. ## The loop as it stands ```mermaid flowchart TD spec["1. spec written
(person or session, by hand)"] tick["2-3. cron tick: model session creates
the worktree, commits handoff.md"] launch["4. writer launched on a lane
(local if every lane refuses)"] work["5. writer works on the lane:
corpus, ground truth, loop, model batch,
admit, commit, docs"] fetch["6. tick fetches the commits back"] rebase["7. tick rebases locally if behind"] gate["8. verify on a lane
(local if no lane answers)"] review["9. spec-reviewer subagent
inside the tick session"] land["10. bench/land.sh: gate again on a lane,
fast-forward merge, tag"] after["11. bench/after-land.sh: dashboard and map update,
announcement"] hand["12-13. by hand: push main;
map rules only for a spec
landed outside a job"] late["14. asynchronous review of the finished feature
(person or coordinator session)"] spec --> tick --> launch --> work --> fetch --> rebase --> gate --> review --> land --> after --> hand after --> late late -. "problems: new or amended spec" .-> spec ``` | # | Step | What runs it | Where | Person or interactive session needed | |---|---|---|---|---| | 1 | Write the spec and its `touches`, `depends-on`, `enabled` | a person or an interactive session; drafting rules are prose in the roster's "Queue" section | development box | yes, every feature | | 2 | Decide a tick is due | cron, `spec-watch-tick.sh` then `spec-watch-due.mjs` (no model) | development box | no | | 3 | Create the worktree, run `tracks.json` `install`, write and commit `handoff.md` | the tick: one `claude -p '/spec-watch'` session (`prompts/spec-watch.md` → "Start unstarted or stale work") | development box | no | | 4 | Start the writer | `spec-watch-launch.mjs` → `bench/lane-writer-launch.sh` → the declared harness's writer on the lane with the lowest load | lane; local when every lane refuses | no | | 5 | Build the feature: corpus, ground truth in three browsers, generation loop, model batch, admission, commit, docs | the writer, following the spec's prose | lane | no | | 6 | Bring commits back | `spec-watch-fetch.mjs` → `bench/lane-writer-fetch.sh`, fast-forward only | development box | no | | 7 | Rebase onto `main` when behind | the tick; a conflict outside `handoff.md` relaunches a writer locally ("A rebase conflict") | development box | no | | 8 | Gate: `bench/verify.sh` | `bench/on-lane.sh` | lane; development box if no lane answers | no | | 9 | Independent review | `spec-reviewer` subagent, four questions, inside the tick session; verdict saved in `$HOME/.local/state/spec-watch`, not in git | development box | no | | 10 | Land | `bench/land.sh`, one track per tick; reruns `verify` and `landChecks` (`bench/fragment-floor.sh`); `bench/after-land.sh` then raises the floors | gate on a lane; merge in the primary checkout | no | | 11 | Dashboard and map update, announcement | `bench/after-land.sh` → `bench/dashboard-update.sh`, `bench/web-features-update.sh`, `bench/feature-announce.py`; a landed job always posts once to #bez (what it admitted, its run time, and the features that moved or why none did), any other landing only when a feature moved to partial or complete | development box | no | | 12 | Push `main` and `spec/*` tags | `bench/land.sh` pushes nothing ("Nothing is pushed"); `jobs/scripts/poll-and-land.sh` pushes a landed job's `spec//` tag to `JOBS_TAG_REMOTE` (default `origin`), not `main` | development box | yes for `main`, after each batch of landings; also for the tag of a landing the poller did not make | | 13 | Floors, map rules for what landed | floors: `bench/generated-floors-update.sh` from `bench/after-land.sh`; map rules: a job's `feature-map` stage from its manifest's `feature_rules`, with a `NOT ANNOUNCED` line from `bench/expected-features-check.py` after landing when a declared feature did not move | lane (floors, the job's stage); development box (the line) | no for a job; yes, by hand, for a spec landed outside a job | | 14 | Review the finished feature; on problems, write a new or amended spec that re-enters at step 1 | a person or a coordinator session, outside the loop, after the result commit and without holding a landing | development box | yes, by design: spec authorship stays with a reviewer | Which parts already work unattended: the overnight run had the tick write, review and land seven specs with no hand intervention (`dashboard-history-graph`, `text-metrics-oracle`, `testdriver-playwright-input`, `transforms2d`, `audio-null-sink`, `inline-atomic`, `tables-fixed-layout`), and `fixed-positioning` landed by hand after `bench/land.sh` refused it. The independent review found two real defects in `aspect-ratio`. So steps 2 to 11 run without a person when a spec is well formed; what fails is the edges (steps 12 and 13) and the checking inside the steps. Steps 1 and 14 need a person or a coordinator session by design. One correction to older text: the roster's "Resume Here" says an owning interactive session's loop drives ticks. `spec-watch-tick.sh`'s header says no interactive session drives ticks, and the crontab runs it every five minutes for genera. The roster line is out of date. ## Reviewing, stopping and rewinding a job's stages A job's stage results can be read and re-run from the development box without watching a lane directory. - **A commit per passed stage.** After each stage passes, `job-run` commits the work tree as `stage: ` on the branch `job-stages/` and pushes it to the `--publish` remote (`result.rs` `commit_stage`, `publish_stage`). Each commit's parent is the previous stage's commit, and the first stage's parent is the job branch head. The tree is the work tree minus git-ignored files and the manifest's `keep_dirs`; a kept directory appears as a `Kept-dir: ` line in the message, because corpus caches are large. A landing keeps the branch on the tag remote as the tag `stages//` before it deletes it from the forge (`jobs/scripts/poll-and-land.sh`); when the tag cannot be pushed, the branch stays on the forge. - **Output under ignored paths.** A stage's declared `outputs` (the stage cache's field) and its `output_dirs` (`jobs/src/manifest.rs`) are force-added to that stage's commit and kept in every later stage commit, even where git ignores them or they lie in a kept directory, so each stage commit holds what the stages so far produced (for `aspect-ratio`, a few MB of `corpus/` per run). The `dump` stage of `jobs/scripts/e2e-fixtures.py` `stage_commits_stages()` names `target/e2e-stage-dump` in `output_dirs`. Until 2026-10-08 no step-pipeline run had a stage commit: `git add` refused the exclude of a kept directory that git ignores, and every stage logged "no stage commit". - **Why a separate branch.** Landing fast-forwards the base branch to the tip of `job/` and takes every commit reachable from it, so generated output on that branch would reach `main`. `job-stages/` is never an ancestor of the job branch, and a landing deletes it with `job/` and `result/`. - **Stopping.** `JOBS_STOP_AFTER= jobs/scripts/submit.sh ` makes `job-run` stop once that stage has passed: exit 13, state saved, job branch pushed, no result written. The poller records the job as `stopped` and does not resend it. A resend by the poller keeps the stop point (record field `stop_after`), so a dependency dropping out cannot carry a held job past it. A plain `submit.sh ` clears it and resumes at the next stage. A resend by the poller likewise keeps the run's other settings from its record (`model`, `batch_models`, `rerun`, `pass_failing_cases`), never the poller's own environment. A run stopped in the middle with `bench/dagu-lane.sh stop-run job ` is recorded `stopped` too and not resent. - **Rewinding.** `JOBS_REWIND_TO=` (`job-run --rewind-to`) restores the work tree from that stage's commit, resets the job branch to the `Job-head:` trailer the commit carries and makes the later stages pending. It applies once, on the send that asked for it: a resend by the poller resumes where the rewound run got to, and a send that died before `job-run` started loses its rewind and must be sent again. It exits 2 for an unknown stage, a stage that has not passed, or one with no commit, and combines with `JOBS_STOP_AFTER` only when the stop stage comes after the rewind stage. - **Stages with no commit.** A job that resumes from a run that began before stage commits existed has passed stages with no commit. None is invented from the current tree, since that would put later content under an earlier stage's name; the next stage commit lists them as `No-commit: `. A resume in a fresh clone first fetches `job-stages/` from the publish remote (`result.rs` `adopt_stage_branch`), so the earlier commits are chained on, not overwritten. - **Reusing a stage whose inputs did not change.** A script stage may declare `inputs` (repo paths or globs it reads; a path inside an earlier stage's `outputs` stands for that stage's output), `env` (variable names whose values it depends on) and `outputs` (everything it writes). Before such a stage runs, `job-run` keys it by those inputs, its command, the `{tools}` files it names and its agent (`jobs/src/cache.rs` `stage_key`), and when the share (`WEBENGINE_LANE_SHARED`) holds an entry for that key under `archive/stage-cache/`, it restores the stored outputs and passes the stage without running it (message `reused `). A stage that runs and passes stores its outputs there. `job-run --rerun ` forces a run and replaces the entry. A reuse rewrites the entry's `last-used` file (`cache.rs` `mark_used()`), and right after each store `job-run` removes that stage's old entries (`cache.rs` `prune()`): per job and stage it keeps the 2 most recently used, with no age floor, and removes the rest along with `.tmp-*` leftovers older than 14 days. This is the one retention rule every job-run folder follows (`jobs/scripts/retention.sh`, `docs/infrastructure.md` → "Retention of job-run folders"), which also applies it at the end of every job and on each lane sweep. An entry's last use is the newest modification time of its directory, `meta.json` and `last-used`; on the share that time comes from the NAS clock. A removed entry is renamed aside first, so another lane's lookup sees it whole or not at all. The prune runs in `job-run` too, rather than only at the end of the job, because a store is the only thing that adds entries; a prune that fails is logged and the stage still passes. `job-check manifest` checks that every repo path the command names lies inside an input or an output, that `--out` lies inside an output, and that no two stages' outputs overlap; what a script reads without naming it cannot be checked. `JOBS_RERUN=[,]` on `submit.sh` passes `--rerun` through `bench/dagu/job.yaml` (`RERUN`). A review is never restored; in a manifest with no writer stage it is passed without running when nothing was committed since the base commit (a `store` that found its candidate already stored), with the message `nothing committed since base; no diff to review`, and `job-run` writes the `review.json` a reviewer would have, a `pass` with that message as its notes, so the result passes `job-check result` (`jobs/src/runner.rs` rule 6). A script stage that declares `outputs` without `inputs` always runs and is never stored. - **Starting afresh instead of resuming.** A resend whose spec changed, or of a job whose manifest gives any stage `inputs` when the manifest or a path some stage lists as an input differs between the recorded base and the base tip, goes at the base tip and starts the job afresh (`jobs/scripts/submit.sh`, "The base sent"): `submit.sh` tags and deletes the forge's `job/` from the older base, and the lane's `prepare` moves the old job directory aside and clones again, so every stage runs again except those the stage cache restores. Any other resend resumes in place at the recorded base, as before; a needs-person resend with the spec unchanged always does, since it carries a person's fix. `prepare` no longer restores `keep_dirs` from the newest archived tar, which said nothing of what the directory was made from. `specs/aspect-ratio.job.json` declares `inputs` and `outputs` on every script stage but `wpt` (its checkout is pinned by `oracle/suites/suites.json`, which keys the stages that read it), `store` (it commits), `findings-pages-prompt` and `findings-pages-keep`, which declare `outputs` only: `findings-pages-prompt` reads the round's rework file, which no path names, and `findings-pages-keep` reads its own earlier output to carry the pages earlier rounds kept, so both run every time, and the stages reading their output are keyed by what they wrote. That manifest has no writer stage; its `rework_from` names `findings-pages-prompt`, so a review `fail` that names the inputs on which the candidate is wrong turns them into browser-measured train cases and repairs again, up to `failed_rounds` (`tasks/job-review-rework.md`). A `fail` that names no such input gets `"rework": false` from `pi-review.sh` and starts no round; its review is advisory, so once no round follows the job lands the stored candidate on the score bar (`jobs/src/runner.rs` rule 22). `repair` lists every folder and file `bench/repair-loop.sh` leaves beside the batch, for each of the twelve rounds its `--rounds 12` allows (`outputs` take no globs, so a change to `--rounds` changes the list with it); the loop only reads the batch itself (each round's `report.json` goes in that round's `-work` folder), so the `candidates` stage's output stays its own. `bench/e2e-jobs.sh` has two scenarios for this: `e2e-stage-commits` covers stage commits, `output_dirs`, rewind through `submit.sh` and the stage branch being gone after a landing; `e2e-stage-cache` edits one stage's input on the base after a failed run and expects the resend to start afresh with only that stage, the stage that reads its output and the stages without `inputs` run again. Unless given `--keep`, the run's cleanup (`bench/e2e-lane-clean.sh`) removes each scenario's stage cache entries (`archive/stage-cache//`), so gate runs leave none on the share. ## Gaps, ranked by how often they stop or degrade the loop Counts come from the overnight run (eight specs, seven landed by the tick, one by hand) and are the roster's, not rechecked against the tick log line by line. ### 1. Specs are written by hand (stops every feature) Nothing creates a spec, and the loop is not meant to: specs are written by a person or a coordinator session outside the loop, so the gap to close is a malformed spec, not the lack of an author. The roster's "Queue" is drafted by a person or a session, and the drafting rules live as prose there: single-line front matter (`bench/land.sh` `touchesOf()` reads a multi-line list as empty), `touches` must name the spec's own test files, its provenance module, `crates/provenance/src/lib.rs`, `docs/generated-functions.md` and `docs/feature-map.json`, and a `depends-on` on the previous generated-function spec because they all edit `fragment.rs`. No check enforces any of it. A drafted spec cannot arrive by a track either: `bench/land.sh` refuses any diff under `specs/` or `tasks/`. Every feature job carries the same two things, and these are checked. Its manifest (`specs/.job.json`) lists the web-features ids it moves as `expected_features` and the exact browser-compat-data keys it implements as `feature_rules`, each with the `status` and `evidence` of a `docs/feature-map.json` rule, and only keys the spec's scope covers (a feature's keys are listed in the pinned `web-features` package under `oracle/node_modules`; `aspect-ratio`'s spec leaves out the `` and `