Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 20 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,10 @@ Work handed to another service manager, such as `systemd-run` or a container
daemon, escapes the scope. If the supervisor itself is killed, its command keeps
running and its scratch is kept; the next `quest worktree` command drops the run
once its scope is empty and then releases the checkout. `status` lists each
run's scope, which `systemctl --user stop` ends.
run's scope, with the reason a run it cannot inspect is kept until an
inspection succeeds. `quest worktree stop example` kills what crashed
supervisors left in their scopes, waits until they are empty, and so releases
the checkout; it leaves runs whose supervisor is alive to that supervisor.

Release deletes only declared scratch, and refuses a path that Git does not
ignore, that holds tracked files or another Git worktree, or that is reached
Expand All @@ -493,10 +496,22 @@ quest worktree restore example
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
Once a checkout is finished, remove it and its record:

```sh
quest worktree remove example
```

`remove` never forces. It refuses while supervised runs are live, and refuses
modified or untracked files and any commit, at `HEAD` or under the checkout's
own refs such as `refs/bisect/`, that no branch or remote-tracking ref
reaches. A squash-merged change leaves its commits unreached, so they count as
preserved only when origin's GitHub repository (a fork's parent) has a merged
PR whose head is exactly that commit; other commits need no forge or token.
It then prunes the scratch and refuses ignored files that remain, leaving the
checkout released, before `git worktree remove` deletes it. A checkout already
removed with `git worktree remove` only drops its record, as with `release`.
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`). The registry lock of a process that exited on
this host is reclaimed; a live lock, or one held from another host, is never
Expand Down
1 change: 0 additions & 1 deletion quest/m0/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ ownership-manifest design:
## Required

- [Gate headroom](/quest/m0/gate-headroom.md) - gated commands start at once while the host has room
- [Worktree removal](/quest/m0/worktree.md) - remove finished checkouts without losing work
- [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
7 changes: 3 additions & 4 deletions quest/m0/kix/runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ replacing the hosted Agent SDK runtime:
decision inbox: each runner's online state, its accounts, and the
preference-order control. Decided 2026-10-18 as a follow-up of the
decision inbox (#144), folded here rather than its own quest.

## Related

- [Worktree removal](/quest/m0/worktree.md) - removes finished checkouts; reuse `quest worktree run` for supervision, scratch release, and disk admission
- Reuse `quest worktree` in the job adapter: `run` for supervision, scratch
release, and disk admission, `remove` once a job's checkout is finished,
and `stop` for a crashed supervisor's leftover processes.
5 changes: 4 additions & 1 deletion quest/m0/worktree-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ merge.
for expensive phases. Remove the skill's physical-core agent-count and load
average admission rules once command admission is integrated. Disk capacity
gates checkout allocation, not the number of reasoning agents.
- Landed: `quest worktree run` supervises a job and releases its scratch,
`remove` deletes a finished checkout without losing work (a squash-merged
head counts only with exact PR-head evidence), and `stop` ends a crashed
supervisor's leftover processes.
- A 2026-10-19 transcript audit counted 1,731 load-average polling commands
in moq-dev/moq and moq-dev/moq.pro sessions since Oct 14, so the remaining
agent-level load rules still cost tokens until this lands.
Expand All @@ -39,5 +43,4 @@ merge.

## Related

- [Worktree removal](/quest/m0/worktree.md) - removes finished checkouts; `quest worktree run` already supervises jobs and releases scratch
- [Own-PC runner](/quest/m0/kix/runner.md) - reuse lifecycle ownership in the hosted job adapter
49 changes: 0 additions & 49 deletions quest/m0/worktree.md

This file was deleted.

16 changes: 16 additions & 0 deletions src/cli/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,22 @@ export class GitHub {
}
}

/**
* Whether a merged PR in `repository` had exactly `commit` as its head, as when it was squash-merged;
* false for a commit GitHub does not have.
*/
export async function mergedHead(github: GitHub, repository: Repository, commit: string): Promise<boolean> {
const data = await github.graphql(
"query($owner: String!, $name: String!, $oid: GitObjectID!) { repository(owner: $owner, name: $name) { " +
"object(oid: $oid) { ... on Commit { associatedPullRequests(first: 100) { nodes { merged headRefOid } } } } } }",
{ owner: repository.owner, name: repository.repo, oid: commit },
);
const found = object(data.repository).object;
const pulls = found === null || found === undefined ? undefined : object(found).associatedPullRequests;
if (pulls === null || pulls === undefined) return false; // Unknown, or not a commit.
return array(object(pulls).nodes).some((pull) => pull.merged === true && pull.headRefOid === commit);
}

/** A login, a committer name without spaces, or `-` for a deleted account. */
export function actor(value: unknown): string {
if (value === null || value === undefined) return "-";
Expand Down
49 changes: 45 additions & 4 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ import {
unblocked,
} from "../core";
import { AgentInputError, HARNESSES, type Launch, processDependencies, runAgent, stopAgent, stopAll } from "./agent";
import { held, questBranch, reach, target, upstreamHead } from "./claims";
import { held, questBranch, reach, target, upstream, upstreamHead } from "./claims";
import { gate } from "./gate";
import { gitHubDependencies, GitHubInputError, pullRequest } from "./github";
import { gitHubDependencies, GitHubInputError, mergedHead, pullRequest, remoteUrl } from "./github";
import { landed } from "./landed";
import { type Report, moveQuest, removeQuest } from "./move";
import { init, uninstall } from "./setup";
Expand Down Expand Up @@ -378,7 +378,9 @@ 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");
.description(
"Reserve disk for Git checkouts, release it by pruning their scratch in place, and remove finished ones",
);
const state = () => new Worktrees();
const record = (value: unknown) => print(JSON.stringify(value));
worktree
Expand Down Expand Up @@ -413,7 +415,7 @@ export function main(argv: string[], out: Output): number | Promise<number> {
"When no other supervised run remains, the scratch declared at creation is pruned. Put the whole job, " +
"such as an agent session or a script that installs, builds, and tests, in one run.\n\n" +
"A supervisor that crashes leaves its run recorded; the next worktree command drops it once its scope " +
"is empty, and releases the checkout. Needs Linux 5.14+ with a systemd 254+ user manager and unified cgroup " +
"is empty, and releases the checkout; `quest worktree stop` ends what it left. Needs Linux 5.14+ with a systemd 254+ user manager and unified cgroup " +
"v2, and fails on other hosts. Exits with the command's status; a release that fails is reported, leaves " +
"the checkout reserved, and turns success into exit 1.",
)
Expand Down Expand Up @@ -442,6 +444,45 @@ export function main(argv: string[], out: Output): number | Promise<number> {
"and drops the record of a removed checkout",
)
.action((id: string) => record(state().release(id)));
const settled = (result: Promise<unknown>) => {
pending = result.then(
(value) => {
record(value);
return 0;
},
(error: unknown) => {
warn(error instanceof Error ? error.message : String(error));
return 1;
},
);
};
// Only a commit no local ref reaches asks the forge, so other removals need no token.
const merged = async (repository: string, commit: string) => {
try {
remoteUrl(repository, "origin");
} catch {
return false; // No forge to ask.
}
const up = await upstream(repository, "origin", { github: () => gitHubDependencies(warn), warn });
return up.github !== null && mergedHead(up.github.client, up.github.repository, commit);
};
worktree
.command("remove <id>")
.description(
"Delete a finished checkout and drop its record, never forcing. Refuses while supervised runs are live, " +
"and refuses modified, untracked, or ignored files beyond the declared scratch, and commits no branch " +
"or remote-tracking ref reaches, unless origin's GitHub repository merged a PR whose head is exactly " +
"that commit. Prunes the scratch first, so a refusal over ignored files leaves the checkout released.",
)
.action((id: string) => settled(state().remove(id, merged)));
worktree
.command("stop <id>")
.description(
"Kill the processes crashed supervisors left in the checkout's scopes and wait until they are gone, then " +
"release the checkout once no run remains. Runs whose supervisor is alive are left to it and reported " +
"as a failure.",
)
.action((id: string) => settled(state().stop(id)));
worktree
.command("restore <id>")
.description("Reserve a released checkout's disk again before rebuilding its scratch")
Expand Down
29 changes: 22 additions & 7 deletions src/cli/supervise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export interface Scopes {
check(): void;
/** Whether any process may still run in the scope. */
live(unit: string): boolean;
/** Kill every process left in the scope and resolve once it is empty, including when it is already gone. */
stop(unit: string): Promise<void>;
/**
* Run a command in a new scope with inherited streams, then stop its remaining descendants.
* Resolves with the command's exit status only once the scope is empty. Rejects with `Retained`
Expand Down Expand Up @@ -143,19 +145,32 @@ export function kill(group: string): void {
}
}

/** The scope's cgroup directory, or undefined once systemd no longer knows the unit. */
function scopeGroup(unit: string): string | undefined {
const group = execFileSync("systemctl", ["--user", "show", "--property=ControlGroup", "--value", unit], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
if (!group) return undefined;
if (!group.endsWith(`/${unit}`)) throw new Error(`unexpected cgroup for ${unit}: ${group}`);
return join("/sys/fs/cgroup", group);
}

export const systemdScopes: Scopes = {
check() {
if (process.platform !== "linux" || !killable()) throw new Error(UNSUPPORTED);
},

live(unit) {
const group = execFileSync("systemctl", ["--user", "show", "--property=ControlGroup", "--value", unit], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
if (!group) return false;
if (!group.endsWith(`/${unit}`)) throw new Error(`unexpected cgroup for ${unit}: ${group}`);
return populated(join("/sys/fs/cgroup", group));
const group = scopeGroup(unit);
return group !== undefined && populated(group);
},

async stop(unit) {
const group = scopeGroup(unit);
if (group === undefined) return;
kill(group);
await vacated(group);
},

run(unit, cwd, command) {
Expand Down
Loading
Loading