18 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 719 | Subagent `ask` permissions stall for 10 minutes, then auto-deny without parent prompt |
Retro: #719 — Subagent ask permissions stall for 10 minutes, then auto-deny without parent prompt
Stage: Planning (2026-08-13T00:52:48Z)
Session summary
Planned a third-party bug report from akozhin-yint: a @gotgenes/pi-subagents child hit an ask rule, no parent dialog appeared, and after exactly ten minutes the child received User denied bash command 'pwd'.
Traced the whole forwarding stack (ParentAuthorizer → file protocol → ForwardingManager → ForwardedRequestServer) and Pi's own showExtensionCustom, and could not determine statically why the parent failed to drain the request.
The plan therefore delivers the failure-mode half in full — truthful confirmationUnavailable denials, an in-process serving registry so the child abandons in seconds naming the target session id, serving-side log lines, and a forwardingTimeoutMs config field — and splits the unexplained stall into #722 with the evidence gathered here.
Observations
- What the ten-minute wait proves.
selectAuthorizerchecksctx.hasUIbeforeisSubagent, and an unresolved target denies immediately, so the duration alone pins the child to theParentAuthorizerpath with a resolved target and a written request file.ForwardedRequestServer.resolveDecisioncatches escalation errors and writes a denial promptly, so the parent never reached it —processInboxreturned early. That narrows the cause to a stopped timer or a session-id mismatch without needing the reporter's logs. - A hypothesis worth killing early.
I spent real effort on "the TUI cannot mount
ui.customwhile the parent is idle at the editor prompt", which fit therun_in_background: truedetail and explained why #710's reporter saw prompts render. AnExploresubagent on the sibling../picheckout refuted it in 80 seconds:showExtensionCustom(interactive-mode.ts:2659) has no turn-state gating and stdin is read non-blockingly, so timers fire while idle. Cheap refutation of a plausible-but-wrong theory was the highest-value tool call of the session. - The root cause is not the deliverable.
The operator chose "observability + truthful failure + fast-fail liveness" over a diagnose-first plan.
That is the right call here: the misleading
User deniedmessage is a definite defect regardless of cause, and the serving-side log line the plan adds is precisely the instrument that makes the next report diagnosable in one diff. - The fix was mostly already in the codebase.
PermissionPromptDecision.confirmationUnavailablealready flips the block message tobuildUnavailableBodyand the review-log resolution toconfirmation_unavailable.DenyingAuthorizersets it;ParentAuthorizernever does. Most of the "truthful abandonment" work is setting an existing flag, not building a mechanism. - One asymmetry blocks the reason from reaching the model.
applyPermissionGatetakesuserDeniedReasonas(decision) => stringbutunavailableReasonas a precomputedstring, so adenialReasonon an unavailable decision is silently dropped. Removing the asymmetry is a better framing than adding a field. - Provenance over re-derivation.
The fast-fail must not fire for out-of-process children, and
resolvePermissionForwardingTargetSessionIdalready knows whether it resolved via the in-process registry or env vars — then throws that away. Returning a{ sessionId, source }product beats re-deriving "in-process" insideParentAuthorizer, which would leave two places that must agree. Landed as a Tidy Firstrefactor:step so the feature steps stay small. - Injecting the timeout unlocks coverage.
getTimeoutMsis nominally about the new config field, but its real payoff is thatParentAuthorizer's timeout branch becomes unit-testable. Today a test covering it would run for ten minutes, which is whytest/composition-root.test.tshad to build a fire-without-await round trip in the first place. - Rejected alternatives. A filesystem claim artifact and a serving heartbeat both cover out-of-process children but carry a version-skew hazard (an older serving node never claims, so a newer child fast-fails on a parent that is about to prompt). The process-global registry has no skew hazard because an in-process child is by construction the same install. Both filesystem options are parked in #721.
- Scope note.
The issue carries both
pkg:labels, but nopi-subagentscode changes — its side of the contract (subagents:child:session-createdcarryingparentSessionId) is already correct. Filed as a single-package plan underpackages/pi-permission-system/docs/plans/. - Version skew in the report.
The reporter is on
pi-permission-system@22.0.0against a current25.0.0. I read the intervening changelogs; nothing between them touches forwarding, so the bug is expected to reproduce on current versions.
Stage: Implementation — TDD (2026-08-13T04:01:54Z)
Session summary
Landed ten commits: two preparatory refactorings from the plan, one preparatory test fixture from the Tidy-First assessor, five behavior commits, and a docs commit.
The pi-permission-system suite grew from 2721 to 2757 tests (+36) across 131 → 132 files.
All deterministic gates green throughout: check, root lint (0 findings), full workspace test, and fallow dead-code.
Observations
- Pre-completion reviewer: PASS — ready for
/ship-issue. - Reviewer warnings — the four new liveness tests run against real timers and the 2000 ms grace window, costing ~2.0–2.5 s wall clock each (measured 2011/2262/2518/2236 ms).
None raced across repeated local runs and all sit well inside vitest's 5000 ms default, but they are the tests most exposed to a slow CI runner.
The reviewer suggests
vi.useFakeTimers()as a follow-up tidy pass only if CI margin becomes a problem; deliberately not done here, since converting them late would trade a measured-safe margin for a fresh flakiness risk. - Deviation: the timeout seam moved a step earlier.
The plan put
getTimeoutMsin step 8 with the config field, but step 6 needs it: the poll-timeout abandonment path is otherwise a literal ten-minute test. Splitting the seam (step 6) from the operator-facing config key (step 8) is the better split anyway. - Deviation: the Tidy-First
abandon()extraction was folded in, not landed separately. The assessor was right that five hand-edited abandonment sites is the friction, but every honest version of the extraction also changes the returned decision shape — a "behavior-preserving" preparatory commit would have needed a helper name that lied about the current behavior. Folding it into step 6 gave the same result: all six paths now route through oneabandon()helper, so no future path can omit the marker. The assessor's other recommendation (makeParentAuthorizerDeps) was genuinely preparatory and landed as its owntest:commit — it absorbed both new deps in one place instead of six. - Deviation:
config-loader.tswas missing from the plan's file table. The plan attributed the scalar merge loop toextension-config.ts'smergeUnifiedConfigs(); that function actually lives inconfig-loader.ts. A field added to the runtime type but not that loop is silently dropped before runtime — the failure class the package skill already warns about — so the omission would have been caught by the merge test regardless, but the plan's grep should have located the function rather than trusting the skill's prose. - The composition-root round trip was quietly depending on nobody watching.
It hand-writes the parent's response instead of running the poll timer, so once the fast-fail landed it was racing the 2 s grace window rather than asserting anything.
Fixing it with an explicit
markServing(parentSessionId)made the test state what it had been assuming, and the paired new test (nomarkServing) is the closest thing in the suite to the reported bug. - Strengthening assertions surfaced the point of the change.
Converting the target-resolution tests from
toBe("parent-x")totoEqual({ sessionId, source })was mechanical, but it is what makes theregistry-vs-envdistinction — the whole reason out-of-process children are never fast-failed — visible in the test names rather than buried in a branch. toMatchObjectwill not assert a key's absence.confirmationUnavailable: undefinedin atoMatchObjectexpectation fails rather than passing on a missing key, so the "a real parent denial is not marked unavailable" discrimination lives in the abandonment tests'toEqualassertions instead.- One test earns its complexity.
Forcing the request-write failure needs a
chmod 0o500on the requests directory, and thefinallyhad to become conditional because the new cleanup removes that directory on the way out — which is itself the assertion that abandonment cleans up after itself.
Stage: Final Retrospective (2026-08-13T04:29:53Z)
Session summary
One continuous session carried #719 from a third-party bug report through planning, twelve commits of TDD, and a clean ship to @gotgenes/pi-permission-system@25.1.0.
The shipped change makes a subagent whose parent is not draining its forwarded-permission inbox abandon in ~2 s with a truthful reason, instead of stalling ten minutes and reporting a User denied message about a user who was never asked.
The unexplained stall itself was deliberately split into #722 rather than chased to a conclusion the code could not support.
Observations
What went well
- Model allocation tracked task shape without being asked.
Planning and TDD ran on
claude-opus-5(judgment-heavy: hypothesis elimination, design trade-offs, test design); the ship sequence ran onclaude-sonnet-5(deterministic, tool-driven) for 29 turns with zero corrections; the retro returned toclaude-opus-5. This is the allocation the model-performance lens looks for, arrived at by the operator mid-session. - The
pre-completion-reviewermeasured instead of estimating. Asked whether the grace-window timing made the new tests flaky, it timed all four (2011 / 2262 / 2518 / 2236 ms) and framed the WARN against vitest's 5000 ms default rather than asserting a risk. That is exactly the measured-vs-estimated disciplineAGENTS.mdasks of numbers, applied by a subagent unprompted. - A Tidy-First recommendation was rejected with an argued reason.
The assessor's
abandon()extraction was right about the friction (five hand-edited abandonment sites) but not landable as a preparatory commit: every honest version also changes the returned decision shape, so the "behavior-preserving" commit would have needed a helper name that lied. Folding it into the behavior step reached the same end state — all six paths route through one helper — and thetidy-firstskill's "the report is advisory; you decide what lands" contract got its first real exercise. - Caught a green test that had stopped asserting.
test/composition-root.test.ts's forwarding round trip hand-writes the parent's response instead of running the poll timer, so once the fast-fail landed it was passing only becauseapproveForwardedRequestbeat the 2 s grace window. Making the assumption explicit (markServing(parentSessionId)) removed a latent flake the suite would not have reported until CI was slow. - Splitting the deliverable from the diagnosis held up under review. The reviewer independently confirmed both plan-named follow-ups (#721, #722) carried recorded issue numbers, and the shipped change stands on its own without the root cause.
What caused friction (agent side)
rabbit-hole— the root-cause hunt ran inline instead of being delegated. Roughly 30 tool calls across ~20 turns read the whole forwarding stack (approval-escalator,forwarded-request-server,forwarding-manager,permission-forwarding,permission-session,lifecycle,before-agent-start,subagent-detection,subagent-context,extension-paths, pluspi-subagents'runtime.tsandcreate-subagent-session.ts), forming and discarding six hypotheses: the child'sForwardingManagerstopping the parent's timer, a childhasUI: trueself-targeting its own inbox,SUBAGENT_ENV_HINT_KEYSmisclassifying the parent, an unhandled rejection loopingprocessInbox, a session-id mismatch, andui.custombeing gated while the parent sits idle. Only the last was delegated — anExploresubagent on../pirefuted it in 80 s. Impact: no rework and the conclusion was correct ("not determinable from the code"), but it consumed a large share of the planning session's context immediately before the plan had to be written.instruction-violation(self-identified) — anEditoldTextanchored on the decorative// ── Mocks ──…rule intest/authority/forwarding-manager.test.ts, whichAGENTS.mdexplicitly says to avoid in favour of adjacent unique code lines. Impact: one rejected atomic batch, one extraRead.instruction-violation(self-identified) — anEditoldTextfortest/authority/serving-registry.test.tsbuilt from the layout I had just emitted, afterpi-autoformatreflowed thedelete store[KEY]statement across three lines.AGENTS.mdstates the rule directly: re-read a region you just edited before editing it again. Impact: one rejected batch, one extraRead.missing-context— wrotedelete store[SERVING_SESSION_REGISTRY_KEY]in the new test file without checking howtest/composition-root.test.tsalready performs the identical process-global-Symbolteardown (it carries aneslint-disable @typescript-eslint/no-dynamic-delete). Impact: one commit blocked by theprekhook, one grep, one edit, one re-commit — the gate working as designed.missing-context— the plan's Module-Level Changes table attributedmergeUnifiedConfigs()toextension-config.tsbecause the package skill's "adding a field" bullet names it in the same sentence as that file; it actually lives inconfig-loader.ts. Impact: two files touched that the plan did not list, recorded as a deviation. No rework — a genuinely dropped field would have failed the merge test.
What caused friction (user side)
- The
ask_usergate worked in a single round: direction, liveness mechanism, and timeout policy were all decided in one call, and the answers drove the plan's Goals directly. No friction to report there. - One opportunity, framed as such: the reporter's
forwarded_permission.*review-log lines would have collapsed the ~30-call hunt into a lookup. For a third-party bug that does not reproduce, asking for the review log in the issue thread before/plan-issueruns would put the decisive evidence in the planning session's hands instead of leaving it to inference.
Diagnostic details
- Model-performance correlation — no mismatch found.
Turn-level attribution:
claude-opus-5for planning + TDD,claude-sonnet-5for the entire ship sequence,claude-opus-5for the retro. Subagents:Exploreonmodel: "sonnet-5"for the../piui.customtrace (the modelAGENTS.mdprescribes for that checkout, sinceExplore's haiku default is too weak);tidy-first-assessorandpre-completion-revieweron their configured defaults, both judgment-heavy and both delivering substantive reports (27 and 46 tool uses). - Escalation-delay tracking — the single
rabbit-holeran ~30 consecutive tool calls on one question before the strategy changed, six times the lens's five-call threshold. The delegation that did happen (theExploredispatch) proved the mechanism: 80 s to kill a hypothesis that had already absorbed several turns. - Unused-tool detection — an
Exploreorgeneral-purposesubagent was available for the main hunt and used only for a sub-question.colgrepwas never dispatched this session; every search was for an exact symbol, wheregrepis the right tool per thecolgrepskill's decision table, so that is not a gap. - Feedback-loop gap analysis — no gap.
pnpm run checkplus the affected test file ran after every Red and every Green; the full package suite ran before every commit; rootpnpm run lintandpnpm fallow dead-coderan at the green baseline, before the docs commit, and again as pre-push checks. The two type errors introduced by wideningAuthorizerSelectionDepssurfaced on thecheckimmediately after that step rather than at end-of-cycle.
Changes made
.pi/prompts/plan-issue.md— added "Gather context" step 6: dispatchExplore(model: "sonnet-5") for the root-cause hunt when a bug report does not reproduce locally. Generalizes the../pi"hunt vs. targeted read" economics already inAGENTS.mdto our own packages. Renumbered the following two steps and updated the "Write the plan" back-reference from "Gather context step 7" to "step 8"..pi/skills/testing/SKILL.md— added a "Test assertions" bullet recording thattoMatchObjectdoes not assert a key's absence (an expectedundefinedrequires the key to be present). Verified against this repo's Vitest 4 with a throwaway probe before landing..pi/skills/package-pi-permission-system/SKILL.md— attributedmergeUnifiedConfigs()toconfig-loader.tsin the "adding a field" checklist, and named its "Number scalars" loop. The unattributed symbol sat in a sentence where every other symbol carried its filename, which is what misled this issue's plan table.
Deliberately not landed: no new text for the two Edit oldText failures (AGENTS.md already states both rules — these were salience misses), nothing for the no-dynamic-delete miss (the prek hook caught it in one cycle), and no vi.useFakeTimers() rule (the reviewer measured the margin as safe and scoped a fix to "only if CI degrades").