<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ramiz Raja</title>
    <description>The latest articles on DEV Community by Ramiz Raja (@codebyramiz).</description>
    <link>https://dev.to/codebyramiz</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4198600%2Fab6ee99c-9caf-4dad-bdc4-27f2e79a53af.jpg</url>
      <title>DEV Community: Ramiz Raja</title>
      <link>https://dev.to/codebyramiz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/codebyramiz"/>
    <language>en</language>
    <item>
      <title>Twelve Cores, Seven Devices, One Disk</title>
      <dc:creator>Ramiz Raja</dc:creator>
      <pubDate>Sun, 25 Oct 2026 08:41:15 +0000</pubDate>
      <link>https://dev.to/codebyramiz/twelve-cores-seven-devices-one-disk-29da</link>
      <guid>https://dev.to/codebyramiz/twelve-cores-seven-devices-one-disk-29da</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a republish from my site; the original is at &lt;a href="https://codebyramiz.com/blog/running-multiple-claude-code-agents/" rel="noopener noreferrer"&gt;codebyramiz.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A release test run on one of my Android apps sat hung for 7 hours 22 minutes and used 258 ms of CPU, before it ran a single test step.&lt;/p&gt;

&lt;p&gt;Most advice on running several Claude Code agents says give each its own git worktree. I think that is the wrong first answer.&lt;/p&gt;

&lt;p&gt;A worktree keeps the files apart. It does nothing for the CPU, the one test phone or the disk, and my own run proves it. The run wasn't slow, it was stuck, inside the test tool's own driver install. Nobody checked on it. Several Claude Code sessions were working on the same machine, and each had its own work to watch.&lt;/p&gt;

&lt;p&gt;That is what running multiple Claude Code agents at once actually looks like after a few months. The problems are not in the code. If you only want the checklist, it's the table at the end; the sections in between are how each row was earned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Git worktrees solve the files, not the machine
&lt;/h2&gt;

&lt;p&gt;If you search for how to run several Claude Code agents in parallel, the answer is consistent: give each session its own git worktree and branch, so two agents never edit the same file. That's right, and I do it. It's also where the guides ranking for that search stop, from &lt;a href="https://towardsdatascience.com/how-to-run-claude-code-agents-in-parallel/" rel="noopener noreferrer"&gt;Towards Data Science&lt;/a&gt; to &lt;a href="https://kanbots.dev/learn" rel="noopener noreferrer"&gt;kanbots&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here's what changed for me. One agent at a time is a tool. Four agents at a time, each with its own worker processes, builds and device runs, is a small team sharing one desk. A worktree gives each of them a separate folder. They still share:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The CPU.&lt;/strong&gt; A Gradle build, an emulator and a test run each want most of the machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The test devices.&lt;/strong&gt; There's one Pixel. Two agents can't both install onto it at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The disk.&lt;/strong&gt; Every build adds cache, and nothing removes it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time.&lt;/strong&gt; A hung run looks exactly like a slow one unless something checks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hard part is that &lt;strong&gt;every session is behaving reasonably on its own.&lt;/strong&gt; No single agent does anything wrong. The failure only exists when you add them up, and no session can see the others. So the guards have to live outside the sessions, at the machine level.&lt;/p&gt;

&lt;p&gt;My setup, for scale: an Apple M3 Pro MacBook Pro (12 cores, 36 GB), a Windows PC with an Intel i9-11900H (8 cores, 32 GB) that takes Android builds, a 2014 MacBook Pro kept as a spare, seven registered test devices, and a real-device cloud for what I don't own. The agents are Claude Code sessions that plan and review, plus cheaper coding agents that do the bulk of the work. That split is covered in &lt;a href="https://codebyramiz.com/blog/two-agents-one-repo/" rel="noopener noreferrer"&gt;how I review AI-generated mobile code&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude Code high CPU usage: when several sessions fan out
&lt;/h2&gt;

