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