---
phase: 20.1-mlx-metal-memory-lifecycle
plan: 01
subsystem: tts
tags: [mlx, metal, ioaccelerator, memory-lifecycle, launchd, kokoro-ios]

# Dependency graph
requires: []
provides:
  - "Synthesis counter tracking in TTSEngine"
  - "Graceful exit(42) for IOAccelerator memory reclaim via launchd restart"
  - "MLX memory diagnostics in GET /health endpoint"
  - "kokoro-ios updated with 20MB cache limit"
affects: [phase-19, phase-20, phase-21]

# Tech tracking
tech-stack:
  added: [kokoro-ios-patch]
  patterns: [process-restart-for-memory-reclaim, synthesis-counter-lifecycle]

key-files:
  created: []
  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/Sources/claude-tts-companion/TelegramBot.swift
    - plugins/claude-tts-companion/Package.swift
    - plugins/claude-tts-companion/launchd/com.terryli.claude-tts-companion.plist

key-decisions:
  - "exit(42) as restart signal -- non-zero triggers launchd KeepAlive.SuccessfulExit=false restart"
  - "Max 10 synthesis calls before restart -- worst case ~17GB IOAccelerator before reclaim"
  - "Restart check fires after playback completes (onStreamingComplete), not during synthesis"
  - "plannedRestart dispatches to main thread for MainActor-isolated subtitlePanel.hide()"

patterns-established:
  - "Memory lifecycle via process restart: counter tracks generateAudio() calls, threshold triggers graceful exit"
  - "Diagnostics in health endpoint: synthesis count + MLX memory stats for observability"

requirements-completed: [LEAK-01, LEAK-02, LEAK-03]

# Metrics
duration: 6min
completed: 2026-03-28
---

<!-- # SSoT-OK — Package.swift is the SSoT for kokoro-ios version; this summary documents execution history -->

# Phase 20.1 Plan 01: MLX Metal Memory Lifecycle Summary

**Synthesis counter triggers graceful exit(42) after 10 generateAudio() calls; kokoro-ios cache reduced to 20MB; launchd restarts within 5 seconds**

## Performance

- **Duration:** 6 min
- **Started:** 2026-03-27T23:59:13Z
- **Completed:** 2026-03-28T00:05:44Z
- **Tasks:** 2
- **Files modified:** 7

## Accomplishments

- Updated kokoro-ios fork to v1.0.14 with Memory.cacheLimit reduced from 32MB to 20MB
- Added synthesis counter in TTSEngine with shouldRestartForMemory check at threshold 10
- Added plannedRestart() function using exit(42) to trigger launchd auto-restart
- Wired checkMemoryLifecycleRestart() into both TelegramBot and HTTPControlServer onStreamingComplete callbacks
- Added tts_synthesis_count, tts_restart_threshold, and MLX memory stats to GET /health response
- Tuned launchd: ThrottleInterval 30s to 5s, MemoryLimit 1GB to 8GB

## Task Commits

Each task was committed atomically:

1. **Task 1: Update kokoro-ios fork -- reduce cache limit to 20MB** - `4528dc21` (chore)
2. **Task 2: Add synthesis counter + graceful restart + launchd tuning** - `8e243f84` (feat)

## Files Created/Modified

- `plugins/claude-tts-companion/Package.swift` - Bumped kokoro-ios to new patch version (see Package.swift for exact version)
- `plugins/claude-tts-companion/Package.resolved` - Updated lock file for new dependency version
- `plugins/claude-tts-companion/Sources/claude-tts-companion/TTSEngine.swift` - Added synthesisCount, shouldRestartForMemory, memoryDiagnostics(), counter increments in all 3 synthesis paths
- `plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift` - Added plannedRestart() and checkMemoryLifecycleRestart() functions
- `plugins/claude-tts-companion/Sources/claude-tts-companion/HTTPControlServer.swift` - Added MLX memory stats to HealthResponse, wired restart check into onStreamingComplete
- `plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift` - Wired checkMemoryLifecycleRestart() into onStreamingComplete callback
- `plugins/claude-tts-companion/launchd/com.terryli.claude-tts-companion.plist` - ThrottleInterval 5s, MemoryLimit 8GB

## Decisions Made

- Used exit(42) as the restart signal -- any non-zero exit code triggers launchd KeepAlive.SuccessfulExit=false restart
- Set max synthesis threshold at 10 calls -- at ~1.7GB IOAccelerator per call, worst case is ~17GB which fits in 32GB system RAM
- Restart check fires after playback completes (onStreamingComplete), not during synthesis, so users hear complete audio
- plannedRestart() dispatches to main thread since subtitlePanel.hide() is MainActor-isolated

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 1 - Bug] Fixed MainActor isolation for plannedRestart**

- **Found during:** Task 2 (build verification)
- **Issue:** `subtitlePanel.hide()` is MainActor-isolated but plannedRestart was synchronous nonisolated
- **Fix:** Wrapped the cleanup body in `DispatchQueue.main.async` to run on main thread
- **Files modified:** main.swift
- **Verification:** `swift build -c release` succeeds with zero errors
- **Committed in:** 8e243f84 (part of Task 2 commit)

---

**Total deviations:** 1 auto-fixed (1 bug)
**Impact on plan:** Necessary fix for Swift 6 concurrency correctness. No scope creep.

## Issues Encountered

None beyond the MainActor isolation fix documented above.

## Known Stubs

None -- all functionality is fully wired.

## User Setup Required

None - no external service configuration required.

## Next Phase Readiness

- Memory lifecycle system is complete and ready for production deployment
- Service needs to be redeployed with `swift build -c release` and launchd plist reload
- Phase 18 (CompanionCore library extraction) can proceed independently

---

_Phase: 20.1-mlx-metal-memory-lifecycle_
_Completed: 2026-03-28_