&lt;p&gt;The first thing that broke was speed, and it took a while to see why.&lt;/p&gt;

&lt;p&gt;A session would launch background shell commands to generate files: about twelve of them, each burning 8 to 10 percent CPU. On its own, fine. But three sessions doing something similar at the same time oversubscribed the M3 Pro, and every build, test and device run on the box slowed to a crawl. Each session saw only its own twelve workers. None of them knew the machine was full.&lt;/p&gt;

&lt;p&gt;The fix is a Claude Code &lt;code&gt;PreToolUse&lt;/code&gt; hook. It's registered once in &lt;code&gt;~/.claude/settings.json&lt;/code&gt;, so it runs before every Bash call in every session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                   &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$HOME/.claude-shared/scripts/resource-budget-guard.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hook reads the tool call as JSON on stdin. If it isn't a &lt;code&gt;run_in_background&lt;/code&gt; call, it exits at once. Otherwise it takes one &lt;code&gt;ps&lt;/code&gt; snapshot and counts busy processes across &lt;strong&gt;all&lt;/strong&gt; Claude sessions on the machine, not just its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# condensed from resource-budget-guard.sh&lt;/span&gt;
&lt;span class="nv"&gt;MAX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_MAX_BG_WORKERS&lt;/span&gt;&lt;span class="k"&gt;:-$((&lt;/span&gt; NCPU &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="k"&gt;))}&lt;/span&gt;   &lt;span class="c"&gt;# 6 on a 12-core Mac&lt;/span&gt;
&lt;span class="nv"&gt;FLOOR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_BG_CPU_FLOOR&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;                 &lt;span class="c"&gt;# %CPU that counts as "working"&lt;/span&gt;
&lt;span class="c"&gt;# for each process: walk up its parents until one is a `claude` process.&lt;/span&gt;
&lt;span class="c"&gt;#   skip the hook itself, and skip MCP servers (long-lived, not fan-out)&lt;/span&gt;
&lt;span class="c"&gt;#   if its %CPU &amp;gt;= FLOOR, count it against that session&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$COUNT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'{"decision":"block","reason":%s}\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-Rs&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REASON&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The block reason names which session owns the busy workers and tells the agent what to do instead: run the command in the foreground, batch the work into one loop, or wait. Foreground calls aren't checked at all, so a blocked agent can still do the work, one step at a time. For a run that genuinely needs more, a leading &lt;code&gt;CLAUDE_MAX_BG_WORKERS=8&lt;/code&gt; on that one command raises the limit for that call only. The hook reads the assignment from the command text itself, because a variable set in the command never reaches the hook's own environment.&lt;/p&gt;

&lt;p&gt;The second half is cleanup. A Stop hook warns when a session still owns more than four busy processes, and a SessionEnd hook kills the busy ones it can trace back to that session (MCP servers excluded; on the Windows box it only logs them unless reaping is switched on). That matters more than it sounds. Claude Code's own issue tracker has reports like &lt;a href="https://github.com/anthropics/claude-code/issues/11122" rel="noopener noreferrer"&gt;this one&lt;/a&gt;, of processes that pile up and outlive the work they were started for. A worker that never gets reaped costs the machine for hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharing test phones between agents: lease, don't grab
&lt;/h2&gt;

&lt;p&gt;Two agents on one phone is worse than two agents on one file, because a phone has no merge step. One installs a build while the other is mid-test, and the second agent reports a failure the app doesn't have.&lt;/p&gt;

&lt;p&gt;The worst version of this took a long misdiagnosis to find. Two Maestro runs, on two &lt;em&gt;different&lt;/em&gt; Android phones connected to the same machine, kept dying with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Connection refused: localhost:7001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That error looks exactly like an app hang. I blamed the number of MCP server processes first, then a concurrent iOS session. Neither was it. The real cause was an upstream Maestro bug: before version 2.6.0 it tracked sessions per platform, not per device, so the second run's driver install was silently skipped (&lt;a href="https://github.com/mobile-dev-inc/Maestro/issues/3065" rel="noopener noreferrer"&gt;Maestro issue #3065&lt;/a&gt;). The rule now is simple: Maestro 2.6.1 or later, everywhere.&lt;/p&gt;

