← Back to task

Commit 95a04b80

commit 95a04b80c4e61797eb882fe2087499fb5496443d
Author: Coder Agent <coder@agents.omni>
Date:   Wed Feb 11 15:18:27 2026

    Omni/Agentd+Ide: harden workflow execution and recovery
    
    - fix agentd workflow prompt handling in containers
      (path mapping, -- separator, pipefail)
    - improve dev-review-release dirty workspace recovery
      and retry behavior
    - tune dev/reviewer/integrator workflow frontmatter and docs
      for dogfood usage
    - remove oisd nsfw filter from Omni/Dev/Vpn.nix
    
    Task-Id: t-584
    Task-Id: t-585
    Task-Id: t-586

diff --git a/Omni/Agentd.hs b/Omni/Agentd.hs
index 6647200e..803b0ca8 100755
--- a/Omni/Agentd.hs
+++ b/Omni/Agentd.hs
@@ -58,7 +58,7 @@ import qualified Options.Applicative as Opt
 import qualified System.Directory as Dir
 import qualified System.Environment as Env
 import qualified System.Exit as Exit
-import System.FilePath ((</>))
+import System.FilePath (makeRelative, (</>))
 import qualified System.IO as IO
 import qualified System.Posix.User as Posix
 import qualified System.Process as Process
@@ -735,6 +735,9 @@ runWorkflow spec verbose = do
   coderoot <- Env.getEnv "CODEROOT"
   home <- Env.getEnv "HOME"
   workspaceAbs <- Dir.makeAbsolute (specWorkspace spec)
+  hasNixStore <- Dir.doesDirectoryExist "/nix/store"
+  hasTaskDb <- Dir.doesDirectoryExist "/var/lib/omni"
+  hasRepoGit <- Dir.doesPathExist (coderoot </> ".git")
 
   -- Build docker command
   let image = toolchainImage (specToolchain spec)
@@ -746,6 +749,23 @@ runWorkflow spec verbose = do
       iterFlag = maybe [] (\i -> ["--max-iter=" <> show i]) (specMaxIterations spec)
       agentFlags = modelFlag ++ providerFlag ++ costFlag ++ iterFlag
 
+      extraMounts =
+        concat
+          [ if hasNixStore then ["-v", "/nix/store:/nix/store:ro"] else [],
+            if hasTaskDb then ["-v", "/var/lib/omni:/var/lib/omni"] else [],
+            if hasRepoGit then ["-v", coderoot <> "/.git:" <> coderoot <> "/.git"] else []
+          ]
+
+      extraEnv =
+        concat
+          [ ["-e", "PATH=/repo/_/bin:/bin"],
+            ["-e", "CODEROOT=/repo"],
+            ["-e", "GIT_CONFIG_COUNT=1"],
+            ["-e", "GIT_CONFIG_KEY_0=safe.directory"],
+            ["-e", "GIT_CONFIG_VALUE_0=*"],
+            if hasTaskDb then ["-e", "TASK_DB_PATH=/var/lib/omni/tasks.db"] else []
+          ]
+
       -- Docker run command
       dockerArgs =
         [ "run",
@@ -756,33 +776,39 @@ runWorkflow spec verbose = do
           workspaceAbs <> ":/workspace",
           -- Mount repo (ro)
           "-v",
-          coderoot <> ":/repo:ro",
-          -- Mount agent auth (rw) to default location - needs write for token refresh
-          "-v",
-          home <> "/.local/share/agent:/root/.local/share/agent",
-          -- Set HOME so agent finds auth at /root/.local/share/agent
-          "-e",
-          "HOME=/root",
-          -- Pass through API keys
-          "-e",
-          "ANTHROPIC_API_KEY",
-          "-e",
-          "OPENROUTER_API_KEY",
-          "-e",
-          "KAGI_API_KEY",
-          -- Set locale for UTF-8 output
-          "-e",
-          "LANG=C.UTF-8",
-          -- Set working directory
-          "-w",
-          "/workspace",
-          -- Image
-          Text.unpack image,
-          -- Command: agent with task
-          "agent"
+          coderoot <> ":/repo:ro"
         ]
