Skip to content
Draft
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
112 changes: 112 additions & 0 deletions bin/agentbox
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,26 @@ def profile_origin(profile):
return repo, rev


def _positive_int(cfg, key, default, section, allow_zero=False):
"""One integer option, validated the way the module's type would.

A config typo must fail the apply, not silently degrade a running box to
a default it never asked for — the same contract lib.types.ints.positive
gives the module.
"""
value = cfg.get(key, default)
if value is None:
return default
if isinstance(value, bool) or not isinstance(value, int):
raise ConfigError(
f"{section}.{key} must be an integer, got {value!r}")
if value < 0 or (value == 0 and not allow_zero):
raise ConfigError(
f"{section}.{key} must be "
f"{'0 or more' if allow_zero else 'greater than 0'}, got {value}")
return value


class Spec:
"""The validated, defaulted view of config.yaml the renderer reads."""

Expand Down Expand Up @@ -438,6 +458,52 @@ class Spec:
"webhook.hookSessionArgs must be a list of strings, got "
f"{hook_session_args!r}")
self.hook_session_args = list(hook_session_args or [])
# The assignment sweep (services.agent-box.watchdog on the module
# side). Same defaults as the module's options, because the failure
# it catches is the same on both backends: an issue assigned to this
# box that never became a PR, which is indistinguishable from one
# nobody has got to yet.
# NOT `or {}`: that reads `watchdog: false` — which plainly means
# "off" — as an absent section and turns the sweep ON with defaults,
# and lets a truthy non-mapping reach .get() as an AttributeError
# instead of a ConfigError (#486 review).
watchdog_cfg = data.get("watchdog")
if watchdog_cfg is None:
watchdog_cfg = {}
elif watchdog_cfg is False:
watchdog_cfg = {"enable": False}
elif not isinstance(watchdog_cfg, dict):
raise ConfigError(
"watchdog must be a mapping (or false), got "
f"{watchdog_cfg!r}")
watchdog_enable = watchdog_cfg.get("enable", True)
if watchdog_enable is not None and not isinstance(
watchdog_enable, bool):
raise ConfigError(
f"watchdog.enable must be a bool, got {watchdog_enable!r}")
self.watchdog_enable = bool(watchdog_enable)
self.watchdog_interval = _positive_int(
watchdog_cfg, "interval", 1800, "watchdog")
self.watchdog_cooldown = _positive_int(
watchdog_cfg, "cooldown", 6, "watchdog")
# 0 is legal here and means "never give up", so this one is unsigned
# rather than positive — the module's option says the same.
self.watchdog_max_attempts = _positive_int(
watchdog_cfg, "maxAttempts", 3, "watchdog", allow_zero=True)
watchdog_repos = watchdog_cfg.get("repos")
if watchdog_repos is not None and not (
isinstance(watchdog_repos, list)
and all(isinstance(r, str) for r in watchdog_repos)):
raise ConfigError(
"watchdog.repos must be a list of strings, got "
f"{watchdog_repos!r}")
self.watchdog_repos = list(watchdog_repos or [])
watchdog_agent = watchdog_cfg.get("agent", "claude")
if not isinstance(watchdog_agent, str) or not watchdog_agent:
raise ConfigError(
f"watchdog.agent must be a harness name, got "
f"{watchdog_agent!r}")
self.watchdog_agent = watchdog_agent
codex_full_access = data.get("codexFullAccess", True)
# A quoted "false" would otherwise pass bool()'s truthiness check
# and turn codex loose with danger-full-access — a string typo
Expand Down Expand Up @@ -1846,6 +1912,15 @@ class Renderer:
if self.spec.webhook_enable:
lines.insert(2, 'Environment="AGENT_BOX_WEBHOOK_REPO='
f'{self.spec.webhook_repo}"')
if self.spec.watchdog_enable:
# Not from the binding contract above: that manifest binds fixed
# programs every box gets, while these two are conditional on an
# option, and the interval is not a program name at all. The
# module's half is the matching lib.optionalAttrs block.
lines.insert(2, 'Environment="AGENT_BOX_WATCHDOG_BIN='
f'{self.bin}/agent-box-watchdog"')
lines.insert(3, 'Environment="AGENT_BOX_WATCHDOG_INTERVAL='
f'{self.spec.watchdog_interval}"')
if self.spec.protect_memory:
# Same knob as the module: the supervisor is the last thing that
# should be killed when the box runs out of memory.
Expand Down Expand Up @@ -2260,6 +2335,43 @@ class Renderer:
f"export AGENT_BOX_PROFILE_BIN="
f"{q('/usr/local/bin/agent-box-profile')}\n"
f'exec {self.bin}/agent-box-session-bare "$@"\n', 0o755)
if self.spec.watchdog_enable:
# The native half of the module's watchdogCli wrapper: the same
# pinned binaries and the same declared defaults, each still
# overridable per box with `agent-box-session env set` because
# which repos an agent user is answerable for is a preference,
# not a system-level fact. modules/src/watchdog.py itself carries
# no backend-specific text — a config-source label baked into a
# shared payload is exactly what #471 was.
repos = " ".join(self.spec.watchdog_repos)
t.file(self.p("/usr/local/bin/agent-box-watchdog"),
"#!/bin/sh\n"
"# Generated by `agentbox apply` — do not edit.\n"
f"export AGENT_BOX_GH_BIN={q(f'{self.bin}/gh')}\n"
f"export AGENT_BOX_SESSION_BIN="
f"{q('/usr/local/bin/agent-box-session')}\n"
"export AGENT_BOX_WATCHDOG_COOLDOWN="
"\"${AGENT_BOX_WATCHDOG_COOLDOWN:-"
f"{self.spec.watchdog_cooldown}}}\"\n"
"export AGENT_BOX_WATCHDOG_MAX_ATTEMPTS="
"\"${AGENT_BOX_WATCHDOG_MAX_ATTEMPTS:-"
f"{self.spec.watchdog_max_attempts}}}\"\n"
# Free-form config, so the default is set by a BARE
# ASSIGNMENT where shlex.quote's quoting is real quoting,
# and tested with `+set` so an operator who exports an
# empty value keeps it. The module's half is written the
# same way, for the same reason (#486 review).
"if [ -z \"${AGENT_BOX_WATCHDOG_AGENT+set}\" ]; then\n"
f" AGENT_BOX_WATCHDOG_AGENT={q(self.spec.watchdog_agent)}\n"
"fi\n"
"export AGENT_BOX_WATCHDOG_AGENT\n"
+ (("if [ -z \"${AGENT_BOX_WATCHDOG_REPOS+set}\" ]; "
"then\n"
f" AGENT_BOX_WATCHDOG_REPOS={q(repos)}\n"
"fi\n"
"export AGENT_BOX_WATCHDOG_REPOS\n")
if repos else "")
+ f'exec {self.bin}/agent-box-watchdog-run "$@"\n', 0o755)
# A profile file is the env store in another directory, so it has
# the same one parser.
t.file(self.p("/usr/local/bin/agent-box-profile"),
Expand Down
26 changes: 26 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -1341,6 +1341,32 @@ open(sys.argv[3], "w").write(header + yaml.safe_dump(data, sort_keys=True))' \
# about ten seconds on every architecture. It also holds the settings
# daemon's fcntl side and the shell side to the same sidecar file,
# which is the one agreement nothing else checks.
# The stalled-assignment classifier (modules/src/watchdog.py). Its
# inputs are a GitHub timeline and other sessions' live claims, so a
# VM test would cost minutes a run to assert one case; the
# classifier performs no I/O of its own precisely so the rules can
# be asserted here instead.
watchdog-classifier =
pkgs.runCommand "agent-box-watchdog-classifier"
{
nativeBuildInputs = [ pkgs.python3 ];
watchdog = ./modules/src/watchdog.py;
tests = ./tests/test-watchdog.py;
} ''
install -d repo/modules/src repo/tests
cp "$watchdog" repo/modules/src/watchdog.py
cp "$tests" repo/tests/test-watchdog.py
# Not piped into tee: the log has to reach the build output
# whether the tests pass or fail, and the exit status has to be
# python's own.
python3 repo/tests/test-watchdog.py > log 2>&1 || {
cat log
exit 1
}
cat log
cp log "$out"
'';

registry-protocol =
pkgs.runCommand "agent-box-registry-protocol"
{
Expand Down
Loading