&lt;p&gt;The general fix is a small device farm. Every command that touches a phone goes through a wrapper that takes a lease first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;farm-with-device &lt;span class="nt"&gt;--alias&lt;/span&gt; plannedping-pixel &lt;span class="nt"&gt;--holder&lt;/span&gt; me:gate &lt;span class="nt"&gt;--ttl&lt;/span&gt; 7800 &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  run-with-watchdog.sh &lt;span class="nt"&gt;--log&lt;/span&gt; gate.log &lt;span class="nt"&gt;--&lt;/span&gt; ./scripts/run-local-release-gate.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The phones hang off the Windows PC, and the farm there is the only lease authority. Single commands (&lt;code&gt;farm-adb&lt;/code&gt;, &lt;code&gt;farm-maestro&lt;/code&gt;, screenshots) forward from the Mac over SSH; a whole gate script like the one above runs on the PC itself. If the PC can't be reached, device commands fail rather than guess.&lt;/p&gt;

&lt;p&gt;The lease is an atomic &lt;code&gt;mkdir&lt;/code&gt; lock with a time limit, set longer than the watchdog's own cap so it can't expire mid-run. A hook blocks raw &lt;code&gt;adb&lt;/code&gt; and &lt;code&gt;maestro&lt;/code&gt; calls, so an agent can't skip the queue by accident. Devices carry a policy, and the policy decides who can pick them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Device&lt;/th&gt;
&lt;th&gt;Availability&lt;/th&gt;
&lt;th&gt;How an agent reaches it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pixel 4a 5G&lt;/td&gt;
&lt;td&gt;always&lt;/td&gt;
&lt;td&gt;by role (default pick)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sony&lt;/td&gt;
&lt;td&gt;always&lt;/td&gt;
&lt;td&gt;by role (default pick)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TCL tablet, Android 16&lt;/td&gt;
&lt;td&gt;always&lt;/td&gt;
&lt;td&gt;by name only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POCO (MIUI)&lt;/td&gt;
&lt;td&gt;always&lt;/td&gt;
&lt;td&gt;by name only, for OEM-specific bugs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Samsung S26 Ultra&lt;/td&gt;
&lt;td&gt;on-demand&lt;/td&gt;
&lt;td&gt;by name plus an extra flag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iPhone 15, iOS 26&lt;/td&gt;
&lt;td&gt;always&lt;/td&gt;
&lt;td&gt;by name only, via Appium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iPhone 11, iOS 18&lt;/td&gt;
&lt;td&gt;on-demand&lt;/td&gt;
&lt;td&gt;by name only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Picking by role fails closed when more than one device matches, so an agent never lands on a phone by luck.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For iPhones the agent drives the device without touching the Mac's mouse at all, which is its own story: &lt;a href="https://codebyramiz.com/blog/agent-drives-a-real-iphone/" rel="noopener noreferrer"&gt;an agent driving a real iPhone&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;There was a second device problem I didn't see coming. To keep a long run from being interrupted, agents set the screen to stay on while charging, then finished and walked away. Phones sat fully awake around the clock, which drains and heats a battery even on a charging cable. Now releasing a lease arms a marker. The next Stop hook that finds a marker older than five minutes turns stay-awake off on that Android device, sets the screen timeout back to 60 seconds and switches the display off. A device that's leased again in the meantime is skipped. When a session ends, it does the same for its devices straight away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude Code disk space: build caches only grow
&lt;/h2&gt;

