Conversation
`git_dir_bytes` was declared INTEGER for the PostgreSQL backend (tables.py Integer + Alembic 0019 `INTEGER`), i.e. int4 with a 2 GiB ceiling. The column exists to observe workspace-repo bloat (#1596), so the values it is there to record are exactly the ones that overflowed: any agent whose `.git` passed 2,147,483,647 bytes made every SyncHealthService upsert raise `psycopg2.errors.NumericValueOutOfRange: integer out of range`, and that agent's sync health went dark at the moment it mattered. SQLite never showed it (its INTEGER is 64-bit), which is how it shipped on 2026-07-14. Dual-track (Invariant #9): - schema.py: `git_dir_bytes BIGINT` — single source of truth for both backends (init_schema_postgres translates the same string), so fresh PG builds get int8 via 0001_baseline. - tables.py: `BigInteger`. - Alembic 0062: `ALTER COLUMN git_dir_bytes TYPE BIGINT` (proven on a real postgres:16 upgraded from 0061 with the column forced back to int4: information_schema reports `bigint` afterwards and a 44 GiB insert lands). - SQLite `agent_sync_state_git_dir_bytes_bigint`: a declared-type rebuild via the #1160 rename-swap, NOT a bare no-op. schema-parity compares a fresh init_schema DB against an upgraded one by declared column type, so a no-op would leave upgraded files reading INTEGER against a fresh BIGINT and turn that guard red forever. One row per agent, all columns copied verbatim, the one index re-created, idempotent. CI regression seam: `TestGitDirBytesRoundTrip` in test_1596_git_sync_observability.py is now `requires_postgres`, so the schema-parity PostgreSQL tier (#2434) runs its [postgres] leg — the leg that had been red for two months while the tier selected only marked tests. A new information_schema assertion names the column type rather than a stack; two SQLite tests pin the rebuild (rows preserved, index back, no-op pre-#1596). Audit of sibling byte-count columns: `agent_shared_files.size_bytes` stays Integer — bounded by MAX_FILE_SIZE_BYTES (50 MB) at the only writer, so it cannot reach int4's ceiling by construction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rename-swap copies exactly the columns its INSERT...SELECT lists and DROPs the old table. Compare the live agent_sync_state column set against that list first and raise — before touching anything — on an unknown column, so a future/unforeseen column is surfaced as a boot failure (`first_pending`, #1160) rather than silently destroyed. No known path produces one today; this is a belt on a migration whose failure mode is data loss. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s are BigInteger, PG tests need the marker Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng budget, not the reader-race 300s ceiling (#2789) A 3600s agent that took a 429 thirty seconds into a Workspace turn was switched to a healthy subscription and re-run with `timeout_seconds=300`: the agent server killed its own process group at exactly 300s with `stop_reason=tool_use` — actively working — and the turn was discarded after being billed. Re-sending the same message succeeded. Both inline retries read `_AUTO_RETRY_MAX_TIMEOUT_S`. That constant was written for #678's reader-race retry — a re-dispatch of a turn that never started, where "reader races fire fast; 5 min is plenty" is right. The SUB-003 retry (#792/#2638) is a full re-run of the user's turn and earns the budget the turn was given. `remaining_s` already bounds first attempt + retry at the operator's `execution_timeout_seconds`, so the second ceiling constrained nothing that was not already constrained and cost every turn honestly longer than five minutes. - SUB-003 retry: `min(remaining_s, 300)` -> `remaining_s`. The #678 path keeps its ceiling; the constant's docstring now names which path owns it and why the portal marker derivation depends on that. - `_AGENT_HTTP_SLACK_S` (10): the first dispatch gives the backend's HTTP read 10s more than the agent's own budget so the agent's structured 504 beats a bare ReadTimeout. The retry collapsed both onto one instant; it now keeps the same slack. - `_AttemptState.applied_timeout_seconds`: `state.start_time` is reset before an inline retry, so `_handle_timeout` measures the retry — it now judges that against the retry's own budget instead of the untouched configured one. A retry that ran its full allowance was being labelled #2106 NETWORK ("aborted after 300s of 3600 seconds allowed"), sending operators to raise a limit that was never reached. None on the first attempt, so #2106's own tests are unaffected. Set inside the #678 CB gate's `else`, not above it: a retry the breaker refuses never runs. - `_warn_if_retry_budget_clamped`: the applied budget appeared in no log line; a clamp is now stated at WARNING where it is decided, silent when nothing was taken. `test_792_subscription_retry.py::test_retry_timeout_bounded` asserted `<= _AUTO_RETRY_MAX_TIMEOUT_S` while running at `timeout_seconds=300`, where the ceiling and the remaining budget are the same number — it passed for every value of this bug. Re-anchored to the bound that is load-bearing. New tests in `test_2789_subswitch_retry_budget.py` are mutation-checked: reverting the budget fails 2, mutating the attribution wiring fails the end-to-end one. Fixes #2789 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…nd is not a clamp (#2789) `_warn_if_retry_budget_clamped` warned on every seat switch: the re-run is always shorter than the turn by what the first attempt spent, so a 429 at 30s logged "clamped to 3570s of 3600s — a timeout at that point is this ceiling". There is no ceiling on that path any more; the 30s is arithmetic. The helper is now `_log_retry_budget` and takes the elapsed time: the #678 reader-race ceiling stays a WARNING ("clamped"), the SUB-003 re-run logs the breakdown at INFO, and escalates to WARNING only when less than the reader-race ceiling is left — a re-run that short is likely hopeless and is still billed, which is the one an operator wants to see. /review I1 on #2817. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…-bigint # Conflicts: # docs/memory/learnings.md
… nothing — the rebuild's justification was false Two merge-train findings on #2805: 1. dev's head moved to `0062_execution_fan_out_task_id` (#2532) after this branched, and this revision declared the same parent — a live #2068 fork, two heads, `upgrade head` applying zero revisions. Re-chained as `0063_agent_sync_state_git_dir_bytes_bigint` off `0062_execution_fan_out_task_id`; `check_alembic_heads.py` on the merged tree: 64 revisions, 1 head. 2. The SQLite rename-swap rebuild was justified by "the schema-parity suite would go red forever" — disproved by a one-line negative control (registration removed, suite still green): both parity fixtures build from empty, so `init_schema` creates the table in both snapshots and the guard cannot see this column's declared type. A boot-time DROP TABLE of a live table for a CI benefit that does not exist is the wrong trade. The rebuild, its column list and its three tests are gone; a note beside `_migrate_agent_sync_state_git_dir_bytes` says why the SQLite track deliberately carries nothing, a test pins that it stays that way for a reason, and the learnings entry now states the honest lesson: run the negative control before writing a guard's behaviour into a durable file. The seven sibling columns with the same int4-vs-`< 2**63` mismatch are filed as #2827. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…om `main` (#2822) `issue-status-on-merge.yml` has produced zero runs since #2769 moved it to `pull_request_target` on 2026-09-14. Seventeen PRs have merged into `dev` since, ten of them carrying a closing keyword for eleven issues, and every one of those issues was relabelled by hand. Neither trigger could start a run, for opposite reasons. GitHub reads a `pull_request` workflow from the PR's merge ref, which resolves against `dev` -- and `dev`'s copy no longer declared it. It reads a `pull_request_target` workflow from the repository's DEFAULT branch (`GITHUB_REF` is the default branch, `GITHUB_SHA` its last commit) -- and `main` receives release cuts only, so `main`'s copy still says `pull_request`. The trigger that decides whether a run starts was being read from a branch the PR never touches, which is why the failure was absent rather than red. `push` is read from the ref being pushed, explicitly including workflows that are not merged into the default branch, so a trigger change to this file takes effect on the merge that lands it and cannot go dark until a release cut again. That is #2822's fourth acceptance criterion, not merely a way to restore today's promotion. It keeps #2767's fix by a safer route: a push to `dev` is a base-repo event whatever the merged PR's origin, so the token carries the declared `permissions:` and a fork author's issue is promoted like anyone else's -- without handing a write token to an event a stranger can start. A push payload carries no pull request, so the merged PR is resolved from the pushed commits with `listPullRequestsAssociatedWithCommit`, which needs `pull-requests: read` and nothing more. Only PRs that merged INTO the pushed branch are read: a commit on `dev` is also in the head of the open `dev` -> `main` release PR, whose body closes every issue in the release. Still no `actions/checkout` and no `run:` step. Closes #2822
`listPullRequestsAssociatedWithCommit` documents that for a commit "not present in the default branch" -- which every `dev` commit is until a release cut -- it returns "merged AND open pull requests associated with the commit". Measured on `b8a790b2`: six PRs come back, one merged and five OPEN, and every one of them is `base: dev`, because any branch cut from `dev` after that commit contains it. So the release PR was the smaller half of the story: `merged_at` is what stops one merge promoting the close list of every open PR in flight, and `base.ref` is what excludes the `dev` -> `main` release PR. The comment said only the second; both are now stated with the measurement behind them, and each has its own test.
…s stale test_2814_workflow_trigger_parity::test_every_accepted_entry_names_a_real_divergence fails once this branch stops declaring pull_request_target on issue-status-on-merge.yml: the ACCEPTED_UNTIL_RELEASE entry recording that divergence no longer matches anything, which is exactly what that guard is for. The entry existed because the promotion was accepted as broken until the next release cut. Moving the trigger to push: branches: [dev] removes the need to wait for a release at all, so the record goes with it.
…ry-budget # Conflicts: # docs/memory/learnings.md
… not the last retry's (#2789) Merge-train review C1 on #2817: `state.start_time` is re-stamped before each inline retry so `_handle_timeout` classifies the attempt it measures — which made it the wrong clock for a BUDGET. On the #678→#792 interplay (502 → reader-race retry → 429 → switch) `elapsed_s` counted only the reader-race retry, and the SUB-003 retry re-granted nearly the whole turn a third time: 6610s of slot time on a 3600s cap, past the slot lease (timeout+300), the watchdog (age > timeout) and the portal marker. - `_AttemptState.turn_started_at`: the turn's clock, never reset; `_turn_elapsed_seconds` reads it. The SUB-003 budget is derived from it, and AFTER the 3s settle delay (W3), so every second the turn has spent counts against it. - `_log_retry_budget` keys "clamped" on a `ceiling=` the caller names, never on `elapsed_s <= 0` (W4). - `client_portal.portal_attempt_ceiling_seconds` imports `_AGENT_HTTP_SLACK_S` instead of a magic 10 (W2). - Tests: the tautological portal test is replaced by two that EXECUTE the interplay on a controlled clock — the SUB-003 grant at t=3005 on a 3600s turn is ≤605s (was 3605s), and the executed worst case (attempt 1 to cap, reader-race to ceiling, SUB-003 the remainder) fits the marker. The literal classifier test is replaced by one that drives `_handle_timeout` with a state carrying an applied budget (W1). Mutations: retry-clock budget → 2 red; classifier ignoring the applied budget → 2 red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
… not just the first (#2789) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
… a silent fallback (#2789) /review I1 on #2817: `turn_started_at` defaulted to None with an `or state.start_time` fallback in the reader — a future construction that names only `start_time` would have anchored the turn budget on the reset clock without anyone noticing. `__post_init__` derives it from `start_time` (the same instant at construction) and the reader no longer falls back to anything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…ue 422 (#2696) `transcribe_portal_audio` mapped every non-200 from ElevenLabs onto `422 "Could not transcribe the audio"`. A missing endpoint permission, a rejected key, exhausted credits, a provider rate limit and a rejected audio container all read identically, while the actionable status word sat in a backend WARNING one line above — a live instance cost an operator with container access a full round-trip to answer a question the system already knew. `stt_capability_service.classify_stt_failure()` (pure) maps a provider answer onto a named category with its own client status and sentence: permission / auth / quota 401·402·403 by status word → 503, operator-actionable rate_limit 429 → 429, the existing retry wording audio 400·413·415·422 → 422, "the recording could not be read" provider 5xx → 502, the existing transport wording unknown anything else → 502, still says who failed No arm returns the old string; a test sweeps every status 300-599 to pin that, so an unrecognised provider answer cannot regress to it. The client sentence never carries the provider body. `record_live_failure` keeps the status word + category for THIS key (`stt:last_failure:<digest>`, 24h; per-process fallback) and still feeds #2695's capability cache on a 401/403, so the mic hides on the next load. `GET /api/settings/elevenlabs` (admin-only) carries it as `stt_last_failure`, and Settings -> Voice renders "Last voice-input failure: <why> (HTTP <status> <word>) — <time>" under the capability badge. Fail-soft is unchanged: every branch raises ClientPortalError, never a 500, and the client can always type instead. Fixes #2696 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
…#2696) Review finding, reproduced by driving the classifier: a 401 whose body carries no status token was classified `quota` whenever its SENTENCE happened to contain one of `_QUOTA_WORDS`. 401 {"detail":"Invalid API key for your plan"} -> quota 401 {"detail":{"message":"…not valid for this subscription"}} -> quota Both are ordinary auth failures. The client was told "the account is out of credits or not on a plan that allows it" and the admin panel said the same, so an operator whose key simply needed replacing was sent to a billing page. That is worse than the opaque 422 this issue replaces — it is confidently wrong in a direction someone acts on. The cause is that `provider_status_word` collapsed two different things: `det["status"]`/`det["code"]`, which are machine tokens the provider documents, and `det["message"]`/a bare string detail, which is prose written for a human. The comment above `_QUOTA_WORDS` already claimed matching happened on "the status token"; the code matched whatever came back. `provider_status_parts(body) -> (token, prose)` keeps them apart, and `classify_stt_failure` matches ONLY the token. A 401/403 carrying prose alone is now `auth` — the honest reading of "the key was rejected and the provider did not say why". `provider_status_word` stays as the operator-facing display value (token else prose), so nothing is lost from the panel: the prose still shows, it just no longer votes. Verified in the backend image: 38 passing in this suite (5 new table rows + 3 new tests), 66 with #2695's alongside it, and the portal/settings sweep has the identical non-passing set on this branch and on its base — no new failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
… provider-error branch Re-applied after rebasing onto dev: #2699 squash-merged with its own final wording of this bullet, so the branch's copy of the paragraph conflicted and dev's was taken; this is #2696's one addition to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
… and invalidate forgets it (#2696) The /review on #2702 (I1): `read_last_failure` fell through to this worker's local copy on a Redis MISS — the pre-#2695-fix shape of `read_cached`, which that PR was held for. After the rebase the two readers in one module disagreed about what a miss means; now they share the rule (Redis answers ⇒ authoritative, miss evicts the local copy; local only when Redis cannot be asked). `invalidate` also drops the key's last-failure row, so re-saving a fixed key does not show the operator the pre-fix failure for 24h beside a fresh `capable` verdict. Mutation: restoring the fallthrough turns the new two-worker test red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…ct, and says so when it cannot (#2828) On 2026-09-15 the watch fired correctly and answered "conflicts with dev — head check not evaluated" for four of nine open PRs, two of which carried a live two-heads fork. All four conflicts were `learnings.md` append collisions. A watcher that stops on ANY conflict is absent on exactly the busy days a fork is likeliest; both forks reached the train green. - The conflict arm now asks WHERE. `git merge-tree --write-tree` writes the merged tree on exit 1 too, and lists the conflicted paths on its own stdout; only a conflict under `src/backend/(enterprise/backend/)?migrations/ versions/` is a `conflict`. Anything else is evaluated on the real three-way merge of the version directories, with the unrelated paths carried onto the verdict as `conflictsElsewhere`. - `alembic-head-verdict.js`: `clean`/`fork` name the unrelated files on the status description and the sticky (capped at 20, fenced); `conflict` — now only a revision file edited on both sides — publishes a visible `error` status plus a sticky that names the file, instead of a comment alone. #2029's rule is against a false `success`; it never argued for silence, and silence is how two forks rode to the train. Proven against the real case, not the YAML: the evaluate step extracted and run locally against origin/dev + #2805's pre-fix head → `fork` with `learnings.md` named (was `unknown`); the fixed head → `clean`; a synthetic both-sides revision edit → `conflict`. Shape pins and executed verdict tests updated in test_2533; the "a conflict publishes no status" pin is re-anchored with the reason. Not here, by scope: per-PR learnings fragments (the collision's own fix) and a merge-time check (direction 3) — both named on the issue. Fixes #2828 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…iling (#2827) `_coerce_nonneg_int` admitted anything up to INT8_MAX for all eight int columns, but only `git_dir_bytes` is BIGINT on PostgreSQL (#2800). The other seven are int4, so a value the boundary admitted but the column could not hold made the whole upsert raise NumericValueOutOfRange and the agent's sync health went dark. The four ahead/behind counters were not coerced at all. - default ceiling is now INT4_MAX; `git_dir_bytes` and the lock-report ints opt into INT8_MAX explicitly - `_coerce_counter` bounds ahead_main/behind_main/ahead_working/ behind_working (and the legacy ahead/behind keys) the same way Tests drive INT4_MAX+1 through `_sync_agent` per column (rejected → NULL/0, BIGINT admitted), pin that INT4_MAX itself lands, and round-trip each column's admitted maximum through SyncStateOperations on both backends. Mutation (default ceiling back to INT8, counter left raw) → 3 red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…arm guard is pinned (#2832) — mechanical, per the merge-train note on the PR - `git -c core.quotePath=off merge-tree …` plus `^"?` in VERSION_LINES: a revision path git C-quotes (non-ASCII, `"`, `\`, control byte) no longer slips past the anchor into the *elsewhere* class, where the guard would run over a marker-bearing file that check_alembic_heads.py omits as unparseable and read PASS. - The `[ -z "$evaluable_conflict" ]` guard on the unknown arm — the one line the #2828 fix turns on — is now asserted; deleting it fails test_an_unrelated_conflict_no_longer_stops_the_evaluation (mutation-checked). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…2702) — mechanical, per the merge-train note on the PR `record_live_failure` stores the provider's status word / prose as the operator `detail` for 24h and the Settings panel renders it. The client half is a constant, so this row is the only place a provider body reaches any surface; the panel promises the key is never echoed, so enforce it here with `scrub_secret` instead of assuming it of the provider. One test drives a body carrying the key and asserts neither the returned failure nor the stored row contains it (mutation: scrub removed → red). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
… not a literal 10 (#2817) — mechanical, per the merge-train note on the PR Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…t gated `workspace_available` was still computed by GET /api/settings/feature-flags, still shipped `false` in `.env.example` and all three compose files, and was still described as *the* Workspace opt-in by `settings.py`'s docstring, the endpoint catalog, the voice-chat flow's env table and the single-server deploy guide — while nothing in `src/` gated on it. ent#438 merged the per-agent workspace page into the Workspace and removed every consumer. That is worse than a dead key. An operator following the docs sets WORKSPACE_ENABLED=true and changes nothing, or reads the default `false` and concludes the Workspace ships dark when it does not — and a release note could repeat the claim. Found by /release-plan as the one *forgotten* item in the 0.9.5 payload: not a forgotten flip, a forgotten removal. Removed: the flag key and its docstring bullet (`routers/settings.py`), the `is_workspace_enabled()` resolver (`settings_service.py`), the store field and its two writers (`stores/sessions.js`), the declaration in `.env.example` and the three compose files, and the four LIVE doc claims. Deliberately kept: the historical references in `observability.md`, `requirements/core-agent.md`, `feature-flows/agent-canvas.md`, `router/index.js` and `AgentHeader.vue`. Each narrates the retirement in the past tense and is how a future reader learns why the knob went away. Also kept: `voice_available`, which has no reader either but is a separate #2559 follow-up — its `sessions.js` comment justified itself by the `workspace_available` derivation, so that sentence is corrected here. `config.py` and `requirements/runtimes.md` described VOIP_ENABLED's default as "mirrors the workspace_available opt-in"; VoIP is independent (`VOIP_ENABLED and gemini_key`), so the stale cross-reference is dropped. Tests: `tests/unit/test_workspace_flag_retired.py` pins the removal four ways — the key is absent from the handler payload, the resolver is gone, no shipped config declares the variable, and no live source reads it. The source scan strips comment REGIONS rather than testing line prefixes, because the reference that motivated it sits on a continuation line of a multi-line `<!-- -->` block in `AgentHeader.vue`. Verified by mutation: re-adding the key as code turns two of the seven red, and restoring turns them green. The two #860 tests in `tests/test_platform_default_model.py` asserted the key must be PRESENT — the contract ent#438 invalidated — and are replaced by one that asserts its absence over the wire. Two now-dangling `is_workspace_enabled` stubs dropped from the #2217 and #2380 unit tests. Verification: 155 backend unit tests pass across the touched files. The frontend vitest suite was NOT run locally (Docker is not running and this clone forbids falling back to local npm); `frontend-build` covers it in CI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Sep 16, 2026
This was referenced Sep 16, 2026
docs(testing): the evidence bar — a test executes the path, a fix records its mutation (#2829)
#2833
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Integration surface for #2825, #2832, #2817, #2702, #2805, #2836. Never merged; members merge individually once green.