Skip to content

feat(gate): admit at once while the host has headroom - #189

Merged
kixelated merged 7 commits into
mainfrom
quest/m0/gate-headroom
Oct 22, 2026
Merged

kixelated merged 7 commits into
mainfrom
quest/m0/gate-headroom

Conversation

@kixelated

@kixelated kixelated commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Finishes quest/m0/gate-headroom.md, planned in kixelated/quest#180 after the measurements in kixelated/quest#177.

Changes

  • Admit with headroom (src/cli/gate.ts, admit()). Each admission from the last minute counts as one more unit of load and one command's memory budget:
    1. While load + recent < CPUs and available memory − recent × budget still covers one command, the command starts at once.
    2. While only the raw load and memory have room, commands start one at a time, SETTLE_MS (5 s) after the previous admission. Waiters sleep until that moment, not to a window boundary.
    3. Otherwise the command waits, as before.
  • Admission ledger, no lock. Host.claim(window) is replaced by ledger() / publish(base, admissions). The ledger is a directory of numbered versions, each the JSON list of the last minute's admissions. A caller decides against the newest version, then publishes version N + 1 by hard-linking a complete draft to that name. Only one caller can create a given name, so a burst elects exactly one winner per version; the losers re-read and decide again. No lock is held, so a caller that crashes at any point blocks no one, on every platform, and there is no PID check to fool. A caller that stalls and re-creates a pruned version number sees that the newest version doesn't hold its admission, withdraws, and retries.
  • Clock. The ledger uses the wall clock. Neither alternative is shared across processes under Bun: process.hrtime() restarts in each Bun process (41 ms in a fresh one), and os.uptime() has whole-second resolution. A backward step dates future admissions to now and publishes that, so they keep counting and spacing admissions but expire a minute after the step. A forward step can expire admissions at most a minute early. Both stay within the one-lifetime bound.
  • Admission time. A caller records the time it publishes, read just before publishing. If it stalled for SETTLE_MS or more after sampling the clock, load, and memory, it decides again, so a descheduled caller can't record a stale admission that lets the next one skip its spacing.
  • Stable directory. claimDirectory() is always ~/.cache/quest/admissions/<hostname>. The home directory comes from the user database, not $HOME or XDG_RUNTIME_DIR, so it no longer switches with the environment or with logind sessions. Bun's os.userInfo() reads $HOME first, so accountHome() runs the lookup in a child process started without HOME. That child sets BUN_BE_BUN=1 so the compiled binary works too, and costs about 10 ms per admission. An account missing from the user database, as in arbitrary-UID containers, uses /tmp/quest-<uid>/admissions/<hostname>. Quest creates /tmp/quest-<uid> with mode 0700 and refuses it unless only that UID can write it. The directory is a new name, so this release never reads the old window files.
  • Ctrl-C once (src/cli/interrupt.ts, shared by quest gate and quest worktree run). SIGINT is not forwarded while Quest is in its terminal's foreground process group, because the terminal already delivered it to the child. Linux reads that from /proc/self/stat; other Unixes use ps -o tpgid=,pgid=. SIGHUP and SIGTERM are always forwarded. On Windows, quest gate waits for the child instead of forwarding.
  • README admission paragraph, quest gate --help, and the admit() doc comment describe the new behaviour.
  • scripts/gate-bench.sh overhead times admission in-process against a fresh ledger per run, since the directory no longer follows XDG_RUNTIME_DIR.
  • Deletes the quest and its links, including the out-of-scope Ctrl-C note in quest/m0/worktree.md.

Hazards covered by tests (test/gate.test.ts)

  • A burst passing one shared free-memory sample: the second caller sees 6 GiB − 4 GiB and is spaced.
  • A burst that all read "0 recent": an interleaved test, plus 12 real processes racing for a 4-CPU headroom. Exactly 4 are admitted, and the ledger holds 4. Ignoring a failed publish makes this admit 12, so the test catches it.
  • Two admissions milliseconds apart across an old window boundary: the next one waits SETTLE_MS − 1.
  • Holes from expired admissions: the count comes from unexpired entries, not from slot numbers.
  • Livelock of an over-capacity burst: callers decide before recording, so each published version has exactly one winner.
  • Dead caller, and PID reuse: there is no lock. Drafts left by a dead caller older than a minute are pruned, and a version cut short by power loss reads as empty.
  • Backward and forward clock steps, as described under Clock above.
  • performance.now() per process, and CLOCK_MONOTONIC at boot: neither is used. The multi-process test checks spacing across processes.
  • Callers with and without XDG_RUNTIME_DIR, or started with another HOME, get the same directory. Each runs as its own process, because Bun reads the environment once at startup. An account missing from the user database gets a directory keyed by its UID.
  • A caller descheduled for SETTLE_MS after sampling the host decides again and records the time it published, so the next caller still waits SETTLE_MS.
  • Terminal Ctrl-C: a pseudo-terminal test (util-linux script) counts the SIGINTs the gated child receives. It sees 1; without the fix it sees 2.

