← Back to task

Commit f4a2c7f5

commit f4a2c7f5cebaf99c8ee37b789b45432fcd3daeee
Author: Coder Agent <coder@agents.omni>
Date:   Thu Apr 9 11:15:37 2026

    agentd: add updated_at to agent status model and DB updates
    
    Add aiUpdatedAt to AgentInfo and include updated_at in status JSON output.
    Extend the agents schema with updated_at and add a safe migration for
    existing databases.
    
    Populate updated_at from SELECT queries and FromRow decoding.
    Track updated_at on every agent row mutation by touching timestamp state
    on status/summary/title/pid updates and setting it inline on started/
    completed transitions.
    
    Also set updated_at on agent inserts and on persistent stop updates.
    Add a JSON serialization assertion that AgentInfo emits updated_at.
    
    Task-Id: t-764

diff --git a/Omni/Agentd/Daemon.hs b/Omni/Agentd/Daemon.hs
index 0563d0d8..c3bdfa2a 100644
--- a/Omni/Agentd/Daemon.hs
+++ b/Omni/Agentd/Daemon.hs
@@ -306,6 +306,7 @@ data AgentInfo = AgentInfo
     aiWorkspace :: Text,
     aiPid :: Maybe Int,
     aiStartedAt :: Maybe Time.UTCTime,
+    aiUpdatedAt :: Maybe Time.UTCTime,
     aiCompletedAt :: Maybe Time.UTCTime,
     aiError :: Maybe Text,
     aiSummary :: Maybe Text,
@@ -394,6 +395,7 @@ instance ToJSON AgentInfo where
         "workspace" .= aiWorkspace info,
         "pid" .= aiPid info,
         "started_at" .= aiStartedAt info,
+        "updated_at" .= aiUpdatedAt info,
         "completed_at" .= aiCompletedAt info,
         "error" .= aiError info,
         "summary" .= aiSummary info,
@@ -486,6 +488,7 @@ initDb conn = do
     \  pid INTEGER,\
     \  status TEXT NOT NULL DEFAULT 'pending',\
     \  started_at TEXT,\
+    \  updated_at TEXT,\
     \  completed_at TEXT,\
     \  error TEXT,\
     \  summary TEXT,\
@@ -512,6 +515,7 @@ initDb conn = do
         _ <- try @SomeException (SQL.execute_ conn sql)
         pure ()
   addColumn "ALTER TABLE agents ADD COLUMN pid INTEGER"
+  addColumn "ALTER TABLE agents ADD COLUMN updated_at TEXT"
   addColumn "ALTER TABLE agents ADD COLUMN summary TEXT"
   addColumn "ALTER TABLE agents ADD COLUMN cost_cents INTEGER"
   addColumn "ALTER TABLE agents ADD COLUMN webhook_url TEXT"
@@ -601,10 +605,19 @@ initDb conn = do
 
 insertAgent :: SQL.Connection -> Text -> Text -> Text -> Maybe Text -> IO ()
 insertAgent conn runId prompt workspace webhookUrl = do
+  now <- Time.getCurrentTime
+  SQL.execute
+    conn
+    "INSERT INTO agents (run_id, prompt, workspace, status, webhook_url, updated_at) VALUES (?, ?, ?, 'pending', ?, ?)"
+    (runId, prompt, workspace, webhookUrl, tshow now)
+
+touchUpdated :: SQL.Connection -> Text -> IO ()
+touchUpdated conn runId = do
+  now <- Time.getCurrentTime
   SQL.execute
     conn
-    "INSERT INTO agents (run_id, prompt, workspace, status, webhook_url) VALUES (?, ?, ?, 'pending', ?)"
-    (runId, prompt, workspace, webhookUrl)
+    "UPDATE agents SET updated_at = ? WHERE run_id = ?"
+    (tshow now, runId)
 
 updateAgentPid :: SQL.Connection -> Text -> Int -> IO ()
 updateAgentPid conn runId pid = do
@@ -612,6 +625,7 @@ updateAgentPid conn runId pid = do
     conn
     "UPDATE agents SET pid = ? WHERE run_id = ?"
     (pid, runId)
+  touchUpdated conn runId
 
 updateAgentStatus :: SQL.Connection -> Text -> Text -> IO ()
 updateAgentStatus conn runId status = do
@@ -619,21 +633,22 @@ updateAgentStatus conn runId status = do
     conn
     "UPDATE agents SET status = ? WHERE run_id = ?"
     (status, runId)
+  touchUpdated conn runId
 
 updateAgentStarted :: SQL.Connection -> Text -> Time.UTCTime -> IO ()
 updateAgentStarted conn runId startTime = do
   SQL.execute
     conn
-    "UPDATE agents SET status = 'running', started_at = ? WHERE run_id = ?"
-    (tshow startTime, runId)
+    "UPDATE agents SET status = 'running', started_at = ?, updated_at = ? WHERE run_id = ?"
+    (tshow startTime, tshow startTime, runId)
 
 updateAgentCompleted :: SQL.Connection -> Text -> Time.UTCTime -> Maybe Text -> Maybe Text -> Maybe Int -> IO ()
 updateAgentCompleted conn runId endTime mError mSummary mCost = do
   let status = maybe "completed" (const "failed") mError
   SQL.execute
     conn
-    "UPDATE agents SET status = ?, completed_at = ?, error = ?, summary = ?, cost_cents = ? WHERE run_id = ?"
-    (status :: Text, tshow endTime, mError, mSummary, mCost, runId)
+    "UPDATE agents SET status = ?, completed_at = ?, updated_at = ?, error = ?, summary = ?, cost_cents = ? WHERE run_id = ?"
+    (status :: Text, tshow endTime, tshow endTime, mError, mSummary, mCost, runId)
 
 updateAgentIdle :: SQL.Connection -> Text -> Maybe Text -> Maybe Int -> IO ()
 updateAgentIdle conn runId mSummary mCost = do
@@ -641,6 +656,7 @@ updateAgentIdle conn runId mSummary mCost = do
     conn
     "UPDATE agents SET status = 'idle', summary = ?, cost_cents = ? WHERE run_id = ?"
     (mSummary, mCost, runId)
+  touchUpdated conn runId
 
 updateAgentSummary :: SQL.Connection -> Text -> Maybe Text -> Maybe Int -> IO ()
 updateAgentSummary conn runId mSummary mCost = do
@@ -648,6 +664,7 @@ updateAgentSummary conn runId mSummary mCost = do
     conn
     "UPDATE agents SET summary = ?, cost_cents = ? WHERE run_id = ?"
     (mSummary, mCost, runId)
+  touchUpdated conn runId
 
 updateAgentTitle :: SQL.Connection -> Text -> Text -> IO ()
 updateAgentTitle conn runId title = do
@@ -655,13 +672,14 @@ updateAgentTitle conn runId title = do
     conn
     "UPDATE agents SET title = ? WHERE run_id = ?"
     (title, runId)
+  touchUpdated conn runId
 
 updateAgentCompletedAt :: SQL.Connection -> Text -> Time.UTCTime -> IO ()
 updateAgentCompletedAt conn runId endTime = do
   SQL.execute
     conn
-    "UPDATE agents SET status = 'completed', completed_at = ? WHERE run_id = ?"
-    (tshow endTime, runId)
+    "UPDATE agents SET status = 'completed', completed_at = ?, updated_at = ? WHERE run_id = ?"
+    (tshow endTime, tshow endTime, runId)
 
 insertWorkspace ::
   SQL.Connection ->
@@ -790,7 +808,7 @@ getAgent conn runId = do
   results <-
     SQL.query @_ @AgentInfo
       conn
-      "SELECT run_id, status, prompt, workspace, pid, started_at, completed_at, error, summary, cost_cents, title FROM agents WHERE run_id = ?"
+      "SELECT run_id, status, prompt, workspace, pid, started_at, updated_at, completed_at, error, summary, cost_cents, title FROM agents WHERE run_id = ?"
       (SQL.Only runId)
   case results of
     [] -> pure Nothing
@@ -805,6 +823,7 @@ instance SQL.FromRow AgentInfo where
       <*> SQL.field -- workspace
       <*> SQL.field -- pid
       <*> (parseTime </ SQL.field) -- started_at
+      <*> (parseTime </ SQL.field) -- updated_at
       <*> (parseTime </ SQL.field) -- completed_at
       <*> SQL.field -- error
       <*> SQL.field -- summary
@@ -815,7 +834,7 @@ getAllAgents :: SQL.Connection -> IO [AgentInfo]
 getAllAgents conn =
   SQL.query_ @AgentInfo
     conn
-    "SELECT run_id, status, prompt, workspace, pid, started_at, completed_at, error, summary, cost_cents, title FROM agents ORDER BY created_at DESC"
+    "SELECT run_id, status, prompt, workspace, pid, started_at, updated_at, completed_at, error, summary, cost_cents, title FROM agents ORDER BY created_at DESC"
 
 parseTime :: Maybe Text -> Maybe Time.UTCTime
 parseTime Nothing = Nothing
@@ -902,6 +921,7 @@ defaultAgentInfo cfg =
       aiWorkspace = Text.pack (acCwd cfg),
       aiPid = Nothing,
       aiStartedAt = Nothing,
+      aiUpdatedAt = Nothing,
       aiCompletedAt = Nothing,
       aiError = Nothing,
       aiSummary = Nothing,
@@ -1051,12 +1071,13 @@ createAgent mDbPath cfg = do
       Nothing -> do
         _ <- writeAgentEnvFile normalizedCfg
         _ <- runSystemctlUser ["daemon-reload"]
+        now <- Time.getCurrentTime
         let workspaceText = Text.pack (acCwd normalizedCfg)
         insertResult <-
           try @SomeException
             <| SQL.execute
               conn
-              "INSERT INTO agents (run_id, prompt, workspace, status, provider, model, cwd, thinking, extra_args, extra_env) VALUES (?, '', ?, 'stopped', ?, ?, ?, ?, ?, ?)"
+              "INSERT INTO agents (run_id, prompt, workspace, status, provider, model, cwd, thinking, extra_args, extra_env, updated_at) VALUES (?, '', ?, 'stopped', ?, ?, ?, ?, ?, ?, ?)"
               ( runId,
                 workspaceText,
                 acProvider normalizedCfg,
@@ -1064,7 +1085,8 @@ createAgent mDbPath cfg = do
                 workspaceText,
                 acThinking normalizedCfg,
                 acExtraArgs normalizedCfg,
-                encodeExtraEnv (acExtraEnv normalizedCfg)
+                encodeExtraEnv (acExtraEnv normalizedCfg),
+                tshow now
               )
         case insertResult of
           Left err -> pure (Left ("Failed to create agent: " <> tshow err))
@@ -1127,7 +1149,7 @@ stopPersistentAgent mDbPath runId =
           Left err -> pure (Left err)
           Right () -> do
             now <- Time.getCurrentTime
-            SQL.execute conn "UPDATE agents SET status = 'stopped', completed_at = ? WHERE run_id = ?" (tshow now, runId)
+            SQL.execute conn "UPDATE agents SET status = 'stopped', completed_at = ?, updated_at = ? WHERE run_id = ?" (tshow now, tshow now, runId)
             Right </ hydratePersistentAgent conn cfg
 
 restartPersistentAgent :: Maybe FilePath -> Text -> IO (Either Text PersistentAgent)
@@ -2031,6 +2053,7 @@ test =
                   aiWorkspace = "/home/test",
                   aiPid = Just 12345,
                   aiStartedAt = Nothing,
+                  aiUpdatedAt = Nothing,
                   aiCompletedAt = Nothing,
                   aiError = Nothing,
                   aiSummary = Just "Test summary",
@@ -2039,7 +2062,9 @@ test =
                 }
         case Aeson.decode (Aeson.encode info) :: Maybe Aeson.Value of
           Nothing -> Test.assertFailure "Failed to serialize AgentInfo"
-          Just _ -> pure (),
+          Just (Aeson.Object obj) ->
+            Test.assertBool "updated_at key should be present" (isJust (KeyMap.lookup "updated_at" obj))
+          Just _ -> Test.assertFailure "Expected object JSON for AgentInfo",
       Test.group
         "integration"
         [ Test.unit "spawn completes and sends webhook" integrationSpawnWebhook,