Skip to content

Feature/gemini runtime support - #2

Merged
vybe merged 33 commits into
mainfrom
feature/gemini-runtime-support
Dec 29, 2025
Merged

Feature/gemini runtime support#2
vybe merged 33 commits into
mainfrom
feature/gemini-runtime-support

Conversation

@oleksandr-korin

Copy link
Copy Markdown
Contributor

No description provided.

Implements runtime adapter pattern to support both Claude Code and Gemini CLI,
enabling cost optimization and provider flexibility.

Key Changes:
- Created AgentRuntime interface for runtime abstraction
- Implemented ClaudeCodeRuntime (wraps existing code)
- Implemented GeminiRuntime with MCP translation
- Added runtime selection to AgentConfig model
- Updated Dockerfile to install Gemini CLI
- Added GOOGLE_API_KEY environment variable support
- Created test-gemini template for validation

Features:
- Seamless runtime switching per agent
- Unified cost/token tracking across providers
- MCP tool support for both runtimes
- 1M token context window for Gemini (5x Claude)
- Free tier support (60 req/min for Gemini)

Documentation:
- Added docs/GEMINI_SUPPORT.md with setup guide
- Updated README.md with multi-runtime info
- Included gemini-research-summary.md for technical details

Backward Compatibility:
- Defaults to claude-code if runtime not specified
- Existing agents continue working unchanged
- No breaking changes to API or templates
Code review fixes:
- state.py: Added runtime_available check for both Claude/Gemini
- state.py: Dynamic context window based on runtime (1M for Gemini)
- chat.py: Fixed parallel task endpoint to use runtime adapter
- chat.py: Fixed WebSocket handler to use runtime adapter
- chat.py: Model validation now supports Gemini model names
- __init__.py: Export get_runtime and AgentRuntime
- info.py: Health endpoint now reports runtime info
- main.py: Log runtime info on startup
- agents.py: Extract runtime config from template.yaml
- docker-compose.yml: Add GOOGLE_API_KEY env var for backend

Runtime adapter improvements:
- Added execute_headless method to AgentRuntime interface
- Implemented execute_headless in both ClaudeCodeRuntime and GeminiRuntime
- Better error handling and timeout support for headless tasks
Updated key documentation files:

- DEPLOYMENT.md: Added GOOGLE_API_KEY configuration section
- TRINITY_COMPATIBLE_AGENT_GUIDE.md:
  - Added runtime field to template.yaml schema
  - New 'Runtime Options' section with comparison table
  - Environment requirements per runtime
- changelog.md: Added 2025-12-28 entry for Gemini integration
- requirements.md:
  - Added Section 12: Multi-Runtime Support (3 requirements)
  - Removed 'Claude only' from Out of Scope section
Introduces formal semantic versioning for Trinity:

New files:
- VERSION: Contains current version (0.9.0)
- docs/VERSIONING_AND_UPGRADES.md: Comprehensive upgrade guide

Changes:
- build-base-image.sh: Now tags images with version number
- main.py: Added /api/version endpoint
- README.md: Link to versioning docs

Versioning strategy:
- Semantic versioning (MAJOR.MINOR.PATCH)
- All components share single version number
- Docker images tagged with version + latest
- Version endpoint for runtime queries

This establishes v0.9.0 as the Gemini support release.
Changed logger.warning() to print() for consistency with the rest
of the file. This was causing 500 errors when creating Gemini agents.
Added 1-second delay and container.reload() after container creation
to ensure Docker reports the correct 'running' status before broadcasting
to the frontend. Previously, the status was checked too quickly after
container.run(), resulting in a transitional state being reported.
WebSocket 'agent_created' event was adding agent to list even when
the API response had already added it. Now checks if agent exists
before adding from WebSocket event.
Gemini CLI expects the environment variable GEMINI_API_KEY, not
GOOGLE_API_KEY. Updated agent creation to pass the correct name.
- Remove push from createAgent() to avoid race condition
- WebSocket 'agent_created' event is now the single source for adding agents
- Keep duplicate check in WebSocket handler for reconnection safety
Gemini CLI outputs {'type':'message','role':'assistant','content':'...'}
for responses, not the format we originally expected. Also fixed stats
parsing from 'stats' field instead of 'usage'.
Gemini CLI outputs tool_use and tool_result at the top level, not nested
inside assistant/user messages like Claude Code. Added handling for both
formats to support tool execution tracking.
Sometimes Gemini CLI returns success with no assistant message content.
Instead of throwing a 500 error, return a placeholder response.
When Gemini executes a tool but doesn't output an assistant message,
use the tool result output as the response instead of '(Task completed)'.
This provides more useful feedback to the user.

Also added debug logging for stream parsing and saved refactoring plan.
- Make trinity_mcp.py runtime-aware (Claude .mcp.json vs Gemini CLI)
- Add _inject_gemini_mcp() for gemini mcp add commands
- Add configure_mcp_servers() shared function
- Simplify GeminiRuntime.configure_mcp() to use shared impl
- Add output field to ExecutionLogEntry model
- Document CLAUDE.md usage for both runtimes
- Add template priority for UI ordering
- Update GEMINI_APPLICATIONS.md status to implemented
Renamed: docs/development/GEMINI_APPLICATIONS.md
     -> docs/memory/feature-flows/gemini-runtime.md

Clearer naming and consistent with other feature flow docs.
- Add GEMINI_PRICING constants for different models
- Add calculate_gemini_cost() function
- Calculate estimated cost from token usage in result parsing
- Gemini free tier shows what costs *would* be for comparison
- Add runtime field to AgentStatus model
- Extract runtime from container env vars in docker_service.py
- Add computed availableModels based on agent.runtime
- Show Gemini models for gemini-cli agents
- Show Claude models for claude-code agents
- Dynamic tooltip based on runtime
- Add gemini-3-pro and gemini-3-flash to UI model selector
- Add estimated pricing for Gemini 3 models
Source: ai.google.dev/pricing (Dec 2024)
- Gemini 3 Pro: $2.00/1M in, $12.00/1M out
- Gemini 3 Flash: $0.50/1M in, $3.00/1M out
- Gemini 2.5 Pro: $1.25/1M in, $10.00/1M out
- Gemini 2.5 Flash: $0.30/1M in, $2.50/1M out
- Gemini 2.0 Flash: $0.10/1M in, $0.40/1M out
- Gemini 2.0 Flash Lite: $0.075/1M in, $0.30/1M out
- Add ADMIN_USERNAME env var support in database.py
- Add stub functions for plan/task helpers in Agents.vue
- Whitespace cleanup across multiple files
- Add multi-runtime capability to welcome page
- Add Google API key to prerequisites (free tier!)
- Update agent creation to mention runtime selection
- Add runtime comparison table to Core Concepts
- Update checklist with both API key options
- Add link to Gemini Support Guide
- Include testing docs folder
- Chat endpoint now uses agent_state.current_model when request.model is None
- WebSocket endpoint also respects model from message or state
- Ensures model selector dropdown actually affects which model is used
- 001: Claude context window shows incorrect values (understated by 20-30x)
- 002: Unified context reporting interface across runtimes
- README: Backlog structure and guidelines for AI agents
Resolves conflicts:
- docs/memory/changelog.md: Merged Gemini entries chronologically
- src/backend/routers/agents.py: Use main's service layer, added multi-runtime to service
- src/frontend/src/views/AgentDetail.vue: Use main's Terminal tab, added model selector computed

Multi-runtime support:
- Added runtime config extraction in agent_service/crud.py
- Added runtime config extraction in agent_service/deploy.py
- Added AGENT_RUNTIME, AGENT_RUNTIME_MODEL, GEMINI_API_KEY env vars
- Added trinity.agent-runtime Docker label
- Added runtime-aware model selector in AgentDetail.vue
oleksandr-korin pushed a commit that referenced this pull request Dec 28, 2025
Bug #1: Terminal session lost when switching tabs
- Changed v-if to v-show for terminal tab content in AgentDetail.vue
- Keeps terminal component mounted, preserving WebSocket connection

Bug #2: MCP deploy_local_agent only copied CLAUDE.md
- Updated startup.sh to copy ALL template files instead of hardcoded list
- Now includes template.yaml and custom directories (src/, lib/, etc.)
- Added .trinity-initialized marker to prevent re-copying on restart

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Write directly to ~/.gemini/settings.json instead of using 'gemini mcp add'
- Workaround for Gemini CLI bug where --transport http creates invalid 'type' field
- Enables Trinity MCP tools (chat_with_agent, etc.) for Gemini agents
- Create schedule_execution records for manual tasks via /api/agents/{name}/task
- Track success/failure status, response, cost, and tool calls
- Makes manual tasks visible in the Tasks panel UI alongside scheduled tasks
vybe pushed a commit that referenced this pull request Jul 3, 2026
* feat(ui): reframe Sharing tab to external-client sharing via channels

Part of the Access/Sharing redesign (Epic trinity-enterprise#16). The Access
tab (#17) already owns Trinity operators; this scopes the Sharing tab to the
operator → external-client surface.

- Google-Docs-style "Share this agent" framing; operator language removed
  (operators live on the Access tab).
- External access policy collapsed into one **Restricted ↔ Open** segmented
  control over require_email/open_access (Restricted = approval-gated, Open =
  anyone verified; identity proof always on for external sharing).
- Pending requests kept, reframed as external clients awaiting approval.
- Channels rendered as compact collapsible summary rows (new
  ChannelDisclosure.vue). Detailed config stays reachable inside the expanded
  row as a non-regressing interim seam — #19 replaces the body with a modal
  dialog. No channel functionality removed.
- Outbound file sharing + public links nudged into a separate "Distribution"
  section (distribution, not client access).

Frontend-only; no API changes. SharingPanel prop/emit contract unchanged.
Verified: both SFCs compile (vue/compiler-sfc), design-token check passes.

Related to Abilityai/trinity-enterprise#18

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ui): move channel config into dialogs + VoIP surface (#19)

Builds on the Sharing-tab reframe (#18). Replaces the inline collapsible
channel panels with compact summary rows that open the existing config panels
in a modal — the tab stays a clean summary instead of a wall of forms.

- New ChannelConfigDialog.vue — Teleport modal (backdrop + Esc close, scrollable
  body) matching the ConfirmDialog idiom.
- New ChannelConfigRow.vue — summary row showing connected/off + a brief status
  (channel/handle/number), a Configure/Manage button that opens the dialog, and
  a lightweight status fetch (shared `api` client) that refetches on dialog
  close so the row reflects edits. The 4 channel panels render untouched in the
  dialog slot — no loss of functionality.
- SharingPanel wires Slack/Telegram/WhatsApp/VoIP through ChannelConfigRow with
  per-channel status derivations. VoIP (previously backend-only) now has its
  Sharing UI surface, gated on `voip_available`.
- Distribution (public links, file sharing) keeps the collapsible disclosure —
  out of scope for the channel-dialog change.

Frontend-only; no API changes. Verified: 3 SFCs compile (vue/compiler-sfc),
design-token check passes, and Vite transforms all modules on the live dev
server (HTTP 200).

Related to Abilityai/trinity-enterprise#19

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ui): mount channel config dialog only when open (no row layout collapse)

A closed ChannelConfigDialog should collapse to Teleport comment nodes, but
under some dev-server HMR states its slotted panel rendered inline as a flex
child of the channel row, squeezing the label (esp. WhatsApp, whose panel is
the widest) to zero width.

Gate the dialog with v-if="dialogOpen" in ChannelConfigRow so the closed dialog
— and its slotted panel — never participates in the row's flex layout, and
mount the panel on demand. Make the dialog's Escape handler bind immediately so
it still works when mounted already-open.

Related to Abilityai/trinity-enterprise#19

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ui): truncate long channel status labels + collapse redundant dialog gating

Addresses code-review findings on PR #1348:

- [Medium] The connected-status label was a flex child without `min-w-0`, so a
  long label (e.g. WhatsApp `whatsapp:+14155238886`) overflowed the row instead
  of clipping. Add `min-w-0` to the label and `shrink-0` to the status dots and
  the "· setup needed" span.
- [Low] Collapse the redundant triple-gating to a single mechanism: the dialog
  is mounted only via the parent `v-if="dialogOpen"`, so drop the now always-true
  `:open` prop and ChannelConfigDialog's internal `v-if="open"` (Escape binds for
  the component's lifetime via onMounted/onUnmounted).

(Skipped the a11y note — focus-trap/scroll-lock would be new behavior beyond the
existing ConfirmDialog parity; not a regression.)

Related to Abilityai/trinity-enterprise#19

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ui): modal a11y for ChannelConfigDialog — focus mgmt, trap, scroll lock (#19 review)

Addresses the open review finding: the dialog had Esc-to-close but no focus
management. Now on open it moves focus into the dialog (first focusable, else
the panel), traps Tab within it (wrap-around), locks background scroll, and
restores focus to the opener (Configure/Manage button) + the prior body
overflow on close. Findings #1 (status-label truncation) and #2 (redundant
dialog gating) were already addressed in earlier commits.

ConfirmDialog.vue shares the same gap; a shared modal primitive applying this
to both is the cleaner longer-term fix.

Verified: 3 SFCs compile, check:tokens passes, clean npm ci + vite build green.

* ci: retrigger checks against dev base

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
vybe pushed a commit that referenced this pull request Jul 7, 2026
…+ confirm-re-read (#1450) (#1495)

* test(canary): unblock canary_invariants fixture broken by #1472 merge

Two pre-existing breakages landed via #1472 (367a5e1, 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>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
vybe added a commit that referenced this pull request Jul 8, 2026
* fix(agent): make agent /tmp tmpfs size configurable via AGENT_TMP_SIZE (#1231) (#1233)

Agent containers mounted /tmp as a hardcoded 100 MB noexec,nosuid RAM-backed
tmpfs. It fills easily — e.g. `gh` CLI install artifacts (~38 MB) that hardcode
/tmp and bypass the #1098 TMPDIR redirect — after which every /tmp write fails
with "No space left on device", including git's commit scratch, so autonomous
scheduled runs "complete" but silently fail to persist. The size being a
literal meant operators couldn't tune it without a code change + base-image
rebuild.

- capabilities.py: AGENT_TMPFS_MOUNT size now read from AGENT_TMP_SIZE (env on
  the backend service, which builds the agent mount spec), default 512m,
  validated `^\d+[mg]$` with empty/invalid → default. noexec,nosuid stay
  hardcoded — only size is configurable, and it stays bounded (counts against
  the agent memory cgroup). Single source of truth, so create (crud.py) and
  recreate (lifecycle.py) can't drift.
- Wire AGENT_TMP_SIZE=${AGENT_TMP_SIZE:-512m} on the backend service in both
  docker-compose.yml and docker-compose.prod.yml; document in .env.example.
- architecture.md Container Security: note the now-configurable size.
- tests/unit/test_1231_agent_tmp_size.py: default, valid m/g, case-fold,
  invalid→default, and the security flags are never dropped.

Mount specs are creation-time: existing agents pick up a new size on recreate,
not restart. Builds on #1098 (TMPDIR redirect) — closes the gap for tools that
hardcode /tmp. The agent-side git-sync silent-persist-failure is a separate
issue in the abilities repo, per the ticket.

Related to #1231

Co-authored-by: Eugene Vyborov <1073874+vybe@users.noreply.github.com>

* feat(ui): per-schedule performance scorecards on Agent Detail (#1115) (#1149)

Surface per-schedule performance on the Overview tab and the Schedules tab,
both from a SINGLE compact aggregate (no N per-schedule round-trips) — extends
#1107 (Overview) and generalises #868 (per-schedule deep analytics).

Backend:
- db `get_agent_schedules_summary(agent, hours)` — one rollup row per
  non-deleted schedule (zero-run schedules included): terminal success_rate
  (success / (success + failed[incl. error]); None when zero terminal),
  NULL-skipping avg_duration_ms, cost_total, context_avg, tool_call_total
  (parsed over newest 5,000 rows agent-wide, tool_calls_sampled flag), and
  last-run outcome. Cheap grouped SQL; iso_cutoff window (Invariant #16).
- GET /api/agents/{name}/schedules/analytics-summary?window=7d|14d|30d
  (AuthorizedAgent). Declared BEFORE /{schedule_id} in routers/schedules.py
  so the literal segment isn't captured as a schedule_id (Invariant #4) —
  putting it in analytics.py would be shadowed (schedules_router mounts first).
- models: ScheduleSummaryRow + AgentSchedulesSummaryResponse (Invariant #14).

Frontend (single fetch, two consumers — Invariant #7):
- executions.js fetchSchedulesSummary, cached per ${name}:${window} like
  fetchAgentAnalytics.
- OverviewPanel: "Schedules performance" section, honors the existing 7/14/30d
  window selector, each row deep-links to the Schedules tab; hidden at zero.
- SchedulesPanel: inline mini-stats per row (success rate, avg duration, runs,
  last-run dot) — badge style, no new chart/modal — from the same call.

Tests: tests/unit/test_1115_schedules_summary.py (6) — terminal success rate,
NULL-skip avg, tool-call total, zero-run-still-appears, soft-delete excluded,
out-of-window excluded. Full analytics suites green (30 passed). Frontend
prod build clean; endpoint verified live across 7/14/30d windows.

Related to #1115

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <1073874+vybe@users.noreply.github.com>

* feat(ui): in-app bug reporting from the floating Help widget (#1116) (#1283)

* docs(readme): document the Trinity Ops Agent and PostgreSQL backend/migration (#1290)

Adds a top-of-README callout recommending PostgreSQL for production (SQLite remains the zero-config dev default, opt-in via DATABASE_URL, #300), links the public Trinity Ops Agent (trinity-ops-public) for instance operations, and documents migrating existing SQLite instances via its /migrate-to-postgres skill. Also adds a Database section, a DATABASE_URL env row, and an ops-agent entry in the docs index.

Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reliability): unify the SUB-003 auth-class failure classifier into one shared module (#1088) (#1297)

The `is_auth_failure` + `AUTH_INDICATORS` + `NON_AUTH_KILL_MARKERS` (#904)
logic was duplicated inline in `subscription_auto_switch.py` and
`scheduler/service.py`, kept in sync by a hand-written "keep these lists in
sync" comment — exactly how the #904 kill-marker bug class re-appears.

Consolidate into one canonical module:

- New `src/backend/services/failure_classifier.py` — canonical, pure-stdlib
  classifier (55 lines). `subscription_auto_switch.py` now re-exports
  `is_auth_failure` unchanged, so existing importers
  (`routers/chat.py`, `services/task_execution_service.py`) and their test
  patch targets keep working.
- New `src/scheduler/failure_classifier.py` — byte-identical vendored mirror.
  The scheduler runs in a separate container and cannot import
  `backend.services`; it uses the classifier for log-labelling only (picks the
  `logger.error` wording, never gates a switch). The agent-runtime classifier
  in `error_classifier` is intentionally NOT merged — it diverges semantically
  and stays kill-safe by `_classify_signal_exit` precedence (D4).
- Byte-identity is enforced by
  `tests/unit/test_904_sigkill_no_false_auth.py::TestBackendSchedulerParity`;
  the re-export is pinned by `TestBackendReExportGuard`. No hand-sync.

Pure structural refactor, no behavioral change (verified by SHA-256 equality
of the two copies and line-for-line comparison vs the deleted code). The test
rewrite also drops the prior `exec(compile(...))` source-slicing in favour of
`importlib` path-loading, removing the only injection primitive in scope.

Tests: 19/19 pass in `test_904_sigkill_no_false_auth.py`.
CSO --diff: CLEAR (docs/security-reports/cso-diff-2026-06-21.md).

Refs #1088

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(orchestration): pull-pilot routing for agent→agent MCP chat behind default-OFF flag (#946) (#1293)

* feat(orchestration): pull-pilot routing for agent→agent MCP chat behind default-OFF flag (#946)

Phase 2 PoC for pull/work-stealing (Epic #1045, umbrella #1081). When
MCP_AGENT_CHAT_PULL_ENABLED is ON, a sequential agent→agent (scope='agent',
non-self) chat_with_agent is routed by the MCP server through the durable async
/task path instead of the synchronous held /chat; the caller gets an immediate
{accepted|queued, execution_id} receipt and polls get_execution_result.
scope='user', self-tasks, and parallel=true are unchanged. Default OFF — flag
flip + MCP routing revert is the whole rollback.

- config.py: canonical MCP_AGENT_CHAT_PULL_ENABLED registry entry (both services
  read the SAME env key, so a single-.env deploy can't drift).
- settings.py: surface mcp_agent_chat_pull_enabled in /api/settings/feature-flags
  (auth-gated, observability-only — not a UI surface).
- chat.py: release the idempotency claim on the two /task dispatch-breaker-open
  (CircuitOpen) deny paths, mirroring /chat and CapacityFull (T5 fix) — without
  it a breaker-open reject silently blocks same-key retries for 24h.
- mcp-server: scope-based pull routing + D8 dispatch-mode idempotency token so a
  flag flip can't replay a wrong-shape snapshot across endpoints; startup log of
  the routing mode for the soak's control/treatment window.
- tests: chat.test.ts (routing fork + key behavior), test_946_task_idempotency_on_deny.py
  (deny-path claim release), feature-flag exposure tests.
- docs: ACTOR_MODEL_POSTCARD (#945 resolved), PULL_PILOT_946_SOAK harness +
  go/no-go record, CSO diff audit (CLEAR), TARGET_ARCHITECTURE/architecture updates.

Refs #946

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(feature-flows): document pull-pilot routing (#946) + AGENT_TMP_SIZE tmpfs (#1231)

Sync feature-flow docs with recent changes:
- agent-to-agent-collaboration.md: new Pull-Pilot Routing (#946) section —
  flag-gated MCP routing fork to the durable async /task path, poll-for-result
  receipt contract, D8 idempotency route token, feature-flag exposure, and the
  T5 /task dispatch-breaker-open deny-path claim release.
- container-capabilities.md: refresh stale tmpfs facts — agent /tmp size is now
  operator-configurable via AGENT_TMP_SIZE (default 512m, noexec,nosuid fixed),
  plus the TMPDIR=/home/developer/.tmp heavy-scratch redirect (#1098).
- feature-flows.md: add Recent Updates index rows for #946 and #1231.

Refs #946

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>

* feat(agent): runtime data_paths with portable export/import (#1169) (#1294)

* feat(agent): runtime data_paths with portable export/import (#1169)

Declare an agent's runtime data (SQLite DBs, datasets) under data/ on the
already-durable home volume — no separate volume, no platform schema change.

- template.yaml `data_paths:` surfaced by template_service (github + local)
  and materialized at creation by crud.py -> git_service.materialize_data_paths:
  writes ~/.trinity/data-paths.yaml and appends data/ + entries to the agent's
  own .gitignore (idempotent). Opt-in; empty list is a no-op.
- S4 persistent-state and data_paths now share one extracted heredoc/list
  primitive (materialize_trinity_yaml_list / _read_trinity_yaml_list).
- New routers/agent_data.py: POST /data/export (stream | base64, 413 over cap,
  manifest-only tar when data/ missing) and POST /data/import (proxies to the
  agent-server restore primitive; data/** allowlist + traversal guard;
  Idempotency-Key). Both serialized per agent by a cross-worker Redis op lock.
- MCP tools export_agent_data / import_agent_data (Invariant #13).
- Validation checks DP-001..DP-005 in agent-validation-spec.
- Docs: architecture, requirements, feature-flows index + agent-data-volumes
  flow, agent guide; CSO diff audit report (0 critical/high).

Tests: ~30 unit + TestClient tests (export/import endpoints, allowlist,
gitignore, template surface). PR2 (scheduled snapshots + pre-snapshot
quiesce hook + retention/cascade) deferred.

Closes #1169

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(agent-data): satisfy sys.modules pollution lint in #1169 tests

The two new data_paths test files copied the baselined `patch.dict` +
bare `del sys.modules[...]` loader from test_persistent_state_allowlist.py,
which the sys.modules pollution lint flags as NEW (non-baselined) violations.

Adopt the blessed snapshot/restore exception (precedent:
test_telegram_webhook_backfill.py): declare a top-level
`_STUBBED_MODULE_NAMES` list + an autouse `_restore_sys_modules` fixture, and
install the stubs / evict the cached module directly (the fixture owns
restoration). Removes the bare `del sys.modules[...]` entirely rather than
hiding it. Drop the now-unused `patch` import from the gitignore test.

Lint passes (no new violations); both files' 16 tests still green; no
cross-file leakage.

Refs #1169

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(feature-flows): sync recent changes — #1231 tmpfs, #1115/#1231 index rows

Fix container-capabilities.md for the now-configurable agent /tmp tmpfs
(#1231): default 100m → 512m via AGENT_TMP_SIZE, and correct the stale
full_capabilities ternary excerpts to the shared AGENT_TMPFS_MOUNT constant
(noexec,nosuid always applied, both modes). Add Recent Updates index rows for
the per-schedule performance scorecards (#1115) and the tmpfs-size fix (#1231);
the #1169 and #1116 rows already shipped in-commit.

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>

* fix(security): authenticate the in-container agent server on the shared agent network (#1159) (#1292)

* fix(security): authenticate the in-container agent server on the shared agent network (#1159)

The in-container agent server (:8000) had zero inbound auth on
trinity-agent-network: any agent could read a sibling's .env secrets or
run arbitrary Claude on it. Every backend->agent call now carries a
per-agent X-Trinity-Agent-Token = HMAC-SHA256(AGENT_AUTH_SECRET,
"trinity-agent-auth:v1:"+name), verified by a pure-ASGI middleware on all
HTTP and WebSocket routes (constant-time compare; only /health exempt).

- Derive-don't-store: the master AGENT_AUTH_SECRET lives only in the
  backend env (auto-generated by start.sh like SECRET_KEY); each container
  receives only its own token, so a compromised agent cannot compute a
  sibling's. Fail-closed -- derive raises on an empty secret.
- Callers route through services/agent_auth.py (agent_httpx_client /
  build_agent_auth_headers / merge_auth_headers); a static guard test
  fails any new raw agent-{name}:8000 caller that bypasses them.
- Removed the dead, unauthenticated /ws/chat route (ran arbitrary Claude)
  and the agent server's wildcard CORS (internal-only).
- Grace path for old images: empty TRINITY_AGENT_AUTH_TOKEN -> allow;
  check_agent_auth_token_env_matches forces a one-pass recreate to inject.
- Retired the unused src/scheduler/agent_client.py.

Tests: unit (matcher, middleware, header guard, derivation) + security
isolation test. CSO diff audit in docs/security-reports/cso-diff-2026-06-20.md.

Closes #1159

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(feature-flows): document agent-server authentication (#1159) + sync recent flows

Add a feature-flow doc for the in-container agent-server inbound auth shipped
in this PR, and sync the index with two recent merged changes.

- New feature-flows/agent-server-authentication.md: end-to-end trace of the
  derived X-Trinity-Agent-Token (HMAC over AGENT_AUTH_SECRET), the pure-ASGI
  middleware enforcing it on every HTTP/WS route, fail-closed vs grace path,
  recreate reconciliation, and the migrated callers + static guard. Added to
  the Authentication & Security catalog in the index.
- container-capabilities.md: refresh the stale /tmp tmpfs size (was a hardcoded
  100m) to the configurable AGENT_TMP_SIZE (default 512m, #1231).
- Recent Updates rows for #1159, #1231, and the previously-missing #1115.

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(voip): genericize moved-issue reference in feature-flow

#1039 (configurable data-retention) moved to the private enterprise
tracker; replace the now-private issue number in voip-telephony.md with a
generic description so the public doc doesn't deep-link a private issue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(agents): server-side compatibility validation with auto-fix (#668)

Run ~100 best-practice checks (11 categories) against a running agent's
workspace, surfaced non-blocking in the Overview tab with one-click
auto-fix for the 10 gitignore checks, plus an MCP tool.

- services/compatibility/ package (spec/collector/static_checks/ai_checks/fixes):
  ONE docker exec -> in-container python -> JSON snapshot (secret files
  existence-only, size/binary caps); pure STATIC checks (HARD-only) +
  category-batched AI checks (Haiku, iterate-expected, fail-open, capped at
  SOFT, secret-redacted); runtime-aware (claude-only checks skipped for
  Codex/Gemini).
- GET/POST endpoints (read AuthorizedAgentByName; fix OwnedAgentByName, gitignore
  only, per-agent Redis lock, atomic write, uncommitted until next sync;
  include_ai path rate-limited). agent_compatibility_results table (dual-track
  SQLite + Alembic) persists the latest snapshot; cascade/rename via AGENT_REFS.
- CompatibilityPanel.vue (two-phase fetch, grouped checklist, per-check fix,
  re-run) in OverviewPanel; get_agent_compatibility_report MCP tool.
- 35 fixture-driven unit tests; spec sync-tested against docs/agent-validation-spec.md.

Persistence departs from the issue's "no DB table" note so AI verdicts show
without re-spend + enable fleet aggregation (see requirements section 41).

Fixes #668

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(compat): remove polynomial-ReDoS in secret-assignment regex (#668)

CodeQL py/polynomial-redos (high): `_ASSIGN_RE` captured the value as
`[ \t]*(.+?)[ \t]*$`. The lazy `.+?` and the surrounding `[ \t]*` can both
match a tab, giving polynomial backtracking when `_redact()` runs the pattern
over up to 48 KB of agent-supplied file text.

Capture the value greedily to end-of-line (`(.*)$`) and let the callers
strip — both `_looks_placeholder()` callers already `.strip()`, so secret
detection and redaction are behaviourally identical (verified: `=`, `:`,
`export`, and indented forms still match). 35 unit tests pass.

* feat(sso): OSS gated surface for enterprise SSO (OIDC) (#32)

Companion to trinity-enterprise#36. OSS carries only the entitlement-gated
surface; all SSO logic lives in the private submodule.

- Login.vue: "Sign in with <IdP>" buttons (shown only when the `sso` feature is
  entitled and a provider is enabled), plus OIDC callback-fragment handling
  (`/login#sso=ok|mfa|error`) — reuses the existing 2FA challenge UI when the
  IdP login still requires a local second factor.
- stores/auth.js: completeSsoLogin() (reuses _finalizeLogin / _setMfaChallenge)
  + fetchSsoProviders() (empty in OSS-only builds — endpoint 404s).
- Settings.vue: admin-gated "SSO" tab → SsoPanel.vue (provider CRUD + test +
  policy). Gated by enterpriseStore.isEntitled('sso'), same as the 2FA tab.
- Bump enterprise submodule to the SSO module commit.
- docs: architecture enterprise-modules row + requirements §40 (SSO/OIDC).

No new backend dependency (python-jose + httpx already in the image) and no
OSS Python changes — the mint/whitelist/mfa seams already exist.

Stacked on feat/5-2fa-totp (reuses the OSS mfa_gate + 2FA challenge surface).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(sso): bump enterprise submodule to OIDC hardening (#32 review)

Pulls in the email_verified / issuer-pinning / login-CSRF fixes
(trinity-enterprise 87c8f97). OSS gated surface unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(planning): note incubating goal-directed direction; reword voip flow

Add an "Incubating Directions (Not Yet Decided)" section to
TARGET_ARCHITECTURE.md capturing the goal-directed control-surface idea
(Objective + policies + roster + externally-measured evals), explicitly
bounded by CLAUDE.md §8 and sequenced after the pull migration + #300.
Incubating in trinity-enterprise#27.

Reword the voip-telephony flow note to drop a stale #1039 reference in
favor of describing the LOG_* data-retention no-op class directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(security): add CSO 2026-06-21 posture report

Routine /cso full-audit posture report (Phases 0–14, daily 8/10 gate).
Follows the docs/security-reports/ convention; no real secrets reproduced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(user-docs): video library + per-page links, v0.6.1 What's New, sync dev features

- Add videos.md (35 published videos, newest-first by topic) and a README Watch section
- Add 'Watch' callouts to 32 feature pages linking the most relevant, newest videos
- Add user-facing whats-new/v0.6.1.md (translated from release notes; no issue numbers)
- Document dev-only features: agent runtimes (Claude Code/Codex/Gemini CLI, #1187),
  agent data paths + export/import (#1169), compatibility validation (#668),
  in-app bug reporting (#1116), subscription hot-reload (#1089), pull-pilot routing (#946),
  configurable AGENT_TMP_SIZE (#1231), Postgres migration-runner groundwork (#1160)
- Index agent-runtimes, agent-data, and the previously-orphaned agent-session pages

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(setup): first-run operator intake + admin email login (abilityai/trinity-enterprise#38, #82)

Capture an optional operator email/company at first-run setup with an explicit,
unchecked-by-default opt-in to "occasionally receive important security & product
updates", submitted once to a new /v1/operator-intake endpoint on #1116's
Cloudflare intake app. The same email binds as the admin's sign-in identity so
the operator can log in with email + password — no verification email is sent (a
fresh install has no Resend key; the email is bound, not code-verified). The
code-based email second factor stays Phase 2 on the existing mfa_gate seam.

- backend: operator_intake_service (fire-and-forget, at-most-once via a
  system_settings marker, DO_NOT_TRACK aware, owns installation_id); setup
  endpoint captures profile + binds admin email; authenticate_user resolves the
  admin by username OR registered email (password guard blocks code-only users);
  PUT /api/users/me/email for the existing-admin transition
- frontend: SetupPassword email/company + consent checkbox; Login "username or
  email" field; Settings -> General "Admin sign-in email" card
- config: OPERATOR_INTAKE_ENABLED / OPERATOR_INTAKE_URL (+ .env.example)
- docs: requirements section 43, architecture catalog, first-time-setup feature flow
- tests: 16 unit tests (intake idempotency/guards, email-login resolution, setup)

Fixes abilityai/trinity-enterprise#38
Fixes #82

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(feature-flows): index row for first-run intake + admin email login (trinity-enterprise#38, #82)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(setup): de-ambiguate email regex to clear CodeQL polynomial-ReDoS (#82)

The _EMAIL_RE pattern duplicated into setup.py and users.py had two
[^@\s]+ atoms around the literal \. that both also match '.', giving the
engine many ways to place the dot and backtracking polynomially on
user-controlled email input (CodeQL alerts #211, #212).

Constrain only the final segment to [^@\s.]+ (no dot) so the trailing \.
can align with exactly one position -> linear matching. Behaviour is
unchanged: multi-subdomain addresses still validate; an 80k-char
pathological input now resolves in ~2ms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(credentials): curated credential file-type injection (SA keys, certs, SSH, binary) (#1305)

* feat(credentials): curated credential file-type injection — SA keys, certs, SSH, binary (enterprise#11)

Widens CRED-002 injection from the fixed 3-path exact allowlist
(.env/.credentials.enc/.mcp.json) to a curated set of credential file *types*,
without reopening the arbitrary-path RCE surface (#183/#590/#598).

- New services/credential_paths.py — single-source policy: ALLOW (.config/gcloud/**,
  .kube/config, *.pem/*.key/*.crt/*.cert/*.p12/*.pfx, .ssh/id_*, + existing exact set)
  with deny-precedence over anything executed/sourced at startup (shell rc,
  CLAUDE.md/AGENTS.md/.claude/**, .mcp.json.template, .ssh/authorized_keys/config,
  .git*, bin/**) and `..`/absolute traversal. Vendored byte-identically into the
  agent image (Invariant #5) with a parity test.
- Agent-server hardening: the inject + update file loops now enforce the policy AND
  a resolve-under-home traversal guard the original write path lacked; parent-dir
  creation + chmod 0o600 preserved. New GET /api/credentials/list for export discovery.
- Binary-safe: inject carries files_b64 (base64); agent writes via write_bytes.
  .credentials.enc gains a v2 {files, files_b64} envelope (legacy flat archives still
  decrypt); encrypt/decrypt stay flat for the single-secret callers (SIEM/2FA/SSO).
- Export now captures the FULL injected set (via /list) + binary, not just the 2 defaults.
- Three surfaces in sync (Invariant #13): MCP inject_credentials gains files_b64;
  frontend CredentialsPanel gains a file-upload affordance (text vs base64 auto-detected).
- Tests: allowlist test now exercises the REAL policy (newly-allowed + still-blocked),
  + credential_paths parity test + binary archive round-trip test. 61 pass.
- docs/memory/architecture.md: credential-path policy documented.

Related to Abilityai/trinity-enterprise#11. Loosens a deliberately-tight boundary —
run /cso on the diff before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(credentials): close /cso findings on the injection widening (#11 review)

Security review of the widening surfaced one HIGH regression + hardening items;
all fixed here.

#1 (HIGH, RCE): the #598 .mcp.json content-validation guard was bypassable via
the new files_b64 (binary) channel — validate_mcp_config only checked `files`,
so `files_b64={".mcp.json": base64(<stdio-command MCP server>)}` skipped it and
configured an RCE MCP server on the target agent. Fix: .mcp.json may only arrive
as TEXT (files), where it is validated; rejected in files_b64 at the backend
inject router AND the agent-server write helper.

#2 (defense-in-depth): import/auto-import wrote decrypted archives via the
agent-server /inject layer only. Added validate_credential_set() (curated path
policy + .mcp.json content + no-binary-.mcp.json) on the backend import boundary
so enforcement is dual-layer as the issue mandates. (Archives are AES-GCM with
the server key, so a forged archive wasn't practical — but the layer belongs.)

#3: .ssh/ is now locked to id_* only — a stray *.key/*.pem under .ssh is no
longer accepted (policy was previously broader than the "SSH keys = id_*" intent).

#4 (noted): .config/gcloud/** can hold a google-auth executable credential_source;
only honored under non-default GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1.
Documented in credential_paths.py.

+6 regression tests (169 pass). CSO report: docs/security-reports/cso-2026-06-22-11-diff.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(credentials): exclude vendor dirs from cert globs + accurate export count (#11 live test)

Found while testing PR #1305 against a real local instance:

1. Over-capture: the broad *.pem/*.key/*.crt globs matched bundled CA files
   (e.g. .local/.../site-packages/certifi/cacert.pem), so export's /list walk
   swept vendored cert material into .credentials.enc. Added node_modules,
   site-packages, .local, .venv/venv, .cache, go/pkg to the deny-list (both
   root and nested forms) so cert globs only catch real credential files.

2. export's files_exported count re-read just the 2 default files (reported 1
   while the archive actually held 5). export_to_agent now returns the true
   captured count; dropped the redundant stale read.

Verified end-to-end on a live agent: allowed types inject (text+binary, 0600,
parent dirs), blocked paths 400 (incl. .ssh non-id_*, .mcp.json-via-files_b64,
weaponized .mcp.json text), and binary round-trips through export→import with
matching sha256. +5 regression tests (72 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(security): annotate CSO 2026-06-21 findings as remediated; drop stale voip hunk

Two review-driven fixes ahead of the v0.7.0 cut:

- Annotate the CSO posture report (.md + .json) with post-audit remediation
  status. The report audited `main` pre-cut and listed F1/F2/F3 as open
  VERIFIED findings; they are already remediated on `dev` and ship in v0.7.0:
    - F1 (unauth agent-server) -> #1159 X-Trinity-Agent-Token middleware
    - F2 (fastmcp -> hono/undici) -> #1255, #1289; fastmcp ^4.3.0
    - F3 (form-data CRLF via axios) -> #1254
    - F4/F5/F8 exploit path closed by #1159 (auth gate)
  Adds a top-of-report banner, per-row status tags, per-finding notes, and a
  machine-readable `remediation_status` block in the JSON. Avoids publishing a
  stale "open CRITICAL + exploit" to a PUBLIC repo without its fix context.

- Drop the voip-telephony.md reword: it is superseded by already-merged #1301,
  which made the identical `#1039` -> `LOG_*` change on `dev`. Restoring to
  merge-base removes the redundant/conflicting hunk from this PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: set version to 0.7.0

* feat: streamline first-time setup wizard (abilityai/trinity-enterprise#49)

Drop the log-copied setup token, require an admin email, and rebuild the
first-run page as a welcoming animated welcome screen.

Backend (routers/setup.py, main.py):
- Remove the setup-token machinery entirely (ensure_setup_token /
  clear_setup_token / Redis-shared token + the main.py startup emission).
  Setup no longer depends on Redis — the admin write goes straight to SQLite.
- Make admin email REQUIRED (sign-in identity): missing -> 422 at the model
  layer; blank/typo -> 400, validated before any write so setup never
  half-completes. Password complexity (OWASP ASVS 2.1) still enforced.
- get_setup_status keeps setup_available:true for frontend back-compat.

Frontend (SetupPassword.vue):
- Full redesign: dark branded hero with an animated orbiting fleet
  constellation (Trinity mark core + agent nodes on three rings), split
  layout (stacks on mobile), prefers-reduced-motion aware.
- No setup-token field; email required; order email -> password (+confirm)
  -> company -> updates opt-in. Removed the Redis-wait panel + polling.

Security tradeoff (chosen: accept + document): removing the token leaves the
unauthenticated first-run window with no proof-of-control. Documented as an
operator responsibility (deploy behind a tunnel/VPN until setup completes) in
docs/DEPLOYMENT.md Security Recommendations; endpoint still self-disables
after first success. See docs/security-reports/cso-diff-2026-06-23.md (F1).

Docs: DEPLOYMENT.md security note, architecture.md, requirements.md
(§15.2/§43), feature-flows/first-time-setup.md.

Tests: remove obsolete test_1165_setup_token_shared.py; update test_setup.py
(no token, email required) and test_setup_operator_profile.py (email
required, model-layer + blank/invalid rejection). 7 operator-profile unit
tests pass; new contract verified live (422/400 negative paths).

Fixes abilityai/trinity-enterprise#49

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(setup): full-bleed setup screen via normal flow, not position:fixed

The redesigned first-run page used `position:fixed; inset:0` for its root.
On wider viewports this left a band of the light `#app` (bg-gray-100)
background showing through on the right/bottom — a fixed root is clipped to
the nearest transformed/contained ancestor instead of the viewport, so its
coverage isn't guaranteed.

Switch the root to the original component's proven normal-flow approach
(`position:relative; width:100%; min-height:100vh`), which fills the
full-width `#app`, and make the decorative aurora/grid `position:absolute`
within it. Verified covering the full viewport at 2560x1440 (light mode, the
repro case) and stacking correctly at 430px.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tests): defer routers.setup import so unit collection can't be corrupted

The CI backend-unit regression gate runs `cd tests && pytest unit/` (the whole
unit suite). test_setup_operator_profile.py imported `routers.setup` at module
(collection) time; that import — pulling in database/dependencies/services and
their many `utils.*` leaves — failed/perturbed sys.modules during collection and
INTERRUPTED the entire `unit/` collection (head collected ~2 of 2734 → the diff
gate flagged it as a new failure).

Defer the `import routers.setup` to a cached `_get_setup()` accessor used inside
the tests, so module collection imports only stdlib/pytest/fastapi/pydantic and
can never corrupt the suite. `_get_setup()` also spec-preloads the backend
`utils.*` leaves (helpers/errors/credential_sanitizer/password_validation/
url_validation/image_optimize) the same way conftest preloads `utils.helpers`,
without touching `sys.modules["utils"]`, so the import resolves cleanly at run
time regardless of harness utils state.

Verified with the exact CI command (`cd tests && pytest unit/ --co`): the full
suite now collects 2734 items with no interruption, and the 7 setup tests pass.
No conftest changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tests): drop sys.modules preload — plain lazy import (passes #762 lint)

The previous commit's `_get_setup()` spec-preloaded backend utils leaves via
`sys.modules[...] = …` / `.pop`, which tests/lint_sys_modules.py (#762) bans
outside conftest. It's also unnecessary: in the backend-unit gate
(`cd tests && pytest unit/`), tests/unit/conftest.py already installs
src/backend/utils as the canonical `utils` package, so a plain lazy
`import routers.setup` resolves the backend `utils.*` leaves natively.

Simplify `_get_setup()` to a cached plain lazy import — no sys.modules
mutation. Verified: lint clean (no new violations), `pytest unit/ --co`
collects 2734 with no interruption, and the 7 setup tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tests): update #858 guards for setup-token removal (#49)

Removing the setup token (trinity-enterprise#49) deleted
`routers/setup.py::ensure_setup_token` and the lifespan token emission, so two
#858 regression guards asserted gone behavior and failed in the backend-unit
gate:
  - test_ensure_setup_token_logs_token_via_logger_warning
  - test_lifespan_emits_setup_token_via_logger_before_event_bus

The #858 invariant itself is intact: the lifespan still emits the first-run
notice via `logger.warning` (not print), after setup_logging() and before
event_bus.start(). Replace the token-specific guard with one that matches the
new FIRST-TIME SETUP warning by content + ordering, drop the now-obsolete
ensure_setup_token guard, and remove the unused BACKEND_SETUP constant. The
Dockerfile PYTHONUNBUFFERED parity checks and the no-print-in-lifespan guard are
unchanged. 4 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(whatsapp): deliver ChannelResponse.files as Twilio MediaUrl (#1315)

WhatsApp agents can now send files to users. send_response delivers
ChannelResponse.files as Twilio MediaUrl attachments (one message per file,
text first), reaching parity with the Slack adapter.

- New create_share_from_bytes() persists in-memory bytes through the FILES-001
  pipeline (MIME-blocklist/quota/disk/DB) and mints a public ?sig= URL; both it
  and create_share now share the extracted _persist_and_register helper.
- Per-agent file_sharing_enabled gate; 1h share TTL (cleanup reaper purges).
- Caps (image/audio/video ~5MB, documents ~16MB) on the detected MIME; graceful
  text-link fallback when public_chat_url is unset/non-HTTPS, the MIME is
  unsupported, or the file is oversized — never silently dropped.
- Per-file isolation: a rejected/failed file never aborts the text or siblings.
- 42 unit tests; requirements.md + whatsapp-integration.md updated.

Fixes #1315

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(whatsapp): webhook routes to backend, not frontend (#1281) (#1316)

The WhatsApp panel's deployment-prerequisite notice told operators to route
/api/whatsapp/webhook/* to the "frontend service". That path is a backend
FastAPI route (Twilio HMAC-verified); pointing tunnel ingress at the static
SPA silently drops inbound messages. Corrected to the backend service
(http://backend:8000), matching the cited PUBLIC_EXTERNAL_ACCESS_SETUP.md.

Related to #1281

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ui): loading skeletons for Dashboard graph & timeline (#1266) (#1312)

The initial fleet/metrics load can take 20s+ on 10+ agent fleets (#1265);
until now the Dashboard rendered the "No agents" empty state (or blank
timeline) during that wait, so the UI looked frozen/broken.

- New reusable `SkeletonLoader.vue` (dark-mode aware, accessible
  role=status/aria-busy, reserves space to avoid layout shift) with `rows`
  (timeline/list) and `nodes` (collaboration graph) variants.
- `stores/network.js`: add `loading` (defaults true so the first paint is a
  skeleton, not the empty state) + `loadError` (distinct failed-load state),
  toggled in `fetchAgents` (finally-cleared so a failure never shows an
  infinite skeleton).
- `Dashboard.vue`: graph canvas and timeline now render skeleton → error →
  empty → content off those flags. Loading shows immediately on nav; error
  states offer a Retry (reuses `refreshAll`).

Frontend-only; pairs with the backend perf work in #1265. `vite build` passes.

Related to #1266

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(db): set SQLite end-of-support to September 1, 2026 + Postgres migration notes (#1278) (#1314)

Records the firm SQLite end-of-support date and the SQLite → PostgreSQL
migration announcement/guidance. Documentation/decision only — SQLite code
removal stays with the migration work (#300/#1183/#746).

- docs/migrations/SQLITE_TO_POSTGRES.md (new): authoritative guide — EOL date,
  what changes and when, switching a fresh deployment (DATABASE_URL + postgres
  profile), migrating an existing deployment (backup-first; no turnkey data-copy
  tool yet — honest cutover options), verification, and release-notes copy.
- docs/releases/v0.6.2.md (new, draft): EOL announcement section linking the
  guide, seeding the next release notes.
- docs/planning/TARGET_ARCHITECTURE.md + docs/memory/architecture.md (Invariant #3):
  reference the EOL date so it's discoverable outside the release.
- Cross-links the in-repo reminder companion (#1279).

Related to #1278

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: stop disclosing enterprise functionality in public docs (trinity-enterprise#45) (#1311)

* docs: stop disclosing enterprise functionality in public docs (trinity-enterprise#45)

The public repo documented the full design, feature catalog, and gating
strategy of the paid enterprise tier — a free blueprint of what we monetize
and how it's built. This removes that competitive content and keeps only the
generic open-core seam public.

- Delete 4 strategy/design docs (OSS_ENTERPRISE_SPLIT_RESEARCH,
  ENTERPRISE_ARCHITECTURE, feature-flows/enterprise-modules, ENTERPRISE_LOCAL_DEV)
- architecture.md "Enterprise Modules" table -> neutral seam pointer
  (no paid-feature catalog, no enterprise_* table DDL, no per-module detail)
- requirements.md §35 -> abstract EntitlementService seam (drop the enumerated
  module list + dead links to the deleted strategy docs)
- audit-trail.md: neutralize the lone enterprise-pillar mention
- CLAUDE.md: standing rule — enterprise designs live only in trinity-enterprise
- CI: enterprise-docs-guard.yml fails the build if live public docs reintroduce
  paid-feature / private-schema tokens

Content is preserved (relocated to the private trinity-enterprise repo, see the
companion PR). Git-history scrub of the deleted files + point-in-time historical
docs (archive/, releases/, security-reports/) tracked as a follow-up.

Related to Abilityai/trinity-enterprise#45

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(enterprise-docs-guard): add least-privilege permissions block

Clears CodeQL actions/missing-workflow-permissions (medium). The guard only
checks out and greps, so contents: read is sufficient.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(releases): add 0.7.0 release notes

* fix(#1115): port get_agent_schedules_summary to SQLAlchemy Core (Postgres-safe)

The #300 SQLAlchemy migration dropped the get_db_connection import from
db/schedules.py but left get_agent_schedules_summary (#1115) calling it,
so the /schedules/analytics-summary endpoint raised NameError at runtime.
Surfaced for the first time by the v0.7.0 release-PR full-suite run (dev
pushes only lint).

Port the method to get_engine() Core queries like its siblings, and
replace the SQLite-only bare-column-with-MAX last-run query with a
portable ROW_NUMBER() window so it works on PostgreSQL too.

Also refresh the test_login_rate_limit_split config stub, which went
stale when auth.py grew a PUBLIC_ACCESS_REQUESTS_ENABLED dependency
(trinity-enterprise#10) — 8 collection errors under HEAD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(voip): per-agent VoIP config panel + persisted voice (abilityai/trinity-enterprise#28)

Add the missing per-agent VoIP config UI (agent Settings/Sharing tab) and a
persisted per-agent Gemini voice. Shipped as plain OSS gated on the existing
voip_available platform flag — NOT entitlement-gated (a UI gate over a
money-spending OSS backend would be cosmetic; deliberate simplification of the
issue's original "entitlement-gated" framing).

Backend:
- agent_ownership.voice_name (default Kore) via dual-track migration
  (SQLite db/migrations.py + Alembic 0004 + schema.py/tables.py). db
  get/set_voice_name with read-path fallback to Kore for unset/invalid values.
- GET/PUT /api/agents/{name}/voice/name (PUT owner-only, validated against
  GEMINI_VOICE_NAMES). _get_voice_name and voip_service now read the persisted
  voice instead of the two hardcoded "Kore" sites.
- PUT /api/agents/{name}/voip/enabled toggle (owner-only, 404 when no binding);
  create_binding upsert no longer forces enabled=1 so re-saving credentials
  preserves a disabled state (call path already refuses disabled bindings).

Frontend:
- VoipChannelPanel.vue (modeled on WhatsAppChannelPanel) mounted in SharingPanel
  under voip_available; shared src/constants/voices.js (drift-guarded vs backend);
  AgentWorkspace picker defaults to the persisted voice; sessions store surfaces
  voip_available.

Tests: tests/unit/test_28_voip_voice_config.py — voice fallback/roundtrip/
invalid->default, enable toggle + re-PUT-preserves-disabled (H3), and the
frontend/backend voice-list drift guard. Schema-parity + voip-db guards green.

Refs abilityai/trinity-enterprise#28

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(voip): HTTP-level endpoint tests for /voice/name + /voip/enabled (#28 review I1)

Pre-landing /review flagged that the new endpoints were covered only at the DB
layer. Add FastAPI TestClient tests (mount real routers, override auth deps, stub
db/voip_service) asserting:
- PUT /voice/name: owner-gated (403), 400 on unknown voice, empty clears to
  default, valid voice persists; GET returns voice_name + available_voices.
- PUT /voip/enabled: owner-gated (403), 404 when no binding / when voip flag off,
  200 reflecting state with no auth_token leaked.

Also capture a durable learning (docs/memory/learnings.md): the schema-parity
test is blind to db/tables.py drift — a missing Column there passes parity but
breaks at runtime; guard it with a db-accessor unit test that executes a live
select on the new column.

Refs abilityai/trinity-enterprise#28

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(docs): agent-readable zero-to-value onboarding (#1280) (#1336)

* refactor(docs): agent-readable zero-to-value onboarding (#1280)

Make the repo's entry points machine-first so an autonomous agent can self-orient and reach a useful result without a human translating context.

- AGENTS.md: add a "Using this file" machine-contract header (declares it the authoritative agent entry point, states the AGENTS/CLAUDE/README boundary, explains how to traverse). Rebuild Route-by-task with an explicit "Done when" zero-to-value signal per persona.
- CLAUDE.md: cross-link to AGENTS.md and frame CLAUDE.md as the contributor working agreement (auto-loaded by Claude Code), not the agent landing page.
- Fixes from a context-free agent onboarding test (AC#5 validation): AGENTS.md deploy verify no longer assumes an undefined $TOKEN (leads with `trinity agents list`, shows token derivation); deploy section states the running-instance prerequisite; README CLI example adds the `trinity agents list` verify step; docs/CLI.md leads with `pip install trinity-cli` (PyPI) and marks `-e src/cli/` as the from-source/dev variant.

Validated by an agent performing a zero-to-value deploy task using only repo files, no human context: self-oriented in 2 hops (README -> AGENTS.md) to a correct deploy+verify answer; friction items above are its findings, folded back in.

Repo-root AGENTS.md is hand-authored; the CLAUDE.md->AGENTS.md mirror (#1187) is per-agent-container (startup.sh), so these edits are conflict-free.

Related to #1280

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(templates): machine-readable starter-template catalog (#1280)

Affordance sweep for agent self-selection. Previously an agent had to `ls`
config/agent-templates/ (24 dirs, 7 of them test fixtures) and open each
template.yaml to find a starting point.

- config/agent-templates/README.md: catalog grouping the 17 real templates
  (single-purpose: scout/sage/scribe/demo-*/trinity-system; the dd-* due-
  diligence suite) with one-line affordances, how-to-use, and an explicit
  "not starting points" list for the test/canary fixtures
- AGENTS.md: link the catalog from the Deploy-an-agent section so the
  zero-to-value path is "pick a ready-made template", not "author from scratch"

Related to #1280

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tests): un-quarantine and fix the 15 unmasked unit failures (#1103) (#1338)

Removes the @pytest.mark.skip quarantines added in #300 and fixes the
underlying issues. Each group fixed at root cause, not by matching assertions
to current behavior.

Environmental (git identity):
- tests/unit/conftest.py: set GIT_AUTHOR/COMMITTER_NAME/EMAIL process-wide so
  in-test `git commit` works on a CI runner with no global git identity
  ("Author identity unknown"). Fixes test_reset_preserve_state_guardrails (3)
  and the test_git_pull_branch end-to-end setups.

Test-setup bug (production code was correct):
- test_git_pull_branch.py: the repos did `git push -u origin main` but `git init`
  defaults to `master` (no init.defaultBranch), so origin/main never existed and
  _get_pull_branch correctly fell back to the working branch — the assertions
  expecting "main" failed. Force `git init -b main` (local + bare). Fixes all 5
  (TestGetPullBranch 2 + TestGitPullFromMainEndToEnd 3).

Test-isolation bug (assertions were correct):
- test_orphaned_execution_recovery.py: shared module-level mocks were reset with
  plain reset_mock(), which keeps return_value/side_effect — so one test's
  get_agent_container.side_effect bled into later tests under random ordering,
  skewing recovery counts ("assert 3 == 2"). Reset with
  reset_mock(return_value=True, side_effect=True). Stable across 5 seeds.

Real lint findings:
- docker/base-image/startup.sh: shellcheck now exits 0. Converted the 4 fragile
  file-iteration loops to `find -print0 | while read` (SC2010/SC2045/SC2044),
  hardened 8 `cd` with `|| exit 1` (SC2164), split the SC2155 export, and
  documented-disabled SC2001 on the two regex `sed` lines that ${//} can't
  express. `bash -n` clean. Un-skips test_startup_sh_shellcheck_clean.

backlog (3) and 929 (1) were already un-quarantined on dev (db_harness schema),
so no change needed there.

Verified: the 11 un-skipped tests pass across multiple random seeds; full unit
suite shows no regressions from these changes (the unrelated pre-existing
test_1115_schedules_summary / test_admin_email_login failures fail on dev too).

Related to #1103

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore(deps-dev): bump happy-dom (#1326)

Bumps the patch-and-minor group in /tests/git-sync with 1 update: [happy-dom](https://github.com/capricorn86/happy-dom).


Updates `happy-dom` from 20.10.5 to 20.10.6
- [Release notes](https://github.com/capricorn86/happy-dom/releases)
- [Commits](https://github.com/capricorn86/happy-dom/compare/v20.10.5...v20.10.6)

---
updated-dependencies:
- dependency-name: happy-dom
  dependency-version: 20.10.6
  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>

* chore(deps-dev): bump @types/node in /src/mcp-server (#1327)

Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.9.3 to 26.0.0.
- [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.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump fastmcp (#1325)

Bumps the patch-and-minor group in /src/mcp-server with 1 update: [fastmcp](https://github.com/punkpeye/fastmcp).


Updates `fastmcp` from 4.3.0 to 4.3.2
- [Release notes](https://github.com/punkpeye/fastmcp/releases)
- [Commits](https://github.com/punkpeye/fastmcp/compare/v4.3.0...v4.3.2)

---
updated-dependencies:
- dependency-name: fastmcp
  dependency-version: 4.3.2
  dependency-type: direct:production
  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>

* chore(deps): bump the patch-and-minor group (#1329)

Bumps the patch-and-minor group in /src/frontend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [axios](https://github.com/axios/axios) | `1.18.0` | `1.18.1` |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.61.0` | `1.61.1` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.5.0` | `10.5.1` |
| [@rollup/rollup-darwin-arm64](https://github.com/rollup/rollup) | `4.62.0` | `4.62.2` |
| [@rollup/rollup-linux-arm64-musl](https://github.com/rollup/rollup) | `4.62.0` | `4.62.2` |


Updates `axios` from 1.18.0 to 1.18.1
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.18.0...v1.18.1)

Updates `@playwright/test` from 1.61.0 to 1.61.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.61.0...v1.61.1)

Updates `autoprefixer` from 10.5.0 to 10.5.1
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.5.0...10.5.1)

Updates `@rollup/rollup-darwin-arm64` from 4.62.0 to 4.62.2
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.62.0...v4.62.2)

Updates `@rollup/rollup-linux-arm64-musl` from 4.62.0 to 4.62.2
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.62.0...v4.62.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch-and-minor
- dependency-name: "@playwright/test"
  dependency-version: 1.61.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: patch-and-minor
- dependency-name: autoprefixer
  dependency-version: 10.5.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: patch-and-minor
- dependency-name: "@rollup/rollup-darwin-arm64"
  dependency-version: 4.62.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch-and-minor
- dependency-name: "@rollup/rollup-linux-arm64-musl"
  dependency-version: 4.62.2
  dependency-type: direct:production
  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>

* feat(executions): propagate cancelled terminal status end-to-end (#679) (#1333)

Defense-in-depth follow-up to #671. Make the agent task-runner aware that an
operator cancel happened and surface a third terminal outcome — `cancelled` —
alongside success/failed, so a cancel is never recorded as a billable success
or an agent failure.

Agent server:
- ProcessRegistry records a `_terminated[execution_id]` marker on a successful
  SIGINT send; `was_terminated()` (read-only, 300s lazy TTL, cleared on
  register) lets the sync chat handler and async result callback relabel a
  graceful-exit-0 / SIGKILL->504 turn as cancelled.
- `record_task_finish` accepts a neutral finish (success=None): a cancel
  neither resets nor increments the dispatch-breaker failure counter (#526).

Backend:
- 3-way status map (success->SUCCESS, cancelled->CANCELLED, else->FAILED) in
  the async callback (routers/agents.py) and the sync applier
  (task_execution_service). An auth/rate terminal is never reclassified as a
  cancellation — guarded at the backend trust boundary too (CSO finding 2).
- Consumers (message_router, chat, paid, public, validation_service) treat
  cancelled as non-delivery; paid no longer settles on cancel (money bug).
- terminate writes CANCELLED only when it actually stopped a running turn; on
  already-finished the agent's real terminal stands (Issue 7).

Tests: 9 new unit suites (64 cases) + execution-termination integration
additions; 85 unit tests pass locally. CSO diff audit: CLEAR.

Refs #679

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ui): unify Chat + Session into one Chat tab with a session-mode toggle (#1112) (#1340)

Collapse the redundant Chat + Session tabs on Agent Detail into a single "Chat"
tab carrying a "Session mode" toggle (default ON), keeping the legacy stateless
surface as a first-class user-selectable mode rather than dead code.

- AgentDetail.vue: single `{ id: 'chat' }` tab (drop the separate Session entry).
  New `chatMode` ref ('session'|'legacy', default 'session') persisted per-user in
  localStorage['trinity.chatMode']. `sessionAvailable` = feature flag on AND
  runtime has --resume (not Codex); `effectiveChatMode` forces legacy when the
  Session surface is unavailable and hides the toggle. The toggle swaps
  SessionPanel ↔ ChatPanel in-place (v-if). isFullscreenTab keys on the single
  'chat' id; `?tab=session` aliases to 'chat' (hinting session mode).
- Execution-resume: ExecutionDetail "continue as chat" (?tab=chat&resumeSessionId)
  forces legacy ChatPanel (which owns resume) via a transient, non-persisted
  routeForcedMode — without rewriting the user's saved preference.
- No backend change (session_tab_enabled already exists). MobileAdmin unaffected:
  its openChat is a self-contained mobile chat overlay, not an AgentDetail
  deep-link, so there is nothing to repoint.
- docs: architecture Session Tab block + requirements §5.8 note.

Related to #1112

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(access): Access tab — manage Trinity operators per agent (trinity-enterprise#17) (#1317)

New Access tab on Agent Detail that manages Trinity operators (platform users)
with access to an agent, distinct from the Sharing tab's external channel
clients. Draws the operator-vs-client line on the read path.

Backend:
- db.get_agent_operator_access(): outer-joins agent_sharing × users on the
  grantee email (lower-cased, engine-based → PG+SQLite). Resolved → active
  operator (username/role/last_active); unresolved → pending invite.
- GET /api/agents/{name}/access (AgentOperatorAccess model). Add/remove reuse
  the existing /share + /share/{email}.

Frontend:
- AccessPanel.vue: operator roster (status + role badges, last-active), add by
  email, remove. Access tab wired into AgentDetail (owner-gated).
- SharingPanel.vue: Team Sharing allow-list removed (moves to Access); dead
  share-management script + stale "Team Sharing below" copy cleaned/repointed.
- stores/agents.js: getAgentAccess().

Tests: active-vs-pending classification + agent scoping. vite build passes.

Note: the strict client-vs-operator split (non-user emails → a dedicated client
roster) is deferred to the Sharing-side redesign (#18/#20); removing them here
now would orphan those grants, so all allow-list entries stay visible on Access.

Related to Abilityai/trinity-enterprise#17

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ui): slim the Overview executions-by-type bars

The "Executions by type" stacked bars rendered full-width with a 1px
gap, so a busy agent's week read as a solid wall of color. Cap each
bar at 56px and center it inside its (still full-width) hover column,
and soften the top corner. The column stays flex-1 so spacing/tooltips
are unchanged and wider windows (14d/30d) thin naturally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(voip): add Gacrux to the Gemini Live voice picker

Adds the "Gacrux — Mature" prebuilt voice to the per-agent VoIP voice
selector (and the shared AgentWorkspace per-session picker, which reads
the same list). Updated in lockstep across the three mirrored sources so
the frontend↔backend parity test stays green:

- src/frontend/src/constants/voices.js — single frontend source of truth
- src/backend/config.py GEMINI_VOICE_NAMES — write-validation allowlist +
  read-path fallback
- tests/unit/test_28_voip_voice_config.py — hardcoded parity tuple

Follow-up to #1323 (per-agent VoIP config panel + persisted voice).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ui): reframe Sharing tab to external-client sharing via channels (#1347)

Part of the Access/Sharing redesign (Epic trinity-enterprise#16). The Access
tab (#17) already owns Trinity operators; this scopes the Sharing tab to the
operator → external-client surface.

- Google-Docs-style "Share this agent" framing; operator language removed
  (operators live on the Access tab).
- External access policy collapsed into one **Restricted ↔ Open** segmented
  control over require_email/open_access (Restricted = approval-gated, Open =
  anyone verified; identity proof always on for external sharing).
- Pending requests kept, reframed as external clients awaiting approval.
- Channels rendered as compact collapsible summary rows (new
  ChannelDisclosure.vue). Detailed config stays reachable inside the expanded
  row as a non-regressing interim seam — #19 replaces the body with a modal
  dialog. No channel functionality removed.
- Outbound file sharing + public links nudged into a separate "Distribution"
  section (distribution, not client access).

Frontend-only; no API changes. SharingPanel prop/emit contract unchanged.
Verified: both SFCs compile (vue/compiler-sfc), design-token check passes.

Related to abilityai/trinity-enterprise#18

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(architecture): trim architecture.md under the 150k-char context limit (#1344)

architecture.md had grown to ~156.7k chars (on dev), past the 150k
soft limit Claude Code warns about when auto-loading it each session
(it's `@`-imported by CLAUDE.md). Over the limit the file risks silent
truncation and eats a large slice of the context window every session.

Compressed the densest Cross-Cutting Subsystem narratives, the migration
and non-root-container invariants (#3/#17), a few catalog/endpoint rows,
and the longest frontend UI prose — preferring summary + pointer where a
dedicated `feature-flows/` doc already owns the deep detail (the doc's own
editorial rule). Result: 156.7k → 149.3k chars (~4.8% smaller).

No facts dropped: every issue tag, field name, default, and error-string
is preserved; protected SQLite DDL (tracked by /validate-schema) untouched;
heading/code-fence/table counts unchanged; all 12 added flow-doc links
resolve.

Related to the over-limit warning surfaced in Claude Code.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(deps): bump js-yaml from 4.2.0 to 5.1.0 in /src/frontend (#1330)

Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.2.0 to 5.1.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.1.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix(ci): guard the Alembic (Postgres) track in schema-parity (#1342) (#1345)

* fix(ci): guard the Alembic (Postgres) track in schema-parity (#1342)

The schema-parity required check validated only the SQLite track
(migrations.py ↔ schema.py). A schema change that ships the SQLite
migration but omits the Alembic revision under
src/backend/migrations/versions/ passed every required check green yet
broke PostgreSQL — init_database() runs alembic_runner.upgrade_to_head(),
which applies revision files only and does not autogenerate from
tables.py. Two PRs reached "green CI but PG-broken" and had to be held by
hand.

Add a cross-track guard, folded into the existing required schema-parity
job (no new required-check to manage):

- scripts/ci/check_alembic_parity.py — fails a PR that ADDS schema DDL to
  db/{migrations,schema,tables}.py without a net-new revision file under
  src/backend/migrations/versions/. Pure stdlib, PR-only (diffs base...head).
- Heuristic / false-positive guard: the signal is a DDL keyword on an
  *added, non-comment* line (SQL: CREATE/ALTER/ADD COLUMN/…; SQLAlchemy:
  Column(/Table(/Index(/…). Comment edits, data-only and down migrations
  carry no DDL keyword, so they don't trip it. Documented in the script
  docstring and the workflow header.
- tests/unit/test_alembic_parity_guard.py — 20 tests incl. the acceptance
  fixtures (SQLite-only change fails; dual-tracked passes; comment/data-only
  pass). Wired into the parity pytest run.

Also notes the enforcement in architecture.md Invariant #3.

Verified locally: 24 tests pass; end-to-end smoke across clean / SQLite-only
(exit 1) / paired-revision (exit 0) scenarios behaves correctly.

Related to #1342

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): tighten Alembic guard to require a new MIGRATIONS entry (#1342)

Verifying against real repo history surfaced a false positive: the
migration-runner refactor #1263 (_atomic_rebuild table rebuilds) re-emits
CREATE TABLE / CREATE INDEX for *existing* tables in a rename-swap but adds
no actual schema and no new MIGRATIONS entry — yet the original "any added
DDL keyword" heuristic flagged it, violating AC #4 (non-schema edits must
not trip).

Tighten the signal to two conjuncts: a schema change must (1) register a
net-new ("name", _migrate_fn) entry in the MIGRATIONS list AND (2) carry a
DDL keyword. Runner refactors / table rebuilds add no entry → exempt;
data-only new migrations carry no DDL → exempt; real column/table adds do
both → caught.

Validated against real commits:
  • #740 agent_loops, #526 agent_ownership column → FAIL (correctly blocked)
  • #668 compat, voice_name (both shipped an Alembic revision) → PASS
  • #1263 runner refactor → PASS (false positive fixed)
A full post-Alembic history scan finds 0 outstanding missing revisions, so
no backfill is owed; the pre-Alembic columns are already in 0001_baseline.

28 tests pass (added MIGRATIONS-entry detection + the #1263 rebuild case).

Related to #1342

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(dependab…
vybe pushed a commit that referenced this pull request Jul 9, 2026
…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>
vybe pushed a commit that referenced this pull request Jul 10, 2026
…04 (Phase 4) (#1497)

* test(canary): unblock canary_invariants fixture broken by #1472 merge

Two pre-existing breakages landed via #1472 (367a5e1, 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>
anubis770 pushed a commit to anubis770/trinity that referenced this pull request Jul 16, 2026
…tall (trinity-enterprise#107)

Bundle Trinity's flagship "second brain" agent into the default install so a
fresh Trinity comes up with a working, Brain-Orb-enabled Cornelius present —
zero manual steps.

Approach (autoplan-reviewed): Path B — a local bundled template, NOT
github-native. The Brain Orb turned out to already be fully OSS (platform
flag-gated, not entitlement-gated), so the issue's "de-gating" dependency was
dropped. github-native-at-boot was rejected: create_agent_internal's dynamic
github path hard-requires a system PAT even for a public repo, and a boot-time
clone adds a network SPOF to startup.

- config/agent-templates/cornelius/: bundled LOCAL template (from public
  github.com/Abilityai/cornelius) — template.yaml (capabilities:[brain-orb]),
  CLAUDE.md, .trinity/brain-orb/ hooks, a pre-generated
  resources/agent-visualization/data.json seed so the orb renders immediately,
  and a minimal seed Brain/ vault.
- services/cornelius_agent_service.py: ensure_seeded() — first-run-only
  (durable cornelius_seeded flag; a deleted Cornelius is not resurrected),
  fresh-install-scoped (skipped when any non-system agent exists, so upgrades
  aren't surprised), Redis SETNX lock for the --workers 2 race (fail-open,
  mirrors Abilityai#1464), provisions local:cornelius via create_agent_internal (no
  PAT/network/clone), existence-guarded enable of brain_orb_enabled (never
  clobbers an admin OFF).
- Two triggers: routers/setup.py (fresh install — BackgroundTask right after
  the admin account is created, since no owner exists pre-setup) and a main.py
  lifespan safety-net (upgrades; gated on setup_completed && !cornelius_seeded,
  backgrounded).
- create_agent_internal: request param widened to Optional[Request]=None
  (boot-time caller has no HTTP request; the param was never dereferenced).
- db.count_non_system_agents() (db/agents.py + database.py facade) — the
  fresh-install signal. No migration (system_settings is free-form KV).
- Tests: tests/unit/test_ent107_cornelius_seed.py (12 cases — gating,
  idempotency, fresh-install scoping, owner deferral, docker skip, flag-seed
  guard, 409 convergence, SETNX lock, local-template config, real-DB facade
  smoke); setup test updated for the new background task.

Known deviation from the issue's decision Abilityai#2: a local bundle has origin=local
(no git upstream), so the default Cornelius won't auto-`git pull` upstream
template updates; durable ownership is deferred to fork-to-own
(trinity-enterprise#109). This was accepted at the autoplan gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Jul 17, 2026
…#1676)

* feat(agents): editable display label with an immutable slug (ent#181)

An agent had exactly one name — its slug — so "rename" meant the heavyweight
identity change: `PUT /rename` stops the container, rewrites ~20 tables, clears
every per-agent Redis keyspace, renames avatar files, and STILL leaves the
agent's volumes under the old base, because Docker can rename neither a volume
nor its immutable `trinity.agent-name` label. That path is the root of
#1664/#1665/#1667/#1669/#1671. Yet "call it Marketing Bot" is the common case,
and it should not touch any of that.

Adds a display label that is rendered, never resolved. The slug stays the
identity everything machine-facing keys on; the label is presentation.

- `agent_ownership.display_label TEXT`, nullable — NULL means "render the slug",
  so no backfill and every existing agent is unchanged until someone sets one;
  clearing reverts to the slug rather than blanking a name. Dual-track migration
  (Invariant #3): SQLite `agent_ownership_display_label` + Alembic 0025, plus
  schema.py / tables.py.
- `DisplayLabelMixin` (Invariant #2 — new setting, new mixin), with a batched
  reader for the fleet list so the hottest endpoint stays one query, and the
  four facade pass-throughs (the #1666 lesson: mocked tests can't see a facade
  gap).
- `GET`/`PUT /api/agents/{name}/label`, owner-only. Read never coerces `label`
  to the slug — the UI must tell "no label" from "label equals slug". WS
  `agent_label_changed` broadcast.
- Frontend: one resolution helper (`utils/agentName.js`) — no per-site
  `label || name`, which would show one agent under two names (§1.3.1 FR-3).
  The header pencil edits the label and shows the slug as secondary text (FR-4);
  list/tile surfaces render the label with the slug in the tooltip. The store
  goes through the shared axios client (Invariant #7).
- The slug rename is demoted, not removed (FR-5): a separate "Rename the id
  instead…" affordance with copy stating what it does (restart, re-key, volumes
  stay under the old id) — owners who need it keep it; it stops being the
  default gesture.

Requirements §1.3.1 written before the code (rule #1). Verified end-to-end on
the live stack: label set → slug untouched (container intact, `/api/agents/{slug}`
still 200, nothing restarted), blank and null both clear back to the slug.
28 tests.

Decision (maintainer): OSS-core; demote the slug rename; label everywhere.

Closes trinity-enterprise#181

* fix(agents): carry the label on the detail endpoint + make the WS broadcast live (ent#181)

Self-review (/review) caught two consistency gaps I'd introduced.

1. `GET /api/agents/{name}` — the endpoint AgentHeader loads on page open —
   enriched the agent dict with owner/is_owner/can_share but NOT display_label.
   So on a fresh load or refresh the header showed the SLUG; the label only
   appeared after an edit round-tripped through the store. Reproduced live
   (detail returned display_label=None for a labelled agent), fixed by adding
   the one read, re-verified live. §1.3.1 FR-3 — the surfaces must agree.

2. The `agent_label_changed` broadcast was dead: it set only `type`, but the
   frontend WS client switches on `event` (both are the convention, per
   agent_started et al.), and no handler existed — so a label change on one
   client never reached others. Added `event` + the `data` shape the other
   agent_* events use, and a handler that updates the cached agent (the slug
   never moves, so it's a pure re-render).

Endpoint test updated to assert the new broadcast shape.
vybe pushed a commit that referenced this pull request Jul 19, 2026
#1296) (#1694)

* docs(requirements): agent self-reminders §10.14 (#1296)

Requirements-first per Rule of Engagement #1: document the new
agent self-reminders capability (durable one-shot deferred
self-trigger) before implementing it — AC 1-7, storage fork,
scheduler fire home, at-least-once delivery, autonomy hold, and
retention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(db): agent_reminders table — 5-track migration (#1296)

Durable one-shot deferred self-trigger storage. Byte-consistent DDL
across all five coordinated edits (Invariant #3):
- db/schema.py: TABLES entry + two indexes (agent + partial active)
- db/migrations.py: _migrate_agent_reminders_table + MIGRATIONS tail
- migrations/versions/0028_agent_reminders (off head 0027)
- db/tables.py: SQLAlchemy Core Table (byte-matched to DDL)
- db/agent_cleanup.py: AGENT_REFS CASCADE (rename cascade + purge +
  soft-delete filter; source_agent_name left unregistered per the
  agent_loops precedent)

schema-parity, alembic-parity, and cleanup-parity all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(executions): wire triggered_by="reminder" into all three trigger constants (#1296)

A new triggered_by value must hit all three sites or reminder
executions silently vanish from the Overview chart, fail to filter,
or misclassify their failure alert:
- _TRIGGER_BUCKETS + _BUCKET_ORDER (db/schedules.py) — first-class
  "Reminders" bucket (mirrors the #1150 loops precedent)
- _AUTONOMOUS_TRIGGERS (task_execution_service.py) — a reminder
  fires unwatched, so a FAILED reminder earns an operator alert
- _VALID_TRIGGERS (routers/executions.py) — the fleet Executions
  ?triggered_by= filter accepts it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reminders): backend create/list/cancel surface (#1296)

Three-layer backend for agent self-reminders (Invariants #1/#2/#8/#14/#18):
- models.py: ReminderCreate (fire_at XOR delay_seconds validator) +
  Reminder/ReminderSummary + env-tunable abuse-bound constants
- db/reminders.py: RemindersOperations (SQLAlchemy Core, tenant-scoped
  by-id ops, CAS cancel, pending/daily counts, retention prune+count)
- database.py: facade delegation
- services/reminder_service.py: thin create — resolved-window bound,
  timeout clamp to agent cap (#929 parity), pending + durable daily
  caps, provenance, relative→absolute, insert
- services/idempotency_service.py: derive_reminder_key over RAW input
- routers/reminders.py: self-only gate (current_user.agent_name==name)
  + _reject_connector_principal; create-idempotency (header wins else
  raw-input key; terminal rows excluded from replay; fail() only
  pre-insert); tenant-scoped cancel (200/409/404); main.py registration

Self-only auth mirrors reports; connector keys stay off the auth-entry
allowlist AND are explicitly rejected. Verified against a real migrated
SQLite DB; models-centralized + enumeration-uniformity green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(scheduler): arm/fire/reconcile agent self-reminders (#1296)

The standalone scheduler (single-instance) owns the reminder fire path
— a near-clone of the RETRY-001 one-shot DateTrigger machinery:
- models.py: Reminder dataclass (naive-UTC fire_at/firing_at)
- database.py: committed CAS methods (claim_reminder_firing pending→
  firing, mark_fired/failed, release_to_pending, set_execution) +
  get_active_reminders (pending∪firing JOIN agent_ownership, filters
  deleted_at + autonomy_enabled=1) + _row_to_reminder via
  parse_scheduler_ts
- service.py: _schedule_reminder_job (DateTrigger), _execute_reminder
  (at-least-once: CAS claim → create real execution row (__manual__,
  triggered_by=reminder) → dispatch via _call_backend_execute_task →
  timeout=assume-dispatched no-force-FAILED / clean-failure=status-
  guarded FAILED + bounded retry), _reconcile_reminders (fail-open,
  arms pending + reclaims stale firing) wired into initialize() (own
  try after _recover_pending_retries), _sync_schedules() (own try,
  NOT under the cron try — Codex C5), and reload_schedules() (Codex C6)
- config.py: MAX_REMINDER_FIRE_ATTEMPTS (default 3)

All 207 existing scheduler tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): set_reminder/list_reminders/cancel_reminder tools (#1296)

Third surface (Invariant #13) mirroring loops.ts 1:1:
- tools/reminders.ts: createReminderTools → {setReminder, listReminders,
  cancelReminder}; reuses the inline getClient + resolveAgentName
  self-scoping (an agent-scoped key defaults to its bound name); Zod
  carries the abuse bounds (message.max(4000), delay 60..2592000, fire_at
  ISO string). Idempotency is enforced server-side over the raw input,
  so a naive retry dedupes without a client-supplied key.
- client.ts: setReminder/listReminders/cancelReminder thin request wrappers
- server.ts: import + register in the tools array

No agent-server mirror (backend-only scheduling primitive). tsc build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(retention): agent_reminders retention sweep + #1644 guard wiring (#1296)

A durable status table needs a retention story (the AGENT_REFS CASCADE
only cleans on agent delete; terminal rows on a LIVE agent accumulate).
Decision: wire the FULL #1644 blast-radius guard (lightweight, mirrors
agent_reports exactly, honors #1638/#1644 discipline):
- settings_service.py: agent_reminders_retention_days in OPS_SETTINGS_
  DEFAULTS (90, wide/safe), OPS_SETTINGS_DESCRIPTIONS, and
  RETENTION_OPS_KEYS (surfaced at GET /api/settings/retention, protected
  from /ops/reset, logged at boot). NOT a community-floor key.
- cleanup_service.py: _sweep_agent_reminders_retention — DELETE terminal
  (fired/cancelled/failed) rows past the window (pending/firing never
  deleted), chunked, gated via _guard_allows + _after_guarded_prune
  (default MAX_ROWS_PER_SWEEP floor); CleanupReport.agent_reminders_pruned.

retention-guard suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(reminders): backend + scheduler coverage (#1296)

Backend (tests/unit/test_1296_reminders.py, 33 tests): migration +
tables.py live-select accessor guard (the guard schema-parity misses),
the three trigger constants (parametrized), ReminderCreate XOR,
derive_reminder_key raw-input stability, db round-trip + tenant-scope +
cancel CAS states, retention (terminal pruned / pending+firing kept),
reminder_service bounds (min/max window, timeout>cap, pending 429,
daily 429), and router self-gate ATTACK (sibling 403, connector 403),
tenant-scope 404, idempotency edges (dup replay, in-flight 409,
cancel-then-recreate-fresh), cancel outcomes, list default-pending.

Scheduler (tests/scheduler_tests/test_1296_reminders.py, 17 tests):
committed single-fire CAS + multi-connection contention (exactly one
wins, Codex C8), _execute_reminder outcomes (claim-loss / success /
timeout=assume-dispatched-no-force-FAILED / clean-failure-retry /
bounded-failed), _reconcile (arm-once, past-due, stale-firing reclaim,
Z-suffix no-raise, missing-table no-op, soft-delete/autonomy-off
filtered), reload path (Codex C6).

Scheduler suite 224 green; backend reminder+parity+guard sweep 124 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(architecture): agent self-reminders subsystem (#1296)

- New 'Agent Self-Reminders (#1296)' subsystem section (sibling to
  Sequential Agent Loops): storage fork, scheduler fire home,
  at-least-once delivery, self-only auth + abuse bounds, autonomy hold
  + AGENT_REFS cascade + retention
- agent_reminders DB schema block (dual-track migration, CASCADE)
- router/service catalog entries (reminders.py, reminder_service.py)
- MCP tools table row (reminders.ts × 3)
- Scheduler Service + Cleanup Service background-service rows updated
- API Endpoints section for the 3 reminder endpoints

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(feature-flows): agent-self-reminders flow doc + index (#1296)

New feature-flow doc mirroring run-agent-loop.md: the two design forks,
MCP/backend/scheduler layers, single-fire + at-least-once delivery
semantics, the 5-track migration, retention, and the three trigger
constants. Adds a Recent Updates row + a Core Agent Features category
row to the index (per the always-add-an-index-row rule).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(config): wire agent-reminder tunables into compose + .env.example (#1296)

The six REMINDER_*/MAX_*REMINDER* caps are backend os.getenv() levers with
working code defaults, so the feature ships functional — but without compose
wiring the .env levers are inert on deploy (the #1056/#1039 packaging class),
mirroring how REPORT_RATE_LIMIT (#918) is wired into all three files. Adds the
block to .env.example, docker-compose.yml, and docker-compose.prod.yml
backend.environment: with defaults matching models.py/routers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: resolve .env.example conflict left in the previous merge commit (keep both #1296 REMINDER_* and #1632 OPERATOR_QUEUE_* blocks)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
AndriiPasternak31 added a commit that referenced this pull request Jul 26, 2026
* 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.

…
vybe pushed a commit that referenced this pull request Aug 3, 2026
…t creation (trinity-enterprise#89) (#1946)

* docs(requirements): template-declared schedules at creation (§10.16, ent#89)

Requirements-first per Rule of Engagement #1 — written and committed before
any implementation.

Covers the declared `schedules:` contract, the total-function reader and its
tolerance matrix, the normalized carrier that feeds BOTH resolver branches
(the `github:` half needs the creation-resolved PAT + parsed ref, not the
catalog's global-PAT cached fetch), the honored-`enabled` decision and its
`set_autonomy_status` caveat, idempotency in all three places creation /
intra-block / manifest deploy, and the T-018 + A-002 + c_p006 compatibility
half.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(templates): tolerant `schedules:` reader for template.yaml (ent#89)

New leaf `services/template_schedules.py`: `schedule_shape_errors` +
`normalize_declared_schedules` over one private `_parse`, mirroring the
sibling `credential_shape_errors` / `credential_mcp_server_names` convention
(ent#128).

The contract is TOTALITY — template.yaml is untrusted and `yaml.safe_load()`
can yield a scalar, list or mapping at any level, so a raise here would empty
the template catalog (#1835 class), enter the creation rollback fence, or
fail-open the T-018 check. Every shape degrades to a safe value plus a named
error.

Cron and timezone are validated with the SAME parser the dedicated scheduler
registers with (#1472) because `_calculate_next_run_at` swallows a bad cron
and `set_schedule_enabled` never re-validates — an unvalidated entry would
become a zombie schedule that exists, shows no next run, and can never fire.

Errors name the index, the key and a YAML type — never the `name`/`message`
VALUE, which is unbounded and lands in a persisted, UI-rendered blob. The
cron/timezone strings are the one echo, bounded and printable-filtered by a
local twin of `_sanitize_for_warning` (importing it would close a cycle).

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(templates): surface `schedules:` in both builders, fence both GitHub list paths (ent#89)

Three changes in one file:

1. `_build_template` (GitHub) and `_build_local_template` (local) both surface
   `schedules` (normalized) + `schedule_errors`. BOTH, explicitly — the
   pre-existing asymmetry (`persistent_state` is surfaced only by the GitHub
   builder) means parity cannot be assumed, and AC #2 covers both sources.

2. R3 — both GitHub catalog list paths were BARE list comprehensions.
   ent#128 PR-A fenced `_build_local_template` only, so adding an
   untrusted-input reader to `_build_template` would have put a new
   raise-capable call on an unfenced path: one malformed repo would 500 the
   whole GitHub half of GET /api/templates. That is the #1835 bug this
   feature is modelled on, re-opened by the feature itself. Both are now
   fenced per-template via `_safe_build_github_template`.

3. `fetch_template_metadata_for_create` — the creation path must NOT read the
   catalog cache. That cache uses the GLOBAL platform PAT (creation resolves
   per-agent -> per-user -> global, ent#162), sends no `?ref=` (so an
   `@branch` create would read the default branch), and is a 10-minute
   per-process TTL. The new fetch takes the resolved PAT + parsed ref, and is
   loud on every failure — a silently empty declaration on the `github:` path
   is the exact class this feature exists to close.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(agents): materialize a template's declared schedules at creation (ent#89)

`_TemplateResolution.declared_schedules` is a normalized carrier populated by
BOTH resolver branches, so AC #2's "GitHub and local" is real rather than
nominal. It is deliberately not folded into `template_data`: that field is raw
template YAML on the `local:` path and `{}` on the `github:` path — which has
never populated it, so #383's `persistent_state` and #1169's `data_paths` are
effectively `local:`-only — and `_stage_config_files` gates credential-file
generation on `if template_data:`, so merging the two shapes would change
credential generation for every GitHub agent.

`reconcile_declared_schedules` is shaped as a reconcile primitive (takes
`agent_name`, not `AgentConfig`) so a future "re-apply template" can reuse it.
No recreate hook is added — an eager re-materialize would resurrect schedules
an operator deliberately deleted.

Two failure modes handled explicitly:
- `db.create_schedule` RETURNS None on three paths (unknown user, no access,
  the #1445 is_agent_live gate) and never raises, so a try/except alone would
  catch nothing and a length-derived counter would report schedules that were
  never written. The return value is checked and counted as failed.
- The whole step is non-fatal and the try/except wraps the entire call
  including `list_agent_schedules` — this function sits inside the destructive
  rollback fence, so an escaping raise would roll back a successful creation
  over a schedule.

`enabled` is passed explicitly (ScheduleCreate defaults it to True, which
would invert AC #3), and ghosts are skipped at the caller per ent#69 fleet
hygiene.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(systems): don't duplicate a schedule the template already materialized (ent#89)

`deploy_manifest` creates each agent and THEN calls `create_schedules`, so
post-ent#89 it is the second schedule producer for the same agent: the first
call materializes the template's declared block, the second adds the
manifest's. With no UNIQUE(agent_name, name) index, a manifest declaring
`daily-briefing` on a template that also declares it produced two rows.

This is a regression ent#89 itself creates, so the guard ships with it rather
than as a follow-up. Fails open on a read error — dropping a manifest's
schedules would be worse than the duplicate this prevents.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(compat): T-018 schedules well-formedness + three in-radius corrections (ent#89)

T-018 (SOFT, STATIC) reports the `schedules:` block's STRUCTURE only, sharing
one reader with the materializer and the catalog surface so the report cannot
drift from what creation actually does. It deliberately does not report cron
syntax — see correction 2.

It fails CLOSED, against the grain of every other check: `run_static` turns a
raise into `skipped` and the report counts only `fail`, so a raising SOFT
check drops soft_count 1->0 and flips overall_status issues->compatible
exactly when its finding was the only failure — the entire population T-018
exists to serve — and `_report_from_persisted` then replays that clean bill of
health from checks_json on every stopped-agent read. Detail carries the
exception TYPE only; `str(e)` can embed untrusted template content into a
persisted, UI-rendered blob.

Three corrections, all in blast radius:

1. `c_p006` was missing the `isinstance(..., list)` guard its four sibling
   readers of this field all carry. `schedules: 5` raised TypeError -> swallowed
   -> a HARD check silently vanished from hard_count. A live instance of the
   exact class T-018 guards against, which is what makes the fail-closed design
   evidence-backed rather than theoretical.

2. `_valid_cron` (A-002) was a per-field `^[\d*/,\-]+$` regex, wrong in BOTH
   directions: it rejected `0 9 * * MON` and accepted `99 99 * * *`. It now
   delegates to `validate_cron_expression` — the same parser the scheduler
   registers with (#1472) and the same one the ent#89 reader gates on. One
   cron authority, agreeing with the executor.

3. `run_static`'s swallow now logs. It previously left no trace anywhere, for
   all ~100 checks; this is the instrument for deciding later whether to flip
   it to `fail` platform-wide.

Catalog 100 -> 101.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(templates): stop echoing the schedule name in the duplicate error (ent#89)

The error list is persisted into agent_compatibility_results.checks_json,
rendered in the UI, and returned in the catalog response, so the discipline is
index + key + type name only. The entry index already identifies the offender;
the name added disclosure without adding actionability. Cron and timezone stay
the only echoed values — bounded, printable-filtered, and what makes those
particular errors fixable.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: cover template-declared schedules end to end (ent#89)

Three new files plus compatibility extensions, 143 tests.

test_ent89_template_schedules.py — the reader's tolerance matrix (40 rows),
error-string discipline (no name/message/description ever echoed; cron
sanitized and bounded), the catalog surface in BOTH builders, the GitHub
list-path fence, and the create-path fetch. Plus a Hypothesis property over
recursive JSON-ish values asserting totality — a 40-row matrix cannot be a
totality proof for `yaml.safe_load` output, and totality is this module's
entire contract.

test_ent89_schedule_materialization.py — REAL DB rows via db_harness, not
`mock.assert_called`. Both recorded lessons apply here: a mock-`db` suite is
blind to a facade gap, and a parameter only one branch consumes is a severed
wire a mock will happily confirm — which is precisely the failure history of
the `github:` half of AC #2. So both resolver branches are driven for real,
and the github fetch's PAT and ref are asserted; without that the §0
regression test would be self-attestation.

test_ent89_manifest_no_duplicate.py — R5, in creation order: template first,
manifest second, one row, and the template's row is not overwritten.

test_compatibility_checks.py — T-018 pass/fail/absent, its fail-closed branch
(and that `detail` never carries `str(e)`), a `build_report`-level test
pinning the DIRECTION (a raising reader must still yield `overall_status ==
"issues"`), the `_report_from_persisted` recompute, A-002 in both directions
and agreeing with the materializer, c_p006 on `schedules: 5`, the run_static
log, and the whole static catalog run against 7 hostile templates asserting no
check lands at `check_error` — a T-018-only assertion would never have caught
c_p006.

The two fence tests pin `get_github_templates` by sys.modules KEY rather than
by module object: `get_all_templates` imports it lazily inside the function,
so an attribute patch on a separately-imported reference passed in isolation
and failed under the full suite, where an earlier file swaps that object.

Full unit suite: 6405 passed. The one red, test_1069_voip_call_path_param, is
a pre-existing venv FastAPI drift (`get_flat_dependant` no longer exported) in
a file this branch does not touch.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(architecture): template-declared schedules + the compat fail-open class (ent#89)

- template_service bullet: the `schedules:` reader beside the `credentials:`
  ones, the newly fenced GitHub catalog list paths, and why the creation path
  needs its own metadata fetch rather than the global-PAT default-branch cache.
- New `template_schedules.py` leaf bullet with its totality contract.
- crud bullet: the `declared_schedules` carrier, why it is not `template_data`,
  and the non-fatal reconcile step.
- Compatibility block: T-018, why it is the one check that fails closed, and
  the two live instances of that class it fixes (c_p006, _valid_cron/A-002).
- template.yaml file-tree note now names its four declarative blocks.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(flows): template-declared schedules + the compat fail-open class (ent#89)

- template-processing.md: the `schedules:` reader beside the `credentials:`
  ones, the three deliberate differences (normalized surface, both GitHub list
  paths fenced, creation does not read the catalog's copy), and the
  error-string discipline.
- scheduling.md: new flow 1c — the fourth schedule producer, why the carrier
  is not `template_data`, why non-fatality is the invariant, the falsy-return
  check, and idempotency in all three places.
- agent-compatibility-validation.md: T-018, why it is the one check that fails
  closed, the A-002 cron-authority consolidation, and the c_p006 live instance.
- feature-flows.md: Recent Updates row (required even when the flow docs
  already existed).
- learnings.md: the durable half — a per-item swallow plus a count that ignores
  skips makes a validator's own bug invisible, and persistence replays it.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: GitHub contents-API contract + live catalog surface for ent#89

Two gaps the /update-tests coverage checklist surfaced:

- Every other create-path-fetch test stubs `_fetch_template_yaml_result`,
  i.e. BELOW the HTTP layer — so a wrong param name (`?ref=` is what pins the
  revision) or a dropped Authorization header would leave them all green while
  the feature silently read the default branch, or read nothing for a private
  repo. That is precisely the R2 failure this fetch exists to prevent, so it
  is now asserted at the wire, including the ent#123 tokenless case sending no
  Authorization header at all.

- The unit suite proves both BUILDERS emit `schedules`/`schedule_errors`; only
  a live call proves the router serves them, that entries are the normalized
  shape, and that a template reporting errors is still LISTED — the
  ent#128/#1835 property, which now also covers the two newly-fenced GitHub
  list paths.

Unit run: 208 passed across the four ent#89-touched unit files.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(flows): sync system-manifest.md with the ent#89 schedule dedupe

/sync-feature-flows caught what the plan had deliberately trimmed: the
`create_schedules()` section documents a function whose behaviour this change
alters. Its docstring block gave no hint that the function is now the SECOND
schedule producer for the same agent.

Adds the name-match skip, why it exists (no UNIQUE(agent_name, name) index and
adding one is a dual-track schema change that would fail on installs already
holding duplicates), that the skip does not overwrite the template's row
including its `enabled` value, and that an unreadable existing set fails open.
Index row now lists the flow too.

Noted, not acted on: `feature-flows.md` Recent Updates carries 65 dated rows
against the ~20 cap #1360 set — pre-existing drift, out of scope here.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(templates): sanitize the create-path fetch's failure reason (ent#89)

`fetch_template_metadata_for_create`'s WARNING interpolated `reason` raw while
its two neighbours on the same call are `_sanitize_for_warning`-wrapped. The
value embeds `str(e)`, and an httpx error message carries the request URL —
i.e. the caller-supplied `owner/repo`. A repo with no `/` skips
`_GITHUB_REPO_PATH_RE` upstream, so control bytes do reach that line.

Bounded at 200 rather than the 80 default: this WARNING exists to be
diagnosable, and an 80-char truncation defeats its purpose.

Raised as a code-consistency item by the /review pass; the CSO diff audit
discarded it as a finding under hard exclusion #9 (log spoofing), so this is
hygiene inside one call, not a security fix.

Tests pin both halves (control-char stripping and the length bound) and both
fail without the change. Also renames a stale `_template_schedule_errors()`
reference in the requirements doc and cites #1945 for the autonomy-clobber
follow-up the doc previously promised without an issue number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(security): CSO diff audit for ent#89 template-declared schedules

PASS with 1 MEDIUM (LLM cost amplification: declared schedules meeting the
pre-existing `set_autonomy_status` clobber). No CRITICAL, no HIGH. The finding
is an amplification of a pre-existing mechanism — there has never been a
per-agent schedule cap — so it is filed as #1945 rather than changing this PR.

Matches the convention of the 98 reports already tracked here; the file's Trend
section is only meaningful alongside its siblings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
dolho added a commit that referenced this pull request Aug 3, 2026
…nt#313)

`_rollback_failed_creation` rolled back DB/quota handles only, and its docstring
justified leaving the container to "the cleanup watchdog". No watchdog covers a
non-ephemeral agent — `cleanup_service._sweep_ephemeral_agents` is gated on the
`trinity.ephemeral` label — so a failure after `containers.run` left a RUNNING
container with no `agent_ownership` row. Two guards then deadlocked: nothing
removed the container, and because it kept its workspace volume mounted,
`_sweep_orphan_agent_volumes` could never advance the #1581 unattached-strike
counter, so the volume was unreclaimable too. The phantom still rendered in the
fleet listing, which is Docker-as-truth (Invariant #11). Observed live: 13+
hours, ~66 MB, an SSH port and a 2g limit held by an agent with zero rows
anywhere; recovery needed manual docker rm -f + volume rm + a Redis DEL.

The except-path now awaits `_reclaim_failed_creation_container`, which removes
the container and clears the name-keyed Redis keyspace (#1560 — otherwise the
next agent to reuse the name inherits stale breaker verdicts). Removing the
container is also what unblocks the existing volume sweep, so AC #2 is met by
the guard that already exists rather than a second destructive path.

Two arrival shapes, because the reported failure has no handle:

* handle in hand (a later step raised) — ownership is unambiguous
* NO handle — the failure happened inside `containers.run` (the observed 60s
  Docker read timeout: the daemon created it, the client never got it). The
  container is re-derived BY NAME, which is a different security problem: on a
  shared Docker daemon (git worktrees, two stacks on one host) a name resolves
  to another install's live agent. Three fail-closed gates: not a 409 name
  conflict (a 409 means the daemon created nothing, so the incumbent is
  definitionally not ours), no `agent_ownership` row, and a `trinity.created`
  label at or after a floor stamped before the docker block. Anything
  unprovable refuses — a false negative costs one manual `docker rm -f`, a
  false positive deletes a running agent.

The ownership gate applies to BOTH shapes on purpose: `_register_agent` writes
the row before the last creation step, so a failure in `_materialize_agent_files`
arrives holding the handle of a container the DB already considers an agent.
Removing it would turn a half-created-but-present agent into a row with no
container still holding its name — strictly worse than the leak.

Deliberately NOT implemented: extending the Docker-as-truth sweep to any
`trinity.platform=agent` container with no ownership row (the issue's option b).
On a shared daemon a sibling stack's live agents have no rows in THIS install's
DB, so that sweep would delete another install's running agents. It needs a
per-install container label first, which by construction only helps containers
created after it ships — i.e. it would not heal the existing leaks that motivate
it. Left as a follow-up with that prerequisite stated.

Two defects the full suite caught that the targeted tests could not:
- `isinstance(exc, docker.errors.APIError)` raises TypeError wherever a sibling
  test stubs the docker module, propagating out of a function whose contract is
  "never raises" and REPLACING the creation error the caller reports. The 409
  check is duck-typed on `.response.status_code` now.
- #1560's guard bans `clear_agent_runtime_state` from crud.py outright, because
  the full sweep drops the slot ZSET and would strip an in-flight async
  execution off a LIVE container. This reclaim is the first place in the file
  where the container is provably GONE. Narrowed the guard to enforce that
  reason — the sweep may appear only inside the reclaim.

20 regression tests; `test_1484`'s case 15 asserted `remove.assert_not_called()`
with the comment "left for the cleanup watchdog (PRESERVED)" — a characterization
test pinning the defect, now inverted.

Related to trinity-enterprise#313
vybe pushed a commit that referenced this pull request Aug 3, 2026
…nt#313) (#1956)

`_rollback_failed_creation` rolled back DB/quota handles only, and its docstring
justified leaving the container to "the cleanup watchdog". No watchdog covers a
non-ephemeral agent — `cleanup_service._sweep_ephemeral_agents` is gated on the
`trinity.ephemeral` label — so a failure after `containers.run` left a RUNNING
container with no `agent_ownership` row. Two guards then deadlocked: nothing
removed the container, and because it kept its workspace volume mounted,
`_sweep_orphan_agent_volumes` could never advance the #1581 unattached-strike
counter, so the volume was unreclaimable too. The phantom still rendered in the
fleet listing, which is Docker-as-truth (Invariant #11). Observed live: 13+
hours, ~66 MB, an SSH port and a 2g limit held by an agent with zero rows
anywhere; recovery needed manual docker rm -f + volume rm + a Redis DEL.

The except-path now awaits `_reclaim_failed_creation_container`, which removes
the container and clears the name-keyed Redis keyspace (#1560 — otherwise the
next agent to reuse the name inherits stale breaker verdicts). Removing the
container is also what unblocks the existing volume sweep, so AC #2 is met by
the guard that already exists rather than a second destructive path.

Two arrival shapes, because the reported failure has no handle:

* handle in hand (a later step raised) — ownership is unambiguous
* NO handle — the failure happened inside `containers.run` (the observed 60s
  Docker read timeout: the daemon created it, the client never got it). The
  container is re-derived BY NAME, which is a different security problem: on a
  shared Docker daemon (git worktrees, two stacks on one host) a name resolves
  to another install's live agent. Three fail-closed gates: not a 409 name
  conflict (a 409 means the daemon created nothing, so the incumbent is
  definitionally not ours), no `agent_ownership` row, and a `trinity.created`
  label at or after a floor stamped before the docker block. Anything
  unprovable refuses — a false negative costs one manual `docker rm -f`, a
  false positive deletes a running agent.

The ownership gate applies to BOTH shapes on purpose: `_register_agent` writes
the row before the last creation step, so a failure in `_materialize_agent_files`
arrives holding the handle of a container the DB already considers an agent.
Removing it would turn a half-created-but-present agent into a row with no
container still holding its name — strictly worse than the leak.

Deliberately NOT implemented: extending the Docker-as-truth sweep to any
`trinity.platform=agent` container with no ownership row (the issue's option b).
On a shared daemon a sibling stack's live agents have no rows in THIS install's
DB, so that sweep would delete another install's running agents. It needs a
per-install container label first, which by construction only helps containers
created after it ships — i.e. it would not heal the existing leaks that motivate
it. Left as a follow-up with that prerequisite stated.

Two defects the full suite caught that the targeted tests could not:
- `isinstance(exc, docker.errors.APIError)` raises TypeError wherever a sibling
  test stubs the docker module, propagating out of a function whose contract is
  "never raises" and REPLACING the creation error the caller reports. The 409
  check is duck-typed on `.response.status_code` now.
- #1560's guard bans `clear_agent_runtime_state` from crud.py outright, because
  the full sweep drops the slot ZSET and would strip an in-flight async
  execution off a LIVE container. This reclaim is the first place in the file
  where the container is provably GONE. Narrowed the guard to enforce that
  reason — the sweep may appear only inside the reclaim.

20 regression tests; `test_1484`'s case 15 asserted `remove.assert_not_called()`
with the comment "left for the cleanup watchdog (PRESERVED)" — a characterization
test pinning the defect, now inverted.

Related to trinity-enterprise#313

Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants