Move queued-approval reconciliation into the permission layer - #374
Merged
TheGreatAxios merged 6 commits intoAug 8, 2026
Merged
Conversation
Collaborator
Author
|
Parking for the release candidate. Nothing is known-wrong here — CI is green and the underlying finding is real and valuable: queue reconciliation was not misplaced but silently absent since the renderer cutover deleted the hooks that owned it. Holding it out of the RC because it adds ~320 lines to the permission gate and has not had an architecture pass. Permission-layer changes fail quietly rather than loudly, so it gets its own review and its own release rather than riding along at the end of a batch. |
TheGreatAxios
force-pushed
the
cl-4995-move-queued-approval-reconciliation-out-of-the-tui-into-the
branch
2 times, most recently
from
August 8, 2026 17:07
89d5db8 to
06256bd
Compare
The permission layer already judges whether one grant covers an already-queued request (isRequestCoveredByGrant); nothing owned the walk that acts on it. This queue enqueues live requests, settles them by id exactly once, drains coverage without a prompt when a grant widens, and denies whatever is left on session teardown so a pending entry can never hang an awaited resolve. Any approval surface can wire it to permission.grant with wirePermissionGrantReconciliation instead of reimplementing the walk.
gate-wire.ts previously tracked settlement with a local boolean and never reacted to a grant that widened mid-queue, so an operator saw a prompt reconciliation should have skipped. It now enqueues each request into the shared permission queue and dispatches whatever settle call comes back, whether that is an operator's own choice, a timeout, a budget abort, or a grant draining the entry silently. Disposal drains anything still queued so teardown can never leave an evaluate() call hanging.
The prior settle path tracked "is my overlay open" with a boolean the caller had to remember to clear at each settle site, which depended on knowing that the shell closes an accepted or cancelled overlay (and may open the next queued gate) before invoking that callback. A generation counter bumped at every open on the shared host makes that comparison explicit instead of assumed: a settle path checks whether its own captured generation still matches the current one before closing anything, so it can never tear down an overlay opened after its own. Closing an overlay from inside a settle path (autoDeny, or a grant-driven reconcile) re-invokes that overlay's own onCancel, since shell.ts's closeInsetOverlay calls onCancel after notifying close listeners. Recording the decision unconditionally at each call site meant that reentrant invocation wrote a second transcript row for the same request. permissionQueue.settle already reports whether an id was still live to settle, so gating the transcript write on that return value makes the reentrant call a no-op instead of a duplicate.
Reconciliation (queue.settle) and transcript recording (recordDecision) are two independent single-fire guards layered on the same terminal paths. Nothing exercised them together: a timeout and an abort racing the same request, or a timeout firing on a request that never opened an overlay.
Every other terminal path (accept, Esc, timeout, abort) writes its own row at its own call site. reconcile() settles a queue entry directly, with no such call site, so a request that ran without ever being shown to the operator left no trace beyond the transient grant-minted flash. That is a gap on the highest-consequence path in the queue: the one where a request is approved without asking.
recordGrantDrain hardcoded "Auto-approved (already granted)" for any request settled with no accept/cancel/autoDeny call site of its own — but drain() (session teardown) also settles that way, denying whatever is still queued. Every remaining request left the transcript mislabeled as approved on exit. The row now reads the settled outcome's allow flag to pick the right label.
TheGreatAxios
force-pushed
the
cl-4995-move-queued-approval-reconciliation-out-of-the-tui-into-the
branch
from
August 8, 2026 17:25
5689f6b to
08c530d
Compare
6 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
PermissionRequestQueueinsrc/permission/queue.ts. It enqueues live requests, settles them by id exactly once, and drains coverage without a prompt viareconcile()whenpermission.grantfires — the coverage judgment itself still comes from the gate's existingisRequestCoveredByGrant.src/tui-opentui/gate-wire.tsnow only renders: it enqueues eachpermission.gaterequest into the shared queue and dispatches whatever settle call comes back (operator choice, timeout, budget abort, or a silent grant-driven settle). Its localsettledboolean is gone — the queue is the single-settle guard.evaluate()call awaiting a resolve that will never come.wirePermissionGrantReconciliation(emitter, queue)instead of reimplementing the walk.gate-wire.ts's overlay-close bookkeeping used to depend on remembering that the shell closes an accepted/cancelled overlay (and may open the next queued gate) before invoking that overlay's own callback — a boolean had to be reset at each settle site to avoid tearing down a newer overlay. That's replaced with a generation counter bumped once, centrally, at the single place any gate opens on the shared host; a settle path compares its own captured generation against the current one instead of relying on call-order knowledge.Finding: this wasn't misplaced, it was silently absent. The ticket describes moving logic out of a React hook. That hook,
src/tui/hooks/use-gates.ts, was deleted outright by the Ink→OpenTUI cutover (commitf62211d, "Cut the interactive TUI over to OpenTUI") along with its reconciliation (reconcileQueue) and its queue-drain-on-teardown behavior. Nothing replaced either: on currentmain,permission.granthas exactly one listener (product-host.ts'sonPermissionGrant), and it only shows a UI notice — it never reconciles the queue. So before this PR, a grant that widened mid-run never silently drained anything it now covered; every already-queued request still surfaced its own prompt.Rebase: two more PRs landed on the same region
This branch was originally reviewed and merge-ready. Since then, three more PRs touched
src/tui-opentui/gate-wire.ts: #381 (onceClosed/GateLifecycleHooks, wrappingev.resolveso a stall-watchdog exemption fires exactly once), #382 (echoChoice: falseplusrecordDecision/recordOperatorDecision, so every terminal path writes exactly one transcript row), and #386/CL-5662/CL-4929 (cwdMatchesGrant/GrantWorkspaceinsrc/permission/authz-grants.tsandgate.ts, so a project-scoped grant matches a sub-agent's worktree cwd instead of only the literal session root). This branch was rebased twice onto currentmainto pick all of that up; the second pass was needed because the first rebase, done against a slightly stale fetch, silently reverted thecwdMatchesGrantfix by auto-merging clean around it (caught by review, confirmed withgit diff origin/main HEAD -- src/permission/gate.ts src/permission/authz-grants.ts, empty after the second rebase).Combining #374's queue-based settle with #382's per-path transcript recording exposed a real reentrancy bug, not present in either PR alone:
closeInsetOverlay(shell.ts) invokes the overlay's ownonCancelhook after notifying its close listeners, so whenautoDeny(timeout/abort) or a grant-drivenreconcile()closes the currently-displayed overlay from inside the queue's resolve callback, that reentrantly re-invokes the sameonCancelhandler — which, post-merge, also callsrecordDecision, printing the row twice. Fixed by gating everyrecordDecisioncall onpermissionQueue.settle's return value (trueonly for the actual first settle), at all three call sites (accept, Esc, autoDeny). Regression test: a timeout and an abort racing the same request settle once and record once.Review (greybeard, critique) surfaced one more real gap while checking the merge:
reconcile()settling a queued request has no call site of its own, so it never calledrecordDecisionat all — the highest-consequence path in the diff (a request that ran without the operator ever seeing it) left zero transcript trace, worse than the double-row bug this rebase fixed. AddedrecordGrantDrain, gated the same way (arecordedflag set by the three known call sites, checked — and snapshotted before the reentrant close can flip it — in the queue's resolve callback) so a silently-drained request now writes exactly one row too. Regression tests cover both the queued-and-never-opened case and the currently-displayed case.Verification
bun run typecheck— cleanbun run build— cleanbun run test— 4120 pass, 1 fail, 12121 expect() calls, across 4121 tests / 313 filesThe one failure is a pre-existing directory-name collision, not a regression.
landing.test.ts's "no titlebar, status strip or counter row survives" test asserts the rendered frame does not contain the literal substring"queue"(checking that old status-strip text is gone). The titlebar renders a truncated form of the working directory's basename, and this ticket's required branch name —cl-4995-move-queued-approval-reconciliation-out-of-the-tui-into-the— contains "queued". Verified unrelated to this diff: unmodifiedorigin/mainchecked out into a differently-named worktree passes; the same content in a worktree whose path contains "queue" fails identically. Pre-existing test/environment coupling, not touched by this change.Follow-up: teardown was mislabeled as approval
Self-caught after the review gauntlet re-ran against this head: the grant-drain transcript fix above was written before a second look at
drain()(session teardown, which denies whatever is still queued through the exact same no-call-site path as a grant drain).recordGrantDrainhardcoded "Auto-approved (already granted)" for both, so every request still queued at shutdown was recorded as having run when it had actually been dropped unanswered. Fixed by reading the settled outcome''''sallowflag to pick the label (recordSilentSettle, renamed fromrecordGrantDrain). Regression test: disposing with a request still queued records it as denied, not approved.Closes CL-4995