mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
225 lines
14 KiB
Markdown
225 lines
14 KiB
Markdown
---
|
||
issue: 128
|
||
issue_title: "refactor: extract ForwardingManager class to encapsulate polling lifecycle"
|
||
---
|
||
|
||
# Extract ForwardingManager class
|
||
|
||
## Problem statement
|
||
|
||
Forwarding poll lifecycle is spread across 3 mutable fields on `ExtensionRuntime` (`permissionForwardingTimer`, `permissionForwardingContext`, `isProcessingForwardedRequests`), 2 free functions (`startForwardedPermissionPolling`, `stopForwardedPermissionPolling` in `runtime.ts`), and raw `PermissionForwardingDeps`.
|
||
Handlers call `deps.startForwardedPermissionPolling(ctx)` and `deps.stopForwardedPermissionPolling()` as opaque callbacks without understanding the lifecycle.
|
||
This is the third extraction in the handler decomposition series (after #126 ExtensionPaths, #127 SessionLogger).
|
||
|
||
## Goals
|
||
|
||
- Extract a `ForwardingManager` class that owns the timer, context, and processing-lock state.
|
||
- Remove `permissionForwardingTimer`, `permissionForwardingContext`, `isProcessingForwardedRequests` from `ExtensionRuntime`.
|
||
- Remove `startForwardedPermissionPolling` and `stopForwardedPermissionPolling` free functions from `runtime.ts`.
|
||
- Replace `startForwardedPermissionPolling` / `stopForwardedPermissionPolling` in `HandlerDeps` with a `forwarding: ForwardingManager` dep (or equivalent narrow interface).
|
||
- No behavioral change — same polling logic, same timer intervals, same subagent-context detection.
|
||
|
||
## Non-goals
|
||
|
||
- Changing `PermissionForwardingDeps` shape or polling logic.
|
||
- Extracting `PermissionSession` (#129) — that depends on this issue.
|
||
- Changing the `/permission-system` slash command or config format.
|
||
- Refactoring `PermissionPrompter` internals (it builds its own `PermissionForwardingDeps` internally).
|
||
|
||
## Background
|
||
|
||
### Permission surface
|
||
|
||
This is an infrastructure refactoring — no permission surface semantics change.
|
||
Forwarding is the mechanism by which subagent permission prompts are relayed to the parent agent's UI.
|
||
|
||
### Dependencies
|
||
|
||
- **#126 ExtensionPaths** — closed, implemented. `ForwardingManager` constructor takes `ExtensionPaths` (needs `subagentSessionsDir` for the subagent-context check).
|
||
- **#127 SessionLogger** — closed, implemented.
|
||
Not directly consumed by `ForwardingManager` (logging goes through `PermissionForwardingDeps`).
|
||
|
||
### Current layout
|
||
|
||
| Artifact | Location |
|
||
| -------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||
| 3 mutable fields | `ExtensionRuntime` interface in `src/runtime.ts` |
|
||
| `startForwardedPermissionPolling()` | Free function in `src/runtime.ts` (~30 lines) |
|
||
| `stopForwardedPermissionPolling()` | Free function in `src/runtime.ts` (~10 lines) |
|
||
| `PermissionForwardingDeps` | `src/forwarded-permissions/polling.ts` |
|
||
| `isSubagentExecutionContext()` | `src/subagent-context.ts` (called inside `start`) |
|
||
| `processForwardedPermissionRequests()` | `src/forwarded-permissions/polling.ts` |
|
||
| `HandlerDeps` forwarding fields | `startForwardedPermissionPolling`, `stopForwardedPermissionPolling` in `src/handlers/types.ts` |
|
||
| Handler call sites | `before-agent-start.ts`, `input.ts`, `tool-call.ts`, `lifecycle.ts` |
|
||
| Composition root wiring | `src/index.ts` lines ~46–108 |
|
||
| Runtime init (3 fields) | `createExtensionRuntime()` in `src/runtime.ts` |
|
||
|
||
## Design overview
|
||
|
||
### ForwardingManager class
|
||
|
||
```typescript
|
||
// src/forwarding-manager.ts
|
||
|
||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||
import type { PermissionForwardingDeps } from "./forwarded-permissions/polling";
|
||
|
||
export class ForwardingManager {
|
||
private timer: NodeJS.Timeout | null = null;
|
||
private context: ExtensionContext | null = null;
|
||
private processing = false;
|
||
|
||
constructor(
|
||
private readonly subagentSessionsDir: string,
|
||
private readonly forwardingDeps: PermissionForwardingDeps,
|
||
) {}
|
||
|
||
/** Start polling if ctx has UI and is not a subagent. No-op if already running. */
|
||
start(ctx: ExtensionContext): void { /* moved from runtime.ts */ }
|
||
|
||
/** Stop polling and clear state. */
|
||
stop(): void { /* moved from runtime.ts */ }
|
||
}
|
||
```
|
||
|
||
The constructor takes `subagentSessionsDir` (from `ExtensionPaths`) rather than the full `ExtensionPaths` object — it is the only path field used by the start/stop logic.
|
||
`PermissionForwardingDeps` is passed at construction time, same as currently wired in `index.ts`.
|
||
|
||
### HandlerDeps change
|
||
|
||
```typescript
|
||
// In src/handlers/types.ts — replace two methods with one dep:
|
||
|
||
// Before:
|
||
startForwardedPermissionPolling(ctx: ExtensionContext): void;
|
||
stopForwardedPermissionPolling(): void;
|
||
|
||
// After:
|
||
readonly forwarding: ForwardingManager;
|
||
```
|
||
|
||
Handler call sites change from `deps.startForwardedPermissionPolling(ctx)` → `deps.forwarding.start(ctx)` and `deps.stopForwardedPermissionPolling()` → `deps.forwarding.stop()`.
|
||
|
||
### ExtensionRuntime slimming
|
||
|
||
Remove from `ExtensionRuntime`:
|
||
|
||
- `permissionForwardingContext: ExtensionContext | null`
|
||
- `permissionForwardingTimer: NodeJS.Timeout | null`
|
||
- `isProcessingForwardedRequests: boolean`
|
||
|
||
Remove from `createExtensionRuntime()` the three field initializations.
|
||
|
||
### Composition root (index.ts) change
|
||
|
||
Replace the `forwardingDeps` construction + two closure wrappers with:
|
||
|
||
```typescript
|
||
const forwardingManager = new ForwardingManager(
|
||
runtime.subagentSessionsDir,
|
||
forwardingDeps,
|
||
);
|
||
```
|
||
|
||
And in the `deps` object: `forwarding: forwardingManager`.
|
||
|
||
## Module-level changes
|
||
|
||
### New files
|
||
|
||
1. **`src/forwarding-manager.ts`** — `ForwardingManager` class.
|
||
Moves `startForwardedPermissionPolling` and `stopForwardedPermissionPolling` logic from `runtime.ts`.
|
||
Imports `isSubagentExecutionContext`, `processForwardedPermissionRequests`, `PERMISSION_FORWARDING_POLL_INTERVAL_MS`.
|
||
2. **`tests/forwarding-manager.test.ts`** — Unit tests for `ForwardingManager.start()` and `.stop()`.
|
||
|
||
### Changed files
|
||
|
||
1. **`src/runtime.ts`**
|
||
- Remove `permissionForwardingContext`, `permissionForwardingTimer`, `isProcessingForwardedRequests` from `ExtensionRuntime` interface.
|
||
- Remove `startForwardedPermissionPolling()` and `stopForwardedPermissionPolling()` free functions.
|
||
- Remove the three field initializations from `createExtensionRuntime()`.
|
||
- Remove `PermissionForwardingDeps` import (if no longer needed).
|
||
2. **`src/handlers/types.ts`**
|
||
- Replace `startForwardedPermissionPolling(ctx)` and `stopForwardedPermissionPolling()` with `readonly forwarding: ForwardingManager`.
|
||
- Add `ForwardingManager` import.
|
||
3. **`src/index.ts`**
|
||
- Import `ForwardingManager`.
|
||
- Construct `ForwardingManager` instance.
|
||
- Replace `startForwardedPermissionPolling` / `stopForwardedPermissionPolling` closures in `deps` with `forwarding: forwardingManager`.
|
||
4. **`src/handlers/before-agent-start.ts`** — `deps.startForwardedPermissionPolling(ctx)` → `deps.forwarding.start(ctx)`.
|
||
5. **`src/handlers/input.ts`** — Same call-site update.
|
||
6. **`src/handlers/tool-call.ts`** — Same call-site update.
|
||
7. **`src/handlers/lifecycle.ts`** — `deps.startForwardedPermissionPolling(ctx)` → `deps.forwarding.start(ctx)`, `deps.stopForwardedPermissionPolling()` → `deps.forwarding.stop()`.
|
||
|
||
### Changed test files
|
||
|
||
1. **`tests/runtime.test.ts`** — Remove the 3 tests asserting initial `null`/`false` values for the removed fields.
|
||
2. **`tests/handlers/before-agent-start.test.ts`** — Replace `startForwardedPermissionPolling: vi.fn()` / `stopForwardedPermissionPolling: vi.fn()` with `forwarding: { start: vi.fn(), stop: vi.fn() }`.
|
||
Update assertion from `deps.startForwardedPermissionPolling` to `deps.forwarding.start`.
|
||
3. **`tests/handlers/input.test.ts`** — Same mock shape update + assertion update.
|
||
4. **`tests/handlers/input-events.test.ts`** — Same mock shape update (no assertions on these mocks).
|
||
5. **`tests/handlers/tool-call.test.ts`** — Same mock shape + assertion update.
|
||
6. **`tests/handlers/tool-call-events.test.ts`** — Same mock shape update.
|
||
7. **`tests/handlers/lifecycle.test.ts`** — Same mock shape + both start/stop assertion updates.
|
||
|
||
### Unchanged files
|
||
|
||
- **`src/forwarded-permissions/polling.ts`** — `PermissionForwardingDeps`, `processForwardedPermissionRequests` stay as-is.
|
||
- **`src/permission-prompter.ts`** — Builds its own `PermissionForwardingDeps` internally; no dependency on `ForwardingManager`.
|
||
- **`tests/permission-system.test.ts`** — Integration test; calls `piPermissionSystemExtension(mockPi)` and never sees handler internals.
|
||
- **`docs/architecture/architecture.md`** — Does not describe forwarding internals in detail; no update needed unless the decomposition plan doc is referenced.
|
||
|
||
## Test impact analysis
|
||
|
||
1. **New unit tests enabled**: `ForwardingManager.start()` and `.stop()` can be tested in isolation with a mock `PermissionForwardingDeps` and fake timers.
|
||
Previously, testing required constructing a full `ExtensionRuntime` or going through the integration test.
|
||
- `start()` with `hasUI: false` → no-op (no timer created).
|
||
- `start()` with subagent context → stops any existing timer.
|
||
- `start()` when already running → updates context but does not create a second timer.
|
||
- `stop()` → clears timer, context, and processing flag.
|
||
- `start()` followed by timer tick → calls `processForwardedPermissionRequests`.
|
||
- Timer tick while `processing` is true → skipped.
|
||
2. **Existing tests that become simpler**: The 3 `createExtensionRuntime` init tests in `runtime.test.ts` for the forwarding fields are deleted — the class constructor handles initialization internally.
|
||
3. **Existing tests that stay**: Handler tests still verify that `start(ctx)` and `stop()` are called at the right lifecycle points — the assertion target changes from `deps.startForwardedPermissionPolling` to `deps.forwarding.start` but the intent is identical.
|
||
|
||
## TDD order
|
||
|
||
1. **Red → Green**: Add `src/forwarding-manager.ts` with the class skeleton and `tests/forwarding-manager.test.ts` with core lifecycle tests (start no-op for non-UI, start no-op for subagent, stop clears state, timer tick calls process, tick skipped while processing, idempotent start).
|
||
Commit: `feat: add ForwardingManager class (#128)`
|
||
2. **Green → Refactor**: Remove the 3 forwarding fields from `ExtensionRuntime`, delete the two free functions from `runtime.ts`, update `createExtensionRuntime()`.
|
||
Remove the 3 init-value tests from `runtime.test.ts`.
|
||
Run `pnpm run build` to verify.
|
||
Commit: `refactor: remove forwarding state from ExtensionRuntime (#128)`
|
||
3. **Green → Refactor**: Update `HandlerDeps` in `src/handlers/types.ts` — replace the two methods with `readonly forwarding: ForwardingManager`.
|
||
Update all 4 handler files to use `deps.forwarding.start(ctx)` / `deps.forwarding.stop()`.
|
||
Update all 7 handler test files (mock shape + assertions).
|
||
Run `pnpm run build` and full test suite.
|
||
Commit: `refactor: wire ForwardingManager through HandlerDeps (#128)`
|
||
4. **Green → Refactor**: Update `src/index.ts` — construct `ForwardingManager`, pass it as `forwarding` in the deps object, remove the two closure wrappers.
|
||
Run full test suite.
|
||
Commit: `refactor: construct ForwardingManager in composition root (#128)`
|
||
|
||
Note: Steps 2–4 can be combined into fewer commits if the changes are small enough, but the ordering must be maintained.
|
||
Step 2 will break the build until step 3 updates callers, so steps 2 and 3 should be done together or step 2 should keep the old functions as deprecated wrappers temporarily.
|
||
|
||
**Revised strategy**: Combine steps 2, 3, and 4 into a single commit since removing the fields from `ExtensionRuntime` and updating `HandlerDeps` + `index.ts` are interdependent.
|
||
The sequence becomes:
|
||
|
||
1. `feat: add ForwardingManager class (#128)` — new file + tests, no existing code changed.
|
||
2. `refactor: wire ForwardingManager and remove legacy forwarding state (#128)` — all mechanical changes in one commit: runtime, types, handlers, index, handler tests, runtime tests.
|
||
|
||
## Risks and mitigations
|
||
|
||
| Risk | Mitigation |
|
||
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| Could this silently weaken a permission? | No — pure structural refactor. Same `processForwardedPermissionRequests` call, same `isSubagentExecutionContext` guard, same timer interval. Integration tests unchanged. |
|
||
| Timer leak if `ForwardingManager` is not stopped | Same risk exists today. `handleSessionEnd` calls `stop()` (via `deps.stopForwardedPermissionPolling()`); the new code calls `deps.forwarding.stop()` in the same place. |
|
||
| Handler test mock shape changes break tests | Mechanical — replace two `vi.fn()` fields with one `{ start: vi.fn(), stop: vi.fn() }` object. Grep ensures no mock factory is missed. |
|
||
| `ForwardingManager` import creates a circular dependency | `forwarding-manager.ts` imports from `forwarded-permissions/polling.ts` and `subagent-context.ts` — neither imports back. No cycle. |
|
||
|
||
## Open questions
|
||
|
||
- Should `ForwardingManager` accept the full `ExtensionPaths` or just `subagentSessionsDir`?
|
||
The issue suggests `ExtensionPaths`; this plan uses the narrower `subagentSessionsDir` to follow the dependency-width heuristic.
|
||
Either works — the `PermissionSession` (#129) will wrap it regardless.
|