+          ++ extraMounts
+          ++ [
+               -- Mount agent auth (rw) to default location - needs write for token refresh
+               "-v",
+               home <> "/.local/share/agent:/root/.local/share/agent",
+               -- Set HOME so agent finds auth at /root/.local/share/agent
+               "-e",
+               "HOME=/root",
+               -- Pass through API keys
+               "-e",
+               "ANTHROPIC_API_KEY",
+               "-e",
+               "OPENROUTER_API_KEY",
+               "-e",
+               "KAGI_API_KEY"
+             ]
+          ++ extraEnv
+          ++ [
+               -- Set locale for UTF-8 output
+               "-e",
+               "LANG=C.UTF-8",
+               -- Set working directory
+               "-w",
+               "/workspace",
+               -- Image
+               Text.unpack image,
+               -- Command: agent with task
+               "agent"
+             ]
           ++ agentFlags
-          ++ [Text.unpack (specTask spec)]
+          ++ ["--", Text.unpack (specTask spec)]
 
   when verbose <| do
     TextIO.hPutStrLn stderr <| "docker " <> Text.unwords (map Text.pack dockerArgs)
@@ -818,8 +844,8 @@ runWorkflow spec verbose = do
 -- | Run agent in a Docker container (background by default)
 --
 -- Returns the run ID (container name)
-runAgent :: Text -> Maybe Text -> Bool -> Bool -> Maybe Int -> Maybe Int -> Maybe Int -> Maybe Text -> IO Text
-runAgent prompt mName foreground verbose maxCost maxIter timeoutSecs mProvider = do
+runAgent :: Text -> Maybe Text -> Bool -> Bool -> Maybe Int -> Maybe Int -> Maybe Int -> Maybe Text -> Maybe Text -> IO Text
+runAgent prompt mName foreground verbose maxCost maxIter timeoutSecs mProvider mToolchain = do
   -- Get paths
   coderoot <- Env.getEnv "CODEROOT"
   home <- Env.getEnv "HOME"
@@ -844,10 +870,51 @@ runAgent prompt mName foreground verbose maxCost maxIter timeoutSecs mProvider =
   -- by spawning the docker process in a shell and redirecting stdout
   -- Resolve provider: CLI flag > env var > default
   envProvider <- Env.lookupEnv "AGENTD_DEFAULT_PROVIDER"
+  envToolchain <- Env.lookupEnv "AGENTD_DEFAULT_TOOLCHAIN"
+  hasNixStore <- Dir.doesDirectoryExist "/nix/store"
+  hasTaskDb <- Dir.doesDirectoryExist "/var/lib/omni"
+  hasRepoGit <- Dir.doesPathExist (coderoot </> ".git")
+
+  promptForContainer <- do
+    let promptPath = Text.unpack prompt
+        isUnder parent child =
+          let parentWithSlash = if "/" `List.isSuffixOf` parent then parent else parent <> "/"
+           in child == parent || parentWithSlash `List.isPrefixOf` child
+    exists <- Dir.doesFileExist promptPath
+    if not exists
+      then pure prompt
+      else do
+        absPrompt <- Dir.makeAbsolute promptPath
+        if isUnder cwd absPrompt
+          then pure <| Text.pack <| "/workspace" </> makeRelative cwd absPrompt
+          else
+            if isUnder coderoot absPrompt
+              then pure <| Text.pack <| "/repo" </> makeRelative coderoot absPrompt
+              else pure prompt
+
   let provider = fromMaybe "openrouter" (mProvider <|> (Text.pack </ envProvider))
       providerFlag = ["--provider=" <> Text.unpack provider]
       maxCostFlag = maybe [] (\c -> ["--max-cost=" <> show c]) maxCost
       maxIterFlag = maybe [] (\i -> ["--max-iter=" <> show i]) maxIter