&lt;p&gt;Disk is the slowest problem and the easiest to ignore. Here's one night from the cleanup log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;00:45  disk-hygiene summary: free 56G -&amp;gt; 56G
03:52  disk-hygiene summary: free 24G -&amp;gt; 24G
05:52  disk-hygiene summary: free 12G -&amp;gt; 12G
07:54  disk-hygiene summary: free 10G -&amp;gt; 7G
08:55  disk-hygiene summary: free 3G -&amp;gt; 3G
10:52  disk-hygiene start:   free 1G
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Below 100 GB free the cleaner starts on its own, so that night it ran almost every hour, and free space kept falling anyway. A cleanup schedule doesn't stop a disk filling: the cleaner may only delete what a rebuild restores, and the warning threshold is what tells a human to look.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That restriction is deliberate, and it's what makes the job safe to run unattended. It deletes merged worktrees, merged branches and old &lt;code&gt;build/&lt;/code&gt;, &lt;code&gt;.gradle/&lt;/code&gt; and &lt;code&gt;DerivedData/&lt;/code&gt; folders. It never touches source, &lt;code&gt;.git&lt;/code&gt;, &lt;code&gt;.env&lt;/code&gt; files or keystores. A worktree with commits that no remote has is never deleted; it's logged for a human to decide.&lt;/p&gt;

&lt;p&gt;The tricky case was dirty worktrees. A coding agent that dies mid-task leaves one behind, and I'd found six of them holding 3 to 8 GB of build output each. Keeping them forever fills the disk. Deleting them loses work. So a dirty worktree idle for 72 hours is first archived, as a binary patch plus a tarball of every untracked and ignored file except regenerable build folders, both size-checked, and only then removed. The ignored files matter: a &lt;code&gt;.env&lt;/code&gt; or a keystore is untracked by design, and a cleaner that ignores them deletes the only copy.&lt;/p&gt;

&lt;p&gt;Normally it runs every six hours at low priority. On a good pass it frees a lot: the largest single sweep cleared 44.5 GB across 92 paths.&lt;/p&gt;

&lt;p&gt;The honest part: for a while it freed nothing at all, while looking like it worked. The cleanup script loaded a helper that had its own variable named &lt;code&gt;APPLY&lt;/code&gt;, set to off. So &lt;code&gt;--apply&lt;/code&gt; silently became a dry run. The log said "DRY-RUN", deleted nothing, and the job looked healthy. The fix was renaming the variables. The lesson was to check what a cleanup job freed, not whether it ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  Long runs: is it hung or just slow?
&lt;/h2&gt;

&lt;p&gt;Back to the opening. Nothing polled that run, and when a session finally did report on it, it read the log and said "no Maestro process alive". The process was still resident. An earlier stop signal had killed the wrapper shell and left the Java process underneath untouched. And it wasn't the port 7001 bug from the last section: that machine was already on Maestro 2.6.1.&lt;/p&gt;

&lt;p&gt;Every run expected to take more than five minutes now goes through a watchdog:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Exit code&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;finished&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;125&lt;/td&gt;
&lt;td&gt;stalled: the log stopped growing for 600 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;124&lt;/td&gt;
&lt;td&gt;hit the hard cap (7,200 seconds)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;143&lt;/td&gt;
&lt;td&gt;killed by a signal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;124 and 125 mean no result was produced. They are never reported as a failing test.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A ten-minute stall check plus one thread dump named that cause immediately. Before it kills a stalled run, the watchdog takes a thread dump of the run's Java processes (&lt;code&gt;jstack&lt;/code&gt;) and a &lt;code&gt;sample&lt;/code&gt; of the rest of its process tree, then kills the whole tree, not just the wrapper. The dump settles the argument you'd otherwise have: if the blocking frame is inside the test tool, it's the tool; if it's inside the app's code, it's a possible product bug worth a closer look. No dump, no verdict either way.&lt;/p&gt;

&lt;p&gt;Two rules came straight out of that run. Answer "is it still running?" from the process table, never from a log file. And kill the tree, not the wrapper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving Android builds to a second machine
&lt;/h2&gt;

