Skip to content

DO NOT MERGE — merge train: 2825,2832,2817,2702,2805,2836 - #2839

Closed
vybe wants to merge 30 commits into
devfrom
train/20260916-0919
Closed

vybe wants to merge 30 commits into
devfrom
train/20260916-0919

Conversation

@vybe

@vybe vybe commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Integration surface for #2825, #2832, #2817, #2702, #2805, #2836. Never merged; members merge individually once green.

dolho and others added 30 commits September 15, 2026 12:35
`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>
# Conflicts:
#	docs/memory/learnings.md
# Conflicts:
#	docs/memory/learnings.md
@vybe

vybe commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

All six members merged individually (#2825, #2832, #2817, #2702, #2805, #2836). Closing the integration surface.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants