---
phase: 260407-h07-antifragile-fix-for-afplayplayer-wav-wri
plan: 01
subsystem: claude-tts-companion / audio playback
tags: [bug-fix, antifragile, telemetry, chaos-test, swift-6]
requires: []
provides:
  - AfplayPlayer.ensureWritableWavDirectory
  - AfplayPlayer.AfplayHealthSnapshot
  - AfplayPlayer.getAfplayHealthSnapshot
  - HealthResponse.afplay (additive /health field)
affects:
  - plugins/claude-tts-companion/Sources/CompanionCore/AfplayPlayer.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/SettingsStore.swift
tech-stack:
  added: []
  patterns:
    - "Self-healing fallback chain (primary → NSTemporaryDirectory → mkstemp)"
    - "Collapsed structured telemetry (one log per failure-class per 60s)"
    - "Sendable health snapshot via MainActor.run hop"
key-files:
  created:
    - plugins/claude-tts-companion/Tests/CompanionCoreTests/AfplayPlayerChaosTests.swift
  modified:
    - plugins/claude-tts-companion/Sources/CompanionCore/AfplayPlayer.swift
    - plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift
    - plugins/claude-tts-companion/Sources/CompanionCore/SettingsStore.swift
decisions:
  - "Init becomes explicit but non-fatal — runtime fallback chain handles all writability issues"
  - "Cocoa NSError code 513 (NSFileWriteNoPermissionError) classifies as eperm via NSCocoaErrorDomain branch — verified live in chaos test 2"
  - "Test seam exposed via #if DEBUG only (ensureWritableWavDirectoryForTesting + __testing_* accessors)"
metrics:
  duration: 4min
  completed: 2026-04-07
---

# Quick 260407-h07: Antifragile fix for AfplayPlayer WAV write Summary

Self-healing WAV path fallback (primary → tmpdir → mkstemp) plus collapsed
60s-windowed telemetry, exposed via /health.afplay, with chaos tests proving
it survives mid-session deletion of `~/.local/share/tts-debug-wav/`.

## Final Line Numbers (as shipped)

| Symbol                                           | File                    | Line    |
| ------------------------------------------------ | ----------------------- | ------- |
| `debugWavDir` (eager path, no createDir)         | AfplayPlayer.swift      | 48      |
| `WavWriteFailureState` struct                    | AfplayPlayer.swift      | 63      |
| `AfplayHealthSnapshot` (public Sendable Codable) | AfplayPlayer.swift      | 73      |
| `wavFailureState` instance var                   | AfplayPlayer.swift      | 83      |
| `public init()` (explicit createDir + log)       | AfplayPlayer.swift      | 85      |
| `__testing_loggedClassCount`                     | AfplayPlayer.swift      | 96      |
| `__testing_consecutiveFailureCount`              | AfplayPlayer.swift      | 97      |
| `ensureWritableWavDirectoryForTesting()`         | AfplayPlayer.swift      | 99      |
| `play()` writeWav site (uses fallback chain)     | AfplayPlayer.swift      | 189     |
| `startSegment()` writeWav site                   | AfplayPlayer.swift      | 425     |
| `ensureWritableWavDirectory()`                   | AfplayPlayer.swift      | 596     |
| `classify(_:)`                                   | AfplayPlayer.swift      | 660     |
| `recordFailure(_:error:attemptedPath:)`          | AfplayPlayer.swift      | 688     |
| `getAfplayHealthSnapshot()`                      | AfplayPlayer.swift      | 701     |
| `afplay: AfplayHealthSnapshot` field             | HTTPControlServer.swift | 54      |
| `await healthResponse()` route handler           | HTTPControlServer.swift | 124     |
| `healthResponse()` async + MainActor.run hop     | HTTPControlServer.swift | 305-306 |
| SettingsStore explicit `do/catch` + logger.error | SettingsStore.swift     | 122-130 |

## /health.afplay JSON keys

```json
{
  "afplay": {
    "primary_dir_writable": true,
    "fallback_level": 0,
    "consecutive_failure_count": 0,
    "total_fallback_writes": 0,
    "last_failure_class": null,
    "last_failure_at": null
  }
}
```

When degraded, `fallback_level` is 1 (NSTemporaryDirectory) or 2 (mkstemp);
`last_failure_class` is one of `missing_dir | eperm | enospc | erofs | eexist | other`;
`last_failure_at` is ISO8601.

SwiftBar `claude-hq.10s.sh` was NOT modified — its `jq -re '.foo // default'`
parser tolerates additive top-level keys. No `?include=afplay` gating needed.

## Test Output

```
◇ Suite AfplayPlayerChaosTests started.
◇ Test primaryRecreatesWhenDeleted() started.
✔ Test primaryRecreatesWhenDeleted() passed after 0.013 seconds.
◇ Test fallsBackWhenPrimaryUnwritable() started.
   [TELEMETRY] WAV write failure CLASS=eperm COUNT=1 …
   [TELEMETRY] fallback_engaged level=1 path=/var/folders/…/T/claude-tts-wav reason=eperm
✔ Test fallsBackWhenPrimaryUnwritable() passed after 0.007 seconds.
◇ Test failureLoggedAtMostOncePerClassIn60sWindow() started.
   [TELEMETRY] WAV write failure CLASS=eperm COUNT=1 … (single log line for 10 attempts)
✔ Test failureLoggedAtMostOncePerClassIn60sWindow() passed after 0.001 seconds.
◇ Test recoveryResetsStateAndIsIdempotent() started.
   [TELEMETRY] primary_recovered prior_failures=1 prior_level=1
✔ Test recoveryResetsStateAndIsIdempotent() passed after 0.001 seconds.
✔ Suite AfplayPlayerChaosTests passed after 0.024 seconds.
✔ Test run with 4 tests passed after 0.025 seconds.
```

`swift build -c release` succeeds. No warnings introduced in any of the three
modified files. (Pre-existing warnings in AudioStreamPlayer.swift,
CompanionApp.swift, TelegramBotCallbacks.swift, TelegramBotNotifications.swift
are out of scope per the deviation rules.)

## Deviations from Plan

None. Plan executed as written. Notes:

- Plan instructed using `.utf8CString.map { Int8($0) }` for the mkstemp template.
  Used the equivalent `Array("…".utf8CString)` form (CChar is Int8 on Apple
  platforms), which is functionally identical and slightly cleaner. No
  behavioral change.
- The eperm-via-Cocoa case (NSCocoaErrorDomain code 513 from FileManager when
  parent dir is `chmod 0o500`) is handled by the existing
  `NSFileWriteNoPermissionError` branch in `classify()` — verified live in
  chaos test 2's telemetry output (`CLASS=eperm`).

## Auto-fixed Issues

None — clean execution.

## Post-merge Manual Smoke Checklist

1. Build and deploy: `cd plugins/claude-tts-companion && make` (build + deploy + restart)
2. `curl -s http://localhost:8780/health | jq '.afplay'` → expect populated object with `primary_dir_writable: true, fallback_level: 0`
3. Trigger a TTS via Telegram or `/tts/speak` endpoint → confirm audio plays
4. `rm -rf ~/.local/share/tts-debug-wav` while companion is running
5. Trigger another TTS → confirm audio still plays (level-1 fallback engaged)
6. `curl -s http://localhost:8780/health | jq '.afplay'` → expect `fallback_level: 1, total_fallback_writes >= 1, last_failure_class: "missing_dir"` (or similar)
7. Manually recreate `~/.local/share/tts-debug-wav` with normal permissions
8. Trigger another TTS → confirm audio plays via primary
9. `curl -s http://localhost:8780/health | jq '.afplay'` → expect `fallback_level: 0, consecutive_failure_count: 0` (recovery), and check log for single `primary_recovered` line
10. Confirm SwiftBar menu bar still renders without errors (no jq parse failures from new top-level key)

## Self-Check: PASSED

- FOUND: plugins/claude-tts-companion/Sources/CompanionCore/AfplayPlayer.swift (modified, ensureWritableWavDirectory at line 596)
- FOUND: plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift (modified, afplay field at line 54)
- FOUND: plugins/claude-tts-companion/Sources/CompanionCore/SettingsStore.swift (modified, do/catch at line 122)
- FOUND: plugins/claude-tts-companion/Tests/CompanionCoreTests/AfplayPlayerChaosTests.swift (created)
- FOUND: commit 285416ab — fix: self-healing WAV path fallback + collapsed telemetry
- FOUND: commit 0489c04a — feat: expose afplay WAV telemetry on /health + log SettingsStore dir failures
- FOUND: commit 11b86163 — test: chaos tests for AfplayPlayer WAV path self-healing
- VERIFIED: 4/4 chaos tests pass; no `try? FileManager.default.createDirectory` remains in Sources/
