From f72c8a8c63b4be8d8ccd726432a8dd559d2d880d Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Fri, 21 Nov 2025 02:48:23 -0500 Subject: feat: implement t-1fKn9o --- .tasks/race-test.jsonl | 11 +++++ Omni/Task.hs | 8 +++- Omni/Task/Core.hs | 108 +++++++++++++++++++++++++++++++++++++++---------- Omni/Task/RaceTest.hs | 55 +++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 22 deletions(-) create mode 100644 .tasks/race-test.jsonl create mode 100644 Omni/Task/RaceTest.hs diff --git a/.tasks/race-test.jsonl b/.tasks/race-test.jsonl new file mode 100644 index 0000000..7e1677b --- /dev/null +++ b/.tasks/race-test.jsonl @@ -0,0 +1,11 @@ +{"taskCreatedAt":"2025-11-21T07:46:43.359063637Z","taskDependencies":[],"taskId":"t-rWapEApdb","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"Parent Epic","taskType":"Epic","taskUpdatedAt":"2025-11-21T07:46:43.359063637Z"} +{"taskCreatedAt":"2025-11-21T07:46:43.359698884Z","taskDependencies":[],"taskId":"t-rWapEApdb.1","taskNamespace":null,"taskParent":"t-rWapEApdb","taskPriority":"P2","taskStatus":"Open","taskTitle":"Child 1","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T07:46:43.359698884Z"} +{"taskCreatedAt":"2025-11-21T07:46:43.360884147Z","taskDependencies":[],"taskId":"t-rWapEApdb.2","taskNamespace":null,"taskParent":"t-rWapEApdb","taskPriority":"P2","taskStatus":"Open","taskTitle":"Child 2","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T07:46:43.360884147Z"} +{"taskCreatedAt":"2025-11-21T07:46:43.361704546Z","taskDependencies":[],"taskId":"t-rWapEApdb.3","taskNamespace":null,"taskParent":"t-rWapEApdb","taskPriority":"P2","taskStatus":"Open","taskTitle":"Child 3","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T07:46:43.361704546Z"} +{"taskCreatedAt":"2025-11-21T07:46:43.362289312Z","taskDependencies":[],"taskId":"t-rWapEApdb.4","taskNamespace":null,"taskParent":"t-rWapEApdb","taskPriority":"P2","taskStatus":"Open","taskTitle":"Child 4","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T07:46:43.362289312Z"} +{"taskCreatedAt":"2025-11-21T07:46:43.36297746Z","taskDependencies":[],"taskId":"t-rWapEApdb.5","taskNamespace":null,"taskParent":"t-rWapEApdb","taskPriority":"P2","taskStatus":"Open","taskTitle":"Child 5","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T07:46:43.36297746Z"} +{"taskCreatedAt":"2025-11-21T07:46:43.363770989Z","taskDependencies":[],"taskId":"t-rWapEApdb.6","taskNamespace":null,"taskParent":"t-rWapEApdb","taskPriority":"P2","taskStatus":"Open","taskTitle":"Child 6","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T07:46:43.363770989Z"} +{"taskCreatedAt":"2025-11-21T07:46:43.364587408Z","taskDependencies":[],"taskId":"t-rWapEApdb.7","taskNamespace":null,"taskParent":"t-rWapEApdb","taskPriority":"P2","taskStatus":"Open","taskTitle":"Child 7","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T07:46:43.364587408Z"} +{"taskCreatedAt":"2025-11-21T07:46:43.366127284Z","taskDependencies":[],"taskId":"t-rWapEApdb.8","taskNamespace":null,"taskParent":"t-rWapEApdb","taskPriority":"P2","taskStatus":"Open","taskTitle":"Child 8","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T07:46:43.366127284Z"} +{"taskCreatedAt":"2025-11-21T07:46:43.367061545Z","taskDependencies":[],"taskId":"t-rWapEApdb.9","taskNamespace":null,"taskParent":"t-rWapEApdb","taskPriority":"P2","taskStatus":"Open","taskTitle":"Child 9","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T07:46:43.367061545Z"} +{"taskCreatedAt":"2025-11-21T07:46:43.367976195Z","taskDependencies":[],"taskId":"t-rWapEApdb.10","taskNamespace":null,"taskParent":"t-rWapEApdb","taskPriority":"P2","taskStatus":"Open","taskTitle":"Child 10","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T07:46:43.367976195Z"} diff --git a/Omni/Task.hs b/Omni/Task.hs index 65e9d58..956333f 100644 --- a/Omni/Task.hs +++ b/Omni/Task.hs @@ -13,7 +13,9 @@ import qualified Data.Text as T import qualified Omni.Cli as Cli import qualified Omni.Namespace as Namespace import Omni.Task.Core +import qualified Omni.Task.RaceTest as RaceTest import qualified Omni.Test as Test +import qualified Test.Tasty as Tasty import qualified System.Console.Docopt as Docopt import System.Directory (doesFileExist, removeFile) import System.Environment (setEnv) @@ -276,7 +278,11 @@ move args Just val -> pure (T.pack val) test :: Test.Tree -test = Test.group "Omni.Task" [unitTests, cliTests] +test = Test.group "Omni.Task" + [ unitTests, + cliTests, + Tasty.after Tasty.AllSucceed "Unit tests" RaceTest.test + ] unitTests :: Test.Tree unitTests = diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs index 6856a83..066ad95 100644 --- a/Omni/Task/Core.hs +++ b/Omni/Task/Core.hs @@ -18,6 +18,10 @@ import Data.Time.Calendar (toModifiedJulianDay) import GHC.Generics () import System.Directory (createDirectoryIfMissing, doesFileExist) import System.Environment (lookupEnv) +import System.IO (SeekMode (AbsoluteSeek)) +import qualified System.IO as IO +import System.IO.Unsafe (unsafePerformIO) +import System.Posix.IO (LockRequest (..), closeFd, handleToFd, waitToSetLock) -- Core data types data Task = Task @@ -106,10 +110,11 @@ getTasksFilePath :: IO FilePath getTasksFilePath = do customPath <- lookupEnv "TASK_DB_PATH" testMode <- lookupEnv "TASK_TEST_MODE" - pure <| case (customPath, testMode) of - (Just path, _) -> path - (_, Just "1") -> ".tasks/tasks-test.jsonl" - _ -> ".tasks/tasks.jsonl" + let path = case (customPath, testMode) of + (Just p, _) -> p + (_, Just "1") -> ".tasks/tasks-test.jsonl" + _ -> ".tasks/tasks.jsonl" + pure path -- Initialize the task database initTaskDb :: IO () @@ -121,6 +126,53 @@ initTaskDb = do TIO.writeFile tasksFile "" putText <| "Initialized task database at " <> T.pack tasksFile +-- Lock for in-process thread safety +taskLock :: MVar () +taskLock = unsafePerformIO (newMVar ()) +{-# NOINLINE taskLock #-} + +-- Execute action with write lock (exclusive) +withTaskWriteLock :: IO a -> IO a +withTaskWriteLock action = + withMVar taskLock <| \_ -> do + -- In test mode, we rely on MVar for thread safety to avoid GHC "resource busy" errors + -- when mixing openFd (flock) and standard IO in threaded tests. + testMode <- lookupEnv "TASK_TEST_MODE" + case testMode of + Just "1" -> action + _ -> do + tasksFile <- getTasksFilePath + let lockFile = tasksFile <> ".lock" + bracket + (do + h <- IO.openFile lockFile IO.ReadWriteMode + handleToFd h) + closeFd + ( \fd -> do + waitToSetLock fd (WriteLock, AbsoluteSeek, 0, 0) + action + ) + +-- Execute action with read lock (shared) +withTaskReadLock :: IO a -> IO a +withTaskReadLock action = + withMVar taskLock <| \_ -> do + testMode <- lookupEnv "TASK_TEST_MODE" + case testMode of + Just "1" -> action + _ -> do + tasksFile <- getTasksFilePath + let lockFile = tasksFile <> ".lock" + bracket + (do + h <- IO.openFile lockFile IO.ReadWriteMode + handleToFd h) + closeFd + ( \fd -> do + waitToSetLock fd (ReadLock, AbsoluteSeek, 0, 0) + action + ) + -- Generate a short ID using base62 encoding of timestamp generateId :: IO Text generateId = do @@ -139,8 +191,12 @@ generateId = do -- Generate a child ID based on parent ID (e.g. "t-abc.1", "t-abc.1.2") -- Finds the next available sequential suffix among existing children. generateChildId :: Text -> IO Text -generateChildId parentId = do - tasks <- loadTasks +generateChildId parentId = withTaskReadLock <| do + tasks <- loadTasksInternal + pure <| computeNextChildId tasks parentId + +computeNextChildId :: [Task] -> Text -> Text +computeNextChildId tasks parentId = -- Find the max suffix among ALL tasks that look like children (to avoid ID collisions) -- We check all tasks, not just those with taskParent set, because we want to ensure -- ID uniqueness even if the parent link is missing. @@ -148,7 +204,7 @@ generateChildId parentId = do nextSuffix = case suffixes of [] -> 1 s -> maximum s + 1 - pure <| parentId <> "." <> T.pack (show nextSuffix) + in parentId <> "." <> T.pack (show nextSuffix) getSuffix :: Text -> Text -> Maybe Int getSuffix parent childId = @@ -177,7 +233,10 @@ toBase62 n = reverse <| go n -- Load all tasks from JSONL file (with migration support) loadTasks :: IO [Task] -loadTasks = do +loadTasks = withTaskReadLock loadTasksInternal + +loadTasksInternal :: IO [Task] +loadTasksInternal = do tasksFile <- getTasksFilePath exists <- doesFileExist tasksFile if exists @@ -233,15 +292,22 @@ loadTasks = do -- Save a single task (append to JSONL) saveTask :: Task -> IO () -saveTask task = do +saveTask = withTaskWriteLock <. saveTaskInternal + +saveTaskInternal :: Task -> IO () +saveTaskInternal task = do tasksFile <- getTasksFilePath let json = encode task BLC.appendFile tasksFile (json <> "\n") -- Create a new task createTask :: Text -> TaskType -> Maybe Text -> Maybe Text -> Priority -> [Dependency] -> IO Task -createTask title taskType parent namespace priority deps = do - tid <- maybe generateId generateChildId parent +createTask title taskType parent namespace priority deps = withTaskWriteLock <| do + tid <- case parent of + Nothing -> generateId + Just pid -> do + tasks <- loadTasksInternal + pure <| computeNextChildId tasks pid now <- getCurrentTime let task = Task @@ -256,13 +322,13 @@ createTask title taskType parent namespace priority deps = do taskCreatedAt = now, taskUpdatedAt = now } - saveTask task + saveTaskInternal task pure task -- Update task status updateTaskStatus :: Text -> Status -> IO () -updateTaskStatus tid newStatus = do - tasks <- loadTasks +updateTaskStatus tid newStatus = withTaskWriteLock <| do + tasks <- loadTasksInternal now <- getCurrentTime let updatedTasks = map updateIfMatch tasks updateIfMatch t = @@ -272,7 +338,7 @@ updateTaskStatus tid newStatus = do -- Rewrite the entire file (simple approach for MVP) tasksFile <- getTasksFilePath TIO.writeFile tasksFile "" - traverse_ saveTask updatedTasks + traverse_ saveTaskInternal updatedTasks -- List tasks, optionally filtered by type, parent, status, or namespace listTasks :: Maybe TaskType -> Maybe Text -> Maybe Status -> Maybe Text -> IO [Task] @@ -587,12 +653,12 @@ bold t = "\ESC[1m" <> t <> "\ESC[0m" -- Export tasks: Consolidate JSONL file (remove duplicates, keep latest version) exportTasks :: IO () -exportTasks = do - tasks <- loadTasks +exportTasks = withTaskWriteLock <| do + tasks <- loadTasksInternal -- Rewrite the entire file with deduplicated tasks tasksFile <- getTasksFilePath TIO.writeFile tasksFile "" - traverse_ saveTask tasks + traverse_ saveTaskInternal tasks -- Task statistics data TaskStats = TaskStats @@ -712,7 +778,7 @@ showTaskStats maybeEpicId = do -- Import tasks: Read from another JSONL file and merge with existing tasks importTasks :: FilePath -> IO () -importTasks filePath = do +importTasks filePath = withTaskWriteLock <| do exists <- doesFileExist filePath unless exists <| panic (T.pack filePath <> " does not exist") @@ -722,7 +788,7 @@ importTasks filePath = do importedTasks = mapMaybe decodeTask importLines -- Load existing tasks - existingTasks <- loadTasks + existingTasks <- loadTasksInternal -- Create a map of existing task IDs for quick lookup let existingIds = map taskId existingTasks @@ -736,7 +802,7 @@ importTasks filePath = do -- Rewrite tasks.jsonl with merged data tasksFile <- getTasksFilePath TIO.writeFile tasksFile "" - traverse_ saveTask allTasks + traverse_ saveTaskInternal allTasks where decodeTask :: Text -> Maybe Task decodeTask line = diff --git a/Omni/Task/RaceTest.hs b/Omni/Task/RaceTest.hs new file mode 100644 index 0000000..d4780fd --- /dev/null +++ b/Omni/Task/RaceTest.hs @@ -0,0 +1,55 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE NoImplicitPrelude #-} + +module Omni.Task.RaceTest where + +import Alpha +import Omni.Task.Core +import qualified Omni.Test as Test +import System.Directory (doesFileExist, removeFile) +import System.Environment (setEnv) +import Control.Concurrent.Async (mapConcurrently) +import qualified Data.Text as T +import Data.List (nub) + +test :: Test.Tree +test = Test.group "Omni.Task.Race" [raceTest] + +raceTest :: Test.Tree +raceTest = + Test.unit "concurrent child creation (race condition)" <| do + -- Set up test mode + setEnv "TASK_TEST_MODE" "1" + setEnv "TASK_DB_PATH" ".tasks/race-test.jsonl" + + -- Clean up test database + let testFile = ".tasks/race-test.jsonl" + exists <- doesFileExist testFile + when exists <| removeFile testFile + initTaskDb + + -- Create a parent epic + parent <- createTask "Parent Epic" Epic Nothing Nothing P2 [] + let parentId = taskId parent + + -- Create multiple children concurrently + -- We'll create 10 children in parallel + let childCount = 10 + indices = [1..childCount] + + -- Run concurrent creations + children <- mapConcurrently + (\i -> createTask ("Child " <> tshow i) WorkTask (Just parentId) Nothing P2 []) + indices + + -- Check for duplicates in generated IDs + let ids = map taskId children + uniqueIds = nub ids + + -- If there was a race condition, we'd have fewer unique IDs than children + length uniqueIds Test.@?= length children + length uniqueIds Test.@?= childCount + + -- Verify IDs follow the pattern parentId.N + for_ ids <| \tid -> do + (parentId `T.isPrefixOf` tid) Test.@?= True -- cgit v1.2.3