# Concurrent Runtime Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Support multiple controllers over a single websocket connection: one reader thread broadcasting to per-controller channels, a writer thread sending queued service calls, supervised worker threads with backoff restarts, and reconnect-on-network-failure. **Architecture:** A `Bus` (broadcast `TChan` inbound, `TChan` outbound, `TVar (Maybe Connection)`, existing `CallIdGen`) connects a reader thread (connect/auth/subscribe/receive-broadcast; restart = reconnect) and a writer thread to N controller threads, each stepping a `Mealy HASSEff` machine over its own dup'd channel. Slice 1 wires them with `mapConcurrently_` (any crash exits). Slice 2 wraps each worker in `supervised` (annotated-exception, exponential backoff, `Fatal` rethrow). **Tech Stack:** Haskell (GHC 9.10, GHC2024), stm, async, annotated-exception, websockets, aeson, hspec, hedgehog. Nix/cabal build. **Spec:** `docs/superpowers/specs/2026-08-20-concurrent-runtime-design.md` ## Global Constraints - Run cabal only through the devShell: `nix develop -c cabal build` / `nix develop -c cabal test`. - After changing the .cabal file, regenerate the derivation: `nix run nixpkgs#cabal2nix -- ./. > default.nix`. Never edit it by hand. - `-Wall` (common `warnings` stanza) must produce zero warnings. - Comments are minimal, contract-style: describe what, not how. - Never read, print, or commit `secrets.yaml`. - Commit after every task. Message style: short imperative, no prefix (see `git log`). - Unit tests: hspec. Property tests: hedgehog. - Modules are vertical (split by feature/concept, not layer). ## File Structure | File | Responsibility | |---|---| | `src/HomeAssistant/Runtime/Bus.hs` (create) | Shared channels + connection cell + call-id gen + channel interpreter | | `src/HomeAssistant/Runtime/Connection.hs` (create) | Reader/writer thread actions, pure `encodeService` | | `src/HomeAssistant/Runtime/Supervisor.hs` (create, slice 2) | Generic restart-with-backoff combinator, `Backoff`, `Fatal` | | `src/HomeAssistant/Runtime.hs` (rewrite) | Glue: `Controller`, `runController`, `controllers`, `defaultMain`, `step`, `dryRunHassEval` | | `src/HomeAssistant/Controller.hs` (1-word change) | Derive `Eq` on `Service` (tests need it) | | `test/Main.hs` (rewrite) | hspec runner | | `test/BusSpec.hs`, `test/ConnectionSpec.hs`, `test/RuntimeSpec.hs`, `test/SupervisorSpec.hs`, `test/BackoffProp.hs` (create) | Specs | | `home-assistant-controller.cabal` | Deps + module lists per task | Slices: Tasks 1–3 = slice 1 (concurrency). Tasks 4–5 = slice 2 (robustness). --- ### Task 1: Bus module **Files:** - Create: `src/HomeAssistant/Runtime/Bus.hs` - Modify: `src/HomeAssistant/Runtime.hs` (move `CallIdGen` out, re-export from Bus) - Modify: `src/HomeAssistant/Controller.hs:43` (derive `Eq`) - Modify: `home-assistant-controller.cabal` (exposed module, `stm` dep, test deps) - Rewrite: `test/Main.hs` - Create: `test/BusSpec.hs` **Interfaces:** - Consumes: `HASSEff(..)`, `Service` from `HomeAssistant.Controller`. - Produces (Bus exports): `Bus(..)` with fields `busInbound :: TChan Value`, `busOutbound :: TChan Service`, `busConn :: TVar (Maybe WS.Connection)`, `busGen :: CallIdGen`; `CallIdGen(..)` (record field `generateCallId :: IO Int`); `mkCallIdGen :: Int -> IO CallIdGen`; `newBus :: Int -> IO Bus`; `channelHassEval :: Bus -> HASSEff a -> IO a`. - [ ] **Step 1: Write the failing test** `test/BusSpec.hs`: ```haskell module BusSpec (spec) where import Control.Concurrent.STM ( atomically , dupTChanIO , readTChan , writeTChan ) import Data.Aeson (Value (..)) import HomeAssistant.Controller (HASSEff (..), Service (..)) import HomeAssistant.Runtime.Bus import Test.Hspec spec :: Spec spec = describe "Bus" $ do it "broadcasts inbound messages to every dup'd channel in order" $ do bus <- newBus 0 p1 <- dupTChanIO (busInbound bus) p2 <- dupTChanIO (busInbound bus) atomically $ writeTChan (busInbound bus) (Number 1) atomically $ writeTChan (busInbound bus) (Number 2) r1 <- atomically $ (,) <$> readTChan p1 <*> readTChan p1 r2 <- atomically $ (,) <$> readTChan p2 <*> readTChan p2 r1 `shouldBe` (Number 1, Number 2) r2 `shouldBe` (Number 1, Number 2) it "channelHassEval writes CallService to the outbound channel" $ do bus <- newBus 0 let svc = Service "light" "turn_on" Nothing "light.bedroom_masse" channelHassEval bus (CallService svc) atomically (readTChan (busOutbound bus)) `shouldReturn` svc it "channelHassEval leaves Pure untouched" $ do bus <- newBus 0 channelHassEval bus (Pure 42) `shouldReturn` (42 :: Int) it "generates unique sequential call ids" $ do gen <- mkCallIdGen 0 a <- generateCallId gen b <- generateCallId gen (a, b) `shouldBe` (1, 2) ``` `test/Main.hs`: ```haskell module Main (main) where import Test.Hspec (hspec) import qualified BusSpec main :: IO () main = hspec BusSpec.spec ``` In `home-assistant-controller.cabal`: Library section: add to `exposed-modules`: `HomeAssistant.Runtime.Bus`; add to `build-depends`: `stm`. Test suite section: add `other-modules: BusSpec` and set: ``` build-depends: base ^>=4.20.2.0, home-assistant-controller, hspec, stm, aeson, text ``` In `src/HomeAssistant/Controller.hs` change line 43 from `deriving Show` to `deriving (Show, Eq)`. - [ ] **Step 2: Run test to verify it fails** Run: `nix develop -c cabal test` Expected: FAIL — compile error, `Could not find module 'HomeAssistant.Runtime.Bus'`. - [ ] **Step 3: Write the implementation** `src/HomeAssistant/Runtime/Bus.hs`: ```haskell {-# LANGUAGE LambdaCase #-} module HomeAssistant.Runtime.Bus ( Bus(..) , CallIdGen(..) , mkCallIdGen , newBus , channelHassEval ) where import Control.Concurrent.STM ( TChan , TVar , atomically , newBroadcastTChanIO , newTChanIO , newTVarIO , writeTChan ) import Data.Aeson (Value) import Data.IORef (IORef, atomicModifyIORef', newIORef) import HomeAssistant.Controller (HASSEff (..), Service) import Network.WebSockets (Connection) -- | Shared runtime state: inbound is a broadcast channel (controllers -- read from 'dupTChanIO' copies), outbound queues service calls for the -- writer, conn holds the current websocket (Nothing before first connect). data Bus = Bus { busInbound :: TChan Value , busOutbound :: TChan Service , busConn :: TVar (Maybe Connection) , busGen :: CallIdGen } newBus :: Int -> IO Bus newBus start = Bus <$> newBroadcastTChanIO <*> newTChanIO <*> newTVarIO Nothing <*> mkCallIdGen start channelHassEval :: Bus -> HASSEff a -> IO a channelHassEval bus = \case CallService svc -> atomically $ writeTChan (busOutbound bus) svc Pure a -> pure a newtype CallIdGen = CallIdGen { generateCallId :: IO Int } mkCallIdGen :: Int -> IO CallIdGen mkCallIdGen start = do gen <- newIORef start pure $ CallIdGen $ atomicModifyIORef' gen (\old -> let new = old + 1 in new `seq` (new, new)) ``` Then in `src/HomeAssistant/Runtime.hs`: - Delete the local `newtype CallIdGen`/`mkCallIdGen` definitions (lines 105–110). - Add import: `import HomeAssistant.Runtime.Bus (Bus(..), CallIdGen(..), mkCallIdGen)`. - Export list stays the same (`CallIdGen`, `mkCallIdGen` now re-exported from Bus). - [ ] **Step 4: Regenerate the nix derivation** Run: `nix run nixpkgs#cabal2nix -- ./. > default.nix` Expected: no output; `git diff default.nix` shows the new `stm`/test dependencies. - [ ] **Step 5: Run tests to verify they pass** Run: `nix develop -c cabal build && nix develop -c cabal test` Expected: build with zero warnings; 4 examples, 0 failures. - [ ] **Step 6: Commit** ```bash git add src test home-assistant-controller.cabal default.nix git commit -m "Add Runtime.Bus with channel-based effect interpreter" ``` --- ### Task 2: Connection module (reader/writer actions) **Files:** - Create: `src/HomeAssistant/Runtime/Connection.hs` - Create: `test/ConnectionSpec.hs` - Modify: `test/Main.hs`, `home-assistant-controller.cabal` **Interfaces:** - Consumes: `Bus(..)`, `CallIdGen(..)` from Task 1. - Produces (Connection exports): `readerAction :: String -> Int -> String -> Bus -> IO Void` (host, port, token; restart = reconnect); `writerAction :: Bus -> IO Void`; `encodeService :: Int -> Service -> Data.Aeson.Value`. **Background for the implementer (verified facts, do not re-verify):** - `WS.runClient` runs the `ClientApp` under `bracket` and closes the socket/stream when the app throws — reconnects do not leak fds. - Exceptions from `WS.receiveData` (e.g. `ConnectionClosed`) are what make the reader restartable; connect failures throw `IOException`. - HA sends a `result` ack for `subscribe_events`; we deliberately do not read it — it flows to controllers and is filtered out by their entity-id lenses (same as current behavior). - [ ] **Step 1: Write the failing test** `test/ConnectionSpec.hs`: ```haskell module ConnectionSpec (spec) where import Data.Aeson (Value, object, (.=)) import Data.Text (Text) import HomeAssistant.Controller (Service (..)) import HomeAssistant.Runtime.Connection (encodeService) import Test.Hspec spec :: Spec spec = describe "encodeService" $ do it "encodes a call_service message" $ encodeService 7 (Service "light" "turn_on" Nothing "light.bedroom_masse") `shouldBe` object [ "id" .= (7 :: Int) , "type" .= ("call_service" :: Text) , "domain" .= ("light" :: Text) , "service" .= ("turn_on" :: Text) , "target" .= object ["entity_id" .= ("light.bedroom_masse" :: Text)] ] it "includes service_data when present" $ encodeService 8 (Service "light" "turn_on" (Just (object ["brightness" .= (200 :: Int)])) "light.bedroom_masse") `shouldBe` object [ "id" .= (8 :: Int) , "type" .= ("call_service" :: Text) , "domain" .= ("light" :: Text) , "service" .= ("turn_on" :: Text) , "target" .= object ["entity_id" .= ("light.bedroom_masse" :: Text)] , "service_data" .= object ["brightness" .= (200 :: Int)] ] ``` `test/Main.hs`: add `import qualified ConnectionSpec` and change `main` to: ```haskell main :: IO () main = hspec $ do BusSpec.spec ConnectionSpec.spec ``` Cabal: library `exposed-modules` += `HomeAssistant.Runtime.Connection`; test `other-modules` += `ConnectionSpec`. - [ ] **Step 2: Run test to verify it fails** Run: `nix develop -c cabal test` Expected: FAIL — `Could not find module 'HomeAssistant.Runtime.Connection'`. - [ ] **Step 3: Write the implementation** `src/HomeAssistant/Runtime/Connection.hs`: ```haskell {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-} module HomeAssistant.Runtime.Connection ( readerAction , writerAction , encodeService ) where import Control.Concurrent.STM ( atomically , readTChan , readTVar , retry , writeTChan , writeTVar ) import Control.Lens ((^?)) import Control.Monad (forever) import Data.Aeson (Value, eitherDecode, encode, object, (.=)) import Data.Aeson.Lens (key, _String) import qualified Data.ByteString.Lazy as BL import qualified Data.Text as T import Data.Void (Void) import HomeAssistant.Controller (Service (..)) import HomeAssistant.Runtime.Bus import qualified Network.WebSockets as WS -- | Connect, authenticate, subscribe, then receive and broadcast forever. -- Restarting this action reconnects. All setup sends happen before the -- connection is published in the bus, so only the writer sends afterwards. readerAction :: String -> Int -> String -> Bus -> IO Void readerAction host port token bus = WS.runClient host port "/api/websocket" $ \conn -> do handshake conn token subscribe bus conn atomically $ writeTVar (busConn bus) (Just conn) putStrLn "[reader] connected" receiveLoop bus conn handshake :: WS.Connection -> String -> IO () handshake conn token = do required <- receiveJSON conn expectType "auth_required" required WS.sendTextData conn $ encode $ object [ "type" .= ("auth" :: T.Text) , "access_token" .= token ] ok <- receiveJSON conn expectType "auth_ok" ok expectType :: T.Text -> Value -> IO () expectType expected msg = case msg ^? key "type" . _String of Just t | t == expected -> pure () _ -> fail $ "expected " <> T.unpack expected <> ", got: " <> show msg subscribe :: Bus -> WS.Connection -> IO () subscribe bus conn = do sid <- generateCallId (busGen bus) WS.sendTextData conn $ encode $ object [ "id" .= sid , "type" .= ("subscribe_events" :: T.Text) , "event_type" .= ("state_changed" :: T.Text) ] -- | Undecodable messages are skipped: reconnecting cannot fix a decode -- problem, so crashing here would only produce a hot restart loop. receiveLoop :: Bus -> WS.Connection -> IO Void receiveLoop bus conn = forever $ do msg <- WS.receiveData conn :: IO BL.ByteString case eitherDecode msg of Left err -> putStrLn $ "[reader] skipping undecodable message: " <> err Right v -> atomically $ writeTChan (busInbound bus) v receiveJSON :: WS.Connection -> IO Value receiveJSON conn = do msg <- WS.receiveData conn case eitherDecode msg of Left err -> fail $ "Invalid JSON from Home Assistant: " ++ err Right x -> pure x writerAction :: Bus -> IO Void writerAction bus = forever $ do svc <- atomically $ readTChan (busOutbound bus) conn <- atomically $ readTVar (busConn bus) >>= maybe retry pure callId <- generateCallId (busGen bus) WS.sendTextData conn $ encode $ encodeService callId svc encodeService :: Int -> Service -> Value encodeService callId Service{..} = object $ [ "id" .= callId , "type" .= ("call_service" :: T.Text) , "domain" .= serviceDomain , "service" .= serviceName , "target" .= object ["entity_id" .= serviceTarget] ] <> maybe [] (\d -> ["service_data" .= d]) serviceData ``` - [ ] **Step 4: Run tests to verify they pass** Run: `nix develop -c cabal build && nix develop -c cabal test` Expected: zero warnings; 6 examples, 0 failures. - [ ] **Step 5: Commit** ```bash git add src test home-assistant-controller.cabal git commit -m "Add Runtime.Connection reader and writer actions" ``` --- ### Task 3: Runtime rewrite — controller threads and wiring (completes slice 1) **Files:** - Rewrite: `src/HomeAssistant/Runtime.hs` - Create: `test/RuntimeSpec.hs` - Modify: `test/Main.hs`, `home-assistant-controller.cabal` **Interfaces:** - Consumes: `Bus(..)`, `newBus`, `channelHassEval`, `CallIdGen(..)`, `mkCallIdGen` (Task 1); `readerAction`, `writerAction` (Task 2). - Produces (Runtime exports): `defaultMain :: IO ()`, `step`, `dryRunHassEval`, `CallIdGen`, `mkCallIdGen`, `Controller(..)` with `data Controller = forall b. Controller T.Text (HASS (Event Value) b)`, `runController :: Bus -> Controller -> IO Void`. - Removes exports: `app`, `hassEval`, `wsCallService`, `receiveJSON` (deleted or internal to Connection). The `get_states` debug dump goes away. **Background:** `mapConcurrently_` rethrows the first worker exception and cancels the rest — a crash exits the process, same as today. That is intended for slice 1. - [ ] **Step 1: Write the failing test** `test/RuntimeSpec.hs`: ```haskell module RuntimeSpec (spec) where import Control.Concurrent (threadDelay) import Control.Concurrent.Async (async) import Control.Concurrent.STM (atomically, readTChan, writeTChan) import Data.Aeson (Value, object, (.=)) import Data.Text (Text) import HomeAssistant.Controller (light, lightController) import HomeAssistant.Runtime (Controller (..), runController) import HomeAssistant.Runtime.Bus import Test.Hspec spec :: Spec spec = describe "runController" $ do it "feeds inbound events through the machine and forwards service calls" $ do bus <- newBus 0 _ <- async (runController bus (Controller "test" lightController)) threadDelay 100000 -- let the controller dup its inbound channel atomically $ writeTChan (busInbound bus) (doorEvent "on") -- initial value: no change event atomically $ writeTChan (busInbound bus) (doorEvent "off") -- door closes: lights on atomically $ writeTChan (busInbound bus) (doorEvent "on") -- door opens: lights off svc1 <- atomically (readTChan (busOutbound bus)) svc2 <- atomically (readTChan (busOutbound bus)) svc1 `shouldBe` light True svc2 `shouldBe` light False doorEvent :: Text -> Value doorEvent state = object [ "event" .= object [ "data" .= object [ "entity_id" .= ("binary_sensor.makuuhuone_ovi_contact" :: Text) , "new_state" .= object ["state" .= state] ] ] ] ``` Note: the first event only seeds the machine (`changes` does not fire on the initial value), hence two expected service calls, not three. `test/Main.hs`: add `import qualified RuntimeSpec`; run all three specs. Cabal: test `other-modules` += `RuntimeSpec`, test `build-depends` += `async`. - [ ] **Step 2: Run test to verify it fails** Run: `nix develop -c cabal test` Expected: FAIL — `Controller` / `runController` not in scope. - [ ] **Step 3: Rewrite the Runtime module** Replace `src/HomeAssistant/Runtime.hs` entirely with: ```haskell {-# LANGUAGE ExistentialQuantification #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} module HomeAssistant.Runtime ( defaultMain , step , CallIdGen , mkCallIdGen , dryRunHassEval , Controller(..) , runController ) where import AFRP (Event (..), Mealy (..)) import Control.Concurrent.Async (mapConcurrently_) import Control.Concurrent.STM (atomically, dupTChanIO, readTChan) import Data.Aeson (Value) import qualified Data.Text as T import Data.Time (getCurrentTime) import Data.Void (Void) import HomeAssistant.Controller (HASS, HASSEff (..), lightController) import HomeAssistant.Runtime.Bus import HomeAssistant.Runtime.Connection (readerAction, writerAction) import Network.Socket (withSocketsDo) import System.Environment (getEnv) step :: (forall x. eff x -> IO x) -> Mealy eff a b -> a -> IO (b, Mealy eff a b) step nt (Mealy f) a = do now <- getCurrentTime f nt now a data Controller = forall b. Controller T.Text (HASS (Event Value) b) controllers :: [Controller] controllers = [Controller "light" lightController] -- | Steps the machine for every inbound message; service calls go to the -- bus. A restart re-dups the inbound channel and starts from the machine's -- initial state; messages broadcast during the restart window are lost. runController :: Bus -> Controller -> IO Void runController bus (Controller _name machine) = do inbound <- dupTChanIO (busInbound bus) go inbound machine where go inbound f = do msg <- atomically (readTChan inbound) (_, f') <- step (channelHassEval bus) f (Event msg) go inbound f' defaultMain :: IO () defaultMain = withSocketsDo $ do token <- getEnv "HA_TOKEN" bus <- newBus 0 mapConcurrently_ id $ [ readerAction "last-resort-redux" 8123 token bus , writerAction bus ] ++ map (runController bus) controllers dryRunHassEval :: CallIdGen -> HASSEff a -> IO a dryRunHassEval gen = \case CallService x -> do callId <- generateCallId gen print (callId, x) Pure a -> pure a ``` - [ ] **Step 4: Regenerate derivation and run tests** Run: `nix run nixpkgs#cabal2nix -- ./. > default.nix && nix develop -c cabal build && nix develop -c cabal test` Expected: zero warnings; 7 examples, 0 failures. - [ ] **Step 5: Commit** ```bash git add src test home-assistant-controller.cabal default.nix git commit -m "Run controllers on their own threads over the bus" ``` --- ### Task 4: Supervisor module (starts slice 2) **Files:** - Create: `src/HomeAssistant/Runtime/Supervisor.hs` - Create: `test/SupervisorSpec.hs`, `test/BackoffProp.hs` - Modify: `test/Main.hs`, `home-assistant-controller.cabal` **Interfaces:** - Consumes: nothing from other runtime modules (generic). - Produces: `supervised :: Text -> Backoff -> IO Void -> IO Void`; `Backoff(..)` (`backoffBase`, `backoffCap`, `backoffQuiet`, all `NominalDiffTime`); `defaultBackoff` (= `Backoff 0.1 30 30`); `backoffDelay :: Backoff -> Int -> NominalDiffTime`; `nextAttempt :: Backoff -> NominalDiffTime -> Int -> Int`; `Fatal (..)` (`newtype Fatal = Fatal Text`). **Background (verified, do not re-verify):** `Control.Exception.Annotated` is built on safe-exceptions: its `catch`/`catches`/`try` only catch *synchronous* exceptions, so async exceptions propagate without any manual `SomeAsyncException` filtering. `catch @(AnnotatedException e)` sees through a single `AnnotatedException` wrapper (bare `e` gets an empty annotation set), which is how the `Fatal` handler below works. **Invariant (document in code):** actions passed to `supervised` must not be wrapped in `checkpoint`s around `Fatal`-throwing code — a double-wrapped `Fatal` is invisible to the handler and would restart instead of crashing. The reader honors this (Task 5). - [ ] **Step 1: Write the failing tests** `test/SupervisorSpec.hs`: ```haskell module SupervisorSpec (spec) where import Control.Concurrent (newEmptyMVar, putMVar, readMVar, threadDelay) import Control.Concurrent.Async (async, cancel, poll, waitCatch) import Control.Exception (SomeException, fromException) import Control.Exception.Annotated (AnnotatedException (..), throw) import Control.Monad (forever) import Data.IORef (atomicModifyIORef', newIORef, readIORef) import Data.Maybe (isJust, isNothing) import HomeAssistant.Runtime.Supervisor import System.IO.Error (ioError, userError) import Test.Hspec tinyBackoff :: Backoff tinyBackoff = Backoff 0.001 0.002 0.001 spec :: Spec spec = describe "supervised" $ do it "restarts a crashing action until it stays up" $ do counter <- newIORef (0 :: Int) up <- newEmptyMVar let action = do n <- atomicModifyIORef' counter (\c -> (c + 1, c + 1)) if n < 3 then ioError (userError "boom") else do putMVar up (); forever (threadDelay 1000000) sup <- async (supervised "test" tinyBackoff action) readMVar up threadDelay 50000 status <- poll sup isNothing status `shouldBe` True readIORef counter `shouldReturn` 3 cancel sup it "rethrows Fatal instead of restarting" $ do counter <- newIORef (0 :: Int) let action = do atomicModifyIORef' counter (\c -> (c + 1, c + 1)) throw (Fatal "auth_invalid") sup <- async (supervised "test" tinyBackoff action) res <- waitCatch sup case res of Left se -> case fromException se :: Maybe (AnnotatedException Fatal) of Just _ -> pure () Nothing -> expectationFailure "expected Fatal to propagate" Right _ -> expectationFailure "supervised returned" threadDelay 50000 readIORef counter `shouldReturn` 1 it "does not restart on async exceptions" $ do counter <- newIORef (0 :: Int) let action = do atomicModifyIORef' counter (\c -> (c + 1, c + 1)) forever (threadDelay 1000000) sup <- async (supervised "test" tinyBackoff action) threadDelay 100000 cancel sup threadDelay 100000 readIORef counter `shouldReturn` 1 status <- poll sup isJust status `shouldBe` True describe "nextAttempt" $ do it "resets after a quiet period" $ nextAttempt tinyBackoff 0.001 5 `shouldBe` 1 it "increments otherwise" $ nextAttempt tinyBackoff 0.0005 5 `shouldBe` 6 describe "backoffDelay" $ do it "starts at base" $ backoffDelay tinyBackoff 1 `shouldBe` 0.001 it "doubles" $ backoffDelay tinyBackoff 2 `shouldBe` 0.002 it "clamps at cap" $ backoffDelay tinyBackoff 3 `shouldBe` 0.002 ``` `test/BackoffProp.hs`: ```haskell module BackoffProp (spec) where import Data.Time (NominalDiffTime) import Hedgehog import qualified Hedgehog.Gen as Gen import qualified Hedgehog.Range as Range import HomeAssistant.Runtime.Supervisor (Backoff (..), backoffDelay) import Test.Hspec (Spec, describe) spec :: Spec spec = describe "backoffDelay" $ prop "doubles from base, clamped at cap" $ property $ do baseD <- forAll $ Gen.double (Range.constant 0.0001 10) ratio <- forAll $ Gen.double (Range.constant 1 100) let base = realToFrac baseD :: NominalDiffTime cap = realToFrac (baseD * ratio) :: NominalDiffTime backoff = Backoff base cap 1 delays = map (backoffDelay backoff) [1 .. 100 :: Int] head delays === min cap base mapM_ (\(a, b) -> b === min cap (a * 2)) (zip delays (drop 1 delays)) ``` `test/Main.hs`: add imports and run all five specs. Cabal: library `exposed-modules` += `HomeAssistant.Runtime.Supervisor`, library `build-depends` += `annotated-exception`; test `other-modules` += `SupervisorSpec, BackoffProp`, test `build-depends` += `hedgehog, annotated-exception, time`. - [ ] **Step 2: Run tests to verify they fail** Run: `nix develop -c cabal test` Expected: FAIL — `Could not find module 'HomeAssistant.Runtime.Supervisor'`. - [ ] **Step 3: Write the implementation** `src/HomeAssistant/Runtime/Supervisor.hs`: ```haskell {-# LANGUAGE ScopedTypeVariables #-} module HomeAssistant.Runtime.Supervisor ( supervised , Backoff(..) , defaultBackoff , backoffDelay , nextAttempt , Fatal(..) ) where import Control.Concurrent (threadDelay) import Control.Exception.Annotated ( Exception , Handler (..) , SomeException , catches , displayException , throw ) import Data.Text (Text) import qualified Data.Text as T import Data.Time (NominalDiffTime, diffUTCTime, getCurrentTime) import Data.Void (Void) -- | A failure that cannot be fixed by restarting; propagates out of -- 'supervised' and terminates the process. newtype Fatal = Fatal Text deriving (Show, Exception) data Backoff = Backoff { backoffBase :: NominalDiffTime -- ^ Delay before the first restart , backoffCap :: NominalDiffTime -- ^ Maximum delay between restarts , backoffQuiet :: NominalDiffTime -- ^ Uptime after which the delay resets } deriving (Eq, Show) defaultBackoff :: Backoff defaultBackoff = Backoff 0.1 30 30 -- | Delay before the @attempt@-th restart: doubles from base, clamped at cap. backoffDelay :: Backoff -> Int -> NominalDiffTime backoffDelay (Backoff base cap _) attempt = go (attempt - 1) base where go 0 d = d go n d = go (n - 1) (min cap (d * 2)) -- | Attempt number to use after a crash that ran for the given uptime. nextAttempt :: Backoff -> NominalDiffTime -> Int -> Int nextAttempt (Backoff _ _ quiet) uptime attempt | uptime >= quiet = 1 | otherwise = attempt + 1 -- | Runs the action forever, restarting it with backoff after synchronous -- exceptions; async exceptions propagate. 'Fatal' is rethrown (crashing the -- caller) rather than restarted. The action must never return normally and -- must not be wrapped in checkpoints around 'Fatal'-throwing code: a -- doubly-wrapped 'Fatal' is indistinguishable from a crash and would be -- restarted instead of escalated. supervised :: Text -> Backoff -> IO Void -> IO Void supervised name backoff action = go 1 where go attempt = do start <- getCurrentTime outcome <- (action >> pure (Nothing :: Maybe (Either Fatal SomeException))) `catches` [ Handler $ \(f :: Fatal) -> pure (Just (Left f)) , Handler $ \(e :: SomeException) -> pure (Just (Right e)) ] case outcome of Nothing -> error "unreachable: supervised action returned" Just (Left f) -> throw f Just (Right e) -> do putStrLn $ "[" <> T.unpack name <> "] attempt " <> show attempt <> " crashed: " <> displayException e let delay = backoffDelay backoff attempt putStrLn $ "[" <> T.unpack name <> "] restarting in " <> show delay <> "s" threadDelay (round (realToFrac delay * 1000000)) end <- getCurrentTime go (nextAttempt backoff (diffUTCTime end start) attempt) ``` - [ ] **Step 4: Regenerate derivation and run tests** Run: `nix run nixpkgs#cabal2nix -- ./. > default.nix && nix develop -c cabal build && nix develop -c cabal test` Expected: zero warnings; 15 examples and 1 property, 0 failures. - [ ] **Step 5: Commit** ```bash git add src test home-assistant-controller.cabal default.nix git commit -m "Add supervisor with backoff restarts" ``` --- ### Task 5: Supervised wiring and fatal auth (completes slice 2) **Files:** - Modify: `src/HomeAssistant/Runtime.hs` (defaultMain only) - Modify: `src/HomeAssistant/Runtime/Connection.hs` (Fatal in handshake) **Interfaces:** - Consumes: `supervised`, `defaultBackoff`, `Fatal` (Task 4). - Produces: no new exports; `defaultMain` behavior changes: workers are supervised, auth failure exits the process with `Fatal`. **Background:** `waitAny` rethrows the exception of the first completed async. Supervised workers only complete by rethrowing `Fatal`, so `waitAny` blocks forever in normal operation and propagates `Fatal` otherwise. - [ ] **Step 1: Change `defaultMain` in `src/HomeAssistant/Runtime.hs`** Update imports: replace `mapConcurrently_` with `async, waitAny` from `Control.Concurrent.Async`; add `absurd` to the `Data.Void` import; add `import HomeAssistant.Runtime.Supervisor (defaultBackoff, supervised)`. Replace `defaultMain` with: ```haskell defaultMain :: IO () defaultMain = withSocketsDo $ do token <- getEnv "HA_TOKEN" bus <- newBus 0 let workers = [ ("reader", readerAction "last-resort-redux" 8123 token bus) , ("writer", writerAction bus) ] ++ [ (name, runController bus c) | c@(Controller name _) <- controllers ] as <- mapM (\(name, act) -> async (supervised name defaultBackoff act)) workers (_, v) <- waitAny as absurd v ``` - [ ] **Step 2: Make handshake failures fatal in `src/HomeAssistant/Runtime/Connection.hs`** Add imports: `import HomeAssistant.Runtime.Supervisor (Fatal)` and `throw` from `Control.Exception.Annotated`. Change `expectType` and `receiveJSON`: ```haskell expectType :: T.Text -> Value -> IO () expectType expected msg = case msg ^? key "type" . _String of Just t | t == expected -> pure () _ -> throw (Fatal $ "expected " <> expected <> ", got: " <> T.pack (show msg)) ``` ```haskell receiveJSON :: WS.Connection -> IO Value receiveJSON conn = do msg <- WS.receiveData conn case eitherDecode msg of Left err -> throw (Fatal $ "Invalid JSON from Home Assistant: " <> T.pack err) Right x -> pure x ``` The `fail`-based behavior (crash the process) is preserved for slice 1's semantics but now carries a `Fatal` marker the supervisor escalates. Do **not** add checkpoints around the handshake — see the `supervised` contract. - [ ] **Step 3: Build and test** Run: `nix develop -c cabal build && nix develop -c cabal test` Expected: zero warnings, zero test failures. - [ ] **Step 4: Manual smoke check (optional, needs real HA)** Run: `HA_TOKEN=... nix develop -c cabal run home-assistant-controller` (only if a Home Assistant instance is reachable; otherwise skip — unit tests cover the wiring logic). - [ ] **Step 5: Commit** ```bash git add src git commit -m "Supervise workers and make auth failures fatal" ``` --- ## Verification (all tasks) - `nix develop -c cabal build` — zero warnings under `-Wall`. - `nix develop -c cabal test` — all specs green. - `git status` clean after each commit. ## Non-goals (from spec) - Request/response correlation for service calls; dynamic controller registration; structured logging; env-based host/port config; state pre-seeding via `get_states`; multiple websocket connections. ## Gaps accepted by spec - Reader's connect/auth/subscribe loop has no integration test (thin IO glue over `WS.runClient`; localhost fake-server scaffolding judged not worth the complexity). - `Fatal` detection relies on the no-checkpoints-around-handshake invariant (documented in `supervised`'s contract and Task 5).