Skip to content

feat: gate checks on host load and memory - #128

Merged
kixelated merged 24 commits into
mainfrom
quest/m0/local-gate
Oct 21, 2026
Merged

kixelated merged 24 commits into
mainfrom
quest/m0/local-gate

Conversation

@kixelated

@kixelated kixelated commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

quest gate <command> [args...] waits until the host has CPU and memory room before starting an expensive command. The just check, test, worker-check, and worker-test recipes use it automatically; nested calls run immediately.

  • Limits come from the host's CPUs and effective RAM, without configuration. Admission waits while the host-wide load average exceeds the host CPU count, or below min(4 GiB, RAM / 4) available memory. Linux uses MemAvailable and the remaining budgets of enclosing v1/v2 memory cgroups, counting their inactive file cache as reclaimable; macOS skips its cache-unaware memory signal, and Windows skips load average.
  • One caller per host claims each 5-second window through exclusive file creation in $XDG_RUNTIME_DIR (or a per-hostname cache directory), and a claim is refused while the previous window's claim exists, so admissions are always at least 5 seconds apart. Expired samples are rejected before and after creation, so cleanup cannot admit a delayed waiter into an old window.
  • Admission is the gate's only job. It holds nothing while the command runs and owns no descendants; an environment marker lets nested gated calls run at once.

The Linux systemd slot backend (scopes, slot handshake, cgroup.kill inode pinning, prerequisite probes, the opt-in systemd suite, and the CI linger step) was removed in this round (decision 21). It is recoverable from commit 5684307. Exact descendant ownership and orphan cleanup move to the worktree process-ownership quest, /quest/m0/worktree.md, which now brings its own backend.

The agent load-average rule is removed from the spawn and complete skills. /quest/m0/local-gate.md remains open only for the representative contention measurement; no responsiveness improvement is claimed yet.

Decisions

  1. What triggers admission?
    • Fixed per-user slots only
    • Load average only
    • Both: slots cap concurrency, and admission also waits while the 1-minute load average exceeds the core count (user) superseded by decision 21
    • ✅ Load and memory admission, no slots (decision 21, user)
  2. Which platforms are gated, and how?
    • Linux only, with an unsupported error elsewhere
    • The load gate runs everywhere, and Linux adds systemd slots; other platforms get load/memory admission only (user) superseded by decision 21
    • ✅ Load and memory admission on every platform, with no Linux-only backend (decision 21, user)
    • Leave entrypoints ungated until native backends exist
  3. Bun --no-orphans
    • ✅ Research only: best-effort registration and bounded PID-tree walks do not establish exact ownership (user, as recommended)
    • Adopt it as a portable ownership backend
  4. Where do the limits come from?
    • A ~/.config/quest/gate.json file, with defaults taken from the CPU count
    • ✅ Host specs (CPU, RAM, and similar), with no configuration file and no compatibility layer (user)
  5. What sets the load threshold?
    • The usable core count from availableParallelism(), which honors affinity (superseded by decision 17)
    • ✅ The host CPU count, because the load average is host-wide (revised 2026-10-21, decision 17)
    • A fraction of the cores
  6. How many Linux slots are there? max(1, min(cores, RAM / 4 GiB)) Moot: superseded by decision 21, which removes slots.
  7. Should admission also wait on memory?
    • ✅ Yes, while available memory is below min(4 GiB, RAM/4), so a small host still runs one command (user suggestion). macOS skips this check because its free count excludes reclaimable cache.
    • No, CPU load only
  8. How are waiters kept from all starting when the host frees up?
    • ✅ At most one admission per 5-second window, claimed with an exclusive per-window file and refused while the previous window's claim exists, so admissions stay a full window apart (Codex finding)
    • A lock held for the whole command
    • Random jitter
  9. What happens on Linux without a systemd user manager? Moot: superseded by decision 21; every Linux host uses load and memory admission.
  10. Which entrypoints are gated?
    • ✅ The check, test, worker-check, and worker-test recipes, each wrapping a private _recipe
    • Every bun run script
  11. Where do the representative contention measurements go?
    • ✅ The quest keeps them as a follow-up, because a fair measurement needs an idle host
    • Measure in this PR
  12. Which skills change?
    • ✅ Remove the agent load-average rule from the spawn and complete skills (user)
  13. How should unsupported Linux ownership prerequisites behave? (probe, then fall back) Moot: superseded by decision 21, which removes the probes.
  14. How should cancellation establish ownership across slot reuse? (pinned cgroup.kill inode) Moot: superseded by decision 21; the gate cancels only its direct child.
  15. Which memory boundary should admission and limits use?
    • ✅ Linux MemAvailable, bounded by each ancestor memory cgroup's limit and current usage; derive effective RAM from the same limits (background recommendation). A real 256 MiB scope reproduced Bun reporting host-wide availableMemory(), so that API cannot establish the container boundary here.
    • Continue using system-wide free memory with a container-sized budget.
  16. How should expired admission files be reclaimed?
    • ✅ Keep cleanup and reject an expired sampled window both before and after exclusive creation (background recommendation).
    • Retain every claim file indefinitely.
  17. What should the host-wide load average be compared with? (Codex finding)
    • ✅ The host CPU count, max(availableParallelism(), cpus().length).
    • A per-cpuset load from /proc/stat or PSI. Rejected: a different signal and more code for an edge case.
  18. How should cgroup headroom treat file cache? (Codex finding)
    • ✅ Subtract inactive_file (v2) or total_inactive_file (v1) from usage, the working set that container runtimes report.
    • Drop cgroup headroom and use MemAvailable alone. Rejected: it reopens the container-headroom finding.
  19. Where do admission claims live? (Codex finding)
    • ✅ $XDG_RUNTIME_DIR/quest/gate, or ~/.cache/quest/gate/<hostname> where the runtime directory is unset.
    • Always the per-hostname cache directory. Rejected: the runtime directory is the standard host-local, per-user location.
  20. How should a waiter detect that an occupied slot is free? Moot: superseded by decision 21, which removes slots.
  21. Keep the systemd slot backend? (user, 2026-10-21, decided in /quest-complete)
    • ✅ Load and memory admission only; process ownership moves to the worktree process-ownership quest, which brings its own backend, restorable from 5684307. Every review round on the handshake found another race, and its contended path never worked on systemd 259.
    • Keep both, as 5684307 had it: hard cap and exact ownership on Linux, with ongoing review cost for the race-prone handshake.
    • Replace systemd slots with portable gate-lifetime slots: a hard cap everywhere, but orphans of a crashed gate escape.

