Skip to content

DO NOT MERGE — merge train: 2714,2675,2679,2706,2704,2645 - #2729

Closed
vybe wants to merge 36 commits into
devfrom
train/20260911-1635
Closed

vybe wants to merge 36 commits into
devfrom
train/20260911-1635

Conversation

@vybe

@vybe vybe commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Integration surface for #2714, #2675, #2679, #2706, #2704, #2645. Never merged; members merge individually once green.

Members in order: #2714 (e2e fix, first), #2675 → #2679 (stacked), #2706, #2704, #2645. Four keep-both resolutions on docs/memory/learnings.md / feature-flows.md only.

dolho and others added 30 commits September 9, 2026 12:40
…tion (#2638)

A Workspace message to an agent whose Claude subscription was rate-limited
failed outright, the message was lost to a FAILED execution, and the client
was told the failure was not retryable. Every SUB-003 mechanism was working —
switch on the first 429 (#441), re-issue once (#792), rank by headroom
(#2409). Four gaps between them left the turn failing anyway.

**1+2 — the 2h skip-list is now overridable, per candidate, on evidence.**
`list_viable_alternative_subscriptions` drops any subscription with ANY
failure event in a flat 2h window, so on a two-subscription install one stale
event means "no viable alternative" while an alternative the provider would
serve sits there — which was #2320's own evidence. `recovery_verdict`
readmits on positive evidence ONLY: a FRESH reading saying the provider is
not refusing (`serving_now` — #447's rule that a probe beats an inference
from past failures), or a blocked window's own reset instant ELAPSED **and
predating the failure** (`window_reset`). That ordering is load-bearing:
without it, a subscription that 429'd a minute AFTER its rollover would be
readmitted on a reset it had already consumed. Instants come from an AGED
snapshot on the asymmetry this codebase already states — a utilisation
number decays, an instant does not.

Three properties keep #444's ping-pong closed: absence of evidence readmits
nothing (that loop was caused by FORGETTING a failure); a fresh refusal does
not fall through to the weaker instant arm; and the fail-open ranking branch
readmits nobody by construction, because it is precisely where the evidence
could not be read.

One interaction found by the end-to-end test rather than by reading: a
`window_reset` candidate's reading carries a `blocked` flag describing the
window that just rolled over, and `rank_subscriptions` drops a blocked
candidate as refused — so the readmission was inert in exactly the case it
exists for. Those readings are handed to the ranker as UNKNOWN.

**3 — the switch can happen BEFORE the first dispatch.** SUB-003 was purely
reactive, so the first message after a wall always burned a failed attempt —
on the Workspace, a person watching their message fail.
`ensure_serviceable_subscription` moves the agent when its subscription is
already known-refused (a fresh provider refusal, or a 429 in the platform's
own 2h window — the 429-only DISPLAY predicate, deliberately, since an auth
failure is a credential problem another subscription may share). It never
raises, records NO failure event (nothing failed, and a synthetic one would
poison the skip-list it feeds), dispatches anyway when there is no
alternative, and performs the SAME `_perform_auto_switch` so the activity,
notification and hot-reload happen whichever path fired.

**1 last resort — the platform API key.** When the switcher declines,
`fallback_to_api_key` clears the assignment, sets `use_platform_api_key` and
restarts rather than hot-reloading (the reload endpoint pushes an OAuth
token; this needs the opposite change, which lifecycle already derives from
DB state). Setting `subscription_api_key_fallback`, default ON, fail-OPEN on
a read error, with `key_configured` on the read — a toggle reading only "on"
with no key stored describes a remedy that cannot run.

**4 — the client is told what changed.** `TaskExecutionResult.
subscription_switch` carries the switch; the portal answers 503
`auth_switched` retryable=True naming the new subscription instead of
#2320's `retryable=False`, which was true only while nothing changed
underneath. With no switch it still refuses, but names the earliest reset the
sampler already caches instead of "try again later".

Gap 5 (pull-dispatched terminals never trigger SUB-003) is filed as #2643 per
AC #7 — a different blast radius, and inert until an agent is piloted.

Tests: `tests/unit/test_2638_subscription_switch_on_turn.py` (37) — the
verdict as a pure table, readmission through the real selector, the
pre-dispatch contracts, the fallback's setting semantics, and three
end-to-end turns through the real `execute_task` + real switcher: completes
on a never-failed alternative, completes on a READMITTED one, and the honest
negative. `test_2409` (87), `test_447`, `test_792`, `test_2352` and the
ping-pong suite are unchanged in substance and green.

Related to #2638

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP
…ck log

`logger.warning(..., API_KEY_FALLBACK_SETTING, ...)` trips
`py/clear-text-logging-sensitive-data`: the rule flags any `*_KEY`-shaped name
reaching a log call, and this one is a hard-coded settings key NAME, not a
secret.

Removed the interpolation rather than dismissing the alert. The constant is one
line above the log, so the message loses nothing an operator wanted, and a
dismissal would leave every future PR touching this file re-litigating the
same finding.

Related to #2638

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP
…k toggle

`rawColorRatchet.spec.js` fails: `SubscriptionsPanel.vue raw_gray 165 -> 173`.
The +8 is the API-key fallback toggle row #2638 adds — the label, the help text
and the toggle's off-state.

Not paid down, because there is nothing to pay it down TO: `gray` is the
sanctioned chrome family and `tailwind.config.js` defines no semantic neutral
(status-* / state-* / brand-* / accent-* / action-* all carry meaning). The
eight classes are copied verbatim from the three identical toggles immediately
above this one; inventing a one-off token for the fourth would make it the odd
one out while leaving its siblings unconverted.

Re-frozen in its OWN commit, as the guard's failure message prescribes, and
scoped to that ONE entry by hand rather than regenerated — a full regeneration
would silently absorb any unrelated drift that has landed on dev since.

Related to #2638

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP
CodeQL flags `py/clear-text-logging-sensitive-data` in the switch+retry block:
a subscription NAME read off the switch result is tainted from
`subscription_credentials`, whose row carries an encrypted token, so the whole
record reads as a credential.

The destination is dropped from the pre-dispatch line rather than dismissed.
`_perform_auto_switch` already logs "Auto-switching agent 'X' from 'A' to 'B'"
one frame down, so the interpolation duplicated the frame below it and was not
worth a standing false positive on the hot path.

The sibling alert on `platform_audit_service.py:391` is untouched by this PR —
it has been open on `dev` since 2026-06-04 and is attributed here only by the
diff-scan.

Related to #2638

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP
…to internal

`regression diff` caught a real defect, not a stale test.
`test_every_declared_category_is_actually_raised_somewhere` keeps
`PORTAL_FAILURE_CATEGORIES` closed in BOTH directions, and the new
`category="auth_switched"` raise site was not in it.

That is not bookkeeping: `record_turn_outcome` coerces an undeclared category
to `internal` — silently. So the switch outcome would have been recorded as an
uncategorised crash, NOT retryable, with the fixed internal copy in place of the
sentence naming the new subscription. The gap-4 fix would have shipped inert
while its raise site read as correct.

Declared as a TENTH token rather than folded into `auth`, because the two
disagree about the only thing the client acts on: `auth` means retrying
re-fails, which holds exactly while nothing changed underneath, and a switch is
something changing underneath. It needs no client branch — `cancelled` and
`invalid_model` are the only categories the client branches on; everything else
renders its message and its `retryable` flag.

Also drops the destination name from #792's switch log, the second CodeQL
`py/clear-text-logging-sensitive-data` sink on this path: a name read off the
switch result is tainted from `subscription_credentials`, whose row carries an
encrypted token. `_perform_auto_switch` already logs "Auto-switching agent 'X'
from 'A' to 'B'" one frame down and the audit row still carries
`new_subscription`, so no operator loses anything — only a duplicated
interpolation goes.

The sibling alert on `platform_audit_service.py:391` is untouched by this PR
and has been open on `dev` since 2026-06-04.

Related to #2638

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP
…ch failed` (#2661)

The #914 gateway-timeout receipt covered the sequential /chat route only.
`chat_with_agent(parallel=true, async=false)` dispatches through
`client.ts::task()`, which held the backend fetch for `timeout_seconds + 60`
(up to 7260s). The MCP client's own gateway timeout kills the JSON-RPC call
long before that, so the caller saw a bare `fetch failed` while the target kept
running — no receipt, no execution_id, nothing to poll. Fleet incident
2026-09-08: every duplicate dispatch in a three-hop cascade was a re-send after
"could not confirm delivery" on this path.

The #914 matcher could NOT simply be mirrored. /chat is queue-serialised, so
"newest non-terminal MCP row wins" is near-unambiguous there. /task exists to
run N tasks concurrently, and every filter that rule applies — triggered_by,
source_mcp_key_id, the recency window — is IDENTICAL across one caller's
concurrent tasks. Mirroring it would have handed caller A the execution_id of
caller B's task; A then polls and acts on a well-formed FOREIGN result. Silent
wrong data is worse than the loud error it replaces.

Attribution is now provable or absent:
  - read the execution_id out of an idempotency 409 instead of collapsing it
    into an opaque `API error (409)` — an exact key->execution mapping the
    backend already sends (RELIABILITY-006)
  - match the call's own `message`, not just key + trigger
  - return NOTHING when more than one candidate survives; ambiguity yields no
    receipt, which is exactly the pre-#914 behaviour, so refusing to guess is
    never worse than before
  - recover on AbortError only — a TypeError may mean the request never landed,
    and on this route a concurrent peer row is the normal state

Deliberately NOT done: re-POSTing the dispatch with the same key as a "probe".
`idempotency_service.begin()` fails open, so on that path the probe would
dispatch a second execution — precisely the bug being fixed.

Three latent defects in the already-shipped /chat route, fixed alongside:

  - The recency window was a fixed 30s while the abort fires at
    MCP_CHAT_TIMEOUT_MS. Raising that documented operator knob to >=30s put
    every candidate row outside the window, silently degrading every receipt on
    both routes to the no-match throw — the knob disabled the feature it was
    meant to tune. The window is now derived (timeout + 10s).
  - The recovery lookup ran through `_fetch`, which has no AbortController and
    re-authenticates once on 401. We abort at 25s precisely BECAUSE the gateway
    ceiling is close; an unbounded lookup spends what is left of it and, when
    the backend is the slow party (the usual reason we aborted), reproduces the
    very `fetch failed` this feature exists to prevent. Now bounded by
    MCP_RECOVERY_TIMEOUT_MS with no retry, and reads 50 rows rather than 10.
  - The trigger allowlist had to become per-call-site. Widening the shared
    constant with `self_task` would have let a /chat abort attribute a
    concurrently-running parallel self-task row — /task is unqueued, so such a
    row can be RUNNING while a /chat sits queued.

Backend counterpart: a failed/cancelled/timed-out sync /task left its
idempotency claim in_flight for the full 24h TTL, because `_map_task_failure`
raises between `begin()` and `complete()` and nothing covered that path. A
legitimate retry then answered 409 for a day against a task dead for minutes,
and REWORDING was the only way through — which derives a different key and
dispatches a genuine duplicate. The wedge did not just block retries, it
selected for the duplicate-dispatch behaviour #2661 exists to stop. `idem` is
keyword-only and required so a third sync branch cannot silently reintroduce it.

Also ships `.github/workflows/mcp-server-test.yml`. Nothing ran this package's
suite: helper-mcp-test.yml covers src/helper-mcp only, and container-security
filters on the Dockerfile, not src/**. So 341 tests across 28 files gated
nothing, and `npm run build` — this package's only typecheck — first ran when
deploy-dev built the image ON dev, breaking the deploy instead of the PR.

Verified live against a running stack, not just in unit tests:
  - abort at 400ms on /task returned a real receipt; polling its execution_id
    resolved, with triggered_by=mcp, the exact message, and the real
    source_mcp_key_id — so the key filter genuinely engaged. The harness
    previously passed a fabricated keyId that could never match a row, making a
    green run meaningless; fixed here.
  - wedge before/after: without the fix a second send after a failure returned
    409 request_in_progress against an already-dead task; with it, a fresh
    dispatch.
  - both new backend guards mutation-verified (remove the release -> 6 fail;
    drop idem= from a call site -> the AST guard fails).

fan_out is the third route of this class and stays open (#2670) — it needs a
fan_out_id receipt and no polling surface resolves one. The read-only
idempotency lookup that would retire this heuristic on every route is #2671.

Fixes #2661

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmVdJbnrh1kZJjuYHQYNCE
- a selection heuristic ported to a route with different concurrency turns
  "no answer" into a confident wrong one; ambiguity must return nothing, and
  the identity the system already records beats elimination
- a documented operator knob whose dependent constant is hardcoded is a silent
  kill-switch: raising MCP_CHAT_TIMEOUT_MS as documented disabled every receipt
- a failure path between begin() and complete() wedges the idempotency claim,
  and the reword that gets a caller past the 409 is what creates a real
  duplicate — fix the choke point, guard the call sites with AST

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmVdJbnrh1kZJjuYHQYNCE
…state, one remediation per turn (#2638)

Four review findings.

1. **The client-facing half was inert for the 429 path that produced the bug.**
   A Claude usage limit surfaces from the agent as 429; `_handle_http_error`
   classified only 503, so `error_code` stayed None. `TaskExecutionErrorCode.
   BILLING` had NO assignment site anywhere in `src/backend` — enum definition,
   comments, and the portal's gate tuple, never set. A Workspace turn is
   `triggered_by="public"`, which is not async-eligible, so it takes exactly
   that sync path: AC 4/5 fired only when a failure happened to arrive as 503.
   A 429 now sets `BILLING`. Downstream is safe by construction — the dispatch
   breaker counts `auth` only (#526 D10); the #1085 governor does count
   `billing`, which is what it was written for and which has never been
   reachable from the sync path, behind a default-OFF flag.

2. **`_assigned_subscription_is_refused` re-opened the #447 OR.** A fresh
   reading now ends the question in both directions — refusing → switch, not
   refusing → dispatch — and only the absence of a usable reading falls through
   to the 2h event predicate. As written, a subscription the provider was
   demonstrably serving was readmitted by `recovery_verdict` and evacuated by
   this on every dispatch: a hot-reload and a high-priority notification per
   turn, and with two such subscriptions, a flap. An unreadable snapshot still
   falls through rather than clearing, so a Redis blip cannot silently disable
   the arm.

3. **The pre-dispatch path now spends the turn's one remediation.** It set
   `subscription_switch`, not `subscription_switch_attempted`, so a turn moved
   before its first attempt and refused again switched a second time and
   re-issued — the cascade that flag exists to stop.

4. **`_with_switch` at every terminal return.** `BackendAgentCallBudgetExhausted`
   and the generic `except Exception` were unwrapped, and both are reachable
   after a pre-dispatch switch, so the portal would say "not retryable" while
   the agent sat on a fresh subscription.

Tests: `TestTheRefusalPredicateIsThreeState` (five cases, including the two
doors agreeing on ONE reading rather than being checked in isolation, and the
fail-closed unreadable snapshot); `TestEveryTerminalCarriesTheSwitch`, an AST
guard over `execute_task`'s return sites with the two pre-dispatch returns named
so a later addition has to be justified; the E2E negative now asserts
`error_code` is BILLING — by `.value`/`.name`, since #1085's fieldless-dataclass
quirk makes `BILLING == AUTH` True — which is the one assertion that would have
caught (1); and a new E2E turn proving a pre-dispatch switch does not switch
twice. Each of the four fails against the pre-fix source, verified by reverting
them one at a time.

1008 passed across every subscription / task-execution / portal / headroom test.

Fixes #2638

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
#2670)

The third and last route of the #914 class, and the one that hits it most
reliably: a fan-out dispatches N tasks and by construction runs longer than any
single one of them, so it exceeds the gateway ceiling more often than the two
routes already fixed. `client.ts::fanOut()` carried the identical unbounded
`(timeout_seconds ?? 7200) + 60`, so the MCP client's own 30-60s timeout killed
the JSON-RPC call and the caller saw a bare `fetch failed` while every dispatched
task kept running — with nothing to poll.

**The receipt names a `fan_out_id`, not an `execution_id`.** A batch is N rows
sharing one id, so a single execution id could only ever name an arbitrary
member of it. `{status: "fan_out_timeout", agent, fan_out_id, execution_ids,
task_count, message}`; `execution_ids` is evidence, not a manifest, since a
slot-starved subtask has no row yet.

**Ambiguity is redefined, not reused.** #2661 refuses when more than one ROW
survives its filters, because on `/task` it cannot tell which row is the
caller's. A fan-out stamps one `fan_out_id` on all N of its rows, so finding ANY
row finds the batch and N survivors is the expected shape. The unit that must be
unambiguous is the BATCH: `pickRecentFanOut` returns nothing when more than one
distinct `fan_out_id` survives. Same rule ("a wrong id is worse than none"),
measured on the right thing.

**Status is deliberately not filtered.** `/chat` and `/task` require a
non-terminal row, because a terminal one is evidence the receipt is unnecessary.
By abort time a fan-out is normally a mix — some subtasks finished, others
running — so requiring non-terminal rows would drop exactly the batches furthest
along. The derived recency window (#2661's rule, `timeout + 10s`, never a fixed
constant) is what bounds staleness.

**The polling surface reads rows, not the idempotency snapshot.**
`GET /api/agents/{name}/fan-out/{fan_out_id}` folds `schedule_executions` — where
`fan_out_id` has been stamped on every subtask since FANOUT-001 — into
`{status, total, completed, failed, running, results[]}`. `routers/fan_out.py`
does store the whole aggregate under the call's key, which looks like a free
receipt, but `complete()` runs only once the batch has FINISHED, so the snapshot
cannot answer what a timed-out caller is actually asking: what is happening
right now. That is also why this does not collapse into #2671.

Batch status is `running` while any subtask can still change, then `completed` /
`partial` / `failed`. `partial` exists because best-effort is the fan-out's
default policy. `deadline_exceeded` is absent by construction — it is the
dispatcher's verdict on its own outer deadline, not a property of any row.
Per-task status is the EXECUTION status verbatim, so a subtask waiting for a
slot is distinguishable from one that ran; the dispatch response's two-value
`completed`/`failed` pair would report a healthy queued subtask as a failure.
Enumeration-safe: malformed, unknown and belonging-to-another-agent are one 404.

**Two backend contracts the client depends on.** The in-flight 409 now returns
the same `{error, message, execution_id}` shape `/chat` and `/task` do — it was
a bare string, so `fan_out` could not benefit from the #2661 client that reads
that field — and the batch id is attached to the idempotency claim WHEN IT IS
MINTED (`FanOutService.execute(on_started=…)`) rather than at `complete()`.
Attaching at the end records it exactly when nobody needs it any more: the
window in which a duplicate arrives, and in which this call's gateway gives up,
is the whole run. The hook is best-effort — bookkeeping must not fail a dispatch.

**MCP surface** (Invariant #13): new `get_fan_out_result` in
`tools/executions.ts`, gated to `{self} ∪ permitted` like `get_execution_result`
beside it; the `fan_out` description states the receipt and the retry asymmetry
(an identical re-send dedupes server-side, a REWORDED one dispatches all N
tasks again). `verify_914.ts` grows a `fanout` mode with three tasks, so the
harness exercises batch identification rather than a single row.

Tests: 13 TS cases on the matcher (N-rows-is-normal, two-batches-refuses,
part-finished-still-found, per-call-site trigger set, derived window) and 26
backend cases on the fold, the read surface's enumeration safety, and the two
contracts. `354` MCP tests and `15072` backend tests pass; the 15 failures in
the full run are DNS-dependent SSRF tests that fail identically on the base
branch with this change stashed.

Fixes #2670
Follow-up of #2661

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
…ss, not the selection bound (#2638)

Found re-reviewing my own #2638 fix. Making `_assigned_subscription_is_refused`
three-state was right, but the reading it trusts came from
`cached_headroom_readings` with no `max_age_seconds` — i.e. the SELECTION bound,
`MAX_READING_AGE_SECONDS` (>= 2h).

That bound is calibrated for RANKING candidates, where a stale reading beats
none. This call does a different job: it decides whether a provider verdict may
OVERRULE the 2h event predicate. A reading as old as the window it overrules
cannot — so a two-hour-old "serving" snapshot could suppress a five-minute-old
429 and pin an agent on a subscription that is refusing it right now. The #447
rule is "a probe is ground truth about NOW", and this was applying it to a probe
that is no longer about now.

It now asks for `FRESHNESS_SECONDS` (30 min) — the same bound
`_headroom_indicates_healthy` uses for the same judgement one module over, and
the one this file already declares for the mirror case
(`REFUSAL_FRESHNESS_SECONDS = FRESHNESS_SECONDS`, "a refusal is trusted exactly
as long as the LIMIT badge trusts one"). It tightens the refusing arm too, which
is deliberate and safe: a stale refusal falls through to the event predicate
rather than evacuating on its own.

Tests: the fixture now models the AGE GATE rather than only the lookup (a fake
that ignores `max_age_seconds` makes the distinction untestable — the trap the
E2E harness already documents for the readmission path), plus three cases — a
stale serving verdict falling through to the event predicate, a display-fresh
one still winning, and the bound asserted as the ARGUMENT, since omitting it is
the bug and a behavioural test alone would pass again the day the default moves.
Both new cases red against the unbounded call.

49 passed.

Fixes #2638

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
Two conflicts, both ADD/ADD at the same point in the file, and both resolved
by keeping BOTH sides — neither is a competing version of the other.

`services/subscription_auto_switch.py`: this branch (#2638) adds the
pre-dispatch switch and the API-key fallback —
`_assigned_subscription_is_refused`, `ensure_serviceable_subscription`,
`is_api_key_fallback_enabled`, `earliest_known_reset`, `fallback_to_api_key`.
dev (#2643) adds the pull-sink hook — `_inflight_switch_tasks`,
`spawn_subscription_failure`, `_guarded_switch`. They landed at the same
offset and share no name; nothing calls into the other. Both blocks kept.

`docs/memory/feature-flows/subscription-auto-switch.md`: two independent
history sections, both dated 2026-09-09. The file lists dated sections
oldest-first (#471, #2352, #2409, …), and #2643 merged after #2638, so the
order is #2638 then #2643.

The two features are complementary rather than overlapping, which is worth
stating because the merge could look like duplication: #2643 gives the PULL
terminal writer the SUB-003 hook the push path already had, and #2638 makes
the switch happen before the first dispatch rather than after a burnt
attempt. Nothing in either reads the other's state.

Subscription/headroom/auto-switch tests green on the result: 497 passed,
2 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
Resolve docs/memory/learnings.md: append-only log, both sides kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
Resolve src/frontend/raw-color-baseline.json: keep the #2638 SubscriptionsPanel
note alongside the #2662 notes that landed on dev. Ratchet test passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
…k list follows (#2703)

Every assign path — `POST /agents/{n}/skills/{s}`, the bulk `PUT`, the Library
control (ent#386), MCP `assign_skill_to_agent` / `set_agent_skills` — wrote the
`agent_skills` row and stopped. The package reached the agent only on a manual
Sync, the next start or the fleet sweep, while unassigning already REMOVED it
(ent#236): the write path was asymmetric, and the natural flow — assign from
the Library, open the Workspace, type `/` — showed nothing. Once it did land,
no open surface refreshed.

Backend
- `skill_service.deliver_assigned(agent, requested)` — the write-side twin of
  `remove_skills`. Container state via the tri-state executor read
  (`docker_utils.agent_container_state_async`, #2196): running → the START-PATH
  injection (every assigned name, `force=False`) — NOT a named subset, because
  `_inject_skills_locked` rewrites CLAUDE.md's Platform Skills section to the
  names of the run it is in, and a subset call on a ten-skill agent would leave
  it advertising one; stopped/missing → `pending_start`; `None` (Docker could
  not be asked) → `not_delivered/docker_unavailable`, never a guessed
  `pending_start`. Bounded by `SKILL_DELIVERY_BUDGET_SECONDS` (20 s): past it
  the injection continues as a strong-ref background task and the caller gets
  `in_progress`; the WS trigger fires on completion. Busy is retried once
  (2 s) — an assign landing while the start path holds the lock would
  otherwise never be delivered. The report is projected onto the requested
  names; the row is never rolled back and the HTTP status stays 200.
- `_inject_skills_locked(assigned_only=True)` re-reads the rows under the lock
  and skips names no longer assigned (`unassigned_meanwhile`) — the mirror of
  removal's `still_assigned` guard, so a PUT-inject on one worker cannot land a
  package a concurrent DELETE just removed. Opt-in: start path, sweep and
  manual Sync keep their explicit lists verbatim.
- Router: `_deliver_assigned_skills` (twin of `_remove_unassigned_skills`) on
  POST — both branches, since the Library control's only retry is a re-click —
  and on the PUT's added names; the PUT is now symmetric. Every listing change
  fires the thin `agent_skills_changed` WS trigger `{type, agent_name}`
  (identifiers only — `/ws` is SCOPE_ALL, the #918/ent#305 rule) from
  `skill_service.broadcast_skills_changed`, which lives in the service so the
  fleet sweep and the background completion can fire it too (Invariant #1).

Frontend
- `stores/skills.js` ticks the agent (300 ms debounce); `ChatPanel` and
  `PlaybooksPanel` watch their agent's tick and refetch `/playbooks`
  (`ChatInput` takes its list from `ChatPanel`, #2198). `saveAssignments`
  keeps the PUT's `delivery` report; the Skills tab's Save note says what
  happened and arms the Sync nudge only when delivery did NOT land
  (`utils/skillDelivery.js`, one pure wording rule).
- `stores/clientPortal.js::revalidateBriefing` — stale-while-revalidate: the
  card's hint cards and `/` entries stay until the new answer lands;
  `briefing_state` is never flipped back to `pending` (that re-enters the
  loading skeleton on a zone with data). An event landing mid-flight marks the
  agent dirty and re-runs once. `applyBriefings` stamps `briefing_hydrated_at`.
- An external client's Workspace has no `/ws` (portal token; the ticket mint is
  JWT-only), so opening the `/` popup re-validates the active agent's briefing
  if older than 60 s — the equivalent invalidation, bounded.
- The Library control (`AssignedAgents.vue`) renders the delivery outcome
  inline; assign/save requests carry a 45 s timeout above the server budget.

MCP: `assign_skill_to_agent` / `set_agent_skills` pass `delivery` through
verbatim and their descriptions name the vocabulary (Invariant #13).

Tests execute every path — the service (running/stopped/None/busy/over-budget/
partial), the under-lock re-read, the routes (200 + `delivery`, broadcast on
all four, 404/422 before any write), the store ticks and debounce, the wording
rule, the portal re-validation (no pending flip, mid-flight coalescing,
maxAge), and the MCP passthrough. /cso --diff: 0 findings.

Fixes #2703

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
…v read (#2703)

`SKILL_DELIVERY_BUDGET_SECONDS` read `os.environ` but no compose file forwarded
it — an inert knob that read as configurable (the #1056 packaging-gap class
/validate-pr §4.9 catches). The #1644 rule applies: a value neither compose
forwards is a constant, literally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
…e, and reads at the display bound (#2638)

The 09-10 merge-train ejection: `recovery_verdict`'s serving_now arm readmitted
a skip-listed subscription on ANY non-refusing reading inside the ≥2h selection
bound, with none of the ordering the window_reset arm has. A reading taken
before the 429 is exactly what a subscription at the wall carries for up to one
refresh interval after hitting it, so on a two-subscription install both at
the wall every user turn readmitted the other on its pre-wall "ok", switched,
failed, and flapped A<->B until a probe recorded rate_limited — #444's
ping-pong re-opened from the other side.

- `recovery_verdict`: serving_now readmits only when the reading POSTDATES
  `last_failure_at` (reading instant = now - age_seconds); no failure instant,
  or an unparseable one, readmits nothing on that arm (fail closed, like the
  instant arm). A pre-failure "ok" falls through to the window_reset arm, which
  orders itself.
- `_readmit_recovered`: the fresh read is bounded at FRESHNESS_SECONDS — the
  same bound the evacuation door uses — not the selection bound.
- `test_subscription_auto_switch_pingpong.py`: the headroom stub gains the
  names the merged selector reads (FRESHNESS_SECONDS, RECOVERY_*,
  recovery_verdict) plus a name-parity assertion, so the suite fails instead of
  taking the fail-open branch and going inert — the ejection's second finding.
- Regression tests for both repro cases (age 600 s / 429 two minutes ago; the
  unorderable failure) at the pure verdict and through the selector; the
  "doors agree" invariant restated with the failure predating the reading,
  plus the one permitted disagreement pinned (both doors shut is not a flap).
- Docs: subscription-auto-switch.md "Not covered" and requirements/security.md
  no longer claim pull terminals never trigger SUB-003 (#2643 gave the sink
  the hook).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
Resolve src/frontend/raw-color-baseline.json: notes block only (dev's _2616_note
kept beside this branch's 2638 note). Ratchet test passes on the merged tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
Resolve docs/memory/learnings.md (append-only, both kept) and
tests/registry.json (both new entries kept).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
…n they went to, and the receiver sentence is decided from that record (#2571)

The send log kept time, outcome, status and payload but not the destination,
and Settings → Usage sharing decided "404 at the default address" vs "your
receiver answered 404" by comparing the CURRENT TELEMETRY_SHARING_URL to the
default at read time, beside the current address. That URL is a boot-time
constant, so after "override → 200 → restore the default" a local sink's
acknowledgement read as the hosted receiver's.

Now each entry carries the origin it was posted to (scheme + host + port after
strip_url_credentials; never path, query or userinfo; total over every URL
shape, stamped in the best-effort writer so no attempt is logged without one),
the 2xx stamp records the origin that acknowledged it
(telemetry_sharing_last_shared_host, written before the date), receiver_hint
is decided from the entry, and the status carries receiver_host /
configured_host / receiver_mismatch so the panel names the host that answered,
says plainly when the newest send went elsewhere than the configured address
and what happens next, and shows "to <host>" per row and "Last delivered <date>
to <host>". Entries and stamps written before this read as "an unknown
receiver" and never as a mismatch; share_url is scrubbed. No migration, no new
setting.

Deferred (registered): a destination change starting a new delivery episode,
and the private benchmark read using the recorded origin instead of hedging.

Fixes #2571

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…te the client change (#2661 review)

The 2026-09-10 merge-train hold on #2675, item by item.

Backend — `_dispatch_sync_backlog` had two exits between `begin()` and
`complete()` that `_map_task_failure` never saw:

- long-poll timeout: no longer raises from the `except`. The row is read
  first; a terminal the wait missed is reconstructed and settled the normal
  way (success → complete, failed → release). A row still queued/running
  completes the claim with a `queued_timeout` RECEIPT (`_queued_payload`
  shape, the MCP client's own vocabulary) — not released, because a retry
  would dispatch a second execution beside the live one; not left in_flight,
  because nothing downstream completes it and a retry after the row
  terminates would still 409 for a day.
- vanished row (503): releases. Nothing is running under this key that
  anyone can find, so a retry must be allowed to dispatch.

Four executed cases plus an AST guard that every `raise ChatDispatchError` in
that function is preceded by a `fail()`/`complete()` in its block.

MCP client — `task-receipt.test.ts` drives `TrinityClient.task()` and `chat()`
against a stubbed `fetch`: the 409 → receipt branch, the abort → executions
lookup → receipt path, the ambiguity throw, async_mode and TypeError NOT
recovering. Mutation-checked: disabling either branch reds the file.

- `chat()` now honours the same 409 rule the tool description promises for
  every sync route, through a shared `inFlightReplayReceipt` so the two
  routes cannot drift.
- `verify_914.ts`: per-route prompt — in `both` mode the still-running chat
  row matched the task lookup's exact-message filter, two survivors, refused
  on ambiguity, exit 1. And `? 0 : 0` is now `? 0 : 1`.
- `MCP_RECOVERY_TIMEOUT_MS` wired into all three composes + `.env.example`;
  `MCP_CHAT_TIMEOUT_MS` added to prod + hosted (it was dev-only).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
…ed-timeout-receipt

# Conflicts:
#	docs/memory/learnings.md
…-receipt' into fix/2670-fanout-receipt

# Conflicts:
#	docs/memory/learnings.md
… never re-optimises mid-session (#2705)

Vite 8's dependency scanner tree-shakes each script block before it records
imports, so CanvasDiagram.vue's `import('mermaid')` inside an unexported helper
in a plain <script> block was never pre-bundled. The first served page whose
module graph included the file registered the missing dep, re-optimised, and
sent a full reload to every open tab — every developer's, and every Playwright
page mid-run, which is why agent-detail-request-dedupe.spec.js saw every /api
request twice.

- vite.config.js: optimizeDeps.include lists every package a dynamic
  import('…') under src/ names (mermaid, qrcode).
- scripts/scan-dynamic-imports.mjs: finds them (script blocks only; comments
  and regex literals blanked, strings kept; non-literal specifiers reported).
- tests/unit/optimizeDepsIncludeGuard.spec.js: checks the list against the
  RESOLVED config via resolveConfig; red on the previous config, green now.
- docs/memory/architecture/frontend.md: the rule, in the owning area file.

Refs #2705

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… reduced motion (#2705)

Since #2676 the workspace rail column enters through a 300 ms width transition
once the roster arrives, and everything left of it moves with it. The spec read
Send's box, then each button's, then clicked at remembered coordinates; the two
Send reads straddled the transition, the stale send.x made the Send-skip miss
Send itself, and "an icon button sits right of the picker" fired on Send's own
right edge — 3 of 3 attempts at 768 and 1280 in some CI stacks, while 375 passed
only because the column is hidden below sm.

Every box now comes from ONE page.evaluate (the locators choose the elements,
the page returns the geometry in one frame), the page emulates reduced motion so
the entry is instant, and the shell click is an element action. 41 of 41 across
ten repeats locally, against 4 failures in 6 before.

Also: the feature-flow rows for the spec, three learnings-ledger entries (the
one-frame rule, the scanner class, and "read the summary line before attributing
a red"), and the cso --diff report (no findings).

The dedupe spec's older compatibility-x2 double fetch remains and is #2710; the
rail column's load-time shift is #2711; e2e on the production image is #2712.

Fixes #2705

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…losing tag's > (#2705)

CodeQL's js/bad-tag-filter flagged the <script> block regex for not matching
`</script >`. The regex extracts script blocks from SFC source rather than
sanitising anything, but a closing tag with whitespace before its `>` is valid
HTML and the extractor should read it, so the pattern now allows it, with a
fixture that closes one block that way.

Refs #2705

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ing before its > (#2705)

CodeQL's js/bad-tag-filter re-flagged the pattern on the previous head with
`</script\t\n bar>`: the rule wants the end tag to accept any attribute-like
noise before `>`, the same `[^>]*` shape the opening tag already uses. A
browser closes the block on such a tag too, so the extractor should read it.

Refs #2705

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…to (#2704) — mechanical, per the merge-train note on the PR

The dev merge 5e53d37 auto-resolved tests/registry.json by running the new
test_2703 entry's keys straight into the test_2694 object: valid JSON, so
nothing failed, but last-key-wins meant the #2694 entry was silently gone
(239 entries where 240 were expected). One object boundary restores it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018h3Ku8HFzwPfNR75kf1Gxe
…dule (#2679) — mechanical, per the merge-train note on the PR

The router is now also covered by executions.ts (get_fan_out_result) — the
Invariant #13 header is how /validate-architecture tells "unexposed on
purpose" from "forgotten", so it names both. The flow doc's harness line
still advertised [chat|task|both]; verify_914.ts takes [chat|task|fanout|all].

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018h3Ku8HFzwPfNR75kf1Gxe
# Conflicts:
#	docs/memory/learnings.md
# Conflicts:
#	docs/memory/feature-flows.md
#	docs/memory/learnings.md
# Conflicts:
#	docs/memory/learnings.md
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