---
phase: 20.1-mlx-metal-memory-lifecycle
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - plugins/claude-tts-companion/Sources/claude-tts-companion/TTSEngine.swift
  - plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift
  - plugins/claude-tts-companion/Sources/claude-tts-companion/HTTPControlServer.swift
  - plugins/claude-tts-companion/Package.swift
  - plugins/claude-tts-companion/launchd/com.terryli.claude-tts-companion.plist
autonomous: true
requirements: [LEAK-01, LEAK-02, LEAK-03]
must_haves:
  truths:
    - "kokoro-ios fork calls Memory.clearCache() after each generateAudio() and sets cacheLimit to <=20MB"
    - "Service exits cleanly after N synthesis sessions with a non-zero exit code"
    - "launchd restarts the service within 5 seconds of planned exit"
    - "IOAccelerator memory stays under 4GB across 10 consecutive TTS sessions (due to process restart)"
    - "Batch-then-play pattern is preserved — no regression in audio quality"
  artifacts:
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/TTSEngine.swift"
      provides: "Synthesis counter + graceful exit trigger"
      contains: "synthesisCount"
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift"
      provides: "Exit handler that logs restart reason"
      contains: "plannedRestart"
    - path: "plugins/claude-tts-companion/launchd/com.terryli.claude-tts-companion.plist"
      provides: "Fast restart on planned exit"
      contains: "ThrottleInterval"
    - path: "plugins/claude-tts-companion/Package.swift"
      provides: "Updated kokoro-ios dependency"
      contains: "kokoro-ios"
  key_links:
    - from: "TTSEngine.swift"
      to: "main.swift"
      via: "exit code 42 triggers launchd restart"
      pattern: "exit\\(42\\)"
    - from: "launchd plist"
      to: "service binary"
      via: "KeepAlive.SuccessfulExit=false restarts on non-zero exit"
      pattern: "ThrottleInterval.*5"
---

<!-- # SSoT-OK — Package.swift is the SSoT for kokoro-ios version; version bump is the deliverable -->

<objective>
Implement MLX Metal memory lifecycle management: reduce kokoro-ios cache limit, add synthesis-count-based process restart, and tune launchd for fast restart.

Purpose: MLX-Swift creates ~1.7-6GB of unreclaimable IOAccelerator allocations per generateAudio() call. The only 100% reclamation mechanism is process exit. This plan adds a synthesis counter that triggers graceful exit after N calls, with launchd auto-restart keeping downtime under 5 seconds.

Output: Working TTS service with bounded memory lifecycle — IOAccelerator stays under 4GB across 10+ sessions.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/20.1-mlx-metal-memory-lifecycle/20.1-CONTEXT.md
@.planning/debug/mlx-metal-resource-crash.md

@plugins/claude-tts-companion/Sources/claude-tts-companion/TTSEngine.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/HTTPControlServer.swift
@plugins/claude-tts-companion/Package.swift
@plugins/claude-tts-companion/launchd/com.terryli.claude-tts-companion.plist

<interfaces>
<!-- Key types and contracts the executor needs -->

From KokoroTTS (kokoro-ios fork, inside dylib):

```swift
// Already exists at current version:
public func clearCache()                    // calls Memory.clearCache() inside dylib
public func setCacheLimit(_ limit: Int) -> Int  // sets Memory.cacheLimit inside dylib
public func memorySnapshot() -> (active: Int, cache: Int, peak: Int)
// generateAudio() already calls Memory.clearCache() at end
// init() sets Memory.cacheLimit = 32 * 1024 * 1024
```

From TTSEngine.swift:

```swift
final class TTSEngine: @unchecked Sendable {
    private var ttsInstance: KokoroTTS?
    private let lock = NSLock()
    // No synthesis counter exists yet
    // No exit trigger exists yet
}
```

From main.swift SIGTERM handler:

