13 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 365 | Encapsulate agent-start cache keys in a `CacheKeyGate` class |
Encapsulate agent-start cache keys in a CacheKeyGate class
Problem Statement
PermissionSession exposes four anemic methods over two private string | null fields (shouldUpdateActiveTools / commitActiveToolsCacheKey / shouldUpdatePromptState / commitPromptStateCacheKey).
AgentPrepHandler drives each pair via ask-then-tell: it asks "should I update?", performs the effect, then tells "commit the key".
The same prev !== next comparison lives in three places — the session's inline !==, the handler's orchestration, and the free function shouldApplyCachedAgentStartState in before-agent-start-cache.ts, which has no production caller and is kept alive only by its own test (so fallow's 0%-dead-exports check misses it).
This is Phase 5, Track B, Step 4 of the pi-permission-system improvement roadmap (docs/architecture/architecture.md).
The goal is to fold the comparison into a single cohesive CacheKeyGate class that owns a previous key and exposes one Tell — runIfChanged(nextKey, effect).
Goals
- Introduce a
CacheKeyGateclass owning a previous key and exposingrunIfChanged(nextKey, effect)plusreset(). - Replace
PermissionSession's four cache methods and twostring | nullfields with twoCacheKeyGatesub-objects exposed asreadonlyproperties. - Collapse
AgentPrepHandler's two ask-then-tell pairs into singlegate.runIfChanged(key, effect)tells. - Remove the dead-in-production
shouldApplyCachedAgentStartState(and the test assertions that keep it alive), folding its comparison intoCacheKeyGate. - Keep
fallow's dead-export and dead-file metrics at 0%.
This change is not breaking: it is an internal encapsulation refactor with no change to config, schema, the /permission-system command, or observable agent-facing behavior in normal operation.
Non-Goals
- Track A (
#362–#364, logger state + composition-root coupling) — closed and shipped; no merge coordination is needed. - Track C (
#366,#367) and Track D (#368) — independent steps, out of scope. - Changing the cache-key content —
createActiveToolsCacheKeyandcreateBeforeAgentStartPromptStateKey(the key builders inbefore-agent-start-cache.ts) are unchanged; only the comparison helper is removed. - Changing
before-agent-start.ts's tool-filtering loop or skill-prompt sanitization logic.
Background
Relevant modules:
src/permission-session.ts—PermissionSessionowns mutable session state. Lines 41–42 declaretoolsCacheKey/promptCacheKey; lines 122–135 declare the four anemic methods;resetForNewSession,shutdown, andreloadeach set both fields tonull.src/handlers/before-agent-start.ts—AgentPrepHandler.handlebuilds the two cache keys, then runs the two ask-then-tell pairs (lines 76–95).src/before-agent-start-cache.ts— the key builders plus the deadshouldApplyCachedAgentStartState.
Current consumers of the four session methods (verified by grep): only src/handlers/before-agent-start.ts, test/permission-session.test.ts, and test/handlers/before-agent-start.test.ts.
Current consumers of shouldApplyCachedAgentStartState: only test/before-agent-start-cache.test.ts.
No .pi/skills/package-*/SKILL.md references any of these symbols.
Constraints from AGENTS.md / package skill:
- A new exported class warrants its own unit test (
code-design: extract helpers into a module with its own public API once they warrant tests). @typescript-eslint/require-awaitis enabled forsrc/;handlekeeps its existing// eslint-disable-next-line @typescript-eslint/require-awaitsince it staysasyncwith noawait.- When a roadmap step ships, mark it complete in
docs/architecture/architecture.mdas part of the shipping change.
Design Overview
CacheKeyGate
A standalone, dependency-free class in a new module src/cache-key-gate.ts:
export class CacheKeyGate {
private previousKey: string | null = null;
runIfChanged<T>(nextKey: string, effect: () => T): T | undefined {
if (this.previousKey === nextKey) {
return undefined;
}
const result = effect();
this.previousKey = nextKey;
return result;
}
reset(): void {
this.previousKey = null;
}
}
Semantics:
- On a changed key: runs
effect, commitsnextKey, returns the effect's value. - On an unchanged key: skips
effect, returnsundefined. reset()re-arms the gate so the next key is treated as changed (used by the session lifecycle).
Commit ordering is run-then-commit: the key is committed only after the effect returns.
This unifies the two paths (the tools path already committed after setActive; the prompt path previously committed before the sanitization work).
The only observable difference is on the pathological path where the effect throws — the key is then left uncommitted and the next before_agent_start event retries, which is strictly safer (no poisoned cache).
In normal (non-throwing) operation the behavior is identical.
PermissionSession
Replace the two fields and four methods with two exposed gates:
readonly activeToolsGate = new CacheKeyGate();
readonly promptStateGate = new CacheKeyGate();
The three lifecycle methods (resetForNewSession, shutdown, reload) replace this.toolsCacheKey = null; this.promptCacheKey = null; with this.activeToolsGate.reset(); this.promptStateGate.reset();.
The gates are exposed as readonly properties (per the resolved design decision) rather than wrapped in delegating methods.
The gate is a cohesive behavior object the session owns; the handler sends it a single Tell (runIfChanged), so this is Tell-Don't-Ask at the gate boundary, not a reach-through to a stranger.
This hits the roadmap's "0 anemic cache accessors / 2 owned CacheKeyGate sub-objects" target.
AgentPrepHandler call sites
The two ask-then-tell pairs become single tells:
this.session.activeToolsGate.runIfChanged(activeToolsCacheKey, () => {
this.toolRegistry.setActive(allowedTools);
});
const promptResult = this.session.promptStateGate.runIfChanged(
promptStateCacheKey,
() => {
const toolPromptResult = sanitizeAvailableToolsSection(
event.systemPrompt,
allowedTools,
);
const skillPromptResult = resolveSkillPromptEntries(
toolPromptResult.prompt,
this.resolver,
agentName,
ctx.cwd,
);
this.session.setActiveSkillEntries(skillPromptResult.entries);
return skillPromptResult.prompt !== event.systemPrompt
? { systemPrompt: skillPromptResult.prompt }
: {};
},
);
return promptResult ?? {};
The effect's return type T is BeforeAgentStartEventResult; runIfChanged returns BeforeAgentStartEventResult | undefined, and ?? {} reproduces the old early-return-{} behavior when the prompt cache is unchanged.
Edge cases preserved
- Unchanged prompt key: effect skipped, skill entries untouched, returns
{}— same as the old early return. - Changed prompt key whose sanitized prompt equals the original: effect runs (commits key, sets skill entries), returns
{}— same as before. setActiveis still invoked at most once per distinct allowed-tools set across repeated events.
Module-Level Changes
src/cache-key-gate.ts— new file; exportsCacheKeyGate.src/permission-session.ts— removetoolsCacheKey/promptCacheKeyfields and the four methods (shouldUpdateActiveTools,commitActiveToolsCacheKey,shouldUpdatePromptState,commitPromptStateCacheKey); addreadonly activeToolsGate/readonly promptStateGate; update the three reset sites; importCacheKeyGatefrom#src/cache-key-gate.src/handlers/before-agent-start.ts— replace the two ask-then-tell blocks withrunIfChangedtells.src/before-agent-start-cache.ts— removeshouldApplyCachedAgentStartState; keep both key builders.test/cache-key-gate.test.ts— new unit test forCacheKeyGate.test/permission-session.test.ts— remove thecache key methodsdescribe block (now covered byCacheKeyGate's test); rewrite theresetForNewSession/shutdown/reload"clears cache keys" assertions to drive the gates viarunIfChanged.test/handlers/before-agent-start.test.ts— rewrite the four tests that spy oncommit*/ mockshouldUpdate*to drive real gate behavior (e.g. callhandletwice with identical inputs and assertsetActiveruns once / the second result is{}).test/before-agent-start-cache.test.ts— remove theshouldApplyCachedAgentStartStateimport and the dedupe test; in the permission-change test, replace theshouldApplyCachedAgentStartState(baselineKey, invalidatedKey)assertion with a direct key comparison (expect(invalidatedKey).not.toBe(baselineKey)).docs/architecture/architecture.md— append✓ completeto the Step 4 line (Phase 5, Track B) as part of shipping.
No docs/architecture/ complexity tables, layout listings, or domain diagrams reference the removed symbols beyond the roadmap step line itself.
Test Impact Analysis
-
New tests enabled by the extraction.
CacheKeyGategets a focused unit test for behavior that was previously only reachable through the session's anemic methods or the free function:runIfChangedruns and returns on a first/changed key, skips and returnsundefinedon an unchanged key, andreset()re-arms the gate. -
Tests that become redundant. The
cache key methodsdescribe block inpermission-session.test.ts(five tests exercisingshouldUpdate*/commit*directly) is superseded by theCacheKeyGateunit test — remove it. Thededupes unchanged active-tool exposure and prompt statetest inbefore-agent-start-cache.test.tsexercises onlyshouldApplyCachedAgentStartState— remove it. -
Tests that must stay (rewritten, not deleted). The
resetForNewSession/shutdown/reload"clears cache keys" tests genuinely exercise session-lifecycle re-arming of the gates — keep them, asserting via the exposed gates. The handler behavior tests (setActivecalled/skipped, returns{}vs{ systemPrompt }) genuinely exercise the handler's dedupe orchestration — keep them, driving real gate state instead of mocking the removed methods. The permission-change test inbefore-agent-start-cache.test.tsgenuinely exercises key invalidation on a policy-stamp change — keep it, asserting key inequality directly.
TDD Order
-
Add
CacheKeyGate(new module + unit test). Surface:test/cache-key-gate.test.tsagainstsrc/cache-key-gate.ts. Covers: runseffectand returns its value on a new/changed key; skipseffectand returnsundefinedon an unchanged key;reset()re-arms so the same key runs again. Commit:feat: add CacheKeyGate for agent-start cache keys (#365). -
Migrate
PermissionSession+AgentPrepHandler+ their tests toCacheKeyGate. Surface:src/permission-session.ts,src/handlers/before-agent-start.ts,test/permission-session.test.ts,test/handlers/before-agent-start.test.ts. This is one step: removing the four methods breaks the handler and both test files at the type/behavior level simultaneously, so the extraction, all consumer updates, and all consumer-test updates land together. Remove the two fields + four methods; add the tworeadonlygates; update the three reset sites; rewrite the handler call sites; rewrite the affected tests per the Module-Level Changes list. Runpnpm run checkimmediately after this commit (shared-surface change). Commit:refactor: encapsulate agent-start cache keys in CacheKeyGate (#365). -
Remove the dead-in-production
shouldApplyCachedAgentStartState. Surface:src/before-agent-start-cache.ts,test/before-agent-start-cache.test.ts. Remove the function and its test references (drop the dedupe test; convert the permission-change assertion to a direct key comparison). Verify withpnpm fallow dead-codethat the export count stays at 0% dead. Commit:refactor: remove test-only shouldApplyCachedAgentStartState (#365). -
Mark the roadmap step complete. Surface:
docs/architecture/architecture.md. Append✓ completeto the Phase 5 Track B Step 4 line. Commit:docs: mark Phase 5 Step 4 complete (#365).
Risks and Mitigations
- Risk: the prompt path's commit ordering shifts from commit-then-run to run-then-commit. Mitigation: observable only when the effect throws (then the key is retried — strictly safer); normal operation is identical. No existing test asserts the throw-path ordering.
- Risk: handler tests currently mock the removed methods (
vi.spyOn(session, "shouldUpdatePromptState")). Mitigation: rewrite them to drive real gate state via repeatedhandlecalls; the handler tests already use a real session (makeRealSession), so the real gates are present. - Risk: removing an export could leave a dangling reference.
Mitigation: grep confirmed the four methods and
shouldApplyCachedAgentStartStateare referenced only in the files listed above (no SKILL.md, no composition-root test); step 3 runsfallowto confirm 0% dead code.
Open Questions
- Track A steps (
#362–#364) shipped but were not individually marked✓ completeindocs/architecture/architecture.md. Step 4 of this plan marks Step 4 complete per the package-skill convention; whether to back-fill the Track A markers is out of scope here.