Benchmarks

Host: 24 CPUs, 61 GiB RAM. Not idle: other agents' builds and tests kept the 1-minute load at about 19 to 42 throughout (24 is the limit), so these numbers are noisy, and the idle-burst case could not be measured on an idle host.

Scenario Mode Failed suites 5 s timeouts Wait median / max Run median Makespan Peak load1 Load before
8 suites, all CPUs gated 0/8 0 60 / 170 s 22.4 s 192.6 s 31.2 31.2 / 36.7 / 37.9
16 on CPUs 0-1 (8×) gated 1/16 1 21 / 146 s 90.2 s 171.3 s 29.2 18.9 / 26.7 / 32.7
  • Idle burst: the load was above the CPU count when the burst arrived, so every suite waited in band 3, as it should. These numbers measure the host's busy period, not admission spacing. The idle case is covered by tests rather than measured: band-1 unit tests, and 12 real processes that are admitted 4 at once against a 4-CPU headroom.
  • Pinned 8×: the load was 18.9 when the burst arrived, so about five suites started together on two CPUs, followed by spaced admissions. Run times rose to about 90 s, against about 14 s under the old spacing in kixelated/quest#177. One suite timed out on test/seed.test.ts, the same test as in test(gate): measure admission under Worker suite contention #177. As the plan expected, headroom is measured across the host, so the old gate's protection does not apply to a taskset-pinned burst.
  • Admission cost (overhead 10): median 1 ms in-process against a fresh ledger. The CLI startup is about 105 ms bypassed, slower than test(gate): measure admission under Worker suite contention #177's 32 ms because of the host load. One run waited 250 s for the load to fall.

Validation

  • just check passes. It covers TypeScript, prettier, quest validation, actionlint, shellcheck, shfmt, nixfmt, and the Worker check.
  • just test passes: 398 + 306 tests, the hook suites, the lifecycle of both builds, and the Worker tests.
  • quest check --base origin/main passes.
  • Mutation checks confirm the new tests can fail. Forwarding every SIGINT makes both Ctrl-C tests see interrupts=2. Ignoring a failed publish makes the multi-process test admit 12 instead of 4. Recording the sampled time makes the stall test record at: 0. Reading the home with an in-process userInfo() makes the directory follow HOME.
  • Not run here: macOS (ps -o tpgid=,pgid= path) and Windows (linkSync, file-removal tolerance). Windows CI runs bun run test once the PR is ready for review.

Follow-ups

  • Re-run scripts/gate-bench.sh gated 8 and ungated 8 on an idle host to measure the idle burst directly. The expectation is close to test(gate): measure admission under Worker suite contention #177's 19 s ungated makespan. Accepted as a follow-up in Decision 1 below.
  • cloudflare/test/seed.test.ts timed out again under pinned 8×. It was folded into quest/m0/kix/inbox-accuracy.md, which kixelated/quest#182 finished. If it still times out under load, it is a /quest-plan candidate.
  • On macOS, os.hostname() can change with the network when no hostname is set with scutil, which would move the ledger directory. A low-priority /quest-plan candidate, if it shows up in practice.

Decisions

Settled by the maintainer:

  1. Benchmarks from a loaded host are accepted. The host never went idle during this session, so the idle-burst case was not measured idle. Band 1 is covered by unit tests and by a real multi-process race; an idle-host re-run of scripts/gate-bench.sh is a suggested follow-up.
  2. The wall-clock ledger under ~/.cache/quest/admissions/<hostname> stays. The ledger uses the wall clock, with clamping, because Bun offers no host-wide monotonic clock; a forward step can expire up to a minute of admissions early. The directory is a new name, so the old window files are never read. The Linux-only /proc/uptime clock was declined to avoid a per-platform branch.

