slowbench
RetrievalInferenceEvalsAgentsFindingsSeriesBenchmarksArchive
Agents7 min1,222 words

The child process inherited nothing, so the test now walks every spawn site

Child process environment variables: config moved to a table loaded by each entry point's first import, and three scripts the bot spawns were not entry points. A test now walks spawn sites.

Contents · 5 sections
  1. 1Why the children had nothing
  2. 2The test: walk the spawn sites
  3. 3The other three rules in the same file
  4. 4What the test does not catch
  5. 5Frequently Asked Questions

On 2026-09-21 the bot's tenant configuration moved out of JSON files and into a database table. Policy, integrations, thresholds: everything a deployment might set differently now lives in config_doc, and code reads it through one object, CFG. The load happens once per process, at the entry point, as the first import:

import "./lib/tenant-loaded.mjs";   // opens the database, loads the tenant, then everything else

Twenty-one entry points had that line. On the morning of 2026-09-22, at the 09:00 deploy slot, three that did not have it were spawned by the bot to run QA, and all three died with the same message: CFG.policy.context.maxTokens is a tenant setting and has no value yet. One run's staging QA did not happen and the health check sent a message saying the staging QA tool had not responded.

Another post is about a spawned process that could not read its config for a different reason. This one is about the test that makes the category of bug fail before it ships.

Why the children had nothing

The bot's long-running process loads the tenant once. The runner that works a ticket is itself an entry point: it loads the tenant on start, and it hands a CONFIG_SNAPSHOT to the permission hook that runs inside the agent, because a hook that fires on every tool call must not open a database. Both of those paths were designed and tested.

QA is spawned from two other places, the deploy batch and the promotion verifier, as plain child processes of batch-qa.mjs, stage-qa.mjs and master-verify.mjs. Those scripts read CFG somewhere in their import graph (the prompt-context module, a few imports down). They were not in the entry-point list, they did not import the loader, and they were not given the snapshot. Before the migration, CFG was built from files at import time and there was nothing to inherit. After it, there was, and they didn't.

The fix for the incident was three lines, one per script, plus three entries in the list. That took ten minutes. The rest of the morning went into the question of how many more of these there were and how to know.

The test: walk the spawn sites

The bot's TypeScript source is the only place that spawns scripts. So the test reads all of src/, collects every quoted reference to a scripts/<name>.mjs path, and treats each as a spawn site:

for (const f of walk(path.join(ROOT, "src")))
  for (const line of readFileSync(f, "utf8").split("\n")) {
    if (/^\s*import\b/.test(line) || /\bfrom\s+["']/.test(line)) continue;
    for (const m of line.matchAll(/["'](?:\.\.\/)*scripts\/([a-z0-9-]+\.mjs)["']/g))
      spawned.add(`scripts/${m[1]}`);
  }

For each spawned script, it walks the script's static import graph (relative paths, including string arguments to import()), and asks one question: does this graph reach a module that reads a tenant leaf? If yes, the script must be in ENTRY_POINTS. If it is not, the test fails and names it.

The first version of that walk failed on four scripts that are never spawned: agent, observe, slack-manifest, slack-surface. They were being picked up from import statements and from comments that mentioned them. Restricting the match to quoted paths on non-import lines removed all four without losing the three real spawns. That is the reason the continue line exists, and the reason it is a test and not a grep.

The other three rules in the same file

The spawn walk is rule 1b. The file has three more, and each is there because of a specific failure in the same week.

Only entry points import the loader. A library module that imports tenant-loaded.mjs would open the database whenever anything imported it, including tests and tools that must run with no database at all. The test lists the modules that must be startable with an empty table (the permission-boundary hook, the test isolation module, the seed and export scripts, the store itself) and walks their graphs to prove none reaches the loader.

Library modules never read a leaf at module top level. This one was measured on 2026-09-21 and surprised me: ES modules evaluate async siblings in parallel, so an entry point's top-level await protects the entry point's own body and nothing else. const HOST = CFG.observability.langfuse.host at the top of a library module runs before the tenant is loaded, regardless of what the entry point awaits. const HOST = () => CFG.observability.langfuse.host is fine. The test walks every library module for top-level reads of the leaf names and has a deliberately planted violation in a temporary folder to prove it still catches them.

Tests never statically import an entry point. A static import runs the entry point's first import, which opens the database and creates tables, in parallel with the test isolation module doing the same thing. On 2026-09-21 that raced into a duplicate pg_class_relname_nsp_index error in one test file. The rule is seed first, then await import(). Four existing tests were changed to that shape on 09-22 when the three QA scripts became entry points, because they had been importing them statically and would have started racing.

What the test does not catch

A spawn whose script path is not a literal in the source is invisible to rule 1b. There is one such site today: the ticket runner, whose command comes from config and is run through a shell. It is safe because the runner is in the entry-point list, but the test sees the list, not the spawn. If a second one appears, the test will not notice, and I do not currently have a cheaper answer than reading spawn( calls by hand once a month.

It also does not catch a script that reads a leaf lazily, inside a function, and is spawned without the loader. That script boots fine and fails later, at the first call. Rule 3 forbids top-level reads in libraries, which makes lazy reads the norm, which makes this gap real. The 09-22 incident surfaced within seconds because the read sits on the child's startup path; a read that only happens on one branch would surface as a QA verdict of "tool did not respond" some time later, which is exactly the health message that went out.

Frequently Asked Questions

Why not pass the snapshot to every child instead of adding the loader to each?

Because the snapshot exists for one reason: the permission hook inside the agent fires on every tool call and must not open a database. Anything that may open the database should, and read the tenant as it is now rather than as it was when the parent started; the parent runs for days. Opening the table costs a few milliseconds per process.

Why is the entry-point list a hand-written array instead of derived?

Because it is the thing the tests check against. A derived list would agree with the code by construction and could not fail. Adding a line to the array is the moment a person says "this script is a process door", and the test's job is to notice when the code disagrees.

Six tests, 237 lines, for one config loader. Is that proportionate?

The migration touched 226 settings and every process the bot starts. The first missed spawn cost one morning's QA and one alarming health message. The test file took part of one morning, including the false-positive round. On this codebase that is cheap.