feat(voice): voice replies v2 — per-message capability, agent-level config, runtime ElevenLabs key (trinity-enterprise#117) - #1549
Conversation
…onfig, runtime ElevenLabs key (trinity-enterprise#117) Rework outbound voice replies (ElevenLabs TTS) along three lines: 1. Voice is a per-message capability, not a hard rule. Channel replies are text by default; a reply becomes a voice note only when the agent explicitly calls the new send_voice_reply MCP tool during the turn. The backend resolves the channel destination from the execution (new schedule_executions.source_channel* columns), gates on TTS availability + agent enable + per-channel flag, wraps delivery in effect_guard (#1084), and reuses each channel's send primitive. The always-voice adapter path (_maybe_send_voice) is removed. 2. Voice config moves to agent Settings (enable + voice selection, one place); channel panels keep only a per-channel on/off flag (agent_ownership.tts_voice_{telegram,slack,whatsapp}_enabled, default ON). GET/PUT /api/agents/{name}/voice-replies extended with channels + effective voice. 3. ElevenLabs API key + platform default voice are runtime-configurable in admin Settings (GET/PUT /api/settings/elevenlabs), key stored AES-256-GCM encrypted and surfaced as configured:bool only; resolved via settings_service.get_elevenlabs_api_key() (stored setting -> env), no restart. New tts_available feature flag. Dual-track migrations (SQLite + Alembic 0015/0016). New send_voice_reply MCP tool (voice.ts) + POST /api/agents/{name}/voice-reply + voice_reply_service. Capability advertised in the platform prompt only when voice is enabled for the agent and the current channel's flag is on. OSS-core. Related to trinity-enterprise#117 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…_execution (trinity-enterprise#117) DatabaseManager.create_task_execution (the `db` facade wrapper) didn't forward the new source_channel / source_channel_chat_id / source_channel_thread kwargs to ScheduleOperations, so a channel-triggered task raised "unexpected keyword argument 'source_channel'" → 500. Add the passthrough. Related to trinity-enterprise#117 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s (trinity-enterprise#117) Channel turns run headless with a restricted `--allowedTools` (default WebSearch,WebFetch), which blocks every MCP tool — so an agent could not call send_voice_reply even when the capability was advertised in its prompt. When the voice capability is advertised for a channel turn, also append `mcp__trinity__send_voice_reply` to the channel allowed-tools list so the agent can actually act on it. Related to trinity-enterprise#117 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ies-v2 # Conflicts: # src/backend/db/migrations.py
|
Resolve by running |
|
Please drop the submodule change so dev stays on git checkout origin/dev -- src/backend/enterprise # or: cd src/backend/enterprise && git checkout f5d69be && cd - && git add src/backend/enterprise
git commit -m 'chore: drop stray enterprise submodule bump (keep dev on-trunk f5d69be)'Everything else in the PR is unaffected. (Voice mode will land forward via its own enterprise-main PR when it's release-ready.) |
- keep both SQLite migration entries (ephemeral first — matches dev apply order) - re-chain Alembic: tts_channel_flags -> 0017 (after 0016_agent_ownership_ephemeral), source_channel -> 0018 - resolve .claude gitlink to dev's 2228cfc (contains this branch's bcc78be) - drop stray src/backend/enterprise bump (630cca9, off-trunk) — keep dev's on-trunk f5d69be per PR hold
|
✅ Hold resolved in the dev-merge commit Same commit also re-chains the Alembic revisions after #1580 landed |
…+ down_revision onto 0016_agent_ownership_ephemeral The dev-merge commit renamed the files but the id/down_revision edits were left unstaged, leaving two heads off 0015 (pg-migrations 'Multiple head revisions' failure).
vybe
left a comment
There was a problem hiding this comment.
Validated via /validate-pr: implements trinity-enterprise#117 (P1 feature) with tests (test_117_voice_replies_v2.py + per-channel voice-out suites), requirements (public-access.md) + architecture.md updated, security greps clean, no new env vars. Hold resolved: stray enterprise gitlink dropped (submodule diff vs dev empty, dev stays on-trunk f5d69be); .claude resolves to dev's pointer. Alembic re-chained linear 0015→0016(ephemeral)→0017(tts)→0018(source_channel) — pg-migrations green on real PostgreSQL; full pytest matrix green. Approving.
* chore(.claude): bump dev-methodology submodule — Product Quality Bar
Points .claude at trinity-dev 57c8b5c: adds a canonical Product Quality Bar
section to DEVELOPMENT_WORKFLOW.md (six adoption/ease-of-use principles) and
hooks it into the dev pipeline — /create-issue (acceptance criteria),
/autoplan (scope calibration → taste decisions), /implement (build-time
checklist), and /review (new 4.15 catch-in-diff check).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(announcements): v0.8.0 announcement record (#1548)
* chore(.claude): bump dev-methodology submodule — Product Quality Bar
Points .claude at trinity-dev 57c8b5c: adds a canonical Product Quality Bar
section to DEVELOPMENT_WORKFLOW.md (six adoption/ease-of-use principles) and
hooks it into the dev pipeline — /create-issue (acceptance criteria),
/autoplan (scope calibration → taste decisions), /implement (build-time
checklist), and /review (new 4.15 catch-in-diff check).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(announcements): v0.8.0 release announcement record (all channels)
---------
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(avatar): prevent thinking-token truncation dropping the color-scheme block
Avatar refinement runs each identity prompt through GEMINI_TEXT_MODEL to append
a fixed technical block (background #1a1f2e/#111827, indigo rim #6366f1,
head-and-shoulders framing, 85mm lens, 5600K key) — the sole source of avatar
color scheme and cross-generation consistency.
The refinement call capped maxOutputTokens at 512. Since #1130 the default model
is gemini-3.5-flash, a *thinking* model whose reasoning phase draws from the
output budget. Reasoning consumed ~491 of 512 tokens, truncating the refined
prompt (finishReason=MAX_TOKENS) to a ~90-char fragment that dropped the entire
technical block. Avatars were then generated from a bare subject description —
wildly inconsistent and off-palette.
Fix in _call_gemini_text:
- Disable thinking (thinkingConfig.thinkingBudget=0) — refinement is a
deterministic rewrite that needs no chain-of-thought.
- Raise maxOutputTokens 512 -> 4096 as headroom even when thinking stays on.
- Retry once WITHOUT thinkingConfig on HTTP 400: a thinking-mandatory model
(e.g. gemini-2.5-pro) rejects budget=0, and refine_prompt silently falls back
to the raw prompt on error, so an unhandled 400 would re-break avatars.
Verified end-to-end against the live model: all 6 technical-block tokens now
survive refinement (was 0/6). Adds 3 regression tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(channels): count inbound client messages on the Sharing-tab roster (#1533) (#1554)
* fix(channels): count inbound client messages on the Sharing-tab roster (#1533)
The roster showed `message_count = 0` for every external client, `last_active`
was frozen at `/login` time, and clients who never ran `/login` never appeared
at all. All three share one cause: `get_or_create_chat_link` and
`increment_message_count` (Telegram + WhatsApp) had zero callers outside the
`database.py` facade. The shared inbound path writes `public_chat_sessions`, a
different table, so `*_chat_links` rows were only ever created by
`set_*_verified_email` — the `/login` flow.
Note: the issue's Context paragraph states that `last_active` "is touched on
inbound traffic via `get_or_create_chat_link`". It is not — that function was
dead, and returns early for an existing row without writing anything.
Revive the write path behind a default-no-op `ChannelAdapter.record_inbound_activity`
hook, called once per delivered DM from `ChannelMessageRouter._handle_message_inner`
at step 5c: after the access gate, so an unauthenticated stranger who messages the
bot cannot create unbounded chat-link rows, and skipped for groups, since a chat
link is keyed by (binding, user) and counting group traffic would list members who
never DM'd the agent. Telegram and WhatsApp override it; Slack and VoIP inherit
the no-op (Invariant #9). The call is best-effort — a counter write never blocks
message processing.
Replace `get_or_create` + `increment` with one atomic `INSERT … ON CONFLICT DO
UPDATE` (`record_inbound`), which removes a cross-worker SELECT-then-INSERT race
under `--workers 2`, halves the writes per message, and refreshes a stale display
name via `COALESCE(excluded, existing)`. Since `increment_message_count` was
`last_active`'s only live writer, one call fixes the count and the timestamp
together. Delete the now-dead methods, their facade delegations, and the
`_row_to_chat_link` helper they alone called.
Historical counts are not backfilled; the roster's "Messages" header says so.
Tests: dual-backend (SQLite + PostgreSQL) roster read-back 0->1->2, `last_active`
advance, username backfill, and `/login` interplay through the real `db_backend`
harness; the real `_handle_message_inner` for the DM, group, access-denied and
counter-failure paths; and the adapter override bodies executed for real —
mutation-verified, a typo'd metadata key turns the suite red. A facade-delegation
guard covers the wholesale-mock blindness recorded in docs/memory/learnings.md.
Verified against a live instance: real Telegram webhook payloads through the real
transport drive the roster 0->1->2; a group message reaches the router and is not
counted; an access-denied message creates no row.
Follow-up #1552 records the read-time-derivation reframe (deriving the roster from
public_chat_messages) that this tactical fix deliberately defers.
Fixes #1533
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(channels): use an example.com placeholder email in the #1533 access-denied case
The public-repo rule in CLAUDE.md calls for `user@example.com`-style
placeholders. `a@b.com` was copied from the neighbouring access-gate test and
was also inconsistent with the rest of this file, which already uses
`alice@example.com`. No behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(ci): auto-trigger frontend-e2e (nightly + frontend paths), keep ui opt-in (#1526) (#1551)
The frontend-e2e Playwright suite ran ONLY on `ui`-labeled PRs, so most PRs never
triggered it and the suite rotted silently — specs broke against structural/UX
changes and sat red on `dev` for weeks (four documented instances: #1134, #1508,
#1378). The gate itself was the root cause.
Add automatic triggers so a red suite is always visible:
- Nightly `schedule` (07:00 UTC) on `dev` — the durable signal. A red nightly
opens/updates a single tracking issue (new `report` job, label
`frontend-e2e-nightly`), à la #1185; a green nightly closes it.
- Auto-run on any PR that touches `src/frontend/**` (dependency-free `changes`
job via `gh pr diff`), so frontend authors see their own breakage.
- The `ui` label still works as a manual opt-in on non-frontend PRs and to force a
run (preserved for the heavier @visual/@interactive tiers, #596).
- `workflow_dispatch` for on-demand runs.
DECISION (AC): advisory, NOT a required merge gate. The recurring failure class is
modal/overlay flake on a fresh zero-user-agent stack, so a hard gate like #715's
unit gate needs a flake budget first (#596). Recorded in the workflow header.
Per-job least-privilege permissions; concurrency keyed per-PR (cancel) vs per-ref
(schedule, no cancel). Only @smoke runs in CI, unchanged.
Related to #1526
* feat(mcp): make per-agent MCP connector OSS-core (trinity-enterprise#118 Part A) (#1555)
* feat(mcp): make per-agent MCP connector OSS-core (trinity-enterprise#118 Part A)
Relocate the per-agent MCP connector (ent#46/#55/#51, shipped v0.8.0 entitlement-
gated as `mcp_connector`) from the private enterprise submodule into OSS core, and
drop the entitlement gate front and back. Decision (Eugene, 2026-07-09): sharing
agents via individual MCP connectors is a platform-adoption surface, not a paid
module.
Backend (router → service → db, Invariants #1/#2/#14):
- routers/connector.py — /api/agents/{name}/connector* (config, mint/regenerate/
revoke key, playbooks), mounted unconditionally in main.py; no requires_entitlement.
- services/connector_service.py — snippet builder + playbook resolution.
- db/connector.py (ConnectorOperations) — config CRUD + scoped-key mint/revoke
into mcp_api_keys (scope='connector'); facade delegators on database.py.
- models.py — ConnectorConfigUpdate/Status/KeySecret/Playbook/ClientSnippet.
Schema: enterprise_connectors table re-homed onto OSS dual-track (db/tables.py,
db/schema.py, db/migrations.py:enterprise_connectors_table + Alembic
0015_enterprise_connectors). Name kept so existing enterprise installs adopt their
data with zero migration (CREATE TABLE IF NOT EXISTS, no duplicate-table drift).
Delete/rename cascade via an enterprise_connectors AGENT_REF.
Frontend: ConnectorChannelPanel un-gated in SharingPanel.vue (dropped the
isEntitled('mcp_connector') v-if + the now-unused enterprise store wiring).
The MCP proxy tools (connector.ts), the connector-scope auth fence
(dependencies.py), and ExposedToolsPanel.vue were already OSS and edition-agnostic.
`mcp_connector` is removed from the entitlement registry by the paired enterprise
PR (deletes register_module).
Docs: requirements mcp.md §7.5 + feature-flows/mcp-connector.md + architecture/index.
Tests: tests/unit/test_118_mcp_connector_oss.py (11) — service helpers, config CRUD,
key mint/regenerate/revoke, scope='connector' validate contract.
Part B (email-auth onboarding, #848) deferred pending design sign-off.
Related to trinity-enterprise#118
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(submodule): bump enterprise to post-#121 so the connector isn't double-mounted (#118)
#1555 makes the per-agent MCP connector OSS-core. The enterprise submodule was
pinned at 630cca9e, which still ships backend/mcp_connector/ and registers it via
register_enterprise() — so an enterprise build would double-mount the connector
router alongside the new OSS-core one. Bump the pin to f5d69be6 (enterprise main
post trinity-enterprise#121), where the private module is removed.
This forward-integrates two already-on-enterprise-main commits into the pin:
- trinity-enterprise#121 — removes backend/mcp_connector/ (the intended pair)
- trinity-enterprise#120 — ENTERPRISE_LOCAL_DEV docs (docs only)
- trinity-enterprise#106 — client-portal umbrella (already on enterprise main)
OSS-only CI is unaffected (submodule is update=none / "boots without enterprise").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
* docs(enterprise): explain the private feature catalog + runtime verification
ENTERPRISE.md documents the open-core seam mechanism (correct) but never
said WHERE the feature-level OSS/enterprise split lives or WHY it isn't
here. Add a short "Why there's no feature catalog here" note: the standing
rule trinity-enterprise#45 (enforced by enterprise-docs-guard.yml) keeps
the paid-feature catalog private, and entitled customers find it in the
private enterprise repo. Point readers at the runtime source of truth for
"what's enabled on this instance" (GET /api/version + feature-flags).
Mechanism-only, no named features — enterprise-docs-guard grep verified green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(announcements): webinar video announcement record 2026-07-10
Record of the "sovereign, isolated AI agents per client" webinar
video announcement (https://youtu.be/8w98dA6hDew) sent via /announce
to Discord, Slack, Telegram, Twitter/X (trinity + default), and
GitHub Discussions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(deps-dev): bump @types/node (#1531)
Bumps the patch-and-minor group in /src/mcp-server with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).
Updates `@types/node` from 26.1.0 to 26.1.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)
---
updated-dependencies:
- dependency-name: "@types/node"
dependency-version: 26.1.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: patch-and-minor
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* fix(reliability): clear per-agent Redis runtime state across the agent lifecycle (#1560) (#1568)
* fix(reliability): clear per-agent Redis runtime state across the agent lifecycle (#1560)
`agent:circuit:{name}` (transport breaker) is keyed by agent NAME, not container
identity, and carries no TTL. Nothing in the lifecycle cleared it, so a container
replaced under the same name inherited its predecessor's `dormant` verdict and
fast-failed every execution with "Agent circuit breaker open — agent is
unhealthy" — without the backend ever contacting the agent.
Adds `services/agent_runtime_state.py` as the single enumeration point for every
name-keyed per-agent Redis keyspace (the Redis-side twin of the `AGENT_REFS`
registry in `db/agent_cleanup.py`), with two entry points whose blast radii
differ by whether a container is running:
clear_agent_breakers heartbeat + transport circuit + dispatch breaker
(safe on a live container)
clear_agent_runtime_state the above + execution slots
(teardown paths only — force_clear_slots would drop
capacity accounting for an in-flight #1083 execution)
Wired into six lifecycle points. The issue's acceptance criteria named delete,
rename and create; none is the reachable path:
* create is unreachable — `is_agent_name_reserved` sees soft-deleted rows and
409s, locking the name for the whole retention window;
* the name only unlocks at the retention purge, which cleared no Redis state;
* the reachable path is `start_agent_internal`, whose `needs_recreation` branch
replaces the container on any config drift (subscription switch, resource
change, auth-token rotation), so one fleet-wide rotation resurrects every
stale verdict.
So start/recreate, purge, and the `trinity-system` bootstrap were added beyond the
written criteria. The start-path clear runs before the recreate, since
`containers_run(detach=True)` brings the replacement up, and is guarded on
`needs_recreation or not was_already_running` so a no-op start cannot reset a
breaker protecting a wedged agent.
Tests: a bidirectional parity guard fails CI when a new `agent:*` keyspace ships
unregistered; wiring tests pin all six call sites and that lifecycle.py never
clears slots; an integration test exercises the real Lua (fakeredis has no
EVALSHA) plus an opt-in leg driving the full recreate path over HTTP. Both guards
are mutation-tested.
Complementary to #1561, which removes the source of breaker poisoning; this
removes the inheritance. Both are needed.
Fixes #1560
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(1560): stop bare sys.modules mutations in the new test files (#762)
The `lint (sys.modules pollution check)` CI gate flagged 7 new violations.
They were real, not a lint technicality: the integration test replaced
`sys.modules["services"]` with a bare stub module and never restored it,
so every later test in the same session that imported the real `services`
package would have seen the stub — the exact cross-file pollution class
#762 introduced this gate for.
- Unit files: drop the `sys.modules[mod_name] = module` registration in
`_load` entirely. `agent_runtime_state.py` is a stdlib-only leaf with no
`@dataclass` needing `sys.modules[cls.__module__]` to resolve annotations,
so the registration bought nothing and risked pollution. Per-test stubs
already go through `monkeypatch.setitem`, which restores itself.
- Integration file: keep the bindings (they are what makes the production
lazy imports resolve to the real modules against real Redis — monkeypatch
cannot reach an import performed inside the function under test) and add
the sanctioned `_STUBBED_MODULE_NAMES` + autouse `_restore_sys_modules`
snapshot/restore pair, per tests/unit/test_telegram_webhook_backfill.py.
Verified: `python tests/lint_sys_modules.py` clean; running the integration
file followed by a suite that imports the real `services` package passes.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ui): prettify Agent Permissions Matrix — bigger labels, no grid jump (#1563)
The fleet permissions matrix was hard to read (text-xs, 32px cells) and
the grid jumped on every grant/revoke: the toast rendered inline between
the toolbar and the grid, so it pushed the grid down on appear and
snapped it back on the 3.5s auto-dismiss.
- Bump base font (text-xs → text-sm), cells (w-8 h-8 → w-11 h-11),
checkmark (text-base), row/column agent labels (font-medium), corner
hints (10px → text-xs), and column-label headroom (8rem → 10rem).
- Move the toast into a reserved min-h-[2.5rem] slot so appear/dismiss
no longer reflows the grid.
Presentation-only — grant/revoke logic and endpoints unchanged.
Grant/revoke verified end-to-end via the backend API.
Related to #1562
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sync-health): exclude soft-deleted agents from list_git_enabled_agents (#1561) (#1565)
`list_git_enabled_agents()` selected from agent_git_config on sync_enabled
alone, without joining agent_ownership or excluding deleted_at. Soft delete
keeps the git_config row, so the 60s SyncHealthService poller kept issuing
HTTP calls to removed containers forever — each httpx.ConnectError poisoned
the transport circuit breaker, eventually driving it DORMANT and emitting a
bogus circuit_breaker_dormant operator-queue alert for a nonexistent agent.
GET /api/fleet/sync-audit (same accessor) also listed dead agents.
- Join agent_ownership + filter deleted_at IS NULL, mirroring the #834
hardening on list_all_enabled_schedules(). Fixes both consumers at once.
- git_service.get_git_status: bare print() → logger.warning (structured).
- Regression tests: soft-deleted agent is excluded from the accessor and
never polled (zero HTTP, no sync_state row, no operator-queue entry).
Audit sweep (per AC): list_git_enabled_agents was the sole background-loop
accessor missing the deleted_at filter. Other per-agent HTTP pollers
(monitoring, operator-queue) enumerate from Docker, so removed containers are
inherently excluded; scheduler already filters (#834); capacity drain is
DB-only (no agent HTTP). Companion breaker-inheritance defect tracked in #1560.
Related to #1561
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(canary): execution-row integrity invariants E-03, G-03, E-04, G-04 (Phase 4) (#1497)
* test(canary): unblock canary_invariants fixture broken by #1472 merge
Two pre-existing breakages landed via #1472 (367a5e12, merged today) in
root-level tests/test_canary_invariants.py — a file the CI unit job
(tests/unit/ only) does not run, so they merged undetected:
1. Duplicate `CREATE TABLE agent_schedules` in the `canary_db` fixture DDL
— the E-06 work added a second definition next to the pre-existing one,
so `executescript` raised "table agent_schedules already exists" and
every fixture-dependent canary test errored. Removed the older, unused
block (its `message`/`owner_id` columns have no consumer; `_add_schedule`
and the E-06/L-03 collectors use only the kept block's columns).
2. `test_run_invariants_all` expected registry set omitted "E-06" (added to
the invariants registry by the same PR). Added E-06 to the expected set
and asserted it is green on a clean platform.
Restores a green baseline (92 passed) so the #1450 B-01 work can be verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(canary): B-01 queue-status coherence — backend-consistent Side B + confirm-re-read (#1450)
B-01 compared two reads that were neither temporally atomic nor
backend-consistent (both latent while the canary is default-OFF and SQLite
makes the two reads hit one file):
(a) Temporal non-atomicity — a concurrent enqueue/backlog-drain landing
between the two reads produced a transient count mismatch → spurious
critical + green→red Slack alert.
(b) Backend divergence (#300/#1093) — Side A (`db.get_queued_count`) honors
`get_engine()`/DATABASE_URL; Side B read raw sqlite3 at DB_PATH. On
Postgres those are two different databases, so B-01 compared Postgres
truth to a stale/absent SQLite file (fatal under the Postgres direction +
SQLite EOL, #1278).
Production-side residue of #1446 (PR #1452), which fixed only the test-harness
`sys.modules` leak and deferred these two gaps.
Fix (localized to B-01):
- New `_collect_queued_ids_via_engine` reads B-01's Side B through the SAME
`get_engine()` seam as the accessor, on a dedicated `queued_ids_via_engine`
snapshot field. Independent code path (SELECT id/literal 'queued' vs
COUNT(*)/the QUEUED enum) — shares a database, not a code path, so a
cache/status-filter regression still surfaces (non-tautology, AC #3). No
cache / second count of the queue (AC #4).
- The collector performs one confirm-re-read on a mismatch: a transient race
self-heals; a persistent drift survives and fires (AC #2). An engine-read or
unconfirmable confirm degrades to a B-01 skip — it never compares an engine
count against the raw-sqlite id-set (the blocker the reviews surfaced).
- `queued_exec_ids` (raw sqlite) stays untouched for B-02/E-02, so blast
radius is B-01-only and the sibling #1077 merge stays clean.
The running-side / known-agents reads remain raw-sqlite (half-migrated
collector); the collector-wide migration + a dark-canary tripwire are a filed
follow-up.
Tests (test_canary_invariants.py): retarget the synthetic B-01 tests at
`queued_ids_via_engine`; add the AC #5 regression net — a diverged-backend
proxy (raw ≠ engine temp files) that false-fires pre-fix and is green
post-fix, a transient-race confirm-absorb + persistent-drift-still-fires pair,
and an engine-read-failure→skip test. New split fixtures (`canary_db_split` /
`reload_canary_split`) model the diverged backend without a live PG. Convert
the file to the sanctioned `_STUBBED_MODULE_NAMES` + `_restore_sys_modules`
escape hatch so its import-time stubs no longer leak (the #1446 mechanism) and
the reimport `del`s are lint-clean.
Docs: architecture.md Canary B-01 row updated for the engine-backed read path
+ confirm-re-read.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(feature-flows): add #1450 canary B-01 Recent Updates row
Sync-feature-flows: canary-internal change, no dedicated flow doc
(architecture.md is canonical); append one Recent Updates row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(canary): fix duplicate agent_schedules DDL + reconcile E-06 in registry test
The canary_db fixture had two `CREATE TABLE agent_schedules` statements in one
executescript (a #1472 merge artifact), so it raised `table already exists` and
reddened the whole file. Merge them into one definition carrying every column
the canary reads (next_run_at/enabled/deleted_at + agent_name). Add
duration_ms/queued_at/backlog_metadata to schedule_executions and extend
_add_execution for the #1077 E-03/E-04 collector work. Reconcile E-06 (already
registered) into test_run_invariants_all's expected key-set.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(canary): terminal-row collector for E-03/G-03 (#1077)
Add _collect_terminal_rows(window_seconds) + Snapshot.terminal_rows. Windowed on
started_at (not completed_at, so E-03 can see NULL-completed_at rows), scoped to
success/failed/cancelled via a local _E03_TERMINAL_STATUSES subset that excludes
skipped (which legitimately has no completed_at/duration_ms). PRAGMA guard skips
the source entirely when completed_at/duration_ms are absent (column-absent !=
value-NULL) rather than false-firing. Window = max per-agent timeout + 300s;
bounded ORDER BY started_at DESC LIMIT 5000 with a logged sampled flag (no
(status,started_at) index over 90-day retention; tripwire, not backfill audit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(canary): register E-03 (completed_at populated) + G-03 (clock sanity) (#1077)
E-03 (A/major): terminal rows must have completed_at NOT NULL. Predicate is
completed_at-only — the catalog's + duration_ms clause false-fires on healthy
queue-terminated rows (cancel/fail/expire set completed_at but never
duration_ms). G-03 (A/minor): started_at <= completed_at with a ~1s cross-worker
clock-skew tolerance, UTC-aware parsing (E-06 _to_utc shape) so a #1474 mixed
naive/Z pair compares without raising. Both are leading-edge tripwires over the
shared terminal-row collector and catch all producers incl. the standalone
scheduler's raw-SQL writers a unit test never exercises.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(canary): E-03/G-03 synthetic + collector + end-to-end coverage (#1077)
Per-invariant synthetic tests (holds-clean, fires-on-violation), collector tests
(started_at window in/out, NULL-completed_at still collected, skipped-status
excluded, column-absent DDL -> unavailable, LIMIT cap + sampled flag), and
end-to-end collect_snapshot tests through the real collector: the C1
cancelled-from-queue holds-clean guard (completed_at set, duration_ms NULL ->
zero E-03), half-written fires E-03, bad-clock fires G-03, sub-second skew does
not, and the #1474 naive-vs-Z compare-without-raising case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(canary): document E-03/G-03 Phase 4 invariants (#1077)
architecture.md canary table gains E-03/G-03 rows + Phase 4 lookup-key line;
requirements/infrastructure.md §31 gains a Phase 4 bullet (and reconciles the
stale 'Phase 2 deferred' note now that #882/#1472 shipped);
orchestration-invariant-catalog.md annotates E-03/G-03 as shipped with explicit
registry-id mapping, notes the E-03 completed_at-only predicate deviation and
G-03 started_at<=completed_at reduction, and flags the catalog-id vs registry-id
E-06 drift (catalog #129 != registry #1472).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(feature-flows): index row for canary Phase 4 E-03/G-03 (#1077)
Canary is an internal invariant harness (no user-facing flow / dedicated flow
doc); follows the #1446 precedent of a Recent Updates row pointing at
architecture.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(canary): queued-row metadata collector for E-04/G-04 (#1077)
Capture queued_at + backlog_metadata for status='queued' rows in the
existing _collect_executions query, keyed by execution_id in a new
AgentSnapshot.queued_meta map. Both columns are PRAGMA-guarded (added by
BACKLOG-001): when either is absent on an older/minimal DDL the map is
left empty so E-04/G-04 skip those eids (older-image fail-open). Scoped
STRICTLY to queued rows — never terminal — so #1449's deferred
terminal-row backlog_metadata NULL-out cannot make E-04 false-fire.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(canary): register E-04 (queued metadata) + G-04 (no creds in metadata) (#1077)
E-04 (Tier A, major): every status='queued' row has queued_at NOT NULL
AND a non-NULL, JSON-parseable backlog_metadata — the
backlog_service.drain_next replay contract. A malformed blob raises
JSONDecodeError and stalls the FIFO. Reports only the failed-predicate
reason code + ids, never the raw metadata (may carry credentials;
violations persist to canary_violations).
G-04 (Tier A, critical): a queued row's backlog_metadata matches no
known secret prefix (sk-/ghp_/gho_/ghs_/ghu_/github_pat_/xoxb-/xoxp-/
AKIA/AIza/sk_live_), word-boundary anchored so common substrings don't
false-fire. Rides E-04's collected bytes. Reports only the matched
pattern NAME + ids, one violation per row (stops at first match) — never
the secret, surrounding bytes, or raw metadata.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(canary): E-04/G-04 synthetic + collector + end-to-end coverage (#1077)
Collector test proves queued_meta is populated for queued rows only (a
terminal row carrying backlog_metadata is excluded — #1449-safe). E-04:
holds on valid rows; fires with the right reason code on NULL queued_at,
NULL backlog_metadata, and non-JSON metadata; skips an eid absent from
queued_meta (older-image fail-open); e2e over a real temp DB. G-04:
holds on benign metadata (incl. "task-" substring that must not
false-fire); fires on github_pat / openai / slack / aws exemplars; skips
NULL metadata (E-04 owns it). Every G-04/E-04 test asserts the secret /
raw metadata bytes appear NOWHERE in the persisted violation record. The
runner test now expects E-04 + G-04 in the registered invariant set.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(canary): document E-04/G-04 Phase 4 invariants (#1077)
architecture.md lookup-key table gains E-04 + G-04 rows and the Phase 4
line now lists all four (E-04/G-04 stacked on #1450). requirements
infrastructure.md Phase 4 bullet expands to the full four-predicate set
with the credential-safety note. Catalog flips E-04/G-04 from
"gated on #1450" to SHIPPED, records the json.loads-vs-json_valid
implementation note, the queued-only scope, older-image fail-open, and
the report-reason/pattern-name-only security discipline; G-04 notes the
implemented check covers the backlog half of the title (log-line
scanning out of scope for #1077).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(feature-flows): index row for canary Phase 4 E-04/G-04 (#1077)
Also corrects the header/separator ordering left malformed by the
earlier E-03/G-03 index-row commit (a data row had slipped above the
|---| separator).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
* fix(config): forward OPERATOR_INTAKE/DO_NOT_TRACK, DISPATCH_BREAKER_ENABLED, PUBLIC_ACCESS_REQUESTS_ENABLED through compose (#1485) (#1493)
* fix(config): honor cross-tool DO_NOT_TRACK convention for operator intake (#1486)
The operator-intake kill switch only disabled the outbound POST when
DO_NOT_TRACK was one of {"1","true","True"}, so DO_NOT_TRACK=yes|on|2|TRUE
leaked despite the obvious opt-out intent — breaking the very
consoledonottrack.com convention config.py's own comment cites.
Flip to a tracking-allowed whitelist: any value not in {0,"",false}
(case/space-insensitive) disables intake. Unset -> "0" -> tracking
allowed (unchanged). Add tests/unit/test_1486_do_not_track_truthiness.py
so a revert to the exact-tuple check fails loudly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(config): forward operator-intake, dispatch-breaker & public-access levers through compose (#1485)
Same packaging-gap class as PR #1067 (VOIP vars): a lever read by
src/backend/config.py that neither compose file forwards into the backend
container, so setting it in .env silently no-ops (compose reads .env only
for ${VAR} interpolation, and neither backend service uses env_file).
Forward in both docker-compose.yml and docker-compose.prod.yml backend
environment: blocks, mirroring the existing PUBLIC_CHAT_URL idiom:
- PUBLIC_ACCESS_REQUESTS_ENABLED (#1488) — secure default false
- OPERATOR_INTAKE_ENABLED / DO_NOT_TRACK / OPERATOR_INTAKE_URL (#1486)
privacy kill switch. OPERATOR_INTAKE_URL keeps its FULL non-empty
default — a bare :- would arrive set-but-empty and shadow the code
default (#1076 set-but-empty class).
- DISPATCH_BREAKER_ENABLED (#1487) — global gate for the #526 dispatch
breaker; without it the owner-facing PUT .../circuit-breaker toggle
silently no-ops (two-tier gating needs both flags on).
.env.example: add the missing DISPATCH_BREAKER_ENABLED block (documenting
the two-tier gate) and a settable DO_NOT_TRACK=0 line. Backend-only —
src/scheduler reads none of these five vars.
Fixes #1486
Fixes #1487
Fixes #1488
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(feature-flows): note DISPATCH_BREAKER_ENABLED compose-forwarding fix (#1487/#1485)
The dispatch-circuit-breaker flow already documented the DISPATCH_BREAKER_ENABLED
env var, but before this PR that var never reached the container (the
#1039/#1067 packaging-gap class), so a reader following the doc would set it and
the owner toggle would silently no-op. Add a one-line Config-surface accuracy
note + a Recent Updates index row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
* fix(scheduler): serialize timestamps as UTC 'Z' so non-UTC browsers show correct relative times (#1474) (#1496)
* fix(scheduler): serialize timestamps as UTC with explicit 'Z' (#1474)
The standalone scheduler wrote execution/schedule timestamps via
datetime.utcnow().isoformat() (no 'Z'), while the backend writes through
utc_now_iso() (Z-suffixed). In a non-UTC browser, JS new Date(naive) parses
the naive string as local time, shifting schedule-triggered rows by the
viewer's UTC offset. Data on disk was fine; only display was wrong.
Vendor a byte-parity mirror of the backend timestamp helpers into
src/scheduler/utils.py (same regenerate-from-backend discipline as
failure_classifier.py): utc_now_iso / to_utc_iso emit 'Z'; parse_scheduler_ts
reads tolerantly and returns naive UTC.
Write + read land together (atomic): once writes emit 'Z',
datetime.fromisoformat("...Z") returns a tz-aware value on 3.11+, and the
duration math (datetime.utcnow() - started_at) would raise aware-naive.
parse_scheduler_ts converts-then-strips so the historical naive model type is
preserved and the subtraction stays naive-naive. All 24 read-parses route
through it; every write site (started_at/completed_at/last_run_at/next_run_at/
retry_scheduled_at/validated_at + process-schedule variants) emits 'Z'.
Updates the #1472 next_run_at comments to note the mapper now returns
naive-UTC (instant preserved), so both compare branches stay correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(executions): normalize naive timestamps to UTC 'Z' at read boundary (#1474)
The summary/list readers returned raw dict(row) values straight from the DB,
so a scheduler-written naive started_at/completed_at serialized naive out of
Pydantic and JS new Date(naive) parsed it as local time — the reported
schedule-triggered relative-time shift. This fixes historical rows for all
consumers at the source (unit-testable, unlike the frontend layer).
Reuse the existing parse_iso_timestamp helper (assume-UTC for naive) + the
Z-emitting to_utc_iso — the same normalization the already-correct sibling
readers (_row_to_execution) apply — via a small _norm_ts shim:
- db/schedules.py: get_agent_executions_summary (TasksPanel),
get_fleet_executions (ExecutionsPanel), get_agent_schedules_summary
last_run_at (Overview/Schedules).
- db/activities.py: _row_to_activity / _mapping_to_activity
(UnifiedActivity) started_at/completed_at/created_at.
None passes through untouched. No data migration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ui): parse backend timestamps as UTC in the 5 execution panels (#1474)
Defense-in-depth for the read side (covers WebSocket-pushed timestamps and any
un-audited endpoint, alongside the backend read-boundary fix). Each panel
hand-rolled `new Date(backendStr)`, which parses a naive (no-'Z') string as
*local* time. Replace only the internal parse with the shared idempotent
`parseUTC` (appends 'Z' when no tz indicator present → correct for legacy-naive,
new-'Z', and '+00:00' alike); `new Date()` "now" calls are left untouched and
per-panel display formats are unchanged.
- TasksPanel: formatRelativeTime + inline log-detail timestamp
- SchedulesPanel: formatRelativeTime, isOverdue, formatOverdue, formatDateTime
- ExecutionsPanel: timeAgo
- UnifiedActivityPanel: formatTime
- OverviewPanel: fmtDateTime
Established pattern (stores/network.js, ReplayTimeline.vue).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs: record #1474 scheduler Z-suffix + read-boundary under Invariant #16
Extend Architectural Invariant #16 (the #476 ISO-Z rule) with its write-side/
read-boundary cousin: the scheduler vendors src/scheduler/utils.py (Z-suffixed
writes, naive-UTC tolerant reads); the leaking backend read boundaries normalize
via parse_iso_timestamp; the 5 panels parse via parseUTC. Notes honestly that
next_run_at stays mixed-format across writers (safe — Python-compared only) and
that main.py SchedulerStatus.last_check is out of scope.
Update the #1472 learnings entry: the scheduler DB mapper now returns naive-UTC
(convert-then-strip, instant preserved), so the "schedules store AWARE
next_run_at" premise is true of the backend writer, not the scheduler read path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(feature-flows): document #1474 scheduler Z-suffix timestamp contract
Add a "Timestamp serialization contract (#1474)" subsection to
scheduler-service.md (vendored utils.py, Z writes, naive-UTC tolerant reads,
backend read-boundary + panel normalization, next_run_at mixed-format caveat)
plus a Revision History row; add the index Recent Updates row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(scheduler): cover _update_business_status validated_at Z-suffix (#1474)
The service-layer VALIDATE-001 write path (service.py::_update_business_status)
is the one #1474 timestamp write site outside database.py. Add sibling-path
coverage per the incomplete-fix rule: assert validated_at is stored 'Z'-suffixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
* docs(user-docs): add Trinity FAQ — 264 grounded Q&As across 14 topic pages
One page per topic (getting started, agents, chat/sessions, credentials,
scheduling, collaboration, channels, MCP/API, operations, sharing,
deployment, security, advanced, troubleshooting) plus a generated
question index. Answers derived from user docs and verified against
code; .claude pointer picks up the generate-user-docs FAQ step.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(user-docs): clarify PAT scope — git transport vs gh CLI/REST API authentication
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(helper-mcp): standalone Trinity docs Q&A MCP server (#1459)
New src/helper-mcp/ package (@abilityai/trinity-docs-mcp): an npx-runnable
stdio MCP server exposing the public ask-trinity docs Q&A endpoint
(DOCS-QA-001) to any MCP client — no Trinity instance or API key required.
- Tools: ask_trinity (multi-turn; detects the endpoint's silent session
reset and warns when context was lost) + get_agent_requirements (agent
guide fetched live from GitHub, quick-reference fallback)
- Guards: 4k question cap, 50s abort timeout, no auto-retry,
redirect:"error", non-JSON guard, structured error text; session_id
handled as opaque string (live values exceed 2^53)
- Deps: official @modelcontextprotocol/sdk + zod only; console.error-only
logging (stdout is the JSON-RPC channel); Node >=18 launcher guard
- Tests: 26 unit (mocked fetch) + pack-and-run stdio smoke test asserting
JSON-RPC stdout purity; CI workflow helper-mcp-test.yml
- Publish: publish-helper-mcp.yml (npm provenance; documented one-time
manual first-publish bootstrap for trusted publishing)
- Corpus: sync-docs-to-vertex.yml now also indexes the agent guide;
feature flow corrected (user-docs/FAQ were already indexed) and extended
with the verified endpoint contract (no citations field, silent session
expiry)
- Docs: requirements/mcp.md entry, README + user-docs install blocks,
package README
Fixes #1459
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(agents): ephemeral ghost agents — budgeted hard-discard lifecycle + spawn provenance (trinity-enterprise#69)
Disposable "ghost" agents: created with a hard budget (max_executions and/or
TTL — expiry ALWAYS stamped, ceiling 24h default), volume-less (container
writable layer; ghosts never recreate), hard-discarded at budget with no
soft-delete/retention/name-reservation. Creation is entitlement-gated
(ephemeral_agents; fail-closed — inert until the enterprise module registers);
all lifecycle mechanics are edition-agnostic OSS primitives.
Lifecycle:
- Schema: 5 additive agent_ownership columns (is_ephemeral, budget, expiry,
spawned_by_agent/key_id); dual-track migration (SQLite + Alembic 0016)
- Creation gates (crud.py): entitlement 403 → ephemeral-caller refusal
(chain-spawn kill) → per-parent spawn rate limit → TTL ceiling 400 →
server-suffixed name (hex8) → atomic per-owner Redis quota (INCR-with-cap,
NX reseed, DB fallback); ghosts skip volume/avatar/cred-injection/auto-sync,
default max_parallel_tasks=1
- Budget: gate at the TOP of CapacityManager.acquire (terminal+active >= max
or expired ⇒ EphemeralBudgetExhausted → 410 Gone / FAILED
ephemeral_exhausted; covers every admission surface); post-CAS-win
apply_result hook (backgrounded, fail-open) triggers discard at budget
- Hard discard (services/agent_service/ephemeral.py): SETNX-locked,
crash-convergent — intent marker → CAS-fail non-terminal rows
(ghost_discarded) → force-remove container → clear Redis state BEFORE purge
→ cascade purge (executions KEEP) → audit. DELETE routes ghosts here before
the container lookup (half-discarded state force-discardable)
- GC (cleanup_service._sweep_ephemeral_agents): DB pass + Docker-as-truth
orphan pass with 15-min newborn grace; capped per cycle
Part 2 — spawn provenance + parent control:
- Any agent-spawned creation persists spawned_by_agent/key_id and auto-grants
the agent_permissions parent→child edge (created_by="spawn:{parent}") so a
parent can immediately chat/list/info the child it spawned
- BEHAVIOR CHANGE: agent-scoped keys may start/stop/delete ONLY agents they
spawned (name AND key-id match; interim until #948); sharing, permission
grants, rename, and credential ops are now human-only (403 for agent keys)
- Ghost-key containment fence at the single auth entry point: a ghost's own
key reaches only heartbeat/result-callback/reports/notifications/self-info
Fleet hygiene: heartbeat watch + fleet health exclude ghosts; operator-queue
polling keeps them; exec/cost stats stay inclusive; schedules on ghosts → 400;
AgentStatus.ephemeral surfaced + GHOST badge.
Tests: tests/unit/test_69_ephemeral_agents.py (40, db_harness real-engine) —
accessors, facade delegations, acquire-gate matrix, key-fence matrix, Part 2
guard matrix, budget hook, discard idempotency/crash-convergence, atomic
quota. Full unit suite verified; residual order-dependent flakes reproduced
on clean dev (pre-existing, documented in learnings.md).
Refs abilityai/trinity-enterprise#69 (Phase 0 record on the issue; closed
manually at release — cross-repo keywords don't auto-close).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(69): patch _REAL_MODULES objects directly, never string targets — fixes seed-12345 order flake
CI's regression-diff (head seed 12345) caught the discard test flaking:
string-form monkeypatch targets resolve through sys.modules at PATCH time,
which under some pytest-randomly orderings is a sibling test's leaked stale
entry — the patch lands on the wrong module object while discard's call-time
import (under the _own_real_modules pin) resolves the real one, so
get_agent_container fell through to the real function (container=None,
removal skipped). Same hazard removed from the gated_capacity and
ghost_fence fixtures (bare fixture-time imports), and the audit assertion
moved to an instance-method patch on the pinned singleton.
Verified: full suite green under all three CI seeds (12345/67890/99999),
3775 passed each.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(learnings): string-target monkeypatch resolves stale sys.modules entries — patch by object
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tests): pin real modules in TestAcquireCeilingClamp to kill sys.modules-leak flake (#1582) (#1584)
The trio (test_acquire_clamps_above_ceiling / test_get_slot_state_clamps /
test_get_all_states_clamps_each) failed order-dependently under
pytest-randomly on clean dev (5/7 observed runs), passed in isolation and
under -p no:randomly.
Root cause (the #762/#1446 family): `_patch_ceiling` patched
`get_max_parallel_tasks_ceiling` on the settings_service resolved via
`from services import settings_service`, but `capacity_manager.acquire`
imports `clamp_to_ceiling` at call time from `services.settings_service`.
When a sibling's module-level stub (e.g. test_fleet_status_resilience
installing a fresh `services` package with a real `__path__`) leaves two
module objects for `services.settings_service` in play, the patch lands on
one and `clamp_to_ceiling.__globals__` reads the other — the patch is
silently never hit and the clamp assertion sees the unpatched (default)
ceiling.
Fix (the pattern documented for this leak family):
- Capture the real `services` / `services.settings_service` /
`services.capacity_manager` at collection time (this file sorts before
every known leaker, so the import is leak-free).
- Autouse fixture re-pins them into sys.modules per test (monkeypatch
auto-restores) so the code-under-test's call-time import and the test's
patch target resolve to the SAME object — last-write-wins over any leak.
- `_patch_ceiling` now patches the captured real module object directly,
never a bare re-import that could resolve a leaked stub.
Verification: 25/25 in the file; 4/4 trio; 32 passed across 6 random seeds
with test_fleet_status_resilience co-resident; 111 passed / 0 ceiling
failures across 5 full-unit randomized seeds.
Related to #1582
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reliability): decouple agent autonomy from the circuit breaker (#1557) (#1571)
Disabling autonomy called force_circuit_dormant, parking the transport
circuit breaker dormant. The execute_task gate consults that breaker for
every trigger, so a healthy paused agent fast-failed all inbound chat
(manual/Telegram/Slack/public) with "circuit breaker open — agent is
unhealthy" — never contacted. Autonomy governs proactive work only; it
now acts solely via set_schedule_enabled and never touches the breaker.
#631's flood protection is unaffected (the breaker's own failure-driven
dormant path + #1464 leader lock + #1121 monitoring-default-off).
Also splits the misleading fast-fail message to name the breaker that
fired (transport = unreachable, dispatch = auth-dead).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
* feat(voice): voice replies v2 — per-message capability, agent-level config, runtime ElevenLabs key (trinity-enterprise#117) (#1549)
* feat(voice): voice replies v2 — per-message capability, agent-level config, runtime ElevenLabs key (trinity-enterprise#117)
Rework outbound voice replies (ElevenLabs TTS) along three lines:
1. Voice is a per-message capability, not a hard rule. Channel replies are text
by default; a reply becomes a voice note only when the agent explicitly calls
the new send_voice_reply MCP tool during the turn. The backend resolves the
channel destination from the execution (new schedule_executions.source_channel*
columns), gates on TTS availability + agent enable + per-channel flag, wraps
delivery in effect_guard (#1084), and reuses each channel's send primitive.
The always-voice adapter path (_maybe_send_voice) is removed.
2. Voice config moves to agent Settings (enable + voice selection, one place);
channel panels keep only a per-channel on/off flag
(agent_ownership.tts_voice_{telegram,slack,whatsapp}_enabled, default ON).
GET/PUT /api/agents/{name}/voice-replies extended with channels + effective voice.
3. ElevenLabs API key + platform default voice are runtime-configurable in admin
Settings (GET/PUT /api/settings/elevenlabs), key stored AES-256-GCM encrypted
and surfaced as configured:bool only; resolved via
settings_service.get_elevenlabs_api_key() (stored setting -> env), no restart.
New tts_available feature flag.
Dual-track migrations (SQLite + Alembic 0015/0016). New send_voice_reply MCP tool
(voice.ts) + POST /api/agents/{name}/voice-reply + voice_reply_service. Capability
advertised in the platform prompt only when voice is enabled for the agent and the
current channel's flag is on. OSS-core.
Related to trinity-enterprise#117
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(voice): forward source_channel* through the db facade create_task_execution (trinity-enterprise#117)
DatabaseManager.create_task_execution (the `db` facade wrapper) didn't forward the
new source_channel / source_channel_chat_id / source_channel_thread kwargs to
ScheduleOperations, so a channel-triggered task raised
"unexpected keyword argument 'source_channel'" → 500. Add the passthrough.
Related to trinity-enterprise#117
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(voice): allow send_voice_reply on channel turns via --allowedTools (trinity-enterprise#117)
Channel turns run headless with a restricted `--allowedTools` (default
WebSearch,WebFetch), which blocks every MCP tool — so an agent could not call
send_voice_reply even when the capability was advertised in its prompt. When the
voice capability is advertised for a channel turn, also append
`mcp__trinity__send_voice_reply` to the channel allowed-tools list so the agent
can actually act on it.
Related to trinity-enterprise#117
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(migrations): stage the Alembic re-chain — 0017/0018 revision ids + down_revision onto 0016_agent_ownership_ephemeral
The dev-merge commit renamed the files but the id/down_revision edits were left unstaged, leaving two heads off 0015 (pg-migrations 'Multiple head revisions' failure).
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
* chore(dev-skills): bump .claude — /cso v1.1 Trinity-shape refresh
Pointer bump to trinity-dev 2bdd362: fixes stale audit checks (setup-token
removal ent#49, Redis ACL model, dual-track DB / two-network stack facts)
and adds checks for post-skill surfaces (agent-key self-boundaries
#307/#1083/#918, backend→agent auth #1159, webhook HMAC ent#77, vendored
parity Invariant #5, enumeration uniformity #186, MCP description leak
#846, backlog_metadata G-04 class, enterprise-docs-guard ent#45).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(security): add CSO full-codebase audit report (2026-07-13)
Full-codebase Chief Security Officer audit (all phases, daily 8/10 gate).
No CRITICAL or exploitable-HIGH findings; prior CRITICAL (unauthenticated
agent-server, #1159) and both HIGH supply-chain items resolved. Ceiling is
MEDIUM: shared-user privilege tier, npm-ci lockfile bypass, unenforced
CODEOWNERS, plaintext backlog_metadata, unpinned deps, enterprise-doc
disclosure the guard can't see, python-multipart CVE. Two candidate findings
(orb.js XSS, Slack code takeover) downgraded to LOW by adversarial verifiers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(portal): hide the platform Help widget on client-portal routes (#1588)
The global "Trinity Help" chat widget is fixed at bottom-right (z-50) and
overlapped the client portal chat composer's Send button. It's an operator/
platform-docs widget (gated on platform auth) and is meaningless on the
standalone client portal, so gate it off there via a `hideHelpWidget` route
meta on the two portal routes (`ClientPortalPublic` /portal and
`EnterpriseClientPortal`). App.vue now renders it only when
`authStore.isAuthenticated && !route.meta.hideHelpWidget`.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(portal): attach/upload files directly in the customer portal chat (ent#144) (#1587)
A client can now attach files in the portal chat composer. The backend already
had everything — `POST /client-portal/agents/{name}/documents` (writes to the
client inbox), and `portal_chat`'s `_collect_inbox_for_turn` attaches inbox
images as vision + lists documents — and the store already had
`uploadDocument`/`fetchUploads`. The only missing piece was the composer UI.
- `PortalChat.vue`: adds an `uploadDocument` prop and a paperclip attach control
(hidden multi-file input). Picked files upload to the inbox immediately and
render as chips with state (uploading / done / error) and a remove button.
On send, the done attachments' (server-sanitized) filenames are appended as
`[Attached: …]` so `_collect_inbox_for_turn` attaches images as vision THAT
turn (it keys on the filename / image-intent) and lists documents. Files-only
turns are allowed; send is blocked while an upload is in flight; a 25 MB
client guard mirrors the backend cap (415/quota surfaced as a chip error).
- `Portal.vue`: passes `:upload-document="(name, file) => store.uploadDocument(name, file)"`.
- The attach control hides when no upload handler is wired (operator-preview
`ClientPortal.vue` renderer), so nothing changes there.
Public-repo (gated Vue) change only — no backend/submodule change (reuses the
shipped enterprise endpoint + inbox consumption).
Refs Abilityai/trinity-enterprise#144
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(access): restore the per-recipient proactive-messaging toggle (#1577) (#1590)
The `allow_proactive` toggle (the only UI for #321/#376) was silently dropped
when the #1317 Access-tab redesign replaced the Sharing tab's Team Sharing rows
— leaving no way to opt a recipient into proactive messages except a raw API
call. Backend was fully intact; this is a UI-only restore.
- `AccessPanel.vue`: each operator row gets a Proactive toggle bound to the
`allow_proactive` the `/access` roster already returns (no extra fetch). On
change it persists via the store and reflects the server's confirmed value,
reverting + surfacing the error on failure (honest status, no optimistic-only
flip). Pending invites are toggleable too (the flag rides on the
`agent_sharing` row, which exists pre-resolution) with a tooltip on timing. A
static note states the owner is always allowed (owners aren't in the roster,
so there's no misleading owner toggle).
- `stores/agents.js`: new `setProactive(name, email, allow)` →
`PUT /api/agents/{name}/shares/proactive`.
- `feature-flows/proactive-messaging.md`: updated — it still documented the
removed SharingPanel markup/line numbers.
- `tests/unit/test_1577_proactive_toggle_guard.py`: static regression guard (3)
asserting the toggle + endpoint wiring survive future panel refactors (the
frontend has no JS unit runner; a full render e2e is a `ui` follow-up).
Related to #1577
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(github): wire the managed agent PAT for the gh CLI + REST API, not just git (#1574) (#1591)
Trinity injected the resolved GitHub PAT as GITHUB_PAT (authenticating git via
the origin URL) but not the `gh` CLI or REST API, which read GH_TOKEN/GITHUB_TOKEN
— so agents had to prefix every command with `GH_TOKEN="$GITHUB_PAT" gh …`, and
`gh` wasn't even installed. This makes the SAME token cover both. No new token,
endpoint, or UI — wiring only.
Expose GH_TOKEN + GITHUB_TOKEN = the resolved PAT at every point GITHUB_PAT is
set today, gated identically (only when a repo + PAT resolve — never an empty
token that makes `gh` look logged-in but broken):
- create (`crud.py`) and recreate (`lifecycle.py`) bake them into the container env;
- the no-restart `.env` propagation (`github_pat_propagation_service._patch_env_github_pat`)
now patches/adds all three keys, kept in sync;
- `startup.sh` exports them from GITHUB_PAT so child processes (agent server,
terminal shells) auto-authenticate — also covering older baked images that
only carry GITHUB_PAT, without a recreate.
Install the `gh` CLI in the agent base image (`Dockerfile`) from GitHub's official
apt repo (base image must be rebuilt for existing agents to get the binary; the
env vars are harmless on older images — git keeps working).
Honest surfaces: the `set_agent_github_pat` MCP tool description and
`docs/.../github-pat-setup.md` now state the managed token covers `gh`/REST too,
keeping the scope caveat (wiring makes the token available; it can't grant scopes
the token lacks). The credential sanitizer's `.*TOKEN.*`/`GITHUB_.*` patterns
already mask GH_TOKEN/GITHUB_TOKEN (asserted).
Tests: `tests/unit/test_1574_gh_token_wiring.py` — the .env patcher mirrors the
PAT onto all three keys (replace-in-place / append, lookalike-key-safe, value
mirrored); static guards that create/recreate/startup/Dockerfile each wire the gh
vars; sanitizer covers the new vars.
Related to #1574
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(operator-queue): quarantine persistently-failing creates to stop the sync hot-loop (#1525) (#1589)
The `created_at` KeyError in `create_item` was already made defensive on dev
(#1426 — `.get(...) or utc_now_iso()` + `on_conflict_do_nothing`). This closes
the remaining #1525 gap: the sync loop still re-attempted ANY failing create on
every ~5s cycle forever (the row never persists → `operator_queue_item_exists`
stays False → retry + ERROR-log indefinitely), so a DB error or any other
persistent create failure — not just the fixed field case — still hot-loops.
- `operator_queue_service._sync_agent`: track per-request consecutive
create-failure counts; after `MAX_CREATE_ATTEMPTS` (3) skip that request
(one WARN at the quarantine threshold instead of an unbounded ERROR stream).
A create that later succeeds clears the counter; an in-memory safety valve
caps the map so it can never grow without bound.
- `db/operator_queue.create_item`: the last hard-indexed field (`id`) now uses
`.get` and raises a clear `ValueError` (which the caller quarantines) instead
of an opaque `KeyError`. Also hardened the WS-broadcast `item["id"]`.
Tests: `test_1525_operator_queue_quarantine.py` (4, pure/mocked) — create is
attempted at most the cap (not once per cycle), success clears the counter, a
healthy request leaks no quarantine state, and the id-guard raises ValueError.
Related to #1525
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(mcp): one-click Copy connection config on the Expose-via-MCP panel (#1575) (#1585)
* feat(mcp): one-click Copy connection config on the Expose-via-MCP panel (#1575)
Surfaces a ready-to-paste external-client config — with a least-privilege,
agent-scoped, revocable API key already embedded — from the #846 exposure
panel, so an external MCP client connects in one flow (enable → copy → paste).
Direction: reuse the existing per-agent MCP connector (ent#46 → OSS #118)
rather than mint a duplicate credential system. The connector already provides
exactly the hard parts #1575 asks for — a scoped `scope='connector'` key,
owner-selected playbooks exposed as tools, and `build_snippets` producing
per-client `.mcp.json`/CLI blocks with the key embedded — but only on the
Sharing tab. This wires that capability onto `McpExposedPanel.vue` (Settings →
Expose via MCP), shown when `mcp_exposed` is on.
- Frontend-only, no new backend endpoint/key type: reuses
`GET/POST/DELETE /api/agents/{name}/connector[/key]` (owner-only,
`OwnedAgentByName`) + `ExposedToolsPanel` for the playbook allow-list.
- One-click "Copy connection config": mints (or regenerates) the scoped key and
copies the `.mcp.json` in a single action via the robust `utils/clipboard`
helper — the only moment the live secret exists.
- Copy-once integrity: with an existing key, "Copy config" copies the
placeholder config and offers "Regenerate & copy" for a fresh live key;
"Revoke" severs any connected client. Key already lists in Settings → MCP Keys.
- Safe default: the section only appears once the agent is MCP-exposed; a plain
warning marks the config as a live secret.
Docs: mcp-connector.md gains the 2nd-surface row; architecture #846 block notes
the connect surface.
Verification: `McpExposedPanel.vue` compiles clean via @vue/compiler-sfc
(full `vite build` blocked by a pre-existing unrelated missing `mermaid` dep in
AgentWorkspace.vue). Live click-through / ui-labeled e2e needs the running
frontend+backend stack (not up locally).
Not changed (documented scope): a 422 on an invalid playbook name — the picker
only offers live user_invocable playbooks, so the invalid path is UI-unreachable;
left to the connector's existing read-time filter.
Related to #1575
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(mcp): flash a "Copied!" animation on the connection-config copy buttons (#1575)
Adds an inline copied-state affordance to the connect-config copy actions
(Copy connection config / Copy config / per-client snippet Copy): on a
successful clipboard write the button swaps to a green "✓ Copied!" with a
scale-pop checkmark for ~1.6s, then reverts. One shared timer (cleared on
unmount); only fires when the copy actually succeeded; honors
prefers-reduced-motion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(mcp): make the copied state transform the button itself (#1575)
Stronger, unmissable copy feedback: on a successful copy the primary
"Copy connection config" button fills solid success-green, gains a
ring + glow, bumps to a bolder "Copied to clipboard!" with a larger
checkmark, and plays a one-shot pop + outward ring-pulse
(copied-btn-flash) before settling. The existing-key "Copy config"
button gets the same solid-green ring treatment. Reverts after ~1.6s;
honors prefers-reduced-motion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(mcp): animate the connector snippet Copy buttons too (#1575)
The MCP connector panel (Sharing tab, ConnectorChannelPanel) is the other
surface that shows per-client copy snippets; its plain "Copy" links gave no
feedback. Give them the same copied-state treatment as the exposure panel:
on a successful copy the button flashes solid success-green with a ring +
pop + "Copied!" checkmark, reverting after ~1.6s (honors reduced-motion).
Also route its copy() through the robust utils/clipboard helper instead of
raw navigator.clipboard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tests): conftest no longer deletes real agents named test-* (#1558) (#1586)
* fix(tests): stop conftest from deleting real agents named test-* (#1558)
The session-scoped `api_client` fixture deleted EVERY agent whose name began
with `test-` on whatever `TRINITY_API_URL` pointed at — silent, unprompted
data loss that destroyed a developer's real `test-agent-2` (bound Telegram
bot, chat history, GitHub sync). Any `test-*` agent on a staging/prod instance
was one test invocation from deletion.
Fix — fail-closed on every axis:
- Dedicated prefix: the suite now names every agent it creates
`pytest-ephemeral-*` (a namespace no human uses), never the broad `test-`.
Renamed all conftest agent generators (test_agent_name, module_agent_name,
stopped_agent, shared_agent).
- Session registry: created names are registered (`register_created_agent`)
and reclaimed by name at session end (belt over each fixture's own teardown).
- Startup leftover-sweep is now OPT-IN (`TRINITY_TEST_CLEANUP_SWEEP`), refuses
any non-localhost target (`is_local_target`), and only removes provably
suite-owned names (`select_sweepable_agents` — pure, fail-closed). Default:
no sweep at all.
- `cleanup_test_agent(require_suite_owned=True)` refuses (no stop/delete call)
a name the session can't prove it created — used by every sweep path.
- Docstring + tests/README now state plainly that the suite mutates the target
instance and must point at localhost.
…
Summary
Reworks outbound voice replies (ElevenLabs TTS) along the three lines of trinity-enterprise#117. OSS-core (chosen at design review — the TTS layer, adapters, and settings pattern are all OSS).
1. Voice as a per-message capability (not a hard rule)
send_voice_replyMCP tool during the turn.schedule_executions.source_channel/source_channel_chat_id/source_channel_thread, populated bymessage_router), gates on TTS availability + agent-level enable + the per-channel flag, wraps delivery ineffect_guard("voice_reply", …)(feat: effect-scoped idempotency keys for outbound side effects #1084), and reuses each channel's send primitive (TelegramsendVoice, Slackupload_file, WhatsAppcreate_share_from_bytes+ TwilioMediaUrl)._maybe_send_voice) is removed. Fail-soft: any miss → not-delivered so the agent falls back to text; cost cap preserved.2. Agent-level config, channel-level flag
agent_ownership.tts_voice_{telegram,slack,whatsapp}_enabled(default ON — preserves capability coverage for already-enabled agents; behavior change: always-voice → agent-chosen).GET/PUT /api/agents/{name}/voice-repliesextended withchannels+effective_voice_id+default_voice_id.3. ElevenLabs key + default voice in platform Settings
GET/PUT /api/settings/elevenlabs(admin) — key stored AES-256-GCM encrypted (Invariant fix: Add ARM64 Alpine compatibility for frontend Docker build #12), surfaced askey_configured: boolonly. Resolved at runtime viasettings_service.get_elevenlabs_api_key()(stored setting →ELEVENLABS_API_KEYenv), no restart. Platform default voice ID that per-agent config falls back to. Audit-logged (key masked).tts_availablefeature flag.Schema / migrations
Dual-track per #1183: SQLite (
db/migrations.py) + Alembic0015_agent_ownership_tts_channel_flags,0016_schedule_executions_source_channel;schema.py/tables.pyupdated.Three surfaces (Invariant #13)
Backend router +
voice_reply_service+ MCP tool (voice.ts) + client method. No agent-server mirror needed (pure backend sink).Testing
tests/unit/test_117_voice_replies_v2.py— 20 passed: resolver precedence (stored→env→''),/api/settings/elevenlabsroutes (403 non-admin, key never echoed, set/clear/conflict, audit masked), service gating (tts-unavailable / disabled / channel-off / no-voice-id / platform-default fallback) + Telegram/Slack/WhatsApp delivery + effect-guard replay.test_25_telegram_voice_out.py(resolver-aware); removed obsolete adapter-leveltest_26/test_56(behavior moved to the service, covered above).Docs
docs/memory/requirements/public-access.md§48.1 (added before implementation) +docs/memory/architecture.md(DDL, tts_service/adapter notes, voice-replies + voice-reply API, MCP catalog, feature-flags,/settings/elevenlabs, Invariant #12).Follow-up (separate repo)
The enterprise client-portal STT path (
enterprise/backend/client_portal/service.py) also routes through the runtime resolver — that edit lands as its own trinity-enterprise PR (kept out of this OSS branch).Related to trinity-enterprise#117
🤖 Generated with Claude Code