20 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 302 | Child subagent shutdown unpublishes the parent's global PermissionsService |
Scope the global PermissionsService slot so a child cannot clobber the parent
Problem Statement
Every extension instance — the top-level parent and each in-process subagent child — runs the same factory in packages/pi-permission-system/src/index.ts.
That factory unconditionally publishes the PermissionsService to a single process-global slot (Symbol.for("@gotgenes/pi-permission-system:service")) at init and deletes that slot on session_shutdown.
Two defects follow.
First, a child's init overwrites the parent's published service, so mid-run getPermissionsService() resolves the child's service (different runtime/config) rather than the parent's.
Second, the child's session_shutdown deletes the slot entirely, so after the first subagent finishes a still-live parent (and any third-party consumer) gets undefined.
The fix must keep /reload working: the slot is intentionally overwrite-safe for reload today, so whichever path we take cannot regress re-publication of a reloaded parent's service.
Goals
- A live parent's
PermissionsServicesurvives an in-process child subagent's shutdown. - Mid-run,
getPermissionsService()resolves the parent's service, never a child's. - Teardown removes only the slot the instance actually owns.
/reloadstill re-publishes the reloaded parent's service (overwrite-safe), even if reload re-runs the factory.- Breaking:
unpublishPermissionsServicegains a requiredserviceparameter (identity compare-and-delete). The package's only public export isservice.ts, so this is a public API change →feat!:.
Non-Goals
- No change to process-based subagent extensions (env-hint / filesystem detection).
They run in their own OS process with their own
globalThis; each still publishes normally. - No change to the event-bus RPC fallback (
permissions:rpc:check/:prompt). Those handlers stay registered at init and remain available at load. - No change to the
SubagentSessionRegistrystorage, keying, or lifecycle-event subscription (#296, #298 already settled those). - No change to the
permissions:decisionevent or the gate/forwarding logic.
Background
Relevant modules:
src/index.ts— the composition root. ConstructspermissionsService, callspublishPermissionsService(...)andemitReadyEvent(pi.events)at init, and wires asession_shutdowncleanup closure that callsunpublishPermissionsService().src/service.ts— the public accessor (publishPermissionsService,getPermissionsService,unpublishPermissionsService, thePermissionsServiceinterface). The packageexportsmap points.at this file.src/subagent-context.ts—isSubagentExecutionContext(ctx, subagentSessionsDir, registry?). Branch 1 (registry by session id) is the only signal that identifies an in-process child; branches 2–3 (env, filesystem) identify process-based subagents.src/subagent-registry.ts— the process-globalSubagentSessionRegistry, populated by the parent'ssubagents:child:session-createdsubscription before the child'sbindExtensions().src/handlers/lifecycle.ts—SessionLifecycleHandler.handleSessionStart(event, ctx)is the first lifecycle event that carries actx(and therefore a session id).handleSessionShutdown()runs the injected cleanup closure.src/permission-events.ts—emitReadyEvent/PERMISSIONS_READY_CHANNEL, documented as "emitted once on extension load."
Key constraint discovered during investigation: the factory has no ctx at init, so it cannot read the child's session id and cannot consult the registry to know whether it is an in-process child.
FakePi / ExtensionAPI expose no session id to the factory body.
The earliest moment child-ness can be determined is session_start, where ctx.sessionManager.getSessionId() is available and the parent has already registered the child.
Constraint from AGENTS.md / the package skill: globalThis + Symbol.for() is the prescribed cross-extension channel; module-scoped singletons do not survive jiti isolation.
The fix stays on that channel.
The #297 composition-root suite (test/composition-root.test.ts) added a multi-instance global service interplay block that characterizes this exact bug: one test asserts the buggy undefined, and an it.fails("DESIRED: ...") asserts the fixed behavior.
Design Overview
Decision model
The publish decision moves from factory-init to session_start, gated on a precise in-process child check (registry only — not the full isSubagentExecutionContext).
Teardown becomes identity-scoped: an instance removes the slot only if it still holds that instance's own service object.
Why the registry-only gate (not isSubagentExecutionContext): the env-hint and filesystem branches identify process-based subagents, which run in a separate OS process with their own globalThis and should publish (they are the sole owner of their process's slot).
Only the registry branch identifies an in-process child sharing the parent's globalThis — the one case that must skip publishing.
Using the full detector would wrongly suppress publication inside a process-based subagent's own process.
Why publish at session_start rather than init: init has no ctx, so the in-process child cannot be distinguished from a reloaded parent at that point — both present as "slot already occupied."
The registry signal (the only reliable discriminator) needs a session id, which first appears at session_start.
Why compare-and-delete on teardown: if /reload re-runs the factory, the old instance's session_shutdown can fire after the new instance's session_start re-publish.
An unconditional delete (or a didPublish boolean) would wipe the new generation's service.
Comparing object identity makes teardown safe regardless of shutdown/init ordering, and also makes a child's shutdown a no-op (the child never owned the slot).
Lifecycle trace (multi-instance, shared globalThis)
parent init → construct adapter A (no publish, no ready yet)
parent session_start → not in registry → publish(A); emit ready slot = A
child init → construct adapter B (no publish, no ready yet)
child session_start → in registry → skip publish; emit ready slot = A
child session_shutdown → unpublish(B): current(A) !== B → no-op slot = A
parent still live → getPermissionsService() === A ✓
Reload trace (factory re-runs for the top-level session)
new parent init → construct adapter A' (no publish)
new parent session_start → not in registry → publish(A') slot = A'
old parent session_shutdown → unpublish(A): current(A') !== A → no-op slot = A'
(If the old shutdown instead fires first: unpublish(A) deletes A, then the new session_start publishes A'.
Either order ends at A'.)
New / changed shapes
unpublishPermissionsService becomes identity-scoped (breaking signature):
/**
* Remove the service from globalThis, but only when the current slot still
* holds `service`. A child instance (which never published) and a superseded
* reload generation are therefore no-ops.
*/
export function unpublishPermissionsService(service: PermissionsService): void {
if (getPermissionsService() === service) {
delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
}
}
New exported helper in subagent-context.ts (extracted from branch 1, returns a value, reused by two call sites):
export function isRegisteredSubagentChild(
ctx: ExtensionContext,
registry: SubagentSessionRegistry,
): boolean {
try {
const sessionId = ctx.sessionManager.getSessionId();
if (!sessionId) {
return false;
}
return registry.has(sessionId);
} catch {
return false; // getSessionId() unavailable → treat as not-a-registered-child
}
}
Composition-root wiring in index.ts (the new collaborator passed into the lifecycle handler):
const activateServiceForSession = (ctx: ExtensionContext): void => {
if (!isRegisteredSubagentChild(ctx, subagentRegistry)) {
publishPermissionsService(permissionsService);
}
emitReadyEvent(pi.events);
};
// ...
const lifecycle = new SessionLifecycleHandler(session, activateServiceForSession, () => {
rpcHandles.unsubCheck();
rpcHandles.unsubPrompt();
unsubSubagentLifecycle();
unpublishPermissionsService(permissionsService);
});
SessionLifecycleHandler.handleSessionStart calls this.activateService(ctx) at the end of its body (after config refresh / reset, so the published adapter sees current runtime state).
This is a Tell-Don't-Ask seam: the handler announces "session started"; the composition root decides whether to publish.
Dependency width of the handler goes from 2 → 3 constructor deps — within the design-review threshold.
permissions:ready semantics (confirmed with maintainer)
emitReadyEvent moves from init to session_start (emitted after the gated publish).
This preserves the #297 ordering contract — a consumer reacting to permissions:ready can still resolve the service immediately — at the cost of changing the event's timing from "once on load" to "once per session_start."
For an in-process child, ready still fires at its session_start, and getPermissionsService() then resolves the parent's service (already published), so the contract holds for children too.
Edge cases
- No session id (
getSessionId()throws or returns empty) →isRegisteredSubagentChildreturnsfalse→ instance publishes. Safe default for top-level / process-based contexts. - Process-based subagent (env hint / filesystem) → not in registry → publishes into its own process's slot. Unchanged from today.
/reload(session_startreason"reload") →activateServiceForSessionruns again, re-publishing the same adapter (idempotent) and re-emittingready. Acceptable under the new per-session_startsemantics.- Concurrent sibling children → none publishes; the slot stays the parent's throughout. (The unsound stash/restore alternative was rejected for exactly this case.)
Module-Level Changes
| File | Change |
|---|---|
src/service.ts |
unpublishPermissionsService(service) → required param, identity compare-and-delete. Update doc comments (drop "overwrites … safe for /reload" framing on publish; describe identity-scoped delete). |
src/subagent-context.ts |
Add exported isRegisteredSubagentChild(ctx, registry); refactor isSubagentExecutionContext branch 1 to call it. |
src/handlers/lifecycle.ts |
SessionLifecycleHandler gains a third constructor dep activateService: (ctx) => void; handleSessionStart calls it after refresh/reset. |
src/index.ts |
Remove init-time publishPermissionsService(...) and emitReadyEvent(...). Add activateServiceForSession, pass it to SessionLifecycleHandler. Change cleanup closure to unpublishPermissionsService(permissionsService). Swap the isSubagentExecutionContext import usage as needed (the existing canRequestPermissionConfirmation call still uses the full detector). |
src/permission-events.ts |
Update PERMISSIONS_READY_CHANNEL / emitReadyEvent doc comments: emitted at session_start, not load. |
docs/cross-extension-api.md |
Update: service published at session_start (not startup); permissions:ready timing in the events table and the "Ready Event" section; the reload note; the "publishes a fresh service on re-initialization" note. Add a short note that an in-process child does not publish and that getPermissionsService() resolves the parent's service. |
docs/architecture/architecture.md |
Line ~457: "publishes … during startup" → at session_start, gated for in-process children. |
Grep sweep confirmed no other src/ consumers of unpublishPermissionsService (sole caller is the index.ts cleanup closure) and no other emitReadyEvent caller.
The package skill (.pi/skills/package-pi-permission-system/SKILL.md) does not document publish-at-load / ready-at-load specifics, so no skill edit is required (re-grep before finalizing the docs commit).
Test Impact Analysis
- New tests the change enables.
service.test.ts: identity compare-and-delete —unpublish(A)clears only when the slot isA;unpublish(B)while slot isAis a no-op.subagent-context.test.ts:isRegisteredSubagentChildunit coverage (registered hit, miss, empty/absent session id,getSessionIdthrow).composition-root.test.ts: theit.fails("DESIRED: the parent's service survives a child's shutdown")flips to a real pass; a new assertion that mid-rungetPermissionsService()resolves the parent's adapter, not the child's.
- Tests that become redundant.
composition-root.test.ts: theit("currently leaves the parent without a resolvable service after the child shuts down")characterization test asserts the bug and must be removed (its premise is now false).
- Tests that stay but need wiring updates (not redundant — they exercise the moved seam).
composition-root.test.ts"ready emitted after service publication" and "shutdown teardown chain": must now firesession_start(publish/ready moved off init). Their contracts are unchanged.handlers/lifecycle.test.ts: thenew SessionLifecycleHandler(session, cleanupRpc)instantiation gains theactivateServicestub; add a case assertinghandleSessionStartinvokes it.service.test.tsafterEachand the existing no-argunpublishPermissionsService()calls: update to pass a service (e.g.afterEachclears viaconst s = getPermissionsService(); if (s) unpublishPermissionsService(s);).
TDD Order
-
refactor: extract isRegisteredSubagentChild from subagent detector(test:+refactor:) Red: addsubagent-context.test.tscases forisRegisteredSubagentChild(hit / miss / empty id / throw). Green: add the exported helper and rewireisSubagentExecutionContextbranch 1 to call it. ExistingisSubagentExecutionContexttests must stay green (behavior unchanged). Commit:refactor: extract isRegisteredSubagentChild seam (#302). -
feat!: identity-scoped unpublishPermissionsServiceThis step removes the no-arg signature, so the solesrc/caller (index.tscleanup closure) and allservice.test.tsno-arg calls break at the type level — fold them into this one commit. Red: addservice.test.tscases —unpublish(A)clears only when slot isA;unpublish(B)while slot isAis a no-op; updateafterEachand existing no-arg calls to pass a service. Green: changeunpublishPermissionsService(service)to compare-and-delete; update theindex.tscleanup closure to passpermissionsService. Commit:feat!: scope service teardown to the publishing instance (#302). -
fix: publish the service at session_start, gated for in-process childrenThis is the bug fix and the user-visible behavior change. Adding theactivateServicedep toSessionLifecycleHandlerbreaks its sole production instantiation and thehandlers/lifecycle.test.tsinstantiation at the type level — fold those into this commit. Red: updatecomposition-root.test.ts— remove the bug-characterization test; convertit.fails(...)to a passing test; firesession_start(with a non-child parent ctx and a registry-registered child ctx) in the multi-instance helper, the teardown-chain test, and the ready-ordering test; add the "mid-run resolves the parent's adapter" assertion. Updatehandlers/lifecycle.test.tsinstantiation + add an "invokes activateService on session_start" case. Green: inindex.ts, remove init-time publish +emitReadyEvent, addactivateServiceForSession, pass it intoSessionLifecycleHandler; inlifecycle.ts, accept and invokeactivateServiceinhandleSessionStart. Commit:fix: keep the parent's service published across child shutdown (#302). -
docs: align service publication and ready-event timingUpdateservice.ts/permission-events.tsdoc comments,docs/cross-extension-api.md(events table, Ready Event section, reload + re-initialization notes, in-process child note), anddocs/architecture/architecture.md. Re-grep the package skill before committing; include it only if it documents the old timing. Commit:docs: document session_start service publication and ready timing (#302).
Risks and Mitigations
- Risk: an in-process child never fires
session_start, so it never reaches the gate. Mitigation: that is the desired outcome — the child simply never publishes; the parent's slot is untouched. The parent always firessession_start, so it always publishes. - Risk: a load-time consumer that resolved the service at extension load now sees
undefineduntilsession_start. Mitigation: the documented best practice is to resolve per-use, andpermissions:ready(the documented readiness signal) moves tosession_startalongside the publish, so aready-driven consumer is unaffected. Called out in the docs commit. - Risk:
unpublishPermissionsServicesignature change breaks an external caller. Mitigation: it is a provider-side lifecycle function; external consumers use onlygetPermissionsService(). Shipped asfeat!:so release-please bumps major and the change is visible in the changelog. - Risk: using the wrong detector (full
isSubagentExecutionContext) would suppress publication inside a process-based subagent's own process. Mitigation: the gate deliberately uses the registry-onlyisRegisteredSubagentChild; the distinction is documented in Design Overview and covered by the helper's unit tests.
Open Questions
- Should
permissions:readybe emitted at most once per instance (guarded) rather than on everysession_startincluding reloads? Deferred: re-emitting on reload is harmless and arguably correct ("re-readied for this session"); revisit only if a consumer reports duplicate-readychurn.