Files

210 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
issue: 365
issue_title: "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 *content*`createActiveToolsCacheKey` 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.ts``PermissionSession` 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.ts``AgentPrepHandler.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`:
```typescript
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:
```typescript
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:
```typescript
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.