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

9.1 KiB

issue, issue_title
issue issue_title
302 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.