Skip to content

AI remediation agent

Implementation contracts and behavior for ai remediation agent.

The issue system turns “my episode won’t download” into a supervised agent workflow:

  1. Observe, then report or detect: users tap “Report a problem” on media (admin-toggleable); every report names the exact active/detail Radarr, Sonarr, Chaptarr, or Lidarr instance, and otherwise-identical reports against different instances remain distinct. A book report names the library’s foreignBookId (plus the format when a title exists as both ebook and audiobook; two distinct records, never silently merged) and the server resolves the durable Chaptarr record ids live at intake. Auto-detection watches all four services’ queues: the Chaptarr and Lidarr pollers end with the same complete-snapshot auto-dispatch pass as Radarr/Sonarr. Every user report and auto detection starts silently as observing/recovering: read, excluded from the badge, no push, no agent run, and no proposal. Successful complete queue snapshots are cached briefly and drive durable observation; incomplete/capped or failed reads are never interpreted as an empty queue. Replacement download IDs stay in one incident keyed by exact instance + movie/episode scope (including exact S00 specials), and every observed ID is retained for recovery attribution. A problem is promoted once only after both the configured minimum age (10 minutes) and unchanged quiet window (5 minutes); absence must also pass the settle window (2 minutes). A “stalled” verdict additionally cannot open an automatic incident until the download is 15 minutes old (arr-clock added): torrent clients flag “stalled” the moment no data moves, which describes every torrent during tracker warmup; a 34-second-old “stalled” flag once walked a book’s only existing release into an auto-approved blocklist before it ever had a chance to connect. The dwell delays only the incident’s birth; a download still stalled past it is picked up by the next sweep, and any other problem class in the same snapshot opens its incident immediately. Continuous connection/proof uncertainty lasting the minimum window becomes needs_admin without starting the agent, so reports neither alert prematurely nor disappear forever. Queue disappearance or file presence alone never proves resolution. arr_state_cleared requires the exact live file plus an exact-media import-history record that binds its file ID to one observed download ID. If Cantinarr’s first baseline already contains that file, the queue response must have supplied the exact media’s file ID (or known absence), any supplied positive ID must match the live/imported file, and the receipt must be no older than the queue attempt’s arr-provided added time. This handles imports that beat the baseline and already-imported queue rows without trusting cross-service clocks. Cantinarr persists only the compact validated receipt (history/download/file IDs and timestamp), never raw history data. One incident recovers the opposite way: a stuck upgrade that Cantinarr deliberately abandoned (blocklist_only) is resolved by the library file staying exactly as the baseline recorded it, because nothing was ever missing and the fix’s whole purpose was to stop chasing a replacement. That branch is gated on the server’s own dispatched abandon fix and runs only after the exact queue target is proven gone; without both, “queue row gone, file unchanged” still means a download that quietly died, which reaches an administrator. A book want has a third ending (removed_no_replacement): the server’s own dispatched blocklisting fix removed the dead download and the arr’s replacement search found nothing to grab; baseline no file, still no file, queue target gone, and the executed blocklist facet all typed-proven. Before this terminal existed that shape could conclude neither success nor failure, so it re-promoted a fresh agent run on every settle and finally told an admin the fix “could not be verified”; about the one thing in the story that verifiably ran. It closes as resolved with copy saying exactly that: removed and blocklisted, nothing available right now, the library keeps monitoring. A standing rule whose action produced this ending keeps its clean record (it did precisely its job), and the give-up path’s rule pause now says the issue was handed to an administrator rather than claiming a close that never happened. Re-promotion is also resume-aware: a fresh enqueue on an issue holding a staged resume_pending decision hands it to the resume lane instead of racing it; a fresh run winning that race by seconds is how a staged approval was once reaped as superseded while a from-scratch run re-litigated the same incident.

  2. Investigate: a server-owned AI agent follows the currently selected admin shared provider and credential and runs a budgeted tool loop against read-only arr state bound to that issue’s instance and media scope. That read set is an exact hardcoded allow list: diagnose_queue, get_manual_import_candidates, search_releases, get_queue, get_history, get_library, get_arr_health, get_episode_timeline, get_book_timeline, get_album_timeline, get_media_file_details, get_service_config, get_quality_profiles, get_custom_formats: and it, not an RBAC role, is the enforcement boundary: only those definitions are offered to the model, and dispatch refuses any other name before the tool server is ever called. The two settings views are read-only and were added because several diagnoses: “Not an upgrade”, “Not a Custom Format upgrade”: are verdicts the service reached from its own configuration, so without them the agent can see the refusal but never the reason for it; scopeReadToolInput pins both to the issue’s own instance (they are the only read tools that do not receive it as trusted call context, and their resolver would otherwise fall back to the service default) and strips the keys that select the raw-JSON forms, leaving only the bounded summary; it also drops any model-chosen instance_id; every arr tool accepts one interactively, but a scoped issue read must stay on the issue’s own library. The write tools stay unreachable. By default it also follows the shared model; an admin may instead save a remediation-only model override after a real response test. It uses only admin-global credentials: the shared Anthropic/OpenAI/Gemini/xAI API key or shared OpenAI/xAI OAuth connection. Reporter identity, personal AI settings, per-user included-access grants, and legacy remediation provider/model fields never participate in provider resolution. The tested override is bound to its provider, so a later provider change falls back to that provider’s shared model instead of sending a stale designation. Budgets cover total tool calls, accumulated active wall-clock time across approval/reporter pauses, and daily run count. API-key providers receive max_turn_tokens as a request cap. Codex app-server has no equivalent request field, so Cantinarr records its per-turn usage notifications and interrupts once reported output reaches the configured ceiling. That is a best-effort guard rather than a hard cap: notification timing can let a response exceed the boundary before interruption. Wall-clock, concurrency, daily-run, and tool-step bounds remain independent safeguards. Each turn’s authoritative scope also carries the issue’s remediation memory: every fix already dispatched, the arr download it acted on, and whether the arr put that same download back afterwards. Without it a fresh run starts from an empty transcript, re-reads the Import Doctor’s prescriptive suggestion, and re-derives the fix that already failed. Only identity and clock fields cross into that system-role block; the arr’s own result text stays at user-role trust with the rest of the untrusted incident data.

  3. Ask: if the agent needs more information, the issue flips to awaiting_user and the question stays in its thread. Admins receive the push and can answer or coordinate with the reporter; the reporter may also reply in the thread.

  4. Propose: in supervised mode, mutating fixes (grab release, remediate queue, manual import, trigger search, rescan, delete media files) become typed agent_actions that always require admin confirmation. investigate_only mode records no proposal. The server validates the action against the issue’s authoritative instance/media/queue/download/episode scope, permits only one active proposal, and stores an admin override separately from the agent’s immutable proposal. For a release grab, the server binds title, quality, size, protocol, indexer, and rejection details from the latest exact scoped search; the approval card shows that server-observed metadata. Raw indexer capabilities are replaced by one-way references before persistence or API delivery. Approval refreshes the exact movie, season, or episode search, requires both the reference and metadata to match, and resolves the live capability only in memory for immediate dispatch; episode reports also trigger only an episode search. A manual import filters the just-fetched candidates by the same movie/series/episode identity even when force is approved. Deleting already-imported files is bound the same way: the action’s TMDB id must be the issue’s, a season- or episode-scoped issue pins the season, an issue naming one exact episode may delete that one episode’s file and no other, and a book issue cannot propose the kind at all. No queue row is left to re-check there: the download it cleans up finished successfully, possibly weeks earlier: so the library lookup from the issue’s own TMDB id is the identity gate, and files are deleted before any release is blocklisted, because a replacement search fired while the old file is still on disk is refused as “not an upgrade”. That one action is the whole repair: it deletes, blocklists, and then replaces, so one problem produces one approval rather than asking an admin to authorise the second half of a decision they already made. The replacement is a TV season search narrowed to the episodes that have already aired and are missing a file, resolved from live air dates at DISPATCH, never at proposal time: episodes air while a proposal waits for an admin, so a set frozen when the fix was written would search for content that still does not exist or skip content that now does. It is skipped in exactly one case: blocklisting already triggered the service’s own failed-download handling: because there the service has dispatched the search itself and a second would only duplicate it. An empty set is a completed search, not a failure. There is deliberately no aired-only variant on trigger_search: leaving one in the agent’s vocabulary would let it split the repair back apart, so the single-approval guarantee is structural rather than a line of prompt. Book issues carry the durable Chaptarr author/book record ids captured from the queue snapshot; title-level book mutations (trigger search, rescan, release grabs) validate against those exact ids; a single-book search must name the issue’s book, an author search/rescan its author, and a book release grab re-searches that exact book; and every one fails closed on a legacy issue that lacks them. The book recovery witness binds the import-history record (bookFileImported, the Readarr-lineage event vocabulary) by exact book + download identity; Chaptarr history carries no reliable file id, so a present file id must name the exact current file while a missing one additionally requires the receipt to postdate the download’s own attempt (a reused download id cannot resurrect a stale import as proof). It tracks the newest book file id across multi-file records, never closes while the incident’s queue row still signals a problem (a partially imported multi-part audiobook promotes for attention instead), and escalates to needs_admin when no receipt exists. Chaptarr queue snapshots carry the same complete-or-error contract as Radarr/Sonarr: truncated, oversized, or duplicate-id responses are read failures, never a shorter queue.

  5. Decide: every approval card and confirmation names the exact target service, instance name, and immutable instance ID. Approval uses a compare-and-swap claim so retries reconcile the durable state instead of dispatching again; denial (with an optional note) resumes the investigation. A fresh exact-scope recovery check runs both before and immediately after the execution claim: if the arr has begun retrying/replacing, the proposal is superseded, its run is aborted, the issue returns silently to recovering, and the executor is never called. A losing concurrent decision returns 409 Conflict, prompting the app to re-read the winner instead of claiming the attempted decision succeeded. Recovery never hides needs_admin, executing, or outcome_unknown. A process loss after dispatch cannot prove the remote outcome, so startup marks that action outcome_unknown and never guesses or silently replays it. Partial or unknown outcomes stop at needs_admin and abort the parked run; the model cannot propose another mutation until a human has verified remote state.

    Standing auto-approval rules remove the repeat approvals without removing the gate’s machinery. Approving a fix with remember: true arms a rule keyed on the exact triple of the issue’s problem label (issues.problem_kind; the Import Doctor’s verdict or the pre-air season finding; a USER report earns the same label when it attaches to a diagnosed queue row or the server’s own season check trips at proposal time, so a rule covers its dispatch too; the reporter still owns the close, and an undiagnosed report never matches anything), the action kind, and a per-kind safety facet (manual_import force vs. plain; each remediate_queue sub-action separately; delete_media_files also blocklisting the release (blocklist) vs. removing only the files (files_only)). Rules are global across instances and media types, but never wider than the triple the admin actually reviewed. Once a minute, the observation sweeper approves any gated proposal an active rule matches through the very same decision core; CAS claim, both recovery preflights, single dispatch, outcome recording; with decided_by null and auto_rule_id set for audit, and the model’s resume says a standing rule (not an admin) approved. Arming a rule is retroactive: matching proposals already waiting are approved on the next sweep, and a proposal auto-approved inside the push hold-down never pages. Trust lasts exactly as long as the track record is clean: the first failed or unverifiable auto-approved outcome pauses the rule in the same transaction that records the outcome, and an issue a rule acted on that later ends in any non-resolved pipeline verdict (give-up, wont_fix) pauses it too, while resolved closes increment its resolved counter (admin dismiss/complete are neutral). A pause posts a system message on the evidence issue and an agent_autoapproval_paused alert; re-arming is an explicit admin action (resume, or checking remember again on the next manual approval). A restart that interrupts an auto-approved executing action pauses its rule at boot, before the interrupted action is repaired to outcome_unknown. A rule may also never replay a fix unattended against a release it already ran on: every dispatched queue-scoped action records the arr download it acted on (agent_actions.target_download_id, taken from the issue’s download identity, which the executor’s identity gate has already proven matches the live queue row), and a proposal repeating that same kind and facet against the download the issue is still holding is left for manual approval while the rule stands down. A fix that ran and did not hold is not a clean track record; an arr blocklist can match on the release title rather than the release itself, so “remove, blocklist, re-search” can be followed seconds later by a re-grab of the identical download.

  6. Complete or audit: when judgment or manual verification is required (especially needs_admin/outcome_unknown), an admin can explicitly mark the issue resolved or wont_fix and may add a bounded completion note. A blank note becomes the attributed audit text Marked resolved. or Closed without a fix. so completion never requires typing and never loses the closing admin’s identity. The audit text, admin actor, aggregate close, proposed-action supersession, and parked-run abort commit together under admin_completed; a race returns 409 and the app reloads the winner. Dismiss remains a separate admin_dismissed workflow and does not claim review. Every action and run remains reachable from the issue, and runs persist their ordered step ledger (agent_runs/agent_steps) with token counts and stop reason. Model-facing issue text, tool results/errors, resume outcomes, transcripts, and audit text are credential-scrubbed before they are sent or stored; the reporter’s original thread message remains intact for the reporter/admin UI.

