Files
my-pi/pi-permission-system/docs/retro/0302-child-shutdown-preserves-parent-service.md

92 lines
9.1 KiB
Markdown

---
issue: 302
issue_title: "Child subagent shutdown unpublishes the parent's global PermissionsService"
---
# Retro: #302 — Child subagent shutdown unpublishes the parent's global PermissionsService
## Stage: Planning (2026-06-01T00:00:00Z)
### Session summary
Investigated the process-global `PermissionsService` slot bug surfaced by the `#297` composition-root suite and produced `docs/plans/0302-child-shutdown-preserves-parent-service.md`.
The fix defers `publishPermissionsService` from factory-init to a child-gated `session_start`, moves `emitReadyEvent` alongside it, and makes `unpublishPermissionsService` identity-scoped (compare-and-delete).
Plan is structured as four TDD steps: extract `isRegisteredSubagentChild`, breaking `unpublishPermissionsService` signature, the `session_start` publish gate, then docs.
### Observations
- Key constraint: the factory has **no `ctx` at init**, so an in-process child cannot be distinguished from a reloaded parent at init (both look like "slot already occupied").
The registry signal needs a session id, which first appears at `session_start` — this forced the publish to move there, which in turn forced `permissions:ready` to move to preserve the `#297` ordering contract.
- Decided to gate on the **registry-only** `isRegisteredSubagentChild`, not the full `isSubagentExecutionContext`.
The env/filesystem branches identify process-based subagents (own OS process, own `globalThis`) which *should* publish; only the registry branch marks an in-process child sharing the parent's `globalThis`.
- Rejected a stash/restore alternative (child captures the previous slot at init, restores it at `session_start`) — it is unsound under concurrent sibling children, where one sibling's restore writes back another sibling's service instead of the parent's.
- Chose identity compare-and-delete over a `didPublish` boolean for teardown: the boolean is unsafe if `/reload` re-runs the factory and the old instance's `session_shutdown` fires after the new instance's `session_start` re-publish.
Identity comparison is order-independent.
- `ask_user` confirmed two decisions: move `permissions:ready` to `session_start` (recommended), and identity compare-and-delete with the maintainer's note "favor the breaking change if it makes a cleaner design" — so `unpublishPermissionsService` takes a **required** param (`feat!:`), not an optional one.
- Package public surface is only `src/service.ts` (the `.` export), which is why the signature change is genuinely public/breaking.
Sole `src/` caller is the `index.ts` cleanup closure; consumers use only `getPermissionsService()`.
- Doc updates identified: `service.ts`, `permission-events.ts`, `docs/cross-extension-api.md` (events table + Ready Event section + reload notes), `docs/architecture/architecture.md`.
Re-grep the package skill before the docs commit.
## Stage: Implementation — TDD (2026-06-01T14:00:00Z)
### Session summary
Executed all four TDD cycles from the plan: extract `isRegisteredSubagentChild` (`refactor:`), identity-scoped `unpublishPermissionsService` (`feat!:`), defer publish + `emitReadyEvent` to a child-gated `session_start` (`fix:`), and doc alignment (`docs:`).
Test count went from 1668 pass + 1 expected-fail to 1674 pass (the `it.fails` DESIRED test was replaced by a real passing assertion; net +5 new tests).
Final state: `check`, `lint`, `test`, and `pnpm fallow dead-code` (repo root) all green; lockfile unchanged.
### Observations
- Two extra tests beyond the plan's list assumed ready-at-load and broke under the moved timing: `composition-root.test.ts` "service and gate share one formatter registry" (resolved the service right after the factory) and `permission-events.test.ts` "ready event wiring" (bespoke fake `pi`).
Both were updated to fire `session_start` first; noted in the `fix:` commit body.
The planning sweep listed the two `composition-root` tests it knew about but missed these two because the grep focused on `getPermissionsService` call sites in `composition-root.test.ts` only — a wider grep across all test files for post-factory service resolution would have caught them during planning.
- The new constructor-dep order chosen for `SessionLifecycleHandler` is `(session, activateService, cleanupRpc)`, matching the plan snippet; the sole production instantiation and the `lifecycle.test.ts` `makeHandler` were updated in the same `fix:` commit (type-level break).
- The multi-instance characterization test was consolidated into one comprehensive test (`keeps the parent's service published across the child's lifecycle`) asserting identity (`toBe(parentService)`) at mid-run and after the child's shutdown — stronger than the plan's separate "survives" + "mid-run" assertions.
- Firing `session_start` through the real `SessionLifecycleHandler` in composition-root tests required a `ctx` with `cwd` (a real tmpdir, for `createPermissionManagerForCwd`), `sessionManager.getSessionId/getSessionDir/getEntries`, and `ui.setStatus`; the existing `makeChildCtx` / `makeUiCtx` helpers supplied these without modification.
- Pre-completion reviewer: WARN (no blocking issues).
Reviewer warnings: (1) `isRegisteredSubagentChild` accepts the full `ExtensionContext` but reads only `getSessionId()` — left as-is for ISP consistency with the sibling `isSubagentExecutionContext` in the same file; (2) `activateServiceForSession` both publishes and emits ready — left as one closure since the two are co-temporal (ready must follow publish) and live at the composition root.
## Stage: Final Retrospective (2026-06-01T18:10:44Z)
### Session summary
Shipped issue #302 end-to-end across three stages (plan → TDD → ship) with zero rework commits and zero CI failures.
The fix scopes the process-global `PermissionsService` slot to the publishing instance: publish defers to a child-gated `session_start`, `permissions:ready` moves alongside it, and `unpublishPermissionsService(service)` becomes an identity compare-and-delete.
Released as `pi-permission-system-v9.0.0` (major bump for the `feat!:` signature change).
### Observations
#### What went well
- The cross-issue `it.fails` handoff worked exactly as the #297 suite designed it: the `it.fails("DESIRED: the parent's service survives a child's shutdown")` characterization test planted by #297 flipped to a real passing assertion in this fix, validating test-driven continuity between sibling issues.
- One `ask_user` call in planning bundled the two genuinely-coupled design decisions (`permissions:ready` timing + teardown mechanism) and the maintainer's reply ("favor the breaking change if it makes a cleaner design") directly shaped the design toward a required param over a muddier optional one.
- Verification ran incrementally, not just at the end: each TDD step ran its affected test file (red then green) plus `pnpm run check`, with the full suite re-run after the shared-signature step (2) and the wiring step (3).
The runtime-breaking test (see friction) was caught by the post-step-3 full-suite run, not deferred to ship.
#### What caused friction (agent side)
- `missing-context` — the plan's Test Impact Analysis under-counted affected tests.
The planning grep keyed on specific test names in `composition-root.test.ts` rather than on the behavior "resolves the service right after the factory," so it missed `composition-root.test.ts` "service and gate share one formatter registry" and `permission-events.test.ts` "ready event wiring" — both assumed ready/publish-at-load.
Impact: two extra test updates folded into the `fix:` commit; no extra commits and no CI failures because the full-suite run caught them, but the plan's Test Impact Analysis was incomplete.
These break at runtime (full suite), not at typecheck, so `pnpm run check` would never have flagged them.
#### What caused friction (user side)
- None material.
The maintainer's breaking-change tolerance arrived at the right moment (the planning `ask_user`) and unblocked the cleaner design; stating that tolerance as a standing repo norm would have pre-empted the question, but that is a minor optimization, not friction.
### Diagnostic details
- **Model-performance correlation** — the only subagent dispatch was the `pre-completion-reviewer` on `anthropic/claude-sonnet-4-6`, a reasoning-capable model appropriate for judgment-heavy review (acceptance criteria, code design, doc staleness).
No mismatch.
- **Escalation-delay tracking** — no `rabbit-hole` friction points; no error sequence exceeded one tool call before resolution.
- **Unused-tool detection** — no tool gap.
The grep miss was a scope-of-query problem (symbol-name grep vs. behavioral grep), not a missing tool; `colgrep` for "tests that consume the published service" could have surfaced the two missed tests during planning.
- **Feedback-loop gap analysis** — no gap; verification was incremental (per-step affected file + `check`, full suite after the two highest-risk steps).
This is the intended loop, not a deferral.
### Changes made
1. `.pi/skills/testing/SKILL.md` — added a TDD-planning rule: when a change moves *when* a value/service becomes available (e.g. factory-init → `session_start`), grep all test files for consumers that resolve it, since the break is at runtime (full suite), not at typecheck.