&lt;p&gt;Once the guards were in, the Mac was still the bottleneck, because it does everything. So Android builds moved to the Windows PC. A &lt;code&gt;win-build &amp;lt;project&amp;gt; &amp;lt;tasks&amp;gt;&lt;/code&gt; command streams the tracked files over SSH, runs Gradle on a warm cache there, and pulls the APKs back. All 13 Android repos build there.&lt;/p&gt;

&lt;p&gt;iOS stays on the Mac, since it has to. The 2014 MacBook Pro is deliberately not a build worker. It's several times slower than the M3, and putting anything on it that someone waits for just moves the bottleneck somewhere worse.&lt;/p&gt;

&lt;p&gt;Two machines bring a new problem: the hooks and scripts above have to be identical on both. A sync job runs every five minutes on each host through a private git repo. Before pushing, it scans local edits for secret patterns. Incoming changes are checked for shell syntax, JSON and file size before they go live, then swapped in one file at a time with an atomic rename. If an incoming check fails, the live files stay as they were and every new session on that host sees a drift warning until it's fixed. The same thinking applies to the code itself, in &lt;a href="https://codebyramiz.com/blog/clean-architecture-ai-agents/" rel="noopener noreferrer"&gt;keeping agent-written code clean&lt;/a&gt;: a rule only holds if something fails when it breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to set up first
&lt;/h2&gt;

&lt;p&gt;If you're running more than two Claude Code sessions on one machine, this is the order I'd do it in, by how much each one cost me before it existed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;What breaks&lt;/th&gt;
&lt;th&gt;Guard&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Runs that hang&lt;/td&gt;
&lt;td&gt;hours lost, false "still running"&lt;/td&gt;
&lt;td&gt;watchdog: stall at 600 s log silence, thread dump, kill the tree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU fan-out&lt;/td&gt;
&lt;td&gt;every build slows, nobody knows why&lt;/td&gt;
&lt;td&gt;block new background launches once half the cores' worth of workers are busy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared phones&lt;/td&gt;
&lt;td&gt;false failures, a phone taken mid-test&lt;/td&gt;
&lt;td&gt;leases with a time limit; raw &lt;code&gt;adb&lt;/code&gt; blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leaked processes&lt;/td&gt;
&lt;td&gt;slow machine hours later&lt;/td&gt;
&lt;td&gt;reap busy processes at session end&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk&lt;/td&gt;
&lt;td&gt;builds fail at 100%&lt;/td&gt;
&lt;td&gt;scheduled cleanup of regenerable files only; archive before delete&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two machines&lt;/td&gt;
&lt;td&gt;a fix on one host, not the other&lt;/td&gt;
&lt;td&gt;validated 5-minute sync with a drift warning&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of this is about the model. It's the same work a team does when several people share one build server, done for agents that can't see each other. If you've inherited an app whose build and test setup already looks like this, that is the kind of thing I sort out in an &lt;a href="https://codebyramiz.com/app-rescue/" rel="noopener noreferrer"&gt;app rescue&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://codebyramiz.com/blog/running-multiple-claude-code-agents/" rel="noopener noreferrer"&gt;codebyramiz.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>devops</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Don't Trust an Agent That Says the Tests Passed</title>
      <dc:creator>Ramiz Raja</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:47:06 +0000</pubDate>
      <link>https://dev.to/codebyramiz/dont-trust-an-agent-that-says-the-tests-passed-1gc4</link>
      <guid>https://dev.to/codebyramiz/dont-trust-an-agent-that-says-the-tests-passed-1gc4</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a republish from my site; the original, with the full reviewer files, is at &lt;a href="https://codebyramiz.com/blog/agent-says-tests-passed/" rel="noopener noreferrer"&gt;codebyramiz.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You have an AI coding agent's report in front of you, and it says the tests pass. What can you conclude from that? Less than it feels like. Here is a case from my own ChatPing iOS app.&lt;/p&gt;

&lt;p&gt;A change came back with 768 passing tests reported. I sent it to an independent reviewer. The reviewer did not rerun those tests, and its verdict begins &lt;code&gt;VERDICT: FAIL&lt;/code&gt;, with the first two findings rated High:&lt;/p&gt;