Admins review repairs; reporters hear when their problem is resolved. Applying a repair does not prove that a subjective report such as “wrong episode” has been fixed. The report stays open as needs_admin, appears unread in the admin queue, and sends admins a Repair needs review push. After an administrator checks the result and closes it as resolved through /api/admin/issues/{id}/resolve, its reporter receives Ready to try again. Pending reviews, questions, failed or dismissed reports, and other people’s reports never trigger that user push. The Problem resolved preference retains the existing issue_report_update key and saved opt-outs; both account and server push controls apply. Tapping the push opens the report, and its fixed title/body contain no diagnostics or report text.

A reporter who has already checked the result may still voluntarily use POST /api/issues/{id}/confirm-fixed. It is available only to the issue’s own reporter while the report is open, a repair has actually reached the service, and no action is mid-dispatch. The explicit confirmation is recorded in the thread and closes the report as reporter_confirmed, without echoing a success push back to the person who just closed it. A free-text reply is never a closure decision. The single-issue read carries can_confirm_fixed for this control; reporters cannot reopen closed threads. An administrator can reopen a report for manual review; its historical fix does not make it eligible for another reporter confirmation.

Reopen for manual review. POST /api/admin/issues/{id}/reopen requires administrator permission and no request body. It returns a closed issue to needs_admin, clears its current closure fields, and appends an attributed reopening message containing the previous outcome, provenance, closing time and note. Existing messages, actions and runs stay in history; old proposals are superseded, stale runs are aborted, and no work is enqueued. The persisted issues.reopened_at marker keeps observation and system-health recovery from automatically reclosing it, including after restart; an administrator completes or dismisses it after review. The thread read advertises can_reopen only to administrators on known closed states. A competing transition returns 409; an existing open issue with the same creation identity also returns 409 with existing_issue_id, which the app links to instead of creating a duplicate. Successful reopening refreshes the thread and queue through issue_updated.