+      defaultImage = toolchainImage (fromMaybe "git" (mToolchain <|> (Text.pack </ envToolchain)))
+
+      extraMounts =
+        concat
+          [ if hasNixStore then ["-v", "/nix/store:/nix/store:ro"] else [],
+            if hasTaskDb then ["-v", "/var/lib/omni:/var/lib/omni"] else [],
+            if hasRepoGit then ["-v", coderoot <> "/.git:" <> coderoot <> "/.git"] else []
+          ]
+
+      extraEnv =
+        concat
+          [ ["-e", "PATH=/repo/_/bin:/bin"],
+            ["-e", "CODEROOT=/repo"],
+            ["-e", "GIT_CONFIG_COUNT=1"],
+            ["-e", "GIT_CONFIG_KEY_0=safe.directory"],
+            ["-e", "GIT_CONFIG_VALUE_0=*"],
+            if hasTaskDb then ["-e", "TASK_DB_PATH=/var/lib/omni/tasks.db"] else []
+          ]
+
       -- Docker run command
       dockerArgs =
         [ "run",
@@ -866,41 +933,47 @@ runAgent prompt mName foreground verbose maxCost maxIter timeoutSecs mProvider =
             cwd <> ":/workspace",
             -- Mount repo (ro)
             "-v",
-            coderoot <> ":/repo:ro",
-            -- Mount agent auth (ro) - XDG_DATA_HOME/agent contains auth.json
-            "-v",
-            home <> "/.local/share/agent:/root/.local/share/agent:ro",
-            -- Set HOME so agent finds auth at /root/.local/share/agent
-            "-e",
-            "HOME=/root",
-            -- Pass through API keys
-            "-e",
-            "ANTHROPIC_API_KEY",
-            "-e",
-            "OPENROUTER_API_KEY",
-            "-e",
-            "KAGI_API_KEY",
-            -- Pass run ID so agent can use it
-            "-e",
-            "AGENT_RUN_ID=" <> Text.unpack runId,
-            "-e",
-            "AGENT_STEERING_FILE=" <> containerSteeringFile,
-            -- Set locale for UTF-8 output
-            "-e",
-            "LANG=C.UTF-8",
-            -- Set working directory
-            "-w",
-            "/workspace",
-            -- Image (use base image for simple tasks)
-            "agent-base",
-            -- Command: agent with prompt using claude-code provider
-            "agent"
+            coderoot <> ":/repo:ro"
           ]
+          ++ extraMounts
+          ++ [
+               -- Mount agent auth (ro) - XDG_DATA_HOME/agent contains auth.json
+               "-v",
+               home <> "/.local/share/agent:/root/.local/share/agent:ro",
+               -- Set HOME so agent finds auth at /root/.local/share/agent
+               "-e",
+               "HOME=/root",
+               -- Pass through API keys
+               "-e",
+               "ANTHROPIC_API_KEY",
+               "-e",
+               "OPENROUTER_API_KEY",
+               "-e",
+               "KAGI_API_KEY",
+               -- Pass run ID so agent can use it
+               "-e",
+               "AGENT_RUN_ID=" <> Text.unpack runId,
+               "-e",
+               "AGENT_STEERING_FILE=" <> containerSteeringFile
+             ]
+          ++ extraEnv
+          ++ [
+               -- Set locale for UTF-8 output
+               "-e",
+               "LANG=C.UTF-8",
+               -- Set working directory
+               "-w",
+               "/workspace",
+               -- Image (default git-capable toolchain)
+               Text.unpack defaultImage,
+               -- Command: agent with prompt
+               "agent"
+             ]
           ++ providerFlag
           ++ maxCostFlag
           ++ maxIterFlag
           ++ ["--json" | not foreground] -- Output JSON events in background mode
-          ++ [Text.unpack prompt]
+          ++ ["--", Text.unpack promptForContainer]
 
   when verbose <| do
     TextIO.hPutStrLn stderr <| "docker " <> Text.unwords (map Text.pack dockerArgs)
@@ -917,6 +990,7 @@ runAgent prompt mName foreground verbose maxCost maxIter timeoutSecs mProvider =
       teeCmd = "tee -a " <> outputPath
       baseCmd = "docker " <> dockerCmd <> " 2>> " <> outputPath <> " | " <> teeCmd
       shellCmd = if foreground then baseCmd else baseCmd <> " > /dev/null &"
+      shellArgs = ["-o", "pipefail", "-c", shellCmd]
 
   for_ timeoutSecs <| \secs -> do
     _ <-
@@ -929,11 +1003,11 @@ runAgent prompt mName foreground verbose maxCost maxIter timeoutSecs mProvider =
 
   if foreground
     then do
-      Process.callCommand shellCmd
+      Process.callProcess "bash" shellArgs
       pure runId
     else do
       -- Run in background: spawn shell process that runs docker and captures stdout to log files