&lt;blockquote&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;[...] concurrent opens can mix chat identity and transcript. [...] If opens for A and B interleave, A can bind the ChatCapability to A while copying B's SDK snapshot into &lt;code&gt;rawConversation&lt;/code&gt;. [...] The new tests cover only one sequential open.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;[...] reopening a failed reply changes recency and rewrites the file. [...] The test fixture contains only a completed reply.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;

&lt;p&gt;The report said 768 passed. The reviewer found two cases those tests did not cover. A green count says how many checks ran, not whether they were the right ones. The fix took two more review rounds before the same reviewer passed it.&lt;/p&gt;

&lt;p&gt;This piece is about the three checks I put between an AI coding agent and the word "done", with the real files and verdicts. It's the same setup as &lt;a href="https://codebyramiz.com/blog/two-agents-one-repo/" rel="noopener noreferrer"&gt;how I review AI changes in a production mobile app&lt;/a&gt;, seen from the angle of what counts as proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an agent that says the tests pass is only making a claim
&lt;/h2&gt;

&lt;p&gt;The agent that wrote the code also chose which tests to write, ran them, read the output and summarised it for me. Every step of that chain is the same author. If it misses a case when writing the code, it will often miss the same case when writing the test, and then report green in good faith.&lt;/p&gt;

&lt;p&gt;Agents also get asked what is happening right now. "Is the gate still running?" is a claim about a process, and a model can answer it from whatever text is in front of it instead of from the process table. The seven-hour hung test run at the start of &lt;a href="https://codebyramiz.com/blog/running-multiple-claude-code-agents/" rel="noopener noreferrer"&gt;running several Claude Code agents on one machine&lt;/a&gt; was a version of that: a log said one thing, the machine said another.&lt;/p&gt;

&lt;p&gt;So a report from an agent is a claim, and a claim gets checked by something that did not make it. What does that check look like when the claim is about code, about status and about a run that went quiet? Three versions follow.&lt;/p&gt;

&lt;h2&gt;
  
  
  A reviewer that refuses to pass tests it could not run
&lt;/h2&gt;

&lt;p&gt;The reviewer in these examples is Codex, run read-only through a wrapper that demands a last line of exactly &lt;code&gt;VERDICT: PASS&lt;/code&gt; or &lt;code&gt;VERDICT: FAIL&lt;/code&gt;. A different worker writes the code; the worker is never the gate.&lt;/p&gt;

&lt;p&gt;The case I find most useful is not a catch. It is a module in my shared &lt;code&gt;ping-ai-sdk&lt;/code&gt; kit, a port of a grounding renderer from Android to iOS. The review request asked the reviewer to build and run the tests itself, so there would be pass counts that did not come from the author. It read the diff and compared it with the Android version, and it found no blocking issues in static review. It could have written "looks right, probably passes". It couldn't run the tests, so there were no independent counts, and it failed the module on that alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No blocking code/file:line issues found in static review. [...]

Gate blocker: I attempted all requested SwiftPM commands from `ios/`, but this
session's read-only sandbox prevented Swift/Xcode from creating `/tmp/xcrun_db-*`
cache files, so no independent pass counts were produced:
- `swift build` failed before compilation: `errno=Operation not permitted`
- `swift test --filter GroundingRendererTests` failed same way
- `swift test --filter TextFeaturesTests` failed same way
- `swift test` failed same way

VERDICT: FAIL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Does the agent's "still running" claim have a fresh check behind it?
&lt;/h2&gt;

&lt;p&gt;The second check is about status, not code. When an agent tells me a build or a device run is "still in progress", I want that to rest on something it just looked up.&lt;/p&gt;

