← Back to task

Commit b3a7429d

commit b3a7429d150720d321b9f7c1024e9b421d292e52
Author: Coder Agent <coder@agents.omni>
Date:   Mon Apr 6 21:19:37 2026

    agent: handle SIGINT cancel and SIGTERM graceful stop
    
    Install POSIX signal handlers once per process run and thread signal state
    through agent execution.
    
    SIGINT now maps to SteeringCancel via the existing Op.check steering path.
    This cleanly cancels the current turn and, in stdin mode, returns to reading
    next NUL-delimited prompts.
    
    SIGTERM now requests graceful shutdown without mid-turn cancellation.
    In stdin mode we stop accepting new prompts after the current turn completes.
    
    Also ignore SIGHUP and keep EOF-trailing prompt flush behavior.
    
    Task-Id: t-759.2

diff --git a/Omni/Agent.hs b/Omni/Agent.hs
index 6d2b85b9..1efe1304 100755
--- a/Omni/Agent.hs
+++ b/Omni/Agent.hs
@@ -222,15 +222,44 @@ runParser =
           optOnEvent = Nothing
         }
 
+-- | Process-level signal state for graceful control.
+data SignalState = SignalState
+  { ssSigIntRequested :: IORef.IORef Bool,
+    ssSigTermRequested :: IORef.IORef Bool
+  }
+
+installSignalHandlers :: IO SignalState
+installSignalHandlers = do
+  sigIntRequested <- IORef.newIORef False
+  sigTermRequested <- IORef.newIORef False
+  _ <- Signals.installHandler Signals.sigINT (Signals.Catch (IORef.writeIORef sigIntRequested True)) Nothing
+  _ <- Signals.installHandler Signals.sigTERM (Signals.Catch (IORef.writeIORef sigTermRequested True)) Nothing
+  _ <- Signals.installHandler Signals.sigHUP Signals.Ignore Nothing
+  pure
+    SignalState
+      { ssSigIntRequested = sigIntRequested,
+        ssSigTermRequested = sigTermRequested
+      }
+
+consumeSignalFlag :: IORef.IORef Bool -> IO Bool
+consumeSignalFlag ref =
+  IORef.atomicModifyIORef'
+    ref
+    ( \value ->
+        let wasSet = value
+         in (False, wasSet)
+    )
+
 -- | Execute agent run with parsed options
 runWithOptions :: AgentOptions -> IO ()
-runWithOptions baseOpts =
+runWithOptions baseOpts = do
+  signalState <- installSignalHandlers
   case optPromptArg baseOpts of
-    Just _ -> runOneShotWithPromptArg baseOpts
-    Nothing -> runNullDelimitedStdinMode baseOpts
+    Just _ -> runOneShotWithPromptArg signalState baseOpts
+    Nothing -> runNullDelimitedStdinMode signalState baseOpts
 
-runOneShotWithPromptArg :: AgentOptions -> IO ()
-runOneShotWithPromptArg baseOpts = do
+runOneShotWithPromptArg :: SignalState -> AgentOptions -> IO ()
+runOneShotWithPromptArg signalState baseOpts = do
   -- Get prompt from arg
   -- If arg is a file path (e.g., shebang invocation), read file content
   let promptArg = fromMaybe "" (optPromptArg baseOpts)
@@ -273,11 +302,12 @@ runOneShotWithPromptArg baseOpts = do
     TextIO.hPutStrLn IO.stderr <| "  Prompt: " <> Text.take 100 (optPrompt opts)
     Exit.exitSuccess
 
-  result <- runAgent opts
+  initialState <- loadInitialAgentState opts
+  (result, _finalState) <- runAgentWithState signalState opts initialState
   emitRunResult opts True result
 
-runNullDelimitedStdinMode :: AgentOptions -> IO ()
-runNullDelimitedStdinMode baseOpts = do
+runNullDelimitedStdinMode :: SignalState -> AgentOptions -> IO ()
+runNullDelimitedStdinMode signalState baseOpts = do
   let opts =
         baseOpts
           { optPrompt = "",
@@ -294,24 +324,38 @@ runNullDelimitedStdinMode baseOpts = do
     Exit.exitSuccess
 
   initialState <- loadInitialAgentState opts
-  runNullDelimitedPromptLoop (opts {optResume = Nothing}) initialState ""
+  runNullDelimitedPromptLoop signalState (opts {optResume = Nothing}) initialState ""
 
-runNullDelimitedPromptLoop :: AgentOptions -> OpAgent.AgentState -> BS.ByteString -> IO ()
-runNullDelimitedPromptLoop opts sessionState buffered = do
-  inputChunk <- BS.hGetSome IO.stdin 4096
-  if BS.null inputChunk
-    then do
-      -- Intentional: EOF flushes any buffered trailing bytes as a final prompt,
-      -- so prompts may be delimited by \0 or EOF.
-      _ <- runPromptChunk opts sessionState buffered
-      pure ()
+runNullDelimitedPromptLoop :: SignalState -> AgentOptions -> OpAgent.AgentState -> BS.ByteString -> IO ()
+runNullDelimitedPromptLoop signalState opts sessionState buffered = do
+  shouldStop <- IORef.readIORef (ssSigTermRequested signalState)
+  if shouldStop
+    then pure ()
     else do
-      let (promptChunks, remainder) = splitNullDelimitedPrompts (buffered <> inputChunk)
-      sessionState' <- foldM (runPromptChunk opts) sessionState promptChunks
-      runNullDelimitedPromptLoop opts sessionState' remainder
-
-runPromptChunk :: AgentOptions -> OpAgent.AgentState -> BS.ByteString -> IO OpAgent.AgentState
-runPromptChunk opts sessionState promptBytes
+      inputChunk <- BS.hGetSome IO.stdin 4096
+      if BS.null inputChunk
+        then do
+          -- Intentional: EOF flushes any buffered trailing bytes as a final prompt,
+          -- so prompts may be delimited by \0 or EOF.
+          _ <- runPromptChunk signalState opts sessionState buffered
+          pure ()
+        else do
+          let (promptChunks, remainder) = splitNullDelimitedPrompts (buffered <> inputChunk)
+          sessionState' <- processPromptChunks signalState opts sessionState promptChunks
+          shouldStop' <- IORef.readIORef (ssSigTermRequested signalState)
+          unless shouldStop' <| runNullDelimitedPromptLoop signalState opts sessionState' remainder
+
+processPromptChunks :: SignalState -> AgentOptions -> OpAgent.AgentState -> [BS.ByteString] -> IO OpAgent.AgentState
+processPromptChunks _ _ sessionState [] = pure sessionState
+processPromptChunks signalState opts sessionState (promptBytes : rest) = do
+  sessionState' <- runPromptChunk signalState opts sessionState promptBytes
+  shouldStop <- IORef.readIORef (ssSigTermRequested signalState)
+  if shouldStop
+    then pure sessionState'
+    else processPromptChunks signalState opts sessionState' rest
+
+runPromptChunk :: SignalState -> AgentOptions -> OpAgent.AgentState -> BS.ByteString -> IO OpAgent.AgentState
+runPromptChunk signalState opts sessionState promptBytes
   | BS.null promptBytes = pure sessionState
   | otherwise =
       case TE.decodeUtf8' promptBytes of
@@ -319,7 +363,7 @@ runPromptChunk opts sessionState promptBytes
           TextIO.hPutStrLn IO.stderr <| "Error: stdin prompt is not valid UTF-8: " <> tshow err
           Exit.exitWith (Exit.ExitFailure 1)
         Right prompt -> do
-          (result, sessionState') <- runAgentWithState (opts {optPrompt = prompt, optPromptPath = Nothing}) sessionState
+          (result, sessionState') <- runAgentWithState signalState (opts {optPrompt = prompt, optPromptPath = Nothing}) sessionState
           emitRunResult opts False result
           pure sessionState'
 
@@ -691,8 +735,9 @@ applyWorkflowMeta opts meta =
 -- | Run the agent with given options
 runAgent :: AgentOptions -> IO AgentResult
 runAgent opts = do
+  signalState <- installSignalHandlers
   initialState <- loadInitialAgentState opts
-  (result, _finalState) <- runAgentWithState opts initialState
+  (result, _finalState) <- runAgentWithState signalState opts initialState
   pure result
 
 loadInitialAgentState :: AgentOptions -> IO OpAgent.AgentState
@@ -709,8 +754,8 @@ loadInitialAgentState opts =
           TextIO.hPutStrLn IO.stderr <| "[resume] Resuming from checkpoint: " <> cpName cp
           pure (cpState cp)
 
-runAgentWithState :: AgentOptions -> OpAgent.AgentState -> IO (AgentResult, OpAgent.AgentState)
-runAgentWithState opts initialState = do
+runAgentWithState :: SignalState -> AgentOptions -> OpAgent.AgentState -> IO (AgentResult, OpAgent.AgentState)
+runAgentWithState signalState opts initialState = do
   let verbosity = optVerbosity opts
       emitTraceEvent :: Trace.Event -> IO ()
       emitTraceEvent event = do
@@ -732,14 +777,6 @@ runAgentWithState opts initialState = do
       emitError :: Text -> IO (AgentResult, OpAgent.AgentState)
       emitError = emitErrorWithState initialState
 
-  shutdownRequested <- IORef.newIORef False
-  shutdownHandled <- IORef.newIORef False
-  _ <-
-    Signals.installHandler
-      Signals.sigTERM
-      (Signals.Catch (IORef.writeIORef shutdownRequested True))
-      Nothing
-
   workflowParsed <-
     case parseWorkflowFrontmatter (optPrompt opts) of
       Left err -> pure <| Left <| "Invalid workflow frontmatter: " <> err
@@ -942,20 +979,14 @@ runAgentWithState opts initialState = do
                     logVerbose event
 
                   checkShutdown :: IO (Maybe Text)
-                  checkShutdown = do
-                    requested <- IORef.readIORef shutdownRequested
-                    if not requested
-                      then pure Nothing
-                      else do
-                        handled <- IORef.readIORef shutdownHandled
-                        if handled
-                          then pure Nothing
-                          else do
-                            IORef.writeIORef shutdownHandled True
-                            pure (Just "SIGTERM")
+                  checkShutdown = pure Nothing
 
                   checkSteering :: IO (Maybe Op.Steering)
-                  checkSteering = checkSteeringFile mSteeringPath
+                  checkSteering = do
+                    cancelRequested <- consumeSignalFlag (ssSigIntRequested signalState)
+                    if cancelRequested
+                      then pure (Just Op.SteeringCancel)
+                      else checkSteeringFile mSteeringPath
 
                   onCheckpoint :: Text -> OpAgent.AgentState -> Trace.Trace -> IO (Either Text ())
                   onCheckpoint cpName' cpState' cpTrace' =
@@ -1001,12 +1032,8 @@ runAgentWithState opts initialState = do
 
               -- Run agent
               result <- Seq.runSequential seqConfig initialState (OpAgent.runAgent opConfig promptBody)
-              shutdownRequested' <- IORef.readIORef shutdownRequested
               case result of
-                Left err ->
-                  if shutdownRequested' && err == "Cancelled by user"
-                    then pure (AgentSuccess "", initialState)
-                    else emitError err
+                Left err -> emitError err
                 Right (agentResult, _trace, finalState) ->
                   case OpAgent.arError agentResult of
                     Just err -> emitErrorWithState finalState err
@@ -1014,12 +1041,9 @@ runAgentWithState opts initialState = do
                       let response = OpAgent.arFinalMessage agentResult
                           maxIterReached = optMaxIter opts' > 0 && response == "Maximum iterations reached"
                           cancelled = response == "Cancelled by user"
-                       in if cancelled && shutdownRequested'
-                            then pure (AgentSuccess "", finalState)
-                            else
-                              if maxIterReached || cancelled
-                                then emitErrorWithState finalState response
-                                else pure (AgentSuccess response, finalState)
+                       in if maxIterReached || cancelled
+                            then emitErrorWithState finalState response
+                            else pure (AgentSuccess response, finalState)
 
 engineToolToSeq :: Engine.Tool -> Seq.Tool
 engineToolToSeq tool =