Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/entrypoints/tui_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
10 changes: 9 additions & 1 deletion src/services/swarm/mailbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -285,16 +286,23 @@ 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] = []
logged_partial = False
try:
handle = open(path, "rb")
except OSError:
if strict:
raise
logger.exception("mailbox open failed for %s", path)
return []
try:
Expand Down
36 changes: 29 additions & 7 deletions src/services/swarm/mailbox_poller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down
22 changes: 22 additions & 0 deletions src/services/swarm/task_board.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
33 changes: 30 additions & 3 deletions src/services/swarm/team_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions tests/server/test_tui_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading