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 =