```swift
let sigSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
signal(SIGTERM, SIG_IGN)
sigSource.setEventHandler {
    logger.info("SIGTERM received, shutting down")
    subtitlePanel.hide()
    notificationWatcher.stop()
    thinkingWatcher.stop()
    if let bot = telegramBot { Task { await bot.stop() } }
    // ... posts dummy event, calls app.stop(nil)
}
```

From launchd plist:

```xml
<key>KeepAlive</key>
<dict>
    <key>SuccessfulExit</key>
    <false/>         <!-- restart only on non-zero exit -->
</dict>
<key>ThrottleInterval</key>
<integer>30</integer>  <!-- 30s between restarts — too slow -->
```

</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Update kokoro-ios fork — reduce cache limit to 20MB</name>
  <files>
    plugins/claude-tts-companion/Package.swift
  </files>
  <read_first>
    plugins/claude-tts-companion/.build/checkouts/kokoro-ios/Sources/KokoroSwift/TTSEngine/KokoroTTS.swift
    plugins/claude-tts-companion/Package.swift
  </read_first>
  <action>
    1. Clone the kokoro-ios fork: `git clone git@github.com:terrylica/kokoro-ios.git /tmp/kokoro-ios-fork`

    2. In `/tmp/kokoro-ios-fork/Sources/KokoroSwift/TTSEngine/KokoroTTS.swift`, find the init() method where `Memory.cacheLimit` is set (currently `32 * 1024 * 1024`). Change it to `20 * 1024 * 1024`. This satisfies LEAK-01 (cache limit <=20MB).

    3. Verify that `Memory.clearCache()` is already called at the end of `generateAudio()` — do NOT remove it. This is the second part of LEAK-01.

    4. Commit: `fix: reduce Metal buffer cache limit from 32MB to 20MB for IOAccelerator leak mitigation`

    5. Tag the new version and push: check current latest tag with `git describe --tags --abbrev=0`, increment the patch version, then `git tag <next-patch> && git push origin main --tags`.

    6. Update `plugins/claude-tts-companion/Package.swift` to use the new exact version in the kokoro-ios dependency line.

    7. Run `cd plugins/claude-tts-companion && swift package resolve` to verify the new tag resolves.

    8. Clean up: `rm -rf /tmp/kokoro-ios-fork`

  </action>
  <verify>
    <automated>cd plugins/claude-tts-companion && swift package resolve 2>&1 | tail -5 && grep 'kokoro-ios' Package.swift && grep 'cacheLimit = 20' .build/checkouts/kokoro-ios/Sources/KokoroSwift/TTSEngine/KokoroTTS.swift</automated>
  </verify>
  <acceptance_criteria>
    - Package.swift references the new kokoro-ios patch version
    - The checked-out KokoroTTS.swift contains `Memory.cacheLimit = 20 * 1024 * 1024`
    - The checked-out KokoroTTS.swift still contains `Memory.clearCache()` at end of generateAudio()
    - `swift package resolve` succeeds without errors
  </acceptance_criteria>
  <done>kokoro-ios new patch published with 20MB cache limit; Package.swift updated and resolves cleanly</done>
</task>