Chosen while addressing review, open to change:

  1. The account home comes from a child process without HOME. It costs about 10 ms for each non-nested quest gate. The alternatives were calling getpwuid through bun:ffi, whose passwd layout differs between Linux and macOS, or parsing /etc/passwd, which misses NSS and macOS directory-service accounts.
  2. Accounts missing from the user database use /tmp/quest-<uid>. The path depends on no environment variable. If another local user creates the name first, Quest refuses it with an error rather than trust their ledger, so they can block that account's gated commands but cannot make them overrun the host.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

kixelated and others added 3 commits October 21, 2026 22:39
Quest-Claim: 264968c8b3e93113
Each admission from the last minute counts as one more unit of load and one
command's memory budget. Commands start at once while load and memory still
fit counting those, one at a time SETTLE_MS after the previous admission while
only the raw measures fit, and otherwise wait.

Admissions live in a lock-free ledger of numbered versions published by
hard-linking a complete draft, so concurrent callers never exceed the rule, a
burst always elects a winner, and a crashed caller blocks no one. Every caller
of one user on one host shares ~/.cache/quest/admissions/<hostname>, whatever
its environment or login session.

A terminal Ctrl-C now reaches the child of `quest gate` and `quest worktree
run` once: SIGINT is not forwarded while Quest is in the terminal's
foreground process group; SIGHUP and SIGTERM always are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title Admit gated commands at once while the host has room feat(gate): admit at once while the host has headroom Oct 22, 2026
@kixelated

Copy link
Copy Markdown
Collaborator Author

Implementation pushed in f13f8c6. just check and just test pass. Admission now uses a three-band headroom rule over a lock-free ledger, the claim directory is stable, and a terminal Ctrl-C reaches the child once in both quest gate and quest worktree run. The PR stays a draft for the two decisions in the description: whether to accept benchmarks taken on a loaded host, and whether to keep the wall-clock ledger at its new location.

(Written by Claude Opus 5.5)

@kixelated
kixelated marked this pull request as ready for review October 22, 2026 15:39
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-22T16:04:49.660011Z 07b0ead New commits
🔒 Security Review ✅ Completed 2026-10-22T16:08:24.472371Z 07b0ead New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f13f8c629f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/cli/gate.ts Resolved
Comment thread src/cli/gate.ts Resolved
kixelated and others added 2 commits October 22, 2026 08:59
A caller stalled for SETTLE_MS or more between sampling the host and
publishing now decides again, and every admission records the time it
was published rather than the time it was sampled.

Bun's os.userInfo() prefers $HOME, so the ledger directory followed the
caller's environment. The account's home now comes from a child process
without $HOME, and an account missing from the user database uses
/tmp/quest-<uid> instead of falling back to $HOME.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 07b0ead14e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/cli/gate.ts Resolved
kixelated and others added 2 commits October 22, 2026 09:27
Another local user can create /tmp/quest-<uid> first. Create it with
mode 0700 and refuse it unless it is a real directory, owned by the UID,
that no one else can write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

Merge summary for this round:

  • Stale admission time (review thread r4350423147): admit() reads the clock again just before publishing. It decides again if it stalled for SETTLE_MS or more, and otherwise records the fresh time. Regression test added.
  • HOME-independent ledger (review thread r4350423157): under Bun, os.userInfo().homedir reads $HOME first, so the primary path depended on HOME as well as the fallback. The earlier in-process test could not see this. accountHome() now looks the account up in a child process started without HOME. An account missing from the user database uses /tmp/quest-<uid>. The test starts a separate process with another HOME.
  • Private UID directory (review thread r4350643529): Quest creates /tmp/quest-<uid> with mode 0700. It refuses the directory if it is a symlink, is owned by another UID, or is group- or world-writable. Tests cover all three cases.
  • Merged main twice, after kixelated/quest#186, kixelated/quest#191 and kixelated/quest#182. The only conflicts were quest files: quest/m0/worktree.md and both Required links were already deleted on each side.
  • just check and just test pass locally. CI passes. quest check --base origin/main, run from a build of main, passes.

The maintainer's earlier decisions stand. New implementation choices are listed under Decisions 3 and 4 in the description.

(Written by Claude Opus 5.5)

@kixelated
kixelated merged commit 4925083 into main Oct 22, 2026
14 checks passed
@kixelated
kixelated deleted the quest/m0/gate-headroom branch October 22, 2026 16:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant