The 1.0 line, from the one-run redesign to the sbxloop home, cut as one
minor release: every entry below shipped as a 1.0.x patch since 0.7.0 and
is on the field host. The version jumps to 1.5 to mark the install and
layout cutover (the home) as the boundary a host must cross with
`sbxloop init --migrate`; see "The sbxloop home" under Changed and the
"1.0 cutover" section for the two manual steps in this line.
Added
- **The console edits configuration one setting at a time.** The Config
screen's Resolved tab is now the place changes are made, not just read.
Every setting is one addressable key — arrays of tables walked down to
`github.repos[1].deliver_base`, free-form maps down to
`sandbox.env.RAILS_ENV`, so nothing is a blob you have to find in a file
— and `Enter` on a row (or `e`) opens that key alone: what it accepts
(its type, the set a `Literal` allows, the bounds the model carries),
what it holds now and which layer is answering, and the file the answer
is written to. The widget follows the type — a picker for a bool or a
fixed set, one item per line for a list, a line of text otherwise — so a
string needs no quotes and a bad value is named before the loader sees
it; `^U` unsets the key instead. `a` adds a key by dotted path (an index
one past the end appends an entry), and `Enter` on the Repos tab narrows
the view to that repository's keys.
The write goes into the draft at that path and nowhere else, **every
comment in the file kept** (`tomlkit` round-trips it), and then through
the same gate the whole-file editor uses: the real loader validates the
entire draft, and only a draft it accepts is saved — atomically, with the
timestamped backup, and the restart offered. A key the environment or the
home config also sets is still written, and the verdict says which layer
wins and what the loop actually sees.
Changed
- **The sbxloop home.** Everything sbxloop puts on a host now lives under
one directory, `~/.sbxloop` (`SBXLOOP_HOME` moves it): the interpreter
(`venv/`, a uv-managed CPython, `bin/uv`), the launchers (`bin/sbxloop`,
`bin/sbx`), Docker's `sbx` (`sbx/`), the host config and secrets
(`config/sbxloop.toml`, `config/secrets.env`, `config/github-app.pem`),
the state (`state/state.db`, the conformance cache, the daemon's control
queue), the runs (`runs/<run>/`), each repository's dedicated clone
(`workspaces/<owner>/<name>/`, cloned by the daemon on first use), the
logs (`logs/daemon.log`, rotated), the caches and temp files, the
rendered systemd units (`systemd/`) and the snapshots (`backups/`).
`sbxloop init` builds it; `scripts/install.sh` bootstraps a bare host to
it with one `curl … | sh`; `sbxloop backup` snapshots it; `sbxloop daemon logs` reads the log file. Every command answers the same from
any directory — there is no runner directory and no working directory
any more.
**Cutover.** This is a hard cut. The former `state_dir` setting,
`[daemon] state_dir` and `SBXLOOP_STATE_DIR` are refused by name; the
`~/.config/sbxloop` user config and `.env`, the `$XDG_STATE_HOME/sbxloop`
state directories, the `~/.sbxloop-venv` venv and the `~/.local/bin`
launchers are not read, and `sbxloop doctor` fails hard while any of them
is still on the host. A host from before the home runs, once, from its
old install: `sbxloop init --migrate --purge` (add `--runner DIR` for an
Actions runner) — it backs up every old file and every `state.db` it
finds to `backups/<stamp>-migrate/`, carries the daemon's `state.db`, the
config (retired keys dropped, configured checkouts moved under
`workspaces/`), the secrets and the App key into the home, lays the home
out, re-renders the units and restarts the daemon, then removes the
leftovers. Old run directories are not carried. `sbxloop init --project`
is the former `sbxloop init` (a repository's own `sbxloop.toml`).
Config: `[daemon] backups_keep` (10) is how many snapshots the daily
sweep keeps.
Fixed
- **A workload plan declares what the ask needs.** Shown bounds it could
not meet, the planner rewrote the ask to fit them ("obtain an mp3 within
the no-external-host constraint"), declared no need, and the judge passed
the substitute — the fail-closed refusal that should have named the
missing setting never fired. The plan prompt now forbids narrowing the
ask and says a refusal is the right result for a need outside the
bounds; the judge fails a task whose criteria were narrowed away from
the outcome. Field finding 800.
- **Console chat: one line per short message.** Every message was drawn
as a header line (time, author, reactions) with its text on the line
below, so a channel of one-liners read at half density. A short message
now reads as one line, as the design and Discord do; cards, code and
multi-line bodies still drop below their header.
Added
- **A workload's files reach the thread.** The chat sink stages the files
its tasks produced beside the artifact sink's (`runs/<run>/artifacts`) and
the result message carries them as attachments — Discord uploads those
under the new `max_attachment_bytes` (10 MB by default) and names the
rest by host path; the console and Slack name every file by path. Field
finding 799: two workloads produced files and the thread got a path on
the daemon host.
- **Console config, secrets and doctor screens** (774). The last
operator acts outside the console were editing the configuration,
judging the secret registrations and running the doctor. **Config**
(`7`) shows the resolved configuration with the layer that set each key,
the effective egress policy and the repositories, and edits
`sbxloop.toml`: a draft is validated by the real loader (against a
scratch copy, the user and environment layers applied) before it is
saved atomically with a timestamped backup, a restart is offered, and a
draft the loader refuses is never written; `$EDITOR` is a key away.
**Doctor** (`8`) runs the host checks and the sbx conformance probes in
the background with their progress, cached with their age; the live
probes and the GitHub probe ask first. **Secrets** (`S` from Doctor, or
the palette) lists the tracked registrations as `sbxloop secrets list`
judges them, cleans the stale ones after a dry run (typed) and rotates
the agent credential's registration from a hidden prompt (typed). The
CLI and the console now render from one fold each:
`sbxloop.cli.doctor.doctor_report` (which hands the host checks over
before the conformance suite runs, so `sbxloop doctor --deep` prints
its table before booting the sandbox), `sbxloop.cli.policyview.policy_view`,
and `secrets_context` / `secret_rows` / `clean_secrets` /
`rotate_registrations` in `sbxloop.sbx.secretstate`. A draft is
validated in place of the daemon's `sbxloop.toml` at the real discovered
root — the project cut-down included, so a repository-carried file's
ignored keys are named instead of "loads" — and the editor follows the
directory the daemon reports in `status` (`cwd`), not the console's.
- **Console admin: sandboxes, daemon control, the journal, run operations,
a command palette** (773). The console could watch and chat but every
operator act still meant leaving it for the CLI, `systemctl` or
`journalctl`. Every admin verb now runs through one path — refused
read-only, refused without a live daemon when the daemon must execute
it, confirmed by tier (`y`/`n`, or the target's name typed for the
destructive ones), run off the UI thread, reported, re-polled. A run's
header offers cancel (the daemon's current run through `ctl`, any other
through the `sbxloop cancel` store write), retry / requeue / abandon /
check-review / approve-merge / grant-rounds on its item, a detached
`sbxloop resume` for a run with no item, and a shell in either sandbox
with the terminal handed over. The Queue screen carries the item verbs
(with the CLI's row-only twin when no daemon runs) and a new run the
daemon's way (the concierge files the issue) or a detached `sbxloop run`.
**Sandboxes** (`5`) is `sbx ls` classified as `sandbox prune` classifies
it plus the run directories `gc` would remove; shell, stop, remove,
prune and gc behind typed confirmations. **Daemon** (`6`) is the systemd
user unit (`[tui] daemon_unit`, `--unit`) with start / stop / restart,
the process behind `status`, pause / resume / cancel / graceful stop,
versions with `[daemon] upgrade_command`, per-repository health with
resume, and the journal streamed through the credential redactor with a
grep and a level floor; with no unit, `D` spawns a supervised
`sbxloop daemon` from the console. `ctrl+p` opens a command palette of
every screen and argument-less verb. `ControlClient` takes a `by` so the
source reads "cancelled by brett via sbxloop tui"; the concierge's
per-run usage fold is now `sbxloop.daemon.usage.usage_for_run`, shared
with the Phases tab's per-persona lines; the interactive shell argv is
`sbxloop.sbx.cli.INTERACTIVE_SHELL_ARGV`.
- **`log` and `stop` operator commands** (772). The daemon's recent log
lines were reachable only through the concierge's tool or `journalctl`
over ssh, and stopping the daemon gracefully was a signal. `!sbx log [--tail N] [--level L] [--grep TEXT]` and `sbxloop daemon ctl log` answer
from the in-process ring buffer — one rendering shared with the
concierge's `daemon_log` tool, so the three cannot drift — and `stop`
asks the daemon to claim nothing new and exit once the current run —
and any approved merge it is still landing — finishes (`cancel` first
to stop that run now); like every ctl request it is served only by a
live daemon, refused when stale, and audited. A log tail sent to chat
drops its oldest lines to fit the message, never its newest; `--tail`
and `--level` may follow `--grep`; `status` says when a stop is under
way; the stop flag goes up only once the reply is on its way, so a chat
bridge is not closed under its own answer; and the concierge's
`sbx_control` tool refuses `stop` — ending the process stays with a
person. Under a service manager that restarts the daemon (the shipped
unit does) `stop` is a restart that drops in-memory holds: `pause` is
the way to keep it off work.
- **Console chat: the control channel and run threads** (771). With the
shell alone an operator could watch a run but not speak to it. The Chat
screen and a run's Thread tab now show the daemon's local chat bridge —
the rows Discord or Slack would show — and the form under them writes
to it: `!sbx` verbs, concierge turns and steers by the bridge's own
routing rules (`sbx`, the sticky `ctrl+t` address gesture, or `r` to
reply to the bot's latest row; plain text is left alone), a button per
clarifying-choice answer, the approve button on a merge gate. Edits,
reactions and resolved gates repaint in place; own rows show dimmed
until the daemon claims them; the bar counts unread control-channel
rows; `--read-only` disables the form.
- **`sbxloop tui`: the operator console** (770). A person on the daemon
host had the CLI's one-shot commands and journald. `sbxloop tui` is a
terminal console that reads the daemon's `state.db` read-only and asks
the daemon `status` through the `ctl` queue every few seconds: an
Overview (the run in flight, the queue, who waits on a human, recent
runs), Runs with a per-run screen (the `sbxloop run` transcript tailed
from the store, tasks, every phase attempt with its tokens and turns —
never a currency, landing state and the newest landing events,
artifacts, the dense event lines with a type filter), the Queue, and
Help. `--run` opens a run at once, `--read-only` removes every action,
`--state-dir` overrides the daemon's rule. Textual is a core dependency.
`docs/tui.md` documents the layout and keys; the docs' mention of a
`sbxloop watch` TUI that never shipped now names this. The chat screens
and the admin screens follow.
- **The daemon always runs a local chat bridge for the operator console**
(769). A person on the daemon host had the CLI and journald; everything
a run *shows* — the headline, the thread, the status line and tool digest
edited in place, steering, the concierge, clarifying-choice buttons, the
merge-gate approve button — existed only on Discord or Slack, and a
headless daemon had no concierge at all. The daemon now runs a
`LocalBridge` beside whatever `[chat] backend` names, through one
fan-out frontend: a third `ChatBridge` whose transport is a mailbox in
the daemon's own `state.db` (`daemon_local_messages`) — every message
the bridge would post becomes a row, edits rewrite it, reactions
decorate it, and what an operator types in `sbxloop tui` arrives as a
row the bridge claims, the same file-drop shape as the ctl queue. A row
typed before the daemon started is refused with a note, never executed.
The concierge is built whenever `[concierge] enabled`, headless
included — a headless host now boots the concierge sandbox at start and
needs the agent credential. `[tui]` carries the console's knobs
(`operator_id`, `emoji`, `daemon_unit`, `refresh_s`, `retention_days`)
beside the shared rendering ones; `[chat] backend` still names only the
external service. `sbxloop.daemon.mailbox.MailboxClient` is the
console's handle: read-only for state, one kind of write, no schema
statement — so a console never migrates a store under a running daemon.
Both stores open read-only for it (`readonly=True`: a `mode=ro` URI,
no schema statement). With two bridges up, each renders only the
requester and watcher ids it owns (a snowflake is Discord's, a member
id Slack's, a login name the console's), and the concierge words each
reply for the surface the message came in on. `ctl status` reports
`pid`, `started_at` and `version`; `sbxloop doctor` shows an `operator console` row and its concierge row no longer needs a chat backend. The
console itself (`sbxloop tui`) follows.
Changed
- **Chat state is keyed by backend** (768). The daemon is about to run
the operator console's local chat bridge beside Discord or Slack, and
the store assumed one bridge: `daemon_chat_threads` was keyed by run
alone, a run's watchers were one list that the first bridge to finish
drained, the merge gate's prompt location lived on the gate row, and
every bridge's clarification sweeper fired every backend's due asks.
Threads are now `(run, backend)` (the bare lookup prefers the external
backend's thread, what a link in prose points at), watches carry their
backend, gate prompts live in `daemon_gate_prompts` per backend, and a
sweep takes only its own backend's asks, and the clarification cap
counts one backend's asks. An existing store is rebuilt on open, once,
with the indexes recreated inside the rebuild's own transaction; a
pre-upgrade watch or gate prompt is filed under the backend that opened
the run's thread — a Slack daemon's under `slack` — and the gate row's
old prompt columns are cleared once carried, so the step is idempotent
through a rollback. Nothing changes for a one-bridge daemon.
`tests/fakes/legacy_db.py` freezes the shape before this as
`pre_local_bridge`.
Fixed
- **No bare sbxloop issue numbers reach users** (635). A provisioning
error ended "(see 46)", doctor's conformance drift rows carried
"(57)", "(250)", "(issue 122)", "(592)", and the files
`sbxloop init` writes into the user's project said "(533)" and
"(568)" — references into sbxloop's tracker that read as noise, or as
the user's own repository's N, to anyone not developing sbxloop. All
stripped; code comments and docstrings keep theirs.
- **CI runs once per pull request** (643). `ci.yml`'s push trigger was
`[main, "sbx/**"]` — a personal branch convention, and one that made
every job run twice on such a branch's PR (push and `pull_request`
both fire). It is `[main]` alone now; working branches are built
through their pull request. The loop's own `sbxloop/<run>` delivery
branches were never in the filter and stay that way, for the same
reason.
- **Rollback keeps both chat extras** (619). The deploy pipeline's rollback
reinstalled `sbxloop[discord]` while the upgrade installs
`sbxloop[discord,slack]`, so a rolled-back Slack host would have lost its
bridge. Both lines now install `[discord,slack]`, and
`test_deploy_workflow.py` asserts the two stay in step — in the
repository's own workflow and in the contrib example alike.
- **Host commands follow `[agent] backend`** (617). One descriptor
(`sbxloop.backends`) says what each backend needs — credential env var
and sbx binding host, the network hosts that credential path reaches,
the missing-credential wording, where its model ids come from — and
`sbxloop doctor`, `sbxloop secrets list|clean|rotate`, `sbxloop list-models`, `--model` help, provisioning and sandbox pruning read it.
Under `backend = "claude"`: doctor's credential, network-policy and
concierge rows name `ANTHROPIC_API_KEY` / `api.anthropic.com` and the
Copilot-SDK permission-kinds row is not emitted; `secrets` manage the
Anthropic registration (`rotate` reads/prompts for `ANTHROPIC_API_KEY`);
`list-models` lists the Anthropic Models API (`GET /v1/models`, all
pages, stdlib only; FIELD-UNVERIFIED against a live key); prune removes
either backend's agent registration since the backend may have changed
since the sandbox was provisioned. The copilot path is byte-identical.
- **Run clones are single-branch and tagless** (632). Every per-run
clone — of a host checkout or of the remote — is cut
`--single-branch --no-tags`, so a repository's whole branch and tag
history no longer travels into each run. Safe because
`merge_from_base` now fetches the delivery base by explicit refspec
before merging and diffing, so a base that is not the clone's branch
still resolves; continuing an existing branch fetches that branch the
same way. Shallow clones stay off the table: a `--depth 1` clone has no
history to compute a merge base from.
- **The "created repository" link is host-correct**: the Discord embed
uses the repository's `html_url` from the probe instead of assuming
`github.com`.
- **Follow-ups on a repository with Issues disabled are not lost** (631).
`POST /issues` answers 410 Gone there, and the filing's best-effort
guard logged it and moved on — the review's out-of-scope notes were
silently dropped. The delivery probe now reads `has_issues` off the
repository payload it already fetched and downgrades
`[landing] followups = "issues"` to the PR checklist comment, recorded
on the `run.followups` event (`downgraded_from = "issues"`,
`reason = "issues_disabled"`); a payload that did not say downgrades on
the 410 itself. The crash-window dedup skipped nothing the issues
endpoint listed — including pull requests — so a labelled PR quoting a
marker suppressed the issue; PRs are now filtered out. The issue body
names the trigger label only when a daemon dispatched the run; under
`sbxloop run` nothing polls the repository and the old sentence pointed
at a label that did nothing.
- **A red non-Actions check's own log reaches the fix brief** (629). The
worker now follows the check's `details_url` / `target_url` best-effort
— unauthenticated, https only, `text/*` or JSON bodies, the Actions log
size clamp — when the check reported no output of its own. Any failure
(a host the sandbox policy does not allow, an HTML page, an auth wall)
leaves the brief at the check's name, link and reproduce-locally
instruction. No new knob: `[sandbox] extra_allow_domains` is where a CI
host worth reading goes.
Changed
- **The version story no longer assumes the user's repository publishes
sbxloop** (638). The concierge prompt, the `version_status` tool
description and `daemon/versions.py` said "the main branch publishes a
release on every merge" and told the operator to
`pip install --upgrade` in a venv — sbxloop's own release cadence and its
own install layout, presented to every host. They now say that
sbxloop's releases ship frequently while upgrading a host is an
operator's step, and the upgrade instruction renders from
`[daemon] upgrade_command` when set — otherwise "the exact command
depends on how sbxloop was installed (pip in a venv, pipx, `uv tool`, a
container image, a deploy pipeline)". The prompt's "the configured
repository" is now "a configured repository" (there may be several).
- **The large-repo preset is package data, framed by gate duration**
(636). `contrib/presets/large-repo.toml` moved to
`sbxloop/data/presets/large-repo.toml` inside the wheel (the contrib
path is a symlink to it), and its header no longer cites sbxloop's own
numbers as the reference case: the trigger is a repository whose gate
command takes two minutes or more, whatever its size. The template's
`[budgets]` comment and the README point at
`sbxloop init --preset large-repo` instead of a checkout path, so
nothing `sbxloop init` writes references a file outside the user's
project. (Recording observed gate duration so a second run self-sizes
its budgets stays a separate follow-up.)
- **The deploy pipeline reads structured control, not files** (639).
`deploy.yml` drives the daemon with `ctl status --json` + `jq` and posts
with `daemon notify`; no step sources `secrets.env`, parses
`sbxloop.toml` or calls the Discord API — a Slack-backend host deploys
unchanged. **Cutover:** the drain step fails closed when the running
daemon answers without a structured status, which a daemon older than
this release does, so the first deploy after it lands stops at "Wait for
the daemon to go idle" *before installing anything*. Upgrade once by hand
(contrib/systemd/README.md, "Upgrading"); every deploy after that is
unattended again.
- **The deploy host is one variable** (640). `deploy.yml` targets
`runs-on: [self-hosted, "${{ vars.SBXLOOP_DEPLOY_HOST || 'db' }}"]` and
derives every path from `$HOME` (job-level `env:` values are literals, so
a first step writes them to `$GITHUB_ENV`); moving the daemon is setting
the repository variable and registering a runner with that label — no
edit to the workflow, nothing `make check` runs. The
drain/hold/upgrade/health-check/rollback pattern ships as
`contrib/workflows/deploy-daemon.yml.example` (`schedule` +
`workflow_dispatch`, installs from PyPI, names nothing), and
`test_deploy_workflow.py` checks both files for the security invariant,
the extras parity and the absence of names.
- **Deploy docs split** (642). `docs/deploy.md` is now the generic "run the
daemon as a service and upgrade it" guide — no hostnames, usernames or
repository slugs, enforced by a test — and `docs/self-deploy.md` the
clearly labelled reference for how sbxloop deploys its own host, with the
cutover notes. The systemd README's upgrade section leads with the
two-command manual path (hold, wait for idle via `ctl status --json`,
pin, `reset-failed` + restart) and mentions the workflow as optional
automation; `github-runner.service` is marked as needed only for it. The
1.0 cutover steps moved from `docs/deploy.md` to this file (below).
- **A gate against self-references in user-facing surfaces** (645).
`scripts/check_self_references.py` (stdlib only; run by `make lint` and
the CI lint job, and by the unit suite) fails with `path:line: rule: text` on a bare `N` in a prompt body below its contract header, in any
`raise`'s message in either package, in the CLI package's and the
conformance table's string literals, or in a file `sbxloop init`
writes; on an sbxloop source path inside a prompt body; and on a
maintainer or deploy-host identifier in any tracked file outside
`contrib/`, `docs/`, `.github/`, package metadata and tests. Comments
and docstrings are not surfaces. Deliberate exceptions live in one
reviewed file, `scripts/self-references.allow` (today: the concierge
prompt's worked-example numbers); an entry that matches nothing fails
the gate too, so the list cannot rot.
- **`[daemon] version_check` and `[daemon] upgrade_command`** (641, 638).
`version_check = false` switches the PyPI release lookup off for the
whole daemon — no startup drift check, no drift notice, and the
concierge's `version_status` reports the installed versions without
looking "latest" up (zero outbound HTTP, alongside the existing `.dev`
skip) — for hosts a pipeline upgrades, which retires the contradiction
between `docs/deploy.md` and the drift notice. `upgrade_command`
(e.g. `"pipx upgrade sbxloop"`) is what the drift notice and the
concierge's report tell the operator to run; unset, they say the command
depends on how sbxloop was installed. Both are `SBXLOOP_DAEMON__*`
overridable; a blank `upgrade_command` is a config error. Operators of
the self-deploy pipeline in `docs/self-deploy.md`: set
`version_check = false` on that host.
- **`sbxloop init --preset NAME`** (636) appends a packaged preset's live
sections to the starter file (`--stdout` streams the same), so
`sbxloop init --preset large-repo` yields one self-contained
`sbxloop.toml` from a wheel with no checkout around. Every table in the
template is commented out, so the appended `[budgets]`/`[limits]` are
the only live ones. An unknown name exits 2 naming the presets that
exist.
- **`sbxloop daemon ctl status --json`** (639) — the daemon's status as one
JSON object (`current`, `claiming`, `holds`, `paused`, `queued`, …) for
scripts, instead of grepping the prose, which is now free to change. The
reply carries the structured dict alongside the text; a daemon that
predates the flag answers prose only and `ctl` exits 1 ("answered without
a structured status") — distinct from exit 2, no daemon.
- **`sbxloop daemon notify "<text>"`** (639) — post one message to the
control channel through the configured `[chat] backend`, from the host
and without the daemon, so a deploy script can say "rollback also failed"
while the daemon is down. Reads the channel from `sbxloop.toml` and the
bot token from the environment (`DISCORD_BOT_TOKEN` / `SLACK_BOT_TOKEN`,
the working directory's `.env` included); Slack text is re-dialected the
way the bridge does it; link previews and pings are suppressed; a
headless daemon cannot notify and says so.
- **`[github] api_url`** (623) — the GitHub REST root
(`https://api.github.com`; `https://ghe.example.com/api/v3` for GitHub
Enterprise Server) is the one source of truth for the REST transport,
App-auth minting, the remote clone URL, PR links and both sandboxes'
network allows. The github sandbox receives `GH_HOST` (for `gh`) and the
worker `SBXLOOP_GITHUB_API_URL` only when the host is not github.com. A
`GH_HOST` in the daemon's environment that disagrees with `api_url`
fails config load with a message naming both. Deliberately not derived:
the Copilot token exchange host stays `api.github.com` (Copilot is served
from github.com even for GHES), and sbx's built-in `github` service
secret stays github.com-keyed. FIELD-UNVERIFIED — no GHES to test
against.
- **`[sandbox] clone_filter`** (632) — opt-in git partial-clone filter
(`"blob:none"`) for the credential-free remote clone of a repository
with no host checkout. Off by default because lazy blob fetches happen
wherever git next needs one, the VM included; a git without `--filter`
logs `workspace.clone_filter_unsupported` and clones in full.
- **`sbxloop init-repo owner/name`** creates the labels the loop relies
on (630): the six lifecycle labels and `[landing] followup_label`, each
with a color and a description, idempotently, through one github-ops
sandbox. Nothing created the trigger label a human was told to apply,
and the lifecycle labels auto-created on first attach with a random
color and no description. `sbxloop doctor` gained advisory rows for missing labels (pointing
at `init-repo`) and for a repository with Issues disabled; it stays
advisory. Every lifecycle label (`trigger_label`, `in_progress_label`,
`failed_label`, `completed_label`, `blocked_label`, `gated_label`) can
now be renamed per `[[github.repos]]` entry — `Config.labels_for(repo)`
is the one merge, and a repository's six must stay distinct. A claim
that GitHub refuses with 403 on the label write now fails with an error
naming the permission (Issues → read and write; classic PAT `repo`)
instead of a bare status.
- **The agent sandbox's allowlist never names a host twice** (616).
`sbx policy allow` refuses a rule it already holds — including one
created moments earlier from the same argv — and the refusal fails the
whole call, so a repeated host did not waste a rule but failed
provisioning outright. The tiers overlap by construction: the claude
backend pulls in the javascript toolchain for the Claude Code CLI, and
its installer host is the `registry.npmjs.org` the advertised baseline
already promises, so *every* claude-backend sandbox hit it — field
failure on `db`, where the concierge box could not provision at all
(`concierge.warm_up_failed`) and Discord mentions were dead. The union
is now deduped in `agent_policy_allows`, and again where any spec's
list is applied, so an operator naming a host in `extra_allow_domains`
that a toolchain or the baseline also opens is no longer fatal.
- **A head with no checks is settled at landing too** (633). `land()`
trusted "nothing has reported" on sight; only the CI stage after a
delivery waited out `ci_settle_s`. A resume at the landing stage, a
merge-gate approve and the head an update-branch makes now hand the
read to `poll_checks`, settling from when the landing first saw that
head — a slow CI's first run is no longer merged ahead of. The engine
passes its delivery time along (`settle_from`) so the window is not
paid twice in one drive. `ci_settle_s` is documented as calibrated to
GitHub Actions' registration latency; raise it for CI that registers
later.
- **A reconciliation marker counts only in the loop's own reply**
(618). `has_reply_marked(marker, login)` now requires the stamped
comment to be loop-authored; a person quoting the marker back no
longer makes a thread answered or acknowledged.
- **The loop's identity carries its kind** (622). `Identity` /
`identities_match` compare logins under the `[bot]` fold **and** the
account kind when both sides know it (App from the slug or
`user.type`/`__typename`, user from `GET /user`), so a person named
`foo` is never the App `foo[bot]`. `resolve_identity` consults, in
order, the App slug, `GET /user`, the new `[github] bot_login`
(overridable per `[[github.repos]]` entry) and — only when the
delivering credential is the reviewing one — the PR's author.
`ThreadComment.is_bot`, `Pipeline.is_bot` and an `is_bot` argument on
the landing, reconciliation and acknowledgement paths carry it through.
- **The merge method follows the repository, and a merge GitHub refuses
is named** (620). `[landing] merge_method` defaulted to `squash`,
which a repository with squash merging disabled answers with a 405 the
loop reported as "branch protection"; and a PR whose checks were green
but whose `mergeable_state` was `blocked` was polled to the timeout.
The default is now `auto`: the first of squash, merge, rebase the
repository's settings allow, resolved once per landing and logged in
`land.merge_method`. An explicit method the repository disallows is
never swapped for another: `sbxloop doctor` reports it on the repo
row (`merge method squash not allowed`) and a run ends `blocked`
naming it. A `blocked` mergeability after green checks is re-read
once and then explained from the base's protection — the required
approvals its identity cannot give (with the `merge_gate = "chat"`
pointer), CODEOWNERS, the merge queue, unresolved conversations —
and a bare 405 carries the same reading. `unstable` (a non-required
check red) stays mergeable.
- **The PR title, commit message and branch name are the operator's,
and the plan can title its own PR** (621). Every PR was
`sbxloop: <outcome>` on `sbxloop/<run>` with a fixed commit message,
which a repository's title lint, commit lint or branch ruleset
refuses — and the refusal read as a mystery 422. `[github]` gains
`pr_title_template` (default `sbxloop: {title}`),
`commit_message_template` and `branch_prefix` (default `sbxloop/`),
each overridable per `[[github.repos]]` entry, with `{title}`,
`{outcome}`, `{run_id}` and `{repo}` placeholders; the defaults
render byte-for-byte what shipped before. The decomposer may return
a `pr_title` in the repository's own commit style (it is shown the
recent `git log`); `{title}` falls back to the outcome when it does
not. A fix round can retitle the PR by writing `.sbxloop/pr-title`
in the workspace — a red title-lint check is thereby curable — and a
re-delivery whose title changed PATCHes the PR (`deliver.title_changed`).
A branch the repository's rulesets refuse fails the delivery naming
`[github] branch_prefix` rather than the raw 422.
- **A workflow waiting on a maintainer's approval ends the run blocked
and named, not timed out** (612). A first-time contributor's — or a
fork's — workflow run sits at `action_required` until a maintainer
approves it; the loop read that as a failure and spent its fix rounds
on it. It is now its own bucket (`ChecksVerdict.needs_approval`): the
poll returns at once, no fix round is spent, and the run ends
`blocked` with "check X needs a maintainer to approve the workflow
run". A real red beside it is fixed first, and the approval is
re-judged on the re-delivery.
- **A backend switch no longer leaves a stale concierge sandbox behind**
(533). The daemon's concierge box is deliberately reused across
restarts, and the reuse gate asked only whether the installed worker
matched this host. But the worker is installed with `[agent] backend`'s
extra, so a box built under copilot carries the Copilot SDK and no
Claude Code CLI while reporting the very same version: switching the
backend kept it, and every mention then failed with
`BackendUnavailableError` until an operator removed the sandbox by
hand. The gate now also asks whether the box is equipped for the
configured backend (`WorkerClient.backend_ready`), and re-provisions
when it is not. The question is answered by the worker's own
precondition, hoisted out of `run_session` into
`backends.ensure_available`, so there is no second host-side copy of
what each backend needs to drift.
- **`sbxloop doctor`'s concierge row follows `[agent] backend`** (533).
It named `COPILOT_GITHUB_TOKEN` unconditionally, so a claude-backend
host was told "mentions will fail" while nothing was wrong — and was
told nothing about `ANTHROPIC_API_KEY`, the credential that actually
gates it. The row now names the configured backend's credential, like
the agent credential row above it.
- **`status`, `logs`, `artifacts` and `gc` look where the daemon actually
writes** (255). The daemon anchors its state at
`$XDG_STATE_HOME/sbxloop/<project>`, away from the top-level
`state_dir`; the run commands read `state_dir` verbatim, so on a daemon
host they reported an unrelated — usually stale, often empty — world,
with no flag to correct them. `sbxloop daemon` and its `ctl`
subcommands already resolved this way; the run commands now do too, via
`paths.resolve_cli_state_dir`. The redirect fires only when a daemon
store is actually present, so a single-user `sbxloop run` host is
unaffected, and nothing moves on disk.
- **`sbxloop bake` installs the configured languages, and a prebaked run
tops up what the template lacks** (615). The bake ignored `[sandbox] languages` and installed Python alone; a run on that template then
verified the baked worker, skipped the install ladder — and with it
the toolchain provisioning of 624 — and handed the agent a sandbox
without the language it had just resolved for. The bake now
provisions the configured languages (through the same allowlist
builder the runs use, so their installer hosts are reachable at bake
time too) and records what actually landed in `bake.json` and the
host's bake record. A prebaked run keeps the fast path but probes its
full resolved set in one batched `sh -c`; whatever is absent is
provisioned on top, named in `worker.prebake_topup` and in the
`sandbox.prebaked` event (`topped_up`). A probe that cannot answer
falls back to the per-tool probes rather than assuming presence.
`sbxloop doctor` gains a *languages in template* row that compares the
baked set with `[sandbox] languages` and says when a re-bake would
stop the per-provision top-up; a bake that cannot probe its own
result fails instead of recording a guess.
- **The gate is detected for more than a Python-and-npm repo** (625,
626). Detection knew a `check`/`ci` target in a makefile, justfile or
Taskfile, an npm script, tox and nox — and always ran the npm script
with `npm run`, which on a pnpm or yarn workspace fails to resolve
the very tools the script names. The package.json script now runs
under the client the project uses: the `packageManager` field, else
the lockfile (`pnpm-lock.yaml`, `yarn.lock`, `bun.lock[b]`), else
npm. `verify` joins `check`/`ci` as a gate target everywhere (`all`
does not: it is the default build). New detectors: a Rakefile
`ci`/`check`/`default` task (`bundle exec rake <task>`), composer
`check`/`ci` scripts, a Gradle build with its wrapper (`./gradlew check`; the sandbox has no Gradle of its own, so a build without a
wrapper is no gate), `pom.xml` (`./mvnw -q verify` or `mvn -q verify`), and a `[alias] ci` in `.cargo/config.toml` (`cargo ci` — a
`check` alias is not honored because cargo silently shadows it with
the built-in). Go, Rust and .NET have no task-runner convention, so
when such a repo declares nothing the tool itself is the gate: `go vet ./... && go test ./...`, `cargo test`, `dotnet test` (a .NET
tree with one solution, or no solution and one project). Every
detector is tied to the language whose toolchain runs it and is
consulted only when that language was resolved for the sandbox
(624), so the loop never asks the sandbox for a command it cannot
run; the task-runner detectors are consulted under any set.
- **Red checks are judged against the base the PR is built on** (611).
A red check on the head used to mean "this PR broke it": every red
spent a CI fix round, and a base whose own CI was already red — a
flaky job, a broken nightly — could never be landed on. The landing
stage now folds the same checks on the PR's merge base (the compare
API; never the base's *current* head, whose red is someone else's)
and reads the base's protection and rulesets (`gh/protection.py`,
also what `doctor` reads now). A red already red on the base is
**preexisting**: merged over and named in a PR comment. A red the PR
caused is a **regression**: fixed for the full `max_ci_rounds` if the
base requires that check, for **one** round if it does not — after
which it is merged over and named, so a signal no human demanded
never blocks a landing. Absent from the base (a check that only runs
on pull requests) or an unreadable baseline counts as the PR's own —
"could not tell" fails closed. A base that declares no required
checks gates on all of them, as before; a required check red on the
base is still fixed (GitHub will refuse the merge otherwise), and its
fix brief says the failure was inherited. Only gating checks are
waited on. New `[landing] required_checks` (an explicit gating set,
overriding what the base declares) and `ignore_checks` (fnmatch
patterns dropped everywhere); new `landing.checks` event with the
gating set and its source, the pending, fix, regression, preexisting,
advisory and ignored names, and the baseline sha.
- **An automated reviewer's changes-requested review is a signal, not
a veto** (613). A GitHub App that reviews pull requests (CodeRabbit,
Copilot, Sourcery…) leaves a `CHANGES_REQUESTED` it never dismisses,
and the landing stage read it as a person's: one fix round, then
`blocked` for ever with "only they can dismiss it", so no PR on a
repository with such a bot ever landed. Reviewers now carry whether
they are a bot — REST `user.type == "Bot"` on reviews and comments,
GraphQL `author.__typename` on threads (`ThreadComment.is_bot`,
`ReviewThread.opened_by_bot`, `HumanObjection.is_bot`; the field
622 reads). A bot's standing review buys **one** dedicated fix round
(`fix.round` kind `bot`, its findings in the brief, its threads
answered by the reconciliation that follows the fix — spending a CI
round when one is left, skipped when none is); a bot review still
standing after that is merged over, named in a PR comment ("bots do
not dismiss their reviews"), and reported as `land.bot_standing`. It
never produces the terminal block. A person's review is untouched:
full authority, and a person standing beside a bot still wins. A
bot's inline threads are neither acknowledged nor a reconciliation
block — the gate is for people. New `[landing] ignore_reviewers`
names User-type accounts to treat as bots (a reviewer on a personal
token); there is no reverse list, an App is never a person. Human
thread acknowledgments are capped at 25 per landing pass
(`land.human_ack_capped`), the remainder blocking truthfully with a
note rather than posting hundreds of replies in one go.
- **Review, comment, check, and thread reads are paginated** (614).
Every GitHub list read took the first page (30 entries) as the whole
list, so on a busy pull request a standing `CHANGES_REQUESTED` past
the first page of reviews was invisible, inline comments past the
first page were never answered, and `reviewThreads` stopped at 100
threads / 50 comments per thread with no cursor — the exact silent
merge the reconciliation gate exists to prevent. REST list reads now
go through one `raw_pages` walk (`per_page=100`, following `page=`
to a short page: reviews, review comments, issue comments, the
follow-up dedupe's label issue list, check runs, commit statuses, the daemon's claim comments and label events;
the worker's `checks.failed_logs` walks the same way); the thread
listing follows `pageInfo.endCursor` and reads each thread's comments
at the connection maximum. Consistent with the gate's "we could not
tell is not there is nothing to answer": a list longer than ten full
pages, or a thread whose comments have a further page, raises
`PaginationError` — the landing gate blocks on it at once, naming
the thread, instead of retrying or judging a prefix.
- **Commit statuses count as CI** (610). The CI gate read only the
Checks API, so a repository whose CI reports through the older Status
API — Jenkins, Buildkite, Travis, CircleCI's default integration,
Codecov, most org bots — looked like a repository with no CI at all,
and its red delivered head was settled as done. `pr_checks` now reads
`/commits/{sha}/status` alongside `/commits/{sha}/check-runs` and folds
both into one verdict (red beats pending beats green; `failure` and
`error` are both red; names are the check name or the status context,
untagged, so they match branch protection's required contexts). The
fold keys on the `statuses` list, never the payload's top-level
`state`, because GitHub reports `pending` for a commit with no statuses
— reading that would deadlock every Checks-only repository. "No CI"
now means both lists empty. The failed-logs op reports red statuses
too, carrying the status `description` and `target_url`.
- **A check without a readable log is briefed as such** (629, minimum).
The fix brief used to show `(no log output was available)` under a
failing check, which reads like an empty log. It now shows the check's
link next to its name, says the log is not readable from the sandbox,
and tells the fixer to reproduce the failure with the project's own
gate before changing anything. Every failing check's `details_url` /
`target_url` is now in the brief.
- **The toolchain series a run provisions comes from the workspace** (627).
Every Python project got Python 3.13 and every Node project Node 24,
whatever they declared; a `requires-python = ">=3.11,<3.12"` project's
own `uv sync` then refused the interpreter it was handed. `Toolchain`
entries with a series now read the declaration — Python from
`.python-version` then `[project] requires-python` (PEP 440, via
`packaging`, a new runtime dependency), Node from `.nvmrc` /
`.node-version` (a major, a full version or an `lts/<codename>` alias)
then `engines.node` (node-semver ranges) — and provision the default
series when it satisfies the declaration, else the highest series this
host can install that does (Python 3.8–3.14; Node 18, 20, 22, 24, each a
pinned, checksum-verified tarball), else the default with a
`toolchains.version_unsatisfiable` warning. Each choice is a
`sandbox.toolchain` run event carrying the series, its source (the file
read, or `default`) and the constraint, so a probe failure reads against
the interpreter the project asked for. `[sandbox] languages` still
decides *which* toolchains; the workspace decides the series either way.
A prebaked template is topped up to the declared series rather than
trusted at the default. An undeclared project provisions exactly what it
did before. Go needs none of this: `go.mod`'s `toolchain` directive
already makes `go` fetch what the module declares.
- **The config-override lint reads TypeScript and Ruby projects** (628).
`verifylint.CONFIG_SCOPED_TOOLS` entries now say how their tool treats
an explicit path: the Python entries keep their include-set rule (a path
outside `[tool.mypy] files`, ruff `src`/`include` or pytest `testpaths`
overrides it; one inside only narrows the run), `rubocop` gains an
*exclude* rule (a file named on the command line is inspected even when
`AllCops/Exclude` in `.rubocop.yml` lists it — what `--force-exclusion`
exists to switch off — while a directory argument is still filtered) and
`tsc` a *whole* rule (any input file makes it ignore `tsconfig.json`
entirely; `-b`/`--build` takes projects and disarms it). Config sources
may be YAML (`pyyaml` is a new runtime dependency), a source with no key
is satisfied by the file's presence, `npx`/`npm exec`/`pnpm exec`/
`pnpm`/`yarn`/`bundle exec`/`dotnet` prefixes are seen through like
`uv run`, Go/Rust/TypeScript/Ruby/Java/C file suffixes and directory
names (`cmd`, `pkg`, `internal`, `spec`, `crates`) read as paths, and
the suggested bare form keeps the command's flags (`uv run mypy --strict packages` → `uv run mypy --strict`). The worked example each entry
renders into the prompts (634) is now asserted to be exactly what the
lint flags, and the ecosystem fixtures gain a `lint` column. Deliberately
*not* added: eslint and golangci-lint. The issue asked for `npx eslint src` against `eslint.config.js` and `golangci-lint run ./pkg/...` against
`.golangci.yml` to be flagged, but both tools keep applying their
configured ignores to command-line paths (eslint flat config's
`ignores`; golangci-lint v2's `exclusion_paths`, the v1 explicit-directory
carve-out is gone), so those commands are narrowings, and flagging them
would reject a correct verify command.
- **Toolchains are detected from the workspace** (624, 616, 644). A run
with `[sandbox] languages` unset now provisions what the repository
declares — `go.mod` selects Go, `package.json` JavaScript, `Cargo.toml`
Rust, `pom.xml`/Gradle files Java, `Gemfile` Ruby, `composer.json` PHP,
`*.csproj`/`*.sln` .NET, `tsconfig.json` TypeScript — reading the root
and two levels of subdirectories (dependency trees and dot-directories
excluded) and selecting every match. Python remains the answer only when
nothing is recognized, so runs on repos with no manifest are unchanged;
an explicit `languages` still replaces detection outright. The resolved
set is decided once per run, before the agent sandbox exists, and
reported as a `sandbox.languages` event (`source`: `config` / `detected`
/ `default`, plus the manifests that fired); the egress allowlist, the
toolchain install, and the verify-command lint all read that one answer.
Each toolchain now also carries its installer hosts, and the agent
sandbox is created with the *selected* toolchains' hosts allowed
(`nodejs.org`, `go.dev` + `dl.google.com`, `static.rust-lang.org`,
`builds.dotnet.microsoft.com`, `getcomposer.org`), so a Node or Go
project provisions under a default-deny sbx preset without
`extra_allow_domains`; a language that was not selected opens nothing,
and `[policy] deny` still wins. `sbxloop bake` allows the same hosts for
the configured languages. An ecosystem fixture matrix under
`tests/fixtures/ecosystems/` now pins these expectations per project
shape.
- **Chat names the agent backend next to its model** (601). Discord and
Slack messages that surfaced only a model reference now read
`backend · model` (`copilot · gpt-5`, `claude · claude-sonnet-4-5`), so a
reader of a control channel or a run thread can tell a GPT model offered
through Copilot apart from a Claude model offered from Claude without
leaving chat. The pair appears everywhere the model was surfaced before —
run headline cards (text and embed), agent message attribution in a run
thread, and the concierge's `run_usage` / `usage_today` reports — and is
identical on both chat backends. The backend shown is the one the run
itself recorded, so re-rendering an old run under a switched backend does
not relabel it. Runs and usage events recorded before this change carry no
backend and render as `unknown` rather than a blank, a placeholder or an
error.
- **Re-adding the trigger label restarts an issue** (600). Applying
`sbxloop:run` again to an issue whose last attempt finished (done,
failed, blocked or cancelled) now re-queues it on the next poll whether
or not the issue text changed — the label is never silently inert, and
an operator `!sbx retry` is no longer the only way back in. The
re-queued item keeps what the previous attempt pushed to origin (its run
id, branch and PR, in the new `prior_run_id` / `prior_branch` /
`prior_pr_number` columns, added in place on open) so the restart
continues that branch instead of redoing it; with nothing usable on
origin the run simply starts fresh. Live items (queued, claimed,
running, resume-pending) still dedup, so a poll never double-dispatches.
- **An optional Claude agent backend** (533). `[agent] backend = "claude"`
runs every agent persona through the Claude Agent SDK (the Claude Code
harness) instead of the Copilot SDK, which stays the default with
unchanged behaviour. The agent sandbox then holds `ANTHROPIC_API_KEY`
alone — bound to `api.anthropic.com`, delivered by the same secret tiers
as every credential, redacted everywhere the Copilot token is — and
provisioning installs the runtime the SDK spawns (Node plus
`anthropic-ai/claude-code`, probe-first) and keeps the CLI hermetic
(no telemetry/auto-update egress). Contract parity throughout: the same
`agent.*` event stream, the read-only critic barrier (allowlist with
default-deny on Claude's tool vocabulary), the tool-call governor,
session resume as an optimisation with fresh-session fallback, host
tools as an in-process MCP server, and token usage reported through the
existing `run_usage`/`usage_today` accounting. Invalid or missing
configuration fails fast: an unknown backend fails config loading; a
missing key fails before any microVM boots; `sbxloop doctor` shows the
backend-appropriate credential and egress rows.
- **Per-job stdin secret delivery** (592). When sbx's proxy cannot feed
exec'd workers, credentials are no longer written at rest into the
sandbox as `~/.sbxloop/env.sh`: the host pipes each job's exports into
the worker launch's stdin, the login shell evals them *after* its
profile ran (so a stamped stale sentinel loses), and the value transits
worker process memory only — never the sandbox filesystem, never any
argv. Whether this sbx passes exec stdin through is a new field probe
(`exec-stdin-env`, cached per version, also a `doctor --deep` row); a
version that doesn't falls back to the 0600 env file exactly as before,
and `sandbox.secret_env_fallback` events now carry `delivery: "stdin" | "env-file"`. In App mode the per-job provider re-mints the
installation token inside its refresh margin by itself, so the hourly
in-VM env-file rewrite disappears on the stdin tier. The strategic fix
remains 46 (proxy-held secrets); this is the interim hardening under
today's `sbx exec` worker model.
- **A persistent Approve-merge button on Discord** for the merge gate. The
prompt carries a `discord.ui` button whose view is persistent
(`timeout=None` + a stable `custom_id` from the gate row) and re-armed
via `Client.add_view` on every (re)connect — a gate's button survives
restarts and never expires, unlike the concierge's clarifying-question
buttons (570). A click runs `approve_merge` off the gateway loop and
answers ephemerally (approval, lost CAS, or refusal); a failed landing
re-opens the gate and the same button works again; resolution clears the
view. Every failure mode — no component support, a rejected send, a dead
view — falls back to the typed `!sbx merge`, which stays in the prompt
body on every backend.
- **The opt-in merge gate — the one human touchpoint** (`[landing] merge_gate = "chat"`, default `"off"`). A run that clears every bar —
review, CI, reconciliation — parks `gated` instead of merging: sandboxes
freed, breaker reset, the daemon moves on, and an approval prompt lands
in the run's chat thread mentioning whoever asked for the work. One
approval — `!sbx merge <item>` in chat (any backend), `sbxloop daemon ctl merge <item>` on the host — completes the landing with gh ops
alone (update if behind, re-checked CI, the same reconciliation gate,
merge, then the ordinary merged settle); `!sbx abandon <item>` declines
and dismisses the gate. No deadline; the park survives restarts (a new
`daemon_merge_gates` table is the durable state, interrupted approvals
re-open at boot), a double-approve loses a CAS instead of double-merging,
and the issue carries `[daemon] gated_label` (`sbxloop:awaiting-merge`)
plus a how-to comment while parked. New `run.gated` chronology and
`gate.approved` / `gate.merge_failed` / `gate.dismissed` notices tell the
story in the thread.
- **GitHub App installation auth as an alternative to a PAT** (568). With
`GITHUB_APP_ID`, `GITHUB_APP_INSTALLATION_ID` and
`GITHUB_APP_PRIVATE_KEY[_PATH]` configured (env / `.env`, like the PATs),
the host signs an RS256 App JWT with its own `openssl` (no new
dependency), exchanges it for a short-lived installation token, and
delivers only that token to the github-ops sandbox via the in-VM env
file — the private key never leaves the host, the agent sandbox still
sees no GitHub credential, and every daemon/run operation is attributed
on GitHub to the app (`<app>[bot]`) rather than a personal account.
Tokens auto-refresh: `WorkerClient` invokes `Provisioner.gh_refresher`'s
hook before each github job, re-minting and rewriting the env file
inside a 10-minute expiry margin, so runs and the daemon's long-lived
polling sandbox outlive the ~1 hour token. PAT-only deployments are
untouched (`GH_TOKEN`/`GITHUB_TOKEN` and per-repo `token_env` behave
exactly as before); supplying both credential sets, or a partial App
set, is a named startup error before any microVM boots. `sbxloop doctor`
reports the selected mode (plus an openssl check in App mode), and the
README / `.env.example` / architecture docs describe both modes.
- **Slack as an alternative chat backend** (532). The daemon's human
channel — headline cards, a thread per run streaming its chronology,
`!sbx` operator commands in the control channel and in run threads,
mention steering of a live run, watch/outcome pings and the concierge
— now runs on Discord *or* Slack, chosen by `[chat] backend = "discord" | "slack"` in `sbxloop.toml` (inferred from whichever of `[discord]` /
`[slack]` carries a `channel_id`; both without a choice, or a named
backend without its section, fail at load with a clear error; neither
means headless as before). The Discord bridge is refactored behind
`sbxloop.daemon.chat.ChatBridge` — the service-agnostic pump, rendering,
steering, watches and commands — with `DiscordBridge` and the new
`SlackBridge` (Socket Mode via the `sbxloop[slack]` extra;
`SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` from the environment only, never
logged) as its two transports; Discord behaves exactly as before.
Threads persist in `daemon_chat_threads` (text ids — Slack's message
`ts` would not survive INTEGER affinity); an existing
`daemon_discord_threads` table is folded in on first open. `sbxloop daemon --slack-channel`, a `chat bridge (slack)` doctor row, the
`sbxloop.toml.example` / `.env.example` entries and the README's Slack
app setup (scopes, events, Socket Mode) document it.
Changed
- **Prompt examples no longer tell the loop's own story** (634). The
decomposer's and reviewer's config-override worked example is rendered
per run from the resolved toolchains — a Python repository reads the
`[tool.mypy] files` story, a TypeScript one reads `tsc` ignoring
`tsconfig.json` when handed input files, a Ruby one reads rubocop
inspecting an `Exclude`d file named on the command line, a Go one reads
a build tag pulling an integration suite into `go test` — so the anchor
the model pattern-matches against is correct for the repository in front
of it, and it costs the same tokens as the one story every run used to
read. The ecosystem-agnostic examples (the persisted-state enumeration,
the symptom-versus-mechanism review, the repro and follow-up JSON, the
concierge's fix-shaped-ask walk-through) now come from a generic web
service, and no prompt body names an issue or PR number, a path, state
name or product vocabulary from this repository; the fix brief's test-id
example is no longer pytest syntax. `test_prompt_bodies_stay_domain_neutral`
holds the line, and the prompt tests anchor on rule phrases so any
example can be swapped while the rule text stands.
- **Landing never waits on a human it never asked.** A human inline
thread outside a standing changes-requested review — an aside on a
COMMENT or approving review — used to block the merge forever: nothing
in the pipeline replied to it and the 520 gate refused to merge over
it. Landing now answers it itself with one marker-stamped "noted — does
not hold up the merge" reply (`land.human_ack` event; the thread is
never resolved, it stays the human's), a failed thread listing is
retried before "could not be read" blocks, and a review round whose
record never reached GitHub (503) reposts it as a marker-stamped PR
comment instead of stranding the run behind the review-record gate. A
standing changes-requested review still blocks after its objections are
answered — a human's voluntary override, not a gate the loop erected.
`sbxloop doctor --probe` now flags a delivery base that requires
approving reviews: the loop cannot approve its own PR, so every merge
there answers 405.
- **Intake asks, but never blocks.** The concierge still asks its one
clarifying question before filing a fix-shaped request with no symptom
(535) — but an unanswered question no longer parks the goal forever.
Every filing-blocking ask now mentions the requester and carries the
concierge's own best guess (a fenced `sbx-pending` block, persisted in a
new `daemon_pending_clarifications` table so a restart only delays the
fallback), and after `[concierge] clarify_ttl_s` (default 15 minutes,
now also the clickable-choice TTL) the bridge announces the assumption
and drives one nudge turn that files the issue with a *Symptom
(assumed)* section — loudly, in the channel, never in silence. Any reply
from the asker settles the wait; a `close_issue` confirmation never
proceeds on silence. The bridge also records the asker as the filed
issue's requester again (`submit_turn` was never handed the author id),
so finish pings reach whoever asked.
- **Provisioning skips the doomed proxy-secret dance on a known sbx
version** (568). The register→probe→auto-downgrade sequence (and its
per-run `sandbox.secret_env_fallback` *warning*) ran on every provision,
even though the probe's verdict — sbx proxy secrets never reach `sbx exec` workers — has been field-stable since sbx 0.35 and was already
recorded in the version-keyed conformance cache. Under the default
`proxy` strategy provisioning now consults that cache first: a cached
invisible/sentinel-under-exec verdict goes straight to the in-VM env
file (one calm `cached=true` event, info-level log), while an unknown or
new sbx version still registers + probes exactly as before, so the cache
re-learns per version and a future sbx that fixes exec injection is
picked up automatically. GitHub App installation tokens never use the
proxy path at all — they rotate ~hourly and every refresh rewrites the
env file, so registering each one with sbx would be pure ceremony.
Fixed
- **A click on a clarifying question that lands before the posted message
id is resolved is now answered instead of being told the question
expired** (573). `_post_choice_question` used to send the message with
its buttons attached and only afterwards resolve the message id and
register the question, so an interaction arriving in that window found
nothing outstanding and got the "expired — type your answer" ephemeral
note even though the question was brand new. The question is now
registered under a provisional key *before* the send, handed to the
view so a click can resolve through it, and rekeyed to the real message
id once the transport reports it (keeping the original deadline). A
transport that cannot report an id leaves the question answerable under
the provisional key rather than dropping it; a failed post drops it.
- **App-auth runs no longer block on their own review threads — the
REST/GraphQL identity split** (field runs r9t8hnv33, ry2t99za6,
ra2k5bv6z). REST attributes an App as `sbxloop[bot]`; GraphQL reports
the same actor as bare `sbxloop`. The resolved login carried the suffix
while `pr_review_threads` (GraphQL) did not, so every loop thread
classified as a human's: the loop ack-replied to its own findings and
fully reconciled PRs still ended blocked on "human review threads have
no reply". Identity comparisons now go through `logins_match` (strip a
trailing `[bot]`, casefold) at every thread/review/author site, so the
two spellings are one identity; an empty login still matches nobody.
- **App-auth runs no longer strand behind "N human review threads have
no reply"** (569 x 536). Under a GitHub App installation token
`GET /user` 403s, and the loop's login could degrade to `""` — which
made `unreconciled_threads` classify every loop-authored thread as a
human's and every reconciled PR end `blocked`. The loop's identity now
comes from the credential itself (`<app-slug>[bot]`, one cached
`GET /app` per process; App mode skips the doomed `GET /user`
entirely), with the delivered PR's author as the fallback, and landing
refuses to classify with an empty login — blocking with the real reason
in the vanishing case where every identity source is dead.
- **Runs no longer fail after delivering their PR when github-ops runs as
a GitHub App installation** (581; field runs `r5ctmq7e8`, `rb20denz3`).
The engine read the loop's own login with `gh api GET /user` — a
user-token endpoint an installation token cannot call (403 "Resource
not accessible by integration"), so runs that had already opened a
working PR died on the identity lookup before review/CI/merge. The
login now falls back to the delivered PR's author (the same token
opened it, so the author *is* the loop's identity under both credential
shapes), and when even that is unreadable it degrades to unknown with a
plain warning instead of raising. `ensure_repository`'s create path
survives the same 403 by taking the organization route.
- **`doctor` no longer fails every repository row under GitHub App auth**
(568 follow-up). `GET /repos/{repo}` reports user-centric permission
booleans that are all `false` for an installation token — including
`pull`, which the successful request itself disproves — while the real
capabilities live on the installation. The permission check now treats
a payload that denies even `pull` as not authoritative instead of
reporting `token missing issues:write, contents:write, pull_requests:write` for a credential that holds all three (doctor-only;
nothing gated dispatch on it).
- **Shape-mimicking sbx proxy placeholders are now recognized as
sentinels everywhere** (576 follow-up; field failure db 2026-08-31).
sbx's *service*-secret placeholders mimic real token shapes
(`gho_sbxproxymanaged…`, docker/sbx-releases 231) — and sbx 0.38's
shell-docker template attaches a `github` secret slot to every sandbox,
stamping that mimic into exec environments regardless of registrations.
The worker's sentinel detector only knew `sbx-cs-…`, so the mimic beat
the env file's real token (daemon github ops 401'd once the stale
registration behind the proxy was purged; before that they silently ran
as the wrong identity), and the secret-visibility probe classified the
mimic as a usable credential — caching a wrong `visible-under-exec`
verdict. `is_sbx_sentinel` now also matches the `sbxproxymanaged`
marker, `looks_like_github_token` excludes sentinels, and both
provisioning probes (secret visibility, the 576 shadow probe) test the
sentinel shapes first — so the env file wins in the worker, GitHub App
installation tokens work on template-stamped boxes, and the shadow
probe no longer refuses a box for a placeholder the worker overrides.
- **A stale sbx secret registration can no longer shadow env-file
credentials — GitHub App boxes were still acting as the retired PAT**
(576). The daemon/doctor github sandboxes have stable names, `sbx rm`
leaves sandbox-scoped registrations behind, sbx stamps registered
secrets into the VM at create, and sbx 0.38 stamps exec environments
too — so after the App cutover the leftover `github` service
registration's shape-mimicking `gho_…` sentinel outranked the
installation token in `~/.sbxloop/env.sh` (the worker keeps
credential-shaped values; the egress proxy rewrote them with the old
PAT). Every write kept succeeding, silently, as the wrong identity.
Env-file provisioning now **purges registrations parked at the sandbox
name before `create`** (`sandbox.stale_registration_purged`), and
github boxes get a post-write shadow probe that fails provisioning
loudly (`sandbox.credential_shadowed`) when a credential-shaped
GH_TOKEN/GITHUB_TOKEN is still stamped (e.g. a global-scope
registration the purge must not touch), instead of running as the
wrong identity. Proxy-mode PAT provisioning is unchanged
(`set_secret_replacing` already replaces in place).
- **Filing follow-up issues no longer errors when the follow-up label
already exists** (556). The run blind-POSTed `/repos/<repo>/labels`
before filing, so every repository that already carried
`[landing] followup_label` took a guaranteed 422 "already_exists" — an
error in the run's chronology for a routine condition. `_ensure_label`
now asks first through the new `label.get` worker op and its host-side
`GithubOps.label_lookup`, which — like `repo.get`/`ref.get` under
`allow_missing` (222, 518) — answers an absent label as
`{"missing": true}` on an ok result, so the repository that *lacks* the
label creates it with one clean call and no `worker.error` panel either.
Only a 404 is a miss: a 403 from a token without repo scope, or a 5xx,
is one warning and no doomed POST behind it. The 422 catch on the create
is kept for the race where the label appears between the two calls, and
a label the run cannot create still does not stop the filing.
- **A fix-round re-delivery no longer fails the branch create before
force-moving it** (518). The delivery branch is a pure function of the
run id, so on every round after the first `deliver` blind-POSTed
`/git/refs`, took the guaranteed 422 "Reference already exists", and
only then force-moved the branch — one doomed API call (~3 s through the
github sandbox), a `worker.error` panel in the run's Discord chronology
and a `worker.job_done error=` per *healthy* re-delivery (field run
`rfxja288b`, rounds 2 and 3), with a hint that misdescribed it as "a
prior attempt". `_point_branch` now asks first (`ref_lookup`, the miss
travels as data): a missing ref is created with one call as before, an
existing one goes straight to the force-move, and
`deliver.branch_force_moved` says what it superseded (`from=<old sha> to=<new sha> round=N`; the manual `sbxloop deliver <run>` path has no
round to report). The 422 catch is kept only for the race where the ref
appears between the lookup and the create.
- **A repository that keeps failing to poll is backed off and suspended on
its own, not warned about every tick forever** (516).
`MultiRepoIssueSource` swallowed a per-repository poll failure so the
healthy repositories still fed the queue — right for an outage, wrong
for a renamed, private or misspelled repository, which logged a warning
and wasted an API call every poll indefinitely while the daemon looked
healthy from Discord and `status`. Each repository now has its own
health: a failure backs it off (poll interval doubling per consecutive
failure, capped at an hour) while its neighbours poll on; after
`[daemon] repo_suspend_after` (default 10) consecutive failures — or at
once when GitHub says the repository is gone for this token (404/410, a
permission 403; rate limits and 5xx back off instead) — it is
**suspended**: excluded from polling, announced once on Discord
(`source.repo_suspended`), shown in `ctl status` (`repos:` line, only
when something is wrong), the concierge's `list_repos` and `sbxloop doctor` (from the health the daemon persists), and resumed with the new
`ctl resume-repo <owner/name>` / `!sbx resume-repo`, or by a daemon
restart. Recovery is one info line and one notice. The all-repositories-
failed re-raise that drives the loop-level source backoff is unchanged,
and a suspended repository no longer counts toward it.
- **`sbxloop doctor` boots nothing by default, and at most one github
sandbox per credential when it probes** (515). Multi-repo support (511)
wired a reachability probe that provisioned one github-only microVM per
configured repository on every `doctor` invocation, so the deploy health
step's wall clock scaled with the repository count. Probing is now behind
`--probe` (implied by `--deep`); the default rows say "reachability
unverified from the host … `sbxloop doctor --probe` boots one to ask".
When it probes, repositories are grouped by credential (`token_env`, or
the daemon-wide token) and share one sandbox per group; a credential
whose sandbox will not boot answers "unverified" for every repository on
it without re-provisioning.
- **Discord's automatic link previews are suppressed in bridge output**
(519). The grey unfurl cards Discord generates under any message
containing a bare URL were what made the control channel hard to read —
not the bridge's own embed cards, which stay. Every send now sets the
`SUPPRESS_EMBEDS` message flag unless the message carries one of our
embeds, in which case the body is angle-bracketed through the new
`discord_format.no_unfurl` (idempotent; leaves code spans, already-
bracketed URLs and markdown link targets alone). Edits go through a new
`DiscordBridge._edit` that re-asserts the flag, because discord.py
clears it otherwise and the first edit of a live status, tool digest or
concierge note would bring the preview back. `[discord] embeds = false`
still falls back to the plain-markdown twins of the cards; unfurl
suppression is unaffected by that toggle.
- **The concierge files symptom-first issues and asks before filing a fix
with no symptom** (535). 519 was filed as the mechanism the person
named ("remove the Discord embeds"); the loop implemented exactly that
(PR 525), and it was wrong — they were seeing link-preview unfurls — so
it was reverted: one run, two releases, two deploys. `create_issue` now
takes `symptom` (the person's own words), `requested_change` (a hint),
`goal` and `acceptance_criteria`, and composes the body in that order
with criteria written against the symptom; a call with a requested
change and no symptom is refused with the one question to ask ("What are
you seeing that you want gone or changed?"). The concierge prompt says a
fix-shaped ask with no observed symptom is genuinely ambiguous — one
question, then file — with the 519 conversation as the worked example;
the decomposer treats a Symptom section as the spec and the requested
change as a hint it may overrule; the reviewer judges the PR against the
symptom in round 1, and a PR that implements the mechanism without
removing the symptom is `request_changes` on the plan. Plain `body`
filing still works.
- **Follow-up issues from a landed run** (517). The reviewer's out-of-scope
notes used to be prose in a review body nobody reads after the merge
(run rfxja288b left two, both worth issues, both filed by hand).
`ReviewVerdict` gains `followups` (`title`, `body`, optional
`path`/`line`), the review prompt asks for them separately from
`findings` and forbids promoting one to a finding, and they render in
the review body under their own heading. After the pull request merges —
never on a failed or blocked run — the engine files them as issues on the
run's repository, along with the findings the fix rounds `deferred:`
(522), each cross-linked to the PR, originating issue, run and round.
Deduplicated by normalised title within the run and by a body marker
against the repository (a resume between filing and recording does not
double-file), capped by `[landing] max_followups_per_run` (5), labelled
`[landing] followup_label` (`sbxloop:follow-up`) and **never** the trigger
label — the loop still files no work of its own; a human promotes one. A
PR comment lists what was filed; `followups = "comment"` lists them on the
PR instead of filing, `"off"` drops them. Narrated as `run.followups`.
Fixed
- **The fixer can no longer drop a non-blocking finding on the floor**
(522). Only blocking findings reached the fix brief, so a `minor`
finding got neither an `addressed:` nor a `refuted:` line, was re-raised
once, and was then carried as prose until the run failed (PR 512's
unread `RepoConfig.labels`). Every finding of a `request_changes` round is
now in the brief — blocking ones to address or refute, the rest to
address, refute or **`deferred: <path:line> — why`** (parsed alongside
the other two; the thread is resolved and the finding is closed for this
PR as a follow-up). A finding with no line is *unanswered*: the engine
logs `fix.unanswered_findings` and narrates `fix.unanswered` in Discord,
the next fix brief lists those findings first marked as previously
unanswered, the review history marks them `UNANSWERED`, and the reviewer
is told silence is not closure — the finding stays at its original
severity and is carried as `still_open`. Refuted and deferred findings
are what the reviewer must not re-raise without a rebuttal. A late answer
— a finding round *k* left unanswered that round *k+n*'s report finally
addresses, refutes or defers — is replied onto round *k*'s own thread
under the later round's marker. Also fixed on the way: a finding the
reviewer *re-filed* on an earlier anchor (rather than confirming it) was
carried by `split_carried` but never reached the fix brief, because the
engine read `carried_forward` off the pre-split verdict.
- **A daemon killed mid-claim no longer orphans the issue** (530). A
restart between "claim comment posted" and "claim persisted" left the
new process losing the claim race to its own dead predecessor and
terminal-failing the row — permanently, since `failed` is what discovery
dedups against (527 was fixed by hand). Four changes, one per hole: the
claim comment carries `host=… pid=… started=…` and a claim from a dead
pid on this host, or older than `[daemon] claim_stale_after_s` (default
300 s) with no "Run … started" comment after it, is released and
reclaimed (`github.claim_reclaimed`); a claim that is not ours — lost
race, closed issue, trigger gone, GitHub down — leaves no row at all
(`DaemonStore.discard`), so the next poll re-creates it if the trigger
label is still there; the claim token is persisted
(`daemon_work_items.claim_token`) before the comment goes up, and
recovery settles a half-claim against the issue (`settle_claim`:
comment present → finish the label swap and dispatch; absent → claim
again), narrated as `recovery.claim_settled`; and SIGINT/SIGTERM are
held for the seconds a claim takes (`defer_signals`) and delivered after
it is persisted. The pre-530 store shape is in the legacy-db fixture.
- **Single-identity review posts PR comments, not a doomed review** (513).
One token opens the PR and reviews it, so every round POSTed
`REQUEST_CHANGES`/`APPROVE`, took a 422 ("can not request changes on your
own pull request") and a `gh.review_event_refused` warning, and re-posted
as a `COMMENT` review — two doomed calls per round on every run. When the
PR's author is the loop's login (decided once per drive from the PR), the
review is now posted as PR comments: each anchored finding as its own
review comment via `POST /pulls/{n}/comments` (a resolvable thread, which
later rounds reply in and resolve exactly as before), and the verdict —
in words, `**Review verdict: changes requested** (round 2)` — with the
summary and every finding that got no thread in one top-level comment. An
anchor GitHub refuses fails only its own comment and lands in the body
(per-finding degradation, the 514 shape), instead of 422ing the whole
review. A distinct reviewer identity still uses the review feature with
the `COMMENT` fallback; the review body now opens with the verdict line in
every mode.
- **A change to persisted state gets its own upgrade-path task** (524).
Issue 511's plan buried the store migration inside two tasks with no
acceptance criteria of its own, and all four review rounds on PR 512 —
and the run's failure — were about that migration. The decomposer prompt
now carries a risk pass: when the outcome alters a SQLite schema or row
meaning, an id or key format, a stored config key or a state-directory
layout, it must add a dedicated *upgrade path for existing state* task
whose acceptance criteria enumerate the row states and id forms a
deployed instance can hold and whose verify commands run tests that
start from a raw pre-change database. The reviewer asks the same
question of the plan in round 1 (a missing task is a blocking finding on
the plan), and the concierge adds a "Migration of existing state"
section to issues whose ask touches persisted state.
`tests/fakes/legacy_db.py` freezes every released schema shape (daemon:
pre-508, pre-511, pre-523; engine: pre-workspace through
pre-granted-rounds) with helpers that write raw rows, and
`tests/unit/test_legacy_db.py` sweeps one work item per state × id form
through each shape; the existing migration tests build on it.
- **Fix rounds no longer converge one adjacent case at a time** (521). Run
`rfxja288b` spent its whole review budget on one migration, one real
finding per round, each in the previous round's new lines, because the
reviewer's reproduction reached the fixer only as prose and the fixer
tested the shape the finding named. A `ReviewFinding` now carries the
reviewer's `repro` (required on blocking/major findings — `ReviewGuard`,
formerly `RefutedGuard`, sends back once a verdict missing one; the
prompt asks the reviewer to reproduce before filing and to name the
neighbours). The fix brief renders each repro as a regression test that
must fail on the current tree first — built the way the repro describes,
not through the code path under test — asks the fixer to list the other
inputs the same path sees, and shows the earlier rounds with each
finding's fate in the previous fixer's words (`render_fix_history`). The
fixer's `addressed:` line names the test it added (`; test: <id>`);
`reconcile()` records it (`Reconciliation.test`) and the thread reply and
the next fixer's history carry it. Repros also appear in the posted
inline comments and review body.
- **A run that exhausts its fix-round budget resumes its own PR instead of
starting over** (523). Exhausting `max_review_rounds` / `max_ci_rounds`
used to be an ordinary failed attempt: the item's retry was a fresh
decompose/build on a new branch with a second PR, while the failed run's
branch sat green one round from mergeable. The engine now records which
budget ran out (`runs.exhausted`); under the daemon the first exhaustion
grants `[landing] retry_rounds` (default 2) more rounds and schedules a
resume of the same run after the retry backoff — no attempt spent, no
breaker count, no resume-budget slot — and a second exhaustion hands the
item over with the run still pinned. `sbxloop daemon ctl grant-rounds <run> <n>` (also `!sbx grant-rounds`, and the concierge understands "give
rXXXX two more rounds") grants more and resumes at once, skipping the
backoff; `sbxloop resume --grant-rounds N` is the CLI equivalent, and a
bare resume of an exhausted run is refused with that hint rather than
re-exhausting after one wasted review. The `run.exhausted` notice says
which budget ran out and what happens next. State: `runs` gains
`exhausted` and `granted_rounds`, `daemon_work_items` gains `not_before`
(a scheduled retry's earliest dispatch); both migrate in place and are
tested from raw pre-upgrade databases.
- **A deploy never restarts the daemon under a live run** (534). The deploy
pipeline's drain was capped at 20 minutes and then restarted anyway; with
the loop merging its own PRs every merge deploys, and the next queued item
is usually already running when the deploy lands, so runs were being
interrupted mid-task and charged a resume-budget slot for it. The drain now
waits for `current: idle` without a cap (the job's 8 h `timeout-minutes` is
the only bound, and a timeout installs nothing), and a claim in progress
counts as busy — `ctl status` reports `current: claiming <item>` — so a
restart is never timed into the window that orphaned 527 (530).
- **Pause is a set of named holds.** `ctl pause --hold NAME` / `ctl resume --hold NAME` (and `!sbx` likewise) take and release a named hold; a bare
`pause`/`resume` acts on the operator's hold; `resume --all` clears every
hold. The daemon idles while any hold stands, `status` lists them, and the
transitions are narrated in Discord (`daemon.paused` / `daemon.resumed`,
naming the hold and who took it). The deploy holds `deploy-<run id>`,
snapshots the *other* holds immediately before the restart and re-takes
them afterwards, and releases its own on `always()` — so an operator pause
survives a deploy, including one issued while the deploy was already
waiting (the two pause/restore races seen on 2026-08-29). Rollback now runs
only once the upgrade step has, and the Discord deploy notices say whether
the restart was deferred behind a run and how long it waited.
- **Review findings are reconciled on the pull request** (520): between a
fix round's re-delivery and the next review, the engine now speaks the
fixer's per-finding answer back onto the review's own threads. Each
prior-round finding with an inline thread gets exactly one reply —
`addressed in <sha>: <what changed>` (and the thread resolved), `refuted: <why>`, or a note that the round did not answer it (both left open) —
while findings posted body-only are gathered into a single
`Reconciliation — round n` pull request comment. Every reply carries a
machine-readable `run`/`round` marker, and the reply/resolve is recorded
in the state database as it happens, so a resume between posting and
recording does not double-reply. A new `review.reconciled` event carries
the addressed/refuted/unanswered counts into the log sink and the Discord
chronology.
- **The next review round confirms carried-over findings in their own
threads** (520): a round-*n+1* reviewer now returns an anchor-keyed
`confirmations` list — `confirmed_fixed` or `still_open` per finding an
earlier round raised — and the engine posts each verdict as a reply in
that finding's existing thread, resolving the ones confirmed fixed. The
new review body carries only the overall summary and genuinely new
findings; a carried finding is never restated there. A `still_open`
verdict leaves the thread unresolved and carries the original finding
(its severity and words, plus the reviewer's note) into the next fix
round. First-round reviews are unchanged, and the confirmation replies
are marker-stamped and store-recorded so a resume does not double-post.
- **A human's changes-requested review is answered on its own threads** (520): a `NeedsFix("human")` round now carries the objections it was
seeded with — the reviewer's review body and each of their inline
comments — and after the fix re-delivers, each inline objection receives
one reply stating the change (`addressed in <sha>: …`), the fixer's
reasoned explanation (`not changed: …`), or, when the round said nothing
about it, that it is being left open. A human's thread is **never**
resolved by the loop; objections raised in the review body are answered in
a single pull request comment instead. Each answered objection is recorded
in the state database, which fixes a repeat-work bug: only its author can
dismiss a `CHANGES_REQUESTED`, so the same review still stands on the next
landing pass — that pass now hands over as `Blocked` naming the replied
objections rather than spending another full `max_ci_rounds` fix pass on
words already answered.
- **A pull request does not merge until its review record is complete**
(520): `land()` gained two preconditions immediately before the merge
call. The approving round's review must actually have posted — a run whose
review post failed used to merge with no review on the pull request at all
— and every inline review thread must be reconciled: a loop thread counts
when it is resolved or carries a later loop reply (the refuted case), a
human thread when the loop replied in it at all. Anything left over ends
the run as `Blocked`, naming the offending anchors
(`N review threads unreconciled: …`), and a thread read that *fails*
blocks too, since "we could not tell" is not "there is nothing to answer".
`docs/architecture.md` gained a *Reconciling review findings on the pull
request* section covering the contract, the fixer's per-finding report
format and this gate.
- **`sbxloop.toml.example` at the repository root** (527), covering every
section and key the config model knows — including both `[github]` forms
(the legacy single `repo` and the `[[github.repos]]` array with
`workspace`/`deliver_base`/`enabled`/`token_env`/`trigger_label`/`labels`)
— with the default and a one-line comment per key. The top-level keys are
live and every section ships commented out, so a fresh copy is exactly the
built-in defaults. It is now the single
source `sbxloop init` writes from (shipped as package data), and the new
`sbxloop init --stdout` prints it. `.env.example` was refreshed: the
`DISCORD_BOT_TOKEN` and `GITHUB_TOKEN` alias credentials, the per-repo
`token_env` pattern, the daemon-host `~/.config/sbxloop/secrets.env`
layout, and the single-repo `SBXLOOP_GITHUB__REPO` override marked legacy.
Tests pin the example against `sbxloop init` and the config model and
reject anything that looks like a real token, snowflake, host path or
non-placeholder repository.
Changed
- **Reverted 525** ("Remove Discord embeds from daemon bridge output in favour of plain markdown", 519): the plain-markdown bridge output read worse in the field than the embeds it replaced. Embeds, the `[discord]` keys 525 removed, and the previous rendering tests are back exactly as they were.
- **One daemon can tend several GitHub repositories** (511). `sbxloop.toml`
accepts an array of `[[github.repos]]` entries, each carrying its own
`deliver_base`, `create_repo`/`create_public`, `trigger_label`, extra
`labels`, an `enabled` switch and an optional `token_env`. The daemon polls
every enabled repository for the trigger label, work items carry the
`owner/name` they came from (ids are repo-qualified — `gh:o/r:issue:12` —
with the legacy `gh:12` form still resolving), and a run's clone, branch,
draft PR, review, CI polling, merge and issue comments/labels all target
that repository. Its github-ops sandbox is provisioned scoped to that repo
and given that repo's credential; the agent/github credential split is
unchanged. The single `[github] repo = "owner/name"` form still loads and
behaves exactly as before, normalised internally into a one-entry list;
the two forms are mutually exclusive and duplicate or malformed entries
fail config loading with an explicit error. `sbxloop doctor` checks each
configured repository on its own line, `sbxloop status` and
`sbxloop daemon items` carry a `repo` column, `sbxloop config repos` lists
the registrations, and the concierge gained a `list_repos` tool (plus an
optional `repo` selector on its GitHub-reading tools) so "what projects are
you configured to work on?" is answerable from chat. The `[daemon]`
guardrails — daily run cap, per-item attempt and resume caps,
consecutive-failure circuit breaker, one run at a time — remain
**daemon-wide** and are shared across every repository; README, the
architecture doc, the deploy doc and the `sbxloop init` template say so
explicitly, and tests assert the cap and the breaker apply across items
from different repositories. The daemon's work-item store keys an item by
`(issue number, repository)` rather than the issue number alone, so issue
4 in two repositories is two items; a store written before multi-repo
support is migrated in place on open and its rows keep working.
Fixed
- **A run for repository B is no longer built from repository A's checkout**
(526). Multi-repo support resolved a run's *workspace* — the host git
checkout every run clones its tree from — from the single daemon-wide
`[sandbox] workspace`, so a daemon upgraded from a working single-repo
deployment routed everything else per repository (claim, labels, PR) while
building each run out of whichever repository that one checkout happened
to be. `[[github.repos]]` entries now take their own `workspace`, and both
the pre-run fast-forward and the provisioner clone resolve it per
repository. A checkout whose `origin` names a different repository is a
hard failure at three points — `sbxloop doctor` fails a check per
offending repo, `sbxloop daemon` refuses to start, and the provisioner
refuses the clone — each naming both repositories and the fix. A repository
with no workspace clones from its own remote (public repositories only:
the host holds no git credential, see 46) or fails the run with that
reason; there is no fallback to another repository's tree anywhere.
`[sandbox] workspace` still works unchanged for a single repository; with
several, migrate by moving it into the matching `[[github.repos]]` entry.
Changed
- **GitHub work-item ids are typed: `gh:issue:1234`, `gh:pr:1234`** (508).
The old `gh:1234` said nothing about what it pointed at, and a run carries
an issue number *and* a PR number side by side in chat, issue comments,
Discord threads and logs — readers had to guess. A new `sbxloop.ghids`
module owns the whole grammar (`format_gh_id`/`issue_item_id`/`pr_item_id`
to render, `parse_gh_id`/`try_parse_gh_id`/`normalize_item_id` to read) and
nothing else slices `gh:` strings by hand. Rendering is strict — every id
produced now carries its kind — and parsing is lenient: a bare `gh:<n>`
read from an old checkpoint, an old watch or typed by an operator is
accepted as the issue it always meant and normalised on the way in.
Adopted at every construction site (GitHub source discovery, the work item
model, the concierge's issue lookups), every lookup in the daemon store
(which also resolves legacy rows under either spelling, so no state
migration is required), the operator verbs (`items`, `queue`, `abandon`,
`retry`, `requeue` — both spellings in, typed out), the concierge tools
and their descriptions, Discord headline cards, thread names and control
replies, the GitHub comments and PR text a run writes back, and the
daemon's log/event fields. README, the architecture doc and the concierge
prompt lost their `gh:12`-style examples.
Fixed
- **The verify-suspect signal no longer collides distinct failures, over-reaches
on narrowing commands, or orders an impossible re-author** (387 review of
PR 509). Three corrections: the duration normaliser no longer treats
`line:column` coordinates as a timestamp (clock-style durations are matched
only after `in`/`took`/`elapsed`/`time`), so the same error at two different
positions is two fingerprints rather than one false "suspect"; the
config-override lint now fires only when the given path lies *outside* the
configured file set, keeping `uv run mypy packages` flagged while allowing
`uv run pytest -q tests/unit` and `uv run mypy packages/sbxloop/src/…`; and
the suspect feedback is written for the builder — the only agent it reaches,
and one that cannot edit the decomposer-authored commands — asking it to
satisfy the command as written or report it unpassable, with the suspect
state now surfaced in the run's failure reason.
- **A fix round re-delivers onto the pull request it knows, never a blind
create.** Field run `r8tzse1qa` (387 → PR 505): round two force-moved
the branch, POSTed a new PR, got the `gh` transport's bare "Validation
Failed (HTTP 422)" — the transport dropped the API's error body, so the
"pull request already exists" match never fired — and the run failed at
`delivering` with its PR number in hand. Three layers: delivery takes the
run's recorded `pr_number` and skips the create entirely; a 422 on a
create is confirmed by looking up the branch's open PR rather than by
matching prose; and the `gh` transport keeps the API error body in the
message. (The old loop had filed exactly this as 488/490/495/497
before its findings were closed at the cutover — a reminder that the
lane, not the findings, was the problem.)
- **Every fix round starts from the current base**, not only a `conflict`
one. CI judges GitHub's test merge of the branch with its base, so a red
check can exist only in that merge — PR 505 failed a test that landed
on `main` after its run branched — and a fixer on a stale clone cannot
even reproduce it. `hostgit.merge_from_base` now runs before any fix
round; a base that no longer merges cleanly leaves conflict markers the
brief lists.
Fixed
- **A review refused for its anchors is re-posted with its findings in the
body.** Field run `rx8amxxvm` (130 → PR 503) approved with two nits
anchored to lines outside the diff; GitHub 422'd the APPROVE *and* the
COMMENT fallback, and nothing reached the PR (the verdict, which is the
run's own, still decided correctly). The engine now retries once
without inline comments, listing every finding in the review body. The
stale `last_event_ts` docstring that review caught is fixed here too.
Changed
- **Streaming deltas are no longer persisted.** `agent.message_delta` is
per-chunk UI telemetry: live surfaces (TUI, Discord) still receive every
delta over the event bus, but `StateStore.append_event` drops them
instead of writing one committed row per chunk. The full `agent.message`
is persisted as before, so `sbxloop logs <run>` and the daemon log sink
are unchanged apart from the absent delta lines, and resume — which
never read deltas — behaves identically. The state DB also runs
`PRAGMA synchronous=NORMAL`, the safe setting under WAL, so a commit no
longer fsyncs per event.
- **Retired config keys are errors.** The `[daemon]`/`[github]` keys the 1.0
pipeline retired (see 0.7.55 below) were loaded with a warning for two
releases so the unattended daemon deploy could not roll back on them;
that tolerance — `Config.retired_keys`, the `config.retired_keys`
warning, the `retired config keys` doctor row — is gone, and an unknown
key fails config loading like any other. Edit `sbxloop.toml` before
upgrading a 0.7.x host straight to 1.0 ("1.0 cutover", below).
- **The run thread follows the pipeline.** The per-run status line now says
which stage the run is in once its tasks are built — `🚦 gate`,
`🔀 delivering`, `🔍 review round 2`, `🛠 fix round 1 (review, budget 1/3) · build`, `⏳ CI · 2 pending` / `❌ CI red` / `✅ CI green`,
`🚀 landing · out of draft` — instead of "1 task(s) planned" for the
whole second half of the run, and ends `🎉` / `🚧` for `merged` /
`blocked`. The note under a queued steer says the same ("the run is
waiting on CI; answered now" — a message wakes a GitHub wait at once).
- **A conflict fix round starts from the merged base.** Before a
`conflict` round the engine merges `origin/<base>` into the run's clone
(`hostgit.merge_from_base`): uncommitted work is checkpointed, a clean
merge just lands, and a conflicting one is left in progress with the
conflicted paths quoted in the fix brief for the fixer to resolve and
commit. Before this, delivery overlaid the run's files onto the current
base tree, so the conflicting hunks were silently overwritten with the
run's version. The review diff is likewise taken against the *current*
base commit, so a round after such a merge reviews the run's changes
and not the base branch's movement.
- **One run, from issue to merged pull request.** The engine now carries a
run past its task graph: GATE (the project's own gate over the whole
tree) → DELIVER (a draft PR) → REVIEW (the run's own adversarial pass
over the PR's diff, a fresh read-only session; its verdict is
authoritative and is also posted to the PR) → FIX rounds (one seeded
`fix-N` task each, built and verified like any other, re-delivered onto
the same branch, back through the gate) → CI (red fetches the failing
jobs' logs into the next fix brief) → LAND (un-draft, update-branch,
merge with the judged head). Run states grew `gating`, `delivering`,
`reviewing`, `fixing`, `awaiting_ci`, `landing`, `merged` and `blocked`
(`running` is now `building`; `finalizing` is gone); `runs.stage` keeps
the last stage entered so `resume` re-enters there — a crash during a CI
wait costs a re-poll, not a rebuild. The knobs live in a new `[landing]`
section (`max_review_rounds`, `max_ci_rounds`, `ci_poll_interval_s`,
`ci_settle_s`, `ci_timeout_s`, `merge_method`, `delete_branch_on_merge`,
`merge_update_attempts`, `deliver_draft`). Merging is not optional any
more: a run that cannot land its PR ends `blocked` with the PR open for a
human; one that runs out of rounds ends `failed` with the PR still a
draft. Waiting on GitHub is not charged to `max_wall_clock_s`, and a
Discord message or a cancel wakes a wait at once.
- **Findings carry forward inside the run.** Every review round sees the
earlier rounds' findings and the fixer's per-finding `addressed` /
`refuted: <why>` list; a verdict that only re-raises refuted findings is
sent back once with the history quoted. This, plus the round budgets, is
what stops a run arguing with itself.
- **The daemon files nothing.** Gone: the agent backlog lane
(`.sbxloop/backlog/*.md` → `sbxloop:backlog` issues), post-mortem
issues, scheduled audit charters (`.github/sbxloop/audits/`), tool
findings routed to `tool_repo`, the per-run tracking issue, and the
review lane's charter issues that re-entered the queue as `audit` work
items. In the field the loop had filed the same finding on consecutive
days under different issue numbers while only 17 of 225 issues ever
reached `sbxloop:completed`. A work item is now exactly one labeled
GitHub issue → one run; the inbox source and its `enqueue_work`
concierge tool are gone with it. The daemon settles each run's outcome
on the issue: `merged` → closed with `sbxloop:completed`, `failed` →
`sbxloop:failed`, `blocked` → the new `sbxloop:blocked`.
- **Config cutover, tolerated.** `[daemon]` lost `inbox_dir`, `backlog`,
`backlog_max_per_run`, `backlog_auto_trigger`, `backlog_label`,
`audits`, `audit_dir`, `audit_label`, `delivered_label`, `postmortems`,
`postmortems_per_day`, `review_deliveries`, `await_review`,
`review_rounds`, `tool_repo`, `tracking_issue`, `close_on_success`,
`auto_merge` (landing is always on) and gained `blocked_label`;
`[github]` lost `report` and `deliver` (a repository means deliver). The
moved knobs (`deliver_draft`, `merge_method`, `delete_branch_on_merge`,
`merge_update_attempts`) are carried into `[landing]` when found in
their old place. A config still carrying any of these loads with a
`config.retired_keys` warning and a `sbxloop doctor` row rather than
failing — the daemon host deploys unattended and a hard failure there
would roll the release back before anyone could edit the file. They
become errors in 1.0.0.
- **State cutover.** The daemon's tables changed shape (no PR-state,
review, audit, post-mortem or backlog tables; one item kind). A pre-1.0
`state.db` is moved aside to `state.db.pre-1.0` on first start rather
than migrated ("1.0 cutover", below).
- **Removed with the above:** `sbxloop deliver` (resume at `delivering` is
the retry path), `sbxloop run --report/--deliver/--deliver-draft`, the
`GithubReporterHook` tracking issue, `sbxloop daemon --inbox/--backlog`,
the `run.report` event, `LoopEngine.deliver()`. New worker op
`checks.failed_logs` (Actions job logs for failed check runs; the REST
transport does not forward the bearer token on the redirect to blob
storage) and `GithubOps.checks_failed_logs` / `pr_review_feedback`.