Files
my-pi/pi-permission-system/docs/plans/0365-cache-key-gate.md
T

13 KiB
Raw Blame History

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 CacheKeyGate class owning a previous key and exposing runIfChanged(nextKey, effect) plus reset().
  • Replace PermissionSession's four cache methods and two string | null fields with two CacheKeyGate sub-objects exposed as readonly properties.
  • Collapse AgentPrepHandler's two ask-then-tell pairs into single gate.runIfChanged(key, effect) tells.
  • Remove the dead-in-production shouldApplyCachedAgentStartState (and the test assertions that keep it alive), folding its comparison into CacheKeyGate.
  • 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 contentcreateActiveToolsCacheKey and createBeforeAgentStartPromptStateKey (the key builders in before-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.tsPermissionSession owns mutable session state. Lines 4142 declare toolsCacheKey / promptCacheKey; lines 122135 declare the four anemic methods; resetForNewSession, shutdown, and reload each set both fields to null.
  • src/handlers/before-agent-start.tsAgentPrepHandler.handle builds the two cache keys, then runs the two ask-then-tell pairs (lines 7695).
  • src/before-agent-start-cache.ts — the key builders plus the dead shouldApplyCachedAgentStartState.

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-await is enabled for src/; handle keeps its existing // eslint-disable-next-line @typescript-eslint/require-await since it stays async with no await.
  • When a roadmap step ships, mark it complete in docs/architecture/architecture.md as 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, commits nextKey, returns the effect's value.
  • On an unchanged key: skips effect, returns undefined.
  • 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.
  • setActive is still invoked at most once per distinct allowed-tools set across repeated events.

Module-Level Changes

  • src/cache-key-gate.ts — new file; exports CacheKeyGate.
  • src/permission-session.ts — remove toolsCacheKey / promptCacheKey fields and the four methods (shouldUpdateActiveTools, commitActiveToolsCacheKey, shouldUpdatePromptState, commitPromptStateCacheKey); add readonly activeToolsGate / readonly promptStateGate; update the three reset sites; import CacheKeyGate from #src/cache-key-gate.
  • src/handlers/before-agent-start.ts — replace the two ask-then-tell blocks with runIfChanged tells.
  • src/before-agent-start-cache.ts — remove shouldApplyCachedAgentStartState; keep both key builders.
  • test/cache-key-gate.test.ts — new unit test for CacheKeyGate.
  • test/permission-session.test.ts — remove the cache key methods describe block (now covered by CacheKeyGate's test); rewrite the resetForNewSession / shutdown / reload "clears cache keys" assertions to drive the gates via runIfChanged.
  • test/handlers/before-agent-start.test.ts — rewrite the four tests that spy on commit* / mock shouldUpdate* to drive real gate behavior (e.g. call handle twice with identical inputs and assert setActive runs once / the second result is {}).
  • test/before-agent-start-cache.test.ts — remove the shouldApplyCachedAgentStartState import and the dedupe test; in the permission-change test, replace the shouldApplyCachedAgentStartState(baselineKey, invalidatedKey) assertion with a direct key comparison (expect(invalidatedKey).not.toBe(baselineKey)).
  • docs/architecture/architecture.md — append ✓ complete to 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

  1. New tests enabled by the extraction. CacheKeyGate gets a focused unit test for behavior that was previously only reachable through the session's anemic methods or the free function: runIfChanged runs and returns on a first/changed key, skips and returns undefined on an unchanged key, and reset() re-arms the gate.

  2. Tests that become redundant. The cache key methods describe block in permission-session.test.ts (five tests exercising shouldUpdate* / commit* directly) is superseded by the CacheKeyGate unit test — remove it. The dedupes unchanged active-tool exposure and prompt state test in before-agent-start-cache.test.ts exercises only shouldApplyCachedAgentStartState — remove it.

  3. 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 (setActive called/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 in before-agent-start-cache.test.ts genuinely exercises key invalidation on a policy-stamp change — keep it, asserting key inequality directly.

TDD Order

  1. Add CacheKeyGate (new module + unit test). Surface: test/cache-key-gate.test.ts against src/cache-key-gate.ts. Covers: runs effect and returns its value on a new/changed key; skips effect and returns undefined on an unchanged key; reset() re-arms so the same key runs again. Commit: feat: add CacheKeyGate for agent-start cache keys (#365).

  2. Migrate PermissionSession + AgentPrepHandler + their tests to CacheKeyGate. 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 two readonly gates; update the three reset sites; rewrite the handler call sites; rewrite the affected tests per the Module-Level Changes list. Run pnpm run check immediately after this commit (shared-surface change). Commit: refactor: encapsulate agent-start cache keys in CacheKeyGate (#365).

  3. 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 with pnpm fallow dead-code that the export count stays at 0% dead. Commit: refactor: remove test-only shouldApplyCachedAgentStartState (#365).

  4. Mark the roadmap step complete. Surface: docs/architecture/architecture.md. Append ✓ complete to 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 repeated handle calls; 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 shouldApplyCachedAgentStartState are referenced only in the files listed above (no SKILL.md, no composition-root test); step 3 runs fallow to confirm 0% dead code.

Open Questions

  • Track A steps (#362#364) shipped but were not individually marked ✓ complete in docs/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.