← Back to task

Commit 96d9eb0f

commit 96d9eb0f5847ed4aebdb86365c4c5cad1d9e9286
Author: Coder Agent <coder@agents.omni>
Date:   Wed Feb 18 13:43:40 2026

    Pipeline: comment-based agent-human communication
    
    Add DevNeedsInput variant to DevResult for when agents exit cleanly
    without committing (i.e., they posted a question comment instead).
    
    Changes:
    - Core.hs: Add DevNeedsInput Text to DevResult type
    - Dev.hs: Include all task comments in dev prompt; add needs-input
      instructions (rule 8); checkForCommitOrInput returns DevNeedsInput
      on clean exit with no commit
    - Pipeline.hs: Handle DevNeedsInput in harvest phase (sets NeedsHelp);
      add re-spawn logic that detects new Human comments on NeedsHelp tasks
      and transitions them back to Open for dev; add 4 new tests for comment
      rendering, needs-input instructions, and no-comments case
    - State.hs: Add getLastDevRunTime query for comment timestamp comparison
    - dev.md: Add 'Asking for Clarification' section with protocol
    
    Task-Id: t-634

diff --git a/Omni/Ide/Workflows/dev.md b/Omni/Ide/Workflows/dev.md
index f0234e7b..11a3d576 100644
--- a/Omni/Ide/Workflows/dev.md
+++ b/Omni/Ide/Workflows/dev.md
@@ -98,6 +98,22 @@ After successful verification and commit/amend:
    task update t-XXX review --json
    ```
 
+## Asking for Clarification
+
+If the task description is ambiguous or missing critical details needed to implement:
+
+1. Post a comment with your specific questions:
+   ```bash
+   task comment t-XXX 'Your specific questions here' --author agent:engineer --json
+   ```
+2. Exit cleanly (exit code 0) **without committing**.
+3. The pipeline will detect the clean exit with no commit, set the task to `NeedsHelp`, and wait for a human response.
+4. Once the human answers via a comment, the pipeline re-spawns you with the full comment history.
+
+**When to ask vs. proceed:**
+- Ask when the task is genuinely ambiguous (multiple valid interpretations) or missing key details (e.g., no target file, unclear scope).
+- Do NOT ask for trivial clarifications you can reasonably infer from context, code, or conventions.
+
 ## Failure Handling
 
 If you cannot complete safely:
diff --git a/Omni/Pipeline.hs b/Omni/Pipeline.hs
index 7d5d4c56..bae4ff48 100755
--- a/Omni/Pipeline.hs
+++ b/Omni/Pipeline.hs
@@ -91,7 +91,90 @@ test =
                 }
             prompt = Dev.buildDevPrompt "live" task 1 (Just "Build failed: missing import")
         Test.assertBool "prompt contains feedback" (T.isInfixOf "missing import" prompt)
-        Test.assertBool "prompt mentions previous rejection" (T.isInfixOf "previous patchset was rejected" (T.toLower prompt))
+        Test.assertBool "prompt mentions previous rejection" (T.isInfixOf "previous patchset was rejected" (T.toLower prompt)),
+      --
+      Test.unit "buildDevPrompt includes comments" <| do
+        let task =
+              Task.Task
+                { Task.taskId = "t-777",
+                  Task.taskTitle = "Task with comments",
+                  Task.taskType = Task.WorkTask,
+                  Task.taskParent = Nothing,
+                  Task.taskNamespace = Just "Omni/Foo.hs",
+                  Task.taskStatus = Task.NeedsHelp,
+                  Task.taskPatchsetCount = 0,
+                  Task.taskPriority = Task.P2,
+                  Task.taskComplexity = Nothing,
+                  Task.taskDependencies = [],
+                  Task.taskDescription = "Do the thing",
+                  Task.taskComments =
+                    [ Task.Comment
+                        { Task.commentText = "What format should the output be?",
+                          Task.commentAuthor = Task.Agent Task.Engineer,
+                          Task.commentCreatedAt = Read.read "2026-01-01 01:00:00 UTC"
+                        },
+                      Task.Comment
+                        { Task.commentText = "Use JSON output",
+                          Task.commentAuthor = Task.Human,
+                          Task.commentCreatedAt = Read.read "2026-01-01 02:00:00 UTC"
+                        }
+                    ],
+                  Task.taskTags = Task.Tags [],
+                  Task.taskCreatedAt = Read.read "2026-01-01 00:00:00 UTC",
+                  Task.taskUpdatedAt = Read.read "2026-01-01 02:00:00 UTC"
+                }
+            prompt = Dev.buildDevPrompt "live" task 0 Nothing
+        Test.assertBool "prompt contains Comments section" (T.isInfixOf "### Comments" prompt)
+        Test.assertBool "prompt contains agent question" (T.isInfixOf "What format should the output be?" prompt)
+        Test.assertBool "prompt contains human answer" (T.isInfixOf "Use JSON output" prompt)
+        Test.assertBool "prompt contains human author tag" (T.isInfixOf "[human]" prompt)
+        Test.assertBool "prompt contains agent author tag" (T.isInfixOf "[agent:engineer]" prompt),
+      --
+      Test.unit "buildDevPrompt includes needs-input instructions" <| do
+        let task =
+              Task.Task
+                { Task.taskId = "t-666",
+                  Task.taskTitle = "Ambiguous task",
+                  Task.taskType = Task.WorkTask,
+                  Task.taskParent = Nothing,
+                  Task.taskNamespace = Nothing,
+                  Task.taskStatus = Task.Open,
+                  Task.taskPatchsetCount = 0,
+                  Task.taskPriority = Task.P2,
+                  Task.taskComplexity = Nothing,
+                  Task.taskDependencies = [],
+                  Task.taskDescription = "Do something",
+                  Task.taskComments = [],
+                  Task.taskTags = Task.Tags [],
+                  Task.taskCreatedAt = Read.read "2026-01-01 00:00:00 UTC",
+                  Task.taskUpdatedAt = Read.read "2026-01-01 00:00:00 UTC"
+                }
+            prompt = Dev.buildDevPrompt "live" task 0 Nothing
+        Test.assertBool "prompt contains needs-input instruction" (T.isInfixOf "ambiguous or missing critical details" prompt)
+        Test.assertBool "prompt mentions task comment command" (T.isInfixOf "task comment" prompt)
+        Test.assertBool "prompt mentions exit 0" (T.isInfixOf "exit 0 without committing" prompt),
+      --
+      Test.unit "buildDevPrompt omits Comments section when no comments" <| do
+        let task =
+              Task.Task
+                { Task.taskId = "t-555",
+                  Task.taskTitle = "No comments task",
+                  Task.taskType = Task.WorkTask,
+                  Task.taskParent = Nothing,
+                  Task.taskNamespace = Just "Omni/Bar.hs",
+                  Task.taskStatus = Task.Open,
+                  Task.taskPatchsetCount = 0,
+                  Task.taskPriority = Task.P2,
+                  Task.taskComplexity = Nothing,
+                  Task.taskDependencies = [],
+                  Task.taskDescription = "Simple task",
+                  Task.taskComments = [],
+                  Task.taskTags = Task.Tags [],
+                  Task.taskCreatedAt = Read.read "2026-01-01 00:00:00 UTC",
+                  Task.taskUpdatedAt = Read.read "2026-01-01 00:00:00 UTC"
+                }
+            prompt = Dev.buildDevPrompt "live" task 0 Nothing
+        Test.assertBool "prompt does not contain Comments section" (not (T.isInfixOf "### Comments" prompt))
     ]
 
 -- ============================================================================
@@ -229,6 +312,14 @@ runOneCycle cfg db pool activeDevs = do
         STM.atomically <| STM.modifyTVar' activeDevs (Map.delete tid)
         -- Immediately verify
         doVerify cfg db ad
+      Core.DevNeedsInput reason -> do
+        logMsg ("Dev needs input for " <> tid <> ": " <> reason)
+        cost <- Dev.getRunCost (Core.adRunId ad)
+        State.markRunFinished db (Core.adRunDbId ad) Core.Success (Just cost) (Just reason)
+        _ <- Task.addComment tid ("Pipeline: agent needs input (run=" <> Core.adRunId ad <> ")") Task.System
+        -- Remove from active, set task to NeedsHelp
+        STM.atomically <| STM.modifyTVar' activeDevs (Map.delete tid)
+        Task.updateTaskStatus tid Task.NeedsHelp []
       Core.DevFailed err -> do
         logMsg ("Dev failed for " <> tid <> ": " <> err)
         cost <- Dev.getRunCost (Core.adRunId ad)
@@ -238,8 +329,33 @@ runOneCycle cfg db pool activeDevs = do
         STM.atomically <| STM.modifyTVar' activeDevs (Map.delete tid)
         Task.updateTaskStatus tid Task.Open []
 
-  -- 3. DEVELOP: spawn agents for Open tasks
-  let open = byStatus Task.Open
+  -- 3. RE-SPAWN: check NeedsHelp tasks for new human comments
+  let needsHelp = byStatus Task.NeedsHelp
+  forM_ needsHelp <| \task -> do
+    let tid = Task.taskId task
+    lastRun <- State.getLastDevRunTime db tid
+    let hasNewHumanComment = case lastRun of
+          Nothing -> False
+          Just runTime ->
+            any
+              ( \c ->
+                  Task.commentAuthor c == Task.Human
+                    && Task.commentCreatedAt c > runTime
+              )
+              (Task.taskComments task)
+    when hasNewHumanComment <| do
+      logMsg ("Human responded to " <> tid <> ", re-spawning dev")
+      Task.updateTaskStatus tid Task.Open []
+      _ <- Task.addComment tid "Pipeline: human responded, re-opening for dev" Task.System
+      pure ()
+
+  -- Reload tasks after NeedsHelp transitions
+  allTasks' <- if null needsHelp then pure allTasks else Task.loadTasks
+  let workTasks' = filter isEligible allTasks'
+      byStatus' s = filter (\t -> Task.taskStatus t == s) workTasks'
+
+  -- 4. DEVELOP: spawn agents for Open tasks
+  let open = byStatus' Task.Open
   slots <- Workspace.slotsAvailable pool
   currentActive <- STM.readTVarIO activeDevs
   let availableSlots = slots - Map.size currentActive
diff --git a/Omni/Pipeline/Core.hs b/Omni/Pipeline/Core.hs
index 3ec054ae..9d21bb5c 100644
--- a/Omni/Pipeline/Core.hs
+++ b/Omni/Pipeline/Core.hs
@@ -135,6 +135,7 @@ data DevResult
   = StillRunning
   | DevSuccess (Maybe Text) -- new commit SHA, if changed
   | DevFailed Text -- error message
+  | DevNeedsInput Text -- agent exited cleanly but posted a question (no commit)
   deriving (Show, Eq)
 
 -- | Workspace path for the integration worktree.
diff --git a/Omni/Pipeline/Dev.hs b/Omni/Pipeline/Dev.hs
index 5e9371b7..7ce5adf4 100644
--- a/Omni/Pipeline/Dev.hs
+++ b/Omni/Pipeline/Dev.hs
@@ -37,6 +37,11 @@ buildDevPrompt baseBranch task patchset maybeReviewFeedback =
       "5. **Do NOT change task status.** The pipeline handles transitions.",
       "6. **Do NOT push.**",
       "7. Follow repository conventions from AGENTS.md.",
+      "8. If the task description is ambiguous or missing critical details needed to implement,",
+      "   post a comment with your questions using:",
+      "   `task comment " <> Task.taskId task <> " 'your questions' --author agent:engineer --json`",
+      "   and exit 0 without committing. The pipeline will set the task to NeedsHelp",
+      "   and re-spawn you once a human answers.",
       "",
       "## Task",
       "- ID: " <> Task.taskId task,
@@ -51,6 +56,7 @@ buildDevPrompt baseBranch task patchset maybeReviewFeedback =
       "### Description",
       Task.taskDescription task,
       "",
+      comments,
       feedback,
       "### Constraints",
       "- Work only in the provided workspace.",
@@ -59,6 +65,27 @@ buildDevPrompt baseBranch task patchset maybeReviewFeedback =
     ]
   where
     ns = fromMaybe "(none)" (Task.taskNamespace task)
+    comments =
+      if null (Task.taskComments task)
+        then ""
+        else
+          T.unlines
+            ( [ "### Comments",
+                ""
+              ]
+                <> map formatComment (Task.taskComments task)
+                <> [""]
+            )
+    formatComment c =
+      "[" <> T.pack (show (Task.commentCreatedAt c)) <> "] "
+        <> "[" <> formatAuthor (Task.commentAuthor c) <> "] "
+        <> Task.commentText c
+    formatAuthor Task.Human = "human"
+    formatAuthor Task.System = "system"
+    formatAuthor (Task.Agent Task.Engineer) = "agent:engineer"
+    formatAuthor (Task.Agent Task.Reviewer) = "agent:reviewer"
+    formatAuthor (Task.Agent Task.ProductMgr) = "agent:product"
+    formatAuthor (Task.Agent Task.Designer) = "agent:designer"
     feedback = case maybeReviewFeedback of
       Nothing -> ""
       Just fb ->
@@ -196,7 +223,7 @@ checkDevStatus ad = do
     then do
       exitStr <- T.strip </ safeReadFile exitFile
       if exitStr == "0"
-        then checkForCommit ad
+        then checkForCommitOrInput ad
         else do
           logTail <- T.takeEnd 1000 </ safeReadFile logFile
           pure (Core.DevFailed ("Agent exited with code " <> exitStr <> ":\n" <> logTail))
@@ -215,6 +242,19 @@ checkDevStatus ad = do
           -- No PID file yet — still starting
           pure Core.StillRunning
 
+-- | After agent completes with exit 0, check if a new commit was produced.
+-- If no commit, return DevNeedsInput (agent asked a question instead).
+checkForCommitOrInput :: Core.ActiveDev -> IO Core.DevResult
+checkForCommitOrInput ad = do
+  afterSha <- Git.branchSha (Core.adWorkspace ad) (Core.adTaskId ad)
+  case (Core.adBeforeSha ad, afterSha) of
+    (_, Nothing) -> pure (Core.DevFailed "Task branch not found after agent run")
+    (before, Just after)
+      | before == Just after ->
+          -- Agent exited 0 but no commit — treat as needs-input
+          pure (Core.DevNeedsInput "Agent exited cleanly without committing (likely posted a question)")
+      | otherwise -> pure (Core.DevSuccess (Just after))
+
 -- | After agent completes, check if a new commit was produced.
 checkForCommit :: Core.ActiveDev -> IO Core.DevResult
 checkForCommit ad = do
diff --git a/Omni/Pipeline/State.hs b/Omni/Pipeline/State.hs
index 082d776a..c09f98a8 100644
--- a/Omni/Pipeline/State.hs
+++ b/Omni/Pipeline/State.hs
@@ -143,6 +143,20 @@ hasActiveRun conn taskId = do
     [SQL.Only n] -> pure (n > 0)
     _ -> pure False
 
+-- | Get the most recent started_at for any dev run on a task.
+-- Used to detect whether new human comments arrived after the last agent run.
+getLastDevRunTime :: SQL.Connection -> Text -> IO (Maybe UTCTime)
+getLastDevRunTime conn taskId = do
+  results <-
+    SQL.query
+      conn
+      "SELECT started_at FROM pipeline_runs WHERE task_id = ? AND phase = 'dev' ORDER BY started_at DESC LIMIT 1"
+      (SQL.Only taskId) ::
+      IO [SQL.Only UTCTime]
+  case results of
+    [SQL.Only t] -> pure (Just t)
+    _ -> pure Nothing
+
 -- | Get the most recent finished_at for a failed dev run on a given patchset.
 -- Used for exponential backoff calculation.
 getLastFailureTime :: SQL.Connection -> Text -> Int -> IO (Maybe UTCTime)