10 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 530 | pi-permission-system: split PermissionForwarder by direction of authority flow |
Retro: #530 — pi-permission-system: split PermissionForwarder by direction of authority flow
Stage: Planning (2026-07-07T00:00:00Z)
Session summary
Planned Phase 8 Step 6: splitting the 578-line dual-role PermissionForwarder into ApprovalEscalator (escalation-up, ApprovalRequester) and ForwardedRequestServer (serving-down, InboxProcessor), relocating the forwarding subsystem into src/authority/ and dissolving src/forwarded-permissions/.
The plan is a non-breaking refactor: sequenced as three tidy-first extraction commits plus a doc-update commit, filed at packages/pi-permission-system/docs/plans/0530-split-permission-forwarder-by-direction.md.
Observations
- The 7-field
PermissionForwarderDepsbag partitions cleanly by role:detection/registryare escalation-only,config/eventsare serving-only, andforwardingDir/logger/requestPermissionDecisionFromUiare shared — so each new deps interface is a strict 5-field narrowing. Confirmed the escalation UI fast path does not emit a UI event (the prompter does), which is why the escalator dropsevents. - The issue's proposed change lists 3 target files but omits where the shared
ForwarderContexttype +getSessionIdhelper live (both classes and both seams need them). Asked the operator; confirmed a dedicatedsrc/authority/forwarder-context.tsover folding intoforwarding-io.tsor duplicating across the sibling classes. - Consumers are well-contained: only
permission-prompter.ts(ApprovalRequester),forwarding-manager.ts(InboxProcessor), andindex.tsimport the split symbols;composition-root.test.tsreaches forwarding via the real factory, not direct imports. - Doc-staleness sweep found
docs/architecture/architecture.md(module tree, Step 6 marker, metrics row),docs/architecture/permission-prompter.md, and.pi/skills/package-pi-permission-system/SKILL.mdnaming the old symbols; the frozendocs/architecture/history/phase docs are intentionally left as-is. - Roadmap tags Steps 4–6
Release: independentwith no batch; as a hiddenrefactor:type this lands and auto-batches into the next release rather than cutting one — Release Recommendation worded accordingly. - Next step is
/tdd-plan(pure-refactor cycles: relocate code + tests, keep the suite green).
Stage: Implementation — TDD (2026-07-07T10:50:00Z)
Session summary
Executed all 4 planned steps as 4 commits: (1) renamed io.ts → forwarding-io.ts and extracted the shared ForwarderContext + getSessionId into a new forwarder-context.ts; (2) extracted ForwardedRequestServer (serving-down role) into src/authority/, narrowing PermissionForwarderDeps to escalation-only fields; (3) renamed the remaining PermissionForwarder → ApprovalEscalator and dissolved both src/forwarded-permissions/ and test/forwarded-permissions/; (4) updated architecture.md, permission-prompter.md, the package SKILL.md, and two doc comments (session-logger.ts, subagent-detection.ts).
Test count: 112 → 113 test files (one new file, forwarded-request-server.test.ts), 2300 → 2300 tests (no net change — pure relocation/split, no new or removed test cases).
All deterministic checks (pnpm run check, pnpm run lint, pnpm run test, pnpm fallow dead-code) passed clean at the end.
Observations
- No deviations from the plan.
The dependency partition predicted in planning (5-field
ApprovalEscalatorDeps/ 5-fieldForwardedRequestServerDeps) held exactly as designed; no unplanned coupling surfaced. - One planning gap surfaced during Step 2: the two
requestApprovaltests in the escalator's test file passed aneventsmock into deps purely to assertevents.emitwas never called — butApprovalEscalatorDepsno longer has aneventsfield. Fixed by keeping theeventsmock as a standalone assertion target (not injected into deps), preserving the "escalator never emits UI events" documentation value of the test without a type error. - The empty
test/forwarded-permissions/directory (left over from Step 1's file move) had to bermdir'd explicitly in Step 3 — git does not track empty directories, so the Step 1 commit left a stray empty dir on disk that only became visible once Step 3 tried to remove the siblingsrc/forwarded-permissions/. - Updated the "What it consolidates" bullet in the Target authority-model section (not explicitly named in the plan's Module-Level Changes) to stop describing the split as future Phase 9 work, since Phase 8 Step 6 already completed it — judged this was within the plan's "verify no current-state prose still claims the class is unsplit" instruction rather than scope creep.
- Pre-completion reviewer: PASS.
No findings; verified the dependency partition, doc updates, cross-step invariants (Step 4
#528harness, Step 5#529SubagentDetectorseam), Mermaid diagrams, and planned follow-up issues (#531,#532) all check out.
Stage: Final Retrospective (2026-07-07T15:15:00Z)
Session summary
Shipped Phase 8 Step 6 across three stages (plan → TDD → ship) with zero rework: the plan's dependency partition and 4-commit sequence held exactly as designed, the pre-completion reviewer returned PASS with no findings, and the release cut pi-permission-system 19.0.1.
The only friction was external and transient — a live GitHub Actions incident (Jul 7 2026, ~15:06 UTC: 500 errors on Actions runners / Codespaces REST APIs, "retries may be successful") degraded the CI runners during the ship window, causing three intermittent failures that each cleared on re-run before the push and the release-please PR could land.
Observations
What went well
- Clean plan-to-execution fidelity: the planning-stage dependency partition (5-field
ApprovalEscalatorDeps/ 5-fieldForwardedRequestServerDeps, sharedForwarderContext+getSessionId) landed verbatim, with no unplanned coupling and no deviations across the four TDD commits. - The single planning-stage
ask_usergate (dedicatedforwarder-context.tsvs. folding intoforwarding-io.tsvs. duplication) resolved the one genuine design fork up front, so the TDD stage never had to stop for a structural decision. - Incremental verification during TDD:
pnpm run checkplus the affected test file ran after each of the four steps, not just at the end — the type-level break from narrowingPermissionForwarderDeps(Step 2) surfaced immediately rather than at end-of-cycle.
What caused friction (agent side)
other(external platform incident) — CI failed three times on 5000mstestTimeouttimeouts inpi-session-tools(read-session.test.ts,read-session-file.test.ts,read-parent-session.test.ts— a different test each run), a package untouched by#530. Root cause was a live GitHub Actions incident (500s on Actions runners, "retries may be successful") degrading runner I/O during the window: those tests run in ~150ms healthy but exceeded 5000ms under the degraded runner, a >30× slowdown that atestTimeoutbump would not reliably survive. Impact: three re-run cycles (one on themainpush, two on release-please PR #552) plus twoask_useroperator round-trips before the release could land; no code rework.missing-context(self-corrected, user-caught) — during the retro I first attributed the failures to thin-margin flaky tests and drafted api-session-toolsfollow-up issue, reasoning from afetch_contentof githubstatus.com that returned a stale/cached view (the live Jul 7 Actions incident was absent, showing June 25 as latest). The operator supplied the live incident text, correcting the attribution. Impact: two extra retro round-trips and a nearly-misfiled follow-up issue; the retro's friction attribution was corrected before landing. Lesson: when a CI failure might be platform-related, treat "retries succeed" plus failures confined to I/O-heavy tests as a strong transient-infra signal, and verify against the status page's active incidents (or the status API) rather than a possibly-cached page fetch.
What caused friction (user side)
- The operator was pulled into three mechanical re-run confirmations during ship, then had to correct the retro's root-cause attribution — oversight and fact-correction rather than strategic judgment. Opportunity: none actionable — the trigger was a transient GitHub platform incident, outside this repo's control.
Follow-ups
- Correction (added post-commit, same day): a fourth CI failure recurred hours later (Jul 7 ~19:21 UTC,
read-parent-session.test.ts) withgithubstatus.comreporting all systems operational — falsifying "transient GitHub incident" as the sole/durable explanation above. Re-diagnosed: every failure across all four runs is the firstit()block in one ofread-session.test.ts/read-session-file.test.ts/read-parent-session.test.ts, each of which doesawait import("#src/index")inside the test body.#src/index.tstransitively pulls in@earendil-works/pi-coding-agent/@earendil-works/pi-ai/@earendil-works/pi-tui; Vitest isolates each test file's module registry, so the cold-import cost is paid on the first call per file and races the 5000ms defaulttestTimeoutunder CI parallel-package contention. A fourth file,list-session-files.test.ts, rides the same edge (3.5–4.9s observed, never yet crossed). The Jul 7 15:06 UTC Actions incident (500s on runners) likely was real and made the first three failures more likely that day, but the underlying vulnerability is independent of it and reproduces on a clean platform. Filing api-session-toolsissue to fix this is warranted after all: the dynamic per-testimport()is unnecessary (Vitest hoistsvi.mock("node:fs", ...)above all imports automatically, so a static top-levelimport sessionTools from "#src/index"would receive the mock and pay the cost once during collection instead of racing a per-test timer) — see the issue filed for the fix. Lesson for future incident-attribution: a single external corroborating signal (a status-page incident) can still leave a coincidental co-occurrence undiagnosed; the recurrence check (does it reproduce absent the external cause?) is what actually distinguishes platform-caused from test-caused flakiness, not the initial correlation alone. Filed as #554 with the full timing evidence and a candidate fix (replace the per-test dynamicimport("#src/index")with a static top-level import —vi.mockhoisting makes the dynamic form unnecessary).