diff options
| author | Ben Sima <ben@bensima.com> | 2025-11-25 23:22:13 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-11-25 23:22:13 -0500 |
| commit | 01de0612b51b64077d10d05268e89f5e2b3b8001 (patch) | |
| tree | ab47e82acba4dcf5cb64b52cf5dcc5b6661403fe /Omni | |
| parent | 2e4bfbffba747efcee6c5de25f8a5325b36859c4 (diff) | |
jr: implement Gerrit-style conflict handling
- Add RetryContext to track failed attempts (merge conflicts,
rejections) - jr review checks for clean cherry-pick before showing
diff - If conflict detected, kicks back to coder with context -
Worker prompt includes retry context (attempt count, conflict files,
reason) - After 3 failed attempts, marks task for human intervention
Task-Id: t-1o2g8gudqlx
Diffstat (limited to 'Omni')
| -rw-r--r-- | Omni/Agent/Worker.hs | 34 | ||||
| -rw-r--r-- | Omni/Jr.hs | 125 | ||||
| -rw-r--r-- | Omni/Task/Core.hs | 77 |
3 files changed, 216 insertions, 20 deletions
diff --git a/Omni/Agent/Worker.hs b/Omni/Agent/Worker.hs index 89800e4..1cdeb6d 100644 --- a/Omni/Agent/Worker.hs +++ b/Omni/Agent/Worker.hs @@ -84,7 +84,10 @@ processTask worker task = do runAmp :: FilePath -> TaskCore.Task -> IO (Exit.ExitCode, Text) runAmp repo task = do - let prompt = + -- Check for retry context + maybeRetry <- TaskCore.getRetryContext (TaskCore.taskId task) + + let basePrompt = "You are a Worker Agent.\n" <> "Your goal is to implement the following task:\n\n" <> formatTask task @@ -103,6 +106,35 @@ runAmp repo task = do <> fromMaybe "root" (TaskCore.taskNamespace task) <> "'.\n" + -- Add retry context if present + let retryPrompt = case maybeRetry of + Nothing -> "" + Just ctx -> + "\n\n## RETRY CONTEXT (IMPORTANT)\n\n" + <> "This task was previously attempted but failed. Attempt: " + <> tshow (TaskCore.retryAttempt ctx) + <> "/3\n" + <> "Reason: " + <> TaskCore.retryReason ctx + <> "\n\n" + <> ( if null (TaskCore.retryConflictFiles ctx) + then "" + else + "Conflicting files from previous attempt:\n" + <> Text.unlines (map (" - " <>) (TaskCore.retryConflictFiles ctx)) + <> "\n" + ) + <> "Original commit: " + <> TaskCore.retryOriginalCommit ctx + <> "\n\n" + <> "INSTRUCTIONS FOR RETRY:\n" + <> "- The codebase has changed since your last attempt\n" + <> "- Re-implement this task on top of the CURRENT codebase\n" + <> "- If there were merge conflicts, the conflicting files may have been modified by others\n" + <> "- Review the current state of those files before making changes\n" + + let prompt = basePrompt <> retryPrompt + let logFile = repo </> "_/llm/amp.log" -- Read AGENTS.md @@ -4,6 +4,7 @@ -- : out jr -- : dep sqlite-simple +-- : dep sqids module Omni.Jr where import Alpha @@ -143,26 +144,112 @@ reviewTask tid = do (x : _) -> x [] -> "" - putText "\n=== Diff for this task ===\n" - _ <- Process.rawSystem "git" ["show", commitSha] - - putText "\n[a]ccept / [r]eject / [s]kip? " - IO.hFlush IO.stdout - choice <- getLine - - case Text.toLower choice of - c - | "a" `Text.isPrefixOf` c -> do - TaskCore.updateTaskStatus tid TaskCore.Done [] - putText ("Task " <> tid <> " marked as Done.") - | "r" `Text.isPrefixOf` c -> do - putText "Enter rejection reason: " - IO.hFlush IO.stdout - reason <- getLine + -- Check for merge conflicts before showing diff + conflictResult <- checkMergeConflict commitSha + case conflictResult of + Just conflictFiles -> do + putText "\n=== MERGE CONFLICT DETECTED ===" + putText "This commit cannot be cleanly applied to live." + putText "Conflicting files:" + traverse_ (\f -> putText (" - " <> f)) conflictFiles + putText "" + putText "Kicking back to coder with context..." + + -- Get current retry count + maybeCtx <- TaskCore.getRetryContext tid + let attempt = maybe 1 (\c -> TaskCore.retryAttempt c + 1) maybeCtx + + if attempt > 3 + then do + putText "\nTask has failed 3 times. Marking as NeedsHuman." + -- For now, just mark as Open with a note (no NeedsHuman status yet) + TaskCore.updateTaskStatus tid TaskCore.Open [] + putText ("Task " <> tid <> " needs human intervention (3 failed attempts).") + else do + -- Save retry context + TaskCore.setRetryContext + TaskCore.RetryContext + { TaskCore.retryTaskId = tid, + TaskCore.retryOriginalCommit = Text.pack commitSha, + TaskCore.retryConflictFiles = conflictFiles, + TaskCore.retryAttempt = attempt, + TaskCore.retryReason = "merge_conflict" + } TaskCore.updateTaskStatus tid TaskCore.Open [] - putText ("Task " <> tid <> " reopened.") - putText ("Reason: " <> reason) - | otherwise -> putText "Skipped; no status change." + putText ("Task " <> tid <> " returned to queue (attempt " <> tshow attempt <> "/3).") + Nothing -> do + -- No conflict, proceed with normal review + putText "\n=== Diff for this task ===\n" + _ <- Process.rawSystem "git" ["show", commitSha] + + putText "\n[a]ccept / [r]eject / [s]kip? " + IO.hFlush IO.stdout + choice <- getLine + + case Text.toLower choice of + c + | "a" `Text.isPrefixOf` c -> do + TaskCore.clearRetryContext tid + TaskCore.updateTaskStatus tid TaskCore.Done [] + putText ("Task " <> tid <> " marked as Done.") + | "r" `Text.isPrefixOf` c -> do + putText "Enter rejection reason: " + IO.hFlush IO.stdout + reason <- getLine + -- Save rejection as retry context + maybeCtx <- TaskCore.getRetryContext tid + let attempt = maybe 1 (\ctx -> TaskCore.retryAttempt ctx + 1) maybeCtx + TaskCore.setRetryContext + TaskCore.RetryContext + { TaskCore.retryTaskId = tid, + TaskCore.retryOriginalCommit = Text.pack commitSha, + TaskCore.retryConflictFiles = [], + TaskCore.retryAttempt = attempt, + TaskCore.retryReason = "rejected: " <> reason + } + TaskCore.updateTaskStatus tid TaskCore.Open [] + putText ("Task " <> tid <> " reopened (attempt " <> tshow attempt <> "/3).") + | otherwise -> putText "Skipped; no status change." + +-- | Check if a commit can be cleanly cherry-picked onto live +-- Returns Nothing if clean, Just [conflicting files] if conflict +checkMergeConflict :: String -> IO (Maybe [Text]) +checkMergeConflict commitSha = do + -- Save current state + (_, _, _) <- Process.readProcessWithExitCode "git" ["branch", "--show-current"] "" + (_, origHead, _) <- Process.readProcessWithExitCode "git" ["rev-parse", "HEAD"] "" + + -- Try cherry-pick + (cpCode, _, cpErr) <- + Process.readProcessWithExitCode + "git" + ["cherry-pick", "--no-commit", commitSha] + "" + + -- Always abort/reset regardless of result + _ <- Process.readProcessWithExitCode "git" ["cherry-pick", "--abort"] "" + _ <- Process.readProcessWithExitCode "git" ["reset", "--hard", List.head (List.lines origHead)] "" + + case cpCode of + Exit.ExitSuccess -> pure Nothing + Exit.ExitFailure _ -> do + -- Parse conflict files from error message + let errLines = Text.lines (Text.pack cpErr) + conflictLines = filter (Text.isPrefixOf "CONFLICT") errLines + -- Extract file names (rough parsing) + files = mapMaybe extractConflictFile conflictLines + pure (Just (if null files then ["(unknown files)"] else files)) + +extractConflictFile :: Text -> Maybe Text +extractConflictFile line = + -- CONFLICT (content): Merge conflict in path/to/file.hs + case Text.breakOn "Merge conflict in " line of + (_, rest) + | not (Text.null rest) -> Just (Text.strip (Text.drop 18 rest)) + _ -> case Text.breakOn "in " line of + (_, rest) + | not (Text.null rest) -> Just (Text.strip (Text.drop 3 rest)) + _ -> Nothing test :: Test.Tree test = diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs index 5b1551c..b28b402 100644 --- a/Omni/Task/Core.hs +++ b/Omni/Task/Core.hs @@ -3,6 +3,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE NoImplicitPrelude #-} +-- : dep sqids module Omni.Task.Core where import Alpha @@ -70,6 +71,16 @@ data TaskProgress = TaskProgress } deriving (Show, Eq, Generic) +-- Retry context for tasks that failed due to merge conflicts +data RetryContext = RetryContext + { retryTaskId :: Text, + retryOriginalCommit :: Text, + retryConflictFiles :: [Text], + retryAttempt :: Int, + retryReason :: Text -- "merge_conflict" | "ci_failure" | "rejected" + } + deriving (Show, Eq, Generic) + instance ToJSON TaskType instance FromJSON TaskType @@ -98,6 +109,10 @@ instance ToJSON TaskProgress instance FromJSON TaskProgress +instance ToJSON RetryContext + +instance FromJSON RetryContext + -- SQLite Instances instance SQL.FromField TaskType where @@ -251,6 +266,15 @@ initTaskDb = do SQL.execute_ conn "INSERT OR IGNORE INTO id_counter (id, counter) VALUES (1, 0)" + SQL.execute_ + conn + "CREATE TABLE IF NOT EXISTS retry_context (\ + \ task_id TEXT PRIMARY KEY, \ + \ original_commit TEXT NOT NULL, \ + \ conflict_files TEXT NOT NULL, \ + \ attempt INTEGER NOT NULL DEFAULT 1, \ + \ reason TEXT NOT NULL \ + \)" -- Sqids configuration: lowercase alphabet only, minimum length 8 sqidsOptions :: Sqids.SqidsOptions @@ -851,3 +875,56 @@ importTasks filePath = do if T.null line then Nothing else decode (BLC.pack <| T.unpack line) + +-- Retry context management + +-- | Get retry context for a task (if any) +getRetryContext :: Text -> IO (Maybe RetryContext) +getRetryContext tid = + withDb <| \conn -> do + rows <- + SQL.query + conn + "SELECT task_id, original_commit, conflict_files, attempt, reason FROM retry_context WHERE task_id = ?" + (SQL.Only tid) :: + IO [(Text, Text, Text, Int, Text)] + case rows of + [] -> pure Nothing + ((taskId, commit, filesJson, attempt, reason) : _) -> + let files = fromMaybe [] (decode (BLC.pack <| T.unpack filesJson)) + in pure + <| Just + RetryContext + { retryTaskId = taskId, + retryOriginalCommit = commit, + retryConflictFiles = files, + retryAttempt = attempt, + retryReason = reason + } + +-- | Set retry context for a task (upsert) +setRetryContext :: RetryContext -> IO () +setRetryContext ctx = + withDb <| \conn -> do + let filesJson = T.pack <| BLC.unpack <| encode (retryConflictFiles ctx) + SQL.execute + conn + "INSERT OR REPLACE INTO retry_context (task_id, original_commit, conflict_files, attempt, reason) VALUES (?, ?, ?, ?, ?)" + (retryTaskId ctx, retryOriginalCommit ctx, filesJson, retryAttempt ctx, retryReason ctx) + +-- | Clear retry context for a task (on successful merge) +clearRetryContext :: Text -> IO () +clearRetryContext tid = + withDb <| \conn -> + SQL.execute conn "DELETE FROM retry_context WHERE task_id = ?" (SQL.Only tid) + +-- | Increment retry attempt and return new count +incrementRetryAttempt :: Text -> IO Int +incrementRetryAttempt tid = do + maybeCtx <- getRetryContext tid + case maybeCtx of + Nothing -> pure 1 + Just ctx -> do + let newAttempt = retryAttempt ctx + 1 + setRetryContext ctx {retryAttempt = newAttempt} + pure newAttempt |