-      _ <- Process.spawnCommand shellCmd
+      _ <- Process.spawnProcess "bash" shellArgs
       -- Give docker a moment to start
       Concurrent.threadDelay 100000 -- 100ms
       pure runId
@@ -1694,8 +1768,8 @@ main = do
               content <- TextIO.getContents
               if Text.null (Text.strip content) then pure Nothing else pure (Just content)
 
-      -- Determine prompt and provider override
-      (prompt, providerOverride) <- case promptArg of
+      -- Determine prompt, provider override, and toolchain override
+      (prompt, providerOverride, toolchainOverride) <- case promptArg of
         Just path
           | isYamlPath path -> do
               -- YAML workflow file - use old workflow runner (always foreground)
@@ -1722,43 +1796,44 @@ main = do
                   code <- runMultiStepWorkflow wf verbose
                   Exit.exitWith code
           | ".md" `Text.isSuffixOf` path -> do
-              -- Markdown file - try container spec first
+              -- Markdown file - keep as file path so `agent` can parse frontmatter
+              -- (imports/tools/system_prompt/model/max_iterations).
               content <- TextIO.readFile (Text.unpack path)
               case parseSpec content of
                 Right spec ->
-                  -- Has container config - run in container
-                  pure (specTask spec, specProvider spec)
+                  pure (path, specProvider spec, Just (specToolchain spec))
                 Left _specErr ->
-                  -- No container config - check for simpler workflow frontmatter
                   case Agent.parseWorkflowFrontmatter content of
-                    Right (Just wm, body) ->
-                      -- Has workflow metadata but no container config - run in container by default
-                      pure (Text.strip body, Agent.wmProvider wm)
+                    Right (Just wm, _body) ->
+                      pure (path, Agent.wmProvider wm, Agent.wmToolchain wm)
                     Right (Nothing, _body) ->
-                      -- No frontmatter at all - use whole file as prompt in container by default
-                      pure (content, Nothing)
+                      pure (path, Nothing, Nothing)
                     Left _parseErr ->
-                      -- Parse error - use whole file as prompt in container by default
-                      pure (content, Nothing)
+                      pure (path, Nothing, Nothing)
           | otherwise ->
               -- Plain text prompt argument - run in container by default
-              pure (path, Nothing)
+              pure (path, Nothing, Nothing)
         Nothing -> case stdinContent of
-          Just content -> pure (content, Nothing)
+          Just content -> pure (content, Nothing, Nothing)
           Nothing -> do
             TextIO.hPutStrLn stderr "Error: prompt required (provide as argument or pipe to stdin)"
             Exit.exitWith (Exit.ExitFailure 1)
 
       let promptWithStdin = case (promptArg, stdinContent) of
+            (Just path, Just _stdinText)
+              | ".md" `Text.isSuffixOf` path ->
+                  -- Keep markdown prompt as a file path so agent can parse its frontmatter.
+                  prompt
             (Just _, Just stdinText) ->
               prompt <> "\n\n---\nInput:\n" <> Text.stripEnd stdinText
             _ -> prompt
 
       -- Merge provider: CLI flag > frontmatter > env > default
       let finalProvider = mProvider <|> providerOverride
+          finalToolchain = toolchainOverride
 
       -- Run the agent in a container
-      runId <- runAgent promptWithStdin mName foreground verbose maxCost maxIter timeoutSecs finalProvider
+      runId <- runAgent promptWithStdin mName foreground verbose maxCost maxIter timeoutSecs finalProvider finalToolchain
 
       -- Output run ID (for background runs)
       unless foreground <| do
diff --git a/Omni/Agentd/SPEC.md b/Omni/Agentd/SPEC.md
index 37e98e87..505fe433 100644
--- a/Omni/Agentd/SPEC.md
+++ b/Omni/Agentd/SPEC.md
@@ -42,13 +42,16 @@ Agentd automatically mounts:
 |------|-----------|------|---------|
 | `<workspace>` | `/workspace` | rw | Agent's working directory |
 | `<repo-root>` | `/repo` | ro | Access to AGENTS.md, skills, code |
+| `<repo-root>/.git` | `<repo-root>/.git` | rw | Resolve worktree gitdir pointers for git commands |
+| `/nix/store` | `/nix/store` | ro | Resolve repo-local symlinked tool binaries (for example `_/bin/task`) |
+| `/var/lib/omni` | `/var/lib/omni` | rw | Shared task database (`tasks.db`) |
 | `~/.local/share/agent/` | `/root/.local/share/agent/` | ro | OAuth tokens |
 
 The agent's CWD is `/workspace`.
 
 ## Body (Task)
 
-Everything after the closing `---` is the task. This is passed to `agent` as the prompt.
+Everything after the closing `---` is the task. For markdown workflows, agentd passes the markdown file path to `agent` so workflow frontmatter (`imports`, `tools`, `system_prompt`, etc.) is honored.
 
 ### Stdin Injection
 
@@ -86,6 +89,12 @@ Agentd also sets:
 
 - `AGENT_RUN_ID`
 - `AGENT_STEERING_FILE`
+- `PATH=/repo/_/bin:/bin`
+- `CODEROOT=/repo`
+- `GIT_CONFIG_COUNT=1`
+- `GIT_CONFIG_KEY_0=safe.directory`
+- `GIT_CONFIG_VALUE_0=*`
+- `TASK_DB_PATH=/var/lib/omni/tasks.db` (when `/var/lib/omni` is mounted)
 
 OAuth tokens are accessed via the mounted data directory (`~/.local/share/agent/auth.json`).
 
diff --git a/Omni/Dev/Vpn.nix b/Omni/Dev/Vpn.nix
index 303653c1..d064c1a2 100644
--- a/Omni/Dev/Vpn.nix
+++ b/Omni/Dev/Vpn.nix
@@ -70,11 +70,6 @@ in {
           name = "AdGuard NSFW Filter";
           url = "https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt";
         }
-        {
-          enabled = true;
-          name = "oisd nsfw";
-          url = "https://nsfw.oisd.nl/";
-        }
       ];
     };
   };
diff --git a/Omni/Ide/DEV_REVIEW_RELEASE.md b/Omni/Ide/DEV_REVIEW_RELEASE.md
index d4aa2093..ceb065d7 100644
--- a/Omni/Ide/DEV_REVIEW_RELEASE.md
+++ b/Omni/Ide/DEV_REVIEW_RELEASE.md
@@ -8,6 +8,7 @@ This workflow runs three independent agent loops in dedicated worktrees.
 - New worktrees are created under `_/worktrees/t-565` by default.
 - Branch cleanup is dry-run by default.
 - Worktree directories are never removed automatically.
+- Loop auto-stashes dirty workspace state by default (can disable with `--no-auto-stash`).
 
 ## 1) Create dedicated worktrees
 
@@ -40,6 +41,7 @@ Optional flags:
 - `--root _/worktrees/t-565`
 - `--parent t-565` (scope to one epic)
 - `--task-id t-565.2` (scope to one task)
+- `--no-auto-stash` (disable automatic dirty-workspace recovery)
 - `--once`
 - `--dry-run`
 
diff --git a/Omni/Ide/README.md b/Omni/Ide/README.md
index ff192eab..2e5ef6f6 100644
--- a/Omni/Ide/README.md
+++ b/Omni/Ide/README.md
@@ -72,6 +72,9 @@ Omni/Ide/dev-review-release.sh loop --role integrator
 # Optional: scope to one epic/task while dogfooding
 Omni/Ide/dev-review-release.sh loop --role dev --parent t-565
 Omni/Ide/dev-review-release.sh loop --role dev --task-id t-565.2
