Skip to content

feat(worktree): remove finished checkouts and stop crashed runs - #186

Merged
kixelated merged 4 commits into
mainfrom
quest/m0/worktree
Oct 22, 2026
Merged

kixelated merged 4 commits into
mainfrom
quest/m0/worktree

Conversation

@kixelated

@kixelated kixelated commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Finishes Worktree removal, following kixelated/quest#127 and kixelated/quest#178. Finished quest worktree checkouts can now be removed without losing work, and processes a crashed supervisor left behind can be stopped.

What changed

  • quest worktree remove <id> deletes a finished checkout and drops its record. It never forces. It refuses:
    • while supervised runs are live;
    • modified or untracked files (git status --untracked-files=normal, so a status.showUntrackedFiles=no config can't hide them);
    • any commit, at HEAD or under the checkout's own per-worktree refs (refs/worktree, refs/bisect, refs/rewritten, which Git deletes with the checkout), that no branch or remote-tracking ref reaches;
    • ignored files beyond the declared scratch. Scratch is pruned first, so this refusal leaves the checkout released.
      Then it unlocks the checkout and runs git worktree remove. If Git still refuses (for example, a checkout with submodules), the quest lock is restored and the record kept. A checkout already removed by hand only drops its record, as with release.
  • Squash merges: when an unreached commit is exactly the head of a merged change, it counts as preserved. The core takes a Merged callback (repository, commit) => Promise<boolean>, so it stays forge-independent. The CLI implements it with GitHub's associatedPullRequests on origin's repository (a fork's parent) and accepts only merged && headRefOid === commit. A PR that merged an ancestor of HEAD doesn't count. The forge is asked only when an unreached commit exists, so other removals need no token or network. The lookup runs outside the registry lock, and the locked pass inspects the checkout again before deleting anything.
  • quest worktree stop <id> ends what crashed supervisors left behind. For each run whose owner provably exited, it kills the scope's cgroup through cgroup.kill (resolving the cgroup with read-only systemctl --user show, never systemctl stop) and waits for cgroup.events to report it empty. Reconciliation then releases the checkout. A run whose supervisor is alive is left to that supervisor and reported, with exit 1. This adds Scopes.stop.
  • Stale inspection failures: a run that can't be inspected, or can't be confirmed stopped, now keeps its reason on the run (runs[].failure) rather than on the checkout. The next successful inspection of that run clears it. The checkout's failure now holds only release and creation failures.
  • README and --help describe remove and stop. The quest file is deleted. Agent lifecycle integration and Own-PC runner keep a plan note on reusing run, remove, and stop in place of the deleted link.

Validation

  • New unit tests cover these cases:
    • removal once a remote-tracking ref or a branch reaches the commits, with the branch surviving and the forge never asked;
    • refusal and forge lookup for an unreached HEAD;
    • exact-head squash evidence versus an ancestor's;
    • refusals for modified files, untracked files, a refs/bisect mark, live runs, and an undeclared ignored .env (the checkout is left released with cache/ pruned);
    • Git refusing a checkout with a submodule, after which the lock and record are restored;
    • a checkout removed by hand;
    • stop killing only the crashed run's scope and reporting the live one;
    • a stale inspection failure cleared by the next inspection;
    • mergedHead against a fake GitHub: exact head, later head, unmerged, unknown commit, and a non-commit object;
    • CLI wiring for stop and remove.
  • The real systemd fixture now ends a SIGKILLed supervisor's orphan with quest worktree stop instead of systemctl --user stop, then checks the checkout is released and the scope is gone.
  • Locally, nix develop -c just _check and nix develop -c just _test pass: 379 CLI tests (2 skipped on Linux), 306 Worker tests, the hook and installer suites, and both lifecycle checks. I ran the ungated recipes because the host's load average stayed near 2× the CPU count while other quests ran. The nested Worker gate still applied.
  • After merging main in 4d04fe5, which resolved an import conflict in src/cli/main.ts with kixelated/quest#192, nix develop -c just check and nix develop -c just test pass (387 CLI tests with 2 skipped, 306 Worker tests), as does quest check --base origin/main --branch quest/m0/worktree.
  • A dispatched Check run on 34c14bd passes all five jobs, including the systemd fixtures and native Windows x64 and ARM: run 39734896975.

Settled decisions

The maintainer accepted all five as implemented:

  1. Refusing over undeclared ignored files after pruning. remove prunes the declared scratch before it checks for remaining ignored files, so that refusal leaves the checkout released (the error says so). A read-only pre-check was not chosen: it would have to match Git's collapsed ignored directories against scratch paths, and it refuses falsely when scratch is nested (cache/nested under an ignored cache/). Scratch is disposable by declaration, and restore reverses the release.
  2. What preserves a commit. Only branches and remote-tracking refs (as the quest specified), plus exact merged-PR heads. Tags and reflog entries don't count, so a commit reachable only from a tag is refused.
  3. Which forge repository is asked. Origin's GitHub repository, or the fork's parent, through the existing upstream() used by claims. Without an origin, or off GitHub, an unreached commit is simply refused. A PR merged into any base branch counts.
  4. stop with a live supervisor. It stops the crashed runs, then exits 1, naming the live runs with their PIDs, instead of killing them too. Signalling the live supervisor already ends its run cleanly.
  5. runs[].failure replaces inspection reasons on the checkout's failure. This changes the experimental status JSON, and makes "cleared by the next successful inspection" exact, with no message matching.

Suggested follow-ups

  • Unsupervised writers between checks and git worktree remove. A process outside quest worktree run could create an ignored file after the check, and git worktree remove would delete it. The README already says to stop unsupervised work first. Recommend: no quest unless agents start writing into checkouts outside run.
  • Adopting remove in agent workflows belongs to Agent lifecycle integration. Nothing new is needed.

🤖 Generated with Claude Code

(Written by Claude Opus 5.5)

kixelated and others added 3 commits October 21, 2026 22:39
Quest-Claim: 13c8c96caecb29b5
`quest worktree remove` deletes a finished checkout and drops its record
without forcing: it refuses live runs, modified or untracked files,
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.

`quest worktree stop` kills what crashed supervisors left in their
scopes through cgroup.kill and waits until they are empty. A run's
inspection failure now lives on the run and clears on the next
successful inspection.

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 Remove finished quest worktree checkouts feat(worktree): remove finished checkouts and stop crashed runs Oct 22, 2026
@kixelated

Copy link
Copy Markdown
Owner Author

/quest-start outcome

This finishes the worktree quest. quest worktree remove deletes a finished checkout without forcing. A squash-merged head counts as preserved only when a merged PR's head is exactly that commit, behind a forge-independent Merged callback. quest worktree stop kills a crashed supervisor's leftover scopes through cgroup.kill. A run's inspection failure now clears on the next successful inspection. The quest file is deleted.

Local just _check and just _test pass. A dispatched Check run passes all five jobs: https://github.com/kixelated/quest/actions/runs/39734896975

This stays a draft. Five choices I made without asking are listed under Decisions needed, each with a recommendation already implemented: pruning before refusing over ignored files, what preserves a commit, which forge repository is asked, stop with a live supervisor, and moving inspection reasons to runs[].failure.

(Written by Claude Opus 5.5)

@kixelated
kixelated marked this pull request as ready for review October 22, 2026 15:41
@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-22T15:45:01.037691Z 4d04fe5 Draft marked ready
🔒 Security Review ✅ Completed 2026-10-22T15:52:17.575034Z 4d04fe5 Draft marked ready
ℹ️ 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: 4d04fe5841

ℹ️ 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/worktree.ts
Comment on lines +192 to +194
const own = git(root, "for-each-ref", "--format=%(objectname)", "refs/worktree", "refs/bisect", "refs/rewritten");
const tips = new Set([git(root, "rev-parse", "HEAD").trim(), ...own.split("\n").filter(Boolean)]);
return [...tips].filter((tip) => git(root, "rev-list", "-n1", tip, "--not", "--branches", "--remotes").trim());

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 Preserve commits referenced by per-worktree recovery state

When a detached checkout creates a commit and then runs git reset --hard HEAD~1, the discarded commit remains recoverable through that worktree's ORIG_HEAD and HEAD reflog, but this scan examines only HEAD and three ref namespaces. The checkout therefore appears clean and fully preserved, and git worktree remove deletes its administrative directory—including ORIG_HEAD and the reflog—leaving the commit unreachable and eligible for garbage collection. Include per-worktree pseudorefs and reflogs in the preservation check before allowing removal.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Not changing this. remove protects the checkout's live work: HEAD, plus the per-worktree refs Git deletes with it (refs/worktree, refs/bisect, and refs/rewritten for an in-progress rebase). ORIG_HEAD and the HEAD reflog record history that was already discarded with reset, rebase, commit --amend, or merge. Agents do those constantly, so counting every reflog entry or ORIG_HEAD as unsaved work would make remove refuse almost every checkout that was ever rebased or amended. The maintainer settled that only branches, remote-tracking refs, and exact merged-PR heads preserve a commit, and that reflog entries don't count. git worktree remove makes the same call. To keep a discarded commit, branch it before removing the checkout.

(Written by Claude Opus 5.5)

@kixelated

Copy link
Copy Markdown
Owner Author

Merging after review.

  • Changes: quest worktree remove <id> deletes a finished checkout only when nothing would be lost (no live runs, no modified or untracked files, no commit that a branch, remote-tracking ref, or exact merged-PR head doesn't preserve, no undeclared ignored files). quest worktree stop <id> kills the cgroups of runs whose supervisor provably exited. Inspection failures now live on runs[].failure. The finished quest quest/m0/worktree.md is deleted.
  • Decisions: the five settled decisions in the PR body stand. The Codex P1 thread on ORIG_HEAD/reflog-only commits (discussion) is declined, as the maintainer decided: only branches, remote-tracking refs, and exact merged-PR heads preserve a commit, so the code is unchanged.
  • Checks: all 17 checks pass on 4d04fe5, and quest check --base origin/main --branch quest/m0/worktree passes from a base build.
  • Follow-ups: none required; the PR body's notes on unsupervised writers and on Agent lifecycle integration need no new quest.

(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