← Back to task

Commit 24a7fdb4

commit 24a7fdb4d31daa845589ebec39e820eca420bdf9
Author: Coder Agent <coder@agents.omni>
Date:   Wed Feb 11 18:43:44 2026

    Omni/Task+Ide: add atomic claim transitions for workflow loops
    
    - add `task claim <id> <expected-status> <new-status>` CAS command
    - add Task.Core claimTask with status-guarded SQL update
    - update dev loop to claim open->in-progress before execution
    - update review loop to claim review->review-in-progress
    - update integrator loop to claim approved->integrating
    - restore claimed review/integrating statuses on failed runs
    - extend status parsing to support review-in-progress and integrating
    
    Task-Id: t-587.5

diff --git a/Omni/Ide/dev-review-release.sh b/Omni/Ide/dev-review-release.sh
index 8ed0c537..20cad1a7 100755
--- a/Omni/Ide/dev-review-release.sh
+++ b/Omni/Ide/dev-review-release.sh
@@ -160,28 +160,49 @@ select_next_task() {
   local task_filter="$2"
   local parent_filter="$3"
 
-  local jq_filter
+  local jq_filter tid status
   case "$role" in
     dev)
       # shellcheck disable=SC2016
       jq_filter='.[] | select(.taskType == "WorkTask" and (.taskStatus == "Open" or .taskStatus == "InProgress") and ($task_id == "" or .taskId == $task_id) and ($parent_id == "" or (.taskParent // "") == $parent_id)) | .taskId'
-      task ready --json \
-        | jq -r --arg task_id "$task_filter" --arg parent_id "$parent_filter" "$jq_filter" \
-        | head -n 1
+
+      while read -r tid; do
+        [[ -z "$tid" ]] && continue
+        status="$(current_task_status "$tid")"
+        if [[ "$status" == "Open" ]]; then
+          if task claim "$tid" open in-progress --json >/dev/null 2>&1; then
+            echo "$tid"
+            return 0
+          fi
+          continue
+        fi
+        if [[ "$status" == "InProgress" ]]; then
+          echo "$tid"
+          return 0
+        fi
+      done < <(task ready --json | jq -r --arg task_id "$task_filter" --arg parent_id "$parent_filter" "$jq_filter")
       ;;
     review)
       # shellcheck disable=SC2016
       jq_filter='.[] | select(.taskType == "WorkTask" and ($task_id == "" or .taskId == $task_id) and ($parent_id == "" or (.taskParent // "") == $parent_id)) | .taskId'
-      task list --status=review --json \
-        | jq -r --arg task_id "$task_filter" --arg parent_id "$parent_filter" "$jq_filter" \
-        | head -n 1
+      while read -r tid; do
+        [[ -z "$tid" ]] && continue
+        if task claim "$tid" review review-in-progress --json >/dev/null 2>&1; then
+          echo "$tid"
+          return 0
+        fi
+      done < <(task list --status=review --json | jq -r --arg task_id "$task_filter" --arg parent_id "$parent_filter" "$jq_filter")
       ;;
     integrator)
       # shellcheck disable=SC2016
       jq_filter='.[] | select(.taskType == "WorkTask" and ($task_id == "" or .taskId == $task_id) and ($parent_id == "" or (.taskParent // "") == $parent_id)) | .taskId'
-      task list --status=approved --json \
-        | jq -r --arg task_id "$task_filter" --arg parent_id "$parent_filter" "$jq_filter" \
-        | head -n 1
+      while read -r tid; do
+        [[ -z "$tid" ]] && continue
+        if task claim "$tid" approved integrating --json >/dev/null 2>&1; then
+          echo "$tid"
+          return 0
+        fi
+      done < <(task list --status=approved --json | jq -r --arg task_id "$task_filter" --arg parent_id "$parent_filter" "$jq_filter")
       ;;
     *)
       echo "Unknown role: $role" >&2
