Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
cfb7e4f
chore: claim recoverable worktree quest
kixelated Oct 16, 2026
890dc70
feat: add recoverable Git worktree primitives
kixelated Oct 16, 2026
748b9ff
Merge remote-tracking branch 'origin/main' into HEAD
kixelated Oct 16, 2026
6de784f
fix(worktree): canonicalize Windows paths and recover interrupted tra…
kixelated Oct 16, 2026
c3cbc1d
Merge remote-tracking branch 'origin/main' into HEAD
kixelated Oct 16, 2026
53fe0c5
test(worktree): compare restored file with its checked-out line endings
kixelated Oct 16, 2026
432d9dc
perf(worktree): batch Git queries and skip redundant restore checkouts
kixelated Oct 16, 2026
38e92f1
test(worktree): budget Git-integration fixtures for slow Windows ARM …
kixelated Oct 16, 2026
107d4de
Merge remote-tracking branch 'origin/main' into HEAD
kixelated Oct 16, 2026
36e4ebc
fix(worktree): preserve recovery state across checkpoint roundtrips
kixelated Oct 16, 2026
34daf4a
Merge remote-tracking branch 'origin/main' into quest/m0/worktree
kixelated Oct 21, 2026
2824044
fix(worktree): refuse nested repositories and handle replaced submodules
kixelated Oct 21, 2026
ff2f1a7
quest: rewrite worktree quest to the process-ownership remainder
kixelated Oct 21, 2026
60735a8
refactor(worktree): release checkouts by pruning scratch in place
kixelated Oct 21, 2026
b9be286
Merge remote-tracking branch 'origin/main' into pr127
kixelated Oct 21, 2026
3399f85
Merge remote-tracking branch 'origin/main' into HEAD
kixelated Oct 21, 2026
d00b93d
quest: give worktree process ownership its own backend
kixelated Oct 21, 2026
132dc5e
fix(worktree): refuse .git aliases, scope free space per filesystem, …
kixelated Oct 21, 2026
419c98e
fix(worktree): refuse a checkout root replaced by another checkout
kixelated Oct 21, 2026
42ba20d
fix(worktree): charge allocated blocks, not sparse-file length
kixelated Oct 21, 2026
862a677
fix(worktree): charge file size on Windows, which reports no blocks
kixelated Oct 21, 2026
9592691
Merge remote-tracking branch 'origin/main' into HEAD
kixelated Oct 21, 2026
5e167ee
Merge remote-tracking branch 'origin/main' into HEAD
kixelated Oct 21, 2026
dec5ec0
fix(worktree): refuse scratch that contains another Git worktree
kixelated Oct 21, 2026
61d9e72
fix(worktree): match tracked scratch case-insensitively and count har…
kixelated Oct 21, 2026
235b253
Merge remote-tracking branch 'origin/main' into HEAD
kixelated Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,48 @@ same facts as one object with the fields `url`, `state`, `draft`, `head`,
as in `quest wait`. Any PR state exits 0, invalid arguments exit 2, and other
failures exit 1.

## Prunable local checkouts

The experimental `quest worktree` commands share one disk budget across this
user's repositories and release a checkout's disk by deleting its declared
scratch in place. They only operate on Git worktrees they create; use the
owning application for Codex, Claude, or other managed checkouts. Process
supervision remains unfinished: stop agents and commands in a checkout before
releasing it.

Set a shared capacity and an explicit reservation in bytes, covering the
checkout and its expected build output. Measure representative jobs before
choosing the budget. Reservations serialize creators and account for concurrent
expected growth. They do not enforce filesystem quotas against arbitrary
writers, and Git objects stored in the shared repository are not charged.

```sh
quest worktree capacity 20000000000
quest worktree create example --reserve 3000000000 --ref HEAD
quest worktree status
quest worktree release example --scratch node_modules dist
quest worktree restore example
```

