Skip to content

feat(gate): own gated descendants inside Quest scopes and on Windows - #437

Merged
kixelated merged 7 commits into
mainfrom
quest/m2/gate-ownership
Nov 3, 2026
Merged

kixelated merged 7 commits into
mainfrom
quest/m2/gate-ownership

Conversation

@kixelated

@kixelated kixelated commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Finishes quest/m2/gate-ownership.md: a gate inside a quest worktree run scope now owns its own subtree, and on Windows a gate owns its command's descendants, not just the command.

Summary

  • Delegated Quest scopes. systemdScopes.run passes --property=Delegate=yes, so every Quest scope (quest worktree run's and the gate's own) lets its processes create child cgroups.
  • nested owner. Inside a delegated Quest scope, quest gate creates <its cgroup>/quest-gate-<uuid>, runs the command there, and once the command exits it cgroup.kills the child, waits until it is empty, and removes it, along with any empty children a crashed nested gate left. The child sits inside the enclosing scope, so the outer scope's cgroup.kill and populated still cover it. A scope that is not delegated, such as one a running supervisor from an older Quest started, still runs direct, as before.
  • Shared supervisor. The handshake, admission, signalling, drain, kill, and wait of systemdScopes.run move into contain (src/cli/supervise.ts), which both systemdScopes.run and the new nest use. The handshake shell for nest writes 0 to the child's cgroup.procs before it reports, so nothing it starts runs outside the child.
  • Cancellation, merged with moq-dev/quest#429. contain keeps fix(gate): cancelling a gate stops its active phase #429's semantics for both owners: a cancelled run signals every process in its cgroup (a scope through systemctl kill --kill-whom=all, a nested gate's child cgroup through its cgroup.procs, recursively), waits until the cgroup empties or a second signal kills it, and a scope inside another Quest scope binds to it (BindsTo=). The nested owner, like fix(gate): cancelling a gate stops its active phase #429's inside, leaves a signal it received, and the second signal's kill, to the enclosing scope's owner, which already delivered them to every process in the scope, the child cgroup included. It still lets the child's processes finish their own cleanup before it kills what is left. owner() keeps inside for a nested call and for a scope that is not delegated.
  • questScope() replaces scoped() and fix(gate): cancelling a gate stops its active phase #429's enclosingScope(). It finds the innermost quest-*.scope anywhere in this process's cgroup path, so a gate running in a gate's child cgroup still counts as inside the scope. supervise.ts joins cgroup paths with node:path/posix, so they stay POSIX paths on every platform.
  • Windows Job Objects (src/cli/job.ts). The job owner runs the command under a small Bun stub (<execPath> -e STUB, with BUN_BE_BUN=1 as accountHome already uses for a compiled Quest). The command and the caller's BUN_BE_BUN reach the stub as JSON in QUEST_GATE_JOB, which it removes before the command starts, because Bun's process.argv for -e differs on Windows. Through bun:ffi the stub creates a job with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE and no breakaway flag, joins it, and only then starts the command. Per Nested Jobs, a child that breaks away from libuv's silent-breakaway job moves up the chain only until it reaches a job that forbids breakaway. So every descendant, detached ones included, stays in the stub's job. The job dies with the stub: when the command exits, when the gate terminates the stub (a second Ctrl-C), or when the gate dies and libuv's own kill-on-close job ends the stub.
  • Help text for gate and worktree run, the README's gate and worktree sections, and scripts/gate-check.sh's header describe the new ownership.
  • The quest file and its links are deleted.

Choices made without asking

  • The stub joins the job itself, instead of the gate assigning the command after spawning it. Node and Bun cannot spawn a process suspended, so assigning after spawn would leave a window in which a grandchild escapes. Having the gate join a job itself would avoid that window, but the gate could then never kill leftovers without killing itself, and libuv aborts the process on some AssignProcessToJobObject failures. The stub costs one extra Bun start per gated command on Windows.

  • Delegation applies to every Quest scope, the gate's own included, not just quest worktree run's. One code path gives one behaviour: a gate whose environment lost QUEST_GATE_ADMITTED inside a gate scope also owns a child cgroup instead of escaping to a sibling scope. On this host the user slices already enable cpu memory pids, so delegation enables no new controller.

  • If the stub cannot create or join the job (for example, under a parent job with UI restrictions), it prints quest gate: cannot own descendants of the command: … and runs the command anyway, owning only the command, as before. This matches Linux settling for a process group without systemd.

  • On Windows, a command that cannot start exits 127 with the error on stderr, as a shell and the systemd scope owner already do. direct rejects instead.

  • The caller's BUN_BE_BUN is restored before the command starts, because a nested compiled Quest would otherwise run as Bun.

  • A received signal reaches a nested gate's command only through the enclosing owner (merge resolution). This PR's nested owner first forwarded each received signal to its command, but fix(gate): cancelling a gate stops its active phase #429, merged since, holds a received signal back inside any Quest scope: the enclosing owner already delivered it to every process there, and a nested Quest treats a second delivery as a kill. nested follows fix(gate): cancelling a gate stops its active phase #429. So, as with fix(gate): cancelling a gate stops its active phase #429's inside, a signal sent with kill to such a gate alone does not reach its command, and a second one does not kill it; scripts/gate-check.sh, which sends one, therefore runs only outside every Quest scope, as its header now says.

Verification

  • After merging main, just check and just test pass on Linux (1001 CLI and core tests, plus the Worker suite).
  • New tests (merge):
    • A SIGTERM to an enclosing scope reaches a Just → trapping Bash → GNU time → leaf phase inside a nested gate once, and the nested gate lets the leaf finish its cleanup instead of killing it once Just exits.
    • A caller's cancel of a nested run signals every process in its child cgroup, including one only a shell's wait holds.
    • Both fail when the drain or the cgroup-wide signal is removed.
    • owner() picks inside for a nested call or child cgroup in a Quest scope and for a scope that is not delegated.
  • New tests:
    • questScope parsing, including child cgroups.
    • A gate inside a real delegated scope picks nested: the command's cgroup is …/quest-gate-<uuid>.scope/quest-gate-<uuid>, a descendant it left is stopped before the gate returns, and the child cgroup is removed.
    • A scope that is not delegated keeps direct.
    • The job stub relays exit status, restores BUN_BE_BUN and drops QUEST_GATE_JOB, passes arguments (spaces, quotes, backslashes, an empty one) as direct does, and returns 127 for a missing command (these run on every platform).
    • Windows only: the gate picks job, and a detached descendant stops both after the command exits and after a kill.
  • scripts/gate-check.sh passes on its own. Before the merge it also passed inside a hand-made Delegate=yes quest-worktree-*.scope, where the gated command reported …/quest-worktree-<uuid>.scope/quest-gate-<uuid>. After the merge, its exit-status and orphan checks still pass there, and its cancel check waits as described above.
  • A compiled dist/quest runs the stub with BUN_BE_BUN=1, passes the exit status through, and clears both BUN_BE_BUN and QUEST_GATE_JOB for the command.
  • Native Windows: the first dispatched run, 41576070560 on f72beff, failed on both windows-2022 and windows-11-arm. On Windows the stub read a path where it expected the BUN_BE_BUN JSON, and questScope joined cgroup paths with backslashes. After the fixes, run 41661583517 on b78e6cc passes every job, both native Windows jobs included, so the Job Object tests (a detached descendant stops after exit and after a kill) pass on x64 and ARM64. After merging main, run 41687335580 on 928e681 passes test/gate.test.ts (Job Object tests included) on both windows-2022 and windows-11-arm. Both native Windows jobs still fail, on 4 tests that main brought in, which this PR does not touch. Main runs its native Windows jobs only on dispatch, so it never caught them: test/move.test.ts's three quest check --fix link-drop tests expect LF where Windows writes CRLF (moq-dev/quest#427), and test/runner.test.ts's reviewerFor test expects a :-joined PATH where Windows joins it with ;.

Review

  • Reviewer: Codex (quest review), gpt-6.1-sol, medium effort, round 2 of session 01a19f93 (first round on b78e6cc, which found nothing), head 6c5b95c, base origin/main b02af47.
  • Findings: none.
  • The current head 928e681 adds only a conflict-free merge of origin/main b02af47 onto the reviewed head, so the review carries over.

Follow-ups

  • Fix main's native-Windows test failures: the CRLF expectations in test/move.test.ts (quest check --fix link drops, from moq-dev/quest#427) and the PATH delimiter in test/runner.test.ts's reviewerFor test. Neither is related to gate ownership. Native Windows runs only on workflow_dispatch, so main never caught them (run 41687335580).

🤖 Generated with Claude Code

(Written by Claude Opus 5.5)

kixelated and others added 2 commits November 2, 2026 19:43
Quest-Claim: 38e85d7a63c81b45
Quest's systemd scopes are now delegated, so a gate inside one, such as
under `quest worktree run`, runs its command in a child cgroup of its own
instead of deferring to the scope. The child shares the handshake, kill,
and wait of `systemdScopes.run` through a common `contain`, and is removed
once empty; the enclosing scope's cgroup.kill still reaches it. A scope
from a Quest that predates delegation keeps the old behaviour.

On Windows the gate runs its command under a small Bun stub that joins a
kill-on-close Job Object forbidding breakaway before it starts the
command, so every descendant, detached ones included, stops with it.

Closes the quest m2/gate-ownership.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title Own gated descendants everywhere feat(gate): own gated descendants inside Quest scopes and on Windows Nov 3, 2026
kixelated and others added 2 commits November 3, 2026 07:01
…cgroup paths as POSIX

The dispatched Windows run failed: Bun's process.argv for `-e` differs on Windows, so the stub
parsed a path as its BUN_BE_BUN JSON, and questScope joined cgroup paths with backslashes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bun's process.argv for a script passed with -e differs on Windows, so the test compares the stub
with running the command directly instead of a fixed argv, and checks the exact argv elsewhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated marked this pull request as ready for review November 3, 2026 15:24
@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 24 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: b0afd5fb-5694-4c61-86c9-bf3e45804b72
Commits

Reviewing files that changed from the base of the PR and between b02af47 and 928e681.

Files selected for processing: 9
  • README.md
  • quest/m2/README.md
  • quest/m2/gate-ownership.md
  • scripts/gate-check.sh
  • src/cli/gate.ts
  • src/cli/job.ts
  • src/cli/main.ts
  • src/cli/supervise.ts
  • test/gate.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.

kixelated and others added 3 commits November 3, 2026 09:30
Keeps #429's cancellation semantics in the shared `contain`: a cancelled run
signals every process in its cgroup (the scope through systemctl kill, a nested
gate's child cgroup through its cgroup.procs), waits until the cgroup empties
or a second signal kills it, and binds a scope inside another Quest scope to
it. A nested gate, like `inside`, leaves a received signal and the second
signal's kill to the enclosing scope's owner, but still lets its child cgroup's
processes finish their cleanup before it kills what is left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…to it alone

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

Copy link
Copy Markdown
Collaborator Author

Design change: merging main brought in moq-dev/quest#429, so the nested owner now follows its rule for gates inside a Quest scope. A signal the gate receives, and the second signal's kill, are left to the enclosing scope's owner, which already delivered them to every process in the scope, including the gate's child cgroup. The gate no longer forwards a received signal to its command. It still lets the child cgroup's processes finish their cleanup before it kills what is left. A caller's own cancel signals every process in the child cgroup. contain now carries #429's signal-everything, drain, and BindsTo= handling for both the scope and nested owners. Trade-off, the same as #429's inside: a signal sent with kill to a nested gate alone does not reach its command, so scripts/gate-check.sh runs only outside every Quest scope.

(Written by Claude Opus 5.5)

@kixelated
kixelated added this pull request to the merge queue Nov 3, 2026
@kixelated

Copy link
Copy Markdown
Collaborator Author

Decisions settled in /quest-complete on 2026-11-03:

Queued at head 928e681.

(Written by Claude Opus 5.5)

Merged via the queue into main with commit ee96a82 Nov 3, 2026
22 of 24 checks passed
@kixelated
kixelated deleted the quest/m2/gate-ownership branch November 3, 2026 18:42
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