@@ -564,11 +585,6 @@ run_single_task() {
 
   local before_sha=""
   if [[ "$role" == "dev" ]]; then
-    local s
-    s="$(current_task_status "$tid")"
-    if [[ "$s" == "Open" ]]; then
-      task start "$tid" --json >/dev/null || true
-    fi
     before_sha="$(task_branch_sha "$workspace" "$tid")"
   fi
 
@@ -605,6 +621,15 @@ run_single_task() {
   if [[ $rc -ne 0 ]]; then
     log "Run failed for $tid (run=$run_name)"
     record_retry_attempt "$tid" "$role" "$patchset_count" "$run_name" "$max_retries" "true"
+
+    # Release role claims on failure so work can be retried.
+    if [[ "$role" == "review" && "$(current_task_status "$tid")" == "ReviewInProgress" ]]; then
+      task update "$tid" review --json >/dev/null || true
+    fi
+    if [[ "$role" == "integrator" && "$(current_task_status "$tid")" == "Integrating" ]]; then
+      task update "$tid" approved --json >/dev/null || true
+    fi
+
     return $rc
   fi
 
diff --git a/Omni/Task.hs b/Omni/Task.hs
index b1a1df95..fc47e708 100644
--- a/Omni/Task.hs
+++ b/Omni/Task.hs
@@ -72,6 +72,7 @@ parser =
         <> Cli.command "update" (Cli.info updateParser (Cli.progDesc "Update task status"))
         <> Cli.command "start" (Cli.info startParser (Cli.progDesc "Start working on a task (sets in-progress)"))
         <> Cli.command "done" (Cli.info doneParser (Cli.progDesc "Mark a task as done"))
+        <> Cli.command "claim" (Cli.info claimParser (Cli.progDesc "Atomically claim a task by status transition"))
         <> Cli.command "deps" (Cli.info depsParser (Cli.progDesc "Show dependency tree"))
         <> Cli.command "tree" (Cli.info treeParser (Cli.progDesc "Show task tree"))
         <> Cli.command "progress" (Cli.info progressParser (Cli.progDesc "Show progress for an epic"))
@@ -255,7 +256,9 @@ doEdit GlobalOpts {..} tidStr maybeTitle maybeType maybeParent maybePriority may
     Just "open" -> pure <| Just Open
     Just "in-progress" -> pure <| Just InProgress
     Just "review" -> pure <| Just Review
+    Just "review-in-progress" -> pure <| Just ReviewInProgress
     Just "approved" -> pure <| Just Approved
+    Just "integrating" -> pure <| Just Integrating
     Just "done" -> pure <| Just Done
     Just "needs-help" -> pure <| Just NeedsHelp
     Just other -> panic <| "Invalid status: " <> T.pack other
@@ -366,7 +369,9 @@ doList GlobalOpts {..} maybeType maybeParent maybeStatus maybeNamespace = do
     Just "open" -> pure <| Just Open
     Just "in-progress" -> pure <| Just InProgress
     Just "review" -> pure <| Just Review
+    Just "review-in-progress" -> pure <| Just ReviewInProgress
     Just "approved" -> pure <| Just Approved
+    Just "integrating" -> pure <| Just Integrating
     Just "done" -> pure <| Just Done
     Just "needs-help" -> pure <| Just NeedsHelp
     Just other -> panic <| "Invalid status: " <> T.pack other
@@ -511,10 +516,12 @@ doUpdate GlobalOpts {..} tidStr statusStr isVerified maybeComplexity maybeDeps m
         "open" -> Open
         "in-progress" -> InProgress
         "review" -> Review
+        "review-in-progress" -> ReviewInProgress
         "approved" -> Approved
+        "integrating" -> Integrating
         "done" -> Done
         "needs-help" -> NeedsHelp
-        _ -> panic "Invalid status. Use: draft, open, in-progress, review, approved, done, or needs-help"
+        _ -> panic "Invalid status. Use: draft, open, in-progress, review, review-in-progress, approved, integrating, done, or needs-help"
 
   -- Show verification checklist warning when marking Done without --verified
   when (newStatus == Done && not isVerified && not globalJson) <| do
@@ -570,6 +577,54 @@ doDone :: GlobalOpts -> String -> Bool -> IO ()
 doDone globalOpts tidStr isVerified =
   doUpdate globalOpts tidStr "done" isVerified Nothing Nothing Nothing
 
+-- | claim command (atomically transition status)
+claimParser :: Cli.Parser (IO ())
+claimParser =
+  doClaim
+    </ globalOptsParser
+    <*> Cli.strArgument (Cli.metavar "ID" <> Cli.help "Task ID")
+    <*> Cli.strArgument (Cli.metavar "EXPECTED_STATUS" <> Cli.help "Expected current status")
+    <*> Cli.strArgument (Cli.metavar "NEW_STATUS" <> Cli.help "New status to transition to")
+
+doClaim :: GlobalOpts -> String -> String -> String -> IO ()
+doClaim GlobalOpts {..} tidStr expectedStatusStr newStatusStr = do
+  for_ globalDb (setEnv "TASK_DB_PATH")
+  let tid = normalizeId (T.pack tidStr)
+
+  expectedStatus <- case parseStatus expectedStatusStr of
+    Nothing -> panic <| "Invalid expected status: " <> T.pack expectedStatusStr
+    Just s -> pure s
+
+  newStatus <- case parseStatus newStatusStr of
+    Nothing -> panic <| "Invalid new status: " <> T.pack newStatusStr
+    Just s -> pure s
+
+  result <- claimTask tid expectedStatus newStatus
+
+  case result of
+    ClaimSuccess -> do
+      if globalJson
+        then outputJson <| Aeson.object ["success" Aeson..= True, "taskId" Aeson..= tid, "from" Aeson..= tshow expectedStatus, "to" Aeson..= tshow newStatus]
+        else putStrLn <| "Claimed task " <> T.unpack tid <> ": " <> expectedStatusStr <> " → " <> newStatusStr
+      exitSuccess
+    ClaimFailure -> do
+      if globalJson
+        then outputJson <| Aeson.object ["success" Aeson..= False, "taskId" Aeson..= tid, "message" Aeson..= ("Task not in expected status" :: Text)]
+        else putStrLn <| "Failed to claim task " <> T.unpack tid <> " (not in status: " <> expectedStatusStr <> ")"
+      exitFailure
+
+parseStatus :: String -> Maybe Status
+parseStatus "draft" = Just Draft
+parseStatus "open" = Just Open
+parseStatus "in-progress" = Just InProgress
+parseStatus "review" = Just Review
+parseStatus "review-in-progress" = Just ReviewInProgress
+parseStatus "approved" = Just Approved
+parseStatus "integrating" = Just Integrating
+parseStatus "done" = Just Done
+parseStatus "needs-help" = Just NeedsHelp
+parseStatus _ = Nothing
+
 -- | deps command
 depsParser :: Cli.Parser (IO ())
 depsParser =
diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs
index 1995c3e3..a12a1eea 100644
--- a/Omni/Task/Core.hs
+++ b/Omni/Task/Core.hs
@@ -51,9 +51,13 @@ data Task = Task
 data TaskType = Epic | WorkTask
   deriving (Show, Eq, Read, Generic)
 
-data Status = Draft | Open | InProgress | Review | Approved | Done | NeedsHelp
+data Status = Draft | Open | InProgress | Review | ReviewInProgress | Approved | Integrating | Done | NeedsHelp
   deriving (Show, Eq, Read, Generic)
 
+-- Result of attempting to claim a task
+data ClaimResult = ClaimSuccess | ClaimFailure
+  deriving (Show, Eq, Generic)
+
 -- Priority levels (matching beads convention)
 data Priority = P0 | P1 | P2 | P3 | P4
   deriving (Show, Eq, Ord, Read, Generic)
@@ -169,6 +173,10 @@ instance ToJSON Status
 
 instance FromJSON Status
 
+instance ToJSON ClaimResult
+
+instance FromJSON ClaimResult
+
 instance ToJSON Priority
 
 instance FromJSON Priority
@@ -837,6 +845,29 @@ updateTaskStatusWithActor tid newStatus newDeps actor =
           sessionId <- getOrCreateCommentSession tid
           insertAgentEvent tid sessionId "status_change" content actor
 
+-- | Atomically claim a task by transitioning from expected status to new status.
+-- Returns ClaimSuccess if the task was in the expected status and transition succeeded.
+-- Returns ClaimFailure if the task was not in the expected status (already claimed by another loop).
+claimTask :: Text -> Status -> Status -> IO ClaimResult
+claimTask tid expectedStatus newStatus =
+  withTaskLock <| do
+    result <-
+      withDb <| \conn -> do
+        now <- getCurrentTime
+        -- Atomic compare-and-swap: only update if current status matches expected
+        SQL.execute
+          conn
+          "UPDATE tasks SET status = ?, updated_at = ? WHERE id = ? AND status = ?"
+          (newStatus, now, tid, expectedStatus)
+        changes <- SQL.changes conn
+        pure <| if changes == 1 then ClaimSuccess else ClaimFailure
+    -- Log the status change event on success
+    when (result == ClaimSuccess && expectedStatus /= newStatus) <| do
+      let content = "{\"from\":\"" <> T.pack (show expectedStatus) <> "\",\"to\":\"" <> T.pack (show newStatus) <> "\"}"
+      sessionId <- getOrCreateCommentSession tid
+      insertAgentEvent tid sessionId "status_change" content System
+    pure result
+
 -- | Increment patchset_count for a task and return the new value.
 incrementTaskPatchsetCount :: Text -> IO (Maybe Int)
 incrementTaskPatchsetCount tid =
@@ -1066,7 +1097,9 @@ showTaskTree maybeId = do
               Open -> "[ ]"
               InProgress -> "[~]"
               Review -> "[?]"
+              ReviewInProgress -> "[R]"
               Approved -> "[+]"
+              Integrating -> "[I]"
               Done -> "[✓]"
               NeedsHelp -> "[!]"
 
@@ -1077,7 +1110,9 @@ showTaskTree maybeId = do
               Open -> bold statusStr
               InProgress -> yellow statusStr
               Review -> magenta statusStr
+              ReviewInProgress -> magenta statusStr
               Approved -> green statusStr
+              Integrating -> cyan statusStr
               Done -> green statusStr
               NeedsHelp -> yellow statusStr
 
@@ -1136,7 +1171,9 @@ printTask t = do
               Open -> bold s
               InProgress -> yellow s
               Review -> magenta s
+              ReviewInProgress -> magenta s
               Approved -> green s
+              Integrating -> cyan s
               Done -> green s
               NeedsHelp -> yellow s