Recorded by the iterating sub-agent without prompting:

  1. How far does this PR edit /quest/m0/worktree.md, which kixelated/quest#127 is rewriting in parallel?

Validation

  • nix develop -c just check passed after merging current main.
  • nix develop -c just test passed: 327 CLI/core tests, shell suites, both built CLI lifecycle checks, and 295 Worker tests.
  • Targeted tests cover host-derived limits, load/memory waits with one claim per window, a full window between admissions across a boundary, signal forwarding, rollover and expired-claim rejection, cgroup paths at and outside the mount root, global options after gate, v1/v2 ancestor headroom with reclaimable cgroup cache, host-local claim directories, cancellation while waiting, nested calls, and exit-status propagation.

Limits

Admission has no timeout or fairness guarantee, and may wait while host load remains high. There is no hard concurrency cap; the 5-second window spacing and the load/memory signals are what bound concurrent starts. The gate does not own or clean up its command's descendants. Representative contention measurement remains follow-up work.

(written by GPT-6; iterated by Claude Opus 5.5)

🤖 Generated with Claude Code

(Written by Claude Opus 5.5)

kixelated and others added 3 commits October 15, 2026 21:57
systemctl show exits 0 with an empty ControlGroup for a unit that no
longer exists, so a scope exiting between a lost claim and the wait
raised a spurious 'obsolete unit' error. Only a unit file now blocks a
slot; an empty cgroup returns to admission.

Also gate checks rather than agents on host load: drop the agent
load-average rule from the spawn and complete skills, document that
configuration changes are safe while commands run, and record the open
admission and platform decisions in the quest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every platform now waits while the 1-minute load average exceeds the
limit (the core count by default), admitting at most one waiter per
5-second window so a falling load does not release every waiter at
once. Linux with a systemd user manager adds the existing scope slots;
macOS, Windows, and Linux without a manager use load admission alone
instead of failing. Nested calls run in place.

The check, test, worker-check, and worker-test recipes run through the
gate, and CI enables the user manager before checking so they exercise
slots. The quest keeps only the representative contention measurement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title feat: preview Linux command admission with systemd scopes feat: gate checks on host load with Linux systemd slots Oct 16, 2026
@kixelated
kixelated marked this pull request as ready for review October 16, 2026 05:26
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 16, 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-21T20:46:50.192051Z b2ca9cc New commits
🔒 Security Review ✅ Completed 2026-10-21T20:47:23.757070Z b2ca9cc 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: de73191132

ℹ️ 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 kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Automated review by review (OpenAI)

Reviewed commit: de73191

Two P2 races found (inline): acquiring a cgroup handle by a reusable slot pathname can target a successor, and cleanup makes old admission windows claimable again. These are separate from the existing systemd prerequisites finding.

Direction: gating expensive commands rather than whole agents, with kernel-backed lifetime ownership where available, is coherent. The added coordination complexity is justified only if identity remains stable through startup cancellation and admission-window cleanup preserves exclusivity.

Verification: GitHub-only source/test inspection. I did not execute systemd, reproduce the races on a host, or run tests; the findings follow concrete interleavings not covered by the current regressions. Portable fallback and real-systemd behavior need runtime validation after fixes.

(written by OpenAI)

Comment thread src/cli/gate.ts Resolved
Comment thread src/cli/gate.ts Resolved
kixelated and others added 2 commits October 15, 2026 22:33
Drop ~/.config/quest/gate.json. The load limit is the usable core
count; each command is budgeted 4 GiB, so Linux gets min(cores,
RAM/4 GiB) slots, and admission also waits while available memory is
below min(4 GiB, RAM/4). RAM honors a container memory limit. macOS
skips the memory signal (it counts reclaimable cache as used).

Systemd ownership tests now run through the idle-host fixture so a busy
host cannot hold them, and the CLI's flag boundary gets a unit test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title feat: gate checks on host load with Linux systemd slots feat: gate checks on host load and memory with Linux systemd slots Oct 16, 2026

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Automated review by review (OpenAI)

Reviewed commit: 83b7d3d

One new P2: memory admission measures host free memory rather than remaining container headroom, despite deriving limits from the container budget (inline).

Prior findings remain open and unchanged, not new findings:

Direction: host-derived limits fit the stated design, but the live memory signal must use the same effective resource boundary as the budget. The new arithmetic/fake-host tests do not validate that boundary; systemd fixtures deliberately bypass real pressure admission.

Verification: incremental GitHub-only source review accounting for the main merge. No tests or systemd/container scenarios executed.

(written by OpenAI)

Comment thread src/cli/gate.ts Resolved

@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: 83b7d3d520

ℹ️ 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

@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: 0ade9d977d

ℹ️ 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
Comment thread src/cli/gate.ts Resolved
kixelated and others added 3 commits October 21, 2026 11:57
A waiter on an occupied systemd slot failed instead of waiting: a live
transient scope reports its own FragmentPath, which was read as a
blocking unit file, and cgroup removal raises no inotify event, so the
watcher waiting for "rename" never resolved under Bun. Block only on a
non-transient fragment, and resolve once the cgroup empties or vanishes.

Also address the open Codex findings: subtract inactive file cache from
cgroup usage, compare the host-wide load average with the host CPU
count, and keep admission claims in $XDG_RUNTIME_DIR or a per-host
cache directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop the Linux systemd slot backend: scopes, the slot handshake,
cgroup.kill inode pinning, prerequisite probes, the opt-in systemd suite,
and the CI linger step. Exact descendant ownership moves to the worktree
process-ownership quest; the backend is recoverable from 5684307.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title feat: gate checks on host load and memory with Linux systemd slots feat: gate checks on host load and memory Oct 21, 2026
kixelated added a commit that referenced this pull request Oct 21, 2026
#128 drops its systemd ownership backend and keeps only CPU
and memory admission. The worktree quest now brings its own backend,
restorable from 5684307, instead of reusing the gate's or waiting for it.

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: 1ddfa3d0f2

