Warning, /swf-monitor/docs/EPICPROD_OPS_AGENT.md is written in an unsupported language. File is not indexed.
0001 # ePIC Production Operations Agent
0002
0003 `epicprod_ops_agent` is the always-on, credentialed executor for ePIC
0004 production. It performs the privileged production actions — submit to PanDA,
0005 stage logs from Rucio over xrootd, and the credentialed work to come — that the
0006 public web tier structurally cannot. Building out epicprod functionality is, in
0007 large part, adding capabilities to this agent: it is the intended instrument for
0008 programmatic work against the privileged production services.
0009
0010 This is a design/planning doc, peer to [PCS.md](PCS.md),
0011 [JEDI_INTEGRATION.md](JEDI_INTEGRATION.md),
0012 [EPICPROD_TASK_CATALOG.md](EPICPROD_TASK_CATALOG.md), and
0013 [PCS_DATASET_REQUEST_WORKFLOW.md](PCS_DATASET_REQUEST_WORKFLOW.md). Its
0014 operations counterpart — how to run, restart, and monitor the agent, and the
0015 concrete payload-log retrieval mechanics — is [EPICPROD_OPS.md](EPICPROD_OPS.md).
0016 The agent is built on the testbed's `swf_common_lib.base_agent.BaseAgent`, so it
0017 inherits testbed agent management and monitor visibility like the other agents.
0018
0019 ## Role — surfaces vs. executor
0020
0021 The system separates *presentation* and *API surface* from *privileged
0022 execution*:
0023
0024 - **Web tier (Apache/Django)** presents pages and holds **no credential**. It
0025 reads world-readable caches and the database, and it drops messages on the
0026 bus. It never runs a privileged client.
0027 - **REST and MCP** are thin peer API surfaces over the credential-free PCS
0028 service layer (`pcs.services`). REST serves the web UI and scripts; MCP is the
0029 LLM-facing API for bots and assistants. Both turn wire-format input into a
0030 service call — they are *surfaces*, not executors. **MCP is an LLM API, not an
0031 execution engine**: no PanDA, Rucio, or xrootd credential is ever wired into
0032 the MCP server or the web tier.
0033 - **The agent** is the **credentialed executor**. It runs as the production-ops
0034 user (currently `wenauseic`), so it alone holds the keys — the Rucio x509
0035 proxy, the PanDA OIDC token, xrootd — and it is the single chokepoint through
0036 which every privileged action passes, whatever surface triggered it.
0037
0038 A trigger may arrive from the web tier, from REST, from an MCP tool driven by a
0039 bot or assistant, or from cron. All of them resolve to one message on the
0040 agent's queue, and the agent does the credentialed work. Human-driven decisions
0041 drive deterministic execution; the agent is where that execution lives.
0042
0043 Concretely, this is what unblocks server-side submission. `JEDI_INTEGRATION.md`
0044 records that submission from swf-monitor was blocked because the web service has
0045 no PanDA identity. The agent removes the block without waiting for a robot
0046 account: running as the operator, it reuses the operator's cached production
0047 token and submits non-interactively. An OIDC service account remains the
0048 long-term path (it would let the agent submit as a robot rather than as the
0049 operator), but it is no longer a prerequisite for automated submission.
0050
0051 ## Credential boundary
0052
0053 The agent runs under the production-ops user, not the web service, putting
0054 ownership with production and keeping every credential out of the public-facing
0055 tier:
0056
0057 | Credential | Used for | Held by |
0058 |---|---|---|
0059 | PanDA OIDC token (cached, `EIC.production`) | `prun` submission to JEDI | agent |
0060 | Rucio x509 proxy (`longproxy-for-rucio`) | replica resolution, DID queries | agent |
0061 | xrootd (`xrdcp`/`xrdfs`) | fetching log/output bytes | agent |
0062
0063 The web tier's only interaction with privileged results is to **read a
0064 world-readable cache** the agent populates, or to **read the database record**
0065 the agent updates. It holds nothing and runs nothing privileged. The
0066 payload-log flow is the reference instance: the job page drops a
0067 `fetch_payload_log` message and later serves the extracted log from the cache;
0068 it never touches the proxy or xrootd.
0069
0070 **Where the agent writes — `$SWF_TMP_DIR`, never the deploy tree.** The agent
0071 runs as the production-ops user (`wenauseic`, group `eic`); the web tier runs as
0072 `apache`. Any artifact the agent produces for the web tier to read must live in
0073 the agent-writable, world-readable shared cache `$SWF_TMP_DIR` (`/data/swf-tmp`)
0074 — the tree the payload-log cache and the Rucio snapshots both use, with paths
0075 derived from the `SWF_TMP_DIR` setting. It must **not** live under the deploy
0076 tree (`/opt/swf-monitor/shared/...`), which is owned by `apache`: a doer that
0077 writes there passes when the web tier writes the file and fails the instant the
0078 agent takes over the write (`[Errno 13] Permission denied`). Like the
0079 external-safe trigger, this is a boundary the internal/dev path hides until the
0080 agent — not the web tier — performs the write.
0081
0082 ## Capability model
0083
0084 The agent is **event-driven, not polled** — the same low-latency model the
0085 testbed agents use, which matters as much for prod entities as for testbed
0086 ones.
0087
0088 - **Queue and identity.** It subscribes to the anycast control queue
0089 `/queue/epicprod.ops`; a single consumer handles each request exactly once. It
0090 runs under a fixed `prodops` namespace (from `agents/prodops.toml`) so it is
0091 identifiable as the system singleton in the monitor and every caller addresses
0092 it explicitly (`namespace: prodops`). Foreign-namespace messages are filtered
0093 out.
0094 - **Dispatch.** Each action is a `msg_type` routed to a `_handle_<msg_type>`
0095 method. **Growing the agent is adding a handler** — a new capability is a new
0096 `_handle_*`, registered in `KNOWN_TYPES`.
0097 - **The doer pattern.** A handler is a thin event front end; the actual work is
0098 delegated to a standalone **doer script** (`scripts/cache-payload-log.py`,
0099 `scripts/submit-prod-task.py`) run as a subprocess under a timeout. Each doer
0100 is usable on its own, by cron, or by the agent — the agent does not embed the
0101 logic. **PCS stays the single source of truth**: the submit doer *fetches* the
0102 `prun` command from the PCS artifact endpoint rather than rebuilding it.
0103 - **Robustness doctrine.** Every handler is **bounded** (a subprocess timeout)
0104 and **self-erroring** (it records its own failure where the triggering surface
0105 will see it — e.g. the `.error` marker in the payload-log cache). Handler
0106 exceptions are caught and logged; one sick capability never crashes the
0107 singleton. Control messages (`health_ping`, `shutdown`) do not flip the
0108 agent's processing state; work messages do, so the monitor shows the agent
0109 busy.
0110 - **Outcome conventions, no polling.** A result lands where the trigger's
0111 surface already reads it: the cache (payload log) or the `ProdTask` record via
0112 `record-submission` (submit). Liveness replies on the bus (`health_ping` →
0113 `pong`). Nothing polls.
0114
0115 ### Async execution — a BaseAgent capability
0116
0117 `BaseAgent` delivers messages on a single STOMP receiver thread, sequentially
0118 (`ack='auto'`), so a handler that blocks stalls every later message — including
0119 `health_ping`. A healthy `submit_task` returns in seconds, but the credentialed
0120 work coming next is not all fast: the campaign-provenance sweep is a genuinely
0121 long Rucio scan, and any privileged call can hang (this is distributed
0122 computing). While the receiver thread is blocked, the cleaner-killer's liveness
0123 ping (every ~2 min) goes unanswered and the watchdog restarts the unit —
0124 killing the in-flight work it was meant to protect.
0125
0126 The fix is **threads, not asyncio**. The work is blocking subprocess/socket I/O
0127 (`prun`, `xrdcp`, Rucio REST) and the stack is thread-based (stomp.py,
0128 subprocess); an asyncio agent would buy nothing here and would force every agent
0129 off the shared base. So the capability lives in `BaseAgent` itself — a
0130 bounded worker pool, reusable by all agents — exposed as
0131 `run_in_background(fn, *args, dedup_key=…, label=…)`. A handler enqueues its
0132 doer and returns, freeing the receiver thread at once.
0133
0134 It is **opt-in**, which is what protects the other agents: one that never calls
0135 `run_in_background` behaves exactly as before. The wrapper drives reentrant
0136 PROCESSING state (PROCESSING while any background work is in flight), catches and
0137 logs every exception (no silent worker death), and skips a call whose
0138 `dedup_key` is already running (the duplicate-work race that concurrency
0139 introduces). A send lock makes worker-thread bus sends safe; shutdown drains the
0140 pool.
0141
0142 In this agent, `fetch_payload_log`, `submit_task`, and `rucio_snapshot_update`
0143 enqueue via `run_in_background`; `health_ping` and `shutdown` stay inline. Because
0144 `BaseAgent` lives in `swf-common-lib` and ships to every agent through the venv
0145 chain, the other agents continue to heartbeat unchanged. The shared API is
0146 documented in the `swf-common-lib` README.
0147
0148 ## Building a new capability — the pattern
0149
0150 The testbed is becoming a live, automated, responsive production system, and this
0151 agent is the standard instrument for it. A new credentialed, slow, or hang-prone
0152 operation is not a new service — it is the same recipe, and the trigger comes
0153 first because it is the step most often gotten wrong:
0154
0155 1. **Choose an external-safe trigger.** Most collaborators reach the agent
0156 through the swf-remote face (`epic-devcloud.org`), where the proxy carries no
0157 session or CSRF and cannot relay a redirect (3xx → 502). Only two trigger
0158 shapes survive that hop:
0159 - a **GET** page-view that drops the message as a side-effect and returns a
0160 body (200/202), never a redirect — `fetch_payload_log` via the job page; or
0161 - a **POST to `/pcs/api/`**, authenticated by `X-Remote-User`
0162 (`TunnelAuthentication`, csrf-exempt) and returning **JSON**, never a
0163 redirect — `submit_task` via the REST `submit` action.
0164
0165 A page-view POST that relies on session+CSRF or ends in `redirect()` passes on
0166 the internal face and **fails through the proxy** — do not use it. See
0167 [EXTERNAL_ACCESS.md](EXTERNAL_ACCESS.md) → *Write actions and triggers*, and
0168 verify on `epic-devcloud.org`, not the internal face, which hides the
0169 constraint.
0170 2. **Handler + doer.** Add `_handle_<msg_type>` (validate, then enqueue) and a
0171 standalone `_do_<msg_type>` / `scripts/<doer>.py` that does the privileged
0172 work; register the type in `KNOWN_TYPES`. Any output the web tier will read
0173 goes under `$SWF_TMP_DIR`, not the deploy tree — see *Credential boundary*.
0174 3. **Run it in the background.** Long work goes through `run_in_background`
0175 (bounded pool, dedup, reentrant PROCESSING) so the receiver thread never
0176 blocks — see *Async execution* above.
0177 4. **Emit a completion event.** On success, publish a small event to
0178 `/topic/epictopic`; the existing SSE relay broadcasts it.
0179 5. **Push it to the browser.** The triggering page holds an `EventSource` and
0180 updates the moment the event arrives — no polling, no manual refresh. See
0181 [SSE_PUSH.md](SSE_PUSH.md).
0182
0183 The result is a button that fires a privileged action server-side under the
0184 agent's credentials and reports back live — internally, and (through the
0185 swf-remote streaming proxy) to remote collaborators. `fetch_payload_log` (GET
0186 side-effect) and `submit_task` (`/pcs/api/` POST) are the worked examples for the
0187 two trigger shapes; the campaign-provenance sweep is the next. Reach for this
0188 pattern before building a poller, a blocking handler, or anything that places a
0189 credential in the web tier.
0190
0191 ## Current capabilities
0192
0193 Verified against `agents/epicprod_ops_agent.py` and its doers, 2026-06-02:
0194
0195 | `msg_type` | Doer | Credential | Outcome | Timeout |
0196 |---|---|---|---|---|
0197 | `fetch_payload_log` | `cache-payload-log.py` | Rucio proxy + xrootd | extracted log members in `$SWF_TMP_DIR/panda-logs/<jeditaskid>/<pandaid>/`, `.done` on success / `.error` on failure | 180s |
0198 | `submit_task` | `submit-prod-task.py` | PanDA OIDC token (operator) | `jediTaskID` recorded on the `ProdTask` (`panda_task_id` + `status='submitted'`) via `record-submission` | 300s |
0199 | `rucio_snapshot_update` | `rucio-snapshot-update.py` | JLab Rucio userpass (public `eicread`) | current+last snapshot refreshed, produced datasets matched onto each task's `overrides['outputs']`; `rucio_snapshot_ready` pushed (ok true/false) | 900s |
0200 | `health_ping` | — | — | `pong` to `reply_to` | — |
0201 | `shutdown` | — | — | deliberate stop; exits `EXIT_DELIBERATE=100` so systemd leaves it down | — |
0202
0203 **`fetch_payload_log`** resolves the log DID's replica (Rucio REST, x509,
0204 account `panda`), `xrdcp`s the tarball, extracts the members into the cache, and
0205 writes a `.done` sentinel. A miss publishes the message; a hit serves from
0206 cache. On failure or timeout it writes an `.error` marker carrying the attempt
0207 count and reason, which the web view surfaces and uses to bound retries.
0208
0209 **`submit_task`** runs the same `prun` an operator runs, non-interactively: it
0210 GETs the `prun` command for the task from
0211 `/pcs/api/prod-tasks/command/?name=<name>&fmt=panda`, runs it in a clean sandbox
0212 under the panda-client environment with the cached token (never deleting
0213 `$PANDA_CONFIG_ROOT/.token`, which would force an interactive device flow),
0214 parses `jediTaskID=<N>`, and POSTs the outcome to
0215 `/pcs/api/prod-tasks/record-submission/` as the task owner (`X-Remote-User`,
0216 trusted on-host by the localhost tunnel). The task IS submitted even if the
0217 final bookkeeping POST fails; that case is surfaced loudly with the task ID so
0218 the operator can re-record.
0219
0220 The `submit_task` message is published by `services.prodtask_submit_request`,
0221 behind the REST `submit` action (the two-pane compose view, and the task-detail
0222 page's "Submit in Compose" link) — the **external-safe** trigger: a `/pcs/api/`
0223 POST returning JSON. It is gated to `status='ready'` with no existing
0224 `panda_task_id`, mirroring the `record-submission` gates so a submission whose
0225 outcome would be refused is never fired. (The legacy `prod_task_submit_panda`
0226 page-view submit — a page-POST+redirect that 502'd through the swf-remote proxy —
0227 was retired.)
0228
0229 ## Roadmap — capabilities as handlers
0230
0231 Each item below is, by design, a new handler + doer on this agent.
0232
0233 - **Async execution** (above) — implemented; the structural prerequisite for
0234 piling more long-running capabilities onto the singleton.
0235 - **Campaign-provenance sweep** — the join Sakib's catalogue and the
0236 `eic/snippets` `check_campaign.py` / `check_storage.py` do by hand, run live
0237 and credentialed: for each requested EVGEN path
0238 `/volatile/eic/EPIC/EVGEN/<suffix>`, resolve the produced
0239 `epic:/RECO/<campaign>/<detector_config>/<suffix>` DID(s), their RSE replicas
0240 (BNL-XRD / EIC-XRD), and file counts, into a provenance cache the catalog
0241 renders. This supersedes the temporary hand-curated dataset catalogue and the
0242 monthly completion-status email.
0243 - **Proactive completion notification** — auto-notify the operator the moment a
0244 fetch or submit finishes, removing the manual refresh; the corun-ai Mattermost
0245 callback is the working model. Browser-push design: [SSE_PUSH.md](SSE_PUSH.md)
0246 (the agent emits `payload_log_ready` / `prodtask_submitted` over the SSE relay).
0247 - **Credentialed MCP provider** — a future `pcs_prodtask_submit` MCP tool routes
0248 through the agent: the bot triggers, the agent (the credential holder)
0249 executes. The MCP server stays credential-free.
0250 - **OIDC service account** — submit as a robot rather than reusing the
0251 operator's token; and **EVGEN-in-Rucio registration** once that workflow is
0252 defined.
0253
0254 ## Operation
0255
0256 Running, restarting, monitoring, the systemd unit, the cleaner-killer cron
0257 (reap duplicates / liveness / prune), the deliberate-stop back doors, and the
0258 payload-log retrieval mechanics are in [EPICPROD_OPS.md](EPICPROD_OPS.md). This
0259 doc does not duplicate them.
0260
0261 **Status (2026-06-02):** deployed and live on `pandaserver02`. Handlers
0262 `fetch_payload_log`, `submit_task`, `rucio_snapshot_update`, `health_ping`, and
0263 `shutdown` are implemented and the `submit_task` path reuses the operator's
0264 cached production token. Async handler execution is implemented (a `BaseAgent`
0265 worker pool, opt-in `run_in_background`); the three work handlers enqueue their
0266 doers through it.