Skip to content

DO NOT MERGE — merge train: 2761,2810,2699,2809,2773,2532 - #2820

Closed
vybe wants to merge 36 commits into
devfrom
train/20260915-1403
Closed

vybe wants to merge 36 commits into
devfrom
train/20260915-1403

Conversation

@vybe

@vybe vybe commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Integration surface only. This PR is never merged. Members merge individually once this is green.

Merging this would put merge commits on the squash history. It exists to pay the merge gate once for the batch, and to buy the one thing nothing else in the lifecycle provides: every PR here is tested against dev, never against its siblings, so two PRs that are individually green and jointly broken are invisible until both land.

# Lane Author Title
2761 B L4XB fix(mcp): let create/update_agent_schedule write the validation config
2810 B L4XB fix(chat): shape-check the caller-supplied model at the interface
2699 B dolho fix(workspace): the mic renders on speech-to-text capability
2809 A webmixgamer feat(tests): J10 journey — agents call each other through the MCP server
2773 C obasilakis fix(onboarding): say what a domain buys, earn the tick
2532 B+sch obasilakis feat(pull): async fan-out join + sync edge adapter

No closing keywords: the members carry those, and this PR must promote and close nothing.

What this batch is actually testing

Six files are touched by more than one member — all merged textually clean, which is exactly why a joint run matters:

The pairing to watch: #2809's J10 journey asserts fan-out cap and batch behaviour, while #2532 rewrites fan-out into an async join. Both are green alone. journey-smoke runs unconditionally on PRs to dev, so this is the first time those two meet.

Alembic: one schema member (#2532, 0062 on 0061), single head verified before and after.

Gate

Not the four required contexts — none of them runs a test. Gating on: backend-unit-test / the pytest seeds, regression diff, frontend-build, frontend-e2e (ui label applied — 8 frontend files), secret-scan, pg-migrations (#2532 touches db/ + migrations/versions/), plus schema-parity, both CodeQL analyses and verify-non-root.

Ejected from this batch: #2811 — headline AC falsified by an untouched second credential writer; three mutations restoring the reported bug leave all 3029 tests green. Reasons on the PR.

🤖 Generated with Claude Code

obasilakis and others added 30 commits September 9, 2026 14:04
…igger is stranded (#2524)

Completes #1081 Phase 4. After this, every autonomous trigger can run on the
durable queue; only the interactive ones stay on push, which is the deliberate
Open Question 7 scope cut (#1982/#1989).

FAN-OUT. `FanOutService.execute` built a `dict[task_id, FanOutTaskResult]`
inside one `asyncio.gather`, so the batch existed only while the request that
started it did. A pull-claimed subtask returns nothing to collect (the row is
queued and the turn runs later in the agent's worker), and nothing could answer
about a batch afterwards — no `async_mode`, no status endpoint, a disconnect
lost it. The batch now lives on `schedule_executions`: every subtask row carries
`fan_out_id` plus the caller's own `fan_out_task_id` (new column — the id used
to be a dict key no async batch could reach), and `build_aggregate` rebuilds the
result from the rows. Adds `async_mode` and
`GET /api/agents/{name}/fan-out/{fan_out_id}`, which also checks the batch
belongs to that agent: `fan_out_id` is opaque but not secret.

Two decisions the issue asked for.

`max_concurrency` keeps its meaning and needed no branch. The semaphore stays
around the `execute_task` call: on push that call spans the whole turn so it
paces dispatch as before, and under pull it returns in milliseconds so the
worker pool becomes the cap — Phase 5's "capacity becomes physical", by
construction. Deleting it, as first planned, would have fired N dispatches at an
agent whose `max_parallel_tasks` is 3 and turned the excess into CapacityFull.

The outer deadline bounds the WAIT, not the work — a contract change. A
still-open subtask now reports `running`, not `failed`; the batch still reports
`deadline_exceeded`. A queued or claimed row is not the backend's to cancel, and
on push the old cancellation was half-illusory anyway (it abandoned the HTTP
call while the agent kept running and billing the turn). After a deadline the
status endpoint is the source of truth.

A2A + OPERATOR_RESPONSE. These were deferred with "a2a cannot hand back a
receipt to poll" — true and beside the point: it does not need a receipt, it
needs to BLOCK CORRECTLY while the turn happens elsewhere, which
`sync_waiter.wait_for_sync_terminal` already did for `/task`.
`dispatch_and_await_terminal` is the adapter: `execute_task`, and on a QUEUED
return, wait for that row's terminal and rebuild from it. Nothing signals that
waiter on the pull path, so the wake is the 5s DB poll — up to ~5s of extra tail
latency on an a2a call against a pilot, deliberately not worth a second
signalling path.

`PULL_REACHABLE_TRIGGERS` now equals `_AUTONOMOUS_TRIGGERS` and stays an
enumerated allow-list on purpose: a structural test forbids deriving it, because
that would hand reach to the next autonomous trigger with nobody checking
dispatch can deliver it — precisely #2048's defect. `note_unreachable_pull_trigger`
is kept and still tested, against a synthetic narrowing.

The loop advance (#2523) and fan-out join share one `_terminal_side_effects`
shim off `spawn_task_terminal_event`, with separate guards so one raising cannot
cost the other its terminal.

Migration 0051 + the SQLite twin: `fan_out_task_id`, plus
`idx_executions_fan_out_status` — the join COUNTs non-terminal rows for one
batch on every fan-out terminal.

Refs #1081, #2048, #2391, #2523, ent#157, ent#329. #2392 gets worse from here:
`effect_guard` still fails open when the execution id is absent, and a fan-out
multiplies that by N.

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

`stt_available` was `bool(tts_service.is_available())` — a non-empty check on
the ElevenLabs key. ElevenLabs permissions are per endpoint, so a key granted
Text-to-Speech but not Speech-to-Text rendered a fully working-looking mic that
failed on every press with `401 missing_permissions`, while spoken replies
played normally on the same instance. Nothing in the UI or the admin panel could
say so: `key_configured` was honestly true.

New `services/stt_capability_service.py`: one provider probe per key — a
one-byte non-audio POST to `/v1/speech-to-text`, which authorises before it
validates, so 401/403 is `refused` (with the provider's status word), any other
definitive status is `capable`, and a transport error / 5xx is `unknown`. The
verdict is cached in Redis under a digest of the key (6h decided / 2min
unknown, per-process fallback when Redis is down), so a key change is a miss by
construction and the key resolver stays uncached across workers. Reads are
bounded (`WAIT_BUDGET_SECONDS`: a slow provider answers `unknown` now and the
probe fills the cache in the background) and fail soft: only a definitive
refusal hides the mic.

`client_portal.service._stt_ready` is now THE gate — the roster card, the
agent page and `transcribe_portal_audio` all resolve it, so the control a
client sees and the endpoint it calls still cannot disagree (#2212's rule). A
real `/stt` 401 stores `refused`, so the reported symptom heals the cache even
if the probe never ran.

`GET`/`PUT /api/settings/elevenlabs` carry `stt_capability`, `stt_detail` and
`stt_checked_at` beside `key_configured`; re-saving a key invalidates its row.
Settings → Voice renders "can transcribe" / "cannot transcribe — <reason>" /
"transcription not verified" next to the presence badge, via the pure
`utils/sttCapability.js` rule.

Tests execute every consumer of the verdict (classifier partition, cache
keying, bounded wait, roster threading, endpoint refusal, live-refusal
feedback, settings state) — none read source text.

Fixes #2695

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 log, both sides kept (dev's
2026-09-10 entries first, this branch's 2026-09-11 entry after).

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

The spec's own docstring says every box is taken from ONE render, but the
icon-button loop re-measured each button after `picker`/`send` were captured,
and skipped Send by coordinate (`box.x >= send.x`). The picker is a native
<select> sized by its widest option label, which re-measures ~150 ms after
paint when the web font lands (211 -> 199 px at 768 on a cold profile); a
Send read after that sits left of the earlier `send.x`, is mistaken for an
icon button, and fails 'an icon button sits right of the picker'. The race is
on dev too — instrumented: dev's picker drifts identically — this branch just
loses it more often (its unmocked requests shift the timing).

Wait for `document.fonts.ready` and for the picker to hold its box across two
consecutive reads before measuring anything, and skip Send by element
identity. 3/3 green locally at 375/768/1280 on this branch (was 2 of 5 failing
every run).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
`list_agent_schedules` and `get_agent_schedule` have returned
`validation_enabled`, `validation_prompt` and `validation_timeout_seconds`
since VALIDATE-001 landed, and neither write tool accepted them. Both
schemas are strict, so the fields could not be smuggled through either --
the post-execution validation hook could be observed over MCP but never
configured over it. The backend takes all three on both write paths
(`ScheduleCreate`, `ScheduleUpdateRequest`), so this is an MCP-layer
parity gap and needs no backend change.

Same shape as #85, which added `timeout_seconds` / `allowed_tools` /
`model` to these two tools; the `validation_*` trio was never added.

`validation_timeout_seconds` is bounded 30-600 in the Zod schema, so an
out-of-range value is refused by name rather than reaching the backend,
matching how `retry_delay_seconds` already handles the same range.

The `validation_enabled` description states what enabling it costs: one
extra execution on the same agent after each technically-successful run,
a `business_status = failed_validation` plus one operator alert on
FAIL/PARTIAL, and no retry -- retries key on technical failure, and
validation only runs after technical success (#1573).

Refs #2759
… hardening guide (#2691, #2692)

The first-run hardening card offered "Add a domain" with no statement of what
it buys or what has to be true first, and then showed a green tick over a value
nobody had checked. Walked live on a fresh droplet, nobody in the room could say
why an operator would add a domain, or whether the setting changed routing at
all.

What the setting actually does, now documented in the copy and in
requirements/infrastructure.md: it is the address Trinity hands out (Telegram,
WhatsApp and VoIP callbacks, Slack's OAuth return, public links, workspace and
file links), and on a provisioned host it authorises the web server in front to
obtain a certificate for that one name. It does not create a DNS record, and
Trinity issues no certificate itself.

The tick is now earned. Caddy's on-demand-TLS gate is called during a real
handshake for the saved name, which is proof of the whole chain an operator
cannot otherwise confirm from inside Trinity — DNS resolves, traffic arrives, a
certificate follows — and stays true behind Cloudflare's proxy, a load balancer
or a reserved IP, where comparing the name's DNS answer against this instance's
own address says the opposite. An authorised ask latches `<iso>|<host>`; the
reader compares that host to the one in force, so a stale row reads as
not-reached rather than showing a tick for a name nobody visited. Until it
flips, both the first-run step and Settings say "saved, waiting for the first
visit". Step completion is unchanged, so no established install re-opens the
overlay.

Only Caddy's own ask latches. The same route is reachable from the public
internet — Caddy proxies to the frontend and nginx forwards /api/ — and the
domain is published in every webhook URL, so an unguarded latch could be forged
with one curl. The guard fails closed: an unrecognised caller is still answered,
it just does not record anything.

Two live defects fixed alongside:

- The gate never IDNA-encoded. SNI is ASCII so Caddy asks about the A-label
  while an operator saves the name as they read it, so a domain with any
  non-ASCII character could never obtain a certificate — silently, forever, on
  every visitor's page load.
- The generic settings PUT accepted any string for this key. `htp://typo.com`
  stored cleanly and the back-fill immediately re-pointed every Telegram webhook
  and WhatsApp binding at an address that answers nothing. A value that cannot
  take effect is now refused before the write, with a named error. Plain http://
  stays legal — the managed fleet advertises exactly that behind a tunnel.

Copy: benefit and prerequisite are readable without opening the disclosure, the
sentence claiming the proxy "picks up the name" is gone (it never did), the
webhook side effect is stated where the setting is owned, and the tunnel is
"optional but recommended" rather than "dismissing it here is a fine answer" —
which contradicted the guide it now links to. Settings → General carries the
same explanation from the same module, and the http-only regex no longer lets
`http://` complete the step it exists to move people off.

tunnel, or a private network instead, each stage with a verification step, the
marketplace-specific bits the generic public-access page does not cover, and
Tailscale framed honestly (operator access, no Trinity integration, not a
substitute for the tunnel). Linked from the deploy index, DEPLOYMENT.md and the
marketplace listing.

Fixes #2691
Fixes #2692

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

The guide listed Slack events among the integrations a VPN breaks. #2380's own
decision table says the opposite, and the code agrees: the live transport is
adapters/transports/slack_socket.py, an outbound WebSocket that needs no public
URL. Only the one-time OAuth install callback does.

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

The page was hand-written and followed the feature template. Pages under
guides/deploying/ that cover an operation follow the operational one instead:
When to Run This → Pre-flight → Procedure → Verify → Recovery, with the
verification table and the reusable compose-restart rule stated verbatim rather
than paraphrased.

Also per the skill's maintenance trigger, the FAQ pages that neighbour this
topic gain the questions a user would actually type — how to lock down a
marketplace instance, what 'waiting for the first visit' means, and why a
domain can show a certificate error while Trinity reports it as set — and
faq/README.md is regenerated from the pages' own headings rather than
hand-edited. That regeneration also corrects one pre-existing anchor that had
drifted from the heading it points at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtMP6kk2GP9oKaEzGwA8H3
…this image has (#2692)

There is no one-click to lean on, and that is now a checked fact rather than an
assumption: DigitalOcean's marketplace has no Tailscale app (its application
image list has none), Vultr's API lists none either, and both apply marketplace
images at create time only — so nothing can be added to a droplet that is
already running. The improvement available is a single non-interactive command
with an auth key, which is now written out, along with the two traps that bite
later: node keys expire after 180 days by default, which silently drops the
machine off the tailnet after port 22 is closed, and --ssh must be opted into
per device before that happens.

It also records a gap rather than papering over it. The provisioned web server
holds certificates for the droplet's public IP and the saved domain, and the
gate authorises only that one name, so https://<tailnet-ip> gets no certificate
— and the public domain still resolves to the address the operator is about to
close off. The private-network path therefore covers shell and agent access
today; reaching the web UI over a tailnet needs a recipe nobody has verified on
a live droplet yet, and the page says so instead of implying it works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtMP6kk2GP9oKaEzGwA8H3
…hat works (#2692)

The private-network path left the operator with shell access and no way into
the web UI, and the page did not explain why. It now does: the web server picks
a site by the hostname in the request and holds exactly two — the droplet's
public IP and the saved domain — so a tailnet address matches neither, Trinity
refuses the certificate request (the same refusal that stops the instance being
an open certificate requester), and no authority could issue for carrier-grade
NAT space anyway. The saved domain does not help either, since public DNS
resolves it to the address the operator just closed off.

The supported answer today is an SSH tunnel to the local frontend port, which
needs no certificate and is unaffected by the container firewall — the same
mechanism the ops agent already ships. The page also explains why browsing the
container port directly does not work, since that is the next thing anyone
tries: container ports are dropped from off-box, and 80/443 work in the public
case only because the web server in front is a host process.

Serving a private address natively is named as a product improvement rather
than dressed up as configuration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtMP6kk2GP9oKaEzGwA8H3
Every site in the provisioned Caddyfile is matched by hostname, and there are
two: the instance's public IP and the domain an admin saved. A tailnet address
matches neither, the certificate gate refuses it — correctly, that refusal is
what stops the instance requesting certificates for any name pointed at it —
and carrier-grade NAT space cannot be validated by a public CA in any case. So
an operator who moved onto a VPN and closed 80/443 was left with shell access
and no URL to open. The saved domain does not help either: public DNS resolves
it to the address they just closed off.

PRIVATE_NETWORK_CIDRS (space-separated, empty by default) renders an
`@private remote_ip` matcher into the http:// site that serves those sources
directly instead of redirecting them. Plain HTTP costs nothing there — the VPN
already encrypts the transport, which is the posture the managed fleet runs and
that DEPLOYMENT.md already calls finished rather than a compromise. Unset
behaves exactly as before.

Source address, never the Host header. A header is supplied by the caller, so
matching on it would let anyone on the internet send `Host: 100.64.0.1` to port
80 and be served the login page in cleartext, having bypassed the HTTPS
redirect — a worse hole than the one being fixed. A source address cannot be
forged into a completed TCP handshake.

Three more properties, each because the failure mode is a box you cannot reach:
0.0.0.0/0 and ::/0 are refused; anything that is not an address range is dropped
with a warning rather than rendered; and the generated file is validated before
the reload, since an invalid config stops the web server outright.

Applying a change is `--caddy-only`, a phase that re-renders the config alone.
The site phase also rewrites FRONTEND_URL and TRINITY_INSTALL_SOURCE, so
re-running it to pick up one variable would silently re-stamp a marketplace
droplet's provenance as a doc-driven install.

Beyond the two tickets this branch carries, added at the user's request after
the gap surfaced while documenting the VPN path.

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

The FAQ answer was written while the fix did not exist and still told operators
to tunnel over SSH because Trinity could not serve a private address. It can
now. The install-provenance flow's Caddyfile walkthrough and the TLS decision
table in DEPLOYMENT.md gain the same setting, with the trade stated where an
operator picks the shape: a private network costs every inbound channel, since
those need a public URL to call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtMP6kk2GP9oKaEzGwA8H3
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Le7kBh9PbTNGHPc5bxm3qz
Three conflicts, resolved on their merits rather than by side:

* `docs/memory/learnings.md` — both sides appended a new entry at the top of a
  ledger. Kept BOTH; this is an append-only file and picking a side would delete
  someone's lesson.

* `client_portal/service.py` — `_row_to_card` gained `stt_ready` here and
  `can_manage_canvases` on dev, in the same signature and the same roster call.
  Kept both parameters and both kwargs; dev's `may_manage_canvases` comment is
  carried verbatim. Both call sites (the roster and the #2160 single-agent
  lookup) now pass both.

* `workspace-model-choice.spec.js` — took dev's version WHOLESALE, which makes
  this branch's `06b86be9` a no-op. Both commits fix the same flake: this branch
  waited for `document.fonts.ready` and the picker's width to settle, then
  skipped Send by element identity; dev's #2705 instead takes every box in ONE
  `page.evaluate` and emulates reduced motion. Dev's is strictly stronger — when
  all boxes come from one frame, a font re-measure cannot invalidate a relative
  assertion, so the coordinate Send-skip it keeps is sound again and no wait is
  needed at all. Two fixes for one race is worse than either; this one loses.

The two `--check` whitespace warnings (`hardeningGuide.js`, `test_voice_auth.py`)
are pre-existing on origin/dev and arrive with the merge — verified byte-for-byte
against `origin/dev` — so they are deliberately left alone rather than swept into
an unrelated branch.

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

The feature-flags handler now reads settings_service.is_public_url_reached(),
and this hand-rolled stub AttributeErrored on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Le7kBh9PbTNGHPc5bxm3qz
- Settings → General: the "waiting for the first visit" state now shows
  only on provisioned installs (hardening_guide_eligible). Only the
  provisioned Caddyfile's TLS ask writes the latch, so own-proxy, tunnel
  and tailnet installs would have waited forever; they keep the saved tick.
- start.sh: render the Caddyfile to Caddyfile.new, validate that, and move
  it over the live file only on success. An invalid file on disk with the
  unit enabled took the site down on the next restart. New test proves the
  previous file survives a failed validate.
- _is_caddy_ask: docstring and PROV-016 now say the stamp is unforgeable
  from the public front door, not from inside the Docker network. A
  loopback source check cannot close that: Caddy reaches the published
  port from a bridge gateway. Advisory stamp, accepted.
- test_2691: monkeypatch ss.db.get_setting_value instead of assigning it
  on the shared db instance (reload did not undo it).
- settings_service: the reached stamp is never cleared; comment said it was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Le7kBh9PbTNGHPc5bxm3qz
Keep both the ent#498 deliver_to_workspace_email fields and the #2759
validation_* fields in schedules.ts / types.ts. The description assertion
in schedules.validation.test.ts now reads the zod .description accessor,
because dev is on zod 4, which no longer serialises it into JSON.stringify.
…en it cannot be asked (#2695)

Review finding on the AC the issue underlines ("must not reintroduce
cross-worker staleness"): `read_cached` fell through to the per-process
`_local` on a Redis MISS, not only on a Redis error, while `store` wrote
`_local` unconditionally and `invalidate` popped it in the calling worker
only. Sequence: admin re-saves the key on worker A → A pops its local,
deletes the Redis row, re-probes; if that probe lands `unknown` (2-minute
row) or times out, the row expires and worker B serves its stale `refused`
from `_local` for the rest of the 6 h — mic withheld on B, offered on A,
for the same customer.

`read_cached` now treats Redis as authoritative whenever it ANSWERS: a hit
is returned, a miss returns None AND evicts this worker's local copy (a
corrupt row is a miss too, never a fall-through); `_local` is read only
when there is no client or the read raised. `store` still writes both, so
the outage fallback stays warm.

Tests: the Redis-present branch had zero executing coverage (the autouse
fixture stubs `_redis` to None). A dict-backed `_FakeRedis` shared by two
module instances (= two uvicorn workers, each with its own `_local`) now
pins: TTLs reach the wire and rows decode; an invalidate on A is honoured
by B and B's local copy is evicted; the review's 6 h sequence (unknown row
expires) is a miss; the local copy is used only while the read raises; a
corrupt row is a miss. 4 of the 5 are red against the pre-fix service.

Riders from the same review:
- `PROBE_TIMEOUT_SECONDS` comment said it sits BELOW `WAIT_BUDGET`; it is
  8.0 vs 4.0 and deliberately above — the reader stops at 4 s, the probe
  runs on to fill the cache. Comment now says why.
- "the Workspace mic is hidden" (service log, docstring, Settings hint,
  backend.md) is browser-dependent: `resolveMicMode` falls back to Web
  Speech when `serverStt` is false, so a refused key disables server-side
  dictation and hides the mic only where the browser has no engine.
- `stt_capability_service.py` gets its own catalog entry in backend.md
  instead of riding the `tts_service.py` line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolves conflicts with #2679 (#2670) by adopting its shipped
GET /api/agents/{name}/fan-out/{fan_out_id} and dropping this branch's
duplicate route, get_status and batch_belongs_to. The GET gains an
additive task_id from fan_out_task_id.

Review fixes (#2524, merge-train 2026-09-09/10):
- Create each subtask row at slot grant inside the max_concurrency
  semaphore instead of up front. Pre-created RUNNING rows waiting behind
  the semaphore were bulk-FAILed by the #106 no-session sweep; QUEUED
  rows would be claimed by claim_next_queued while _dispatch_all also
  dispatched them. The sync caller now waits for the shielded dispatch,
  then for queued rows to reach a terminal.
- Default wait budget covers ceil(N / min(max_concurrency,
  max_parallel_tasks)) waves instead of one subtask's bound.
- Snapshot subscription_id on fan-out rows (SUB-004).
- Carry execute_task's error_code into the sync aggregate.
- _fail_subtask gates side effects on the CAS and emits through
  spawn_task_terminal_event.
- A failed fan-out DB poll read no longer escapes the wait.
- MCP fan_out: async_mode param, corrected deadline wording.
- Docs: architecture/execution.md, api-endpoints.md,
  requirements/scheduling.md 37.4, fan-out flow.
- Real-schema test for the fan-out SQL.

Renumber the Alembic revision to 0062_execution_fan_out_task_id off
0061_execution_open_canvas (single head).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ver with their own keys (#2349)

The journey harness for J10, "my agents can call each other, and I can see
what they said". Two ephemeral agents; every call is made the way a real
playbook makes it — through the MCP server with the caller's own agent-scoped
key, read from its container because no API returns it — so the harness crosses
the same checkAgentAccess gate a real agent does. Credential-free on every PR:
the permitted call lands on the callee attributed to the caller with a
collaboration activity on the caller (IA-01, AC-01); a call with no edge is
refused with a reason naming both agents and nothing runs (P-02); a stopped
callee answers 503 "Agent is not running" within seconds and leaves no row
(IA-03); a fan-out is refused past 50 at the tool and the backend and lands as
one batch on one agent (IA-02); a loop stops at its budget; deleting the callee
leaves no dangling edge (L-03). On a keyed stack the callee's real answer is
read back from its execution record and every fan-out subtask completes.

Three strict=True xfails carry the findings, each with its own issue:
no chain-depth guard on agent-to-agent chat chains (#2806); a refused call is
audited as a successful tool call (#2807); run_agent_loop never runs the
permission gate (Abilityai/trinity-enterprise#628). The backend routes' owner-
equivalence for agent keys is a ruling, Abilityai/trinity-enterprise#629, with
no public reproducer.

Journey conftest gains the primitives: create_agent_and_wait (lifted out of
journey_agent), agent_mcp_key (Docker SDK, by the trinity.agent-name label,
extracting exactly one variable), McpSession (httpx JSON-RPC over the server's
streamable-HTTP transport, reprs never carry headers), permission-edge and
read-back helpers, and a module-scoped concurrently provisioned agent pair.
Catalog: J10 flips to tier journey-smoke (the smoke lane runs the directory
wholesale) with built: false, J03-style. IA-03's A2A sentence is corrected —
the inbound route never answers 409. Registry entry, regenerated JOURNEYS.md,
Testing pointers in four feature flows, two learnings entries, and the
diff-scoped CSO report.

Journey Impact: extends: J10

Fixes #2349

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ot a bare sys.modules write (#2695)

Both red checks on this PR were the same two lines.

`_load_worker` builds a second instance of the module to stand in for a second
uvicorn worker. The synthetic name has to be in `sys.modules` while
`exec_module` runs — `@dataclass` resolves the class's module BY NAME during the
module body — and that was done with a bare assignment plus a `finally` pop:

    sys.modules[name] = mod
    try:
        spec.loader.exec_module(mod)
    finally:
        sys.modules.pop(name, None)

`tests/lint_sys_modules.py` exists to stop exactly that, because one leaked
entry is a module every later test in the session imports instead of the real
one. It failed twice over: the `lint (sys.modules pollution check)` job
directly, and `regression diff`, whose sole new failure was the pytest mirror of
the same rule (`test_lint_sys_modules::test_committed_baseline_matches_current_repo_state`).

`monkeypatch.setitem(sys.modules, name, mod)` is the fix the lint names, and it
is available here because the only caller is a fixture. It is also stronger than
the hand-rolled pair: pytest removes the entry at teardown even if `exec_module`
raises before the `finally` is reached.

The entry now lives until teardown instead of being popped immediately. Harmless
and deliberate — the name is unique per fixture instance and nothing after
`exec_module` resolves it.

Not a baseline regeneration: the violations are gone, so the committed baseline
(0 for this file) is satisfied as written.

Verified: `python tests/lint_sys_modules.py` → "no new violations"; the two
affected test files pass under all three CI seeds (12345 / 67890 / 99999), 58
tests each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…est boundary (#2796)

`model` arrived as an unvalidated free string on all three interactive entry
points (ParallelTaskRequest, ChatMessageRequest, SessionMessageRequest) and
reached the runtime as a `--model` argv element. A value that cannot name a
model therefore died agent-side as `unrecognized_model` — exit code 1, no
output, and no field named anywhere in the message. Shape-check it at the
boundary and answer 422 naming the offending value instead.

The reported mechanism does not reproduce: Trinity does not pass the caller's
role into the model slot. With the body the Chat tab sends, the slot is None,
and the only "admin" in scope is the `source_user_email` fallback for an admin
account with no email, which lands in its own column. The value reaches `model`
only when the request body carries it.

The accepted families are mirrored from `model_context._FAMILY_PREFIX_WINDOWS`,
which is already the platform's answer to "is this id one we recognise?" — so
Claude, Gemini and Codex (`gpt-*`, `codex`) agents all keep dispatching, bare
aliases keep working, and a newly supported runtime is one edit away. A guard
test fails the build if the two lists drift.

Deliberately NOT the Workspace's closed allow-list: that set is 3 of the 11 ids
in `model_catalog.py`, and the operator picker documents free-text passthrough
(including the `[1m]` extended-context suffix), so a closed set would refuse ids
this repo ships on the surface they were written for. A leading `-` matches no
family, so argv smuggling closes in passing.

`run_resumable_turn` splats one value into `execute_task` twice (initial
attempt and cold retry); validating the router's single source covers both, and
a test asserts the second call is not missed.
… the SSE parser skips bad frames (#2349)

Review of #2809, findings I1 and I3. `clear_edges` now asserts every revoke
and the empty edge set afterwards, so a leaked edge blames the harness reset
rather than the product in a later permission test — the property the
module-scoped pair's order-independence rests on. `McpSession._post` skips a
`data:` frame that is not JSON or not an object instead of raising from
inside the transport, so a malformed frame surfaces as the caller's named
failure. No assertion changed; J10 rerun on a live stack: 9 passed, 3 xfailed.

I2 (the model gate reads the pytest host's environment, so keyed journeys
skip invisibly on a subscription-authenticated stack) predates this PR — J03
carries the same gate — and is filed as #2812.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…py-and-hardening-docs

# Conflicts:
#	docs/user-docs/faq/README.md
Resolves the append-only ledger conflict in docs/memory/learnings.md by
keeping both sides in landing order: dev's three 2026-09-15 entries
(#2795, #2794 ×2) first, then this branch's two (#2349). tests/registry.json
auto-merged; no migration files were incoming.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The tick's client half is correctly wired, but the store action that
parses it had no assertion at all: deleting both
`stores/sessions.js:164` and `:195` left the full 2930-test frontend
suite green, with `publicUrlReached` permanently false, the
`https-domain-reached` copy dead, and the tick this PR exists to earn
never appearing.

Extends the existing `describe('the store fails closed')` harness, which
already pins three sibling flags on this exact code path, with the
fourth field — plus one sequenced test the other four cannot replace:
they each start from a fresh store where every closed value equals the
state default, so they pass with the `catch` block's resets deleted and
cannot tell a reset from an initial value. Earning the tick and then
failing a forced re-read is the only shape that pins the fail-closed
branch itself, and it is the real path — `FirstRunOverlay.vue:378` and
the three `Settings.vue` save handlers all re-read with `force`, which
is when a just-saved domain's tick resolves.

Both mutations now caught (drop the success assignment → 2 red; drop the
catch reset → 1 red); 130 files / 2931 tests pass.

The overlay's own binding (`FirstRunOverlay.vue:217`) is deliberately
left uncovered: `vitest.config.js` is `environment: 'node'` with no DOM,
so driving it needs a mount harness — an author decision, not a
mechanical fix. A `toContain` pin over the SFC text would only restate
the source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_row_to_card`'s new `stt_ready` parameter carried `= None`, and an
omitted value resolves to the PRE-FIX presence behaviour
(`stt_ready if stt_ready is not None else True`). The guard that exists
to forbid exactly that — `test_row_to_cards_capability_arguments_have_no_defaults`,
whose own docstring says "a default would let a call site keep compiling
while silently serving the wrong card" — pinned only `is_platform`,
`runtime` and `model_context`, so the fourth capability argument slipped
past it.

Not a live bug: both production call sites (`get_agent_card`,
`get_roster`) pass it explicitly. But a third call site added later
would silently un-fix #2695 with the whole suite green, which is the
regression the guard was written to make impossible.

Drops the default, adds `stt_ready` to the guard's tuple, and passes the
neutral `True` at the four older call sites that relied on it — `True`
reproduces exactly what the bit meant before the capability probe
existed, so no assertion changes meaning.

Also corrects the field docstring in `client_portal/models.py`, which
still defined `stt_available` as "an ElevenLabs key resolves" — the
pre-#2695 gate this PR replaced.

Verified: reintroducing the default fails the guard; reverting the card
gate to presence-only fails 2 tests in the PR's own suite; 2081 related
unit tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

5 participants