&lt;p&gt;A CLAUDE.md rule asking for that did not hold, so it became a Claude Code Stop hook, &lt;code&gt;verify-before-status-claim-hook.sh&lt;/code&gt;. It runs when the agent tries to end its turn and reads the transcript for that turn. If the assistant text matches a status-claim pattern and the turn contains no qualifying check, it blocks the stop. This is the pattern, abridged and not runnable as shown:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# abridged: [...] marks removed alternatives&lt;/span&gt;
&lt;span class="nv"&gt;claim_re&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'waiting for|still waiting|in progress|currently (mid|waiting|[...])|
should (complete|finish|fire|be done)|probably (done|finished|complete)|[...]|
still (running|underway|executing|active|going|working)|[...]|
(job|task|process|work|fork|subagent|it) (is|remains) (active|running|[...])'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A qualifying check is a &lt;code&gt;Read&lt;/code&gt; of an image file, or a Bash command whose leading token is &lt;code&gt;ps&lt;/code&gt;, &lt;code&gt;adb&lt;/code&gt;, &lt;code&gt;stat&lt;/code&gt;, &lt;code&gt;date&lt;/code&gt;, &lt;code&gt;logcat&lt;/code&gt; and a few more. When the hook applies, the agent gets this back and the turn does not end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BLOCKED: this reply states or implies async/background work is "in progress" / "still
running" / "waiting" [...] but this turn contains no fresh verification [...] If you
already believe it's true from memory or a prior turn, that is exactly the failure mode
this hook exists to catch [...]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second guard is the part people don't expect. Even when the claim is backed by a real &lt;code&gt;ps&lt;/code&gt;, the hook blocks unless the turn also schedules the next check. One verified look is a snapshot, not monitoring. So the agent has to book a wake-up within 15 minutes, and the wake-up has to name something concrete to inspect, such as a process, a device or a log's modification time. "Check back later" doesn't count.&lt;/p&gt;

&lt;p&gt;I should be plain about what this is: a heuristic. It checks that the current turn contains a qualifying tool call, and a bare &lt;code&gt;date&lt;/code&gt; counts. It does not check that the call was about the task being claimed. It also stands down in some cases: when the turn itself just started background work (the wake-up guard below covers that turn instead), when &lt;code&gt;jq&lt;/code&gt; or the transcript is missing, when the hook is already re-running after a block, and in headless worker sessions that have no next turn. It narrows the gap between "still running" and a fresh &lt;code&gt;ps&lt;/code&gt;; it does not close it.&lt;/p&gt;

&lt;p&gt;The script itself isn't published; it is Bash plus &lt;code&gt;jq&lt;/code&gt;, reads the turn from the transcript path Claude Code passes on stdin, and the wake-up guard looks for a &lt;code&gt;ScheduleWakeup&lt;/code&gt; tool call in the turn. It is registered as a Stop hook in Claude Code's &lt;code&gt;settings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Stop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                   &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$HOME/.claude-shared/scripts/verify-before-status-claim-hook.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Seven review rounds on one hook
&lt;/h2&gt;

&lt;p&gt;I did not write that hook correctly the first time. The file's own header is the changelog, and it reads like a list of ways to be wrong:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;What the reviewer found&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;v2&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tool_result&lt;/code&gt; entries were logged as user turns, so the turn boundary was wrong and evidence was discarded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v3&lt;/td&gt;
&lt;td&gt;the word &lt;code&gt;date&lt;/code&gt; anywhere in a command, even &lt;code&gt;printf date&lt;/code&gt;, counted as a live check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v4&lt;/td&gt;
&lt;td&gt;a wake-up prompt of "check back later" passed with no concrete target&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v5&lt;/td&gt;
&lt;td&gt;one evidenced "still running" and then silence: monitoring ended after a single recheck&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v6&lt;/td&gt;
&lt;td&gt;a wake-up whose tool call had errored was still counted as scheduled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v7&lt;/td&gt;
&lt;td&gt;"The background process is running" matched none of the claim phrases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v8&lt;/td&gt;
&lt;td&gt;the phrase list kept missing wordings, so it became a subject-plus-state pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those were Codex adversarial re-reviews: seven rounds against the same hook on one day. Every finding was real. My CLAUDE.md says a reviewer's finding is evidence, not an assignment, and that after two rounds on one target a third needs a written decision: fix it, register it as a follow-up, or delete the check. I didn't stop at two, and I'm not going to claim the later rounds were each priced. Whether rounds three to seven were worth it, I can't tell you. Round 7 in particular was another version of the same problem one level up, which is the pattern that rule warns about.&lt;/p&gt;