ℹ️ 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 Outdated
? ["memory.max", "memory.high"]
: ["memory.limit_in_bytes", "memory.soft_limit_in_bytes"];
// Child cgroups inherit their parents' limits and shared memory usage.
for (let group = join(root, path); ; group = dirname(group)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Resolve cgroup paths against the actual mount root

On Linux containers where cgroupfs exposes a bind-mounted subtree, /proc/self/cgroup can contain the hierarchy path while the files are rooted directly at the mount point; joining that path to the hard-coded /sys/fs/cgroup location therefore looks in a nonexistent duplicated subtree. The kernel exposes the mount root and mount point as separate fields in /proc/self/mountinfo, but every resulting ENOENT is swallowed here, so linuxMemory() silently falls back to host RAM and may admit a build that exceeds a much smaller container limit. Discover the applicable cgroup mount and translate the membership path relative to its mount root.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Partly disagree, partly fixed in e98f6f3.

The bind-mount case doesn't fall back to host RAM: the walk climbs from the membership path to the mount point and reads the limit files there, and in a container without a cgroup namespace that mount point is the container's own cgroup. So 0::/system.slice/docker-abc.scope misses below the mount, then reads the container's memory.max at /sys/fs/cgroup. e98f6f3 adds a regression test for exactly that layout, plus 0::/ (cgroup namespace).

What the review did surface: a / path walked above the mount (harmless ENOENTs), and a /.. path (a cgroup outside the namespace) walked outside it and never read the mount at all. The walk now starts inside the mount and stops there, and skips hierarchies whose path escapes it.

I'm not adding /proc/self/mountinfo translation. It would only change the result for a nested sub-cgroup with its own tighter limit inside a non-namespaced bind mount, or for a cgroupfs mounted somewhere other than /sys/fs/cgroup. Admission is a heuristic, and the container's own limit still bounds it in those cases. A follow-up can take it if that ever matters.

(Written by Claude Opus 5.5)

Comment thread src/cli/main.ts Resolved
kixelated and others added 2 commits October 21, 2026 12:45
`quest gate --version` and `quest gate --root <dir> <cmd>` now parse
Quest's global options instead of treating them as the executable.

The memory cgroup walk stays inside the mount: a membership path absent
below a bind-mounted container cgroup still ends at the mount root, where
the container's limit lives; a `/` path no longer walks above the mount,
and a cgroup outside the namespace (`/..`) is skipped.

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: 6ff9c2dde2

ℹ️ 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
for (const file of limits) {
try {
const limit = Number(readFileSync(join(group, file), "utf8").trim());
if (!Number.isFinite(limit) || limit <= 0) continue; // "max", or an unset v1 soft limit.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Honor zero-valued cgroup limits

When a cgroup v2 hierarchy sets memory.high to 0, which is a valid value that places all memory usage above the throttling threshold, this condition treats it as unset alongside the nonnumeric max value. linuxMemory() consequently reports headroom from memory.max or host memory and may admit expensive work into a cgroup that is already under immediate reclaim pressure; distinguish max and the v1 unset sentinel from a finite zero limit.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Disagree; leaving this as is. A zero memory.high (or v1 soft limit) is a pathological setting, and honoring it would not hold work back here: it would make usable RAM 0, so the memory budget min(4 GiB, RAM / 4) becomes 0 and available >= 0 admits anyway. Keeping the host-sized budget instead would make the gate, which has no timeout, wait forever on a cgroup that still runs work. Skipping a zero leaves admission bounded by memory.max and the host's MemAvailable, which is the useful signal.

(Written by Claude Opus 5.5)

kixelated and others added 2 commits October 21, 2026 13:02
The `/..` check matched only `/`, so on Windows the test's escaping path
walked past the mount to the drive root and looped there. Compare against
the platform separator and keep the filesystem-root stop as a backstop.

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: 900eb59b96

ℹ️ 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 21, 2026 13:14
Claims were fixed 5-second buckets, so one caller could claim window N at
4,999 ms and another window N+1 at 5,000 ms. A claim is now refused while
the previous window's claim exists. Only an admitted claim removes older
ones, and none can be admitted while a newer claim exists, so cleanup
cannot delete that claim before the check. Refused claims are removed so
they never hold back the following window.

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: 5322a33981

ℹ️ 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
Comment on lines +133 to +135
return env.XDG_RUNTIME_DIR
? join(env.XDG_RUNTIME_DIR, "quest", "gate")
: join(home, ".cache", "quest", "gate", host);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use one claim directory across a user's sessions

When the same account runs one gate with XDG_RUNTIME_DIR set and another without it—such as an interactive login alongside a cron or stripped SSH environment—the processes claim different directories. Both can consequently win the same admission window and start expensive commands together, defeating the per-user, per-host spacing guarantee; directory selection needs to be stable for the account regardless of this environment-variable difference.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Leaving this as a follow-up rather than changing it here. It only matters when the same account runs gates both with and without XDG_RUNTIME_DIR at the same moment, such as a login session alongside cron. Even then, the load and memory checks still apply to both, so the worst case is two starts inside one window, not unbounded overcommit. Always using the cache directory was considered and rejected in decision 19. A stable choice would derive the runtime directory from the uid (for example /run/user/<uid> when it exists), which is better scoped as its own small change.

(Written by Claude Opus 5.5)

kixelated and others added 2 commits October 21, 2026 13:33
An interrupted gate always sent SIGTERM, so a foreground command could get
the terminal's SIGINT and then a SIGTERM that cut its cleanup short. The
gate now forwards the signal it received.

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: 9069da7e57

ℹ️ 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
? (signal.reason as NodeJS.Signals)
: "SIGTERM",
);
signal.addEventListener("abort", cancel, { once: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid delivering terminal interrupts to the child twice

When cancellation comes from Ctrl-C on a POSIX terminal, the spawned child inherits Quest's foreground process group, so the terminal already sends SIGINT to both processes; this abort listener then calls child.kill("SIGINT") and can deliver a second interrupt. Programs that treat a second Ctrl-C as a forced exit may therefore skip graceful cleanup. Although the earlier fix now preserves the signal name, the fresh code still forwards it without isolating the child into a separate process group or accounting for terminal delivery.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Leaving this as a follow-up. The double delivery is older than af7dfe0: before it, a terminal Ctrl-C reached the command as SIGINT followed by the gate's SIGTERM. af7dfe0 reduces that to a repeated SIGINT, which most programs handle no worse. Removing the repeat needs a design choice. One option is to forward only signals the child's process group could not have received (for example, skip forwarding while Quest is in the terminal's foreground process group). Another is to put the child in its own process group and make the gate the only sender. Either affects job control and terminal ownership, so it's better as its own small change than as another round here.

(Written by Claude Opus 5.5)

kixelated added a commit that referenced this pull request Oct 21, 2026
* chore: claim recoverable worktree quest

* feat: add recoverable Git worktree primitives

* fix(worktree): canonicalize Windows paths and recover interrupted transitions

Git prints worktree paths with forward slashes on Windows, so every owned
checkout looked nested inside itself. Compare canonical native paths.

Interrupted create, restore, and release now recover through restore and
release without checkpointing a partial checkout, and restore no longer
fails when the saved branch moved since the main checkout stays detached.

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

* test(worktree): compare restored file with its checked-out line endings

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

* perf(worktree): batch Git queries and skip redundant restore checkouts

Each Git process costs ~100 ms on Windows. Checkpoints now read the index
once, combine rev-parse queries, list untracked and ignored files together,
and skip verifying a full bundle. Restores write every tracked file and the
index from the checkpoint anyway, so they no longer check out first.

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

* test(worktree): budget Git-integration fixtures for slow Windows ARM spawns

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

* fix(worktree): preserve recovery state across checkpoint roundtrips

* fix(worktree): refuse nested repositories and handle replaced submodules

Release scans the checkout for any `.git` entry its checkpoint did not save,
so a forced removal never deletes an independent repository or another
repository's worktree under a scratch path. The index alone decides which
paths are submodules, so staged replacements of a HEAD submodule checkpoint.
Keep paths are checked with lstat so dangling symlinks can be retained.

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

* quest: rewrite worktree quest to the process-ownership remainder

The PR lands manual capacity, checkpoint, release, and restore. The quest
now covers only supervised process ownership on the quest gate's Linux
backend, automatic reconciliation and release, and cancellation fixtures.

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

* refactor(worktree): release checkouts by pruning scratch in place

Release now deletes only the declared --scratch paths and leaves the
checkout, its Git state, and untracked source in place. Each scratch path
must be ignored by Git, hold no tracked files, and not be reached through a
symlink; removal unlinks symlinks inside it rather than following them.
Restore reserves the checkout's disk again and the caller rebuilds the
scratch.

This drops the private checkpoint store, bundles, index materialization,
submodule reconstruction, the nested-repository scan, the checkpoint command,
and --keep/--notes/--check. Nothing outside declared scratch is deleted, so
the forced checkout removal and its data-loss edge cases are gone. Shared
capacity and reservations remain: active records are charged their
reservation, released ones what remains on disk. Release drops the record of
a checkout that Git and the disk both lack.

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

* quest: give worktree process ownership its own backend

#128 drops its systemd ownership backend and keeps only CPU
and memory admission. The worktree quest now brings its own backend,
restorable from 5684307, instead of reusing the gate's or waiting for it.

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

* fix(worktree): refuse .git aliases, scope free space per filesystem, allow nested scratch

- Refuse a scratch path that is the checkout's .git entry under any alias
  (case folding, platform normalization), by spelling and by file identity.
- Compare free space only with the growth reservations expect on the
  target filesystem.
- Tolerate overlapping scratch paths whose parent was removed first.

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

* fix(worktree): refuse a checkout root replaced by another checkout

Canonical paths follow a root replaced by a symlink to another checkout, so
the replaced record's registration could match the other checkout and prune
its scratch. Read the lock reason through the on-disk checkout's .git file
and administrative directory instead.

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

* fix(worktree): charge allocated blocks, not sparse-file length

Taking the larger of allocated blocks and logical size charged sparse files
their full length, rejecting valid reservations. Use allocated blocks, and
fall back to size only where the platform reports none.

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

* fix(worktree): charge file size on Windows, which reports no blocks

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

* fix(worktree): refuse scratch that contains another Git worktree

Running create inside a managed checkout nests another managed checkout
under its ignored .worktrees/, so releasing the outer one with that scratch
deleted the inner one. Refuse scratch that holds a worktree registered to
the repository, or any linked worktree of another repository, found by its
.git file without following symlinks.

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

* fix(worktree): match tracked scratch case-insensitively and count hard links once

- A case-insensitive filesystem resolves CACHE to a tracked cache, while a
  literal pathspec is case-sensitive; match tracked files with icase.
- Hard links to one inode were charged once per name; count each inode once.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Luke Curley <432854+kixelated@users.noreply.github.com>
# Conflicts:
#	README.md
#	quest/m0/README.md
#	quest/m0/local-gate.md
#	quest/m0/worktree.md
#	src/cli/main.ts

@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: b2ca9cccc7

ℹ️ 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
// claim is still here. A descheduled caller may also have created an old
// claim after cleanup; it must not admit work from that expired sample.
// A refused claim is removed, so it never holds back the next window.
if (existsSync(join(directory, String(window - 1))) || window !== Math.floor(now() / SETTLE_MS)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Block claims after backward clock steps

When Date.now() is corrected backward across a five-second boundary, for example by host or VM clock synchronization, a command admitted in window N leaves file N, but the next caller samples N-1 and this condition checks only N-2. That caller can therefore be admitted immediately beside the first, violating the promised minimum spacing; reject a claim when a newer adjacent claim exists or use a clock-rollback-safe timestamp scheme.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Leaving this as a follow-up. A backward clock step across a window boundary can cost at most one extra admission, and the load and memory checks still apply to it. The fix is cheap: refuse a claim while window N+1's claim also exists, or switch to a monotonic or last-admission timestamp. But a wall-clock correction during an admission is an edge case of a heuristic, so it doesn't need another review round on this PR.

(Written by Claude Opus 5.5)

@kixelated

Copy link
Copy Markdown
Collaborator Author

Merge summary

Merging the shrunk design: quest gate admits commands on host load and memory only, with no slots and no descendant ownership (decision 21). The systemd slot backend can be restored from 5684307, and process ownership moves to /quest/m0/worktree.md, which kixelated/quest#127 has now landed with the matching "own backend" wording.

Changes since the shrink (fea6a08, which Codex 👍'd)

Findings left as follow-ups (replied on each thread)

Validation

nix develop -c just check and nix develop -c just test passed on the final merge: 327 CLI/core tests, the shell suites, both lifecycle checks, and 295 Worker tests. quest check --base origin/main --branch quest/m0/local-gate passes, and all 14 CI checks are green on b2ca9cc.

(Written by Claude Opus 5.5)

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