+
+# Disable dirty-workspace auto-stash recovery
+Omni/Ide/dev-review-release.sh loop --role dev --no-auto-stash
 ```
 
 The convention for all programs in the omnirepo is to run their tests if the first argument is `test`. So for example:
diff --git a/Omni/Ide/Workflows/dev.md b/Omni/Ide/Workflows/dev.md
index 7ca618f6..f0234e7b 100644
--- a/Omni/Ide/Workflows/dev.md
+++ b/Omni/Ide/Workflows/dev.md
@@ -1,9 +1,8 @@
 ---
 model: claude-sonnet-4-5
+toolchain: haskell
 max_cost_cents: 200
 max_iterations: 80
-imports:
-  - ../../../AGENTS.md
 tools:
   - read_file
   - write_file
@@ -32,11 +31,13 @@ The runtime context (Task ID, workspace, branch, namespace) is appended to this
 
 ### 0) Pre-flight
 
-1. Check clean tree:
+1. Check tree state:
    ```bash
    git status --porcelain
    ```
-   If dirty: stop and report exact files.
+   If dirty:
+   - If changes appear to be in-progress work for this same task branch, continue and recover by finishing/committing them.
+   - If changes are unrelated or suspicious, stop and report exact files.
 
 2. Ensure task branch is checked out (`t-XXX`).
    - If branch exists: checkout it.
@@ -51,10 +52,11 @@ The runtime context (Task ID, workspace, branch, namespace) is appended to this
 ### 1) Understand
 
 - Read task details and relevant files.
-- Load coder skill:
+- Optionally load coder skill if available:
   ```bash
