Skip to content

Move queued-approval reconciliation into the permission layer - #374

Merged
TheGreatAxios merged 6 commits into
mainfrom
cl-4995-move-queued-approval-reconciliation-out-of-the-tui-into-the
Aug 8, 2026
Merged

Move queued-approval reconciliation into the permission layer#374
TheGreatAxios merged 6 commits into
mainfrom
cl-4995-move-queued-approval-reconciliation-out-of-the-tui-into-the

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Queued-approval reconciliation (silently settling already-queued requests once a grant widens enough to cover them) is now owned by a headless PermissionRequestQueue in src/permission/queue.ts. It enqueues live requests, settles them by id exactly once, and drains coverage without a prompt via reconcile() when permission.grant fires — the coverage judgment itself still comes from the gate's existing isRequestCoveredByGrant.
  • src/tui-opentui/gate-wire.ts now only renders: it enqueues each permission.gate request into the shared queue and dispatches whatever settle call comes back (operator choice, timeout, budget abort, or a silent grant-driven settle). Its local settled boolean is gone — the queue is the single-settle guard.
  • Teardown now drains anything still queued (denying it) so disposing mid-session can never leave an evaluate() call awaiting a resolve that will never come.
  • Any non-TUI approval surface gets the same behavior for free via 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 (commit f62211d, "Cut the interactive TUI over to OpenTUI") along with its reconciliation (reconcileQueue) and its queue-drain-on-teardown behavior. Nothing replaced either: on current main, permission.grant has exactly one listener (product-host.ts's onPermissionGrant), 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, wrapping ev.resolve so a stall-watchdog exemption fires exactly once), #382 (echoChoice: false plus recordDecision/recordOperatorDecision, so every terminal path writes exactly one transcript row), and #386/CL-5662/CL-4929 (cwdMatchesGrant/GrantWorkspace in src/permission/authz-grants.ts and gate.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 current main to pick all of that up; the second pass was needed because the first rebase, done against a slightly stale fetch, silently reverted the cwdMatchesGrant fix by auto-merging clean around it (caught by review, confirmed with git 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 own onCancel hook after notifying its close listeners, so when autoDeny (timeout/abort) or a grant-driven reconcile() closes the currently-displayed overlay from inside the queue's resolve callback, that reentrantly re-invokes the same onCancel handler — which, post-merge, also calls recordDecision, printing the row twice. Fixed by gating every recordDecision call on permissionQueue.settle's return value (true only 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 called recordDecision at 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. Added recordGrantDrain, gated the same way (a recorded flag 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 — clean
  • bun run build — clean
  • bun run test — 4120 pass, 1 fail, 12121 expect() calls, across 4121 tests / 313 files

The 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: unmodified origin/main checked 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). recordGrantDrain hardcoded "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''''s allow flag to pick the label (recordSilentSettle, renamed from recordGrantDrain). Regression test: disposing with a request still queued records it as denied, not approved.

Closes CL-4995

@linear-code

linear-code Bot commented Aug 7, 2026

Copy link
Copy Markdown

CL-4995

@TheGreatAxios

Copy link
Copy Markdown
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
TheGreatAxios force-pushed the cl-4995-move-queued-approval-reconciliation-out-of-the-tui-into-the branch 2 times, most recently from 89d5db8 to 06256bd Compare August 8, 2026 17:07
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
TheGreatAxios force-pushed the cl-4995-move-queued-approval-reconciliation-out-of-the-tui-into-the branch from 5689f6b to 08c530d Compare August 8, 2026 17:25
@TheGreatAxios
TheGreatAxios merged commit 69fbe87 into main Aug 8, 2026
3 checks passed
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