&lt;p&gt;The later versions, v9 and v10, were not about correctness. Before v9 the hook re-read the whole transcript on every turn, which on a 71,525-line session cost about 9.5 minutes per turn. After v10 pulled the tool-call fields out in a single &lt;code&gt;jq&lt;/code&gt; pass, a 200-call turn took 94 ms where it had taken 3.5 s. A check that takes minutes gets turned off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exit codes that separate "failed" from "no result"
&lt;/h2&gt;

&lt;p&gt;The third check is for long runs. A release gate, a device suite or a build can go quiet. If a hung run gets reported as a failing test, I go hunting for a product bug that doesn't exist, and if it reads as "nothing wrong yet", I wait.&lt;/p&gt;

&lt;p&gt;Every run expected to take more than a few minutes goes through a watchdog wrapper, and its exit codes carry the distinction:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Exit code&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;the command succeeded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;125&lt;/td&gt;
&lt;td&gt;stalled: no output for the stall window; diagnostics attempted, process tree killed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;124&lt;/td&gt;
&lt;td&gt;hard cap exceeded; diagnostics attempted, process tree killed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;143&lt;/td&gt;
&lt;td&gt;the watchdog itself was signalled (TERM, INT or HUP); it killed the tree and exited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;usage error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;other&lt;/td&gt;
&lt;td&gt;the command's own exit code, passed through&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The line in &lt;code&gt;long-run-watchdog.md&lt;/code&gt; that matters is this one: &lt;em&gt;124 and 125 are not product verdicts. They mean the run produced no verdict.&lt;/em&gt; One catch: a child process that exits 124 or 125 on its own looks identical from outside, so the watchdog prints a stderr line saying which one it was. A stalled run is classified from its diagnostics first, and only then is it a test failure, a tool bug or nothing.&lt;/p&gt;

&lt;p&gt;The review gate follows the same logic. Exit 0 means a verdict exists, and I read the final verdict line (&lt;code&gt;VERDICT: PASS&lt;/code&gt; or &lt;code&gt;VERDICT: FAIL&lt;/code&gt;) and the opening banner, which says whether Codex or the Opus substitute produced it. A timeout is "inconclusive, not a pass", and so is any other non-zero exit. When Codex is out of quota, the wrapper falls back to a Claude Opus adversarial review, and that fallback has to produce a verdict too. No verdict never rounds up to approval.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to copy from this setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Make "done" a line a machine can parse (&lt;code&gt;VERDICT: PASS&lt;/code&gt;), produced by something that did not write the code, and treat a missing line as a fail.&lt;/li&gt;
&lt;li&gt;Make the reviewer run the tests itself, and let it fail the work when it can't.&lt;/li&gt;
&lt;li&gt;Keep exit codes honest. "I don't know" and "it failed" must not share a number.&lt;/li&gt;
&lt;li&gt;If you write a Stop hook for status claims, budget your review rounds before you start. Mine got seven in a day.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this makes the code correct. A reviewer can be wrong, and a regex can be fooled. What it does is move the question from "do I believe the agent" to "what did a second party see", and that one I can answer without reading the diff. For how the code itself stays clean when nobody reads it, see &lt;a href="https://codebyramiz.com/blog/clean-architecture-ai-agents/" rel="noopener noreferrer"&gt;what keeps agent-written code clean&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://codebyramiz.com/blog/agent-says-tests-passed/" rel="noopener noreferrer"&gt;codebyramiz.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>devops</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
