diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a6e94379..ab2ec32e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **The TUI no longer slows to a crawl in long sessions.** The bundle shipped + React's development build: the launcher runs `node dist/entry.js` with + `NODE_ENV` unset, and React picks its build from that at load time. Its + performance tracks record a `performance.measure()` on every commit, and + Node keeps each entry for the life of the process, so the heap grew with + every frame — roughly 200 MB an hour from the spinner alone, about three + times that while agents reported progress. A multi-hour agent-team session + reached 3 GB of heap against V8's 4.5 GB limit, where frequent full garbage + collections pause the UI. The build now folds `NODE_ENV` to `production`, + dropping the dev build (and its per-render checks) from the bundle, and the + `bun run` fallback used without a built bundle gets the same define. +- **An idle agent team no longer keeps the backend busy.** Every 50 ms the + team's mailbox poller re-read and re-parsed every message each inbox had + ever held, and each idle teammate re-read the whole task board, so an idle + team burned CPU in proportion to how much it had said. Both now skip a file + that has not changed since they last read it to the end (a failed read is + retried, and an idle teammate still re-reads the board every 2 seconds). - **The TUI's agents overlay names each agent.** Rows in the spawn tree showed only the task description, so a team of named specialists read as a list of anonymous tasks. Each row now leads with the spawn's name (a teammate's diff --git a/src/entrypoints/tui_launcher.py b/src/entrypoints/tui_launcher.py index 54c42591b..e1bf53005 100644 --- a/src/entrypoints/tui_launcher.py +++ b/src/entrypoints/tui_launcher.py @@ -267,7 +267,12 @@ def _resolve_tui_command(tui_dir: Path | None) -> list[str] | None: return [node, str(dist)] bun = shutil.which("bun") if bun: - return [bun, "run", str(tui_dir / "src" / "entry.tsx")] + # Same fold as scripts/build.mjs: with NODE_ENV unset React loads its + # development build, whose per-render performance.measure() entries + # are never freed. A transpile-time define keeps NODE_ENV itself out + # of the environment the agent-server and user commands inherit. + return [bun, "run", "--define", 'process.env.NODE_ENV:"production"', + str(tui_dir / "src" / "entry.tsx")] return None diff --git a/src/services/swarm/mailbox.py b/src/services/swarm/mailbox.py index d0350775a..5f752d71a 100644 --- a/src/services/swarm/mailbox.py +++ b/src/services/swarm/mailbox.py @@ -275,6 +275,7 @@ def read_mailbox( *, team_name: str, workspace_root: Path, + strict: bool = False, ) -> list[TeammateMessage]: """Read every parseable message; skip blank/unparseable lines. @@ -285,9 +286,14 @@ def read_mailbox( The ``log-once`` guard prevents a corrupt file from spamming the log on every read. + + ``strict`` raises instead of returning ``[]`` when the inbox cannot be + opened, for a caller that must tell "could not read" apart from "nothing + there". It skips the ``exists()`` pre-check, which can swallow an error + as "missing"; the open itself reports it. """ path = get_inbox_path(recipient_name, team_name, workspace_root) - if not path.exists(): + if not strict and not path.exists(): return [] messages: list[TeammateMessage] = [] @@ -295,6 +301,8 @@ def read_mailbox( try: handle = open(path, "rb") except OSError: + if strict: + raise logger.exception("mailbox open failed for %s", path) return [] try: diff --git a/src/services/swarm/mailbox_poller.py b/src/services/swarm/mailbox_poller.py index 320e51e95..396413d5b 100644 --- a/src/services/swarm/mailbox_poller.py +++ b/src/services/swarm/mailbox_poller.py @@ -57,6 +57,7 @@ from src.services.swarm.leader_permission_bridge import deliver_permission_decision from src.services.swarm.mailbox import TeammateMessage, get_inbox_path, read_mailbox +from src.services.swarm.task_board import file_version if TYPE_CHECKING: from src.task_registry import RuntimeTaskRegistry @@ -220,12 +221,19 @@ def sweep_mailboxes( expected_lead_agent_id: str | None = None, recipient_to_agent_id: dict[str, str] | None = None, deliver: Callable[[str, TeammateMessage], None] | None = None, + consumed: dict[Path, tuple[int, int, int]] | None = None, ) -> int: """Read every tracked recipient's inbox and dispatch new envelopes. Returns the count of envelopes dispatched on this sweep (for observability / test assertions). + ``consumed`` lets a long-lived poller carry, across sweeps, the inbox + versions it has already read to the end. An inbox still at that version + holds nothing new, so it is skipped instead of re-read and re-parsed in + full — which a 50 ms team poller otherwise does forever, at a cost that + grows with every message the inbox has ever held. + ``recipient_to_agent_id`` maps the on-disk recipient name to the in-process teammate's ``agent_id``. Callers that don't have this mapping populated yet (e.g. a fresh team with no in-process @@ -243,16 +251,25 @@ def sweep_mailboxes( except ValueError: # Sanitization-rejected name — skip; the inbox can't exist. continue - if not inbox_path.exists(): + # Inboxes are append-only, so any new line changes the version. + version = file_version(inbox_path) + if version is None: + continue + if consumed is not None and consumed.get(inbox_path) == version: continue - all_msgs = read_mailbox( - recipient_name, team_name=team_name, workspace_root=workspace_root, - ) + try: + all_msgs = read_mailbox( + recipient_name, team_name=team_name, workspace_root=workspace_root, + strict=True, + ) + except OSError: + # Not read is not consumed: leave the version unrecorded so the + # next sweep retries rather than waiting for another append. + logger.exception("mailbox read failed for %s", inbox_path) + continue offset = _read_offset(inbox_path) new_msgs = all_msgs[offset:] - if not new_msgs: - continue for msg in new_msgs: if deliver is not None: @@ -304,7 +321,12 @@ def sweep_mailboxes( envelope_type, inbox_path, ) - _write_offset(inbox_path, offset + len(new_msgs)) + if new_msgs: + _write_offset(inbox_path, offset + len(new_msgs)) + if consumed is not None: + # The version was taken BEFORE the read: a line appended mid-read + # moves the file past it, so the next sweep reads again. + consumed[inbox_path] = version return dispatched diff --git a/src/services/swarm/task_board.py b/src/services/swarm/task_board.py index e2269a4fb..497e8cc6f 100644 --- a/src/services/swarm/task_board.py +++ b/src/services/swarm/task_board.py @@ -57,6 +57,28 @@ def task_board( raise +def file_version(path: Path) -> tuple[int, int, int] | None: + """Identity of a file's current contents; ``None`` if it can't be stat'ed. + + Pollers compare it to skip re-reading a file nobody has written since. + """ + try: + st = path.stat() + except OSError: + return None + return (st.st_ino, st.st_size, st.st_mtime_ns) + + +def board_version(context: Any) -> tuple[int, int, int] | None: + """``file_version`` of the task board; ``None`` when there is no board file. + + Every write lands through ``write_json_atomic``'s rename, so a change to + the board normally gives the file a new inode, size, or mtime. + """ + path = context.task_board_path + return None if path is None else file_version(path) + + def with_task_board(*, write: bool = False) -> Callable: """Apply the board transaction to a synchronous task tool.""" diff --git a/src/services/swarm/team_runtime.py b/src/services/swarm/team_runtime.py index 526051f0f..f5bb7626e 100644 --- a/src/services/swarm/team_runtime.py +++ b/src/services/swarm/team_runtime.py @@ -9,6 +9,7 @@ import threading import time from dataclasses import replace +from pathlib import Path from typing import Any from uuid import uuid4 from xml.sax.saxutils import escape, quoteattr @@ -25,6 +26,7 @@ ) from src.services.swarm.mailbox_poller import sweep_mailboxes from src.services.swarm.task_board import ( + board_version, claim_next_task, release_tasks, write_json_atomic, @@ -56,6 +58,11 @@ logger = logging.getLogger(__name__) +# How long an idle teammate may trust an unchanged board version. A same-size +# rewrite can repeat inode+size+mtime where mtimes are coarse (FAT, older ext4), +# so the skip re-reads anyway after this long rather than miss a task for good. +_IDLE_BOARD_RECHECK_SECONDS = 2.0 + def _approve(message: dict[str, Any]) -> bool: value = message.get("approve") @@ -434,6 +441,9 @@ def _receive(self, agent_id: str, message: TeammateMessage) -> None: ) def _poll(self, stop_event: threading.Event) -> None: + # Inbox versions already read to the end, so each 50 ms sweep skips + # the unchanged inboxes instead of re-parsing every message they hold. + consumed: dict[Path, tuple[int, int, int]] = {} while not self.stop.is_set() and not stop_event.is_set(): try: with self.lock: @@ -446,6 +456,7 @@ def _poll(self, stop_event: threading.Event) -> None: team_name=self.name, recipient_to_agent_id=recipients, deliver=self._receive, + consumed=consumed, ) except Exception: logger.exception("team mailbox sweep failed for %s", self.name) @@ -595,6 +606,9 @@ async def _run( ) prompt = params.prompt first = True + # Board version (and when) at the last claim that found nothing. + idle_board: tuple[int, int, int] | None = None + idle_since = 0.0 while not controller.signal.aborted: state = registry.get(agent_id) if state is None or state.shutdown_approved: @@ -612,9 +626,22 @@ async def _run( ) ) if not pending and not state.awaiting_plan_approval: - assignment = claim_next_task(self.context, name) - if assignment: - pending = ["Task assigned: " + json.dumps(assignment)] + # An idle teammate wakes every 50 ms; re-reading and + # parsing the whole board each time only finds the + # same nothing until someone writes it. The version is + # taken before the claim, so a write racing the claim + # still differs next time round. + board = board_version(self.context) + if ( + board is None + or board != idle_board + or time.monotonic() - idle_since >= _IDLE_BOARD_RECHECK_SECONDS + ): + assignment = claim_next_task(self.context, name) + if assignment: + pending = ["Task assigned: " + json.dumps(assignment)] + else: + idle_board, idle_since = board, time.monotonic() if not pending: await asyncio.sleep(0.05) continue diff --git a/tests/server/test_tui_launcher.py b/tests/server/test_tui_launcher.py index 7db291435..d6124a23a 100644 --- a/tests/server/test_tui_launcher.py +++ b/tests/server/test_tui_launcher.py @@ -30,6 +30,21 @@ def test_resolve_tui_dir_finds_repo_client(): assert (found / "src" / "entry.tsx").exists() +def test_bun_fallback_runs_the_production_react_build(tmp_path, monkeypatch): + """With no built bundle the client runs from source under bun, which must + fold NODE_ENV like the esbuild bundle: the development React leaks a + performance.measure() entry per render for the life of the process.""" + import src.entrypoints.tui_launcher as launcher + + monkeypatch.delenv("CLAWCODEX_TUI_CMD", raising=False) + monkeypatch.setattr(launcher.shutil, "which", lambda name: "/opt/bun" if name == "bun" else None) + + assert launcher._resolve_tui_command(tmp_path) == [ + "/opt/bun", "run", "--define", 'process.env.NODE_ENV:"production"', + str(tmp_path / "src" / "entry.tsx"), + ] + + def test_agent_server_cmd_invokes_module(): """The backend command the client spawns is a runnable python -m entry.""" class _Args: diff --git a/tests/services/swarm/test_mailbox_poller.py b/tests/services/swarm/test_mailbox_poller.py index 237e702f5..df555be15 100644 --- a/tests/services/swarm/test_mailbox_poller.py +++ b/tests/services/swarm/test_mailbox_poller.py @@ -312,6 +312,159 @@ def test_offset_advances_so_envelopes_are_not_replayed(tmp_path: Path) -> None: assert state.pending_user_messages == ["first"] # not duplicated +def _counting_reads(monkeypatch: pytest.MonkeyPatch, after_read=None) -> list: + import src.services.swarm.mailbox_poller as poller + + reads: list = [] + real_read = poller.read_mailbox + + def read(*args, **kwargs): + messages = real_read(*args, **kwargs) + reads.append(len(messages)) + if after_read is not None: + after_read(len(reads)) + return messages + + monkeypatch.setattr(poller, "read_mailbox", read) + return reads + + +def test_consumed_inbox_is_not_reread_until_it_changes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # The team poller sweeps every 50 ms; re-parsing every message an inbox + # ever held on each sweep burned hours of CPU in a long team session. + reg = RuntimeTaskRegistry() + agent_id = _make_teammate(reg) + write_to_mailbox("alice", TeammateMessage(from_="x", text="first", timestamp="t"), + team_name="t", workspace_root=tmp_path) + reads = _counting_reads(monkeypatch) + consumed: dict = {} + + def sweep() -> int: + return sweep_mailboxes( + runtime_tasks=reg, workspace_root=tmp_path, team_name="t", + recipient_to_agent_id={"alice": agent_id}, consumed=consumed, + ) + + assert sweep() == 1 + assert [sweep(), sweep(), sweep()] == [0, 0, 0] + assert reads == [1] # the unchanged inbox was never read again + + write_to_mailbox("alice", TeammateMessage(from_="x", text="second", timestamp="t"), + team_name="t", workspace_root=tmp_path) + assert sweep() == 1 + assert reads == [1, 2] + assert reg.get(agent_id).pending_user_messages == ["first", "second"] + + +def test_line_appended_during_a_read_is_picked_up_next_sweep( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + reg = RuntimeTaskRegistry() + agent_id = _make_teammate(reg) + write_to_mailbox("alice", TeammateMessage(from_="x", text="first", timestamp="t"), + team_name="t", workspace_root=tmp_path) + + def race(n_reads: int) -> None: + if n_reads == 1: # a writer lands between the read and the bookkeeping + write_to_mailbox("alice", TeammateMessage(from_="x", text="raced", timestamp="t"), + team_name="t", workspace_root=tmp_path) + + _counting_reads(monkeypatch, after_read=race) + consumed: dict = {} + + def sweep() -> int: + return sweep_mailboxes( + runtime_tasks=reg, workspace_root=tmp_path, team_name="t", + recipient_to_agent_id={"alice": agent_id}, consumed=consumed, + ) + + assert sweep() == 1 + assert sweep() == 1 + assert reg.get(agent_id).pending_user_messages == ["first", "raced"] + + +def test_inbox_that_failed_to_open_is_retried_next_sweep( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # A failed read is not a consumed read. Before the skip existed the next + # sweep retried on its own; recording the version after a swallowed open + # error would park the message until some other message arrived. + import builtins + + import src.services.swarm.mailbox as mailbox + + reg = RuntimeTaskRegistry() + agent_id = _make_teammate(reg) + consumed: dict = {} + + def sweep() -> int: + return sweep_mailboxes( + runtime_tasks=reg, workspace_root=tmp_path, team_name="t", + recipient_to_agent_id={"alice": agent_id}, consumed=consumed, + ) + + write_to_mailbox("alice", TeammateMessage(from_="x", text="first", timestamp="t"), + team_name="t", workspace_root=tmp_path) + assert sweep() == 1 + write_to_mailbox("alice", TeammateMessage(from_="x", text="shutdown please", timestamp="t"), + team_name="t", workspace_root=tmp_path) + + real_open = builtins.open + failed: list = [] + + def open_fails_once(path, *args, **kwargs): + if str(path).endswith("alice.jsonl") and not failed: + failed.append(path) + raise OSError(24, "Too many open files") + return real_open(path, *args, **kwargs) + + monkeypatch.setattr(mailbox, "open", open_fails_once, raising=False) + assert sweep() == 0 + assert failed + assert sweep() == 1 + assert reg.get(agent_id).pending_user_messages == ["first", "shutdown please"] + + +def test_inbox_whose_existence_check_fails_is_retried_next_sweep( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # Path.exists() turns a transient stat error into False (every OSError on + # 3.14). A strict read must not report that as an empty, consumed inbox. + reg = RuntimeTaskRegistry() + agent_id = _make_teammate(reg) + consumed: dict = {} + + def sweep() -> int: + return sweep_mailboxes( + runtime_tasks=reg, workspace_root=tmp_path, team_name="t", + recipient_to_agent_id={"alice": agent_id}, consumed=consumed, + ) + + write_to_mailbox("alice", TeammateMessage(from_="x", text="first", timestamp="t"), + team_name="t", workspace_root=tmp_path) + assert sweep() == 1 + write_to_mailbox("alice", TeammateMessage(from_="x", text="shutdown please", timestamp="t"), + team_name="t", workspace_root=tmp_path) + + real_exists = Path.exists + failed: list = [] + + def exists_fails_once(self, *args, **kwargs): + if self.name == "alice.jsonl" and not failed: + failed.append(self) + return False + return real_exists(self, *args, **kwargs) + + monkeypatch.setattr(Path, "exists", exists_fails_once) + delivered = sweep() + monkeypatch.undo() + delivered += sum(sweep() for _ in range(3)) + assert delivered == 1 + assert reg.get(agent_id).pending_user_messages == ["first", "shutdown please"] + + # --------------------------------------------------------------------------- # Daemon thread lifecycle # --------------------------------------------------------------------------- diff --git a/tests/test_team_runtime_e2e.py b/tests/test_team_runtime_e2e.py index e75896f6c..6da2551b5 100644 --- a/tests/test_team_runtime_e2e.py +++ b/tests/test_team_runtime_e2e.py @@ -326,6 +326,108 @@ def test_task_dependencies_shared_board_and_automatic_claim(team): assert context.team_runtime.contexts[bob].tasks is context.tasks +def watch_idle_claims(monkeypatch, version=None): + """Count an idle teammate's board checks and claims; ``version`` fakes + what ``board_version`` reports.""" + import src.services.swarm.team_runtime as team_runtime + + claims, checks = [], [] + real_claim, real_version = team_runtime.claim_next_task, team_runtime.board_version + + def counting_claim(ctx, owner): + claims.append(owner) + return real_claim(ctx, owner) + + def counting_version(ctx): + checks.append(1) + return real_version(ctx) if version is None else version(ctx) + + monkeypatch.setattr(team_runtime, "claim_next_task", counting_claim) + monkeypatch.setattr(team_runtime, "board_version", counting_version) + return claims, checks + + +def test_idle_teammate_without_a_board_file_claims_every_wake(team, monkeypatch): + provider, context, registry = team + claims, checks = watch_idle_claims(monkeypatch, version=lambda ctx: None) + spawn(team, "alice") + eventually(lambda: len(checks) >= 5) + # Nothing to compare, so never skip. Read checks first: claims only catch up. + assert len(checks) - 1 <= len(claims) + + +def test_idle_teammate_rechecks_a_board_whose_version_never_moves(team, monkeypatch): + # Coarse mtimes plus inode reuse can repeat a version across a same-size + # rewrite; the skip must not trust it forever. + import src.services.swarm.team_runtime as team_runtime + + provider, context, registry = team + monkeypatch.setattr(team_runtime, "_IDLE_BOARD_RECHECK_SECONDS", 0.2) + _, checks = watch_idle_claims(monkeypatch, version=lambda ctx: (1, 1, 1)) + spawn(team, "alice") + eventually(lambda: len(checks) >= 2) # the empty claim is recorded first + task = call( + registry, context, "TaskCreate", subject="Research", description="Inspect" + )["task"]["id"] + eventually( + lambda: call(registry, context, "TaskGet", taskId=task)["task"]["status"] + == "completed" + ) + assert provider.assignments == [("alice", task)] + + +def test_idle_teammate_rereads_the_board_only_after_it_changes(team, monkeypatch): + # An idle teammate wakes every 50 ms. Re-reading and parsing the whole + # board on every wake kept a long team session's backend busy while + # nothing was happening. + import src.services.swarm.team_runtime as team_runtime + + provider, context, registry = team + monkeypatch.setattr(team_runtime, "_IDLE_BOARD_RECHECK_SECONDS", 60.0) + claims, checks = watch_idle_claims(monkeypatch) + spawn(team, "alice") + eventually(lambda: len(checks) >= 10) # ten idle wakes, well inside the recheck window + assert claims == ["alice"] + + task = call( + registry, context, "TaskCreate", subject="Research", description="Inspect" + )["task"]["id"] + eventually( + lambda: call(registry, context, "TaskGet", taskId=task)["task"]["status"] + == "completed" + ) + assert provider.assignments == [("alice", task)] + + +def test_task_created_while_an_idle_claim_runs_is_still_claimed(team, monkeypatch): + import src.services.swarm.team_runtime as team_runtime + + provider, context, registry = team + # Out of the way, so only taking the version before the claim can catch it. + monkeypatch.setattr(team_runtime, "_IDLE_BOARD_RECHECK_SECONDS", 60.0) + created = [] + real_claim = team_runtime.claim_next_task + + def racing_claim(ctx, owner): + taken = real_claim(ctx, owner) + if not created: # the board changes after the claim read it + created.append( + call( + registry, context, "TaskCreate", subject="Race", description="Mid-claim" + )["task"]["id"] + ) + return taken + + monkeypatch.setattr(team_runtime, "claim_next_task", racing_claim) + spawn(team, "alice") + eventually(lambda: created) + eventually( + lambda: call(registry, context, "TaskGet", taskId=created[0])["task"]["status"] + == "completed" + ) + assert provider.assignments == [("alice", created[0])] + + def test_plan_rejection_preserves_restrictions_and_approval_unlocks_work(team): provider, context, registry = team planner = spawn(team, "planner", "MAKE_PLAN", mode="plan") diff --git a/ui-tui/scripts/build.mjs b/ui-tui/scripts/build.mjs index e9991b49f..ec75c5eac 100644 --- a/ui-tui/scripts/build.mjs +++ b/ui-tui/scripts/build.mjs @@ -8,7 +8,9 @@ import { dirname, resolve } from 'node:path' const here = dirname(fileURLToPath(import.meta.url)) const root = resolve(here, '..') -const out = resolve(root, 'dist/entry.js') +// `--outfile=` lets a test build a throwaway bundle without touching dist/. +const outArg = process.argv.find(a => a.startsWith('--outfile=')) +const out = outArg ? resolve(outArg.slice('--outfile='.length)) : resolve(root, 'dist/entry.js') // `react-devtools-core` is only imported when DEV=true at runtime (Ink dev // mode). Stub it out so the bundle doesn't carry the dep. @@ -35,6 +37,14 @@ await build({ outfile: out, jsx: 'automatic', jsxImportSource: 'react', + // React picks its build from `process.env.NODE_ENV` at require time, and the + // launcher runs `node dist/entry.js` with NODE_ENV unset — so without this + // every user ran the development React. Its performance tracks call + // `performance.measure()` as components re-render, and Node keeps each entry + // in its global timeline forever, so a long agent-team session's heap grew + // by gigabytes. Folding NODE_ENV here (not setting it at runtime, which + // would leak into the agent-server's child commands) drops the dev build. + define: { 'process.env.NODE_ENV': '"production"' }, // Skip the prebuilt @clawcodex/ink bundle — esbuild's __esm helper doesn't // await nested async init, which breaks lazy-initialized exports like // `render`. Bundling from source sidesteps that. diff --git a/ui-tui/src/__tests__/buildProductionReact.test.ts b/ui-tui/src/__tests__/buildProductionReact.test.ts new file mode 100644 index 000000000..4a9c75b63 --- /dev/null +++ b/ui-tui/src/__tests__/buildProductionReact.test.ts @@ -0,0 +1,38 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +// The shipped bundle must carry React's production build. The development +// build's performance tracks call `performance.measure()` on every commit and +// Node never evicts those entries, so a long session's heap grew by GBs — +// a spinner alone leaked ~290 MB/hour. +describe('dist bundle', () => { + const root = resolve(__dirname, '../..') + const dir = mkdtempSync(join(tmpdir(), 'tui-build-')) + const outfile = join(dir, 'entry.js') + let code = '' + + beforeAll(() => { + execFileSync(process.execPath, [join(root, 'scripts/build.mjs'), `--outfile=${outfile}`], { + cwd: root, + env: { ...process.env, NODE_ENV: '' }, + stdio: 'pipe' + }) + code = readFileSync(outfile, 'utf8') + }, 120_000) + + afterAll(() => rmSync(dir, { force: true, recursive: true })) + + it('bundles the production React, not the development build', () => { + expect(code).toContain('react-reconciler.production') + expect(code).not.toContain('react-reconciler.development') + expect(code).not.toContain('performance.measure(') + }) + + it('leaves no runtime NODE_ENV switch for a library to fall back on', () => { + expect(code).not.toContain('process.env.NODE_ENV') + }) +})