-  skill(operation="load", query="Coder")
+  skill(operation="load", query="coder")
   ```
+  If skill lookup fails, continue without it.
 
 ### 2) Implement
 
diff --git a/Omni/Ide/Workflows/integrator.md b/Omni/Ide/Workflows/integrator.md
index cf3e82aa..04afebc6 100644
--- a/Omni/Ide/Workflows/integrator.md
+++ b/Omni/Ide/Workflows/integrator.md
@@ -1,9 +1,8 @@
 ---
 model: claude-sonnet-4-5
+toolchain: haskell
 max_cost_cents: 200
 max_iterations: 120
-imports:
-  - ../../../AGENTS.md
 tools:
   - read_file
   - write_file
diff --git a/Omni/Ide/Workflows/reviewer.md b/Omni/Ide/Workflows/reviewer.md
index 1decd3a8..413f5bf7 100644
--- a/Omni/Ide/Workflows/reviewer.md
+++ b/Omni/Ide/Workflows/reviewer.md
@@ -1,9 +1,8 @@
 ---
 model: claude-opus-4-6
+toolchain: haskell
 max_cost_cents: 200
 max_iterations: 120
-imports:
-  - ../../../AGENTS.md
 tools:
   - read_file
   - write_file
diff --git a/Omni/Ide/dev-review-release.sh b/Omni/Ide/dev-review-release.sh
index 54cd916f..5b876a46 100755
--- a/Omni/Ide/dev-review-release.sh
+++ b/Omni/Ide/dev-review-release.sh
@@ -39,6 +39,7 @@ Loop options:
   --timeout SEC     agentd timeout per run (default: 1800)
   --max-iter N      agentd max iterations per run (default: 80)
   --max-cost CENTS  agentd max cost cents per run (default: 300)
+  --no-auto-stash   Disable auto-stashing dirty workspace state before retry
   --once            Process at most one task, then exit
   --dry-run         Print what would run, do not invoke agentd
 
@@ -160,10 +161,29 @@ select_next_task() {
   esac
 }
 
+workspace_dirty_output() {
+  local workspace="$1"
+  git -C "$workspace" status --porcelain
+}
+
+workspace_has_tracked_changes() {
+  local workspace="$1"
+  local dirty
+  dirty="$(workspace_dirty_output "$workspace")"
+  [[ -n "$dirty" ]] || return 1
+  while IFS= read -r line; do
+    [[ -z "$line" ]] && continue
+    if [[ "$line" != \?\?* ]]; then
+      return 0
+    fi
+  done <<<"$dirty"
+  return 1
+}
+
 ensure_clean_workspace() {
   local workspace="$1"
   local dirty
-  dirty="$(git -C "$workspace" status --porcelain)"
+  dirty="$(workspace_dirty_output "$workspace")"
   if [[ -n "$dirty" ]]; then
     log "Workspace is dirty, refusing to run: $workspace"
     printf '%s\n' "$dirty"
@@ -171,6 +191,36 @@ ensure_clean_workspace() {
   fi
 }
 
+auto_stash_workspace() {
+  local workspace="$1"
+  local role="$2"
+  local tid="$3"
+
+  local dirty
+  dirty="$(workspace_dirty_output "$workspace")"
+  if [[ -z "$dirty" ]]; then
+    return 0
+  fi
+
+  local stamp stash_msg stash_out
+  stamp="$(date +%Y%m%d-%H%M%S)"
+  stash_msg="dev-review-release-autostash-$role-$tid-$stamp"
+
+  if ! stash_out="$(git -C "$workspace" stash push --include-untracked -m "$stash_msg" 2>&1)"; then
+    log "Failed to auto-stash dirty workspace for $tid"
+    printf '%s\n' "$stash_out"
+    return 1
+  fi
+
+  if [[ "$stash_out" == "No local changes to save" ]]; then
+    return 0
+  fi
+
+  log "Auto-stashed dirty workspace for $tid in $workspace ($stash_msg)"
+  task comment "$tid" "Automation ($role) auto-stashed dirty workspace state ($stash_msg) before retrying." --json >/dev/null || true
+  return 0
+}
+
 prepare_workspace_for_task() {
   local role="$1"
   local workspace="$2"
@@ -259,6 +309,7 @@ run_single_task() {
   local dry_run="$8"
   local task_filter="$9"
   local parent_filter="${10}"
+  local auto_stash_dirty="${11}"
 
   local tid
   tid="$(select_next_task "$role" "$task_filter" "$parent_filter")"
@@ -278,7 +329,27 @@ run_single_task() {
     return 0
   fi
 
-  ensure_clean_workspace "$workspace" || return 1
+  if ! ensure_clean_workspace "$workspace"; then
+    local has_tracked_changes="false"
+    if workspace_has_tracked_changes "$workspace"; then
+      has_tracked_changes="true"
+    fi
+
+    if [[ "$auto_stash_dirty" == "true" && "$role" == "dev" && "$has_tracked_changes" == "false" ]]; then
+      if auto_stash_workspace "$workspace" "$role" "$tid" && ensure_clean_workspace "$workspace"; then
+        log "Recovered dirty workspace by auto-stashing ($workspace)"
+      else
+        log "Auto-stash recovery failed for dirty workspace ($workspace)"
+        return 1
+      fi
+    else
+      if [[ "$role" == "dev" && "$has_tracked_changes" == "true" ]]; then
+        log "Workspace has tracked changes for $tid; continuing to allow recovery"
+      else
+        return 1
+      fi
+    fi
+  fi
 
   if ! prepare_workspace_for_task "$role" "$workspace" "$tid" "$base_branch"; then
     return 1
@@ -314,13 +385,15 @@ run_single_task() {
     cmd+=( -p "$provider" )
   fi
 
-  set +e
-  (
+  local rc=0
+  if (
     cd "$workspace"
     "${cmd[@]}"
-  )
-  local rc=$?
-  set -e
+  ); then
+    rc=0
+  else
+    rc=$?
+  fi
 
   if [[ $rc -ne 0 ]]; then
     log "Run failed for $tid (run=$run_name)"
@@ -434,6 +507,7 @@ loop_cmd() {
   local timeout="$DEFAULT_TIMEOUT_SECONDS"
   local max_iter="$DEFAULT_MAX_ITER"
   local max_cost="$DEFAULT_MAX_COST_CENTS"
+  local auto_stash_dirty="true"
   local once="false"
   local dry_run="false"
 
@@ -479,6 +553,10 @@ loop_cmd() {
         max_cost="$2"
         shift 2
         ;;
+      --no-auto-stash)
+        auto_stash_dirty="false"
+        shift
+        ;;
       --once)
         once="true"
         shift
@@ -516,11 +594,11 @@ loop_cmd() {
   fi
 
   log "Starting $role loop"
-  log "workspace=$workspace base=$base_branch interval=${interval}s dry_run=$dry_run task_filter=${task_filter:-<none>} parent_filter=${parent_filter:-<none>}"
+  log "workspace=$workspace base=$base_branch interval=${interval}s dry_run=$dry_run auto_stash_dirty=$auto_stash_dirty task_filter=${task_filter:-<none>} parent_filter=${parent_filter:-<none>}"
 
   while true; do
     set +e
-    run_single_task "$role" "$workspace" "$base_branch" "$provider" "$timeout" "$max_iter" "$max_cost" "$dry_run" "$task_filter" "$parent_filter"
+    run_single_task "$role" "$workspace" "$base_branch" "$provider" "$timeout" "$max_iter" "$max_cost" "$dry_run" "$task_filter" "$parent_filter" "$auto_stash_dirty"
     rc=$?
     set -e