<task type="auto">
  <name>Task 2: Add synthesis counter + graceful restart + launchd tuning</name>
  <files>
    plugins/claude-tts-companion/Sources/claude-tts-companion/TTSEngine.swift
    plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift
    plugins/claude-tts-companion/Sources/claude-tts-companion/HTTPControlServer.swift
    plugins/claude-tts-companion/launchd/com.terryli.claude-tts-companion.plist
  </files>
  <read_first>
    plugins/claude-tts-companion/Sources/claude-tts-companion/TTSEngine.swift
    plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift
    plugins/claude-tts-companion/Sources/claude-tts-companion/HTTPControlServer.swift
    plugins/claude-tts-companion/launchd/com.terryli.claude-tts-companion.plist
  </read_first>
  <action>
    **TTSEngine.swift changes:**

    1. Add a synthesis counter property (near the circuit breaker section, around line 159):

       ```swift
       /// Total number of generateAudio() calls since process start.
       /// Used by the memory lifecycle system to trigger planned restart
       /// before IOAccelerator allocations exhaust system RAM.
       private(set) var synthesisCount: Int = 0

       /// Maximum generateAudio() calls before triggering graceful exit for memory reclaim.
       /// IOAccelerator grows ~1.7GB per call and is only reclaimable via process exit.
       /// At 10 calls, worst case is ~17GB before restart — safely under 32GB system RAM.
       static let maxSynthesisBeforeRestart = 10
       ```

    2. In `synthesize()` (around line 278 where `tts.generateAudio()` is called), increment the counter AFTER the call succeeds:

       ```swift
       let (audio, _) = try tts.generateAudio(...)
       lock.lock()
       synthesisCount += 1
       lock.unlock()
       ```

    3. In `synthesizeStreaming()` (around line 491 where `tts.generateAudio()` is called inside the for-loop), increment after EACH chunk synthesis:

       ```swift
       (audio, tokenArray) = try tts.generateAudio(...)
       lock.lock()
       synthesisCount += 1
       lock.unlock()
       ```

    4. Add a public method to check if restart is needed:

       ```swift
       /// Whether the synthesis count has reached the restart threshold.
       /// Callers should trigger graceful exit after current playback completes.
       var shouldRestartForMemory: Bool {
           lock.lock()
           defer { lock.unlock() }
           return synthesisCount >= Self.maxSynthesisBeforeRestart
       }
       ```

    5. Add a method to get memory stats for /health:

       ```swift
       /// Returns synthesis count and optional MLX memory snapshot for diagnostics.
       func memoryDiagnostics() -> (synthesisCount: Int, mlxActive: Int?, mlxCache: Int?, mlxPeak: Int?) {
           lock.lock()
           let count = synthesisCount
           lock.unlock()
           if let tts = ttsInstance {
               let snap = tts.memorySnapshot()
               return (count, snap.active, snap.cache, snap.peak)
           }
           return (count, nil, nil, nil)
       }
       ```

    **main.swift changes:**

    6. Add a planned restart function near the SIGTERM handler (around line 207):

       ```swift
       /// Triggers a graceful process exit for IOAccelerator memory reclaim.
       /// Uses exit code 42 (non-zero) so launchd KeepAlive restarts the service.
       /// exit(0) would NOT trigger restart because KeepAlive.SuccessfulExit = false.
       func plannedRestart(reason: String) {
           logger.warning("Planned restart: \(reason)")
           subtitlePanel.hide()
           notificationWatcher.stop()
           thinkingWatcher.stop()
           if let bot = telegramBot {
               Task { await bot.stop() }
           }
           // Give async cleanup 1 second to complete
           DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
               logger.info("Exiting with code 42 for launchd restart")
               exit(42)
           }
       }
       ```

    7. After the TTS dispatch section in main.swift (wherever dispatchTTS or synthesizeStreaming completes), add a restart check. Find where synthesizeStreaming's onAllComplete is handled and add AFTER playback completes:

       ```swift
       // Check synthesis count for memory lifecycle restart
       if ttsEngine.shouldRestartForMemory {
           plannedRestart(reason: "Synthesis count \(ttsEngine.memoryDiagnostics().synthesisCount) reached threshold \(TTSEngine.maxSynthesisBeforeRestart)")
       }
       ```

       NOTE: This check should happen AFTER playback finishes (not during synthesis), so the user hears the complete audio before the service restarts. Look for where SubtitleSyncDriver's onAllChunksPlayed or similar completion callback fires.

    **HTTPControlServer.swift changes:**

    8. Find the GET /health endpoint handler. Add synthesis count and MLX memory to the health response JSON:

       ```swift
       let diag = ttsEngine.memoryDiagnostics()
       // Add these keys to the response dictionary:
       "tts_synthesis_count": diag.synthesisCount,
       "tts_restart_threshold": TTSEngine.maxSynthesisBeforeRestart,
       "mlx_memory_active_mb": diag.mlxActive.map { $0 / (1024 * 1024) },
       "mlx_memory_cache_mb": diag.mlxCache.map { $0 / (1024 * 1024) },
       ```

       Read HTTPControlServer.swift first to match the existing response structure pattern.

    **launchd plist changes:**

    9. In `plugins/claude-tts-companion/launchd/com.terryli.claude-tts-companion.plist`:
       - Change `ThrottleInterval` from `30` to `5` (fast restart after planned exit)
       - The existing `KeepAlive.SuccessfulExit = false` already ensures restart on non-zero exit (exit(42))
       - Increase `SoftResourceLimits.MemoryLimit` from `1073741824` (1GB) to `8589934592` (8GB) — the service legitimately uses up to 6GB of IOAccelerator memory before restart threshold triggers

    CRITICAL: Do NOT call any MLX API (Memory.clearCache, Memory.cacheLimit, Stream.gpu.synchronize) directly from the main binary. All MLX calls must go through the kokoro-ios dylib public API (tts.clearCache(), tts.setCacheLimit(), tts.memorySnapshot()). See mlx-metal-resource-crash.md.

    CRITICAL: Preserve the batch-then-play pattern — all chunks must be synthesized BEFORE playback begins. Do not interleave synthesis with playback.

  </action>
  <verify>
    <automated>cd plugins/claude-tts-companion && swift build -c release 2>&1 | tail -5 && echo "---" && grep -n "synthesisCount" Sources/claude-tts-companion/TTSEngine.swift | head -5 && echo "---" && grep -n "plannedRestart\|exit(42)" Sources/claude-tts-companion/main.swift | head -5 && echo "---" && grep "ThrottleInterval" launchd/com.terryli.claude-tts-companion.plist</automated>
  </verify>
  <acceptance_criteria>
    - `swift build -c release` succeeds with zero errors
    - TTSEngine.swift contains `synthesisCount` property and `shouldRestartForMemory` computed property
    - TTSEngine.swift increments `synthesisCount` after each `generateAudio()` call (both synthesize and synthesizeStreaming paths)
    - main.swift contains `plannedRestart` function that calls `exit(42)`
    - main.swift checks `shouldRestartForMemory` after playback completes
    - HTTPControlServer.swift health endpoint includes `tts_synthesis_count` in response
    - launchd plist has `ThrottleInterval` of 5 (not 30)
    - launchd plist has `MemoryLimit` of 8589934592 (8GB)
    - No direct MLX API calls (Memory.clearCache, Memory.cacheLimit) exist in the main binary — only calls through ttsInstance methods
    - Batch-then-play pattern is preserved (synthesizeStreaming shows all-chunks-first pattern)
  </acceptance_criteria>
  <done>Synthesis counter triggers graceful exit(42) after 10 generateAudio() calls; launchd restarts within 5 seconds; /health shows synthesis count and MLX memory stats</done>
