Ten Agents Ran the Same Type Check at Once. The Fix Was a Directory.

Seventeen coding agents in one checkout, ten tsc processes at the same time, load average 37 and 87 MB of free RAM. A ninety second type check took 7 minutes 36. Here is the measurement, why the machine was not computing, and the small shared lock that fixed it. Copyable into any repository.

On 7 September, our development machine stopped responding properly. Not a crash, not a freeze. Everything simply took ten times longer, including things that had nothing to do with code.

The obvious suspects were all wrong. The laptop was not overheating: no throttling recorded, battery at 30.6 C. No runaway process was eating the CPU. Nothing had been deployed. The only unusual thing was that seventeen agent CLIs were alive in the same repository, which is a normal working day here.

Here is what was actually happening, measured rather than guessed.

The measurement

A 16 GB, 8 core machine, up for five and a half hours, seventeen agents working:

What we measuredValue
Live agent CLIs17
Concurrent tsc --noEmit processes, seen within two minutes3, then 10
Load average37 to 41
Free RAM / memory compressor87 MB / 7.2 GB
One type check on the desktop app, saturated machine7 min 36 of wall clock for 26 s of CPU
The same type check, calm machine33 s

The decisive line is the second to last one. Twenty six seconds of CPU spread over seven and a half minutes is ten percent utilisation. The type check was not computing. It was waiting for memory.

And one of those processes was killed by the operating system mid run. A killed tsc exits with a non zero code and an empty output, which is indistinguishable from a real type error. So on top of being slow, the machine was producing verdicts nobody could trust.

Nobody did anything wrong

This is the part worth sitting with, because it is what makes the failure so hard to see coming.

Every one of those agents was following the rule. Each had edited TypeScript. Each was told to verify its types before handing back. Each ran tsc --noEmit. None of them could see the others. There is no shared blackboard where an agent writes "I am currently doing the expensive thing, hold on".

Then it feeds itself. The type check slows down because the machine is saturated. The agent watching it decides it is stuck. So it kills it and starts another one. That reflex is correct in isolation and catastrophic in a group, and it is the same failure family we documented a month earlier when agents left stuck search processes behind: Process Guard is the safety net that finds what did get started, this is the fix that stops it starting.

The three answers we did not take

Run fewer agents. This halves the symptom and keeps the bug. Two simultaneous type checks on a loaded machine are still slower than one, and cutting the fleet is paying for the problem with the thing that makes the work fast.

One type check at the end. Tempting, and wrong for a reason that has nothing to do with performance. A type error found ten tickets later is an orphan: the agent that wrote it is closed, its context is gone, and a human has to reopen the whole subject to fix one line. We did not want to defer the verification.

Incremental compilation. Tested and dropped. The gain is doubtful in --noEmit mode, and concurrent processes corrupt the shared .tsbuildinfo. It solves half the problem by making the other half worse.

What we did instead: one check, shared

The rule is not "check less often". It is one type check at a time, per project, for everyone. A wrapper script replaces N checks with one, and answers three cases:

  1. Nothing has changed since the last run, so hand back its result.
  2. A run is already in progress, so wait for it and take its result.
  3. Otherwise, take the lock and be the only tsc on the machine.

From the agent's point of view nothing changed: it types yarn typecheck, it gets its type errors. It never waits longer than before either, because a run it waits behind is a run that started before its own. The machine pays for one instead of ten.

That is the whole idea. The interesting part is that both mechanisms it needs are much smaller than you would expect.

The lock is a directory

Not a file, not a database, not a daemon. A directory.

try {
  mkdirSync(lockDir);       // succeeds: the lock is ours
} catch (err) {
  if (err.code === 'EEXIST') { /* someone else holds it, wait */ }
}

mkdir either creates the directory or fails with EEXIST, and it does so atomically on macOS, Windows and Linux, with no dependency and no native call. Writing a file and then checking whether it exists would be two operations, and two operations is precisely where a second agent slips in between.

Inside the directory we drop an owner.json with the pid, the hostname and the start time. That file is for diagnosis and for detecting a dead lock. It is never what does the excluding.

A dead lock is reclaimed automatically in two cases: the owning process is gone (checked only when the hostname matches, because a pid means nothing across machines), or the lock is older than fifteen minutes.

