feat(soft-delete): agent_ownership soft-delete + retention purge (#834 Phase 1a) - #838
Conversation
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>
There was a problem hiding this comment.
This PR requires the following changes before merge:
-
Update
docs/memory/requirements.md: add an entry for the soft-delete + retention lifecycle capability (Phase 1a scope, Phase 1b phases noted as pending). -
Update
docs/memory/architecture.md:- (a) Add
deleted_at TEXT+ the partial indexidx_agent_ownership_deleted_atto theagent_ownershipschema table - (b) Add the soft-delete retention sweep to the Cleanup Service background services table
- (c) Document the
agent_soft_delete_retention_dayssetting (default 180d, 0 = disabled)
- (a) Add
-
Address the scheduler/soft-delete gap:
list_all_enabled_schedules()(src/backend/db/schedules.py:307) queriesagent_schedules WHERE enabled = 1with no join toagent_ownership, so the scheduler will attempt (and fail) every enabled schedule for a soft-deleted agent until the 180-day purge window expires — generating aschedule_executionsfailure row per attempt. Fix: addJOIN agent_ownership ao ON ao.agent_name = agent_schedules.agent_name WHERE ao.deleted_at IS NULLto the query, OR open a tracked issue for Phase 1b and document the known gap explicitly in this PR. -
Either create
tests/unit/test_agent_cleanup_parity.py(the enforcement test theagent_cleanup.pydocstring promises), or remove the guarantee from the docstring until the test is written.
Implementation is solid overall — the read-path audit is thorough, is_agent_name_reserved() correctly catches the UNIQUE constraint edge case before container provisioning starts, the canary snapshot exclusion is well-reasoned, and iso_cutoff() is used correctly. CI is fully green. The blockers are docs + the scheduler gap.
f3ddefd to
b164fe4
Compare
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>
…e 1b) Extends the #834 Phase 1a pattern to `agent_schedules`. Stacked on `feature/834-soft-delete-agents` (PR #838). Both PRs merge cleanly in either order — schema migration + retention sweep are additive. What changes - `DELETE /api/agents/{name}/schedules/{id}` marks `deleted_at = NOW` instead of hard-deleting. The schedule disappears from the scheduler's firing list immediately (the scheduler service's poll filters `enabled = 1 AND deleted_at IS NULL`), but the row + every `schedule_executions` child stay for the retention window so an operator can recover them by hand via `UPDATE agent_schedules SET deleted_at = NULL WHERE id = '...'`. - The retention sweep in `cleanup_service.py` hard-purges schedules past `schedule_soft_delete_retention_days` (default 30 days — shorter than the agent window since schedules are higher-churn). Each purge also wipes the schedule's `schedule_executions` rows, matching the previous hard-delete semantics. - Webhook tokens on soft-deleted schedules stop resolving — the `get_schedule_by_webhook_token` lookup filters `deleted_at IS NULL` too, so a leaked URL is invalidated the moment the schedule is marked deleted, not only at retention end. Where - Schema: `agent_schedules.deleted_at TEXT`, partial index `WHERE deleted_at IS NOT NULL`. Versioned migration in `db/migrations.py`. - `db/schedules.py`: - `delete_schedule()` flipped from DELETE to soft-delete. Idempotent on re-delete with permission. - `purge_schedule()` added — refuses live rows; called by the cleanup sweep. - `find_soft_deleted_schedules_past_retention()` drives the sweep. - 7 read sites filtered: `get_schedule`, `list_agent_schedules`, `list_all_enabled_schedules`, `list_all_disabled_schedules`, `list_all_schedules`, `get_schedule_by_webhook_token`, `get_webhook_status`, `get_all_agents_schedule_counts`. - `src/scheduler/database.py` (separate scheduler service process): same 4 read sites filtered. Without this the dedicated scheduler process would keep firing soft-deleted schedules. - `services/cleanup_service.py`: new sweep block calling `purge_schedule()` for eligible IDs, bounded by the existing 5000- row/cycle cap. - `services/settings_service.py`: new `schedule_soft_delete_retention_days` (default 30). Out of scope (intentional) - `delete_agent_schedules(agent_name)` (mass-delete) was left as a hard-delete. Its only caller path was removed in Phase 1a's `delete_agent_endpoint` rewrite; the function is now dormant and the cascade-on-purge path uses #816's `cascade_delete` instead. Refactoring the dead helper is out of scope. - Soft-delete does not interact with `schedule_executions` rows — they're billing-relevant (subscription_id rollup) and #772's existing retention sweep ages them out independently. Live verification on the running stack (uvicorn auto-reload picked up every change): 1. CREATE agent + schedule: 1 row in list ✓ 2. DELETE /api/agents/X/schedules/Y → 204 3. LIST after delete: 0 rows (filter working) ✓ 4. DB row still present, deleted_at populated ✓ 5. db.purge_schedule(id) → True, row + executions gone ✓ Related to #834. Phase 1b only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1c) Closes the user-facing side of the soft-delete story. Pre-#834 recovery was a direct-DB `UPDATE deleted_at = NULL` — unauditable, required shell access, soft-deleted set wasn't surfaced anywhere. Endpoints (admin-only, all audit-logged): GET /api/admin/soft-deleted/agents list soft-deleted agents with `deleted_at` + `purge_eta` (= deleted_at + agent_soft_delete_retention_days) POST /api/admin/soft-deleted/agents/{name}/recover clear deleted_at; 404 if not in the soft-deleted set GET /api/admin/soft-deleted/schedules[?agent_name=...] list soft-deleted schedules; purge_eta from schedule_soft_delete_retention_days POST /api/admin/soft-deleted/schedules/{id}/recover same shape as the agent variant Recovery is metadata-only: flipping deleted_at back to NULL makes the row reappear via all the user-facing read paths Phase 1a+1b filtered. For agents, the container is NOT recreated automatically; the operator does that explicitly via POST /api/agents/{name}/start. The preserved workspace volume keeps the agent's files intact. DB helpers (mirror Phase 1a/1b shape): - AgentOperations.recover_agent_ownership / list_soft_deleted_agents - ScheduleOperations.recover_schedule / list_soft_deleted_schedules (latter takes optional agent_name to scope per agent) All four refuse live rows (returns False / 404). All return False for nonexistent ids — admin endpoint translates to 404. Stacked on #839 (Phase 1b schedules). Stack: #838 (1a) → #839 (1b) → this (1c). Merge order matters because the schedule helpers live on Phase 1b's branch. Live verification on the running stack: 1. CREATE agent + schedule, soft-delete both 2. GET /api/admin/soft-deleted/agents → match found, purge_eta=2026-11-10 (180d from now) 3. GET /api/admin/soft-deleted/schedules?agent_name=X → 1 row, purge_eta=2026-06-13 (30d from now) 4. POST recover agent → 200, deleted_at cleared in DB ✓ 5. POST recover schedule → 200, deleted_at cleared ✓ 6. POST recover on now-live agent → 404 (correct refusal) ✓ 10 unit tests in test_admin_recovery.py cover: recover/list/scope, refuse-live-row, refuse-nonexistent, newest-first ordering, limit handling, agent_name scoping. Related to #834. Phase 1c. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1c) Closes the user-facing side of the soft-delete story. Pre-#834 recovery was a direct-DB `UPDATE deleted_at = NULL` — unauditable, required shell access, soft-deleted set wasn't surfaced anywhere. Endpoints (admin-only, all audit-logged): GET /api/admin/soft-deleted/agents list soft-deleted agents with `deleted_at` + `purge_eta` (= deleted_at + agent_soft_delete_retention_days) POST /api/admin/soft-deleted/agents/{name}/recover clear deleted_at; 404 if not in the soft-deleted set GET /api/admin/soft-deleted/schedules[?agent_name=...] list soft-deleted schedules; purge_eta from schedule_soft_delete_retention_days POST /api/admin/soft-deleted/schedules/{id}/recover same shape as the agent variant Recovery is metadata-only: flipping deleted_at back to NULL makes the row reappear via all the user-facing read paths Phase 1a+1b filtered. For agents, the container is NOT recreated automatically; the operator does that explicitly via POST /api/agents/{name}/start. The preserved workspace volume keeps the agent's files intact. DB helpers (mirror Phase 1a/1b shape): - AgentOperations.recover_agent_ownership / list_soft_deleted_agents - ScheduleOperations.recover_schedule / list_soft_deleted_schedules (latter takes optional agent_name to scope per agent) All four refuse live rows (returns False / 404). All return False for nonexistent ids — admin endpoint translates to 404. Stacked on #839 (Phase 1b schedules). Stack: #838 (1a) → #839 (1b) → this (1c). Merge order matters because the schedule helpers live on Phase 1b's branch. Live verification on the running stack: 1. CREATE agent + schedule, soft-delete both 2. GET /api/admin/soft-deleted/agents → match found, purge_eta=2026-11-10 (180d from now) 3. GET /api/admin/soft-deleted/schedules?agent_name=X → 1 row, purge_eta=2026-06-13 (30d from now) 4. POST recover agent → 200, deleted_at cleared in DB ✓ 5. POST recover schedule → 200, deleted_at cleared ✓ 6. POST recover on now-live agent → 404 (correct refusal) ✓ 10 unit tests in test_admin_recovery.py cover: recover/list/scope, refuse-live-row, refuse-nonexistent, newest-first ordering, limit handling, agent_name scoping. Related to #834. Phase 1c. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1c) Closes the user-facing side of the soft-delete story. Pre-#834 recovery was a direct-DB `UPDATE deleted_at = NULL` — unauditable, required shell access, soft-deleted set wasn't surfaced anywhere. Endpoints (admin-only, all audit-logged): GET /api/admin/soft-deleted/agents list soft-deleted agents with `deleted_at` + `purge_eta` (= deleted_at + agent_soft_delete_retention_days) POST /api/admin/soft-deleted/agents/{name}/recover clear deleted_at; 404 if not in the soft-deleted set GET /api/admin/soft-deleted/schedules[?agent_name=...] list soft-deleted schedules; purge_eta from schedule_soft_delete_retention_days POST /api/admin/soft-deleted/schedules/{id}/recover same shape as the agent variant Recovery is metadata-only: flipping deleted_at back to NULL makes the row reappear via all the user-facing read paths Phase 1a+1b filtered. For agents, the container is NOT recreated automatically; the operator does that explicitly via POST /api/agents/{name}/start. The preserved workspace volume keeps the agent's files intact. DB helpers (mirror Phase 1a/1b shape): - AgentOperations.recover_agent_ownership / list_soft_deleted_agents - ScheduleOperations.recover_schedule / list_soft_deleted_schedules (latter takes optional agent_name to scope per agent) All four refuse live rows (returns False / 404). All return False for nonexistent ids — admin endpoint translates to 404. Stacked on #839 (Phase 1b schedules). Stack: #838 (1a) → #839 (1b) → this (1c). Merge order matters because the schedule helpers live on Phase 1b's branch. Live verification on the running stack: 1. CREATE agent + schedule, soft-delete both 2. GET /api/admin/soft-deleted/agents → match found, purge_eta=2026-11-10 (180d from now) 3. GET /api/admin/soft-deleted/schedules?agent_name=X → 1 row, purge_eta=2026-06-13 (30d from now) 4. POST recover agent → 200, deleted_at cleared in DB ✓ 5. POST recover schedule → 200, deleted_at cleared ✓ 6. POST recover on now-live agent → 404 (correct refusal) ✓ 10 unit tests in test_admin_recovery.py cover: recover/list/scope, refuse-live-row, refuse-nonexistent, newest-first ordering, limit handling, agent_name scoping. Related to #834. Phase 1c. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e 1a) Addresses PR #838 review (vybe, CHANGES_REQUESTED): - list_all_enabled_schedules() (backend db.schedules AND the standalone scheduler process) now JOINs agent_ownership and filters deleted_at IS NULL — a soft-deleted agent's enabled schedules stop firing immediately instead of writing a schedule_executions failure row per cron tick until the 180-day purge. - Add tests/unit/test_agent_cleanup_parity.py — the enforcement test agent_cleanup.py's docstring promises. Bidirectional schema↔AGENT_REFS parity + KEEP-policy lock. Stdlib-only loader, real CI gate (no venv skip). Plus a scheduler regression test for the agent-soft-delete gap. - requirements.md: new §32 Agent Soft-Delete & Retention Lifecycle (Phase 1a detailed; 1b/1c noted pending). - architecture.md: agent_ownership.deleted_at + idx_agent_ownership_deleted_at in the schema block; soft-delete purge added to the Cleanup Service row; agent_soft_delete_retention_days (default 180, 0=disabled). - Fix stale "default 30" comment in routers/agents.py (bumped to 180 in 45d99a1). Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@vybe — all four blockers addressed in 3a45ef1:
Also corrected a stale Local: |
…e 1b) Extends the #834 Phase 1a pattern to `agent_schedules`. Stacked on `feature/834-soft-delete-agents` (PR #838). Both PRs merge cleanly in either order — schema migration + retention sweep are additive. What changes - `DELETE /api/agents/{name}/schedules/{id}` marks `deleted_at = NOW` instead of hard-deleting. The schedule disappears from the scheduler's firing list immediately (the scheduler service's poll filters `enabled = 1 AND deleted_at IS NULL`), but the row + every `schedule_executions` child stay for the retention window so an operator can recover them by hand via `UPDATE agent_schedules SET deleted_at = NULL WHERE id = '...'`. - The retention sweep in `cleanup_service.py` hard-purges schedules past `schedule_soft_delete_retention_days` (default 30 days — shorter than the agent window since schedules are higher-churn). Each purge also wipes the schedule's `schedule_executions` rows, matching the previous hard-delete semantics. - Webhook tokens on soft-deleted schedules stop resolving — the `get_schedule_by_webhook_token` lookup filters `deleted_at IS NULL` too, so a leaked URL is invalidated the moment the schedule is marked deleted, not only at retention end. Where - Schema: `agent_schedules.deleted_at TEXT`, partial index `WHERE deleted_at IS NOT NULL`. Versioned migration in `db/migrations.py`. - `db/schedules.py`: - `delete_schedule()` flipped from DELETE to soft-delete. Idempotent on re-delete with permission. - `purge_schedule()` added — refuses live rows; called by the cleanup sweep. - `find_soft_deleted_schedules_past_retention()` drives the sweep. - 7 read sites filtered: `get_schedule`, `list_agent_schedules`, `list_all_enabled_schedules`, `list_all_disabled_schedules`, `list_all_schedules`, `get_schedule_by_webhook_token`, `get_webhook_status`, `get_all_agents_schedule_counts`. - `src/scheduler/database.py` (separate scheduler service process): same 4 read sites filtered. Without this the dedicated scheduler process would keep firing soft-deleted schedules. - `services/cleanup_service.py`: new sweep block calling `purge_schedule()` for eligible IDs, bounded by the existing 5000- row/cycle cap. - `services/settings_service.py`: new `schedule_soft_delete_retention_days` (default 30). Out of scope (intentional) - `delete_agent_schedules(agent_name)` (mass-delete) was left as a hard-delete. Its only caller path was removed in Phase 1a's `delete_agent_endpoint` rewrite; the function is now dormant and the cascade-on-purge path uses #816's `cascade_delete` instead. Refactoring the dead helper is out of scope. - Soft-delete does not interact with `schedule_executions` rows — they're billing-relevant (subscription_id rollup) and #772's existing retention sweep ages them out independently. Live verification on the running stack (uvicorn auto-reload picked up every change): 1. CREATE agent + schedule: 1 row in list ✓ 2. DELETE /api/agents/X/schedules/Y → 204 3. LIST after delete: 0 rows (filter working) ✓ 4. DB row still present, deleted_at populated ✓ 5. db.purge_schedule(id) → True, row + executions gone ✓ Related to #834. Phase 1b only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses PR #839 review (vybe, CHANGES_REQUESTED) + rebase onto the updated #838 (Phase 1a): - list_all_enabled_schedules() (backend + standalone scheduler) now filters BOTH s.deleted_at IS NULL (Phase 1b, this PR) AND ao.deleted_at IS NULL via the agent_ownership JOIN (Phase 1a, #838). A schedule is skipped if either it or its agent is soft-deleted. - test_schedule_soft_delete.py: ephemeral schema now creates agent_ownership + seeds a live owner row per agent so the merged firing-query JOIN resolves. - architecture.md: agent_schedules.deleted_at + idx_agent_schedules_deleted_at in the schema block; schedule soft-delete prose; Phase 1b purge added to the Cleanup Service row with schedule_soft_delete_retention_days (default 30, 0=disabled). - requirements.md: §32.2 fleshed out (read paths, idempotency, retention, execution-row ownership pre-purge vs at-purge, storage). PR body corrected: the schedule_executions "Out of scope" line now accurately states #772 owns them pre-purge while purge_schedule() cascades the delete at purge — consistent with prior hard-delete behavior (reviewer's option a). Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1c) Closes the user-facing side of the soft-delete story. Pre-#834 recovery was a direct-DB `UPDATE deleted_at = NULL` — unauditable, required shell access, soft-deleted set wasn't surfaced anywhere. Endpoints (admin-only, all audit-logged): GET /api/admin/soft-deleted/agents list soft-deleted agents with `deleted_at` + `purge_eta` (= deleted_at + agent_soft_delete_retention_days) POST /api/admin/soft-deleted/agents/{name}/recover clear deleted_at; 404 if not in the soft-deleted set GET /api/admin/soft-deleted/schedules[?agent_name=...] list soft-deleted schedules; purge_eta from schedule_soft_delete_retention_days POST /api/admin/soft-deleted/schedules/{id}/recover same shape as the agent variant Recovery is metadata-only: flipping deleted_at back to NULL makes the row reappear via all the user-facing read paths Phase 1a+1b filtered. For agents, the container is NOT recreated automatically; the operator does that explicitly via POST /api/agents/{name}/start. The preserved workspace volume keeps the agent's files intact. DB helpers (mirror Phase 1a/1b shape): - AgentOperations.recover_agent_ownership / list_soft_deleted_agents - ScheduleOperations.recover_schedule / list_soft_deleted_schedules (latter takes optional agent_name to scope per agent) All four refuse live rows (returns False / 404). All return False for nonexistent ids — admin endpoint translates to 404. Stacked on #839 (Phase 1b schedules). Stack: #838 (1a) → #839 (1b) → this (1c). Merge order matters because the schedule helpers live on Phase 1b's branch. Live verification on the running stack: 1. CREATE agent + schedule, soft-delete both 2. GET /api/admin/soft-deleted/agents → match found, purge_eta=2026-11-10 (180d from now) 3. GET /api/admin/soft-deleted/schedules?agent_name=X → 1 row, purge_eta=2026-06-13 (30d from now) 4. POST recover agent → 200, deleted_at cleared in DB ✓ 5. POST recover schedule → 200, deleted_at cleared ✓ 6. POST recover on now-live agent → 404 (correct refusal) ✓ 10 unit tests in test_admin_recovery.py cover: recover/list/scope, refuse-live-row, refuse-nonexistent, newest-first ordering, limit handling, agent_name scoping. Related to #834. Phase 1c. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses PR #840 review (vybe, CHANGES_REQUESTED); rebased onto the updated #839/#838. - Move SoftDeletedAgent / SoftDeletedSchedule from routers/admin_recovery.py to models.py (Architectural Invariant #14 — response models are centralized, not declared in router files). - Cap limit on both list endpoints: limit: int = Query(200, le=500) — prevents unbounded DB reads (422 on >500). - requirements.md: §32.3 fleshed out (endpoints, recovery semantics, metadata-only/container-recreate-is-Phase-2, audit events, model location). - architecture.md: new "Soft-Delete Admin Recovery (#834 Phase 1c)" endpoint table with all 4 endpoints, auth, and behavior notes. Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3a45ef1 to
0960d5c
Compare
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>
…e 1a) Addresses PR #838 review (vybe, CHANGES_REQUESTED): - list_all_enabled_schedules() (backend db.schedules AND the standalone scheduler process) now JOINs agent_ownership and filters deleted_at IS NULL — a soft-deleted agent's enabled schedules stop firing immediately instead of writing a schedule_executions failure row per cron tick until the 180-day purge. - Add tests/unit/test_agent_cleanup_parity.py — the enforcement test agent_cleanup.py's docstring promises. Bidirectional schema↔AGENT_REFS parity + KEEP-policy lock. Stdlib-only loader, real CI gate (no venv skip). Plus a scheduler regression test for the agent-soft-delete gap. - requirements.md: new §32 Agent Soft-Delete & Retention Lifecycle (Phase 1a detailed; 1b/1c noted pending). - architecture.md: agent_ownership.deleted_at + idx_agent_ownership_deleted_at in the schema block; soft-delete purge added to the Cleanup Service row; agent_soft_delete_retention_days (default 180, 0=disabled). - Fix stale "default 30" comment in routers/agents.py (bumped to 180 in 45d99a1). Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…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>
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>
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>
…e 1a) Addresses PR #838 review (vybe, CHANGES_REQUESTED): - list_all_enabled_schedules() (backend db.schedules AND the standalone scheduler process) now JOINs agent_ownership and filters deleted_at IS NULL — a soft-deleted agent's enabled schedules stop firing immediately instead of writing a schedule_executions failure row per cron tick until the 180-day purge. - Add tests/unit/test_agent_cleanup_parity.py — the enforcement test agent_cleanup.py's docstring promises. Bidirectional schema↔AGENT_REFS parity + KEEP-policy lock. Stdlib-only loader, real CI gate (no venv skip). Plus a scheduler regression test for the agent-soft-delete gap. - requirements.md: new §32 Agent Soft-Delete & Retention Lifecycle (Phase 1a detailed; 1b/1c noted pending). - architecture.md: agent_ownership.deleted_at + idx_agent_ownership_deleted_at in the schema block; soft-delete purge added to the Cleanup Service row; agent_soft_delete_retention_days (default 180, 0=disabled). - Fix stale "default 30" comment in routers/agents.py (bumped to 180 in 45d99a1). Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
ac66c2b to
765c6ea
Compare
Phase 1c) Closes the user-facing side of the soft-delete story. Pre-#834 recovery was a direct-DB `UPDATE deleted_at = NULL` — unauditable, required shell access, soft-deleted set wasn't surfaced anywhere. Endpoints (admin-only, all audit-logged): GET /api/admin/soft-deleted/agents list soft-deleted agents with `deleted_at` + `purge_eta` (= deleted_at + agent_soft_delete_retention_days) POST /api/admin/soft-deleted/agents/{name}/recover clear deleted_at; 404 if not in the soft-deleted set GET /api/admin/soft-deleted/schedules[?agent_name=...] list soft-deleted schedules; purge_eta from schedule_soft_delete_retention_days POST /api/admin/soft-deleted/schedules/{id}/recover same shape as the agent variant Recovery is metadata-only: flipping deleted_at back to NULL makes the row reappear via all the user-facing read paths Phase 1a+1b filtered. For agents, the container is NOT recreated automatically; the operator does that explicitly via POST /api/agents/{name}/start. The preserved workspace volume keeps the agent's files intact. DB helpers (mirror Phase 1a/1b shape): - AgentOperations.recover_agent_ownership / list_soft_deleted_agents - ScheduleOperations.recover_schedule / list_soft_deleted_schedules (latter takes optional agent_name to scope per agent) All four refuse live rows (returns False / 404). All return False for nonexistent ids — admin endpoint translates to 404. Stacked on #839 (Phase 1b schedules). Stack: #838 (1a) → #839 (1b) → this (1c). Merge order matters because the schedule helpers live on Phase 1b's branch. Live verification on the running stack: 1. CREATE agent + schedule, soft-delete both 2. GET /api/admin/soft-deleted/agents → match found, purge_eta=2026-11-10 (180d from now) 3. GET /api/admin/soft-deleted/schedules?agent_name=X → 1 row, purge_eta=2026-06-13 (30d from now) 4. POST recover agent → 200, deleted_at cleared in DB ✓ 5. POST recover schedule → 200, deleted_at cleared ✓ 6. POST recover on now-live agent → 404 (correct refusal) ✓ 10 unit tests in test_admin_recovery.py cover: recover/list/scope, refuse-live-row, refuse-nonexistent, newest-first ordering, limit handling, agent_name scoping. Related to #834. Phase 1c. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses PR #840 review (vybe, CHANGES_REQUESTED); rebased onto the updated #839/#838. - Move SoftDeletedAgent / SoftDeletedSchedule from routers/admin_recovery.py to models.py (Architectural Invariant #14 — response models are centralized, not declared in router files). - Cap limit on both list endpoints: limit: int = Query(200, le=500) — prevents unbounded DB reads (422 on >500). - requirements.md: §32.3 fleshed out (endpoints, recovery semantics, metadata-only/container-recreate-is-Phase-2, audit events, model location). - architecture.md: new "Soft-Delete Admin Recovery (#834 Phase 1c)" endpoint table with all 4 endpoints, auth, and behavior notes. Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vybe
left a comment
There was a problem hiding this comment.
All four CHANGES_REQUESTED blockers from the prior review are resolved:
- requirements.md §33 entry added
- architecture.md schema block + Cleanup Service row + retention setting documented
- Scheduler gap closed in both
db/schedules.py:list_all_enabled_schedules()and the standalone scheduler process tests/unit/test_agent_cleanup_parity.pycreated using the sanctioned_STUBBED_MODULE_NAMESpattern
CI fully green (6-seed pytest matrix + regression-diff + schema-parity + lint + CodeQL). Read-path audit is thorough; the 4 intentionally-unfiltered sites are all commented with rationale. Ready to merge.
Brings dev forward to include #838 (agent_ownership soft-delete + retention purge, #834 Phase 1a). Conflict resolved: src/backend/db/migrations.py — both sides appended a migration to the registry. Same pattern as the earlier 2026-05-17 merge: dev's migration first (`agent_ownership_soft_delete` landed on dev as part of #838), then our `execution_retry_count` (lands when PR #797 merges). Migrations are additive and independent — order doesn't matter operationally, but matching landing chronology keeps the registry readable. Both migration functions (_migrate_agent_ownership_soft_delete at :2102, _migrate_execution_retry_count at :2011) are present in the auto-merged file. Refs #678
…e-586-plan Two new commits on dev since the previous merge (c9008e4): - b892069: feat(observability): emit [METRIC] drain_outcome on slow-path orphan-killer (#586) (#837) - c6bc456: feat(soft-delete): agent_ownership soft-delete + retention purge (#834 Phase 1a) (#838) Conflicts resolved: - tests/unit/test_subprocess_pgroup.py: kept BOTH this PR's test_setsid_escapee_drained_via_orphan_killer_preserves_result_line (#586 regression) AND dev's #837 metric tests (test_emits_metric_on_natural_drain, test_emits_metric_on_force_close_path). Tests cover orthogonal aspects of the slow-path drain: setsid escape + result-line preservation vs. drain_outcome metric emission. - docs/memory/feature-flows.md: kept both this PR's #35d4e78 row and dev's #586/#837 row. Lint still green; sys.modules baseline already current after the previous dev-merge (c9008e4).
…e 1b) Extends the #834 Phase 1a pattern to `agent_schedules`. Stacked on `feature/834-soft-delete-agents` (PR #838). Both PRs merge cleanly in either order — schema migration + retention sweep are additive. What changes - `DELETE /api/agents/{name}/schedules/{id}` marks `deleted_at = NOW` instead of hard-deleting. The schedule disappears from the scheduler's firing list immediately (the scheduler service's poll filters `enabled = 1 AND deleted_at IS NULL`), but the row + every `schedule_executions` child stay for the retention window so an operator can recover them by hand via `UPDATE agent_schedules SET deleted_at = NULL WHERE id = '...'`. - The retention sweep in `cleanup_service.py` hard-purges schedules past `schedule_soft_delete_retention_days` (default 30 days — shorter than the agent window since schedules are higher-churn). Each purge also wipes the schedule's `schedule_executions` rows, matching the previous hard-delete semantics. - Webhook tokens on soft-deleted schedules stop resolving — the `get_schedule_by_webhook_token` lookup filters `deleted_at IS NULL` too, so a leaked URL is invalidated the moment the schedule is marked deleted, not only at retention end. Where - Schema: `agent_schedules.deleted_at TEXT`, partial index `WHERE deleted_at IS NOT NULL`. Versioned migration in `db/migrations.py`. - `db/schedules.py`: - `delete_schedule()` flipped from DELETE to soft-delete. Idempotent on re-delete with permission. - `purge_schedule()` added — refuses live rows; called by the cleanup sweep. - `find_soft_deleted_schedules_past_retention()` drives the sweep. - 7 read sites filtered: `get_schedule`, `list_agent_schedules`, `list_all_enabled_schedules`, `list_all_disabled_schedules`, `list_all_schedules`, `get_schedule_by_webhook_token`, `get_webhook_status`, `get_all_agents_schedule_counts`. - `src/scheduler/database.py` (separate scheduler service process): same 4 read sites filtered. Without this the dedicated scheduler process would keep firing soft-deleted schedules. - `services/cleanup_service.py`: new sweep block calling `purge_schedule()` for eligible IDs, bounded by the existing 5000- row/cycle cap. - `services/settings_service.py`: new `schedule_soft_delete_retention_days` (default 30). Out of scope (intentional) - `delete_agent_schedules(agent_name)` (mass-delete) was left as a hard-delete. Its only caller path was removed in Phase 1a's `delete_agent_endpoint` rewrite; the function is now dormant and the cascade-on-purge path uses #816's `cascade_delete` instead. Refactoring the dead helper is out of scope. - Soft-delete does not interact with `schedule_executions` rows — they're billing-relevant (subscription_id rollup) and #772's existing retention sweep ages them out independently. Live verification on the running stack (uvicorn auto-reload picked up every change): 1. CREATE agent + schedule: 1 row in list ✓ 2. DELETE /api/agents/X/schedules/Y → 204 3. LIST after delete: 0 rows (filter working) ✓ 4. DB row still present, deleted_at populated ✓ 5. db.purge_schedule(id) → True, row + executions gone ✓ Related to #834. Phase 1b only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses PR #839 review (vybe, CHANGES_REQUESTED) + rebase onto the updated #838 (Phase 1a): - list_all_enabled_schedules() (backend + standalone scheduler) now filters BOTH s.deleted_at IS NULL (Phase 1b, this PR) AND ao.deleted_at IS NULL via the agent_ownership JOIN (Phase 1a, #838). A schedule is skipped if either it or its agent is soft-deleted. - test_schedule_soft_delete.py: ephemeral schema now creates agent_ownership + seeds a live owner row per agent so the merged firing-query JOIN resolves. - architecture.md: agent_schedules.deleted_at + idx_agent_schedules_deleted_at in the schema block; schedule soft-delete prose; Phase 1b purge added to the Cleanup Service row with schedule_soft_delete_retention_days (default 30, 0=disabled). - requirements.md: §32.2 fleshed out (read paths, idempotency, retention, execution-row ownership pre-purge vs at-purge, storage). PR body corrected: the schedule_executions "Out of scope" line now accurately states #772 owns them pre-purge while purge_schedule() cascades the delete at purge — consistent with prior hard-delete behavior (reviewer's option a). Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1c) Closes the user-facing side of the soft-delete story. Pre-#834 recovery was a direct-DB `UPDATE deleted_at = NULL` — unauditable, required shell access, soft-deleted set wasn't surfaced anywhere. Endpoints (admin-only, all audit-logged): GET /api/admin/soft-deleted/agents list soft-deleted agents with `deleted_at` + `purge_eta` (= deleted_at + agent_soft_delete_retention_days) POST /api/admin/soft-deleted/agents/{name}/recover clear deleted_at; 404 if not in the soft-deleted set GET /api/admin/soft-deleted/schedules[?agent_name=...] list soft-deleted schedules; purge_eta from schedule_soft_delete_retention_days POST /api/admin/soft-deleted/schedules/{id}/recover same shape as the agent variant Recovery is metadata-only: flipping deleted_at back to NULL makes the row reappear via all the user-facing read paths Phase 1a+1b filtered. For agents, the container is NOT recreated automatically; the operator does that explicitly via POST /api/agents/{name}/start. The preserved workspace volume keeps the agent's files intact. DB helpers (mirror Phase 1a/1b shape): - AgentOperations.recover_agent_ownership / list_soft_deleted_agents - ScheduleOperations.recover_schedule / list_soft_deleted_schedules (latter takes optional agent_name to scope per agent) All four refuse live rows (returns False / 404). All return False for nonexistent ids — admin endpoint translates to 404. Stacked on #839 (Phase 1b schedules). Stack: #838 (1a) → #839 (1b) → this (1c). Merge order matters because the schedule helpers live on Phase 1b's branch. Live verification on the running stack: 1. CREATE agent + schedule, soft-delete both 2. GET /api/admin/soft-deleted/agents → match found, purge_eta=2026-11-10 (180d from now) 3. GET /api/admin/soft-deleted/schedules?agent_name=X → 1 row, purge_eta=2026-06-13 (30d from now) 4. POST recover agent → 200, deleted_at cleared in DB ✓ 5. POST recover schedule → 200, deleted_at cleared ✓ 6. POST recover on now-live agent → 404 (correct refusal) ✓ 10 unit tests in test_admin_recovery.py cover: recover/list/scope, refuse-live-row, refuse-nonexistent, newest-first ordering, limit handling, agent_name scoping. Related to #834. Phase 1c. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses PR #840 review (vybe, CHANGES_REQUESTED); rebased onto the updated #839/#838. - Move SoftDeletedAgent / SoftDeletedSchedule from routers/admin_recovery.py to models.py (Architectural Invariant #14 — response models are centralized, not declared in router files). - Cap limit on both list endpoints: limit: int = Query(200, le=500) — prevents unbounded DB reads (422 on >500). - requirements.md: §32.3 fleshed out (endpoints, recovery semantics, metadata-only/container-recreate-is-Phase-2, audit events, model location). - architecture.md: new "Soft-Delete Admin Recovery (#834 Phase 1c)" endpoint table with all 4 endpoints, auth, and behavior notes. Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1c) Closes the user-facing side of the soft-delete story. Pre-#834 recovery was a direct-DB `UPDATE deleted_at = NULL` — unauditable, required shell access, soft-deleted set wasn't surfaced anywhere. Endpoints (admin-only, all audit-logged): GET /api/admin/soft-deleted/agents list soft-deleted agents with `deleted_at` + `purge_eta` (= deleted_at + agent_soft_delete_retention_days) POST /api/admin/soft-deleted/agents/{name}/recover clear deleted_at; 404 if not in the soft-deleted set GET /api/admin/soft-deleted/schedules[?agent_name=...] list soft-deleted schedules; purge_eta from schedule_soft_delete_retention_days POST /api/admin/soft-deleted/schedules/{id}/recover same shape as the agent variant Recovery is metadata-only: flipping deleted_at back to NULL makes the row reappear via all the user-facing read paths Phase 1a+1b filtered. For agents, the container is NOT recreated automatically; the operator does that explicitly via POST /api/agents/{name}/start. The preserved workspace volume keeps the agent's files intact. DB helpers (mirror Phase 1a/1b shape): - AgentOperations.recover_agent_ownership / list_soft_deleted_agents - ScheduleOperations.recover_schedule / list_soft_deleted_schedules (latter takes optional agent_name to scope per agent) All four refuse live rows (returns False / 404). All return False for nonexistent ids — admin endpoint translates to 404. Stacked on #839 (Phase 1b schedules). Stack: #838 (1a) → #839 (1b) → this (1c). Merge order matters because the schedule helpers live on Phase 1b's branch. Live verification on the running stack: 1. CREATE agent + schedule, soft-delete both 2. GET /api/admin/soft-deleted/agents → match found, purge_eta=2026-11-10 (180d from now) 3. GET /api/admin/soft-deleted/schedules?agent_name=X → 1 row, purge_eta=2026-06-13 (30d from now) 4. POST recover agent → 200, deleted_at cleared in DB ✓ 5. POST recover schedule → 200, deleted_at cleared ✓ 6. POST recover on now-live agent → 404 (correct refusal) ✓ 10 unit tests in test_admin_recovery.py cover: recover/list/scope, refuse-live-row, refuse-nonexistent, newest-first ordering, limit handling, agent_name scoping. Related to #834. Phase 1c. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses PR #840 review (vybe, CHANGES_REQUESTED); rebased onto the updated #839/#838. - Move SoftDeletedAgent / SoftDeletedSchedule from routers/admin_recovery.py to models.py (Architectural Invariant #14 — response models are centralized, not declared in router files). - Cap limit on both list endpoints: limit: int = Query(200, le=500) — prevents unbounded DB reads (422 on >500). - requirements.md: §32.3 fleshed out (endpoints, recovery semantics, metadata-only/container-recreate-is-Phase-2, audit events, model location). - architecture.md: new "Soft-Delete Admin Recovery (#834 Phase 1c)" endpoint table with all 4 endpoints, auth, and behavior notes. Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1c) Closes the user-facing side of the soft-delete story. Pre-#834 recovery was a direct-DB `UPDATE deleted_at = NULL` — unauditable, required shell access, soft-deleted set wasn't surfaced anywhere. Endpoints (admin-only, all audit-logged): GET /api/admin/soft-deleted/agents list soft-deleted agents with `deleted_at` + `purge_eta` (= deleted_at + agent_soft_delete_retention_days) POST /api/admin/soft-deleted/agents/{name}/recover clear deleted_at; 404 if not in the soft-deleted set GET /api/admin/soft-deleted/schedules[?agent_name=...] list soft-deleted schedules; purge_eta from schedule_soft_delete_retention_days POST /api/admin/soft-deleted/schedules/{id}/recover same shape as the agent variant Recovery is metadata-only: flipping deleted_at back to NULL makes the row reappear via all the user-facing read paths Phase 1a+1b filtered. For agents, the container is NOT recreated automatically; the operator does that explicitly via POST /api/agents/{name}/start. The preserved workspace volume keeps the agent's files intact. DB helpers (mirror Phase 1a/1b shape): - AgentOperations.recover_agent_ownership / list_soft_deleted_agents - ScheduleOperations.recover_schedule / list_soft_deleted_schedules (latter takes optional agent_name to scope per agent) All four refuse live rows (returns False / 404). All return False for nonexistent ids — admin endpoint translates to 404. Stacked on #839 (Phase 1b schedules). Stack: #838 (1a) → #839 (1b) → this (1c). Merge order matters because the schedule helpers live on Phase 1b's branch. Live verification on the running stack: 1. CREATE agent + schedule, soft-delete both 2. GET /api/admin/soft-deleted/agents → match found, purge_eta=2026-11-10 (180d from now) 3. GET /api/admin/soft-deleted/schedules?agent_name=X → 1 row, purge_eta=2026-06-13 (30d from now) 4. POST recover agent → 200, deleted_at cleared in DB ✓ 5. POST recover schedule → 200, deleted_at cleared ✓ 6. POST recover on now-live agent → 404 (correct refusal) ✓ 10 unit tests in test_admin_recovery.py cover: recover/list/scope, refuse-live-row, refuse-nonexistent, newest-first ordering, limit handling, agent_name scoping. Related to #834. Phase 1c. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses PR #840 review (vybe, CHANGES_REQUESTED); rebased onto the updated #839/#838. - Move SoftDeletedAgent / SoftDeletedSchedule from routers/admin_recovery.py to models.py (Architectural Invariant #14 — response models are centralized, not declared in router files). - Cap limit on both list endpoints: limit: int = Query(200, le=500) — prevents unbounded DB reads (422 on >500). - requirements.md: §32.3 fleshed out (endpoints, recovery semantics, metadata-only/container-recreate-is-Phase-2, audit events, model location). - architecture.md: new "Soft-Delete Admin Recovery (#834 Phase 1c)" endpoint table with all 4 endpoints, auth, and behavior notes. Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e 1b) (#839) * feat(soft-delete): agent_schedules soft-delete + retention (#834 Phase 1b) Extends the #834 Phase 1a pattern to `agent_schedules`. Stacked on `feature/834-soft-delete-agents` (PR #838). Both PRs merge cleanly in either order — schema migration + retention sweep are additive. What changes - `DELETE /api/agents/{name}/schedules/{id}` marks `deleted_at = NOW` instead of hard-deleting. The schedule disappears from the scheduler's firing list immediately (the scheduler service's poll filters `enabled = 1 AND deleted_at IS NULL`), but the row + every `schedule_executions` child stay for the retention window so an operator can recover them by hand via `UPDATE agent_schedules SET deleted_at = NULL WHERE id = '...'`. - The retention sweep in `cleanup_service.py` hard-purges schedules past `schedule_soft_delete_retention_days` (default 30 days — shorter than the agent window since schedules are higher-churn). Each purge also wipes the schedule's `schedule_executions` rows, matching the previous hard-delete semantics. - Webhook tokens on soft-deleted schedules stop resolving — the `get_schedule_by_webhook_token` lookup filters `deleted_at IS NULL` too, so a leaked URL is invalidated the moment the schedule is marked deleted, not only at retention end. Where - Schema: `agent_schedules.deleted_at TEXT`, partial index `WHERE deleted_at IS NOT NULL`. Versioned migration in `db/migrations.py`. - `db/schedules.py`: - `delete_schedule()` flipped from DELETE to soft-delete. Idempotent on re-delete with permission. - `purge_schedule()` added — refuses live rows; called by the cleanup sweep. - `find_soft_deleted_schedules_past_retention()` drives the sweep. - 7 read sites filtered: `get_schedule`, `list_agent_schedules`, `list_all_enabled_schedules`, `list_all_disabled_schedules`, `list_all_schedules`, `get_schedule_by_webhook_token`, `get_webhook_status`, `get_all_agents_schedule_counts`. - `src/scheduler/database.py` (separate scheduler service process): same 4 read sites filtered. Without this the dedicated scheduler process would keep firing soft-deleted schedules. - `services/cleanup_service.py`: new sweep block calling `purge_schedule()` for eligible IDs, bounded by the existing 5000- row/cycle cap. - `services/settings_service.py`: new `schedule_soft_delete_retention_days` (default 30). Out of scope (intentional) - `delete_agent_schedules(agent_name)` (mass-delete) was left as a hard-delete. Its only caller path was removed in Phase 1a's `delete_agent_endpoint` rewrite; the function is now dormant and the cascade-on-purge path uses #816's `cascade_delete` instead. Refactoring the dead helper is out of scope. - Soft-delete does not interact with `schedule_executions` rows — they're billing-relevant (subscription_id rollup) and #772's existing retention sweep ages them out independently. Live verification on the running stack (uvicorn auto-reload picked up every change): 1. CREATE agent + schedule: 1 row in list ✓ 2. DELETE /api/agents/X/schedules/Y → 204 3. LIST after delete: 0 rows (filter working) ✓ 4. DB row still present, deleted_at populated ✓ 5. db.purge_schedule(id) → True, row + executions gone ✓ Related to #834. Phase 1b only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(schedules): idempotent delete_schedule + complete test users schema (#834 Phase 1b) Found running the targeted test batch on the rebased stack. Production bug: `delete_schedule` ran its owner/admin permission check against `self.get_schedule()`, which Phase 1b changed to filter `deleted_at IS NULL`. So a retry on an already-soft-deleted schedule got None back, fell through to `return False`, and the router (schedules.py:330) turned that False into `403 "Cannot delete schedule - access denied"` — a misleading forbidden error shown to the legitimate owner who simply double-clicked or retried the delete. Fix: read `owner_id, deleted_at` directly (unfiltered) for the permission gate so a soft-deleted schedule's owner is still verifiable. Behavior now: - row absent → False (router 403; nothing to delete) - caller not owner/admin → False (router 403; correct) - already soft-deleted → True (idempotent; router 204) - live → soft-delete, True Matches the idempotent shape of #834 Phase 1a's `delete_agent_ownership`. Test fixture bug: `test_schedule_soft_delete.py`'s ephemeral `users` table only had (id, username, email, role). `delete_schedule` → `UserOperations.get_user_by_username` selects the full `_USER_COLUMNS` set (password_hash, auth0_sub, name, picture, created_at, updated_at, last_login), so the lookup raised `OperationalError: no such column: password_hash`. Expanded the fixture's users DDL to the full column set. The idempotent-retry test now asserts the corrected True (was masking the 403 bug). All 29 tests across the three soft-delete files pass. Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): add deleted_at to scheduler conftest agent_schedules fixture (#834 Phase 1b) Phase 1b added `WHERE deleted_at IS NULL` to the scheduler service's agent_schedules read paths (src/scheduler/database.py: get_schedule, list_all_enabled_schedules, list_all_schedules, list_agent_schedules) but the scheduler test fixture's ephemeral agent_schedules DDL didn't get the column — 5 tests in scheduler_tests/test_database.py failed with `OperationalError: no such column: deleted_at`. Fixture-only change. All 10 scheduler_tests/test_database.py tests pass. Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(soft-delete): docs + merged firing-query filter (#834 Phase 1b) Addresses PR #839 review (vybe, CHANGES_REQUESTED) + rebase onto the updated #838 (Phase 1a): - list_all_enabled_schedules() (backend + standalone scheduler) now filters BOTH s.deleted_at IS NULL (Phase 1b, this PR) AND ao.deleted_at IS NULL via the agent_ownership JOIN (Phase 1a, #838). A schedule is skipped if either it or its agent is soft-deleted. - test_schedule_soft_delete.py: ephemeral schema now creates agent_ownership + seeds a live owner row per agent so the merged firing-query JOIN resolves. - architecture.md: agent_schedules.deleted_at + idx_agent_schedules_deleted_at in the schema block; schedule soft-delete prose; Phase 1b purge added to the Cleanup Service row with schedule_soft_delete_retention_days (default 30, 0=disabled). - requirements.md: §32.2 fleshed out (read paths, idempotency, retention, execution-row ownership pre-purge vs at-purge, storage). PR body corrected: the schedule_executions "Out of scope" line now accurately states #772 owns them pre-purge while purge_schedule() cascades the delete at purge — consistent with prior hard-delete behavior (reviewer's option a). Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): defend test_schedule_soft_delete against db.schedules stub leak (#834 Phase 1b) CI regression-diff (PR #839) showed all 11 soft-delete tests failing with `no such table: agent_schedules` / `users` under pytest-randomly seeds 67890 and 99999 — passing under 12345. Diagnosed via head-99999 JUnit XML. Root cause: sibling tests (`test_execution_retention_prune.py`, `test_audit_retention_prune.py`, `test_session_operations.py`) install freshly-loaded modules into `sys.modules["db.schedules"]` / `sys.modules["db.monitoring"]` via plain assignment (no monkeypatch, no _STUBBED_MODULE_NAMES restore). Those stubs bind `get_db_connection` at exec_module time to the polluter's tmp-DB `_erp_db_connection`. After the polluter finishes, monkeypatch restores `db.connection` but the stale `db.schedules` stub remains. When `test_schedule_soft_delete.py` later imports `db.schedules`, it gets the stale stub whose `get_db_connection()` points at a deleted tmp file — hence the missing-table errors. The `monkeypatch.setattr` on the real `db.connection.DB_PATH` is bypassed entirely because the stub uses a different connection module. Fix: adopt the sanctioned `_STUBBED_MODULE_NAMES` + autouse `_restore_sys_modules` pattern (precedent: `tests/unit/test_agent_cleanup_parity.py`, `tests/unit/test_telegram_webhook_backfill.py`) — snapshot, pop on setup so imports re-resolve fresh, restore on teardown. List covers `db.schedules`, `db.users`, `db.agents`, `db.monitoring`. The lint-sys-modules check explicitly whitelists files exhibiting both `_STUBBED_MODULE_NAMES` and `_restore_sys_modules` (`tests/lint_sys_modules.py:100`), so no baseline change needed. Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): force-reload db.connection per test (#834 Phase 1b) Follow-up to the previous test-pollution fix. The autouse `_restore_sys_modules` pops the consumer modules (`db.schedules`, `db.users`, `db.agents`, `db.monitoring`) so they re-bind fresh — which removed 8 of the 11 seed-pollution failures on CI. The remaining 3 (all read-path tests under seed 67890) showed empty result sets, indicating `schedule_ops` was opening a *different* SQLite file than `_seed_schedule` wrote to. Root cause: when a sibling test (e.g. `test_session_operations`, `test_file_sharing_mixin`) installs a freshly-loaded `db.connection` into `sys.modules`, our `monkeypatch.setattr(connection_mod, "DB_PATH", ...)` patched the wrong module object on some seed orderings — the copy then re-imported by `db.schedules` saw the polluter's path instead of our tmp file. Fix: in `tmp_schedule_db`, layer three defenses: 1. `monkeypatch.setenv("TRINITY_DB_PATH", ...)` so any fresh `db.connection` import reads our tmp file as the module-level `DB_PATH`; 2. `monkeypatch.delitem(sys.modules, "db.connection", raising=False)` so the next import IS a fresh load against (1) — auto-restored on teardown so we don't pollute either; 3. retain the `monkeypatch.setattr` belt for the case where the fresh load somehow picks up a different env value. Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1c) Closes the user-facing side of the soft-delete story. Pre-#834 recovery was a direct-DB `UPDATE deleted_at = NULL` — unauditable, required shell access, soft-deleted set wasn't surfaced anywhere. Endpoints (admin-only, all audit-logged): GET /api/admin/soft-deleted/agents list soft-deleted agents with `deleted_at` + `purge_eta` (= deleted_at + agent_soft_delete_retention_days) POST /api/admin/soft-deleted/agents/{name}/recover clear deleted_at; 404 if not in the soft-deleted set GET /api/admin/soft-deleted/schedules[?agent_name=...] list soft-deleted schedules; purge_eta from schedule_soft_delete_retention_days POST /api/admin/soft-deleted/schedules/{id}/recover same shape as the agent variant Recovery is metadata-only: flipping deleted_at back to NULL makes the row reappear via all the user-facing read paths Phase 1a+1b filtered. For agents, the container is NOT recreated automatically; the operator does that explicitly via POST /api/agents/{name}/start. The preserved workspace volume keeps the agent's files intact. DB helpers (mirror Phase 1a/1b shape): - AgentOperations.recover_agent_ownership / list_soft_deleted_agents - ScheduleOperations.recover_schedule / list_soft_deleted_schedules (latter takes optional agent_name to scope per agent) All four refuse live rows (returns False / 404). All return False for nonexistent ids — admin endpoint translates to 404. Stacked on #839 (Phase 1b schedules). Stack: #838 (1a) → #839 (1b) → this (1c). Merge order matters because the schedule helpers live on Phase 1b's branch. Live verification on the running stack: 1. CREATE agent + schedule, soft-delete both 2. GET /api/admin/soft-deleted/agents → match found, purge_eta=2026-11-10 (180d from now) 3. GET /api/admin/soft-deleted/schedules?agent_name=X → 1 row, purge_eta=2026-06-13 (30d from now) 4. POST recover agent → 200, deleted_at cleared in DB ✓ 5. POST recover schedule → 200, deleted_at cleared ✓ 6. POST recover on now-live agent → 404 (correct refusal) ✓ 10 unit tests in test_admin_recovery.py cover: recover/list/scope, refuse-live-row, refuse-nonexistent, newest-first ordering, limit handling, agent_name scoping. Related to #834. Phase 1c. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses PR #840 review (vybe, CHANGES_REQUESTED); rebased onto the updated #839/#838. - Move SoftDeletedAgent / SoftDeletedSchedule from routers/admin_recovery.py to models.py (Architectural Invariant #14 — response models are centralized, not declared in router files). - Cap limit on both list endpoints: limit: int = Query(200, le=500) — prevents unbounded DB reads (422 on >500). - requirements.md: §32.3 fleshed out (endpoints, recovery semantics, metadata-only/container-recreate-is-Phase-2, audit events, model location). - architecture.md: new "Soft-Delete Admin Recovery (#834 Phase 1c)" endpoint table with all 4 endpoints, auth, and behavior notes. Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(soft-delete): admin recovery endpoints for agents + schedules (#834 Phase 1c) Closes the user-facing side of the soft-delete story. Pre-#834 recovery was a direct-DB `UPDATE deleted_at = NULL` — unauditable, required shell access, soft-deleted set wasn't surfaced anywhere. Endpoints (admin-only, all audit-logged): GET /api/admin/soft-deleted/agents list soft-deleted agents with `deleted_at` + `purge_eta` (= deleted_at + agent_soft_delete_retention_days) POST /api/admin/soft-deleted/agents/{name}/recover clear deleted_at; 404 if not in the soft-deleted set GET /api/admin/soft-deleted/schedules[?agent_name=...] list soft-deleted schedules; purge_eta from schedule_soft_delete_retention_days POST /api/admin/soft-deleted/schedules/{id}/recover same shape as the agent variant Recovery is metadata-only: flipping deleted_at back to NULL makes the row reappear via all the user-facing read paths Phase 1a+1b filtered. For agents, the container is NOT recreated automatically; the operator does that explicitly via POST /api/agents/{name}/start. The preserved workspace volume keeps the agent's files intact. DB helpers (mirror Phase 1a/1b shape): - AgentOperations.recover_agent_ownership / list_soft_deleted_agents - ScheduleOperations.recover_schedule / list_soft_deleted_schedules (latter takes optional agent_name to scope per agent) All four refuse live rows (returns False / 404). All return False for nonexistent ids — admin endpoint translates to 404. Stacked on #839 (Phase 1b schedules). Stack: #838 (1a) → #839 (1b) → this (1c). Merge order matters because the schedule helpers live on Phase 1b's branch. Live verification on the running stack: 1. CREATE agent + schedule, soft-delete both 2. GET /api/admin/soft-deleted/agents → match found, purge_eta=2026-11-10 (180d from now) 3. GET /api/admin/soft-deleted/schedules?agent_name=X → 1 row, purge_eta=2026-06-13 (30d from now) 4. POST recover agent → 200, deleted_at cleared in DB ✓ 5. POST recover schedule → 200, deleted_at cleared ✓ 6. POST recover on now-live agent → 404 (correct refusal) ✓ 10 unit tests in test_admin_recovery.py cover: recover/list/scope, refuse-live-row, refuse-nonexistent, newest-first ordering, limit handling, agent_name scoping. Related to #834. Phase 1c. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(recover): make recovered agents visible + honest message (#834 Phase 1c) Smoke-testing the rebased stack on the live instance surfaced a recovery dead-end: 1. `GET /api/agents/{name}` 404'd after a successful recover. The handler resolves the agent via `get_agent_by_name` (Docker-backed); soft-delete removed the container and recovery is metadata-only, so there's no container → 404. The operator literally could not see the agent they just recovered, let alone act on it. Recovery was pointless from the UI's perspective. Fix: when `get_agent_by_name` returns None but a live agent_ownership row exists (the auth dependency already proved it does — `get_agent_owner` filters `deleted_at IS NULL`), synthesize a DB-backed `status=stopped, needs_start=true` representation so the agent lists and the UI can offer a Start affordance. 2. The recover endpoint's success message claimed "POST /start to bring it back online" — but Start also 404s on a container-less agent (it too requires an existing container). Container recreate from the preserved workspace volume is the explicitly-deferred #834 Phase 2. Rewrote the message to be accurate: recovery restores all *relational* state (the hard-to-recreate part that #834 exists to protect); re-running the agent needs Phase 2. Added `needs_container_recreate: true` to the response. What recovery delivers today (correct + tested): the agent_ownership row, chat history, schedules, sharing, permissions, credentials config — every relational artifact — comes back intact and visible. What it doesn't (Phase 2): a runnable container. Live-verified on the running stack: create → soft-delete (GET 404) → recover (200) → GET now 200 with needs_start=true → agent visible. Start still 404s by design until Phase 2; message + flag now say so. Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(recover): centralize models + cap limit + docs (#834 Phase 1c) Addresses PR #840 review (vybe, CHANGES_REQUESTED); rebased onto the updated #839/#838. - Move SoftDeletedAgent / SoftDeletedSchedule from routers/admin_recovery.py to models.py (Architectural Invariant #14 — response models are centralized, not declared in router files). - Cap limit on both list endpoints: limit: int = Query(200, le=500) — prevents unbounded DB reads (422 on >500). - requirements.md: §32.3 fleshed out (endpoints, recovery semantics, metadata-only/container-recreate-is-Phase-2, audit events, model location). - architecture.md: new "Soft-Delete Admin Recovery (#834 Phase 1c)" endpoint table with all 4 endpoints, auth, and behavior notes. Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 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…
Summary
DELETE /api/agents/{name}is no longer destructive. Marksagent_ownership.deleted_at, preserves every child row (sharing, schedules, chat history, permissions, credentials, MCP key, workspace volume), and runs the hard-purge from a configurable retention sweep (default 30 days,agent_soft_delete_retention_daysinsystem_settings;0disables).Scope:
agent_ownershiponly. Phase 1b will extend toagent_schedules/users/agent_shared_files/agent_sessions/chat_sessionsonce this pattern is validated.Changed behavior
DELETE /api/agents/Xdeleted_at; keeps everything elseGET /api/agents/X(deleted)GET /api/agentsdeleted_at IS NULL)POST /api/agentswith deleted-but-not-purged namedeleted_atpast retention →purge_agent_ownership()(cascade via #816)Read-path audit
35 SELECT/JOIN sites against
agent_ownershipfiltered withWHERE deleted_at IS NULL. The 4 unfiltered sites are intentional and commented:purge_agent_ownershipinternals — need to see the soft-deleted rowrename_agentuniqueness check on destination name — name reservationcanary/snapshot.py_collect_agent_settings—known_agentsset drives L-03; treating soft-deleted-pending-purge agents as "unknown" would surface their preserved child rows as false-positive orphansNew helper
is_agent_name_reserved()(unfiltered companion toget_agent_owner()) caught a real bug during smoke testing: without it, the create flow's existence check atcrud.py:95returns False for a soft-deleted name (becauseget_agent_ownernow filters), the create proceeds, then the SQL INSERT hits the UNIQUE constraint half-way through container provisioning. The unfiltered check at the same site rejects the create up-front with a clean 409.Live verification (uvicorn auto-reload on the running stack)
#816 registry (vendored)
PR #829 was closed — its standalone-cascade approach is superseded by this PR's soft-delete pattern (the registry still ships, now firing at purge time, not at delete time). This PR is now the canonical source of
src/backend/db/agent_cleanup.py.Schema
agent_ownership.deleted_at TEXT(NULL = live)idx_agent_ownership_deleted_at ON agent_ownership(deleted_at) WHERE deleted_at IS NOT NULL— keeps the retention sweep scan cheap as the live fleet growsdb/migrations.py:_migrate_agent_ownership_soft_deleteOut of scope (later)
agent_schedules,users,agent_shared_files,agent_sessions,chat_sessions. Each as a separate PR.UPDATE agent_ownership SET deleted_at = NULL WHERE agent_name = X.Test plan
deleted_atcolumn + partial index presentdeleted_at, leaves child rows intactGET /api/agents/{name}returns 404GET /api/agentsexcludes soft-deletedpurge_agent_ownership()cascades + drops parent rowtests/unit/test_agent_soft_delete.pycover soft-delete, idempotency, purge, name-reservation, retention-cutoff math (CI will run them)Related to #834. Phase 1a only.
🤖 Generated with Claude Code