Creation prints JSON with the ID and the detached, locked checkout path under
`.worktrees/`. Release deletes only its `--scratch` paths, and refuses one that
Git does not ignore, that holds tracked files or another Git worktree, or that
is reached through a symbolic link; links inside scratch are removed, never
followed. Missing paths
are skipped, so release can be rerun. The checkout, its Git state, and
untracked files stay in place, and `status` charges what remains. Restore
reserves the checkout's disk again; then reinstall or rebuild its scratch, for
example with `just install`.

Quest never removes a checkout. Once one is finished, remove it with
`git worktree unlock` and `git worktree remove`, which refuses modified or
untracked files but deletes ignored ones; `release` then drops its record. A
failed creation stays charged until `release` drops it. Records live in
`$QUEST_WORKTREE_HOME`, or `$XDG_STATE_HOME/quest/worktrees` (default
`~/.local/state/quest/worktrees`). No live lock is stolen: after a crash,
confirm the registry lock's owner has exited before removing it. This initial
interface refuses unknown or changed checkout ownership.

## Work with your agents

Open this repository in Claude Code or Codex. The binary carries the skills;
Expand Down
2 changes: 1 addition & 1 deletion quest/m0/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ ownership-manifest design:

- [Accurate landed quests](/quest/m0/landed-quests.md) - a quest-branch PR deletes or rewrites its quest, and `quest check --base` refuses one that does neither
- [Expensive command gate](/quest/m0/local-gate.md) - shared CPU admission across worktrees and repositories
- [Recoverable worktrees](/quest/m0/worktree.md) - disk admission, checkpoints, release, and restore
- [Worktree process ownership](/quest/m0/worktree.md) - supervise checkout commands and release checkouts automatically
- [Agent lifecycle integration](/quest/m0/worktree-agents.md) - reclaim completed and suspended checkouts through their harness
- [Agent workflow helpers](/quest/m0/helpers/README.md) - CLI commands for CI waits, PR status, claims, quest moves, and serialized merges

Expand Down
2 changes: 1 addition & 1 deletion quest/m0/kix/runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,4 @@ replacing the hosted Agent SDK runtime:

## Required

- [Recoverable worktrees](/quest/m0/worktree.md) - reuse checkout supervision, checkpoints, and disk admission
- [Worktree process ownership](/quest/m0/worktree.md) - reuse checkout supervision, scratch release, and disk admission
2 changes: 1 addition & 1 deletion quest/m0/local-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,5 @@ remain useful without multiplying full-machine builds and test stacks.

## Related

- [Recoverable worktrees](/quest/m0/worktree.md) - disk admission and checkout lifetime
- [Worktree process ownership](/quest/m0/worktree.md) - owns checkout command descendants, with its own process-ownership backend
- [Own-PC runner](/quest/m0/kix/runner.md) - reuse local command supervision
9 changes: 5 additions & 4 deletions quest/m0/worktree-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

## Goal

Quest workflows use recoverable worktree ownership through supported Codex and
Claude lifecycle integrations. A completed or suspended worker releases its
checkout without waiting for the parent chat to end or the PR to merge.
Quest workflows use `quest worktree` checkout ownership through supported
Codex and Claude lifecycle integrations. A completed or suspended worker
releases its checkout without waiting for the parent chat to end or the PR to
merge.

## Plan

Expand Down Expand Up @@ -38,7 +39,7 @@ checkout without waiting for the parent chat to end or the PR to merge.

## Required

- [Recoverable worktrees](/quest/m0/worktree.md) - checkpoint, release, and restore
- [Worktree process ownership](/quest/m0/worktree.md) - supervised commands and automatic scratch release
- [Expensive command gate](/quest/m0/local-gate.md) - resource control before removing agent-count throttles

## Related
Expand Down
76 changes: 42 additions & 34 deletions quest/m0/worktree.md
Original file line number Diff line number Diff line change
@@ -1,44 +1,52 @@
# [L] Checkpoint and release worktrees with quest worktree
# [M] Own worktree processes and release checkouts automatically

## Goal

`quest worktree` owns a recoverable checkout lifecycle: admit disk use, create
or reuse a checkout, run work, checkpoint, release, and restore on resume.
Waiting for a decision or PR review does not require retaining a large checkout.
Agent count is unrestricted by this command; expensive work uses `quest gate`.
`quest worktree` supervises the commands that run in a checkout it created,
then releases that checkout automatically once they stop by pruning its
declared scratch in place. A crash or cancellation is reconciled without losing
work, stealing a live lock, or deleting scratch a running process still uses.

## Plan

- Decided 2026-10-15: disk capacity is the admission constraint. Account for
concurrent allocations and expected build growth, with configurable limits
informed by measured footprints. A free-space check alone cannot reserve
space against simultaneous creators or subsequent builds.
- Checkpoint meaningful edits before a blocking handoff and at completion,
then release the idle checkout. An asynchronous question with independent
work still running is not a release boundary. Checkpoints are recoverable
WIP, not claims of passing tests, and do not run the validation suite.
- Preserve source, unique commits, and resume metadata independently of the
checkout before removing it. Preserve local-only submodule history outside
its disposable gitdir. Keep valuable untracked or ignored files explicitly;
never publish secrets or treat every ignored file as disposable.
- Record unresolved work and which checks passed or were not run with each
checkpoint. Recovery metadata lives outside the disposable checkout.
- Delete owned disposable scratch at handoff or exit. If scratch must live in
a checkout, ignore its declared paths and teach cleanup those same paths.
Unstaged source changes are work to preserve, not scratch to delete.
- Own foreground child processes until they stop; only then release capacity
and remove their checkout. Record ownership so crashes can be reconciled.
Reuse the owning tool for managed worktrees and report failed cleanup as a
retained allocation, not success. Do not steal live locks or remove shared
or current checkouts underneath their users.
- Preserve branches/recovery refs independently of checkout retention. A
squash-merged branch may not be an ancestor of main; exact PR-head evidence
matters when deciding whether a published result is finished.
- Verify checkpoint/restore across dirty files, unique commits, submodules,
cancellation, interrupted cleanup, and simultaneous disk admission. Measure
retained disk after complete and suspended jobs; update existing docs inline.
- Landed in [kixelated/quest#127](https://github.com/kixelated/quest/pull/127):
manual disk capacity and reservations, `create`, `release` (delete declared
scratch in place), and `restore` (reserve again; the caller rebuilds the
scratch) of Git-owned checkouts. The README section "Prunable local
checkouts" documents it as experimental and tells users to stop all work
before releasing.
- Decided (user, 2026-10-21): prune in place. Release deletes only declared
scratch that Git ignores, that holds no tracked files or other Git worktree,
and that is not reached through a symlink; it never removes the checkout or
its Git state.
Checkout-independent recovery is out of scope: four review rounds of the
checkpoint-and-remove design kept finding data-loss edge cases, while the
disk cost is scratch (2026-10-15 on this repository: 1.6 MB fresh source,
376 MB built).
- Decided 2026-10-21 (user): this quest brings its own process-ownership
backend; `quest gate` admits on CPU load and memory only and owns no
descendants. The Linux systemd scope backend (scopes, slot handshake,
`cgroup.kill` inode pinning, prerequisite probes, opt-in systemd tests) was
dropped from the gate and is restorable from commit 5684307 in
[kixelated/quest#128](https://github.com/kixelated/quest/pull/128). Process
groups and PID files cannot prove detached descendants stopped after a
supervisor crash, so hosts without a supported backend fail explicitly
instead of guessing.
- Own foreground child processes and their descendants until they stop; only
then prune scratch and release the reservation. Record ownership so a crash
can be reconciled, including a stale registry lock whose owner has exited.
Report failed cleanup as a retained allocation, not success.
- Release automatically at lifecycle boundaries: before a blocking handoff and
at completion, once meaningful edits are committed. An asynchronous question
with independent work still running is not a release boundary. Automatic
release needs the scratch declarations without a caller; recording them at
`create` is the expected route.
- Add cancellation and crash fixtures: interrupt a supervised command with live
descendants, kill the supervisor, and reconcile. Measure retained disk after
complete and suspended jobs.
- Native agent lifecycle adoption stays in the agent integration quest.

## Related

- [Expensive command gate](/quest/m0/local-gate.md) - independent CPU admission
- [Expensive command gate](/quest/m0/local-gate.md) - independent CPU and memory admission
- [Agent lifecycle integration](/quest/m0/worktree-agents.md) - native agents and workflow adoption
52 changes: 44 additions & 8 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { type Terminal, Theme } from "./theme";
import { collect, exists, load, under } from "./tree";
import { pullRequestStatus, statusLines } from "./status";
import { waitForActivity, WaitTimeout } from "./wait";
import { Worktrees } from "./worktree";

/** Where the command writes; the process streams, or a buffer under test. */
export interface Output {
Expand Down Expand Up @@ -153,7 +154,7 @@ export function main(argv: string[], out: Output): number | Promise<number> {
)
.argument("[path]", "Quest to explain instead of ranking every ready quest")
.option("--json", "Print a deterministic JSON array")
.option("--limit <count>", "Maximum number of ready quests to print", positiveInteger)
.option("--limit <count>", "Maximum number of ready quests to print", positiveInteger("limit"))
.option("--unclaimed", "Skip quests with a claim branch or open PR, listing them on stderr")
.option("--remote <name>", "Remote whose upstream holds the claims", "origin")
.action(
Expand Down Expand Up @@ -312,6 +313,39 @@ export function main(argv: string[], out: Output): number | Promise<number> {
);
});

const worktree = program
.command("worktree")
.description("Reserve disk for Git checkouts and release it by pruning their scratch in place");
const state = () => new Worktrees();
const record = (value: unknown) => print(JSON.stringify(value));
worktree
.command("capacity <bytes>")
.description("Set this user's shared disk budget in bytes")
.action((bytes: string) => {
state().configure(positiveInteger("bytes")(bytes));
});
worktree
.command("status")
.description("Print the capacity, allocated bytes, and checkouts as JSON")
.action(() => record(state().status()));
worktree
.command("create <name>")
.description("Create an exclusively owned detached Git worktree under .worktrees/")
.requiredOption("--reserve <bytes>", "Checkout and expected build growth in bytes", positiveInteger("bytes"))
.option("--ref <ref>", "Initial Git revision", "HEAD")
.action((name: string, options: { reserve: number; ref: string }) =>
record(state().create(root(), name, options.reserve, options.ref)),
);
worktree
.command("release <id>")
.description("Delete declared scratch and keep the checkout; drop the record of a removed checkout")
.option("--scratch <path...>", "Ignored paths without tracked files, such as dependencies and build output", [])
.action((id: string, options: { scratch: string[] }) => record(state().release(id, options.scratch)));
worktree
.command("restore <id>")
.description("Reserve a released checkout's disk again before rebuilding its scratch")
.action((id: string) => record(state().restore(id)));

/** Changes on stdout; mentions and check findings on stderr, which fail the command. */
const report = (result: Report): number => {
for (const line of result.changes) print(line);
Expand Down Expand Up @@ -454,11 +488,13 @@ function positiveSeconds(value: string): number {
return seconds;
}

/** A result limit must be a positive, safe integer. */
function positiveInteger(value: string): number {
const count = Number(value);
if (!/^\d+$/.test(value) || !Number.isSafeInteger(count) || count <= 0) {
throw new InvalidArgumentError("limit must be a positive safe integer");
}
return count;
/** Parses a positive, safe integer option named `name`. */
function positiveInteger(name: string): (value: string) => number {
return (value) => {
const count = Number(value);
if (!/^\d+$/.test(value) || !Number.isSafeInteger(count) || count <= 0) {
throw new InvalidArgumentError(`${name} must be a positive safe integer`);
}
return count;
};
}
Loading
Loading