One trap that cost us a bug. Between the mkdir and the write of owner.json there is a window where the owner is unreadable. Declaring the lock dead in that window steals it from the process that just took it, which is the exact race the file exists to prevent. So when there is no readable owner, we judge on the age of the directory, not on the missing file.

The fingerprint is a date and a count

Case 1 needs to know whether anything changed since the last run. The obvious answer is to hash the source files. We do not.

The fingerprint is <most recent mtime>:<number of files> over the roots derived from the tsconfig include, plus the tsconfig itself.

On 2,300 files, reading every byte costs more than the check it saves. The date alone misses a deletion. The count alone misses an edit. Together they cover both. The accepted false negative is two edits inside the same millisecond that leave the count identical, and the worst case there is a cached result a few seconds stale, never a silent type error, because the blocking verification is still the build.

A written rule was not enough, so we added a hook

The instruction was in AGENTS.md from day one: never run tsc directly, always the shared script. It was not enough, and it is worth being honest about why.

Verifying types after an edit is a deeply ingrained reflex. Under pressure, an agent types npx tsc --noEmit without rereading the instructions. And it only takes one agent to skip the rule to recreate the pile-up the lock exists to prevent. An instruction is negotiable. A hook is not.

So we wired a PreToolUse hook on the Bash tool that refuses a direct tsc and names the right command in the refusal:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/scripts/hooks/block-direct-tsc.mjs\"" }
        ]
      }
    ]
  }
}

The hook reads the tool call as JSON on stdin, exits 2 with the reason on stderr to refuse, exits 0 to allow. Two details make the difference between a useful hook and an annoying one.

It matches tsc in command position, not anywhere in the string. Searching for the three letters anywhere would refuse grep -rn tsc AGENTS.md. So the pattern requires tsc at the start of a line or after ;, &&, ||, | or (, optionally preceded by a package runner and a path. It also lets tsc --version through: there is no reason to refuse an informative flag.

It refuses a second thing we did not anticipate. We watched an agent wait behind the lock, decide after a while that it must be dead, and delete the lock directory to unblock itself. That starts a second heavy process next to the live one, which is the perfect bypass of everything the lock protects. So deleting the lock or cache directory is refused too, with the explanation that a dead lock is reclaimed on its own.

That second refusal is the one we would never have written in advance. It came from watching what agents actually do when they are blocked, which is a better source of guard rails than imagining what they might do.

Where it stops

The hook is Claude Code specific. The other agent CLIs in the fleet only see the written rule. That is a known hole and we accepted it: a guard rail that covers most of the fleet beats no guard rail while waiting for a hook standard every CLI reads.

The shared script itself is provider neutral, because it is just a command. Any CLI that can run yarn typecheck benefits from the lock, whether or not anything forces it to.

What to take from this

The type check was our loudest case, not a special one. The pattern applies to any command that is expensive, idempotent over a short window, and started by every agent for the same good reason: installing dependencies, running the full test suite, building for production, starting a dev server on a fixed port.

Three questions, in this order, and you have the whole design:

  1. Can I reuse a recent result?
  2. Can I join the run already in progress?
  3. Otherwise, am I the one who starts it, alone?

If several agents share your machine, the thing worth measuring is not how many are running. It is how many of them start the same command in the same minute. That number is what your machine actually feels, and until you look at it, you will blame the heat.

If you want the wider picture on how we run several agents on one repository without them stepping on each other, that is in Running coding agents in parallel, and the safety net for the processes that do get started is Process Guard. Both the shared script and the hook live in the AgentsRoom repository, which is where those seventeen agents were working that afternoon.

Download AgentsRoom

Run all your AI agents, on all your projects, from a single window.

FreeDownload AgentsRoom

Companion app: monitor your agents on the go

Bring your own: Claude, Codex, Antigravity CLI, or other AI provider.

Get the extension
Chrome Web Store

Push bugs and requests straight to your public backlog.

A glimpse of AgentsRoom in action.

Multiple projects
Multi-provider
Multiple agents
Live status
File diff & commit
Mobile companion
Live preview
Agent teams
Browser automation
Backlog-driven dev
Prompt Library
Skills Library
View all features

Keep reading