You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The agent loop (src/agent/loop.ts) rebalances on a fixed hourly cadence with no notion of "conditions are abnormal — stop moving money." If a protocol's reported APY spikes because of a data glitch, if a stablecoin the platform treats as $1 de-pegs, if positions are taking a coordinated drawdown, or if the same batch has rebalanced repeatedly in a short window (flip-flopping between two protocols), the agent keeps trading into the anomaly, paying fees and compounding the damage. There is no kill switch short of stopping the whole process. This issue adds an agent circuit breaker: a set of pre-trade guards that trip on abnormal loss, de-peg, oscillation, or stale data, halt rebalancing (globally, per-protocol, or per-user), emit alerts and events, and require an explicit, audited reset — while leaving user-initiated withdrawals untouched.
Current State
src/agent/loop.ts — rebalanceCheckJob runs 0 * * * *; groups positions into byProtocolAndStrategy batches; calls executeRebalanceIfNeeded per batch; on success publishes agent.rebalanced / portfolio.updated. On exception it sets lastError and determineHealthStatus() returns degraded — but the next tick still runs.
src/agent/router.ts — compareProtocols already computes netImprovement after estimateRebalanceCosts and only fires when netImprovement > minimumImprovement. No history-aware guard (oscillation, drawdown).
src/agent/scanner.ts — scanAllProtocols / getCurrentOnChainApy feed the comparison; a bad scan is only logger.warn'd.
prisma/schema.prisma — ProtocolRate, ProtocolRiskScore, YieldSnapshot, Position.currentValue, AgentLog. OutboxOp has isUserHalted semantics already (src/outbox/service.ts — a "frozen user" concept exists for compliance halts, per AML Transaction Monitoring & Sanctions Screening Pipeline #321).
enumBreakerScope { GLOBAL PROTOCOL USER }enumBreakerState { CLOSED OPEN HALF_OPEN }modelAgentCircuitBreaker {idString@id@default(uuid())scopeBreakerScopescopeKeyString// "" for GLOBAL, protocolName, or userIdstateBreakerState@default(CLOSED)trippedRuleString?// "abnormal_loss" | "depeg" | "oscillation" | "stale_data" | "manual"trippedAtDateTime?detailJson?// the measurements that tripped itresetByString?// admin identity on manual resetresetAtDateTime?autoResetAtDateTime?// earliest time HALF_OPEN is allowedupdatedAtDateTime@updatedAt@@unique([scope, scopeKey])}
Evaluation order in rebalanceCheckJob, before any batch executes: GLOBAL → PROTOCOL(from/to) → USER. An OPEN breaker at any applicable scope skips the affected batches (logged as BLOCKED with the breaker id — ties into the explainable-rebalance record).
2. Trip rules (src/agent/breakerRules.ts, new — pure, unit-tested)
abnormal_loss: aggregate mark-to-market drawdown across the scope's active positions over a trailing window exceeds BREAKER_LOSS_PCT (e.g. portfolio down >X% in Y hours), computed from YieldSnapshot/Position.currentValue series using the robust conventions the risk-analytics stack already uses (period returns, not the smoothed cumulative column).
depeg: a stablecoin the platform prices at $1 deviates beyond BREAKER_DEPEG_BPS from $1 on the DEX (uses the fee-oracle/routing price path or an oracle feed). Trips PROTOCOL breakers for every protocol holding that asset and blocks conversions into it.
oscillation: the same batchKey has produced ≥ BREAKER_MAX_FLIPS rebalances within BREAKER_FLIP_WINDOW (detects A→B→A→B fee-burning). Reads recent RebalanceDecision/AgentLog history.
stale_data: scanAllProtocols returned data older than BREAKER_STALE_MINUTES, or a scan failed N consecutive times — the agent must not trade on a stale APY table.
Each rule returns { tripped: boolean, detail }; thresholds are config with sane defaults; rules are individually toggleable.
3. State transitions
CLOSED → OPEN on any rule tripping; sets trippedRule, detail, autoResetAt = now + BREAKER_COOLDOWN.
OPEN → HALF_OPEN automatically once autoResetAt passes and the tripping rule no longer evaluates true.
HALF_OPEN → CLOSED after one clean evaluation cycle with no trips; HALF_OPEN → OPEN immediately if any rule trips again (cooldown doubles, capped).
manual trips and resets: admin-only, always allowed, always audited.
4. Alerts, events, API
On trip: alertingService.emit({ severity: 'critical', component: 'agent', ... }) + a agent.circuit_breaker_tripped event to affected users (USER/GLOBAL scope) with a plain-language reason.
On reset: agent.circuit_breaker_reset event.
GET /api/v1/admin/agent/breakers — list/inspect; POST /api/v1/admin/agent/breakers — manual trip; POST /api/v1/admin/agent/breakers/:id/reset — manual reset (requires a reason). All admin-scoped + in the admin audit log.
GET /api/v1/agent/status (existing getAgentStatus) gains a breakers: { global, affectingYou } summary for the calling user.
Prometheus: gauge of open breakers by scope/rule; alert rule for "GLOBAL breaker open > N minutes".
5. What the breaker does NOT stop
User-initiated withdrawals (CRITICAL outbox ops) always proceed — the breaker only halts agent-initiated rebalances. This is explicit in code and tested.
Snapshotting, scanning, metrics, and event delivery continue.
Edge Cases & Failure Modes
Breaker evaluation itself fails (DB error): fail closed for GLOBAL (skip rebalancing that tick, alert) — never fail open and trade blind.
De-peg recovery: the depeg breaker must require the price to be back within band for a sustained period (N consecutive checks), not a single tick, before HALF_OPEN.
Legitimate market-wide drawdown: abnormal_loss will trip in a real crash — that is intended (stop churning fees while everything is falling); the runbook documents operator judgment for manual reset, and withdrawals are unaffected so users are never trapped.
Oscillation false positive from a genuinely improving-then-reversing APY: the flip counter only counts rebalances that each individually passed the net-improvement threshold; document that this is a fee-protection heuristic, not a correctness guarantee.
Problem Statement
The agent loop (
src/agent/loop.ts) rebalances on a fixed hourly cadence with no notion of "conditions are abnormal — stop moving money." If a protocol's reported APY spikes because of a data glitch, if a stablecoin the platform treats as$1de-pegs, if positions are taking a coordinated drawdown, or if the same batch has rebalanced repeatedly in a short window (flip-flopping between two protocols), the agent keeps trading into the anomaly, paying fees and compounding the damage. There is no kill switch short of stopping the whole process. This issue adds an agent circuit breaker: a set of pre-trade guards that trip on abnormal loss, de-peg, oscillation, or stale data, halt rebalancing (globally, per-protocol, or per-user), emit alerts and events, and require an explicit, audited reset — while leaving user-initiated withdrawals untouched.Current State
src/agent/loop.ts—rebalanceCheckJobruns0 * * * *; groups positions intobyProtocolAndStrategybatches; callsexecuteRebalanceIfNeededper batch; on success publishesagent.rebalanced/portfolio.updated. On exception it setslastErroranddetermineHealthStatus()returnsdegraded— but the next tick still runs.src/agent/router.ts—compareProtocolsalready computesnetImprovementafterestimateRebalanceCostsand only fires whennetImprovement > minimumImprovement. No history-aware guard (oscillation, drawdown).src/agent/scanner.ts—scanAllProtocols/getCurrentOnChainApyfeed the comparison; a bad scan is onlylogger.warn'd.prisma/schema.prisma—ProtocolRate,ProtocolRiskScore,YieldSnapshot,Position.currentValue,AgentLog.OutboxOphasisUserHaltedsemantics already (src/outbox/service.ts— a "frozen user" concept exists for compliance halts, per AML Transaction Monitoring & Sanctions Screening Pipeline #321).src/services/alerting.ts—alertingService.emit({ severity, component, ... })operator alerting.deploy/monitoring/prometheus/alert-rules.yaml,docs/OBSERVABILITY.md,docs/RUNBOOK.md.Proposed Solution
1. Breaker model & scopes
rebalanceCheckJob, before any batch executes: GLOBAL → PROTOCOL(from/to) → USER. AnOPENbreaker at any applicable scope skips the affected batches (logged asBLOCKEDwith the breaker id — ties into the explainable-rebalance record).2. Trip rules (
src/agent/breakerRules.ts, new — pure, unit-tested)BREAKER_LOSS_PCT(e.g. portfolio down >X% in Y hours), computed fromYieldSnapshot/Position.currentValueseries using the robust conventions the risk-analytics stack already uses (period returns, not the smoothed cumulative column).$1deviates beyondBREAKER_DEPEG_BPSfrom$1on the DEX (uses the fee-oracle/routing price path or an oracle feed). Trips PROTOCOL breakers for every protocol holding that asset and blocks conversions into it.batchKeyhas produced ≥BREAKER_MAX_FLIPSrebalances withinBREAKER_FLIP_WINDOW(detects A→B→A→B fee-burning). Reads recentRebalanceDecision/AgentLoghistory.scanAllProtocolsreturned data older thanBREAKER_STALE_MINUTES, or a scan failed N consecutive times — the agent must not trade on a stale APY table.{ tripped: boolean, detail }; thresholds are config with sane defaults; rules are individually toggleable.3. State transitions
CLOSED → OPENon any rule tripping; setstrippedRule,detail,autoResetAt = now + BREAKER_COOLDOWN.OPEN → HALF_OPENautomatically onceautoResetAtpasses and the tripping rule no longer evaluates true.HALF_OPEN → CLOSEDafter one clean evaluation cycle with no trips;HALF_OPEN → OPENimmediately if any rule trips again (cooldown doubles, capped).manualtrips and resets: admin-only, always allowed, always audited.4. Alerts, events, API
alertingService.emit({ severity: 'critical', component: 'agent', ... })+ aagent.circuit_breaker_trippedevent to affected users (USER/GLOBAL scope) with a plain-language reason.agent.circuit_breaker_resetevent.GET /api/v1/admin/agent/breakers— list/inspect;POST /api/v1/admin/agent/breakers— manual trip;POST /api/v1/admin/agent/breakers/:id/reset— manual reset (requires areason). All admin-scoped + in the admin audit log.GET /api/v1/agent/status(existinggetAgentStatus) gains abreakers: { global, affectingYou }summary for the calling user.5. What the breaker does NOT stop
CRITICALoutbox ops) always proceed — the breaker only halts agent-initiated rebalances. This is explicit in code and tested.Edge Cases & Failure Modes
Nconsecutive checks), not a single tick, beforeHALF_OPEN.BREAKER_MAX_COOLDOWN.Security & Privacy Considerations
reasonand is audit-logged with identity.detailblobs may contain position values — admin-scoped only; the user event carries a sanitized reason string.Out of Scope
Suggested Implementation Plan
AgentCircuitBreaker+ enums + migration/rollback.src/agent/breakerRules.ts— the four pure rules + exhaustive unit tests (trip / no-trip / recovery).rebalanceCheckJob, before batch execution; fail-closed on error;BLOCKEDdecision records.getAgentStatussummary, Prometheus gauge + alert rule.docs/RUNBOOK.mdoperator procedures,docs/OBSERVABILITY.md.Acceptance Criteria
BLOCKEDdecisionagent.circuit_breaker_tripped/_resetevents to affected users with plain-language reasons; critical operator alert on tripgetAgentStatusbreaker summary + Prometheus gauge/alert;docs/RUNBOOK.md+docs/OBSERVABILITY.mdupdated; tests green