Part of Trinity's requirements set. Index & write-path rule: requirements.md.
-
Implements: Issue #834 Phase 1a
-
Description:
DELETE /api/agents/{name}no longer hard-deletes theagent_ownershiprow. It marksagent_ownership.deleted_at = NOW(NULL = live) and preserves every per-agent child row, so an accidentally-deleted agent's history, schedules, and config remain recoverable until the retention window expires. The container and runtime resources are still torn down on delete — only the database rows are retained. -
Retention purge: the Cleanup Service (
cleanup_service.py, 5-min loop) hard-purgesagent_ownershiprows whosedeleted_atis older thanagent_soft_delete_retention_days(default 180,0= disabled — soft-deleted rows then persist until manually purged). Purge runs the #816purge_agent_ownership→cascade_deleteprimitive so all per-agent child rows are removed in one transaction;KEEP-policy tables (schedule_executions,nevermined_payment_log) survive per their own retention discipline. Each purge additionally removes the agent's Docker data volumes (#1581) and is therefore unrecoverable — so the #1644 blast-radius guard floors this sweep at 0: any purge at all requires an explicit admin acknowledgement before it runs. (RETENTION_CHUNK_SIZE_PER_CYCLEbounds each transaction, not the call — there is no per-cycle row cap; see #1644.) -
Name reservation:
is_agent_name_reserved()is intentionally unfiltered — it sees soft-deleted rows so a soft-deleted name cannot be reused (and silently clobbered) before purge. -
Scheduler gap closed:
list_all_enabled_schedules()(backend + the standalone scheduler process) joinsagent_ownershipand filtersdeleted_at IS NULL, so a soft-deleted agent's enabled schedules stop firing immediately rather than generating aschedule_executionsfailure row per cron tick for up to 180 days. -
Canary: soft-deleted agents are intentionally kept in the canary snapshot's
known_agentsset (NOT filtered bydeleted_at) so L-03 (delete-cascade) does not false-positive on the child rows that are legitimately preserved until the retention purge runs. -
Setting:
agent_soft_delete_retention_daysin the ops settings block (default"180","0"disables). -
Storage:
agent_ownership.deleted_at TEXT+ partial indexidx_agent_ownership_deleted_at ON agent_ownership(deleted_at) WHERE deleted_at IS NOT NULL. Migrationagent_ownership_soft_delete.
- Implements: Issue #1644 (follow-up to #1638)
- Description: before any window-driven destructive prune,
services/retention_guard.pycounts the candidate set (bounded, so the cost is O(threshold) not O(candidates)) and refuses the prune if it exceeds the threshold, logging at ERROR and raising anoperator_queuealarm naming the setting, the window, the window's source (db-row/code-default), and the counts. The prune proceeds only after an admin acknowledges it. Covers all 7 window-driven prunes. - Why: #1638 fixed one mechanism (a retroactive default change).
It left every other route to a destructive window open — an unvalidated
PUT /api/settings/ops/config, a future default regression, a direct DB write. The guard does not care how the bad window arrived. - Acknowledgement:
POST /api/settings/retention/acknowledge(admin and human-only) is the gate; the operator-queue item is an alarm and authorizes nothing. An ack is bound to the window in force (409 on mismatch — approving a prune at 30 days does not approve one at 1 day) and single-use (consumed once the prune runs, so the guard re-arms and one approval can never authorize an unboundedly larger future delete at the same window). - Threshold: a fixed constant (
retention_guard.MAX_ROWS_PER_SWEEP, 1000) — deliberately NOT an operator setting. It was briefly configurable via Settings; that was wrong twice over: nobody can reason about the right value (it depends on per-cycle churn they cannot see — the panel needed a caption explaining that bigger is worse, and a control that must explain which way is safe is the wrong control), and a mutable constant read at action time gating a destructive operation is #1638 one level up — raising it would silently disarm the guard fleet-wide. Deleting the knob deleted its clamp, its endpoint, its blocklist entry, and a whole fail-closed branch. Chosen against steady state, not table size: only rows crossing the cutoff within one 5-min cycle are candidates, so four digits means something changed. Lowering is always safe; raising is a code change with a reviewer, not a text box. Surfaced read-only atGET /api/settings/retention→guard.max_rows. Per-sweep floors: rows → the constant, schedules → 100, agents → 0. - Fail-closed: any error — the count throws, the ack lookup throws — refuses the prune. A guard that fails open is worse than no guard because it manufactures confidence. (There is no 'threshold unreadable' path: the threshold is a constant, so that failure mode does not exist.)
- Expected behaviour: a legitimate first-enable of retention on a mature install will trip the guard once, and that is intended — the guard cannot distinguish a large legitimate backlog from a mistyped window, so it asks once and the operator acknowledges once.
- Implements: Issue #834 Phase 1b (PR #839)
- Description:
DELETE /api/agents/{name}/schedules/{id}marksagent_schedules.deleted_at = NOWinstead of hard-deleting. The row and itsschedule_executionsare preserved for the retention window so an accidentally-deleted schedule (and its run history) is recoverable. - Read paths: every schedule read filters
deleted_at IS NULL— including the cron-firinglist_all_enabled_schedules()in both the backend (db/schedules.py) and the standalone scheduler process (src/scheduler/database.py), so a soft-deleted schedule stops firing immediately. That firing query also retains the Phase 1aagent_ownershipjoin (ao.deleted_at IS NULL), so a schedule is skipped if either it or its agent is soft-deleted. - Idempotency:
delete_schedule()on an already-soft-deleted row is a no-op success (no double-soft-delete, no error). - Retention purge: the Cleanup Service hard-purges
agent_schedulesrows pastschedule_soft_delete_retention_days(default 30 — shorter than the 180-day agent window because schedules are higher-churn;0= disabled).purge_schedule()refuses to purge a live row and cascades the schedule'sschedule_executionsdelete alongside the parent row — consistent with the previous hard-delete behavior and with agent-purgecascade_delete. No #816 chain (schedules have no #816-registered child tables). Bounded by the shared 5000-row/cycle cap. - Execution-row ownership: pre-purge, a soft-deleted schedule's
schedule_executionsare #772's responsibility (its 90-day terminal-row sweep ages them out independently); at purge they are deleted with the row. - Setting:
schedule_soft_delete_retention_daysin the ops settings block (default"30","0"disables). - Storage:
agent_schedules.deleted_at TEXT+ partial indexidx_agent_schedules_deleted_at ON agent_schedules(deleted_at) WHERE deleted_at IS NOT NULL. Migration indb/migrations.py.
- Implements: Issue #834 Phase 1c (PR #840)
- Description: Admin-only surface to list and recover soft-deleted
agents/schedules before the retention purge hard-deletes them.
Replaces the prior shell-only workaround (manual
UPDATE ... SET deleted_at = NULL), which required DB access and was unauditable. - Endpoints (all
require_admin, all audit-logged):GET /api/admin/soft-deleted/agents— list soft-deleted agents, newest first. Each row carries a computedpurge_eta(when the retention sweep would hard-purge it;nullwhenagent_soft_delete_retention_days = 0).limitcapped at 500.POST /api/admin/soft-deleted/agents/{name}/recover— cleardeleted_at. 404 if not in the soft-deleted set. Metadata-only: the Docker container is not recreated (removed at soft-delete); the agent showsstatus=stopped/needs_container_recreate=true. Operator brings it back viaPOST /api/agents/{name}/startfrom the preserved workspace volume. Container recreate-on-recover is #834 Phase 2.GET /api/admin/soft-deleted/schedules— list soft-deleted schedules (optionally?agent_name=-scoped), withpurge_etafromschedule_soft_delete_retention_days.limitcapped at 500.POST /api/admin/soft-deleted/schedules/{id}/recover— cleardeleted_at. 404 if not soft-deleted. The schedule rejoins the scheduler firing list on the next poll if it was enabled.
- Recovery semantics: flips
deleted_atback to NULL; child rows already survived the soft-delete so the entity is immediately usable via the regular (deleted_at-filtered) read paths. - Audit: every recovery emits an
agent_lifecycle:recover/agent_lifecycle:schedule_recoverplatform-audit event. - Models:
SoftDeletedAgent/SoftDeletedScheduleresponse models live inmodels.py(Architectural Invariant #14).
Description: Agents deployed to Trinity that don't follow Trinity
best-practices (no playbooks, missing YAML, .claude/ excluded from
.gitignore, no template.yaml) fail silently at runtime in ways that are hard
to diagnose. Trinity runs server-side compatibility checks against a running
agent's workspace and surfaces actionable recommendations — without blocking
deployment. Canonical check list: docs/agent-validation-spec.md (100
checks, 11 categories), the single source of truth kept in lockstep with
services/compatibility/spec.py by a sync test.
- FR-1 — Surface: results render in the Agent Detail Overview tab
(
components/CompatibilityPanel.vue, reusing the "needs attention" idiom — count hidden when clean, expandable to the full grouped checklist) and via the MCP toolget_agent_compatibility_report. Re-runnable on demand. Non-blocking. - FR-2 — Severity: each check is HARD (will likely break Trinity),
SOFT (best practice), or INFO, with
pass/fail/skippedstatus. HARD is reserved for deterministic STATIC checks; AI-evaluated checks are capped at SOFT (an LLM verdict never drives the HARD count). - FR-3 — Check types:
[STATIC]deterministic file/pattern analysis (run always, free);[AI]LLM-evaluated quality judgments (Claude Haiku, batched by category, persisted so they show on every load;include_aiforces a re-run). - FR-4 — Collection: ONE
docker execruns an in-container Python script that emits a single JSON workspace snapshot (per-file binary/size/truncation handling, secret-bearing files existence-only); pure check functions evaluate the snapshot (unit-testable, no Docker). Stopped/unreadable container → a degradedunavailablereport (showing the last persisted result), never a 500. - FR-5 — Auto-fix: the 10 gitignore-related checks are auto-fixable via
POST /api/agents/{name}/compatibility/fix(owner/admin); the fix edits the in-container.gitignoreonly (atomic write, per-agent Redis lock) and is uncommitted until the agent's next git sync (no auto-commit). - FR-6 — Runtime-aware: Claude-specific checks (
CLAUDE.md,.claude/skills) are omitted for non-Claude runtimes (Codex/Gemini, #1187). - FR-7 — Reuse/consolidate: builds on the #950/#982 deploy-local logic
(
_is_platform_injected, the${VAR}/.env.exampleparsing) for the C-001/C-002 and K-001/K-002 overlaps, and ongit_service._GITIGNORE_PATTERNS_detect_git_dirfor the fixes.
API: GET /api/agents/{name}/compatibility?include_ai= (read; STATIC live +
persisted AI), POST /api/agents/{name}/compatibility/fix (owner/admin).
MCP: get_agent_compatibility_report(agent_name, include_ai?).
Persistence decision (departs from the issue's "no DB table" note). The
original issue specified transient results with no table. Implementation adds
agent_compatibility_results (latest-snapshot-per-agent, dual-track SQLite +
Alembic) because AI verdicts are not cheaply recomputable (they cost API
calls): persistence lets AI findings show on every Overview load without
re-spending tokens, unlocks fleet aggregation ("N agents have HARD findings"),
and enables cheap post-fix re-checks. STATIC checks still recompute live each
read; persisted AI verdicts merge in until a re-run. History/trend retention is a
fast-follow (latest-only for now).
Out of scope (fast-follow): broken-agent boot triage (a stopped/failing container can't be exec'd — this validates running agents); AI-verdict trend history; the forward-looking template-level checks (#927 replica-safety, #1084 side-effect profile).
Description: At first-run setup (the admin-creation step), the operator may
provide their email + company (plus optional name/role/use-case) and opt
in to "occasionally receive important security & product updates." On that
affirmative consent, the details are submitted once to an Ability.ai-operated
hosted intake endpoint — a sibling endpoint on the same Cloudflare-fronted intake
app as #1116's in-app bug reporter (/v1/report-bug → /v1/operator-intake).
This is identifiable, explicit opt-in contact capture, distinct from the
anonymous usage telemetry tracked separately (#758 / trinity-enterprise#12).
- FR-1 — Capture & consent: required
email(the admin sign-in identity, trinity-enterprise#49) plus optionalcompany/name/role/use_caseonPOST /api/setup/admin-password; an affirmative, unchecked-by-default consent checkbox (consent_updates). Declining the updates opt-in (or skipping the optional profile fields) never blocks completing setup; only the email and password are mandatory. The form shows exactly what is sent and to whom. - FR-2 — Hosted intake, no email needed: the submission is a fire-and-forget
HTTPS POST (
services/operator_intake_service.py,httpx, 5s) — it does not use the email provider, so it works on a fresh install with no Resend key. A blocked/failed/air-gapped POST never delays or breaks setup. - FR-3 — At-most-once: a server-side
operator_intake_submittedmarker insystem_settingsis claimed before the POST, so restarts / re-runs / concurrent workers never double-submit. A stable randominstallation_id(also insystem_settings, the seed for future #758 telemetry) correlates the submission. - FR-4 — Off switch:
OPERATOR_INTAKE_ENABLED=false(or the cross-toolDO_NOT_TRACK=1) fully disables the outbound submission for air-gapped / privacy-strict installs — the consent box still appears, nothing leaves the box.OPERATOR_INTAKE_URLrepoints the endpoint (self-host). Consent fires only onconsent_updates && email.
Description: The email captured at setup becomes the admin's sign-in
identity — the operator can log in with email + password instead of the
fixed admin username. No verification email is sent: a fresh install has no
email provider configured, so the email is simply bound to the admin account
(not verified via a code). The code-based second factor (email OTP after
password) is Phase 2, gated on a configured email provider and the existing
mfa_gate/SecondFactorProvider seam (#5/#388) — out of scope here.
- FR-1 — Resolve by username OR email:
dependencies.authenticate_userresolves the identifier by username, then (when it looks like an email and no username matches) by email. The password check still runs, so only an account with a password hash (the admin) can authenticate — email-code-only users (no password) never can. - FR-2 — Setup binding:
POST /api/setup/admin-passwordrequires the email (missing → 422 at the model layer; blank/typo → 400, validated before any write so setup never half-completes) and binds it to the admin viadb.update_user('admin', {'email': …}). Login UI exposes an editable "Username or email" field (defaultadmin). The setup token (#1165/SEC #177) is removed (trinity-enterprise#49) — no token field, no Redis dependency for setup. - FR-3 — Existing-admin transition: an admin created before #82 (stored email
= placeholder
admin) registers a real email viaPUT /api/users/me/email(own-account scoped; 409 if the email belongs to another account), surfaced as an Admin sign-in email card in Settings → General. No verification email is sent; existingadmin+password login keeps working until/unless an email is set.
Description: A generic agent report primitive — agents publish typed-but-flexible structured reports (telemetry, domain results: leads found, KPI snapshots, weekly summaries) via an MCP tool. Reports are persisted, surfaced on the Agent Detail "Reports" tab and a fleet-wide Reports view, so users see what each agent produces without reading chat transcripts. Three-surface feature (backend router, MCP tool, frontend); no agent-server endpoint — reports flow agent → MCP → backend.
- FR-1 — MCP tool
report:report(report_type, title, payload, display_hint?, schema_version?, period_start?, period_end?). The reporting agent + author are resolved server-side from the MCP auth context (agent-scoped key → bound agent); the tool requires an agent-scoped key so a report cannot be attributed to another agent. - FR-2 — Storage:
agent_reportstable (id, agent_name, user_id, report_type, title, payload JSON, display_hint, schema_version, period_start/end, created_at). Indexes on(agent_name, created_at DESC),(report_type, created_at DESC), and(created_at)for the retention sweep. Dual-track migration (SQLitemigrations.py+ Alembic0006). - FR-3 — Backend API (access control mirrors
/api/executions): self-gatedPOST /api/agents/{name}/reports(agent-scoped key must equal the path agent; payload capped at 256 KB → 413; fields strictly validated),GET /api/agents/{name}/reports(metadata only),GET /api/reports(fleet, accessible-agent filtered;agent/report_type/hours/search),GET /api/reports/stats(total / by_type / agents KPI counts),GET /api/reports/{id}(full payload; 404 on no-access),DELETE /api/agents/{name}/reports/{id}(owner; scoped by agent_name + id). - FR-4 — Real-time: a thin
agent_reportWebSocket trigger (agent_name, report_id, report_type, created_at — never title/payload, since/wsis unfiltered SCOPE_ALL); the frontend refetches via the access-controlled REST endpoints. - FR-5 — Frontend: Agent Detail "Reports" tab + Operations → "Reports" fleet tab. Generic
- typed renderers (table / KPI tiles / markdown / timeline / JSON) chosen by
display_hint, thenreport_typeprefix, then JSON; each renderer validates payload shape and falls back to the JSON viewer on mismatch. List shows metadata; full payload lazy-loads on expand.
- typed renderers (table / KPI tiles / markdown / timeline / JSON) chosen by
- FR-6 — Retention: cleanup sweep deletes
agent_reportsolder thanagent_reports_retention_days(default 90;0disables), chunked like the #772 sweeps.
Deferred: effect-guard dedup on report() for at-least-once pull-mode re-delivery
(#1084/Epic #1045); audit-log entry on write; per-report sharing distinct from agent access.
Description: A local-only product-event capture layer — the Tier-1
half of the two-tier telemetry model (Tier-2 = opt-in anonymized fleet sharing,
#758 / trinity-enterprise#12, which builds on this). Tier-1 records
activation/usage events on the operator's own instance, default-ON, with zero
network egress, so the operator can see where their own first-run users drop
off. It is not a sovereignty concern — nothing leaves the box — and is distinct
from the identifiable opt-in operator intake (§43.1): this is anonymous,
instance-local instrumentation keyed by the same installation_id.
Open-core split (product decision, gating confirmed ent#184): the capture
is OSS-core (the edition-agnostic instrumentation primitive, default-on); the
operator-facing activation-funnel view is an entitlement-gated enterprise
surface (telemetry feature-id). The generic seam is documented here; the funnel
module's design lives in the private submodule.
- FR-1 — Event set v1 (OSS capture): the genuinely-new client beacons are the
onboarding-wizard step transitions —
setup_started,setup_step_intro,setup_step_create,setup_step_credential,setup_completed,setup_dismissed— emitted bycomponents/OnboardingWizard.vuethroughstores/productTelemetry.js→POST /api/product-events. First-value events (first_agent_created,first_chat,first_schedule_created,first_channel_connected) are derived on read from the rows Trinity already writes (audit_log,agent_activities,schedule_executions), never re-emitted — so they survive restart by construction and add no write path. - FR-2 — Storage (OSS): a local SQLite/Postgres table
product_events(installation_id,event_type,event_contextoptional small JSON,created_at; dual-track migration +db/tables.pyMetaData). The emit endpoint accepts only a fixed allow-list ofevent_typevalues (unknown → 422) so the table can't be spammed with arbitrary strings. Rows carry the stableinstallation_id(§43.1) and a UTC timestamp so Tier-2's opt-in retroactive backfill at consent can serialize history — the mechanism that rescues early-funnel data despite consent arriving late. - FR-3 — Zero egress: the capture layer NEVER phones home; the emit endpoint writes one local row and returns. All sharing/consent lives in Tier-2 (#12). Verifiable and documented as local-only in user docs.
- FR-4 — Operator funnel view (enterprise-gated): an operator-facing
activation/funnel panel on an existing admin surface (Settings, admin-only)
shows step-by-step activation counts + drop-off with an honest empty state when
there's no data yet. It reads a gated enterprise endpoint
(
requires_entitlement("telemetry")) that aggregatesproduct_events+ derives the first-value events from the OSS tables above. The panel Vue ships in the OSS bundle but is hidden unlesstelemetryis inenterprise_features(the standard feature-flag gating). Explicitly NOT a new standalone analytics dashboard in v1.
Deferred: auto-retention sweep for product_events (volume is negligible —
a handful of rows per install); per-user (vs per-install) funnel cohorts.
Description: the opt-in egress layer on top of Tier-1 (§45). On explicit, default-off, reversible operator consent, Trinity periodically shares anonymized aggregates with the Ability-operated hosted intake in exchange for reciprocal value (fleet benchmarks). The hosted aggregation/benchmark service is a separate issue; this covers the client consent + egress + backfill + the gated benchmark status surface.
Open-core split (gating confirmed ent#12): the consent + egress + backfill
are OSS-core (the sovereignty primitive — the operator's choice to share is
edition-agnostic, and it mirrors the OSS operator-intake #38 credential-free
transport); only the reciprocity benchmark view is entitlement-gated
(telemetry).
- FR-1 — Two-gate egress, never without consent: egress fires only when BOTH
the stored
telemetry_sharing_enabledconsent (system_settings, default-off) AND the config switchTELEMETRY_SHARING_ENABLED(honorsDO_NOT_TRACK) are on. Either off ⇒ nothing leaves the box. Both re-checked inshare_now. - FR-2 — Anonymized aggregates only:
services/telemetry_sharing_service.pybuild_aggregate_payload—installation_id(anonymous), version/edition/ platform/python, coarseenterprise_features, agent + execution counts, and the Tier-1 activation-funnel counts. No PII, no content, no prompts, no emails, no agent names. The exact payload is inspectable before send viaGET /api/settings/telemetry-sharing→payload_preview(the Settings panel). - FR-3 — Periodic heartbeat + reversibility:
TelemetrySharingServiceis a sleeps-first background loop (default 24h, jittered) that shares when consent is on; opt-out stops egress at the next heartbeat. Fail-open (a blocked/failed/ air-gapped POST never affects the platform). Reuses the operator-intake httpx fire-and-forget transport. - FR-4 — Retroactive backfill at consent: on the off→on transition the router
schedules an immediate fire-and-forget backfill share over a disclosed window
(
backfill_days, default 30) sourced from Tier-1product_events, so late consent still yields accurate benchmarks. Disclosed at the moment of consent. - FR-5 — Consent surfaces: a value-framed, optional, non-blocking ask in the
onboarding wizard (
OnboardingWizard.vue, hidden when hard-disabled) + a reversible default-off toggle in Settings → General (components/settings/TelemetrySharingPanel.vue), each stating exactly what is shared.PUT /api/settings/telemetry-sharingis admin + human-only, audit-logged. - FR-6 — Reciprocity carrot (gated, v1 status surface):
GET /api/enterprise/telemetry/benchmark(entitlement-gated) reports whether the operator is sharing and that benchmarks arepending_hosted_serviceuntil the hosted service lands; the OSSActivationFunnelPanelrenders it. Percentiles are computable only for participants, so sharing is structurally the price of the comparison.
Deferred: the hosted aggregation/benchmark service (separate issue); v2/v3 carrots (targeted alerts, live in-app benchmark panel, roadmap influence); warm-ask-after-value prompt.
Description: A disposable-agent lifecycle — an agent is created with a hard
budget (max_executions and/or ttl_seconds) and is hard-discarded when
the budget is exhausted: container removed, DB rows purged via the cascade
primitive, Redis runtime state cleared. Ghosts never enter soft-delete/retention
(no 180-day name reservation) and are volume-less (container writable layer only —
they never recreate, so nothing needs to survive a recreate). Every requirement
below is OSS code; creating an agent with a budget additionally requires the
ephemeral_agents entitlement (registry read — the registering module is
private). Scoped to heterogeneous-workspace jobs
(different repo/config per ghost); same-agent burst parallelism stays with
fan_out and, post-pull, replica groups.
- FR-1 — Budgeted creation:
POST /api/agentsaccepts an optionalephemeral {max_executions?, ttl_seconds?}block (≥1 required;ephemeral_expires_atis ALWAYS stamped, defaulting to the TTL ceiling, so no ghost is immortal). Ghost names are server-suffixed ({name}-{rand}) — unique-by-construction. Defaults:max_parallel_tasks=1, no credential injection (opt-in), git auto-sync off, no avatar seed, no workspace volume. Gates, in order: entitlement (403) → ephemeral-caller refusal (an ephemeral agent cannot spawn ephemeral agents, 403) → atomic per-owner ephemeral quota (Redis INCR-with-cap, 429) → per-parent spawn rate limit (429, agent-scoped callers). Labels:trinity.ephemeral=true,trinity.ephemeral-expires-at,trinity.spawned-by. - FR-2 — Budget enforcement: admission gate at the TOP of
CapacityManager.acquire(beside the dispatch-breaker gate — nothing is enqueued for an exhausted/expired ghost; predicate counts terminal + running + queued rows). Terminal-side: anapply_resultpost-CAS-win hook counts ALL terminal statuses and background-triggers discard at budget (fail-open, after slot release)./chatfinalizes outsideapply_result— its exhaustion is admission-gated immediately and discard lags to the GC sweep (≤5 min), documented. Pull-mode note: the #1081 claim endpoint must re-check the same predicate. - FR-3 — Hard discard:
discard_ephemeral_agent(name)under a per-name Redis SETNX lock, crash-convergent ordering: (0) durable intent marker (ephemeral_expires_at = now) → (1) cancel queued + CAS-fail all non-terminal rows (ghost_discarded) + close activities → (2) remove container (force, NotFound-tolerated) → (3)clear_agent_runtime_state(BEFORE purge — the name must never free while slots/heartbeat keys survive) → (4) purge viacascade_delete(executions KEEP; age out via the 90d retention sweep) → (5) auditephemeral_discard.DELETE /api/agents/{name}routes ephemeral agents here (branch BEFORE the container lookup; a half-discarded ghost is force-discardable, never 404). - FR-4 — GC:
cleanup_service._sweep_ephemeral_agents(5-min): DB pass (expired/exhausted rows → discard) + Docker-as-truth orphan pass (trinity.ephemeralcontainers with no live ownership row, older than a ~15-min newborn grace window → removed). Capped per cycle; folds into the consolidated lease reaper later (#429). - FR-5 — Ghost key containment: a ghost's key stays
scope="agent"(a new scope value would break heartbeat/report/callback auth, which key offUser.agent_name= scope-"agent"-only); containment is a(method, path)allowlist enforced at the single auth entry point (the connector-fence pattern), keyed off the agent row'sis_ephemeral— the flag dies with the ghost. Allowed: heartbeat, execution result callback, reports, notifications, own info; everything else 403. v1 has NO trusted opt-out (a parent needing a fully-capable worker creates a durable agent); fail-open on DB read error. - FR-6 — Spawn provenance + parent control (Part 2): any agent-spawned
creation (durable or ephemeral) auto-writes the
agent_permissionsparent→child edge (created_by="spawn:{parent}") and persistsspawned_by_agent+spawned_by_key_idonagent_ownership— the parent can immediately chat/list/info the child. Agent-scoped callers may start/stop/delete ONLY agents whosespawned_by_agentANDspawned_by_key_idmatch the calling key (interim until #948 capability tokens); sharing, permission grants, rename, and credential ops stay human-only (403 for agent-scoped callers). Fleet-wide narrowing of agent-key breadth on other mutating routes is an accepted-risk follow-up. - FR-7 — Fleet hygiene: ghosts are excluded from the heartbeat watch loop and
fleet health polling (no stale-alerts for discarded ghosts); operator-queue
polling keeps them (a ghost may escalate). Execution/cost stats stay inclusive
(billing truth). Schedule creation on a ghost → 400
schedule_on_ephemeral_agent.is_ephemeralsurfaced onGET /api/agents+ MCPlist_agents. Post-discard, KEEP execution rows are admin-only visible (owner visibility derives from the purged ownership row) — documented.
Deferred: non-LLM command-runner runtime; gVisor/microVM isolation lane;
per-ghost egress control; creation UI (MCP-first); is_ephemeral filter on
/api/executions if stats skew materializes; durable-agent volume-leak fix
(separate public bug — volume_remove has no callers).