Auto-dispatch has a circuit breaker: repeated agent give-ups disable it and notify admins. A missing provider is not a give-up: when remediation is enabled but no shared AI provider resolves, a detected issue simply stays open and is re-enqueued until one exists, one deduped source=system issue (system:remediation-provider) says so once, and the first successful resolve closes it with resolution_kind=remediation_provider_configured; the breaker counts agent failures, never configuration gaps, so a provider-less install can no longer silently switch its own auto-dispatch off. A tool-less answer or exhausted investigation becomes needs_admin rather than falsely resolving the report. Issue statuses: observing, recovering, open, investigating, awaiting_user, awaiting_approval, awaiting_confirmation, needs_admin, waiting, resolved, wont_fix, failed, dismissed. waiting is a system-owned wait on an external service doing its own work (today only the book-import stall): the server retries and resolves it itself, so clients render it as passive tracking; no completion verbs, no attention badge; and, like every non-open/investigating status, it is never enqueued for an agent run. New applied repairs awaiting a human check go directly to needs_admin. Startup moves legacy open awaiting_confirmation rows into that admin queue without closing them or replaying pushes; the former day-3 reporter reminder and day-7 escalation are retired. issue_question notifies admins under Problem reports (issue_created), and issue_fix_confirm notifies admins under Fixes to review (agent_action_pending). issue_closed is a reporter push only for a committed resolved user report owned by that recipient, excluding their own confirmation. Terminal issues also expose resolution, resolution_kind, and closed_at; current provenance kinds are agent_concluded, arr_state_cleared, reporter_confirmed, reporter_timeout, admin_completed, admin_dismissed, ai_health_restored, push_delivery_restored, book_import_cleared, remediation_provider_configured, prevention_setting_changed, and legacy_unknown.

Each issue also carries an admin read/unread flag: promoted issues start unread, as does an import-time pre-air finding (written straight to open rather than tracked), any non-admin (agent/system/reporter) status change re-flags it unread, and an admin opening the thread (or dismissing it) marks it read. Passive observing/recovering incidents stay read and do not count in the drawer’s Issues badge; a waiting issue is written unread (its creation push is the alert) but the app treats every tracking status as quiet; no badge, no unread dot on the row. The mark_resolved_as_read setting (default on) keeps a cleanly resolved issue read instead of re-flagging it.

View the maintained source for this page.