</task>

</tasks>

<verification>
1. `swift build -c release` succeeds
2. `grep -c "synthesisCount" plugins/claude-tts-companion/Sources/claude-tts-companion/TTSEngine.swift` returns >= 3 (declaration + 2 increment sites)
3. `grep "exit(42)" plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift` matches
4. `grep "ThrottleInterval" plugins/claude-tts-companion/launchd/com.terryli.claude-tts-companion.plist` shows 5
5. `grep -c "Memory\.clearCache\|Memory\.cacheLimit\|Stream\.gpu" plugins/claude-tts-companion/Sources/claude-tts-companion/TTSEngine.swift` returns 0 (no direct MLX calls — only comments)
</verification>

<success_criteria>

- kokoro-ios new patch with 20MB cache limit resolves in Package.swift
- TTSEngine tracks synthesis count and signals when restart is needed
- Service exits with code 42 after 10 synthesis calls, triggering launchd auto-restart
- /health endpoint shows synthesis count and MLX memory stats
- launchd restarts within 5 seconds (ThrottleInterval=5)
- No direct MLX API calls from main binary (only through kokoro-ios dylib)
- Release build succeeds with zero errors
  </success_criteria>

<output>
After completion, create `.planning/phases/20.1-mlx-metal-memory-lifecycle/20.1-01-SUMMARY.md`
</output>
